Android 用のクラウド メディア プロバイダを作成する

クラウド メディア プロバイダは、Android の写真選択ツールに追加のクラウド メディア コンテンツを提供します。アプリが ACTION_PICK_IMAGES または ACTION_GET_CONTENT を使用してユーザーにメディア ファイルをリクエストすると、ユーザーはクラウド メディア プロバイダが提供する写真や動画を選択できます。クラウド メディア プロバイダは、Android の写真選択ツールで閲覧できるアルバムに関する情報も提供できます。

始める前に

クラウド メディア プロバイダの構築を開始する前に、次の点を考慮してください。

対象条件

Android では、OEM が推薦したアプリをクラウド メディア プロバイダにできるパイロット プログラムを実施しています。現時点で Android のクラウド メディア プロバイダになるためのこのプログラムに参加できるのは、OEM が推薦したアプリのみです。各 OEM はアプリを 3 つまで推薦できます。承認されると、これらのアプリは、インストール先の GMS Android 搭載デバイスで、クラウド メディア プロバイダとしてアクセスできるようになります。

Android は、対象となるすべてのクラウド プロバイダのサーバー側のリストを保持しています。各 OEM は、設定可能なオーバーレイを使用して、デフォルトのクラウド プロバイダを選択できます。ノミネートされたアプリは、すべての技術要件を満たし、すべての品質テストに合格する必要があります。OEM クラウド メディア プロバイダ パイロット プログラムのプロセスと要件について詳しくは、お問い合わせフォームをご利用ください。

クラウド メディア プロバイダを作成する必要があるかどうかを決定する

クラウド メディア プロバイダは、ユーザーがクラウドから写真や動画をバックアップおよび取得するための主なソースとして機能するアプリまたはサービスとして意図されています。アプリに有用なコンテンツのライブラリがあるが、通常は写真ストレージ ソリューションとして使用されていない場合は、代わりにドキュメント プロバイダの作成を検討する必要があります。

プロファイルごとに 1 つのアクティブなクラウド プロバイダ

Android プロファイルごとに同時に存在できるアクティブなクラウド メディア プロバイダは 1 つだけです。ユーザーは、選択したクラウド メディア プロバイダ アプリを写真選択ツールの設定からいつでも削除または変更できます。

デフォルトでは、Android の写真選択ツールでクラウド プロバイダが自動的に選択されます。

  • デバイス上に適格なクラウド プロバイダが 1 つしかない場合は、そのアプリが自動的に現在のプロバイダとして選択されます。
  • デバイス上に対象となるクラウド プロバイダが複数あり、そのうちの 1 つが OEM が選択したデフォルトと一致する場合、OEM が選択したアプリが選択されます。

  • デバイス上に対象となるクラウド プロバイダが複数あり、いずれも OEM が選択したデフォルトと一致しない場合、アプリは選択されません。

クラウド メディア プロバイダを構築する

次の図は、Android アプリ、Android の写真選択ツール、ローカル デバイスの MediaProviderCloudMediaProvider の間での写真選択セッションの前と最中のイベントのシーケンスを示しています。

写真選択ツールからクラウド メディア プロバイダへのフローを示すシーケンス図
図 1: 写真選択セッションのイベント シーケンス図
  1. システムは、ユーザーが選択したクラウド プロバイダを初期化し、メディア メタデータを Android の写真選択ツールのバックエンドに定期的に同期します。
  2. Android アプリが写真選択ツールを起動すると、結合されたローカルまたはクラウドのアイテム グリッドがユーザーに表示される前に、写真選択ツールはレイテンシの影響を受けやすいクラウド プロバイダとの増分同期を行い、結果が可能な限り最新であることを確認します。レスポンスを受信するか、期限に達すると、写真選択ツールのグリッドにアクセス可能なすべての写真が表示され、デバイスのローカルに保存されている写真とクラウドから同期された写真とが結合されます。
  3. ユーザーがスクロールすると、写真選択ツールがクラウド メディア プロバイダからメディア サムネイルを取得し、UI に表示します。
  4. ユーザーがセッションを完了し、結果にクラウド メディア アイテムが含まれている場合、写真選択ツールはコンテンツのファイル記述子をリクエストして URI を生成し、呼び出し元のアプリケーションにファイルへのアクセスを許可します。
  5. これで、アプリで URI を開くことができるようになりました。また、メディア コンテンツに対する読み取り専用アクセス権を持ちます。デフォルトでは、機密メタデータは削除されます。写真選択ツールは、FUSE ファイル システムを利用して、Android アプリとクラウド メディア プロバイダ間のデータ交換を調整します。

一般的な問題

実装を検討する際に留意すべき重要な考慮事項は次のとおりです。

ファイルの重複を避ける

Android の写真選択ツールにはクラウド メディアの状態を調べる方法がないため、CloudMediaProvider はクラウドとローカル デバイスの両方に存在するファイルのカーソル行に MEDIA_STORE_URI を提供する必要があります。そうしないと、写真選択ツールにファイルが重複して表示されます。

プレビュー表示用の画像サイズを最適化する

onOpenPreview から返されるファイルが最大解像度の画像ではなく、リクエストされた Size を遵守していることが重要です。画像が大きすぎると、UI の読み込みに時間がかかり、小さすぎる画像はデバイスの画面サイズに応じてピクセル化したりぼやけたりすることがあります。

正しい向きを処理する

onOpenPreview で返されるサムネイルに EXIF データが含まれていない場合は、プレビュー グリッド内でサムネイルが不適切に回転しないように、正しい向きで返される必要があります。

不正アクセスの防止

ContentProvider から呼び出し元にデータを返す前に、MANAGE_CLOUD_MEDIA_PROVIDERS_PERMISSION を確認します。これにより、未承認のアプリがクラウドデータにアクセスできなくなります。

CloudMediaProvider クラス

android.content.ContentProvider から派生した CloudMediaProvider クラスには、次の例に示すようなメソッドが含まれています。

Kotlin

abstract class CloudMediaProvider : ContentProvider() {

    @NonNull
    abstract override fun onGetMediaCollectionInfo(@NonNull bundle: Bundle): Bundle

    @NonNull
    override fun onQueryAlbums(@NonNull bundle: Bundle): Cursor = TODO("Implement onQueryAlbums")

    @NonNull
    abstract override fun onQueryDeletedMedia(@NonNull bundle: Bundle): Cursor

    @NonNull
    abstract override fun onQueryMedia(@NonNull bundle: Bundle): Cursor

    @NonNull
    abstract override fun onOpenMedia(
        @NonNull string: String,
        @Nullable bundle: Bundle?,
        @Nullable cancellationSignal: CancellationSignal?
    ): ParcelFileDescriptor

    @NonNull
    abstract override fun onOpenPreview(
        @NonNull string: String,
        @NonNull point: Point,
        @Nullable bundle: Bundle?,
        @Nullable cancellationSignal: CancellationSignal?
    ): AssetFileDescriptor

    @Nullable
    override fun onCreateCloudMediaSurfaceController(
        @NonNull bundle: Bundle,
        @NonNull callback: CloudMediaSurfaceStateChangedCallback
    ): CloudMediaSurfaceController? = null
}

Java

public abstract class CloudMediaProvider extends android.content.ContentProvider {

  @NonNull
  public abstract android.os.Bundle onGetMediaCollectionInfo(@NonNull android.os.Bundle);

  @NonNull
  public android.database.Cursor onQueryAlbums(@NonNull android.os.Bundle);

  @NonNull
  public abstract android.database.Cursor onQueryDeletedMedia(@NonNull android.os.Bundle);

  @NonNull
  public abstract android.database.Cursor onQueryMedia(@NonNull android.os.Bundle);

  @NonNull
  public abstract android.os.ParcelFileDescriptor onOpenMedia(@NonNull String, @Nullable android.os.Bundle, @Nullable android.os.CancellationSignal) throws java.io.FileNotFoundException;

  @NonNull
  public abstract android.content.res.AssetFileDescriptor onOpenPreview(@NonNull String, @NonNull android.graphics.Point, @Nullable android.os.Bundle, @Nullable android.os.CancellationSignal) throws java.io.FileNotFoundException;

  @Nullable
  public android.provider.CloudMediaProvider.CloudMediaSurfaceController onCreateCloudMediaSurfaceController(@NonNull android.os.Bundle, @NonNull android.provider.CloudMediaProvider.CloudMediaSurfaceStateChangedCallback);
}

