안드로이드 - SAF(Storage Access Framework)로 파일 읽고 쓰는 방법

SAF(Storage Access Framework)는 Android 4.4에 공개되었습니다. 문서 및 이미지 등 각종 파일을 탐색하고 저장하는 작업을 간편하게 하려고 도입하였습니다.

프레임워크라는 말에서 느낄 수 있듯이 SAF는 파일을 제공하는 Provider와 파일을 사용하는 Client로 나뉩니다. 서드파티 앱은 프로바이더가 되어 파일을 제공할 수 있습니다. 또, 다른 프로바이더가 제공하는 파일을 사용하는 클라이언트가 될 수 있습니다.

SAF 구성 요소 및 특징

Android developer에는 SAF가 다음 항목들을 포함하고 있다고 소개합니다.

  • Document Provider : 문서(파일)을 제공하는 주체
  • Client app: 프로바이더가 제공하는 문서를 사용하는 앱
  • Selector: 일종의 시스템 UI로, 클라이언트 앱에서 필요한 파일을 사용자가 선택할 때 사용됩니다

SAF의 특징은 다음과 같습니다.

  • 디바이스에는 여러 프로바이더가 존재할 수 있습니다.
  • 사용자는 프로바이더들이 제공하는 모든 파일을 탐색할 수 있습니다.
  • 앱은 프로바이더가 제공하는 문서에 대한 접근 권한을 가질 수 있습니다.
  • 이 접근 권한으로 앱은 파일을 추가, 편집, 저장 및 삭제할 수 있습니다.
  • USB가 연결되었을 때 USB의 데이터를 제공하는 프로바이더도 있습니다.

알아볼 내용은 ?

이 글에서는 다음 항목들에 대해서 어떻게 구현하는지 예제와 함께 알아볼 예정입니다.

  • 파일 읽기
  • 파일 생성
  • 파일 수정
  • 파일 삭제
  • 지속적인 접근 권한
  • 폴더 접근 권한

샘플 코드에서는 이미지 파일을 다루지만, PDF나 TXT 등의 파일도 동일한 방식으로 읽고 쓸 수 있습니다.

샘플 앱은 코틀린으로 작성되었으며 SDK API 29에서 테스트되었습니다. 샘플은 GitHub에서 확인할 수 있습니다.

지금까지 앱은 SAF를 잘 사용하지 않은 것 같습니다. 하지만 Android 10에서는 꼭 사용해야 하는 기능이 되었습니다.

Android 10의 Scoped Storage로 앱은 외부 저장소의 파일에 직접적으로 접근할 수 없게 되었습니다. MediaStore를 이용하거나 SAF를 이용해서 파일에 간접적으로 접근해야 합니다.

필요한 권한

파일에 접근할 때 SAF는 권한을 요구하지 않습니다. 대신 Selector UI를 띄워 사용자가 앱이 파일에 접근할 수 있도록 허락해야 합니다.

Selector UI는 아래 그림처럼 생겼습니다. 파일 탐색기처럼 디렉토리를 탐색할 수 있고, 파일을 선택하면 앱이 접근할 수 있습니다. saf selector

파일 읽기

먼저 Selector 화면을 띄워야 합니다.

다음은 Selector Activity를 실행하는 코드입니다.

val READ_REQUEST_CODE: Int = 42

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {    // 1
    addCategory(Intent.CATEGORY_OPENABLE)   // 2
    type = "image/*"    // 3
}

startActivityForResult(intent, READ_REQUEST_CODE)   // 4

위의 "// 1"와 같이 주석으로 표시한 코드는 아래에 자세히 설명하였습니다.

  1. 파일을 열 때 Intent.ACTION_OPEN_DOCUMENT 액션을 사용합니다.
  2. 열 수 있는 파일들만 보고 싶을 때 Intent.CATEGORY_OPENABLE 를 카테고리로 넣어줍니다.
  3. 타입과 일치하는 파일들만 필터링해서 보여줍니다. "image/*" 타입으로 설정하면 이미지 파일만 보여줍니다.
  4. 인텐트로 실행된 화면에서 파일을 선택하면 그 파일의 Uri가 내 앱의 액티비티로 전달됩니다.

