建立 Android 雲端媒體供應商

雲端媒體供應商會向 Android 相片挑選工具提供額外的雲端媒體內容。應用程式使用 ACTION_PICK_IMAGESACTION_GET_CONTENT 要求使用者提供媒體檔案時,使用者可以選取雲端媒體供應商提供的相片或影片。雲端媒體供應商也可以提供相簿資訊,方便使用者在 Android 相片挑選工具中瀏覽。

事前準備

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

資格條件

Android 正在進行試辦計畫,允許 OEM 指定的應用程式成為雲端媒體供應商。目前只有由原始設備製造商 (OEM) 提名的應用程式,才能加入這項計畫,成為 Android 的雲端媒體供應商。每個原始設備製造商最多可提名 3 個應用程式。核准後,這些應用程式就會成為雲端媒體供應商,使用者可在安裝這些應用程式的任何 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 類別

CloudMediaProvider 類別衍生自 android.content.ContentProvider,包含下列範例所示的方法:

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() 傳回的項目。這些項目可以選擇依額外內容篩選。 雲端媒體供應商必須在傳回的 Cursor#setExtras 中設定 CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID。如果未設定,系統會發生錯誤,並使 Cursor 無效。如果供應商在提供的額外內容中處理任何篩選器,就必須將金鑰新增至 ContentResolver#EXTRA_HONORED_ARGS

onQueryAlbums

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() 方法應傳回所提供 size 的縮圖,適用於所提供 mediaId 的項目。縮圖應為原始 CloudMediaProviderContract.MediaColumns#MIME_TYPE,且解析度應遠低於 onOpenMedia 傳回的項目。如果下載內容至裝置時,這個方法遭到封鎖,您應定期檢查提供的 CancellationSignal,中止遭捨棄的要求。

onCreateCloudMediaSurfaceController

onCreateCloudMediaSurfaceController() 方法應傳回用於轉譯媒體項目預覽畫面的 CloudMediaSurfaceController,或在不支援預覽轉譯時傳回 null

CloudMediaSurfaceController 會管理在指定 Surface 執行個體上轉譯媒體項目預覽畫面。這個類別的方法應為非同步,且不應執行任何耗用大量資源的作業,以免遭到封鎖。單一 CloudMediaSurfaceController 執行個體負責算繪與多個介面相關聯的多個媒體項目。

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