CloudMediaProviderContract クラス

Android の写真選択ツールには、メインの CloudMediaProvider 実装クラスに加えて、CloudMediaProviderContract クラスが組み込まれています。このクラスでは、同期オペレーションの MediaCollectionInfo、想定される Cursor 列、Bundle エクストラなど、写真選択ツールとクラウド メディア プロバイダ間の相互運用性の概要を説明します。

Kotlin

object CloudMediaProviderContract {

    const val EXTRA_ALBUM_ID = "android.provider.extra.ALBUM_ID"
    const val EXTRA_LOOPING_PLAYBACK_ENABLED = "android.provider.extra.LOOPING_PLAYBACK_ENABLED"
    const val EXTRA_MEDIA_COLLECTION_ID = "android.provider.extra.MEDIA_COLLECTION_ID"
    const val EXTRA_PAGE_SIZE = "android.provider.extra.PAGE_SIZE"
    const val EXTRA_PAGE_TOKEN = "android.provider.extra.PAGE_TOKEN"
    const val EXTRA_PREVIEW_THUMBNAIL = "android.provider.extra.PREVIEW_THUMBNAIL"
    const val EXTRA_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED = "android.provider.extra.SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED"
    const val EXTRA_SYNC_GENERATION = "android.provider.extra.SYNC_GENERATION"
    const val MANAGE_CLOUD_MEDIA_PROVIDERS_PERMISSION = "com.android.providers.media.permission.MANAGE_CLOUD_MEDIA_PROVIDERS"
    const val PROVIDER_INTERFACE = "android.content.action.CLOUD_MEDIA_PROVIDER"

    object MediaColumns {
        const val DATE_TAKEN_MILLIS = "date_taken_millis"
        const val DURATION_MILLIS = "duration_millis"
        const val HEIGHT = "height"
        const val ID = "id"
        const val IS_FAVORITE = "is_favorite"
        const val MEDIA_STORE_URI = "media_store_uri"
        const val MIME_TYPE = "mime_type"
        const val ORIENTATION = "orientation"
        const val SIZE_BYTES = "size_bytes"
        const val STANDARD_MIME_TYPE_EXTENSION = "standard_mime_type_extension"
        const val STANDARD_MIME_TYPE_EXTENSION_ANIMATED_WEBP = 3 // 0x3
        const val STANDARD_MIME_TYPE_EXTENSION_GIF = 1 // 0x1
        const val STANDARD_MIME_TYPE_EXTENSION_MOTION_PHOTO = 2 // 0x2
        const val STANDARD_MIME_TYPE_EXTENSION_NONE = 0 // 0x0
        const val SYNC_GENERATION = "sync_generation"
        const val WIDTH = "width"
    }

    object AlbumColumns {
        const val DATE_TAKEN_MILLIS = "date_taken_millis"
        const val DISPLAY_NAME = "display_name"
        const val ID = "id"
        const val MEDIA_COUNT = "album_media_count"
        const val MEDIA_COVER_ID = "album_media_cover_id"
    }

    object MediaCollectionInfo {
        const val ACCOUNT_CONFIGURATION_INTENT = "account_configuration_intent"
        const val ACCOUNT_NAME = "account_name"
        const val LAST_MEDIA_SYNC_GENERATION = "last_media_sync_generation"
        const val MEDIA_COLLECTION_ID = "media_collection_id"
    }
}

Java

public final class CloudMediaProviderContract {

  public static final String EXTRA_ALBUM_ID = "android.provider.extra.ALBUM_ID";
  public static final String EXTRA_LOOPING_PLAYBACK_ENABLED = "android.provider.extra.LOOPING_PLAYBACK_ENABLED";
  public static final String EXTRA_MEDIA_COLLECTION_ID = "android.provider.extra.MEDIA_COLLECTION_ID";
  public static final String EXTRA_PAGE_SIZE = "android.provider.extra.PAGE_SIZE";
  public static final String EXTRA_PAGE_TOKEN = "android.provider.extra.PAGE_TOKEN";
  public static final String EXTRA_PREVIEW_THUMBNAIL = "android.provider.extra.PREVIEW_THUMBNAIL";
  public static final String EXTRA_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED = "android.provider.extra.SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED";
  public static final String EXTRA_SYNC_GENERATION = "android.provider.extra.SYNC_GENERATION";
  public static final String MANAGE_CLOUD_MEDIA_PROVIDERS_PERMISSION = "com.android.providers.media.permission.MANAGE_CLOUD_MEDIA_PROVIDERS";
  public static final String PROVIDER_INTERFACE = "android.content.action.CLOUD_MEDIA_PROVIDER";
}

// Columns available for every media item
public static final class CloudMediaProviderContract.MediaColumns {

  public static final String DATE_TAKEN_MILLIS = "date_taken_millis";
  public static final String DURATION_MILLIS = "duration_millis";
  public static final String HEIGHT = "height";
  public static final String ID = "id";
  public static final String IS_FAVORITE = "is_favorite";
  public static final String MEDIA_STORE_URI = "media_store_uri";
  public static final String MIME_TYPE = "mime_type";
  public static final String ORIENTATION = "orientation";
  public static final String SIZE_BYTES = "size_bytes";
  public static final String STANDARD_MIME_TYPE_EXTENSION = "standard_mime_type_extension";
  public static final int STANDARD_MIME_TYPE_EXTENSION_ANIMATED_WEBP = 3; // 0x3
  public static final int STANDARD_MIME_TYPE_EXTENSION_GIF = 1; // 0x1 
  public static final int STANDARD_MIME_TYPE_EXTENSION_MOTION_PHOTO = 2; // 0x2 
  public static final int STANDARD_MIME_TYPE_EXTENSION_NONE = 0; // 0x0 
  public static final String SYNC_GENERATION = "sync_generation";
  public static final String WIDTH = "width";
}

// Columns available for every album item
public static final class CloudMediaProviderContract.AlbumColumns {

  public static final String DATE_TAKEN_MILLIS = "date_taken_millis";
  public static final String DISPLAY_NAME = "display_name";
  public static final String ID = "id";
  public static final String MEDIA_COUNT = "album_media_count";
  public static final String MEDIA_COVER_ID = "album_media_cover_id";
}

// Media Collection metadata that is cached by the OS to compare sync states.
public static final class CloudMediaProviderContract.MediaCollectionInfo {

  public static final String ACCOUNT_CONFIGURATION_INTENT = "account_configuration_intent";
  public static final String ACCOUNT_NAME = "account_name";
  public static final String LAST_MEDIA_SYNC_GENERATION = "last_media_sync_generation";
  public static final String MEDIA_COLLECTION_ID = "media_collection_id";
}

onGetMediaCollectionInfo

onGetMediaCollectionInfo() メソッドは、オペレーティング システムがキャッシュに保存されたクラウド メディア アイテムの有効性を評価し、クラウド メディア プロバイダと必要な同期を判断するために使用します。オペレーティング システムによって頻繁に呼び出される可能性があるため、onGetMediaCollectionInfo() はパフォーマンスが重要とみなされます。長時間実行オペレーションや、パフォーマンスに悪影響を与える可能性のある副作用を避けることが重要です。オペレーティング システムは、このメソッドからの以前のレスポンスをキャッシュに保存し、後続のレスポンスと比較して適切なアクションを決定します。

Kotlin

abstract fun onGetMediaCollectionInfo(extras: Bundle): Bundle

Java

@NonNull
public abstract Bundle onGetMediaCollectionInfo(@NonNull Bundle extras);

返される MediaCollectionInfo バンドルには次の定数が含まれます。

onQueryMedia

onQueryMedia() メソッドは、さまざまなビューで写真選択ツールのメインの写真グリッドにデータを入力するために使用されます。これらの呼び出しはレイテンシの影響を受けやすい場合があり、バックグラウンドでのプロアクティブな同期の一部として、または完全または増分の同期状態が必要な写真選択ツール セッション中に呼び出すことができます。写真選択ツールのユーザー インターフェースは、結果が表示されるレスポンスを無期限に待つことはなく、ユーザー インターフェースのためにこれらのリクエストがタイムアウトになることがあります。返されたカーソルは、今後のセッションのために写真選択ツールのデータベースへの処理を引き続き試行します。

このメソッドは、メディア コレクション内のすべてのメディア アイテムを表す Cursor を返します。指定されたエクストラでフィルタして、MediaColumns#DATE_TAKEN_MILLIS の逆順(最新のアイテムが最初)に並べ替えます。

