• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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