개요
SD Card 또는 External Storage에 대해 가볍게 접근했다가 대혼돈이라는걸 뒤늦게 알았다. 내맘같아선, 그냥 기존처럼 자유롭게 접근하게 냅두면 좋겠지만… 모든 변화의 핵심은 Internal Storage 수준의 보안과 권한(Permission)이다. 사족을 달자면, 대부분 앱들은 아이폰용으로도 개발되기 때문에 이 골치아픈 External Storage를 다루지 않고 전부 Internal Storage만 사용한다, 카카오 톡처럼. 내용을 볼수록 이것도 이해가 되긴 하는데, 안드로이드 유저로선 그냥 게으르고 무책임한게 아닌가도 생각이… ㅋ
Android 10 이전까지는 그냥 READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE 권한만 가지고 다음의 API로 디렉토리를 얻어오면, 공유 영역으로서 External Storage에 접근해 자유롭게 사용해왔다.
Environment.getExternalStorageDirectory()
Android 10부터, Scoped Storage라는 개념이 들어오면서 이전방식을 사용하기 위해선, 다음을 명시해줘야 했다.
<application android:requestLegacyExternalStorage="true" ... >
...
</application>
이조차도 Android 11부터는 사용할 수가 없다. 더이상은 External Storage에 자유로운 접근은 불가능하다.
Scoped Storage가 적용되면서 변화했다고 얘기했는데, 이것은 간단하게 External Storage에서도 Internal Storage처럼 다른앱은 접근 못하는 App specific storage를 사용한다는 얘기다. Internal storage에서 context.filesDir 로 경로를 가져오듯이, ContextCompat.getExternalFilesDir(), ContextCompat.getExternalFilesDirs() 를 이용한다.
ContextCompat.getExternalFilesDir(type)
ContextCompat.getExternalFilesDirs(applicationContext, type)
type은 root는 null을 넣어 얻어올 수 있고, 서브디렉토리는 Environment.DIRECTORY_MUSIC 과 같은 파라미터를 넣어주면 된다. Scoped Storage는 앱에 귀속된 영역이기 때문에, Internal Storage가 그렇듯 앱을 삭제하면 같이 사라진다고 한다.
그렇다면, External Storage를 Scoped Storage로 밖에 사용하지 못하나 생각이 든다. 그렇지는 않고 기존 Internal Storage에서 사용하는 Media store기능을 사용할 수 있다. 이렇게하면, 공용영역에서 Images, Videos, Audio files, Download files등의 공간으로서 사용가능하다. 다만, 공용영역으로 다른 앱이 생성한 미디어 파일에 접근하기 위해, Android 13부터는 다음 권한을 별도로 필요로 한다.
<!-- Required only if your app needs to access images or photos
that other apps created. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- Required only if your app needs to access videos
that other apps created. -->
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<!-- Required only if your app needs to access audio files
that other apps created. -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
미디어 별로 권한이 따로 생긴거 외에, 여전히 READ_EXTERNAL_STORAGE가 사용된다는걸 주의하자. 관련된 내용은 공식문서에서 확인 가능하다.
아니 그럼, External Storage를 이렇게 Internal Storage정도로만 사용이 가능한거냐 여전히 생각이 든다. 이를 위해 존재하는게 Storage Access Framework(SAF)이다.
Storage Access Framework(SAF)
놀랍게도 Storage Access Framework(이하 SAF)는 안드로이드 4.4때부터 존재해 왔다. 이 정체가 그냥 추상적인 저장소의 Document File들에 대한 Content Provider이기 때문이다(Documents Provider). 추상적인 저장소라 함은 Internal Storage와 cloud storage에도 사용이 가능하기 때문이다. External Storage에 접근이 제한되는 Android 10, 11부터는 이를 통해, External Storage의 공용 저장공간(Shared Storage)에 접근할 수 있다.

SAF에서 앱은 직접 파일 엑세스를 하지 않는다. Documents Provider의 Client로서 Provider에게 파일에 대한 read, edit, create, delete 권한을 요청해서 사용한다.
Documents Provider도 Contents Provider이기 때문에, Intent를 날려 요청을 한다. 사용되는 인텐트는 ACTION_OPEN_DOCUMENT, ACTION_CREATE_DOCUMENT, ACTION_OPEN_DOCUMENT_TREE 세가지이다. 이 인텐트에는 MIME type을 이용하여 원하는 파일 타입을 필터링할 수 있다. 이 인텐트들을 날리게 되면, 파일이나 디렉토리를 선택하는 시스템이 제공하는 Picker UI가 뜨게된다. 다음 이미지는 photo 파일로 MIME type을 설정한 ACTION_OPEN_DOCUMENT 인텐트를 보내서 파일을 여는 예이다.

UI모양은 시스템에 따라 다를 수 있는데, 먼저 디렉토리를 선택하는 UI가 뜬다. 위 이미지를 보면, SDCARD도 보이고 구글 드라이브도 선택할 수 있는게 보인다. 여기서 Downlaods를 선택했다고 가정하면 다음과 같은 파일 Picker를 볼 수 있다.

여기서 파일을 선택하면 앱에 해당 파일에 대한 Uri가 리턴되는 방식이다. ACTION_CREATE_DOCUMENT의 경우에도 파일을 어디에 생성할지 디렉토리 선택창이 뜨게되고, ACTION_OPEN_DOCUMENT_TREE의 경우에도 디렉토리 선택창이 보여지고 디렉토리를 선택하면, 해당 디렉토리 내의 내용이 Uri로 리턴된다. 실사용 예제는 뒤에서 다룰 것이다.
이와같이 시스템 Picker UI는 해당 파일(또는 디렉토리)에 대한 Uri가 앱에 리턴된다. 세스템 UI에서 사용자에게 물어보는 방식으로 권한과 관련된 처리가 되기 때문에, 앱에서 별도로 설정할 권한이 필요없이 리턴받은 Uri를 이용하여 파일 작업이 가능해진다. 이렇게 부여받은 Uri에 대한 권한은 디바이스가 재부팅 될 때까지 유효하다. 하지만, 재부팅 시에도 권한이 필요한 경우, 인텐트의 플래그를 이용하여 다음 코드와 같이 영구적인 권한요청도 가능하다.
val contentResolver = applicationContext.contentResolver
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
// Check for the freshest data.
contentResolver.takePersistableUriPermission(uri, takeFlags)
당연하겠지만, 해당 파일이 이동하거나, 삭제되면 이렇게 획득한 권한은 유지되지 않는다.
SAF가 어떻게 작동하는지는 충분히 설명한거 같고, 이제 다큐먼트 프로바이더의 클라이언트로서 세가지 인텐트 ACTION_OPEN_DOCUMENT, ACTION_CREATE_DOCUMENT, ACTION_OPEN_DOCUMENT_TREE 들을 어떻게 사용하는지 알아보자.
SAF Intents
ACTION_OPEN_DOCUMENT_TREE
작업 디렉토리를 선택하는 인텐트이다. 사용방법은 다음과 같다.
class MainActivity : AppCompatActivity() {
...
private val openDocTree = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
binding.currentTreeUri.text = uri.toString()
if(uri != null) requestPersistantPermission(uri)
}
override fun onCreate(savedInstanceState: Bundle?) {
...
binding.openDocTree.setOnClickListener {
val uri = null // root directory
openDocTree.launch(uri)
}
}
...
인텐트의 사용에 registerForActivityResult()를 사용했다. 이 API의 사용상 주의점은 등록을 onStart 이전에 해야한다는 것이다. 만약에 start 이후에 사용하면, 다음과 같은 런타임 에러를 만나게 된다.
attempting to register while current state is RESUMED. LifecycleOwners must call register before they are STARTED.
registerForActivityResult()를 사용시, ACTION_OPEN_DOCUMENT_TREE에 대한 contract가 ActivityResultContracts.OpenDocumentTree()로 미리 정의되어 있으므로 그대로 사용한다. 이렇게 등록을 해놓고, 버튼 클릭시, 불리도록 구현해놨다. openDocTree.launch(uri)가 그 부분이다. 인자로는 uri가 넘어가는데, EXTRA_INITIAL_URI 값을 받는다. 해당 부분 코드를 찾아보면 다음과 같다.
open class OpenDocumentTree : ActivityResultContract<Uri?, Uri?>() {
@CallSuper
override fun createIntent(context: Context, input: Uri?): Intent {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && input != null) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input)
}
return intent
}
...
createIntent()가 launch()때마다 인텐트를 생성하는데 호출된다. 코드를 보면, android O( API 8.0/26) 이상일 때, putExtra()로 EXTRA_INITIAL_URI값을 넣어주고 있다. 하지만, 없는 기능으로 생각하는게 맘 편할거다. 이 EXTRA_INITIAL_URI에 대해선 뒤에 추가로 다루겠다.
이렇게 해서 실행하게되면, 다음과 같은 시스템 UI가 떠서 디렉토리 선택이 가능하다. UI는 안드로이드 버전에 따라 다르다.

사용할 수 없는 디렉토리의 경우, 위쪽에 Create new Folder가 뜨고 여길 클릭해서 새 폴더를 생성해 사용할 수 있다. 바로 사용할 수 있다면, 하단에 “Use this folder” 버튼이 활성화 되고 버튼을 클릭해서 원래 앱으로 복귀하며 해당 폴더의 uri를 반환한다.
안드로이드 버전에 따라 다르게 동작할지 모르겠으나, 이렇게 한 번 선택한 폴더가 다음번 실행해도 이 위치로 계속 유지된다. EXTRA_INITIAL_URI를 직접 넣어줘도 무시되는 것을 확인했다. 뒤의 다른 인텐트에 대해서도 마찬가지다.
ACTION_CREATE_DOCUMENT
새로운 파일을 만드는 인텐트이다. 사용법은 ACTION_OPEN_DOCUMENT_TREE와 크게 다르지 않다. 내가 원하는 위치에 저장할 수 있으면 좋겠지만, 시스템 UI에서 사용자에 의해 저장 위치가 정해진다.
private val createDoc = registerForActivityResult(MyCreateDocument("text/plain")) { uri ->
binding.currentDocUri.text = uri.toString()
if(uri != null) addText(uri)
}
...
binding.createDocument.setOnClickListener {
val filename = "test.txt"
createDoc.launch(filename)
}
Contract위치에 MyCreateDocument()가 들어가 있는데, 이는 다음과 같이 구현했다.
class MyCreateDocument(mimetype: String, private var initialUri: Uri? = null): ActivityResultContracts.CreateDocument(mimetype) {
override fun createIntent(context: Context, input: String): Intent {
val intent = super.createIntent(context, input)
return if(initialUri != null)
intent.putExtra(EXTRA_INITIAL_URI, initialUri)
else
intent
}
fun putInitialUri(uri: Uri) {
initialUri = uri
}
}
ActivityResultContracts에 미리 구현되어 있는 CreateDocument(mimeType)을 사용하면 되는데, EXTRA_INITIAL_URI를 추가하고 싶어서 상속받아 createIntent()를 구현했다. EXTRA_INITIAL_URI가 의도대로 동작하지 않아서 필요 없을수 있지만, 이런식의 사용이 가능하다는걸 보여주기위해 그대로 사용해봤다.
이미 봤지만, CreateDocument() Contract는 생성자에 mimeType을 인자로 받는다. 이는 저장할 파일의 type을 지정해준다. 실제로 실행하는 launch()에서는 인자로 파일이름을 받는다. 이는 시스템 UI에서 저장 파일 이름으로 사용된다.
시스템 UI에서 “저장”을 선택하면 해당 이름의 파일에 대한 uri를 돌려받는다. 이 uri로 부터 파일을 얻어와 써주면 된다. 코드에서 addText(uri)를 불러주고 있는데, 이 내용은 다음과 같다.
private fun addText(uri: Uri) {
try{
val os = contentResolver.openOutputStream(uri, "w")
if(os != null) {
os.write("This is test text file".toByteArray())
os.close()
}
}catch(e: Exception){
Log.e("SAFSample", "Can't open file")
}
}
uri로부터 파일을 스트림으로 얻어와 써주고 있다.
ACTION_OPEN_DOCUMENT
이미 존재하는 파일을 열 때 사용하는 인텐트이다.
...
private val openDoc = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
binding.currentDocUri.text = uri.toString()
}
...
binding.openDocument.setOnClickListener {
openDoc.launch(arrayOf("*/*"))
}
launch()시 인자는 MimeType의 리스트를 넘겨준다. 이는 보여줄 파일 타입들의 필터링에 사용된다. Mime type에 대해서는 모질라 문서를 참고하자.
시스템 UI에서 파일을 선택하면, 그 파일에 대한 uri를 돌려받게 된다. 이걸 ACTION_CREATE_DOCUMENT에서 했듯이, 파일 입출력을 이용해 원하는 작업을 수행하면 된다.
결론 및 EXTRA_INITIAL_URI 문제
어… 이걸 정말 써야하나 고민이 많아진다. 일단, 시스템 UI가 무지하게 늦게 뜬다. 로딩이 뜨지도 않고 그냥 빈화면이 한참 보인다고. 그리고 결정적으로 EXTRA_INITIAL_URI 문제. SD 카드에 저장하고 싶을 때는 앱에서 위치를 지정해주고 싶을거다. 난잡한 외장 스토리지의 디렉토리 구조를 유저에게 넘겨줄 마음이 없다고. 그런데, 이게 제대로 동작을 안한다. 또하나, 디렉토리를 변경하면 매번 사용여부를 물어보고 권한을 준다. 한 번 사용했다고 해도, 다른 디렉토리로 갔다가 또 다시 변경하려면 새로 얻어야 한다.
정, 외장 스토리지를 사용하고 싶다면, scoped storage로 그냥 앱에 제한된 영역을 사용하자. 외부로 빼야할 경우엔 파일 export같은 기능만 추가하여 SAF를 이용, 어느 위치에 파일을 저장할지는 시스템 UI를 통해 유저에게 맡겨버리자. 더 욕심내지 말자 ㅋㅋㅋ 아, 외장 스토리지를 맘대로 사용하던 Android 10까지와 그게 불가능한 Android 11이상을 구분해서 다루는 것도 도움이 될거다.