안드로이드 11 – 스토리지 정책 업데이트 대응

모두 안드로이드의 스토리지 사용이 매우 개방적이라는 것에 동의할 것이다. 사용자에게 저장소 전체에 대한 읽기 및 쓰기 동의만 받는다면 내부저장소 어디든 읽고 쓸 수 있기 때문이다. 그 때문에 개발자가 마음만 먹으면 사용자의 스토리지 정보를 갈취하는것도 어려운일이 아닐것이다. 필자도 수년간 안드로이드 휴대폰만 사용하다가, 아이폰을 사용했을 때 각 파일을 읽고, 쓸 때 마다 매번 권한을 요청하는 다이어로그가 뜨는 것은 매우 신기한 일 이였다.

안드로이드도 개방적인 스토리지 사용에 문제점을 느낀것인지, 몇 년전 부터 칼을 빼들기 시작했다. 꽤 오래전부터 내부저장소의 경로를 가져오는 getExtenralStorageDirectory 함수가 depreacted 된 것이 그 예이다.

물론 애플리케이션의 자체 내부 저장소(앱별 저장공간)를 사용하는데에는 안드로이드 11 버전 이후에도 제약이 없다. (예컨대, getFilesDir or getCacheDir 을 통해 가져오는 경로들, 외부 저장소는 getExternalFilesDir) 또한 Music, Video, Downloads 등 일반적인 작업을 하는 파일 경로는 모두 대체할 수 있는 공유 저장공간이 있기 때문에 크게 문제가 되지 않는다.

일반적으로 단순하게 저장소를 사용하는것에 있어서는 다음 레퍼런스를 참고하면 도움이 될 수 있다.

https://developer.android.com/about/versions/11/privacy/storage

그러나 내가 개발하는 앱은 특정 경로에 컨텐츠를 이동시켜 패치하고, 미디어들을 수정하고, 일부 소스 파일에서 사용자의 경로로 extract 해야 하는 부분들이 있었다.

이 글에서는 내가 운영하고 있는 앱에 특수성 때문에 고민하게 된 몇가지 부분들에 해결책을 기록한다. 동영상 편집, 사진 편집등의 앱을 개발하는 분에게는 도움이 될 수도 있겠다.

만약 본인의 앱이 MANAGE_EXTERNAL_STORAGE 권한을 얻기에 용도가 적합하다면 모든 파일 액세스 권한을 사용자에게 요청할 수도 있다. Google Play에서는 5월 5일 부터 해당 권한이 필요한 경우 명시하도록 하고, 앱에 특성에 따라 해당 권한 사용 여부를 검토할것이라고 한다. 파일 관리자 앱이나 꼭 필요한 경우에만 사용하는 것을 권고하는데, 아직까지 대응하지 않은 파일 관리자 앱도 많은 것을 보면 단호하게 이 앱들을 모두 내려칠지는 의문이다.

https://support.google.com/googleplay/android-developer/answer/9956427?hl=ko

또한 특별한 경우가 아니라면 공유 저장공간을 활용 할 수 있기 때문에 아래 문서도 읽어 보면 좋다. MediaStore API를 사용하면 대부분의 작업이 가능하며, 사실상 내부 저장소를 직접 이용하는 것 보다 이를 따르는 것이 가장 이상적인 구조가 아닐까 싶다.

https://developer.android.com/training/data-storage/shared/media

사용자 내부저장소에 권한 얻기

사용자 내부저장소에 기존처럼 파일을 읽고 쓰기 위해서는 사용자의 내부저장소에 권한을 요청해야한다. 일어날 수 있는 경우는 크게 두가지이다. 단일 파일에만 권한을 얻는 경우와, 디렉토리 및 디렉토리 하위 파일들에 대해 권한을 얻어야 하는 경우. 나는 디렉토리 하위에 대한 모든 권한을 얻기 위해 다음과 같이 사용자에게 디렉토리 권한을 요청했다.

private fun selectDirectory(reqCode: Int, pickerInitialUri: Uri?) {
     val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
         flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
         pickerInitialUri?.let {
             putExtra(DocumentsContract.EXTRA_INITIAL_URI, it)
         }
     }
     startActivityForResult(intent, reqCode)
 }

따로 initialUri를 지정할 수 있지만, 미디어 저장공간 등 사전에 제공되는 Uri에 대해서나 시작점을 지정할 수 있기 때문에 이전처럼 정확히 원하는 경로에 권한을 요청하기는 어렵다.

다만 크게 문제는 되지 않는다. 어차피 이전처럼 고정된 Path에 대해서 작업을 수행하는 플로우가 아닌, 사용자 선택에 의해서 제공된 Uri를 제공받아 이를 저장하고, 해당 Uri에 작업을 요청하는 방식으로 모든 코드를 수정해야할 필요가 있기 때문이다.

override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
     if (requestCode == REQ_SELECT_DONWLOAD_DIRECTORY && resultCode == Activity.RESULT_OK) {
         resultData?.data?.also { uri ->
             val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
             requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
         }
     }
 }

