/ ANDROID, FILE, SCOPED STORAGE

안드로이드 파일 쓰기

안드로이드 저장소의 형태를 구분하고, 실제 파일을 생성하고 사용하는 방법에 대해 정리하였습니다.

코드 아래 path를 정리해두었는데 path는 제조사,기종,OS버전등에 의해 따라 달라질 수 있습니다. 대략적인 형상에 대해 정리 해두는 자료로 이해하기 바랍니다.

용어정리

Device Storage(저장소)의 구분

A.Internal Storage : 내부 저장소  
B.External Storage : 외부 저장소  
   B1. Built in non-removable storage : 기본으로 제공되는 빌트인 저장소. 보통 내장메모리라고 표현.  
   B2. SDcard : 디바이스 제조사, 모델에 따라 지원 할 수도 있는 SDcard    
   B3. USB Storage : USB 단자를 통해 연결된 저장장치  

기본적으로 내부 메모리 영역은 사용자가 직접 접근할 수 없는 영역이라고 생각 하면 됩니다. 보통의 경우 외부저장소는 B1을 지칭합니다.

External Storage 정책의 구분

- Legacy External Storage 
   : Scoped Storage가 적용되지않은 상태. targetSdkVersion이 28이하 이거나 단말이 Android 9이하. 
- Scoped Storage
   : Scoped Storage가 적용된 상태. targetSdkVersion이 29이상이고 단말이 Android 10이상.  
     단, targetSdkVersion이 29인 경우 requestLegacyExternalStorage를 활성화 하지 않아야 함 

앱별 저장소(App-specific storage)

내부/외부 저장소에 전용 디렉터리에 앱 전용으로 사용되는 파일을 저장합니다. 다른 앱이 액세스해서는 안 되는 민감한 정보를 저장하려면 내부 저장소 내의 디렉터리를 사용합니다. 외부저장소에 저장된 파일은 사용자나 다른 앱에 의해 읽기/쓰기/삭제 가능합니다.

1. Cache 영역

별도의 권한없이 파일을 생성,삭제 가능합니다. 다른 앱이나 사용자가 접근 할 수 없습니다. 추가 디스크 공간이 필요할 때 캐시 데이터의 총합이 할당된 크기를 초과한 경우 캐시영역의 파일이 삭제됩니다. sharedUserId 기능을 사용하는 경우 공유 UID의 모든 패키지에 대해 캐시 된 데이터가 함께 추적됩니다. 앱이 삭제되면 함께 삭제됩니다 .link

// 내부 저장소
val cacheFile = File(context.cacheDir, "cache_file.txt")
// > /data/user/0/*{applicationId}*/**cache**/cache_file.txt  #A
// 외부 저장소
val externalCacheFile = File(context.externalCacheDir, "ex_cache_file.txt")
// > /storage/emulated/0/Android/data/{applicationId}/**cache**/ex_cache_file.txt  #B1

cache에 사용되는 데이터도

캐시 할당 크기 및 사용중인 크기

Api level 26(O)부터는 가용공간 관리를 위해 몇가지 함수들이 추가되었습니다. getCacheQuotaBytes캐시에 사용할 공간으로 할당된 용량을 반환하고 getCacheSizeBytes사용중인 캐시파일의 전체 총합을 확인 할 수 있습니다.

