Android 10から13までのメディアファイルへのアクセス

# 経緯
Androidアプリで久しぶりに画像・動画・音声などのメディアファイルへのアクセスを実装する機会があり、かなり昔と変わったなと感じたので整理してみます。
主に[対象範囲別ストレージ](https://developer.android.com/training/data-storage?hl=ja#scoped-storage)が実装されたAndroid 10以降からが大きな変更かと思いますので、Android 10以降のお話をまとめます。

結局今どうやって実装すれば良いのかを早く知りたい！という方は[こちら](#実装)からご覧ください。

# Android10から13までのメディアファイルアクセスに関する変更点

## Android 10 (APIレベル 29)
Android 10で[対象範囲別ストレージ](https://developer.android.com/training/data-storage?hl=ja#scoped-storage)が実装されました。
メディアファイルに限った話を端的にまとめると、**システム側でアプリと各メディアファイルが紐付けられる**ようになりました。

各メディアファイルは1つのアプリにのみ紐付けられ、アプリは自身が作成したメディアファイルに追加の権限なしでアクセス/変更することができるようになりました。

ただ、アプリを一度アンインストールして再度インストールした場合、
アンインストール前にアプリが作成したファイルにアクセスするには `READ_EXTERNAL_STORAGE` パーミッションが必要になります。

またこの影響で、メディアファイルへは `MediaStore API` か `Storage Access Framework` を通してしかアクセスできないようになりました。

ただ、この変更はアプリによってはかなり影響が大きかった（遠い目）ため、
一時的な措置として `AndroidManifest.xml` に `requestLegacyExternalStorage` を指定することでAndroid 9以前のアクセス方法でも外部ファイルにアクセスできるような救済措置が取られました。
※ 後述しますが、今は使えません。

「Android 10からメディアファイルに追加の権限なしでアクセス/変更することができるようになった」のに、`WRITE_EXTERNAL_STORAGE`パーミッションの`android:maxSdkVersion`が29に設定されているケースが多くあるのは、この救済措置があったためではないかと推測しています。

```xml:AndroidManifest.xml
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
                 android:maxSdkVersion="29" />
```

## Android 11 (APIレベル 30)
`targetSdkVersion`が30(Android 11)以上のアプリで`requestLegacyExternalStorage="true"`が無視されるようになりました。

また、アプリの運用的なお話をすると、このあたりから Google Play にリリースするアプリに`targetSdkVersion`のAPIレベルの最低要件が追加されました（本題ではなく詳しくは調べてないので、正確にいつから始まったかは曖昧です）。

https://developer.android.com/distribute/best-practices/develop/target-sdk?hl=ja

なので、これ以前は
「`targetSdkVersion`上げるとうまく動かないからとりあえずそのままにしとこうぜ」
ということができたものがそれすらもできなくなり、対応に追われた方も多いのではないかと思います。

また、メディアファイルへのアクセスについてですが、このバージョンから外部ファイルの書き込みに `WRITE_EXTERNAL_STORAGE` パーミッションが不要になりました。

## Android 12 (APIレベル 31)
メディアファイル関連でいえば `MANAGE_MEDIA` というパーミッションが追加されました。

例えばフォトギャラリーアプリのようにメディアファイルへのアクセス/編集が頻繁に必要なアプリに適した権限ですが、今回の本筋と外れるので割愛します。

## Android 13 (APIレベル 33)

他のアプリが作成したメディアファイルにアクセスするために、下記3つの権限から選択して必要な権限をリクエストするようになりました。

| メディアの種類 | 該当する権限 |
|---|---|
| 画像や写真 | `READ_MEDIA_IMAGES` |
| 動画 | `READ_MEDIA_VIDEO` |
| 音声ファイル | `READ_MEDIA_AUDIO` |

https://developer.android.com/about/versions/13/behavior-changes-13?hl=ja#granular-media-permissions

また、これまでの`READ_EXTERNAL_STORAGE`権限からの移行や新しく権限をもらう必要は特になく、自動的にこちらの`READ_MEDIA_XXXXX`権限が付与されるようです。

> ユーザーが以前にアプリに READ_EXTERNAL_STORAGE 権限を付与していた場合は、きめ細かいメディアの権限がアプリに自動的に付与されます。それ以外の場合は、アプリが前の表に示すいずれかの権限をリクエストすると、ユーザー向けダイアログが表示されます。

# 実装
サンプルの実装をGithubにアップロードしました。

https://github.com/y-mimura/MediaStore

実装の概要としては、アプリ内でカメラを利用して写真を撮影し、撮影した写真をアプリ内で確認する、という実装になっています。

写真の撮影処理はこのあたり。

```kotlin:app/src/main/java/com/example/mediastore/MainActivity.kt
    private fun capture() {
        if (!::imageCapture.isInitialized) {
            return
        }

        val fileName = "${System.currentTimeMillis()}.jpg"
        val contentValues = ContentValues().apply {
            put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
            put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
        }

        val outputFileOptions = ImageCapture.OutputFileOptions.Builder(
            contentResolver,
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            contentValues
        ).build()

        imageCapture.takePicture(outputFileOptions, ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageSavedCallback {
            override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
                Toast.makeText(this@MainActivity, "Image saved", Toast.LENGTH_SHORT).show()
            }

            override fun onError(exception: ImageCaptureException) {
                Toast.makeText(this@MainActivity, "Error saving image", Toast.LENGTH_SHORT).show()
            }
        })
    }
```

`ImageCapture.OutputFileOptions.Builder`の第二引数`saveCollection: Uri` に `MediaStore.Images.Media.EXTERNAL_CONTENT_URI` を指定しており、この撮影時点で外部ストレージへのアクセスが発生します。
が、このアプリ自身が作成したメディアファイルであるため、追加のパーミッションリクエストなどは必要ありません。

続けて読み取りの方はこのあたり。

```kotlin:app/src/main/java/com/example/mediastore/MainActivity.kt

    companion object {
        private const val MEDIA_IMAGE_PERMISSION = android.Manifest.permission.READ_EXTERNAL_STORAGE
        @RequiresApi(Build.VERSION_CODES.TIRAMISU)
        private const val MEDIA_IMAGE_PERMISSION_TIRAMISU = android.Manifest.permission.READ_MEDIA_IMAGES
    }

    private val mediaImagePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        MEDIA_IMAGE_PERMISSION_TIRAMISU
    } else {
        MEDIA_IMAGE_PERMISSION
    }

    private fun confirmShowImages() {
        AlertDialog.Builder(this)
            .setTitle("画像一覧を表示")
            .setSingleChoiceItems(arrayOf("このアプリで撮影した画像", "ライブラリの画像も含める"), checkedItem) { _, which ->
                checkedItem = which
            }
            .setPositiveButton("OK") { _, _ ->
                when (checkedItem) {
                    0 -> listImages()
                    1 -> checkMediaImagePermission()
                }
                checkedItem = 0
            }
            .setNegativeButton("キャンセル") { _, _ ->
                checkedItem = 0
            }
            .show()
    }

    // ...

    private fun isMediaImagePermissionGranted(): Boolean {
        return checkSelfPermission(mediaImagePermission) == PackageManager.PERMISSION_GRANTED
    }

    private fun checkMediaImagePermission() {
        if (isMediaImagePermissionGranted()) {
            listImages()
        } else {
            requestPermissions(arrayOf(mediaImagePermission), MEDIA_IMAGE_PERMISSION_REQUEST_CODE)
        }
    }

    // ...

    private fun listImages() {
        startActivity(Intent(this, ImageListActivity::class.java))
    }
```

```kotlin:app/src/main/java/com/example/mediastore/ImageListActivity.kt
    // ...

    private fun setData() {
        val items = mutableListOf<ImageItem>()
        val projection = arrayOf(
            MediaStore.Images.Media._ID,
            MediaStore.Images.Media.DISPLAY_NAME,
            MediaStore.Images.Media.MIME_TYPE,
        )
        val selection = "${MediaStore.Images.Media.MIME_TYPE} = ?"
        val selectionArgs = arrayOf("image/jpeg")
        val sortOrder = "${MediaStore.Images.Media.DISPLAY_NAME} ASC"
        val query = contentResolver.query(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            projection,
            selection,
            selectionArgs,
            sortOrder
        )
        query?.use { cursor ->
            val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
            val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
            while (cursor.moveToNext()) {
                val id = cursor.getLong(idColumn)
                val name = cursor.getString(nameColumn)
                items.add(ImageItem(ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id), name))
            }
        }

        adapter.setItems(items)
    }
```

`AlertDialog`で「このアプリで撮影した画像」を選択した場合、パーミッション要求なしで`ImageListActivity`に遷移します。
表示されるのはダイアログで選択した通り、このアプリで撮影した画像のみです。
「ライブラリの画像も含める」を選択すると、Android 13以降ではメディアファイルのうち、画像ファイルにのみアクセスが可能になります。

# 最後に
もうAndroid 14が[最終テスト用のほぼ最終版のビルド](https://developer.android.com/about/versions/14/overview?hl=ja)なのに今さら13の話？という感じですが、このあたりの知見ってアプリによっては全然使わなくて、久々に使ってみたらナニコレとなりがちだと思ってます。（まさに自分がそうだった）
同じような状況に陥ってしまった方のご参考になれば幸いです。

# 参考にさせていただいたサイト
https://qiita.com/aqua_ix/items/213c470d5c49c52686ba

https://qiita.com/irgaly/items/70a0f0599d78eafc1136