[안드로이드 compose] 엑셀 불러오기, 외부 저장소 저장하기, 공유하기

이우건·2025년 4월 3일
0

안드로이드

목록 보기
20/20

앱 내부의 데이터 리스트를 엑셀로 추출해서 데이터 분석용으로 사용하는 경우가 있었습니다.
Apache POI 라이브러리를 이용하여 리스트를 xlsx 파일로 저장하고 공유하고 읽어올 수 있는 기능을 구현해봤습니다.

dependency

버전 카탈로그를 사용하고 계시다면 다음과 같이 의존성을 추가합니다.

libs.versions.toml

[versions]
# excel lib
poi = "5.4.0"

[libraries]
# poi (excel)
poi = { module = "org.apache.poi:poi", version.ref = "poi" }
poi-ooxml = { module = "org.apache.poi:poi-ooxml", version.ref = "poiooxml" }
build.gradle(:app)

implementation(libs.poi)
implementation(libs.poi.ooxml)

poi 라이브러리는 엑셀 확장자 파일을 xls로 처리 가능하고
poi--ooxml 라이브러리는 xlsx로 처리 가능합니다.

minSdk가 26보다 작다면 다음과 같은 에러가 발생 할 수 있습니다.
MethodHandle.invoke and MethodHandle.invokeExact are only supported starting with Android O (--min-api 26)
만약 minSdk를 26이상으로 올릴 수 없다면 poi 라이브러리 버전을 낮춰보면서 확인해야합니다.

안드로이드 Scoped Storage 정책

기능을 설명하기에 앞서 안드로이드 Q 이상부터는 Scoped Storage 정책이 적용됩니다.

기존에는 공용공간안에 모든 파일이 저장되었다면, 변경된 후에는 개별공간이 샌드박스 형태로 보호되어있고 공용공간 또한 타입별로 (사진 및 동영상, 음악, 다운로드) 분리되었습니다. 개별 앱 공간은 앱 삭제시 함께 제거되고, 공용공간은 앱이 삭제되어도 기기에 남아있습니다.

Q 이전 버전에서 공용 폴더에 읽거나 쓰기를 하려면 READ_EXTERNAL_STOAGE 혹은 WRITE_EXTERNAL_STOARGE 권한을 얻어서 사용 할 수 있었습니다.
안드로이드 Q에서 새롭게 추가된 사진, 음악, 다운로드 같은 공용 공간은 MediaStore api를 통해서만 읽고 쓸 수 있습니다.

자세한 내용은 아래 블로그 글에서 확인하시면 도움이 되실 것 같습니다.
안드로이드 Q Scoped Storage 이해하기

추가)
Android 11부터 다시 file path의 사용을 허용 합니다. 자세한 내용은 아래 영상에서 확인해주세요.
Android 11의 현대식 저장소

권한 추가하기 (안드로이드 9 이하)

안드로이드 9 이하 버전을 타겟하시면 다음 권한을 추가 후 런타임 시 권한을 요청해주세요.

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
MainActivity.kt

