• 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.content.ContentResolver.EXTRA_HONORED_ARGS;
20 import static android.provider.CloudMediaProviderContract.AlbumColumns;
21 import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_CAMERA;
22 import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_DOWNLOADS;
23 import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_SCREENSHOTS;
24 import static android.provider.CloudMediaProviderContract.EXTRA_ALBUM_ID;
25 import static android.provider.CloudMediaProviderContract.EXTRA_MEDIA_COLLECTION_ID;
26 import static android.provider.CloudMediaProviderContract.EXTRA_PAGE_SIZE;
27 import static android.provider.CloudMediaProviderContract.EXTRA_PAGE_TOKEN;
28 import static android.provider.CloudMediaProviderContract.EXTRA_SYNC_GENERATION;
29 import static android.provider.CloudMediaProviderContract.MediaCollectionInfo;
30 
31 import static com.android.providers.media.photopicker.data.PickerDbFacade.QueryFilterBuilder.INT_DEFAULT;
32 import static com.android.providers.media.photopicker.data.PickerDbFacade.QueryFilterBuilder.LONG_DEFAULT;
33 import static com.android.providers.media.photopicker.data.PickerDbFacade.addMimeTypesToQueryBuilderAndSelectionArgs;
34 import static com.android.providers.media.photopicker.util.CursorUtils.getCursorLong;
35 import static com.android.providers.media.photopicker.util.CursorUtils.getCursorString;
36 import static com.android.providers.media.util.DatabaseUtils.bindList;
37 
38 import android.content.ContentValues;
39 import android.content.Context;
40 import android.database.Cursor;
41 import android.database.MatrixCursor;
42 import android.database.sqlite.SQLiteConstraintException;
43 import android.database.sqlite.SQLiteDatabase;
44 import android.database.sqlite.SQLiteQueryBuilder;
45 import android.os.Bundle;
46 import android.os.Environment;
47 import android.provider.CloudMediaProviderContract;
48 import android.provider.MediaStore;
49 import android.provider.MediaStore.Files.FileColumns;
50 import android.provider.MediaStore.MediaColumns;
51 import android.text.TextUtils;
52 import android.util.Log;
53 
54 import androidx.annotation.VisibleForTesting;
55 
56 import com.android.providers.media.DatabaseHelper;
57 import com.android.providers.media.VolumeCache;
58 import com.android.providers.media.photopicker.PickerSyncController;
59 import com.android.providers.media.util.MimeUtils;
60 
61 import java.util.ArrayList;
62 import java.util.Arrays;
63 import java.util.List;
64 
65 /**
66  * This is a facade that hides the complexities of executing some SQL statements on the external db.
67  * It does not do any caller permission checks and is only intended for internal use within the
68  * MediaProvider for the Photo Picker.
69  */
70 public class ExternalDbFacade {
71     private static final String TAG = "ExternalDbFacade";
72     @VisibleForTesting
73     static final String TABLE_FILES = "files";
74 
75     @VisibleForTesting
76     static final String TABLE_DELETED_MEDIA = "deleted_media";
77     @VisibleForTesting
78     static final String COLUMN_OLD_ID = "old_id";
79     private static final String COLUMN_OLD_ID_AS_ID = COLUMN_OLD_ID + " AS " +
80             CloudMediaProviderContract.MediaColumns.ID;
81     private static final String COLUMN_GENERATION_MODIFIED = MediaColumns.GENERATION_MODIFIED;
82 
83     private static final String[] PROJECTION_MEDIA_COLUMNS = new String[] {
84         MediaColumns._ID + " AS " + CloudMediaProviderContract.MediaColumns.ID,
85         "COALESCE(" + MediaColumns.DATE_TAKEN + "," + MediaColumns.DATE_MODIFIED +
86                     "* 1000) AS " + CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS,
87         MediaColumns.GENERATION_MODIFIED + " AS " +
88                 CloudMediaProviderContract.MediaColumns.SYNC_GENERATION,
89         MediaColumns.SIZE + " AS " + CloudMediaProviderContract.MediaColumns.SIZE_BYTES,
90         MediaColumns.MIME_TYPE + " AS " + CloudMediaProviderContract.MediaColumns.MIME_TYPE,
91         FileColumns._SPECIAL_FORMAT + " AS " +
92                 CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION,
93         MediaColumns.DURATION + " AS " + CloudMediaProviderContract.MediaColumns.DURATION_MILLIS,
94         MediaColumns.IS_FAVORITE + " AS " + CloudMediaProviderContract.MediaColumns.IS_FAVORITE,
95         MediaColumns.WIDTH + " AS " + CloudMediaProviderContract.MediaColumns.WIDTH,
96         MediaColumns.HEIGHT + " AS " + CloudMediaProviderContract.MediaColumns.HEIGHT,
97         MediaColumns.ORIENTATION + " AS " + CloudMediaProviderContract.MediaColumns.ORIENTATION,
98         MediaColumns.OWNER_PACKAGE_NAME + " AS "
99                 + CloudMediaProviderContract.MediaColumns.OWNER_PACKAGE_NAME,
100         FileColumns._USER_ID + " AS " + CloudMediaProviderContract.MediaColumns.USER_ID,
101     };
102     private static final String[] PROJECTION_MEDIA_INFO = new String[] {
103         "MAX(" + MediaColumns.GENERATION_MODIFIED + ") AS "
104         + MediaCollectionInfo.LAST_MEDIA_SYNC_GENERATION
105     };
106     private static final String[] PROJECTION_ALBUM_DB = new String[] {
107         "COUNT(" + MediaColumns._ID + ") AS " + CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT,
108         "MAX(COALESCE(" + MediaColumns.DATE_TAKEN + "," + MediaColumns.DATE_MODIFIED +
109                     "* 1000)) AS " + CloudMediaProviderContract.AlbumColumns.DATE_TAKEN_MILLIS,
110         MediaColumns._ID + " AS " + CloudMediaProviderContract.AlbumColumns.MEDIA_COVER_ID,
111     };
112 
113     private static final String WHERE_IMAGE_TYPE = FileColumns.MEDIA_TYPE + " = "
114             + FileColumns.MEDIA_TYPE_IMAGE;
115     private static final String WHERE_VIDEO_TYPE = FileColumns.MEDIA_TYPE + " = "
116             + FileColumns.MEDIA_TYPE_VIDEO;
117     private static final String WHERE_MEDIA_TYPE = WHERE_IMAGE_TYPE + " OR " + WHERE_VIDEO_TYPE;
118     private static final String WHERE_IS_DOWNLOAD = MediaColumns.IS_DOWNLOAD + " = 1";
119     private static final String WHERE_NOT_TRASHED = MediaColumns.IS_TRASHED + " = 0";
120     private static final String WHERE_NOT_PENDING = MediaColumns.IS_PENDING + " = 0";
121     private static final String WHERE_GREATER_GENERATION =
122             MediaColumns.GENERATION_MODIFIED + " > ?";
123     private static final String WHERE_RELATIVE_PATH = MediaStore.MediaColumns.RELATIVE_PATH
124             + " LIKE ?";
125 
126     private static final String WHERE_DATE_TAKEN_MILLIS_BEFORE =
127             String.format("(%s < CAST(? AS INT) OR (%s = CAST(? AS INT) AND %s < CAST(? AS INT)))",
128                     CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS,
129                     CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS,
130                     MediaColumns._ID);
131 
132 
133     /* Include any directory named exactly {@link Environment.DIRECTORY_SCREENSHOTS}
134      * and its child directories. */
135     private static final String WHERE_RELATIVE_PATH_IS_SCREENSHOT_DIR =
136             MediaStore.MediaColumns.RELATIVE_PATH
137                     + " LIKE '%/"
138                     + Environment.DIRECTORY_SCREENSHOTS
139                     + "/%' OR "
140                     + MediaStore.MediaColumns.RELATIVE_PATH
141                     + " LIKE '"
142                     + Environment.DIRECTORY_SCREENSHOTS
143                     + "/%'";
144 
145     private static final String WHERE_VOLUME_IN_PREFIX =
146             MediaStore.MediaColumns.VOLUME_NAME + " IN %s";
147 
148     public static final String RELATIVE_PATH_CAMERA = Environment.DIRECTORY_DCIM + "/Camera/%";
149 
150     @VisibleForTesting
151     static String[] LOCAL_ALBUM_IDS = {
152         ALBUM_ID_CAMERA,
153         ALBUM_ID_SCREENSHOTS,
154         ALBUM_ID_DOWNLOADS
155     };
156 
157     private final Context mContext;
158     private final DatabaseHelper mDatabaseHelper;
159     private final VolumeCache mVolumeCache;
160 
ExternalDbFacade(Context context, DatabaseHelper databaseHelper, VolumeCache volumeCache)161     public ExternalDbFacade(Context context, DatabaseHelper databaseHelper,
162             VolumeCache volumeCache) {
163         mContext = context;
164         mDatabaseHelper = databaseHelper;
165         mVolumeCache = volumeCache;
166     }
167 
168     /**
169      * Returns {@code true} if the PhotoPicker should be notified of this change, {@code false}
170      * otherwise
171      */
onFileInserted(int mediaType, boolean isPending)172     public boolean onFileInserted(int mediaType, boolean isPending) {
173         if (!mDatabaseHelper.isExternal()) {
174             return false;
175         }
176 
177         return !isPending && MimeUtils.isImageOrVideoMediaType(mediaType);
178     }
179 
180     /**
181      * Adds or removes media to the deleted_media tables
182      *
183      * Returns {@code true} if the PhotoPicker should be notified of this change, {@code false}
184      * otherwise
185      */
onFileUpdated(long oldId, int oldMediaType, int newMediaType, boolean oldIsTrashed, boolean newIsTrashed, boolean oldIsPending, boolean newIsPending, boolean oldIsFavorite, boolean newIsFavorite, int oldSpecialFormat, int newSpecialFormat)186     public boolean onFileUpdated(long oldId, int oldMediaType, int newMediaType,
187             boolean oldIsTrashed, boolean newIsTrashed, boolean oldIsPending,
188             boolean newIsPending, boolean oldIsFavorite, boolean newIsFavorite,
189             int oldSpecialFormat, int newSpecialFormat) {
190         if (!mDatabaseHelper.isExternal()) {
191             return false;
192         }
193 
194         final boolean oldIsMedia= MimeUtils.isImageOrVideoMediaType(oldMediaType);
195         final boolean newIsMedia = MimeUtils.isImageOrVideoMediaType(newMediaType);
196 
197         final boolean oldIsVisible = !oldIsTrashed && !oldIsPending;
198         final boolean newIsVisible = !newIsTrashed && !newIsPending;
199 
200         final boolean oldIsVisibleMedia = oldIsVisible && oldIsMedia;
201         final boolean newIsVisibleMedia = newIsVisible && newIsMedia;
202 
203         if (!oldIsVisibleMedia && newIsVisibleMedia) {
204             // Was not visible media and is now visible media
205             removeDeletedMedia(oldId);
206             return true;
207         } else if (oldIsVisibleMedia && !newIsVisibleMedia) {
208             // Was visible media and is now not visible media
209             addDeletedMedia(oldId);
210             return true;
211         }
212 
213         if (newIsVisibleMedia) {
214             return (oldIsFavorite != newIsFavorite) || (oldSpecialFormat != newSpecialFormat);
215         }
216 
217 
218         // Do nothing, not an interesting change
219         return false;
220     }
221 
222     /**
223      * Adds or removes media to the deleted_media tables
224      *
225      * Returns {@code true} if the PhotoPicker should be notified of this change, {@code false}
226      * otherwise
227      */
onFileDeleted(long id, int mediaType)228     public boolean onFileDeleted(long id, int mediaType) {
229         if (!mDatabaseHelper.isExternal()) {
230             return false;
231         }
232         if (!MimeUtils.isImageOrVideoMediaType(mediaType)) {
233             return false;
234         }
235 
236         addDeletedMedia(id);
237         return true;
238     }
239 
240     /**
241      * Adds media with row id {@code oldId} to the deleted_media table. Returns {@code true} if
242      * if it was successfully added, {@code false} otherwise.
243      */
244     @VisibleForTesting
addDeletedMedia(long oldId)245     boolean addDeletedMedia(long oldId) {
246         return mDatabaseHelper.runWithTransaction((db) -> {
247             SQLiteQueryBuilder qb = createDeletedMediaQueryBuilder();
248 
249             ContentValues cv = new ContentValues();
250             cv.put(COLUMN_OLD_ID, oldId);
251             cv.put(COLUMN_GENERATION_MODIFIED, DatabaseHelper.getGeneration(db));
252 
253             try {
254                 return qb.insert(db, cv) > 0;
255             } catch (SQLiteConstraintException e) {
256                 String select = COLUMN_OLD_ID + " = ?";
257                 String[] selectionArgs = new String[] {String.valueOf(oldId)};
258 
259                 return qb.update(db, cv, select, selectionArgs) > 0;
260             }
261          });
262     }
263 
264     /**
265      * Removes media with row id {@code oldId} from the deleted_media table. Returns {@code true} if
266      * it was successfully removed, {@code false} otherwise.
267      */
268     @VisibleForTesting
removeDeletedMedia(long oldId)269     boolean removeDeletedMedia(long oldId) {
270         return mDatabaseHelper.runWithTransaction(db -> {
271             SQLiteQueryBuilder qb = createDeletedMediaQueryBuilder();
272 
273             return qb.delete(db, COLUMN_OLD_ID + " = ?", new String[] {String.valueOf(oldId)}) > 0;
274          });
275     }
276 
277     /**
278      * Returns all items from the deleted_media table.
279      */
280     public Cursor queryDeletedMedia(long generation) {
281         final Cursor cursor = mDatabaseHelper.runWithTransaction(db -> {
282             SQLiteQueryBuilder qb = createDeletedMediaQueryBuilder();
283             String[] projection = new String[] {COLUMN_OLD_ID_AS_ID};
284             String select = COLUMN_GENERATION_MODIFIED + " > ?";
285             String[] selectionArgs = new String[] {String.valueOf(generation)};
286 
287             return qb.query(db, projection, select, selectionArgs,  /* groupBy */ null,
288                     /* having */ null, /* orderBy */ null);
289          });
290 
291         cursor.setExtras(getCursorExtras(generation, /* albumId */ null, /*pageSize*/ -1,
292                 /*pageToken*/ null));
293         return cursor;
294     }
295 
296     /**
297      * Returns all items from the files table where {@link MediaColumns#GENERATION_MODIFIED}
298      * is greater than {@code generation}.
299      */
300     public Cursor queryMedia(long generation, String albumId, String[] mimeTypes, int pageSize,
301             String pageToken) {
302         final List<String> selectionArgs = new ArrayList<>();
303         final String orderBy = getOrderByClause();
304 
305         Log.d(TAG, "Token received for queryMedia = " + pageToken);
306 
307         final Cursor cursor = mDatabaseHelper.runWithTransaction(db -> {
308             SQLiteQueryBuilder qb = createMediaQueryBuilder();
309             qb.appendWhereStandalone(WHERE_GREATER_GENERATION);
310             selectionArgs.add(String.valueOf(generation));
311 
312             if (pageToken != null) {
313                 String[] lastMedia = parsePageToken(pageToken);
314                 if (lastMedia != null) {
315                     qb.appendWhereStandalone(getDateTakenWhereClause());
316                     addSelectionArgsForWhereClause(lastMedia, selectionArgs);
317                 }
318             }
319 
320             selectionArgs.addAll(appendWhere(qb, albumId, mimeTypes));
321 
322             return qb.query(db, PROJECTION_MEDIA_COLUMNS, /* select */ null,
323                     selectionArgs.toArray(new String[selectionArgs.size()]), /* groupBy */ null,
324                     /* having */ null, orderBy, String.valueOf(pageSize));
325         });
326 
327         String nextPageToken = null;
328         if (cursor.getCount() > 0 && pageSize != INT_DEFAULT) {
329             nextPageToken = setPageToken(cursor);
330 
331         }
332         cursor.setExtras(getCursorExtras(generation, albumId, pageSize, nextPageToken));
333         return cursor;
334     }
335 
336     private static void addSelectionArgsForWhereClause(String[] lastMedia,
337             List<String> selectionArgs) {
338         selectionArgs.add(lastMedia[0]);
339         selectionArgs.add(lastMedia[0]);
340         selectionArgs.add(lastMedia[1]);
341     }
342 
343     private static String[] parsePageToken(String pageToken) {
344         String[] lastMedia = pageToken.split("\\|");
345 
346         if (lastMedia.length != 2) {
347             Log.w(TAG, "Error parsing token in queryMedia.");
348             return null;
349         }
350         return lastMedia;
351     }
352 
353     private static String getDateTakenWhereClause() {
354         return CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS + " IS NOT NULL AND "
355                 + WHERE_DATE_TAKEN_MILLIS_BEFORE;
356     }
357 
358     private static String getOrderByClause() {
359         return CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS + " DESC,"
360                 + CloudMediaProviderContract.MediaColumns.ID + " DESC";
361     }
362 
363 
364     private String setPageToken(Cursor mediaList) {
365         String token = null;
366         if (mediaList.moveToLast()) {
367             String timeTakenMillis = getCursorString(mediaList,
368                     CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS);
369             String lastItemRowId = getCursorString(mediaList,
370                     CloudMediaProviderContract.MediaColumns.ID);
371             token = timeTakenMillis + "|" + lastItemRowId;
372             mediaList.moveToFirst();
373         }
374         return token;
375     }
376 
377     private Bundle getCursorExtras(long generation, String albumId, int pageSize,
378             String pageToken) {
379         final Bundle bundle = new Bundle();
380         final ArrayList<String> honoredArgs = new ArrayList<>();
381 
382         if (generation > LONG_DEFAULT) {
383             honoredArgs.add(EXTRA_SYNC_GENERATION);
384         }
385         if (!TextUtils.isEmpty(albumId)) {
386             honoredArgs.add(EXTRA_ALBUM_ID);
387         }
388 
389         if (pageSize > INT_DEFAULT) {
390             honoredArgs.add(EXTRA_PAGE_SIZE);
391         }
392 
393         if (pageToken != null) {
394             honoredArgs.add(EXTRA_PAGE_TOKEN);
395         }
396 
397         bundle.putString(EXTRA_MEDIA_COLLECTION_ID, getMediaCollectionId());
398         if (pageToken != null) {
399             bundle.putString(EXTRA_PAGE_TOKEN, pageToken);
400         }
401         bundle.putStringArrayList(EXTRA_HONORED_ARGS, honoredArgs);
402 
403         return bundle;
404     }
405 
406     /**
407      * Returns the total count and max {@link MediaColumns#GENERATION_MODIFIED} value
408      * of the media items in the files table greater than {@code generation}.
409      */
410     private Cursor getMediaCollectionInfoCursor(long generation) {
411         final String[] selectionArgs = new String[] {String.valueOf(generation)};
412         final String[] projection = new String[] {
413             MediaCollectionInfo.LAST_MEDIA_SYNC_GENERATION
414         };
415 
416         return mDatabaseHelper.runWithTransaction(db -> {
417                 SQLiteQueryBuilder qbMedia = createMediaQueryBuilder();
418                 qbMedia.appendWhereStandalone(WHERE_GREATER_GENERATION);
419                 SQLiteQueryBuilder qbDeletedMedia = createDeletedMediaQueryBuilder();
420                 qbDeletedMedia.appendWhereStandalone(WHERE_GREATER_GENERATION);
421 
422                 try (Cursor mediaCursor = query(qbMedia, db, PROJECTION_MEDIA_INFO, selectionArgs);
423                         Cursor deletedMediaCursor = query(qbDeletedMedia, db,
424                                 PROJECTION_MEDIA_INFO, selectionArgs)) {
425                     final int mediaGenerationIndex = mediaCursor.getColumnIndexOrThrow(
426                             MediaCollectionInfo.LAST_MEDIA_SYNC_GENERATION);
427                     final int deletedMediaGenerationIndex =
428                             deletedMediaCursor.getColumnIndexOrThrow(
429                                     MediaCollectionInfo.LAST_MEDIA_SYNC_GENERATION);
430 
431                     long mediaGeneration = 0;
432                     if (mediaCursor.moveToFirst()) {
433                         mediaGeneration = mediaCursor.getLong(mediaGenerationIndex);
434                     }
435 
436                     long deletedMediaGeneration = 0;
437                     if (deletedMediaCursor.moveToFirst()) {
438                         deletedMediaGeneration = deletedMediaCursor.getLong(
439                                 deletedMediaGenerationIndex);
440                     }
441 
442                     long maxGeneration = Math.max(mediaGeneration, deletedMediaGeneration);
443                     MatrixCursor result = new MatrixCursor(projection);
444                     result.addRow(new Long[] { maxGeneration });
445 
446                     return result;
447                 }
448             });
449     }
450 
451     public Bundle getMediaCollectionInfo(long generation) {
452         final Bundle bundle = new Bundle();
453         try (Cursor cursor = getMediaCollectionInfoCursor(generation)) {
454             if (cursor.moveToFirst()) {
455                 int generationIndex = cursor.getColumnIndexOrThrow(
456                         MediaCollectionInfo.LAST_MEDIA_SYNC_GENERATION);
457 
458                 bundle.putString(MediaCollectionInfo.MEDIA_COLLECTION_ID, getMediaCollectionId());
459                 bundle.putLong(MediaCollectionInfo.LAST_MEDIA_SYNC_GENERATION,
460                         cursor.getLong(generationIndex));
461             }
462         }
463         return bundle;
464     }
465 
466     /**
467      * Returns the media item categories from the files table.
468      * Categories are determined with the {@link #LOCAL_ALBUM_IDS}.
469      * If there are no media items under an albumId, the album is skipped from the results.
470      */
471     public Cursor queryAlbums(String[] mimeTypes) {
472         final MatrixCursor c = new MatrixCursor(AlbumColumns.ALL_PROJECTION);
473 
474         for (String albumId: LOCAL_ALBUM_IDS) {
475             Cursor cursor = mDatabaseHelper.runWithTransaction(db -> {
476                 final SQLiteQueryBuilder qb = createMediaQueryBuilder();
477                 final List<String> selectionArgs = new ArrayList<>();
478                 selectionArgs.addAll(appendWhere(qb, albumId, mimeTypes));
479 
480                 return qb.query(db, PROJECTION_ALBUM_DB, /* selection */ null,
481                         selectionArgs.toArray(new String[selectionArgs.size()]), /* groupBy */ null,
482                         /* having */ null, /* orderBy */ null);
483             });
484 
485             if (cursor == null || !cursor.moveToFirst()) {
486                 continue;
487             }
488 
489             long count = getCursorLong(cursor, CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT);
490             if (count == 0) {
491                 continue;
492             }
493 
494             final String[] projectionValue = new String[] {
495                 /* albumId */ albumId,
496                 getCursorString(cursor, AlbumColumns.DATE_TAKEN_MILLIS),
497                 /* displayName */ albumId,
498                 getCursorString(cursor, AlbumColumns.MEDIA_COVER_ID),
499                 String.valueOf(count),
500                 PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY
501             };
502 
503             c.addRow(projectionValue);
504         }
505 
506         return c;
507     }
508 
509     private static Cursor query(SQLiteQueryBuilder qb, SQLiteDatabase db, String[] projection,
510             String[] selectionArgs) {
511         return qb.query(db, projection, /* select */ null, selectionArgs,
512                 /* groupBy */ null, /* having */ null, /* orderBy */ null);
513     }
514 
515     private static List<String> appendWhere(SQLiteQueryBuilder qb, String albumId,
516             String[] mimeTypes) {
517         final List<String> selectionArgs = new ArrayList<>();
518 
519         addMimeTypesToQueryBuilderAndSelectionArgs(qb, selectionArgs, mimeTypes);
520 
521         if (albumId == null) {
522             return selectionArgs;
523         }
524 
525         switch (albumId) {
526             case ALBUM_ID_CAMERA:
527                 qb.appendWhereStandalone(WHERE_RELATIVE_PATH);
528                 selectionArgs.add(RELATIVE_PATH_CAMERA);
529                 break;
530             case ALBUM_ID_SCREENSHOTS:
531                 qb.appendWhereStandalone(WHERE_RELATIVE_PATH_IS_SCREENSHOT_DIR);
532                 break;
533             case ALBUM_ID_DOWNLOADS:
534                 qb.appendWhereStandalone(WHERE_IS_DOWNLOAD);
535                 break;
536             default:
537                 Log.w(TAG, "No match for album: " + albumId);
538                 break;
539         }
540 
541         return selectionArgs;
542     }
543 
544     private static SQLiteQueryBuilder createDeletedMediaQueryBuilder() {
545         SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
546         qb.setTables(TABLE_DELETED_MEDIA);
547 
548         return qb;
549     }
550 
551     private SQLiteQueryBuilder createMediaQueryBuilder() {
552         SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
553         qb.setTables(TABLE_FILES);
554         qb.appendWhereStandalone(WHERE_MEDIA_TYPE);
555         qb.appendWhereStandalone(WHERE_NOT_TRASHED);
556         qb.appendWhereStandalone(WHERE_NOT_PENDING);
557 
558         // the file is corrupted if both datetaken and takenmodified are null.
559         // hence exclude those files.
560         qb.appendWhereStandalone(getDateTakenOrDateModifiedNonNull());
561 
562         String[] volumes = getVolumeList();
563         if (volumes.length > 0) {
564             qb.appendWhereStandalone(buildWhereVolumeIn(volumes));
565         }
566 
567         return qb;
568     }
569 
570     private CharSequence getDateTakenOrDateModifiedNonNull() {
571         return MediaColumns.DATE_TAKEN + " IS NOT NULL OR "
572                 + MediaColumns.DATE_MODIFIED + " IS NOT NULL";
573     }
574 
575     private String buildWhereVolumeIn(String[] volumes) {
576         return String.format(WHERE_VOLUME_IN_PREFIX, bindList((Object[]) volumes));
577     }
578 
579     private String[] getVolumeList() {
580         String[] volumeNames = mVolumeCache.getExternalVolumeNames().toArray(new String[0]);
581         Arrays.sort(volumeNames);
582 
583         return volumeNames;
584     }
585 
586     private String getMediaCollectionId() {
587         final String[] volumes = getVolumeList();
588         if (volumes.length == 0) {
589             return MediaStore.getVersion(mContext);
590         }
591 
592         return MediaStore.getVersion(mContext) + ":" + TextUtils.join(":", getVolumeList());
593     }
594 }
595