Create a cloud media provider for Android

A cloud media provider provides additional cloud media content to the Android photo picker. Users are able to select photos or videos supplied by the cloud media provider when an app uses ACTION_PICK_IMAGES or ACTION_GET_CONTENT to request media files from the user. A cloud media provider can also give information about albums, which can be browsed in the Android photo picker.

Before you begin

Take the following items into consideration before you begin building your cloud media provider.

Eligibility

Android is running a pilot program to allow OEM-nominated apps to become cloud media providers. Only apps nominated by OEMs are eligible to participate in this program to become a cloud media provider for Android at this time. Each OEM can nominate up to 3 apps. Once approved, these apps become accessible as cloud media providers on any GMS Android-powered device on which they are installed.

Android maintains a server-side list of all eligible cloud providers. Each OEM can choose a default cloud provider using a configurable overlay. Nominated apps must meet all technical requirements and pass all quality tests. To learn more about the OEM cloud media provider pilot program's process and requirements, complete the inquiry form.

Decide if you need to create a cloud media provider

Cloud media providers are intended to be apps or services that act as a users' primary source for backing up and retrieving photos and videos from the cloud. If your app has a library of useful content, but it is not typically used as a photo storage solution, you should consider creating a document provider instead.

One active cloud provider per profile

There can be at most one active cloud media provider at a time for each Android profile. Users might remove or change their selected cloud media provider app at any time from photo picker settings.

By default, the Android photo picker will attempt to choose a cloud provider automatically.

  • If there is only one eligible cloud provider on the device, that app will be selected as the current provider automatically.
  • If there are more than one eligible cloud providers on the device and one of them matches the OEM chosen default, then the OEM-chosen app will be selected.

  • If there are more than one eligible cloud providers on the device, and none of them match the OEM chosen default, no app will be selected.

Build your cloud media provider

The following diagram illustrates the sequence of events both before and during a photo selection session between the Android app, the Android photo picker, the local device's MediaProvider, and a CloudMediaProvider.

Sequence diagram showing flow from photo picker to a cloud media provider
Figure 1: Event sequence diagram during a photo selection session.
  1. The system initializes the user's preferred cloud provider and periodically syncs media metadata into the Android photo picker backend.
  2. When an Android app launches the photo picker, before showing a merged local or cloud item grid to the user, the photo picker performs a latency-sensitive incremental sync with the cloud provider to ensure results are as up-to-date as possible. After receiving a response, or when the deadline is reached, the photo picker grid now displays all accessible photos, combining those stored locally on your device with those synced from the cloud.
  3. While the user scrolls, the photo picker fetches media thumbnails from the cloud media provider to display in the UI.
  4. When the user completes the session and the results include a cloud media item, the photo picker requests file descriptors for the content, generates a URI, and grants access to the file to the calling application.
  5. The app is now able to open the URI and has read-only access to the media contents. By default, sensitive metadata is redacted. The photo picker leverages the FUSE file system to coordinate data exchange between the Android app and the cloud media provider.

Common Issues

Here are some important considerations to keep in mind when considering your implementation:

Avoid duplicate files

Since the Android photo picker has no way of inspecting the cloud media state, the CloudMediaProvider needs to provide the MEDIA_STORE_URI in the cursor row of any file that exists both in the cloud and on the local device, or the user will see duplicate files in the photo picker.

Optimize image sizes for preview display

It's very important that the file returned from onOpenPreview is not the full resolution image, and adheres to the Size being requested. Too large an image will incur loading times in the UI, and too small an image might be pixelated or blurry based on the screen size of the device.

Handle correct orientation

If thumbnails returned in onOpenPreview do not contain their EXIF data, they should be returned in the correct orientation to avoid thumbnails being rotated incorrectly in the preview grid.

Prevent unauthorized access

Check for the MANAGE_CLOUD_MEDIA_PROVIDERS_PERMISSION before returning data to the caller from the ContentProvider. This will prevent unauthorized apps from accessing cloud data.

The CloudMediaProvider class

Derived from android.content.ContentProvider, the CloudMediaProvider class includes methods like the ones shown in the following example:

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);
}

The CloudMediaProviderContract class

In addition to the primary CloudMediaProvider implementation class, the Android photo picker incorporates a CloudMediaProviderContract class. This class outlines the interoperability between the photo picker and the cloud media provider, encompassing aspects such as MediaCollectionInfo for synchronization operations, anticipated Cursor columns, and Bundle extras.

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

