Criar um provedor de mídia em nuvem para Android

Um provedor de mídia em nuvem oferece mais conteúdo de mídia em nuvem para o seletor de fotos do Android. Os usuários podem selecionar fotos ou vídeos fornecidos pelo provedor de mídia em nuvem quando um app usa ACTION_PICK_IMAGES ou ACTION_GET_CONTENT para solicitar arquivos de mídia do usuário. Um provedor de mídia em nuvem também pode fornecer informações sobre álbuns, que podem ser navegados no seletor de fotos do Android.

Antes de começar

Considere os itens a seguir antes de começar a criar seu provedor de mídia em nuvem.

Qualificação

O Android está executando um programa piloto para permitir que apps indicados pelo OEM se tornem provedores de mídia em nuvem. No momento, somente os apps indicados por OEMs estão qualificados para participar desse programa e se tornarem provedores de mídia em nuvem para Android. Cada OEM pode indicar até três apps. Depois de aprovados, esses apps ficam acessíveis como provedores de mídia na nuvem em qualquer dispositivo com tecnologia GMS Android em que estão instalados.

O Android mantém uma lista do lado do servidor de todos os provedores de nuvem qualificados. Cada OEM pode escolher um provedor de nuvem padrão usando uma sobreposição configurável. Os apps indicados precisam atender a todos os requisitos técnicos e passar em todos os testes de qualidade. Para saber mais sobre o processo e os requisitos do programa piloto de provedor de mídia em nuvem do OEM, preencha o formulário de consulta.

Decida se é necessário criar um provedor de mídia em nuvem

Os provedores de mídia em nuvem destinam-se a apps ou serviços que atuam como a principal fonte dos usuários para fazer backup e recuperar fotos e vídeos da nuvem. Se o app tem uma biblioteca de conteúdo útil, mas normalmente não é usada como uma solução de armazenamento de fotos, crie um provedor de documentos.

Um provedor de nuvem ativo por perfil

Pode haver no máximo um provedor de mídia na nuvem ativo por vez para cada perfil do Android. Os usuários podem remover ou mudar o app do provedor de mídia em nuvem selecionado a qualquer momento nas configurações do seletor de fotos.

Por padrão, o seletor de fotos do Android tentará escolher um provedor de nuvem automaticamente.

  • Se houver apenas um provedor de nuvem qualificado no dispositivo, esse app será selecionado como o provedor atual automaticamente.
  • Se houver mais de um provedor de nuvem qualificado no dispositivo e um deles corresponder ao padrão escolhido pelo OEM, o app escolhido pelo OEM será selecionado.

  • Se houver mais de um provedor de nuvem qualificado no dispositivo e nenhum deles corresponder ao padrão escolhido pelo OEM, nenhum app será selecionado.

Crie seu provedor de mídia na nuvem

O diagrama a seguir ilustra a sequência de eventos antes e durante uma sessão de seleção de fotos entre o app Android, o seletor de fotos do Android, a MediaProvider do dispositivo local e uma CloudMediaProvider.

Diagrama de sequência mostrando o fluxo do seletor de fotos para um provedor de mídia em nuvem
Figura 1:diagrama de sequência de eventos durante uma sessão de seleção de fotos.
  1. O sistema inicializa o provedor de nuvem preferido do usuário e sincroniza periodicamente os metadados de mídia no back-end do seletor de fotos do Android.
  2. Quando um app Android inicia o seletor de fotos, antes de mostrar uma grade de itens locais ou da nuvem mesclados ao usuário, o seletor executa uma sincronização incremental sensível à latência com o provedor de nuvem para garantir que os resultados estejam os mais atualizados possível. Depois de receber uma resposta, ou quando o prazo for atingido, a grade do seletor de fotos vai mostrar todas as fotos acessíveis, combinando aquelas armazenadas localmente no seu dispositivo com as sincronizadas na nuvem.
  3. Enquanto o usuário rola a tela, o seletor de fotos busca miniaturas de mídia do provedor de mídia da nuvem para exibição na interface.
  4. Quando o usuário conclui a sessão e os resultados incluem um item de mídia em nuvem, o seletor de fotos solicita descritores de arquivo para o conteúdo, gera um URI e concede acesso ao arquivo ao aplicativo que fez a chamada.
  5. Agora, o app poderá abrir o URI e terá acesso somente leitura ao conteúdo da mídia. Por padrão, os metadados confidenciais são editados. O seletor de fotos aproveita o sistema de arquivos FUSE para coordenar a troca de dados entre o app Android e o provedor de mídia em nuvem.

