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.PickerUriResolver.getMediaUri; 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.sqlite.SQLiteConstraintException; 37 import android.database.sqlite.SQLiteDatabase; 38 import android.database.sqlite.SQLiteQueryBuilder; 39 import android.net.Uri; 40 import android.os.SystemProperties; 41 import android.provider.DeviceConfig; 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.photopicker.PickerSyncController; 52 53 import java.util.ArrayList; 54 import java.util.List; 55 import java.util.Objects; 56 57 /** 58 * This is a facade that hides the complexities of executing some SQL statements on the picker db. 59 * It does not do any caller permission checks and is only intended for internal use within the 60 * MediaProvider for the Photo Picker. 61 */ 62 public class PickerDbFacade { 63 private static final String VIDEO_MIME_TYPES = "video/%"; 64 65 private final Object mLock = new Object(); 66 private final Context mContext; 67 private final SQLiteDatabase mDatabase; 68 private final String mLocalProvider; 69 private String mCloudProvider; 70 PickerDbFacade(Context context)71 public PickerDbFacade(Context context) { 72 this(context, PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY); 73 } 74 75 @VisibleForTesting PickerDbFacade(Context context, String localProvider)76 public PickerDbFacade(Context context, String localProvider) { 77 this(context, localProvider, new PickerDatabaseHelper(context)); 78 } 79 80 @VisibleForTesting PickerDbFacade(Context context, String localProvider, PickerDatabaseHelper dbHelper)81 public PickerDbFacade(Context context, String localProvider, PickerDatabaseHelper dbHelper) { 82 mContext = context; 83 mLocalProvider = localProvider; 84 mDatabase = dbHelper.getWritableDatabase(); 85 } 86 87 private static final String TAG = "PickerDbFacade"; 88 89 private static final int RETRY = 0; 90 private static final int SUCCESS = 1; 91 private static final int FAIL = -1; 92 93 private static final String TABLE_MEDIA = "media"; 94 // Intentionally use /sdcard path so that the receiving app resolves it to it's per-user 95 // external storage path, e.g. /storage/emulated/<userid>. That way FUSE cross-user access is 96 // not required for picker paths sent across users 97 private static final String PICKER_PATH = "/sdcard/" + getPickerRelativePath(); 98 private static final String TABLE_ALBUM_MEDIA = "album_media"; 99 100 @VisibleForTesting 101 public static final String KEY_ID = "_id"; 102 @VisibleForTesting 103 public static final String KEY_LOCAL_ID = "local_id"; 104 @VisibleForTesting 105 public static final String KEY_CLOUD_ID = "cloud_id"; 106 @VisibleForTesting 107 public static final String KEY_IS_VISIBLE = "is_visible"; 108 @VisibleForTesting 109 public static final String KEY_DATE_TAKEN_MS = "date_taken_ms"; 110 @VisibleForTesting 111 public static final String KEY_SYNC_GENERATION = "sync_generation"; 112 @VisibleForTesting 113 public static final String KEY_SIZE_BYTES = "size_bytes"; 114 @VisibleForTesting 115 public static final String KEY_DURATION_MS = "duration_ms"; 116 @VisibleForTesting 117 public static final String KEY_MIME_TYPE = "mime_type"; 118 @VisibleForTesting 119 public static final String KEY_STANDARD_MIME_TYPE_EXTENSION = "standard_mime_type_extension"; 120 @VisibleForTesting 121 public static final String KEY_IS_FAVORITE = "is_favorite"; 122 @VisibleForTesting 123 public static final String KEY_ALBUM_ID = "album_id"; 124 125 @VisibleForTesting 126 public static final String IMAGE_FILE_EXTENSION = ".jpg"; 127 @VisibleForTesting 128 public static final String VIDEO_FILE_EXTENSION = ".mp4"; 129 130 private static final String WHERE_ID = KEY_ID + " = ?"; 131 private static final String WHERE_LOCAL_ID = KEY_LOCAL_ID + " = ?"; 132 private static final String WHERE_CLOUD_ID = KEY_CLOUD_ID + " = ?"; 133 private static final String WHERE_NULL_CLOUD_ID = KEY_CLOUD_ID + " IS NULL"; 134 private static final String WHERE_NOT_NULL_CLOUD_ID = KEY_CLOUD_ID + " IS NOT NULL"; 135 private static final String WHERE_NOT_NULL_LOCAL_ID = KEY_LOCAL_ID + " IS NOT NULL"; 136 private static final String WHERE_IS_VISIBLE = KEY_IS_VISIBLE + " = 1"; 137 private static final String WHERE_MIME_TYPE = KEY_MIME_TYPE + " LIKE ? "; 138 private static final String WHERE_IS_FAVORITE = KEY_IS_FAVORITE + " = 1"; 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 148 private static final String[] PROJECTION_ALBUM_DB = new String[] { 149 "COUNT(" + KEY_ID + ") AS " + CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT, 150 "MAX(" + KEY_DATE_TAKEN_MS + ") AS " 151 + CloudMediaProviderContract.AlbumColumns.DATE_TAKEN_MILLIS, 152 String.format("IFNULL(%s, %s) AS %s", KEY_CLOUD_ID, 153 KEY_LOCAL_ID, CloudMediaProviderContract.AlbumColumns.MEDIA_COVER_ID) 154 }; 155 156 // Matches all media including cloud+local, cloud-only and local-only 157 private static final SQLiteQueryBuilder QB_MATCH_ALL = createMediaQueryBuilder(); 158 // Matches media with id 159 private static final SQLiteQueryBuilder QB_MATCH_ID = createIdMediaQueryBuilder(); 160 // Matches media with local_id including cloud+local and local-only 161 private static final SQLiteQueryBuilder QB_MATCH_LOCAL = createLocalMediaQueryBuilder(); 162 // Matches cloud media including cloud+local and cloud-only 163 private static final SQLiteQueryBuilder QB_MATCH_CLOUD = createCloudMediaQueryBuilder(); 164 // Matches all visible media including cloud+local, cloud-only and local-only 165 private static final SQLiteQueryBuilder QB_MATCH_VISIBLE = createVisibleMediaQueryBuilder(); 166 // Matches visible media with local_id including cloud+local and local-only 167 private static final SQLiteQueryBuilder QB_MATCH_VISIBLE_LOCAL = 168 createVisibleLocalMediaQueryBuilder(); 169 // Matches stricly local-only media 170 private static final SQLiteQueryBuilder QB_MATCH_LOCAL_ONLY = 171 createLocalOnlyMediaQueryBuilder(); 172 173 private static final ContentValues CONTENT_VALUE_VISIBLE = new ContentValues(); 174 private static final ContentValues CONTENT_VALUE_HIDDEN = new ContentValues(); 175 176 static { CONTENT_VALUE_VISIBLE.put(KEY_IS_VISIBLE, 1)177 CONTENT_VALUE_VISIBLE.put(KEY_IS_VISIBLE, 1); 178 CONTENT_VALUE_HIDDEN.putNull(KEY_IS_VISIBLE); 179 } 180 181 /** 182 * Sets the cloud provider to be returned after querying the picker db 183 * If null, cloud media will be excluded from all queries. 184 */ setCloudProvider(String authority)185 public void setCloudProvider(String authority) { 186 synchronized (mLock) { 187 mCloudProvider = authority; 188 } 189 } 190 191 /** 192 * Returns the cloud provider that will be returned after querying the picker db 193 */ 194 @VisibleForTesting getCloudProvider()195 public String getCloudProvider() { 196 synchronized (mLock) { 197 return mCloudProvider; 198 } 199 } 200 getLocalProvider()201 public String getLocalProvider() { 202 return mLocalProvider; 203 } 204 205 /** 206 * Returns {@link DbWriteOperation} to add media belonging to {@code authority} into the picker 207 * db. 208 */ beginAddMediaOperation(String authority)209 public DbWriteOperation beginAddMediaOperation(String authority) { 210 return new AddMediaOperation(mDatabase, isLocal(authority)); 211 } 212 213 /** 214 * Returns {@link DbWriteOperation} to add album_media belonging to {@code authority} 215 * into the picker db. 216 */ beginAddAlbumMediaOperation(String authority, String albumId)217 public DbWriteOperation beginAddAlbumMediaOperation(String authority, String albumId) { 218 return new AddAlbumMediaOperation(mDatabase, isLocal(authority), albumId); 219 } 220 221 /** 222 * Returns {@link DbWriteOperation} to remove media belonging to {@code authority} from the 223 * picker db. 224 */ beginRemoveMediaOperation(String authority)225 public DbWriteOperation beginRemoveMediaOperation(String authority) { 226 return new RemoveMediaOperation(mDatabase, isLocal(authority)); 227 } 228 229 /** 230 * Returns {@link DbWriteOperation} to clear local media or all cloud media from the picker 231 * db. 232 * 233 * @param authority to determine whether local or cloud media should be cleared 234 */ beginResetMediaOperation(String authority)235 public DbWriteOperation beginResetMediaOperation(String authority) { 236 return new ResetMediaOperation(mDatabase, isLocal(authority)); 237 } 238 239 /** 240 * Returns {@link DbWriteOperation} to clear album media for a given albumId from the picker 241 * db. 242 * 243 * @param authority to determine whether local or cloud media should be cleared 244 */ beginResetAlbumMediaOperation(String authority, String albumId)245 public DbWriteOperation beginResetAlbumMediaOperation(String authority, String albumId) { 246 return new ResetAlbumOperation(mDatabase, isLocal(authority), albumId); 247 } 248 249 /** 250 * Represents an atomic write operation to the picker database. 251 * 252 * <p>This class is not thread-safe and is meant to be used within a single thread only. 253 */ 254 public static abstract class DbWriteOperation implements AutoCloseable { 255 256 private final SQLiteDatabase mDatabase; 257 private final boolean mIsLocal; 258 private final String mAlbumId; 259 260 private boolean mIsSuccess = false; 261 262 // Needed for Album Media Write operations. DbWriteOperation(SQLiteDatabase database, boolean isLocal)263 private DbWriteOperation(SQLiteDatabase database, boolean isLocal) { 264 this(database, isLocal, ""); 265 } 266 267 // Needed for Album Media Write operations. DbWriteOperation(SQLiteDatabase database, boolean isLocal, String albumId)268 private DbWriteOperation(SQLiteDatabase database, boolean isLocal, String albumId) { 269 mDatabase = database; 270 mIsLocal = isLocal; 271 mAlbumId = albumId; 272 mDatabase.beginTransaction(); 273 } 274 275 /* 276 * Execute the write operation. 277 * 278 * @param cursor containing items to add/remove 279 * @return {@link WriteResult} indicating success/failure and the number of {@code cursor} 280 * items that were inserted/updated/deleted in the picker db 281 * @throws {@link IllegalStateException} if no DB transaction is active 282 */ execute(@ullable Cursor cursor)283 public int execute(@Nullable Cursor cursor) { 284 if (!mDatabase.inTransaction()) { 285 throw new IllegalStateException("No ongoing DB transaction."); 286 } 287 return executeInternal(cursor); 288 } 289 setSuccess()290 public void setSuccess() { 291 mIsSuccess = true; 292 } 293 294 @Override close()295 public void close() { 296 if (mDatabase.inTransaction()) { 297 if (mIsSuccess) { 298 mDatabase.setTransactionSuccessful(); 299 } else { 300 Log.w(TAG, "DB write transaction failed."); 301 } 302 mDatabase.endTransaction(); 303 } else { 304 throw new IllegalStateException("close() has already been called previously."); 305 } 306 } 307 executeInternal(@ullable Cursor cursor)308 abstract int executeInternal(@Nullable Cursor cursor); 309 getDatabase()310 SQLiteDatabase getDatabase() { 311 return mDatabase; 312 } 313 isLocal()314 boolean isLocal() { 315 return mIsLocal; 316 } 317 albumId()318 String albumId() { 319 return mAlbumId; 320 } 321 updateMedia(SQLiteQueryBuilder qb, ContentValues values, String[] selectionArgs)322 int updateMedia(SQLiteQueryBuilder qb, ContentValues values, 323 String[] selectionArgs) { 324 try { 325 if (qb.update(mDatabase, values, /* selection */ null, selectionArgs) > 0) { 326 return SUCCESS; 327 } else { 328 Log.d(TAG, "Failed to update picker db media. ContentValues: " + values); 329 return FAIL; 330 } 331 } catch (SQLiteConstraintException e) { 332 Log.d(TAG, "Failed to update picker db media. ContentValues: " + values, e); 333 return RETRY; 334 } 335 } 336 querySingleMedia(SQLiteQueryBuilder qb, String[] projection, String[] selectionArgs, int columnIndex)337 String querySingleMedia(SQLiteQueryBuilder qb, String[] projection, 338 String[] selectionArgs, int columnIndex) { 339 try (Cursor cursor = qb.query(mDatabase, projection, /* selection */ null, 340 selectionArgs, /* groupBy */ null, /* having */ null, 341 /* orderBy */ null)) { 342 if (cursor.moveToFirst()) { 343 return cursor.getString(columnIndex); 344 } 345 } 346 347 return null; 348 } 349 } 350 351 private static final class AddMediaOperation extends DbWriteOperation { 352 AddMediaOperation(SQLiteDatabase database, boolean isLocal)353 private AddMediaOperation(SQLiteDatabase database, boolean isLocal) { 354 super(database, isLocal); 355 } 356 357 @Override executeInternal(@ullable Cursor cursor)358 int executeInternal(@Nullable Cursor cursor) { 359 final boolean isLocal = isLocal(); 360 final SQLiteQueryBuilder qb = isLocal ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD; 361 int counter = 0; 362 363 while (cursor.moveToNext()) { 364 ContentValues values = cursorToContentValue(cursor, isLocal); 365 366 String[] upsertArgs = {values.getAsString(isLocal ? 367 KEY_LOCAL_ID : KEY_CLOUD_ID)}; 368 if (upsertMedia(qb, values, upsertArgs) == SUCCESS) { 369 counter++; 370 continue; 371 } 372 373 // Because we want to prioritize visible local media over visible cloud media, 374 // we do the following if the upsert above failed 375 if (isLocal) { 376 // For local syncs, we attempt hiding the visible cloud media 377 String cloudId = getVisibleCloudIdFromDb(values.getAsString(KEY_LOCAL_ID)); 378 demoteCloudMediaToHidden(cloudId); 379 } else { 380 // For cloud syncs, we prepare an upsert as hidden cloud media 381 values.putNull(KEY_IS_VISIBLE); 382 } 383 384 // Now attempt upsert again, this should succeed 385 if (upsertMedia(qb, values, upsertArgs) == SUCCESS) { 386 counter++; 387 } 388 } 389 return counter; 390 } 391 insertMedia(ContentValues values)392 private int insertMedia(ContentValues values) { 393 try { 394 if (QB_MATCH_ALL.insert(getDatabase(), values) > 0) { 395 return SUCCESS; 396 } else { 397 Log.d(TAG, "Failed to insert picker db media. ContentValues: " + values); 398 return FAIL; 399 } 400 } catch (SQLiteConstraintException e) { 401 Log.d(TAG, "Failed to insert picker db media. ContentValues: " + values, e); 402 return RETRY; 403 } 404 } 405 upsertMedia(SQLiteQueryBuilder qb, ContentValues values, String[] selectionArgs)406 private int upsertMedia(SQLiteQueryBuilder qb, 407 ContentValues values, String[] selectionArgs) { 408 int res = insertMedia(values); 409 if (res == RETRY) { 410 // Attempt equivalent of CONFLICT_REPLACE resolution 411 Log.d(TAG, "Retrying failed insert as update. ContentValues: " + values); 412 res = updateMedia(qb, values, selectionArgs); 413 } 414 415 return res; 416 } 417 demoteCloudMediaToHidden(@ullable String cloudId)418 private void demoteCloudMediaToHidden(@Nullable String cloudId) { 419 if (cloudId == null) { 420 return; 421 } 422 423 final String[] updateArgs = new String[] {cloudId}; 424 if (updateMedia(QB_MATCH_CLOUD, CONTENT_VALUE_HIDDEN, updateArgs) == SUCCESS) { 425 Log.d(TAG, "Demoted picker db media item to hidden. CloudId: " + cloudId); 426 } 427 } 428 getVisibleCloudIdFromDb(String localId)429 private String getVisibleCloudIdFromDb(String localId) { 430 final String[] cloudIdProjection = new String[] {KEY_CLOUD_ID}; 431 final String[] queryArgs = new String[] {localId}; 432 return querySingleMedia(QB_MATCH_VISIBLE_LOCAL, cloudIdProjection, queryArgs, 433 /* columnIndex */ 0); 434 } 435 } 436 437 private static final class RemoveMediaOperation extends DbWriteOperation { 438 RemoveMediaOperation(SQLiteDatabase database, boolean isLocal)439 private RemoveMediaOperation(SQLiteDatabase database, boolean isLocal) { 440 super(database, isLocal); 441 } 442 443 @Override executeInternal(@ullable Cursor cursor)444 int executeInternal(@Nullable Cursor cursor) { 445 final boolean isLocal = isLocal(); 446 final SQLiteQueryBuilder qb = isLocal ? QB_MATCH_LOCAL_ONLY : QB_MATCH_CLOUD; 447 448 int counter = 0; 449 450 while (cursor.moveToNext()) { 451 // Need to fetch the local_id before delete because for cloud items 452 // we need a db query to fetch the local_id matching the id received from 453 // cursor (cloud_id). 454 final String localId = getLocalIdFromCursorOrDb(cursor, isLocal); 455 456 // Delete cloud/local row 457 final int idIndex = cursor.getColumnIndex( 458 CloudMediaProviderContract.MediaColumns.ID); 459 final String[] deleteArgs = {cursor.getString(idIndex)}; 460 if (qb.delete(getDatabase(), /* selection */ null, deleteArgs) > 0) { 461 counter++; 462 } 463 464 promoteCloudMediaToVisible(localId); 465 } 466 467 return counter; 468 } 469 promoteCloudMediaToVisible(@ullable String localId)470 private void promoteCloudMediaToVisible(@Nullable String localId) { 471 if (localId == null) { 472 return; 473 } 474 475 final String[] idProjection = new String[] {KEY_ID}; 476 final String[] queryArgs = {localId}; 477 // First query for an exact row id matching the criteria for promotion so that we don't 478 // attempt promoting multiple hidden cloud rows matching the |localId| 479 final String id = querySingleMedia(QB_MATCH_LOCAL, idProjection, queryArgs, 480 /* columnIndex */ 0); 481 if (id == null) { 482 Log.w(TAG, "Unable to promote cloud media with localId: " + localId); 483 return; 484 } 485 486 final String[] updateArgs = {id}; 487 if (updateMedia(QB_MATCH_ID, CONTENT_VALUE_VISIBLE, updateArgs) == SUCCESS) { 488 Log.d(TAG, "Promoted picker db media item to visible. LocalId: " + localId); 489 } 490 } 491 getLocalIdFromCursorOrDb(Cursor cursor, boolean isLocal)492 private String getLocalIdFromCursorOrDb(Cursor cursor, boolean isLocal) { 493 final String id = cursor.getString(0); 494 495 if (isLocal) { 496 // For local, id in cursor is already local_id 497 return id; 498 } else { 499 // For cloud, we need to query db with cloud_id from cursor to fetch local_id 500 final String[] localIdProjection = new String[] {KEY_LOCAL_ID}; 501 final String[] queryArgs = new String[] {id}; 502 return querySingleMedia(QB_MATCH_CLOUD, localIdProjection, queryArgs, 503 /* columnIndex */ 0); 504 } 505 } 506 } 507 508 private static final class ResetMediaOperation extends DbWriteOperation { 509 ResetMediaOperation(SQLiteDatabase database, boolean isLocal)510 private ResetMediaOperation(SQLiteDatabase database, boolean isLocal) { 511 super(database, isLocal); 512 } 513 514 @Override executeInternal(@ullable Cursor unused)515 int executeInternal(@Nullable Cursor unused) { 516 final boolean isLocal = isLocal(); 517 final SQLiteQueryBuilder qb = createMediaQueryBuilder(); 518 519 if (isLocal) { 520 qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); 521 } else { 522 qb.appendWhereStandalone(WHERE_NOT_NULL_CLOUD_ID); 523 } 524 525 SQLiteDatabase database = getDatabase(); 526 int counter = qb.delete(database, /* selection */ null, /* selectionArgs */ null); 527 528 if (isLocal) { 529 // If we reset local media, we need to promote cloud media items 530 // Ignore conflicts in case we have multiple cloud_ids mapped to the 531 // same local_id. Promoting either is fine. 532 database.updateWithOnConflict(TABLE_MEDIA, CONTENT_VALUE_VISIBLE, /* where */ null, 533 /* whereClause */ null, SQLiteDatabase.CONFLICT_IGNORE); 534 } 535 536 return counter; 537 } 538 } 539 540 /** Filter for {@link #queryMedia} to modify returned results */ 541 public static class QueryFilter { 542 private final int mLimit; 543 private final long mDateTakenBeforeMs; 544 private final long mDateTakenAfterMs; 545 private final long mId; 546 private final String mAlbumId; 547 private final long mSizeBytes; 548 private final String mMimeType; 549 private final boolean mIsFavorite; 550 private final boolean mIsVideo; 551 QueryFilter(int limit, long dateTakenBeforeMs, long dateTakenAfterMs, long id, String albumId, long sizeBytes, String mimeType, boolean isFavorite, boolean isVideo)552 private QueryFilter(int limit, long dateTakenBeforeMs, long dateTakenAfterMs, long id, 553 String albumId, long sizeBytes, String mimeType, boolean isFavorite, 554 boolean isVideo) { 555 this.mLimit = limit; 556 this.mDateTakenBeforeMs = dateTakenBeforeMs; 557 this.mDateTakenAfterMs = dateTakenAfterMs; 558 this.mId = id; 559 this.mAlbumId = albumId; 560 this.mSizeBytes = sizeBytes; 561 this.mMimeType = mimeType; 562 this.mIsFavorite = isFavorite; 563 this.mIsVideo = isVideo; 564 } 565 } 566 567 /** Builder for {@link Query} filter. */ 568 public static class QueryFilterBuilder { 569 public static final long LONG_DEFAULT = -1; 570 public static final String STRING_DEFAULT = null; 571 public static final boolean BOOLEAN_DEFAULT = false; 572 573 public static final int LIMIT_DEFAULT = 1000; 574 575 private final int limit; 576 private long dateTakenBeforeMs = LONG_DEFAULT; 577 private long dateTakenAfterMs = LONG_DEFAULT; 578 private long id = LONG_DEFAULT; 579 private String albumId = STRING_DEFAULT; 580 private long sizeBytes = LONG_DEFAULT; 581 private String mimeType = STRING_DEFAULT; 582 private boolean isFavorite = BOOLEAN_DEFAULT; 583 private boolean mIsVideo = BOOLEAN_DEFAULT; 584 QueryFilterBuilder(int limit)585 public QueryFilterBuilder(int limit) { 586 this.limit = limit; 587 } 588 setDateTakenBeforeMs(long dateTakenBeforeMs)589 public QueryFilterBuilder setDateTakenBeforeMs(long dateTakenBeforeMs) { 590 this.dateTakenBeforeMs = dateTakenBeforeMs; 591 return this; 592 } 593 setDateTakenAfterMs(long dateTakenAfterMs)594 public QueryFilterBuilder setDateTakenAfterMs(long dateTakenAfterMs) { 595 this.dateTakenAfterMs = dateTakenAfterMs; 596 return this; 597 } 598 599 /** 600 * The {@code id} helps break ties with db rows having the same {@code dateTakenAfterMs} or 601 * {@code dateTakenBeforeMs}. 602 * 603 * If {@code dateTakenAfterMs} is specified, all returned items are either strictly more 604 * recent than {@code dateTakenAfterMs} or have a picker db id strictly greater than 605 * {@code id} for ties. 606 * 607 * If {@code dateTakenBeforeMs} is specified, all returned items are either strictly older 608 * than {@code dateTakenBeforeMs} or have a picker db id strictly less than {@code id} 609 * for ties. 610 */ setId(long id)611 public QueryFilterBuilder setId(long id) { 612 this.id = id; 613 return this; 614 } setAlbumId(String albumId)615 public QueryFilterBuilder setAlbumId(String albumId) { 616 this.albumId = albumId; 617 return this; 618 } 619 setSizeBytes(long sizeBytes)620 public QueryFilterBuilder setSizeBytes(long sizeBytes) { 621 this.sizeBytes = sizeBytes; 622 return this; 623 } 624 setMimeType(String mimeType)625 public QueryFilterBuilder setMimeType(String mimeType) { 626 this.mimeType = mimeType; 627 return this; 628 } 629 630 /** 631 * If {@code isFavorite} is {@code true}, the {@link QueryFilter} returns only 632 * favorited items, however, if it is {@code false}, it returns all items including 633 * favorited and non-favorited items. 634 */ setIsFavorite(boolean isFavorite)635 public QueryFilterBuilder setIsFavorite(boolean isFavorite) { 636 this.isFavorite = isFavorite; 637 return this; 638 } 639 640 /** 641 * If {@code isVideo} is {@code true}, the {@link QueryFilter} returns only 642 * video items, however, if it is {@code false}, it returns all items including 643 * video and non-video items. 644 */ setIsVideo(boolean isVideo)645 public QueryFilterBuilder setIsVideo(boolean isVideo) { 646 this.mIsVideo = isVideo; 647 return this; 648 } 649 build()650 public QueryFilter build() { 651 return new QueryFilter(limit, dateTakenBeforeMs, dateTakenAfterMs, id, albumId, 652 sizeBytes, mimeType, isFavorite, mIsVideo); 653 } 654 } 655 656 /** 657 * Returns sorted and deduped cloud and local media items from the picker db. 658 * 659 * Returns a {@link Cursor} containing picker db media rows with columns as 660 * {@link CloudMediaProviderContract#MediaColumns}. 661 * 662 * The result is sorted in reverse chronological order, i.e. newest first, up to a maximum of 663 * {@code limit}. They can also be filtered with {@code query}. 664 */ queryMediaForUi(QueryFilter query)665 public Cursor queryMediaForUi(QueryFilter query) { 666 final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder(); 667 final String[] selectionArgs = buildSelectionArgs(qb, query); 668 669 final String cloudProvider; 670 synchronized (mLock) { 671 cloudProvider = mCloudProvider; 672 } 673 674 return queryMediaForUi(qb, selectionArgs, query.mLimit, TABLE_MEDIA, cloudProvider); 675 } 676 677 /** 678 * Returns sorted cloud or local media items from the picker db for a given album (either cloud 679 * or local). 680 * 681 * Returns a {@link Cursor} containing picker db media rows with columns as 682 * {@link CloudMediaProviderContract#MediaColumns} except for is_favorites column because that 683 * column is only used for fetching the Favorites album. 684 * 685 * The result is sorted in reverse chronological order, i.e. newest first, up to a maximum of 686 * {@code limit}. They can also be filtered with {@code query}. 687 */ queryAlbumMediaForUi(QueryFilter query, String authority)688 public Cursor queryAlbumMediaForUi(QueryFilter query, String authority) { 689 final SQLiteQueryBuilder qb = createAlbumMediaQueryBuilder(isLocal(authority)); 690 final String[] selectionArgs = buildSelectionArgs(qb, query); 691 692 return queryMediaForUi(qb, selectionArgs, query.mLimit, TABLE_ALBUM_MEDIA, authority); 693 } 694 695 /** 696 * Returns an individual cloud or local item from the picker db matching {@code authority} and 697 * {@code mediaId}. 698 * 699 * Returns a {@link Cursor} containing picker db media rows with columns as {@code projection}, 700 * a subset of {@link PickerMediaColumns}. 701 */ queryMediaIdForApps(String authority, String mediaId, @NonNull String[] projection)702 public Cursor queryMediaIdForApps(String authority, String mediaId, 703 @NonNull String[] projection) { 704 final String[] selectionArgs = new String[] { mediaId }; 705 final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder(); 706 if (isLocal(authority)) { 707 qb.appendWhereStandalone(WHERE_LOCAL_ID); 708 } else { 709 qb.appendWhereStandalone(WHERE_CLOUD_ID); 710 } 711 712 synchronized (mLock) { 713 if (authority.equals(mLocalProvider) || authority.equals(mCloudProvider)) { 714 return qb.query(mDatabase, getMediaStoreProjectionLocked(authority, mediaId, 715 projection), 716 /* selection */ null, selectionArgs, /* groupBy */ null, /* having */ null, 717 /* orderBy */ null, /* limitStr */ null); 718 } 719 } 720 721 return null; 722 } 723 724 /** 725 * Returns empty {@link Cursor} if there are no items matching merged album constraints {@code 726 * query} 727 */ getMergedAlbums(QueryFilter query)728 public Cursor getMergedAlbums(QueryFilter query) { 729 final MatrixCursor c = new MatrixCursor(AlbumColumns.ALL_PROJECTION); 730 List<String> mergedAlbums = List.of(ALBUM_ID_FAVORITES, ALBUM_ID_VIDEOS); 731 for (String albumId : mergedAlbums) { 732 List<String> selectionArgs = new ArrayList<>(); 733 final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder(); 734 if (albumId.equals(ALBUM_ID_FAVORITES)) { 735 qb.appendWhereStandalone(WHERE_IS_FAVORITE); 736 } else if (albumId.equals(ALBUM_ID_VIDEOS)) { 737 qb.appendWhereStandalone(WHERE_MIME_TYPE); 738 selectionArgs.add("video/%"); 739 } 740 if (query.mMimeType != null) { 741 qb.appendWhereStandalone(WHERE_MIME_TYPE); 742 selectionArgs.add(query.mMimeType.replace('*', '%')); 743 } 744 745 Cursor cursor = qb.query(mDatabase, PROJECTION_ALBUM_DB, /* selection */ null, 746 selectionArgs.toArray(new String[0]), /* groupBy */ null, /* having */ null, 747 /* orderBy */ null, /* limit */ null); 748 749 if (cursor == null || !cursor.moveToFirst()) { 750 continue; 751 } 752 753 long count = getCursorLong(cursor, CloudMediaProviderContract.AlbumColumns.MEDIA_COUNT); 754 if (count == 0) { 755 continue; 756 } 757 758 final String[] projectionValue = new String[]{ 759 /* albumId */ albumId, 760 getCursorString(cursor, AlbumColumns.DATE_TAKEN_MILLIS), 761 /* displayName */ albumId, 762 getCursorString(cursor, AlbumColumns.MEDIA_COVER_ID), 763 String.valueOf(count), 764 mLocalProvider, 765 }; 766 c.addRow(projectionValue); 767 } 768 return c; 769 } 770 isLocal(String authority)771 private boolean isLocal(String authority) { 772 return mLocalProvider.equals(authority); 773 } 774 queryMediaForUi(SQLiteQueryBuilder qb, String[] selectionArgs, int limit, String tableName, String authority)775 private Cursor queryMediaForUi(SQLiteQueryBuilder qb, String[] selectionArgs, 776 int limit, String tableName, String authority) { 777 // Use the <table>.<column> form to order _id to avoid ordering against the projection '_id' 778 final String orderBy = getOrderClause(tableName); 779 final String limitStr = String.valueOf(limit); 780 781 // Hold lock while checking the cloud provider and querying so that cursor extras containing 782 // the cloud provider is consistent with the cursor results and doesn't race with 783 // #setCloudProvider 784 synchronized (mLock) { 785 if (mCloudProvider == null || !Objects.equals(mCloudProvider, authority)) { 786 // If cloud provider is null or has changed from what we received from the UI, 787 // skip all cloud items in the picker db 788 qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); 789 } 790 791 return qb.query(mDatabase, getCloudMediaProjectionLocked(), /* selection */ null, 792 selectionArgs, /* groupBy */ null, /* having */ null, orderBy, limitStr); 793 } 794 } 795 getOrderClause(String tableName)796 private static String getOrderClause(String tableName) { 797 return "date_taken_ms DESC," + tableName + "._id DESC"; 798 } 799 getCloudMediaProjectionLocked()800 private String[] getCloudMediaProjectionLocked() { 801 return new String[] { 802 getProjectionAuthorityLocked(), 803 getProjectionDataLocked(MediaColumns.DATA), 804 getProjectionId(MediaColumns.ID), 805 getProjectionSimple(KEY_DATE_TAKEN_MS, MediaColumns.DATE_TAKEN_MILLIS), 806 getProjectionSimple(KEY_SYNC_GENERATION, MediaColumns.SYNC_GENERATION), 807 getProjectionSimple(KEY_SIZE_BYTES, MediaColumns.SIZE_BYTES), 808 getProjectionSimple(KEY_DURATION_MS, MediaColumns.DURATION_MILLIS), 809 getProjectionSimple(KEY_MIME_TYPE, MediaColumns.MIME_TYPE), 810 getProjectionSimple(KEY_STANDARD_MIME_TYPE_EXTENSION, 811 MediaColumns.STANDARD_MIME_TYPE_EXTENSION), 812 }; 813 } 814 getMediaStoreProjectionLocked(String authority, String mediaId, String[] columns)815 private String[] getMediaStoreProjectionLocked(String authority, String mediaId, 816 String[] columns) { 817 final String[] projection = new String[columns.length]; 818 819 for (int i = 0; i < projection.length; i++) { 820 switch (columns[i]) { 821 case PickerMediaColumns.DATA: 822 projection[i] = getProjectionDataLocked(PickerMediaColumns.DATA); 823 break; 824 case PickerMediaColumns.DISPLAY_NAME: 825 projection[i] = getProjectionSimple(getDisplayNameSql(), 826 PickerMediaColumns.DISPLAY_NAME); 827 break; 828 case PickerMediaColumns.MIME_TYPE: 829 projection[i] = getProjectionSimple(KEY_MIME_TYPE, 830 PickerMediaColumns.MIME_TYPE); 831 break; 832 case PickerMediaColumns.DATE_TAKEN: 833 projection[i] = getProjectionSimple(KEY_DATE_TAKEN_MS, 834 PickerMediaColumns.DATE_TAKEN); 835 break; 836 case PickerMediaColumns.SIZE: 837 projection[i] = getProjectionSimple(KEY_SIZE_BYTES, PickerMediaColumns.SIZE); 838 break; 839 case PickerMediaColumns.DURATION_MILLIS: 840 projection[i] = getProjectionSimple(KEY_DURATION_MS, 841 PickerMediaColumns.DURATION_MILLIS); 842 break; 843 default: 844 Uri uri = getMediaUri(authority).buildUpon().appendPath(mediaId).build(); 845 throw new IllegalArgumentException("Unexpected picker URI projection. Uri:" 846 + uri + ". Column:" + columns[i]); 847 } 848 } 849 850 return projection; 851 } 852 getProjectionAuthorityLocked()853 private String getProjectionAuthorityLocked() { 854 // Note that we prefer cloud_id over local_id here. It's important to remember that this 855 // logic is for computing the projection and doesn't affect the filtering of results which 856 // has already been done and ensures that only is_visible=true items are returned. 857 // Here, we need to distinguish between cloud+local and local-only items to determine the 858 // correct authority. Checking whether cloud_id IS NULL distinguishes the former from the 859 // latter. 860 return String.format("CASE WHEN %s IS NULL THEN '%s' ELSE '%s' END AS %s", 861 KEY_CLOUD_ID, mLocalProvider, mCloudProvider, MediaColumns.AUTHORITY); 862 } 863 getProjectionDataLocked(String asColumn)864 private String getProjectionDataLocked(String asColumn) { 865 // _data format: 866 // /sdcard/.transforms/synthetic/picker/<user-id>/<authority>/media/<display-name> 867 // See PickerUriResolver#getMediaUri 868 final String authority = String.format("CASE WHEN %s IS NULL THEN '%s' ELSE '%s' END", 869 KEY_CLOUD_ID, mLocalProvider, mCloudProvider); 870 final String fullPath = "'" + PICKER_PATH + "/'" 871 + "||" + "'" + MediaStore.MY_USER_ID + "/'" 872 + "||" + authority 873 + "||" + "'/" + CloudMediaProviderContract.URI_PATH_MEDIA + "/'" 874 + "||" + getDisplayNameSql(); 875 return String.format("%s AS %s", fullPath, asColumn); 876 } 877 getProjectionId(String asColumn)878 private String getProjectionId(String asColumn) { 879 // We prefer cloud_id first and it only matters for cloud+local items. For those, the row 880 // will already be associated with a cloud authority, see #getProjectionAuthorityLocked. 881 // Note that hidden cloud+local items will not be returned in the query, so there's no 882 // concern of preferring the cloud_id in a cloud+local item over the local_id in a 883 // local-only item. 884 return String.format("IFNULL(%s, %s) AS %s", KEY_CLOUD_ID, KEY_LOCAL_ID, asColumn); 885 } 886 getProjectionSimple(String dbColumn, String column)887 private static String getProjectionSimple(String dbColumn, String column) { 888 return String.format("%s AS %s", dbColumn, column); 889 } 890 getDisplayNameSql()891 private String getDisplayNameSql() { 892 // _display_name format: 893 // <media-id>.<file-extension> 894 // See comment in #getProjectionAuthorityLocked for why cloud_id is preferred over local_id 895 final String mediaId = String.format("IFNULL(%s, %s)", KEY_CLOUD_ID, KEY_LOCAL_ID); 896 // TODO(b/195009139): Add .gif fileextension support 897 final String fileExtension = 898 String.format("CASE WHEN %s LIKE 'image/%%' THEN '%s' ELSE '%s' END", 899 KEY_MIME_TYPE, IMAGE_FILE_EXTENSION, VIDEO_FILE_EXTENSION); 900 901 return mediaId + "||" + fileExtension; 902 } 903 cursorToContentValue(Cursor cursor, boolean isLocal)904 private static ContentValues cursorToContentValue(Cursor cursor, boolean isLocal) { 905 return cursorToContentValue(cursor, isLocal, ""); 906 } 907 cursorToContentValue(Cursor cursor, boolean isLocal, String albumId)908 private static ContentValues cursorToContentValue(Cursor cursor, boolean isLocal, 909 String albumId) { 910 final ContentValues values = new ContentValues(); 911 if(TextUtils.isEmpty(albumId)) { 912 values.put(KEY_IS_VISIBLE, 1); 913 } 914 else { 915 values.put(KEY_ALBUM_ID, albumId); 916 } 917 918 final int count = cursor.getColumnCount(); 919 for (int index = 0; index < count; index++) { 920 String key = cursor.getColumnName(index); 921 switch (key) { 922 case CloudMediaProviderContract.MediaColumns.ID: 923 if (isLocal) { 924 values.put(KEY_LOCAL_ID, cursor.getString(index)); 925 } else { 926 values.put(KEY_CLOUD_ID, cursor.getString(index)); 927 } 928 break; 929 case CloudMediaProviderContract.MediaColumns.MEDIA_STORE_URI: 930 String uriString = cursor.getString(index); 931 if (uriString != null) { 932 Uri uri = Uri.parse(uriString); 933 values.put(KEY_LOCAL_ID, ContentUris.parseId(uri)); 934 } 935 break; 936 case CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS: 937 values.put(KEY_DATE_TAKEN_MS, cursor.getLong(index)); 938 break; 939 case CloudMediaProviderContract.MediaColumns.SYNC_GENERATION: 940 values.put(KEY_SYNC_GENERATION, cursor.getLong(index)); 941 break; 942 case CloudMediaProviderContract.MediaColumns.SIZE_BYTES: 943 values.put(KEY_SIZE_BYTES, cursor.getLong(index)); 944 break; 945 case CloudMediaProviderContract.MediaColumns.MIME_TYPE: 946 values.put(KEY_MIME_TYPE, cursor.getString(index)); 947 break; 948 case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION: 949 int standardMimeTypeExtension = cursor.getInt(index); 950 if (isValidStandardMimeTypeExtension(standardMimeTypeExtension)) { 951 values.put(KEY_STANDARD_MIME_TYPE_EXTENSION, standardMimeTypeExtension); 952 } else { 953 throw new IllegalArgumentException("Invalid standard mime type extension"); 954 } 955 break; 956 case CloudMediaProviderContract.MediaColumns.DURATION_MILLIS: 957 values.put(KEY_DURATION_MS, cursor.getLong(index)); 958 break; 959 case CloudMediaProviderContract.MediaColumns.IS_FAVORITE: 960 if(TextUtils.isEmpty(albumId)) { 961 values.put(KEY_IS_FAVORITE, cursor.getInt(index)); 962 } 963 break; 964 default: 965 Log.w(TAG, "Unexpected cursor key: " + key); 966 } 967 } 968 969 return values; 970 } 971 isValidStandardMimeTypeExtension(int standardMimeTypeExtension)972 private static boolean isValidStandardMimeTypeExtension(int standardMimeTypeExtension) { 973 switch (standardMimeTypeExtension) { 974 case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_NONE: 975 case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_GIF: 976 case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_MOTION_PHOTO: 977 case CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_ANIMATED_WEBP: 978 return true; 979 default: 980 return false; 981 } 982 } 983 buildSelectionArgs(SQLiteQueryBuilder qb, QueryFilter query)984 private static String[] buildSelectionArgs(SQLiteQueryBuilder qb, QueryFilter query) { 985 List<String> selectArgs = new ArrayList<>(); 986 987 if (query.mId >= 0) { 988 if (query.mDateTakenAfterMs >= 0) { 989 qb.appendWhereStandalone(WHERE_DATE_TAKEN_MS_AFTER); 990 // Add date args twice because the sql statement evaluates date twice 991 selectArgs.add(String.valueOf(query.mDateTakenAfterMs)); 992 selectArgs.add(String.valueOf(query.mDateTakenAfterMs)); 993 } else { 994 qb.appendWhereStandalone(WHERE_DATE_TAKEN_MS_BEFORE); 995 // Add date args twice because the sql statement evaluates date twice 996 selectArgs.add(String.valueOf(query.mDateTakenBeforeMs)); 997 selectArgs.add(String.valueOf(query.mDateTakenBeforeMs)); 998 } 999 selectArgs.add(String.valueOf(query.mId)); 1000 } 1001 1002 if (query.mSizeBytes >= 0) { 1003 qb.appendWhereStandalone(WHERE_SIZE_BYTES); 1004 selectArgs.add(String.valueOf(query.mSizeBytes)); 1005 } 1006 1007 if (query.mMimeType != null) { 1008 qb.appendWhereStandalone(WHERE_MIME_TYPE); 1009 selectArgs.add(replaceMatchAnyChar(query.mMimeType)); 1010 } 1011 if (query.mIsVideo) { 1012 qb.appendWhereStandalone(WHERE_MIME_TYPE); 1013 selectArgs.add(VIDEO_MIME_TYPES); 1014 } else if (query.mIsFavorite) { 1015 qb.appendWhereStandalone(WHERE_IS_FAVORITE); 1016 } else if (!TextUtils.isEmpty(query.mAlbumId)) { 1017 qb.appendWhereStandalone(WHERE_ALBUM_ID); 1018 selectArgs.add(query.mAlbumId); 1019 } 1020 1021 if (selectArgs.isEmpty()) { 1022 return null; 1023 } 1024 1025 return selectArgs.toArray(new String[selectArgs.size()]); 1026 } 1027 createMediaQueryBuilder()1028 private static SQLiteQueryBuilder createMediaQueryBuilder() { 1029 SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); 1030 qb.setTables(TABLE_MEDIA); 1031 1032 return qb; 1033 } 1034 createAlbumMediaQueryBuilder(boolean isLocal)1035 private static SQLiteQueryBuilder createAlbumMediaQueryBuilder(boolean isLocal) { 1036 SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); 1037 qb.setTables(TABLE_ALBUM_MEDIA); 1038 1039 if (isLocal) { 1040 qb.appendWhereStandalone(WHERE_NOT_NULL_LOCAL_ID); 1041 } else { 1042 qb.appendWhereStandalone(WHERE_NOT_NULL_CLOUD_ID); 1043 } 1044 1045 return qb; 1046 } 1047 createLocalOnlyMediaQueryBuilder()1048 private static SQLiteQueryBuilder createLocalOnlyMediaQueryBuilder() { 1049 SQLiteQueryBuilder qb = createLocalMediaQueryBuilder(); 1050 qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID); 1051 1052 return qb; 1053 } 1054 createLocalMediaQueryBuilder()1055 private static SQLiteQueryBuilder createLocalMediaQueryBuilder() { 1056 SQLiteQueryBuilder qb = createMediaQueryBuilder(); 1057 qb.appendWhereStandalone(WHERE_LOCAL_ID); 1058 1059 return qb; 1060 } 1061 createCloudMediaQueryBuilder()1062 private static SQLiteQueryBuilder createCloudMediaQueryBuilder() { 1063 SQLiteQueryBuilder qb = createMediaQueryBuilder(); 1064 qb.appendWhereStandalone(WHERE_CLOUD_ID); 1065 1066 return qb; 1067 } 1068 createIdMediaQueryBuilder()1069 private static SQLiteQueryBuilder createIdMediaQueryBuilder() { 1070 SQLiteQueryBuilder qb = createMediaQueryBuilder(); 1071 qb.appendWhereStandalone(WHERE_ID); 1072 1073 return qb; 1074 } 1075 createVisibleMediaQueryBuilder()1076 private static SQLiteQueryBuilder createVisibleMediaQueryBuilder() { 1077 SQLiteQueryBuilder qb = createMediaQueryBuilder(); 1078 qb.appendWhereStandalone(WHERE_IS_VISIBLE); 1079 1080 return qb; 1081 } 1082 createVisibleLocalMediaQueryBuilder()1083 private static SQLiteQueryBuilder createVisibleLocalMediaQueryBuilder() { 1084 SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder(); 1085 qb.appendWhereStandalone(WHERE_LOCAL_ID); 1086 1087 return qb; 1088 } 1089 1090 private static final class ResetAlbumOperation extends DbWriteOperation { 1091 /** 1092 * Resets the given cloud or local album_media identified by {@code isLocal} and 1093 * {@code albumId}. If {@code albumId} is null, resets all the respective cloud or 1094 * local albums. 1095 */ ResetAlbumOperation(SQLiteDatabase database, boolean isLocal, String albumId)1096 private ResetAlbumOperation(SQLiteDatabase database, boolean isLocal, String albumId) { 1097 super(database, isLocal, albumId); 1098 } 1099 1100 @Override executeInternal(@ullable Cursor unused)1101 int executeInternal(@Nullable Cursor unused) { 1102 final String albumId = albumId(); 1103 final boolean isLocal = isLocal(); 1104 1105 final SQLiteQueryBuilder qb = createAlbumMediaQueryBuilder(isLocal); 1106 1107 String[] selectionArgs = null; 1108 if(!TextUtils.isEmpty(albumId)) { 1109 qb.appendWhereStandalone(WHERE_ALBUM_ID); 1110 selectionArgs = new String[]{albumId}; 1111 } 1112 1113 return qb.delete(getDatabase(), /* selection */ null, /* selectionArgs */ 1114 selectionArgs); 1115 } 1116 } 1117 1118 private static final class AddAlbumMediaOperation extends DbWriteOperation { AddAlbumMediaOperation(SQLiteDatabase database, boolean isLocal, String albumId)1119 private AddAlbumMediaOperation(SQLiteDatabase database, boolean isLocal, String albumId) { 1120 super(database, isLocal, albumId); 1121 if(TextUtils.isEmpty(albumId)) { 1122 throw new IllegalArgumentException("Missing albumId."); 1123 } 1124 } 1125 1126 @Override executeInternal(@ullable Cursor cursor)1127 int executeInternal(@Nullable Cursor cursor) { 1128 final boolean isLocal = isLocal(); 1129 final String albumId = albumId(); 1130 final SQLiteQueryBuilder qb = createAlbumMediaQueryBuilder(isLocal); 1131 int counter = 0; 1132 1133 while (cursor.moveToNext()) { 1134 ContentValues values = cursorToContentValue(cursor, isLocal, albumId); 1135 try { 1136 if (qb.insert(getDatabase(), values) > 0) { 1137 counter++; 1138 } else { 1139 Log.d(TAG, "Failed to insert album_media. ContentValues: " + values); 1140 } 1141 } catch (SQLiteConstraintException e) { 1142 Log.d(TAG, "Failed to insert album_media. ContentValues: " + values, e); 1143 } 1144 } 1145 1146 return counter; 1147 } 1148 } 1149 } 1150