위의 코드를 실행하면 다음과 같은 화면이 나옵니다. openable images

선택된 파일의 Uri 리턴 받기

이미지를 하나 선택하면 그 파일의 Uri가 앱으로 전달됩니다. 앱은 다음과 같은 코드로 Uri를 받을 수 있습니다.

override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
    super.onActivityResult(requestCode, resultCode, resultData)

    if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
        resultData?.data?.also { uri ->
            Log.i(TAG, "Uri: $uri")   // 1
            dumpImageMetaData(uri)    // 2
            showImage(uri)    // 3
        }
    }
}
  1. Uri 정보를 스트링으로 출력합니다.
  2. Image의 MetaData를 출력합니다.
  3. 이미지 파일을 읽어서 액티비티에 출력합니다.

MetaData

Uri로 MetaData는 다음과 같이 가져올 수 있습니다.

fun dumpImageMetaData(uri: Uri) {
    val cursor: Cursor? = contentResolver.query( uri, null, null, null, null, null)
    cursor?.use {
        if (it.moveToFirst()) {
            val displayName: String =
                it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))  // 1
            Log.i(TAG, "Display Name: $displayName")

            val sizeIndex: Int = it.getColumnIndex(OpenableColumns.SIZE)  // 2
            val size: String = if (!it.isNull(sizeIndex)) {
                it.getString(sizeIndex)
            } else {
                "Unknown"
            }
            Log.i(TAG, "Size: $size")
        }
    }
}
  1. ContentResolver에 쿼리하여 가져온 데이터에서 OpenableColumns.DISPLAY_NAME 컬럼으로 Display Name을 가져올 수 있습니다. Display Name은 파일명입니다.
  2. OpenableColumns.SIZE 는 파일의 사이즈(Byte)를 가져오는데 사용하는 칼럼입니다.

Uri와 MetaData를 로그로 출력하면 다음과 같이 보입니다.

OpenExampleActivity: Uri: content://com.android.providers.media.documents/document/image%3A45
OpenExampleActivity: Display Name: matterhorn-4535693_1920.jpg
OpenExampleActivity: Size: 862778

이미지를 화면에 출력

Uri에서 이미지 파일을 읽고 액티비티의 ImageView에 출력하는 코드는 다음과 같습니다.

private fun showImage(uri: Uri) {
    GlobalScope.launch {    // 1
        val bitmap = getBitmapFromUri(uri)    // 2
        withContext(Dispatchers.Main) {
            mainImageView.setImageBitmap(bitmap)    // 3
        }
    }
}

@Throws(IOException::class)
private fun getBitmapFromUri(uri: Uri): Bitmap {
    val parcelFileDescriptor: ParcelFileDescriptor? = contentResolver.openFileDescriptor(uri, "r")
    val fileDescriptor: FileDescriptor = parcelFileDescriptor!!.fileDescriptor
    val image: Bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor)
    parcelFileDescriptor.close()
    return image
}
  1. 이미지를 읽는 작업은 오래걸리기 때문에 coroutine을 이용하여 다른 쓰레드에서 작업하도록 하였습니다.
  2. ContentResolver를 통해 Uri의 File descriptior를 가져와서 이미지 파일을 읽었습니다. 이미지는 Bitmap으로 변환하였습니다.
  3. bitmap을 ImageView에 set하였습니다. UI작업은 main thread에서 해야 하기 때문에 Dispatchers.Main에서 작업하도록 하였습니다.

결과를 보면 다음처럼 이미지가 화면에 출력됩니다. image open result

이런식으로 SAF로 파일을 열고, 파일의 데이터를 읽을 수 있습니다.

SAF UI 실행 시, 특정 경로로 이동

ACTION_OPEN_DOCUMENT 인텐트를 실행하여 파일 또는 폴더의 권한을 얻을 수 있습니다. 실행되는 UI는 DocumentsUI라는 앱의 UI입니다. 문제는 가장 마지막에 실행된 폴더의 경로가 실행된다는 것입니다 만약 어떤 폴더의 Uri만 알고 있다면, 그 경로의 위치에서 UI가 시작하도록 만들 수 있습니다.

