-
MediaStore File 다루기 (Android ScopedStorage)Android 2019. 12. 11. 12:33반응형
https://github.com/DNights/AndroidScopedStorageSample
SDK 29(Android Q/Android 10) 부터 ScopedStorage가 적용되어 외부 저장소에 접근이 제한되었습니다.
앱별 디렉터리의 경우 앱 제거시 저장된 파일 들이 같이 삭제가 됩니다.
미디어 컬랙션의 경우 지정된 위치의 폴더(DCIM, Pictures ...)에만 접근이 가능합니다.
다운로드 폴더의 경우 저장소 엑세스 프레임워크(SAF) 를 통해서 접근이 가능합니다.
그외 기존에 사용하던 모든 접근은 차단 됩니다.
현재 일시적으로 아래와 같이 menifest 옵션으로 기존과 동일하게 처리를 할 수 있습니다.
<manifest ... > <!-- This attribute is "false" by default on apps targeting Android 10 or higher. --> <application android:requestLegacyExternalStorage="true" ... > ... </application> </manifest>
하지만 차후 ScopedStorage로 변경 될 것이기 대문에 미리 확인하여 적용테스트를 해보도록 하겠습니다.
우선 미디어 컬랙션 부터 다루도록 하겠습니다.
[파일 탐색/읽기]
fun getFileList(context: Context, type: MediaStoreFileType): List<MediaFileData> { val fileList = mutableListOf<MediaFileData>() val projection = arrayOf( MediaStore.Files.FileColumns._ID, MediaStore.Files.FileColumns.DISPLAY_NAME, MediaStore.Files.FileColumns.DATE_TAKEN ) val sortOrder = "${MediaStore.Files.FileColumns.DATE_TAKEN} DESC" val cursor = context.contentResolver.query( type.externalContentUri, projection, null, null, sortOrder ) cursor?.use { val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID) val dateTakenColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_TAKEN) val displayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME) while (cursor.moveToNext()) { val id = cursor.getLong(idColumn) val dateTaken = Date(cursor.getLong(dateTakenColumn)) val displayName = cursor.getString(displayNameColumn) val contentUri = Uri.withAppendedPath( type.externalContentUri, id.toString() ) Log.d( "test", "id: $id, display_name: $displayName, date_taken: $dateTaken, content_uri: $contentUri\n" ) fileList.add(MediaFileData(id, dateTaken, displayName, contentUri)) } } return fileList } enum class MediaStoreFileType( val externalContentUri: Uri, val mimeType: String, val pathByDCIM: String ) { IMAGE(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*", "/image"), AUDIO(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, "audio/*", "/audio"), VIDEO(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, "video/*", "/video"); }
작성한 샘플 코드를 보면 위와 같이 작성되어 있으며
context 의 getContentResolver 를 얻어서 query를 통해 미디어 파일들을 탐색할 수 있습니다.
val cursor = context.contentResolver.query( type.externalContentUri, projection, null, null, sortOrder )
externalContentUri의 경우 파일 타입별로 아래와 같습니다.
이미지의 경우 MediaStore.Images.Media.EXTERNAL_CONTENT_URI
오디오의 경우 MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
비디오의 경우 MediaStore.Video.Media.EXTERNAL_CONTENT_URI
파일 타입별로 조회가 가능합니다.
p.s (2019/12/11)
조회시 넘어오는 데이터 중에서 Date의 경우 어떤 것으로 조회를 하여도 1970-1-1 Thu Jan 01 09:00:00 GMT+09:00 1970 의 Date 초기값으로 지정되어 있어 작동상에 문제가 있는것으로 보입니다.
파일을 읽을경우 Uri를 통해서 접근하여 InputStream 등의 방법으로 파일을 읽을수 있습니다.
val contentUri = Uri.withAppendedPath( type.externalContentUri, id.toString() )
해당 파일의 Uri 는 위애서 설명한 externalContentUri + id 입니다.
이미지파일을 예로 들면 MediaStore.Images.Media.EXTERNAL_CONTENT_URI + id 이며
실제 로그상에서 String으로 출력시 content://media/external/images/media/11 와 같이 표시 됩니다.
[파일 생성]
fun createFile( context: Context, fileName: String, fileType: MediaStoreFileType, fileContents: ByteArray ) { val contentValues = ContentValues() /** * image allowed directories are [DCIM, Pictures] * audio allowed directories are [Alarms, Music, Notifications, Podcasts, Ringtones] * video allowed directories are [DCIM, Movies] */ when(fileType){ MediaStoreFileType.IMAGE ->{ contentValues.put( MediaStore.Files.FileColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + fileType.pathByDCIM ) } MediaStoreFileType.AUDIO ->{ contentValues.put( MediaStore.Files.FileColumns.RELATIVE_PATH, Environment.DIRECTORY_MUSIC + fileType.pathByDCIM ) } MediaStoreFileType.VIDEO ->{ contentValues.put( MediaStore.Files.FileColumns.RELATIVE_PATH, Environment.DIRECTORY_MOVIES + fileType.pathByDCIM ) } } contentValues.put(MediaStore.Files.FileColumns.DISPLAY_NAME, fileName) contentValues.put(MediaStore.Files.FileColumns.MIME_TYPE, fileType.mimeType) contentValues.put(MediaStore.Files.FileColumns.IS_PENDING, 1) val uri = context.contentResolver.insert( fileType.externalContentUri, contentValues ) val parcelFileDescriptor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { context.contentResolver.openFileDescriptor(uri!!, "w", null) } else { TODO("VERSION.SDK_INT < KITKAT") } val fileOutputStream = FileOutputStream(parcelFileDescriptor!!.fileDescriptor) fileOutputStream.write(fileContents) fileOutputStream.close() contentValues.clear() contentValues.put(MediaStore.Files.FileColumns.IS_PENDING, 0) context.contentResolver.update(uri, contentValues, null, null) }
파일 생성의 경우 ContentValues 를 통해서 파일을 생성할 수 있습니다.
생성시 MediaStore.Files.FileColumns.RELATIVE_PATH, MediaStore.Files.FileColumns.DISPLAY_NAME, MediaStore.Files.FileColumns.MIME_TYPE 등의 항목을 추가할 수 있습니다.
MediaStore.Files.FileColumns.RELATIVE_PATH 의 경우 DCIM, Pictures만 접근 가능하며
그외의 경로를 접근시 아래와 같이 애러를 발생 시키며 접근이 불가능하게 됩니다.
java.lang.IllegalArgumentException: Primary directory (invalid) not allowed for content://media/external/images/media; allowed directories are [DCIM, Pictures]
접근 가능한 폴더는 파일 타입별로 아래와 같습니다.
이미지 - DCIM, Pictures
오디오 - Alarms, Music, Notifications, Podcasts, Ringtones
비디오 - DCIM, Movies
[파일 삭제]
private var removeUri = Uri.EMPTY fun removeMediaFile(context: Context, uri: Uri) { try { uri.let { context.contentResolver.delete(uri, null, null) Log.d("test", "Removed MediaStore: $it") } (rv_file_list.adapter as MediaFileAdepter).setFileList(getFileList(this, curType)) } catch (e: RecoverableSecurityException) { val intentSender = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { e.userAction.actionIntent.intentSender } else { TODO("VERSION.SDK_INT < O") } intentSender?.let { ActivityCompat.startIntentSenderForResult( context as Activity, intentSender, DELETE_PERMISSION_REQUEST, null, 0, 0, 0, null ) } } removeUri = uri } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (resultCode == Activity.RESULT_OK && requestCode == DELETE_PERMISSION_REQUEST) { removeMediaFile(this, removeUri) } }
파일을 삭제시 해당 앱에서 생성하고 권한이 있는 앱의 경우 삭제시 바로 삭제가 가능합니다.
다른 앱의 파일의 경우 삭제를 시도시 RecoverableSecurityException 발생합니다.
이경우 userAction.actionIntent.intentSender 를 통해서 startIntentSenderForResult 를 사용하여 사용자에게 권한이 없는 파일의 삭제여부를 전달할 수 있습니다.
요청시 아래와 같은 창이 표시되며 삭제 여부를 사용자에게 물어 봅니다.
허용을 누르게 되면 onActivityResult 통해서 삭제의 허용여부에 대한 답변과 권한을 가지고 옵니다.
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (resultCode == Activity.RESULT_OK && requestCode == DELETE_PERMISSION_REQUEST) { removeMediaFile(this, removeUri) } }
허용 이후 다시 removeMediaFile 을 호출하면 해당파일을 삭제합니다.
p.s (2019/12/11)
현재시점을 기준으로 삭제를 한뒤 파일을 조회하면 query 상으로는 삭제가 되었으나 실제 파일을 삭제되지 않고 아래와 같은 에러를 표시합니다. 정상적으로 삭제를 못하는 버그가 있는 것으로 확인 됩니다.
E/MediaProvider: Couldn't delete /storage/emulated/0/Pictures/Screenshots/Screenshot_20191211-093326.png
android.app.RecoverableSecurityException: dev.dnights.scopedstoragesample has no access to content://media/external/images/media/119https://issuetracker.google.com/issues/142270549
https://issuetracker.google.com/issues/145348304
p.s2(2019/12/11)
앱을 삭제후 다시 설치했을때 재설치 이전에 생성된 파일도 권한이 없는 파일로 인식하여 삭제여부를 물어봅니다.
[참고링크]
https://developer.android.google.cn/training/data-storage/files/external-scoped?hl=ko
https://brunch.co.kr/@huewu/14
https://codechacha.com/ko/android-mediastore-insert-media-files/
https://codechacha.com/ko/android-mediastore-read-media-files/
https://choidev-1.tistory.com/73
https://choidev-1.tistory.com/74
반응형'Android' 카테고리의 다른 글
Android Learning Links (안드로이드 학습 링크) (0) 2020.01.07 Android 고유식별자 (unique identifiers) (0) 2020.01.02 Android Studio Editor 폰트(글꼴) 변경 (0) 2019.12.17 Storage Access Framwork 다루기 (Android ScopedStorage) (1) 2019.12.13 Android R8 Compiler (0) 2019.12.05 Android Studio Layout Inspector (0) 2019.12.05 Android Decompile Setting (0) 2019.11.28 Declarative UI Patterns (0) 2019.10.26