blob: 5c2bec811ddcd45a6153212168f765577bd18401 [file] [log] [blame]
/*
* Copyright (C) 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.photopicker.data
import android.content.ContentResolver
import android.content.Intent
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import androidx.core.os.bundleOf
import androidx.paging.PagingSource.LoadResult
import com.android.photopicker.data.model.Group
import com.android.photopicker.data.model.Media
import com.android.photopicker.data.model.MediaPageKey
import com.android.photopicker.data.model.MediaSource
import com.android.photopicker.data.model.Provider
import com.android.photopicker.extensions.getPhotopickerMimeTypes
/**
* A client class that is reponsible for holding logic required to interact with [MediaProvider].
*
* It typically fetches data from [MediaProvider] using content queries and call methods.
*/
open class MediaProviderClient {
companion object {
private const val TAG = "MediaProviderClient"
private const val MEDIA_INIT_CALL_METHOD: String = "picker_media_init"
private const val EXTRA_MIME_TYPES = "mime_types"
private const val EXTRA_INTENT_ACTION = "intent_action"
private const val EXTRA_LOCAL_ONLY = "is_local_only"
private const val EXTRA_ALBUM_ID = "album_id"
private const val EXTRA_ALBUM_AUTHORITY = "album_authority"
}
/** Contains all optional and mandatory keys required to make a Media query */
private enum class MediaQuery(val key: String) {
PICKER_ID("picker_id"),
DATE_TAKEN("date_taken_millis"),
PAGE_SIZE("page_size"),
PROVIDERS("providers"),
}
/**
* Contains all mandatory keys required to make an Album Media query that are not present in
* [MediaQuery] already.
*/
private enum class AlbumMediaQuery(val key: String) {
ALBUM_AUTHORITY("album_authority"),
}
/**
* Contains all optional and mandatory keys for data in the Available Providers query response.
*/
enum class AvailableProviderResponse(val key: String) {
AUTHORITY("authority"),
MEDIA_SOURCE("media_source"),
UID("uid"),
}
/** Contains all optional and mandatory keys for data in the Media query response. */
enum class MediaResponse(val key: String) {
MEDIA_ID("id"),
PICKER_ID("picker_id"),
AUTHORITY("authority"),
MEDIA_SOURCE("media_source"),
MEDIA_URI("wrapped_uri"),
LOADABLE_URI("unwrapped_uri"),
DATE_TAKEN("date_taken_millis"),
SIZE("size_bytes"),
MIME_TYPE("mime_type"),
STANDARD_MIME_TYPE_EXT("standard_mime_type_extension"),
DURATION("duration_millis"),
}
/** Contains all optional and mandatory keys for data in the Media query response extras. */
enum class MediaResponseExtras(val key: String) {
PREV_PAGE_ID("prev_page_picker_id"),
PREV_PAGE_DATE_TAKEN("prev_page_date_taken"),
NEXT_PAGE_ID("next_page_picker_id"),
NEXT_PAGE_DATE_TAKEN("next_page_date_taken"),
}
/** Contains all optional and mandatory keys for data in the Media query response. */
enum class AlbumResponse(val key: String) {
ALBUM_ID("id"),
PICKER_ID("picker_id"),
AUTHORITY("authority"),
DATE_TAKEN("date_taken_millis"),
ALBUM_NAME("display_name"),
UNWRAPPED_COVER_URI("unwrapped_cover_uri"),
COVER_MEDIA_SOURCE("media_source")
}
/** Fetch available [Provider]-s from the Media Provider process. */
fun fetchAvailableProviders(
contentResolver: ContentResolver,
): List<Provider> {
try {
contentResolver
.query(
AVAILABLE_PROVIDERS_URI,
/* projection */ null,
/* queryArgs */ null,
/* cancellationSignal */ null // TODO
)
.use { cursor ->
return getListOfProviders(cursor!!)
}
} catch (e: RuntimeException) {
throw RuntimeException("Could not fetch available providers", e)
}
}
/** Fetch a list of [Media] from MediaProvider for the given page key. */
fun fetchMedia(
pageKey: MediaPageKey,
pageSize: Int,
contentResolver: ContentResolver,
availableProviders: List<Provider>,
intent: Intent?,
): LoadResult<MediaPageKey, Media> {
val input: Bundle =
bundleOf(
MediaQuery.PICKER_ID.key to pageKey.pickerId,
MediaQuery.DATE_TAKEN.key to pageKey.dateTakenMillis,
MediaQuery.PAGE_SIZE.key to pageSize,
MediaQuery.PROVIDERS.key to
ArrayList<String>().apply {
availableProviders.forEach { provider -> add(provider.authority) }
},
EXTRA_MIME_TYPES to intent?.getPhotopickerMimeTypes(),
EXTRA_INTENT_ACTION to intent?.action
)
try {
return contentResolver
.query(
MEDIA_URI,
/* projection */ null,
input,
/* cancellationSignal */ null // TODO
)
.use { cursor ->
cursor?.let {
LoadResult.Page(
data = cursor.getListOfMedia(),
prevKey = cursor.getPrevPageKey(),
nextKey = cursor.getNextPageKey()
)
}
?: throw IllegalStateException(
"Received a null response from Content Provider"
)
}
} catch (e: RuntimeException) {
throw RuntimeException("Could not fetch media", e)
}
}
/** Fetch a list of [Group.Album] from MediaProvider for the given page key. */
fun fetchAlbums(
pageKey: MediaPageKey,
pageSize: Int,
contentResolver: ContentResolver,
availableProviders: List<Provider>,
intent: Intent?
): LoadResult<MediaPageKey, Group.Album> {
val input: Bundle =
bundleOf(
MediaQuery.PICKER_ID.key to pageKey.pickerId,
MediaQuery.DATE_TAKEN.key to pageKey.dateTakenMillis,
MediaQuery.PAGE_SIZE.key to pageSize,
MediaQuery.PROVIDERS.key to
ArrayList<String>().apply {
availableProviders.forEach { provider -> add(provider.authority) }
},
EXTRA_MIME_TYPES to intent?.getPhotopickerMimeTypes(),
EXTRA_INTENT_ACTION to intent?.action
)
try {
return contentResolver
.query(
ALBUM_URI,
/* projection */ null,
input,
/* cancellationSignal */ null // TODO
)
.use { cursor ->
cursor?.let {
LoadResult.Page(
data = cursor.getListOfAlbums(),
prevKey = cursor.getPrevPageKey(),
nextKey = cursor.getNextPageKey()
)
}
?: throw IllegalStateException(
"Received a null response from Content Provider"
)
}
} catch (e: RuntimeException) {
throw RuntimeException("Could not fetch albums", e)
}
}
/** Fetch a list of [Media] from MediaProvider for the given page key. */
fun fetchAlbumMedia(
albumId: String,
albumAuthority: String,
pageKey: MediaPageKey,
pageSize: Int,
contentResolver: ContentResolver,
availableProviders: List<Provider>,
intent: Intent?
): LoadResult<MediaPageKey, Media> {
val input: Bundle =
bundleOf(
AlbumMediaQuery.ALBUM_AUTHORITY.key to albumAuthority,
MediaQuery.PICKER_ID.key to pageKey.pickerId,
MediaQuery.DATE_TAKEN.key to pageKey.dateTakenMillis,
MediaQuery.PAGE_SIZE.key to pageSize,
MediaQuery.PROVIDERS.key to
ArrayList<String>().apply {
availableProviders.forEach { provider -> add(provider.authority) }
},
EXTRA_MIME_TYPES to intent?.getPhotopickerMimeTypes(),
EXTRA_INTENT_ACTION to intent?.action
)
try {
return contentResolver
.query(
getAlbumMediaUri(albumId),
/* projection */ null,
input,
/* cancellationSignal */ null // TODO
)
.use { cursor ->
cursor?.let {
LoadResult.Page(
data = cursor.getListOfMedia(),
prevKey = cursor.getPrevPageKey(),
nextKey = cursor.getNextPageKey()
)
}
?: throw IllegalStateException(
"Received a null response from Content Provider"
)
}
} catch (e: RuntimeException) {
throw RuntimeException("Could not fetch album media", e)
}
}
/**
* Send a refresh media request to MediaProvider. This is a signal for MediaProvider to refresh
* its cache, if required.
*/
fun refreshMedia(
@Suppress("UNUSED_PARAMETER") providers: List<Provider>,
resolver: ContentResolver,
intent: Intent?
) {
val extras = Bundle()
// TODO(b/340246010): Currently, we trigger sync for all providers. This is because
// the UI is responsible for triggering syncs which is sometimes required to enable
// providers. This should be changed to triggering syncs for specific providers once the
// backend takes responsibility for the sync triggers.
val initLocalOnlyMedia = false
extras.putBoolean(EXTRA_LOCAL_ONLY, initLocalOnlyMedia)
extras.putStringArrayList(EXTRA_MIME_TYPES, intent?.getPhotopickerMimeTypes())
extras.putString(EXTRA_INTENT_ACTION, intent?.action)
refreshMedia(extras, resolver)
}
/**
* Send a refresh album media request to MediaProvider. This is a signal for MediaProvider to
* refresh its cache for the given album media, if required.
*/
fun refreshAlbumMedia(
albumId: String,
albumAuthority: String,
providers: List<Provider>,
resolver: ContentResolver,
intent: Intent?
) {
val extras = Bundle()
val initLocalOnlyMedia: Boolean =
providers.all { provider -> (provider.mediaSource == MediaSource.LOCAL) }
extras.putBoolean(EXTRA_LOCAL_ONLY, initLocalOnlyMedia)
extras.putStringArrayList(EXTRA_MIME_TYPES, intent?.getPhotopickerMimeTypes())
extras.putString(EXTRA_INTENT_ACTION, intent?.action)
extras.putString(EXTRA_ALBUM_ID, albumId)
extras.putString(EXTRA_ALBUM_AUTHORITY, albumAuthority)
refreshMedia(extras, resolver)
}
/** Creates a list of [Provider] from the given [Cursor]. */
private fun getListOfProviders(cursor: Cursor): List<Provider> {
val result: MutableList<Provider> = mutableListOf<Provider>()
if (cursor.moveToFirst()) {
do {
result.add(
Provider(
authority =
cursor.getString(
cursor.getColumnIndexOrThrow(
AvailableProviderResponse.AUTHORITY.key
)
),
mediaSource =
MediaSource.valueOf(
cursor.getString(
cursor.getColumnIndexOrThrow(
AvailableProviderResponse.MEDIA_SOURCE.key
)
)
),
uid =
cursor.getInt(
cursor.getColumnIndexOrThrow(AvailableProviderResponse.UID.key)
),
)
)
} while (cursor.moveToNext())
}
return result
}
/**
* Creates a list of [Media] from the given [Cursor].
*
* [Media] can be of type [Media.Image] or [Media.Video].
*/
private fun Cursor.getListOfMedia(): List<Media> {
val result: MutableList<Media> = mutableListOf<Media>()
if (this.moveToFirst()) {
do {
val mediaId: String = getString(getColumnIndexOrThrow(MediaResponse.MEDIA_ID.key))
val pickerId: Long = getLong(getColumnIndexOrThrow(MediaResponse.PICKER_ID.key))
val authority: String =
getString(getColumnIndexOrThrow(MediaResponse.AUTHORITY.key))
val mediaSource: MediaSource =
MediaSource.valueOf(
getString(getColumnIndexOrThrow(MediaResponse.MEDIA_SOURCE.key))
)
val mediaUri: Uri =
Uri.parse(getString(getColumnIndexOrThrow(MediaResponse.MEDIA_URI.key)))
val loadableUri: Uri =
Uri.parse(getString(getColumnIndexOrThrow(MediaResponse.LOADABLE_URI.key)))
val dateTakenMillisLong: Long =
getLong(getColumnIndexOrThrow(MediaResponse.DATE_TAKEN.key))
val sizeInBytes: Long = getLong(getColumnIndexOrThrow(MediaResponse.SIZE.key))
val mimeType: String = getString(getColumnIndexOrThrow(MediaResponse.MIME_TYPE.key))
val standardMimeTypeExtension: Int =
getInt(getColumnIndexOrThrow(MediaResponse.STANDARD_MIME_TYPE_EXT.key))
if (mimeType.startsWith("image/")) {
result.add(
Media.Image(
mediaId = mediaId,
pickerId = pickerId,
authority = authority,
mediaSource = mediaSource,
mediaUri = mediaUri,
glideLoadableUri = loadableUri,
dateTakenMillisLong = dateTakenMillisLong,
sizeInBytes = sizeInBytes,
mimeType = mimeType,
standardMimeTypeExtension = standardMimeTypeExtension,
)
)
} else if (mimeType.startsWith("video/")) {
result.add(
Media.Video(
mediaId = mediaId,
pickerId = pickerId,
authority = authority,
mediaSource = mediaSource,
mediaUri = mediaUri,
glideLoadableUri = loadableUri,
dateTakenMillisLong = dateTakenMillisLong,
sizeInBytes = sizeInBytes,
mimeType = mimeType,
standardMimeTypeExtension = standardMimeTypeExtension,
duration = getInt(getColumnIndexOrThrow(MediaResponse.DURATION.key)),
)
)
} else {
throw UnsupportedOperationException("Could not recognize mime type $mimeType")
}
} while (moveToNext())
}
return result
}
/**
* Extracts the previous page key from the given [Cursor]. In case the cursor contains the
* contents of the first page, the previous page key will be null.
*/
private fun Cursor.getPrevPageKey(): MediaPageKey? {
val id: Long = extras.getLong(MediaResponseExtras.PREV_PAGE_ID.key, Long.MIN_VALUE)
val date: Long =
extras.getLong(MediaResponseExtras.PREV_PAGE_DATE_TAKEN.key, Long.MIN_VALUE)
return if (date == Long.MIN_VALUE) {
null
} else {
MediaPageKey(pickerId = id, dateTakenMillis = date)
}
}
/**
* Extracts the next page key from the given [Cursor]. In case the cursor contains the contents
* of the last page, the next page key will be null.
*/
private fun Cursor.getNextPageKey(): MediaPageKey? {
val id: Long = extras.getLong(MediaResponseExtras.NEXT_PAGE_ID.key, Long.MIN_VALUE)
val date: Long =
extras.getLong(MediaResponseExtras.NEXT_PAGE_DATE_TAKEN.key, Long.MIN_VALUE)
return if (date == Long.MIN_VALUE) {
null
} else {
MediaPageKey(pickerId = id, dateTakenMillis = date)
}
}
/** Creates a list of [Group.Album]-s from the given [Cursor]. */
private fun Cursor.getListOfAlbums(): List<Group.Album> {
val result: MutableList<Group.Album> = mutableListOf<Group.Album>()
if (this.moveToFirst()) {
do {
val albumId = getString(getColumnIndexOrThrow(AlbumResponse.ALBUM_ID.key))
result.add(
Group.Album(
id = albumId,
// This is a temporary solution till we cache album data in Picker DB
pickerId = albumId.hashCode().toLong(),
authority = getString(getColumnIndexOrThrow(AlbumResponse.AUTHORITY.key)),
dateTakenMillisLong =
getLong(getColumnIndexOrThrow(AlbumResponse.DATE_TAKEN.key)),
displayName =
getString(getColumnIndexOrThrow(AlbumResponse.ALBUM_NAME.key)),
coverUri =
Uri.parse(
getString(
getColumnIndexOrThrow(AlbumResponse.UNWRAPPED_COVER_URI.key)
)
),
coverMediaSource =
MediaSource.valueOf(
getString(
getColumnIndexOrThrow(AlbumResponse.COVER_MEDIA_SOURCE.key)
)
)
)
)
} while (moveToNext())
}
return result
}
/**
* Send a refresh [Media] request to MediaProvider with the prepared input args. This is a
* signal for MediaProvider to refresh its cache, if required.
*/
private fun refreshMedia(extras: Bundle, contentResolver: ContentResolver) {
try {
contentResolver.call(
MEDIA_PROVIDER_AUTHORITY,
MEDIA_INIT_CALL_METHOD,
/* arg */ null,
extras
)
} catch (e: RuntimeException) {
throw RuntimeException("Could not send refresh media call to Media Provider $extras", e)
}
}
}