Android 14 미디어 파일 일부 접근 권한(READ_MEDIA_VISUAL_USER_SELECTED)
Android 14의 새로운 미디어 파일 권한 컨셉, 미디어 파일(사진/동영상)의 일부 액세스 권한에 대해서 알아보겠습니다.
Android 13에서는 앱은 미디어 파일 전체 접근 권한만 요청할 수 있었는데, Android 14에서 일부 파일 접근 권한만 요청할 수 있도록 변경되었습니다.
사생활(Privacy) 보호 측면에서 사용자는 앱이 필요한 파일만 접근할 수 있도록 권한을 부여할 수 있습니다. 앱 또한 전체 파일이 필요 없는데 과도한 권한을 사용자에게 요청하지 않아도 되서, 사용자가 앱에게 파일 권한을 거부감 없이 부여해줄 수 있습니다.
1. 새로운 권한 소개
Android 13에서는 아래 두개 권한을 사용자에게 요청하여 모든 사진/동영상 파일의 접근 권한을 받을 수 있었습니다.
android.permission.READ_MEDIA_IMAGES
android.permission.READ_MEDIA_VIDEOS
Android 14에서는 위 두개 권한과 함께 READ_MEDIA_VISUAL_USER_SELECTED
권한을 요청하면, 파일 전체가 아닌, 일부 파일 접근 권한만 부여 받을 수 있습니다.
- Android 14에서 SDK API 34를 타게팅하는 앱은 이 권한을 추가하지 않아도 미디어 파일 권한을 추가하면 자동으로 추가됨 (앱은 강제로 일부 접근 권한 기능을 비활성화 할 수 없음)
<!-- Devices running Android 12L (API level 32) or lower -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<!-- Devices running Android 13 (API level 33) or higher -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<!-- To handle the reselection within the app on Android 14 (API level 34) -->
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
2. 권한 요청 방법
Android 버전 별로 아래와 같이 미디어 파일 접근 권한을 요청할 수 있습니다.
- Android 14에서는 미디어 파일 권한(
READ_MEDIA_IMAGES
등)과 함께READ_MEDIA_VISUAL_USER_SELECTED
를 요청합니다. - 팝업을 통해 사용자는 일부 파일 접근 권한을 앱에 부여할 수 있음
- 부여된 파일 접근 권한은 영원하지 않으며, 디바이스 리부팅 또는 시간이 오래 지났을 때 접근 권한이 만료될 수 있음. 따라서, 앱은 이전에 접근 권한을 부여 받은 파일이라도, 다시 접근할 때, 접근 가능한지 확인 후, 불가하다면 다시 사용자에게 권한을 요청해야 함.
// Register ActivityResult handler
val requestPermissions = registerForActivityResult(RequestMultiplePermissions()) { results ->
// Handle permission requests results
// See the permission example in the Android platform samples: https://github.com/android/platform-samples
}
// Permission request logic
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
requestPermissions.launch(arrayOf(READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_VISUAL_USER_SELECTED))
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestPermissions.launch(arrayOf(READ_MEDIA_IMAGES, READ_MEDIA_VIDEO))
} else {
requestPermissions.launch(arrayOf(READ_EXTERNAL_STORAGE))
}
일부 파일 접근 권한을 요청하면, 아래와 같은 화면이 보입니다.
- “Select photo and videos” 메뉴가 생겼고, 이 메뉴를 눌러 일부 파일 접근 권한만 앱에게 부여할 수 있음
- 일부 파일 접근 권한이 부여되면,
READ_MEDIA_VISUAL_USER_SELECTED
권한이 앱에 부여됨, 하지만READ_MEDIA_IMAGES
등의 미디어 권한은 부여되지 않음.
3. 권한 확인 방법
SDK API 34(UPSIDE_DOWN_CAKE) 이상을 타게팅하는 앱은 자신이 권한을 갖고 있는지 체크할 때 아래와 같이 체크할 수 있습니다.
READ_MEDIA_VISUAL_USER_SELECTED
권한만 갖고 있으면 일부 파일 접근 권한만 갖고 있는 상태READ_EXTERNAL_STORAGE
등, 미디어 파일 권한을 갖고 있으면 전체 파일 접근 권한을 갖고 있는 상태
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
(
ContextCompat.checkSelfPermission(context, READ_MEDIA_IMAGES) == PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, READ_MEDIA_VIDEO) == PERMISSION_GRANTED
)
) {
// Full access on Android 13 (API level 33) or higher
} else if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
ContextCompat.checkSelfPermission(context, READ_MEDIA_VISUAL_USER_SELECTED) == PERMISSION_GRANTED
) {
// Partial access on Android 14 (API level 34) or higher
} else if (ContextCompat.checkSelfPermission(context, READ_EXTERNAL_STORAGE) == PERMISSION_GRANTED) {
// Full access up to Android 12 (API level 32)
} else {
// Access denied
}
4. 미디어 파일 찾기 & 접근 방법
미디어 파일에 대한 접근 권한을 얻었다면, 아래와 같이 MediaStore의 query API를 사용하여 접근 가능한 파일을 탐색할 수 있습니다. 리턴되는 URI를 통해 파일 접근이 가능합니다.
data class Media(
val uri: Uri,
val name: String,
val size: Long,
val mimeType: String,
)
// Run the querying logic in a coroutine outside of the main thread to keep the app responsive.
// Keep in mind that this code snippet is querying only images of the shared storage.
suspend fun getImages(contentResolver: ContentResolver): List<Media> = withContext(Dispatchers.IO) {
val projection = arrayOf(
Images.Media._ID,
Images.Media.DISPLAY_NAME,
Images.Media.SIZE,
Images.Media.MIME_TYPE,
)
val collectionUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Query all the device storage volumes instead of the primary only
Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
} else {
Images.Media.EXTERNAL_CONTENT_URI
}
val images = mutableListOf<Media>()
contentResolver.query(
collectionUri,
projection,
null,
null,
"${Images.Media.DATE_ADDED} DESC"
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(Images.Media._ID)
val displayNameColumn = cursor.getColumnIndexOrThrow(Images.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndexOrThrow(Images.Media.SIZE)
val mimeTypeColumn = cursor.getColumnIndexOrThrow(Images.Media.MIME_TYPE)
while (cursor.moveToNext()) {
val uri = ContentUris.withAppendedId(collectionUri, cursor.getLong(idColumn))
val name = cursor.getString(displayNameColumn)
val size = cursor.getLong(sizeColumn)
val mimeType = cursor.getString(mimeTypeColumn)
val image = Media(uri, name, size, mimeType)
images.add(image)
}
}
return@withContext images
}