Android: アプリ外からアクセスできるように画像を保存する (対象範囲別ストレージ対応)

Android 10 以上の対象範囲別ストレージ (Scoped Storage) 環境で、外部のアプリから参照できるように画像ファイルを保存します。

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

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

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

## 前提

Android 10 以上ではメディア領域への書き込みに**権限は不要です**。権限の取得なくこの実装が利用できます。

Android 9 以下ではメディア領域への書き込みに WRITE_EXTERNAL_STORAGE 権限が必要です。WRITE_EXTERNAL_STORAGE 権限を取得してから `storeMediaImage()` を実行してください。

## 前提 (Android 9 以下での権限取得)

Android 9 以下に対応するため、WRITE_EXTERNAL_STORAGE 権限を宣言しておきます。Android 10 以上では WRITE_EXTERNAL_STORAGE 権限は不要であるため `android:maxSdkVersion` で Android 9 以下でのみ有効となるように宣言します。

AndroidManifest.xml

```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="...">
    <uses-permission
        android:name="android.permission.WRITE_EXTERNAL_STORAGE"
        android:maxSdkVersion="28" />
    <application android:theme="@stylve/AppTheme">
        <!-- ... -->
    </application>
</manifest>
```

AndroidManifest の設定に加えて、実行時の権限要求も必要です。ActivityResultContract を使ってパーミッションを要求する方法は以下の記事を参照してください。

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

## 実装

```kotlin
suspend fun Context.storeMediaImage(
    imageUri: Uri,
    fileName: String,
    mimeType: String  // "image/jpeg" など
): Uri = withContext(Dispatchers.IO) {
    val collection = if (Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT) {
        // データ書き込みの場合は MediaStore.VOLUME_EXTERNAL_PRIMARY が適切
        MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
    } else {
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI
    } // collection = "content://media/external/images/media" のような Content URI
    // destination = "content://media/external/images/media/{id}" のような 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_CODES.Q <= Build.VERSION.SDK_INT) {
                put(MediaStore.Images.Media.IS_PENDING, true)
            }
        }
    ) ?: throw IllegalStateException("保存メディアファイルの作成に失敗")
    var input: InputStream? = null
    var output: OutputStream? = null
    try {
        input = contentResolver.openInputStream(imageUri) ?: error("画像ファイルを開けない")
        output = contentResolver.openOutputStream(destination) ?: error("保存メディアファイルを開けない")
        input.copyTo(output)
    } catch (e: FileNotFoundException) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
            contentResolver.delete(destination, null, null)
        }
        throw IllegalStateException(e)
    } catch (e: IOException) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
            contentResolver.delete(destination, null, null)
        }
        throw e
    } finally {
        input?.close()
        output?.close()
    }
    if (Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT) {
        contentResolver.update(destination, ContentValues().apply {
            put(MediaStore.Images.Media.IS_PENDING, false)
        }, null, null)
    }
    destination
}
```

## 解説

Android 10 以上では VOLUME_EXTERNAL_PRIMARY Content を参照します。Android 9 以下では EXTERNAL_CONTENT_URI Content を参照します。実際の値は EXTERNAL_CONTENT_URI は `content://media/external/images/media` となりますが、 VOLUME_EXTERNAL_PRIMARY の場合は端末により `content://media/external/images/media` または `content://media/external_primary/images/media` となるようです。

VOLUME_EXTERNAL_PRIMARY の説明は以下のドキュメントにあります。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.insert()` で新規メディア項目をデータベースに追加します。Android 10 以上であれば、ファイルの書き込みが完了するまで処理中の項目であることを表す [MediaStore.Images.Media.IS_PENDING](https://developer.android.com/reference/kotlin/android/provider/MediaStore.MediaColumns?hl=ja#IS_PENDING:kotlin.String) を設定します。`IS_PENDING = true` の間は、データが不完全とみなされ、他のアプリからは不可視の状態となります。

作成した `destination: Uri` 項目を `contentResolver.openOutputStream()` で書き込み用にオープンし、データを書き込みます。Android 10 以上であれば、書き込み完了後に [MediaStore.Images.Media.IS_PENDING](https://developer.android.com/reference/kotlin/android/provider/MediaStore.MediaColumns?hl=ja#IS_PENDING:kotlin.String) を更新し、他のアプリからアクセスできるようにします。

## 解説 (IS_PENDING)

Android 10 以上では `IS_PENDING = true` の項目は他のアプリから不可視の状態となり、`IS_PENDING = true` のまま放置されると [DATE_EXPIRES](https://developer.android.com/reference/kotlin/android/provider/MediaStore.MediaColumns?hl=ja#DATE_EXPIRES:kotlin.String) で設定された期限経過後に自動で削除されます。[DATE_EXPIRES](https://developer.android.com/reference/kotlin/android/provider/MediaStore.MediaColumns?hl=ja#DATE_EXPIRES:kotlin.String) は IS_PENDING が変更されたときに自動的に 7 日後に設定されるようです。

[DATE_EXPIRES](https://developer.android.com/reference/kotlin/android/provider/MediaStore.MediaColumns?hl=ja#DATE_EXPIRES:kotlin.String)

> The value stored in this column is automatically calculated when IS_PENDING or IS_TRASHED is changed. The default pending expiration is typically 7 days, and the default trashed expiration is typically 30 days.

このため、Android 10 以上であればメディア領域へのファイル書き込みが失敗した場合に、残されたゴミ項目を自前で削除しなくても問題はありません。

Android 9 以下では IS_PENDING の仕組みはないので、`contentResolver.delete()` でゴミ項目を削除してあげると親切です。