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.PickerSyncController.PAGE_SIZE; 26 import static com.android.providers.media.photopicker.util.CursorUtils.getCursorLong; 27 import static com.android.providers.media.photopicker.util.CursorUtils.getCursorString; 28 import static com.android.providers.media.util.DatabaseUtils.replaceMatchAnyChar; 29 import static com.android.providers.media.util.SyntheticPathUtils.getPickerRelativePath; 30 31 import android.content.ContentUris; 32 import android.content.ContentValues; 33 import android.content.Context; 34 import android.database.Cursor; 35 import android.database.MatrixCursor; 36 import android.database.MergeCursor; 37 import android.database.sqlite.SQLiteConstraintException; 38 import android.database.sqlite.SQLiteDatabase; 39 import android.database.sqlite.SQLiteQueryBuilder; 40 import android.net.Uri; 41 import android.os.Trace; 42 import android.provider.CloudMediaProviderContract; 43 import android.provider.MediaStore; 44 import android.text.TextUtils; 45 import android.util.Log; 46 47 import androidx.annotation.NonNull; 48 import androidx.annotation.Nullable; 49 import androidx.annotation.VisibleForTesting; 50 51 import com.android.providers.media.PickerUriResolver; 52 import com.android.providers.media.photopicker.PickerSyncController; 53 import com.android.providers.media.photopicker.data.model.Item; 54 import com.android.providers.media.photopicker.sync.CloseableReentrantLock; 55 import com.android.providers.media.photopicker.sync.PickerSyncLockManager; 56 import com.android.providers.media.photopicker.sync.SyncTrackerRegistry; 57 import com.android.providers.media.photopicker.util.exceptions.UnableToAcquireLockException; 58 import com.android.providers.media.photopicker.v2.PickerNotificationSender; 59 import com.android.providers.media.util.MimeUtils; 60 61 import java.io.PrintWriter; 62 import java.util.ArrayList; 63 import java.util.List; 64 import java.util.Objects; 65 66 /** 67 * This is a facade that hides the complexities of executing some SQL statements on the picker db. 68 * It does not do any caller permission checks and is only intended for internal use within the 69 * MediaProvider for the Photo Picker. 70 */ 71 public class PickerDbFacade { 72 private static final String VIDEO_MIME_TYPES = "video/%"; 73 private final Context mContext; 74 private final SQLiteDatabase mDatabase; 75 private final PickerSyncLockManager mPickerSyncLockManager; 76 private final String mLocalProvider; 77 // This is the cloud provider the database is synced with. It can be set as null to disable 78 // cloud queries when database is not in sync with the current cloud provider. 79 @Nullable 80 private String mCloudProvider; 81 PickerDbFacade(Context context, PickerSyncLockManager pickerSyncLockManager)82 public PickerDbFacade(Context context, PickerSyncLockManager pickerSyncLockManager) { 83 this(context, pickerSyncLockManager, PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY); 84 } 85 86 @VisibleForTesting PickerDbFacade(Context context, PickerSyncLockManager pickerSyncLockManager, String localProvider)87 public PickerDbFacade(Context context, PickerSyncLockManager pickerSyncLockManager, 88 String localProvider) { 89 this(context, pickerSyncLockManager, localProvider, new PickerDatabaseHelper(context)); 90 } 91 92 @VisibleForTesting PickerDbFacade(Context context, PickerSyncLockManager pickerSyncLockManager, String localProvider, PickerDatabaseHelper dbHelper)93 public PickerDbFacade(Context context, PickerSyncLockManager pickerSyncLockManager, 94 String localProvider, PickerDatabaseHelper dbHelper) { 95 mContext = context; 96 mLocalProvider = localProvider; 97 mDatabase = dbHelper.getWritableDatabase(); 98 mPickerSyncLockManager = pickerSyncLockManager; 99 } 100 101 private static final String TAG = "PickerDbFacade"; 102 103 private static final int RETRY = 0; 104 private static final int SUCCESS = 1; 105 private static final int FAIL = -1; 106 107 private static final String TABLE_MEDIA = "media"; 108 109 private static final String TABLE_ALBUM_MEDIA = "album_media"; 110 111 public static final String KEY_ID = "_id"; 112 public static final String KEY_LOCAL_ID = "local_id"; 113 public static final String KEY_CLOUD_ID = "cloud_id"; 114 public static final String KEY_IS_VISIBLE = "is_visible"; 115 public static final String KEY_DATE_TAKEN_MS = "date_taken_ms"; 116 @VisibleForTesting 117 public static final String KEY_SYNC_GENERATION = "sync_generation"; 118 public static final String KEY_SIZE_BYTES = "size_bytes"; 119 public static final String KEY_DURATION_MS = "duration_ms"; 120 public static final String KEY_MIME_TYPE = "mime_type"; 121 public static final String KEY_STANDARD_MIME_TYPE_EXTENSION = "standard_mime_type_extension"; 122 public static final String KEY_IS_FAVORITE = "is_favorite"; 123 public static final String KEY_ALBUM_ID = "album_id"; 124 @VisibleForTesting 125 public static final String KEY_HEIGHT = "height"; 126 @VisibleForTesting 127 public static final String KEY_WIDTH = "width"; 128 @VisibleForTesting 129 public static final String KEY_ORIENTATION = "orientation"; 130 131 private static final String WHERE_ID = KEY_ID + " = ?"; 132 private static final String WHERE_LOCAL_ID = KEY_LOCAL_ID + " = ?"; 133 private static final String WHERE_CLOUD_ID = KEY_CLOUD_ID + " = ?"; 134 private static final String WHERE_NULL_CLOUD_ID = KEY_CLOUD_ID + " IS NULL"; 135 private static final String WHERE_NOT_NULL_CLOUD_ID = KEY_CLOUD_ID + " IS NOT NULL"; 136 private static final String WHERE_NOT_NULL_LOCAL_ID = KEY_LOCAL_ID + " IS NOT NULL"; 137 private static final String WHERE_IS_VISIBLE = KEY_IS_VISIBLE + " = 1"; 138 private static final String WHERE_MIME_TYPE = KEY_MIME_TYPE + " LIKE ? "; 139 private static final String WHERE_SIZE_BYTES = KEY_SIZE_BYTES + " <= ?"; 140 private static final String WHERE_DATE_TAKEN_MS_AFTER = 141 String.format("%s > ? OR (%s = ? AND %s > ?)", 142 KEY_DATE_TAKEN_MS, KEY_DATE_TAKEN_MS, KEY_ID); 143 private static final String WHERE_DATE_TAKEN_MS_BEFORE = 144 String.format("%s < ? OR (%s = ? AND %s < ?)", 145 KEY_DATE_TAKEN_MS, KEY_DATE_TAKEN_MS, KEY_ID); 146 private static final String WHERE_ALBUM_ID = KEY_ALBUM_ID + " = ?"; 147 private static final String WHERE_LOCAL_ID_IN = KEY_LOCAL_ID + " IN "; 148 private static final String WHERE_CLOUD_ID_IN = KEY_CLOUD_ID + " IN "; 149 150 // This where clause returns all rows for media items that are local-only and are marked as 151 // favorite. 152 // 153 // 'cloud_id' IS NULL AND 'is_favorite' = 1 154 private static final String WHERE_FAVORITE_LOCAL_ONLY = String.format( 155 "%s IS NULL AND %s = 1", KEY_CLOUD_ID, KEY_IS_FAVORITE); 156 // This where clause returns all rows for media items that are cloud-only and are marked as 157 // favorite. 158 // 159 // 'local_id' IS NULL AND 'is_favorite' = 1 160 private static final String WHERE_FAVORITE_CLOUD_ONLY = String.format( 161 "%s IS NULL AND %s = 1", KEY_LOCAL_ID, KEY_IS_FAVORITE); 162 // This where clause returns all local rows from media items for which either local row is 163 // marked as favorite or corresponding cloud row is marked as favorite. 164 // E.g., Rows - 165 // Row1 : local_id=1, cloud_id=null, is_favorite=0 166 // Row2 : local_id=2, cloud_id=null, is_favorite=0 167 // Row3 : local_id=3, cloud_id=null, is_favorite=1 168 // Row4 : local_id=4, cloud_id=null, is_favorite=1 169 // -- 170 // Row5 : local_id=2, cloud_id=c1, is_favorite=1 171 // Row6 : local_id=3, cloud_id=c2, is_favorite=1 172 // Row7 : local_id=null, cloud_id=c3, is_favorite=1 173 // 174 // Returns - 175 // Row2 : local_id=2, cloud_id=null, is_favorite=0 176 // Row3 : local_id=3, cloud_id=null, is_favorite=1 177 // Row4 : local_id=4, cloud_id=null, is_favorite=1 178 // 179 // 'local_id' IN (SELECT 'local_id' 180 // FROM 'media' 181 // WHERE 'local_id' IS NOT NULL 182 // GROUP BY 'local_id' 183 // HAVING SUM('is_favorite') >= 1) 184 private static final String WHERE_FAVORITE_LOCAL_PLUS_CLOUD = String.format( 185 "%s IN (SELECT %s FROM %s WHERE %s IS NOT NULL GROUP BY %s HAVING SUM(%s) >= 1)", 186 KEY_LOCAL_ID, KEY_LOCAL_ID, TABLE_MEDIA, KEY_LOCAL_ID, KEY_LOCAL_ID, KEY_IS_FAVORITE); 187 // This where clause returns all rows for media items that are marked as favorite. 188 // Note that this is different from "WHERE_FAVORITE_LOCAL_ONLY + WHERE_FAVORITE_CLOUD_ONLY" 189 // because for local+cloud row with is_favorite=1 we need to pick corresponding local row. 190 private static final String WHERE_FAVORITE_ALL = String.format( 191 "( %s OR %s )", WHERE_FAVORITE_LOCAL_PLUS_CLOUD, WHERE_FAVORITE_CLOUD_ONLY); 192 193 // Matches all media including cloud+local, cloud-only and local-only 194 private static final SQLiteQueryBuilder QB_MATCH_ALL = createMediaQueryBuilder(); 195 // Matches media with id 196 private static final SQLiteQueryBuilder QB_MATCH_ID = createIdMediaQueryBuilder(); 197 // Matches media with local_id including cloud+local and local-only 198 private static final SQLiteQueryBuilder QB_MATCH_LOCAL = createLocalMediaQueryBuilder(); 199 // Matches cloud media including cloud+local and cloud-only 200 private static final SQLiteQueryBuilder QB_MATCH_CLOUD = createCloudMediaQueryBuilder(); 201 // Matches all visible media including cloud+local, cloud-only and local-only 202 private static final SQLiteQueryBuilder QB_MATCH_VISIBLE = createVisibleMediaQueryBuilder(); 203 // Matches visible media with local_id including cloud+local and local-only 204 private static final SQLiteQueryBuilder QB_MATCH_VISIBLE_LOCAL = 205 createVisibleLocalMediaQueryBuilder(); 206 // Matches strictly local-only media 207 private static final SQLiteQueryBuilder QB_MATCH_LOCAL_ONLY = 208 createLocalOnlyMediaQueryBuilder(); 209 210 private static final ContentValues CONTENT_VALUE_VISIBLE = new ContentValues(); 211 private static final ContentValues CONTENT_VALUE_HIDDEN = new ContentValues(); 212 213 static { CONTENT_VALUE_VISIBLE.put(KEY_IS_VISIBLE, 1)214 CONTENT_VALUE_VISIBLE.put(KEY_IS_VISIBLE, 1); 215 CONTENT_VALUE_HIDDEN.putNull(KEY_IS_VISIBLE); 216 } 217 218 /** 219 * Sets the cloud provider to be returned after querying the picker db 220 * If null, cloud media will be excluded from all queries. 221 * This should not be used in picker sync paths because we should not wait on a lock 222 * indefinitely during the picker sync process. 223 * Use {@link this#setCloudProviderWithTimeout} instead. 224 */ setCloudProvider(String authority)225 public void setCloudProvider(String authority) { 226 try (CloseableReentrantLock ignored = mPickerSyncLockManager 227 .lock(PickerSyncLockManager.DB_CLOUD_LOCK)) { 228 final String previousCloudProvider = mCloudProvider; 229 mCloudProvider = authority; 230 if (!Objects.equals(previousCloudProvider, mCloudProvider)) { 231 PickerNotificationSender.notifyAvailableProvidersChange(mContext); 232 } 233 } 234 } 235 236 /** 237 * Sets the cloud provider to be returned after querying the picker db 238 * If null, cloud media will be excluded from all queries. 239 * This should be used in picker sync paths because we should not wait on a lock 240 * indefinitely during the picker sync process 241 */ setCloudProviderWithTimeout(String authority)242 public void setCloudProviderWithTimeout(String authority) throws UnableToAcquireLockException { 243 try (CloseableReentrantLock ignored = 244 mPickerSyncLockManager.tryLock(PickerSyncLockManager.DB_CLOUD_LOCK)) { 245 final String previousCloudProvider = mCloudProvider; 246 mCloudProvider = authority; 247 if (!Objects.equals(previousCloudProvider, mCloudProvider)) { 248 PickerNotificationSender.notifyAvailableProvidersChange(mContext); 249 } 250 } 251 } 252 253 /** 254 * Returns the cloud provider that will be returned after querying the picker db. 255 * This should not be used in picker sync paths because we should not wait on a lock 256 * indefinitely during the picker sync process. 257 */ getCloudProvider()258 public String getCloudProvider() { 259 try (CloseableReentrantLock ignored = mPickerSyncLockManager 260 .lock(PickerSyncLockManager.DB_CLOUD_LOCK)) { 261 return mCloudProvider; 262 } 263 } 264 getLocalProvider()265 public String getLocalProvider() { 266 return mLocalProvider; 267 } 268 269 /** 270 * Returns {@link DbWriteOperation} to add media belonging to {@code authority} into the picker 271 * db. 272 */ beginAddMediaOperation(String authority)273 public DbWriteOperation beginAddMediaOperation(String authority) { 274 return new AddMediaOperation(mDatabase, isLocal(authority)); 275 } 276 277 /** 278 * Returns {@link DbWriteOperation} to add album_media belonging to {@code authority} 279 * into the picker db. 280 */ beginAddAlbumMediaOperation(String authority, String albumId)281 public DbWriteOperation beginAddAlbumMediaOperation(String authority, String albumId) { 282 return new AddAlbumMediaOperation(mDatabase, isLocal(authority), albumId); 283 } 284 285 /** 286 * Returns {@link DbWriteOperation} to remove media belonging to {@code authority} from the 287 * picker db. 288 */ beginRemoveMediaOperation(String authority)289 public DbWriteOperation beginRemoveMediaOperation(String authority) { 290 return new RemoveMediaOperation(mDatabase, isLocal(authority)); 291 } 292 293 /** 294 * Returns {@link DbWriteOperation} to clear local media or all cloud media from the picker 295 * db. 296 * 297 * @param authority to determine whether local or cloud media should be cleared 298 */ beginResetMediaOperation(String authority)299 public DbWriteOperation beginResetMediaOperation(String authority) { 300 return new ResetMediaOperation(mDatabase, isLocal(authority)); 301 } 302 303 /** 304 * Returns {@link DbWriteOperation} to clear album media for a given albumId from the picker 305 * db. 306 * 307 * <p>The {@link DbWriteOperation} clears local or cloud album based on {@code authority} and 308 * {@code albumId}. If {@code albumId} is null, it clears all local or cloud albums based on 309 * {@code authority}. 310 * 311 * @param authority to determine whether local or cloud media should be cleared 312 */ beginResetAlbumMediaOperation(String authority, String albumId)313 public DbWriteOperation beginResetAlbumMediaOperation(String authority, String albumId) { 314 return new ResetAlbumOperation(mDatabase, isLocal(authority), albumId); 315 } 316 317 /** 318 * Returns {@link UpdateMediaOperation} to update media belonging to {@code authority} in the 319 * picker db. 320 * 321 * @param authority to determine whether local or cloud media should be updated 322 */ beginUpdateMediaOperation(String authority)323 public UpdateMediaOperation beginUpdateMediaOperation(String authority) { 324 return new UpdateMediaOperation(mDatabase, isLocal(authority)); 325 } 326 327 /** 328 * Represents an atomic write operation to the picker database. 329 * 330 * <p>This class is not thread-safe and is meant to be used within a single thread only. 331 */ 332 public abstract static class DbWriteOperation implements AutoCloseable { 333 334 private final SQLiteDatabase mDatabase; 335 private final boolean mIsLocal; 336 337 private boolean mIsSuccess = false; 338 DbWriteOperation(SQLiteDatabase database, boolean isLocal)339 private DbWriteOperation(SQLiteDatabase database, boolean isLocal) { 340 mDatabase = database; 341 mIsLocal = isLocal; 342 mDatabase.beginTransaction(); 343 } 344 345 /** 346 * Execute a write operation. 347 * 348 * @param cursor containing items to add/remove 349 * @return number of {@code cursor} items that were inserted/updated/deleted in the db 350 * @throws {@link IllegalStateException} if no DB transaction is active 351 */ execute(@ullable Cursor cursor)352 public int execute(@Nullable Cursor cursor) { 353 if (!mDatabase.inTransaction()) { 354 throw new IllegalStateException("No ongoing DB transaction."); 355 } 356 final String traceSectionName = getClass().getSimpleName() 357 + ".execute[" + (mIsLocal ? "local" : "cloud") + ']'; 358 Trace.beginSection(traceSectionName); 359 try { 360 return executeInternal(cursor); 361 } finally { 362 Trace.endSection(); 363 } 364 } 365 setSuccess()366 public void setSuccess() { 367 mIsSuccess = true; 368 } 369 370 @Override close()371 public void close() { 372 if (mDatabase.inTransaction()) { 373 if (mIsSuccess) { 374 mDatabase.setTransactionSuccessful(); 375 } else { 376 Log.w(TAG, "DB write transaction failed."); 377 } 378 mDatabase.endTransaction(); 379 } else { 380 throw new IllegalStateException("close() has already been called previously."); 381 } 382 } 383 executeInternal(@ullable Cursor cursor)384 abstract int executeInternal(@Nullable Cursor cursor); 385 getDatabase()386 SQLiteDatabase getDatabase() { 387 return mDatabase; 388 } 389 isLocal()390 boolean isLocal() { 391 return mIsLocal; 392 } 393 updateMedia(SQLiteQueryBuilder qb, ContentValues values, String[] selectionArgs)394 int updateMedia(SQLiteQueryBuilder qb, ContentValues values, 395 String[] selectionArgs) { 396 try { 397 if (qb.update(mDatabase, values, /* selection */ null, selectionArgs) > 0) { 398 return SUCCESS; 399 } else { 400 Log.v(TAG, "Failed to update picker db media. ContentValues: " + values); 401 return FAIL; 402 } 403 } catch (SQLiteConstraintException e) { 404 Log.v(TAG, "Failed to update picker db media. ContentValues: " + values, e); 405 return RETRY; 406 } 407 } 408 querySingleMedia(SQLiteQueryBuilder qb, String[] projection, String[] selectionArgs, int columnIndex)409 String querySingleMedia(SQLiteQueryBuilder qb, String[] projection, 410 String[] selectionArgs, int columnIndex) { 411 try (Cursor cursor = qb.query(mDatabase, projection, /* selection */ null, 412 selectionArgs, /* groupBy */ null, /* having */ null, 413 /* orderBy */ null)) { 414 if (cursor.moveToFirst()) { 415 return cursor.getString(columnIndex); 416 } 417 } 418 419 return null; 420 } 421 422 /** 423 * Returns the first date taken present in the columns affected by the DB write operation 424 * when this method is overridden. Otherwise, it returns Long.MIN_VALUE. 425 */ getFirstDateTakenMillis()426 public long getFirstDateTakenMillis() { 427 Log.e(TAG, "Method getFirstDateTakenMillis() is not overridden. " 428 + "It will always return Long.MIN_VALUE"); 429 return Long.MIN_VALUE; 430 } 431 } 432 433 /** 434 * Represents an atomic media update operation to the picker database. 435 * 436 * <p>This class is not thread-safe and is meant to be used within a single thread only. 437 */ 438 public static final class UpdateMediaOperation extends DbWriteOperation { 439 UpdateMediaOperation(SQLiteDatabase database, boolean isLocal)440 private UpdateMediaOperation(SQLiteDatabase database, boolean isLocal) { 441 super(database, isLocal); 442 } 443 444 /** 445 * Execute a media update operation. 446 * 447 * @param id id of the media to be updated 448 * @param contentValues key-value pairs indicating fields to be updated for the media 449 * @return boolean indicating success/failure of the update 450 * @throws {@link IllegalStateException} if no DB transaction is active 451 */ execute(String id, ContentValues contentValues)452 public boolean execute(String id, ContentValues contentValues) { 453 final SQLiteDatabase database = getDatabase(); 454 if (!database.inTransaction()) { 455 throw new IllegalStateException("No ongoing DB transaction."); 456 } 457 458 final SQLiteQueryBuilder qb = isLocal() ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD; 459 return qb.update(database, contentValues, /* selection */ null, new String[] {id}) > 0; 460 } 461 462 @Override executeInternal(@ullable Cursor cursor)463 int executeInternal(@Nullable Cursor cursor) { 464 throw new UnsupportedOperationException("Cursor updates are not supported."); 465 } 466 } 467 468 private static final class AddMediaOperation extends DbWriteOperation { 469 AddMediaOperation(SQLiteDatabase database, boolean isLocal)470 private AddMediaOperation(SQLiteDatabase database, boolean isLocal) { 471 super(database, isLocal); 472 } 473 474 @Override executeInternal(@ullable Cursor cursor)475 int executeInternal(@Nullable Cursor cursor) { 476 final boolean isLocal = isLocal(); 477 final SQLiteQueryBuilder qb = isLocal ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD; 478 int counter = 0; 479 480 if (cursor.getCount() > PAGE_SIZE) { 481 Log.w(TAG, 482 String.format("Expected a cursor page size of %d, but received a cursor " 483 + "with %d rows instead.", PAGE_SIZE, cursor.getCount())); 484 } 485 486 if (cursor.moveToFirst()) { 487 do { 488 ContentValues values = cursorToContentValue(cursor, isLocal); 489 490 String[] upsertArgs = {values.getAsString(isLocal ? KEY_LOCAL_ID 491 : KEY_CLOUD_ID)}; 492 if (upsertMedia(qb, values, upsertArgs) == SUCCESS) { 493 counter++; 494 continue; 495 } 496 497 // Because we want to prioritize visible local media over visible cloud media, 498 // we do the following if the upsert above failed 499 if (isLocal) { 500 // For local syncs, we attempt hiding the visible cloud media 501 String cloudId = getVisibleCloudIdFromDb(values.getAsString(KEY_LOCAL_ID)); 502 demoteCloudMediaToHidden(cloudId); 503 } else { 504 // For cloud syncs, we prepare an upsert as hidden cloud media 505 values.putNull(KEY_IS_VISIBLE); 506 } 507 508 // Now attempt upsert again, this should succeed 509 if (upsertMedia(qb, values, upsertArgs) == SUCCESS) { 510 counter++; 511 } 512 } while (cursor.moveToNext()); 513 } 514 515 return counter; 516 } 517 insertMedia(ContentValues values)518 private int insertMedia(ContentValues values) { 519 try { 520 if (QB_MATCH_ALL.insert(getDatabase(), values) > 0) { 521 return SUCCESS; 522 } else { 523 Log.v(TAG, "Failed to insert picker db media. ContentValues: " + values); 524 return FAIL; 525 } 526 } catch (SQLiteConstraintException e) { 527 Log.v(TAG, "Failed to insert picker db media. ContentValues: " + values, e); 528 return RETRY; 529 } 530 } 531 upsertMedia(SQLiteQueryBuilder qb, ContentValues values, String[] selectionArgs)532 private int upsertMedia(SQLiteQueryBuilder qb, 533 ContentValues values, String[] selectionArgs) { 534 int res = insertMedia(values); 535 if (res == RETRY) { 536 // Attempt equivalent of CONFLICT_REPLACE resolution 537 Log.v(TAG, "Retrying failed insert as update. ContentValues: " + values); 538 res = updateMedia(qb, values, selectionArgs); 539 } 540 541 return res; 542 } 543 demoteCloudMediaToHidden(@ullable String cloudId)544 private void demoteCloudMediaToHidden(@Nullable String cloudId) { 545 if (cloudId == null) { 546 return; 547 } 548 549 final String[] updateArgs = new String[] {cloudId}; 550 if (updateMedia(QB_MATCH_CLOUD, CONTENT_VALUE_HIDDEN, updateArgs) == SUCCESS) { 551 Log.d(TAG, "Demoted picker db media item to hidden. CloudId: " + cloudId); 552 } 553 } 554 getVisibleCloudIdFromDb(String localId)555 private String getVisibleCloudIdFromDb(String localId) { 556 final String[] cloudIdProjection = new String[] {KEY_CLOUD_ID}; 557 final String[] queryArgs = new String[] {localId}; 558 return querySingleMedia(QB_MATCH_VISIBLE_LOCAL, cloudIdProjection, queryArgs, 559 /* columnIndex */ 0); 560 } 561 } 562 563 private static final class RemoveMediaOperation extends DbWriteOperation { 564 private static final String[] sDateTakenProjection = new String[] {KEY_DATE_TAKEN_MS}; 565 private long mFirstDateTakenMillis = Long.MIN_VALUE; 566 RemoveMediaOperation(SQLiteDatabase database, boolean isLocal)567 private RemoveMediaOperation(SQLiteDatabase database, boolean isLocal) { 568 super(database, isLocal); 569 } 570 571 @Override executeInternal(@ullable Cursor cursor)572 int executeInternal(@Nullable Cursor cursor) { 573 final boolean isLocal = isLocal(); 574 final SQLiteQueryBuilder qb = isLocal ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD; 575 576 int counter = 0; 577 578 while (cursor.moveToNext()) { 579 if (cursor.isFirst()) { 580 updateFirstDateTakenMillis(cursor, isLocal); 581 } 582 583 // Need to fetch the local_id before delete because for cloud items 584 // we need a db query to fetch the local_id matching the id received from 585 // cursor (cloud_id). 586 final String localId = getLocalIdFromCursorOrDb(cursor, isLocal); 587 588 // Delete cloud/local row 589 final int idIndex = cursor.getColumnIndex( 590 CloudMediaProviderContract.MediaColumns.ID); 591 final String[] deleteArgs = {cursor.getString(idIndex)}; 592 if (qb.delete(getDatabase(), /* selection */ null, deleteArgs) > 0) { 593 counter++; 594 } 595 596 promoteCloudMediaToVisible(localId); 597 } 598 599 return counter; 600 } 601 602 @Override getFirstDateTakenMillis()603 public long getFirstDateTakenMillis() { 604 return mFirstDateTakenMillis; 605 } 606 promoteCloudMediaToVisible(@ullable String localId)607 private void promoteCloudMediaToVisible(@Nullable String localId) { 608 if (localId == null) { 609 return; 610 } 611 612 final String[] idProjection = new String[] {KEY_ID}; 613 final String[] queryArgs = {localId}; 614 // First query for an exact row id matching the criteria for promotion so that we don't 615 // attempt promoting multiple hidden cloud rows matching the |localId| 616 final String id = querySingleMedia(QB_MATCH_LOCAL, idProjection, queryArgs, 617 /* columnIndex */ 0); 618 if (id == null) { 619 Log.w(TAG, "Unable to promote cloud media with localId: " + localId); 620 return; 621 } 622 623 final String[] updateArgs = {id}; 624 if (updateMedia(QB_MATCH_ID, CONTENT_VALUE_VISIBLE, updateArgs) == SUCCESS) { 625 Log.d(TAG, "Promoted picker db media item to visible. LocalId: " + localId); 626 } 627 } 628 getLocalIdFromCursorOrDb(Cursor cursor, boolean isLocal)629 private String getLocalIdFromCursorOrDb(Cursor cursor, boolean isLocal) { 630 final String id = cursor.getString(0); 631 632 if (isLocal) { 633 // For local, id in cursor is already local_id 634 return id; 635 } else { 636 // For cloud, we need to query db with cloud_id from cursor to fetch local_id 637 final String[] localIdProjection = new String[] {KEY_LOCAL_ID}; 638 final String[] queryArgs = new String[] {id}; 639 return querySingleMedia(QB_MATCH_CLOUD, localIdProjection, queryArgs, 640 /* columnIndex */ 0); 641 } 642 } 643 updateFirstDateTakenMillis(Cursor inputCursor, boolean isLocal)644 private void updateFirstDateTakenMillis(Cursor inputCursor, boolean isLocal) { 645 final int idIndex = inputCursor 646 .getColumnIndex(CloudMediaProviderContract.MediaColumns.ID); 647 if (idIndex < 0) { 648 Log.e(TAG, "Id is not present in the cursor"); 649 return; 650 } 651 652 final String id = inputCursor.getString(idIndex); 653 if (TextUtils.isEmpty((id))) { 654 Log.e(TAG, "Input id is empty"); 655 return; 656 } 657 658 final SQLiteQueryBuilder qb = isLocal ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD; 659 final String[] queryArgs = new String[]{id}; 660 661 try (Cursor outputCursor = qb.query(getDatabase(), sDateTakenProjection, 662 /* selection */ null, queryArgs, /* groupBy */ null, /* having */ null, 663 /* orderBy */ null)) { 664 if (outputCursor.moveToFirst()) { 665 mFirstDateTakenMillis = outputCursor.getLong(/* columnIndex */ 0); 666 } else { 667 Log.e(TAG, "Could not get first date taken millis for media id: " + id); 668 } 669 } 670 } 671 } 672 673 private static final class ResetMediaOperation extends DbWriteOperation { 674 ResetMediaOperation(SQLiteDatabase database, boolean isLocal)675 private ResetMediaOperation(SQLiteDatabase database, boolean isLocal) { 676 super(database, isLocal); 677 } 678 679 @Override executeInternal(@ullable Cursor unused)680 int executeInternal(@Nullable Cursor unused) { 681 final boolean isLocal = isLocal(); 682 final SQLiteQueryBuilder qb = createMediaQueryBuilder(); 683 684 if (isLocal) { 685 qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); 686 } else { 687 qb.appendWhereStandalone(WHERE_NOT_NULL_CLOUD_ID); 688 } 689 690 SQLiteDatabase database = getDatabase(); 691 int counter = qb.delete(database, /* selection */ null, /* selectionArgs */ null); 692 693 if (isLocal) { 694 // If we reset local media, we need to promote cloud media items 695 // Ignore conflicts in case we have multiple cloud_ids mapped to the 696 // same local_id. Promoting either is fine. 697 database.updateWithOnConflict(TABLE_MEDIA, CONTENT_VALUE_VISIBLE, /* where */ null, 698 /* whereClause */ null, SQLiteDatabase.CONFLICT_IGNORE); 699 } 700 701 return counter; 702 } 703 } 704 705 /** Filter for {@link #queryMedia} to modify returned results */ 706 public static class QueryFilter { 707 private final int mLimit; 708 private final long mDateTakenBeforeMs; 709 private final long mDateTakenAfterMs; 710 private final long mId; 711 private final String mAlbumId; 712 private final long mSizeBytes; 713 private final String[] mMimeTypes; 714 private final boolean mIsFavorite; 715 private final boolean mIsVideo; 716 public boolean mIsLocalOnly; 717 private int mPageSize; 718 private String mPageToken; 719 private final boolean mShouldScreenSelectionUris; 720 private List<String> mLocalPreSelectedIds; 721 private List<String> mCloudPreSelectedIds; 722 QueryFilter(int limit, long dateTakenBeforeMs, long dateTakenAfterMs, long id, String albumId, long sizeBytes, String[] mimeTypes, boolean isFavorite, boolean isVideo, boolean isLocalOnly, boolean shouldScreenSelectionUris, List<String> localPreSelectedIds, List<String> cloudPreSelectedIds, int pageSize, String pageToken)723 private QueryFilter(int limit, long dateTakenBeforeMs, long dateTakenAfterMs, long id, 724 String albumId, long sizeBytes, String[] mimeTypes, boolean isFavorite, 725 boolean isVideo, boolean isLocalOnly, boolean shouldScreenSelectionUris, 726 List<String> localPreSelectedIds, List<String> cloudPreSelectedIds, int pageSize, 727 String pageToken) { 728 this.mLimit = limit; 729 this.mDateTakenBeforeMs = dateTakenBeforeMs; 730 this.mDateTakenAfterMs = dateTakenAfterMs; 731 this.mId = id; 732 this.mAlbumId = albumId; 733 this.mSizeBytes = sizeBytes; 734 this.mMimeTypes = mimeTypes; 735 this.mIsFavorite = isFavorite; 736 this.mIsVideo = isVideo; 737 this.mIsLocalOnly = isLocalOnly; 738 this.mShouldScreenSelectionUris = shouldScreenSelectionUris; 739 this.mLocalPreSelectedIds = localPreSelectedIds; 740 this.mCloudPreSelectedIds = cloudPreSelectedIds; 741 this.mPageSize = pageSize; 742 this.mPageToken = pageToken; 743 } 744 } 745 746 /** Builder for {@link Query} filter. */ 747 public static class QueryFilterBuilder { 748 public static final int INT_DEFAULT = -1; 749 public static final long LONG_DEFAULT = -1; 750 public static final String STRING_DEFAULT = null; 751 public static final String[] STRING_ARRAY_DEFAULT = null; 752 public static final boolean BOOLEAN_DEFAULT = false; 753 754 public static final List LIST_DEFAULT = null; 755 public static final int LIMIT_DEFAULT = 1000; 756 757 private final int limit; 758 private long mDateTakenBeforeMs = Long.MIN_VALUE; 759 private long mDateTakenAfterMs = Long.MIN_VALUE; 760 private long id = LONG_DEFAULT; 761 private String albumId = STRING_DEFAULT; 762 private long sizeBytes = LONG_DEFAULT; 763 private String[] mimeTypes = STRING_ARRAY_DEFAULT; 764 private boolean isFavorite = BOOLEAN_DEFAULT; 765 private boolean mIsVideo = BOOLEAN_DEFAULT; 766 private boolean mIsLocalOnly = BOOLEAN_DEFAULT; 767 private int mPageSize = INT_DEFAULT; 768 private String mPageToken = STRING_DEFAULT; 769 private boolean mShouldScreenSelectionUris = BOOLEAN_DEFAULT; 770 private List<String> mLocalPreSelectedIds = LIST_DEFAULT; 771 private List<String> mCloudPreSelectedIds = LIST_DEFAULT; 772 QueryFilterBuilder(int limit)773 public QueryFilterBuilder(int limit) { 774 this.limit = limit; 775 } 776 setDateTakenBeforeMs(long dateTakenBeforeMs)777 public QueryFilterBuilder setDateTakenBeforeMs(long dateTakenBeforeMs) { 778 this.mDateTakenBeforeMs = dateTakenBeforeMs; 779 return this; 780 } 781 setDateTakenAfterMs(long dateTakenAfterMs)782 public QueryFilterBuilder setDateTakenAfterMs(long dateTakenAfterMs) { 783 this.mDateTakenAfterMs = dateTakenAfterMs; 784 return this; 785 } 786 787 /** 788 * The {@code id} helps break ties with db rows having the same {@code dateTakenAfterMs} or 789 * {@code dateTakenBeforeMs}. 790 * 791 * If {@code dateTakenAfterMs} is specified, all returned items are equal or more 792 * recent than {@code dateTakenAfterMs} and have a picker db id equal or greater than 793 * {@code id} for ties. 794 * 795 * If {@code dateTakenBeforeMs} is specified, all returned items are either strictly older 796 * than {@code dateTakenBeforeMs} or have a picker db id strictly less than {@code id} 797 * for ties. 798 */ setId(long id)799 public QueryFilterBuilder setId(long id) { 800 this.id = id; 801 return this; 802 } 803 setAlbumId(String albumId)804 public QueryFilterBuilder setAlbumId(String albumId) { 805 this.albumId = albumId; 806 return this; 807 } 808 setSizeBytes(long sizeBytes)809 public QueryFilterBuilder setSizeBytes(long sizeBytes) { 810 this.sizeBytes = sizeBytes; 811 return this; 812 } 813 setMimeTypes(String[] mimeTypes)814 public QueryFilterBuilder setMimeTypes(String[] mimeTypes) { 815 this.mimeTypes = mimeTypes; 816 return this; 817 } 818 819 /** 820 * Sets the shouldScreenSelectionUris parameter. 821 */ setShouldScreenSelectionUris(boolean shouldScreenSelectionUris)822 public QueryFilterBuilder setShouldScreenSelectionUris(boolean shouldScreenSelectionUris) { 823 this.mShouldScreenSelectionUris = shouldScreenSelectionUris; 824 return this; 825 } 826 827 /** 828 * Sets the local id selection filter. 829 */ setLocalPreSelectedIds(List<String> localPreSelectedIds)830 public QueryFilterBuilder setLocalPreSelectedIds(List<String> localPreSelectedIds) { 831 this.mLocalPreSelectedIds = localPreSelectedIds; 832 return this; 833 } 834 835 /** 836 * Sets the cloud id selection filter. 837 */ setCloudPreSelectionIds(List<String> cloudPreSelectedIds)838 public QueryFilterBuilder setCloudPreSelectionIds(List<String> cloudPreSelectedIds) { 839 this.mCloudPreSelectedIds = cloudPreSelectedIds; 840 return this; 841 } 842 843 /** 844 * If {@code isFavorite} is {@code true}, the {@link QueryFilter} returns only 845 * favorited items, however, if it is {@code false}, it returns all items including 846 * favorited and non-favorited items. 847 */ setIsFavorite(boolean isFavorite)848 public QueryFilterBuilder setIsFavorite(boolean isFavorite) { 849 this.isFavorite = isFavorite; 850 return this; 851 } 852 853 /** 854 * If {@code isVideo} is {@code true}, the {@link QueryFilter} returns only 855 * video items, however, if it is {@code false}, it returns all items including 856 * video and non-video items. 857 */ setIsVideo(boolean isVideo)858 public QueryFilterBuilder setIsVideo(boolean isVideo) { 859 this.mIsVideo = isVideo; 860 return this; 861 } 862 863 /** 864 * If {@code isLocalOnly} is {@code true}, the {@link QueryFilter} returns only 865 * local items. 866 */ setIsLocalOnly(boolean isLocalOnly)867 public QueryFilterBuilder setIsLocalOnly(boolean isLocalOnly) { 868 this.mIsLocalOnly = isLocalOnly; 869 return this; 870 } 871 872 /** 873 * Sets the page size. 874 */ setPageSize(int pageSize)875 public QueryFilterBuilder setPageSize(int pageSize) { 876 mPageSize = pageSize; 877 return this; 878 } 879 880 /** 881 * Sets the page token. 882 */ setPageToken(String pageToken)883 public QueryFilterBuilder setPageToken(String pageToken) { 884 mPageToken = pageToken; 885 return this; 886 } 887 build()888 public QueryFilter build() { 889 return new QueryFilter(limit, mDateTakenBeforeMs, mDateTakenAfterMs, id, albumId, 890 sizeBytes, mimeTypes, isFavorite, mIsVideo, mIsLocalOnly, 891 mShouldScreenSelectionUris, mLocalPreSelectedIds, mCloudPreSelectedIds, 892 mPageSize, mPageToken); 893 } 894 } 895 896 /** 897 * Returns sorted and deduped cloud and local media items from the picker db. 898 * 899 * Returns a {@link Cursor} containing picker db media rows with columns as 900 * {@link CloudMediaProviderContract.MediaColumns}. 901 * 902 * The result is sorted in reverse chronological order, i.e. newest first, up to a maximum of 903 * {@code limit}. They can also be filtered with {@code query}. 904 */ queryMediaForUi(QueryFilter query)905 public Cursor queryMediaForUi(QueryFilter query) { 906 boolean isAnyIdsForSelectionPresent = 907 (query.mLocalPreSelectedIds != null && !query.mLocalPreSelectedIds.isEmpty()) || ( 908 query.mCloudPreSelectedIds != null 909 && !query.mCloudPreSelectedIds.isEmpty()); 910 if (isAnyIdsForSelectionPresent) { 911 Log.d(TAG, "Query is being performed with id selection"); 912 return queryMediaForUiWithIdSelection(query); 913 } else if (query.mShouldScreenSelectionUris) { 914 Log.d(TAG, "No ids present for selection, returning empty cursor"); 915 // If no ids are present for the query selection but the pre-selection is enabled 916 // (indicated by the flag mShouldScreenSelectionUris) then an empty cursor should be 917 // returned). 918 return new MatrixCursor(getCloudMediaProjectionLocked(), 0); 919 } 920 921 final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder(); 922 final String[] selectionArgs = buildSelectionArgs(qb, query); 923 if (query.mIsLocalOnly) { 924 return queryMediaForUi(qb, selectionArgs, query.mLimit, /* isLocalOnly*/true, 925 TABLE_MEDIA, /* cloudProvider*/ null); 926 } 927 928 // If the cloud sync is in progress or the cloud provider has changed but a sync has not 929 // been completed and committed, {@link PickerDBFacade.mCloudProvider} will be 930 // {@code null}. 931 final String cloudProvider = getCloudProvider(); 932 933 return queryMediaForUi(qb, selectionArgs, query.mLimit, query.mIsLocalOnly, 934 TABLE_MEDIA, cloudProvider); 935 } 936 937 queryMediaForUiWithIdSelection(QueryFilter query)938 private Cursor queryMediaForUiWithIdSelection(QueryFilter query) { 939 // Since 'WHERE IN' clause has an upper limit of items that can be included in the sql 940 // statement and also there is an upper limit to the size of the sql statement. 941 // Splitting the query into multiple smaller ones. 942 // This query will now process 150 items in a batch. 943 List<Cursor> resultCursor = new ArrayList<>(); 944 List<String> localIds = query.mLocalPreSelectedIds == null ? null : new ArrayList<>( 945 query.mLocalPreSelectedIds); 946 List<String> cloudIds = query.mCloudPreSelectedIds == null ? null : new ArrayList<>( 947 query.mCloudPreSelectedIds); 948 949 batchedQueryForIdSelection(query, resultCursor, null, localIds); 950 if (!query.mIsLocalOnly) { 951 batchedQueryForIdSelection(query, resultCursor, getCloudProvider(), 952 cloudIds); 953 } 954 955 Cursor[] resultCursorsAsArray = resultCursor.toArray(new Cursor[0]); 956 if (resultCursorsAsArray.length == 0) { 957 // If after query no cursor has been added to the result, then return an empty cursor. 958 return new MatrixCursor(getCloudMediaProjectionLocked(), 0); 959 } 960 return new MergeCursor(resultCursorsAsArray); 961 } 962 batchedQueryForIdSelection(QueryFilter query, List<Cursor> resultCursor, String cloudAuthority, List<String> selectionIds)963 private void batchedQueryForIdSelection(QueryFilter query, List<Cursor> resultCursor, 964 String cloudAuthority, List<String> selectionIds) { 965 if (selectionIds == null || selectionIds.isEmpty()) { 966 return; 967 } 968 List<List<String>> listOfSelectionArgsForLocalId = splitArrayList( 969 selectionIds, 970 /* number of ids per query */ 150); 971 972 for (List<String> selectionArgForLocalPreSelectedIds : listOfSelectionArgsForLocalId) { 973 final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder(); 974 if (cloudAuthority == null) { 975 query.mLocalPreSelectedIds = selectionArgForLocalPreSelectedIds; 976 query.mCloudPreSelectedIds = QueryFilterBuilder.LIST_DEFAULT; 977 } else { 978 query.mCloudPreSelectedIds = selectionArgForLocalPreSelectedIds; 979 query.mLocalPreSelectedIds = QueryFilterBuilder.LIST_DEFAULT; 980 } 981 final String[] selectionArgs = buildSelectionArgs(qb, query); 982 resultCursor.add( 983 queryMediaForUi(qb, selectionArgs, query.mLimit, cloudAuthority == null, 984 TABLE_MEDIA, /* cloud provider */cloudAuthority)); 985 } 986 } 987 splitArrayList(List<T> list, int chunkSize)988 private static <T> List<List<T>> splitArrayList(List<T> list, int chunkSize) { 989 List<List<T>> subLists = new ArrayList<>(); 990 for (int i = 0; i < list.size(); i += chunkSize) { 991 subLists.add(list.subList(i, Math.min(i + chunkSize, list.size()))); 992 } 993 return subLists; 994 } 995 996 /** 997 * Returns sorted cloud or local media items from the picker db for a given album (either cloud 998 * or local). 999 * 1000 * Returns a {@link Cursor} containing picker db media rows with columns as 1001 * {@link CloudMediaProviderContract#MediaColumns} except for is_favorites column because that 1002 * column is only used for fetching the Favorites album. 1003 * 1004 * The result is sorted in reverse chronological order, i.e. newest first, up to a maximum of 1005 * {@code limit}. They can also be filtered with {@code query}. 1006 */ queryAlbumMediaForUi(@onNull QueryFilter query, @NonNull String authority)1007 public Cursor queryAlbumMediaForUi(@NonNull QueryFilter query, @NonNull String authority) { 1008 final SQLiteQueryBuilder qb = createAlbumMediaQueryBuilder(isLocal(authority)); 1009 final String[] selectionArgs = buildSelectionArgs(qb, query); 1010 1011 return queryMediaForUi(qb, selectionArgs, query.mLimit, query.mIsLocalOnly, 1012 TABLE_ALBUM_MEDIA, authority); 1013 } 1014 1015 /** 1016 * Returns an individual cloud or local item from the picker db matching {@code authority} and 1017 * {@code mediaId}. 1018 * 1019 * Returns a {@link Cursor} containing picker db media rows with columns as {@code projection}, 1020 * a subset of {@link PickerMediaColumns}. 1021 */ queryMediaIdForApps(String pickerSegmentType, String authority, String mediaId, @NonNull String[] projection)1022 public Cursor queryMediaIdForApps(String pickerSegmentType, String authority, String mediaId, 1023 @NonNull String[] projection) { 1024 final String[] selectionArgs = new String[] { mediaId }; 1025 final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder(); 1026 if (isLocal(authority)) { 1027 qb.appendWhereStandalone(WHERE_LOCAL_ID); 1028 } else { 1029 qb.appendWhereStandalone(WHERE_CLOUD_ID); 1030 } 1031 1032 if (authority.equals(mLocalProvider)) { 1033 return queryMediaIdForAppsLocked(qb, projection, selectionArgs, pickerSegmentType); 1034 } 1035 1036 try (CloseableReentrantLock ignored = mPickerSyncLockManager 1037 .lock(PickerSyncLockManager.DB_CLOUD_LOCK)) { 1038 if (authority.equals(mCloudProvider)) { 1039 return queryMediaIdForAppsLocked(qb, projection, selectionArgs, pickerSegmentType); 1040 } 1041 } 1042 1043 return null; 1044 } 1045 queryMediaIdForAppsLocked(@onNull SQLiteQueryBuilder qb, @NonNull String[] projection, @NonNull String[] selectionArgs, String pickerSegmentType)1046 private Cursor queryMediaIdForAppsLocked(@NonNull SQLiteQueryBuilder qb, 1047 @NonNull String[] projection, @NonNull String[] selectionArgs, 1048 String pickerSegmentType) { 1049 return qb.query(mDatabase, getMediaStoreProjectionLocked(projection, pickerSegmentType), 1050 /* selection */ null, selectionArgs, /* groupBy */ null, /* having */ null, 1051 /* orderBy */ null, /* limitStr */ null); 1052 } 1053 1054 /** 1055 * Returns empty {@link Cursor} if there are no items matching merged album constraints {@code 1056 * query} 1057 */ getMergedAlbums(QueryFilter query, String cloudProvider)1058 public Cursor getMergedAlbums(QueryFilter query, String cloudProvider) { 1059 final MatrixCursor c = new MatrixCursor(AlbumColumns.ALL_PROJECTION); 1060 List<String> mergedAlbums = List.of(ALBUM_ID_FAVORITES, ALBUM_ID_VIDEOS); 1061 for (String albumId : mergedAlbums) { 1062 List<String> selectionArgs = new ArrayList<>(); 1063 final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder(); 1064 1065 if (query.mIsLocalOnly) { 1066 qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); 1067 } 1068 1069 if (albumId.equals(ALBUM_ID_FAVORITES)) { 1070 qb.appendWhereStandalone(getWhereForFavorite(query.mIsLocalOnly)); 1071 } else if (albumId.equals(ALBUM_ID_VIDEOS)) { 1072 qb.appendWhereStandalone(WHERE_MIME_TYPE); 1073 selectionArgs.add("video/%"); 1074 } 1075 addMimeTypesToQueryBuilderAndSelectionArgs(qb, selectionArgs, query.mMimeTypes); 1076 1077 Cursor cursor = qb.query(mDatabase, getMergedAlbumProjection(), /* selection */ null, 1078 selectionArgs.toArray(new String[0]), /* groupBy */ null, /* having */ null, 1079 /* orderBy */ null, /* limit */ null); 1080 1081 if (cursor == null || !cursor.moveToFirst()) { 1082 continue; 1083 } 1084 1085 long count = getCursorLong(cursor, CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT); 1086 1087 // We want to display empty merged folder in case of cloud picker. 1088 if (shouldHideMergedAlbum(query, albumId, cloudProvider, count)) { 1089 continue; 1090 } 1091 1092 final String[] projectionValue = new String[]{ 1093 /* albumId */ albumId, 1094 getCursorString(cursor, AlbumColumns.DATE_TAKEN_MILLIS), 1095 /* displayName */ albumId, 1096 getCursorString(cursor, AlbumColumns.MEDIA_COVER_ID), 1097 String.valueOf(count), 1098 getCursorString(cursor, AlbumColumns.AUTHORITY), 1099 }; 1100 c.addRow(projectionValue); 1101 } 1102 return c; 1103 } 1104 shouldHideMergedAlbum(QueryFilter query, String albumId, String cloudProvider, long count)1105 private static boolean shouldHideMergedAlbum(QueryFilter query, String albumId, 1106 String cloudProvider, long count) { 1107 final boolean isAlbumEmpty = (count == 0); 1108 final boolean shouldNotShowCloudItems = (query.mIsLocalOnly || cloudProvider == null); 1109 1110 return (isAlbumEmpty && (shouldNotShowCloudItems || hideVideosAlbum(query, albumId))); 1111 } 1112 hideVideosAlbum(QueryFilter query, String albumId)1113 private static boolean hideVideosAlbum(QueryFilter query, String albumId) { 1114 String[] mimeTypes = query.mMimeTypes; 1115 if (!albumId.equals(ALBUM_ID_VIDEOS) || mimeTypes == null) { 1116 return false; 1117 } 1118 for (String mimeType : mimeTypes) { 1119 if (MimeUtils.isVideoMimeType(mimeType)) { 1120 return false; 1121 } 1122 } 1123 return true; 1124 } 1125 getMergedAlbumProjection()1126 private String[] getMergedAlbumProjection() { 1127 return new String[] { 1128 "COUNT(" + KEY_ID + ") AS " + CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT, 1129 "MAX(" + KEY_DATE_TAKEN_MS + ") AS " 1130 + CloudMediaProviderContract.AlbumColumns.DATE_TAKEN_MILLIS, 1131 String.format("IFNULL(%s, %s) AS %s", KEY_CLOUD_ID, 1132 KEY_LOCAL_ID, CloudMediaProviderContract.AlbumColumns.MEDIA_COVER_ID), 1133 // Note that we prefer cloud_id over local_id here. This logic is for computing the 1134 // projection and doesn't affect the filtering of results which has already been 1135 // done and ensures that only is_visible=true items are returned. 1136 // Here, we need to distinguish between cloud+local and local-only items to 1137 // determine the correct authority. 1138 String.format("CASE WHEN %s IS NULL THEN '%s' ELSE '%s' END AS %s", 1139 KEY_CLOUD_ID, mLocalProvider, mCloudProvider, AlbumColumns.AUTHORITY) 1140 }; 1141 } 1142 isLocal(String authority)1143 private boolean isLocal(String authority) { 1144 return mLocalProvider.equals(authority); 1145 } 1146 1147 /** 1148 * Returns sorted and deduped cloud and local media or album content items from the picker db. 1149 */ queryMediaForUi(SQLiteQueryBuilder qb, String[] selectionArgs, int limit, boolean isLocalOnly, String tableName, String authority)1150 private Cursor queryMediaForUi(SQLiteQueryBuilder qb, String[] selectionArgs, 1151 int limit, boolean isLocalOnly, String tableName, String authority) { 1152 // Use the <table>.<column> form to order _id to avoid ordering against the projection '_id' 1153 final String orderBy = getOrderClause(tableName); 1154 final String limitStr = String.valueOf(limit); 1155 1156 if (isLocalOnly) { 1157 qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); 1158 return queryMediaForUiLocked(qb, selectionArgs, orderBy, limitStr); 1159 } 1160 1161 // Hold lock while checking the cloud provider and querying so that cursor extras containing 1162 // the cloud provider is consistent with the cursor results and doesn't race with 1163 // #setCloudProvider 1164 try (CloseableReentrantLock ignored = mPickerSyncLockManager 1165 .lock(PickerSyncLockManager.DB_CLOUD_LOCK)) { 1166 if (mCloudProvider == null || !Objects.equals(mCloudProvider, authority)) { 1167 // TODO(b/278086344): If cloud provider is null or has changed from what we received 1168 // from the UI, skip all cloud items in the picker db. 1169 qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); 1170 } 1171 return queryMediaForUiLocked(qb, selectionArgs, orderBy, limitStr); 1172 } 1173 } 1174 queryMediaForUiLocked(SQLiteQueryBuilder qb, String[] selectionArgs, String orderBy, String limitStr)1175 private Cursor queryMediaForUiLocked(SQLiteQueryBuilder qb, String[] selectionArgs, 1176 String orderBy, String limitStr) { 1177 return qb.query(mDatabase, getCloudMediaProjectionLocked(), /* selection */ null, 1178 selectionArgs, /* groupBy */ null, /* having */ null, orderBy, limitStr); 1179 } 1180 getOrderClause(String tableName)1181 private static String getOrderClause(String tableName) { 1182 return "date_taken_ms DESC," + tableName + "._id DESC"; 1183 } 1184 getCloudMediaProjectionLocked()1185 private String[] getCloudMediaProjectionLocked() { 1186 return new String[] { 1187 getProjectionAuthorityLocked(), 1188 getProjectionDataLocked(MediaColumns.DATA, PickerUriResolver.PICKER_SEGMENT), 1189 getProjectionId(MediaColumns.ID), 1190 // The id in the picker.db table represents the row id. This is used in UI pagination. 1191 getProjectionSimple(KEY_ID, Item.ROW_ID), 1192 getProjectionSimple(KEY_DATE_TAKEN_MS, MediaColumns.DATE_TAKEN_MILLIS), 1193 getProjectionSimple(KEY_SYNC_GENERATION, MediaColumns.SYNC_GENERATION), 1194 getProjectionSimple(KEY_SIZE_BYTES, MediaColumns.SIZE_BYTES), 1195 getProjectionSimple(KEY_DURATION_MS, MediaColumns.DURATION_MILLIS), 1196 getProjectionSimple(KEY_MIME_TYPE, MediaColumns.MIME_TYPE), 1197 getProjectionSimple(KEY_STANDARD_MIME_TYPE_EXTENSION, 1198 MediaColumns.STANDARD_MIME_TYPE_EXTENSION), 1199 }; 1200 } 1201 getMediaStoreProjectionLocked(String[] columns, String pickerSegmentType)1202 private String[] getMediaStoreProjectionLocked(String[] columns, String pickerSegmentType) { 1203 final String[] projection = new String[columns.length]; 1204 1205 for (int i = 0; i < projection.length; i++) { 1206 switch (columns[i]) { 1207 case PickerMediaColumns.DATA: 1208 projection[i] = getProjectionDataLocked(PickerMediaColumns.DATA, 1209 pickerSegmentType); 1210 break; 1211 case PickerMediaColumns.DISPLAY_NAME: 1212 projection[i] = 1213 getProjectionSimple( 1214 getDisplayNameSql(), PickerMediaColumns.DISPLAY_NAME); 1215 break; 1216 case PickerMediaColumns.MIME_TYPE: 1217 projection[i] = 1218 getProjectionSimple(KEY_MIME_TYPE, PickerMediaColumns.MIME_TYPE); 1219 break; 1220 case PickerMediaColumns.DATE_TAKEN: 1221 projection[i] = 1222 getProjectionSimple(KEY_DATE_TAKEN_MS, PickerMediaColumns.DATE_TAKEN); 1223 break; 1224 case PickerMediaColumns.SIZE: 1225 projection[i] = getProjectionSimple(KEY_SIZE_BYTES, PickerMediaColumns.SIZE); 1226 break; 1227 case PickerMediaColumns.DURATION_MILLIS: 1228 projection[i] = 1229 getProjectionSimple( 1230 KEY_DURATION_MS, PickerMediaColumns.DURATION_MILLIS); 1231 break; 1232 case PickerMediaColumns.HEIGHT: 1233 projection[i] = getProjectionSimple(KEY_HEIGHT, PickerMediaColumns.HEIGHT); 1234 break; 1235 case PickerMediaColumns.WIDTH: 1236 projection[i] = getProjectionSimple(KEY_WIDTH, PickerMediaColumns.WIDTH); 1237 break; 1238 case PickerMediaColumns.ORIENTATION: 1239 projection[i] = 1240 getProjectionSimple(KEY_ORIENTATION, PickerMediaColumns.ORIENTATION); 1241 break; 1242 default: 1243 projection[i] = getProjectionSimple("NULL", columns[i]); 1244 // Ignore unsupported columns; we do not throw error here to support 1245 // backward compatibility 1246 Log.w(TAG, "Unexpected Picker column: " + columns[i]); 1247 } 1248 } 1249 1250 return projection; 1251 } 1252 getProjectionAuthorityLocked()1253 private String getProjectionAuthorityLocked() { 1254 // Note that we prefer cloud_id over local_id here. It's important to remember that this 1255 // logic is for computing the projection and doesn't affect the filtering of results which 1256 // has already been done and ensures that only is_visible=true items are returned. 1257 // Here, we need to distinguish between cloud+local and local-only items to determine the 1258 // correct authority. Checking whether cloud_id IS NULL distinguishes the former from the 1259 // latter. 1260 return String.format("CASE WHEN %s IS NULL THEN '%s' ELSE '%s' END AS %s", 1261 KEY_CLOUD_ID, mLocalProvider, mCloudProvider, MediaColumns.AUTHORITY); 1262 } 1263 getProjectionDataLocked(String asColumn, String pickerSegmentType)1264 private String getProjectionDataLocked(String asColumn, String pickerSegmentType) { 1265 // _data format: 1266 // /sdcard/.transforms/synthetic/picker/<user-id>/<authority>/media/<display-name> 1267 // See PickerUriResolver#getMediaUri 1268 final String authority = String.format("CASE WHEN %s IS NULL THEN '%s' ELSE '%s' END", 1269 KEY_CLOUD_ID, mLocalProvider, mCloudProvider); 1270 final String fullPath = "'" + getPickerPath(pickerSegmentType) + "/'" 1271 + "||" + "'" + MediaStore.MY_USER_ID + "/'" 1272 + "||" + authority 1273 + "||" + "'/" + CloudMediaProviderContract.URI_PATH_MEDIA + "/'" 1274 + "||" + getDisplayNameSql(); 1275 return String.format("%s AS %s", fullPath, asColumn); 1276 } 1277 getPickerPath(String pickerSegmentType)1278 private String getPickerPath(String pickerSegmentType) { 1279 // Intentionally use /sdcard path so that the receiving app resolves it to its per-user 1280 // external storage path, e.g. /storage/emulated/<userid>. That way FUSE cross-user 1281 // access is not required for picker paths sent across users 1282 return "/sdcard/" + getPickerRelativePath(pickerSegmentType); 1283 } 1284 getProjectionId(String asColumn)1285 private String getProjectionId(String asColumn) { 1286 // We prefer cloud_id first and it only matters for cloud+local items. For those, the row 1287 // will already be associated with a cloud authority, see #getProjectionAuthorityLocked. 1288 // Note that hidden cloud+local items will not be returned in the query, so there's no 1289 // concern of preferring the cloud_id in a cloud+local item over the local_id in a 1290 // local-only item. 1291 return String.format("IFNULL(%s, %s) AS %s", KEY_CLOUD_ID, KEY_LOCAL_ID, asColumn); 1292 } 1293 getProjectionSimple(String dbColumn, String column)1294 private static String getProjectionSimple(String dbColumn, String column) { 1295 return String.format("%s AS %s", dbColumn, column); 1296 } 1297 getDisplayNameSql()1298 private String getDisplayNameSql() { 1299 // _display_name format: 1300 // <media-id>.<file-extension> 1301 // See comment in #getProjectionAuthorityLocked for why cloud_id is preferred over local_id 1302 final String mediaId = String.format("IFNULL(%s, %s)", KEY_CLOUD_ID, KEY_LOCAL_ID); 1303 final String fileExtension = String.format("_GET_EXTENSION(%s)", KEY_MIME_TYPE); 1304 1305 return mediaId + "||" + fileExtension; 1306 } 1307 cursorToContentValue(Cursor cursor, boolean isLocal)1308 private static ContentValues cursorToContentValue(Cursor cursor, boolean isLocal) { 1309 return cursorToContentValue(cursor, isLocal, ""); 1310 } 1311 cursorToContentValue(Cursor cursor, boolean isLocal, String albumId)1312 private static ContentValues cursorToContentValue(Cursor cursor, boolean isLocal, 1313 String albumId) { 1314 final ContentValues values = new ContentValues(); 1315 if (TextUtils.isEmpty(albumId)) { 1316 values.put(KEY_IS_VISIBLE, 1); 1317 } 1318 else { 1319 values.put(KEY_ALBUM_ID, albumId); 1320 } 1321 1322 final int count = cursor.getColumnCount(); 1323 for (int index = 0; index < count; index++) { 1324 String key = cursor.getColumnName(index); 1325 switch (key) { 1326 case CloudMediaProviderContract.MediaColumns.ID: 1327 if (isLocal) { 1328 values.put(KEY_LOCAL_ID, cursor.getString(index)); 1329 } else { 1330 values.put(KEY_CLOUD_ID, cursor.getString(index)); 1331 } 1332 break; 1333 case CloudMediaProviderContract.MediaColumns.MEDIA_STORE_URI: 1334 String uriString = cursor.getString(index); 1335 if (uriString != null) { 1336 Uri uri = Uri.parse(uriString); 1337 values.put(KEY_LOCAL_ID, ContentUris.parseId(uri)); 1338 } 1339 break; 1340 case CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS: 1341 values.put(KEY_DATE_TAKEN_MS, cursor.getLong(index)); 1342 break; 1343 case CloudMediaProviderContract.MediaColumns.SYNC_GENERATION: 1344 values.put(KEY_SYNC_GENERATION, cursor.getLong(index)); 1345 break; 1346 case CloudMediaProviderContract.MediaColumns.SIZE_BYTES: 1347 values.put(KEY_SIZE_BYTES, cursor.getLong(index)); 1348 break; 1349 case CloudMediaProviderContract.MediaColumns.MIME_TYPE: 1350 values.put(KEY_MIME_TYPE, cursor.getString(index)); 1351 break; 1352 case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION: 1353 int standardMimeTypeExtension = cursor.getInt(index); 1354 if (isValidStandardMimeTypeExtension(standardMimeTypeExtension)) { 1355 values.put(KEY_STANDARD_MIME_TYPE_EXTENSION, standardMimeTypeExtension); 1356 } else { 1357 throw new IllegalArgumentException("Invalid standard mime type extension"); 1358 } 1359 break; 1360 case CloudMediaProviderContract.MediaColumns.DURATION_MILLIS: 1361 values.put(KEY_DURATION_MS, cursor.getLong(index)); 1362 break; 1363 case CloudMediaProviderContract.MediaColumns.IS_FAVORITE: 1364 if (TextUtils.isEmpty(albumId)) { 1365 values.put(KEY_IS_FAVORITE, cursor.getInt(index)); 1366 } 1367 break; 1368 1369 /* The below columns are only included if this is not the album_media table 1370 * (AlbumId is an empty string) 1371 * 1372 * The columns should be in the cursor either way, but we don't duplicate these 1373 * columns to album_media since they are not needed for the UI. 1374 */ 1375 case CloudMediaProviderContract.MediaColumns.WIDTH: 1376 if (TextUtils.isEmpty(albumId)) { 1377 values.put(KEY_WIDTH, cursor.getInt(index)); 1378 } 1379 break; 1380 case CloudMediaProviderContract.MediaColumns.HEIGHT: 1381 if (TextUtils.isEmpty(albumId)) { 1382 values.put(KEY_HEIGHT, cursor.getInt(index)); 1383 } 1384 break; 1385 case CloudMediaProviderContract.MediaColumns.ORIENTATION: 1386 if (TextUtils.isEmpty(albumId)) { 1387 values.put(KEY_ORIENTATION, cursor.getInt(index)); 1388 } 1389 break; 1390 default: 1391 Log.w(TAG, "Unexpected cursor key: " + key); 1392 } 1393 } 1394 1395 return values; 1396 } 1397 isValidStandardMimeTypeExtension(int standardMimeTypeExtension)1398 private static boolean isValidStandardMimeTypeExtension(int standardMimeTypeExtension) { 1399 switch (standardMimeTypeExtension) { 1400 case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_NONE: 1401 case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_GIF: 1402 case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_MOTION_PHOTO: 1403 case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_ANIMATED_WEBP: 1404 return true; 1405 default: 1406 return false; 1407 } 1408 } 1409 buildSelectionArgs(SQLiteQueryBuilder qb, QueryFilter query)1410 private static String[] buildSelectionArgs(SQLiteQueryBuilder qb, QueryFilter query) { 1411 List<String> selectArgs = new ArrayList<>(); 1412 1413 if (query.mIsLocalOnly) { 1414 qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); 1415 } 1416 1417 if (query.mId >= 0) { 1418 if (query.mDateTakenAfterMs >= 0) { 1419 qb.appendWhereStandalone(WHERE_DATE_TAKEN_MS_AFTER); 1420 // Add date args twice because the sql statement evaluates date twice 1421 selectArgs.add(String.valueOf(query.mDateTakenAfterMs)); 1422 selectArgs.add(String.valueOf(query.mDateTakenAfterMs)); 1423 } else { 1424 qb.appendWhereStandalone(WHERE_DATE_TAKEN_MS_BEFORE); 1425 // Add date args twice because the sql statement evaluates date twice 1426 selectArgs.add(String.valueOf(query.mDateTakenBeforeMs)); 1427 selectArgs.add(String.valueOf(query.mDateTakenBeforeMs)); 1428 } 1429 selectArgs.add(String.valueOf(query.mId)); 1430 } 1431 1432 if (query.mSizeBytes >= 0) { 1433 qb.appendWhereStandalone(WHERE_SIZE_BYTES); 1434 selectArgs.add(String.valueOf(query.mSizeBytes)); 1435 } 1436 1437 addMimeTypesToQueryBuilderAndSelectionArgs(qb, selectArgs, query.mMimeTypes); 1438 1439 if (query.mIsVideo) { 1440 qb.appendWhereStandalone(WHERE_MIME_TYPE); 1441 selectArgs.add(VIDEO_MIME_TYPES); 1442 } else if (query.mIsFavorite) { 1443 qb.appendWhereStandalone(getWhereForFavorite(query.mIsLocalOnly)); 1444 } else if (!TextUtils.isEmpty(query.mAlbumId)) { 1445 qb.appendWhereStandalone(WHERE_ALBUM_ID); 1446 selectArgs.add(query.mAlbumId); 1447 } 1448 1449 if (query.mLocalPreSelectedIds != QueryFilterBuilder.LIST_DEFAULT 1450 || query.mCloudPreSelectedIds != QueryFilterBuilder.LIST_DEFAULT) { 1451 List<String> selectionIds; 1452 String whereCondition; 1453 if (query.mLocalPreSelectedIds != QueryFilterBuilder.LIST_DEFAULT) { 1454 selectionIds = query.mLocalPreSelectedIds; 1455 whereCondition = WHERE_LOCAL_ID_IN; 1456 } else { 1457 selectionIds = query.mCloudPreSelectedIds; 1458 whereCondition = WHERE_CLOUD_ID_IN; 1459 } 1460 if (!selectionIds.isEmpty()) { 1461 StringBuilder idSelectionPlaceholder = new StringBuilder("("); 1462 for (int itr = 0; itr < selectionIds.size(); itr++) { 1463 idSelectionPlaceholder.append("?,"); 1464 } 1465 idSelectionPlaceholder.deleteCharAt(idSelectionPlaceholder.length() - 1); 1466 idSelectionPlaceholder.append(")"); 1467 1468 // Append the where clause for id selection to the query builder. 1469 qb.appendWhereStandalone(whereCondition + idSelectionPlaceholder); 1470 1471 // Add ids to the selection args. 1472 selectArgs.addAll(selectionIds); 1473 } 1474 } 1475 1476 if (selectArgs.isEmpty()) { 1477 return null; 1478 } 1479 1480 return selectArgs.toArray(new String[selectArgs.size()]); 1481 } 1482 1483 /** 1484 * Returns where clause to obtain rows that are marked as favorite 1485 * 1486 * Favorite information can either come from local or from cloud. In case where an item is 1487 * marked as favorite in cloud provider, we try to obtain the local row corresponding to this 1488 * cloud row to avoid downloading cloud file unnecessarily. 1489 * See {@code WHERE_FAVORITE_LOCAL_PLUS_CLOUD} 1490 * 1491 * For queries that are local only, we don't need any of these complex queries, hence we stick 1492 * to simple query like {@code WHERE_FAVORITE_LOCAL_ONLY} 1493 */ getWhereForFavorite(boolean isLocalOnly)1494 private static String getWhereForFavorite(boolean isLocalOnly) { 1495 if (isLocalOnly) { 1496 return WHERE_FAVORITE_LOCAL_ONLY; 1497 } else { 1498 return WHERE_FAVORITE_ALL; 1499 } 1500 } 1501 addMimeTypesToQueryBuilderAndSelectionArgs(SQLiteQueryBuilder qb, List<String> selectionArgs, String[] mimeTypes)1502 static void addMimeTypesToQueryBuilderAndSelectionArgs(SQLiteQueryBuilder qb, 1503 List<String> selectionArgs, String[] mimeTypes) { 1504 if (mimeTypes == null) { 1505 return; 1506 } 1507 1508 mimeTypes = replaceMatchAnyChar(mimeTypes); 1509 ArrayList<String> whereMimeTypes = new ArrayList<>(); 1510 for (String mimeType : mimeTypes) { 1511 if (!TextUtils.isEmpty(mimeType)) { 1512 whereMimeTypes.add(WHERE_MIME_TYPE); 1513 selectionArgs.add(mimeType); 1514 } 1515 } 1516 1517 if (whereMimeTypes.isEmpty()) { 1518 return; 1519 } 1520 qb.appendWhereStandalone(TextUtils.join(" OR ", whereMimeTypes)); 1521 } 1522 createMediaQueryBuilder()1523 private static SQLiteQueryBuilder createMediaQueryBuilder() { 1524 SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); 1525 qb.setTables(TABLE_MEDIA); 1526 1527 return qb; 1528 } 1529 createAlbumMediaQueryBuilder(boolean isLocal)1530 private static SQLiteQueryBuilder createAlbumMediaQueryBuilder(boolean isLocal) { 1531 SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); 1532 qb.setTables(TABLE_ALBUM_MEDIA); 1533 1534 // In case of local albums, local_id cannot be null. 1535 // In case of cloud albums, there can be 2 types of media items: 1536 // 1. Cloud-only - Only cloud_id will be populated and local_id will be null. 1537 // 2. Local + Cloud - Only local_id will be populated and cloud_id will be null as showing 1538 // local copy is preferred over cloud copy. 1539 if (isLocal) { 1540 qb.appendWhereStandalone(WHERE_NOT_NULL_LOCAL_ID); 1541 } 1542 1543 return qb; 1544 } 1545 createLocalOnlyMediaQueryBuilder()1546 private static SQLiteQueryBuilder createLocalOnlyMediaQueryBuilder() { 1547 SQLiteQueryBuilder qb = createLocalMediaQueryBuilder(); 1548 qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); 1549 1550 return qb; 1551 } 1552 createLocalMediaQueryBuilder()1553 private static SQLiteQueryBuilder createLocalMediaQueryBuilder() { 1554 SQLiteQueryBuilder qb = createMediaQueryBuilder(); 1555 qb.appendWhereStandalone(WHERE_LOCAL_ID); 1556 1557 return qb; 1558 } 1559 createCloudMediaQueryBuilder()1560 private static SQLiteQueryBuilder createCloudMediaQueryBuilder() { 1561 SQLiteQueryBuilder qb = createMediaQueryBuilder(); 1562 qb.appendWhereStandalone(WHERE_CLOUD_ID); 1563 1564 return qb; 1565 } 1566 createIdMediaQueryBuilder()1567 private static SQLiteQueryBuilder createIdMediaQueryBuilder() { 1568 SQLiteQueryBuilder qb = createMediaQueryBuilder(); 1569 qb.appendWhereStandalone(WHERE_ID); 1570 1571 return qb; 1572 } 1573 createVisibleMediaQueryBuilder()1574 private static SQLiteQueryBuilder createVisibleMediaQueryBuilder() { 1575 SQLiteQueryBuilder qb = createMediaQueryBuilder(); 1576 qb.appendWhereStandalone(WHERE_IS_VISIBLE); 1577 1578 return qb; 1579 } 1580 createVisibleLocalMediaQueryBuilder()1581 private static SQLiteQueryBuilder createVisibleLocalMediaQueryBuilder() { 1582 SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder(); 1583 qb.appendWhereStandalone(WHERE_LOCAL_ID); 1584 1585 return qb; 1586 } 1587 1588 private abstract static class AlbumWriteOperation extends DbWriteOperation { 1589 1590 private final String mAlbumId; 1591 AlbumWriteOperation(SQLiteDatabase database, boolean isLocal, String albumId)1592 private AlbumWriteOperation(SQLiteDatabase database, boolean isLocal, String albumId) { 1593 super(database, isLocal); 1594 mAlbumId = albumId; 1595 } 1596 getAlbumId()1597 String getAlbumId() { 1598 return mAlbumId; 1599 } 1600 } 1601 1602 private static final class ResetAlbumOperation extends AlbumWriteOperation { 1603 ResetAlbumOperation(SQLiteDatabase database, boolean isLocal, String albumId)1604 private ResetAlbumOperation(SQLiteDatabase database, boolean isLocal, String albumId) { 1605 super(database, isLocal, albumId); 1606 } 1607 1608 @Override executeInternal(@ullable Cursor unused)1609 int executeInternal(@Nullable Cursor unused) { 1610 final String albumId = getAlbumId(); 1611 final boolean isLocal = isLocal(); 1612 1613 final SQLiteQueryBuilder qb = createAlbumMediaQueryBuilder(isLocal); 1614 1615 String[] selectionArgs = null; 1616 if (!TextUtils.isEmpty(albumId)) { 1617 qb.appendWhereStandalone(WHERE_ALBUM_ID); 1618 selectionArgs = new String[]{albumId}; 1619 } 1620 1621 return qb.delete(getDatabase(), /* selection */ null, /* selectionArgs */ 1622 selectionArgs); 1623 } 1624 } 1625 1626 private static final class AddAlbumMediaOperation extends AlbumWriteOperation { 1627 private static final String[] sLocalMediaProjection = new String[] { 1628 KEY_DATE_TAKEN_MS, 1629 KEY_SYNC_GENERATION, 1630 KEY_SIZE_BYTES, 1631 KEY_DURATION_MS, 1632 KEY_MIME_TYPE, 1633 KEY_STANDARD_MIME_TYPE_EXTENSION 1634 }; 1635 AddAlbumMediaOperation(SQLiteDatabase database, boolean isLocal, String albumId)1636 private AddAlbumMediaOperation(SQLiteDatabase database, boolean isLocal, String albumId) { 1637 super(database, isLocal, albumId); 1638 1639 if (TextUtils.isEmpty(albumId)) { 1640 throw new IllegalArgumentException("Missing albumId."); 1641 } 1642 } 1643 1644 @Override executeInternal(@ullable Cursor cursor)1645 int executeInternal(@Nullable Cursor cursor) { 1646 final boolean isLocal = isLocal(); 1647 final String albumId = getAlbumId(); 1648 final SQLiteQueryBuilder qb = createAlbumMediaQueryBuilder(isLocal); 1649 final SQLiteQueryBuilder qbMedia = createMediaQueryBuilder(); 1650 int counter = 0; 1651 1652 if (cursor.getCount() > PAGE_SIZE) { 1653 Log.w(TAG, 1654 String.format("Expected a cursor page size of %d, but received a cursor " 1655 + "with %d rows instead.", PAGE_SIZE, cursor.getCount())); 1656 } 1657 1658 if (cursor.moveToFirst()) { 1659 do { 1660 ContentValues values = cursorToContentValue(cursor, isLocal, albumId); 1661 1662 // In case of cloud albums, cloud provider returns both local and cloud ids. 1663 // We give preference to inserting media data for the local copy of an item 1664 // instea of the cloud copy. Hence, if local copy is available, fetch metadata 1665 // from media table and update the album_media row accordingly. 1666 if (!isLocal) { 1667 final String localId = values.getAsString(KEY_LOCAL_ID); 1668 final String cloudId = values.getAsString(KEY_CLOUD_ID); 1669 if (!TextUtils.isEmpty(localId) && !TextUtils.isEmpty(cloudId)) { 1670 // Fetch local media item details from media table. 1671 try (Cursor cursorLocalMedia = getLocalMediaMetadata(localId)) { 1672 if (cursorLocalMedia != null && cursorLocalMedia.getCount() == 1) { 1673 // If local media item details are present in the media table, 1674 // update content values and remove cloud id. 1675 values.putNull(KEY_CLOUD_ID); 1676 updateContentValues(values, cursorLocalMedia); 1677 } else { 1678 // If local media item details are NOT present in the media 1679 // table, insert cloud row after removing local_id. This will 1680 // only happen when local id points to a deleted item. 1681 values.putNull(KEY_LOCAL_ID); 1682 } 1683 } 1684 } 1685 } 1686 1687 try { 1688 if (qb.insert(getDatabase(), values) > 0) { 1689 counter++; 1690 } else { 1691 Log.v(TAG, "Failed to insert album_media. ContentValues: " + values); 1692 } 1693 } catch (SQLiteConstraintException e) { 1694 Log.v(TAG, "Failed to insert album_media. ContentValues: " + values, e); 1695 } 1696 1697 // Check if a Cloud sync is running, and additionally insert this row to media 1698 // table if true. 1699 maybeInsertFileToMedia(qbMedia, cursor, isLocal); 1700 } while (cursor.moveToNext()); 1701 } 1702 1703 return counter; 1704 } 1705 1706 /** 1707 * Will (possibly) insert this file to the Picker database's media table if there's an 1708 * existing Cloud Sync running. 1709 * 1710 * <p>This is necessary to guarantee it exists in case it is selected by the user. (So that 1711 * the pre-loader can load it to the device before the session is closed.) 1712 * 1713 * @param queryBuilder The media table query builder to use for the insert 1714 * @param cursor The current cursor being processed (this method does not advance the 1715 * cursor). 1716 * @param isLocal Whether this is the local provider sync or not. 1717 */ maybeInsertFileToMedia( SQLiteQueryBuilder queryBuilder, Cursor cursor, boolean isLocal)1718 private void maybeInsertFileToMedia( 1719 SQLiteQueryBuilder queryBuilder, Cursor cursor, boolean isLocal) { 1720 if (SyncTrackerRegistry.getCloudSyncTracker().pendingSyncFutures().size() > 0) { 1721 ContentValues values = cursorToContentValue(cursor, isLocal); 1722 Log.d( 1723 TAG, 1724 String.format( 1725 "Encountered running Cloud sync during AddAlbumMediaOperation while" 1726 + " processing row. Will additional insert to media table: %s", 1727 values)); 1728 try { 1729 queryBuilder.insert(getDatabase(), values); 1730 } catch (SQLiteConstraintException ignored) { 1731 // If we hit a constraint exception it means this row is already in media, 1732 // so nothing to do here. 1733 } 1734 } 1735 } 1736 updateContentValues(ContentValues values, Cursor cursor)1737 private void updateContentValues(ContentValues values, Cursor cursor) { 1738 if (cursor.moveToFirst()) { 1739 for (int columnIndex = 0; columnIndex < cursor.getColumnCount(); columnIndex++) { 1740 String column = cursor.getColumnName(columnIndex); 1741 switch (column) { 1742 case KEY_DATE_TAKEN_MS: 1743 case KEY_SYNC_GENERATION: 1744 case KEY_SIZE_BYTES: 1745 case KEY_DURATION_MS: 1746 case KEY_STANDARD_MIME_TYPE_EXTENSION: 1747 values.put(column, cursor.getLong(columnIndex)); 1748 break; 1749 case KEY_MIME_TYPE: 1750 values.put(column, cursor.getString(columnIndex)); 1751 break; 1752 default: 1753 throw new IllegalArgumentException( 1754 "Column " + column + " not recognized."); 1755 } 1756 } 1757 } 1758 } 1759 getLocalMediaMetadata(String localId)1760 private Cursor getLocalMediaMetadata(String localId) { 1761 final SQLiteQueryBuilder qb = createVisibleLocalMediaQueryBuilder(); 1762 final String[] selectionArgs = new String[] {localId}; 1763 qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); 1764 1765 return qb.query(getDatabase(), sLocalMediaProjection, /* selection */ null, 1766 selectionArgs, /* groupBy */ null, /* having */ null, 1767 /* orderBy */ null); 1768 } 1769 } 1770 1771 /** 1772 * Print the {@link PickerDbFacade} state into the given stream. 1773 */ dump(PrintWriter writer)1774 public void dump(PrintWriter writer) { 1775 writer.println("Picker db facade state:"); 1776 writer.println(" mLocalProvider=" + getLocalProvider()); 1777 writer.println(" mCloudProvider=" + getCloudProvider()); 1778 } 1779 1780 /** 1781 * Returns the associated SQLiteDatabase instance. 1782 */ getDatabase()1783 public SQLiteDatabase getDatabase() { 1784 return mDatabase; 1785 } 1786 } 1787