创建适用于 Android 的云媒体提供程序

云媒体提供程序会向 Android 照片选择器提供额外的云媒体内容。当应用使用 ACTION_PICK_IMAGESACTION_GET_CONTENT 向用户请求媒体文件时,用户可以选择云媒体提供方提供的照片或视频。云媒体 提供程序还可以提供有关相册的信息,这些相册可以在 Android 照片选择器中浏览。

准备工作

在开始构建云媒体提供程序之前,请考虑以下事项。

资格要求

Android 正在运行一项试点计划,允许 OEM 指定的应用成为云媒体提供程序。目前,只有原始设备制造商(OEM) 指定的应用才有资格参与此计划,成为 Android 的云媒体提供方 。每个 OEM 最多可以指定 3 个应用。获得批准后,这些应用将可在安装它们的任何 GMS Android 设备上作为云媒体提供程序进行访问。

Android 会维护一个服务器端列表,其中包含所有符合条件的云提供程序。每个原始设备制造商(OEM)都可以使用可配置的叠加层选择默认云提供方。指定应用必须满足所有技术要求并通过所有质量测试。如需详细了解原始设备制造商(OEM) 云媒体提供方小规模测试计划的流程和 要求,请填写咨询表单

确定是否需要创建云媒体提供方

云媒体提供程序旨在作为应用或服务,充当用户从云端备份和检索照片和视频的主要来源。 如果您的应用包含一个实用内容库,但通常不作为 照片存储解决方案使用,则应考虑改为创建文档提供方

每个个人资料一个活跃的云提供方

每个 Android 个人资料一次最多只能有一个活跃的云媒体提供方。用户可以随时从照片选择器设置中移除或更改其选择的云媒体提供程序应用。

默认情况下,Android 照片选择器会尝试自动选择云提供程序。

  • 如果设备上只有一个符合条件的云提供程序,系统会自动选择该应用作为当前提供程序。
  • 如果设备上有多个符合条件的云提供程序,并且其中一个与 OEM 选择的默认提供程序匹配,则系统会选择 OEM 选择的应用。

  • 如果设备上有多个符合条件的云提供程序,但没有一个与 OEM 选择的默认提供程序匹配,则系统不会选择任何应用。

构建云媒体提供程序

下图说明了 Android 应用、Android 照片选择器、本地设备的 MediaProviderCloudMediaProvider 在照片选择会话之前和期间的事件序列。

显示从照片选择器到云媒体提供商的流程的序列图
图 1: 照片选择会话期间的事件序列图。
  1. 系统会初始化用户首选的云提供程序,并定期将媒体元数据同步到 Android 照片选择器后端。
  2. 当 Android 应用启动照片选择器时,在向用户显示合并的本地或云端项网格之前,照片选择器会与云提供程序执行对延迟敏感的增量同步,以确保结果尽可能最新。收到响应后或达到截止时间时,照片选择器网格现在会显示所有可访问的照片,包括存储在设备本地的照片和从云端同步的照片。
  3. 当用户滚动时,照片选择器会从云媒体提供程序提取媒体缩略图,以在界面中显示。
  4. 当用户完成会话且结果包含云媒体项时,照片选择器会请求内容的 file descriptor,生成 URI,并向调用应用授予对该文件的访问权限。
  5. 应用现在可以打开 URI,并且对媒体内容具有只读访问权限。默认情况下,敏感元数据会被修订。照片选择器利用 FUSE 文件系统来协调 Android 应用与云媒体提供程序之间的数据交换。

常见问题

在考虑实现时,请注意以下重要事项:

避免重复文件

由于 Android 照片选择器无法检查云媒体状态,因此 CloudMediaProvider 需要在云端和本地设备中都存在的任何文件的游标行中提供 MEDIA_STORE_URI,否则用户会在照片选择器中看到重复文件。

优化图片大小以进行预览显示

非常重要的一点是,从 onOpenPreview 返回的文件不是全分辨率图片,并且符合所请求的 Size。图片过大会导致界面加载时间过长,而图片过小则可能会因设备屏幕尺寸而出现像素化或模糊。

处理正确的屏幕方向

如果 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 extra 等方面。

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 bundle 包含以下常量:

onQueryMedia

onQueryMedia() 方法用于在 照片选择器中填充主照片网格。这些调用可能对延迟敏感,并且可以作为后台主动同步的一部分调用,也可以在需要完整或增量同步状态的照片选择器会话期间调用。照片选择器界面不会无限期地等待响应来显示结果,并且可能会出于界面目的而使这些请求超时。返回的游标仍会尝试处理到照片选择器的数据库中,以供以后的会话使用。

此方法会返回一个 Cursor,表示媒体集合中的所有媒体项,这些媒体项可以选择按提供的 extra 进行过滤,并按 MediaColumns#DATE_TAKEN_MILLIS 的逆时间顺序排序(最近的项在前)。

返回的 CloudMediaProviderContract bundle 包含以下 常量:

云媒体提供方必须将 CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID 设置为返回的 Bundle 的一部分。如果不设置此项,则会出错并使返回的 Cursor 无效。如果云媒体提供程序处理了提供的 extra 中的任何过滤条件,则必须将该键添加到 ContentResolver#EXTRA_HONORED_ARGS,作为返回的 Cursor#setExtras 的一部分。

onQueryDeletedMedia

onQueryDeletedMedia() 方法用于确保从 云端账号中删除的项会从照片选择器界面中正确移除。由于这些调用可能对延迟敏感,因此可能会作为以下内容的一部分启动:

  • 后台主动同步
  • 照片选择器会话(当需要完整或增量同步状态时)

照片选择器的界面优先考虑响应迅速的用户体验,不会无限期地等待响应。为保持流畅的互动,可能会发生超时。任何返回的 Cursor 仍会尝试处理到照片选择器的数据库中,以供以后的会话使用。

此方法会返回一个 Cursor,表示当前提供程序版本中整个媒体集合中的所有已删除媒体文件,如 onGetMediaCollectionInfo() 返回的那样。这些项可以选择按 extra 进行过滤。 云媒体提供程序必须将 CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID设置为返回的 Cursor#setExtras的一部分。如果不设置此项,则会出错并使Cursor无效。如果提供程序处理了提供的 extra 中的任何过滤条件,则必须将该键添加到 ContentResolver#EXTRA_HONORED_ARGS

onQueryAlbums

onQueryAlbums() 方法用于提取云提供方中可用的云端相册列表及其关联的元数据。如需了解更多详情,请参阅 CloudMediaProviderContract.AlbumColumns

此方法会返回一个 Cursor,表示媒体集合中的所有相册项,这些相册项可以选择按提供的 extra 进行过滤,并按 AlbumColumns#DATE_TAKEN_MILLIS 的逆时间顺序排序(最近的项在前)。云媒体提供程序必须将 CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID 设置为返回的 Cursor 的一部分。如果不设置此项,则会出错并使返回的 Cursor 无效。如果提供程序处理了提供的 extra 中的任何过滤条件,则必须将该键添加到 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 实例负责呈现与多个 Surface 关联的多个媒体项。

CloudMediaSurfaceController 支持以下生命周期回调列表: