N予備校 Android アプリで Android 14 の「写真と動画への部分的なアクセス権を付与する」に対応した話

#

[N予備校 Android](https://play.google.com/store/apps/details?id=nico.ed.nnn.zane&hl=ja&gl=US&pli=1) チームでテックリードをしている鎌田です。
本記事は [Android Advent Calendar 2023](https://qiita.com/advent-calendar/2023/android) の 25 日目の記事です。
2023 年 10 月、[Android 14](https://developer.android.com/about/versions/14) がリリースされ、今回も様々なアップデートがありました。
その中でも`N予備校` Android アプリでは、[写真と動画への部分的なアクセス権を付与する](https://developer.android.com/about/versions/14/changes/partial-photo-video-access)について対応する必要がありました。
本記事では、「写真と動画への部分的なアクセス権を付与する」についての説明と、具体的にどう対応したかについて記載します。
他のアップデートに関しては、本記事では取り扱っておりませんのでご承知おきください。

## 写真と動画への部分的なアクセス権を付与する

Android 14 より、以下のように [READ_MEDIA_IMAGES](https://developer.android.com/reference/android/Manifest.permission#READ_MEDIA_IMAGES) パーミッションの許可を要求すると、
「写真と動画を選択」という選択肢が追加されるようになりました。

|  Android 13  |  Android 14  |
|:---:|:---:|
|  ![android_13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/93963/60d2d231-cd6a-44a5-66a6-251080abd1a5.png)  |  ![android_14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/93963/0740b073-9dad-339a-a2ce-8a618734eb3a.png)  |

以下はもともと実装していた画像へアクセスするためのパーミッションをチェックする処理です。

```kotlin
private val requestImagePermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
    if (it) {
        // 「許可」を選択した場合の処理を行う
        // カメラかギャラリーかを選択させる
        showCameraOrGallery()
    } else {
        // 「許可しない」を選択した場合の処理を行う
        // 権限が必要な旨のスナックバーを表示する
        showPermissionSnackBar()
    }
}
...
fun checkImagePermissions() {
    val permission = if (Build.VERSION.SDK_INT == Build.VERSION_CODES.TIRAMISU) {
        Manifest.permission.READ_MEDIA_IMAGES
    } else {
        Manifest.permission.READ_EXTERNAL_STORAGE
    }
    if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) {
        // 既に許可済みであれば requestImagePermissionLauncher.launch は実行せず、許可済みの時の処理を行う
    } else {
        // パーミッションを確認する
        requestImagePermissionLauncher.launch(permissions)
    }
}
```

`checkImagePermissions` はパーミッションを確認するための関数です。
`ContextCompat.checkSelfPermission` で許可済みか確認します。
許可されていなければ `requestImagePermissionLauncher.launch(permissions)` でパーミッションを確認します。
`registerForActivityResult` ブロックの中で、パーミッションダイアログでどの選択肢が選ばれたかに応じて処理します。

「写真と動画を選択」を選択すると、[Photo Picker](https://developer.android.com/training/data-storage/shared/photopicker) が表示されます。

Photo Picker とは、Android 11 から使用可能な画像や動画を追加するためのツールで、メディア全体ではなく、選択した画像と動画にのみアクセスを許可するという安全な方法が取られています。
また、このツールは自動的に更新されるため、コードを変更しなくても、ユーザーは時間の経過に伴いアプリの拡張された機能を利用できるようになります。

Photo Picker の挙動としては以下になります。

- x ボタンをタップすると、READ_MEDIA_IMAGES パーミッションが許可されない
- 任意の画像を選択して Add ボタンをタップすると、READ_MEDIA_IMAGES パーミッションが許可される
  - 許可後、アプリ終了後に再度上記のダイアログが表示されるようになる

既存実装の問題として、Photo Picker で任意の画像を選択して Add ボタンをタップしても READ_MEDIA_IMAGES パーミッションを許可したときの挙動（カメラかギャラリーかを選択する）になりました。
そのため、Photo Picker で選択された画像が反映されるわけではなく、不自然な挙動になっていました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/93963/9936b99a-1dbb-f2ff-18b9-a9160a1be151.gif" width="400" />

そこで Photo Picker で選択された任意の画像が反映されるよう、仕組みを変えることになりました。

## カメラロール画面を実装する

上記の問題を解決するため、新たにカメラロール画面を実装しました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/93963/ed494f12-d343-d48d-fc3d-fa4e551d7661.gif" width="400" />

カメラロール画面を開いた際にパーミッションを確認し、選択された結果に応じて状態を変化させます。

### AndroidManifest.xml に READ_MEDIA_VISUAL_USER_SELECTED パーミッションを追加する

AndroidManifest.xml に [READ_MEDIA_VISUAL_USER_SELECTED](https://developer.android.com/reference/android/Manifest.permission#READ_MEDIA_VISUAL_USER_SELECTED) パーミッションを追加します。

```xml:AndroidManifest.xml
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
```

READ_MEDIA_VISUAL_USER_SELECTED パーミッションは Android 14 から追加されました。
Photo Picker でユーザーが選択した外部ストレージの画像または動画ファイルを、アプリケーションが読み取ることを許可します。
このパーミッションは、READ_MEDIA_IMAGES パーミッション や READ_MEDIA_VIDEO パーミッション と一緒にリクエストする必要があります。

### カメラロールを開くまでの処理を実装する

カメラロール画面を開いた際のフローチャートは以下になります。

![camera_roll_flowchart.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/93963/1e7931d6-0225-8fae-ecea-6bc17cb3d86b.png)

フローチャートの中で肝になるのが「メディアのパーミッション確認」になります。
requestImagePermissionLauncher および checkImagePermissions を以下のように書き換えました。

```kotlin
private val requestImagePermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
    if (it[Manifest.permission.READ_MEDIA_IMAGES] == true ||
        (it[Manifest.permission.READ_EXTERNAL_STORAGE] == true && it[Manifest.permission.WRITE_EXTERNAL_STORAGE] == true)
    ) {
        // 「すべて許可」を選択した場合の処理を行う
        // 外部ストレージにある画像を全て読み込む
        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.setCameraRollImages(getCameraRollImagesFromExternalStorage())
        }
        // 外部ストレージにある画像を全て読み込むため、Photo Picker ボタンは表示しない
        viewModel.setShouldShowPhotoPickerButton(false)
    } else if (it[Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED] == true) {
        // 「写真と動画を選択」を選択した場合の処理を行う
        // Photo Picker で選択された画像を読み込む
        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.setCameraRollImages(getCameraRollImagesFromExternalStorage())
        }
    } else {
        // 「許可しない」を選択した場合の処理を行う
        // パーミッションが許可されていない旨のダイアログを表示する
        viewModel.setShouldShowPermissionDialog(true)
    }
}

/**
 * メディアのパーミッション確認
 */
fun checkImagePermissions() {
    // 事前に ContextCompat.checkSelfPermission() で各種権限を確認しておく
    val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
        // 写真やビデオへの部分的なアクセスを許可します
        // https://developer.android.com/about/versions/14/behavior-changes-all#partial-photo-video-access
        arrayOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED)
    } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.TIRAMISU) {
        // Android 13 以降から権限の種類が変わりました
        // https://developer.android.com/about/versions/13/behavior-changes-13#granular-media-permissions
        arrayOf(Manifest.permission.READ_MEDIA_IMAGES)
    } else {
        arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE)
    }
    val isAllGranted = !permissions.map {
        ContextCompat.checkSelfPermission(requireContext(), it)
    }.contains(PackageManager.PERMISSION_DENIED)
    if (isAllGranted) {
        // 既に許可済みの場合、外部ストレージから画像を読み込む
        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.setCameraRollImages(getCameraRollImagesFromExternalStorage())
        }
        // 外部ストレージにある画像を全て読み込むため、Photo Picker ボタンは表示しない
        viewModel.setShouldShowPhotoPickerButton(false)
    } else {
        // Photo Picker ボタンを表示する
        viewModel.setShouldShowPhotoPickerButton(true)
        // パーミッションを確認する
        requestImagePermissionLauncher.launch(permissions)
    }
}

/**
 * 外部ストレージからカメラロールに表示する画像一覧を取得する
 */
private suspend fun getCameraRollImagesFromExternalStorage(): List<String> = withContext(Dispatchers.IO) {
    val contentResolver = requireContext().contentResolver
    val projection = arrayOf(BaseColumns._ID)
    val cameraRollImages = mutableListOf<String>()
    contentResolver.query(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        projection,
        null,
        null,
        "${MediaStore.MediaColumns.DATE_ADDED} DESC" // 新しく追加された順
    ).use { c ->
        val cursor = c ?: return@use
        while (cursor.moveToNext()) {
            // ファイルパスを取得する
            val index = cursor.getColumnIndex(projection[0])
            val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, cursor.getLong(index))
            cameraRollImages.add(uri.toString())
        }
    }
    return@withContext cameraRollImages
}
```

変更点としては Android 14 の場合の分岐を追加しました。
上述の通り、READ_MEDIA_IMAGES パーミッションと一緒に READ_MEDIA_VISUAL_USER_SELECTED パーミッションをリクエストしました。

```kotlin
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
    // 写真やビデオへの部分的なアクセスを許可します
    // https://developer.android.com/about/versions/14/behavior-changes-all#partial-photo-video-access
    arrayOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED)
}
```

また、「写真と動画を選択」という選択肢が追加されたことで、それに対応した分岐処理も追加しました。
ここでは Photo Picker で選択された画像を読み込むようにします。
`getCameraRollImagesFromExternalStorage` というメソッドを新たに追加し、外部ストレージにある許可された画像を取得します。

```kotlin
else if (it[Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED] == true) {
    // 「写真と動画を選択」を選択した場合の処理を行う
    // Photo Picker で選択された画像を読み込む
    viewLifecycleOwner.lifecycleScope.launch {
        viewModel.setCameraRollImages(getCameraRollImagesFromExternalStorage())
    }
}
```

### カメラロール画面を開いて、パーミッション確認が終わってからの処理を実装する

カメラロール画面を開いて、パーミッション確認が終わってから、ユーザは以下のいずれかの処理を行います。

1. カメラを起動し、撮影した画像をカメラロール画面に反映する
2. Photo Picker を起動し、選択した画像をカメラロール画面に反映する
3. カメラロール画面から任意の画像を選択する

1 と 3 に関しては Android 14 対応とは直接関係ないので、2 についてのみ解説します。

#### Photo Picker を起動し、選択した画像をカメラロール画面に反映する

Photo Picker を起動するための実装は、単一選択と複数選択で実装が異なります。

##### 単一選択の場合

```kotlin
val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
    // Photo Picker からの画像選択後呼ばれる
    // uri には選択された画像の URI が格納されている
    uri?.let {
        viewModel.addCameraRollImage(uri.toString())
    }
}
...
// Photo Picker を起動する
pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
```

`registerForActivityResult` の引数に `ActivityResultContracts.PickVisualMedia()` を指定します。
そして、`ActivityResultLauncher<PickVisualMediaRequest>` を取得します。

`ActivityResultLauncher#launch` を呼び出す際、`ActivityResultContracts.PickVisualMedia.ImageOnly` を指定することで、画像のみを選択できるようにしています。
他にも `PickVisualMediaRequest` に渡せるパラメータとしては下記があります。

|　パラメータ | 概要 |
| --- | --- |
| ActivityResultContracts.PickVisualMedia.ImageAndVideo | 画像と動画が選択可能 |
| ActivityResultContracts.PickVisualMedia.ImageOnly | 画像のみ選択可能 |
| ActivityResultContracts.PickVisualMedia.VideoOnly | 動画のみ選択可能 |
| ActivityResultContracts.PickVisualMedia.SingleMimeType(mimeType) | 指定した mimeType に合致するコンテンツのみ選択可能 |

そして、`registerForActivityResult` ブロックの中で、選択された画像をカメラロール画面に反映するなどの処理をします。

##### 複数選択の場合

基本的には単一選択時と同じですが、`registerForActivityResult` の引数に `ActivityResultContracts.PickMultipleVisualMedia()` を指定する点が異なります。

```kotlin
val pickMultipleMedia = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris ->
    // Photo Picker からの画像選択後呼ばれる
    // uris には選択された画像群の URI が格納されている
    uris.forEach { viewModel.addCameraRollImage(it.toString()) }
}
...
// Photo Picker を起動する
pickMultipleMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
```

## 他に実装した内容

### カメラで撮影した画像をギャラリーに登録する処理が機能しなくなっていたのを修正した

もともとカメラで撮影した画像をギャラリーに登録するため、以下の処理を行っていました。

```kotlin
// ギャラリーへスキャンを促しカメラで撮影した画像を反映
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf("image/jpeg"), null)
```

ですが、この処理を呼び出しても以下のようなログが出力されていて、機能していないようでした。

```txt
2023-11-20 17:53:20.453 10896-11638 MediaScannerConnection パッケージ名 D Scanned ファイルパス to null
```

そこで、以下のサイトを参考に MediaStore へ画像ファイルを保存するための処理を実装し、こちらを呼び出すように修正しました。

- [Android: アプリ外からアクセスできるように画像を保存する (対象範囲別ストレージ対応)](https://qiita.com/irgaly/items/70a0f0599d78eafc1136)

```kotlin
/**
 * 外部のアプリから参照できるよう、MediaStore に画像ファイルを保存する
 */
private suspend fun storeImageToMediaStore(
    contentResolver: ContentResolver,
    imageUri: Uri,
    fileName: String,
    mimeType: String,
) = withContext(Dispatchers.IO) {
    // collection = "content://media/external/images/media" のような Content URI
    val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        // データ書き込みの場合は MediaStore.VOLUME_EXTERNAL_PRIMARY が適切
        // https://developer.android.com/training/data-storage/shared/media#add-item
        MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
    } else {
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI
    }
    val destination = contentResolver.insert(
        collection,
        ContentValues().apply {
            put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
            put(MediaStore.Images.Media.MIME_TYPE, mimeType)
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                // Android 10 以上であれば、ファイルの書き込みが完了するまで処理中の項目であることを表す MediaStore.Images.Media.IS_PENDING を設定する
                // IS_PENDING = true の間は、データが不完全とみなされ、他のアプリからは不可視の状態となる
                // https://developer.android.com/training/data-storage/shared/media#toggle-pending-status
                put(MediaStore.Images.Media.IS_PENDING, true)
            }
        }
    ) ?: run {
        Timber.e("保存メディアファイルの作成に失敗")
        return@withContext
    }
    try {
        contentResolver.openInputStream(imageUri).use { inputStream ->
            contentResolver.openOutputStream(destination).use { outputStream ->
                outputStream?.let {
                    inputStream?.copyTo(it)
                }
            }
        }
    } catch (e: FileNotFoundException) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
            // IS_PENDING = true のまま放置されると DATE_EXPIRES で設定された期限経過後に自動で削除される。
            // ただ Android 9 以下では IS_PENDING の仕組みはないので、手動で不要な項目を削除する
            // https://developer.android.com/reference/kotlin/android/provider/MediaStore.MediaColumns#DATE_EXPIRES:kotlin.String
            contentResolver.delete(destination, null, null)
        }
    }
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        // Android 10 以上であれば、書き込み完了後に MediaStore.Images.Media.IS_PENDING を更新し、 他のアプリからアクセスできるようにする
        contentResolver.update(
            destination,
            ContentValues().apply {
                put(MediaStore.Images.Media.IS_PENDING, false)
            },
            null,
            null
        )
    }
}
```

これにより、カメラで撮影した画像をギャラリーに登録する処理を復活させることができました。

## まとめ

本記事では、「写真と動画への部分的なアクセス権を付与する」についての説明と、具体的にどう対応したかについて記載しました。
Android 14　対応をしていく中で、画像選択の UI も都度ギャラリーアプリを起動するのではなく、極力アプリ内で完結するように改修しました。
アプリ内で処理が完結するようになったことで、アプリとしてもより使いやすくなったのではと感じています。

## 参考 URL

- [Request runtime permissions  |  Android Developers](https://developer.android.com/training/permissions/requesting)