다음과 같이 onAcitivtyResult를 통해서 사용자가 선택한 uri 정보를 로드 할 수 있다. 로드된 Uri는 Document Tree의 Uri 라고 불리는 듯 하고, 단일 파일들은 Document Single Uri 라고 불리는 듯 하다. 또한 takePersistableUriPermission을 호출해서 uri 대한 권한을 계속 유지하겠다고 선언해야 이후에 추가 권한 요청 없이 권한을 유지할 수 있다. 사실상 일회성으로 파일받 다운로드 해주는 경우가 아니라면 꼭 호출해 주어야 한다.

이 Uri는 ContentResolver로 InputStream 이나 OutputStream을 열 때 사용 가능 하므로, Uri를 데이터베이스나 파일 등에 문자열로 저장해 보관하다가 사용하면 된다.

파일 선택의 경우 “ACTION_OPEN_DOCUMENT_TREE” 대신 “ACTION_OPEN_DOCUMENT“로 인텐트를 호출하여 선택할 수 있다.

ACTION_CREATE_DOCUMENT“를 이용하여 파일 생성 요청을 할 수도 있는데 이것은 흡사 우리가 PC에서 “다른 이름으로 저장”과 비슷한 창이 나온다.

세가지 경우 모두 위 코드와 같은 방식으로 권한을 얻고 takePersistableUriPermission를 통해 권한을 유지를 요청할 수 있다. 본인에게 맞는 방법을 잘 이용하면 될것 같다.

파일의 이용

권한이 부여된 Uri를 얻었으니 이를 이용해 볼 것이다. 기존 File 클래스를 통해서는 해당 Uri 접근이 어렵다. DocumentFile 클래스를 이용해 해당 Uri에 위치한 문서를 관리할 수 있다.

val documentFile = DocumentFile.fromSingleUri(this@MainActivity, documentUri)
documentFile.createFile("application/pdf", "test.pdf")
        
val documentFileTree = DocumentFile.fromTreeUri(this@MainActivity, documentTreeUri)
documentFileTree.createDirectory("test directory")
documentFileTree.listFiles()?.forEach { }

자세한 함수들은 레퍼런스 문서를 참조하면 좋을 것 같다. 기존 파일 클래스에서 제공하는 함수를 일부 제공하니 사용은 어렵지 않다.

활용 1: 생성 가능 폴더 이름 구하기

DocumentFile 사용법을 보여주기 위해 생성 가능 폴더 이름을 구하는 함수를 만들어봤다. “감자”가 존재할 경우 “감자(2)” “감자(3)” 등의 이름을 구하는 함수.

아 그런데, 실제로는 DocumentFile의 createDirectory 함수 호출시 중복 이름이 있다면 알아서 숫자를 붙여서 생성해주기 때문에(!) 이 함수를 쓸일은 없다.

/**
* 디렉토리 이름이 중복된 경우 뒤에 숫자가 붙여진 가능한 디렉토리 이름을 만들어 리턴한다.
*
* @param dirPath 폴더 경로
* @param fileName 파일 이름
* @return
*/
private fun getAvailableDirectoryName(dirPath: DocumentFile, fileName: String): String {
    var currentName: String = fileName
    var count = 1
    while (dirPath.findFile(currentName) != null) {
       count = count.inc()
       currentName = String.format("$fileName (%d)", count)
    }
    return currentName
}

활용 2: 애플리케이션 FilesDir 에서 파일 복사하기

애플리케이션 내부 파일 디렉토리에서 사용자가 볼 수 있는 내장저장소로 파일 복사할 경우가 생긴다. 예를 들어 압축 해제 라이브러리나 이미지 컴프레션 라이브러리 등이 content uri에 대응하지 않는 경우, 애플리케이션 내부 저장소에서 가공된 파일들을 사용자에게 제공해야하는 경우.

기존에 (Kotlin) File 에서의 copyRecursively 함수와 같은 기능을 한다. 직접 작성해 본 것이라 성능 부분은 장담하지 못하겠다. ByteStreams.copy() 는 Guava 에서 제공하는 함수를 이용한 것이다.

private fun copyDirectory(sourceDir: File, destDir: DocumentFile) {
     val directories = mutableMapOf()
     directories[sourceDir] = destDir
     while (directories.isNotEmpty()) {
         val currentDir = directories.keys.first()
         currentDir.listFiles()?.forEach { file ->
             if (file.isDirectory) {
                 directories[file] = directories[currentDir]?.createDirectory(file.name)!!
             } else {
                 val createdFile = directories[currentDir]?.createFile("", file.name)!!
                 // InputStream - 복사할 소스 파일
                 val iss = FileInputStream(file)
                 // OutputStream - 복제 파일의 위치
                 val oss = context.contentResolver.openOutputStream(createdFile.uri)!!
                 ByteStreams.copy(iss, oss)
             }
         }
         directories.remove(currentDir)
     }
 }

최근들어 저장소 위치 변경으로 이리저리 많은 시도를 해보고 있다. 또 끄적일만한 코드가 있다면 계속해서 글을 업데이트 하겠다.

Leave a comment

Your email address will not be published. Required fields are marked *