• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * 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.providers.media.photopicker.v2;
18 
19 import static android.provider.CloudMediaProviderContract.SEARCH_SUGGESTION_ALBUM;
20 
21 import static com.android.providers.media.MediaGrants.FILE_ID_COLUMN;
22 import static com.android.providers.media.MediaGrants.MEDIA_GRANTS_TABLE;
23 import static com.android.providers.media.MediaGrants.OWNER_PACKAGE_NAME_COLUMN;
24 import static com.android.providers.media.MediaGrants.PACKAGE_USER_ID_COLUMN;
25 import static com.android.providers.media.MediaProvider.isOwnedPhotosEnabled;
26 import static com.android.providers.media.PickerUriResolver.getAlbumUri;
27 import static com.android.providers.media.photopicker.PickerSyncController.getPackageNameFromUid;
28 import static com.android.providers.media.photopicker.PickerSyncController.uidToUserId;
29 import static com.android.providers.media.photopicker.data.PickerDbFacade.KEY_LOCAL_ID;
30 import static com.android.providers.media.photopicker.sync.PickerSyncManager.IMMEDIATE_GRANTS_SYNC_WORK_NAME;
31 import static com.android.providers.media.photopicker.sync.PickerSyncManager.IMMEDIATE_LOCAL_SYNC_WORK_NAME;
32 import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_CLOUD_ONLY;
33 import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_ONLY;
34 import static com.android.providers.media.photopicker.sync.WorkManagerInitializer.getWorkManager;
35 import static com.android.providers.media.photopicker.v2.SearchSuggestionsProvider.getDefaultSuggestions;
36 import static com.android.providers.media.photopicker.v2.SearchSuggestionsProvider.getSuggestionsFromCloudProvider;
37 import static com.android.providers.media.photopicker.v2.SearchSuggestionsProvider.getSuggestionsFromLocalProvider;
38 import static com.android.providers.media.photopicker.v2.SearchSuggestionsProvider.maybeCacheSearchSuggestions;
39 import static com.android.providers.media.photopicker.v2.SearchSuggestionsProvider.suggestionsToCursor;
40 import static com.android.providers.media.photopicker.v2.model.AlbumsCursorWrapper.EMPTY_MEDIA_ID;
41 import static com.android.providers.media.photopicker.v2.model.MediaGroup.ALBUM;
42 import static com.android.providers.media.photopicker.v2.model.MediaGroup.CATEGORY;
43 
44 import static java.util.Objects.requireNonNull;
45 
46 import android.annotation.UserIdInt;
47 import android.content.Context;
48 import android.content.Intent;
49 import android.content.pm.PackageManager;
50 import android.content.pm.PackageManager.NameNotFoundException;
51 import android.content.pm.ProviderInfo;
52 import android.database.Cursor;
53 import android.database.MatrixCursor;
54 import android.database.MergeCursor;
55 import android.database.sqlite.SQLiteDatabase;
56 import android.database.sqlite.SQLiteQueryBuilder;
57 import android.os.Bundle;
58 import android.os.CancellationSignal;
59 import android.os.Process;
60 import android.provider.CloudMediaProviderContract;
61 import android.provider.CloudMediaProviderContract.AlbumColumns;
62 import android.provider.MediaStore;
63 import android.util.Log;
64 import android.util.Pair;
65 
66 import androidx.annotation.NonNull;
67 import androidx.annotation.Nullable;
68 import androidx.work.WorkManager;
69 
70 import com.android.providers.media.flags.Flags;
71 import com.android.providers.media.photopicker.PickerSyncController;
72 import com.android.providers.media.photopicker.SearchState;
73 import com.android.providers.media.photopicker.sync.PickerSearchProviderClient;
74 import com.android.providers.media.photopicker.sync.PickerSyncManager;
75 import com.android.providers.media.photopicker.sync.SyncCompletionWaiter;
76 import com.android.providers.media.photopicker.sync.SyncTrackerRegistry;
77 import com.android.providers.media.photopicker.util.exceptions.RequestObsoleteException;
78 import com.android.providers.media.photopicker.util.exceptions.UnableToAcquireLockException;
79 import com.android.providers.media.photopicker.v2.model.AlbumMediaQuery;
80 import com.android.providers.media.photopicker.v2.model.AlbumsCursorWrapper;
81 import com.android.providers.media.photopicker.v2.model.MediaGroup;
82 import com.android.providers.media.photopicker.v2.model.MediaInMediaSetSyncRequestParams;
83 import com.android.providers.media.photopicker.v2.model.MediaQuery;
84 import com.android.providers.media.photopicker.v2.model.MediaQueryForPreSelection;
85 import com.android.providers.media.photopicker.v2.model.MediaSetsSyncRequestParams;
86 import com.android.providers.media.photopicker.v2.model.MediaSource;
87 import com.android.providers.media.photopicker.v2.model.PreviewMediaQuery;
88 import com.android.providers.media.photopicker.v2.model.ProviderCollectionInfo;
89 import com.android.providers.media.photopicker.v2.model.SearchRequest;
90 import com.android.providers.media.photopicker.v2.model.SearchSuggestion;
91 import com.android.providers.media.photopicker.v2.model.SearchSuggestionRequest;
92 import com.android.providers.media.photopicker.v2.sqlite.MediaGroupCursorUtils;
93 import com.android.providers.media.photopicker.v2.sqlite.MediaInMediaSetsDatabaseUtil;
94 import com.android.providers.media.photopicker.v2.sqlite.MediaInMediaSetsQuery;
95 import com.android.providers.media.photopicker.v2.sqlite.MediaSetsDatabaseUtil;
96 import com.android.providers.media.photopicker.v2.sqlite.PickerMediaDatabaseUtil;
97 import com.android.providers.media.photopicker.v2.sqlite.PickerSQLConstants;
98 import com.android.providers.media.photopicker.v2.sqlite.SearchMediaQuery;
99 import com.android.providers.media.photopicker.v2.sqlite.SearchRequestDatabaseUtil;
100 import com.android.providers.media.photopicker.v2.sqlite.SearchResultsDatabaseUtil;
101 import com.android.providers.media.photopicker.v2.sqlite.SearchSuggestionsDatabaseUtils;
102 import com.android.providers.media.photopicker.v2.sqlite.SearchSuggestionsQuery;
103 
104 import java.util.ArrayList;
105 import java.util.Arrays;
106 import java.util.HashMap;
107 import java.util.HashSet;
108 import java.util.List;
109 import java.util.Locale;
110 import java.util.Map;
111 import java.util.Objects;
112 import java.util.Set;
113 import java.util.concurrent.CompletableFuture;
114 import java.util.concurrent.ExecutionException;
115 import java.util.concurrent.Executor;
116 import java.util.concurrent.ForkJoinPool;
117 import java.util.concurrent.TimeUnit;
118 import java.util.concurrent.TimeoutException;
119 
120 /**
121  * This class handles Photo Picker content queries.
122  */
123 public class PickerDataLayerV2 {
124     private static final String TAG = "PickerDataLayerV2";
125     private static final int CLOUD_SYNC_TIMEOUT_MILLIS = 500;
126     private static final String MEDIA_TABLE = "media";
127     private static final String MEDIA_LEFT_JOIN_MEDIA_GRANTS_TABLE =
128             MEDIA_TABLE + " LEFT JOIN " + MEDIA_GRANTS_TABLE
129                     + " ON " + MEDIA_TABLE + "." + KEY_LOCAL_ID
130                     + " = " + MEDIA_GRANTS_TABLE + "." + FILE_ID_COLUMN;
131 
132     // Local and merged albums have a predefined order that they should be displayed in. They always
133     // need to be displayed above the cloud albums too.
134     public static final List<String> PINNED_ALBUMS_ORDER = List.of(
135             AlbumColumns.ALBUM_ID_FAVORITES,
136             AlbumColumns.ALBUM_ID_CAMERA,
137             AlbumColumns.ALBUM_ID_VIDEOS,
138             AlbumColumns.ALBUM_ID_SCREENSHOTS,
139             AlbumColumns.ALBUM_ID_DOWNLOADS
140     );
141 
142     // Pinned albums and categories have a predefined order that they should be displayed in.
143     public static final List<Pair<MediaGroup, String>> PINNED_CATEGORIES_AND_ALBUMS_ORDER = List.of(
144             new Pair<>(ALBUM, AlbumColumns.ALBUM_ID_FAVORITES),
145             new Pair<>(ALBUM, AlbumColumns.ALBUM_ID_CAMERA),
146             new Pair<>(CATEGORY, CloudMediaProviderContract.MEDIA_CATEGORY_TYPE_PEOPLE_AND_PETS),
147             new Pair<>(ALBUM, AlbumColumns.ALBUM_ID_DOWNLOADS),
148             new Pair<>(ALBUM, AlbumColumns.ALBUM_ID_SCREENSHOTS),
149             new Pair<>(ALBUM, AlbumColumns.ALBUM_ID_VIDEOS)
150     );
151 
152     // Set of known merged albums.
153     public static final Set<String> MERGED_ALBUMS = Set.of(
154             AlbumColumns.ALBUM_ID_FAVORITES,
155             AlbumColumns.ALBUM_ID_VIDEOS
156     );
157 
158     // Set of known local albums.
159     public static final Set<String> LOCAL_ALBUMS = Set.of(
160             AlbumColumns.ALBUM_ID_CAMERA,
161             AlbumColumns.ALBUM_ID_SCREENSHOTS,
162             AlbumColumns.ALBUM_ID_DOWNLOADS
163     );
164 
165     /**
166      * Table used to store the items for which the app hold read grants but have been de-selected
167      * by the user in the current photo-picker session.
168      */
169     public static final String DE_SELECTIONS_TABLE = "de_selections";
170 
171     /**
172      * Table used to store the items for which the app hold read grants but have been de-selected
173      * by the user in the current photo-picker session, filtered by calling package name and userId.
174      */
175     public static final String CURRENT_DE_SELECTIONS_TABLE = "current_de_selections";
176 
177     private static final String IS_FIRST_PAGE = "is_first_page";
178     /**
179      * In SQL joins for media_grants table, it is filtered to only provide the rows corresponding to
180      * the current package and userId. This is the name for the filtered table that is computed in a
181      * sub-query. Any references to the columns for media_grants table should use this table name
182      * instead.
183      */
184     public static final String CURRENT_GRANTS_TABLE = "current_media_grants";
185 
186     public static final String COLUMN_GRANTS_COUNT = "grants_count";
187 
188     private static final String PROJECTION_GRANTS_COUNT = String.format(
189             Locale.ROOT, "COUNT(*) AS %s",
190             COLUMN_GRANTS_COUNT);
191 
192     /**
193      * Refresh the cloud provider in-memory cache in PickerSyncController.
194      */
ensureProviders()195     public static void ensureProviders() {
196         try {
197             final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
198             syncController.maybeEnableCloudMediaQueries();
199         } catch (UnableToAcquireLockException | RequestObsoleteException exception) {
200             Log.e(TAG, "Could not ensure that the providers are set.");
201         }
202     }
203 
204     /**
205      * Returns a cursor with the Photo Picker media in response.
206      *
207      * @param appContext The application context.
208      * @param queryArgs The arguments help us filter on the media query to yield the desired
209      *                  results.
210      */
211     @NonNull
queryMedia(@onNull Context appContext, @NonNull Bundle queryArgs)212     public static Cursor queryMedia(@NonNull Context appContext, @NonNull Bundle queryArgs) {
213         final MediaQuery query = new MediaQuery(queryArgs);
214         final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
215         final String effectiveLocalAuthority =
216                 query.getProviders().contains(syncController.getLocalProvider())
217                         ? syncController.getLocalProvider()
218                         : null;
219         final String cloudAuthority = syncController
220                 .getCloudProviderOrDefault(/* defaultValue */ null);
221         final String effectiveCloudAuthority =
222                 syncController.shouldQueryCloudMedia(query.getProviders(), cloudAuthority)
223                         ? cloudAuthority
224                         : null;
225 
226         waitForOngoingSync(appContext, effectiveLocalAuthority, effectiveCloudAuthority,
227                 query.getIntentAction());
228 
229         return PickerMediaDatabaseUtil.queryMedia(
230                 appContext,
231                 syncController,
232                 query,
233                 effectiveLocalAuthority,
234                 effectiveCloudAuthority
235         );
236     }
237 
238     /**
239      * Returns a cursor with the Photo Picker media in response.
240      *
241      * @param appContext The application context.
242      * @param queryArgs The arguments help us filter on the media query to yield the desired
243      *                  results.
244      */
245     @NonNull
queryPreviewMedia(@onNull Context appContext, @NonNull Bundle queryArgs)246     static Cursor queryPreviewMedia(@NonNull Context appContext, @NonNull Bundle queryArgs) {
247         final PreviewMediaQuery query = new PreviewMediaQuery(queryArgs, appContext);
248 
249         final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
250         final String effectiveLocalAuthority =
251                 query.getProviders().contains(syncController.getLocalProvider())
252                         ? syncController.getLocalProvider()
253                         : null;
254         final String cloudAuthority = syncController
255                 .getCloudProviderOrDefault(/* defaultValue */ null);
256         final String effectiveCloudAuthority =
257                 syncController.shouldQueryCloudMedia(query.getProviders(), cloudAuthority)
258                         ? cloudAuthority
259                         : null;
260 
261         if (queryArgs.getBoolean(IS_FIRST_PAGE)) {
262             PreviewMediaQuery.insertDeSelections(appContext, syncController,
263                     query.getCallingPackageUid(), query.getCurrentDeSelection());
264         }
265 
266         waitForOngoingSync(appContext, effectiveLocalAuthority, effectiveCloudAuthority,
267                 query.getIntentAction());
268 
269         return PickerMediaDatabaseUtil.queryMedia(
270                 appContext,
271                 syncController,
272                 query,
273                 effectiveLocalAuthority,
274                 effectiveCloudAuthority
275         );
276     }
277 
278     /**
279      * Returns a cursor with cached media sets in response
280      * @param queryArgs The arguments to filter and fetch media sets
281      */
282     @NonNull
queryMediaSets(@onNull Bundle queryArgs)283     public static Cursor queryMediaSets(@NonNull Bundle queryArgs) {
284         requireNonNull(queryArgs);
285 
286         MediaSetsSyncRequestParams requestParams = new MediaSetsSyncRequestParams(queryArgs);
287         PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
288         final Set<String> providers = new HashSet<>(
289                 Objects.requireNonNull(queryArgs.getStringArrayList("providers")));
290         final String effectiveLocalAuthority = providers.contains(
291                 syncController.getLocalProvider()) ? syncController.getLocalProvider() : null;
292         final String currentCloudAuthority = syncController
293                 .getCloudProviderOrDefault(/*defaultValue*/ null);
294         final String effectiveCloudAuthority = syncController
295                 .shouldQueryCloudMediaSets(providers, currentCloudAuthority)
296                 ? currentCloudAuthority : null;
297 
298         waitForOngoingMediaSetsSync(effectiveLocalAuthority, effectiveCloudAuthority);
299 
300         Cursor mediaSetsCursor = MediaSetsDatabaseUtil.getMediaSetsForCategory(
301                 syncController.getDbFacade().getDatabase(),
302                 requestParams
303                );
304 
305         return MediaGroupCursorUtils.getMediaGroupCursorForMediaSets(mediaSetsCursor);
306     }
307 
308     /**
309      * Returns a cursor with the Photo Picker albums in response.
310      *
311      * @param appContext The application context.
312      * @param queryArgs The arguments help us filter on the media query to yield the desired
313      *                  results.
314      */
315     @Nullable
queryAlbums(@onNull Context appContext, @NonNull Bundle queryArgs)316     static Cursor queryAlbums(@NonNull Context appContext, @NonNull Bundle queryArgs) {
317         final MediaQuery query = new MediaQuery(queryArgs);
318         final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
319         final SQLiteDatabase database = syncController.getDbFacade().getDatabase();
320         final String localAuthority = syncController.getLocalProvider();
321         final boolean shouldShowLocalAlbums = query.getProviders().contains(localAuthority);
322         final String cloudAuthority =
323                 syncController.getCloudProviderOrDefault(/* defaultValue */ null);
324         final boolean shouldShowCloudAlbums = syncController.shouldQueryCloudMedia(
325                 query.getProviders(), cloudAuthority);
326         final List<AlbumsCursorWrapper> allAlbumCursors = new ArrayList<>();
327 
328         final String effectiveLocalAuthority = shouldShowLocalAlbums ? localAuthority : null;
329         final String effectiveCloudAuthority = shouldShowCloudAlbums ? cloudAuthority : null;
330 
331         // Get all local albums from the local provider in separate cursors to facilitate zipping
332         // them with merged albums.
333         final Map<String, AlbumsCursorWrapper> localAlbums = getLocalAlbumCursors(
334                 appContext, query, effectiveLocalAuthority);
335 
336         // Add Pinned album cursors to the list of all album cursors in the order in which they
337         // should be displayed. Note that pinned albums can only be local and merged albums.
338         for (String albumId: PINNED_ALBUMS_ORDER) {
339             final AlbumsCursorWrapper albumCursor;
340             if (MERGED_ALBUMS.contains(albumId)) {
341                 albumCursor = PickerMediaDatabaseUtil.getMergedAlbumsCursor(
342                         albumId, appContext, queryArgs, database,
343                         effectiveLocalAuthority, effectiveCloudAuthority);
344             } else if (LOCAL_ALBUMS.contains(albumId)) {
345                 albumCursor = localAlbums.getOrDefault(albumId, null);
346             } else {
347                 Log.e(TAG, "Could not recognize pinned album id, skipping it : " + albumId);
348                 albumCursor = null;
349             }
350             allAlbumCursors.add(albumCursor);
351         }
352 
353         // Add cloud albums at the end.
354         // This is an external query into the CMP, so catch any exceptions that might get thrown
355         // so that at a minimum, the local results are sent back to the UI.
356         try {
357             allAlbumCursors.add(getCloudAlbumsCursor(appContext, query, localAuthority,
358                     effectiveCloudAuthority));
359         } catch (RuntimeException ex) {
360             Log.w(TAG, "Cloud provider exception while fetching cloud albums cursor", ex);
361         }
362 
363         // Remove empty cursors.
364         allAlbumCursors.removeIf(it -> it == null || !it.moveToFirst());
365 
366         if (allAlbumCursors.isEmpty()) {
367             Log.e(TAG, "No albums available");
368             return null;
369         } else {
370             Cursor mergeCursor = new MergeCursor(allAlbumCursors.toArray(new Cursor[0]));
371             Log.i(TAG, "Returning " + mergeCursor.getCount() + " albums' metadata");
372             return mergeCursor;
373         }
374     }
375 
376     /**
377      * Returns a cursor with the Photo Picker albums and categories in response.
378      *
379      * @param appContext The application context.
380      * @param queryArgs The arguments help us filter on the media query to yield the desired
381      *                  results.
382      * @param cancellationSignal CancellationSignal object that notifies if the request has been
383      *                           cancelled.
384      */
385     @Nullable
queryCategoriesAndAlbums( @onNull Context appContext, @NonNull Bundle queryArgs, @Nullable CancellationSignal cancellationSignal)386     public static Cursor queryCategoriesAndAlbums(
387             @NonNull Context appContext,
388             @NonNull Bundle queryArgs,
389             @Nullable CancellationSignal cancellationSignal) {
390         final MediaQuery query = new MediaQuery(queryArgs);
391         final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
392         final String localAuthority = syncController.getLocalProvider();
393         final boolean shouldShowLocalAlbums = query.getProviders().contains(localAuthority);
394         final String cloudAuthority =
395                 syncController.getCloudProviderOrDefault(/* defaultValue */ null);
396         final boolean shouldShowCloudAlbums = syncController.shouldQueryCloudMedia(
397                 query.getProviders(), cloudAuthority);
398 
399         final String effectiveLocalAuthority = shouldShowLocalAlbums ? localAuthority : null;
400         final String effectiveCloudAuthority = shouldShowCloudAlbums ? cloudAuthority : null;
401 
402         final SQLiteDatabase database = PickerSyncController.getInstanceOrThrow()
403                 .getDbFacade().getDatabase();
404         final List<Cursor> allMediaGroupCursors = new ArrayList<>();
405 
406         // Get all local albums from the local provider in separate cursors to facilitate zipping
407         // them with merged albums.
408         final Map<String, AlbumsCursorWrapper> localAlbums = getLocalAlbumCursors(
409                 appContext, query, effectiveLocalAuthority);
410 
411         // Get cloud categories from cloud provider.
412         final Cursor categories = getCloudCategories(
413                 appContext, query, effectiveCloudAuthority, syncController, cancellationSignal);
414 
415         // Add Pinned album and categories to the list of cursors in the order in which they
416         // should be displayed. Note that pinned albums can only be local and merged albums.
417         long index = 0;
418         for (Pair<MediaGroup, String> mediaGroup: PINNED_CATEGORIES_AND_ALBUMS_ORDER) {
419             final Cursor cursor;
420             switch (mediaGroup.first) {
421                 case ALBUM:
422                     final String albumId = mediaGroup.second;
423                     if (MERGED_ALBUMS.contains(albumId)) {
424                         final Cursor mergedAlbumCursor =
425                                 PickerMediaDatabaseUtil.getMergedAlbumsCursor(
426                                 albumId, appContext, queryArgs, database, effectiveLocalAuthority,
427                                 effectiveCloudAuthority);
428                         cursor = MediaGroupCursorUtils.getMediaGroupCursorForAlbums(
429                                 mergedAlbumCursor, index);
430                     } else if (LOCAL_ALBUMS.contains(albumId)) {
431                         final Cursor localAlbumCursor = localAlbums.getOrDefault(albumId, null);
432                         cursor = MediaGroupCursorUtils.getMediaGroupCursorForAlbums(
433                                 localAlbumCursor, index);
434                     } else {
435                         Log.e(TAG, "Could not recognize pinned album id, skipping it : " + albumId);
436                         cursor = null;
437                     }
438 
439                     break;
440                 case CATEGORY:
441                     switch (mediaGroup.second) {
442                         case CloudMediaProviderContract.MEDIA_CATEGORY_TYPE_PEOPLE_AND_PETS:
443                             cursor = MediaGroupCursorUtils.getMediaGroupCursorForCategories(
444                                     categories, effectiveCloudAuthority, index);
445                             break;
446                         default:
447                             Log.e(TAG, "Could not recognize pinned category type, skipping it : "
448                                     + mediaGroup.second);
449                             cursor = null;
450                     }
451 
452                     break;
453                 default:
454                     Log.e(TAG, "Could not recognize media group, skipping it : " + mediaGroup);
455                     cursor = null;
456             }
457 
458             if (cursor != null) {
459                 index += cursor.getCount();
460                 allMediaGroupCursors.add(cursor);
461             }
462         }
463 
464         // Add cloud albums at the end.
465         // This is an external query into the CMP, so catch any exceptions that might get thrown
466         // so that at a minimum, the local results are sent back to the UI.
467         try {
468             final Cursor cloudAlbumsCursor = getCloudAlbumsCursor(appContext, query,
469                     localAuthority, effectiveCloudAuthority);
470             allMediaGroupCursors.add(
471                     MediaGroupCursorUtils.getMediaGroupCursorForAlbums(cloudAlbumsCursor, index));
472         } catch (RuntimeException ex) {
473             Log.w(TAG, "Cloud provider exception while fetching cloud albums cursor", ex);
474         }
475 
476         // Remove empty cursors.
477         allMediaGroupCursors.removeIf(it -> it == null || !it.moveToFirst());
478 
479         if (allMediaGroupCursors.isEmpty()) {
480             Log.e(TAG, "No categories or albums available");
481             return null;
482         } else {
483             Cursor mergeCursor = new MergeCursor(
484                     allMediaGroupCursors.toArray(
485                             new Cursor[allMediaGroupCursors.size()]));
486             Log.i(TAG, "Returning " + mergeCursor.getCount() + " categories and albums.");
487             return mergeCursor;
488         }
489     }
490 
491     /**
492      * Returns a cursor with the Photo Picker album media in response.
493      *
494      * @param appContext The application context.
495      * @param queryArgs The arguments help us filter on the media query to yield the desired
496      *                  results.
497      * @param albumId The album ID of the requested album media.
498      */
queryAlbumMedia( @onNull Context appContext, @NonNull Bundle queryArgs, @NonNull String albumId)499     static Cursor queryAlbumMedia(
500             @NonNull Context appContext,
501             @NonNull Bundle queryArgs,
502             @NonNull String albumId) {
503         final AlbumMediaQuery query = new AlbumMediaQuery(queryArgs, albumId);
504         final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
505         final String effectiveLocalAuthority =
506                 query.getProviders().contains(syncController.getLocalProvider())
507                         ? syncController.getLocalProvider()
508                         : null;
509         final String cloudAuthority = syncController
510                 .getCloudProviderOrDefault(/* defaultValue */ null);
511         final String effectiveCloudAuthority =
512                 syncController.shouldQueryCloudMedia(query.getProviders(), cloudAuthority)
513                         ? cloudAuthority
514                         : null;
515 
516         if (MERGED_ALBUMS.contains(albumId)) {
517             waitForOngoingSync(appContext, effectiveLocalAuthority, effectiveCloudAuthority,
518                     query.getIntentAction());
519 
520             return PickerMediaDatabaseUtil.queryMergedAlbumMedia(
521                     albumId,
522                     appContext,
523                     syncController,
524                     queryArgs,
525                     effectiveLocalAuthority,
526                     effectiveCloudAuthority
527             );
528         } else {
529             waitForOngoingAlbumSync(appContext, query, effectiveLocalAuthority);
530 
531             return PickerMediaDatabaseUtil.queryAlbumMedia(
532                     appContext,
533                     syncController,
534                     query,
535                     effectiveLocalAuthority,
536                     effectiveCloudAuthority
537             );
538         }
539     }
540 
541     /**
542      * Returns a cursor with the Photo Picker search results media in response.
543      *
544      * @param queryArgs The arguments help us filter on the media query to yield the desired
545      *                  results.
546      * @param searchRequestID Identifier of the search request.
547      */
querySearchMedia( @onNull Context appContext, @NonNull Bundle queryArgs, int searchRequestID)548     public static Cursor querySearchMedia(
549             @NonNull Context appContext,
550             @NonNull Bundle queryArgs,
551             int searchRequestID) {
552         final SearchMediaQuery query = new SearchMediaQuery(queryArgs, searchRequestID);
553 
554         // Validate query input
555         if (MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP.equals(query.getIntentAction())) {
556             throw new RuntimeException("Search feature cannot be enabled with Picker Choice");
557         }
558 
559         final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
560         final String effectiveLocalAuthority =
561                 query.getProviders().contains(syncController.getLocalProvider())
562                         ? syncController.getLocalProvider()
563                         : null;
564         final String cloudAuthority = syncController
565                 .getCloudProviderOrDefault(/* defaultValue */ null);
566         final String effectiveCloudAuthority =
567                 syncController.shouldQueryCloudMedia(query.getProviders(), cloudAuthority)
568                         ? cloudAuthority
569                         : null;
570 
571         waitForOngoingSearchResultSync(effectiveLocalAuthority, effectiveCloudAuthority);
572         // TODO(b/361042632) resume sync if required
573 
574         return SearchResultsDatabaseUtil.querySearchMedia(
575                 syncController,
576                 query,
577                 effectiveLocalAuthority,
578                 effectiveCloudAuthority
579         );
580     }
581 
582     /**
583      * Returns a cursor with the cached content of a media set in response
584      * @param queryArgs The arguments to filter and fetch media set content
585      */
queryMediaInMediaSet(@onNull Bundle queryArgs)586     public static Cursor queryMediaInMediaSet(@NonNull Bundle queryArgs) {
587 
588         requireNonNull(queryArgs);
589 
590         MediaInMediaSetSyncRequestParams requestParams =
591                 new MediaInMediaSetSyncRequestParams(queryArgs);
592         MediaInMediaSetsQuery query = new MediaInMediaSetsQuery(
593                 queryArgs, requestParams.getMediaSetPickerId()
594         );
595 
596         if (MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP.equals(query.getIntentAction())) {
597             throw new RuntimeException("Search feature cannot be enabled with PickerChoice. "
598                     + "Can't query MediaSet content");
599         }
600 
601         PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
602         final Set<String> providers = new HashSet<>(query.getProviders());
603         final String effectiveLocalAuthority = syncController
604                 .getLocalProvider().equals(requestParams.getAuthority())
605                 ? requestParams.getAuthority() : null;
606         String currentCloudAuthority = syncController.getCloudProviderOrDefault(
607                 /*defaultValue*/ null);
608         final String effectiveCloudAuthority = syncController
609                 .shouldQueryCloudMediaSets(providers, currentCloudAuthority)
610                 ? currentCloudAuthority : null;
611 
612         waitForOngoingMediaInMediaSetSync(effectiveLocalAuthority, effectiveCloudAuthority);
613 
614         return MediaInMediaSetsDatabaseUtil.queryMediaInMediaSet(
615                 syncController, query, effectiveLocalAuthority, effectiveCloudAuthority);
616 
617     }
618 
619     /**
620      * Get search suggestions for a given prefix from the cloud media provider and search history.
621      * In case cloud media provider is taking time in returning the suggestion results, we'll try to
622      * fallback on previously cached search results.
623      *
624      * @param appContext Application context.
625      * @param queryArgs The arguments help us filter on the media query to get the desired results.
626      * @param cancellationSignal CancellationSignal that indicates that the client has cancelled
627      *                           the suggestions request and the results are not needed anymore.
628      * @return A cursor with search suggestion data.
629      * See {@link PickerSQLConstants.SearchSuggestionsResponseColumns}.
630      */
querySearchSuggestions( @onNull Context appContext, @NonNull Bundle queryArgs, @Nullable CancellationSignal cancellationSignal)631     static Cursor querySearchSuggestions(
632             @NonNull Context appContext,
633             @NonNull Bundle queryArgs,
634             @Nullable CancellationSignal cancellationSignal) {
635         // By default use ForkJoinPool.commonPool() to reduce resource usage instead of creating a
636         // custom pool. Its threads are slowly reclaimed during periods of non-use, and reinstated
637         // upon subsequent use.
638         return querySearchSuggestions(appContext, queryArgs, ForkJoinPool.commonPool(),
639                 cancellationSignal);
640     }
641 
642     /**
643      * Get search suggestions for a given prefix from the cloud media provider and search history.
644      * In case cloud media provider is taking time in returning the suggestion results, we'll try to
645      * fallback on previously cached search results.
646      *
647      * @param appContext Application context.
648      * @param queryArgs The arguments help us filter on the media query to get the desired results.
649      * @param executor The executor used to run async tasks.
650      * @param cancellationSignal CancellationSignal that indicates that the client has cancelled
651      *                           the suggestions request and the results are not needed anymore.
652      * @return A cursor with search suggestion data.
653      * See {@link PickerSQLConstants.SearchSuggestionsResponseColumns}.
654      */
querySearchSuggestions( @onNull Context appContext, @NonNull Bundle queryArgs, @NonNull Executor executor, @Nullable CancellationSignal cancellationSignal)655     static Cursor querySearchSuggestions(
656             @NonNull Context appContext,
657             @NonNull Bundle queryArgs,
658             @NonNull Executor executor,
659             @Nullable CancellationSignal cancellationSignal) {
660         final SearchSuggestionsQuery query = new SearchSuggestionsQuery(queryArgs);
661 
662         // Attempt to fetch search suggestions from CMPs within the given timeout.
663         List<SearchSuggestion> cloudSearchSuggestions = new ArrayList<>();
664         CompletableFuture<List<SearchSuggestion>> cloudSuggestionsFuture =
665                 CompletableFuture.supplyAsync(() ->
666                         getSuggestionsFromCloudProvider(appContext, query, cancellationSignal),
667                         executor);
668 
669         List<SearchSuggestion> localSearchSuggestions = new ArrayList<>();
670         CompletableFuture<List<SearchSuggestion>> localSuggestionsFuture =
671                 CompletableFuture.supplyAsync(() ->
672                         getSuggestionsFromLocalProvider(appContext, query, cancellationSignal),
673                         executor);
674         try {
675             localSearchSuggestions = localSuggestionsFuture.get(
676                     /* timeout */ 200, TimeUnit.MILLISECONDS);
677         } catch (TimeoutException e) {
678             Log.e(TAG, "Could not get search suggestions from local provider on time");
679             localSuggestionsFuture.cancel(/* mayInterruptIfRunning */ false);
680         } catch (RuntimeException | ExecutionException | InterruptedException e) {
681             Log.e(TAG, ("Something went wrong, "
682                     + "could not fetch search results from the local provider"), e);
683         }
684 
685         try {
686             cloudSearchSuggestions = cloudSuggestionsFuture.get(
687                     /* timeout */ 1500, TimeUnit.MILLISECONDS);
688             cloudSuggestionsFuture.thenApplyAsync(
689                     (suggestions) -> maybeCacheSearchSuggestions(query, suggestions),
690                     executor);
691         } catch (TimeoutException e) {
692             Log.e(TAG, "Could not get search suggestions from cloud provider on time");
693 
694             // Only cancel suggestion request if the results don't need to be cached.
695             if (!query.isZeroState()) {
696                 cloudSuggestionsFuture.cancel(/* mayInterruptIfRunning */ false);
697             }
698         } catch (RuntimeException | ExecutionException | InterruptedException e) {
699             Log.e(TAG, ("Something went wrong, "
700                     + "could not fetch search results from the cloud provider"), e);
701         }
702 
703         // Fallback to cached suggestions if required.
704         if (cloudSearchSuggestions.isEmpty()) {
705             Log.d(TAG, "Attempting to fallback on cached search suggestions");
706             cloudSearchSuggestions = SearchSuggestionsDatabaseUtils.getCachedSuggestions(
707                     PickerSyncController.getInstanceOrThrow().getDbFacade().getDatabase(),
708                     query
709             );
710         }
711 
712         // Get History Suggestions
713         final List<SearchSuggestion> historySuggestions =
714                 SearchSuggestionsDatabaseUtils.getHistorySuggestions(
715                         PickerSyncController.getInstanceOrThrow().getDbFacade().getDatabase(),
716                         query);
717 
718         // Get Default Suggestions
719         final List<SearchSuggestion> defaultSuggestions = getDefaultSuggestions();
720 
721         // Merge suggestions in the order of priority
722         final List<SearchSuggestion> result = new ArrayList<>();
723         result.addAll(historySuggestions);
724         result.addAll(cloudSearchSuggestions);
725         result.addAll(localSearchSuggestions);
726         result.addAll(defaultSuggestions);
727 
728         // Remove extra suggestions if the result exceeds the limit.
729         if (result.size() > query.getLimit()) {
730             result.subList(result.size() - query.getLimit(), result.size()).clear();
731         }
732 
733         return suggestionsToCursor(result);
734     }
735 
736     /**
737      * Queries the picker database and fetches the count of pre-granted media. Returns count of
738      * media either owned by the app or user has granted access to.
739      *
740      * @return a [Cursor] containing only one column [COLUMN_GRANTS_COUNT] which have a single
741      * row representing the count.
742      */
fetchCountForPreGrantedItems( @onNull Context appContext, @NonNull Bundle queryArgs)743     static Cursor fetchCountForPreGrantedItems(
744             @NonNull Context appContext,
745             @NonNull Bundle queryArgs) {
746         String[] projectionIn = new String[]{PROJECTION_GRANTS_COUNT};
747         final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
748         final SQLiteDatabase database = syncController.getDbFacade().getDatabase();
749 
750         waitForOngoingGrantsSync(appContext);
751 
752         int packageUid = queryArgs.getInt(Intent.EXTRA_UID);
753         int userId = uidToUserId(packageUid);
754         String[] packageNames = getPackageNameFromUid(appContext,
755                 packageUid);
756 
757         SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
758         if (isOwnedPhotosEnabled(packageUid)) {
759             waitForOngoingSync(appContext, syncController.getLocalProvider(), null,
760                     MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP);
761 
762             qb.setTables(MEDIA_LEFT_JOIN_MEDIA_GRANTS_TABLE);
763 
764             String packageSelectionForMediaGrants = getPackageSelectionWhereClause(packageNames,
765                     MEDIA_GRANTS_TABLE).toString();
766             String packageSelectionForMedia = getPackageSelectionWhereClause(packageNames,
767                     MEDIA_TABLE).toString();
768 
769             /*
770             (media.owner_package_name IN (com.android.example) AND media._user_id = 0) OR
771             (media_grants.owner_package_name IN (com.android.example) AND media_grants._user_id = 0)
772              */
773             String whereClause = String.format(Locale.ROOT,
774                     "(%s AND %s.%s = %d) OR (%s AND %s.%s = %d)",
775                     packageSelectionForMedia,
776                     MEDIA_TABLE, MediaStore.Files.FileColumns._USER_ID, userId,
777                     packageSelectionForMediaGrants,
778                     MEDIA_GRANTS_TABLE, PACKAGE_USER_ID_COLUMN, userId);
779 
780             qb.appendWhereStandalone(whereClause);
781         } else {
782             qb.setTables(MEDIA_GRANTS_TABLE);
783             addWhereClausesForPackageAndUserIdSelection(userId, packageNames, MEDIA_GRANTS_TABLE,
784                     qb);
785         }
786 
787         Cursor result = qb.query(database, projectionIn, null,
788                 null, null, null, null);
789         return result;
790     }
791 
792     /**
793      * Adds the clause to select rows based on calling packageName and userId.
794      */
addWhereClausesForPackageAndUserIdSelection(int userId, @NonNull String[] packageNames, String table, SQLiteQueryBuilder qb)795     public static void addWhereClausesForPackageAndUserIdSelection(int userId,
796             @NonNull String[] packageNames, String table, SQLiteQueryBuilder qb) {
797         // Add where clause for userId selection.
798         qb.appendWhereStandalone(
799                 String.format(Locale.ROOT,
800                         "%s.%s = %d", table, PACKAGE_USER_ID_COLUMN, userId));
801 
802         // Add where clause for package name selection.
803         Objects.requireNonNull(packageNames);
804         qb.appendWhereStandalone(getPackageSelectionWhereClause(packageNames,
805                 table).toString());
806     }
807 
waitForOngoingSync( @onNull Context appContext, @Nullable String localAuthority, @Nullable String cloudAuthority, String intentAction)808     private static void waitForOngoingSync(
809             @NonNull Context appContext,
810             @Nullable String localAuthority,
811             @Nullable String cloudAuthority, String intentAction) {
812         // when the intent action is ACTION_USER_SELECT_IMAGES_FOR_APP, the flow should wait for
813         // the sync of grants and since this is a localOnly session. It should not wait or check
814         // cloud media.
815         boolean isUserSelectAction = MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP.equals(
816                 intentAction);
817         if (localAuthority != null) {
818             SyncCompletionWaiter.waitForSync(
819                     getWorkManager(appContext),
820                     SyncTrackerRegistry.getLocalSyncTracker(),
821                     IMMEDIATE_LOCAL_SYNC_WORK_NAME
822             );
823             if (isUserSelectAction) {
824                 SyncCompletionWaiter.waitForSync(
825                         getWorkManager(appContext),
826                         SyncTrackerRegistry.getGrantsSyncTracker(),
827                         IMMEDIATE_GRANTS_SYNC_WORK_NAME
828                 );
829             }
830         }
831 
832         if (cloudAuthority != null && !isUserSelectAction) {
833             boolean syncIsComplete = SyncCompletionWaiter.waitForSyncWithTimeout(
834                     SyncTrackerRegistry.getCloudSyncTracker(),
835                     CLOUD_SYNC_TIMEOUT_MILLIS);
836             Log.i(TAG, "Finished waiting for cloud sync.  Is cloud sync complete: "
837                     + syncIsComplete);
838         }
839     }
840 
waitForOngoingGrantsSync( @onNull Context appContext)841     private static void waitForOngoingGrantsSync(
842             @NonNull Context appContext) {
843         SyncCompletionWaiter.waitForSync(
844                 getWorkManager(appContext),
845                 SyncTrackerRegistry.getGrantsSyncTracker(),
846                 IMMEDIATE_GRANTS_SYNC_WORK_NAME
847         );
848     }
849 
850     /**
851      * @param appContext The application context.
852      * @param query The AlbumMediaQuery object instance that tells us about the media query args.
853      * @param localAuthority The effective local authority that we need to consider for this
854      *                       transaction. If the local items should not be queries but the local
855      *                       authority has some value, the effective local authority would be null.
856      */
waitForOngoingAlbumSync( @onNull Context appContext, @NonNull AlbumMediaQuery query, @Nullable String localAuthority)857     private static void waitForOngoingAlbumSync(
858             @NonNull Context appContext,
859             @NonNull AlbumMediaQuery query,
860             @Nullable String localAuthority) {
861         boolean isLocal = localAuthority != null
862                 && localAuthority.equals(query.getAlbumAuthority());
863         SyncCompletionWaiter.waitForSyncWithTimeout(
864                 SyncTrackerRegistry.getAlbumSyncTracker(isLocal),
865                 /* timeoutInMillis */ 500);
866     }
867 
868     /**
869      * @param appContext The application context.
870      * @param localAuthority The effective local authority that we need to consider for this
871      *                       transaction. If the local items should not be queried but the local
872      *                       authority has some value, the effective local authority would be null.
873      * @param cloudAuthority The effective cloud authority that we need to consider for this
874      *                       transaction. If the cloud items should not be queried but the cloud
875      *                       authority has some value, the effective cloud authority would be null.
876      */
waitForOngoingSearchResultSync( @ullable String localAuthority, @Nullable String cloudAuthority)877     private static void waitForOngoingSearchResultSync(
878             @Nullable String localAuthority,
879             @Nullable String cloudAuthority) {
880         final SearchState searchState = PickerSyncController.getInstanceOrThrow().getSearchState();
881 
882         if (localAuthority != null) {
883             Log.d(TAG, "Waiting for local search results");
884             SyncCompletionWaiter.waitForSyncWithTimeout(
885                     SyncTrackerRegistry.getLocalSearchSyncTracker(), /* timeoutInMillis */ 500);
886         }
887 
888         if (cloudAuthority != null) {
889             Log.d(TAG, "Waiting for cloud search results");
890             SyncCompletionWaiter.waitForSyncWithTimeout(
891                     SyncTrackerRegistry.getCloudSearchSyncTracker(), /* timeoutInMillis */ 5000);
892         }
893     }
894 
895     /**
896      * @param localAuthority The effective local authority that we need to consider for this
897      *                       transaction. If the local items should not be queried but the local
898      *                       authority has some value, the effective local authority would be null.
899      * @param cloudAuthority The effective cloud authority that we need to consider for this
900      *                       transaction. If the cloud items should not be queried but the cloud
901      *                       authority has some value, the effective cloud authority would be null.
902      */
waitForOngoingMediaInMediaSetSync( @ullable String localAuthority, @Nullable String cloudAuthority)903     private static void waitForOngoingMediaInMediaSetSync(
904             @Nullable String localAuthority,
905             @Nullable String cloudAuthority) {
906         if (localAuthority != null && cloudAuthority != null) {
907             Log.w(TAG, "Media sets sync can only happen with either the local provider or a "
908                     + "cloud provider for a parent category. Please check the input providers.");
909         }
910         if (localAuthority != null) {
911             SyncCompletionWaiter.waitForSyncWithTimeout(
912                     SyncTrackerRegistry.getLocalMediaInMediaSetTracker(), /*timeoutInMillis*/ 500);
913         }
914         if (cloudAuthority != null) {
915             SyncCompletionWaiter.waitForSyncWithTimeout(
916                     SyncTrackerRegistry.getCloudMediaInMediaSetTracker(), /*timeoutInMillis*/ 5000);
917         }
918     }
919 
920     /**
921      * @param localAuthority The effective local authority that we need to consider for this
922      *                       transaction. If the local items should not be queried but the local
923      *                       authority has some value, the effective local authority would be null.
924      * @param cloudAuthority The effective cloud authority that we need to consider for this
925      *                       transaction. If the cloud items should not be queried but the cloud
926      *                       authority has some value, the effective cloud authority would be null.
927      */
waitForOngoingMediaSetsSync( @ullable String localAuthority, @Nullable String cloudAuthority)928     private static void waitForOngoingMediaSetsSync(
929             @Nullable String localAuthority,
930             @Nullable String cloudAuthority) {
931         if (localAuthority != null && cloudAuthority != null) {
932             Log.w(TAG, "Media set contents sync can only happen with either the local provider or"
933                     + " a cloud provider for a parent category. Please check the input providers.");
934         }
935         if (localAuthority != null) {
936             SyncCompletionWaiter.waitForSyncWithTimeout(
937                     SyncTrackerRegistry.getLocalMediaSetsSyncTracker(), /*timeoutInMillis*/ 500);
938         }
939         if (cloudAuthority != null) {
940             SyncCompletionWaiter.waitForSyncWithTimeout(
941                     SyncTrackerRegistry.getCloudMediaSetsSyncTracker(), /*timeoutInMillis*/ 2000);
942         }
943     }
944 
945     /**
946      * Returns a clause that can be used to filter OWNER_PACKAGE_NAME_COLUMN using the input
947      * packageNames in a query.
948      */
getPackageSelectionWhereClause(String[] packageNames, String table)949     public static @NonNull StringBuilder getPackageSelectionWhereClause(String[] packageNames,
950             String table) {
951         StringBuilder packageSelection = new StringBuilder();
952         String packageColumn = String.format(
953                 Locale.ROOT, "%s.%s", table, OWNER_PACKAGE_NAME_COLUMN);
954         packageSelection.append(packageColumn).append(" IN (\'");
955 
956         String joinedPackageNames = String.join("\',\'", packageNames);
957         packageSelection.append(joinedPackageNames);
958 
959         packageSelection.append("\')");
960         return packageSelection;
961     }
962 
getDefaultEmptyAlbum(@onNull String albumId)963     public static Cursor getDefaultEmptyAlbum(@NonNull String albumId) {
964         // Conform to the album response projection. Temporary code, this will change once we start
965         // caching album metadata.
966         final MatrixCursor result = new MatrixCursor(AlbumColumns.ALL_PROJECTION);
967         final String[] projectionValue = new String[]{
968                 /* albumId */ albumId,
969                 /* dateTakenMillis */ Long.toString(Long.MAX_VALUE),
970                 /* displayName */ albumId,
971                 /* mediaId */ EMPTY_MEDIA_ID,
972                 /* count */ "0", // This value is not used anymore
973                 /* authority */ null, // Authority is populated in AlbumsCursorWrapper
974         };
975         result.addRow(projectionValue);
976         return result;
977     }
978 
979     /**
980      * Returns local albums in individial cursors mapped against their album id after fetching them
981      * from the local provider.
982      *
983      * @param appContext The application context.
984      * @param query Query arguments that will be used to filter albums.
985      * @param localAuthority Authority of the local media provider.
986      */
987     @Nullable
getLocalAlbumCursors( @onNull Context appContext, @NonNull MediaQuery query, @Nullable String localAuthority)988     private static Map<String, AlbumsCursorWrapper> getLocalAlbumCursors(
989             @NonNull Context appContext,
990             @NonNull MediaQuery query,
991             @Nullable String localAuthority) {
992         if (localAuthority == null) {
993             Log.d(TAG, "Cannot fetch local albums when local authority is null.");
994             return null;
995         }
996 
997         final Cursor localAlbumsCursor =
998                 getAlbumsCursorFromProvider(appContext, query, localAuthority);
999 
1000         final Map<String, AlbumsCursorWrapper> localAlbumsMap = new HashMap<>();
1001         if (localAlbumsCursor != null && localAlbumsCursor.moveToFirst()) {
1002             do {
1003                 try {
1004                     final String albumId =
1005                             localAlbumsCursor.getString(
1006                                     localAlbumsCursor.getColumnIndex(AlbumColumns.ID));
1007                     final MatrixCursor albumCursor =
1008                             new MatrixCursor(localAlbumsCursor.getColumnNames());
1009                     MatrixCursor.RowBuilder builder = albumCursor.newRow();
1010                     for (String columnName : localAlbumsCursor.getColumnNames()) {
1011                         final int columnIndex = localAlbumsCursor.getColumnIndex(columnName);
1012                         switch (localAlbumsCursor.getType(columnIndex)) {
1013                             case Cursor.FIELD_TYPE_INTEGER:
1014                                 builder.add(columnName, localAlbumsCursor.getInt(columnIndex));
1015                                 break;
1016                             case Cursor.FIELD_TYPE_FLOAT:
1017                                 builder.add(columnName, localAlbumsCursor.getFloat(columnIndex));
1018                                 break;
1019                             case Cursor.FIELD_TYPE_BLOB:
1020                                 builder.add(columnName, localAlbumsCursor.getBlob(columnIndex));
1021                                 break;
1022                             case Cursor.FIELD_TYPE_NULL:
1023                                 builder.add(columnName, null);
1024                                 break;
1025                             case Cursor.FIELD_TYPE_STRING:
1026                                 builder.add(columnName, localAlbumsCursor.getString(columnIndex));
1027                                 break;
1028                             default:
1029                                 throw new IllegalArgumentException(
1030                                         "Could not recognize column type "
1031                                                 + localAlbumsCursor.getType(columnIndex));
1032                         }
1033                     }
1034                     localAlbumsMap.put(
1035                             albumId,
1036                             new AlbumsCursorWrapper(albumCursor,
1037                                     /* coverAuthority */ localAuthority,
1038                                     /* localAuthority */ localAuthority)
1039                     );
1040                 } catch (RuntimeException e) {
1041                     Log.e(TAG,
1042                             "Could not read album cursor values received from local provider", e);
1043                 }
1044             } while(localAlbumsCursor.moveToNext());
1045         }
1046 
1047         // Close localAlbumsCursor because it's data was copied into new Cursor(s) and it won't
1048         // be used again.
1049         if (localAlbumsCursor != null) localAlbumsCursor.close();
1050 
1051         // Always show Camera album.
1052         if (!localAlbumsMap.containsKey(AlbumColumns.ALBUM_ID_CAMERA)) {
1053             localAlbumsMap.put(
1054                     AlbumColumns.ALBUM_ID_CAMERA,
1055                     new AlbumsCursorWrapper(
1056                             getDefaultEmptyAlbum(AlbumColumns.ALBUM_ID_CAMERA),
1057                             /* albumAuthority */ localAuthority,
1058                             /* localAuthority */ localAuthority)
1059             );
1060         }
1061 
1062         return localAlbumsMap;
1063     }
1064 
1065     /**
1066      * Returns cloud albums cursor after fetching them from the local provider.
1067      *
1068      * @param appContext The application context.
1069      * @param query Query arguments that will be used to filter albums.
1070      * @param localAuthority Authority of the local media provider.
1071      * @param cloudAuthority Authority of the cloud media provider.
1072      */
1073     @Nullable
getCloudAlbumsCursor( @onNull Context appContext, @NonNull MediaQuery query, @Nullable String localAuthority, @Nullable String cloudAuthority)1074     private static AlbumsCursorWrapper getCloudAlbumsCursor(
1075             @NonNull Context appContext,
1076             @NonNull MediaQuery query,
1077             @Nullable String localAuthority,
1078             @Nullable String cloudAuthority) {
1079         if (cloudAuthority == null) {
1080             Log.d(TAG, "Cannot fetch cloud albums when cloud authority is null.");
1081             return null;
1082         }
1083 
1084         Log.d(TAG, "Fetching albums from CMP " + cloudAuthority);
1085         final Cursor cursor = getAlbumsCursorFromProvider(appContext, query, cloudAuthority);
1086 
1087         Log.d(TAG, "Received albums from CMP " + cloudAuthority);
1088         return cursor == null
1089                 ? null
1090                 : new AlbumsCursorWrapper(cursor, cloudAuthority, localAuthority);
1091     }
1092 
1093     /**
1094      * Returns {@link AlbumsCursorWrapper} object that wraps the albums cursor response from the
1095      * provider.
1096      *
1097      * @param appContext The application context.
1098      * @param query Query arguments that will be used to filter albums.
1099      * @param providerAuthority Authority of the cloud media provider.
1100      */
1101     @Nullable
getAlbumsCursorFromProvider( @onNull Context appContext, @NonNull MediaQuery query, @NonNull String providerAuthority)1102     private static Cursor getAlbumsCursorFromProvider(
1103             @NonNull Context appContext,
1104             @NonNull MediaQuery query,
1105             @NonNull String providerAuthority) {
1106         return appContext.getContentResolver().query(
1107                 getAlbumUri(providerAuthority),
1108                 /* projection */ null,
1109                 query.prepareCMPQueryArgs(),
1110                 /* cancellationSignal */ null);
1111     }
1112 
1113     /**
1114      * @param appContext Application context.
1115      * @param query Query arguments that will be used to filter categories.
1116      * @param cloudAuthority Effective cloud authority from which cloud categories should be
1117      *                       fetched. This could be null.
1118      * @param cancellationSignal CancellationSignal object that notifies that the request has been
1119      *                           cancelled.
1120      * @return Cursor with Categories from the cloud provider. Returns null if an error occurs in
1121      * fetching the categories.
1122      */
1123     @Nullable
getCloudCategories( @onNull Context appContext, @NonNull MediaQuery query, @Nullable String cloudAuthority, @NonNull PickerSyncController syncController, @Nullable CancellationSignal cancellationSignal)1124     private static Cursor getCloudCategories(
1125             @NonNull Context appContext,
1126             @NonNull MediaQuery query,
1127             @Nullable String cloudAuthority,
1128             @NonNull PickerSyncController syncController,
1129             @Nullable CancellationSignal cancellationSignal) {
1130         try {
1131             if (cloudAuthority == null) {
1132                 Log.d(TAG, "Cannot fetch cloud categories when cloud authority is null.");
1133                 return null;
1134             }
1135 
1136             try {
1137                 if (syncController.isFullSyncPending(cloudAuthority, /* isLocal */ false)) {
1138                     Log.d(TAG, "Don't return cloud categories when full sync is pending.");
1139                     return null;
1140                 }
1141             } catch (RequestObsoleteException | RuntimeException e) {
1142                 Log.e(TAG, "Could not check if full sync is pending. "
1143                         + "Not returning cloud categories", e);
1144                 return null;
1145             }
1146 
1147             final PickerSearchProviderClient searchClient = PickerSearchProviderClient.create(
1148                     appContext, cloudAuthority);
1149             if (syncController.getCategoriesState().areCategoriesEnabled(
1150                     appContext, cloudAuthority)) {
1151                 Log.d(TAG, "Media categories feature is enabled. Fetching cloud categories.");
1152                 return searchClient.fetchMediaCategoriesFromCmp(
1153                         /* parentCategoryId */ null,
1154                         query.prepareCMPQueryArgs(),
1155                         /* cancellationSignal */ cancellationSignal);
1156             }
1157         } catch (RuntimeException e) {
1158             Log.e(TAG, "Could not fetch cloud categories.", e);
1159         }
1160 
1161         return null;
1162     }
1163 
1164     /**
1165      * @return a cursor with the available providers.
1166      */
1167     @NonNull
queryAvailableProviders(@onNull Context context)1168     public static Cursor queryAvailableProviders(@NonNull Context context) {
1169         try {
1170             final PackageManager packageManager = context.getPackageManager();
1171             final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
1172             final String[] columnNames = Arrays
1173                     .stream(PickerSQLConstants.AvailableProviderResponse.values())
1174                     .map(PickerSQLConstants.AvailableProviderResponse::getColumnName)
1175                     .toArray(String[]::new);
1176             final MatrixCursor matrixCursor = new MatrixCursor(columnNames, /*initialCapacity */ 2);
1177             final String localAuthority = syncController.getLocalProvider();
1178             final ProviderInfo localProviderInfo = packageManager.resolveContentProvider(
1179                     localAuthority, /* flags */ 0);
1180             final String localProviderLabel =
1181                     String.valueOf(localProviderInfo.loadLabel(packageManager));
1182             addAvailableProvidersToCursor(
1183                     matrixCursor,
1184                     localAuthority,
1185                     MediaSource.LOCAL,
1186                     Process.myUid(),
1187                     localProviderLabel
1188             );
1189 
1190             final String cloudAuthority =
1191                     syncController.getCloudProviderOrDefault(/* defaultValue */ null);
1192             if (syncController.shouldQueryCloudMedia(cloudAuthority)) {
1193                 final ProviderInfo cloudProviderInfo = requireNonNull(
1194                         packageManager.resolveContentProvider(cloudAuthority, /* flags */ 0));
1195                 final int uid = packageManager.getPackageUid(
1196                         cloudProviderInfo.packageName,
1197                         /* flags */ 0
1198                 );
1199                 final String cloudProviderLabel =
1200                         String.valueOf(cloudProviderInfo.loadLabel(packageManager));
1201                 addAvailableProvidersToCursor(
1202                         matrixCursor,
1203                         cloudAuthority,
1204                         MediaSource.REMOTE,
1205                         uid,
1206                         cloudProviderLabel
1207                 );
1208             }
1209 
1210             return matrixCursor;
1211         } catch (IllegalStateException | NameNotFoundException e) {
1212             throw new RuntimeException("Unexpected internal error occurred", e);
1213         }
1214     }
1215 
1216     /**
1217      * @return a cursor with the Collection Info for all the available providers.
1218      */
queryCollectionInfo()1219     public static Cursor queryCollectionInfo() {
1220         try {
1221             final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
1222             final String[] columnNames = Arrays
1223                     .stream(PickerSQLConstants.CollectionInfoResponse.values())
1224                     .map(PickerSQLConstants.CollectionInfoResponse::getColumnName)
1225                     .toArray(String[]::new);
1226             final MatrixCursor matrixCursor = new MatrixCursor(columnNames, /*initialCapacity */ 2);
1227             Bundle extras = new Bundle();
1228             matrixCursor.setExtras(extras);
1229             final ProviderCollectionInfo localCollectionInfo =
1230                     syncController.getLocalProviderLatestCollectionInfo();
1231             addCollectionInfoToCursor(
1232                     matrixCursor,
1233                     localCollectionInfo
1234             );
1235 
1236             final ProviderCollectionInfo cloudCollectionInfo =
1237                     syncController.getCloudProviderLatestCollectionInfo();
1238             if (cloudCollectionInfo != null
1239                     && syncController.shouldQueryCloudMedia(cloudCollectionInfo.getAuthority())) {
1240                 addCollectionInfoToCursor(
1241                         matrixCursor,
1242                         cloudCollectionInfo
1243                 );
1244             }
1245 
1246             return matrixCursor;
1247         } catch (IllegalStateException e) {
1248             throw new RuntimeException("Unexpected internal error occurred", e);
1249         }
1250     }
1251 
addAvailableProvidersToCursor( @onNull MatrixCursor cursor, @NonNull String authority, @NonNull MediaSource source, @UserIdInt int uid, @Nullable String displayName)1252     private static void addAvailableProvidersToCursor(
1253             @NonNull MatrixCursor cursor,
1254             @NonNull String authority,
1255             @NonNull MediaSource source,
1256             @UserIdInt int uid,
1257             @Nullable String displayName) {
1258         cursor.newRow()
1259                 .add(PickerSQLConstants.AvailableProviderResponse.AUTHORITY.getColumnName(),
1260                         authority)
1261                 .add(PickerSQLConstants.AvailableProviderResponse.MEDIA_SOURCE.getColumnName(),
1262                         source.name())
1263                 .add(PickerSQLConstants.AvailableProviderResponse.UID.getColumnName(), uid)
1264                 .add(PickerSQLConstants.AvailableProviderResponse.DISPLAY_NAME.getColumnName(),
1265                         displayName);
1266     }
1267 
addCollectionInfoToCursor( @onNull MatrixCursor cursor, @NonNull ProviderCollectionInfo providerCollectionInfo)1268     private static void addCollectionInfoToCursor(
1269             @NonNull MatrixCursor cursor,
1270             @NonNull ProviderCollectionInfo providerCollectionInfo) {
1271         if (providerCollectionInfo != null) {
1272             cursor.newRow()
1273                     .add(PickerSQLConstants.CollectionInfoResponse.AUTHORITY.getColumnName(),
1274                             providerCollectionInfo.getAuthority())
1275                     .add(PickerSQLConstants.CollectionInfoResponse.COLLECTION_ID.getColumnName(),
1276                             providerCollectionInfo.getCollectionId())
1277                     .add(PickerSQLConstants.CollectionInfoResponse.ACCOUNT_NAME.getColumnName(),
1278                             providerCollectionInfo.getAccountName());
1279 
1280             Bundle extras = cursor.getExtras();
1281             extras.putParcelable(providerCollectionInfo.getAuthority(),
1282                     providerCollectionInfo.getAccountConfigurationIntent());
1283         }
1284     }
1285 
1286     /**
1287      * @return a Bundle with the details of the requested cloud provider.
1288      */
getCloudProviderDetails(Bundle queryArgs)1289     public static Bundle getCloudProviderDetails(Bundle queryArgs) {
1290         throw new UnsupportedOperationException("This method is not implemented yet.");
1291     }
1292 
1293     /**
1294      * Returns a cursor for media filtered by ids based on input URIs.
1295      */
queryMediaForPreSelection(@onNull Context appContext, Bundle queryArgs)1296     public static Cursor queryMediaForPreSelection(@NonNull Context appContext, Bundle queryArgs) {
1297         final MediaQueryForPreSelection query = new MediaQueryForPreSelection(queryArgs);
1298         final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
1299         final String effectiveLocalAuthority =
1300                 query.getProviders().contains(syncController.getLocalProvider())
1301                         ? syncController.getLocalProvider()
1302                         : null;
1303         final String cloudAuthority = syncController
1304                 .getCloudProviderOrDefault(/* defaultValue */ null);
1305         final String effectiveCloudAuthority =
1306                 syncController.shouldQueryCloudMedia(query.getProviders(), cloudAuthority)
1307                         ? cloudAuthority
1308                         : null;
1309         waitForOngoingSync(appContext, effectiveLocalAuthority, effectiveCloudAuthority,
1310                 query.getIntentAction());
1311 
1312         query.processUrisForSelection(query.getPreSelectionUris(), effectiveLocalAuthority,
1313                 effectiveCloudAuthority, effectiveCloudAuthority == null, appContext,
1314                 query.getCallingPackageUid());
1315 
1316         return PickerMediaDatabaseUtil.queryPreSelectedMedia(
1317                 appContext,
1318                 syncController,
1319                 query,
1320                 effectiveLocalAuthority,
1321                 effectiveCloudAuthority
1322         );
1323     }
1324 
1325     /**
1326      * Handle Picker application's request to initialize search request media. If a new search
1327      * request id needs to be created, return a Bundle with the search request Id.
1328      *
1329      * Also trigger search results sync with the providers and saves the incoming search request in
1330      * the search history table if this is a new request.
1331      *
1332      * By default use ForkJoinPool.commonPool() for small background tasks to reduce resource
1333      * usage instead of creating a custom pool. Its threads are slowly reclaimed during periods
1334      * of non-use, and reinstated upon subsequent use.
1335      *
1336      * @param appContext Application context.
1337      * @param extras Bundle with input parameters.
1338      * @return a response Bundle.
1339      */
1340     @NonNull
handleSearchResultsInit( @onNull Context appContext, @NonNull Bundle extras)1341     public static Bundle handleSearchResultsInit(
1342             @NonNull Context appContext,
1343             @NonNull Bundle extras) {
1344         final int defaultSearchRequestId = -1;
1345         final int inputSearchRequestId = extras.getInt("search_request_id", defaultSearchRequestId);
1346         final WorkManager workManager = getWorkManager(appContext);
1347 
1348         if (inputSearchRequestId == defaultSearchRequestId) {
1349             Log.d(TAG, "New search request id needs to be created");
1350 
1351             return handleNewSearchRequest(appContext, extras,
1352                     ForkJoinPool.commonPool(), workManager);
1353         } else {
1354             Log.d(TAG, "Search request id already exists. Ensure that sync is complete for the "
1355                     + "search request id.");
1356 
1357             return ensureSearchResultsSynced(appContext, extras, workManager);
1358         }
1359     }
1360 
1361     /**
1362      * Handle Picker application's request to create a new search request and return a Bundle with
1363      * the search request Id.
1364      * Also trigger search results sync with the providers and saves the incoming search request in
1365      * the search history table.
1366      *
1367      * @param appContext Application context.
1368      * @param extras Bundle with input parameters.
1369      * @param executor Executor to asynchronously save the request as search history in database.
1370      * @param workManager An instance of {@link WorkManager}
1371      * @return a response Bundle.
1372      */
1373     @NonNull
handleNewSearchRequest(@onNull Context appContext, @NonNull Bundle extras, @NonNull Executor executor, @NonNull WorkManager workManager)1374     public static Bundle handleNewSearchRequest(@NonNull Context appContext,
1375                                                 @NonNull Bundle extras,
1376                                                 @NonNull Executor executor,
1377                                                 @NonNull WorkManager workManager) {
1378         requireNonNull(extras);
1379         Log.d(TAG, "Received a new search request: " + extras);
1380 
1381         final SearchRequest searchRequest = SearchRequest.create(extras);
1382         final SQLiteDatabase database = PickerSyncController.getInstanceOrThrow().getDbFacade()
1383                 .getDatabase();
1384 
1385         SearchRequestDatabaseUtil.saveSearchRequest(database, searchRequest);
1386         final int searchRequestId =
1387                 SearchRequestDatabaseUtil.getSearchRequestID(database, searchRequest);
1388 
1389         if (searchRequestId == -1) {
1390             throw new RuntimeException("Could not create search request!");
1391         } else {
1392             // Asynchronously save data in search history table.
1393             CompletableFuture<Boolean> ignored = CompletableFuture.supplyAsync(
1394                     () -> SearchSuggestionsDatabaseUtils.saveSearchHistory(database, searchRequest),
1395                     executor);
1396 
1397             // Schedule search results sync
1398             final Set<String> providers = new HashSet<>(
1399                     Objects.requireNonNull(extras.getStringArrayList("providers")));
1400             scheduleSearchResultsSync(appContext, searchRequest, searchRequestId, providers,
1401                     workManager);
1402 
1403             Log.d(TAG, "Returning search request id: " + searchRequestId);
1404             return getSearchRequestInitResponse(searchRequestId);
1405         }
1406     }
1407 
1408     /**
1409      * Ensure that the search results are synced for the given search request id. If not,
1410      * start or resume the sync for the search results. Both of these aspects are taken care of by
1411      * scheduling a work request.
1412      *
1413      * @param appContext Application context.
1414      * @param extras Bundle with input parameters.
1415      * @param workManager An instance of {@link WorkManager}
1416      * @return a response Bundle.
1417      */
1418     @NonNull
ensureSearchResultsSynced(@onNull Context appContext, @NonNull Bundle extras, @NonNull WorkManager workManager)1419     public static Bundle ensureSearchResultsSynced(@NonNull Context appContext,
1420                                                 @NonNull Bundle extras,
1421                                                 @NonNull WorkManager workManager) {
1422         requireNonNull(extras);
1423         Log.d(TAG, "Received a previously known search request again: " + extras);
1424 
1425         final int searchRequestId = extras.getInt("search_request_id", -1);
1426         final SearchRequest searchRequest = SearchRequest.create(extras);
1427 
1428         // Schedule search results sync with REPLACE policy. This takes care of cancelling any
1429         // existing search results sync that might be obsolete.
1430         final Set<String> providers = new HashSet<>(
1431                 Objects.requireNonNull(extras.getStringArrayList("providers")));
1432         scheduleSearchResultsSync(appContext, searchRequest, searchRequestId, providers,
1433                 workManager);
1434 
1435         return getSearchRequestInitResponse(searchRequestId);
1436     }
1437 
1438     /**
1439      * Handles Photopicker's request to trigger a sync for media items in a media set
1440      * based on whether the provider implements search categories and media sets
1441      * @param extras Bundle with all input parameters
1442      * @param appContext The application context
1443      */
triggerMediaSyncForMediaSet( @onNull Bundle extras, @NonNull Context appContext)1444     public static void triggerMediaSyncForMediaSet(
1445             @NonNull Bundle extras, @NonNull Context appContext) {
1446         requireNonNull(extras);
1447         requireNonNull(appContext);
1448         triggerMediaSyncForMediaSet(extras, appContext, getWorkManager(appContext));
1449     }
1450 
1451     /**
1452      * Handles Photopicker's request to trigger a sync for media items in a media set
1453      * based on whether the provider implements search categories and media sets
1454      * @param extras Bundle with all input parameters
1455      * @param appContext The application context
1456      * @param workManager An instance of {@link WorkManager}
1457      */
triggerMediaSyncForMediaSet( @onNull Bundle extras, @NonNull Context appContext, @NonNull WorkManager workManager)1458     public static void triggerMediaSyncForMediaSet(
1459             @NonNull Bundle extras, @NonNull Context appContext, @NonNull WorkManager workManager) {
1460         requireNonNull(extras);
1461         requireNonNull(appContext);
1462         requireNonNull(workManager);
1463         MediaInMediaSetSyncRequestParams requestParams =
1464                 new MediaInMediaSetSyncRequestParams(extras);
1465         final Set<String> providers = new HashSet<>(
1466                 Objects.requireNonNull(extras.getStringArrayList("providers")));
1467         scheduleMediaInMediaSetSync(
1468                 requestParams, appContext, workManager, providers);
1469     }
1470 
1471     /**
1472      * Schedules a sync of media items in the given media set for the local or cloud provider if t
1473      * he corresponding provider implements Categories and MediaSets.
1474      * @param context  The application context
1475      * @param requestParams Wrapper object to hold all media in media set sync parameters
1476      * @param providers Set of available providers
1477      * @param workManager An instance of {@link WorkManager}
1478      */
scheduleMediaInMediaSetSync( @onNull MediaInMediaSetSyncRequestParams requestParams, @NonNull Context context, @NonNull WorkManager workManager, @NonNull Set<String> providers)1479     private static void scheduleMediaInMediaSetSync(
1480             @NonNull MediaInMediaSetSyncRequestParams requestParams, @NonNull Context context,
1481             @NonNull WorkManager workManager, @NonNull Set<String> providers) {
1482 
1483         PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
1484         PickerSyncManager syncManager = new PickerSyncManager(workManager, context);
1485         int syncSource = Objects.equals(requestParams.getAuthority(),
1486                 syncController.getLocalProvider())
1487                 ? SYNC_LOCAL_ONLY : SYNC_CLOUD_ONLY;
1488 
1489         // Sync MediaSet content only if the media sets can actually be queried
1490         if (syncSource == SYNC_LOCAL_ONLY && syncController.shouldQueryLocalMediaSets(providers)) {
1491             syncManager.syncMediaInMediaSetForProvider(
1492                     requestParams, SYNC_LOCAL_ONLY);
1493         } else if (syncController.shouldQueryCloudMediaSets(
1494                 providers, requestParams.getAuthority())) {
1495             syncManager.syncMediaInMediaSetForProvider(
1496                     requestParams, SYNC_CLOUD_ONLY);
1497         } else {
1498             Log.e(TAG, "Unidentified provider authority: " + requestParams.getAuthority()
1499                     + " skipping MediaSet content sync.");
1500         }
1501     }
1502 
1503 
1504 
1505     /**
1506      * Handles Photopicker's request to trigger a sync for media sets for the given category
1507      * based on whether the providers implement search categories.
1508      * @param extras Bundle with all input parameters
1509      * @param appContext The application context
1510      */
triggerMediaSetsSync( @onNull Bundle extras, @NonNull Context appContext)1511     public static void triggerMediaSetsSync(
1512             @NonNull Bundle extras, @NonNull Context appContext) {
1513         requireNonNull(extras);
1514         requireNonNull(appContext);
1515         triggerMediaSetsSync(extras, appContext, getWorkManager(appContext));
1516     }
1517 
1518     /**
1519      * Handles Photopicker's request to trigger a sync for media sets for the given category
1520      * based on whether the providers implement search categories.
1521      * @param extras Bundle with all input parameters
1522      * @param appContext The application context
1523      * @param workManager An instance of {@link WorkManager}
1524      */
triggerMediaSetsSync( @onNull Bundle extras, @NonNull Context appContext, @NonNull WorkManager workManager)1525     public static void triggerMediaSetsSync(
1526             @NonNull Bundle extras, @NonNull Context appContext, @NonNull WorkManager workManager) {
1527 
1528         requireNonNull(workManager);
1529         MediaSetsSyncRequestParams mediaSetsSyncRequestParams =
1530                 new MediaSetsSyncRequestParams(extras);
1531         final Set<String> providers = new HashSet<>(
1532                 Objects.requireNonNull(extras.getStringArrayList("providers")));
1533 
1534         scheduleMediaSetsSync(appContext, mediaSetsSyncRequestParams, providers, workManager);
1535     }
1536 
1537     /**
1538      * Handle cloud media queries being disabled. This method is called from the critical path of
1539      * updating the PickerDBFacade cloud provider. This method should not take a long time to
1540      * execute and it should ensure that unexpected exceptions/failures don't cause any disruption.
1541      * @param context The application context.
1542      */
handleCloudMediaReset(Context context)1543     public static void handleCloudMediaReset(Context context) {
1544         try {
1545             if (Flags.enablePhotopickerSearch()) {
1546                 Log.d(TAG, "Cloud media reset detected. "
1547                         + "Clear search results cache that has synced with a cloud media provider");
1548 
1549                 final PickerSyncManager syncManager =
1550                         new PickerSyncManager(getWorkManager(context), context);
1551                 final String cloudAuthority = PickerSyncController.getInstanceOrThrow()
1552                         .getCloudProviderOrDefault(null);
1553                 syncManager.resetCloudSearchCache(cloudAuthority);
1554             }
1555         } catch (Exception e) {
1556             Log.e(TAG, "Unexpected error occurred in handleCloudMediaReset", e);
1557         }
1558     }
1559 
1560     /**
1561      * @param context the application context.
1562      * @return a bundle with the list of available provider authorities that support the
1563      * search feature. If no providers are available, return an empty list in the bundle.
1564      */
1565     @NonNull
getSearchProviders(@onNull Context context)1566     public static Bundle getSearchProviders(@NonNull Context context) {
1567         Log.d(TAG, "Calculating available search providers.");
1568 
1569         requireNonNull(context);
1570 
1571         // Check the state of cloud and local search.
1572         final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
1573         final String cloudProvider = syncController.getCloudProviderOrDefault(null);
1574         final boolean isCloudSearchEnabled =
1575                 syncController.getSearchState().isCloudSearchEnabled(context, cloudProvider);
1576         final boolean isLocalSearchEnabled = syncController.getSearchState().isLocalSearchEnabled();
1577 
1578         // Prepare a bundle response with the result.
1579         final ArrayList<String> searchProviderAuthorities = new ArrayList<>();
1580         if (isCloudSearchEnabled) searchProviderAuthorities.add(cloudProvider);
1581         if (isLocalSearchEnabled) searchProviderAuthorities.add(syncController.getLocalProvider());
1582 
1583         final Bundle result = new Bundle();
1584         result.putStringArrayList(
1585                 PickerSQLConstants.EXTRA_SEARCH_PROVIDER_AUTHORITIES, searchProviderAuthorities);
1586         Log.d(TAG, "Available search providers are: " + result);
1587         return result;
1588     }
1589 
1590     /**
1591      * Schedules MediaSets sync for both local and cloud provider if the corresponding
1592      * providers implement Categories.
1593      * @param appContext  The application context
1594      * @param requestParams Wrapper object to hold all media set sync parameters
1595      * @param providers List of available providers
1596      * @param workManager An instance of {@link WorkManager}
1597      */
scheduleMediaSetsSync( @onNull Context appContext, @NonNull MediaSetsSyncRequestParams requestParams, @NonNull Set<String> providers, @NonNull WorkManager workManager)1598     private static void scheduleMediaSetsSync(
1599             @NonNull Context appContext, @NonNull MediaSetsSyncRequestParams requestParams,
1600             @NonNull Set<String> providers, @NonNull WorkManager workManager) {
1601 
1602         final PickerSyncManager syncManager = new PickerSyncManager(workManager, appContext);
1603         final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
1604         int syncSource = syncController.getLocalProvider().equals(requestParams.getAuthority())
1605                 ? SYNC_LOCAL_ONLY : SYNC_CLOUD_ONLY;
1606 
1607         // Schedule local sync only if the provider holds local authority
1608         if (syncSource == SYNC_LOCAL_ONLY && syncController.shouldQueryLocalMediaSets(providers)) {
1609             syncManager.syncMediaSetsForProvider(requestParams, SYNC_LOCAL_ONLY);
1610         } else if (syncController.shouldQueryCloudMediaSets(
1611                 providers, requestParams.getAuthority())) {
1612             // Schedule cloud sync otherwise
1613             syncManager.syncMediaSetsForProvider(requestParams, SYNC_CLOUD_ONLY);
1614         } else {
1615             Log.e(TAG, "Unrecognised provider authority received for MediaSetSync, skipping");
1616         }
1617     }
1618 
1619     /**
1620      * Schedules search results sync for the incoming search request with local or cloud providers,
1621      * or both.
1622      *
1623      * @param appContext      Application context.
1624      * @param searchRequest   Search request for which search results need to be synced.
1625      * @param searchRequestId Identifier of the search request.
1626      * @param extras          Bundle with input parameters.
1627      * @param workManager     An instance of {@link WorkManager}
1628      */
scheduleSearchResultsSync( @onNull Context appContext, @NonNull SearchRequest searchRequest, int searchRequestId, @NonNull Set<String> providers, WorkManager workManager)1629     private static void scheduleSearchResultsSync(
1630             @NonNull Context appContext,
1631             @NonNull SearchRequest searchRequest,
1632             int searchRequestId,
1633             @NonNull Set<String> providers,
1634             WorkManager workManager) {
1635         final PickerSyncManager syncManager = new PickerSyncManager(workManager, appContext);
1636 
1637         final boolean localSyncWasScheduled = scheduleSearchSyncWithLocalProvider(
1638                 searchRequest, searchRequestId, syncManager, providers);
1639         final boolean cloudSyncWasScheduled = scheduleSearchSyncWithCloudProvider(
1640                 searchRequest, searchRequestId, syncManager, providers);
1641 
1642         if (localSyncWasScheduled || cloudSyncWasScheduled) {
1643             syncManager.delayedResetSearchCache();
1644         }
1645     }
1646 
1647     /**
1648      * Schedules search results sync for the incoming search request with local provider if local
1649      * search is enabled.
1650      *
1651      * @param searchRequest Search request for which search results need to be synced.
1652      * @param searchRequestId Identifier of the search request.
1653      * @param syncManager An instance of PickerSyncManager that helps us schedule work manager
1654      *                    sync requests.
1655      * @param providers Set of valid providers we can sync search results from.
1656      * @return True if the sync was schedules, else returns false.
1657      */
scheduleSearchSyncWithLocalProvider( @onNull SearchRequest searchRequest, int searchRequestId, @NonNull PickerSyncManager syncManager, @NonNull Set<String> providers)1658     private static boolean scheduleSearchSyncWithLocalProvider(
1659             @NonNull SearchRequest searchRequest,
1660             int searchRequestId,
1661             @NonNull PickerSyncManager syncManager,
1662             @NonNull Set<String> providers) {
1663         final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
1664 
1665         if (!syncController.shouldQueryLocalMediaForSearch(providers)) {
1666             Log.d(TAG, "Search is not enabled for the current local authority. "
1667                     + "Not syncing search results with local provider for request id "
1668                     + searchRequestId);
1669             return false;
1670         }
1671 
1672         if (searchRequest instanceof SearchSuggestionRequest) {
1673             final SearchSuggestion suggestion =
1674                     ((SearchSuggestionRequest) searchRequest).getSearchSuggestion();
1675             if (suggestion.getSearchSuggestionType() == SEARCH_SUGGESTION_ALBUM) {
1676                 if (!syncController.getLocalProvider().equals(suggestion.getAuthority())) {
1677                     Log.d(TAG, "Album search suggestion does not belong to local provider. "
1678                             + "Not syncing search results with local provider for request id "
1679                             + searchRequestId);
1680                     return false;
1681                 }
1682             }
1683         }
1684 
1685         Log.d(TAG, "Scheduling search results sync with local provider: " + searchRequestId);
1686         syncManager.syncSearchResultsForProvider(
1687                 searchRequestId,
1688                 SYNC_LOCAL_ONLY,
1689                 syncController.getLocalProvider());
1690         return true;
1691     }
1692 
1693     /**
1694      * Schedules search results sync for the incoming search request with cloud provider if cloud
1695      * search is enabled.
1696      *
1697      * @param searchRequest Search request for which search results need to be synced.
1698      * @param searchRequestId Identifier of the search request.
1699      * @param syncManager An instance of PickerSyncManager that helps us schedule work manager
1700      *                    sync requests.
1701      * @param providers Set of valid providers we can sync search results from.
1702      * @return True if the sync was schedules, else returns false.
1703      */
scheduleSearchSyncWithCloudProvider( @onNull SearchRequest searchRequest, int searchRequestId, @NonNull PickerSyncManager syncManager, @NonNull Set<String> providers)1704     private static boolean scheduleSearchSyncWithCloudProvider(
1705             @NonNull SearchRequest searchRequest,
1706             int searchRequestId,
1707             @NonNull PickerSyncManager syncManager,
1708             @NonNull Set<String> providers) {
1709         final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
1710         final String cloudAuthority =
1711                 syncController.getCloudProviderOrDefault(/* defaultValue */ null);
1712 
1713         if (!syncController.shouldQueryCloudMediaForSearch(providers, cloudAuthority)) {
1714             Log.d(TAG, "Search is not enabled for the current cloud authority. "
1715                     + "Not syncing search results with cloud provider for request id "
1716                     + searchRequestId);
1717             return false;
1718         }
1719 
1720         if (searchRequest instanceof SearchSuggestionRequest) {
1721             final SearchSuggestion suggestion =
1722                     ((SearchSuggestionRequest) searchRequest).getSearchSuggestion();
1723             if (suggestion.getSearchSuggestionType() == SEARCH_SUGGESTION_ALBUM) {
1724                 if (!cloudAuthority.equals(suggestion.getAuthority())) {
1725                     Log.d(TAG, "Album search suggestion does not belong to cloud provider. "
1726                             + "Not syncing search results with cloud provider for request id "
1727                             + searchRequestId);
1728                     return false;
1729                 }
1730             }
1731         }
1732 
1733         Log.d(TAG, "Scheduling search results sync with cloud provider: " + searchRequestId);
1734         syncManager.syncSearchResultsForProvider(
1735                 searchRequestId,
1736                 SYNC_CLOUD_ONLY,
1737                 cloudAuthority);
1738         return true;
1739     }
1740 
1741     /**
1742      * @param searchRequestId Identifier of a search request.
1743      * @return A response bundle containing the search request id.
1744      */
1745     @NonNull
getSearchRequestInitResponse(int searchRequestId)1746     private static Bundle getSearchRequestInitResponse(int searchRequestId) {
1747         final Bundle response = new Bundle();
1748         response.putInt(PickerSQLConstants.EXTRA_SEARCH_REQUEST_ID, searchRequestId);
1749         return response;
1750     }
1751 }
1752