建立 Android 雲端媒體供應商

雲端媒體供應商為 Android 相片挑選工具提供額外的雲端媒體內容。當應用程式使用 ACTION_PICK_IMAGESACTION_GET_CONTENT 向使用者要求媒體檔案時,使用者可以選擇雲端媒體供應商提供的相片或影片。雲端媒體供應商也可以提供「專輯」的相關資訊,並可透過 Android 相片挑選工具瀏覽。

事前準備

開始建構雲端媒體供應商之前,請考量下列事項。

使用資格

Android 正在進行前測計畫,讓原始設備製造商 (OEM) 提名的應用程式成為雲端服務供應商。目前只有原始設備製造商 (OEM) 提名的應用程式有資格參與這項計畫,成為 Android 雲端媒體供應商。每個原始設備製造商 (OEM) 最多可以指定 3 個應用程式。獲得核准後,只要在已安裝 GMS Android 裝置的任何 GMS Android 裝置上,就能以雲端服務供應商的身分存取這些應用程式。

Android 提供一份完整的伺服器端清單,列出所有符合資格的雲端服務供應商。每個原始設備製造商 (OEM) 都可以使用「可設定的疊加層」選擇預設的雲端服務供應商。指定的應用程式必須符合所有技術規定,並通過所有品質測試。如要進一步瞭解原始設備製造商 (OEM) 雲端媒體供應商前測計畫的程序和規定,請填寫諮詢表單

判斷您是否需要建立雲端媒體供應商

雲端媒體供應商是用來做為使用者主要來源,用於備份及擷取雲端中的相片和影片。如果應用程式內含實用內容的資料庫,但通常不做為相片儲存解決方案使用,建議您改為建立文件供應器

每個設定檔只能有一個有效的雲端服務供應商

每個 Android 設定檔一次只能有一個有效的雲端媒體供應商。使用者隨時可以透過相片挑選工具設定,移除或變更所選的雲端媒體供應商應用程式。

根據預設,Android 相片挑選工具會自動嘗試選擇雲端服務供應商。

  • 如果裝置上只有一個符合資格的雲端服務供應商,系統會自動選取該應用程式做為目前的供應商。
  • 如果裝置上有多個符合資格的雲端服務供應商,且其中一家供應商與原始設備製造商 (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.ContentProviderCloudMediaProvider 類別包含類似以下範例中的方法:

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 類別

除了主要的 CloudMediaProvider 實作類別之外,Android 相片挑選工具還會加入 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 組合包含下列常數:

雲端媒體供應商必須將 CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID 設為傳回的 Bundle 的一部分。不設定則屬錯誤,且傳回的 Cursor 會失效。如果雲端媒體供應商在提供的額外項目中處理任何篩選器,則必須將金鑰新增至 ContentResolver#EXTRA_HONORED_ARGS 做為傳回的 Cursor#setExtras 的一部分。

onQuerydeletedMedia

onQueryDeletedMedia() 方法可確保從相片挑選工具使用者介面中,正確移除雲端帳戶中的已刪除項目。由於其中可能有延遲時間敏感,因此這些呼叫可能做為下列一部分發起:

  • 背景主動同步處理
  • 相片挑選工具工作階段 (需要完整或漸進式同步狀態時)

相片挑選工具的使用者介面會優先提供回應式使用者體驗,不會無限期地等候回應。為維持順暢的互動情形,系統可能會發生逾時情況。任何傳回的 Cursor 仍會嘗試在相片挑選工具的資料庫中處理,以供日後工作階段使用。

這個方法會傳回 Cursor,代表 onGetMediaCollectionInfo() 傳回的目前供應器版本內整個媒體集合中所有已刪除的媒體項目。您可以選擇使用額外項目篩選這些項目。雲端媒體供應商必須將 CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID 設為傳回的 Cursor#setExtras 的一部分。將此設定視為錯誤,並使 Cursor 失效。如果提供者在提供的額外項目中處理任何篩選器,則必須將鍵新增至 ContentResolver#EXTRA_HONORED_ARGS

onQuery 相簿

onQueryAlbums() 方法可用於擷取雲端服務供應商提供的雲端相簿清單,以及相關聯的中繼資料。詳情請參閱 CloudMediaProviderContract.AlbumColumns

這個方法會傳回一個 Cursor,代表媒體集合中的所有專輯項目,您可以選擇根據提供的額外項目進行篩選,並依 AlbumColumns#DATE_TAKEN_MILLIS 的反向時間順序排序,最新的項目會優先顯示。雲端媒體供應商必須將 CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID 設為傳回的 Cursor 的一部分。不設定則屬錯誤,且傳回的 Cursor 會失效。如果提供者在提供的額外項目中處理任何篩選器,則必須將金鑰新增至 ContentResolver#EXTRA_HONORED_ARGS,做為傳回的 Cursor 的一部分。

onOpenMedia

onOpenMedia() 方法應傳回提供的 mediaId 識別的完整大小媒體。如果這個方法在下載內容至裝置時封鎖,您應定期檢查提供的 CancellationSignal,以取消放棄的要求。

onOpenPreview

onOpenPreview() 方法應針對所提供 mediaId 的項目傳回所提供 size 的縮圖。縮圖應位於原始的 CloudMediaProviderContract.MediaColumns#MIME_TYPE 中,且解析度應遠低於 onOpenMedia 傳回的項目。如果這個方法在下載內容至裝置時遭到封鎖,您應定期檢查提供的 CancellationSignal,以取消放棄的要求。

onCreateCloudMediaSurfaceController

onCreateCloudMediaSurfaceController() 方法應傳回用於算繪媒體項目預覽的 CloudMediaSurfaceController;如果不支援預覽算繪,則會傳回 null

CloudMediaSurfaceController 會管理特定 Surface 例項上媒體項目的預覽算繪作業。此類別的方法為非同步,不應因執行任何繁重的作業而遭到封鎖。單一 CloudMediaSurfaceController 例項負責算繪與多個途徑相關聯的多個媒體項目。

CloudMediaSurfaceController 支援下列生命週期回呼清單: