2021년 11월부터 구글 스토어에 앱을 등록하려면 타겟버전이 30 이상이여야 한대요.. 그리고 안드로이드 11 (Q, API level 30) 이상부터 Scoped Storage 를 사용하라고 합니다.. (Scoped Storage : 기존 외부...

[안드로이드] Android 11 대응, MediaStore 간단 예제

 


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]

Android develpoer

https://medium.com/hongbeomi-dev/android-11-scoped-storage-%EB%8C%80%EC%9D%91%ED%95%98%EA%B8%B0-6b4319cfac19

https://codechacha.com/ko/android-mediastore-remove-media-files/

 

 

 

0 comments: