本文へスキップ
すべての記事

2026年版 Android Photo Picker API:ギャラリー全体へのアクセス権を求めずに写真を1枚選ばせる

AndroidのPhoto Picker APIの実践ガイド。単一・複数選択、MIMEフィルタリング、Android 13未満向けのバックポート、そしてREAD_MEDIA_IMAGESより優れている理由を解説する。

MFKAPPS 1 分で読めます

1件の支出にレシート写真を1枚添付させるためだけに、ユーザーの写真ライブラリ全体を見る権限を求めるのは、その機能に必要な範囲をはるかに超えた要求だ。READ_MEDIA_IMAGESは、旅行の写真、他人とのメッセージのスクリーンショット、その他何であれ、デバイス上のすべての写真への恒常的なアクセス権をアプリに与えてしまう——にもかかわらず、その機能が本当に必要としているのは、ユーザーが選んだたった1枚の画像だけだ。この不釣り合いをまさに解消するために作られたのが、AndroidのPhoto Pickerである。

私はこれをGranynに組み込み、記録した支出にレシート写真を添付できるようにした。このAPIが実際にどう動くのか、そしてなぜ権限リクエストそのものを消し去れたのかを、以下にまとめる。

従来のやり方と、それが問題になる理由

Photo Pickerが登場する前、「ユーザーに写真を選ばせる」ことは2つの方法のどちらかを意味していた。READ_MEDIA_IMAGES(Android 13より前はREAD_EXTERNAL_STORAGE)をリクエストして自前でMediaStoreをクエリするか、ACTION_GET_CONTENTを起動してシステムのドキュメントピッカーが各端末メーカーのスキン間で一貫して動作することを願うか。権限を使う方法は動作はするが、次のような代償を伴う。

  • 習慣的に拒否されやすい機能に対する実行時権限ダイアログ——これは静かに、一定割合のユーザーに対して機能そのものを機能不全にする。
  • Play Consoleの「機密性の高い権限」申告フロー——広範なメディアアクセスは審査でより厳しい目を向けられる。
  • アプリが実際にはユーザーが選んだ1枚の写真にしか触れなくても、プライバシーポリシーで正当化しなければならない恒常的なアクセス権。

これらのどれ一つとして、ユーザーに何かをもたらすわけではない。この機能が必要としているのは、1枚の画像への、1回限りの読み取りアクセスだけだ。

Photo Pickerが実際にどう動くか

Photo Pickerはシステムが所有するUIだ——独立した信頼できるプロセスで動作し、ユーザーがタップした画像はcontent:// URIとしてアプリに渡される。アプリがギャラリーへのアクセス権限そのものを得ることは決してない。得られるのは、その1つのURIだけであり、その写真に限定され、必要な間だけ有効だ。

単一選択であれば、セットアップにマニフェスト権限は一切不要だ。

class AddExpenseFragment : Fragment() {

    private val pickImage = registerForActivityResult(
        ActivityResultContracts.PickVisualMedia(),
    ) { uri: Uri? ->
        if (uri != null) attachReceiptPhoto(uri)
    }

    private fun launchPicker() {
        pickImage.launch(
            PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
        )
    }
}

ActivityResultContracts.PickVisualMedia.ImageOnlyはピッカーを画像のみに絞り込む。VideoOnlySingleMimeType("image/png")はその他の一般的なケースをカバーする。<uses-permission>の記述もなく、実行時ダイアログもなく、根拠説明用の文言を書くダイアログも不要だ。

複数の写真を選ばせる

レシートは通常1枚だが、同じcontractには、自分で指定した上限数までの複数選択バリアントも用意されている。

private val pickImages = registerForActivityResult(
    ActivityResultContracts.PickMultipleVisualMedia(maxItems = 5),
) { uris: List<Uri> ->
    uris.forEach { attachReceiptPhoto(it) }
}

上限はシステムUI自体が強制する——ユーザーが上限に達すると、それ以上のタップを無効化する仕組みになっており、アプリのコード側で後から長すぎるリストを拒否する必要はない。

受け取ったURIは永続的ではない

ここが多くの人がつまずくポイントだ。ActivityResultCallbackに渡されるcontent:// URIは、そのコールバックの間だけ読み取り可能であることが保証されているに過ぎない。そのURI文字列をデータベースに保存し、数日後に開こうとするとSecurityExceptionが発生することがある——永続化されたStorage Access Framework URIとは違い、この権限はピッカーのセッションより長くは生き残らない。

対処法は、必要なバイトデータを即座に、アプリ自身が所有するファイルへコピーしてしまうことだ。

private fun attachReceiptPhoto(sourceUri: Uri) {
    val destFile = File(requireContext().filesDir, "receipts/${UUID.randomUUID()}.jpg")
    destFile.parentFile?.mkdirs()

    requireContext().contentResolver.openInputStream(sourceUri)?.use { input ->
        destFile.outputStream().use { output -> input.copyTo(output) }
    }

    viewModel.setReceiptPath(destFile.absolutePath)
}

コピー処理はメインスレッドの外で行う——ファイルI/Oだからだ——が、後続の画面ではなく、ピッカーのActivity結果コールバックが戻る前に済ませること。バイトデータをコピーしてしまえば、一時的なcontent://権限はもう関係ない。アプリは必要な間いつでも読み取れる、自身が所有するプライベートファイルを手に入れたことになる。

Android 13未満向けのバックポート

Photo PickerはAndroid 13でシステム機能として登場したが、Google Play services経由でAPI 21以上までバックポートされている——同じActivityResultContracts.PickVisualMedia呼び出しが、Play servicesがインストールされた旧端末では自動的にこのバックポート版に解決される。最小機能セットが本当にネイティブピッカーの挙動(アプリごとのクラウドメディアプロバイダー連携など)を必要とする場合には、明示的にチェックしておく価値がある項目がひとつある。

val isPhotoPickerAvailable = ActivityResultContracts.PickVisualMedia
    .isPhotoPickerAvailable(requireContext())

実際には、単純な「写真を1枚添付する」フローであれば、この分岐は不要だ——contractは自身で問題なく降格してくれる。これが重要になるのは、ネイティブ版でしか意味を持たないピッカー固有のUIヒントを表示するかどうかを決める場面だ。

本当に重要だったこと

ここでの収穫は、新しいジェスチャーでも、より見栄えの良いピッカーUIでもない。権限モデルがようやくその機能の実態と一致した、ということだ。レシート写真を1枚添付するだけのユーザーが、カメラロール全体への恒常的なアクセス権を渡す必要など、そもそもなかった。そしてPhoto Pickerが存在するまで、Androidにはそれを避けるきれいな方法がなかった。READ_MEDIA_IMAGESPickVisualMediaに置き換えたことで、マニフェストの権限がひとつ、実行時ダイアログがひとつ、そしてプライバシーポリシーの一段落が消えた——それでいて機能はユーザー側から見ればまったく同じように動作する。必要以上のものを求めなくなっただけだ。