Problemas comuns

Veja algumas considerações importantes a serem consideradas ao considerar sua implementação:

Evite arquivos duplicados

Como o seletor de fotos do Android não tem como inspecionar o estado da mídia na nuvem, a CloudMediaProvider precisa fornecer a MEDIA_STORE_URI na linha do cursor de qualquer arquivo que exista na nuvem e no dispositivo local, ou o usuário verá arquivos duplicados no seletor de fotos.

Otimizar tamanhos de imagem para exibição de visualização

É muito importante que o arquivo retornado de onOpenPreview não seja a imagem de resolução máxima e cumpra o Size solicitado. Uma imagem muito grande gera tempos de carregamento na interface e uma imagem muito pequena pode ficar pixelada ou desfocada com base no tamanho da tela do dispositivo.

Processar a orientação correta

Se as miniaturas retornadas em onOpenPreview não contiverem dados EXIF, elas precisarão ser retornadas na orientação correta para evitar que as miniaturas sejam giradas incorretamente na grade de visualização.

Impedir acessos não autorizados

Verifique o MANAGE_CLOUD_MEDIA_PROVIDERS_PERMISSION antes de retornar dados ao autor da chamada do ContentProvider. Isso impedirá que apps não autorizados acessem dados na nuvem.

A classe CloudMediaProvider

Derivada do android.content.ContentProvider, a classe CloudMediaProvider inclui métodos como os mostrados no exemplo a seguir:

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

A classe CloudMediaProviderContract

Além da classe de implementação principal CloudMediaProvider, o seletor de fotos do Android incorpora uma classe CloudMediaProviderContract. Esta classe descreve a interoperabilidade entre o seletor de fotos e o provedor de mídia em nuvem, abrangendo aspectos como MediaCollectionInfo para operações de sincronização, colunas Cursor previstas e extras de 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

O método onGetMediaCollectionInfo() é usado pelo sistema operacional para avaliar a validade dos itens de mídia na nuvem armazenados em cache e determinar a sincronização necessária com o provedor de mídia em nuvem. Devido ao potencial de chamadas frequentes do sistema operacional, o método onGetMediaCollectionInfo() é considerado essencial para o desempenho. É fundamental evitar operações de longa duração ou efeitos colaterais que possam afetar negativamente o desempenho. O sistema operacional armazena em cache as respostas anteriores desse método e as compara com respostas subsequentes para determinar as ações apropriadas.

Kotlin

abstract fun onGetMediaCollectionInfo(extras: Bundle): Bundle

Java

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

O pacote MediaCollectionInfo retornado inclui as seguintes constantes:

onQueryMedia

O método onQueryMedia() é usado para preencher a grade de fotos principal no seletor de fotos em várias visualizações. Essas chamadas podem ser sensíveis à latência e podem ser chamadas como parte de uma sincronização proativa em segundo plano ou durante sessões do seletor de fotos quando um estado de sincronização completo ou incremental é necessário. A interface do usuário do seletor de fotos não espera indefinidamente uma resposta para exibir resultados e pode expirar essas solicitações para fins da interface do usuário. O cursor retornado ainda tentará ser processado no banco de dados do seletor de fotos para sessões futuras.

Esse método retorna um Cursor que representa todos os itens de mídia na coleção de mídia, opcionalmente filtrado pelos extras fornecidos e classificados em ordem cronológica inversa de MediaColumns#DATE_TAKEN_MILLIS (os itens mais recentes primeiro).