필요한 것은 DocumentFile의 Uri인데, 우와하게 이 Uri를 가져오는 방법은 저도 모릅니다. 제가 사용한 방법은 onActivityResult()로 리턴 받은 Uri를 통해서 DocumentFile을 가져왔고 이것의 Uri를 구했습니다.

Log.d(TAG, "Uri from onActivityResult: $uri")
val docFile = DocumentFile.fromTreeUri(this, uri)!!
Log.d(TAG, "Uri from DocumentFile: ${docFile.uri}")

위 두개의 Uri를 출력해보면 결과가 비슷하지만 다릅니다.

Uri from onActivityResult: content://com.android.externalstorage.documents/tree/14E7-1B13%3AMovies
Uri from DocumentFile: content://com.android.externalstorage.documents/tree/14E7-1B13%3AMovies/document/14E7-1B13%3AMovies

DocumentFile의 Uri를 따로 저장해두고, 다음에 권한 요청이 필요할 때 다음과 같이 인텐트의 EXTRA_INITIAL_URI에 uri를 넣어줍니다.

val docUri = Uri.parse("content://com.android.externalstorage.documents/tree/14E7-1B13%3AMovies/document/14E7-1B13%3AMovies")

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, docUri);
startActivityForResult(intent, REQUEST_OPEN_TREE)

이 인텐트로 DocumentsUI 앱의 activity를 실행하면 Uri가 가리키는 폴더 위치에서 실행이 됩니다.

다음은 Developer에서 가져온 EXTRA_INITIAL_URI의 설명입니다.

Sets the desired initial location visible to user when file chooser is shown.

Applicable to Intent with actions:
  Intent#ACTION_OPEN_DOCUMENT
  Intent#ACTION_CREATE_DOCUMENT
  Intent#ACTION_OPEN_DOCUMENT_TREE

Location should specify a document URI or a tree URI with document ID. If this URI identifies a non-directory, document navigator will attempt to use the parent of the document as the initial location.

The initial location is system specific if this extra is missing or document navigator failed to locate the desired initial location.

DocumentFile의 Uri는 Document Provider가 제공하는 파일의 Uri입니다. 어떤 Provider로 부터 제공 받는 파일이냐에 따라서 Uri가 달라질 수 있습니다. 일반적으로 구글의 DocumentsUI로부터 파일을 제공받습니다. 이 파일들의 Uri를 얻기 위해, DocumentsContract와 ContentResolver를 적절히 활용하여 파일들을 탐색할 수 있을 것 같은데요.

이것도 "ACTION_OPEN_DOCUMENT"로 권한을 얻지 않으면 탐색이 제한되는 것 같습니다. 먼저 사용자로부터 폴더에 대한 권한을 얻고, 리턴받은 Uri로 하위 파일들의 Uri를 구해야 할 것 같습니다. 혹시 좋은 방법을 알고 계신다면 댓글에 남겨주세요.

파일 생성

파일을 생성하는 것은 Open과 조금 다릅니다. 존재하지 않는 파일을 생성하고, 그 파일에 데이터를 write해야 합니다.

  1. 빈 파일을 만든다.
  2. 생성된 파일에 데이터를 write한다.

앱은 파일을 생성할 권한이 없기 때문에 Selector UI를 띄워 사용자가 파일을 만들게 해야 합니다.

다음은 SAF로 파일을 생성하는 코드입니다.

val WRITE_REQUEST_CODE: Int = 43

val fileName = "NewImage.jpg"   // 1
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {  // 2
    addCategory(Intent.CATEGORY_OPENABLE)   // 3
    type = "image/jpg"    // 4
    putExtra(Intent.EXTRA_TITLE, fileName)   // 5
}

startActivityForResult(intent, WRITE_REQUEST_CODE)    // 6
  1. 파일명을 정해주면 Select UI에 자동으로 생성할 파일명이 입력됩니다.
  2. 파일을 생성할 때는 Intent.ACTION_CREATE_DOCUMENT 인텐트를 사용해야 합니다.
  3. UI에 열 수 있는 파일만 보고 싶으면 Intent.CATEGORY_OPENABLE 를 카테고리로 입력합니다.
  4. 파일의 타입을 설정합니다.
  5. 파일명을 Intent.EXTRA_TITLE 라는 이름의 Extra로 인텐트에 설정합니다.
  6. Selector UI에서 사용자가 파일을 만들면, 생성된 파일의 Uri가 내 앱의 액티비티로 전달됩니다.

생성된 파일의 Uri 리턴 받기

사용자가 Selector UI에서 파일을 생성하면, 생성된 파일의 Uri가 내 앱의 액티비티로 전달됩니다.

Uri 정보를 받는 코드는 다음과 같습니다.

override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
    super.onActivityResult(requestCode, resultCode, resultData)

    if (requestCode == WRITE_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
        resultData?.data?.also { uri ->
            Log.i(TAG, "Uri: $uri")   // 1
            writeImage(uri)   // 2
        }
    }
}
  1. Uri 정보 출력
  2. Uri에 이미지 파일을 write

Uri에 파일 write

위에서 생성한 파일은 0byte 크기의 빈 파일입니다. 이 파일에 내가 원하는 데이터를 write해줘야 합니다.

private fun writeImage(uri: Uri) {
    GlobalScope.launch {
        contentResolver.openFileDescriptor(uri, "w").use {    // 1
            FileOutputStream(it!!.fileDescriptor).use { it ->   // 2
                writeFromRawDataToFile(it)    // 3
                it.close()
            }
        }
        withContext(Dispatchers.Main) {   //  4
            Toast.makeText(applicationContext, "Done writing an image", Toast.LENGTH_SHORT).show()
        }
    }
}

private fun writeFromRawDataToFile(outStream: FileOutputStream) {   
    val imageInputStream = resources.openRawResource(R.raw.my_image)  // 5
    while (true) {
        val data = imageInputStream.read()
        if (data == -1) {
            break
        }
        outStream.write(data)   // 6
    }
    imageInputStream.close()
}
  1. Uri로 ParcelFileDescriptor를 쓰기 전용으로 가져오는 코드입니다.
  2. ParcelFileDescriptor을 FileOutputStream으로 변경합니다.
  3. File descriptor에 데이터를 write합니다.
  4. Toast로 write를 끝냈다고 알려주는 코드입니다. UI작업이기 때문에 Dispatchers.Main 에서 수행합니다.
  5. 웹에서 이미지를 받고 써주는 시나리오면 더욱 좋겠지만, 저는 간편히 앱의 "/res/raw/" 폴더에 이미지를 저장하고 그것을 읽어 write하도록 구현하였습니다.
  6. Byte를 FileOutputStream에 write하는 코드입니다.

완료되었다는 토스트가 뜨면, 작업이 모두 종료된 것입니다. 위의 Open 예제로 파일이 저장되었는지 확인할 수 있습니다.

saf write result

파일 삭제

파일을 삭제하는 것은 Open과 비슷합니다. Open을 통해 Uri를 받고 데이터를 읽는 대신에 삭제를 하면 됩니다.

위의 Open 예제와 동일하게 Selector UI를 띄웁니다.

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
    addCategory(Intent.CATEGORY_OPENABLE)
    type = "image/*"
}

startActivityForResult(intent, READ_REQUEST_CODE)

파일을 선택하면 내 앱의 액티비티에 Uri가 전달됩니다.

다음 코드로 Uri를 받아서 파일을 삭제할 수 있습니다.

override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
    super.onActivityResult(requestCode, resultCode, resultData)

    if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
        resultData?.data?.also { uri ->
            Log.i(TAG, "Uri: $uri")
            deleteFile(uri)   // 1
        }
    }
}

private fun deleteFile(uri: Uri) {
    DocumentsContract.deleteDocument(contentResolver, uri)    // 2
    Toast.makeText(applicationContext, "Done deleting an image", Toast.LENGTH_SHORT).show()
}
  1. Uri로 파일 삭제합니다.
  2. DocumentsContract.deleteDocument()에 ContentResolver와 Uri를 인자로 전달하면 해당 Uri를 삭제합니다.

삭제가 완료되면, 실제로 삭제되었는지 확인해보세요.

권한 유지

앱은 사용자가 선택한 파일들을 디바이스를 다시 실행될 때까지 읽고/쓸 수 있습니다. 하지만 디바이스가 재실행되면 접근 권한을 잃습니다. 그럼 다시 사용자보고 파일을 선택해서 권한을 받아야 합니다.

이런 UX는 매우 좋지 않기 때문에, 디바이스가 재실행되더라도 권한을 유지하는 방법이 있습니다.

Selector로부터 Uri를 받았을 때, 다음코드를 수행하면 파일의 접근 권한을 계속 유지할 수 있습니다. 앱의 로컬 DB에 Uri의 정보만 기억하고 있으면, 디바이스가 재실행되어도 파일에 접근할 수 있습니다.

val takeFlags: Int = intent.flags and
        (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
// Check for the freshest data.
contentResolver.takePersistableUriPermission(uri, takeFlags)

폴더 접근 권한

지금까지 파일 1개에 대해서 접근하는 예제를 다루었습니다. 폴더 전체에 대한 권한을 얻을 수도 있습니다.

다음은 폴더에 대한 접근 권한을 요청하는 코드입니다.

private val REQUEST_OPEN_TREE = 44

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)   // 1
startActivityForResult(intent, REQUEST_OPEN_TREE)
  1. Dir에 대해서 접근 권한을 얻기 위해 SAF를 띄웁니다.

그럼 다음처럼 Selector UI가 뜹니다. 이전과 차이점은 파일을 선택할 수 없고, 하단에 "ALLOW ACCESS TO ~" 버튼을 눌러 Dir 전체에 대해서 권한을 줄 수 있습니다. 하단 버튼을 누르면, 디렉토리의 모든 파일에 대한 권한을 허용할 것이냐는 팝업을 보여줍니다.

saf open tree result

권한을 받으면, Dir에 대한 Uri가 앱으로 전달됩니다.

다음은 Uri를 받아서 처리하는 코드입니다.

override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
    super.onActivityResult(requestCode, resultCode, resultData)

    if (requestCode == REQUEST_OPEN_TREE && resultCode == Activity.RESULT_OK) {
        resultData?.data?.also { uri ->
            val takeFlags: Int = intent.flags and
                    (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
            contentResolver.takePersistableUriPermission(uri, takeFlags)  // 1
            printDirFiles(uri) // 2
        }
    }
}

private fun printDirFiles(uri: Uri) {
    val docFile = DocumentFile.fromTreeUri(this, uri)!!   // 3
    if (docFile.canRead()) {
        val files = docFile.listFiles()   // 4
        for (file : DocumentFile in files) {
            Log.d(TAG, "List files: ${file.name}")  // 5
        }
    }
}
  1. 전달 받은 Uri에 대해서 지속적인 권한을 얻도록 요청합니다. 요청하면 디바이스가 Reboot되어도 앱은 권한을 유지할 수 있습니다.
  2. Uri의 Dir에 존재하는 파일들을 모두 출력해 줍니다.
  3. DocumentFile 문서들을 쉽게 다루기 위해 추상화된 클래스입니다. 인자로 Context와 Uri를 넘겨줍니다.
  4. listFiles()는 Uri의 하위 파일들을 DocumentFile 배열로 리턴해 줍니다.
  5. For 문으로 모든 DocumentFile을 출력합니다.

DocumentFile은 Gradle에서 implementation 'androidx.documentfile:documentfile:1.0.1' 를 추가하시면 사용할 수 있습니다.

실행 결과를 보면, 이 폴더에 존재하는 두개의 파일이 모두 출력되었습니다.

TreeExampleActivity: List files: NewImage.jpg (1)
TreeExampleActivity: List files: NewImage.jpg

이 폴더에 대한 지속적인(Persistable) 권한을 얻었기 때문에 디바이스를 Reboot해도 이 폴더에 접근할 수 있습니다. Uri를 App의 Preference에 저장해두었다가, reboot 후, 저장한 Uri에 대해서 접근해보세요. 권한이 유지된 것을 확인할 수 있습니다.

Uri를 저장하여 Reboot후 다시 접근하는 예제의 전체 코드를 보고 싶으시면 GitHub - TreeExampleActivity.kt를 확인해주세요

참고

Loading script...

Related Posts

codechachaCopyright ©2019 codechacha