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