Android 14 신규 컨셉, 사진/동영상의 일부 접근 권한
Android 13 이하에서 앱은 사진/동영상 접근 권한을 사용자에게 요청하면, 모든 파일에 대해서 접근할 수 있었습니다.
하지만, Android 14에서 사진/동영상에 대한 일부 접근 권한 부여 컨셉이 도입되어, 사용자는 파일 1개 또는 몇개만 앱에 접근 권한을 부여할 수 있게 되었습니다. 아래 사진을 보시면, “Select photos and videos” 메뉴가 새로 생겼고, 이 메뉴를 통해 사용자가 앱에 일부 파일 접근 권한만 부여할 수 있습니다.
이런 새로운 컨셉은 사용자의 Privacy를 보호하기 위해서 입니다. 앱은 미디어 접근 권한을 얻고, 무분별하게 파일에 접근했었지만, 사용자는 필요한 파일만 권한을 부여할 수 있기 때문에, 사용자에게 필요한 파일이 무엇인지 왜 이 파일에 접근해야하는지 상세히 안내할 필요가 있습니다.
1. 퍼미션 요청
Android 14에서 일부 접근 권한 방식을 처리하려면 AndroidManifest에서 READ_MEDIA_VISUAL_USER_SELECTED
권한을 별도로 추가해야 합니다.
READ_EXTERNAL_STORAGE
권한은 Android 14에서 사용되지 않도록 변경되었습니다. Android 12L 이하 디바이스에 앱이 설치되고 이 권한이 사용된다면 선언하고maxSdkVersion="32"
로 max SDK를 제한할 수 있습니다.- Android 14 디바이스에 앱이 설치될 때,
READ_MEDIA_VISUAL_USER_SELECTED
를 선언하지 않아도 자동으로 추가됩니다. 따라서, 권한을 선언하지 않는다고 해서 새로운 정책을 피할 수는 없습니다.
<!-- Android 12L (API level 32) 또는 이하 디바이스에서 미디어 권한 요청 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<!-- Android 13 (API level 33) 또는 이상 디바이스에서 미디어 권한 요청 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<!-- Android 14 (API level 34) 이상 디바이스에서 READ_MEDIA_VISUAL_USER_SELECTED 권한 추가 필요 -->
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
OS 별로 아래와 같이 사용자에게 권한을 요청할 수 있습니다.
- Android 14(UPSIDE_DOWN_CAKE) 이상에서는
READ_MEDIA_IMAGES
와 함께READ_MEDIA_VISUAL_USER_SELECTED
권한도 함께 요청합니다. 물론,READ_MEDIA_VISUAL_USER_SELECTED
권한을 요청하지 않아도, 자동으로 요청됩니다. 즉, 명시적으로 앱이 이 권한을 요청 안한다고 해서 신규 정책을 피할 수 없습니다. - Android 10 이상의 디바이스에서 Scoped Storage 방식으로 미디어 파일에 접근한다면
READ_EXTERNAL_STORAGE
권한은 받을 필요가 없습니다.
val requestPermissions = registerForActivityResult(RequestMultiplePermissions()) { results ->
// Handle permission requests results
// See the permission example in the Android platform samples: https://github.com/android/platform-samples
}
// OS 별로 필요한 권한 요청
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))
}
2. 동작 원리
이제부터 Android 14 디바이스 기준으로 설명하겠습니다.
Android 14 디바이스에 아래와 같은 퍼미션이 AndroidManifest에 선언되었습니다.
READ_MEDIA_IMAGES
READ_MEDIA_VIDEO
READ_MEDIA_VISUAL_USER_SELECTED
그리고, 권한 팝업을 띄워 사용자는 아래 3개의 메뉴가 보이게 됩니다.
- Select photos and videos
- Allow all
- Don’t allow
만약 사용자가 “Allow all”을 눌러 모든 접근 권한을 앱에 부여했다고 가정하면, 이 때 아래 3개 권한이 모두 앱에 부여됩니다.
READ_MEDIA_IMAGES
READ_MEDIA_VIDEO
READ_MEDIA_VISUAL_USER_SELECTED
만약 사용자가 “Select photos and videos” 메뉴를 눌러, 1개의 파일 접근 권한을 앱에 부여했다고 가정하면, 이 때는 앱에 1개 권한만 부여됩니다. “READ_MEDIA_IMAGES” 또는 “READ_MEDIA_VIDEO”는 앱에 부여되지 않습니다. 이 두개 권한이 앱에 부여되면 모든 파일에 접근할 수 있기 때문입니다.
READ_MEDIA_VISUAL_USER_SELECTED
따라서, 아래와 같은 방식으로 내 앱이 일부 파일 접근 권한을 갖고 있는지, 모든 파일 접근 권한을 갖고 있는지 확인할 수 있습니다.
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
)
) {
// Android 13 (API level 33) 이상 디바이스에서
// 모든 파일 접근 권한 갖고 있음
} else if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
ContextCompat.checkSelfPermission(context, READ_MEDIA_VISUAL_USER_SELECTED) == PERMISSION_GRANTED
) {
// Android 14 (API level 34) 이상 디바이스에서
// 일부 접근 권한만 갖고 있음
} else if (ContextCompat.checkSelfPermission(context, READ_EXTERNAL_STORAGE) == PERMISSION_GRANTED) {
// Android 12 (API level 32) 이하 디바이스에서
// 모든 파일 접근 권한 갖고 있음
} else {
// 어떤 권한도 갖고 있지 않음
// Scoped Storage를 사용한다면, 이 컨셉에서 기본으로 제공하는 파일 접근은 허용됨
}
일부 접근 권한과 전체 접근 권한의 차이점
전체 접근 권한을 갖고 있을 때(READ_MEDIA_IMAGES
), 아래와 코드와 같이 MediaStore를 통해 미디어 파일을 찾았을 때, 모든 미디어 파일 정보를 얻을 수 있습니다.
하지만, 일부 접근 권한(READ_MEDIA_VISUAL_USER_SELECTED
)만 갖고 있다면, 아래 코드에서 사용자가 허락한 파일 1개 또는 몇개에 대한 정보만 리턴됩니다.
따라서, 내 앱이 일부 접근 권한(READ_MEDIA_VISUAL_USER_SELECTED
)만 갖고 있는데, 원하는 파일이 검색 안된다면, 사용자에게 다른 파일이 필요하다고 설명하며, 다시 권한을 요청하여 팝업을 띄우고, 사용자가 다른 파일 권한도 앱에게 부여될 수 있도록 요청이 필요합니다.
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
}