if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { // Android 9 이하
	val permissionArray = arrayOf(
    Manifest.permission.WRITE_EXTERNAL_STORAGE,
    Manifest.permission.READ_EXTERNAL_STORAGE
    )
    if (ContextCompat.checkSelfPermission(this,Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED ||
    	ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
    	ActivityCompat.requestPermissions(this, permissionArray, 1)
    }
}

권한 요청 후 승인, 거부에 대한 처리는 따로 다루지 않겠습니다.

추가

  • Scoped Storage 정책에 따라 안드로이드 10 부터는 공용 공간이 사진 및 동영상, 음악, 다운로드로 분리되었고 다운로드 폴더의 경우 권한 요청 없이 MediaStore를 통해 접근 가능합니다.
  • 하지만 안드로이드 13+ 부터 미디어 권한이 세분화 되었으며 사진, 동영상, 음악 파일에 접근할 경우 필요에 맞게 3가지 권한을 요청해야합니다.
이미지 READ_MEDIA_IMAGES
동영상	READ_MEDIA_VIDEO
오디오 파일	READ_MEDIA_AUDIO

엑셀 저장하기

화면은 다음과 같이 구성했습니다.

엑셀에 기록할 샘플 데이터는 다음과 같이 정의했습니다.

object SampleData {
    val countriesAndCapitals = listOf(
        "대한민국" to "서울",
        "일본" to "도쿄",
        "중국" to "베이징",
        "미국" to "워싱턴 D.C.",
        "영국" to "런던",
        "프랑스" to "파리",
        "독일" to "베를린",
        "이탈리아" to "로마",
        "스페인" to "마드리드",
        "캐나다" to "오타와",
        "브라질" to "브라질리아",
        "멕시코" to "멕시코시티",
        "인도" to "뉴델리",
        "러시아" to "모스크바",
        "호주" to "캔버라",
        "남아프리카공화국" to "프리토리아",
        "이집트" to "카이로",
        "사우디아라비아" to "리야드",
        "터키" to "앙카라",
        "아르헨티나" to "부에노스아이레스"
    )
}

엑셀 파일을 만들고 앱 외부 저장소에 엑셀을 저장하는 코드는 다음과 같습니다.

@HiltViewModel
class MainViewModel @Inject constructor(
    @ApplicationContext private val context: Context
): ViewModel() {
	private val list = SampleData.countriesAndCapitals
    
	private val _excel = MutableStateFlow<Workbook?>(null)
    val excel = _excel.asStateFlow()
    
	private fun createExcel() {
        val workbook: Workbook = XSSFWorkbook() // XLSX 포맷 사용
        val sheet: Sheet = workbook.createSheet("countrySheet") // 시트 이름 추가

        // 🚀 첫 번째 행(0번 행)에 헤더 추가
        val headerRow: Row = sheet.createRow(0) // 첫 번째 행 생성
        headerRow.createCell(0).setCellValue("나라") // 첫 번째 열
        headerRow.createCell(1).setCellValue("수도") // 두 번째 열

        list.forEachIndexed { index, pair ->
            val row: Row = sheet.createRow(index + 1) // 1번 행부터 데이터 입력

            row.createCell(0).setCellValue(pair.first) // 나라 데이터
            row.createCell(1).setCellValue(pair.second) // 수도 데이터
        }

        _excel.update { workbook }
    }
    
    /**
     * 안드로이드 29이상 부터는 Scoped Storage 정책을 따름
     */
    fun saveExcel() {
        viewModelScope.launch {
            createExcel()
            val excel = _excel.value ?: return@launch

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                val contentValues = ContentValues().apply {
                    put(MediaStore.Files.FileColumns.DISPLAY_NAME, "countries.xlsx")
                    put(MediaStore.Files.FileColumns.MIME_TYPE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
                    put(MediaStore.Files.FileColumns.RELATIVE_PATH, Environment.DIRECTORY_DOCUMENTS)
                }

                val uri = context.contentResolver.insert(MediaStore.Files.getContentUri("external"), contentValues)
                uri?.let {
                    runCatching {
                        context.contentResolver.openOutputStream(it)?.use { outputStream ->
                            excel.write(outputStream)
                        }
                    }.onSuccess {
                        Toast.makeText(context, "저장 되었습니다.", Toast.LENGTH_SHORT).show()
                    }.onFailure {
                        Toast.makeText(context, "저장이 실패 하였습니다.", Toast.LENGTH_SHORT).show()
                        Log.e("MainViewModel", "error: ${it.message}")
                    }
                }
            } else {
                val file = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "countries.xlsx")

                runCatching {
                    file.outputStream().use { outputStream ->
                        excel.write(outputStream)
                    }
                }.onSuccess {
                    Toast.makeText(context, "저장 되었습니다.", Toast.LENGTH_SHORT).show()
                }.onFailure {
                    Log.e("MainViewModel", "error: ${it.message}")
                    Toast.makeText(context, "저장이 실패 하였습니다.", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }
}

MIME_TYPE ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")은 파일을 xlsx 형식으로 저장합니다.
xls 형식으로 저장을 하고 싶다면 application/vnd.ms-excel로 설정해주세요.

안드로이드 Scoped Storage 정책에 따라 Q를 기점으로 분기 처리를 하였습니다.
1. createExcel()을 통해 list를 순회하여 excel sheet를 만듭니다.
2. saveExcel()에서는 ContentValue에 MediaStore 파일 정보를 저장하고 ContentProvider에 insert하여 uri을 생성합니다.
3. 생성된 uri를 통해 OutputStream을 열어 파일을 쓰기(저장) 합니다.

휴대폰 내 파일 -> 내장 저장공간 -> Documents 폴더에 엑셀 파일이 저장 된것을 확인 할 수 있습니다.

엑셀 공유하기

MainViewModel.kt

private val _mainUiEffect = MutableSharedFlow<MainUiEffect>()
val mainUiEffect = _mainUiEffect.asSharedFlow()
    
fun shareExcel() {
	viewModelScope.launch {
    	val file = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "countries.xlsx")

		if (!file.exists()) {
    		Toast.makeText(context, "파일이 존재하지 않습니다.", Toast.LENGTH_SHORT).show()
        	return@launch
    	}

		_mainUiEffect.emit(MainUiEffect.ShareExcel(file))
    }
}

@Stable
sealed interface MainUiEffect {
    @Immutable
    data class ShareExcel(
        val file: File
    ): MainUiEffect
}
MainActivity.kt
	
setContent {
	AndroidExcelTheme {
    	LaunchedEffect(Unit) {
        	viewModel.mainUiEffect.collect {
            	when (it) {
                	is MainUiEffect.ShareExcel -> {
                    	intentUtil.shareExcel(it.file)
                    }
                }
            }
        }
    }
}
IntentUtil class

class IntentUtil(
    private val context: Context
) {
    fun shareExcel(file: File) {
        CoroutineScope(Dispatchers.IO).launch {
            runCatching {
                val uri: Uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)

                val intent = Intent(Intent.ACTION_SEND).apply {
                    type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
                    putExtra(Intent.EXTRA_STREAM, uri)
                    addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
                }

                context.startActivity(Intent.createChooser(intent, "엑셀 파일 공유하기"))
            }.onFailure {
                withContext(Dispatchers.Main) {
                    Log.e("IntentUtil", "error: ${it.message}")
                    Toast.makeText(context, "엑셀 공유 실패", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }
}

공유하기 같은 경우에는 따로 MediaStore를 이용해 파일을 탐색하지 않았습니다. MediaStore를 이용해 파일을 탐색하고 쿼리하는 경우는 엑셀 불러오기에서 설명드리겠습니다.
안드로이드 11이상부터는 getExternalStoragePublicDirectory 를 통해 File path를 탐색 할 수 있지만 안드로이드 10은 MediaStore Api만을 사용해야 합니다.

따라서 안드로이드 10이 타겟에 포함이 될 경우 분기 처리를 해주시거나 Manifest에 다음 flag를 추가해주세요.

<application
	android:requestLegacyExternalStorage="true">

File을 sharedFlow에 emit 해준 이유는 viewModel에서 주입 받은 context는 ApplicationContext이기 때문입니다.

context.startActivity(Intent.createChooser(intent, "엑셀 파일 공유하기"))

startActivity 함수를 호출하기 위해선 Activity의 context가 필요하기 떄문에 MainActivity에서 flow collect으로 File을 넘겨받아 로직을 수행 했습니다.

File의 경로 file://는 보안 문제로 인해 다른 앱이 접근 할 수 없기 때문에 FileProvider를 이용해 content:// Uri로 변환하여 intent에 제공해줘야 합니다.

FileProvider 설정

Manifest

<application
    ...         
    <provider
	    android:name="androidx.core.content.FileProvider"
        android:authorities="${applicationId}.provider"
        android:exported="false"
        android:grantUriPermissions="true">
        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/file_paths" />
    </provider>
</application>
file_paths.xml
<paths>
    <external-path name="documents" path="Documents/" />
</paths>

엑셀 불러오기

@HiltViewModel
class MainViewModel @Inject constructor(
    @ApplicationContext private val context: Context
) {
	private val _mainDialogEffect = MutableStateFlow<MainDialogEffect>(MainDialogEffect.Idle)
    val mainDialogEffect = _mainDialogEffect.asStateFlow()
	
    fun readExcel() {
        viewModelScope.launch {
            val fileName = "countries.xlsx"
            val dataList = mutableListOf<Pair<String, String>>() // 읽어온 데이터를 저장할 리스트

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                val contentUri = MediaStore.Files.getContentUri("external")

                val projection = arrayOf(
                    MediaStore.Files.FileColumns._ID,
                    MediaStore.Files.FileColumns.DISPLAY_NAME
                )

                val selection = "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?"
                val selectionArgs = arrayOf(fileName)

                runCatching {
                    context.contentResolver.query(contentUri, projection, selection, selectionArgs, null)?.use { cursor ->
                        if (cursor.moveToFirst()) {
                            val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
                            val fileId = cursor.getLong(idColumn)
                            val uri = ContentUris.withAppendedId(contentUri, fileId)

                            context.contentResolver.openInputStream(uri)?.use { inputStream ->
                                dataList.addAll(parseExcel(inputStream))
                            }
                        }
                    }
                }.onFailure {
                    Toast.makeText(context, "파일 읽기 실패", Toast.LENGTH_SHORT).show()
                    Log.e("MainViewModel", "error: ${it.message}")
                }

            } else {
                runCatching {
                    val file = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), fileName)
                    if (file.exists()) {
                        file.inputStream().use { inputStream ->
                            dataList.addAll(parseExcel(inputStream))
                        }
                    }
                }.onFailure {
                    Toast.makeText(context, "파일 읽기 실패", Toast.LENGTH_SHORT).show()
                    Log.e("MainViewModel", "error: ${it.message}")
                }
            }

            if (dataList.isNotEmpty()) {
                _mainDialogEffect.update { MainDialogEffect.CountryDialog(dataList.toList()) }
            }
        }
    }
    
    private fun parseExcel(inputStream: InputStream): List<Pair<String, String>> {
        val list = mutableListOf<Pair<String, String>>()
        val workbook: Workbook = XSSFWorkbook(inputStream)
        val sheet: Sheet = workbook.getSheetAt(0) // 첫 번째 시트 사용

        for (row in sheet) {
            if (row.rowNum == 0) continue // 첫 번째 행(헤더)은 건너뜀

            val country = row.getCell(0)?.stringCellValue ?: ""
            val capital = row.getCell(1)?.stringCellValue ?: ""
            list.add(country to capital)
        }

        workbook.close()
        return list
    }
}



@Stable
sealed interface MainDialogEffect {

    @Immutable
    data object Idle: MainDialogEffect

    @Immutable
    data class CountryDialog(
        val list: List<Pair<String, String>>
    ): MainDialogEffect
}

MediaStore를 통해 외부 저장소의 Uri를 받아오고 projection에 반환할 컬럼 리스트를 정의하고 selection에 fileName(엑셀 파일) Where절을 설정합니다.

MediaStore에서 가져온 Uri를 ContentResolver에 전달하고 query 함수를 통해 ContentProvider의 데이터에 접근 할 수 있게 됩니다.

query로 가져온 데이터는 Uri에 id를 추가하여 inputStream에 전달하여 데이터를 읽어옵니다. inputStream에서 반환된 데이터는 parseExcel() 함수를 통해 리스트에 추가하고 StateFlow를 업데이트 하여 다이얼로그로 엑셀 데이터를 확인해봤습니다.

전체 코드는 아래 링크에서 확인 할 수 있습니다.
https://github.com/Leewoogun/android_excel

참조

https://kiwinam.com/posts/21/android-create-excel/
https://velog.io/@kimbangto/Android-Apache-POI%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%97%91%EC%85%80-%ED%8C%8C%EC%9D%BC-%EC%9D%BD%EA%B8%B0
https://jtm0609.tistory.com/142
https://medium.com/androiddevelopers/modern-user-storage-on-android-e9469e8624f9
https://developer.android.com/about/versions/11/privacy/storage?hl=ko#media-files-raw-paths
https://www.youtube.com/watch?v=RjyYCUW-9tY

profile
머리가 나쁘면 기록이라도 잘하자

0개의 댓글