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 android.os.CancellationSignal 25 import android.util.Log 26 import androidx.core.os.bundleOf 27 import androidx.paging.PagingSource.LoadResult 28 import com.android.modules.utils.build.SdkLevel 29 import com.android.photopicker.core.configuration.PhotopickerConfiguration 30 import com.android.photopicker.data.model.CollectionInfo 31 import com.android.photopicker.data.model.Group 32 import com.android.photopicker.data.model.GroupPageKey 33 import com.android.photopicker.data.model.Icon 34 import com.android.photopicker.data.model.KeyToCategoryType 35 import com.android.photopicker.data.model.Media 36 import com.android.photopicker.data.model.MediaPageKey 37 import com.android.photopicker.data.model.MediaSource 38 import com.android.photopicker.data.model.Provider 39 import com.android.photopicker.features.search.model.KeyToSearchSuggestionType 40 import com.android.photopicker.features.search.model.SearchRequest 41 import com.android.photopicker.features.search.model.SearchSuggestion 42 import com.android.photopicker.features.search.model.SearchSuggestionType 43 44 /** 45 * A client class that is responsible for holding logic required to interact with [MediaProvider]. 46 * 47 * It typically fetches data from [MediaProvider] using content queries and call methods. 48 */ 49 open class MediaProviderClient { 50 companion object { 51 private const val TAG = "MediaProviderClient" 52 private const val MEDIA_SETS_INIT_CALL_METHOD: String = "picker_media_sets_init_call" 53 private const val MEDIA_SET_CONTENTS_INIT_CALL_METHOD: String = 54 "picker_media_in_media_set_init" 55 private const val EXTRA_MIME_TYPES = "mime_types" 56 private const val EXTRA_INTENT_ACTION = "intent_action" 57 private const val EXTRA_PROVIDERS = "providers" 58 private const val EXTRA_LOCAL_ONLY = "is_local_only" 59 private const val EXTRA_ALBUM_ID = "album_id" 60 private const val EXTRA_ALBUM_AUTHORITY = "album_authority" 61 private const val COLUMN_GRANTS_COUNT = "grants_count" 62 private const val PRE_SELECTION_URIS = "pre_selection_uris" 63 const val MEDIA_INIT_CALL_METHOD: String = "picker_media_init" 64 const val SEARCH_REQUEST_INIT_CALL_METHOD = "picker_internal_search_media_init" 65 const val GET_SEARCH_PROVIDERS_CALL_METHOD = "picker_internal_get_search_providers" 66 const val SEARCH_PROVIDER_AUTHORITIES = "search_provider_authorities" 67 const val SEARCH_REQUEST_ID = "search_request_id" 68 } 69 70 /** Contains all optional and mandatory keys required to make a Media query */ 71 private enum class MediaQuery(val key: String) { 72 PICKER_ID("picker_id"), 73 DATE_TAKEN("date_taken_millis"), 74 PAGE_SIZE("page_size"), 75 PROVIDERS("providers"), 76 } 77 78 /** 79 * Contains all mandatory keys required to make an Album Media query that are not present in 80 * [MediaQuery] already. 81 */ 82 private enum class AlbumMediaQuery(val key: String) { 83 ALBUM_AUTHORITY("album_authority") 84 } 85 86 /** 87 * Contains all mandatory keys required to make a Category and Album query that are not present 88 * in [MediaQuery] already. 89 */ 90 private enum class CategoryAndAlbumQuery(val key: String) { 91 PARENT_CATEGORY_ID("parent_category_id") 92 } 93 94 /** 95 * Contains all mandatory keys required to make a Media Set query that are not present in 96 * [MediaQuery] already. 97 */ 98 private enum class MediaSetsQuery(val key: String) { 99 PARENT_CATEGORY_ID("parent_category_id"), 100 PARENT_CATEGORY_AUTHORITY("parent_category_authority"), 101 } 102 103 /** 104 * Contains all mandatory keys required to make a Media Set contents query that are not present 105 * in [MediaQuery] already. 106 */ 107 private enum class MediaSetContentsQuery(val key: String) { 108 PARENT_MEDIA_SET_PICKER_ID("media_set_picker_id"), 109 PARENT_MEDIA_SET_AUTHORITY("media_set_picker_authority"), 110 } 111 112 /** 113 * Contains all optional and mandatory keys for data in the Available Providers query response. 114 */ 115 enum class AvailableProviderResponse(val key: String) { 116 AUTHORITY("authority"), 117 MEDIA_SOURCE("media_source"), 118 UID("uid"), 119 DISPLAY_NAME("display_name"), 120 } 121 122 enum class CollectionInfoResponse(val key: String) { 123 AUTHORITY("authority"), 124 COLLECTION_ID("collection_id"), 125 ACCOUNT_NAME("account_name"), 126 } 127 128 /** Contains all optional and mandatory keys for data in the Media query response. */ 129 enum class MediaResponse(val key: String) { 130 MEDIA_ID("id"), 131 PICKER_ID("picker_id"), 132 AUTHORITY("authority"), 133 MEDIA_SOURCE("media_source"), 134 MEDIA_URI("wrapped_uri"), 135 LOADABLE_URI("unwrapped_uri"), 136 DATE_TAKEN("date_taken_millis"), 137 SIZE("size_bytes"), 138 MIME_TYPE("mime_type"), 139 STANDARD_MIME_TYPE_EXT("standard_mime_type_extension"), 140 DURATION("duration_millis"), 141 IS_PRE_GRANTED("is_pre_granted"), 142 } 143 144 /** Contains all optional and mandatory keys for data in the Media query response extras. */ 145 enum class MediaResponseExtras(val key: String) { 146 PREV_PAGE_ID("prev_page_picker_id"), 147 PREV_PAGE_DATE_TAKEN("prev_page_date_taken"), 148 NEXT_PAGE_ID("next_page_picker_id"), 149 NEXT_PAGE_DATE_TAKEN("next_page_date_taken"), 150 ITEMS_BEFORE_COUNT("items_before_count"), 151 } 152 153 /** Contains all optional and mandatory keys for data in the Media query response. */ 154 enum class AlbumResponse(val key: String) { 155 ALBUM_ID("id"), 156 PICKER_ID("picker_id"), 157 AUTHORITY("authority"), 158 DATE_TAKEN("date_taken_millis"), 159 ALBUM_NAME("display_name"), 160 UNWRAPPED_COVER_URI("unwrapped_cover_uri"), 161 COVER_MEDIA_SOURCE("media_source"), 162 } 163 164 /** Contains all optional and mandatory keys for the Preview Media Query. */ 165 enum class PreviewMediaQuery(val key: String) { 166 CURRENT_SELECTION("current_selection"), 167 CURRENT_DE_SELECTION("current_de_selection"), 168 IS_FIRST_PAGE("is_first_page"), 169 } 170 171 enum class SearchRequestInitRequest(val key: String) { 172 SEARCH_TEXT("search_text"), 173 MEDIA_SET_ID("media_set_id"), 174 AUTHORITY("authority"), 175 TYPE("search_suggestion_type"), 176 } 177 178 enum class SearchSuggestionsQuery(val key: String) { 179 LIMIT("limit"), 180 HISTORY_LIMIT("history_limit"), 181 PREFIX("prefix"), 182 PROVIDERS("providers"), 183 } 184 185 enum class SearchSuggestionsResponse(val key: String) { 186 AUTHORITY("authority"), 187 MEDIA_SET_ID("media_set_id"), 188 SEARCH_TEXT("display_text"), 189 COVER_MEDIA_URI("cover_media_uri"), 190 SUGGESTION_TYPE("suggestion_type"), 191 } 192 193 enum class GroupResponse(val key: String) { 194 MEDIA_GROUP("media_group"), 195 /** Identifier received from CMP. This cannot be null. */ 196 GROUP_ID("group_id"), 197 /** Identifier used in Picker Backend, if any. */ 198 PICKER_ID("picker_id"), 199 DISPLAY_NAME("display_name"), 200 AUTHORITY("authority"), 201 UNWRAPPED_COVER_URI("unwrapped_cover_uri"), 202 ADDITIONAL_UNWRAPPED_COVER_URI_1("additional_cover_uri_1"), 203 ADDITIONAL_UNWRAPPED_COVER_URI_2("additional_cover_uri_2"), 204 ADDITIONAL_UNWRAPPED_COVER_URI_3("additional_cover_uri_3"), 205 CATEGORY_TYPE("category_type"), 206 IS_LEAF_CATEGORY("is_leaf_category"), 207 } 208 209 enum class GroupType() { 210 CATEGORY, 211 MEDIA_SET, 212 ALBUM, 213 } 214 215 /** Fetch available [Provider]-s from the Media Provider process. */ 216 fun fetchAvailableProviders(contentResolver: ContentResolver): List<Provider> { 217 try { 218 contentResolver 219 .query( 220 AVAILABLE_PROVIDERS_URI, 221 /* projection */ null, 222 /* queryArgs */ null, 223 /* cancellationSignal */ null, // TODO 224 ) 225 .use { cursor -> 226 return getListOfProviders(cursor!!) 227 } 228 } catch (e: RuntimeException) { 229 // If we can't fetch the available providers, basic functionality of photopicker does 230 // not work. In order to catch this earlier in testing, throw an error instead of 231 // silencing it. 232 throw RuntimeException("Could not fetch available providers", e) 233 } 234 } 235 236 /** Ensure that available providers are up to date. */ 237 suspend fun ensureProviders(contentResolver: ContentResolver) { 238 try { 239 contentResolver.call( 240 MEDIA_PROVIDER_AUTHORITY, 241 "ensure_providers_call", 242 /* arg */ null, 243 null, 244 ) 245 } catch (e: RuntimeException) { 246 Log.e(TAG, "Ensure providers failed", e) 247 } 248 } 249 250 /** Fetch a list of [Media] from MediaProvider for the given page key. */ 251 open suspend fun fetchMedia( 252 pageKey: MediaPageKey, 253 pageSize: Int, 254 contentResolver: ContentResolver, 255 availableProviders: List<Provider>, 256 config: PhotopickerConfiguration, 257 ): LoadResult<MediaPageKey, Media> { 258 val input: Bundle = 259 bundleOf( 260 MediaQuery.PICKER_ID.key to pageKey.pickerId, 261 MediaQuery.DATE_TAKEN.key to pageKey.dateTakenMillis, 262 MediaQuery.PAGE_SIZE.key to pageSize, 263 MediaQuery.PROVIDERS.key to 264 ArrayList<String>().apply { 265 availableProviders.forEach { provider -> add(provider.authority) } 266 }, 267 EXTRA_MIME_TYPES to config.mimeTypes, 268 EXTRA_INTENT_ACTION to config.action, 269 Intent.EXTRA_UID to config.callingPackageUid, 270 ) 271 272 try { 273 return contentResolver 274 .query( 275 MEDIA_URI, 276 /* projection */ null, 277 input, 278 /* cancellationSignal */ null, // TODO 279 ) 280 .use { cursor -> 281 cursor?.let { 282 LoadResult.Page( 283 data = cursor.getListOfMedia(), 284 prevKey = cursor.getPrevMediaPageKey(), 285 nextKey = cursor.getNextMediaPageKey(), 286 itemsBefore = 287 cursor.getItemsBeforeCount() ?: LoadResult.Page.COUNT_UNDEFINED, 288 ) 289 } 290 ?: throw IllegalStateException( 291 "Received a null response from Content Provider" 292 ) 293 } 294 } catch (e: RuntimeException) { 295 throw RuntimeException("Could not fetch media", e) 296 } 297 } 298 299 /** Fetch search results as a list of [Media] from MediaProvider for the given page key. */ 300 suspend fun fetchSearchResults( 301 searchRequestId: Int, 302 pageKey: MediaPageKey, 303 pageSize: Int, 304 contentResolver: ContentResolver, 305 availableProviders: List<Provider>, 306 config: PhotopickerConfiguration, 307 cancellationSignal: CancellationSignal?, 308 ): LoadResult<MediaPageKey, Media> { 309 val input: Bundle = 310 bundleOf( 311 MediaQuery.PICKER_ID.key to pageKey.pickerId, 312 MediaQuery.DATE_TAKEN.key to pageKey.dateTakenMillis, 313 MediaQuery.PAGE_SIZE.key to pageSize, 314 MediaQuery.PROVIDERS.key to 315 ArrayList<String>().apply { 316 availableProviders.forEach { provider -> add(provider.authority) } 317 }, 318 EXTRA_MIME_TYPES to config.mimeTypes, 319 EXTRA_INTENT_ACTION to config.action, 320 Intent.EXTRA_UID to config.callingPackageUid, 321 ) 322 323 try { 324 return contentResolver 325 .query( 326 getSearchResultsMediaUri(searchRequestId), 327 /* projection */ null, 328 input, 329 cancellationSignal, 330 ) 331 .use { cursor -> 332 cursor?.let { 333 LoadResult.Page( 334 data = cursor.getListOfMedia(), 335 prevKey = cursor.getPrevMediaPageKey(), 336 nextKey = cursor.getNextMediaPageKey(), 337 itemsBefore = 338 cursor.getItemsBeforeCount() ?: LoadResult.Page.COUNT_UNDEFINED, 339 ) 340 } 341 ?: throw IllegalStateException( 342 "Received a null response from Media Provider for search results" 343 ) 344 } 345 } catch (e: RuntimeException) { 346 throw RuntimeException("Could not fetch search results media", e) 347 } 348 } 349 350 /** Fetch a list of [Media] from MediaProvider for the given page key. */ 351 suspend fun fetchPreviewMedia( 352 pageKey: MediaPageKey, 353 pageSize: Int, 354 contentResolver: ContentResolver, 355 availableProviders: List<Provider>, 356 config: PhotopickerConfiguration, 357 currentSelection: List<String> = emptyList(), 358 currentDeSelection: List<String> = emptyList(), 359 isFirstPage: Boolean = false, 360 ): LoadResult<MediaPageKey, Media> { 361 val input: Bundle = 362 bundleOf( 363 MediaQuery.PICKER_ID.key to pageKey.pickerId, 364 MediaQuery.DATE_TAKEN.key to pageKey.dateTakenMillis, 365 MediaQuery.PAGE_SIZE.key to pageSize, 366 MediaQuery.PROVIDERS.key to 367 ArrayList<String>().apply { 368 availableProviders.forEach { provider -> add(provider.authority) } 369 }, 370 EXTRA_MIME_TYPES to config.mimeTypes, 371 EXTRA_INTENT_ACTION to config.action, 372 Intent.EXTRA_UID to config.callingPackageUid, 373 PreviewMediaQuery.CURRENT_SELECTION.key to currentSelection, 374 PreviewMediaQuery.CURRENT_DE_SELECTION.key to currentDeSelection, 375 PreviewMediaQuery.IS_FIRST_PAGE.key to isFirstPage, 376 ) 377 378 try { 379 return contentResolver 380 .query( 381 MEDIA_PREVIEW_URI, 382 /* projection */ null, 383 input, 384 /* cancellationSignal */ null, // TODO 385 ) 386 .use { cursor -> 387 cursor?.let { 388 LoadResult.Page( 389 data = cursor.getListOfMedia(), 390 prevKey = cursor.getPrevMediaPageKey(), 391 nextKey = cursor.getNextMediaPageKey(), 392 ) 393 } 394 ?: throw IllegalStateException( 395 "Received a null response from Content Provider" 396 ) 397 } 398 } catch (e: RuntimeException) { 399 throw RuntimeException("Could not fetch preview media", e) 400 } 401 } 402 403 /** Fetch a list of [Group.Album] from MediaProvider for the given page key. */ 404 open suspend fun fetchAlbums( 405 pageKey: MediaPageKey, 406 pageSize: Int, 407 contentResolver: ContentResolver, 408 availableProviders: List<Provider>, 409 config: PhotopickerConfiguration, 410 ): LoadResult<MediaPageKey, Group.Album> { 411 val input: Bundle = 412 bundleOf( 413 MediaQuery.PICKER_ID.key to pageKey.pickerId, 414 MediaQuery.DATE_TAKEN.key to pageKey.dateTakenMillis, 415 MediaQuery.PAGE_SIZE.key to pageSize, 416 MediaQuery.PROVIDERS.key to 417 ArrayList<String>().apply { 418 availableProviders.forEach { provider -> add(provider.authority) } 419 }, 420 EXTRA_MIME_TYPES to config.mimeTypes, 421 EXTRA_INTENT_ACTION to config.action, 422 Intent.EXTRA_UID to config.callingPackageUid, 423 ) 424 try { 425 return contentResolver 426 .query( 427 ALBUM_URI, 428 /* projection */ null, 429 input, 430 /* cancellationSignal */ null, // TODO 431 ) 432 .use { cursor -> 433 cursor?.let { 434 LoadResult.Page( 435 data = cursor.getListOfAlbums(), 436 prevKey = cursor.getPrevMediaPageKey(), 437 nextKey = cursor.getNextMediaPageKey(), 438 ) 439 } 440 ?: throw IllegalStateException( 441 "Received a null response from Content Provider" 442 ) 443 } 444 } catch (e: RuntimeException) { 445 throw RuntimeException("Could not fetch albums", e) 446 } 447 } 448 449 /** Fetch a list of [Media] from MediaProvider for the given page key. */ 450 open suspend fun fetchAlbumMedia( 451 albumId: String, 452 albumAuthority: String, 453 pageKey: MediaPageKey, 454 pageSize: Int, 455 contentResolver: ContentResolver, 456 availableProviders: List<Provider>, 457 config: PhotopickerConfiguration, 458 ): LoadResult<MediaPageKey, Media> { 459 val input: Bundle = 460 bundleOf( 461 AlbumMediaQuery.ALBUM_AUTHORITY.key to albumAuthority, 462 MediaQuery.PICKER_ID.key to pageKey.pickerId, 463 MediaQuery.DATE_TAKEN.key to pageKey.dateTakenMillis, 464 MediaQuery.PAGE_SIZE.key to pageSize, 465 MediaQuery.PROVIDERS.key to 466 ArrayList<String>().apply { 467 availableProviders.forEach { provider -> add(provider.authority) } 468 }, 469 EXTRA_MIME_TYPES to config.mimeTypes, 470 EXTRA_INTENT_ACTION to config.action, 471 Intent.EXTRA_UID to config.callingPackageUid, 472 ) 473 474 try { 475 return contentResolver 476 .query( 477 getAlbumMediaUri(albumId), 478 /* projection */ null, 479 input, 480 /* cancellationSignal */ null, // TODO 481 ) 482 .use { cursor -> 483 cursor?.let { 484 LoadResult.Page( 485 data = cursor.getListOfMedia(), 486 prevKey = cursor.getPrevMediaPageKey(), 487 nextKey = cursor.getNextMediaPageKey(), 488 ) 489 } 490 ?: throw IllegalStateException( 491 "Received a null response from Content Provider" 492 ) 493 } 494 } catch (e: RuntimeException) { 495 throw RuntimeException("Could not fetch album media", e) 496 } 497 } 498 499 /** 500 * Tries to fetch the latest collection info for the available providers. 501 * 502 * @param resolver The [ContentResolver] of the current active user 503 * @return list of [CollectionInfo] 504 * @throws RuntimeException if data source is unable to fetch the collection info. 505 */ 506 fun fetchCollectionInfo(resolver: ContentResolver): List<CollectionInfo> { 507 try { 508 resolver 509 .query( 510 COLLECTION_INFO_URI, 511 /* projection */ null, 512 /* queryArgs */ null, 513 /* cancellationSignal */ null, 514 ) 515 .use { cursor -> 516 return getListOfCollectionInfo(cursor!!) 517 } 518 } catch (e: RuntimeException) { 519 throw RuntimeException("Could not fetch collection info", e) 520 } 521 } 522 523 /** 524 * Fetches the count of pre-granted media for a given package from the MediaProvider. 525 * 526 * This function is designed to be used within the MediaProvider client-side context. It queries 527 * the `MEDIA_GRANTS_URI` using a Bundle containing the calling package's UID to retrieve the 528 * count of media grants. 529 * 530 * @param contentResolver The ContentResolver used to interact with the MediaProvider. 531 * @param callingPackageUid The UID of the calling package (app) for which to fetch the count. 532 * @return The count of media grants for the calling package. 533 * @throws RuntimeException if an error occurs during the query or fetching of the grants count. 534 */ 535 fun fetchMediaGrantsCount(contentResolver: ContentResolver, callingPackageUid: Int): Int { 536 if (callingPackageUid < 0) { 537 // return with 0 value since the input callingUid is invalid. 538 Log.e(TAG, "invalid calling package UID.") 539 throw IllegalArgumentException("Invalid input for uid.") 540 } 541 // Create a Bundle containing the calling package's UID. This is used as a selection 542 // argument for the query. 543 val input: Bundle = bundleOf(Intent.EXTRA_UID to callingPackageUid) 544 545 try { 546 contentResolver.query(MEDIA_GRANTS_COUNT_URI, /* projection */ null, input, null).use { 547 cursor -> 548 if (cursor != null && cursor.moveToFirst()) { 549 // Move the cursor to the first row and extract the count. 550 551 return cursor.getInt(cursor.getColumnIndexOrThrow(COLUMN_GRANTS_COUNT)) 552 } else { 553 // return 0 if cursor is empty. 554 return 0 555 } 556 } 557 } catch (e: Exception) { 558 throw RuntimeException("Could not fetch media grants count. ", e) 559 } 560 } 561 562 /** Fetches a list of [Media] from MediaProvider filtered by the input URI list. */ 563 fun fetchFilteredMedia( 564 pageKey: MediaPageKey, 565 pageSize: Int, 566 contentResolver: ContentResolver, 567 availableProviders: List<Provider>, 568 config: PhotopickerConfiguration, 569 uris: List<Uri>, 570 ): List<Media> { 571 val input: Bundle = 572 bundleOf( 573 MediaQuery.PICKER_ID.key to pageKey.pickerId, 574 MediaQuery.DATE_TAKEN.key to pageKey.dateTakenMillis, 575 MediaQuery.PAGE_SIZE.key to pageSize, 576 MediaQuery.PROVIDERS.key to 577 ArrayList<String>().apply { 578 availableProviders.forEach { provider -> add(provider.authority) } 579 }, 580 EXTRA_MIME_TYPES to config.mimeTypes, 581 EXTRA_INTENT_ACTION to config.action, 582 Intent.EXTRA_UID to config.callingPackageUid, 583 PRE_SELECTION_URIS to 584 ArrayList<String>().apply { uris.forEach { uri -> add(uri.toString()) } }, 585 ) 586 587 try { 588 return contentResolver 589 .query( 590 MEDIA_PRE_SELECTION_URI, 591 /* projection */ null, 592 input, 593 /* cancellationSignal */ null, // TODO 594 ) 595 ?.getListOfMedia() ?: ArrayList() 596 } catch (e: RuntimeException) { 597 throw RuntimeException("Could not fetch media", e) 598 } 599 } 600 601 /** 602 * Fetches a list of search suggestions from MediaProvider filtered by the input prefix string. 603 */ 604 suspend fun fetchSearchSuggestions( 605 resolver: ContentResolver, 606 prefix: String, 607 limit: Int, 608 historyLimit: Int, 609 availableProviders: List<Provider>, 610 cancellationSignal: CancellationSignal?, 611 ): List<SearchSuggestion> { 612 try { 613 val input: Bundle = 614 bundleOf( 615 SearchSuggestionsQuery.PREFIX.key to prefix, 616 SearchSuggestionsQuery.LIMIT.key to limit, 617 SearchSuggestionsQuery.HISTORY_LIMIT.key to historyLimit, 618 MediaQuery.PROVIDERS.key to 619 ArrayList<String>().apply { 620 availableProviders.forEach { provider -> add(provider.authority) } 621 }, 622 ) 623 624 return resolver 625 .query(SEARCH_SUGGESTIONS_URI, /* projection */ null, input, cancellationSignal) 626 ?.getListOfSearchSuggestions(availableProviders) ?: ArrayList() 627 } catch (e: RuntimeException) { 628 throw RuntimeException("Could not fetch search suggestions", e) 629 } 630 } 631 632 /** 633 * Fetches a list of categories and albums from MediaProvider filtered by the input list of 634 * available providers, mime types and parent category id. 635 */ 636 suspend fun fetchCategoriesAndAlbums( 637 pageKey: GroupPageKey, 638 pageSize: Int, 639 contentResolver: ContentResolver, 640 availableProviders: List<Provider>, 641 parentCategoryId: String?, 642 config: PhotopickerConfiguration, 643 cancellationSignal: CancellationSignal?, 644 ): LoadResult<GroupPageKey, Group> { 645 val input: Bundle = 646 bundleOf( 647 MediaQuery.PICKER_ID.key to pageKey.pickerId, 648 MediaQuery.PAGE_SIZE.key to pageSize, 649 MediaQuery.PROVIDERS.key to 650 ArrayList<String>().apply { 651 availableProviders.forEach { provider -> add(provider.authority) } 652 }, 653 EXTRA_MIME_TYPES to config.mimeTypes, 654 EXTRA_INTENT_ACTION to config.action, 655 Intent.EXTRA_UID to config.callingPackageUid, 656 CategoryAndAlbumQuery.PARENT_CATEGORY_ID.key to parentCategoryId, 657 ) 658 try { 659 return contentResolver 660 .query( 661 getCategoryUri(parentCategoryId), 662 /* projection */ null, 663 input, 664 cancellationSignal, 665 ) 666 .use { cursor -> 667 cursor?.let { 668 LoadResult.Page( 669 data = cursor.getListOfCategoriesAndAlbums(availableProviders), 670 prevKey = cursor.getPrevGroupPageKey(), 671 nextKey = cursor.getNextGroupPageKey(), 672 ) 673 } 674 ?: throw IllegalStateException( 675 "Received a null response from Content Provider" 676 ) 677 } 678 } catch (e: RuntimeException) { 679 throw RuntimeException( 680 "Could not fetch categories and albums for parent category $parentCategoryId", 681 e, 682 ) 683 } 684 } 685 686 /** 687 * Fetches a list of media sets from MediaProvider filtered by the input list of available 688 * providers, mime types and parent category id. 689 */ 690 suspend fun fetchMediaSets( 691 pageKey: GroupPageKey, 692 pageSize: Int, 693 contentResolver: ContentResolver, 694 availableProviders: List<Provider>, 695 parentCategory: Group.Category, 696 config: PhotopickerConfiguration, 697 cancellationSignal: CancellationSignal?, 698 ): LoadResult<GroupPageKey, Group.MediaSet> { 699 val input: Bundle = 700 bundleOf( 701 MediaQuery.PICKER_ID.key to pageKey.pickerId, 702 MediaQuery.PAGE_SIZE.key to pageSize, 703 MediaQuery.PROVIDERS.key to arrayListOf(parentCategory.authority), 704 EXTRA_MIME_TYPES to config.mimeTypes, 705 EXTRA_INTENT_ACTION to config.action, 706 MediaSetsQuery.PARENT_CATEGORY_ID.key to parentCategory.id, 707 MediaSetsQuery.PARENT_CATEGORY_AUTHORITY.key to parentCategory.authority, 708 ) 709 try { 710 return contentResolver 711 .query(MEDIA_SETS_URI, /* projection */ null, input, cancellationSignal) 712 .use { cursor -> 713 cursor?.let { 714 LoadResult.Page( 715 data = cursor.getListOfMediaSets(availableProviders), 716 prevKey = cursor.getPrevGroupPageKey(), 717 nextKey = cursor.getNextGroupPageKey(), 718 ) 719 } 720 ?: throw IllegalStateException( 721 "Received a null response from Content Provider" 722 ) 723 } 724 } catch (e: RuntimeException) { 725 throw RuntimeException( 726 "Could not fetch media sets for parent category ${parentCategory.id}", 727 e, 728 ) 729 } 730 } 731 732 /** 733 * Fetches a list of media items in a media set from MediaProvider filtered by the input list of 734 * available providers, mime types and parent media set id. 735 */ 736 suspend fun fetchMediaSetContents( 737 pageKey: MediaPageKey, 738 pageSize: Int, 739 contentResolver: ContentResolver, 740 parentMediaSet: Group.MediaSet, 741 config: PhotopickerConfiguration, 742 cancellationSignal: CancellationSignal?, 743 ): LoadResult<MediaPageKey, Media> { 744 val input: Bundle = 745 bundleOf( 746 MediaQuery.PICKER_ID.key to pageKey.pickerId, 747 MediaQuery.DATE_TAKEN.key to pageKey.dateTakenMillis, 748 MediaQuery.PAGE_SIZE.key to pageSize, 749 MediaQuery.PROVIDERS.key to arrayListOf(parentMediaSet.authority), 750 EXTRA_MIME_TYPES to config.mimeTypes, 751 EXTRA_INTENT_ACTION to config.action, 752 Intent.EXTRA_UID to config.callingPackageUid, 753 MediaSetContentsQuery.PARENT_MEDIA_SET_PICKER_ID.key to parentMediaSet.pickerId, 754 MediaSetContentsQuery.PARENT_MEDIA_SET_AUTHORITY.key to parentMediaSet.authority, 755 ) 756 try { 757 return contentResolver 758 .query(MEDIA_SET_CONTENTS_URI, /* projection */ null, input, cancellationSignal) 759 .use { cursor -> 760 cursor?.let { 761 LoadResult.Page( 762 data = cursor.getListOfMedia(), 763 prevKey = cursor.getPrevMediaPageKey(), 764 nextKey = cursor.getNextMediaPageKey(), 765 ) 766 } 767 ?: throw IllegalStateException( 768 "Received a null response from Content Provider" 769 ) 770 } 771 } catch (e: RuntimeException) { 772 throw RuntimeException( 773 "Could not fetch media set contents for parent media set ${parentMediaSet.id}", 774 e, 775 ) 776 } 777 } 778 779 /** 780 * Send a refresh media request to MediaProvider. This is a signal for MediaProvider to refresh 781 * its cache, if required. 782 */ 783 fun refreshMedia( 784 @Suppress("UNUSED_PARAMETER") providers: List<Provider>, 785 resolver: ContentResolver, 786 config: PhotopickerConfiguration, 787 ) { 788 val extras = Bundle() 789 790 // TODO(b/340246010): Currently, we trigger sync for all providers. This is because 791 // the UI is responsible for triggering syncs which is sometimes required to enable 792 // providers. This should be changed to triggering syncs for specific providers once the 793 // backend takes responsibility for the sync triggers. 794 val initLocalOnlyMedia = false 795 796 extras.putBoolean(EXTRA_LOCAL_ONLY, initLocalOnlyMedia) 797 extras.putStringArrayList(EXTRA_MIME_TYPES, config.mimeTypes) 798 extras.putString(EXTRA_INTENT_ACTION, config.action) 799 extras.putInt(Intent.EXTRA_UID, config.callingPackageUid ?: -1) 800 refreshMedia(extras, resolver) 801 } 802 803 /** 804 * Send a refresh album media request to MediaProvider. This is a signal for MediaProvider to 805 * refresh its cache for the given album media, if required. 806 */ 807 suspend fun refreshAlbumMedia( 808 albumId: String, 809 albumAuthority: String, 810 providers: List<Provider>, 811 resolver: ContentResolver, 812 config: PhotopickerConfiguration, 813 ) { 814 val extras = Bundle() 815 val initLocalOnlyMedia: Boolean = 816 providers.all { provider -> (provider.mediaSource == MediaSource.LOCAL) } 817 extras.putBoolean(EXTRA_LOCAL_ONLY, initLocalOnlyMedia) 818 extras.putStringArrayList(EXTRA_MIME_TYPES, config.mimeTypes) 819 extras.putString(EXTRA_INTENT_ACTION, config.action) 820 extras.putString(EXTRA_ALBUM_ID, albumId) 821 extras.putString(EXTRA_ALBUM_AUTHORITY, albumAuthority) 822 refreshMedia(extras, resolver) 823 } 824 825 /** 826 * Send a refresh media sets request to MediaProvider. This is a signal for MediaProvider to 827 * refresh its cache for the given parent category id and authority, if required. 828 */ 829 suspend fun refreshMediaSets( 830 contentResolver: ContentResolver, 831 category: Group.Category, 832 config: PhotopickerConfiguration, 833 providers: List<Provider>, 834 ) { 835 val extras = 836 bundleOf( 837 EXTRA_MIME_TYPES to config.mimeTypes, 838 MediaSetsQuery.PARENT_CATEGORY_ID.key to category.id, 839 MediaSetsQuery.PARENT_CATEGORY_AUTHORITY.key to category.authority, 840 MediaQuery.PROVIDERS.key to 841 ArrayList<String>().apply { 842 providers.forEach { provider -> add(provider.authority) } 843 }, 844 ) 845 846 try { 847 contentResolver.call( 848 MEDIA_PROVIDER_AUTHORITY, 849 MEDIA_SETS_INIT_CALL_METHOD, 850 /* arg */ null, 851 extras, 852 ) 853 } catch (e: RuntimeException) { 854 Log.e(TAG, "Could not send refresh media sets call to Media Provider $extras", e) 855 } 856 } 857 858 /** 859 * Send a refresh media set contents request to MediaProvider. This is a signal for 860 * MediaProvider to refresh its cache for the given parent media set id and authority, if 861 * required. 862 */ 863 suspend fun refreshMediaSetContents( 864 contentResolver: ContentResolver, 865 mediaSet: Group.MediaSet, 866 config: PhotopickerConfiguration, 867 providers: List<Provider>, 868 ) { 869 val extras = 870 bundleOf( 871 EXTRA_MIME_TYPES to config.mimeTypes, 872 MediaSetContentsQuery.PARENT_MEDIA_SET_PICKER_ID.key to mediaSet.pickerId, 873 MediaSetContentsQuery.PARENT_MEDIA_SET_AUTHORITY.key to mediaSet.authority, 874 MediaQuery.PROVIDERS.key to 875 ArrayList<String>().apply { 876 providers.forEach { provider -> add(provider.authority) } 877 }, 878 ) 879 880 try { 881 contentResolver.call( 882 MEDIA_PROVIDER_AUTHORITY, 883 MEDIA_SET_CONTENTS_INIT_CALL_METHOD, 884 /* arg */ null, 885 extras, 886 ) 887 } catch (e: RuntimeException) { 888 Log.e( 889 TAG, 890 "Could not send refresh media set contents call to Media Provider $extras", 891 e, 892 ) 893 } 894 } 895 896 /** 897 * Creates a search request with the data source. 898 * 899 * The data source is expected to return a search request id associated with the request. 900 * [MediaProviderClient] can use this search request id to query search results throughout the 901 * photopicker session. 902 * 903 * This call lets [MediaProvider] know that the Photopicker session has made a new search 904 * request and the backend should prepare to handle search results queries for the given search 905 * request. 906 */ 907 suspend fun createSearchRequest( 908 searchRequest: SearchRequest, 909 providers: List<Provider>, 910 resolver: ContentResolver, 911 config: PhotopickerConfiguration, 912 ): Int { 913 val extras: Bundle = 914 prepareSearchResultsExtras( 915 searchRequest = searchRequest, 916 providers = providers, 917 config = config, 918 ) 919 920 val result: Bundle? = 921 resolver.call( 922 MEDIA_PROVIDER_AUTHORITY, 923 SEARCH_REQUEST_INIT_CALL_METHOD, 924 /* arg */ null, 925 extras, 926 ) 927 return checkNotNull(result?.getInt(SEARCH_REQUEST_ID)) { 928 "Search request ID cannot be null" 929 } 930 } 931 932 /** 933 * Notifies the Data Source that the previously known search query is performed again by the 934 * user in the same session. 935 * 936 * This call lets [MediaProvider] know that the user has triggered a known search request again 937 * and the backend should prepare to handle search results queries for the given search request. 938 */ 939 suspend fun ensureSearchResults( 940 searchRequest: SearchRequest, 941 searchRequestId: Int, 942 providers: List<Provider>, 943 resolver: ContentResolver, 944 config: PhotopickerConfiguration, 945 ) { 946 val extras: Bundle = 947 prepareSearchResultsExtras( 948 searchRequest = searchRequest, 949 searchRequestId = searchRequestId, 950 providers = providers, 951 config = config, 952 ) 953 954 resolver.call( 955 MEDIA_PROVIDER_AUTHORITY, 956 SEARCH_REQUEST_INIT_CALL_METHOD, 957 /* arg */ null, 958 extras, 959 ) 960 } 961 962 /** 963 * Creates an extras [Bundle] with the required args for MediaProvider's 964 * [SEARCH_REQUEST_INIT_CALL_METHOD]. 965 * 966 * See [createSearchRequest] and [ensureSearchResults]. 967 */ 968 private fun prepareSearchResultsExtras( 969 searchRequest: SearchRequest, 970 searchRequestId: Int? = null, 971 providers: List<Provider>, 972 config: PhotopickerConfiguration, 973 ): Bundle { 974 val extras = 975 bundleOf( 976 EXTRA_MIME_TYPES to config.mimeTypes, 977 EXTRA_INTENT_ACTION to config.action, 978 EXTRA_PROVIDERS to 979 ArrayList<String>().apply { 980 providers.forEach { provider -> add(provider.authority) } 981 }, 982 ) 983 984 if (searchRequestId != null) { 985 extras.putInt(SEARCH_REQUEST_ID, searchRequestId) 986 } 987 988 when (searchRequest) { 989 is SearchRequest.SearchTextRequest -> 990 extras.putString(SearchRequestInitRequest.SEARCH_TEXT.key, searchRequest.searchText) 991 is SearchRequest.SearchSuggestionRequest -> { 992 extras.putString( 993 SearchRequestInitRequest.SEARCH_TEXT.key, 994 searchRequest.suggestion.displayText, 995 ) 996 extras.putString( 997 SearchRequestInitRequest.AUTHORITY.key, 998 searchRequest.suggestion.authority, 999 ) 1000 extras.putString( 1001 SearchRequestInitRequest.MEDIA_SET_ID.key, 1002 searchRequest.suggestion.mediaSetId, 1003 ) 1004 extras.putString( 1005 SearchRequestInitRequest.TYPE.key, 1006 searchRequest.suggestion.type.name, 1007 ) 1008 } 1009 } 1010 1011 return extras 1012 } 1013 1014 /** 1015 * Get available search providers from the Media Provider client using the available 1016 * [ContentResolver]. 1017 * 1018 * If the available providers are known at the time of the query, this method will filter the 1019 * results of the call so that search providers are a subset of the available providers. 1020 * 1021 * @param resolver The [ContentResolver] that resolves to the desired instance of MediaProvider. 1022 * (This may resolve in a cross profile instance of MediaProvider). 1023 * @param availableProviders 1024 */ 1025 suspend fun fetchSearchProviderAuthorities( 1026 resolver: ContentResolver, 1027 availableProviders: List<Provider>? = null, 1028 ): List<String>? { 1029 try { 1030 val availableProviderAuthorities: Set<String>? = 1031 availableProviders?.map { it.authority }?.toSet() 1032 val result: Bundle? = 1033 resolver.call( 1034 MEDIA_PROVIDER_AUTHORITY, 1035 GET_SEARCH_PROVIDERS_CALL_METHOD, 1036 /* arg */ null, 1037 /* extras */ null, 1038 ) 1039 return result?.getStringArrayList(SEARCH_PROVIDER_AUTHORITIES)?.filter { 1040 availableProviderAuthorities?.contains(it) ?: true 1041 } 1042 } catch (e: RuntimeException) { 1043 // If we can't fetch the available providers, basic functionality of photopicker does 1044 // not work. In order to catch this earlier in testing, throw an error instead of 1045 // silencing it. 1046 Log.e(TAG, "Could not fetch providers with search enabled", e) 1047 return null 1048 } 1049 } 1050 1051 /** Creates a list of [Provider] from the given [Cursor]. */ 1052 private fun getListOfProviders(cursor: Cursor): List<Provider> { 1053 val result: MutableList<Provider> = mutableListOf<Provider>() 1054 if (cursor.moveToFirst()) { 1055 do { 1056 result.add( 1057 Provider( 1058 authority = 1059 cursor.getString( 1060 cursor.getColumnIndexOrThrow( 1061 AvailableProviderResponse.AUTHORITY.key 1062 ) 1063 ), 1064 mediaSource = 1065 MediaSource.valueOf( 1066 cursor.getString( 1067 cursor.getColumnIndexOrThrow( 1068 AvailableProviderResponse.MEDIA_SOURCE.key 1069 ) 1070 ) 1071 ), 1072 uid = 1073 cursor.getInt( 1074 cursor.getColumnIndexOrThrow(AvailableProviderResponse.UID.key) 1075 ), 1076 displayName = 1077 cursor.getString( 1078 cursor.getColumnIndexOrThrow( 1079 AvailableProviderResponse.DISPLAY_NAME.key 1080 ) 1081 ), 1082 ) 1083 ) 1084 } while (cursor.moveToNext()) 1085 } 1086 1087 return result 1088 } 1089 1090 /** Creates a list of [CollectionInfo] from the given [Cursor]. */ 1091 private fun getListOfCollectionInfo(cursor: Cursor): List<CollectionInfo> { 1092 val result: MutableList<CollectionInfo> = mutableListOf<CollectionInfo>() 1093 if (cursor.moveToFirst()) { 1094 do { 1095 val authority = 1096 cursor.getString( 1097 cursor.getColumnIndexOrThrow(CollectionInfoResponse.AUTHORITY.key) 1098 ) 1099 val accountConfigurationIntent: Intent? = 1100 if (SdkLevel.isAtLeastT()) 1101 // Bundle.getParcelable API in T+ 1102 cursor.getExtras().getParcelable(authority, Intent::class.java) 1103 // Fallback API for S or lower 1104 else 1105 @Suppress("DEPRECATION") 1106 cursor.getExtras().getParcelable(authority) as? Intent 1107 result.add( 1108 CollectionInfo( 1109 authority = authority, 1110 collectionId = 1111 cursor.getString( 1112 cursor.getColumnIndexOrThrow( 1113 CollectionInfoResponse.COLLECTION_ID.key 1114 ) 1115 ), 1116 accountName = 1117 cursor.getString( 1118 cursor.getColumnIndexOrThrow( 1119 CollectionInfoResponse.ACCOUNT_NAME.key 1120 ) 1121 ), 1122 accountConfigurationIntent = accountConfigurationIntent, 1123 ) 1124 ) 1125 } while (cursor.moveToNext()) 1126 } 1127 1128 return result 1129 } 1130 1131 /** 1132 * Creates a list of [Media] from the given [Cursor]. 1133 * 1134 * [Media] can be of type [Media.Image] or [Media.Video]. 1135 */ 1136 private fun Cursor.getListOfMedia(): List<Media> { 1137 val result: MutableList<Media> = mutableListOf<Media>() 1138 val itemsBeforeCount: Int? = getItemsBeforeCount() 1139 var indexCounter: Int? = itemsBeforeCount 1140 if (this.moveToFirst()) { 1141 do { 1142 val mediaId: String = getString(getColumnIndexOrThrow(MediaResponse.MEDIA_ID.key)) 1143 val pickerId: Long = getLong(getColumnIndexOrThrow(MediaResponse.PICKER_ID.key)) 1144 val index: Int? = indexCounter?.let { ++indexCounter } 1145 val authority: String = 1146 getString(getColumnIndexOrThrow(MediaResponse.AUTHORITY.key)) 1147 val mediaSource: MediaSource = 1148 MediaSource.valueOf( 1149 getString(getColumnIndexOrThrow(MediaResponse.MEDIA_SOURCE.key)) 1150 ) 1151 val mediaUri: Uri = 1152 Uri.parse(getString(getColumnIndexOrThrow(MediaResponse.MEDIA_URI.key))) 1153 val loadableUri: Uri = 1154 Uri.parse(getString(getColumnIndexOrThrow(MediaResponse.LOADABLE_URI.key))) 1155 val dateTakenMillisLong: Long = 1156 getLong(getColumnIndexOrThrow(MediaResponse.DATE_TAKEN.key)) 1157 val sizeInBytes: Long = getLong(getColumnIndexOrThrow(MediaResponse.SIZE.key)) 1158 val mimeType: String = getString(getColumnIndexOrThrow(MediaResponse.MIME_TYPE.key)) 1159 val standardMimeTypeExtension: Int = 1160 getInt(getColumnIndexOrThrow(MediaResponse.STANDARD_MIME_TYPE_EXT.key)) 1161 val isPregranted: Int = 1162 getInt(getColumnIndexOrThrow(MediaResponse.IS_PRE_GRANTED.key)) 1163 if (mimeType.startsWith("image/")) { 1164 result.add( 1165 Media.Image( 1166 mediaId = mediaId, 1167 pickerId = pickerId, 1168 index = index, 1169 authority = authority, 1170 mediaSource = mediaSource, 1171 mediaUri = mediaUri, 1172 glideLoadableUri = loadableUri, 1173 dateTakenMillisLong = dateTakenMillisLong, 1174 sizeInBytes = sizeInBytes, 1175 mimeType = mimeType, 1176 standardMimeTypeExtension = standardMimeTypeExtension, 1177 isPreGranted = (isPregranted == 1), // here 1 denotes true else false 1178 ) 1179 ) 1180 } else if (mimeType.startsWith("video/")) { 1181 result.add( 1182 Media.Video( 1183 mediaId = mediaId, 1184 pickerId = pickerId, 1185 index = index, 1186 authority = authority, 1187 mediaSource = mediaSource, 1188 mediaUri = mediaUri, 1189 glideLoadableUri = loadableUri, 1190 dateTakenMillisLong = dateTakenMillisLong, 1191 sizeInBytes = sizeInBytes, 1192 mimeType = mimeType, 1193 standardMimeTypeExtension = standardMimeTypeExtension, 1194 duration = getInt(getColumnIndexOrThrow(MediaResponse.DURATION.key)), 1195 isPreGranted = (isPregranted == 1), // here 1 denotes true else false 1196 ) 1197 ) 1198 } else { 1199 throw UnsupportedOperationException("Could not recognize mime type $mimeType") 1200 } 1201 } while (moveToNext()) 1202 } 1203 1204 return result 1205 } 1206 1207 /** 1208 * Extracts the previous media page key from the given [Cursor]. In case the cursor contains the 1209 * contents of the first page, the previous page key will be null. 1210 */ 1211 private fun Cursor.getPrevMediaPageKey(): MediaPageKey? { 1212 val id: Long = extras.getLong(MediaResponseExtras.PREV_PAGE_ID.key, Long.MIN_VALUE) 1213 val date: Long = 1214 extras.getLong(MediaResponseExtras.PREV_PAGE_DATE_TAKEN.key, Long.MIN_VALUE) 1215 return if (date == Long.MIN_VALUE) { 1216 null 1217 } else { 1218 MediaPageKey(pickerId = id, dateTakenMillis = date) 1219 } 1220 } 1221 1222 /** 1223 * Extracts the next media page key from the given [Cursor]. In case the cursor contains the 1224 * contents of the last page, the next page key will be null. 1225 */ 1226 private fun Cursor.getNextMediaPageKey(): MediaPageKey? { 1227 val id: Long = extras.getLong(MediaResponseExtras.NEXT_PAGE_ID.key, Long.MIN_VALUE) 1228 val date: Long = 1229 extras.getLong(MediaResponseExtras.NEXT_PAGE_DATE_TAKEN.key, Long.MIN_VALUE) 1230 return if (date == Long.MIN_VALUE) { 1231 null 1232 } else { 1233 MediaPageKey(pickerId = id, dateTakenMillis = date) 1234 } 1235 } 1236 1237 /** 1238 * Extracts the previous group page key from the given [Cursor]. In case the cursor contains the 1239 * contents of the first page, the previous page key will be null. 1240 */ 1241 private fun Cursor.getPrevGroupPageKey(): GroupPageKey? { 1242 val id: Long = extras.getLong(MediaResponseExtras.PREV_PAGE_ID.key, Long.MIN_VALUE) 1243 return if (id == Long.MIN_VALUE) { 1244 null 1245 } else { 1246 GroupPageKey(pickerId = id) 1247 } 1248 } 1249 1250 /** 1251 * Extracts the next group page key from the given [Cursor]. In case the cursor contains the 1252 * contents of the last page, the next page key will be null. 1253 */ 1254 private fun Cursor.getNextGroupPageKey(): GroupPageKey? { 1255 val id: Long = extras.getLong(MediaResponseExtras.NEXT_PAGE_ID.key, Long.MAX_VALUE) 1256 return if (id == Long.MAX_VALUE) { 1257 null 1258 } else { 1259 GroupPageKey(pickerId = id) 1260 } 1261 } 1262 1263 /** 1264 * Extracts the before items count from the given [Cursor]. In case the cursor does not contain 1265 * this value, return null. 1266 */ 1267 private fun Cursor.getItemsBeforeCount(): Int? { 1268 val defaultValue = -1 1269 val itemsBeforeCount: Int = 1270 extras.getInt(MediaResponseExtras.ITEMS_BEFORE_COUNT.key, defaultValue) 1271 return if (defaultValue == itemsBeforeCount) null else itemsBeforeCount 1272 } 1273 1274 /** Creates a list of [Group.Album]-s from the given [Cursor]. */ 1275 private fun Cursor.getListOfAlbums(): List<Group.Album> { 1276 val result: MutableList<Group.Album> = mutableListOf<Group.Album>() 1277 1278 if (this.moveToFirst()) { 1279 do { 1280 val albumId = getString(getColumnIndexOrThrow(AlbumResponse.ALBUM_ID.key)) 1281 val coverUriString = 1282 getString(getColumnIndexOrThrow(AlbumResponse.UNWRAPPED_COVER_URI.key)) 1283 result.add( 1284 Group.Album( 1285 id = albumId, 1286 // This is a temporary solution till we cache album data in Picker DB 1287 pickerId = albumId.hashCode().toLong(), 1288 authority = getString(getColumnIndexOrThrow(AlbumResponse.AUTHORITY.key)), 1289 dateTakenMillisLong = 1290 getLong(getColumnIndexOrThrow(AlbumResponse.DATE_TAKEN.key)), 1291 displayName = 1292 getString(getColumnIndexOrThrow(AlbumResponse.ALBUM_NAME.key)), 1293 coverUri = coverUriString?.let { Uri.parse(it) } ?: Uri.parse(""), 1294 coverMediaSource = 1295 MediaSource.valueOf( 1296 getString( 1297 getColumnIndexOrThrow(AlbumResponse.COVER_MEDIA_SOURCE.key) 1298 ) 1299 ), 1300 ) 1301 ) 1302 } while (moveToNext()) 1303 } 1304 1305 return result 1306 } 1307 1308 /** Creates a list of [SearchSuggestion]-s from the given [Cursor]. */ 1309 private fun Cursor.getListOfSearchSuggestions( 1310 availableProviders: List<Provider> 1311 ): List<SearchSuggestion> { 1312 val result: MutableList<SearchSuggestion> = mutableListOf<SearchSuggestion>() 1313 val authorityToSourceMap: Map<String, MediaSource> = 1314 availableProviders.associate { provider -> provider.authority to provider.mediaSource } 1315 1316 if (this.moveToFirst()) { 1317 do { 1318 try { 1319 result.add( 1320 SearchSuggestion( 1321 mediaSetId = 1322 getString( 1323 getColumnIndexOrThrow( 1324 SearchSuggestionsResponse.MEDIA_SET_ID.key 1325 ) 1326 ), 1327 authority = 1328 getString( 1329 getColumnIndexOrThrow(SearchSuggestionsResponse.AUTHORITY.key) 1330 ), 1331 displayText = 1332 getString( 1333 getColumnIndexOrThrow(SearchSuggestionsResponse.SEARCH_TEXT.key) 1334 ), 1335 type = 1336 getSearchSuggestionType( 1337 getString( 1338 getColumnIndexOrThrow( 1339 SearchSuggestionsResponse.SUGGESTION_TYPE.key 1340 ) 1341 ) 1342 ), 1343 icon = 1344 this.getIcon( 1345 authorityToSourceMap, 1346 SearchSuggestionsResponse.COVER_MEDIA_URI.key, 1347 ), 1348 ) 1349 ) 1350 } catch (e: RuntimeException) { 1351 Log.e(TAG, "Received an invalid search suggestion. Skipping it.", e) 1352 } 1353 } while (moveToNext()) 1354 } 1355 1356 return result 1357 } 1358 1359 /** Creates a list of [Group.Category]-s and [Group.Album]-s from the given [Cursor]. */ 1360 private fun Cursor.getListOfCategoriesAndAlbums( 1361 availableProviders: List<Provider> 1362 ): List<Group> { 1363 val result: MutableList<Group> = mutableListOf<Group>() 1364 val authorityToSourceMap: Map<String, MediaSource> = 1365 availableProviders.associate { provider -> provider.authority to provider.mediaSource } 1366 1367 if (this.moveToFirst()) { 1368 do { 1369 try { 1370 val groupType = getString(getColumnIndexOrThrow(GroupResponse.MEDIA_GROUP.key)) 1371 when (groupType) { 1372 GroupType.CATEGORY.name -> { 1373 val icons: List<Icon> = 1374 listOf<Icon?>( 1375 this.getIcon( 1376 authorityToSourceMap, 1377 GroupResponse.UNWRAPPED_COVER_URI.key, 1378 ), 1379 this.getIcon( 1380 authorityToSourceMap, 1381 GroupResponse.ADDITIONAL_UNWRAPPED_COVER_URI_1.key, 1382 ), 1383 this.getIcon( 1384 authorityToSourceMap, 1385 GroupResponse.ADDITIONAL_UNWRAPPED_COVER_URI_2.key, 1386 ), 1387 this.getIcon( 1388 authorityToSourceMap, 1389 GroupResponse.ADDITIONAL_UNWRAPPED_COVER_URI_3.key, 1390 ), 1391 ) 1392 .filterNotNull() 1393 1394 result.add( 1395 Group.Category( 1396 id = 1397 getString( 1398 getColumnIndexOrThrow(GroupResponse.GROUP_ID.key) 1399 ), 1400 pickerId = 1401 getLong(getColumnIndexOrThrow(GroupResponse.PICKER_ID.key)), 1402 authority = 1403 getString( 1404 getColumnIndexOrThrow(GroupResponse.AUTHORITY.key) 1405 ), 1406 displayName = 1407 getString( 1408 getColumnIndexOrThrow(GroupResponse.DISPLAY_NAME.key) 1409 ), 1410 categoryType = 1411 KeyToCategoryType[ 1412 getString( 1413 getColumnIndexOrThrow( 1414 GroupResponse.CATEGORY_TYPE.key 1415 ) 1416 )] 1417 ?: throw IllegalArgumentException( 1418 "Could not recognize category type" 1419 ), 1420 icons = icons, 1421 isLeafCategory = 1422 getInt( 1423 getColumnIndexOrThrow( 1424 GroupResponse.IS_LEAF_CATEGORY.key 1425 ) 1426 ) == 1, 1427 ) 1428 ) 1429 } 1430 1431 GroupType.ALBUM.name -> { 1432 val coverUriString = 1433 getString( 1434 getColumnIndexOrThrow(GroupResponse.UNWRAPPED_COVER_URI.key) 1435 ) 1436 val coverUri = coverUriString?.let { Uri.parse(it) } ?: Uri.parse("") 1437 1438 result.add( 1439 Group.Album( 1440 id = 1441 getString( 1442 getColumnIndexOrThrow(GroupResponse.GROUP_ID.key) 1443 ), 1444 pickerId = 1445 getLong(getColumnIndexOrThrow(GroupResponse.PICKER_ID.key)), 1446 authority = 1447 getString( 1448 getColumnIndexOrThrow(GroupResponse.AUTHORITY.key) 1449 ), 1450 dateTakenMillisLong = 1451 Long.MAX_VALUE, // This is not used and will soon be 1452 // obsolete 1453 displayName = 1454 getString( 1455 getColumnIndexOrThrow(GroupResponse.DISPLAY_NAME.key) 1456 ), 1457 coverUri = coverUri, 1458 coverMediaSource = 1459 coverUri?.let { 1460 authorityToSourceMap[coverUri.getAuthority()] 1461 } ?: MediaSource.LOCAL, 1462 ) 1463 ) 1464 } 1465 1466 else -> { 1467 Log.w(TAG, "Invalid group type: $groupType") 1468 } 1469 } 1470 } catch (e: RuntimeException) { 1471 Log.w(TAG, "Could not extract category or album from cursor, skipping it", e) 1472 } 1473 } while (moveToNext()) 1474 } 1475 1476 return result 1477 } 1478 1479 /** Creates a list of [Group.MediaSet]-s from the given [Cursor]. */ 1480 private fun Cursor.getListOfMediaSets( 1481 availableProviders: List<Provider> 1482 ): List<Group.MediaSet> { 1483 val result: MutableList<Group.MediaSet> = mutableListOf<Group.MediaSet>() 1484 val authorityToSourceMap: Map<String, MediaSource> = 1485 availableProviders.associate { provider -> provider.authority to provider.mediaSource } 1486 1487 if (this.moveToFirst()) { 1488 do { 1489 try { 1490 result.add( 1491 Group.MediaSet( 1492 id = getString(getColumnIndexOrThrow(GroupResponse.GROUP_ID.key)), 1493 pickerId = getLong(getColumnIndexOrThrow(GroupResponse.PICKER_ID.key)), 1494 authority = 1495 getString(getColumnIndexOrThrow(GroupResponse.AUTHORITY.key)), 1496 displayName = 1497 getString(getColumnIndexOrThrow(GroupResponse.DISPLAY_NAME.key)), 1498 icon = 1499 this.getIcon( 1500 authorityToSourceMap, 1501 GroupResponse.UNWRAPPED_COVER_URI.key, 1502 ) ?: Icon(uri = Uri.parse(""), mediaSource = MediaSource.LOCAL), 1503 ) 1504 ) 1505 } catch (e: RuntimeException) { 1506 Log.w(TAG, "Could not extract media set from cursor, skipping it", e) 1507 } 1508 } while (moveToNext()) 1509 } 1510 1511 return result 1512 } 1513 1514 /** Creates an [Icon] object from the current [Cursor] row. If an error occurs, returns null. */ 1515 private fun Cursor.getIcon( 1516 authorityToSourceMap: Map<String, MediaSource>, 1517 columnName: String, 1518 ): Icon? { 1519 var unwrappedUriString: String? = null 1520 1521 try { 1522 unwrappedUriString = getString(getColumnIndexOrThrow(columnName)) 1523 } catch (e: RuntimeException) { 1524 Log.e(TAG, "Could not get unwrapped uri $unwrappedUriString from cursor", e) 1525 } 1526 1527 return unwrappedUriString?.let { 1528 val unwrappedUri: Uri = Uri.parse(unwrappedUriString) 1529 val authority: String? = unwrappedUri.getAuthority() 1530 val mediaSource: MediaSource = authorityToSourceMap[authority] ?: MediaSource.LOCAL 1531 val icon = Icon(unwrappedUri, mediaSource) 1532 icon 1533 } 1534 } 1535 1536 /** Convert the input search suggestion type string to enum */ 1537 private fun getSearchSuggestionType(stringSuggestionType: String?): SearchSuggestionType { 1538 requireNotNull(stringSuggestionType) { "Suggestion type is null" } 1539 1540 return KeyToSearchSuggestionType[stringSuggestionType] 1541 ?: throw IllegalArgumentException( 1542 "Unrecognized search suggestion type $stringSuggestionType" 1543 ) 1544 } 1545 1546 /** 1547 * Send a refresh [Media] request to MediaProvider with the prepared input args. This is a 1548 * signal for MediaProvider to refresh its cache, if required. 1549 */ 1550 private fun refreshMedia(extras: Bundle, contentResolver: ContentResolver) { 1551 try { 1552 contentResolver.call( 1553 MEDIA_PROVIDER_AUTHORITY, 1554 MEDIA_INIT_CALL_METHOD, 1555 /* arg */ null, 1556 extras, 1557 ) 1558 } catch (e: RuntimeException) { 1559 Log.e(TAG, "Could not send refresh media call to Media Provider $extras", e) 1560 } 1561 } 1562 } 1563