• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2021 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.data;
18 
19 import static android.provider.CloudMediaProviderContract.AlbumColumns;
20 import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_FAVORITES;
21 import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_VIDEOS;
22 import static android.provider.CloudMediaProviderContract.MediaColumns;
23 import static android.provider.MediaStore.PickerMediaColumns;
24 
25 import static com.android.providers.media.MediaGrants.FILE_ID_COLUMN;
26 import static com.android.providers.media.MediaGrants.OWNER_PACKAGE_NAME_COLUMN;
27 import static com.android.providers.media.MediaGrants.PACKAGE_USER_ID_COLUMN;
28 import static com.android.providers.media.photopicker.PickerSyncController.PAGE_SIZE;
29 import static com.android.providers.media.photopicker.util.CursorUtils.getCursorLong;
30 import static com.android.providers.media.photopicker.util.CursorUtils.getCursorString;
31 import static com.android.providers.media.util.DatabaseUtils.replaceMatchAnyChar;
32 import static com.android.providers.media.util.SyntheticPathUtils.getPickerRelativePath;
33 
34 import android.content.ContentUris;
35 import android.content.ContentValues;
36 import android.content.Context;
37 import android.database.Cursor;
38 import android.database.DatabaseUtils;
39 import android.database.MatrixCursor;
40 import android.database.MergeCursor;
41 import android.database.sqlite.SQLiteConstraintException;
42 import android.database.sqlite.SQLiteDatabase;
43 import android.database.sqlite.SQLiteQueryBuilder;
44 import android.net.Uri;
45 import android.os.Trace;
46 import android.provider.CloudMediaProviderContract;
47 import android.provider.MediaStore;
48 import android.text.TextUtils;
49 import android.util.Log;
50 
51 import androidx.annotation.NonNull;
52 import androidx.annotation.Nullable;
53 import androidx.annotation.VisibleForTesting;
54 
55 import com.android.providers.media.PickerUriResolver;
56 import com.android.providers.media.photopicker.PickerSyncController;
57 import com.android.providers.media.photopicker.data.model.Item;
58 import com.android.providers.media.photopicker.sync.CloseableReentrantLock;
59 import com.android.providers.media.photopicker.sync.PickerSyncLockManager;
60 import com.android.providers.media.photopicker.sync.SyncTrackerRegistry;
61 import com.android.providers.media.photopicker.util.exceptions.UnableToAcquireLockException;
62 import com.android.providers.media.photopicker.v2.PickerDataLayerV2;
63 import com.android.providers.media.photopicker.v2.PickerNotificationSender;
64 import com.android.providers.media.util.MimeUtils;
65 
66 import java.io.PrintWriter;
67 import java.util.ArrayList;
68 import java.util.List;
69 import java.util.Objects;
70 
71 /**
72  * This is a facade that hides the complexities of executing some SQL statements on the picker db.
73  * It does not do any caller permission checks and is only intended for internal use within the
74  * MediaProvider for the Photo Picker.
75  */
76 public class PickerDbFacade {
77     private static final String VIDEO_MIME_TYPES = "video/%";
78     private final Context mContext;
79     private final PickerDatabaseHelper mPickerDatabaseHelper;
80     private final PickerSyncLockManager mPickerSyncLockManager;
81     private final String mLocalProvider;
82     // This is the cloud provider the database is synced with. It can be set as null to disable
83     // cloud queries when database is not in sync with the current cloud provider.
84     @Nullable
85     private String mCloudProvider;
86 
87 
PickerDbFacade(Context context, PickerSyncLockManager pickerSyncLockManager)88     public PickerDbFacade(Context context, PickerSyncLockManager pickerSyncLockManager) {
89         this(context, pickerSyncLockManager, PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY);
90     }
91 
92     @VisibleForTesting
PickerDbFacade(Context context, PickerSyncLockManager pickerSyncLockManager, String localProvider)93     public PickerDbFacade(Context context, PickerSyncLockManager pickerSyncLockManager,
94             String localProvider) {
95         this(context, pickerSyncLockManager, localProvider, new PickerDatabaseHelper(context));
96     }
97 
98     @VisibleForTesting
PickerDbFacade(Context context, PickerSyncLockManager pickerSyncLockManager, String localProvider, PickerDatabaseHelper dbHelper)99     public PickerDbFacade(Context context, PickerSyncLockManager pickerSyncLockManager,
100             String localProvider, PickerDatabaseHelper dbHelper) {
101         mContext = context;
102         mLocalProvider = localProvider;
103         mPickerDatabaseHelper = dbHelper;
104         mPickerSyncLockManager = pickerSyncLockManager;
105     }
106 
107     private static final String TAG = "PickerDbFacade";
108 
109     private static final int RETRY = 0;
110     private static final int SUCCESS = 1;
111     private static final int FAIL = -1;
112 
113     private static final String TABLE_MEDIA = "media";
114 
115     private static final String TABLE_ALBUM_MEDIA = "album_media";
116 
117     private static final String TABLE_GRANTS = "media_grants";
118 
119     public static final String KEY_ID = "_id";
120     public static final String KEY_LOCAL_ID = "local_id";
121     public static final String KEY_CLOUD_ID = "cloud_id";
122     public static final String KEY_IS_VISIBLE = "is_visible";
123     public static final String KEY_DATE_TAKEN_MS = "date_taken_ms";
124     @VisibleForTesting
125     public static final String KEY_SYNC_GENERATION = "sync_generation";
126     public static final String KEY_SIZE_BYTES = "size_bytes";
127     public static final String KEY_DURATION_MS = "duration_ms";
128     public static final String KEY_MIME_TYPE = "mime_type";
129     public static final String KEY_STANDARD_MIME_TYPE_EXTENSION = "standard_mime_type_extension";
130     public static final String KEY_IS_FAVORITE = "is_favorite";
131     public static final String KEY_ALBUM_ID = "album_id";
132     @VisibleForTesting
133     public static final String KEY_HEIGHT = "height";
134     @VisibleForTesting
135     public static final String KEY_WIDTH = "width";
136     @VisibleForTesting
137     public static final String KEY_ORIENTATION = "orientation";
138     public static final String KEY_OWNER_PACKAGE_NAME = "owner_package_name";
139     public static final String KEY_USER_ID = "_user_id";
140     public static final String EXTRA_OWNER_PACKAGE_NAMES = "owner_package_names";
141     public static final String EXTRA_PACKAGE_USER_ID = "package_user_id";
142 
143     private static final String WHERE_ID = KEY_ID + " = ?";
144     private static final String WHERE_LOCAL_ID = KEY_LOCAL_ID + " = ?";
145     private static final String WHERE_CLOUD_ID = KEY_CLOUD_ID + " = ?";
146     private static final String WHERE_NULL_CLOUD_ID = KEY_CLOUD_ID + " IS NULL";
147     private static final String WHERE_NOT_NULL_CLOUD_ID = KEY_CLOUD_ID + " IS NOT NULL";
148     private static final String WHERE_NOT_NULL_LOCAL_ID = KEY_LOCAL_ID + " IS NOT NULL";
149     private static final String WHERE_IS_VISIBLE = KEY_IS_VISIBLE + " = 1";
150     private static final String WHERE_MIME_TYPE = KEY_MIME_TYPE + " LIKE ? ";
151     private static final String WHERE_SIZE_BYTES = KEY_SIZE_BYTES + " <= ?";
152     private static final String WHERE_DATE_TAKEN_MS_AFTER =
153             String.format("%s > ? OR (%s = ? AND %s > ?)",
154                     KEY_DATE_TAKEN_MS, KEY_DATE_TAKEN_MS, KEY_ID);
155     private static final String WHERE_DATE_TAKEN_MS_BEFORE =
156             String.format("%s < ? OR (%s = ? AND %s < ?)",
157                     KEY_DATE_TAKEN_MS, KEY_DATE_TAKEN_MS, KEY_ID);
158     private static final String WHERE_ALBUM_ID = KEY_ALBUM_ID  + " = ?";
159     private static final String WHERE_LOCAL_ID_IN = KEY_LOCAL_ID  + " IN ";
160     private static final String WHERE_CLOUD_ID_IN = KEY_CLOUD_ID  + " IN ";
161 
162     // This where clause returns all rows for media items that are local-only and are marked as
163     // favorite.
164     //
165     // 'cloud_id' IS NULL AND 'is_favorite' = 1
166     private static final String WHERE_FAVORITE_LOCAL_ONLY = String.format(
167             "%s IS NULL AND %s = 1", KEY_CLOUD_ID, KEY_IS_FAVORITE);
168     // This where clause returns all rows for media items that are cloud-only and are marked as
169     // favorite.
170     //
171     // 'local_id' IS NULL AND 'is_favorite' = 1
172     private static final String WHERE_FAVORITE_CLOUD_ONLY = String.format(
173             "%s IS NULL AND %s = 1", KEY_LOCAL_ID, KEY_IS_FAVORITE);
174     // This where clause returns all local rows from media items for which either local row is
175     // marked as favorite or corresponding cloud row is marked as favorite.
176     // E.g., Rows -
177     // Row1 : local_id=1,    cloud_id=null, is_favorite=0
178     // Row2 : local_id=2,    cloud_id=null, is_favorite=0
179     // Row3 : local_id=3,    cloud_id=null, is_favorite=1
180     // Row4 : local_id=4,    cloud_id=null, is_favorite=1
181     // --
182     // Row5 : local_id=2,    cloud_id=c1,   is_favorite=1
183     // Row6 : local_id=3,    cloud_id=c2,   is_favorite=1
184     // Row7 : local_id=null, cloud_id=c3,   is_favorite=1
185     //
186     // Returns -
187     // Row2 : local_id=2,    cloud_id=null, is_favorite=0
188     // Row3 : local_id=3,    cloud_id=null, is_favorite=1
189     // Row4 : local_id=4,    cloud_id=null, is_favorite=1
190     //
191     // 'local_id' IN (SELECT 'local_id'
192     //      FROM 'media'
193     //      WHERE 'local_id' IS NOT NULL
194     //      GROUP BY 'local_id'
195     //      HAVING SUM('is_favorite') >= 1)
196     private static final String WHERE_FAVORITE_LOCAL_PLUS_CLOUD = String.format(
197             "%s IN (SELECT %s FROM %s WHERE %s IS NOT NULL GROUP BY %s HAVING SUM(%s) >= 1)",
198             KEY_LOCAL_ID, KEY_LOCAL_ID, TABLE_MEDIA, KEY_LOCAL_ID, KEY_LOCAL_ID, KEY_IS_FAVORITE);
199     // This where clause returns all rows for media items that are marked as favorite.
200     // Note that this is different from "WHERE_FAVORITE_LOCAL_ONLY + WHERE_FAVORITE_CLOUD_ONLY"
201     // because for local+cloud row with is_favorite=1 we need to pick corresponding local row.
202     private static final String WHERE_FAVORITE_ALL = String.format(
203             "( %s OR %s )", WHERE_FAVORITE_LOCAL_PLUS_CLOUD, WHERE_FAVORITE_CLOUD_ONLY);
204 
205     // Matches all media including cloud+local, cloud-only and local-only
206     private static final SQLiteQueryBuilder QB_MATCH_ALL = createMediaQueryBuilder();
207     // Matches media with id
208     private static final SQLiteQueryBuilder QB_MATCH_ID = createIdMediaQueryBuilder();
209     // Matches media with local_id including cloud+local and local-only
210     private static final SQLiteQueryBuilder QB_MATCH_LOCAL = createLocalMediaQueryBuilder();
211     // Matches cloud media including cloud+local and cloud-only
212     private static final SQLiteQueryBuilder QB_MATCH_CLOUD = createCloudMediaQueryBuilder();
213     // Matches all visible media including cloud+local, cloud-only and local-only
214     private static final SQLiteQueryBuilder QB_MATCH_VISIBLE = createVisibleMediaQueryBuilder();
215     // Matches visible media with local_id including cloud+local and local-only
216     private static final SQLiteQueryBuilder QB_MATCH_VISIBLE_LOCAL =
217             createVisibleLocalMediaQueryBuilder();
218     // Matches strictly local-only media
219     private static final SQLiteQueryBuilder QB_MATCH_LOCAL_ONLY =
220             createLocalOnlyMediaQueryBuilder();
221 
222     private static final ContentValues CONTENT_VALUE_VISIBLE = new ContentValues();
223     private static final ContentValues CONTENT_VALUE_HIDDEN = new ContentValues();
224 
225     static {
CONTENT_VALUE_VISIBLE.put(KEY_IS_VISIBLE, 1)226         CONTENT_VALUE_VISIBLE.put(KEY_IS_VISIBLE, 1);
227         CONTENT_VALUE_HIDDEN.putNull(KEY_IS_VISIBLE);
228     }
229 
230     /**
231      * Sets the cloud provider to be returned after querying the picker db
232      *
233      * Set cloud provider to null in case the CMP or collection id has changed and the cloud media
234      * results previously synced in the database should not be displayed on the UI.
235      *
236      * This should not be used in picker sync paths because we should not wait on a lock
237      * indefinitely during the picker sync process.
238      * Use {@link this#setCloudProviderWithTimeout} instead.
239      */
setCloudProvider(String authority)240     public void setCloudProvider(String authority) {
241         try (CloseableReentrantLock ignored = mPickerSyncLockManager
242                 .lock(PickerSyncLockManager.DB_CLOUD_LOCK)) {
243             final String previousCloudProvider = mCloudProvider;
244             mCloudProvider = authority;
245             if (!Objects.equals(previousCloudProvider, mCloudProvider)) {
246                 onCloudProviderUpdate(mCloudProvider);
247             }
248         }
249     }
250 
251     /**
252      * Sets the cloud provider to be returned after querying the picker db
253      *
254      * Set cloud provider to null in case the CMP or collection id has changed and the cloud media
255      * results previously synced in the database should not be displayed on the UI.
256      *
257      * This should be used in picker sync paths because we should not wait on a lock
258      * indefinitely during the picker sync process
259      */
setCloudProviderWithTimeout(String authority)260     public void setCloudProviderWithTimeout(String authority) throws UnableToAcquireLockException {
261         try (CloseableReentrantLock ignored =
262                      mPickerSyncLockManager.tryLock(PickerSyncLockManager.DB_CLOUD_LOCK)) {
263             final String previousCloudProvider = mCloudProvider;
264             mCloudProvider = authority;
265             if (!Objects.equals(previousCloudProvider, mCloudProvider)) {
266                 onCloudProviderUpdate(mCloudProvider);
267             }
268         }
269     }
270 
271     /**
272      * Notifies dependant systems that the cloud provider has changed.
273      */
onCloudProviderUpdate(String mCloudProvider)274     public void onCloudProviderUpdate(String mCloudProvider) {
275         PickerNotificationSender.notifyAvailableProvidersChange(mContext);
276         // If cloud provider set is null, it means that the cloud queries have been disabled because
277         // a full sync is required (this is typically triggered by collection id change
278         // or CMP change). Notify PickerDataLayerV2 to handle this change.
279         if (mCloudProvider == null) {
280             PickerDataLayerV2.handleCloudMediaReset(mContext);
281         }
282     }
283 
284     /**
285      * Returns the cloud provider that will be returned after querying the picker db.
286      * This should not be used in picker sync paths because we should not wait on a lock
287      * indefinitely during the picker sync process.
288      */
getCloudProvider()289     public String getCloudProvider() {
290         try (CloseableReentrantLock ignored = mPickerSyncLockManager
291                 .lock(PickerSyncLockManager.DB_CLOUD_LOCK)) {
292             return mCloudProvider;
293         }
294     }
295 
getLocalProvider()296     public String getLocalProvider() {
297         return mLocalProvider;
298     }
299 
300     /**
301      * Returns {@link DbWriteOperation} to add media belonging to {@code authority} into the picker
302      * db.
303      */
beginAddMediaOperation(String authority)304     public DbWriteOperation beginAddMediaOperation(String authority) {
305         return new AddMediaOperation(getDatabase(), isLocal(authority));
306     }
307 
308     /**
309      * Returns {@link DbWriteOperation} that can be used to insert grants into the database.
310      */
beginInsertGrantsOperation()311     public DbWriteOperation beginInsertGrantsOperation() {
312         return new InsertGrantsOperation(getDatabase(), /* isLocal */ true);
313     }
314 
315     /**
316      * Returns {@link DbWriteOperation} that can be used to clear all grants from the database.
317      */
beginClearGrantsOperation(String[] packageNames, int userId)318     public DbWriteOperation beginClearGrantsOperation(String[] packageNames, int userId) {
319         return new ClearGrantsOperation(getDatabase(), /* isLocal */ true, packageNames, userId);
320     }
321 
322     /**
323      * Returns {@link DbWriteOperation} to add album_media belonging to {@code authority}
324      * into the picker db.
325      */
beginAddAlbumMediaOperation(String authority, String albumId)326     public DbWriteOperation beginAddAlbumMediaOperation(String authority, String albumId) {
327         return new AddAlbumMediaOperation(getDatabase(), isLocal(authority), albumId);
328     }
329 
330     /**
331      * Returns {@link DbWriteOperation} to remove media belonging to {@code authority} from the
332      * picker db.
333      */
beginRemoveMediaOperation(String authority)334     public DbWriteOperation beginRemoveMediaOperation(String authority) {
335         return new RemoveMediaOperation(getDatabase(), isLocal(authority));
336     }
337 
338     /**
339      * Returns {@link DbWriteOperation} to clear local media or all cloud media from the picker
340      * db.
341      *
342      * @param authority to determine whether local or cloud media should be cleared
343      */
beginResetMediaOperation(String authority)344     public DbWriteOperation beginResetMediaOperation(String authority) {
345         return new ResetMediaOperation(getDatabase(), isLocal(authority));
346     }
347 
348     /**
349      * Returns {@link DbWriteOperation} to clear album media for a given albumId from the picker
350      * db.
351      *
352      * <p>The {@link DbWriteOperation} clears local or cloud album based on {@code authority} and
353      * {@code albumId}. If {@code albumId} is null, it clears all local or cloud albums based on
354      * {@code authority}.
355      *
356      * @param authority to determine whether local or cloud media should be cleared
357      */
beginResetAlbumMediaOperation(String authority, String albumId)358     public DbWriteOperation beginResetAlbumMediaOperation(String authority, String albumId) {
359         return new ResetAlbumOperation(getDatabase(), isLocal(authority), albumId);
360     }
361 
362     /**
363      * Returns {@link UpdateMediaOperation} to update media belonging to {@code authority} in the
364      * picker db.
365      *
366      * @param authority to determine whether local or cloud media should be updated
367      */
beginUpdateMediaOperation(String authority)368     public UpdateMediaOperation beginUpdateMediaOperation(String authority) {
369         return new UpdateMediaOperation(getDatabase(), isLocal(authority));
370     }
371 
372     /**
373      * Represents an atomic write operation to the picker database.
374      *
375      * <p>This class is not thread-safe and is meant to be used within a single thread only.
376      */
377     public abstract static class DbWriteOperation implements AutoCloseable {
378 
379         private final SQLiteDatabase mDatabase;
380         private final boolean mIsLocal;
381 
382         private boolean mIsSuccess = false;
383 
DbWriteOperation(SQLiteDatabase database, boolean isLocal)384         private DbWriteOperation(SQLiteDatabase database, boolean isLocal) {
385             mDatabase = database;
386             mIsLocal = isLocal;
387             mDatabase.beginTransaction();
388         }
389 
390         /**
391          * Execute a write operation.
392          *
393          * @param cursor containing items to add/remove
394          * @return number of {@code cursor} items that were inserted/updated/deleted in the db
395          * @throws {@link IllegalStateException} if no DB transaction is active
396          */
execute(@ullable Cursor cursor)397         public int execute(@Nullable Cursor cursor) {
398             if (!mDatabase.inTransaction()) {
399                 throw new IllegalStateException("No ongoing DB transaction.");
400             }
401             final String traceSectionName = getClass().getSimpleName()
402                     + ".execute[" + (mIsLocal ? "local" : "cloud") + ']';
403             Trace.beginSection(traceSectionName);
404             try {
405                 return executeInternal(cursor);
406             } finally {
407                 Trace.endSection();
408             }
409         }
410 
setSuccess()411         public void setSuccess() {
412             mIsSuccess = true;
413         }
414 
415         @Override
close()416         public void close() {
417             if (mDatabase.inTransaction()) {
418                 if (mIsSuccess) {
419                     mDatabase.setTransactionSuccessful();
420                 } else {
421                     Log.w(TAG, "DB write transaction failed.");
422                 }
423                 mDatabase.endTransaction();
424             } else {
425                 throw new IllegalStateException("close() has already been called previously.");
426             }
427         }
428 
executeInternal(@ullable Cursor cursor)429         abstract int executeInternal(@Nullable Cursor cursor);
430 
getDatabase()431         SQLiteDatabase getDatabase() {
432             return mDatabase;
433         }
434 
isLocal()435         boolean isLocal() {
436             return mIsLocal;
437         }
438 
updateMedia(SQLiteQueryBuilder qb, ContentValues values, String[] selectionArgs)439         int updateMedia(SQLiteQueryBuilder qb, ContentValues values,
440                 String[] selectionArgs) {
441             try {
442                 if (qb.update(mDatabase, values, /* selection */ null, selectionArgs) > 0) {
443                     return SUCCESS;
444                 } else {
445                     Log.v(TAG, "Failed to update picker db media. ContentValues: " + values);
446                     return FAIL;
447                 }
448             } catch (SQLiteConstraintException e) {
449                 Log.v(TAG, "Failed to update picker db media. ContentValues: " + values, e);
450                 return RETRY;
451             }
452         }
453 
querySingleMedia(SQLiteQueryBuilder qb, String[] projection, String[] selectionArgs, int columnIndex)454         String querySingleMedia(SQLiteQueryBuilder qb, String[] projection,
455                 String[] selectionArgs, int columnIndex) {
456             try (Cursor cursor = qb.query(mDatabase, projection, /* selection */ null,
457                     selectionArgs, /* groupBy */ null, /* having */ null,
458                     /* orderBy */ null)) {
459                 if (cursor.moveToFirst()) {
460                     return cursor.getString(columnIndex);
461                 }
462             }
463 
464             return null;
465         }
466 
467         /**
468          * Returns the first date taken present in the columns affected by the DB write operation
469          * when this method is overridden. Otherwise, it returns Long.MIN_VALUE.
470          */
getFirstDateTakenMillis()471         public long getFirstDateTakenMillis() {
472             Log.e(TAG, "Method getFirstDateTakenMillis() is not overridden. "
473                     + "It will always return Long.MIN_VALUE");
474             return Long.MIN_VALUE;
475         }
476     }
477 
478     /**
479      * Database operation to insert the grants synced.
480      */
481     public static class InsertGrantsOperation extends DbWriteOperation {
482 
InsertGrantsOperation(SQLiteDatabase database, boolean isLocal)483         public InsertGrantsOperation(SQLiteDatabase database, boolean isLocal) {
484             super(database, isLocal);
485         }
486 
487         @Override
executeInternal(@ullable Cursor cursor)488         int executeInternal(@Nullable Cursor cursor) {
489             int numberOfGrantsInserted = 0;
490 
491             // fetch ids from thw cursor.
492             if (cursor == null) {
493                 Log.d(TAG, "No item grants to sync");
494                 return numberOfGrantsInserted;
495             }
496 
497             ContentValues values = new ContentValues();
498             SQLiteQueryBuilder qb = createGrantsQueryBuilder();
499             if (cursor.moveToFirst()) {
500                 do {
501                     Integer id = cursor.getInt(cursor.getColumnIndexOrThrow(FILE_ID_COLUMN));
502                     String packageName = cursor.getString(cursor.getColumnIndexOrThrow(
503                             OWNER_PACKAGE_NAME_COLUMN));
504                     Integer userId = cursor.getInt(
505                             cursor.getColumnIndexOrThrow(PACKAGE_USER_ID_COLUMN));
506 
507                     // insert the grant into the grants table.
508                     values.clear();
509                     values.put(FILE_ID_COLUMN, id);
510                     values.put(OWNER_PACKAGE_NAME_COLUMN, packageName);
511                     values.put(PACKAGE_USER_ID_COLUMN, userId);
512                     try {
513                         qb.insert(getDatabase(), values);
514                         numberOfGrantsInserted++;
515                     } catch (SQLiteConstraintException ignored) {
516                         Log.e(TAG, "Duplicate row insertion encountered for table media_grants."
517                                 + ignored);
518                         // If we hit a constraint exception it means this row is already in media,
519                         // so nothing to do here.
520                     }
521                 } while (cursor.moveToNext());
522             }
523             Log.d(TAG, numberOfGrantsInserted + " grants synced.");
524             return numberOfGrantsInserted;
525         }
526     }
527 
528     /**
529      * Represents an update to the picker database where all grants needs to be cleared.
530      *
531      * This needs to be happen before every sync.
532      */
533     public static class ClearGrantsOperation extends DbWriteOperation {
534 
535         private final String[] mPackageNames;
536         private final int mUserId;
537 
ClearGrantsOperation(SQLiteDatabase database, boolean isLocal, @NonNull String[] packageNames, int userId)538         public ClearGrantsOperation(SQLiteDatabase database, boolean isLocal,
539                 @NonNull String[] packageNames,
540                 int userId) {
541             super(database, isLocal);
542             mPackageNames = packageNames;
543             mUserId = userId;
544         }
545 
546         @Override
executeInternal(@ullable Cursor cursor)547         int executeInternal(@Nullable Cursor cursor) {
548             // Delete everything from the grants table for the calling package.
549             SQLiteQueryBuilder qb = createGrantsQueryBuilder();
550             Objects.requireNonNull(mPackageNames);
551             addWhereClausesForMediaGrantsTable(qb, mUserId, mPackageNames);
552             Log.d(TAG, "Clearing all picker database grants for calling package.");
553             return qb.delete(getDatabase(), /* selection */ null, /* selectionArgs */ null);
554         }
555     }
556 
557     /**
558      * Represents an atomic media update operation to the picker database.
559      *
560      * <p>This class is not thread-safe and is meant to be used within a single thread only.
561      */
562     public static final class UpdateMediaOperation extends DbWriteOperation {
563 
UpdateMediaOperation(SQLiteDatabase database, boolean isLocal)564         private UpdateMediaOperation(SQLiteDatabase database, boolean isLocal) {
565             super(database, isLocal);
566         }
567 
568         /**
569          * Execute a media update operation.
570          *
571          * @param id id of the media to be updated
572          * @param contentValues key-value pairs indicating fields to be updated for the media
573          * @return boolean indicating success/failure of the update
574          * @throws {@link IllegalStateException} if no DB transaction is active
575          */
execute(String id, ContentValues contentValues)576         public boolean execute(String id, ContentValues contentValues) {
577             final SQLiteDatabase database = getDatabase();
578             if (!database.inTransaction()) {
579                 throw new IllegalStateException("No ongoing DB transaction.");
580             }
581 
582             final SQLiteQueryBuilder qb = isLocal() ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD;
583             return qb.update(database, contentValues, /* selection */ null, new String[] {id}) > 0;
584         }
585 
586         @Override
executeInternal(@ullable Cursor cursor)587         int executeInternal(@Nullable Cursor cursor) {
588             throw new UnsupportedOperationException("Cursor updates are not supported.");
589         }
590     }
591 
592     private static final class AddMediaOperation extends DbWriteOperation {
593 
AddMediaOperation(SQLiteDatabase database, boolean isLocal)594         private AddMediaOperation(SQLiteDatabase database, boolean isLocal) {
595             super(database, isLocal);
596         }
597 
598         @Override
executeInternal(@ullable Cursor cursor)599         int executeInternal(@Nullable Cursor cursor) {
600             final boolean isLocal = isLocal();
601             final SQLiteQueryBuilder qb = isLocal ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD;
602             int counter = 0;
603 
604             if (cursor.getCount() > PAGE_SIZE) {
605                 Log.w(TAG,
606                         String.format("Expected a cursor page size of %d, but received a cursor "
607                             + "with %d rows instead.", PAGE_SIZE, cursor.getCount()));
608             }
609 
610             if (cursor.moveToFirst()) {
611                 do {
612                     ContentValues values = cursorToContentValue(cursor, isLocal);
613 
614                     String[] upsertArgs = {values.getAsString(isLocal ? KEY_LOCAL_ID
615                             : KEY_CLOUD_ID)};
616                     if (upsertMedia(qb, values, upsertArgs) == SUCCESS) {
617                         counter++;
618                         continue;
619                     }
620 
621                     // Because we want to prioritize visible local media over visible cloud media,
622                     // we do the following if the upsert above failed
623                     if (isLocal) {
624                         // For local syncs, we attempt hiding the visible cloud media
625                         String cloudId = getVisibleCloudIdFromDb(values.getAsString(KEY_LOCAL_ID));
626                         demoteCloudMediaToHidden(cloudId);
627                     } else {
628                         // For cloud syncs, we prepare an upsert as hidden cloud media
629                         values.putNull(KEY_IS_VISIBLE);
630                     }
631 
632                     // Now attempt upsert again, this should succeed
633                     if (upsertMedia(qb, values, upsertArgs) == SUCCESS) {
634                         counter++;
635                     }
636                 } while (cursor.moveToNext());
637             }
638 
639             return counter;
640         }
641 
insertMedia(ContentValues values)642         private int insertMedia(ContentValues values) {
643             try {
644                 if (QB_MATCH_ALL.insert(getDatabase(), values) > 0) {
645                     return SUCCESS;
646                 } else {
647                     Log.v(TAG, "Failed to insert picker db media. ContentValues: " + values);
648                     return FAIL;
649                 }
650             } catch (SQLiteConstraintException e) {
651                 Log.v(TAG, "Failed to insert picker db media. ContentValues: " + values, e);
652                 return RETRY;
653             }
654         }
655 
upsertMedia(SQLiteQueryBuilder qb, ContentValues values, String[] selectionArgs)656         private int upsertMedia(SQLiteQueryBuilder qb,
657                 ContentValues values, String[] selectionArgs) {
658             int res = insertMedia(values);
659             if (res == RETRY) {
660                 // Attempt equivalent of CONFLICT_REPLACE resolution
661                 Log.v(TAG, "Retrying failed insert as update. ContentValues: " + values);
662                 res = updateMedia(qb, values, selectionArgs);
663             }
664 
665             return res;
666         }
667 
demoteCloudMediaToHidden(@ullable String cloudId)668         private void demoteCloudMediaToHidden(@Nullable String cloudId) {
669             if (cloudId == null) {
670                 return;
671             }
672 
673             final String[] updateArgs = new String[] {cloudId};
674             if (updateMedia(QB_MATCH_CLOUD, CONTENT_VALUE_HIDDEN, updateArgs) == SUCCESS) {
675                 Log.d(TAG, "Demoted picker db media item to hidden. CloudId: " + cloudId);
676             }
677         }
678 
getVisibleCloudIdFromDb(String localId)679         private String getVisibleCloudIdFromDb(String localId) {
680             final String[] cloudIdProjection = new String[] {KEY_CLOUD_ID};
681             final String[] queryArgs = new String[] {localId};
682             return querySingleMedia(QB_MATCH_VISIBLE_LOCAL, cloudIdProjection, queryArgs,
683                     /* columnIndex */ 0);
684         }
685     }
686 
687     private static final class RemoveMediaOperation extends DbWriteOperation {
688         private static final String[] sDateTakenProjection = new String[] {KEY_DATE_TAKEN_MS};
689         private long mFirstDateTakenMillis = Long.MIN_VALUE;
690 
RemoveMediaOperation(SQLiteDatabase database, boolean isLocal)691         private RemoveMediaOperation(SQLiteDatabase database, boolean isLocal) {
692             super(database, isLocal);
693         }
694 
695         @Override
executeInternal(@ullable Cursor cursor)696         int executeInternal(@Nullable Cursor cursor) {
697             final boolean isLocal = isLocal();
698             final SQLiteQueryBuilder qb = isLocal ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD;
699 
700             int counter = 0;
701 
702             while (cursor.moveToNext()) {
703                 if (cursor.isFirst()) {
704                     updateFirstDateTakenMillis(cursor, isLocal);
705                 }
706 
707                 // Need to fetch the local_id before delete because for cloud items
708                 // we need a db query to fetch the local_id matching the id received from
709                 // cursor (cloud_id).
710                 final String localId = getLocalIdFromCursorOrDb(cursor, isLocal);
711 
712                 // Delete cloud/local row
713                 final int idIndex = cursor.getColumnIndex(
714                         CloudMediaProviderContract.MediaColumns.ID);
715                 final String[] deleteArgs = {cursor.getString(idIndex)};
716                 if (qb.delete(getDatabase(), /* selection */ null, deleteArgs) > 0) {
717                     counter++;
718                 }
719 
720                 promoteCloudMediaToVisible(localId);
721             }
722 
723             return counter;
724         }
725 
726         @Override
getFirstDateTakenMillis()727         public long getFirstDateTakenMillis() {
728             return mFirstDateTakenMillis;
729         }
730 
promoteCloudMediaToVisible(@ullable String localId)731         private void promoteCloudMediaToVisible(@Nullable String localId) {
732             if (localId == null) {
733                 return;
734             }
735 
736             final String[] idProjection = new String[] {KEY_ID};
737             final String[] queryArgs = {localId};
738             // First query for an exact row id matching the criteria for promotion so that we don't
739             // attempt promoting multiple hidden cloud rows matching the |localId|
740             final String id = querySingleMedia(QB_MATCH_LOCAL, idProjection, queryArgs,
741                     /* columnIndex */ 0);
742             if (id == null) {
743                 Log.w(TAG, "Unable to promote cloud media with localId: " + localId);
744                 return;
745             }
746 
747             final String[] updateArgs = {id};
748             if (updateMedia(QB_MATCH_ID, CONTENT_VALUE_VISIBLE, updateArgs) == SUCCESS) {
749                 Log.d(TAG, "Promoted picker db media item to visible. LocalId: " + localId);
750             }
751         }
752 
getLocalIdFromCursorOrDb(Cursor cursor, boolean isLocal)753         private String getLocalIdFromCursorOrDb(Cursor cursor, boolean isLocal) {
754             final String id = cursor.getString(0);
755 
756             if (isLocal) {
757                 // For local, id in cursor is already local_id
758                 return id;
759             } else {
760                 // For cloud, we need to query db with cloud_id from cursor to fetch local_id
761                 final String[] localIdProjection = new String[] {KEY_LOCAL_ID};
762                 final String[] queryArgs = new String[] {id};
763                 return querySingleMedia(QB_MATCH_CLOUD, localIdProjection, queryArgs,
764                         /* columnIndex */ 0);
765             }
766         }
767 
updateFirstDateTakenMillis(Cursor inputCursor, boolean isLocal)768         private void updateFirstDateTakenMillis(Cursor inputCursor, boolean isLocal) {
769             final int idIndex = inputCursor
770                     .getColumnIndex(CloudMediaProviderContract.MediaColumns.ID);
771             if (idIndex < 0) {
772                 Log.e(TAG, "Id is not present in the cursor");
773                 return;
774             }
775 
776             final String id = inputCursor.getString(idIndex);
777             if (TextUtils.isEmpty((id))) {
778                 Log.e(TAG, "Input id is empty");
779                 return;
780             }
781 
782             final SQLiteQueryBuilder qb = isLocal ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD;
783             final String[] queryArgs = new String[]{id};
784 
785             try (Cursor outputCursor = qb.query(getDatabase(), sDateTakenProjection,
786                     /* selection */ null, queryArgs, /* groupBy */ null, /* having */ null,
787                     /* orderBy */ null)) {
788                 if (outputCursor.moveToFirst()) {
789                     mFirstDateTakenMillis = outputCursor.getLong(/* columnIndex */ 0);
790                 } else {
791                     Log.e(TAG, "Could not get first date taken millis for media id: " + id);
792                 }
793             }
794         }
795     }
796 
797     private static final class ResetMediaOperation extends DbWriteOperation {
798 
ResetMediaOperation(SQLiteDatabase database, boolean isLocal)799         private ResetMediaOperation(SQLiteDatabase database, boolean isLocal) {
800             super(database, isLocal);
801         }
802 
803         @Override
executeInternal(@ullable Cursor unused)804         int executeInternal(@Nullable Cursor unused) {
805             final boolean isLocal = isLocal();
806             final SQLiteQueryBuilder qb = createMediaQueryBuilder();
807 
808             if (isLocal) {
809                 qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID);
810             } else {
811                 qb.appendWhereStandalone(WHERE_NOT_NULL_CLOUD_ID);
812             }
813 
814             SQLiteDatabase database = getDatabase();
815             int counter = qb.delete(database, /* selection */ null, /* selectionArgs */ null);
816 
817             if (isLocal) {
818                 // If we reset local media, we need to promote cloud media items
819                 // Ignore conflicts in case we have multiple cloud_ids mapped to the
820                 // same local_id. Promoting either is fine.
821                 database.updateWithOnConflict(TABLE_MEDIA, CONTENT_VALUE_VISIBLE, /* where */ null,
822                         /* whereClause */ null, SQLiteDatabase.CONFLICT_IGNORE);
823             }
824 
825             return counter;
826         }
827     }
828 
829     /** Filter for {@link #queryMedia} to modify returned results */
830     public static class QueryFilter {
831         private final int mLimit;
832         private final long mDateTakenBeforeMs;
833         private final long mDateTakenAfterMs;
834         private final long mId;
835         private final String mAlbumId;
836         private final long mSizeBytes;
837         private final String[] mMimeTypes;
838         private final boolean mIsFavorite;
839         private final boolean mIsVideo;
840         public boolean mIsLocalOnly;
841         private int mPageSize;
842         private String mPageToken;
843         private final boolean mShouldScreenSelectionUris;
844         private List<String> mLocalPreSelectedIds;
845         private List<String> mCloudPreSelectedIds;
846 
QueryFilter(int limit, long dateTakenBeforeMs, long dateTakenAfterMs, long id, String albumId, long sizeBytes, String[] mimeTypes, boolean isFavorite, boolean isVideo, boolean isLocalOnly, boolean shouldScreenSelectionUris, List<String> localPreSelectedIds, List<String> cloudPreSelectedIds, int pageSize, String pageToken)847         private QueryFilter(int limit, long dateTakenBeforeMs, long dateTakenAfterMs, long id,
848                 String albumId, long sizeBytes, String[] mimeTypes, boolean isFavorite,
849                 boolean isVideo, boolean isLocalOnly, boolean shouldScreenSelectionUris,
850                 List<String> localPreSelectedIds, List<String> cloudPreSelectedIds, int pageSize,
851                 String pageToken) {
852             this.mLimit = limit;
853             this.mDateTakenBeforeMs = dateTakenBeforeMs;
854             this.mDateTakenAfterMs = dateTakenAfterMs;
855             this.mId = id;
856             this.mAlbumId = albumId;
857             this.mSizeBytes = sizeBytes;
858             this.mMimeTypes = mimeTypes;
859             this.mIsFavorite = isFavorite;
860             this.mIsVideo = isVideo;
861             this.mIsLocalOnly = isLocalOnly;
862             this.mShouldScreenSelectionUris = shouldScreenSelectionUris;
863             this.mLocalPreSelectedIds = localPreSelectedIds;
864             this.mCloudPreSelectedIds = cloudPreSelectedIds;
865             this.mPageSize = pageSize;
866             this.mPageToken = pageToken;
867         }
868     }
869 
870     /** Builder for {@link Query} filter. */
871     public static class QueryFilterBuilder {
872         public static final int INT_DEFAULT = -1;
873         public static final long LONG_DEFAULT = -1;
874         public static final String STRING_DEFAULT = null;
875         public static final String[] STRING_ARRAY_DEFAULT = null;
876         public static final boolean BOOLEAN_DEFAULT = false;
877 
878         public static final List LIST_DEFAULT = null;
879         public static final int LIMIT_DEFAULT = 1000;
880 
881         private final int limit;
882         private long mDateTakenBeforeMs = Long.MIN_VALUE;
883         private long mDateTakenAfterMs = Long.MIN_VALUE;
884         private long id = LONG_DEFAULT;
885         private String albumId = STRING_DEFAULT;
886         private long sizeBytes = LONG_DEFAULT;
887         private String[] mimeTypes = STRING_ARRAY_DEFAULT;
888         private boolean isFavorite = BOOLEAN_DEFAULT;
889         private boolean mIsVideo = BOOLEAN_DEFAULT;
890         private boolean mIsLocalOnly = BOOLEAN_DEFAULT;
891         private int mPageSize = INT_DEFAULT;
892         private String mPageToken = STRING_DEFAULT;
893         private boolean mShouldScreenSelectionUris = BOOLEAN_DEFAULT;
894         private List<String> mLocalPreSelectedIds = LIST_DEFAULT;
895         private List<String> mCloudPreSelectedIds = LIST_DEFAULT;
896 
QueryFilterBuilder(int limit)897         public QueryFilterBuilder(int limit) {
898             this.limit = limit;
899         }
900 
setDateTakenBeforeMs(long dateTakenBeforeMs)901         public QueryFilterBuilder setDateTakenBeforeMs(long dateTakenBeforeMs) {
902             this.mDateTakenBeforeMs = dateTakenBeforeMs;
903             return this;
904         }
905 
setDateTakenAfterMs(long dateTakenAfterMs)906         public QueryFilterBuilder setDateTakenAfterMs(long dateTakenAfterMs) {
907             this.mDateTakenAfterMs = dateTakenAfterMs;
908             return this;
909         }
910 
911         /**
912          * The {@code id} helps break ties with db rows having the same {@code dateTakenAfterMs} or
913          * {@code dateTakenBeforeMs}.
914          *
915          * If {@code dateTakenAfterMs} is specified, all returned items are equal or more
916          * recent than {@code dateTakenAfterMs} and have a picker db id equal or greater than
917          * {@code id} for ties.
918          *
919          * If {@code dateTakenBeforeMs} is specified, all returned items are either strictly older
920          * than {@code dateTakenBeforeMs} or have a picker db id strictly less than {@code id}
921          * for ties.
922          */
setId(long id)923         public QueryFilterBuilder setId(long id) {
924             this.id = id;
925             return this;
926         }
927 
setAlbumId(String albumId)928         public QueryFilterBuilder setAlbumId(String albumId) {
929             this.albumId = albumId;
930             return this;
931         }
932 
setSizeBytes(long sizeBytes)933         public QueryFilterBuilder setSizeBytes(long sizeBytes) {
934             this.sizeBytes = sizeBytes;
935             return this;
936         }
937 
setMimeTypes(String[] mimeTypes)938         public QueryFilterBuilder setMimeTypes(String[] mimeTypes) {
939             this.mimeTypes = mimeTypes;
940             return this;
941         }
942 
943         /**
944          * Sets the shouldScreenSelectionUris parameter.
945          */
setShouldScreenSelectionUris(boolean shouldScreenSelectionUris)946         public QueryFilterBuilder setShouldScreenSelectionUris(boolean shouldScreenSelectionUris) {
947             this.mShouldScreenSelectionUris = shouldScreenSelectionUris;
948             return this;
949         }
950 
951         /**
952          * Sets the local id selection filter.
953          */
setLocalPreSelectedIds(List<String> localPreSelectedIds)954         public QueryFilterBuilder setLocalPreSelectedIds(List<String> localPreSelectedIds) {
955             this.mLocalPreSelectedIds = localPreSelectedIds;
956             return this;
957         }
958 
959         /**
960          * Sets the cloud id selection filter.
961          */
setCloudPreSelectionIds(List<String> cloudPreSelectedIds)962         public QueryFilterBuilder setCloudPreSelectionIds(List<String> cloudPreSelectedIds) {
963             this.mCloudPreSelectedIds = cloudPreSelectedIds;
964             return this;
965         }
966 
967         /**
968          * If {@code isFavorite} is {@code true}, the {@link QueryFilter} returns only
969          * favorited items, however, if it is {@code false}, it returns all items including
970          * favorited and non-favorited items.
971          */
setIsFavorite(boolean isFavorite)972         public QueryFilterBuilder setIsFavorite(boolean isFavorite) {
973             this.isFavorite = isFavorite;
974             return this;
975         }
976 
977         /**
978          * If {@code isVideo} is {@code true}, the {@link QueryFilter} returns only
979          * video items, however, if it is {@code false}, it returns all items including
980          * video and non-video items.
981          */
setIsVideo(boolean isVideo)982         public QueryFilterBuilder setIsVideo(boolean isVideo) {
983             this.mIsVideo = isVideo;
984             return this;
985         }
986 
987         /**
988          * If {@code isLocalOnly} is {@code true}, the {@link QueryFilter} returns only
989          * local items.
990          */
setIsLocalOnly(boolean isLocalOnly)991         public QueryFilterBuilder setIsLocalOnly(boolean isLocalOnly) {
992             this.mIsLocalOnly = isLocalOnly;
993             return this;
994         }
995 
996         /**
997          * Sets the page size.
998          */
setPageSize(int pageSize)999         public QueryFilterBuilder setPageSize(int pageSize) {
1000             mPageSize = pageSize;
1001             return this;
1002         }
1003 
1004         /**
1005          * Sets the page token.
1006          */
setPageToken(String pageToken)1007         public QueryFilterBuilder setPageToken(String pageToken) {
1008             mPageToken = pageToken;
1009             return this;
1010         }
1011 
build()1012         public QueryFilter build() {
1013             return new QueryFilter(limit, mDateTakenBeforeMs, mDateTakenAfterMs, id, albumId,
1014                     sizeBytes, mimeTypes, isFavorite, mIsVideo, mIsLocalOnly,
1015                     mShouldScreenSelectionUris, mLocalPreSelectedIds, mCloudPreSelectedIds,
1016                     mPageSize, mPageToken);
1017         }
1018     }
1019 
1020     /**
1021      * Returns sorted and deduped cloud and local media items from the picker db.
1022      *
1023      * Returns a {@link Cursor} containing picker db media rows with columns as
1024      * {@link CloudMediaProviderContract.MediaColumns}.
1025      *
1026      * The result is sorted in reverse chronological order, i.e. newest first, up to a maximum of
1027      * {@code limit}. They can also be filtered with {@code query}.
1028      */
queryMediaForUi(QueryFilter query)1029     public Cursor queryMediaForUi(QueryFilter query) {
1030         boolean isAnyIdsForSelectionPresent =
1031                 (query.mLocalPreSelectedIds != null && !query.mLocalPreSelectedIds.isEmpty()) || (
1032                         query.mCloudPreSelectedIds != null
1033                                 && !query.mCloudPreSelectedIds.isEmpty());
1034         if (isAnyIdsForSelectionPresent) {
1035             Log.d(TAG, "Query is being performed with id selection");
1036             return queryMediaForUiWithIdSelection(query);
1037         } else if (query.mShouldScreenSelectionUris) {
1038             Log.d(TAG, "No ids present for selection, returning empty cursor");
1039             // If no ids are present for the query selection but the pre-selection is enabled
1040             // (indicated by the flag mShouldScreenSelectionUris) then an empty cursor should be
1041             // returned).
1042             return new MatrixCursor(getCloudMediaProjectionLocked(), 0);
1043         }
1044 
1045         final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder();
1046         final String[] selectionArgs = buildSelectionArgs(qb, query);
1047         if (query.mIsLocalOnly) {
1048             return queryMediaForUi(qb, selectionArgs, query.mLimit,  /* isLocalOnly*/true,
1049                     TABLE_MEDIA, /* cloudProvider*/ null);
1050         }
1051 
1052         // If the cloud sync is in progress or the cloud provider has changed but a sync has not
1053         // been completed and committed, {@link PickerDBFacade.mCloudProvider} will be
1054         // {@code null}.
1055         final String cloudProvider = getCloudProvider();
1056 
1057         return queryMediaForUi(qb, selectionArgs, query.mLimit, query.mIsLocalOnly,
1058                 TABLE_MEDIA, cloudProvider);
1059     }
1060 
1061 
queryMediaForUiWithIdSelection(QueryFilter query)1062     private Cursor queryMediaForUiWithIdSelection(QueryFilter query) {
1063         // Since 'WHERE IN' clause has an upper limit of items that can be included in the sql
1064         // statement and also there is an upper limit to the size of the sql statement.
1065         // Splitting the query into multiple smaller ones.
1066         // This query will now process 150 items in a batch.
1067         List<Cursor> resultCursor = new ArrayList<>();
1068         List<String> localIds = query.mLocalPreSelectedIds == null ? null : new ArrayList<>(
1069                 query.mLocalPreSelectedIds);
1070         List<String> cloudIds = query.mCloudPreSelectedIds == null ? null : new ArrayList<>(
1071                 query.mCloudPreSelectedIds);
1072 
1073         batchedQueryForIdSelection(query, resultCursor, null, localIds);
1074         if (!query.mIsLocalOnly) {
1075             batchedQueryForIdSelection(query, resultCursor, getCloudProvider(),
1076                     cloudIds);
1077         }
1078 
1079         Cursor[] resultCursorsAsArray = resultCursor.toArray(new Cursor[0]);
1080         if (resultCursorsAsArray.length == 0) {
1081             // If after query no cursor has been added to the result, then return an empty cursor.
1082             return new MatrixCursor(getCloudMediaProjectionLocked(), 0);
1083         }
1084         return new MergeCursor(resultCursorsAsArray);
1085     }
1086 
batchedQueryForIdSelection(QueryFilter query, List<Cursor> resultCursor, String cloudAuthority, List<String> selectionIds)1087     private void batchedQueryForIdSelection(QueryFilter query, List<Cursor> resultCursor,
1088             String cloudAuthority, List<String> selectionIds) {
1089         if (selectionIds == null || selectionIds.isEmpty()) {
1090             return;
1091         }
1092         List<List<String>> listOfSelectionArgsForLocalId = splitArrayList(
1093                 selectionIds,
1094                 /* number of ids per query */ 150);
1095 
1096         for (List<String> selectionArgForLocalPreSelectedIds : listOfSelectionArgsForLocalId) {
1097             final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder();
1098             if (cloudAuthority == null) {
1099                 query.mLocalPreSelectedIds = selectionArgForLocalPreSelectedIds;
1100                 query.mCloudPreSelectedIds = QueryFilterBuilder.LIST_DEFAULT;
1101             } else {
1102                 query.mCloudPreSelectedIds = selectionArgForLocalPreSelectedIds;
1103                 query.mLocalPreSelectedIds = QueryFilterBuilder.LIST_DEFAULT;
1104             }
1105             final String[] selectionArgs = buildSelectionArgs(qb, query);
1106             resultCursor.add(
1107                     queryMediaForUi(qb, selectionArgs, query.mLimit, cloudAuthority == null,
1108                             TABLE_MEDIA, /* cloud provider */cloudAuthority));
1109         }
1110     }
1111 
splitArrayList(List<T> list, int chunkSize)1112     private static <T> List<List<T>> splitArrayList(List<T> list, int chunkSize) {
1113         List<List<T>> subLists = new ArrayList<>();
1114         for (int i = 0; i < list.size(); i += chunkSize) {
1115             subLists.add(list.subList(i, Math.min(i + chunkSize, list.size())));
1116         }
1117         return subLists;
1118     }
1119 
1120     /**
1121      * Returns sorted cloud or local media items from the picker db for a given album (either cloud
1122      * or local).
1123      *
1124      * Returns a {@link Cursor} containing picker db media rows with columns as
1125      * {@link CloudMediaProviderContract#MediaColumns} except for is_favorites column because that
1126      * column is only used for fetching the Favorites album.
1127      *
1128      * The result is sorted in reverse chronological order, i.e. newest first, up to a maximum of
1129      * {@code limit}. They can also be filtered with {@code query}.
1130      */
queryAlbumMediaForUi(@onNull QueryFilter query, @NonNull String authority)1131     public Cursor queryAlbumMediaForUi(@NonNull QueryFilter query, @NonNull String authority) {
1132         final SQLiteQueryBuilder qb = createAlbumMediaQueryBuilder(isLocal(authority));
1133         final String[] selectionArgs = buildSelectionArgs(qb, query);
1134 
1135         return queryMediaForUi(qb, selectionArgs, query.mLimit, query.mIsLocalOnly,
1136                 TABLE_ALBUM_MEDIA, authority);
1137     }
1138 
1139     /**
1140      * Returns an individual cloud or local item from the picker db matching {@code authority} and
1141      * {@code mediaId}.
1142      *
1143      * Returns a {@link Cursor} containing picker db media rows with columns as {@code projection},
1144      * a subset of {@link PickerMediaColumns}.
1145      */
queryMediaIdForApps(String pickerSegmentType, String authority, String mediaId, @NonNull String[] projection)1146     public Cursor queryMediaIdForApps(String pickerSegmentType, String authority, String mediaId,
1147             @NonNull String[] projection) {
1148         final String[] selectionArgs = new String[] { mediaId };
1149         final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder();
1150         if (isLocal(authority)) {
1151             qb.appendWhereStandalone(WHERE_LOCAL_ID);
1152         } else {
1153             qb.appendWhereStandalone(WHERE_CLOUD_ID);
1154         }
1155 
1156         if (authority.equals(mLocalProvider)) {
1157             return queryMediaIdForAppsLocked(qb, projection, selectionArgs, pickerSegmentType);
1158         }
1159 
1160         try (CloseableReentrantLock ignored = mPickerSyncLockManager
1161                 .lock(PickerSyncLockManager.DB_CLOUD_LOCK)) {
1162             if (authority.equals(mCloudProvider)) {
1163                 return queryMediaIdForAppsLocked(qb, projection, selectionArgs, pickerSegmentType);
1164             }
1165         }
1166 
1167         return null;
1168     }
1169 
queryMediaIdForAppsLocked(@onNull SQLiteQueryBuilder qb, @NonNull String[] columns, @NonNull String[] selectionArgs, String pickerSegmentType)1170     private Cursor queryMediaIdForAppsLocked(@NonNull SQLiteQueryBuilder qb,
1171             @NonNull String[] columns, @NonNull String[] selectionArgs,
1172             String pickerSegmentType) {
1173         final Cursor cursor =
1174                 qb.query(getDatabase(), getMediaStoreProjectionLocked(columns, pickerSegmentType),
1175                     /* selection */ null, selectionArgs, /* groupBy */ null, /* having */ null,
1176                     /* orderBy */ null, /* limitStr */ null);
1177 
1178         if (columns == null || columns.length == 0 || cursor.getColumnCount() == columns.length) {
1179             return cursor;
1180         } else {
1181             // An unknown column was encountered. Populate it will null for backwards compatibility.
1182             final MatrixCursor result = new MatrixCursor(columns);
1183             if (cursor.moveToFirst()) {
1184                 do {
1185                     final ContentValues contentValues = new ContentValues();
1186                     DatabaseUtils.cursorRowToContentValues(cursor, contentValues);
1187                     final MatrixCursor.RowBuilder rowBuilder = result.newRow();
1188                     for (String column : columns) {
1189                         rowBuilder.add(column, contentValues.get(column));
1190                     }
1191                 } while (cursor.moveToNext());
1192             }
1193             cursor.close();
1194             return result;
1195         }
1196     }
1197 
1198     /**
1199      * Returns empty {@link Cursor} if there are no items matching merged album constraints {@code
1200      * query}
1201      */
getMergedAlbums(QueryFilter query, String cloudProvider)1202     public Cursor getMergedAlbums(QueryFilter query, String cloudProvider) {
1203         final MatrixCursor c = new MatrixCursor(AlbumColumns.ALL_PROJECTION);
1204         List<String> mergedAlbums = List.of(ALBUM_ID_FAVORITES, ALBUM_ID_VIDEOS);
1205         for (String albumId : mergedAlbums) {
1206             List<String> selectionArgs = new ArrayList<>();
1207             final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder();
1208 
1209             if (query.mIsLocalOnly) {
1210                 qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID);
1211             }
1212 
1213             if (albumId.equals(ALBUM_ID_FAVORITES)) {
1214                 qb.appendWhereStandalone(getWhereForFavorite(query.mIsLocalOnly));
1215             } else if (albumId.equals(ALBUM_ID_VIDEOS)) {
1216                 qb.appendWhereStandalone(WHERE_MIME_TYPE);
1217                 selectionArgs.add("video/%");
1218             }
1219             addMimeTypesToQueryBuilderAndSelectionArgs(qb, selectionArgs, query.mMimeTypes);
1220 
1221             Cursor cursor = qb.query(getDatabase(), getMergedAlbumProjection(),
1222                     /* selection */ null, selectionArgs.toArray(new String[0]), /* groupBy */ null,
1223                     /* having */ null, /* orderBy */ null, /* limit */ null);
1224 
1225             if (cursor == null || !cursor.moveToFirst()) {
1226                 continue;
1227             }
1228 
1229             long count = getCursorLong(cursor, CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT);
1230 
1231             // We want to display empty merged folder in case of cloud picker.
1232             if (shouldHideMergedAlbum(query, albumId, cloudProvider, count)) {
1233                 continue;
1234             }
1235 
1236             final String[] projectionValue = new String[]{
1237                     /* albumId */ albumId,
1238                     getCursorString(cursor, AlbumColumns.DATE_TAKEN_MILLIS),
1239                     /* displayName */ albumId,
1240                     getCursorString(cursor, AlbumColumns.MEDIA_COVER_ID),
1241                     String.valueOf(count),
1242                     getCursorString(cursor, AlbumColumns.AUTHORITY),
1243             };
1244             c.addRow(projectionValue);
1245         }
1246         return c;
1247     }
1248 
shouldHideMergedAlbum(QueryFilter query, String albumId, String cloudProvider, long count)1249     private static boolean shouldHideMergedAlbum(QueryFilter query, String albumId,
1250             String cloudProvider, long count) {
1251         final boolean isAlbumEmpty = (count == 0);
1252         final boolean shouldNotShowCloudItems = (query.mIsLocalOnly || cloudProvider == null);
1253 
1254         return (isAlbumEmpty && (shouldNotShowCloudItems || hideVideosAlbum(query, albumId)));
1255     }
1256 
hideVideosAlbum(QueryFilter query, String albumId)1257     private static boolean hideVideosAlbum(QueryFilter query, String albumId) {
1258         String[] mimeTypes = query.mMimeTypes;
1259         if (!albumId.equals(ALBUM_ID_VIDEOS) || mimeTypes == null) {
1260             return false;
1261         }
1262         for (String mimeType : mimeTypes) {
1263             if (MimeUtils.isVideoMimeType(mimeType)) {
1264                 return false;
1265             }
1266         }
1267         return true;
1268     }
1269 
getMergedAlbumProjection()1270     private String[] getMergedAlbumProjection() {
1271         return new String[] {
1272                 "COUNT(" + KEY_ID + ") AS " + CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT,
1273                 "MAX(" + KEY_DATE_TAKEN_MS + ") AS "
1274                         + CloudMediaProviderContract.AlbumColumns.DATE_TAKEN_MILLIS,
1275                 String.format("IFNULL(%s, %s) AS %s", KEY_CLOUD_ID,
1276                         KEY_LOCAL_ID, CloudMediaProviderContract.AlbumColumns.MEDIA_COVER_ID),
1277                 // Note that we prefer cloud_id over local_id here. This logic is for computing the
1278                 // projection and doesn't affect the filtering of results which has already been
1279                 // done and ensures that only is_visible=true items are returned.
1280                 // Here, we need to distinguish between cloud+local and local-only items to
1281                 // determine the correct authority.
1282                 String.format("CASE WHEN %s IS NULL THEN '%s' ELSE '%s' END AS %s",
1283                         KEY_CLOUD_ID, mLocalProvider, mCloudProvider, AlbumColumns.AUTHORITY)
1284         };
1285     }
1286 
isLocal(String authority)1287     private boolean isLocal(String authority) {
1288         return mLocalProvider.equals(authority);
1289     }
1290 
1291     /**
1292      * Returns sorted and deduped cloud and local media or album content items from the picker db.
1293      */
queryMediaForUi(SQLiteQueryBuilder qb, String[] selectionArgs, int limit, boolean isLocalOnly, String tableName, String authority)1294     private Cursor queryMediaForUi(SQLiteQueryBuilder qb, String[] selectionArgs,
1295             int limit, boolean isLocalOnly, String tableName, String authority) {
1296         // Use the <table>.<column> form to order _id to avoid ordering against the projection '_id'
1297         final String orderBy = getOrderClause(tableName);
1298         final String limitStr = String.valueOf(limit);
1299 
1300         if (isLocalOnly) {
1301             qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID);
1302             return queryMediaForUiLocked(qb, selectionArgs, orderBy, limitStr);
1303         }
1304 
1305         // Hold lock while checking the cloud provider and querying so that cursor extras containing
1306         // the cloud provider is consistent with the cursor results and doesn't race with
1307         // #setCloudProvider
1308         try (CloseableReentrantLock ignored = mPickerSyncLockManager
1309                 .lock(PickerSyncLockManager.DB_CLOUD_LOCK)) {
1310             if (mCloudProvider == null || !Objects.equals(mCloudProvider, authority)) {
1311                 // TODO(b/278086344): If cloud provider is null or has changed from what we received
1312                 //  from the UI, skip all cloud items in the picker db.
1313                 qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID);
1314             }
1315             return queryMediaForUiLocked(qb, selectionArgs, orderBy, limitStr);
1316         }
1317     }
1318 
queryMediaForUiLocked(SQLiteQueryBuilder qb, String[] selectionArgs, String orderBy, String limitStr)1319     private Cursor queryMediaForUiLocked(SQLiteQueryBuilder qb, String[] selectionArgs,
1320             String orderBy, String limitStr) {
1321         return qb.query(getDatabase(), getCloudMediaProjectionLocked(), /* selection */ null,
1322                 selectionArgs, /* groupBy */ null, /* having */ null, orderBy, limitStr);
1323     }
1324 
getOrderClause(String tableName)1325     private static String getOrderClause(String tableName) {
1326         return "date_taken_ms DESC," + tableName + "._id DESC";
1327     }
1328 
getCloudMediaProjectionLocked()1329     private String[] getCloudMediaProjectionLocked() {
1330         return new String[] {
1331             getProjectionAuthorityLocked(),
1332             getProjectionDataLocked(MediaColumns.DATA, PickerUriResolver.PICKER_SEGMENT),
1333             getProjectionId(MediaColumns.ID),
1334             // The id in the picker.db table represents the row id. This is used in UI pagination.
1335             getProjectionSimple(KEY_ID, Item.ROW_ID),
1336             getProjectionSimple(KEY_DATE_TAKEN_MS, MediaColumns.DATE_TAKEN_MILLIS),
1337             getProjectionSimple(KEY_SYNC_GENERATION, MediaColumns.SYNC_GENERATION),
1338             getProjectionSimple(KEY_SIZE_BYTES, MediaColumns.SIZE_BYTES),
1339             getProjectionSimple(KEY_DURATION_MS, MediaColumns.DURATION_MILLIS),
1340             getProjectionSimple(KEY_MIME_TYPE, MediaColumns.MIME_TYPE),
1341             getProjectionSimple(KEY_STANDARD_MIME_TYPE_EXTENSION,
1342                     MediaColumns.STANDARD_MIME_TYPE_EXTENSION),
1343             getProjectionSimple(KEY_OWNER_PACKAGE_NAME, MediaColumns.OWNER_PACKAGE_NAME),
1344             getProjectionSimple(KEY_USER_ID, MediaColumns.USER_ID),
1345         };
1346     }
1347 
getMediaStoreProjectionLocked(String[] columns, String pickerSegmentType)1348     private String[] getMediaStoreProjectionLocked(String[] columns, String pickerSegmentType) {
1349         final List<String> projection = new ArrayList<>();
1350 
1351         for (int i = 0; i < columns.length; i++) {
1352             switch (columns[i]) {
1353                 case PickerMediaColumns.DATA:
1354                     projection.add(getProjectionDataLocked(PickerMediaColumns.DATA,
1355                             pickerSegmentType));
1356                     break;
1357                 case PickerMediaColumns.DISPLAY_NAME:
1358                     projection.add(getProjectionSimple(
1359                             getDisplayNameSql(), PickerMediaColumns.DISPLAY_NAME));
1360                     break;
1361                 case PickerMediaColumns.MIME_TYPE:
1362                     projection.add(getProjectionSimple(
1363                             KEY_MIME_TYPE, PickerMediaColumns.MIME_TYPE));
1364                     break;
1365                 case PickerMediaColumns.DATE_TAKEN:
1366                     projection.add(getProjectionSimple(
1367                             KEY_DATE_TAKEN_MS, PickerMediaColumns.DATE_TAKEN));
1368                     break;
1369                 case PickerMediaColumns.SIZE:
1370                     projection.add(getProjectionSimple(KEY_SIZE_BYTES, PickerMediaColumns.SIZE));
1371                     break;
1372                 case PickerMediaColumns.DURATION_MILLIS:
1373                     projection.add(getProjectionSimple(
1374                             KEY_DURATION_MS, PickerMediaColumns.DURATION_MILLIS));
1375                     break;
1376                 case PickerMediaColumns.HEIGHT:
1377                     projection.add(getProjectionSimple(KEY_HEIGHT, PickerMediaColumns.HEIGHT));
1378                     break;
1379                 case PickerMediaColumns.WIDTH:
1380                     projection.add(getProjectionSimple(KEY_WIDTH, PickerMediaColumns.WIDTH));
1381                     break;
1382                 case PickerMediaColumns.ORIENTATION:
1383                     projection.add(getProjectionSimple(
1384                             KEY_ORIENTATION, PickerMediaColumns.ORIENTATION));
1385                     break;
1386                 default:
1387                     // Ignore unsupported columns; we do not throw error here to support
1388                     // backward compatibility for ACTION_GET_CONTENT takeover.
1389                     Log.w(TAG, "Unexpected Picker column: " + columns[i]);
1390             }
1391         }
1392 
1393         return projection.toArray(new String[0]);
1394     }
1395 
getProjectionAuthorityLocked()1396     private String getProjectionAuthorityLocked() {
1397         // Note that we prefer cloud_id over local_id here. It's important to remember that this
1398         // logic is for computing the projection and doesn't affect the filtering of results which
1399         // has already been done and ensures that only is_visible=true items are returned.
1400         // Here, we need to distinguish between cloud+local and local-only items to determine the
1401         // correct authority. Checking whether cloud_id IS NULL distinguishes the former from the
1402         // latter.
1403         return String.format("CASE WHEN %s IS NULL THEN '%s' ELSE '%s' END AS %s",
1404                 KEY_CLOUD_ID, mLocalProvider, mCloudProvider, MediaColumns.AUTHORITY);
1405     }
1406 
getProjectionDataLocked(String asColumn, String pickerSegmentType)1407     private String getProjectionDataLocked(String asColumn, String pickerSegmentType) {
1408         // _data format:
1409         // /sdcard/.transforms/synthetic/picker/<user-id>/<authority>/media/<display-name>
1410         // See PickerUriResolver#getMediaUri
1411         final String authority = String.format("CASE WHEN %s IS NULL THEN '%s' ELSE '%s' END",
1412                 KEY_CLOUD_ID, mLocalProvider, mCloudProvider);
1413         final String fullPath = "'" + getPickerPath(pickerSegmentType) + "/'"
1414                 + "||" + "'" + MediaStore.MY_USER_ID + "/'"
1415                 + "||" + authority
1416                 + "||" + "'/" + CloudMediaProviderContract.URI_PATH_MEDIA + "/'"
1417                 + "||" + getDisplayNameSql();
1418         return String.format("%s AS %s", fullPath, asColumn);
1419     }
1420 
getPickerPath(String pickerSegmentType)1421     private String getPickerPath(String pickerSegmentType) {
1422         // Intentionally use /sdcard path so that the receiving app resolves it to its per-user
1423         // external storage path, e.g. /storage/emulated/<userid>. That way FUSE cross-user
1424         // access is not required for picker paths sent across users
1425         return "/sdcard/" + getPickerRelativePath(pickerSegmentType);
1426     }
1427 
getProjectionId(String asColumn)1428     private String getProjectionId(String asColumn) {
1429         // We prefer cloud_id first and it only matters for cloud+local items. For those, the row
1430         // will already be associated with a cloud authority, see #getProjectionAuthorityLocked.
1431         // Note that hidden cloud+local items will not be returned in the query, so there's no
1432         // concern of preferring the cloud_id in a cloud+local item over the local_id in a
1433         // local-only item.
1434         return String.format("IFNULL(%s, %s) AS %s", KEY_CLOUD_ID, KEY_LOCAL_ID, asColumn);
1435     }
1436 
getProjectionSimple(String dbColumn, String column)1437     private static String getProjectionSimple(String dbColumn, String column) {
1438         return String.format("%s AS %s", dbColumn, column);
1439     }
1440 
getDisplayNameSql()1441     private String getDisplayNameSql() {
1442         // _display_name format:
1443         // <media-id>.<file-extension>
1444         // See comment in #getProjectionAuthorityLocked for why cloud_id is preferred over local_id
1445         final String mediaId = String.format("IFNULL(%s, %s)", KEY_CLOUD_ID, KEY_LOCAL_ID);
1446         final String fileExtension = String.format("_GET_EXTENSION(%s)", KEY_MIME_TYPE);
1447 
1448         return mediaId + "||" + fileExtension;
1449     }
1450 
cursorToContentValue(Cursor cursor, boolean isLocal)1451     private static ContentValues cursorToContentValue(Cursor cursor, boolean isLocal) {
1452         return cursorToContentValue(cursor, isLocal, "");
1453     }
1454 
cursorToContentValue(Cursor cursor, boolean isLocal, String albumId)1455     private static ContentValues cursorToContentValue(Cursor cursor, boolean isLocal,
1456             String albumId) {
1457         final ContentValues values = new ContentValues();
1458         if (TextUtils.isEmpty(albumId)) {
1459             values.put(KEY_IS_VISIBLE, 1);
1460         }
1461         else {
1462             values.put(KEY_ALBUM_ID, albumId);
1463         }
1464 
1465         final int count = cursor.getColumnCount();
1466         for (int index = 0; index < count; index++) {
1467             String key = cursor.getColumnName(index);
1468             switch (key) {
1469                 case CloudMediaProviderContract.MediaColumns.ID:
1470                     if (isLocal) {
1471                         values.put(KEY_LOCAL_ID, cursor.getString(index));
1472                     } else {
1473                         values.put(KEY_CLOUD_ID, cursor.getString(index));
1474                     }
1475                     break;
1476                 case CloudMediaProviderContract.MediaColumns.MEDIA_STORE_URI:
1477                     String uriString = cursor.getString(index);
1478                     if (uriString != null) {
1479                         Uri uri = Uri.parse(uriString);
1480                         values.put(KEY_LOCAL_ID, ContentUris.parseId(uri));
1481                     }
1482                     break;
1483                 case CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS:
1484                     values.put(KEY_DATE_TAKEN_MS, cursor.getLong(index));
1485                     break;
1486                 case CloudMediaProviderContract.MediaColumns.SYNC_GENERATION:
1487                     values.put(KEY_SYNC_GENERATION, cursor.getLong(index));
1488                     break;
1489                 case CloudMediaProviderContract.MediaColumns.SIZE_BYTES:
1490                     values.put(KEY_SIZE_BYTES, cursor.getLong(index));
1491                     break;
1492                 case CloudMediaProviderContract.MediaColumns.MIME_TYPE:
1493                     values.put(KEY_MIME_TYPE, cursor.getString(index));
1494                     break;
1495                 case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION:
1496                     int standardMimeTypeExtension = cursor.getInt(index);
1497                     if (isValidStandardMimeTypeExtension(standardMimeTypeExtension)) {
1498                         values.put(KEY_STANDARD_MIME_TYPE_EXTENSION, standardMimeTypeExtension);
1499                     } else {
1500                         throw new IllegalArgumentException("Invalid standard mime type extension");
1501                     }
1502                     break;
1503                 case CloudMediaProviderContract.MediaColumns.DURATION_MILLIS:
1504                     values.put(KEY_DURATION_MS, cursor.getLong(index));
1505                     break;
1506                 case CloudMediaProviderContract.MediaColumns.IS_FAVORITE:
1507                     if (TextUtils.isEmpty(albumId)) {
1508                         values.put(KEY_IS_FAVORITE, cursor.getInt(index));
1509                     }
1510                     break;
1511                 case MediaColumns.OWNER_PACKAGE_NAME:
1512                     values.put(KEY_OWNER_PACKAGE_NAME, cursor.getString(index));
1513                     break;
1514                 case MediaColumns.USER_ID:
1515                     values.put(KEY_USER_ID, cursor.getInt(index));
1516                     break;
1517 
1518                     /* The below columns are only included if this is not the album_media table
1519                      * (AlbumId is an empty string)
1520                      *
1521                      * The columns should be in the cursor either way, but we don't duplicate these
1522                      * columns to album_media since they are not needed for the UI.
1523                      */
1524                 case CloudMediaProviderContract.MediaColumns.WIDTH:
1525                     if (TextUtils.isEmpty(albumId)) {
1526                         values.put(KEY_WIDTH, cursor.getInt(index));
1527                     }
1528                     break;
1529                 case CloudMediaProviderContract.MediaColumns.HEIGHT:
1530                     if (TextUtils.isEmpty(albumId)) {
1531                         values.put(KEY_HEIGHT, cursor.getInt(index));
1532                     }
1533                     break;
1534                 case CloudMediaProviderContract.MediaColumns.ORIENTATION:
1535                     if (TextUtils.isEmpty(albumId)) {
1536                         values.put(KEY_ORIENTATION, cursor.getInt(index));
1537                     }
1538                     break;
1539                 default:
1540                     Log.w(TAG, "Unexpected cursor key: " + key);
1541             }
1542         }
1543 
1544         return values;
1545     }
1546 
isValidStandardMimeTypeExtension(int standardMimeTypeExtension)1547     private static boolean isValidStandardMimeTypeExtension(int standardMimeTypeExtension) {
1548         switch (standardMimeTypeExtension) {
1549             case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_NONE:
1550             case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_GIF:
1551             case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_MOTION_PHOTO:
1552             case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_ANIMATED_WEBP:
1553                 return true;
1554             default:
1555                 return false;
1556         }
1557     }
1558 
buildSelectionArgs(SQLiteQueryBuilder qb, QueryFilter query)1559     private static String[] buildSelectionArgs(SQLiteQueryBuilder qb, QueryFilter query) {
1560         List<String> selectArgs = new ArrayList<>();
1561 
1562         if (query.mIsLocalOnly) {
1563             qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID);
1564         }
1565 
1566         if (query.mId >= 0) {
1567             if (query.mDateTakenAfterMs >= 0) {
1568                 qb.appendWhereStandalone(WHERE_DATE_TAKEN_MS_AFTER);
1569                 // Add date args twice because the sql statement evaluates date twice
1570                 selectArgs.add(String.valueOf(query.mDateTakenAfterMs));
1571                 selectArgs.add(String.valueOf(query.mDateTakenAfterMs));
1572             } else {
1573                 qb.appendWhereStandalone(WHERE_DATE_TAKEN_MS_BEFORE);
1574                 // Add date args twice because the sql statement evaluates date twice
1575                 selectArgs.add(String.valueOf(query.mDateTakenBeforeMs));
1576                 selectArgs.add(String.valueOf(query.mDateTakenBeforeMs));
1577             }
1578             selectArgs.add(String.valueOf(query.mId));
1579         }
1580 
1581         if (query.mSizeBytes >= 0) {
1582             qb.appendWhereStandalone(WHERE_SIZE_BYTES);
1583             selectArgs.add(String.valueOf(query.mSizeBytes));
1584         }
1585 
1586         addMimeTypesToQueryBuilderAndSelectionArgs(qb, selectArgs, query.mMimeTypes);
1587 
1588         if (query.mIsVideo) {
1589             qb.appendWhereStandalone(WHERE_MIME_TYPE);
1590             selectArgs.add(VIDEO_MIME_TYPES);
1591         } else if (query.mIsFavorite) {
1592             qb.appendWhereStandalone(getWhereForFavorite(query.mIsLocalOnly));
1593         } else if (!TextUtils.isEmpty(query.mAlbumId)) {
1594             qb.appendWhereStandalone(WHERE_ALBUM_ID);
1595             selectArgs.add(query.mAlbumId);
1596         }
1597 
1598         if (query.mLocalPreSelectedIds != QueryFilterBuilder.LIST_DEFAULT
1599                 || query.mCloudPreSelectedIds != QueryFilterBuilder.LIST_DEFAULT) {
1600             List<String> selectionIds;
1601             String whereCondition;
1602             if (query.mLocalPreSelectedIds != QueryFilterBuilder.LIST_DEFAULT) {
1603                 selectionIds = query.mLocalPreSelectedIds;
1604                 whereCondition = WHERE_LOCAL_ID_IN;
1605             } else {
1606                 selectionIds = query.mCloudPreSelectedIds;
1607                 whereCondition = WHERE_CLOUD_ID_IN;
1608             }
1609             if (!selectionIds.isEmpty()) {
1610                 StringBuilder idSelectionPlaceholder = new StringBuilder("(");
1611                 for (int itr = 0; itr < selectionIds.size(); itr++) {
1612                     idSelectionPlaceholder.append("?,");
1613                 }
1614                 idSelectionPlaceholder.deleteCharAt(idSelectionPlaceholder.length() - 1);
1615                 idSelectionPlaceholder.append(")");
1616 
1617                 // Append the where clause for id selection to the query builder.
1618                 qb.appendWhereStandalone(whereCondition + idSelectionPlaceholder);
1619 
1620                 // Add ids to the selection args.
1621                 selectArgs.addAll(selectionIds);
1622             }
1623         }
1624 
1625         if (selectArgs.isEmpty()) {
1626             return null;
1627         }
1628 
1629         return selectArgs.toArray(new String[selectArgs.size()]);
1630     }
1631 
1632     /**
1633      * Returns where clause to obtain rows that are marked as favorite
1634      *
1635      * Favorite information can either come from local or from cloud. In case where an item is
1636      * marked as favorite in cloud provider, we try to obtain the local row corresponding to this
1637      * cloud row to avoid downloading cloud file unnecessarily.
1638      * See {@code WHERE_FAVORITE_LOCAL_PLUS_CLOUD}
1639      *
1640      * For queries that are local only, we don't need any of these complex queries, hence we stick
1641      * to simple query like {@code WHERE_FAVORITE_LOCAL_ONLY}
1642      */
getWhereForFavorite(boolean isLocalOnly)1643     private static String getWhereForFavorite(boolean isLocalOnly) {
1644         if (isLocalOnly) {
1645             return WHERE_FAVORITE_LOCAL_ONLY;
1646         } else {
1647             return WHERE_FAVORITE_ALL;
1648         }
1649     }
1650 
addMimeTypesToQueryBuilderAndSelectionArgs(SQLiteQueryBuilder qb, List<String> selectionArgs, String[] mimeTypes)1651     static void addMimeTypesToQueryBuilderAndSelectionArgs(SQLiteQueryBuilder qb,
1652             List<String> selectionArgs, String[] mimeTypes) {
1653         if (mimeTypes == null) {
1654             return;
1655         }
1656 
1657         mimeTypes = replaceMatchAnyChar(mimeTypes);
1658         ArrayList<String> whereMimeTypes = new ArrayList<>();
1659         for (String mimeType : mimeTypes) {
1660             if (!TextUtils.isEmpty(mimeType)) {
1661                 whereMimeTypes.add(WHERE_MIME_TYPE);
1662                 selectionArgs.add(mimeType);
1663             }
1664         }
1665 
1666         if (whereMimeTypes.isEmpty()) {
1667             return;
1668         }
1669         qb.appendWhereStandalone(TextUtils.join(" OR ", whereMimeTypes));
1670     }
1671 
createMediaQueryBuilder()1672     private static SQLiteQueryBuilder createMediaQueryBuilder() {
1673         SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
1674         qb.setTables(TABLE_MEDIA);
1675 
1676         return qb;
1677     }
1678 
createGrantsQueryBuilder()1679     private static SQLiteQueryBuilder createGrantsQueryBuilder() {
1680         SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
1681         qb.setTables(TABLE_GRANTS);
1682         return qb;
1683     }
1684 
1685     /**
1686      * Appends where clause for package and user id selection to the input query builder.
1687      */
addWhereClausesForMediaGrantsTable(SQLiteQueryBuilder qb, int userId, @NonNull String[] packageNames)1688     public static void addWhereClausesForMediaGrantsTable(SQLiteQueryBuilder qb, int userId,
1689             @NonNull String[] packageNames) {
1690         // Add where clause for userId selection.
1691         qb.appendWhereStandalone(
1692                 String.format("%s.%s = %s", TABLE_GRANTS, PACKAGE_USER_ID_COLUMN,
1693                         String.valueOf(userId)));
1694 
1695         // Add where clause for package name selection.
1696         Objects.requireNonNull(packageNames);
1697         StringBuilder packageSelection = new StringBuilder("(");
1698         for (int itr = 0; itr < packageNames.length; itr++) {
1699             packageSelection.append("\"").append(packageNames[itr]).append("\",");
1700         }
1701         packageSelection.deleteCharAt(packageSelection.length() - 1);
1702         packageSelection.append(")");
1703         qb.appendWhereStandalone(OWNER_PACKAGE_NAME_COLUMN + " IN "
1704                 + packageSelection.toString());
1705     }
1706 
createAlbumMediaQueryBuilder(boolean isLocal)1707     private static SQLiteQueryBuilder createAlbumMediaQueryBuilder(boolean isLocal) {
1708         SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
1709         qb.setTables(TABLE_ALBUM_MEDIA);
1710 
1711         // In case of local albums, local_id cannot be null.
1712         // In case of cloud albums, there can be 2 types of media items:
1713         // 1. Cloud-only - Only cloud_id will be populated and local_id will be null.
1714         // 2. Local + Cloud - Only local_id will be populated and cloud_id will be null as showing
1715         // local copy is preferred over cloud copy.
1716         if (isLocal) {
1717             qb.appendWhereStandalone(WHERE_NOT_NULL_LOCAL_ID);
1718         }
1719 
1720         return qb;
1721     }
1722 
createLocalOnlyMediaQueryBuilder()1723     private static SQLiteQueryBuilder createLocalOnlyMediaQueryBuilder() {
1724         SQLiteQueryBuilder qb = createLocalMediaQueryBuilder();
1725         qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID);
1726 
1727         return qb;
1728     }
1729 
createLocalMediaQueryBuilder()1730     private static SQLiteQueryBuilder createLocalMediaQueryBuilder() {
1731         SQLiteQueryBuilder qb = createMediaQueryBuilder();
1732         qb.appendWhereStandalone(WHERE_LOCAL_ID);
1733 
1734         return qb;
1735     }
1736 
createCloudMediaQueryBuilder()1737     private static SQLiteQueryBuilder createCloudMediaQueryBuilder() {
1738         SQLiteQueryBuilder qb = createMediaQueryBuilder();
1739         qb.appendWhereStandalone(WHERE_CLOUD_ID);
1740 
1741         return qb;
1742     }
1743 
createIdMediaQueryBuilder()1744     private static SQLiteQueryBuilder createIdMediaQueryBuilder() {
1745         SQLiteQueryBuilder qb = createMediaQueryBuilder();
1746         qb.appendWhereStandalone(WHERE_ID);
1747 
1748         return qb;
1749     }
1750 
createVisibleMediaQueryBuilder()1751     private static SQLiteQueryBuilder createVisibleMediaQueryBuilder() {
1752         SQLiteQueryBuilder qb = createMediaQueryBuilder();
1753         qb.appendWhereStandalone(WHERE_IS_VISIBLE);
1754 
1755         return qb;
1756     }
1757 
createVisibleLocalMediaQueryBuilder()1758     private static SQLiteQueryBuilder createVisibleLocalMediaQueryBuilder() {
1759         SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder();
1760         qb.appendWhereStandalone(WHERE_LOCAL_ID);
1761 
1762         return qb;
1763     }
1764 
1765     private abstract static class AlbumWriteOperation extends DbWriteOperation {
1766 
1767         private final String mAlbumId;
1768 
AlbumWriteOperation(SQLiteDatabase database, boolean isLocal, String albumId)1769         private AlbumWriteOperation(SQLiteDatabase database, boolean isLocal, String albumId) {
1770             super(database, isLocal);
1771             mAlbumId = albumId;
1772         }
1773 
getAlbumId()1774         String getAlbumId() {
1775             return mAlbumId;
1776         }
1777     }
1778 
1779     private static final class ResetAlbumOperation extends AlbumWriteOperation {
1780 
ResetAlbumOperation(SQLiteDatabase database, boolean isLocal, String albumId)1781         private ResetAlbumOperation(SQLiteDatabase database, boolean isLocal, String albumId) {
1782             super(database, isLocal, albumId);
1783         }
1784 
1785         @Override
executeInternal(@ullable Cursor unused)1786         int executeInternal(@Nullable Cursor unused) {
1787             final String albumId = getAlbumId();
1788             final boolean isLocal = isLocal();
1789 
1790             final SQLiteQueryBuilder qb = createAlbumMediaQueryBuilder(isLocal);
1791 
1792             String[] selectionArgs = null;
1793             if (!TextUtils.isEmpty(albumId)) {
1794                 qb.appendWhereStandalone(WHERE_ALBUM_ID);
1795                 selectionArgs = new String[]{albumId};
1796             }
1797 
1798             return qb.delete(getDatabase(), /* selection */ null, /* selectionArgs */
1799                     selectionArgs);
1800         }
1801     }
1802 
1803     private static final class AddAlbumMediaOperation extends AlbumWriteOperation {
1804         private static final String[] sLocalMediaProjection = new String[] {
1805                 KEY_DATE_TAKEN_MS,
1806                 KEY_SYNC_GENERATION,
1807                 KEY_SIZE_BYTES,
1808                 KEY_DURATION_MS,
1809                 KEY_MIME_TYPE,
1810                 KEY_STANDARD_MIME_TYPE_EXTENSION
1811         };
1812 
AddAlbumMediaOperation(SQLiteDatabase database, boolean isLocal, String albumId)1813         private AddAlbumMediaOperation(SQLiteDatabase database, boolean isLocal, String albumId) {
1814             super(database, isLocal, albumId);
1815 
1816             if (TextUtils.isEmpty(albumId)) {
1817                 throw new IllegalArgumentException("Missing albumId.");
1818             }
1819         }
1820 
1821         @Override
executeInternal(@ullable Cursor cursor)1822         int executeInternal(@Nullable Cursor cursor) {
1823             final boolean isLocal = isLocal();
1824             final String albumId = getAlbumId();
1825             final SQLiteQueryBuilder qb = createAlbumMediaQueryBuilder(isLocal);
1826             final SQLiteQueryBuilder qbMedia = createMediaQueryBuilder();
1827             int counter = 0;
1828 
1829             if (cursor.getCount() > PAGE_SIZE) {
1830                 Log.w(TAG,
1831                         String.format("Expected a cursor page size of %d, but received a cursor "
1832                             + "with %d rows instead.", PAGE_SIZE, cursor.getCount()));
1833             }
1834 
1835             if (cursor.moveToFirst()) {
1836                 do {
1837                     ContentValues values = cursorToContentValue(cursor, isLocal, albumId);
1838 
1839                     // In case of cloud albums, cloud provider returns both local and cloud ids.
1840                     // We give preference to inserting media data for the local copy of an item
1841                     // instea of the cloud copy. Hence, if local copy is available, fetch metadata
1842                     // from media table and update the album_media row accordingly.
1843                     if (!isLocal) {
1844                         final String localId = values.getAsString(KEY_LOCAL_ID);
1845                         final String cloudId = values.getAsString(KEY_CLOUD_ID);
1846                         if (!TextUtils.isEmpty(localId) && !TextUtils.isEmpty(cloudId)) {
1847                             // Fetch local media item details from media table.
1848                             try (Cursor cursorLocalMedia = getLocalMediaMetadata(localId)) {
1849                                 if (cursorLocalMedia != null && cursorLocalMedia.getCount() == 1) {
1850                                     // If local media item details are present in the media table,
1851                                     // update content values and remove cloud id.
1852                                     values.putNull(KEY_CLOUD_ID);
1853                                     updateContentValues(values, cursorLocalMedia);
1854                                 } else {
1855                                     // If local media item details are NOT present in the media
1856                                     // table, insert cloud row after removing local_id. This will
1857                                     // only happen when local id points to a deleted item.
1858                                     values.putNull(KEY_LOCAL_ID);
1859                                 }
1860                             }
1861                         }
1862                     }
1863 
1864                     try {
1865                         if (qb.insert(getDatabase(), values) > 0) {
1866                             counter++;
1867                         } else {
1868                             Log.v(TAG, "Failed to insert album_media. ContentValues: " + values);
1869                         }
1870                     } catch (SQLiteConstraintException e) {
1871                         Log.v(TAG, "Failed to insert album_media. ContentValues: " + values, e);
1872                     }
1873 
1874                     // Check if a Cloud sync is running, and additionally insert this row to media
1875                     // table if true.
1876                     maybeInsertFileToMedia(qbMedia, cursor, isLocal);
1877                 } while (cursor.moveToNext());
1878             }
1879 
1880             return counter;
1881         }
1882 
1883         /**
1884          * Will (possibly) insert this file to the Picker database's media table if there's an
1885          * existing Cloud Sync running.
1886          *
1887          * <p>This is necessary to guarantee it exists in case it is selected by the user. (So that
1888          * the pre-loader can load it to the device before the session is closed.)
1889          *
1890          * @param queryBuilder The media table query builder to use for the insert
1891          * @param cursor The current cursor being processed (this method does not advance the
1892          *     cursor).
1893          * @param isLocal Whether this is the local provider sync or not.
1894          */
maybeInsertFileToMedia( SQLiteQueryBuilder queryBuilder, Cursor cursor, boolean isLocal)1895         private void maybeInsertFileToMedia(
1896                 SQLiteQueryBuilder queryBuilder, Cursor cursor, boolean isLocal) {
1897             if (SyncTrackerRegistry.getCloudSyncTracker().pendingSyncFutures().size() > 0) {
1898                 ContentValues values = cursorToContentValue(cursor, isLocal);
1899                 Log.d(
1900                         TAG,
1901                         String.format(
1902                                 "Encountered running Cloud sync during AddAlbumMediaOperation while"
1903                                     + " processing row. Will additional insert to media table:  %s",
1904                                 values));
1905                 try {
1906                     queryBuilder.insert(getDatabase(), values);
1907                 } catch (SQLiteConstraintException ignored) {
1908                     // If we hit a constraint exception it means this row is already in media,
1909                     // so nothing to do here.
1910                 }
1911             }
1912         }
1913 
updateContentValues(ContentValues values, Cursor cursor)1914         private void updateContentValues(ContentValues values, Cursor cursor) {
1915             if (cursor.moveToFirst()) {
1916                 for (int columnIndex = 0; columnIndex < cursor.getColumnCount(); columnIndex++) {
1917                     String column = cursor.getColumnName(columnIndex);
1918                     switch (column) {
1919                         case KEY_DATE_TAKEN_MS:
1920                         case KEY_SYNC_GENERATION:
1921                         case KEY_SIZE_BYTES:
1922                         case KEY_DURATION_MS:
1923                         case KEY_STANDARD_MIME_TYPE_EXTENSION:
1924                             values.put(column, cursor.getLong(columnIndex));
1925                             break;
1926                         case KEY_MIME_TYPE:
1927                             values.put(column, cursor.getString(columnIndex));
1928                             break;
1929                         default:
1930                             throw new IllegalArgumentException(
1931                                     "Column " + column + " not recognized.");
1932                     }
1933                 }
1934             }
1935         }
1936 
getLocalMediaMetadata(String localId)1937         private Cursor getLocalMediaMetadata(String localId) {
1938             final SQLiteQueryBuilder qb = createVisibleLocalMediaQueryBuilder();
1939             final String[] selectionArgs = new String[] {localId};
1940             qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID);
1941 
1942             return qb.query(getDatabase(), sLocalMediaProjection, /* selection */ null,
1943                     selectionArgs, /* groupBy */ null, /* having */ null,
1944                     /* orderBy */ null);
1945         }
1946     }
1947 
1948     /**
1949      * Print the {@link PickerDbFacade} state into the given stream.
1950      */
dump(PrintWriter writer)1951     public void dump(PrintWriter writer) {
1952         writer.println("Picker db facade state:");
1953         writer.println("  mLocalProvider=" + getLocalProvider());
1954         writer.println("  mCloudProvider=" + getCloudProvider());
1955     }
1956 
1957     /**
1958      * Returns the associated SQLiteDatabase instance.
1959      */
getDatabase()1960     public SQLiteDatabase getDatabase() {
1961         return mPickerDatabaseHelper.getWritableDatabase();
1962     }
1963 }
1964