O pacote CloudMediaProviderContract retornado inclui as seguintes constantes:

O provedor de mídia em nuvem precisa definir CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID como parte da Bundle retornada. Não definir isso é um erro e invalida o Cursor retornado. Se o provedor de mídia em nuvem processou algum filtro nos extras fornecidos, ele precisa adicionar a chave ao ContentResolver#EXTRA_HONORED_ARGS como parte da Cursor#setExtras retornada.

onQuerydeletedMedia

O método onQueryDeletedMedia() é usado para garantir que os itens excluídos na conta da nuvem sejam removidos corretamente da interface do usuário do seletor de fotos. Devido à potencial sensibilidade à latência, essas chamadas podem ser iniciadas como parte de:

  • Sincronização proativa em segundo plano
  • Sessões do seletor de fotos (quando um estado de sincronização completo ou incremental é necessário)

A interface do usuário do seletor de fotos prioriza uma experiência do usuário responsiva e não espera indefinidamente por uma resposta. Para manter interações suaves, tempos limite podem ocorrer. As Cursor retornadas ainda tentarão ser processadas no banco de dados do seletor de fotos para sessões futuras.

Esse método retorna um Cursor que representa todos os itens de mídia excluídos em toda a coleção de mídia dentro da versão atual do provedor, conforme retornado por onGetMediaCollectionInfo(). Esses itens podem ser filtrados por extras. O provedor de mídia em nuvem precisa definir o CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID como parte da mensagem Cursor#setExtras retornada. Não definir isso é um erro e invalida o Cursor. Se o provedor processou algum filtro nos extras fornecidos, ele precisa adicionar a chave ao ContentResolver#EXTRA_HONORED_ARGS.

onQueryAlbums

O método onQueryAlbums() é usado para buscar uma lista de álbuns do Cloud disponíveis no provedor de nuvem e os metadados associados a eles. Consulte CloudMediaProviderContract.AlbumColumns para mais detalhes.

Esse método retorna um Cursor que representa todos os itens de álbum na coleção de mídia, opcionalmente filtrado pelos extras fornecidos e classificados em ordem cronológica inversa de AlbumColumns#DATE_TAKEN_MILLIS, os itens mais recentes primeiro. O provedor de mídia em nuvem precisa definir o CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID como parte da Cursor retornada. Não definir isso é um erro e invalida o Cursor retornado. Se o provedor processou algum filtro nos extras fornecidos, ele precisa adicionar a chave ao ContentResolver#EXTRA_HONORED_ARGS como parte do Cursor retornado.

onOpenMedia

O método onOpenMedia() precisa retornar a mídia de tamanho original identificada pelo mediaId fornecido. Se esse método for bloqueado durante o download de conteúdo para o dispositivo, verifique periodicamente o CancellationSignal fornecido para cancelar solicitações abandonadas.

onOpenPreview

O método onOpenPreview() precisa retornar uma miniatura do size fornecido para o item do mediaId informado. A miniatura precisa estar no CloudMediaProviderContract.MediaColumns#MIME_TYPE original e precisa ter uma resolução muito menor do que o item retornado por onOpenMedia. Se esse método for bloqueado ao fazer o download de conteúdo no dispositivo, verifique periodicamente o CancellationSignal fornecido para cancelar solicitações abandonadas.

onCreateCloudMediaSurfaceController

O método onCreateCloudMediaSurfaceController() retornará um CloudMediaSurfaceController usado para renderizar a visualização de itens de mídia ou null se a renderização de visualização não tiver suporte.

O CloudMediaSurfaceController gerencia a renderização da visualização de itens de mídia em determinadas instâncias de Surface. Os métodos dessa classe devem ser assíncronos e não devem ser bloqueados com a realização de operações pesadas. Uma única instância de CloudMediaSurfaceController é responsável pela renderização de vários itens de mídia associados a várias plataformas.

O CloudMediaSurfaceController oferece suporte à lista de callbacks do ciclo de vida abaixo: