Android용 클라우드 미디어 제공자 만들기

클라우드 미디어 제공업체는 Android 사진 선택 도구에 클라우드 미디어 콘텐츠를 추가로 제공합니다. 사용자는 앱이 ACTION_PICK_IMAGES 또는 ACTION_GET_CONTENT를 사용하여 사용자에게 미디어 파일을 요청할 때 클라우드 미디어 제공자가 제공하는 사진이나 동영상을 선택할 수 있습니다. 클라우드 미디어 제공업체는 Android 사진 선택 도구에서 찾아볼 수 있는 앨범에 관한 정보도 제공할 수 있습니다.

시작하기 전에

클라우드 미디어 제공자 빌드를 시작하기 전에 다음 사항을 고려하세요.

자격 요건

Android에서는 OEM 후보 앱이 클라우드 미디어 제공업체가 될 수 있도록 파일럿 프로그램을 실행하고 있습니다. 현재 OEM에서 지명한 앱만 이 프로그램에 참여하여 Android용 클라우드 미디어 제공업체가 될 수 있습니다. 각 OEM은 앱을 최대 3개까지 추천할 수 있습니다. 승인된 앱은 앱이 설치된 모든 GMS Android 지원 기기에서 클라우드 미디어 제공업체로 액세스할 수 있습니다.

Android는 적격한 모든 클라우드 제공업체의 서버 측 목록을 유지합니다. 각 OEM은 구성 가능한 오버레이를 사용하여 기본 클라우드 제공업체를 선택할 수 있습니다. 후보로 선정된 앱은 모든 기술 요구사항을 충족하고 모든 품질 테스트를 통과해야 합니다. OEM 클라우드 미디어 제공업체 파일럿 프로그램의 프로세스 및 요구사항에 관해 자세히 알아보려면 문의 양식을 작성하세요.

클라우드 미디어 제공업체를 만들어야 하는지 결정

클라우드 미디어 제공업체는 클라우드에서 사진과 동영상을 백업하고 검색하기 위한 사용자의 기본 소스 역할을 하는 앱 또는 서비스입니다. 앱에 유용한 콘텐츠 라이브러리가 있지만 일반적으로 사진 저장소 솔루션으로 사용되지 않는다면 대신 문서 제공자를 만드는 것이 좋습니다.

프로필당 활성 클라우드 제공업체 1개

Android 프로필에는 한 번에 최대 하나의 활성 클라우드 미디어 제공업체가 있을 수 있습니다. 사용자는 언제든지 사진 선택 도구 설정에서 선택한 클라우드 미디어 제공업체 앱을 삭제하거나 변경할 수 있습니다.

기본적으로 Android 사진 선택 도구는 클라우드 제공업체를 자동으로 선택하려고 시도합니다.

  • 기기에 요건을 충족하는 클라우드 제공업체가 하나만 있는 경우 해당 앱이 현재 제공업체로 자동 선택됩니다.
  • 기기에 운영 가능한 클라우드 제공업체가 두 개 이상 있고 그중 하나가 OEM에서 선택한 기본값과 일치하는 경우 OEM에서 선택한 앱이 선택됩니다.

  • 기기에 운영 가능한 클라우드 제공업체가 두 개 이상 있고 OEM에서 선택한 기본값과 일치하지 않는 경우 앱이 선택되지 않습니다.

클라우드 미디어 제공업체 빌드

다음 다이어그램은 Android 앱, Android 사진 선택 도구, 로컬 기기의 MediaProvider, CloudMediaProvider 간의 사진 선택 세션 전후의 이벤트 시퀀스를 보여줍니다.

사진 선택 도구에서 클라우드 미디어 제공업체로의 흐름을 보여주는 시퀀스 다이어그램
그림 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.ContentProvider에서 파생된 CloudMediaProvider 클래스에는 다음 예와 같은 메서드가 포함됩니다.

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 클래스

Android 사진 선택 도구에는 기본 CloudMediaProvider 구현 클래스 외에도 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 번들에는 다음 상수가 포함됩니다.

클라우드 미디어 제공자는 반환된 Bundle의 일부로 CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID를 설정해야 합니다. 이를 설정하지 않으면 오류가 발생하며 반환된 Cursor가 무효화됩니다. 클라우드 미디어 제공업체가 제공된 추가 항목의 필터를 처리했다면 반환된 Cursor#setExtras의 일부로 키를 ContentResolver#EXTRA_HONORED_ARGS에 추가해야 합니다.

onQuerydeletedMedia

onQueryDeletedMedia() 메서드는 클라우드 계정의 삭제된 항목이 사진 선택 도구 사용자 인터페이스에서 올바르게 삭제되도록 하는 데 사용됩니다. 지연 시간 민감도로 인해 이러한 호출은 다음의 일부로 시작될 수 있습니다.

  • 백그라운드 사전 동기화
  • 사진 선택 도구 세션 (전체 또는 증분 동기화 상태가 필요한 경우)

사진 선택 도구의 사용자 인터페이스는 반응형 사용자 환경을 우선시하며 응답을 무기한 기다리지 않습니다. 원활한 상호작용을 유지하기 위해 제한 시간이 발생할 수 있습니다. 반환된 모든 Cursor는 향후 세션을 위해 여전히 사진 선택 도구의 데이터베이스로 처리하려고 시도합니다.

이 메서드는 onGetMediaCollectionInfo()에서 반환한 현재 제공자 버전 내 전체 미디어 컬렉션에 있는 모든 삭제된 미디어 항목을 나타내는 Cursor를 반환합니다. 이러한 항목은 선택적으로 추가 항목으로 필터링할 수 있습니다. 클라우드 미디어 제공자는 반환된 Cursor#setExtras의 일부로 CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID를 설정해야 합니다. 설정하지 않으면 오류가 발생하며 Cursor를 무효화합니다. 제공업체가 제공된 추가 항목의 필터를 처리했다면 키를 ContentResolver#EXTRA_HONORED_ARGS에 추가해야 합니다.

onQuery앨범

onQueryAlbums() 메서드는 클라우드 제공업체에서 사용할 수 있는 클라우드 앨범 목록과 관련 메타데이터를 가져오는 데 사용됩니다. 자세한 내용은 CloudMediaProviderContract.AlbumColumns를 참고하세요.

이 메서드는 미디어 컬렉션의 모든 앨범 항목을 나타내는 Cursor를 반환하며 , 선택적으로 제공된 추가 항목으로 필터링되고 AlbumColumns#DATE_TAKEN_MILLIS의 역순으로 정렬됩니다. 가장 최근 항목이 먼저 표시됩니다. 클라우드 미디어 제공자는 CloudMediaProviderContract#EXTRA_MEDIA_COLLECTION_ID를 반환된 Cursor의 일부로 설정해야 합니다. 이를 설정하지 않으면 오류가 발생하며 반환된 Cursor가 무효화됩니다. 제공자가 제공된 추가 항목의 필터를 처리하면 반환된 Cursor의 일부로 키를 ContentResolver#EXTRA_HONORED_ARGS에 추가해야 합니다.

onOpenMedia

onOpenMedia() 메서드는 제공된 mediaId로 식별된 전체 크기의 미디어를 반환해야 합니다. 기기에 콘텐츠를 다운로드하는 동안 이 메서드가 차단되면 제공된 CancellationSignal를 주기적으로 확인하여 포기된 요청을 취소해야 합니다.

onOpenPreview

onOpenPreview() 메서드는 제공된 mediaId의 항목에 대해 제공된 size의 썸네일을 반환해야 합니다. 썸네일은 원본 CloudMediaProviderContract.MediaColumns#MIME_TYPE에 있어야 하며 onOpenMedia에서 반환된 항목보다 해상도가 훨씬 낮을 것으로 예상됩니다. 기기에 콘텐츠를 다운로드하는 동안 이 메서드가 차단되면 제공된 CancellationSignal를 주기적으로 확인하여 포기된 요청을 취소해야 합니다.

onCreateCloudMediaSurfaceController

onCreateCloudMediaSurfaceController() 메서드는 미디어 항목의 미리보기를 렌더링하는 데 사용되는 CloudMediaSurfaceController 또는 미리보기 렌더링이 지원되지 않는 경우 null를 반환해야 합니다.

CloudMediaSurfaceController는 지정된 Surface 인스턴스에서 미디어 항목의 미리보기 렌더링을 관리합니다. 이 클래스의 메서드는 비동기식을 사용해야 하며 과도한 작업을 실행하여 차단해서는 안 됩니다. 단일 CloudMediaSurfaceController 인스턴스는 여러 노출 영역에 연결된 여러 미디어 항목을 렌더링합니다.

CloudMediaSurfaceController는 다음과 같은 수명 주기 콜백을 지원합니다.