返される CloudMediaProviderContract バンドルには次の定数が含まれます。

クラウド メディア プロバイダは、返される Bundle の一部として CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID を設定する必要があります。設定しないとエラーになり、返された Cursor が無効になります。クラウド メディア プロバイダが提供されたエクストラでフィルタを処理した場合、返された Cursor#setExtras の一部としてキーを ContentResolver#EXTRA_HONORED_ARGS に追加する必要があります。

onQueryDeletedMedia

onQueryDeletedMedia() メソッドは、クラウド アカウント内の削除されたアイテムを写真選択ツールのユーザー インターフェースから正しく削除するために使用されます。レイテンシの影響を受けやすいため、これらの呼び出しは以下の一環として開始される可能性があります。

  • バックグラウンドでのプロアクティブな同期
  • 写真選択ツール セッション(完全または増分の同期が必要な場合)

写真選択ツールのユーザー インターフェースはレスポンシブなユーザー エクスペリエンスを優先しており、レスポンスを無限に待つことはありません。スムーズな操作を維持するために、タイムアウトが発生することがあります。返された Cursor については、今後のセッションのために写真選択ツールのデータベースへの処理が引き続き試行されます。

このメソッドは、onGetMediaCollectionInfo() によって返された現在のプロバイダ バージョン内のメディア コレクション全体にある削除済みのメディア アイテムすべてを表す Cursor を返します。これらのアイテムは、必要に応じてエクストラでフィルタできます。クラウド メディア プロバイダは、返された Cursor#setExtras の一部として CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID を設定する必要があります。設定しない場合、これはエラーであり、Cursor が無効になります。提供されたエクストラでプロバイダがフィルタを処理した場合は、キーを ContentResolver#EXTRA_HONORED_ARGS に追加する必要があります。

onQueryAlbums

onQueryAlbums() メソッドは、クラウド プロバイダで利用できるクラウド アルバムのリストとそれに関連するメタデータを取得するために使用されます。詳細については、CloudMediaProviderContract.AlbumColumns をご覧ください。

このメソッドは、メディア コレクション内のすべてのアルバム アイテムを表す Cursor を返します。指定されたエクストラでフィルタして、AlbumColumns#DATE_TAKEN_MILLIS の新しい順(新しいアイテムが先)に並べ替えます。クラウド メディア プロバイダは、返される Cursor の一部として CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID を設定する必要があります。設定しないとエラーになり、返された Cursor が無効になります。プロバイダが提供されたエクストラでフィルタを処理した場合、プロバイダは返された Cursor の一部としてキーを ContentResolver#EXTRA_HONORED_ARGS に追加する必要があります。

onOpenMedia

onOpenMedia() メソッドは、指定された mediaId によって識別されるフルサイズのメディアを返します。デバイスにコンテンツをダウンロードしている間にこのメソッドがブロックされる場合は、提供された CancellationSignal を定期的に確認して、放棄されたリクエストを中止する必要があります。

onOpenPreview

onOpenPreview() メソッドは、指定された mediaId のアイテムに対して指定された size のサムネイルを返す必要があります。サムネイルは元の CloudMediaProviderContract.MediaColumns#MIME_TYPE に含める必要があり、onOpenMedia が返すアイテムよりもはるかに低い解像度になります。デバイスにコンテンツをダウンロードしている間にこのメソッドがブロックされた場合は、提供された CancellationSignal を定期的に確認して、放棄されたリクエストを中止する必要があります。

onCreateCloudMediaSurfaceController

onCreateCloudMediaSurfaceController() メソッドは、メディア アイテムのプレビューのレンダリングに使用される CloudMediaSurfaceController を返すか、プレビューのレンダリングがサポートされていない場合は null を返します。

CloudMediaSurfaceController は、Surface の特定のインスタンスでのメディア アイテムのプレビューのレンダリングを管理します。このクラスのメソッドは非同期であることが想定されており、負荷の大きいオペレーションを実行してブロックすべきではありません。1 つの CloudMediaSurfaceController インスタンスが、複数のサーフェスに関連付けられた複数のメディア アイテムをレンダリングします。

CloudMediaSurfaceController は、次のライフサイクル コールバックのリストをサポートしています。