1 /* <lambda>null2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.photopicker.data 18 19 import android.content.ContentResolver 20 import android.content.Intent 21 import android.database.Cursor 22 import android.net.Uri 23 import android.os.Bundle 24 import androidx.core.os.bundleOf 25 import androidx.paging.PagingSource.LoadResult 26 import com.android.photopicker.data.model.Group 27 import com.android.photopicker.data.model.Media 28 import com.android.photopicker.data.model.MediaPageKey 29 import com.android.photopicker.data.model.MediaSource 30 import com.android.photopicker.data.model.Provider 31 import com.android.photopicker.extensions.getPhotopickerMimeTypes 32 33 /** 34 * A client class that is reponsible for holding logic required to interact with [MediaProvider]. 35 * 36 * It typically fetches data from [MediaProvider] using content queries and call methods. 37 */ 38 open class MediaProviderClient { 39 companion object { 40 private const val TAG = "MediaProviderClient" 41 private const val MEDIA_INIT_CALL_METHOD: String = "picker_media_init" 42 private const val EXTRA_MIME_TYPES = "mime_types" 43 private const val EXTRA_INTENT_ACTION = "intent_action" 44 private const val EXTRA_LOCAL_ONLY = "is_local_only" 45 private const val EXTRA_ALBUM_ID = "album_id" 46 private const val EXTRA_ALBUM_AUTHORITY = "album_authority" 47 } 48 49 /** Contains all optional and mandatory keys required to make a Media query */ 50 private enum class MediaQuery(val key: String) { 51 PICKER_ID("picker_id"), 52 DATE_TAKEN("date_taken_millis"), 53 PAGE_SIZE("page_size"), 54 PROVIDERS("providers"), 55 } 56 57 /** 58 * Contains all mandatory keys required to make an Album Media query that are not present in 59 * [MediaQuery] already. 60 */ 61 private enum class AlbumMediaQuery(val key: String) { 62 ALBUM_AUTHORITY("album_authority"), 63 } 64 65 /** 66 * Contains all optional and mandatory keys for data in the Available Providers query response. 67 */ 68 enum class AvailableProviderResponse(val key: String) { 69 AUTHORITY("authority"), 70 MEDIA_SOURCE("media_source"), 71 UID("uid"), 72 } 73 74 /** Contains all optional and mandatory keys for data in the Media query response. */ 75 enum class MediaResponse(val key: String) { 76 MEDIA_ID("id"), 77 PICKER_ID("picker_id"), 78 AUTHORITY("authority"), 79 MEDIA_SOURCE("media_source"), 80 MEDIA_URI("wrapped_uri"), 81 LOADABLE_URI("unwrapped_uri"), 82 DATE_TAKEN("date_taken_millis"), 83 SIZE("size_bytes"), 84 MIME_TYPE("mime_type"), 85 STANDARD_MIME_TYPE_EXT("standard_mime_type_extension"), 86 DURATION("duration_millis"), 87 } 88 89 /** Contains all optional and mandatory keys for data in the Media query response extras. */ 90 enum class MediaResponseExtras(val key: String) { 91 PREV_PAGE_ID("prev_page_picker_id"), 92 PREV_PAGE_DATE_TAKEN("prev_page_date_taken"), 93 NEXT_PAGE_ID("next_page_picker_id"), 94 NEXT_PAGE_DATE_TAKEN("next_page_date_taken"), 95 } 96 97 /** Contains all optional and mandatory keys for data in the Media query response. */ 98 enum class AlbumResponse(val key: String) { 99 ALBUM_ID("id"), 100 PICKER_ID("picker_id"), 101 AUTHORITY("authority"), 102 DATE_TAKEN("date_taken_millis"), 103 ALBUM_NAME("display_name"), 104 UNWRAPPED_COVER_URI("unwrapped_cover_uri"), 105 COVER_MEDIA_SOURCE("media_source") 106 } 107 108 /** Fetch available [Provider]-s from the Media Provider process. */ 109 fun fetchAvailableProviders( 110 contentResolver: ContentResolver, 111 ): List<Provider> { 112 try { 113 contentResolver 114 .query( 115 AVAILABLE_PROVIDERS_URI, 116 /* projection */ null, 117 /* queryArgs */ null, 118 /* cancellationSignal */ null // TODO 119 ) 120 .use { cursor -> 121 return getListOfProviders(cursor!!) 122 } 123 } catch (e: RuntimeException) { 124 throw RuntimeException("Could not fetch available providers", e) 125 } 126 } 127 128 /** Fetch a list of [Media] from MediaProvider for the given page key. */ 129 fun fetchMedia( 130 pageKey: MediaPageKey, 131 pageSize: Int, 132 contentResolver: ContentResolver, 133 availableProviders: List<Provider>, 134 intent: Intent?, 135 ): LoadResult<MediaPageKey, Media> { 136 val input: Bundle = 137 bundleOf( 138 MediaQuery.PICKER_ID.key to pageKey.pickerId, 139 MediaQuery.DATE_TAKEN.key to pageKey.dateTakenMillis, 140 MediaQuery.PAGE_SIZE.key to pageSize, 141 MediaQuery.PROVIDERS.key to 142 ArrayList<String>().apply { 143 availableProviders.forEach { provider -> add(provider.authority) } 144 }, 145 EXTRA_MIME_TYPES to intent?.getPhotopickerMimeTypes(), 146 EXTRA_INTENT_ACTION to intent?.action 147 ) 148 149 try { 150 return contentResolver 151 .query( 152 MEDIA_URI, 153 /* projection */ null, 154 input, 155 /* cancellationSignal */ null // TODO 156 ) 157 .use { cursor -> 158 cursor?.let { 159 LoadResult.Page( 160 data = cursor.getListOfMedia(), 161 prevKey = cursor.getPrevPageKey(), 162 nextKey = cursor.getNextPageKey() 163 ) 164 } 165 ?: throw IllegalStateException( 166 "Received a null response from Content Provider" 167 ) 168 } 169 } catch (e: RuntimeException) { 170 throw RuntimeException("Could not fetch media", e) 171 } 172 } 173 174 /** Fetch a list of [Group.Album] from MediaProvider for the given page key. */ 175 fun fetchAlbums( 176 pageKey: MediaPageKey, 177 pageSize: Int, 178 contentResolver: ContentResolver, 179 availableProviders: List<Provider>, 180 intent: Intent? 181 ): LoadResult<MediaPageKey, Group.Album> { 182 val input: Bundle = 183 bundleOf( 184 MediaQuery.PICKER_ID.key to pageKey.pickerId, 185 MediaQuery.DATE_TAKEN.key to pageKey.dateTakenMillis, 186 MediaQuery.PAGE_SIZE.key to pageSize, 187 MediaQuery.PROVIDERS.key to 188 ArrayList<String>().apply { 189 availableProviders.forEach { provider -> add(provider.authority) } 190 }, 191 EXTRA_MIME_TYPES to intent?.getPhotopickerMimeTypes(), 192 EXTRA_INTENT_ACTION to intent?.action 193 ) 194 195 try { 196 return contentResolver 197 .query( 198 ALBUM_URI, 199 /* projection */ null, 200 input, 201 /* cancellationSignal */ null // TODO 202 ) 203 .use { cursor -> 204 cursor?.let { 205 LoadResult.Page( 206 data = cursor.getListOfAlbums(), 207 prevKey = cursor.getPrevPageKey(), 208 nextKey = cursor.getNextPageKey() 209 ) 210 } 211 ?: throw IllegalStateException( 212 "Received a null response from Content Provider" 213 ) 214 } 215 } catch (e: RuntimeException) { 216 throw RuntimeException("Could not fetch albums", e) 217 } 218 } 219 220 /** Fetch a list of [Media] from MediaProvider for the given page key. */ 221 fun fetchAlbumMedia( 222 albumId: String, 223 albumAuthority: String, 224 pageKey: MediaPageKey, 225 pageSize: Int, 226 contentResolver: ContentResolver, 227 availableProviders: List<Provider>, 228 intent: Intent? 229 ): LoadResult<MediaPageKey, Media> { 230 val input: Bundle = 231 bundleOf( 232 AlbumMediaQuery.ALBUM_AUTHORITY.key to albumAuthority, 233 MediaQuery.PICKER_ID.key to pageKey.pickerId, 234 MediaQuery.DATE_TAKEN.key to pageKey.dateTakenMillis, 235 MediaQuery.PAGE_SIZE.key to pageSize, 236 MediaQuery.PROVIDERS.key to 237 ArrayList<String>().apply { 238 availableProviders.forEach { provider -> add(provider.authority) } 239 }, 240 EXTRA_MIME_TYPES to intent?.getPhotopickerMimeTypes(), 241 EXTRA_INTENT_ACTION to intent?.action 242 ) 243 244 try { 245 return contentResolver 246 .query( 247 getAlbumMediaUri(albumId), 248 /* projection */ null, 249 input, 250 /* cancellationSignal */ null // TODO 251 ) 252 .use { cursor -> 253 cursor?.let { 254 LoadResult.Page( 255 data = cursor.getListOfMedia(), 256 prevKey = cursor.getPrevPageKey(), 257 nextKey = cursor.getNextPageKey() 258 ) 259 } 260 ?: throw IllegalStateException( 261 "Received a null response from Content Provider" 262 ) 263 } 264 } catch (e: RuntimeException) { 265 throw RuntimeException("Could not fetch album media", e) 266 } 267 } 268 269 /** 270 * Send a refresh media request to MediaProvider. This is a signal for MediaProvider to refresh 271 * its cache, if required. 272 */ 273 fun refreshMedia( 274 @Suppress("UNUSED_PARAMETER") providers: List<Provider>, 275 resolver: ContentResolver, 276 intent: Intent? 277 ) { 278 val extras = Bundle() 279 280 // TODO(b/340246010): Currently, we trigger sync for all providers. This is because 281 // the UI is responsible for triggering syncs which is sometimes required to enable 282 // providers. This should be changed to triggering syncs for specific providers once the 283 // backend takes responsibility for the sync triggers. 284 val initLocalOnlyMedia = false 285 286 extras.putBoolean(EXTRA_LOCAL_ONLY, initLocalOnlyMedia) 287 extras.putStringArrayList(EXTRA_MIME_TYPES, intent?.getPhotopickerMimeTypes()) 288 extras.putString(EXTRA_INTENT_ACTION, intent?.action) 289 refreshMedia(extras, resolver) 290 } 291 292 /** 293 * Send a refresh album media request to MediaProvider. This is a signal for MediaProvider to 294 * refresh its cache for the given album media, if required. 295 */ 296 fun refreshAlbumMedia( 297 albumId: String, 298 albumAuthority: String, 299 providers: List<Provider>, 300 resolver: ContentResolver, 301 intent: Intent? 302 ) { 303 val extras = Bundle() 304 val initLocalOnlyMedia: Boolean = 305 providers.all { provider -> (provider.mediaSource == MediaSource.LOCAL) } 306 extras.putBoolean(EXTRA_LOCAL_ONLY, initLocalOnlyMedia) 307 extras.putStringArrayList(EXTRA_MIME_TYPES, intent?.getPhotopickerMimeTypes()) 308 extras.putString(EXTRA_INTENT_ACTION, intent?.action) 309 extras.putString(EXTRA_ALBUM_ID, albumId) 310 extras.putString(EXTRA_ALBUM_AUTHORITY, albumAuthority) 311 refreshMedia(extras, resolver) 312 } 313 314 /** Creates a list of [Provider] from the given [Cursor]. */ 315 private fun getListOfProviders(cursor: Cursor): List<Provider> { 316 val result: MutableList<Provider> = mutableListOf<Provider>() 317 if (cursor.moveToFirst()) { 318 do { 319 result.add( 320 Provider( 321 authority = 322 cursor.getString( 323 cursor.getColumnIndexOrThrow( 324 AvailableProviderResponse.AUTHORITY.key 325 ) 326 ), 327 mediaSource = 328 MediaSource.valueOf( 329 cursor.getString( 330 cursor.getColumnIndexOrThrow( 331 AvailableProviderResponse.MEDIA_SOURCE.key 332 ) 333 ) 334 ), 335 uid = 336 cursor.getInt( 337 cursor.getColumnIndexOrThrow(AvailableProviderResponse.UID.key) 338 ), 339 ) 340 ) 341 } while (cursor.moveToNext()) 342 } 343 344 return result 345 } 346 347 /** 348 * Creates a list of [Media] from the given [Cursor]. 349 * 350 * [Media] can be of type [Media.Image] or [Media.Video]. 351 */ 352 private fun Cursor.getListOfMedia(): List<Media> { 353 val result: MutableList<Media> = mutableListOf<Media>() 354 if (this.moveToFirst()) { 355 do { 356 val mediaId: String = getString(getColumnIndexOrThrow(MediaResponse.MEDIA_ID.key)) 357 val pickerId: Long = getLong(getColumnIndexOrThrow(MediaResponse.PICKER_ID.key)) 358 val authority: String = 359 getString(getColumnIndexOrThrow(MediaResponse.AUTHORITY.key)) 360 val mediaSource: MediaSource = 361 MediaSource.valueOf( 362 getString(getColumnIndexOrThrow(MediaResponse.MEDIA_SOURCE.key)) 363 ) 364 val mediaUri: Uri = 365 Uri.parse(getString(getColumnIndexOrThrow(MediaResponse.MEDIA_URI.key))) 366 val loadableUri: Uri = 367 Uri.parse(getString(getColumnIndexOrThrow(MediaResponse.LOADABLE_URI.key))) 368 val dateTakenMillisLong: Long = 369 getLong(getColumnIndexOrThrow(MediaResponse.DATE_TAKEN.key)) 370 val sizeInBytes: Long = getLong(getColumnIndexOrThrow(MediaResponse.SIZE.key)) 371 val mimeType: String = getString(getColumnIndexOrThrow(MediaResponse.MIME_TYPE.key)) 372 val standardMimeTypeExtension: Int = 373 getInt(getColumnIndexOrThrow(MediaResponse.STANDARD_MIME_TYPE_EXT.key)) 374 375 if (mimeType.startsWith("image/")) { 376 result.add( 377 Media.Image( 378 mediaId = mediaId, 379 pickerId = pickerId, 380 authority = authority, 381 mediaSource = mediaSource, 382 mediaUri = mediaUri, 383 glideLoadableUri = loadableUri, 384 dateTakenMillisLong = dateTakenMillisLong, 385 sizeInBytes = sizeInBytes, 386 mimeType = mimeType, 387 standardMimeTypeExtension = standardMimeTypeExtension, 388 ) 389 ) 390 } else if (mimeType.startsWith("video/")) { 391 result.add( 392 Media.Video( 393 mediaId = mediaId, 394 pickerId = pickerId, 395 authority = authority, 396 mediaSource = mediaSource, 397 mediaUri = mediaUri, 398 glideLoadableUri = loadableUri, 399 dateTakenMillisLong = dateTakenMillisLong, 400 sizeInBytes = sizeInBytes, 401 mimeType = mimeType, 402 standardMimeTypeExtension = standardMimeTypeExtension, 403 duration = getInt(getColumnIndexOrThrow(MediaResponse.DURATION.key)), 404 ) 405 ) 406 } else { 407 throw UnsupportedOperationException("Could not recognize mime type $mimeType") 408 } 409 } while (moveToNext()) 410 } 411 412 return result 413 } 414 415 /** 416 * Extracts the previous page key from the given [Cursor]. In case the cursor contains the 417 * contents of the first page, the previous page key will be null. 418 */ 419 private fun Cursor.getPrevPageKey(): MediaPageKey? { 420 val id: Long = extras.getLong(MediaResponseExtras.PREV_PAGE_ID.key, Long.MIN_VALUE) 421 val date: Long = 422 extras.getLong(MediaResponseExtras.PREV_PAGE_DATE_TAKEN.key, Long.MIN_VALUE) 423 return if (date == Long.MIN_VALUE) { 424 null 425 } else { 426 MediaPageKey(pickerId = id, dateTakenMillis = date) 427 } 428 } 429 430 /** 431 * Extracts the next page key from the given [Cursor]. In case the cursor contains the contents 432 * of the last page, the next page key will be null. 433 */ 434 private fun Cursor.getNextPageKey(): MediaPageKey? { 435 val id: Long = extras.getLong(MediaResponseExtras.NEXT_PAGE_ID.key, Long.MIN_VALUE) 436 val date: Long = 437 extras.getLong(MediaResponseExtras.NEXT_PAGE_DATE_TAKEN.key, Long.MIN_VALUE) 438 return if (date == Long.MIN_VALUE) { 439 null 440 } else { 441 MediaPageKey(pickerId = id, dateTakenMillis = date) 442 } 443 } 444 445 /** Creates a list of [Group.Album]-s from the given [Cursor]. */ 446 private fun Cursor.getListOfAlbums(): List<Group.Album> { 447 val result: MutableList<Group.Album> = mutableListOf<Group.Album>() 448 449 if (this.moveToFirst()) { 450 do { 451 val albumId = getString(getColumnIndexOrThrow(AlbumResponse.ALBUM_ID.key)) 452 result.add( 453 Group.Album( 454 id = albumId, 455 // This is a temporary solution till we cache album data in Picker DB 456 pickerId = albumId.hashCode().toLong(), 457 authority = getString(getColumnIndexOrThrow(AlbumResponse.AUTHORITY.key)), 458 dateTakenMillisLong = 459 getLong(getColumnIndexOrThrow(AlbumResponse.DATE_TAKEN.key)), 460 displayName = 461 getString(getColumnIndexOrThrow(AlbumResponse.ALBUM_NAME.key)), 462 coverUri = 463 Uri.parse( 464 getString( 465 getColumnIndexOrThrow(AlbumResponse.UNWRAPPED_COVER_URI.key) 466 ) 467 ), 468 coverMediaSource = 469 MediaSource.valueOf( 470 getString( 471 getColumnIndexOrThrow(AlbumResponse.COVER_MEDIA_SOURCE.key) 472 ) 473 ) 474 ) 475 ) 476 } while (moveToNext()) 477 } 478 479 return result 480 } 481 482 /** 483 * Send a refresh [Media] request to MediaProvider with the prepared input args. This is a 484 * signal for MediaProvider to refresh its cache, if required. 485 */ 486 private fun refreshMedia(extras: Bundle, contentResolver: ContentResolver) { 487 try { 488 contentResolver.call( 489 MEDIA_PROVIDER_AUTHORITY, 490 MEDIA_INIT_CALL_METHOD, 491 /* arg */ null, 492 extras 493 ) 494 } catch (e: RuntimeException) { 495 throw RuntimeException("Could not send refresh media call to Media Provider $extras", e) 496 } 497 } 498 } 499