Trying Embedded Photo Picker

# 更新 (2025-06-13)

Jetpackに[photopickerライブラリ](https://developer.android.com/jetpack/androidx/releases/photopicker)が追加されています。

これを使うと以下のように自前でCompose関数を作らなくてもEmbedded Photo Pickerが使えそうです。
# 概要
Embedded Photo PickerはAndroid 16で追加された新しいAPIです。([Android Developers Blog記事](https://android-developers.googleblog.com/2024/11/the-first-developer-preview-android16.html))

[API Reference](https://developer.android.com/reference/android/widget/photopicker/EmbeddedPhotoPickerProvider)を見るとU Extensions 15の記載もありますのでいずれAndroid 14でも[SDK Extensions](https://developer.android.com/guide/sdk-extensions?hl=ja)から~~使えそうです~~使えました。

大きな特徴は**パーミッションの取得なし**でPhoto Pickerを**アプリのUIに組み込める**ところでしょう。自前でPhoto Pickerを実装するより柔軟性は落ちますがEmbedded Photo Pickerでもある程度はカスタマイズ可能です。[^customization]
[^customization]: Light or Darkモード、アクセントカラー、MIMEタイプ、選択可能な最大数などなど

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18159/b16adb48-fb2d-4e13-a328-6cb1392ac57e.gif" width="320">

# 実装方法

まだドキュメントがないので手探りで実装してみました。

## Embedded Photo Pickerを表示

まずEmbedded Photo Pickerを表示するには[SurfaceView](https://developer.android.com/reference/android/view/SurfaceView)が必要です。layout xmlかComposeのAndroidView内で定義しましょう。

また、SurfaceView上のタッチイベントに反応するために[SurfaceView#setZOrderOnTop()](https://developer.android.com/reference/android/view/SurfaceView#setZOrderOnTop(boolean))はtrueを設定しておきましょう。
```kotlin
// It's necessary for handling touch and click events
surfaceView.setZOrderOnTop(true)
```

次に[EmbeddedPhotoPickerProviderFactory](https://developer.android.com/reference/android/widget/photopicker/EmbeddedPhotoPickerProviderFactory)から[EmbeddedPhotoPickerProvider](https://developer.android.com/reference/android/widget/photopicker/EmbeddedPhotoPickerProvider)のインスタンスを取得します。

```kotlin
val provider = EmbeddedPhotoPickerProviderFactory.create(context)
```


[EmbeddedPhotoPickerProvider#openSession()](https://developer.android.com/reference/android/widget/photopicker/EmbeddedPhotoPickerProvider#openSession(android.os.IBinder,%20int,%20int,%20int,%20android.widget.photopicker.EmbeddedPhotoPickerFeatureInfo,%20java.util.concurrent.Executor,%20android.widget.photopicker.EmbeddedPhotoPickerClient))関数からEmbedded Photo Pickerを提供しているMediaProviderシステムと接続します。
[EmbeddedPhotoPickerFeatureInfo](https://developer.android.com/reference/android/widget/photopicker/EmbeddedPhotoPickerFeatureInfo)はEmbedded Photo Pickerをカスタマイズするための各種オプションを設定できます。
[EmbeddedPhotoPickerClient](https://developer.android.com/reference/android/widget/photopicker/EmbeddedPhotoPickerClient)はcallbackインターフェースでEmbedded Photo Pickerから呼び出されるのでアプリ側で処理を実装します。(後述)

```kotlin
val hostToken = surfaceView.hostToken
val width = surfaceView.width
val height = surfaceView.height
val featureInfo = EmbeddedPhotoPickerFeatureInfo.Builder().build()
val clientExecutor = Executors.newSingleThreadExecutor()
val callback = EmbeddedPhotoPickerClientImpl()

provider.openSession(hostToken, displayId, width, height, featureInfo, clientExecutor, callback)

class EmbeddedPhotoPickerClientImpl() : EmbeddedPhotoPickerClient {
    // 省略
}
```

また、この際にEmbeddedPhotoPickerProvider内部で[bindServiceを使ってMedia Providerに接続する](https://cs.android.com/android/platform/superproject/main/+/main:packages/providers/MediaProvider/photopicker/framework/java/android/widget/photopicker/EmbeddedPhotoPickerProviderFactory.java;l=403-404?q=EmbeddedPhotoPickerProviderFactory)ので以下の設定がAndroidManifest.xmlに必要です。

```xml
<manifest>
    <queries>
        <intent>
            <action android:name="com.android.photopicker.core.embedded.EmbeddedService.BIND" />
        </intent>
    </queries>
<manifest>
```

セッションが開かれるとEmbeddedPhotoPickerClient#onSessionOpened()関数が[EmbeddedPhotoPickerSession](https://developer.android.com/reference/android/widget/photopicker/EmbeddedPhotoPickerSession)のインスタンスとともにコールバックされます。この時に渡されるsessionはEmbedded Photo Pickerを制御するための関数が定義されているのでどこかに保持しておきます。そして[session.surfacePackage](https://developer.android.com/reference/android/widget/photopicker/EmbeddedPhotoPickerSession#getSurfacePackage())を[SurfaceView#setChildSurfacePackage()](https://developer.android.com/reference/android/view/SurfaceView#setChildSurfacePackage(android.view.SurfaceControlViewHost.SurfacePackage))関数に渡すことでEmbedded Photo PickerのUIがSurfaceView上に描画されます。

```kotlin
class EmbeddedPhotoPickerClientImpl() : EmbeddedPhotoPickerClient {
    private var session: EmbeddedPhotoPickerSession? = null

    override fun onSessionOpened(session: EmbeddedPhotoPickerSession) {
		// To render photo picker on the surfaceView
        surfaceView.setChildSurfacePackage(session.surfacePackage)
        this.session = session
    }
}
```

## 写真の選択・決定

ユーザーがPhoto Picker上で選択した写真のUriやOKボタンを押したイベントはEmbeddedPhotoPickerClientを通してアプリに伝えられます。

onUriPermissionGranted()とonUriPermissionRevoked()ではユーザーが選択 or 選択解除した写真のUriのリストが渡されます。

そして、ユーザーがOKボタンをクリックするとonSelectionComplete()が呼ばれるのでその際には選択された写真をアプリに保存するなどの処理を行います。[^onSelectionComplete]
[^onSelectionComplete]: 渡されたUriはデータにアクセスできる期間が限られるのですぐに取り込んでおきましょう。

処理を終わった後に選択を解除したい場合は[EmbeddedPhotoPickerSession#requestRevokeUriPermission()](https://developer.android.com/reference/android/widget/photopicker/EmbeddedPhotoPickerSession#requestRevokeUriPermission(java.util.List%3Candroid.net.Uri%3E))を呼び出すことで選択を解除できます。


```kotlin
class EmbeddedPhotoPickerClientImpl : EmbeddedPhotoPickerClient {
    private var session: EmbeddedPhotoPickerSession? = null
    private var selectedPhotoUris: List<Uri> = emptyList()

    override fun onSelectionComplete() {
        val uris = selectedPhotoUris
		// TODO: Do something on the background thread
        session?.requestRevokeUriPermission(uris)
        selectedPhotoUris = emptyList()
    }

    override fun onUriPermissionGranted(uris: MutableList<Uri>) {
        selectedPhotoUris = buildList {
            addAll(selectedPhotoUris)
            uris.forEach {
                if (!selectedPhotoUris.contains(it)) {
                    add(it)
                }
            }
        }
    }

    override fun onUriPermissionRevoked(uris: MutableList<Uri>) {
        selectedPhotoUris = buildList {
            addAll(selectedPhotoUris)
            uris.forEach {
                remove(it)
            }
        }
    }
}
```


# キャプチャできるのでは?

アプリ内のUIだからキャプチャしたらパーミッション無しで写真読めるのでは?と試したのですがキャプチャできません。

キャプチャする方法はいろいろありますが[PixelCopy](https://developer.android.com/reference/android/view/PixelCopy)でSurfaceViewをキャプチャしようとしても[ERROR_SOURCE_NO_DATA](https://developer.android.com/reference/android/view/PixelCopy#ERROR_SOURCE_NO_DATA)で失敗しました。

他の方法も試しましたがSurfaceView領域は何も描画されません。

ただし、Power + Volume Down長押しなどユーザーが手動でキャプチャするのは可能です。

# その他

- [SurfaceView#getHostToken()](https://developer.android.com/reference/android/view/SurfaceView#getHostToken())はdeprecatedになっていて[AttachedSurfaceControl.getInputTransferToken()](https://developer.android.com/reference/android/view/AttachedSurfaceControl#getInputTransferToken())を使えと書いてあるけど[InputTransferToken](https://developer.android.com/reference/android/window/InputTransferToken)は型が違うのでサンプルでは仕方なくSurfaceView#getHostToken()を使っている...
- 初期状態だとPhoto Pickerがほとんどスクロールしないし選択ボタンも表示されなくて使い方がよくわからない... [Expandedをtrueに設定すると](https://developer.android.com/reference/android/widget/photopicker/EmbeddedPhotoPickerSession#notifyPhotoPickerExpanded(boolean))期待通りに動作します。
- sessionをclose()してしまうとPhoto Pickerの状態(スクロール位置, 選択中の写真等)が失われてしまうので状態を残したい場合はsessionを維持しておく必要がある
- Embedded Photo PickerのUIの実装は[こちら](https://cs.android.com/android/platform/superproject/main/+/main:packages/providers/MediaProvider/photopicker/src/com/android/photopicker/core/embedded/Session.kt;l=362-422;drc=118b0803594c21065509c5ae6802f629c9a9c1a5)から確認できます。システム側でも普通にCompose使ってるんですね。

# Sample app

雑なコードですが一応動きます
https://github.com/tsuyosh/embedded-photo-picker-sample

# まとめ

別プロセスでUIを構築してそれをアプリ内に描画するやり方はアプリのUXを改善しつつユーザーのプライバシーも保護できる面白いやり方だと思いました。[^5]
[^5]: [Privacy Sandbox](https://developers.google.com/privacy-sandbox/private-advertising/sdk-runtime/architecture?hl=ja#media-rendering)も同じような手法で広告を描画しているようです

# Reference

- [Test code at AOSP repository](https://cs.android.com/android/platform/superproject/main/+/main:cts/tests/PhotoPicker/src/android/photopicker/cts/EmbeddedTestActivity.java)