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