Android: ActivityResultContract を使ってアプリ外からアクセスできる写真を撮影する (対象範囲別ストレージ対応)

ActivityResultContract を使って、外部のカメラアプリを起動して写真を撮影します。

Android 10 以上の対象範囲別ストレージ (Scoped Storage) 環境で、他のアプリからアクセスできるようにメディア領域へ写真を保存します。

Android 9 以下でも権限や MediaStoreAPI の処理が少しだけ異なる以外はほとんど同じ実装となります。

カメラアプリを起動して撮影した写真は、なるべく外部のアプリからもアクセスできることが求められます。ライフログ系のコンテンツやブログ記事の添付ファイルなどコンテンツ性のある写真であれば、ファイルをアップロードするだけでなく、ユーザーの端末のメディア領域へ保存し、アプリがアンインストールされても写真が消えないようにすることが求められます。

https://developer.android.com/training/camera/photobasics#TaskPath

撮影した写真をアプリ内だけで利用し、他のアプリからアクセスできないように扱いたい場合は、以下の記事を参照してください。

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

## 実現方法

TakePictureContract を作成し、カメラアプリからアプリ固有ストレージへ写真を保存したあと、メディア領域へ保存することで他のアプリからアクセスできるようにします。

以下の 3 つの記事の組み合わせで対応します。本記事では実装について詳細な説明は省いています。詳細な解説はそれぞれの記事に書かれていますので、参照してください。

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

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

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

## 前提

AndroidX を導入していること。

## OS バージョン

外部ストレージの権限の扱いをシンプルにするため、Android 4.4 以上のみサポートすることを想定します。

## 権限について

Android 10 以上では、外部のカメラアプリからアプリ固有ストレージへ写真を書き込んだあと、メディア領域へ書き込むため、**必要な権限はありません**。

カメラアプリを起動するため、CAMERA 権限も不要ですが、AndroidManifest.xml に CAMERA 権限を定義している場合のみ、CAMERA 権限のランタイムパーミッション取得が必要です。

Android 9 以下ではメディア領域への書き込みに WRITE_EXTERNAL_STORAGE 権限が必要です。TakePictureContract を実行する前に WRITE_EXTERNAL_STORAGE 権限を取得するように実装しています。

## 実装

カメラアプリがアプリ固有ストレージへ書き込めるように、FileProvider の設定を追加します。

src/main/res/xml/provider_path.xml

```xml
<paths>
    <!-- external-files-path: getExternalFilesDir() 用の宣言 -->
    <external-files-path name="external-files" path="." />
    <!-- files-path: filesDir 用の宣言 -->
    <files-path name="files" path="." />
</paths>
```

AndroidManifest.xml に FileProvider の設定と、WRITE_EXTERNAL_STORAGE 権限の宣言を記載します。

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">
        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="${applicationId}.fileprovider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/provider_path" />
        </provider>
        <!-- ... -->
    </application>
</manifest>
```

storeMediaImage() を実装します。以下の記事のものと全く同じものです。

https://qiita.com/irgaly/items/70a0f0599d78eafc1136#%E5%AE%9F%E8%A3%85

```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
}
```

メディア領域への保存に対応した TakePictureContract を実装します。

```kotlin
class TakePictureContract(
    owner: SavedStateRegistryOwner,
    savedStateKey: String,
    private val contextProvider: () -> Context
) : ActivityResultContract<String, suspend () -> String?>() {
    private var cameraOutputFile: File? = null

    init {
        owner.lifecycle.addObserver(LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_CREATE) {
                val registry = owner.savedStateRegistry
                registry.registerSavedStateProvider(savedStateKey) {
                    bundleOf("output" to cameraOutputFile?.toString())
                }
                registry.consumeRestoredStateForKey(savedStateKey)?.getString("output")?.let { output ->
                    cameraOutputFile = File(output)
                }
            }
        })
    }

    override fun createIntent(context: Context, input: String): Intent {
        val file = File(
            // /storage/emulated/0/Android/data/.../files/Pictures/{input} のようなアプリ固有の外部ストレージ
            context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
                // /data/user/0/.../files のようなアプリ固有の内部ストレージ
                ?: context.filesDir
            ,
            input
        ).also {
            cameraOutputFile = it
        }
        file.createNewFile()
        // uri: content://{packageName}.fileprovider/external-files/Pictures/{input} のような Content URI
        val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
        return Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
            putExtra(MediaStore.EXTRA_OUTPUT, uri)
        }
    }

    override fun parseResult(resultCode: Int, intent: Intent?): suspend () -> String? {
        return suspend {
            var result: String? = null
            if (resultCode == Activity.RESULT_OK) {
                val file = requireNotNull(cameraOutputFile)
                result = try {
                    contextProvider().storeMediaImage(
                        Uri.fromFile(file),
                        file.name,
                        "image/jpeg"
                    ).toString()
                } catch (e: IllegalStateException) {
                    // ファイルが存在しないなど
                    null
                } catch (e: IOException) {
                    // ファイル読み書き失敗
                    null
                }
            }
            cameraOutputFile?.delete()
            cameraOutputFile = null
            result
        }
    }
}
```

TakePictureContract を使用してカメラアプリでの写真撮影を実行します。

```kotlin
class MyFragment: Fragment() {
    private val takePictureLauncher = registerForActivityResult(
        // savedStateKey 他の savedStateKey と被らなければ OK
        TakePictureContract(this, "takePicture") { requireContext() }
    ) { result ->
        lifecycleScope.launch {
            val file = result()
            if (file != null) {
                // file = content://media/external/images/media/{id} のような Content URI
            } else {
                // 撮影がキャンセルされた
            }
        }
    }
    private val requestPermissionLauncher = registerForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted ->
        if (granted) {
            takePicture()
        } else {
            // 権限を取得できなかった
        }
    }
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val binding = /* ... */
        binding.button.setOnClickListener {
            if (Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT) {
                takePicture()
            } else {
                // Android 9 以下ではメディア領域への保存に WRITE_EXTERNAL_STORAGE 権限が必要
                requestPermissionLauncher.launch(WRITE_EXTERNAL_STORAGE)
            }
        }
    }
    private fun takePicture() {
        // ファイル名は getExternalFilesDir() や filesDir() の既存のファイルと被らないものを渡す
        takePictureLauncher.launch("image.jpg")
    }
// ...
```

## 解説

詳しい解説はそれぞれの記事を参照してください。

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

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

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

ActivityResultContract.parseResult() は Coroutine に対応していないため、結果を `suspend () -> String?` として評価を遅延させています。結果を受け取る callback 側で適切な CoroutineScope を使って結果を受け取るようにしています。

TakePictureContract.parseResult() はディスク容量が足りないなどでメディア領域へのファイル保存に失敗する可能性があるため、エラー処理が必要になるかもしれません。エラー処理はたとえば以下のように実装できます。

```kotlin
class TakePictureContract(
    owner: SavedStateRegistryOwner,
    savedStateKey: String,
    private val contextProvider: () -> Context,
    private val errorHandler: ((e: Exception) -> Unit)? = null
) : ActivityResultContract<String, suspend () -> String?>() {
// ...
    override fun parseResult(resultCode: Int, intent: Intent?): suspend () -> String? {
// ...
                } catch (e: IllegalStateException) {
                    // ファイルが存在しないなど
                    errorHandler(e)
                    null
                } catch (e: IOException) {
                    // ファイル読み書き失敗
                    errorHandler(e)
                    null
                }
// ...
    }
}
```