The onGetMediaCollectionInfo() method is used by the operating system to assess the validity of its cached cloud media items and determine necessary synchronization with the cloud media provider. Due to the potential for frequent calls by the operating system, onGetMediaCollectionInfo() is considered performance-critical; it is crucial to avoid long-running operations or side effects that could negatively impact performance. The operating system caches previous responses from this method and compares them with subsequent responses to determine the appropriate actions.

Kotlin

abstract fun onGetMediaCollectionInfo(extras: Bundle): Bundle

Java

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

The returned MediaCollectionInfo bundle includes the following constants:

onQueryMedia

The onQueryMedia() method is used to populate the main photo grid in photo picker in a variety of views. These calls might be latency sensitive, and can be called as part of a background proactive sync, or during photo picker sessions when a full or incremental sync state is required. The photo picker user interface won't wait indefinitely for a response to display results, and might time out these requests for user interface purposes. The returned cursor will still attempt to be processed into the photo picker's database for future sessions.

This method returns a Cursor representing all media items in the media collection optionally filtered by the provided extras and sorted in reverse chronological order of MediaColumns#DATE_TAKEN_MILLIS (most recent items first).

The returned CloudMediaProviderContract bundle includes the following constants:

The cloud media provider must set CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID as part of the returned Bundle. Not setting this is an error and invalidates the returned Cursor. If the cloud media provider handled any filters in the provided extras, it must add the key to the ContentResolver#EXTRA_HONORED_ARGS as part of the returned Cursor#setExtras.

onQueryDeletedMedia

The onQueryDeletedMedia() method is used to ensure deleted items in the cloud account are correctly removed from the photo picker user interface. Due to their potential latency sensitivity, these calls might be initiated as part of:

  • Background proactive synchronization
  • Photo picker sessions (when a full or incremental sync state is required)

The photo picker's user interface prioritizes a responsive user experience and will not wait indefinitely for a response. To maintain smooth interactions, timeouts might occur. Any returned Cursor will still attempt to be processed into the photo picker's database for future sessions.

This method returns a Cursor representing all deleted media items in the entire media collection within the current provider version as returned by onGetMediaCollectionInfo(). These items can be optionally filtered by extras. The cloud media provider must set the CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID as part of the returned Cursor#setExtras Not setting this is an error and invalidates the Cursor. If the provider handled any filters in the provided extras, it must add the key to the ContentResolver#EXTRA_HONORED_ARGS.

onQueryAlbums

The onQueryAlbums() method is used to fetch a list of Cloud albums that are available in the cloud provider, and their associated metadata. See CloudMediaProviderContract.AlbumColumns for additional details.

This method returns a Cursor representing all album items in the media collection optionally filtered by the provided extras and sorted in reverse chronological order of AlbumColumns#DATE_TAKEN_MILLIS , most recent items first. The cloud media provider must set the CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID as part of the returned Cursor. Not setting this is an error and invalidates the returned Cursor. If the provider handled any filters in the provided extras, it must add the key to the ContentResolver#EXTRA_HONORED_ARGS as part of the returned Cursor.

onOpenMedia

The onOpenMedia() method should return the full size media identified by the provided mediaId. If this method blocks while downloading content to the device, you should periodically check the provided CancellationSignal to abort abandoned requests.

onOpenPreview

The onOpenPreview() method should return a thumbnail of the provided size for the item of the provided mediaId. The thumbnail should be in the original CloudMediaProviderContract.MediaColumns#MIME_TYPE and is expected to be much lower resolution than the item returned by onOpenMedia. If this method is blocked while downloading content to the device, you should periodically check the provided CancellationSignal to abort abandoned requests.

onCreateCloudMediaSurfaceController

The onCreateCloudMediaSurfaceController() method should return a CloudMediaSurfaceController used for rendering the preview of media items, or null if preview rendering is not supported.

The CloudMediaSurfaceController manages rendering the preview of media items on given instances of Surface. The methods of this class are meant to be asynchronous, and should not block by performing any heavy operation. A single CloudMediaSurfaceController instance is responsible for rendering multiple media items associated with multiple surfaces.

The CloudMediaSurfaceController has support for the following list of lifecycle callbacks: