Android: ActivityResultContract を使って外部のカメラアプリで写真を撮影する (アプリ固有ストレージを使う方法)

ActivityResultContract を使って、外部のカメラアプリを起動し、写真を撮影して画像ファイルを取得します。

この記事の実装では撮影した画像ファイルはアプリ固有ストレージに保存し、他のアプリからはアクセスできないファイルとなります。

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

ライフログ系のコンテンツやブログ記事の添付ファイルなど、多くの場合では撮影した写真がコンテンツとしての価値がある場合はなるべく対象範囲別ストレージ (Scoped Storage) のメディア領域へ保存し、アプリをアンインストールしても写真が消えないようにすべきです。

撮影した写真が一時的に使われるだけで、アプリのアンインストールで写真が消えてしまってもユーザーが困らないものである場合はこの記事の方法でアプリ固有ストレージへ保存します。撮影した写真を外部アプリと共有しなくて良いケースは稀なので、慎重に検討してください。

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

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

https://qiita.com/irgaly/items/81d0b2bba726531486c6

## 前提

AndroidX を導入していること。

## OS バージョン

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

Android 4.4 未満での権限については [getExternalFilesDir()](https://developer.android.com/reference/kotlin/android/content/Context#getexternalfilesdir) ドキュメントを参照してください。Android 4.4 未満では [FLAG_GRANT_WRITE_URI_PERMISSION](https://developer.android.google.cn/topic/security/best-practices?hl=ja#permissions-share-data) の明示も必要かもしれません。

## 権限について

外部のカメラアプリで写真撮影し、アプリ固有ストレージにファイルを書き込む場合、**必要な権限はありません**。

カメラアプリからアプリ固有ストレージへ直接ファイルを書き込むため、READ_EXTERNAL_STORAGE も WRITE_EXTERNAL_STORAGE も不要です。

カメラ機能は外部のカメラアプリからアクセスするため、CAMERA 権限は不要です。

ただし、自アプリ内でカメラ機能 (AndroidX Camera や QR コード読み取りや AR 機能など) を使っていて、AndroidManifest.xml に以下のように CAMERA 権限の宣言がある場合は、**CAMERA 権限のランタイムパーミッションのリクエストが必要です**。

AndroidManifest.xml

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

AndroidManifest で CAMERA 権限を宣言しているにも関わらず、ランタイムパーミッションとして CAMERA 権限をリクエストせずに ACTION_IMAGE_CAPTURE Intent を発行すると、SecurityException が発生します。これは、ユーザーがカメラ権限を拒否したにもかかわらずカメラアプリが起動するのはユーザーの意思に反するため[このような仕様になっている](https://issuetracker.google.com/issues/37063818#comment8)らしいです。

https://developer.android.com/reference/android/provider/MediaStore#ACTION_IMAGE_CAPTURE

> Note: if you app targets M and above and declares as using the Manifest.permission.CAMERA permission which is not granted, then attempting to use this action will result in a SecurityException.

## 実装

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

以下の XML リソースを追加します。
`<paths>` 内で使えるタグとストレージの対応関係は [FileProvider - Specifying Available Files](https://developer.android.com/reference/kotlin/androidx/core/content/FileProvider#specifying-available-files) を参照してください。

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 へ以下の設定を追加します。

AndroidManifest.xml

```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="...">
    <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>
```

TakePictureContract を実装します。

```kotlin
class TakePictureContract(
    owner: SavedStateRegistryOwner,
    savedStateKey: String
) : ActivityResultContract<String, 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(
            // /data/user/0/.../files のようなアプリ固有の内部ストレージ
            context.filesDir
            /*
            撮影した写真が機密データでなければ、getExternalFilesDir() を使っても良い
            // /storage/emulated/0/Android/data/.../files/Pictures/{input} のようなアプリ固有の外部ストレージ
            context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
                ?: context.filesDir
            */
            ,
            input
        ).also {
            cameraOutputFile = it
        }
        file.createNewFile()
        // uri: content://{packageName}.fileprovider/files/{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?): String? {
        var result: String? = null
        if (resultCode == Activity.RESULT_OK) {
            result = cameraOutputFile?.toString()
        } else {
            cameraOutputFile?.delete()
        }
        cameraOutputFile = null
        // result: file://data/user/0/.../files/{input} のような File URI
        return result
    }
}
```

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

```kotlin
class MyFragment: Fragment() {
    private val takePictureLauncher = registerForActivityResult(
        // savedStateKey: 他の savedStateKey と被らなければ OK
        TakePictureContract(this, "takePicture")
    ) { file ->
        if (file != null) {
            // file = /data/user/0/.../files/{input} のようなファイルパス
        } else {
            // 撮影がキャンセルされた
        }
    }
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val binding = /* ... */
        binding.button.setOnClickListener {
            // ファイル名は既存のファイルと被らないものを渡す
            takePictureLauncher.launch("image.jpg")
        }
    }
// ...
```

## 解説

AndroidX FileProvider は外部のアプリとファイルを安全に共有するための仕組みです。`<provider>` タグおよび provider_path.xml で定義したファイルパス以下のファイルだけを外部のアプリと共有することができます。

provider_path.xml へ定義するパスは外部のアプリと共有したいストレージに合わせて適切に設定してください。

https://developer.android.com/training/secure-file-sharing/setup-sharing?hl=ja

https://developer.android.com/reference/kotlin/androidx/core/content/FileProvider#specifying-available-files

TakePictureContract では ACTION_IMAGE_CAPTURE Intent を発行して外部のカメラアプリを起動します。

保存先は cacheDir, filesDir, getExternalFilesDir() がお勧めです。ただし、getExternalFilesDir() は SD カードなどの外部ストレージとなるため、保存したファイルを取り出したり読み取ったりできる可能性があります。撮影した写真の利用を確実にアプリ内だけに制限したい場合は cacheDir, filesDir を使用してください。

カメラアプリが書き込むファイルは事前に作成し、`FileProvider.getUriForFile()` で共有可能な Content URI へ変換しておく必要があります。

カメラアプリへ渡したファイルパスは、`parseResult()` での処理にも必要になります。カメラアプリ起動中に自アプリの Activity / Fragment が破棄されても正しく復帰できるように、TakePictureContract では SavedStateRegistory を用いて `cameraOutputFile` を保存するようにしています。

https://developer.android.com/topic/libraries/architecture/saving-states#savedstateregistry

`parseResult()` では、カメラアプリが正常に写真を撮影できた場合は `cameraOutputFile` をパスとして返します。撮影がキャンセルされた場合は `cameraOutputFile` は空データファイルのままであるため、ファイルを削除しています。

`TakePictureContract.launch()` の引数に渡すファイル名はすでに存在しないファイル名を渡すことを想定していますが、存在するファイル名を渡した場合はカメラアプリによってファイルが上書きされる可能性があります。