| /* |
| * Copyright (C) 2021 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.providers.media.photopicker.data; |
| |
| import static android.provider.CloudMediaProviderContract.AlbumColumns; |
| import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_FAVORITES; |
| import static android.provider.CloudMediaProviderContract.AlbumColumns.ALBUM_ID_VIDEOS; |
| import static android.provider.CloudMediaProviderContract.MediaColumns; |
| import static android.provider.MediaStore.PickerMediaColumns; |
| |
| import static com.android.providers.media.photopicker.util.CursorUtils.getCursorLong; |
| import static com.android.providers.media.photopicker.util.CursorUtils.getCursorString; |
| import static com.android.providers.media.util.DatabaseUtils.replaceMatchAnyChar; |
| import static com.android.providers.media.util.SyntheticPathUtils.getPickerRelativePath; |
| |
| import android.content.ContentUris; |
| import android.content.ContentValues; |
| import android.content.Context; |
| import android.database.Cursor; |
| import android.database.MatrixCursor; |
| import android.database.sqlite.SQLiteConstraintException; |
| import android.database.sqlite.SQLiteDatabase; |
| import android.database.sqlite.SQLiteQueryBuilder; |
| import android.net.Uri; |
| import android.os.Trace; |
| import android.provider.CloudMediaProviderContract; |
| import android.provider.MediaStore; |
| import android.text.TextUtils; |
| import android.util.Log; |
| |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.VisibleForTesting; |
| |
| import com.android.providers.media.photopicker.PickerSyncController; |
| |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.Objects; |
| |
| /** |
| * This is a facade that hides the complexities of executing some SQL statements on the picker db. |
| * It does not do any caller permission checks and is only intended for internal use within the |
| * MediaProvider for the Photo Picker. |
| */ |
| public class PickerDbFacade { |
| private static final String VIDEO_MIME_TYPES = "video/%"; |
| |
| // TODO(b/278562157): If there is a dependency on |
| // {@link PickerSyncController#mCloudProviderLock}, always acquire |
| // {@link PickerSyncController#mCloudProviderLock} before {@link mLock} to avoid deadlock. |
| @NonNull |
| private final Object mLock = new Object(); |
| private final Context mContext; |
| private final SQLiteDatabase mDatabase; |
| private final String mLocalProvider; |
| // This is the cloud provider the database is synced with. It can be set as null to disable |
| // cloud queries when database is not in sync with the current cloud provider. |
| @Nullable |
| private String mCloudProvider; |
| |
| public PickerDbFacade(Context context) { |
| this(context, PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY); |
| } |
| |
| @VisibleForTesting |
| public PickerDbFacade(Context context, String localProvider) { |
| this(context, localProvider, new PickerDatabaseHelper(context)); |
| } |
| |
| @VisibleForTesting |
| public PickerDbFacade(Context context, String localProvider, PickerDatabaseHelper dbHelper) { |
| mContext = context; |
| mLocalProvider = localProvider; |
| mDatabase = dbHelper.getWritableDatabase(); |
| } |
| |
| private static final String TAG = "PickerDbFacade"; |
| |
| private static final int RETRY = 0; |
| private static final int SUCCESS = 1; |
| private static final int FAIL = -1; |
| |
| private static final String TABLE_MEDIA = "media"; |
| // Intentionally use /sdcard path so that the receiving app resolves it to it's per-user |
| // external storage path, e.g. /storage/emulated/<userid>. That way FUSE cross-user access is |
| // not required for picker paths sent across users |
| private static final String PICKER_PATH = "/sdcard/" + getPickerRelativePath(); |
| private static final String TABLE_ALBUM_MEDIA = "album_media"; |
| |
| @VisibleForTesting |
| public static final String KEY_ID = "_id"; |
| @VisibleForTesting |
| public static final String KEY_LOCAL_ID = "local_id"; |
| @VisibleForTesting |
| public static final String KEY_CLOUD_ID = "cloud_id"; |
| @VisibleForTesting |
| public static final String KEY_IS_VISIBLE = "is_visible"; |
| @VisibleForTesting |
| public static final String KEY_DATE_TAKEN_MS = "date_taken_ms"; |
| @VisibleForTesting |
| public static final String KEY_SYNC_GENERATION = "sync_generation"; |
| @VisibleForTesting |
| public static final String KEY_SIZE_BYTES = "size_bytes"; |
| @VisibleForTesting |
| public static final String KEY_DURATION_MS = "duration_ms"; |
| @VisibleForTesting |
| public static final String KEY_MIME_TYPE = "mime_type"; |
| public static final String KEY_STANDARD_MIME_TYPE_EXTENSION = "standard_mime_type_extension"; |
| @VisibleForTesting |
| public static final String KEY_IS_FAVORITE = "is_favorite"; |
| @VisibleForTesting |
| public static final String KEY_ALBUM_ID = "album_id"; |
| @VisibleForTesting |
| public static final String KEY_HEIGHT = "height"; |
| @VisibleForTesting |
| public static final String KEY_WIDTH = "width"; |
| @VisibleForTesting |
| public static final String KEY_ORIENTATION = "orientation"; |
| |
| private static final String WHERE_ID = KEY_ID + " = ?"; |
| private static final String WHERE_LOCAL_ID = KEY_LOCAL_ID + " = ?"; |
| private static final String WHERE_CLOUD_ID = KEY_CLOUD_ID + " = ?"; |
| private static final String WHERE_NULL_CLOUD_ID = KEY_CLOUD_ID + " IS NULL"; |
| private static final String WHERE_NOT_NULL_CLOUD_ID = KEY_CLOUD_ID + " IS NOT NULL"; |
| private static final String WHERE_NOT_NULL_LOCAL_ID = KEY_LOCAL_ID + " IS NOT NULL"; |
| private static final String WHERE_IS_VISIBLE = KEY_IS_VISIBLE + " = 1"; |
| private static final String WHERE_MIME_TYPE = KEY_MIME_TYPE + " LIKE ? "; |
| private static final String WHERE_SIZE_BYTES = KEY_SIZE_BYTES + " <= ?"; |
| private static final String WHERE_DATE_TAKEN_MS_AFTER = |
| String.format("%s > ? OR (%s = ? AND %s > ?)", |
| KEY_DATE_TAKEN_MS, KEY_DATE_TAKEN_MS, KEY_ID); |
| private static final String WHERE_DATE_TAKEN_MS_BEFORE = |
| String.format("%s < ? OR (%s = ? AND %s < ?)", |
| KEY_DATE_TAKEN_MS, KEY_DATE_TAKEN_MS, KEY_ID); |
| private static final String WHERE_ALBUM_ID = KEY_ALBUM_ID + " = ?"; |
| |
| // This where clause returns all rows for media items that are local-only and are marked as |
| // favorite. |
| // |
| // 'cloud_id' IS NULL AND 'is_favorite' = 1 |
| private static final String WHERE_FAVORITE_LOCAL_ONLY = String.format( |
| "%s IS NULL AND %s = 1", KEY_CLOUD_ID, KEY_IS_FAVORITE); |
| // This where clause returns all rows for media items that are cloud-only and are marked as |
| // favorite. |
| // |
| // 'local_id' IS NULL AND 'is_favorite' = 1 |
| private static final String WHERE_FAVORITE_CLOUD_ONLY = String.format( |
| "%s IS NULL AND %s = 1", KEY_LOCAL_ID, KEY_IS_FAVORITE); |
| // This where clause returns all local rows from media items for which either local row is |
| // marked as favorite or corresponding cloud row is marked as favorite. |
| // E.g., Rows - |
| // Row1 : local_id=1, cloud_id=null, is_favorite=0 |
| // Row2 : local_id=2, cloud_id=null, is_favorite=0 |
| // Row3 : local_id=3, cloud_id=null, is_favorite=1 |
| // Row4 : local_id=4, cloud_id=null, is_favorite=1 |
| // -- |
| // Row5 : local_id=2, cloud_id=c1, is_favorite=1 |
| // Row6 : local_id=3, cloud_id=c2, is_favorite=1 |
| // Row7 : local_id=null, cloud_id=c3, is_favorite=1 |
| // |
| // Returns - |
| // Row2 : local_id=2, cloud_id=null, is_favorite=0 |
| // Row3 : local_id=3, cloud_id=null, is_favorite=1 |
| // Row4 : local_id=4, cloud_id=null, is_favorite=1 |
| // |
| // 'local_id' IN (SELECT 'local_id' |
| // FROM 'media' |
| // WHERE 'local_id' IS NOT NULL |
| // GROUP BY 'local_id' |
| // HAVING SUM('is_favorite') >= 1) |
| private static final String WHERE_FAVORITE_LOCAL_PLUS_CLOUD = String.format( |
| "%s IN (SELECT %s FROM %s WHERE %s IS NOT NULL GROUP BY %s HAVING SUM(%s) >= 1)", |
| KEY_LOCAL_ID, KEY_LOCAL_ID, TABLE_MEDIA, KEY_LOCAL_ID, KEY_LOCAL_ID, KEY_IS_FAVORITE); |
| // This where clause returns all rows for media items that are marked as favorite. |
| // Note that this is different from "WHERE_FAVORITE_LOCAL_ONLY + WHERE_FAVORITE_CLOUD_ONLY" |
| // because for local+cloud row with is_favorite=1 we need to pick corresponding local row. |
| private static final String WHERE_FAVORITE_ALL = String.format( |
| "( %s OR %s )", WHERE_FAVORITE_LOCAL_PLUS_CLOUD, WHERE_FAVORITE_CLOUD_ONLY); |
| |
| // Matches all media including cloud+local, cloud-only and local-only |
| private static final SQLiteQueryBuilder QB_MATCH_ALL = createMediaQueryBuilder(); |
| // Matches media with id |
| private static final SQLiteQueryBuilder QB_MATCH_ID = createIdMediaQueryBuilder(); |
| // Matches media with local_id including cloud+local and local-only |
| private static final SQLiteQueryBuilder QB_MATCH_LOCAL = createLocalMediaQueryBuilder(); |
| // Matches cloud media including cloud+local and cloud-only |
| private static final SQLiteQueryBuilder QB_MATCH_CLOUD = createCloudMediaQueryBuilder(); |
| // Matches all visible media including cloud+local, cloud-only and local-only |
| private static final SQLiteQueryBuilder QB_MATCH_VISIBLE = createVisibleMediaQueryBuilder(); |
| // Matches visible media with local_id including cloud+local and local-only |
| private static final SQLiteQueryBuilder QB_MATCH_VISIBLE_LOCAL = |
| createVisibleLocalMediaQueryBuilder(); |
| // Matches strictly local-only media |
| private static final SQLiteQueryBuilder QB_MATCH_LOCAL_ONLY = |
| createLocalOnlyMediaQueryBuilder(); |
| |
| private static final ContentValues CONTENT_VALUE_VISIBLE = new ContentValues(); |
| private static final ContentValues CONTENT_VALUE_HIDDEN = new ContentValues(); |
| |
| static { |
| CONTENT_VALUE_VISIBLE.put(KEY_IS_VISIBLE, 1); |
| CONTENT_VALUE_HIDDEN.putNull(KEY_IS_VISIBLE); |
| } |
| |
| /** |
| * Sets the cloud provider to be returned after querying the picker db |
| * If null, cloud media will be excluded from all queries. |
| */ |
| public void setCloudProvider(String authority) { |
| synchronized (mLock) { |
| mCloudProvider = authority; |
| } |
| } |
| |
| /** |
| * Returns the cloud provider that will be returned after querying the picker db |
| */ |
| @VisibleForTesting |
| public String getCloudProvider() { |
| synchronized (mLock) { |
| return mCloudProvider; |
| } |
| } |
| |
| public String getLocalProvider() { |
| return mLocalProvider; |
| } |
| |
| /** |
| * Returns {@link DbWriteOperation} to add media belonging to {@code authority} into the picker |
| * db. |
| */ |
| public DbWriteOperation beginAddMediaOperation(String authority) { |
| return new AddMediaOperation(mDatabase, isLocal(authority)); |
| } |
| |
| /** |
| * Returns {@link DbWriteOperation} to add album_media belonging to {@code authority} |
| * into the picker db. |
| */ |
| public DbWriteOperation beginAddAlbumMediaOperation(String authority, String albumId) { |
| return new AddAlbumMediaOperation(mDatabase, isLocal(authority), albumId); |
| } |
| |
| /** |
| * Returns {@link DbWriteOperation} to remove media belonging to {@code authority} from the |
| * picker db. |
| */ |
| public DbWriteOperation beginRemoveMediaOperation(String authority) { |
| return new RemoveMediaOperation(mDatabase, isLocal(authority)); |
| } |
| |
| /** |
| * Returns {@link DbWriteOperation} to clear local media or all cloud media from the picker |
| * db. |
| * |
| * @param authority to determine whether local or cloud media should be cleared |
| */ |
| public DbWriteOperation beginResetMediaOperation(String authority) { |
| return new ResetMediaOperation(mDatabase, isLocal(authority)); |
| } |
| |
| /** |
| * Returns {@link DbWriteOperation} to clear album media for a given albumId from the picker |
| * db. |
| * |
| * <p>The {@link DbWriteOperation} clears local or cloud album based on {@code authority} and |
| * {@code albumId}. If {@code albumId} is null, it clears all local or cloud albums based on |
| * {@code authority}. |
| * |
| * @param authority to determine whether local or cloud media should be cleared |
| */ |
| public DbWriteOperation beginResetAlbumMediaOperation(String authority, String albumId) { |
| return new ResetAlbumOperation(mDatabase, isLocal(authority), albumId); |
| } |
| |
| /** |
| * Returns {@link UpdateMediaOperation} to update media belonging to {@code authority} in the |
| * picker db. |
| * |
| * @param authority to determine whether local or cloud media should be updated |
| */ |
| public UpdateMediaOperation beginUpdateMediaOperation(String authority) { |
| return new UpdateMediaOperation(mDatabase, isLocal(authority)); |
| } |
| |
| /** |
| * Represents an atomic write operation to the picker database. |
| * |
| * <p>This class is not thread-safe and is meant to be used within a single thread only. |
| */ |
| public abstract static class DbWriteOperation implements AutoCloseable { |
| |
| private final SQLiteDatabase mDatabase; |
| private final boolean mIsLocal; |
| |
| private boolean mIsSuccess = false; |
| |
| private DbWriteOperation(SQLiteDatabase database, boolean isLocal) { |
| mDatabase = database; |
| mIsLocal = isLocal; |
| mDatabase.beginTransaction(); |
| } |
| |
| /** |
| * Execute a write operation. |
| * |
| * @param cursor containing items to add/remove |
| * @return number of {@code cursor} items that were inserted/updated/deleted in the db |
| * @throws {@link IllegalStateException} if no DB transaction is active |
| */ |
| public int execute(@Nullable Cursor cursor) { |
| if (!mDatabase.inTransaction()) { |
| throw new IllegalStateException("No ongoing DB transaction."); |
| } |
| final String traceSectionName = getClass().getSimpleName() |
| + ".execute[" + (mIsLocal ? "local" : "cloud") + ']'; |
| Trace.beginSection(traceSectionName); |
| try { |
| return executeInternal(cursor); |
| } finally { |
| Trace.endSection(); |
| } |
| } |
| |
| public void setSuccess() { |
| mIsSuccess = true; |
| } |
| |
| @Override |
| public void close() { |
| if (mDatabase.inTransaction()) { |
| if (mIsSuccess) { |
| mDatabase.setTransactionSuccessful(); |
| } else { |
| Log.w(TAG, "DB write transaction failed."); |
| } |
| mDatabase.endTransaction(); |
| } else { |
| throw new IllegalStateException("close() has already been called previously."); |
| } |
| } |
| |
| abstract int executeInternal(@Nullable Cursor cursor); |
| |
| SQLiteDatabase getDatabase() { |
| return mDatabase; |
| } |
| |
| boolean isLocal() { |
| return mIsLocal; |
| } |
| |
| int updateMedia(SQLiteQueryBuilder qb, ContentValues values, |
| String[] selectionArgs) { |
| try { |
| if (qb.update(mDatabase, values, /* selection */ null, selectionArgs) > 0) { |
| return SUCCESS; |
| } else { |
| Log.v(TAG, "Failed to update picker db media. ContentValues: " + values); |
| return FAIL; |
| } |
| } catch (SQLiteConstraintException e) { |
| Log.v(TAG, "Failed to update picker db media. ContentValues: " + values, e); |
| return RETRY; |
| } |
| } |
| |
| String querySingleMedia(SQLiteQueryBuilder qb, String[] projection, |
| String[] selectionArgs, int columnIndex) { |
| try (Cursor cursor = qb.query(mDatabase, projection, /* selection */ null, |
| selectionArgs, /* groupBy */ null, /* having */ null, |
| /* orderBy */ null)) { |
| if (cursor.moveToFirst()) { |
| return cursor.getString(columnIndex); |
| } |
| } |
| |
| return null; |
| } |
| } |
| |
| /** |
| * Represents an atomic media update operation to the picker database. |
| * |
| * <p>This class is not thread-safe and is meant to be used within a single thread only. |
| */ |
| public static final class UpdateMediaOperation extends DbWriteOperation { |
| |
| private UpdateMediaOperation(SQLiteDatabase database, boolean isLocal) { |
| super(database, isLocal); |
| } |
| |
| /** |
| * Execute a media update operation. |
| * |
| * @param id id of the media to be updated |
| * @param contentValues key-value pairs indicating fields to be updated for the media |
| * @return boolean indicating success/failure of the update |
| * @throws {@link IllegalStateException} if no DB transaction is active |
| */ |
| public boolean execute(String id, ContentValues contentValues) { |
| final SQLiteDatabase database = getDatabase(); |
| if (!database.inTransaction()) { |
| throw new IllegalStateException("No ongoing DB transaction."); |
| } |
| |
| final SQLiteQueryBuilder qb = isLocal() ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD; |
| return qb.update(database, contentValues, /* selection */ null, new String[] {id}) > 0; |
| } |
| |
| @Override |
| int executeInternal(@Nullable Cursor cursor) { |
| throw new UnsupportedOperationException("Cursor updates are not supported."); |
| } |
| } |
| |
| private static final class AddMediaOperation extends DbWriteOperation { |
| |
| private AddMediaOperation(SQLiteDatabase database, boolean isLocal) { |
| super(database, isLocal); |
| } |
| |
| @Override |
| int executeInternal(@Nullable Cursor cursor) { |
| final boolean isLocal = isLocal(); |
| final SQLiteQueryBuilder qb = isLocal ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD; |
| int counter = 0; |
| |
| while (cursor.moveToNext()) { |
| ContentValues values = cursorToContentValue(cursor, isLocal); |
| |
| String[] upsertArgs = {values.getAsString(isLocal ? |
| KEY_LOCAL_ID : KEY_CLOUD_ID)}; |
| if (upsertMedia(qb, values, upsertArgs) == SUCCESS) { |
| counter++; |
| continue; |
| } |
| |
| // Because we want to prioritize visible local media over visible cloud media, |
| // we do the following if the upsert above failed |
| if (isLocal) { |
| // For local syncs, we attempt hiding the visible cloud media |
| String cloudId = getVisibleCloudIdFromDb(values.getAsString(KEY_LOCAL_ID)); |
| demoteCloudMediaToHidden(cloudId); |
| } else { |
| // For cloud syncs, we prepare an upsert as hidden cloud media |
| values.putNull(KEY_IS_VISIBLE); |
| } |
| |
| // Now attempt upsert again, this should succeed |
| if (upsertMedia(qb, values, upsertArgs) == SUCCESS) { |
| counter++; |
| } |
| } |
| return counter; |
| } |
| |
| private int insertMedia(ContentValues values) { |
| try { |
| if (QB_MATCH_ALL.insert(getDatabase(), values) > 0) { |
| return SUCCESS; |
| } else { |
| Log.v(TAG, "Failed to insert picker db media. ContentValues: " + values); |
| return FAIL; |
| } |
| } catch (SQLiteConstraintException e) { |
| Log.v(TAG, "Failed to insert picker db media. ContentValues: " + values, e); |
| return RETRY; |
| } |
| } |
| |
| private int upsertMedia(SQLiteQueryBuilder qb, |
| ContentValues values, String[] selectionArgs) { |
| int res = insertMedia(values); |
| if (res == RETRY) { |
| // Attempt equivalent of CONFLICT_REPLACE resolution |
| Log.v(TAG, "Retrying failed insert as update. ContentValues: " + values); |
| res = updateMedia(qb, values, selectionArgs); |
| } |
| |
| return res; |
| } |
| |
| private void demoteCloudMediaToHidden(@Nullable String cloudId) { |
| if (cloudId == null) { |
| return; |
| } |
| |
| final String[] updateArgs = new String[] {cloudId}; |
| if (updateMedia(QB_MATCH_CLOUD, CONTENT_VALUE_HIDDEN, updateArgs) == SUCCESS) { |
| Log.d(TAG, "Demoted picker db media item to hidden. CloudId: " + cloudId); |
| } |
| } |
| |
| private String getVisibleCloudIdFromDb(String localId) { |
| final String[] cloudIdProjection = new String[] {KEY_CLOUD_ID}; |
| final String[] queryArgs = new String[] {localId}; |
| return querySingleMedia(QB_MATCH_VISIBLE_LOCAL, cloudIdProjection, queryArgs, |
| /* columnIndex */ 0); |
| } |
| } |
| |
| private static final class RemoveMediaOperation extends DbWriteOperation { |
| |
| private RemoveMediaOperation(SQLiteDatabase database, boolean isLocal) { |
| super(database, isLocal); |
| } |
| |
| @Override |
| int executeInternal(@Nullable Cursor cursor) { |
| final boolean isLocal = isLocal(); |
| final SQLiteQueryBuilder qb = isLocal ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD; |
| |
| int counter = 0; |
| |
| while (cursor.moveToNext()) { |
| // Need to fetch the local_id before delete because for cloud items |
| // we need a db query to fetch the local_id matching the id received from |
| // cursor (cloud_id). |
| final String localId = getLocalIdFromCursorOrDb(cursor, isLocal); |
| |
| // Delete cloud/local row |
| final int idIndex = cursor.getColumnIndex( |
| CloudMediaProviderContract.MediaColumns.ID); |
| final String[] deleteArgs = {cursor.getString(idIndex)}; |
| if (qb.delete(getDatabase(), /* selection */ null, deleteArgs) > 0) { |
| counter++; |
| } |
| |
| promoteCloudMediaToVisible(localId); |
| } |
| |
| return counter; |
| } |
| |
| private void promoteCloudMediaToVisible(@Nullable String localId) { |
| if (localId == null) { |
| return; |
| } |
| |
| final String[] idProjection = new String[] {KEY_ID}; |
| final String[] queryArgs = {localId}; |
| // First query for an exact row id matching the criteria for promotion so that we don't |
| // attempt promoting multiple hidden cloud rows matching the |localId| |
| final String id = querySingleMedia(QB_MATCH_LOCAL, idProjection, queryArgs, |
| /* columnIndex */ 0); |
| if (id == null) { |
| Log.w(TAG, "Unable to promote cloud media with localId: " + localId); |
| return; |
| } |
| |
| final String[] updateArgs = {id}; |
| if (updateMedia(QB_MATCH_ID, CONTENT_VALUE_VISIBLE, updateArgs) == SUCCESS) { |
| Log.d(TAG, "Promoted picker db media item to visible. LocalId: " + localId); |
| } |
| } |
| |
| private String getLocalIdFromCursorOrDb(Cursor cursor, boolean isLocal) { |
| final String id = cursor.getString(0); |
| |
| if (isLocal) { |
| // For local, id in cursor is already local_id |
| return id; |
| } else { |
| // For cloud, we need to query db with cloud_id from cursor to fetch local_id |
| final String[] localIdProjection = new String[] {KEY_LOCAL_ID}; |
| final String[] queryArgs = new String[] {id}; |
| return querySingleMedia(QB_MATCH_CLOUD, localIdProjection, queryArgs, |
| /* columnIndex */ 0); |
| } |
| } |
| } |
| |
| private static final class ResetMediaOperation extends DbWriteOperation { |
| |
| private ResetMediaOperation(SQLiteDatabase database, boolean isLocal) { |
| super(database, isLocal); |
| } |
| |
| @Override |
| int executeInternal(@Nullable Cursor unused) { |
| final boolean isLocal = isLocal(); |
| final SQLiteQueryBuilder qb = createMediaQueryBuilder(); |
| |
| if (isLocal) { |
| qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); |
| } else { |
| qb.appendWhereStandalone(WHERE_NOT_NULL_CLOUD_ID); |
| } |
| |
| SQLiteDatabase database = getDatabase(); |
| int counter = qb.delete(database, /* selection */ null, /* selectionArgs */ null); |
| |
| if (isLocal) { |
| // If we reset local media, we need to promote cloud media items |
| // Ignore conflicts in case we have multiple cloud_ids mapped to the |
| // same local_id. Promoting either is fine. |
| database.updateWithOnConflict(TABLE_MEDIA, CONTENT_VALUE_VISIBLE, /* where */ null, |
| /* whereClause */ null, SQLiteDatabase.CONFLICT_IGNORE); |
| } |
| |
| return counter; |
| } |
| } |
| |
| /** Filter for {@link #queryMedia} to modify returned results */ |
| public static class QueryFilter { |
| private final int mLimit; |
| private final long mDateTakenBeforeMs; |
| private final long mDateTakenAfterMs; |
| private final long mId; |
| private final String mAlbumId; |
| private final long mSizeBytes; |
| private final String[] mMimeTypes; |
| private final boolean mIsFavorite; |
| private final boolean mIsVideo; |
| public boolean mIsLocalOnly; |
| |
| private QueryFilter(int limit, long dateTakenBeforeMs, long dateTakenAfterMs, long id, |
| String albumId, long sizeBytes, String[] mimeTypes, boolean isFavorite, |
| boolean isVideo, boolean isLocalOnly) { |
| this.mLimit = limit; |
| this.mDateTakenBeforeMs = dateTakenBeforeMs; |
| this.mDateTakenAfterMs = dateTakenAfterMs; |
| this.mId = id; |
| this.mAlbumId = albumId; |
| this.mSizeBytes = sizeBytes; |
| this.mMimeTypes = mimeTypes; |
| this.mIsFavorite = isFavorite; |
| this.mIsVideo = isVideo; |
| this.mIsLocalOnly = isLocalOnly; |
| } |
| } |
| |
| /** Builder for {@link Query} filter. */ |
| public static class QueryFilterBuilder { |
| public static final long LONG_DEFAULT = -1; |
| public static final String STRING_DEFAULT = null; |
| public static final String[] STRING_ARRAY_DEFAULT = null; |
| public static final boolean BOOLEAN_DEFAULT = false; |
| |
| public static final int LIMIT_DEFAULT = 1000; |
| |
| private final int limit; |
| private long dateTakenBeforeMs = LONG_DEFAULT; |
| private long dateTakenAfterMs = LONG_DEFAULT; |
| private long id = LONG_DEFAULT; |
| private String albumId = STRING_DEFAULT; |
| private long sizeBytes = LONG_DEFAULT; |
| private String[] mimeTypes = STRING_ARRAY_DEFAULT; |
| private boolean isFavorite = BOOLEAN_DEFAULT; |
| private boolean mIsVideo = BOOLEAN_DEFAULT; |
| private boolean mIsLocalOnly = BOOLEAN_DEFAULT; |
| |
| public QueryFilterBuilder(int limit) { |
| this.limit = limit; |
| } |
| |
| public QueryFilterBuilder setDateTakenBeforeMs(long dateTakenBeforeMs) { |
| this.dateTakenBeforeMs = dateTakenBeforeMs; |
| return this; |
| } |
| |
| public QueryFilterBuilder setDateTakenAfterMs(long dateTakenAfterMs) { |
| this.dateTakenAfterMs = dateTakenAfterMs; |
| return this; |
| } |
| |
| /** |
| * The {@code id} helps break ties with db rows having the same {@code dateTakenAfterMs} or |
| * {@code dateTakenBeforeMs}. |
| * |
| * If {@code dateTakenAfterMs} is specified, all returned items are equal or more |
| * recent than {@code dateTakenAfterMs} and have a picker db id equal or greater than |
| * {@code id} for ties. |
| * |
| * If {@code dateTakenBeforeMs} is specified, all returned items are either strictly older |
| * than {@code dateTakenBeforeMs} or have a picker db id strictly less than {@code id} |
| * for ties. |
| */ |
| public QueryFilterBuilder setId(long id) { |
| this.id = id; |
| return this; |
| } |
| public QueryFilterBuilder setAlbumId(String albumId) { |
| this.albumId = albumId; |
| return this; |
| } |
| |
| public QueryFilterBuilder setSizeBytes(long sizeBytes) { |
| this.sizeBytes = sizeBytes; |
| return this; |
| } |
| |
| public QueryFilterBuilder setMimeTypes(String[] mimeTypes) { |
| this.mimeTypes = mimeTypes; |
| return this; |
| } |
| |
| /** |
| * If {@code isFavorite} is {@code true}, the {@link QueryFilter} returns only |
| * favorited items, however, if it is {@code false}, it returns all items including |
| * favorited and non-favorited items. |
| */ |
| public QueryFilterBuilder setIsFavorite(boolean isFavorite) { |
| this.isFavorite = isFavorite; |
| return this; |
| } |
| |
| /** |
| * If {@code isVideo} is {@code true}, the {@link QueryFilter} returns only |
| * video items, however, if it is {@code false}, it returns all items including |
| * video and non-video items. |
| */ |
| public QueryFilterBuilder setIsVideo(boolean isVideo) { |
| this.mIsVideo = isVideo; |
| return this; |
| } |
| |
| /** |
| * If {@code isLocalOnly} is {@code true}, the {@link QueryFilter} returns only |
| * local items. |
| */ |
| public QueryFilterBuilder setIsLocalOnly(boolean isLocalOnly) { |
| this.mIsLocalOnly = isLocalOnly; |
| return this; |
| } |
| |
| public QueryFilter build() { |
| return new QueryFilter(limit, dateTakenBeforeMs, dateTakenAfterMs, id, albumId, |
| sizeBytes, mimeTypes, isFavorite, mIsVideo, mIsLocalOnly); |
| } |
| } |
| |
| /** |
| * Returns sorted and deduped cloud and local media items from the picker db. |
| * |
| * Returns a {@link Cursor} containing picker db media rows with columns as |
| * {@link CloudMediaProviderContract.MediaColumns}. |
| * |
| * The result is sorted in reverse chronological order, i.e. newest first, up to a maximum of |
| * {@code limit}. They can also be filtered with {@code query}. |
| */ |
| public Cursor queryMediaForUi(QueryFilter query) { |
| final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder(); |
| final String[] selectionArgs = buildSelectionArgs(qb, query); |
| |
| final String cloudProvider; |
| synchronized (mLock) { |
| // If the cloud sync is in progress or the cloud provider has changed but a sync has not |
| // been completed and committed, {@link PickerDBFacade.mCloudProvider} will be |
| // {@code null}. |
| cloudProvider = mCloudProvider; |
| } |
| |
| return queryMediaForUi(qb, selectionArgs, query.mLimit, TABLE_MEDIA, cloudProvider); |
| } |
| |
| /** |
| * Returns sorted cloud or local media items from the picker db for a given album (either cloud |
| * or local). |
| * |
| * Returns a {@link Cursor} containing picker db media rows with columns as |
| * {@link CloudMediaProviderContract#MediaColumns} except for is_favorites column because that |
| * column is only used for fetching the Favorites album. |
| * |
| * The result is sorted in reverse chronological order, i.e. newest first, up to a maximum of |
| * {@code limit}. They can also be filtered with {@code query}. |
| */ |
| public Cursor queryAlbumMediaForUi(QueryFilter query, String authority) { |
| final SQLiteQueryBuilder qb = createAlbumMediaQueryBuilder(isLocal(authority)); |
| final String[] selectionArgs = buildSelectionArgs(qb, query); |
| |
| return queryMediaForUi(qb, selectionArgs, query.mLimit, TABLE_ALBUM_MEDIA, authority); |
| } |
| |
| /** |
| * Returns an individual cloud or local item from the picker db matching {@code authority} and |
| * {@code mediaId}. |
| * |
| * Returns a {@link Cursor} containing picker db media rows with columns as {@code projection}, |
| * a subset of {@link PickerMediaColumns}. |
| */ |
| public Cursor queryMediaIdForApps(String authority, String mediaId, |
| @NonNull String[] projection) { |
| final String[] selectionArgs = new String[] { mediaId }; |
| final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder(); |
| if (isLocal(authority)) { |
| qb.appendWhereStandalone(WHERE_LOCAL_ID); |
| } else { |
| qb.appendWhereStandalone(WHERE_CLOUD_ID); |
| } |
| |
| if (authority.equals(mLocalProvider)) { |
| return queryMediaIdForAppsInternal(qb, projection, selectionArgs); |
| } |
| |
| synchronized (mLock) { |
| if (authority.equals(mCloudProvider)) { |
| return queryMediaIdForAppsInternal(qb, projection, selectionArgs); |
| } |
| } |
| |
| return null; |
| } |
| |
| private Cursor queryMediaIdForAppsInternal(@NonNull SQLiteQueryBuilder qb, |
| @NonNull String[] projection, @NonNull String[] selectionArgs) { |
| return qb.query(mDatabase, getMediaStoreProjectionLocked(projection), |
| /* selection */ null, selectionArgs, /* groupBy */ null, /* having */ null, |
| /* orderBy */ null, /* limitStr */ null); |
| } |
| |
| /** |
| * Returns empty {@link Cursor} if there are no items matching merged album constraints {@code |
| * query} |
| */ |
| public Cursor getMergedAlbums(QueryFilter query) { |
| final MatrixCursor c = new MatrixCursor(AlbumColumns.ALL_PROJECTION); |
| List<String> mergedAlbums = List.of(ALBUM_ID_FAVORITES, ALBUM_ID_VIDEOS); |
| for (String albumId : mergedAlbums) { |
| List<String> selectionArgs = new ArrayList<>(); |
| final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder(); |
| |
| if (query.mIsLocalOnly) { |
| qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); |
| } |
| |
| if (albumId.equals(ALBUM_ID_FAVORITES)) { |
| qb.appendWhereStandalone(getWhereForFavorite(query.mIsLocalOnly)); |
| } else if (albumId.equals(ALBUM_ID_VIDEOS)) { |
| qb.appendWhereStandalone(WHERE_MIME_TYPE); |
| selectionArgs.add("video/%"); |
| } |
| addMimeTypesToQueryBuilderAndSelectionArgs(qb, selectionArgs, query.mMimeTypes); |
| |
| Cursor cursor = qb.query(mDatabase, getMergedAlbumProjection(), /* selection */ null, |
| selectionArgs.toArray(new String[0]), /* groupBy */ null, /* having */ null, |
| /* orderBy */ null, /* limit */ null); |
| |
| if (cursor == null || !cursor.moveToFirst()) { |
| continue; |
| } |
| |
| long count = getCursorLong(cursor, CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT); |
| if (count == 0) { |
| continue; |
| } |
| |
| final String[] projectionValue = new String[]{ |
| /* albumId */ albumId, |
| getCursorString(cursor, AlbumColumns.DATE_TAKEN_MILLIS), |
| /* displayName */ albumId, |
| getCursorString(cursor, AlbumColumns.MEDIA_COVER_ID), |
| String.valueOf(count), |
| getCursorString(cursor, AlbumColumns.AUTHORITY), |
| }; |
| c.addRow(projectionValue); |
| } |
| return c; |
| } |
| |
| private String[] getMergedAlbumProjection() { |
| return new String[] { |
| "COUNT(" + KEY_ID + ") AS " + CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT, |
| "MAX(" + KEY_DATE_TAKEN_MS + ") AS " |
| + CloudMediaProviderContract.AlbumColumns.DATE_TAKEN_MILLIS, |
| String.format("IFNULL(%s, %s) AS %s", KEY_CLOUD_ID, |
| KEY_LOCAL_ID, CloudMediaProviderContract.AlbumColumns.MEDIA_COVER_ID), |
| // Note that we prefer cloud_id over local_id here. This logic is for computing the |
| // projection and doesn't affect the filtering of results which has already been |
| // done and ensures that only is_visible=true items are returned. |
| // Here, we need to distinguish between cloud+local and local-only items to |
| // determine the correct authority. |
| String.format("CASE WHEN %s IS NULL THEN '%s' ELSE '%s' END AS %s", |
| KEY_CLOUD_ID, mLocalProvider, mCloudProvider, AlbumColumns.AUTHORITY) |
| }; |
| } |
| |
| private boolean isLocal(String authority) { |
| return mLocalProvider.equals(authority); |
| } |
| |
| private Cursor queryMediaForUi(SQLiteQueryBuilder qb, String[] selectionArgs, |
| int limit, String tableName, String authority) { |
| // Use the <table>.<column> form to order _id to avoid ordering against the projection '_id' |
| final String orderBy = getOrderClause(tableName); |
| final String limitStr = String.valueOf(limit); |
| |
| // Hold lock while checking the cloud provider and querying so that cursor extras containing |
| // the cloud provider is consistent with the cursor results and doesn't race with |
| // #setCloudProvider |
| synchronized (mLock) { |
| if (mCloudProvider == null || !Objects.equals(mCloudProvider, authority)) { |
| // TODO(b/278086344): If cloud provider is null or has changed from what we received |
| // from the UI, skip all cloud items in the picker db. |
| qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); |
| } |
| |
| return qb.query(mDatabase, getCloudMediaProjectionLocked(), /* selection */ null, |
| selectionArgs, /* groupBy */ null, /* having */ null, orderBy, limitStr); |
| } |
| } |
| |
| private static String getOrderClause(String tableName) { |
| return "date_taken_ms DESC," + tableName + "._id DESC"; |
| } |
| |
| private String[] getCloudMediaProjectionLocked() { |
| return new String[] { |
| getProjectionAuthorityLocked(), |
| getProjectionDataLocked(MediaColumns.DATA), |
| getProjectionId(MediaColumns.ID), |
| getProjectionSimple(KEY_DATE_TAKEN_MS, MediaColumns.DATE_TAKEN_MILLIS), |
| getProjectionSimple(KEY_SYNC_GENERATION, MediaColumns.SYNC_GENERATION), |
| getProjectionSimple(KEY_SIZE_BYTES, MediaColumns.SIZE_BYTES), |
| getProjectionSimple(KEY_DURATION_MS, MediaColumns.DURATION_MILLIS), |
| getProjectionSimple(KEY_MIME_TYPE, MediaColumns.MIME_TYPE), |
| getProjectionSimple(KEY_STANDARD_MIME_TYPE_EXTENSION, |
| MediaColumns.STANDARD_MIME_TYPE_EXTENSION), |
| }; |
| } |
| |
| private String[] getMediaStoreProjectionLocked(String[] columns) { |
| final String[] projection = new String[columns.length]; |
| |
| for (int i = 0; i < projection.length; i++) { |
| switch (columns[i]) { |
| case PickerMediaColumns.DATA: |
| projection[i] = getProjectionDataLocked(PickerMediaColumns.DATA); |
| break; |
| case PickerMediaColumns.DISPLAY_NAME: |
| projection[i] = |
| getProjectionSimple( |
| getDisplayNameSql(), PickerMediaColumns.DISPLAY_NAME); |
| break; |
| case PickerMediaColumns.MIME_TYPE: |
| projection[i] = |
| getProjectionSimple(KEY_MIME_TYPE, PickerMediaColumns.MIME_TYPE); |
| break; |
| case PickerMediaColumns.DATE_TAKEN: |
| projection[i] = |
| getProjectionSimple(KEY_DATE_TAKEN_MS, PickerMediaColumns.DATE_TAKEN); |
| break; |
| case PickerMediaColumns.SIZE: |
| projection[i] = getProjectionSimple(KEY_SIZE_BYTES, PickerMediaColumns.SIZE); |
| break; |
| case PickerMediaColumns.DURATION_MILLIS: |
| projection[i] = |
| getProjectionSimple( |
| KEY_DURATION_MS, PickerMediaColumns.DURATION_MILLIS); |
| break; |
| case PickerMediaColumns.HEIGHT: |
| projection[i] = getProjectionSimple(KEY_HEIGHT, PickerMediaColumns.HEIGHT); |
| break; |
| case PickerMediaColumns.WIDTH: |
| projection[i] = getProjectionSimple(KEY_WIDTH, PickerMediaColumns.WIDTH); |
| break; |
| case PickerMediaColumns.ORIENTATION: |
| projection[i] = |
| getProjectionSimple(KEY_ORIENTATION, PickerMediaColumns.ORIENTATION); |
| break; |
| default: |
| projection[i] = getProjectionSimple("NULL", columns[i]); |
| // Ignore unsupported columns; we do not throw error here to support |
| // backward compatibility |
| Log.w(TAG, "Unexpected Picker column: " + columns[i]); |
| } |
| } |
| |
| return projection; |
| } |
| |
| private String getProjectionAuthorityLocked() { |
| // Note that we prefer cloud_id over local_id here. It's important to remember that this |
| // logic is for computing the projection and doesn't affect the filtering of results which |
| // has already been done and ensures that only is_visible=true items are returned. |
| // Here, we need to distinguish between cloud+local and local-only items to determine the |
| // correct authority. Checking whether cloud_id IS NULL distinguishes the former from the |
| // latter. |
| return String.format("CASE WHEN %s IS NULL THEN '%s' ELSE '%s' END AS %s", |
| KEY_CLOUD_ID, mLocalProvider, mCloudProvider, MediaColumns.AUTHORITY); |
| } |
| |
| private String getProjectionDataLocked(String asColumn) { |
| // _data format: |
| // /sdcard/.transforms/synthetic/picker/<user-id>/<authority>/media/<display-name> |
| // See PickerUriResolver#getMediaUri |
| final String authority = String.format("CASE WHEN %s IS NULL THEN '%s' ELSE '%s' END", |
| KEY_CLOUD_ID, mLocalProvider, mCloudProvider); |
| final String fullPath = "'" + PICKER_PATH + "/'" |
| + "||" + "'" + MediaStore.MY_USER_ID + "/'" |
| + "||" + authority |
| + "||" + "'/" + CloudMediaProviderContract.URI_PATH_MEDIA + "/'" |
| + "||" + getDisplayNameSql(); |
| return String.format("%s AS %s", fullPath, asColumn); |
| } |
| |
| private String getProjectionId(String asColumn) { |
| // We prefer cloud_id first and it only matters for cloud+local items. For those, the row |
| // will already be associated with a cloud authority, see #getProjectionAuthorityLocked. |
| // Note that hidden cloud+local items will not be returned in the query, so there's no |
| // concern of preferring the cloud_id in a cloud+local item over the local_id in a |
| // local-only item. |
| return String.format("IFNULL(%s, %s) AS %s", KEY_CLOUD_ID, KEY_LOCAL_ID, asColumn); |
| } |
| |
| private static String getProjectionSimple(String dbColumn, String column) { |
| return String.format("%s AS %s", dbColumn, column); |
| } |
| |
| private String getDisplayNameSql() { |
| // _display_name format: |
| // <media-id>.<file-extension> |
| // See comment in #getProjectionAuthorityLocked for why cloud_id is preferred over local_id |
| final String mediaId = String.format("IFNULL(%s, %s)", KEY_CLOUD_ID, KEY_LOCAL_ID); |
| final String fileExtension = String.format("_GET_EXTENSION(%s)", KEY_MIME_TYPE); |
| |
| return mediaId + "||" + fileExtension; |
| } |
| |
| private static ContentValues cursorToContentValue(Cursor cursor, boolean isLocal) { |
| return cursorToContentValue(cursor, isLocal, ""); |
| } |
| |
| private static ContentValues cursorToContentValue(Cursor cursor, boolean isLocal, |
| String albumId) { |
| final ContentValues values = new ContentValues(); |
| if (TextUtils.isEmpty(albumId)) { |
| values.put(KEY_IS_VISIBLE, 1); |
| } |
| else { |
| values.put(KEY_ALBUM_ID, albumId); |
| } |
| |
| final int count = cursor.getColumnCount(); |
| for (int index = 0; index < count; index++) { |
| String key = cursor.getColumnName(index); |
| switch (key) { |
| case CloudMediaProviderContract.MediaColumns.ID: |
| if (isLocal) { |
| values.put(KEY_LOCAL_ID, cursor.getString(index)); |
| } else { |
| values.put(KEY_CLOUD_ID, cursor.getString(index)); |
| } |
| break; |
| case CloudMediaProviderContract.MediaColumns.MEDIA_STORE_URI: |
| String uriString = cursor.getString(index); |
| if (uriString != null) { |
| Uri uri = Uri.parse(uriString); |
| values.put(KEY_LOCAL_ID, ContentUris.parseId(uri)); |
| } |
| break; |
| case CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS: |
| values.put(KEY_DATE_TAKEN_MS, cursor.getLong(index)); |
| break; |
| case CloudMediaProviderContract.MediaColumns.SYNC_GENERATION: |
| values.put(KEY_SYNC_GENERATION, cursor.getLong(index)); |
| break; |
| case CloudMediaProviderContract.MediaColumns.SIZE_BYTES: |
| values.put(KEY_SIZE_BYTES, cursor.getLong(index)); |
| break; |
| case CloudMediaProviderContract.MediaColumns.MIME_TYPE: |
| values.put(KEY_MIME_TYPE, cursor.getString(index)); |
| break; |
| case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION: |
| int standardMimeTypeExtension = cursor.getInt(index); |
| if (isValidStandardMimeTypeExtension(standardMimeTypeExtension)) { |
| values.put(KEY_STANDARD_MIME_TYPE_EXTENSION, standardMimeTypeExtension); |
| } else { |
| throw new IllegalArgumentException("Invalid standard mime type extension"); |
| } |
| break; |
| case CloudMediaProviderContract.MediaColumns.DURATION_MILLIS: |
| values.put(KEY_DURATION_MS, cursor.getLong(index)); |
| break; |
| case CloudMediaProviderContract.MediaColumns.IS_FAVORITE: |
| if (TextUtils.isEmpty(albumId)) { |
| values.put(KEY_IS_FAVORITE, cursor.getInt(index)); |
| } |
| break; |
| |
| /* The below columns are only included if this is not the album_media table |
| * (AlbumId is an empty string) |
| * |
| * The columns should be in the cursor either way, but we don't duplicate these |
| * columns to album_media since they are not needed for the UI. |
| */ |
| case CloudMediaProviderContract.MediaColumns.WIDTH: |
| if (TextUtils.isEmpty(albumId)) { |
| values.put(KEY_WIDTH, cursor.getInt(index)); |
| } |
| break; |
| case CloudMediaProviderContract.MediaColumns.HEIGHT: |
| if (TextUtils.isEmpty(albumId)) { |
| values.put(KEY_HEIGHT, cursor.getInt(index)); |
| } |
| break; |
| case CloudMediaProviderContract.MediaColumns.ORIENTATION: |
| if (TextUtils.isEmpty(albumId)) { |
| values.put(KEY_ORIENTATION, cursor.getInt(index)); |
| } |
| break; |
| default: |
| Log.w(TAG, "Unexpected cursor key: " + key); |
| } |
| } |
| |
| return values; |
| } |
| |
| private static boolean isValidStandardMimeTypeExtension(int standardMimeTypeExtension) { |
| switch (standardMimeTypeExtension) { |
| case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_NONE: |
| case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_GIF: |
| case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_MOTION_PHOTO: |
| case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_ANIMATED_WEBP: |
| return true; |
| default: |
| return false; |
| } |
| } |
| |
| private static String[] buildSelectionArgs(SQLiteQueryBuilder qb, QueryFilter query) { |
| List<String> selectArgs = new ArrayList<>(); |
| |
| if (query.mIsLocalOnly) { |
| qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); |
| } |
| |
| if (query.mId >= 0) { |
| if (query.mDateTakenAfterMs >= 0) { |
| qb.appendWhereStandalone(WHERE_DATE_TAKEN_MS_AFTER); |
| // Add date args twice because the sql statement evaluates date twice |
| selectArgs.add(String.valueOf(query.mDateTakenAfterMs)); |
| selectArgs.add(String.valueOf(query.mDateTakenAfterMs)); |
| } else { |
| qb.appendWhereStandalone(WHERE_DATE_TAKEN_MS_BEFORE); |
| // Add date args twice because the sql statement evaluates date twice |
| selectArgs.add(String.valueOf(query.mDateTakenBeforeMs)); |
| selectArgs.add(String.valueOf(query.mDateTakenBeforeMs)); |
| } |
| selectArgs.add(String.valueOf(query.mId)); |
| } |
| |
| if (query.mSizeBytes >= 0) { |
| qb.appendWhereStandalone(WHERE_SIZE_BYTES); |
| selectArgs.add(String.valueOf(query.mSizeBytes)); |
| } |
| |
| addMimeTypesToQueryBuilderAndSelectionArgs(qb, selectArgs, query.mMimeTypes); |
| |
| if (query.mIsVideo) { |
| qb.appendWhereStandalone(WHERE_MIME_TYPE); |
| selectArgs.add(VIDEO_MIME_TYPES); |
| } else if (query.mIsFavorite) { |
| qb.appendWhereStandalone(getWhereForFavorite(query.mIsLocalOnly)); |
| } else if (!TextUtils.isEmpty(query.mAlbumId)) { |
| qb.appendWhereStandalone(WHERE_ALBUM_ID); |
| selectArgs.add(query.mAlbumId); |
| } |
| |
| if (selectArgs.isEmpty()) { |
| return null; |
| } |
| |
| return selectArgs.toArray(new String[selectArgs.size()]); |
| } |
| |
| /** |
| * Returns where clause to obtain rows that are marked as favorite |
| * |
| * Favorite information can either come from local or from cloud. In case where an item is |
| * marked as favorite in cloud provider, we try to obtain the local row corresponding to this |
| * cloud row to avoid downloading cloud file unnecessarily. |
| * See {@code WHERE_FAVORITE_LOCAL_PLUS_CLOUD} |
| * |
| * For queries that are local only, we don't need any of these complex queries, hence we stick |
| * to simple query like {@code WHERE_FAVORITE_LOCAL_ONLY} |
| */ |
| private static String getWhereForFavorite(boolean isLocalOnly) { |
| if (isLocalOnly) { |
| return WHERE_FAVORITE_LOCAL_ONLY; |
| } else { |
| return WHERE_FAVORITE_ALL; |
| } |
| } |
| |
| static void addMimeTypesToQueryBuilderAndSelectionArgs(SQLiteQueryBuilder qb, |
| List<String> selectionArgs, String[] mimeTypes) { |
| if (mimeTypes == null) { |
| return; |
| } |
| |
| mimeTypes = replaceMatchAnyChar(mimeTypes); |
| ArrayList<String> whereMimeTypes = new ArrayList<>(); |
| for (String mimeType : mimeTypes) { |
| if (!TextUtils.isEmpty(mimeType)) { |
| whereMimeTypes.add(WHERE_MIME_TYPE); |
| selectionArgs.add(mimeType); |
| } |
| } |
| |
| if (whereMimeTypes.isEmpty()) { |
| return; |
| } |
| qb.appendWhereStandalone(TextUtils.join(" OR ", whereMimeTypes)); |
| } |
| |
| private static SQLiteQueryBuilder createMediaQueryBuilder() { |
| SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); |
| qb.setTables(TABLE_MEDIA); |
| |
| return qb; |
| } |
| |
| private static SQLiteQueryBuilder createAlbumMediaQueryBuilder(boolean isLocal) { |
| SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); |
| qb.setTables(TABLE_ALBUM_MEDIA); |
| |
| // In case of local albums, local_id cannot be null. |
| // In case of cloud albums, there can be 2 types of media items: |
| // 1. Cloud-only - Only cloud_id will be populated and local_id will be null. |
| // 2. Local + Cloud - Only local_id will be populated and cloud_id will be null as showing |
| // local copy is preferred over cloud copy. |
| if (isLocal) { |
| qb.appendWhereStandalone(WHERE_NOT_NULL_LOCAL_ID); |
| } |
| |
| return qb; |
| } |
| |
| private static SQLiteQueryBuilder createLocalOnlyMediaQueryBuilder() { |
| SQLiteQueryBuilder qb = createLocalMediaQueryBuilder(); |
| qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); |
| |
| return qb; |
| } |
| |
| private static SQLiteQueryBuilder createLocalMediaQueryBuilder() { |
| SQLiteQueryBuilder qb = createMediaQueryBuilder(); |
| qb.appendWhereStandalone(WHERE_LOCAL_ID); |
| |
| return qb; |
| } |
| |
| private static SQLiteQueryBuilder createCloudMediaQueryBuilder() { |
| SQLiteQueryBuilder qb = createMediaQueryBuilder(); |
| qb.appendWhereStandalone(WHERE_CLOUD_ID); |
| |
| return qb; |
| } |
| |
| private static SQLiteQueryBuilder createIdMediaQueryBuilder() { |
| SQLiteQueryBuilder qb = createMediaQueryBuilder(); |
| qb.appendWhereStandalone(WHERE_ID); |
| |
| return qb; |
| } |
| |
| private static SQLiteQueryBuilder createVisibleMediaQueryBuilder() { |
| SQLiteQueryBuilder qb = createMediaQueryBuilder(); |
| qb.appendWhereStandalone(WHERE_IS_VISIBLE); |
| |
| return qb; |
| } |
| |
| private static SQLiteQueryBuilder createVisibleLocalMediaQueryBuilder() { |
| SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder(); |
| qb.appendWhereStandalone(WHERE_LOCAL_ID); |
| |
| return qb; |
| } |
| |
| private abstract static class AlbumWriteOperation extends DbWriteOperation { |
| |
| private final String mAlbumId; |
| |
| private AlbumWriteOperation(SQLiteDatabase database, boolean isLocal, String albumId) { |
| super(database, isLocal); |
| mAlbumId = albumId; |
| } |
| |
| String getAlbumId() { |
| return mAlbumId; |
| } |
| } |
| |
| private static final class ResetAlbumOperation extends AlbumWriteOperation { |
| |
| private ResetAlbumOperation(SQLiteDatabase database, boolean isLocal, String albumId) { |
| super(database, isLocal, albumId); |
| } |
| |
| @Override |
| int executeInternal(@Nullable Cursor unused) { |
| final String albumId = getAlbumId(); |
| final boolean isLocal = isLocal(); |
| |
| final SQLiteQueryBuilder qb = createAlbumMediaQueryBuilder(isLocal); |
| |
| String[] selectionArgs = null; |
| if (!TextUtils.isEmpty(albumId)) { |
| qb.appendWhereStandalone(WHERE_ALBUM_ID); |
| selectionArgs = new String[]{albumId}; |
| } |
| |
| return qb.delete(getDatabase(), /* selection */ null, /* selectionArgs */ |
| selectionArgs); |
| } |
| } |
| |
| private static final class AddAlbumMediaOperation extends AlbumWriteOperation { |
| private static final String[] sLocalMediaProjection = new String[] { |
| KEY_DATE_TAKEN_MS, |
| KEY_SYNC_GENERATION, |
| KEY_SIZE_BYTES, |
| KEY_DURATION_MS, |
| KEY_MIME_TYPE, |
| KEY_STANDARD_MIME_TYPE_EXTENSION |
| }; |
| |
| private AddAlbumMediaOperation(SQLiteDatabase database, boolean isLocal, String albumId) { |
| super(database, isLocal, albumId); |
| |
| if (TextUtils.isEmpty(albumId)) { |
| throw new IllegalArgumentException("Missing albumId."); |
| } |
| } |
| |
| @Override |
| int executeInternal(@Nullable Cursor cursor) { |
| final boolean isLocal = isLocal(); |
| final String albumId = getAlbumId(); |
| final SQLiteQueryBuilder qb = createAlbumMediaQueryBuilder(isLocal); |
| int counter = 0; |
| |
| while (cursor.moveToNext()) { |
| ContentValues values = cursorToContentValue(cursor, isLocal, albumId); |
| |
| // In case of cloud albums, cloud provider returns both local and cloud ids. |
| // We give preference to inserting media data for the local copy of an item instead |
| // of the cloud copy. Hence, if local copy is available, fetch metadata from media |
| // table and update the album_media row accordingly. |
| if (!isLocal) { |
| final String localId = values.getAsString(KEY_LOCAL_ID); |
| final String cloudId = values.getAsString(KEY_CLOUD_ID); |
| if (!TextUtils.isEmpty(localId) && !TextUtils.isEmpty(cloudId)) { |
| // Fetch local media item details from media table. |
| try (Cursor cursorLocalMedia = getLocalMediaMetadata(localId)) { |
| if (cursorLocalMedia != null && cursorLocalMedia.getCount() == 1) { |
| // If local media item details are present in the media table, |
| // update content values and remove cloud id. |
| values.putNull(KEY_CLOUD_ID); |
| updateContentValues(values, cursorLocalMedia); |
| } else { |
| // If local media item details are NOT present in the media table, |
| // insert cloud row after removing local_id. This will only happen |
| // when local id points to a deleted item. |
| values.putNull(KEY_LOCAL_ID); |
| } |
| } |
| } |
| } |
| |
| try { |
| if (qb.insert(getDatabase(), values) > 0) { |
| counter++; |
| } else { |
| Log.v(TAG, "Failed to insert album_media. ContentValues: " + values); |
| } |
| } catch (SQLiteConstraintException e) { |
| Log.v(TAG, "Failed to insert album_media. ContentValues: " + values, e); |
| } |
| } |
| |
| return counter; |
| } |
| |
| private void updateContentValues(ContentValues values, Cursor cursor) { |
| if (cursor.moveToFirst()) { |
| for (int columnIndex = 0; columnIndex < cursor.getColumnCount(); columnIndex++) { |
| String column = cursor.getColumnName(columnIndex); |
| switch (column) { |
| case KEY_DATE_TAKEN_MS: |
| case KEY_SYNC_GENERATION: |
| case KEY_SIZE_BYTES: |
| case KEY_DURATION_MS: |
| case KEY_STANDARD_MIME_TYPE_EXTENSION: |
| values.put(column, cursor.getLong(columnIndex)); |
| break; |
| case KEY_MIME_TYPE: |
| values.put(column, cursor.getString(columnIndex)); |
| break; |
| default: |
| throw new IllegalArgumentException( |
| "Column " + column + " not recognized."); |
| } |
| } |
| } |
| } |
| |
| private Cursor getLocalMediaMetadata(String localId) { |
| final SQLiteQueryBuilder qb = createVisibleLocalMediaQueryBuilder(); |
| final String[] selectionArgs = new String[] {localId}; |
| qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); |
| |
| return qb.query(getDatabase(), sLocalMediaProjection, /* selection */ null, |
| selectionArgs, /* groupBy */ null, /* having */ null, |
| /* orderBy */ null); |
| } |
| } |
| } |