12
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Android: 複数の ActivityResultContract を IntentChooser でまとめて、カメラアプリでの撮影かメディア選択のどちらにも対応する (Google Photos アプリ選択にも対応)

Last updated at Posted at 2021-07-28

ActivityResultContract を使って、Android 標準の画像選択アプリと Google Photos アプリ画像選択とカメラアプリ起動の IntentChooser を表示し、ユーザーがいずれかを選べるようにします。Google Photos アプリやカメラアプリが存在しない端末であれば、それぞれの選択肢は非表示となります。Google Photos アプリもカメラアプリも存在しない場合は、Intent Chooser は表示されず、Android 標準の画像選択アプリが起動します。

image.png

ActivityResultContract の仕組みは便利ですが、複数の ActivityResultContract をまとめる仕組みはありません。複数の ActivityResultContract をうまくマージするような ImageChooserContract を実装して対応します。

ActivityResultContracts.GetContent と以下の記事の PickImageContract と TakePictureContract を組み合わせて実装します。本記事では PickImageContract と TakePictureContract の実装について詳細な解説は省いています。それぞれの解説は以下の記事を参照してください。

前提

AndroidX を導入していること。

権限について

GetContent や Google Photos アプリによる画像選択に 必要な権限はありません。カメラアプリ起動による写真撮影も 必要な権限はありません

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

実装

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

src/main/res/xml/provider_path.xml

<paths>
    <!-- files-path: filesDir 用の宣言 -->
    <files-path name="files" path="." />
</paths>

AndroidManifest.xml に FileProvider の設定を追加します。
Google Photos アプリの存在確認とカメラアプリの存在確認では resolveActivity() を使用します。Android 11 以上の Package Visibility に対応するため、<queries> タグも追加します。

AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="...">
    <queries>
        <intent>
            <action android:name="android.intent.action.PICK" />
            <data android:mimeType="image/*" />
        </intent>
        <intent>
            <action android:name="android.media.action.IMAGE_CAPTURE" />
        </intent>
    </queries>
    <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>

PickImageContract を実装します。以下の記事のものとまったく同じものです。

class PickImageContract : ActivityResultContract<Unit, String?>() {
    companion object {
        fun canPickImage(context: Context): Boolean {
            return (createIntentInternal().resolveActivity(context.packageManager) != null)
        }

        private fun createIntentInternal(): Intent {
            return Intent(Intent.ACTION_PICK).setType("image/*")
        }
    }

    override fun createIntent(context: Context, input: Unit): Intent {
        return createIntentInternal()
    }

    override fun parseResult(resultCode: Int, intent: Intent?): String? {
        return if (resultCode == Activity.RESULT_OK) intent?.data?.toString() else null
    }
}

TakePictureContract を実装します。以下の記事のものとほぼ同じものですが、カメラアプリが選択されなかった場合に空のファイルを削除する cleanOutputFile() を追加しています。

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,
            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} のようなファイルパス
        return result
    }

    fun cleanOutputFile() {
        cameraOutputFile?.let { file ->
            if (file.length() == 0L) {
                file.delete()
            }
        }
        cameraOutputFile = null
    }
}

ImageChooserContract を実装します。GetContent と TakePictureContract を用いて、どちらも動作するように実装しています。

class ImageChooserContract(
    owner: SavedStateRegistryOwner,
    savedStateKey: String
) : ActivityResultContract<Unit, String?>() {
    private val getContentContract = ActivityResultContracts.GetContent()
    private val pickImageContract = PickImageContract()
    private val takePictureContract = TakePictureContract(owner, savedStateKey)

    override fun createIntent(context: Context, input: Unit): Intent {
        val canPickImage = PickImageContract.canPickImage(context)
        val hasCameraApp = (Intent(MediaStore.ACTION_IMAGE_CAPTURE)
            .resolveActivity(context.packageManager) != null)
        val getContentIntent = getContentContract.createIntent(context, "image/*")
        val pickImageIntent = if (canPickImage) {
            pickImageContract.createIntent(context, Unit)
        } else null
        val cameraIntent = if (hasCameraApp) {
            takePictureContract.createIntent(context, "image.jpeg")
        } else null
        return Intent.createChooser(getContentIntent, "画像を選択").apply {
            val extra = listOfNotNull(pickImageIntent, cameraIntent)
            if (extra.isNotEmpty()) {
                putExtra(Intent.EXTRA_INITIAL_INTENTS, extra.toTypedArray())
            }
        }
    }

    override fun parseResult(resultCode: Int, intent: Intent?): String? {
        var result: String? = null
        if (resultCode == Activity.RESULT_OK) {
            // result = content://com.android.providers.media.documents/document/image...
            // result = content://com.google.android.apps.photos.contentprovider/...
            // result = file://data/user/0/.../files/{input}
            // のような画像 Content URI / File URI
            result = getContentContract.parseResult(resultCode, intent)?.toString()
                ?: pickImageContract.parseResult(resultCode, intent)
                ?: takePictureContract.parseResult(resultCode, intent)
        }
        takePictureContract.cleanOutputFile()
        return result
    }
}

ImageChooserContract を使用して画像選択か Google Photos アプリかカメラアプリでの写真撮影を実行します。

class MyFragment: Fragment() {
    private val imageChooserLauncher = registerForActivityResult(
        // savedStateKey: 他の savedStateKey と被らなければ OK
        ImageChooserContract(this, "imageChooser")
    ) { file ->
        if (file != null) {
            // file = content://com.android.providers.media.documents/document/image...
            // file = content://com.google.android.apps.photos.contentprovider/...
            // file = file://data/user/0/.../files/{input}
            // のような選択した画像または撮影した画像ファイルパス
        } else {
            // 画像選択がキャンセルされた
        }
    }
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val binding = /* ... */
        binding.button.setOnClickListener {
            imageChooserLauncher.launch(Unit)
        }
    }
// ...

解説

ImageChooserContract では GetContent() と PickImageContract() と TakePictureContract() を初期化して保持しておきます。

PickImageContract.canPickImage() では、ACTION_PICK と mimeType = "image/*" 指定の Intent を resolveActivity() することで Google Photos アプリが端末にインストールされているかを確認しています。

ACTION_IMAGE_CAPTURE Intent の resolveActivity() により、カメラアプリが端末にインストールされているかを確認しています。

Android 11 以上では resolveActivity() の使用にあたり Package Visibility に従う必要があるため、AndroidManifest.xml へ <queries> を宣言しています。

createIntent() で、getContentContract.createIntent() と pickImageContract.createIntent() takePictureContract.createIntent() によりそれぞれの Intent を生成します。

それぞれの Intent を createChooser() でまとめます。このとき、いずれかの Intent をメインの Intent とする必要があります。pickImageIntent と cameraIntent は対象のアプリが存在しなければスキップするため、今回は getContentIntent をメインの Intent に設定しています。それ以外の Intent は EXTRA_INITIAL_INTENTS として設定することで、追加の選択肢に設定します。

parseResult() ではそれぞれの Intent の結果を処理します。ユーザーが画像を選択したのか、カメラアプリで写真を撮影したのかが不明であるため、順に処理する必要があります。今回は GetContent.parseResult()、ickImageContract.parseResult()、TakePictureContract.parseResult() の順で処理しています。

parseResult() を呼び出す順番は、それぞれの Intent の結果の扱いを考慮して決めます。

GetContent Contract は ACTION_GET_CONTENT Intent を発行します。
GetContent.parseResult() の内部実装は以下のとおりとなっています。

public final Uri parseResult(int resultCode, @Nullable Intent intent) {
    if (intent == null || resultCode != Activity.RESULT_OK) return null;
    return intent.getData();
}

PickImageContract は ACTION_PICK Intent を発行し、parseResult() は以下のとおりとなっています。

override fun parseResult(resultCode: Int, intent: Intent?): String? {
    return if (resultCode == Activity.RESULT_OK) intent?.data?.toString() else null
}

TakePictureContract は ACTION_IMAGE_CAPTURE Intent を発行し、parseResult() は以下のとおりとなっています。

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
    return result
}

GetContent Contract と PickImageContract は resultCode == RESULT_OK かつ、Intent.data != null であれば成功となり、選択した画像のパスが手に入ります。TakePictureContract は resultCode == RESULT_OK であれば成功とみなして、あらかじめ記憶していた cameraOutputFile が撮影した写真の画像ファイルであるとみなしています。

TakePictureContract の条件が緩いため、TakePictureContract.parseResult() を先に処理してしまうと常にカメラアプリで写真撮影をしたと判定されてしまいます。

複数の ActivityResultContract を組み合わせるときは parseResult() の順番に気をつけてください。

PickImageContract.parseResult() は GetContentContract.parseResult() と同じ処理であるため、どちらかの parseResult() は省略しても構いません。Android 標準の画像選択アプリが使われても、Google Photos アプリが使われても、選択された画像のパスは Intent.data に保持されていることに変わりはありません。

parseResult() で結果を取得したあとに takePictureContract.cleanOutputFile() でカメラアプリ向け一時ファイルの削除をしています。cleanOutputFile() ではカメラアプリ向けに作成したファイルのファイルサイズを確認し、0 bytes であればカメラアプリは選択されなかったと判断してファイルを削除しています。カメラアプリで写真を撮影していた場合はファイルサイズが 0 bytes ではないため、ファイルを削除しません。

12
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?