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 등의 미디어 권한은 부여되지 않음.

Android 14 미디어 파일 일부 접근 권한

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
}