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