Android: アプリ外の画像一覧を取得する (対象範囲別ストレージ対応)

Android 10 以上の対象範囲別ストレージ (Scoped Storage) 環境で、メディア領域にある画像一覧を取得します。

アプリ独自の画像選択 UI を実装するときなどは、画像選択 Intent に頼らず、MediaStoreAPI から画像一覧へアクセスする必要があります。

Android 9 以下でも MediaStoreAPI 経由でアクセスすることに変わりはないため、すべての Android で動作するような実装としています。

https://developer.android.com/training/data-storage/use-cases?hl=ja

https://developer.android.com/training/data-storage/shared/media?hl=ja

## 前提

事前に READ_EXTERNAL_STORAGE パーミッションを得ておきます。

ActivityResultContract を使ってパーミッションを要求する方法は以下の記事を参照してください。

https://qiita.com/irgaly/items/f98acbadb83c799cb666

## 実装

``` kotlin
data class Page(
    val page: Int,
    val items: List<String>,
    val hasNext: Boolean
)

suspend fun Context.loadMediaImages(
    page: Int,
    pageSize: Int
): Page = withContext(Dispatchers.IO) {
    val images = mutableListOf<String>()
    var hasNext = false
    val readExternalStorageGranted = (checkSelfPermission(READ_EXTERNAL_STORAGE) == PERMISSION_GRANTED)
    if (
        Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT ||
        readExternalStorageGranted
    ) {
        // READ_EXTERNAL_STORAGE 権限があれば、MediaStoreAPI 経由で画像一覧をすべて読み取れる。
        // Android 10 以上では READ_EXTERNAL_STORAGE 権限がなければ自アプリで保存した画像一覧のみ読み取れる
        // Android 9 以下では READ_EXTERNAL_STORAGE 権限がなければ画像一覧は一つも読み取れない
        val collection = if (Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT) {
            // データ読み込みだけの場合は MediaStore.VOLUME_EXTERNAL が適切
            MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
        } else {
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI
        } // collection = "content://media/external/images/media" のような Content URI
        val cursor = contentResolver.query(
            collection,
            arrayOf(MediaStore.Images.Media._ID),
            null,
            null,
            "${MediaStore.Images.Media.DATE_MODIFIED} DESC"
        )
        if (cursor != null) {
            val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
            val first = pageSize * page
            val last = pageSize * (page + 1) - 1
            var position = first
            while (position <= last && cursor.moveToPosition(position)) {
                val id = cursor.getLong(idColumn)
                // image = "content://media/external/images/media/27" のような Content URI
                images.add(ContentUris.withAppendedId(collection, id).toString())
                position++
            }
            hasNext = (position <= cursor.count - 1)
            cursor.close()
        }
    }
    Page(page, images.toList(), hasNext)
}
```

## 解説

Android 10 以上では VOLUME_EXTERNAL Content を参照します。Android 9 以下では EXTERNAL_CONTENT_URI Content を参照します。実際にはどちらも `content://media/external/images/media` となるようでしたが、Android 10 以上では VOLUME_EXTERNAL Content URI を使うことが推奨されています。

VOLUME_EXTERNAL の説明は以下のドキュメントにあります。VOLUME_EXTERNAL は読み取り専用のデータベース、VOLUME_EXTERNAL_PRIMARY は読み書き可能なデータベースとしてアクセスするときに使います。

https://developer.android.com/training/data-storage/shared/media#storage-volume

> The VOLUME_EXTERNAL volume provides a view of all shared storage volumes on the device. You can read the contents of this synthetic volume, but you cannot modify the contents.
> The VOLUME_EXTERNAL_PRIMARY volume represents the primary shared storage volume on the device. You can read and modify the contents of this volume.

`contentResolver.query()` で、画像ファイルの更新日降順で、コンテンツ ID だけを読み取るクエリを発行します。そのあと、ページング処理をしながらコンテンツ ID を読み取り、`ContentUris.withAppendedId()` で画像ファイルの Content URI を生成します。

ここで取得できた Content URI `content://media/external/images/media/{id}` が画像ファイルのファイルパスとなります。