StorageManager storageManager = (StorageManager) getSystemService(Context.
val quotaByte = storageManager.getCacheQuotaBytes(storageManager.getUuidForPath(getCacheDir())) // 캐시에 사용할 공간으로 할당된 용량
val cacheSize = storageManager.getCacheSizeBytes(uuidForPath) // 사용중인 캐시파일의 전체 총합

2. File 영역

앱이 설치 되어있는 동안만 영속적(Persistence)으로 필요한 파일을 저장하는 영역입니다. 앱이 삭제되면 함께 삭제됩니다. 별도의 권한 없이 파일을 생성가능합니다.

// style #1
val file1 = File(context.fileDir, "file.txt")
val outputStream = file.outputStream()
// > /data/user/0/*{applicationId}*/**files**/file.txt  #A
// style #2
val outputStream = openFileOutput("file.txt", MODE_PRIVATE)
// > /data/user/0/*{applicationId}*/**files**/file.txt  #A

style #1style #2의 결과로 얻어지는 FileOutputStream은 동일한 파일을 가르키고 있습니다. 하지만 개인적으로는 openFileOutput메서드를 사용하면 FileOutputStream에서 path를 얻을 수 없기 때문에 명시적으로 File 객체를 생성하여 사용하는 style #1을 주로 사용하고 있습니다.

val externalFile = File(context.getExternalFilesDir({Environment Type}, "file.txt")
// > /storage/emulated/0/Android/data/**files**/*{Environment Type}*/file.txt #B1

외부저장소에 앱데이터를 저장할때 사용합니다. 공유저장 장치가 에뮬레이션 된 경우에는 Scoped Storage가 적용되지 않은 상태에서는 WRITE_EXTERNAL_STORAGE권한만 있으면, 이 영역에 접근가능하기 때문에 보안에 취약합니다. 또한 사용자가 파일브라우져를 통해 삭제 할 수 있습니다.

공유 저장소 (Shared storage)

미디어, 문서 및 기타 파일을 비롯하여 앱이 다른 앱과 공유하려는 파일을 저장합니다. 다른앱에서 접근을 허용해야 하기 때문에 공유 저장소는 내부저장소를 사용 할 수 없습니다.

Legacy External Storage

Scoped Storage가 적용되지않은 상태. targetSdkVersion이 28이하 이거나 단말이 Android 9이하인 경우에는 Legacy External Storage를 사용가능 했습니다. 미디어와 파일의 구분없이 생성가능합니다.

val file = File(Environment.getExternalStorageDirectory(), "file.txt")
// > /storage/emulated/0/file.txt #B1

Legacy External Storage일때는 WRITE_EXTERNAL_STORAGE권한이 있는 경우 위와같이 외부저장소의 루트부터 대부분의 공간에 접근하고 쓰기가 가능했습니다. 그리고 필요한 경우 미디어는 ContentProvider에 등록했습니다.

Legacy External Storage 정책에서 공유되는 파일 작성 순서
Step 1. 쓰기 권한을 얻습니다.
Step 2. 파일을 작성합니다.
Step 3. 그리고 용도에 따라 명시적으로 ContentProvider에 등록합니다.

Scoped Storage

Media 유형

// Scoped Storage
val resolver = applicationContext.contentResolver
val audioCollection = MediaStore.Audio.Media.getContentUri(
        MediaStore.VOLUME_EXTERNAL_PRIMARY)
val newSongDetails = ContentValues().apply {
    put(MediaStore.Audio.Media.DISPLAY_NAME, "My Song.mp3")
}
val myFavoriteSongUri = resolver.insert(audioCollection, newSongDetails)
// > content://media/external/audio/media/{id}

Scoped Storage가 적용되면 다른앱과 공유되는 미디어 파일은 모두 MediaStore를 이용하여 생성해야 합니다.

개인적으로 처음에 Scoped Storage를 접했을때 MediaStore를 이용하여 생성해야 합니다라는 문맥때문에 혼란이 왔습니다. MediaStore 클래스가 핵심인 것은 맞지만 구현의 관점에서는 아래처럼 읽으면 문맥이 더 이해가 쉬웠습니다.
ContentResolver.insert는 MediaStore API와 ContentValues 객체를 인자로 받아 Uri를 생성합니다. ContentResolver.openXXXXX 메서드를 통해 FileOutputStream이나 ParcelFileDescriptor를 얻어서 파일을 쓰도록 합니다.

참고로 insert에서 반환하는 Uri는 Content Provider의 컨텐츠이기 때문에 content://로 시작합니다.

Scoped Storage 정책에서 공유되는 파일 작성
Step 1. ContentProvider에 파일에 대한 정보를 추가합니다.
Step 2. Step 1에서 얻은 Uri를 통해 파일을 작성합니다.

File(문서) 유형

Scoped Storage에서도 사용자 액션을 통해 파일을 특정 외부저장소에 저장가능합니다. 하지만, Legacy External Storage 에서 처럼 지정한 폴더에 지정한 이름으로 저장하지 않고, SAF(Storage Access Framework)를 통해 유저가 선택 할 수 있습니다. 우리는 단지 생성할 폴더,파일명 초기값만 넘겨주고 사용자가 선택하도록 합니다. onResultActivity를 통해 전달받은 uri를 통해 우리는 해당 파일을 컨트롤 할 수 있습니다.

아래는 간단한 예를 들기 위해 액티비티가 시작되면 사용자가 파일을 생성하고, 생성한 파일에 다운로드 받아 저장하는 아주 단순한 예제코드입니다. 가독성과 복붙만으로 동작을 보장하기 위해 의존성과 코드를 최대한으로 줄였습니다.

class DownloadAndSaveActivity : AppCompatActivity() {
    private val requestCodeForCreateDocument: Int = 1000

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        reqeustCreateDoc()
    }

    private fun reqeustCreateDoc() {
        val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            putExtra(Intent.EXTRA_TITLE, "example.pdf")
            type = "application/pdf"
        }
        startActivityForResult(intent, requestCodeForCreateDocument)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == requestCodeForCreateDocument && resultCode == Activity.RESULT_OK && data != null) {
            Thread {
                val inUri = Uri.parse("http://www.africau.edu/images/default/sample.pdf")
                val outUri = (data.data) as Uri
                download(inUri, outUri)
            }.start()
        }
    }

    private fun download(sourceUri: Uri, destUri: Uri) {
        val connect = URL(sourceUri.toString()).openConnection()
        val inputStream = connect.getInputStream()
        val outputStream: FileOutputStream = contentResolver.openFileDescriptor(destUri, "w")?.use { pfd ->
            FileOutputStream(pfd.fileDescriptor)
        } ?: throw IllegalArgumentException()
        writeStream(inputStream, outputStream)
    }


    private fun writeStream(inputStream: InputStream, outputStream: OutputStream) {
        var total = 0
        while (true) {
            val data = inputStream.read()
            if (data == -1) {
                break
            }
            total += data
            outputStream.write(data)
        }
        inputStream.close()
        outputStream.close()
    }
}

Scoped Storage 자세히 들여다 보기

Scoped Storage로 전환하는 방법에 대한 내용은 따로 정리하였습니다.

Search

Get more post