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