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 com.android.providers.media.PickerUriResolver.getAlbumUri;
20 import static com.android.providers.media.photopicker.sync.PickerSyncManager.IMMEDIATE_LOCAL_SYNC_WORK_NAME;
21 import static com.android.providers.media.photopicker.sync.WorkManagerInitializer.getWorkManager;
22 import static com.android.providers.media.photopicker.v2.model.AlbumsCursorWrapper.EMPTY_MEDIA_ID;
23 
24 import static java.util.Objects.requireNonNull;
25 
26 import android.annotation.UserIdInt;
27 import android.content.Context;
28 import android.content.pm.PackageManager;
29 import android.content.pm.PackageManager.NameNotFoundException;
30 import android.content.pm.ProviderInfo;
31 import android.database.Cursor;
32 import android.database.MatrixCursor;
33 import android.database.MergeCursor;
34 import android.database.sqlite.SQLiteDatabase;
35 import android.os.Bundle;
36 import android.os.Process;
37 import android.provider.CloudMediaProviderContract.AlbumColumns;
38 import android.util.Log;
39 
40 import androidx.annotation.NonNull;
41 import androidx.annotation.Nullable;
42 
43 import com.android.providers.media.photopicker.PickerSyncController;
44 import com.android.providers.media.photopicker.sync.SyncCompletionWaiter;
45 import com.android.providers.media.photopicker.sync.SyncTrackerRegistry;
46 import com.android.providers.media.photopicker.v2.model.AlbumMediaQuery;
47 import com.android.providers.media.photopicker.v2.model.AlbumsCursorWrapper;
48 import com.android.providers.media.photopicker.v2.model.FavoritesMediaQuery;
49 import com.android.providers.media.photopicker.v2.model.MediaQuery;
50 import com.android.providers.media.photopicker.v2.model.MediaSource;
51 import com.android.providers.media.photopicker.v2.model.VideoMediaQuery;
52 
53 import java.util.ArrayList;
54 import java.util.Arrays;
55 import java.util.List;
56 import java.util.Objects;
57 
58 /**
59  * This class handles Photo Picker content queries.
60  */
61 public class PickerDataLayerV2 {
62     private static final String TAG = "PickerDataLayerV2";
63     private static final int CLOUD_SYNC_TIMEOUT_MILLIS = 500;
64     public static final List<String> sMergedAlbumIds = List.of(
65             AlbumColumns.ALBUM_ID_FAVORITES,
66             AlbumColumns.ALBUM_ID_VIDEOS
67     );
68 
69     /**
70      * Returns a cursor with the Photo Picker media in response.
71      *
72      * @param appContext The application context.
73      * @param queryArgs The arguments help us filter on the media query to yield the desired
74      *                  results.
75      */
76     @NonNull
queryMedia(@onNull Context appContext, @NonNull Bundle queryArgs)77     static Cursor queryMedia(@NonNull Context appContext, @NonNull Bundle queryArgs) {
78         final MediaQuery query = new MediaQuery(queryArgs);
79         final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
80         final String effectiveLocalAuthority =
81                 query.getProviders().contains(syncController.getLocalProvider())
82                         ? syncController.getLocalProvider()
83                         : null;
84         final String cloudAuthority = syncController
85                 .getCloudProviderOrDefault(/* defaultValue */ null);
86         final String effectiveCloudAuthority =
87                 syncController.shouldQueryCloudMedia(query.getProviders(), cloudAuthority)
88                         ? cloudAuthority
89                         : null;
90 
91         return queryMediaInternal(
92                 appContext,
93                 syncController,
94                 query,
95                 effectiveLocalAuthority,
96                 effectiveCloudAuthority
97         );
98     }
99 
100     /**
101      * Returns a cursor with the Photo Picker albums in response.
102      *
103      * @param appContext The application context.
104      * @param queryArgs The arguments help us filter on the media query to yield the desired
105      *                  results.
106      */
107     @Nullable
queryAlbums(@onNull Context appContext, @NonNull Bundle queryArgs)108     static Cursor queryAlbums(@NonNull Context appContext, @NonNull Bundle queryArgs) {
109         final MediaQuery query = new MediaQuery(queryArgs);
110         final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
111         final SQLiteDatabase database = syncController.getDbFacade().getDatabase();
112         final String localAuthority = syncController.getLocalProvider();
113         final boolean shouldShowLocalAlbums = query.getProviders().contains(localAuthority);
114         final String cloudAuthority =
115                 syncController.getCloudProviderOrDefault(/* defaultValue */ null);
116         final boolean shouldShowCloudAlbums = syncController.shouldQueryCloudMedia(
117                 query.getProviders(), cloudAuthority);
118         final List<AlbumsCursorWrapper> cursors = new ArrayList<>();
119 
120         if (shouldShowLocalAlbums || shouldShowCloudAlbums) {
121             cursors.add(getMergedAlbumsCursor(
122                     AlbumColumns.ALBUM_ID_FAVORITES, queryArgs, database,
123                     shouldShowLocalAlbums ? localAuthority : null,
124                     shouldShowCloudAlbums ? cloudAuthority : null));
125 
126             cursors.add(getMergedAlbumsCursor(
127                     AlbumColumns.ALBUM_ID_VIDEOS, queryArgs, database,
128                     shouldShowLocalAlbums ? localAuthority : null,
129                     shouldShowCloudAlbums ? cloudAuthority : null));
130         }
131 
132         if (shouldShowLocalAlbums) {
133             cursors.add(getLocalAlbumsCursor(appContext, query, localAuthority));
134         }
135 
136         if (shouldShowCloudAlbums) {
137             cursors.add(getCloudAlbumsCursor(appContext, query, localAuthority, cloudAuthority));
138         }
139 
140         cursors.removeIf(Objects::isNull);
141         if (cursors.isEmpty()) {
142             Log.e(TAG, "No albums available");
143             return null;
144         } else {
145             Cursor mergeCursor = new MergeCursor(cursors.toArray(new Cursor[0]));
146             Log.i(TAG, "Returning " + mergeCursor.getCount() + " albums metadata");
147             return mergeCursor;
148         }
149     }
150 
151     /**
152      * Returns a cursor with the Photo Picker album media in response.
153      *
154      * @param appContext The application context.
155      * @param queryArgs The arguments help us filter on the media query to yield the desired
156      *                  results.
157      * @param albumId The album ID of the requested album media.
158      */
queryAlbumMedia( @onNull Context appContext, @NonNull Bundle queryArgs, @NonNull String albumId)159     static Cursor queryAlbumMedia(
160             @NonNull Context appContext,
161             @NonNull Bundle queryArgs,
162             @NonNull String albumId) {
163         final AlbumMediaQuery query = new AlbumMediaQuery(queryArgs, albumId);
164         final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
165         final String effectiveLocalAuthority =
166                 query.getProviders().contains(syncController.getLocalProvider())
167                         ? syncController.getLocalProvider()
168                         : null;
169         final String cloudAuthority = syncController
170                 .getCloudProviderOrDefault(/* defaultValue */ null);
171         final String effectiveCloudAuthority =
172                 syncController.shouldQueryCloudMedia(query.getProviders(), cloudAuthority)
173                         ? cloudAuthority
174                         : null;
175 
176         if (isMergedAlbum(albumId)) {
177             return queryMergedAlbumMedia(
178                     albumId,
179                     appContext,
180                     syncController,
181                     queryArgs,
182                     effectiveLocalAuthority,
183                     effectiveCloudAuthority
184             );
185         } else {
186             return queryAlbumMediaInternal(
187                     appContext,
188                     syncController,
189                     query,
190                     effectiveLocalAuthority,
191                     effectiveCloudAuthority
192             );
193         }
194     }
195 
196     /**
197      * Query media from the database and prepare a cursor in response.
198      *
199      * We need to make multiple queries to prepare a response for the media query.
200      * {@link android.database.sqlite.SQLiteQueryBuilder} currently does not support the creation of
201      * a transaction in {@code DEFERRED} mode. This is why we'll perform the read queries in
202      * {@code IMMEDIATE} mode instead.
203      *
204      * @param appContext The application context.
205      * @param syncController Instance of the PickerSyncController singleton.
206      * @param query The MediaQuery object instance that tells us about the media query args.
207      * @param localAuthority The effective local authority that we need to consider for this
208      *                       transaction. If the local items should not be queries but the local
209      *                       authority has some value, the effective local authority would be null.
210      * @param cloudAuthority The effective cloud authority that we need to consider for this
211      *                       transaction. If the local items should not be queries but the local
212      *                       authority has some value, the effective local authority would
213      *                       be null.
214      * @return The cursor with the album media query results.
215      */
216     @NonNull
queryMediaInternal( @onNull Context appContext, @NonNull PickerSyncController syncController, @NonNull MediaQuery query, @Nullable String localAuthority, @Nullable String cloudAuthority )217     private static Cursor queryMediaInternal(
218             @NonNull Context appContext,
219             @NonNull PickerSyncController syncController,
220             @NonNull MediaQuery query,
221             @Nullable String localAuthority,
222             @Nullable String cloudAuthority
223     ) {
224         try {
225             final SQLiteDatabase database = syncController.getDbFacade().getDatabase();
226 
227             waitForOngoingSync(appContext, localAuthority, cloudAuthority);
228 
229             try {
230                 database.beginTransactionNonExclusive();
231                 Cursor pageData = database.rawQuery(
232                         getMediaPageQuery(
233                             query,
234                             database,
235                             PickerSQLConstants.Table.MEDIA,
236                             localAuthority,
237                             cloudAuthority
238                         ),
239                         /* selectionArgs */ null
240                 );
241 
242                 Bundle extraArgs = new Bundle();
243                 Cursor nextPageKeyCursor = database.rawQuery(
244                         getMediaNextPageKeyQuery(
245                             query,
246                             database,
247                             PickerSQLConstants.Table.MEDIA,
248                             localAuthority,
249                             cloudAuthority
250                         ),
251                         /* selectionArgs */ null
252                 );
253                 addNextPageKey(extraArgs, nextPageKeyCursor);
254 
255                 Cursor prevPageKeyCursor = database.rawQuery(
256                         getMediaPreviousPageQuery(
257                                 query,
258                                 database,
259                                 PickerSQLConstants.Table.MEDIA,
260                                 localAuthority,
261                                 cloudAuthority
262                         ),
263                         /* selectionArgs */ null
264                 );
265                 addPrevPageKey(extraArgs, prevPageKeyCursor);
266 
267                 database.setTransactionSuccessful();
268 
269                 pageData.setExtras(extraArgs);
270                 Log.i(TAG, "Returning " + pageData.getCount() + " media metadata");
271                 return pageData;
272             } finally {
273                 database.endTransaction();
274             }
275 
276 
277         } catch (Exception e) {
278             throw new RuntimeException("Could not fetch media", e);
279         }
280     }
281 
waitForOngoingSync( @onNull Context appContext, @Nullable String localAuthority, @Nullable String cloudAuthority)282     private static void waitForOngoingSync(
283             @NonNull Context appContext,
284             @Nullable String localAuthority,
285             @Nullable String cloudAuthority) {
286         if (localAuthority != null) {
287             SyncCompletionWaiter.waitForSync(
288                     getWorkManager(appContext),
289                     SyncTrackerRegistry.getLocalSyncTracker(),
290                     IMMEDIATE_LOCAL_SYNC_WORK_NAME
291             );
292         }
293 
294         if (cloudAuthority != null) {
295             boolean syncIsComplete = SyncCompletionWaiter.waitForSyncWithTimeout(
296                     SyncTrackerRegistry.getCloudSyncTracker(),
297                     CLOUD_SYNC_TIMEOUT_MILLIS);
298             Log.i(TAG, "Finished waiting for cloud sync.  Is cloud sync complete: "
299                     + syncIsComplete);
300         }
301     }
302 
303     /**
304      * @param appContext The application context.
305      * @param query The AlbumMediaQuery object instance that tells us about the media query args.
306      * @param localAuthority The effective local authority that we need to consider for this
307      *                       transaction. If the local items should not be queries but the local
308      *                       authority has some value, the effective local authority would be null.
309      */
waitForOngoingAlbumSync( @onNull Context appContext, @NonNull AlbumMediaQuery query, @Nullable String localAuthority)310     private static void waitForOngoingAlbumSync(
311             @NonNull Context appContext,
312             @NonNull AlbumMediaQuery query,
313             @Nullable String localAuthority) {
314         boolean isLocal = localAuthority != null
315                 && localAuthority.equals(query.getAlbumAuthority());
316         SyncCompletionWaiter.waitForSyncWithTimeout(
317                 SyncTrackerRegistry.getAlbumSyncTracker(isLocal),
318                 /* timeoutInMillis */ 500);
319     }
320 
321     /**
322      * Adds the next page key to the cursor extras from the given cursor.
323      *
324      * This is not a part of the page data. Photo Picker UI uses the Paging library requires us to
325      * provide the previous page key and the next page key as part of a page load response.
326      * The page key in this case refers to the date taken and the picker id of the first item in
327      * the page.
328      */
addNextPageKey(Bundle extraArgs, Cursor nextPageKeyCursor)329     private static void addNextPageKey(Bundle extraArgs, Cursor nextPageKeyCursor) {
330         if (nextPageKeyCursor.moveToFirst()) {
331             final int pickerIdColumnIndex = nextPageKeyCursor.getColumnIndex(
332                     PickerSQLConstants.MediaResponse.PICKER_ID.getProjectedName()
333             );
334 
335             if (pickerIdColumnIndex >= 0) {
336                 extraArgs.putLong(PickerSQLConstants.MediaResponseExtras.NEXT_PAGE_ID.getKey(),
337                         nextPageKeyCursor.getLong(pickerIdColumnIndex)
338                 );
339             }
340 
341             final int dateTakenColumnIndex = nextPageKeyCursor.getColumnIndex(
342                     PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getProjectedName()
343             );
344 
345             if (dateTakenColumnIndex >= 0) {
346                 extraArgs.putLong(PickerSQLConstants.MediaResponseExtras
347                                 .NEXT_PAGE_DATE_TAKEN.getKey(),
348                         nextPageKeyCursor.getLong(dateTakenColumnIndex)
349                 );
350             }
351         }
352     }
353 
354     /**
355      * Adds the previous page key to the cursor extras from the given cursor.
356      *
357      * This is not a part of the page data. Photo Picker UI uses the Paging library requires us to
358      * provide the previous page key and the next page key as part of a page load response.
359      * The page key in this case refers to the date taken and the picker id of the first item in
360      * the page.
361      */
addPrevPageKey(Bundle extraArgs, Cursor prevPageKeyCursor)362     private static void addPrevPageKey(Bundle extraArgs, Cursor prevPageKeyCursor) {
363         if (prevPageKeyCursor.moveToLast()) {
364             final int pickerIdColumnIndex = prevPageKeyCursor.getColumnIndex(
365                     PickerSQLConstants.MediaResponse.PICKER_ID.getProjectedName()
366             );
367 
368             if (pickerIdColumnIndex >= 0) {
369                 extraArgs.putLong(PickerSQLConstants.MediaResponseExtras.PREV_PAGE_ID.getKey(),
370                         prevPageKeyCursor.getLong(pickerIdColumnIndex)
371                 );
372             }
373 
374             final int dateTakenColumnIndex = prevPageKeyCursor.getColumnIndex(
375                     PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getProjectedName()
376             );
377 
378             if (dateTakenColumnIndex >= 0) {
379                 extraArgs.putLong(PickerSQLConstants.MediaResponseExtras
380                                 .PREV_PAGE_DATE_TAKEN.getKey(),
381                         prevPageKeyCursor.getLong(dateTakenColumnIndex)
382                 );
383             }
384         }
385     }
386 
387     /**
388      * Builds and returns the SQL query to get the page contents from the Media table in Picker DB.
389      */
getMediaPageQuery( @onNull MediaQuery query, @NonNull SQLiteDatabase database, @NonNull PickerSQLConstants.Table table, @Nullable String localAuthority, @Nullable String cloudAuthority)390     private static String getMediaPageQuery(
391             @NonNull MediaQuery query,
392             @NonNull SQLiteDatabase database,
393             @NonNull PickerSQLConstants.Table table,
394             @Nullable String localAuthority,
395             @Nullable String cloudAuthority) {
396         SelectSQLiteQueryBuilder queryBuilder = new SelectSQLiteQueryBuilder(database)
397                 .setTables(table.name())
398                 .setProjection(List.of(
399                         PickerSQLConstants.MediaResponse.MEDIA_ID.getProjection(),
400                         PickerSQLConstants.MediaResponse.PICKER_ID.getProjection(),
401                         PickerSQLConstants.MediaResponse
402                                 .AUTHORITY.getProjection(localAuthority, cloudAuthority),
403                         PickerSQLConstants.MediaResponse.MEDIA_SOURCE.getProjection(),
404                         PickerSQLConstants.MediaResponse.WRAPPED_URI.getProjection(
405                                 localAuthority, cloudAuthority, query.getIntentAction()),
406                         PickerSQLConstants.MediaResponse
407                                 .UNWRAPPED_URI.getProjection(localAuthority, cloudAuthority),
408                         PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getProjection(),
409                         PickerSQLConstants.MediaResponse.SIZE_IN_BYTES.getProjection(),
410                         PickerSQLConstants.MediaResponse.MIME_TYPE.getProjection(),
411                         PickerSQLConstants.MediaResponse.STANDARD_MIME_TYPE.getProjection(),
412                         PickerSQLConstants.MediaResponse.DURATION_MS.getProjection()
413                 ))
414                 .setSortOrder(
415                         String.format(
416                                 "%s DESC, %s DESC",
417                                 PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getColumnName(),
418                                 PickerSQLConstants.MediaResponse.PICKER_ID.getColumnName()
419                         )
420                 )
421                 .setLimit(query.getPageSize());
422 
423         query.addWhereClause(
424                 queryBuilder,
425                 localAuthority,
426                 cloudAuthority,
427                 /* reverseOrder */ false
428         );
429 
430         return queryBuilder.buildQuery();
431     }
432 
433     /**
434      * Builds and returns the SQL query to get the next page key from the Media table in Picker DB.
435      */
436     @Nullable
getMediaNextPageKeyQuery( @onNull MediaQuery query, @NonNull SQLiteDatabase database, @NonNull PickerSQLConstants.Table table, @Nullable String localAuthority, @Nullable String cloudAuthority)437     private static String getMediaNextPageKeyQuery(
438             @NonNull MediaQuery query,
439             @NonNull SQLiteDatabase database,
440             @NonNull PickerSQLConstants.Table table,
441             @Nullable String localAuthority,
442             @Nullable String cloudAuthority) {
443         if (query.getPageSize() == Integer.MAX_VALUE) {
444             return null;
445         }
446 
447         SelectSQLiteQueryBuilder queryBuilder = new SelectSQLiteQueryBuilder(database)
448                 .setTables(table.name())
449                 .setProjection(List.of(
450                         PickerSQLConstants.MediaResponse.PICKER_ID.getProjection(),
451                         PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getProjection()
452                 ))
453                 .setSortOrder(
454                         String.format(
455                                 "%s DESC, %s DESC",
456                                 PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getColumnName(),
457                                 PickerSQLConstants.MediaResponse.PICKER_ID.getColumnName()
458                         )
459                 )
460                 .setLimit(1)
461                 .setOffset(query.getPageSize());
462 
463         query.addWhereClause(
464                 queryBuilder,
465                 localAuthority,
466                 cloudAuthority,
467                 /* reverseOrder */ false
468         );
469 
470         return queryBuilder.buildQuery();
471     }
472 
473     /**
474      * Builds and returns the SQL query to get the previous page contents from the Media table in
475      * Picker DB.
476      *
477      * We fetch the whole page and not just one key because it is possible that the previous page
478      * is smaller than the page size. So, we get the whole page and only use the last row item to
479      * get the previous page key.
480      */
getMediaPreviousPageQuery( @onNull MediaQuery query, @NonNull SQLiteDatabase database, @NonNull PickerSQLConstants.Table table, @Nullable String localAuthority, @Nullable String cloudAuthority)481     private static String getMediaPreviousPageQuery(
482             @NonNull MediaQuery query,
483             @NonNull SQLiteDatabase database,
484             @NonNull PickerSQLConstants.Table table,
485             @Nullable String localAuthority,
486             @Nullable String cloudAuthority) {
487         SelectSQLiteQueryBuilder queryBuilder = new SelectSQLiteQueryBuilder(database)
488                 .setTables(table.name())
489                 .setProjection(List.of(
490                         PickerSQLConstants.MediaResponse.PICKER_ID.getProjection(),
491                         PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getProjection()
492                 )).setSortOrder(
493                         String.format(
494                                 "%s ASC, %s ASC",
495                                 PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getColumnName(),
496                                 PickerSQLConstants.MediaResponse.PICKER_ID.getColumnName()
497                         )
498                 ).setLimit(query.getPageSize());
499 
500 
501         query.addWhereClause(
502                 queryBuilder,
503                 localAuthority,
504                 cloudAuthority,
505                 /* reverseOrder */ true
506         );
507 
508         return queryBuilder.buildQuery();
509     }
510 
511     /**
512      * Return merged albums cursor for the given merged album id.
513      *
514      * @param albumId Merged album id.
515      * @param queryArgs Query arguments bundle that will be used to filter albums.
516      * @param database Instance of Picker SQLiteDatabase.
517      * @param localAuthority The local authority if local albums should be returned, otherwise this
518      *                       argument should be null.
519      * @param cloudAuthority The cloud authority if cloud albums should be returned, otherwise this
520      *                       argument should be null.
521      */
getMergedAlbumsCursor( @onNull String albumId, @NonNull Bundle queryArgs, @NonNull SQLiteDatabase database, @Nullable String localAuthority, @Nullable String cloudAuthority)522     private static AlbumsCursorWrapper getMergedAlbumsCursor(
523             @NonNull String albumId,
524             @NonNull Bundle queryArgs,
525             @NonNull SQLiteDatabase database,
526             @Nullable String localAuthority,
527             @Nullable String cloudAuthority) {
528         final MediaQuery query;
529         if (albumId.equals(AlbumColumns.ALBUM_ID_VIDEOS)) {
530             VideoMediaQuery videoQuery = new VideoMediaQuery(queryArgs, 1);
531             if (!videoQuery.shouldDisplayVideosAlbum()) {
532                 return null;
533             }
534             query = videoQuery;
535         } else if (albumId.equals(AlbumColumns.ALBUM_ID_FAVORITES)) {
536             query = new FavoritesMediaQuery(queryArgs, 1);
537         } else {
538             Log.e(TAG, "Cannot recognize merged album " + albumId);
539             return null;
540         }
541 
542         try {
543             database.beginTransactionNonExclusive();
544             Cursor pickerDBResponse = database.rawQuery(
545                     getMediaPageQuery(
546                             query,
547                             database,
548                             PickerSQLConstants.Table.MEDIA,
549                             localAuthority,
550                             cloudAuthority
551                     ),
552                     /* selectionArgs */ null
553             );
554 
555             if (pickerDBResponse.moveToFirst()) {
556                 // Conform to the album response projection. Temporary code, this will change once
557                 // we start caching album metadata.
558                 final MatrixCursor result = new MatrixCursor(AlbumColumns.ALL_PROJECTION);
559                 final String authority = pickerDBResponse.getString(pickerDBResponse.getColumnIndex(
560                         PickerSQLConstants.MediaResponse.AUTHORITY.getProjectedName()));
561                 final String[] projectionValue = new String[]{
562                         /* albumId */ albumId,
563                         pickerDBResponse.getString(pickerDBResponse.getColumnIndex(
564                                 PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getProjectedName())),
565                         /* displayName */ albumId,
566                         pickerDBResponse.getString(pickerDBResponse.getColumnIndex(
567                                 PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName())),
568                         /* count */ "0", // This value is not used anymore
569                         authority,
570                 };
571                 result.addRow(projectionValue);
572                 return new AlbumsCursorWrapper(result, authority, localAuthority);
573             }
574 
575             // Show merged albums even if no data is currently available in the DB when cloud media
576             // feature is enabled.
577             if (cloudAuthority != null) {
578                 // Conform to the album response projection. Temporary code, this will change once
579                 // we start caching album metadata.
580                 final MatrixCursor result = new MatrixCursor(AlbumColumns.ALL_PROJECTION);
581                 final String[] projectionValue = new String[]{
582                         /* albumId */ albumId,
583                         /* dateTakenMillis */ Long.toString(Long.MAX_VALUE),
584                         /* displayName */ albumId,
585                         /* mediaId */ EMPTY_MEDIA_ID,
586                         /* count */ "0", // This value is not used anymore
587                         localAuthority,
588                 };
589                 result.addRow(projectionValue);
590                 return new AlbumsCursorWrapper(result, localAuthority, localAuthority);
591             }
592 
593             return null;
594         } finally {
595             database.setTransactionSuccessful();
596             database.endTransaction();
597         }
598 
599     }
600 
601     /**
602      * Returns local albums cursor after fetching them from the local provider.
603      *
604      * @param appContext The application context.
605      * @param query Query arguments that will be used to filter albums.
606      * @param localAuthority Authority of the local media provider.
607      */
608     @Nullable
getLocalAlbumsCursor( @onNull Context appContext, @NonNull MediaQuery query, @NonNull String localAuthority)609     private static AlbumsCursorWrapper getLocalAlbumsCursor(
610             @NonNull Context appContext,
611             @NonNull MediaQuery query,
612             @NonNull String localAuthority) {
613         return getCMPAlbumsCursor(appContext, query, localAuthority, localAuthority);
614     }
615 
616     /**
617      * Returns cloud albums cursor after fetching them from the local provider.
618      *
619      * @param appContext The application context.
620      * @param query Query arguments that will be used to filter albums.
621      * @param localAuthority Authority of the local media provider.
622      * @param cloudAuthority Authority of the cloud media provider.
623      */
624     @Nullable
getCloudAlbumsCursor( @onNull Context appContext, @NonNull MediaQuery query, @NonNull String localAuthority, @NonNull String cloudAuthority)625     private static AlbumsCursorWrapper getCloudAlbumsCursor(
626             @NonNull Context appContext,
627             @NonNull MediaQuery query,
628             @NonNull String localAuthority,
629             @NonNull String cloudAuthority) {
630         return getCMPAlbumsCursor(appContext, query, localAuthority, cloudAuthority);
631     }
632 
633     /**
634      * Returns {@link AlbumsCursorWrapper} object that wraps the albums cursor response from the
635      * CMP.
636      *
637      * @param appContext The application context.
638      * @param query Query arguments that will be used to filter albums.
639      * @param localAuthority Authority of the local media provider.
640      * @param cmpAuthority Authority of the cloud media provider.
641      */
642     @Nullable
getCMPAlbumsCursor( @onNull Context appContext, @NonNull MediaQuery query, @NonNull String localAuthority, @NonNull String cmpAuthority)643     private static AlbumsCursorWrapper getCMPAlbumsCursor(
644             @NonNull Context appContext,
645             @NonNull MediaQuery query,
646             @NonNull String localAuthority,
647             @NonNull String cmpAuthority) {
648         final Cursor cursor = appContext.getContentResolver().query(
649                 getAlbumUri(cmpAuthority),
650                 /* projection */ null,
651                 query.prepareCMPQueryArgs(),
652                 /* cancellationSignal */ null);
653         return cursor == null
654                 ? null
655                 : new AlbumsCursorWrapper(cursor, cmpAuthority, localAuthority);
656     }
657 
658     /**
659      * @param appContext The application context.
660      * @param syncController Instance of the PickerSyncController singleton.
661      * @param query The AlbumMediaQuery object instance that tells us about the media query args.
662      * @param localAuthority The effective local authority that we need to consider for this
663      *                       transaction. If the local items should not be queries but the local
664      *                       authority has some value, the effective local authority would be null.
665      * @param cloudAuthority The effective cloud authority that we need to consider for this
666      *                       transaction. If the local items should not be queries but the local
667      *                       authority has some value, the effective local authority would
668      *                       be null.
669      * @return The cursor with the album media query results.
670      */
671     @NonNull
queryAlbumMediaInternal( @onNull Context appContext, @NonNull PickerSyncController syncController, @NonNull AlbumMediaQuery query, @Nullable String localAuthority, @Nullable String cloudAuthority )672     private static Cursor queryAlbumMediaInternal(
673             @NonNull Context appContext,
674             @NonNull PickerSyncController syncController,
675             @NonNull AlbumMediaQuery query,
676             @Nullable String localAuthority,
677             @Nullable String cloudAuthority
678     ) {
679         try {
680             final SQLiteDatabase database = syncController.getDbFacade().getDatabase();
681 
682             waitForOngoingAlbumSync(appContext, query, localAuthority);
683 
684             try {
685                 database.beginTransactionNonExclusive();
686                 Cursor pageData = database.rawQuery(
687                         getMediaPageQuery(
688                                 query,
689                                 database,
690                                 PickerSQLConstants.Table.ALBUM_MEDIA,
691                                 localAuthority,
692                                 cloudAuthority
693                         ),
694                         /* selectionArgs */ null
695                 );
696 
697                 Bundle extraArgs = new Bundle();
698                 Cursor nextPageKeyCursor = database.rawQuery(
699                         getMediaNextPageKeyQuery(
700                                 query,
701                                 database,
702                                 PickerSQLConstants.Table.ALBUM_MEDIA,
703                                 localAuthority,
704                                 cloudAuthority
705                         ),
706                         /* selectionArgs */ null
707                 );
708                 addNextPageKey(extraArgs, nextPageKeyCursor);
709 
710                 Cursor prevPageKeyCursor = database.rawQuery(
711                         getMediaPreviousPageQuery(
712                                 query,
713                                 database,
714                                 PickerSQLConstants.Table.ALBUM_MEDIA,
715                                 localAuthority,
716                                 cloudAuthority
717                         ),
718                         /* selectionArgs */ null
719                 );
720                 addPrevPageKey(extraArgs, prevPageKeyCursor);
721 
722                 database.setTransactionSuccessful();
723 
724                 pageData.setExtras(extraArgs);
725                 Log.i(TAG, "Returning " + pageData.getCount() + " album media items for album "
726                         + query.getAlbumId());
727                 return pageData;
728             } finally {
729                 database.endTransaction();
730             }
731 
732 
733         } catch (Exception e) {
734             throw new RuntimeException("Could not fetch media", e);
735         }
736     }
737 
738     /**
739      * @param albumId The album id of the request album media.
740      * @param appContext The application context.
741      * @param syncController Instance of the PickerSyncController singleton.
742      * @param queryArgs The Bundle with query args received with the request.
743      * @param localAuthority The effective local authority that we need to consider for this
744      *                       transaction. If the local items should not be queries but the local
745      *                       authority has some value, the effective local authority would be null.
746      * @param cloudAuthority The effective cloud authority that we need to consider for this
747      *                       transaction. If the local items should not be queries but the local
748      *                       authority has some value, the effective local authority would
749      *                       be null.
750      * @return The cursor with the album media query results.
751      */
752     @NonNull
queryMergedAlbumMedia( @onNull String albumId, @NonNull Context appContext, @NonNull PickerSyncController syncController, @NonNull Bundle queryArgs, @Nullable String localAuthority, @Nullable String cloudAuthority )753     private static Cursor queryMergedAlbumMedia(
754             @NonNull String albumId,
755             @NonNull Context appContext,
756             @NonNull PickerSyncController syncController,
757             @NonNull Bundle queryArgs,
758             @Nullable String localAuthority,
759             @Nullable String cloudAuthority
760     ) {
761         try {
762             MediaQuery query;
763             switch (albumId) {
764                 case AlbumColumns.ALBUM_ID_FAVORITES:
765                     query = new FavoritesMediaQuery(queryArgs);
766                     break;
767                 case AlbumColumns.ALBUM_ID_VIDEOS:
768                     query = new VideoMediaQuery(queryArgs);
769                     break;
770                 default:
771                     throw new IllegalArgumentException("Cannot recognize album " + albumId);
772             }
773 
774             final SQLiteDatabase database = syncController.getDbFacade().getDatabase();
775 
776             waitForOngoingSync(appContext, localAuthority, cloudAuthority);
777 
778             try {
779                 database.beginTransactionNonExclusive();
780                 Cursor pageData = database.rawQuery(
781                         getMediaPageQuery(
782                                 query,
783                                 database,
784                                 PickerSQLConstants.Table.MEDIA,
785                                 localAuthority,
786                                 cloudAuthority
787                         ),
788                         /* selectionArgs */ null
789                 );
790 
791                 Bundle extraArgs = new Bundle();
792                 Cursor nextPageKeyCursor = database.rawQuery(
793                         getMediaNextPageKeyQuery(
794                                 query,
795                                 database,
796                                 PickerSQLConstants.Table.MEDIA,
797                                 localAuthority,
798                                 cloudAuthority
799                         ),
800                         /* selectionArgs */ null
801                 );
802                 addNextPageKey(extraArgs, nextPageKeyCursor);
803 
804                 Cursor prevPageKeyCursor = database.rawQuery(
805                         getMediaPreviousPageQuery(
806                                 query,
807                                 database,
808                                 PickerSQLConstants.Table.MEDIA,
809                                 localAuthority,
810                                 cloudAuthority
811                         ),
812                         /* selectionArgs */ null
813                 );
814                 addPrevPageKey(extraArgs, prevPageKeyCursor);
815 
816                 database.setTransactionSuccessful();
817 
818                 pageData.setExtras(extraArgs);
819                 Log.i(TAG, "Returning " + pageData.getCount() + " album media items for album "
820                         + albumId);
821                 return pageData;
822             } finally {
823                 database.endTransaction();
824             }
825         } catch (Exception e) {
826             throw new RuntimeException("Could not fetch media", e);
827         }
828     }
829 
830     /**
831      * @return a cursor with the available providers.
832      */
833     @NonNull
queryAvailableProviders(@onNull Context context)834     public static Cursor queryAvailableProviders(@NonNull Context context) {
835         try {
836             final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow();
837             final String[] columnNames = Arrays
838                     .stream(PickerSQLConstants.AvailableProviderResponse.values())
839                     .map(PickerSQLConstants.AvailableProviderResponse::getColumnName)
840                     .toArray(String[]::new);
841             final MatrixCursor matrixCursor = new MatrixCursor(columnNames, /*initialCapacity */ 2);
842             final String localAuthority = syncController.getLocalProvider();
843             addAvailableProvidersToCursor(matrixCursor,
844                     localAuthority,
845                     MediaSource.LOCAL,
846                     Process.myUid());
847 
848             final String cloudAuthority =
849                     syncController.getCloudProviderOrDefault(/* defaultValue */ null);
850             if (syncController.shouldQueryCloudMedia(cloudAuthority)) {
851                 final PackageManager packageManager = context.getPackageManager();
852                 final ProviderInfo providerInfo = requireNonNull(
853                         packageManager.resolveContentProvider(cloudAuthority, /* flags */ 0));
854                 final int uid = packageManager.getPackageUid(
855                         providerInfo.packageName,
856                         /* flags */ 0
857                 );
858                 addAvailableProvidersToCursor(
859                         matrixCursor,
860                         cloudAuthority,
861                         MediaSource.REMOTE,
862                         uid);
863             }
864 
865             return matrixCursor;
866         } catch (IllegalStateException | NameNotFoundException e) {
867             throw new RuntimeException("Unexpected internal error occurred", e);
868         }
869     }
870 
addAvailableProvidersToCursor( @onNull MatrixCursor cursor, @NonNull String authority, @NonNull MediaSource source, @UserIdInt int uid)871     private static void addAvailableProvidersToCursor(
872             @NonNull MatrixCursor cursor,
873             @NonNull String authority,
874             @NonNull MediaSource source,
875             @UserIdInt int uid) {
876         cursor.newRow()
877                 .add(PickerSQLConstants.AvailableProviderResponse.AUTHORITY.getColumnName(),
878                         authority)
879                 .add(PickerSQLConstants.AvailableProviderResponse.MEDIA_SOURCE.getColumnName(),
880                         source.name())
881                 .add(PickerSQLConstants.AvailableProviderResponse.UID.getColumnName(), uid);
882     }
883 
884     /**
885      * @param albumId Album identifier.
886      * @return True if the given album id matches the album id of any merged album.
887      */
isMergedAlbum(@onNull String albumId)888     private static boolean isMergedAlbum(@NonNull String albumId) {
889         return sMergedAlbumIds.contains(albumId);
890     }
891 
892     /**
893      * @return a Bundle with the details of the requested cloud provider.
894      */
getCloudProviderDetails(Bundle queryArgs)895     public static Bundle getCloudProviderDetails(Bundle queryArgs) {
896         throw new UnsupportedOperationException("This method is not implemented yet.");
897     }
898 }
899