Android 13 이하에서 앱은 사진/동영상 접근 권한을 사용자에게 요청하면, 모든 파일에 대해서 접근할 수 있었습니다.

하지만, Android 14에서 사진/동영상에 대한 일부 접근 권한 부여 컨셉이 도입되어, 사용자는 파일 1개 또는 몇개만 앱에 접근 권한을 부여할 수 있게 되었습니다. 아래 사진을 보시면, “Select photos and videos” 메뉴가 새로 생겼고, 이 메뉴를 통해 사용자가 앱에 일부 파일 접근 권한만 부여할 수 있습니다.

Android 14 신규 컨셉, 사진/동영상의 일부 접근 권한

이런 새로운 컨셉은 사용자의 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개의 메뉴가 보이게 됩니다.

  1. Select photos and videos
  2. Allow all
  3. 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
}

3. 참고