2021년 11월부터 구글 스토어에 앱을 등록하려면 타겟버전이 30 이상이여야 한대요..
그리고 안드로이드 11 (Q, API level 30) 이상부터 Scoped Storage 를 사용하라고 합니다..
(Scoped Storage : 기존 외부 저장소에 public 한 공간이 사라지고 미디어 파일에 직적접인 접근이 안됨..ㅠㅠ)
이에 대한 대응으로 MediaStore 또는 SAF(Storage Access Framework) 를 알아야하는 상황인 것 같습니다.
MediaStore API 를 활용해서 예제를 만들고 정리하려고 합니다.
1. 미디어 저장소
미디어 저장소에는 저희가 접근할 수 있는 5가지의 컬렉션이 있습니다.
- Image : 이미지 관련 파일이 MediaStore.Image 테이블에 추가되어있음
- Video : 비디오 관련 파일이 MediaStore.Video 테이블에 추가되어있음
- Audio : 오디오 관련 파일이 MediaStore.Audio 테이블에 추가되어있음
- Download : 다운로드된 파일(sdcard/Download/)이 MediaStore.Download 테이블에 추가되어있음
(**Download 컬렉션은 앱에서 직접 생성하지 않은 경우 SAF 를 사용해야한다고 합니다.)
- File : 이미지, 비디오, 오디오, 다운로드에 있는 미디어 파일이 MediaStore.File 테이블에 추가되어있음.
2. 권한
내 앱에서 생성한 미디어 파일에만 접근하는 경우 권한이 필요 없습니다.다른 앱에서 생성된 미디어 파일에 접근 해야 하는 경우에는 권한이 필요합니다.
<!-- 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" />
<!-- If your app doesn't need to access media files that other apps created,
set the "maxSdkVersion" attribute to "28" instead. -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
3. 파일 저장
파일 스트림(inputStream) 을 인자로 받아서 MediaStore API 를 활용해 파일을 저장하는 함수입니다.
예제에서는 assets 에 저장된 sampleFile 을 저장하는 방식으로 테스트하였습니다.
@RequiresApi(Build.VERSION_CODES.Q)
fun saveMediaFile(context: Context, fileName: String, collection: Int, inputStream: InputStream): Boolean {
try {
val contentValues = ContentValues()
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1)
val contentResolver = context.contentResolver
val collectionUri = when (collection) {
COLLECTION_IMAGE -> contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
COLLECTION_AUDIO -> contentResolver.insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, contentValues)
COLLECTION_VIDEO -> contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues)
COLLECTION_DOWNLOAD -> contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
else -> contentResolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), contentValues)
} ?: return false
val fileDescriptor = contentResolver.openFileDescriptor(collectionUri, "w", null) ?: return false
val fileOutputStream = FileOutputStream(fileDescriptor.fileDescriptor)
val buffer = ByteArray(1024)
var read: Int
while (inputStream.read(buffer).also { read = it } != -1) {
fileOutputStream.write(buffer, 0, read)
}
inputStream.close()
fileOutputStream.close()
fileDescriptor.close()
contentValues.clear()
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
contentResolver.update(collectionUri, contentValues, null, null)
return true
} catch (e: Exception) {
e.printStackTrace()
return false
}
}
4. 미디어 파일
MedaiStore API 를 활용해 파일을 다루기 위해 아래와 같이 custom data class 를 정의해주었습니다.
data class MediaFile(
val contentUri: Uri,
val name: String,
val mimeType: String,
val relativePath: String,
val fullPath: String,
val date: Long,
val size: Int
)
5. 파일 불러오기
미디어 파일들을 query 해서 불러오는 함수입니다.
query 에 필요한 인자들을 잠깐 살펴보자면 아래와 같습니다.
- uri : 쿼리할 데이터의 URI
- projection : 쿼리 결과로 받고 싶은 데이터 종류
- selection, selectionArg : 쿼리 조건문
- setorder : 쿼리 결과 정렬
@RequiresApi(Build.VERSION_CODES.Q)
fun queryMediaFiles(context: Context, collection: Int, keyword: String?): ArrayList<MediaFile> {
val mediaFiles = ArrayList<MediaFile>()
// uri : 쿼리할 데이터의 URI
val uri = when (collection) {
COLLECTION_IMAGE -> MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
COLLECTION_AUDIO -> MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
COLLECTION_VIDEO -> MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
COLLECTION_DOWNLOAD -> MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL)
else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
} ?: return mediaFiles
// projection : 쿼리 결과로 받고싶은 데이터 종류
val projection = arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.MIME_TYPE,
MediaStore.MediaColumns.RELATIVE_PATH,
MediaStore.MediaColumns.DATA,
MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.MediaColumns.SIZE
)
// selection, selectionArgs : 쿼리 조건문 (DISPLAY_NAME 이 keyword 를 포함할 경우)
var selection: String? = null
var selectionArgs: Array<String>? = null
if (keyword != null && keyword.isNotEmpty()) {
selection = "${MediaStore.Files.FileColumns.DISPLAY_NAME} LIKE?"
selectionArgs = arrayOf("%$keyword%")
}
// setOrder : 쿼리 결과 정렬
val sortOrderDesc = "${MediaStore.Images.Media.DATE_MODIFIED} DESC" // 내림차순 정렬
val sortOrderAsc = "${MediaStore.Images.Media.DATE_MODIFIED} ASC" // 오름차순 정렬
val cursor = context.contentResolver.query(uri, projection, selection, selectionArgs, sortOrderDesc)
if (cursor == null || !cursor.moveToFirst()) return mediaFiles
cursor.use { c ->
val idIndex = c.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val nameIndex = c.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
val mimeTypeIndex = c.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE)
val pathIndex = c.getColumnIndexOrThrow(MediaStore.Images.Media.RELATIVE_PATH)
val dataIndex = c.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
val dateIndex = c.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED)
val sizeIndex = c.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)
do {
val id = c.getLong(idIndex)
val name = c.getString(nameIndex)
val mimeType = c.getString(mimeTypeIndex)
val relativePath = c.getString(pathIndex)
val fullPath = c.getString(dataIndex)
val date = c.getLong(dateIndex) * 1000
val size = c.getInt(sizeIndex)
val contentUri = ContentUris.withAppendedId(uri, id)
val mediaFile = MediaFile(contentUri, name, mimeType, relativePath, fullPath, date, size)
mediaFiles.add(mediaFile)
Log.d("MediaStore", "queryFiles add mediaFile : $mediaFile")
} while (c.moveToNext())
}
cursor.close()
return mediaFiles
}
5. 파일 삭제
미디어 파일을 삭제하는 함수입니다.
다른 앱에서 조작된 파일의 경우 RecoverableSecurityException 이 발생하며 실패합니다.
해당 Exception 이 발생했을 때 intentSender 를 통해서 삭제 권한을 부여 받을 수 있습니다.
@RequiresApi(Build.VERSION_CODES.Q)
fun deleteFile(context: Context, mediaFile: MediaFile): Int {
return try {
val contentResolver = context.contentResolver
contentResolver.delete(mediaFile.contentUri, null, null)
} catch (e: RecoverableSecurityException) {
// 권한이 없기 때문에 예외가 발생됩니다.
// RemoteAction은 Exception과 함께 전달됩니다.
// RemoteAction에서 IntentSender 객체를 가져올 수 있습니다.
// startIntentSenderForResult()를 호출하여 팝업을 띄웁니다.
val intentSender = e.userAction.actionIntent.intentSender
startIntentSenderForResult(context as Activity,
intentSender,
REQUEST_PERMISSION_DELETE,
null,
0,
0,
0,
null
)
return 0
}
}
6. 파일 읽기
미디어 파일의 contentUri 만 알고 있다면 해당 파일의 inputStream 을 가져올 수 있습니다.
해당 파일의 스트림을 통해서 데이터를 읽고 활용 할 수 있을 겁니다.
fun openInputStream(context: Context, mediaFile: MediaFile): InputStream? {
return context.contentResolver.openInputStream(mediaFile.contentUri)
}
7. 예제 다운로드
https://github.com/bictoselfdev/exampleMediaStore
[Related Post]
[안드로이드] SAF(Storage Access Framwork) 파일 생성/열기 예제
[Reference]
https://codechacha.com/ko/android-mediastore-remove-media-files/
0 comments: