• 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.PickerUriResolver.getMediaUri;
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.sqlite.SQLiteConstraintException;
37 import android.database.sqlite.SQLiteDatabase;
38 import android.database.sqlite.SQLiteQueryBuilder;
39 import android.net.Uri;
40 import android.os.SystemProperties;
41 import android.provider.DeviceConfig;
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.photopicker.PickerSyncController;
52 
53 import java.util.ArrayList;
54 import java.util.List;
55 import java.util.Objects;
56 
57 /**
58  * This is a facade that hides the complexities of executing some SQL statements on the picker db.
59  * It does not do any caller permission checks and is only intended for internal use within the
60  * MediaProvider for the Photo Picker.
61  */
62 public class PickerDbFacade {
63     private static final String VIDEO_MIME_TYPES = "video/%";
64 
65     private final Object mLock = new Object();
66     private final Context mContext;
67     private final SQLiteDatabase mDatabase;
68     private final String mLocalProvider;
69     private String mCloudProvider;
70 
PickerDbFacade(Context context)71     public PickerDbFacade(Context context) {
72         this(context, PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY);
73     }
74 
75     @VisibleForTesting
PickerDbFacade(Context context, String localProvider)76     public PickerDbFacade(Context context, String localProvider) {
77         this(context, localProvider, new PickerDatabaseHelper(context));
78     }
79 
80     @VisibleForTesting
PickerDbFacade(Context context, String localProvider, PickerDatabaseHelper dbHelper)81     public PickerDbFacade(Context context, String localProvider, PickerDatabaseHelper dbHelper) {
82         mContext = context;
83         mLocalProvider = localProvider;
84         mDatabase = dbHelper.getWritableDatabase();
85     }
86 
87     private static final String TAG = "PickerDbFacade";
88 
89     private static final int RETRY = 0;
90     private static final int SUCCESS = 1;
91     private static final int FAIL = -1;
92 
93     private static final String TABLE_MEDIA = "media";
94     // Intentionally use /sdcard path so that the receiving app resolves it to it's per-user
95     // external storage path, e.g. /storage/emulated/<userid>. That way FUSE cross-user access is
96     // not required for picker paths sent across users
97     private static final String PICKER_PATH = "/sdcard/" + getPickerRelativePath();
98     private static final String TABLE_ALBUM_MEDIA = "album_media";
99 
100     @VisibleForTesting
101     public static final String KEY_ID = "_id";
102     @VisibleForTesting
103     public static final String KEY_LOCAL_ID = "local_id";
104     @VisibleForTesting
105     public static final String KEY_CLOUD_ID = "cloud_id";
106     @VisibleForTesting
107     public static final String KEY_IS_VISIBLE = "is_visible";
108     @VisibleForTesting
109     public static final String KEY_DATE_TAKEN_MS = "date_taken_ms";
110     @VisibleForTesting
111     public static final String KEY_SYNC_GENERATION = "sync_generation";
112     @VisibleForTesting
113     public static final String KEY_SIZE_BYTES = "size_bytes";
114     @VisibleForTesting
115     public static final String KEY_DURATION_MS = "duration_ms";
116     @VisibleForTesting
117     public static final String KEY_MIME_TYPE = "mime_type";
118     @VisibleForTesting
119     public static final String KEY_STANDARD_MIME_TYPE_EXTENSION = "standard_mime_type_extension";
120     @VisibleForTesting
121     public static final String KEY_IS_FAVORITE = "is_favorite";
122     @VisibleForTesting
123     public static final String KEY_ALBUM_ID = "album_id";
124 
125     @VisibleForTesting
126     public static final String IMAGE_FILE_EXTENSION = ".jpg";
127     @VisibleForTesting
128     public static final String VIDEO_FILE_EXTENSION = ".mp4";
129 
130     private static final String WHERE_ID = KEY_ID + " = ?";
131     private static final String WHERE_LOCAL_ID = KEY_LOCAL_ID + " = ?";
132     private static final String WHERE_CLOUD_ID = KEY_CLOUD_ID + " = ?";
133     private static final String WHERE_NULL_CLOUD_ID = KEY_CLOUD_ID + " IS NULL";
134     private static final String WHERE_NOT_NULL_CLOUD_ID = KEY_CLOUD_ID + " IS NOT NULL";
135     private static final String WHERE_NOT_NULL_LOCAL_ID = KEY_LOCAL_ID + " IS NOT NULL";
136     private static final String WHERE_IS_VISIBLE = KEY_IS_VISIBLE + " = 1";
137     private static final String WHERE_MIME_TYPE = KEY_MIME_TYPE + " LIKE ? ";
138     private static final String WHERE_IS_FAVORITE = KEY_IS_FAVORITE + " = 1";
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 
148     private static final String[] PROJECTION_ALBUM_DB = new String[] {
149         "COUNT(" + KEY_ID + ") AS " + CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT,
150         "MAX(" + KEY_DATE_TAKEN_MS + ") AS "
151         + CloudMediaProviderContract.AlbumColumns.DATE_TAKEN_MILLIS,
152         String.format("IFNULL(%s, %s) AS %s", KEY_CLOUD_ID,
153                 KEY_LOCAL_ID, CloudMediaProviderContract.AlbumColumns.MEDIA_COVER_ID)
154     };
155 
156     // Matches all media including cloud+local, cloud-only and local-only
157     private static final SQLiteQueryBuilder QB_MATCH_ALL = createMediaQueryBuilder();
158     // Matches media with id
159     private static final SQLiteQueryBuilder QB_MATCH_ID = createIdMediaQueryBuilder();
160     // Matches media with local_id including cloud+local and local-only
161     private static final SQLiteQueryBuilder QB_MATCH_LOCAL = createLocalMediaQueryBuilder();
162     // Matches cloud media including cloud+local and cloud-only
163     private static final SQLiteQueryBuilder QB_MATCH_CLOUD = createCloudMediaQueryBuilder();
164     // Matches all visible media including cloud+local, cloud-only and local-only
165     private static final SQLiteQueryBuilder QB_MATCH_VISIBLE = createVisibleMediaQueryBuilder();
166     // Matches visible media with local_id including cloud+local and local-only
167     private static final SQLiteQueryBuilder QB_MATCH_VISIBLE_LOCAL =
168             createVisibleLocalMediaQueryBuilder();
169     // Matches stricly local-only media
170     private static final SQLiteQueryBuilder QB_MATCH_LOCAL_ONLY =
171             createLocalOnlyMediaQueryBuilder();
172 
173     private static final ContentValues CONTENT_VALUE_VISIBLE = new ContentValues();
174     private static final ContentValues CONTENT_VALUE_HIDDEN = new ContentValues();
175 
176     static {
CONTENT_VALUE_VISIBLE.put(KEY_IS_VISIBLE, 1)177         CONTENT_VALUE_VISIBLE.put(KEY_IS_VISIBLE, 1);
178         CONTENT_VALUE_HIDDEN.putNull(KEY_IS_VISIBLE);
179     }
180 
181     /**
182      * Sets the cloud provider to be returned after querying the picker db
183      * If null, cloud media will be excluded from all queries.
184      */
setCloudProvider(String authority)185     public void setCloudProvider(String authority) {
186         synchronized (mLock) {
187             mCloudProvider = authority;
188         }
189     }
190 
191     /**
192      * Returns the cloud provider that will be returned after querying the picker db
193      */
194     @VisibleForTesting
getCloudProvider()195     public String getCloudProvider() {
196         synchronized (mLock) {
197             return mCloudProvider;
198         }
199     }
200 
getLocalProvider()201     public String getLocalProvider() {
202         return mLocalProvider;
203     }
204 
205     /**
206      * Returns {@link DbWriteOperation} to add media belonging to {@code authority} into the picker
207      * db.
208      */
beginAddMediaOperation(String authority)209     public DbWriteOperation beginAddMediaOperation(String authority) {
210         return new AddMediaOperation(mDatabase, isLocal(authority));
211     }
212 
213     /**
214      * Returns {@link DbWriteOperation} to add album_media belonging to {@code authority}
215      * into the picker db.
216      */
beginAddAlbumMediaOperation(String authority, String albumId)217     public DbWriteOperation beginAddAlbumMediaOperation(String authority, String albumId) {
218         return new AddAlbumMediaOperation(mDatabase, isLocal(authority), albumId);
219     }
220 
221     /**
222      * Returns {@link DbWriteOperation} to remove media belonging to {@code authority} from the
223      * picker db.
224      */
beginRemoveMediaOperation(String authority)225     public DbWriteOperation beginRemoveMediaOperation(String authority) {
226         return new RemoveMediaOperation(mDatabase, isLocal(authority));
227     }
228 
229     /**
230      * Returns {@link DbWriteOperation} to clear local media or all cloud media from the picker
231      * db.
232      *
233      * @param authority to determine whether local or cloud media should be cleared
234      */
beginResetMediaOperation(String authority)235     public DbWriteOperation beginResetMediaOperation(String authority) {
236         return new ResetMediaOperation(mDatabase, isLocal(authority));
237     }
238 
239     /**
240      * Returns {@link DbWriteOperation} to clear album media for a given albumId from the picker
241      * db.
242      *
243      * @param authority to determine whether local or cloud media should be cleared
244      */
beginResetAlbumMediaOperation(String authority, String albumId)245     public DbWriteOperation beginResetAlbumMediaOperation(String authority, String albumId) {
246         return new ResetAlbumOperation(mDatabase, isLocal(authority), albumId);
247     }
248 
249     /**
250      * Represents an atomic write operation to the picker database.
251      *
252      * <p>This class is not thread-safe and is meant to be used within a single thread only.
253      */
254     public static abstract class DbWriteOperation implements AutoCloseable {
255 
256         private final SQLiteDatabase mDatabase;
257         private final boolean mIsLocal;
258         private final String mAlbumId;
259 
260         private boolean mIsSuccess = false;
261 
262         // Needed for Album Media Write operations.
DbWriteOperation(SQLiteDatabase database, boolean isLocal)263         private DbWriteOperation(SQLiteDatabase database, boolean isLocal) {
264             this(database, isLocal, "");
265         }
266 
267         // Needed for Album Media Write operations.
DbWriteOperation(SQLiteDatabase database, boolean isLocal, String albumId)268         private DbWriteOperation(SQLiteDatabase database, boolean isLocal, String albumId) {
269             mDatabase = database;
270             mIsLocal = isLocal;
271             mAlbumId = albumId;
272             mDatabase.beginTransaction();
273         }
274 
275         /*
276          * Execute the write operation.
277          *
278          * @param cursor containing items to add/remove
279          * @return {@link WriteResult} indicating success/failure and the number of {@code cursor}
280          *          items that were inserted/updated/deleted in the picker db
281          * @throws {@link IllegalStateException} if no DB transaction is active
282          */
execute(@ullable Cursor cursor)283         public int execute(@Nullable Cursor cursor) {
284             if (!mDatabase.inTransaction()) {
285                 throw new IllegalStateException("No ongoing DB transaction.");
286             }
287             return executeInternal(cursor);
288         }
289 
setSuccess()290         public void setSuccess() {
291             mIsSuccess = true;
292         }
293 
294         @Override
close()295         public void close() {
296             if (mDatabase.inTransaction()) {
297                 if (mIsSuccess) {
298                     mDatabase.setTransactionSuccessful();
299                 } else {
300                     Log.w(TAG, "DB write transaction failed.");
301                 }
302                 mDatabase.endTransaction();
303             } else {
304                 throw new IllegalStateException("close() has already been called previously.");
305             }
306         }
307 
executeInternal(@ullable Cursor cursor)308         abstract int executeInternal(@Nullable Cursor cursor);
309 
getDatabase()310         SQLiteDatabase getDatabase() {
311             return mDatabase;
312         }
313 
isLocal()314         boolean isLocal() {
315             return mIsLocal;
316         }
317 
albumId()318         String albumId() {
319             return mAlbumId;
320         }
321 
updateMedia(SQLiteQueryBuilder qb, ContentValues values, String[] selectionArgs)322         int updateMedia(SQLiteQueryBuilder qb, ContentValues values,
323                 String[] selectionArgs) {
324             try {
325                 if (qb.update(mDatabase, values, /* selection */ null, selectionArgs) > 0) {
326                     return SUCCESS;
327                 } else {
328                     Log.d(TAG, "Failed to update picker db media. ContentValues: " + values);
329                     return FAIL;
330                 }
331             } catch (SQLiteConstraintException e) {
332                 Log.d(TAG, "Failed to update picker db media. ContentValues: " + values, e);
333                 return RETRY;
334             }
335         }
336 
querySingleMedia(SQLiteQueryBuilder qb, String[] projection, String[] selectionArgs, int columnIndex)337         String querySingleMedia(SQLiteQueryBuilder qb, String[] projection,
338                 String[] selectionArgs, int columnIndex) {
339             try (Cursor cursor = qb.query(mDatabase, projection, /* selection */ null,
340                     selectionArgs, /* groupBy */ null, /* having */ null,
341                     /* orderBy */ null)) {
342                 if (cursor.moveToFirst()) {
343                     return cursor.getString(columnIndex);
344                 }
345             }
346 
347             return null;
348         }
349     }
350 
351     private static final class AddMediaOperation extends DbWriteOperation {
352 
AddMediaOperation(SQLiteDatabase database, boolean isLocal)353         private AddMediaOperation(SQLiteDatabase database, boolean isLocal) {
354             super(database, isLocal);
355         }
356 
357         @Override
executeInternal(@ullable Cursor cursor)358         int executeInternal(@Nullable Cursor cursor) {
359             final boolean isLocal = isLocal();
360             final SQLiteQueryBuilder qb = isLocal ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD;
361             int counter = 0;
362 
363             while (cursor.moveToNext()) {
364                 ContentValues values = cursorToContentValue(cursor, isLocal);
365 
366                 String[] upsertArgs = {values.getAsString(isLocal ?
367                         KEY_LOCAL_ID : KEY_CLOUD_ID)};
368                 if (upsertMedia(qb, values, upsertArgs) == SUCCESS) {
369                     counter++;
370                     continue;
371                 }
372 
373                 // Because we want to prioritize visible local media over visible cloud media,
374                 // we do the following if the upsert above failed
375                 if (isLocal) {
376                     // For local syncs, we attempt hiding the visible cloud media
377                     String cloudId = getVisibleCloudIdFromDb(values.getAsString(KEY_LOCAL_ID));
378                     demoteCloudMediaToHidden(cloudId);
379                 } else {
380                     // For cloud syncs, we prepare an upsert as hidden cloud media
381                     values.putNull(KEY_IS_VISIBLE);
382                 }
383 
384                 // Now attempt upsert again, this should succeed
385                 if (upsertMedia(qb, values, upsertArgs) == SUCCESS) {
386                     counter++;
387                 }
388             }
389             return counter;
390         }
391 
insertMedia(ContentValues values)392         private int insertMedia(ContentValues values) {
393             try {
394                 if (QB_MATCH_ALL.insert(getDatabase(), values) > 0) {
395                     return SUCCESS;
396                 } else {
397                     Log.d(TAG, "Failed to insert picker db media. ContentValues: " + values);
398                     return FAIL;
399                 }
400             } catch (SQLiteConstraintException e) {
401                 Log.d(TAG, "Failed to insert picker db media. ContentValues: " + values, e);
402                 return RETRY;
403             }
404         }
405 
upsertMedia(SQLiteQueryBuilder qb, ContentValues values, String[] selectionArgs)406         private int upsertMedia(SQLiteQueryBuilder qb,
407                 ContentValues values, String[] selectionArgs) {
408             int res = insertMedia(values);
409             if (res == RETRY) {
410                 // Attempt equivalent of CONFLICT_REPLACE resolution
411                 Log.d(TAG, "Retrying failed insert as update. ContentValues: " + values);
412                 res = updateMedia(qb, values, selectionArgs);
413             }
414 
415             return res;
416         }
417 
demoteCloudMediaToHidden(@ullable String cloudId)418         private void demoteCloudMediaToHidden(@Nullable String cloudId) {
419             if (cloudId == null) {
420                 return;
421             }
422 
423             final String[] updateArgs = new String[] {cloudId};
424             if (updateMedia(QB_MATCH_CLOUD, CONTENT_VALUE_HIDDEN, updateArgs) == SUCCESS) {
425                 Log.d(TAG, "Demoted picker db media item to hidden. CloudId: " + cloudId);
426             }
427         }
428 
getVisibleCloudIdFromDb(String localId)429         private String getVisibleCloudIdFromDb(String localId) {
430             final String[] cloudIdProjection = new String[] {KEY_CLOUD_ID};
431             final String[] queryArgs = new String[] {localId};
432             return querySingleMedia(QB_MATCH_VISIBLE_LOCAL, cloudIdProjection, queryArgs,
433                     /* columnIndex */ 0);
434         }
435     }
436 
437     private static final class RemoveMediaOperation extends DbWriteOperation {
438 
RemoveMediaOperation(SQLiteDatabase database, boolean isLocal)439         private RemoveMediaOperation(SQLiteDatabase database, boolean isLocal) {
440             super(database, isLocal);
441         }
442 
443         @Override
executeInternal(@ullable Cursor cursor)444         int executeInternal(@Nullable Cursor cursor) {
445             final boolean isLocal = isLocal();
446             final SQLiteQueryBuilder qb = isLocal ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD;
447 
448             int counter = 0;
449 
450             while (cursor.moveToNext()) {
451                 // Need to fetch the local_id before delete because for cloud items
452                 // we need a db query to fetch the local_id matching the id received from
453                 // cursor (cloud_id).
454                 final String localId = getLocalIdFromCursorOrDb(cursor, isLocal);
455 
456                 // Delete cloud/local row
457                 final int idIndex = cursor.getColumnIndex(
458                         CloudMediaProviderContract.MediaColumns.ID);
459                 final String[] deleteArgs = {cursor.getString(idIndex)};
460                 if (qb.delete(getDatabase(), /* selection */ null, deleteArgs) > 0) {
461                     counter++;
462                 }
463 
464                 promoteCloudMediaToVisible(localId);
465             }
466 
467             return counter;
468         }
469 
promoteCloudMediaToVisible(@ullable String localId)470         private void promoteCloudMediaToVisible(@Nullable String localId) {
471             if (localId == null) {
472                 return;
473             }
474 
475             final String[] idProjection = new String[] {KEY_ID};
476             final String[] queryArgs = {localId};
477             // First query for an exact row id matching the criteria for promotion so that we don't
478             // attempt promoting multiple hidden cloud rows matching the |localId|
479             final String id = querySingleMedia(QB_MATCH_LOCAL, idProjection, queryArgs,
480                     /* columnIndex */ 0);
481             if (id == null) {
482                 Log.w(TAG, "Unable to promote cloud media with localId: " + localId);
483                 return;
484             }
485 
486             final String[] updateArgs = {id};
487             if (updateMedia(QB_MATCH_ID, CONTENT_VALUE_VISIBLE, updateArgs) == SUCCESS) {
488                 Log.d(TAG, "Promoted picker db media item to visible. LocalId: " + localId);
489             }
490         }
491 
getLocalIdFromCursorOrDb(Cursor cursor, boolean isLocal)492         private String getLocalIdFromCursorOrDb(Cursor cursor, boolean isLocal) {
493             final String id = cursor.getString(0);
494 
495             if (isLocal) {
496                 // For local, id in cursor is already local_id
497                 return id;
498             } else {
499                 // For cloud, we need to query db with cloud_id from cursor to fetch local_id
500                 final String[] localIdProjection = new String[] {KEY_LOCAL_ID};
501                 final String[] queryArgs = new String[] {id};
502                 return querySingleMedia(QB_MATCH_CLOUD, localIdProjection, queryArgs,
503                         /* columnIndex */ 0);
504             }
505         }
506     }
507 
508     private static final class ResetMediaOperation extends DbWriteOperation {
509 
ResetMediaOperation(SQLiteDatabase database, boolean isLocal)510         private ResetMediaOperation(SQLiteDatabase database, boolean isLocal) {
511             super(database, isLocal);
512         }
513 
514         @Override
executeInternal(@ullable Cursor unused)515         int executeInternal(@Nullable Cursor unused) {
516             final boolean isLocal = isLocal();
517             final SQLiteQueryBuilder qb = createMediaQueryBuilder();
518 
519             if (isLocal) {
520                 qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID);
521             } else {
522                 qb.appendWhereStandalone(WHERE_NOT_NULL_CLOUD_ID);
523             }
524 
525             SQLiteDatabase database = getDatabase();
526             int counter = qb.delete(database, /* selection */ null, /* selectionArgs */ null);
527 
528             if (isLocal) {
529                 // If we reset local media, we need to promote cloud media items
530                 // Ignore conflicts in case we have multiple cloud_ids mapped to the
531                 // same local_id. Promoting either is fine.
532                 database.updateWithOnConflict(TABLE_MEDIA, CONTENT_VALUE_VISIBLE, /* where */ null,
533                         /* whereClause */ null, SQLiteDatabase.CONFLICT_IGNORE);
534             }
535 
536             return counter;
537         }
538     }
539 
540     /** Filter for {@link #queryMedia} to modify returned results */
541     public static class QueryFilter {
542         private final int mLimit;
543         private final long mDateTakenBeforeMs;
544         private final long mDateTakenAfterMs;
545         private final long mId;
546         private final String mAlbumId;
547         private final long mSizeBytes;
548         private final String mMimeType;
549         private final boolean mIsFavorite;
550         private final boolean mIsVideo;
551 
QueryFilter(int limit, long dateTakenBeforeMs, long dateTakenAfterMs, long id, String albumId, long sizeBytes, String mimeType, boolean isFavorite, boolean isVideo)552         private QueryFilter(int limit, long dateTakenBeforeMs, long dateTakenAfterMs, long id,
553                 String albumId, long sizeBytes, String mimeType, boolean isFavorite,
554                 boolean isVideo) {
555             this.mLimit = limit;
556             this.mDateTakenBeforeMs = dateTakenBeforeMs;
557             this.mDateTakenAfterMs = dateTakenAfterMs;
558             this.mId = id;
559             this.mAlbumId = albumId;
560             this.mSizeBytes = sizeBytes;
561             this.mMimeType = mimeType;
562             this.mIsFavorite = isFavorite;
563             this.mIsVideo = isVideo;
564         }
565     }
566 
567     /** Builder for {@link Query} filter. */
568     public static class QueryFilterBuilder {
569         public static final long LONG_DEFAULT = -1;
570         public static final String STRING_DEFAULT = null;
571         public static final boolean BOOLEAN_DEFAULT = false;
572 
573         public static final int LIMIT_DEFAULT = 1000;
574 
575         private final int limit;
576         private long dateTakenBeforeMs = LONG_DEFAULT;
577         private long dateTakenAfterMs = LONG_DEFAULT;
578         private long id = LONG_DEFAULT;
579         private String albumId = STRING_DEFAULT;
580         private long sizeBytes = LONG_DEFAULT;
581         private String mimeType = STRING_DEFAULT;
582         private boolean isFavorite = BOOLEAN_DEFAULT;
583         private boolean mIsVideo = BOOLEAN_DEFAULT;
584 
QueryFilterBuilder(int limit)585         public QueryFilterBuilder(int limit) {
586             this.limit = limit;
587         }
588 
setDateTakenBeforeMs(long dateTakenBeforeMs)589         public QueryFilterBuilder setDateTakenBeforeMs(long dateTakenBeforeMs) {
590             this.dateTakenBeforeMs = dateTakenBeforeMs;
591             return this;
592         }
593 
setDateTakenAfterMs(long dateTakenAfterMs)594         public QueryFilterBuilder setDateTakenAfterMs(long dateTakenAfterMs) {
595             this.dateTakenAfterMs = dateTakenAfterMs;
596             return this;
597         }
598 
599         /**
600          * The {@code id} helps break ties with db rows having the same {@code dateTakenAfterMs} or
601          * {@code dateTakenBeforeMs}.
602          *
603          * If {@code dateTakenAfterMs} is specified, all returned items are either strictly more
604          * recent than {@code dateTakenAfterMs} or have a picker db id strictly greater than
605          * {@code id} for ties.
606          *
607          * If {@code dateTakenBeforeMs} is specified, all returned items are either strictly older
608          * than {@code dateTakenBeforeMs} or have a picker db id strictly less than {@code id}
609          * for ties.
610          */
setId(long id)611         public QueryFilterBuilder setId(long id) {
612             this.id = id;
613             return this;
614         }
setAlbumId(String albumId)615         public QueryFilterBuilder setAlbumId(String albumId) {
616             this.albumId = albumId;
617             return this;
618         }
619 
setSizeBytes(long sizeBytes)620         public QueryFilterBuilder setSizeBytes(long sizeBytes) {
621             this.sizeBytes = sizeBytes;
622             return this;
623         }
624 
setMimeType(String mimeType)625         public QueryFilterBuilder setMimeType(String mimeType) {
626             this.mimeType = mimeType;
627             return this;
628         }
629 
630         /**
631          * If {@code isFavorite} is {@code true}, the {@link QueryFilter} returns only
632          * favorited items, however, if it is {@code false}, it returns all items including
633          * favorited and non-favorited items.
634          */
setIsFavorite(boolean isFavorite)635         public QueryFilterBuilder setIsFavorite(boolean isFavorite) {
636             this.isFavorite = isFavorite;
637             return this;
638         }
639 
640         /**
641          * If {@code isVideo} is {@code true}, the {@link QueryFilter} returns only
642          * video items, however, if it is {@code false}, it returns all items including
643          * video and non-video items.
644          */
setIsVideo(boolean isVideo)645         public QueryFilterBuilder setIsVideo(boolean isVideo) {
646             this.mIsVideo = isVideo;
647             return this;
648         }
649 
build()650         public QueryFilter build() {
651             return new QueryFilter(limit, dateTakenBeforeMs, dateTakenAfterMs, id, albumId,
652                     sizeBytes, mimeType, isFavorite, mIsVideo);
653         }
654     }
655 
656     /**
657      * Returns sorted and deduped cloud and local media items from the picker db.
658      *
659      * Returns a {@link Cursor} containing picker db media rows with columns as
660      * {@link CloudMediaProviderContract#MediaColumns}.
661      *
662      * The result is sorted in reverse chronological order, i.e. newest first, up to a maximum of
663      * {@code limit}. They can also be filtered with {@code query}.
664      */
queryMediaForUi(QueryFilter query)665     public Cursor queryMediaForUi(QueryFilter query) {
666         final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder();
667         final String[] selectionArgs = buildSelectionArgs(qb, query);
668 
669         final String cloudProvider;
670         synchronized (mLock) {
671             cloudProvider = mCloudProvider;
672         }
673 
674         return queryMediaForUi(qb, selectionArgs, query.mLimit, TABLE_MEDIA, cloudProvider);
675     }
676 
677     /**
678      * Returns sorted cloud or local media items from the picker db for a given album (either cloud
679      * or local).
680      *
681      * Returns a {@link Cursor} containing picker db media rows with columns as
682      * {@link CloudMediaProviderContract#MediaColumns} except for is_favorites column because that
683      * column is only used for fetching the Favorites album.
684      *
685      * The result is sorted in reverse chronological order, i.e. newest first, up to a maximum of
686      * {@code limit}. They can also be filtered with {@code query}.
687      */
queryAlbumMediaForUi(QueryFilter query, String authority)688     public Cursor queryAlbumMediaForUi(QueryFilter query, String authority) {
689         final SQLiteQueryBuilder qb = createAlbumMediaQueryBuilder(isLocal(authority));
690         final String[] selectionArgs = buildSelectionArgs(qb, query);
691 
692         return queryMediaForUi(qb, selectionArgs, query.mLimit, TABLE_ALBUM_MEDIA, authority);
693     }
694 
695     /**
696      * Returns an individual cloud or local item from the picker db matching {@code authority} and
697      * {@code mediaId}.
698      *
699      * Returns a {@link Cursor} containing picker db media rows with columns as {@code projection},
700      * a subset of {@link PickerMediaColumns}.
701      */
queryMediaIdForApps(String authority, String mediaId, @NonNull String[] projection)702     public Cursor queryMediaIdForApps(String authority, String mediaId,
703             @NonNull String[] projection) {
704         final String[] selectionArgs = new String[] { mediaId };
705         final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder();
706         if (isLocal(authority)) {
707             qb.appendWhereStandalone(WHERE_LOCAL_ID);
708         } else {
709             qb.appendWhereStandalone(WHERE_CLOUD_ID);
710         }
711 
712         synchronized (mLock) {
713             if (authority.equals(mLocalProvider) || authority.equals(mCloudProvider)) {
714                 return qb.query(mDatabase, getMediaStoreProjectionLocked(authority, mediaId,
715                                 projection),
716                         /* selection */ null, selectionArgs, /* groupBy */ null, /* having */ null,
717                         /* orderBy */ null, /* limitStr */ null);
718             }
719         }
720 
721         return null;
722     }
723 
724     /**
725      * Returns empty {@link Cursor} if there are no items matching merged album constraints {@code
726      * query}
727      */
getMergedAlbums(QueryFilter query)728     public Cursor getMergedAlbums(QueryFilter query) {
729         final MatrixCursor c = new MatrixCursor(AlbumColumns.ALL_PROJECTION);
730         List<String> mergedAlbums = List.of(ALBUM_ID_FAVORITES, ALBUM_ID_VIDEOS);
731         for (String albumId : mergedAlbums) {
732             List<String> selectionArgs = new ArrayList<>();
733             final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder();
734             if (albumId.equals(ALBUM_ID_FAVORITES)) {
735                 qb.appendWhereStandalone(WHERE_IS_FAVORITE);
736             } else if (albumId.equals(ALBUM_ID_VIDEOS)) {
737                 qb.appendWhereStandalone(WHERE_MIME_TYPE);
738                 selectionArgs.add("video/%");
739             }
740             if (query.mMimeType != null) {
741                 qb.appendWhereStandalone(WHERE_MIME_TYPE);
742                 selectionArgs.add(query.mMimeType.replace('*', '%'));
743             }
744 
745             Cursor cursor = qb.query(mDatabase, PROJECTION_ALBUM_DB, /* selection */ null,
746                     selectionArgs.toArray(new String[0]), /* groupBy */ null, /* having */ null,
747                     /* orderBy */ null, /* limit */ null);
748 
749             if (cursor == null || !cursor.moveToFirst()) {
750                 continue;
751             }
752 
753             long count = getCursorLong(cursor, CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT);
754             if (count == 0) {
755                 continue;
756             }
757 
758             final String[] projectionValue = new String[]{
759                     /* albumId */ albumId,
760                     getCursorString(cursor, AlbumColumns.DATE_TAKEN_MILLIS),
761                     /* displayName */ albumId,
762                     getCursorString(cursor, AlbumColumns.MEDIA_COVER_ID),
763                     String.valueOf(count),
764                     mLocalProvider,
765             };
766             c.addRow(projectionValue);
767         }
768         return c;
769     }
770 
isLocal(String authority)771     private boolean isLocal(String authority) {
772         return mLocalProvider.equals(authority);
773     }
774 
queryMediaForUi(SQLiteQueryBuilder qb, String[] selectionArgs, int limit, String tableName, String authority)775     private Cursor queryMediaForUi(SQLiteQueryBuilder qb, String[] selectionArgs,
776             int limit, String tableName, String authority) {
777         // Use the <table>.<column> form to order _id to avoid ordering against the projection '_id'
778         final String orderBy = getOrderClause(tableName);
779         final String limitStr = String.valueOf(limit);
780 
781         // Hold lock while checking the cloud provider and querying so that cursor extras containing
782         // the cloud provider is consistent with the cursor results and doesn't race with
783         // #setCloudProvider
784         synchronized (mLock) {
785             if (mCloudProvider == null || !Objects.equals(mCloudProvider, authority)) {
786                 // If cloud provider is null or has changed from what we received from the UI,
787                 // skip all cloud items in the picker db
788                 qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID);
789             }
790 
791             return qb.query(mDatabase, getCloudMediaProjectionLocked(), /* selection */ null,
792                     selectionArgs, /* groupBy */ null, /* having */ null, orderBy, limitStr);
793         }
794     }
795 
getOrderClause(String tableName)796     private static String getOrderClause(String tableName) {
797         return "date_taken_ms DESC," + tableName + "._id DESC";
798     }
799 
getCloudMediaProjectionLocked()800     private String[] getCloudMediaProjectionLocked() {
801         return new String[] {
802             getProjectionAuthorityLocked(),
803             getProjectionDataLocked(MediaColumns.DATA),
804             getProjectionId(MediaColumns.ID),
805             getProjectionSimple(KEY_DATE_TAKEN_MS, MediaColumns.DATE_TAKEN_MILLIS),
806             getProjectionSimple(KEY_SYNC_GENERATION, MediaColumns.SYNC_GENERATION),
807             getProjectionSimple(KEY_SIZE_BYTES, MediaColumns.SIZE_BYTES),
808             getProjectionSimple(KEY_DURATION_MS, MediaColumns.DURATION_MILLIS),
809             getProjectionSimple(KEY_MIME_TYPE, MediaColumns.MIME_TYPE),
810             getProjectionSimple(KEY_STANDARD_MIME_TYPE_EXTENSION,
811                     MediaColumns.STANDARD_MIME_TYPE_EXTENSION),
812         };
813     }
814 
getMediaStoreProjectionLocked(String authority, String mediaId, String[] columns)815     private String[] getMediaStoreProjectionLocked(String authority, String mediaId,
816             String[] columns) {
817         final String[] projection = new String[columns.length];
818 
819         for (int i = 0; i < projection.length; i++) {
820             switch (columns[i]) {
821                 case PickerMediaColumns.DATA:
822                     projection[i] = getProjectionDataLocked(PickerMediaColumns.DATA);
823                     break;
824                 case PickerMediaColumns.DISPLAY_NAME:
825                     projection[i] = getProjectionSimple(getDisplayNameSql(),
826                             PickerMediaColumns.DISPLAY_NAME);
827                     break;
828                 case PickerMediaColumns.MIME_TYPE:
829                     projection[i] = getProjectionSimple(KEY_MIME_TYPE,
830                             PickerMediaColumns.MIME_TYPE);
831                     break;
832                 case PickerMediaColumns.DATE_TAKEN:
833                     projection[i] = getProjectionSimple(KEY_DATE_TAKEN_MS,
834                             PickerMediaColumns.DATE_TAKEN);
835                     break;
836                 case PickerMediaColumns.SIZE:
837                     projection[i] = getProjectionSimple(KEY_SIZE_BYTES, PickerMediaColumns.SIZE);
838                     break;
839                 case PickerMediaColumns.DURATION_MILLIS:
840                     projection[i] = getProjectionSimple(KEY_DURATION_MS,
841                             PickerMediaColumns.DURATION_MILLIS);
842                     break;
843                 default:
844                     Uri uri = getMediaUri(authority).buildUpon().appendPath(mediaId).build();
845                     throw new IllegalArgumentException("Unexpected picker URI projection. Uri:"
846                             + uri + ". Column:" + columns[i]);
847             }
848         }
849 
850         return projection;
851     }
852 
getProjectionAuthorityLocked()853     private String getProjectionAuthorityLocked() {
854         // Note that we prefer cloud_id over local_id here. It's important to remember that this
855         // logic is for computing the projection and doesn't affect the filtering of results which
856         // has already been done and ensures that only is_visible=true items are returned.
857         // Here, we need to distinguish between cloud+local and local-only items to determine the
858         // correct authority. Checking whether cloud_id IS NULL distinguishes the former from the
859         // latter.
860         return String.format("CASE WHEN %s IS NULL THEN '%s' ELSE '%s' END AS %s",
861                 KEY_CLOUD_ID, mLocalProvider, mCloudProvider, MediaColumns.AUTHORITY);
862     }
863 
getProjectionDataLocked(String asColumn)864     private String getProjectionDataLocked(String asColumn) {
865         // _data format:
866         // /sdcard/.transforms/synthetic/picker/<user-id>/<authority>/media/<display-name>
867         // See PickerUriResolver#getMediaUri
868         final String authority = String.format("CASE WHEN %s IS NULL THEN '%s' ELSE '%s' END",
869                 KEY_CLOUD_ID, mLocalProvider, mCloudProvider);
870         final String fullPath = "'" + PICKER_PATH + "/'"
871                 + "||" + "'" + MediaStore.MY_USER_ID + "/'"
872                 + "||" + authority
873                 + "||" + "'/" + CloudMediaProviderContract.URI_PATH_MEDIA + "/'"
874                 + "||" + getDisplayNameSql();
875         return String.format("%s AS %s", fullPath, asColumn);
876     }
877 
getProjectionId(String asColumn)878     private String getProjectionId(String asColumn) {
879         // We prefer cloud_id first and it only matters for cloud+local items. For those, the row
880         // will already be associated with a cloud authority, see #getProjectionAuthorityLocked.
881         // Note that hidden cloud+local items will not be returned in the query, so there's no
882         // concern of preferring the cloud_id in a cloud+local item over the local_id in a
883         // local-only item.
884         return String.format("IFNULL(%s, %s) AS %s", KEY_CLOUD_ID, KEY_LOCAL_ID, asColumn);
885     }
886 
getProjectionSimple(String dbColumn, String column)887     private static String getProjectionSimple(String dbColumn, String column) {
888         return String.format("%s AS %s", dbColumn, column);
889     }
890 
getDisplayNameSql()891     private String getDisplayNameSql() {
892         // _display_name format:
893         // <media-id>.<file-extension>
894         // See comment in #getProjectionAuthorityLocked for why cloud_id is preferred over local_id
895         final String mediaId = String.format("IFNULL(%s, %s)", KEY_CLOUD_ID, KEY_LOCAL_ID);
896         // TODO(b/195009139): Add .gif fileextension support
897         final String fileExtension =
898                 String.format("CASE WHEN %s LIKE 'image/%%' THEN '%s' ELSE '%s' END",
899                         KEY_MIME_TYPE, IMAGE_FILE_EXTENSION, VIDEO_FILE_EXTENSION);
900 
901         return mediaId + "||" + fileExtension;
902     }
903 
cursorToContentValue(Cursor cursor, boolean isLocal)904     private static ContentValues cursorToContentValue(Cursor cursor, boolean isLocal) {
905         return cursorToContentValue(cursor, isLocal, "");
906     }
907 
cursorToContentValue(Cursor cursor, boolean isLocal, String albumId)908     private static ContentValues cursorToContentValue(Cursor cursor, boolean isLocal,
909             String albumId) {
910         final ContentValues values = new ContentValues();
911         if(TextUtils.isEmpty(albumId)) {
912             values.put(KEY_IS_VISIBLE, 1);
913         }
914         else {
915             values.put(KEY_ALBUM_ID, albumId);
916         }
917 
918         final int count = cursor.getColumnCount();
919         for (int index = 0; index < count; index++) {
920             String key = cursor.getColumnName(index);
921             switch (key) {
922                 case CloudMediaProviderContract.MediaColumns.ID:
923                     if (isLocal) {
924                         values.put(KEY_LOCAL_ID, cursor.getString(index));
925                     } else {
926                         values.put(KEY_CLOUD_ID, cursor.getString(index));
927                     }
928                     break;
929                 case CloudMediaProviderContract.MediaColumns.MEDIA_STORE_URI:
930                     String uriString = cursor.getString(index);
931                     if (uriString != null) {
932                         Uri uri = Uri.parse(uriString);
933                         values.put(KEY_LOCAL_ID, ContentUris.parseId(uri));
934                     }
935                     break;
936                 case CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS:
937                     values.put(KEY_DATE_TAKEN_MS, cursor.getLong(index));
938                     break;
939                 case CloudMediaProviderContract.MediaColumns.SYNC_GENERATION:
940                     values.put(KEY_SYNC_GENERATION, cursor.getLong(index));
941                     break;
942                 case CloudMediaProviderContract.MediaColumns.SIZE_BYTES:
943                     values.put(KEY_SIZE_BYTES, cursor.getLong(index));
944                     break;
945                 case CloudMediaProviderContract.MediaColumns.MIME_TYPE:
946                     values.put(KEY_MIME_TYPE, cursor.getString(index));
947                     break;
948                 case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION:
949                     int standardMimeTypeExtension = cursor.getInt(index);
950                     if (isValidStandardMimeTypeExtension(standardMimeTypeExtension)) {
951                         values.put(KEY_STANDARD_MIME_TYPE_EXTENSION, standardMimeTypeExtension);
952                     } else {
953                         throw new IllegalArgumentException("Invalid standard mime type extension");
954                     }
955                     break;
956                 case CloudMediaProviderContract.MediaColumns.DURATION_MILLIS:
957                     values.put(KEY_DURATION_MS, cursor.getLong(index));
958                     break;
959                 case CloudMediaProviderContract.MediaColumns.IS_FAVORITE:
960                     if(TextUtils.isEmpty(albumId)) {
961                         values.put(KEY_IS_FAVORITE, cursor.getInt(index));
962                     }
963                     break;
964                 default:
965                     Log.w(TAG, "Unexpected cursor key: " + key);
966             }
967         }
968 
969         return values;
970     }
971 
isValidStandardMimeTypeExtension(int standardMimeTypeExtension)972     private static boolean isValidStandardMimeTypeExtension(int standardMimeTypeExtension) {
973         switch (standardMimeTypeExtension) {
974             case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_NONE:
975             case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_GIF:
976             case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_MOTION_PHOTO:
977             case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_ANIMATED_WEBP:
978                 return true;
979             default:
980                 return false;
981         }
982     }
983 
buildSelectionArgs(SQLiteQueryBuilder qb, QueryFilter query)984     private static String[] buildSelectionArgs(SQLiteQueryBuilder qb, QueryFilter query) {
985         List<String> selectArgs = new ArrayList<>();
986 
987         if (query.mId >= 0) {
988             if (query.mDateTakenAfterMs >= 0) {
989                 qb.appendWhereStandalone(WHERE_DATE_TAKEN_MS_AFTER);
990                 // Add date args twice because the sql statement evaluates date twice
991                 selectArgs.add(String.valueOf(query.mDateTakenAfterMs));
992                 selectArgs.add(String.valueOf(query.mDateTakenAfterMs));
993             } else {
994                 qb.appendWhereStandalone(WHERE_DATE_TAKEN_MS_BEFORE);
995                 // Add date args twice because the sql statement evaluates date twice
996                 selectArgs.add(String.valueOf(query.mDateTakenBeforeMs));
997                 selectArgs.add(String.valueOf(query.mDateTakenBeforeMs));
998             }
999             selectArgs.add(String.valueOf(query.mId));
1000         }
1001 
1002         if (query.mSizeBytes >= 0) {
1003             qb.appendWhereStandalone(WHERE_SIZE_BYTES);
1004             selectArgs.add(String.valueOf(query.mSizeBytes));
1005         }
1006 
1007         if (query.mMimeType != null) {
1008             qb.appendWhereStandalone(WHERE_MIME_TYPE);
1009             selectArgs.add(replaceMatchAnyChar(query.mMimeType));
1010         }
1011         if (query.mIsVideo) {
1012             qb.appendWhereStandalone(WHERE_MIME_TYPE);
1013             selectArgs.add(VIDEO_MIME_TYPES);
1014         } else if (query.mIsFavorite) {
1015             qb.appendWhereStandalone(WHERE_IS_FAVORITE);
1016         } else if (!TextUtils.isEmpty(query.mAlbumId)) {
1017             qb.appendWhereStandalone(WHERE_ALBUM_ID);
1018             selectArgs.add(query.mAlbumId);
1019         }
1020 
1021         if (selectArgs.isEmpty()) {
1022             return null;
1023         }
1024 
1025         return selectArgs.toArray(new String[selectArgs.size()]);
1026     }
1027 
createMediaQueryBuilder()1028     private static SQLiteQueryBuilder createMediaQueryBuilder() {
1029         SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
1030         qb.setTables(TABLE_MEDIA);
1031 
1032         return qb;
1033     }
1034 
createAlbumMediaQueryBuilder(boolean isLocal)1035     private static SQLiteQueryBuilder createAlbumMediaQueryBuilder(boolean isLocal) {
1036         SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
1037         qb.setTables(TABLE_ALBUM_MEDIA);
1038 
1039         if (isLocal) {
1040             qb.appendWhereStandalone(WHERE_NOT_NULL_LOCAL_ID);
1041         } else {
1042             qb.appendWhereStandalone(WHERE_NOT_NULL_CLOUD_ID);
1043         }
1044 
1045         return qb;
1046     }
1047 
createLocalOnlyMediaQueryBuilder()1048     private static SQLiteQueryBuilder createLocalOnlyMediaQueryBuilder() {
1049         SQLiteQueryBuilder qb = createLocalMediaQueryBuilder();
1050         qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID);
1051 
1052         return qb;
1053     }
1054 
createLocalMediaQueryBuilder()1055     private static SQLiteQueryBuilder createLocalMediaQueryBuilder() {
1056         SQLiteQueryBuilder qb = createMediaQueryBuilder();
1057         qb.appendWhereStandalone(WHERE_LOCAL_ID);
1058 
1059         return qb;
1060     }
1061 
createCloudMediaQueryBuilder()1062     private static SQLiteQueryBuilder createCloudMediaQueryBuilder() {
1063         SQLiteQueryBuilder qb = createMediaQueryBuilder();
1064         qb.appendWhereStandalone(WHERE_CLOUD_ID);
1065 
1066         return qb;
1067     }
1068 
createIdMediaQueryBuilder()1069     private static SQLiteQueryBuilder createIdMediaQueryBuilder() {
1070         SQLiteQueryBuilder qb = createMediaQueryBuilder();
1071         qb.appendWhereStandalone(WHERE_ID);
1072 
1073         return qb;
1074     }
1075 
createVisibleMediaQueryBuilder()1076     private static SQLiteQueryBuilder createVisibleMediaQueryBuilder() {
1077         SQLiteQueryBuilder qb = createMediaQueryBuilder();
1078         qb.appendWhereStandalone(WHERE_IS_VISIBLE);
1079 
1080         return qb;
1081     }
1082 
createVisibleLocalMediaQueryBuilder()1083     private static SQLiteQueryBuilder createVisibleLocalMediaQueryBuilder() {
1084         SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder();
1085         qb.appendWhereStandalone(WHERE_LOCAL_ID);
1086 
1087         return qb;
1088     }
1089 
1090     private static final class ResetAlbumOperation extends DbWriteOperation {
1091         /**
1092          * Resets the given cloud or local album_media identified by {@code isLocal} and
1093          * {@code albumId}. If {@code albumId} is null, resets all the respective cloud or
1094          * local albums.
1095          */
ResetAlbumOperation(SQLiteDatabase database, boolean isLocal, String albumId)1096         private ResetAlbumOperation(SQLiteDatabase database, boolean isLocal, String albumId) {
1097             super(database, isLocal, albumId);
1098         }
1099 
1100         @Override
executeInternal(@ullable Cursor unused)1101         int executeInternal(@Nullable Cursor unused) {
1102             final String albumId = albumId();
1103             final boolean isLocal = isLocal();
1104 
1105             final SQLiteQueryBuilder qb = createAlbumMediaQueryBuilder(isLocal);
1106 
1107             String[] selectionArgs = null;
1108             if(!TextUtils.isEmpty(albumId)) {
1109                 qb.appendWhereStandalone(WHERE_ALBUM_ID);
1110                 selectionArgs = new String[]{albumId};
1111             }
1112 
1113             return qb.delete(getDatabase(), /* selection */ null, /* selectionArgs */
1114                     selectionArgs);
1115         }
1116     }
1117 
1118     private static final class AddAlbumMediaOperation extends DbWriteOperation {
AddAlbumMediaOperation(SQLiteDatabase database, boolean isLocal, String albumId)1119         private AddAlbumMediaOperation(SQLiteDatabase database, boolean isLocal, String albumId) {
1120             super(database, isLocal, albumId);
1121             if(TextUtils.isEmpty(albumId)) {
1122                 throw new IllegalArgumentException("Missing albumId.");
1123             }
1124         }
1125 
1126         @Override
executeInternal(@ullable Cursor cursor)1127         int executeInternal(@Nullable Cursor cursor) {
1128             final boolean isLocal = isLocal();
1129             final String albumId = albumId();
1130             final SQLiteQueryBuilder qb = createAlbumMediaQueryBuilder(isLocal);
1131             int counter = 0;
1132 
1133             while (cursor.moveToNext()) {
1134                 ContentValues values = cursorToContentValue(cursor, isLocal, albumId);
1135                 try {
1136                     if (qb.insert(getDatabase(), values) > 0) {
1137                         counter++;
1138                     } else {
1139                         Log.d(TAG, "Failed to insert album_media. ContentValues: " + values);
1140                     }
1141                 } catch (SQLiteConstraintException e) {
1142                     Log.d(TAG, "Failed to insert album_media. ContentValues: " + values, e);
1143                 }
1144             }
1145 
1146             return counter;
1147         }
1148     }
1149 }
1150