blob: ada041cf39418fa80f819fa0f9c36ef4c7a7b2d8 [file] [log] [blame]
/*
* 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 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.FileUtils.buildPrimaryVolumeFile;
import static com.android.providers.media.util.SyntheticPathUtils.getPickerRelativePath;
import android.content.ContentValues;
import android.content.ContentUris;
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.provider.CloudMediaProviderContract;
import android.provider.MediaStore;
import android.provider.MediaStore.MediaColumns;
import android.os.Bundle;
import android.os.SystemProperties;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.providers.media.photopicker.PickerSyncController;
import com.android.providers.media.photopicker.data.model.Category;
import com.android.providers.media.photopicker.data.model.Item;
import java.util.ArrayList;
import java.util.List;
/**
* 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 final Object mLock = new Object();
private final Context mContext;
private final SQLiteDatabase mDatabase;
private final String mLocalProvider;
private String mCloudProvider;
public PickerDbFacade(Context context) {
this(context, PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY);
}
@VisibleForTesting
public PickerDbFacade(Context context, String localProvider) {
final PickerDatabaseHelper databaseHelper = new PickerDatabaseHelper(context);
mContext = context;
mDatabase = databaseHelper.getWritableDatabase();
mLocalProvider = localProvider;
}
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";
private static final String PICKER_PATH = buildPrimaryVolumeFile(MediaStore.MY_USER_ID,
getPickerRelativePath()).getAbsolutePath();
@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_SIZE_BYTES = "size_bytes";
@VisibleForTesting
public static final String KEY_DURATION_MS = "duration_ms";
@VisibleForTesting
public static final String KEY_MIME_TYPE = "mime_type";
@VisibleForTesting
public static final String KEY_IS_FAVORITE = "is_favorite";
// 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.
private static final String PROJECTION_ID = String.format("IFNULL(%s, %s) AS %s", KEY_CLOUD_ID,
KEY_LOCAL_ID, CloudMediaProviderContract.MediaColumns.ID);
private static final String PROJECTION_DATE_TAKEN = String.format("%s AS %s", KEY_DATE_TAKEN_MS,
CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MS);
private static final String PROJECTION_SIZE = String.format("%s AS %s", KEY_SIZE_BYTES,
CloudMediaProviderContract.MediaColumns.SIZE_BYTES);
private static final String PROJECTION_DURATION = String.format("%s AS %s", KEY_DURATION_MS,
CloudMediaProviderContract.MediaColumns.DURATION_MS);
private static final String PROJECTION_MIME_TYPE = String.format("%s AS %s", KEY_MIME_TYPE,
CloudMediaProviderContract.MediaColumns.MIME_TYPE);
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_IS_VISIBLE = KEY_IS_VISIBLE + " = 1";
private static final String WHERE_MIME_TYPE = KEY_MIME_TYPE + " LIKE ? ";
private static final String WHERE_IS_FAVORITE = KEY_IS_FAVORITE + " = 1";
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[] PROJECTION_ALBUM_CURSOR = new String[] {
CloudMediaProviderContract.AlbumColumns.ID,
CloudMediaProviderContract.AlbumColumns.DATE_TAKEN_MS,
CloudMediaProviderContract.AlbumColumns.DISPLAY_NAME,
CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT,
CloudMediaProviderContract.AlbumColumns.MEDIA_COVER_ID,
CloudMediaProviderContract.AlbumColumns.TYPE
};
private static final String[] PROJECTION_ALBUM_DB = new String[] {
"COUNT(" + KEY_ID + ") AS " + CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT,
"MAX(" + KEY_DATE_TAKEN_MS + ") AS "
+ CloudMediaProviderContract.AlbumColumns.DATE_TAKEN_MS,
String.format("IFNULL(%s, %s) AS %s", KEY_CLOUD_ID,
KEY_LOCAL_ID, CloudMediaProviderContract.AlbumColumns.MEDIA_COVER_ID)
};
// 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 stricly 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();
{
CONTENT_VALUE_VISIBLE.put(KEY_IS_VISIBLE, 1);
CONTENT_VALUE_HIDDEN.putNull(KEY_IS_VISIBLE);
}
/*
* Add media belonging to {@code authority} into the picker db.
*
* @param cursor containing items to add
* @param authority to add media from
* @return the number of {@code cursor} items that were inserted/updated in the picker db
*/
public int addMedia(Cursor cursor, String authority) {
final boolean isLocal = isLocal(authority);
final SQLiteQueryBuilder qb = isLocal ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD;
int counter = 0;
mDatabase.beginTransaction();
try {
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++;
continue;
}
}
mDatabase.setTransactionSuccessful();
} finally {
mDatabase.endTransaction();
}
return counter;
}
/*
* Remove media belonging to {@code authority} from the picker db.
*
* @param cursor containing items to remove
* @param idIndex column index in {@code cursor} of the local id
* @param authority to remove media from
* @return the number of {@code cursor} items that were deleted/updated in the picker db
*/
public int removeMedia(Cursor cursor, int idIndex, String authority) {
final boolean isLocal = isLocal(authority);
final SQLiteQueryBuilder qb = isLocal ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD;
int counter = 0;
mDatabase.beginTransaction();
try {
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 String deleteArgs[] = {cursor.getString(idIndex)};
if (qb.delete(mDatabase, /* selection */ null, deleteArgs) > 0) {
counter++;
}
promoteCloudMediaToVisible(localId);
}
mDatabase.setTransactionSuccessful();
} finally {
mDatabase.endTransaction();
}
return counter;
}
/**
* Clear local media or all cloud media from the picker db. If {@code authority} is
* null, we also clear all cloud media.
*
* @param authority to determine whether local or cloud media should be cleared
* @return the number of items deleted
*/
public int resetMedia(String authority) {
final boolean isLocal = isLocal(authority);
final SQLiteQueryBuilder qb = createMediaQueryBuilder();
if (isLocal) {
qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID);
} else {
qb.appendWhereStandalone(WHERE_NOT_NULL_CLOUD_ID);
}
int counter = 0;
mDatabase.beginTransaction();
try {
counter = qb.delete(mDatabase, /* 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.
mDatabase.updateWithOnConflict(TABLE_MEDIA, CONTENT_VALUE_VISIBLE, /* where */ null,
/* whereClause */ null, SQLiteDatabase.CONFLICT_IGNORE);
}
mDatabase.setTransactionSuccessful();
} finally {
mDatabase.endTransaction();
}
return counter;
}
/**
* 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;
}
private boolean isLocal(String authority) {
return mLocalProvider.equals(authority);
}
private int insertMedia(ContentValues values) {
try {
if (QB_MATCH_ALL.insert(mDatabase, values) > 0) {
return SUCCESS;
} else {
Log.d(TAG, "Failed to insert picker db media. ContentValues: " + values);
return FAIL;
}
} catch (SQLiteConstraintException e) {
Log.d(TAG, "Failed to insert picker db media. ContentValues: " + values, e);
return RETRY;
}
}
private int updateMedia(SQLiteQueryBuilder qb, ContentValues values, String[] selectionArgs) {
try {
if (qb.update(mDatabase, values, /* selection */ null, selectionArgs) > 0) {
return SUCCESS;
} else {
Log.d(TAG, "Failed to update picker db media. ContentValues: " + values);
return FAIL;
}
} catch (SQLiteConstraintException e) {
Log.d(TAG, "Failed to update 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.d(TAG, "Retrying failed insert as update. ContentValues: " + values);
res = updateMedia(qb, values, selectionArgs);
}
return res;
}
private 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;
}
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 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 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 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);
}
/** Filter for {@link #queryMedia} to modify returned results */
public static class QueryFilter {
private final int limit;
private final long dateTakenBeforeMs;
private final long dateTakenAfterMs;
private final long id;
private final long sizeBytes;
private final String mimeType;
private final boolean isFavorite;
private QueryFilter(int limit, long dateTakenBeforeMs, long dateTakenAfterMs, long id,
long sizeBytes, String mimeType, boolean isFavorite) {
this.limit = limit;
this.dateTakenBeforeMs = dateTakenBeforeMs;
this.dateTakenAfterMs = dateTakenAfterMs;
this.id = id;
this.sizeBytes = sizeBytes;
this.mimeType = mimeType;
this.isFavorite = isFavorite;
}
}
/** 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 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 long sizeBytes = LONG_DEFAULT;
private String mimeType = STRING_DEFAULT;
private boolean isFavorite = 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 either strictly more
* recent than {@code dateTakenAfterMs} or have a picker db id strictly 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 setSizeBytes(long sizeBytes) {
this.sizeBytes = sizeBytes;
return this;
}
public QueryFilterBuilder setMimeType(String mimeType) {
this.mimeType = mimeType;
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;
}
public QueryFilter build() {
return new QueryFilter(limit, dateTakenBeforeMs, dateTakenAfterMs, id, sizeBytes,
mimeType, isFavorite);
}
}
/*
* Returns sorted and deduped cloud and local media items from the picker db.
*
* Returns a {@link Cursor} containing picker db media rows sorted in reverse chronological
* order, i.e. newest first, up to a maximum of {@code limit}.
*
* The results can be filtered with {@code query}.
*/
public Cursor queryMedia(QueryFilter query) {
final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder();
final String[] selectionArgs = buildSelectionArgs(qb, query);
return queryMedia(qb, selectionArgs, query.limit);
}
public Cursor queryMediaId(String authority, String mediaId) {
final String[] selectionArgs = new String[] { mediaId };
final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder();
if (isLocal(authority)) {
qb.appendWhereStandalone(WHERE_LOCAL_ID);
} else {
qb.appendWhereStandalone(WHERE_CLOUD_ID);
}
synchronized (mLock) {
if (authority.equals(mLocalProvider) || authority.equals(mCloudProvider)) {
return qb.query(mDatabase, getProjectionLocked(), /* selection */ null,
selectionArgs, /* groupBy */ null, /* having */ null, /* orderBy */ null,
/* limitStr */ null);
}
}
return null;
}
/** Returns {@code null} if there are no favorited items matching {@code query} */
public Cursor getFavoriteAlbum(QueryFilter query) {
final String[] selectionArgs;
final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder();
qb.appendWhereStandalone(WHERE_IS_FAVORITE);
if (query.mimeType != null) {
qb.appendWhereStandalone(WHERE_MIME_TYPE);
selectionArgs = new String [] { query.mimeType.replace('*', '%') };
} else {
selectionArgs = null;
}
Cursor cursor = qb.query(mDatabase, PROJECTION_ALBUM_DB, /* selection */ null,
selectionArgs, /* groupBy */ null, /* having */ null,
/* orderBy */ null, /* limit */ null);
if (cursor == null || !cursor.moveToFirst()) {
return null;
}
long count = getCursorLong(cursor, CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT);
if (count == 0) {
return null;
}
final MatrixCursor c = new MatrixCursor(PROJECTION_ALBUM_CURSOR);
final String[] projectionValue = new String[] {
Category.CATEGORY_FAVORITES,
getCursorString(cursor, CloudMediaProviderContract.AlbumColumns.DATE_TAKEN_MS),
Category.getCategoryName(mContext, Category.CATEGORY_FAVORITES),
String.valueOf(count),
getCursorString(cursor, CloudMediaProviderContract.AlbumColumns.MEDIA_COVER_ID),
CloudMediaProviderContract.AlbumColumns.TYPE_FAVORITES
};
c.addRow(projectionValue);
return c;
}
public static boolean isPickerDbEnabled() {
return SystemProperties.getBoolean("sys.photopicker.pickerdb.enabled", false);
}
private Cursor queryMedia(SQLiteQueryBuilder qb, String[] selectionArgs, int limit) {
// Use the <table>.<column> form to order _id to avoid ordering against the projection '_id'
final String orderBy = "date_taken_ms DESC," + TABLE_MEDIA + "._id DESC";
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) {
// If cloud provider is null, skip all cloud items in the picker db
qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID);
}
return qb.query(mDatabase, getProjectionLocked(), /* selection */ null, selectionArgs,
/* groupBy */ null, /* having */ null, orderBy, limitStr);
}
}
private String[] getProjectionLocked() {
return new String[] {
getProjectionAuthorityLocked(),
getProjectionDataLocked(),
PROJECTION_ID,
PROJECTION_DATE_TAKEN,
PROJECTION_SIZE,
PROJECTION_DURATION,
PROJECTION_MIME_TYPE
};
}
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("IIF(%s IS NULL, '%s', '%s') AS %s",
KEY_CLOUD_ID, mLocalProvider, mCloudProvider,
CloudMediaProviderContract.MediaColumns.AUTHORITY);
}
private String getProjectionDataLocked() {
// _data format:
// /storage/emulated/<user-id>/.transforms/synthetic/<authority>/media/<media-id>
// See PickerUriResolver#getMediaUri
final String authority = String.format("IIF(%s IS NULL, '%s', '%s')",
KEY_CLOUD_ID, mLocalProvider, mCloudProvider);
// 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 fullPath = "'" + PICKER_PATH + "/'"
+ "||" + authority
+ "||" + "'/" + CloudMediaProviderContract.URI_PATH_MEDIA + "/'"
+ "||" + mediaId;
return String.format("%s AS %s", fullPath, CloudMediaProviderContract.MediaColumns.DATA);
}
private static ContentValues cursorToContentValue(Cursor cursor, boolean isLocal) {
final ContentValues values = new ContentValues();
values.put(KEY_IS_VISIBLE, 1);
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_MS:
values.put(KEY_DATE_TAKEN_MS, 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.DURATION_MS:
values.put(KEY_DURATION_MS, cursor.getLong(index));
break;
case CloudMediaProviderContract.MediaColumns.IS_FAVORITE:
values.put(KEY_IS_FAVORITE, cursor.getInt(index));
break;
default:
Log.w(TAG, "Unexpected cursor key: " + key);
}
}
return values;
}
private static String[] buildSelectionArgs(SQLiteQueryBuilder qb, QueryFilter query) {
List<String> selectArgs = new ArrayList<>();
if (query.id >= 0) {
if (query.dateTakenAfterMs >= 0) {
qb.appendWhereStandalone(WHERE_DATE_TAKEN_MS_AFTER);
// Add date args twice because the sql statement evaluates date twice
selectArgs.add(String.valueOf(query.dateTakenAfterMs));
selectArgs.add(String.valueOf(query.dateTakenAfterMs));
} else {
qb.appendWhereStandalone(WHERE_DATE_TAKEN_MS_BEFORE);
// Add date args twice because the sql statement evaluates date twice
selectArgs.add(String.valueOf(query.dateTakenBeforeMs));
selectArgs.add(String.valueOf(query.dateTakenBeforeMs));
}
selectArgs.add(String.valueOf(query.id));
}
if (query.sizeBytes >= 0) {
qb.appendWhereStandalone(WHERE_SIZE_BYTES);
selectArgs.add(String.valueOf(query.sizeBytes));
}
if (query.mimeType != null) {
qb.appendWhereStandalone(WHERE_MIME_TYPE);
selectArgs.add(replaceMatchAnyChar(query.mimeType));
}
if (query.isFavorite) {
qb.appendWhereStandalone(WHERE_IS_FAVORITE);
}
if (selectArgs.isEmpty()) {
return null;
}
return selectArgs.toArray(new String[selectArgs.size()]);
}
private static SQLiteQueryBuilder createMediaQueryBuilder() {
SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
qb.setTables(TABLE_MEDIA);
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;
}
}