1 /* 2 * Copyright (C) 2024 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.v2; 18 19 import static com.android.providers.media.PickerUriResolver.getAlbumUri; 20 import static com.android.providers.media.photopicker.sync.PickerSyncManager.IMMEDIATE_LOCAL_SYNC_WORK_NAME; 21 import static com.android.providers.media.photopicker.sync.WorkManagerInitializer.getWorkManager; 22 import static com.android.providers.media.photopicker.v2.model.AlbumsCursorWrapper.EMPTY_MEDIA_ID; 23 24 import static java.util.Objects.requireNonNull; 25 26 import android.annotation.UserIdInt; 27 import android.content.Context; 28 import android.content.pm.PackageManager; 29 import android.content.pm.PackageManager.NameNotFoundException; 30 import android.content.pm.ProviderInfo; 31 import android.database.Cursor; 32 import android.database.MatrixCursor; 33 import android.database.MergeCursor; 34 import android.database.sqlite.SQLiteDatabase; 35 import android.os.Bundle; 36 import android.os.Process; 37 import android.provider.CloudMediaProviderContract.AlbumColumns; 38 import android.util.Log; 39 40 import androidx.annotation.NonNull; 41 import androidx.annotation.Nullable; 42 43 import com.android.providers.media.photopicker.PickerSyncController; 44 import com.android.providers.media.photopicker.sync.SyncCompletionWaiter; 45 import com.android.providers.media.photopicker.sync.SyncTrackerRegistry; 46 import com.android.providers.media.photopicker.v2.model.AlbumMediaQuery; 47 import com.android.providers.media.photopicker.v2.model.AlbumsCursorWrapper; 48 import com.android.providers.media.photopicker.v2.model.FavoritesMediaQuery; 49 import com.android.providers.media.photopicker.v2.model.MediaQuery; 50 import com.android.providers.media.photopicker.v2.model.MediaSource; 51 import com.android.providers.media.photopicker.v2.model.VideoMediaQuery; 52 53 import java.util.ArrayList; 54 import java.util.Arrays; 55 import java.util.List; 56 import java.util.Objects; 57 58 /** 59 * This class handles Photo Picker content queries. 60 */ 61 public class PickerDataLayerV2 { 62 private static final String TAG = "PickerDataLayerV2"; 63 private static final int CLOUD_SYNC_TIMEOUT_MILLIS = 500; 64 public static final List<String> sMergedAlbumIds = List.of( 65 AlbumColumns.ALBUM_ID_FAVORITES, 66 AlbumColumns.ALBUM_ID_VIDEOS 67 ); 68 69 /** 70 * Returns a cursor with the Photo Picker media in response. 71 * 72 * @param appContext The application context. 73 * @param queryArgs The arguments help us filter on the media query to yield the desired 74 * results. 75 */ 76 @NonNull queryMedia(@onNull Context appContext, @NonNull Bundle queryArgs)77 static Cursor queryMedia(@NonNull Context appContext, @NonNull Bundle queryArgs) { 78 final MediaQuery query = new MediaQuery(queryArgs); 79 final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow(); 80 final String effectiveLocalAuthority = 81 query.getProviders().contains(syncController.getLocalProvider()) 82 ? syncController.getLocalProvider() 83 : null; 84 final String cloudAuthority = syncController 85 .getCloudProviderOrDefault(/* defaultValue */ null); 86 final String effectiveCloudAuthority = 87 syncController.shouldQueryCloudMedia(query.getProviders(), cloudAuthority) 88 ? cloudAuthority 89 : null; 90 91 return queryMediaInternal( 92 appContext, 93 syncController, 94 query, 95 effectiveLocalAuthority, 96 effectiveCloudAuthority 97 ); 98 } 99 100 /** 101 * Returns a cursor with the Photo Picker albums in response. 102 * 103 * @param appContext The application context. 104 * @param queryArgs The arguments help us filter on the media query to yield the desired 105 * results. 106 */ 107 @Nullable queryAlbums(@onNull Context appContext, @NonNull Bundle queryArgs)108 static Cursor queryAlbums(@NonNull Context appContext, @NonNull Bundle queryArgs) { 109 final MediaQuery query = new MediaQuery(queryArgs); 110 final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow(); 111 final SQLiteDatabase database = syncController.getDbFacade().getDatabase(); 112 final String localAuthority = syncController.getLocalProvider(); 113 final boolean shouldShowLocalAlbums = query.getProviders().contains(localAuthority); 114 final String cloudAuthority = 115 syncController.getCloudProviderOrDefault(/* defaultValue */ null); 116 final boolean shouldShowCloudAlbums = syncController.shouldQueryCloudMedia( 117 query.getProviders(), cloudAuthority); 118 final List<AlbumsCursorWrapper> cursors = new ArrayList<>(); 119 120 if (shouldShowLocalAlbums || shouldShowCloudAlbums) { 121 cursors.add(getMergedAlbumsCursor( 122 AlbumColumns.ALBUM_ID_FAVORITES, queryArgs, database, 123 shouldShowLocalAlbums ? localAuthority : null, 124 shouldShowCloudAlbums ? cloudAuthority : null)); 125 126 cursors.add(getMergedAlbumsCursor( 127 AlbumColumns.ALBUM_ID_VIDEOS, queryArgs, database, 128 shouldShowLocalAlbums ? localAuthority : null, 129 shouldShowCloudAlbums ? cloudAuthority : null)); 130 } 131 132 if (shouldShowLocalAlbums) { 133 cursors.add(getLocalAlbumsCursor(appContext, query, localAuthority)); 134 } 135 136 if (shouldShowCloudAlbums) { 137 cursors.add(getCloudAlbumsCursor(appContext, query, localAuthority, cloudAuthority)); 138 } 139 140 cursors.removeIf(Objects::isNull); 141 if (cursors.isEmpty()) { 142 Log.e(TAG, "No albums available"); 143 return null; 144 } else { 145 Cursor mergeCursor = new MergeCursor(cursors.toArray(new Cursor[0])); 146 Log.i(TAG, "Returning " + mergeCursor.getCount() + " albums metadata"); 147 return mergeCursor; 148 } 149 } 150 151 /** 152 * Returns a cursor with the Photo Picker album media in response. 153 * 154 * @param appContext The application context. 155 * @param queryArgs The arguments help us filter on the media query to yield the desired 156 * results. 157 * @param albumId The album ID of the requested album media. 158 */ queryAlbumMedia( @onNull Context appContext, @NonNull Bundle queryArgs, @NonNull String albumId)159 static Cursor queryAlbumMedia( 160 @NonNull Context appContext, 161 @NonNull Bundle queryArgs, 162 @NonNull String albumId) { 163 final AlbumMediaQuery query = new AlbumMediaQuery(queryArgs, albumId); 164 final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow(); 165 final String effectiveLocalAuthority = 166 query.getProviders().contains(syncController.getLocalProvider()) 167 ? syncController.getLocalProvider() 168 : null; 169 final String cloudAuthority = syncController 170 .getCloudProviderOrDefault(/* defaultValue */ null); 171 final String effectiveCloudAuthority = 172 syncController.shouldQueryCloudMedia(query.getProviders(), cloudAuthority) 173 ? cloudAuthority 174 : null; 175 176 if (isMergedAlbum(albumId)) { 177 return queryMergedAlbumMedia( 178 albumId, 179 appContext, 180 syncController, 181 queryArgs, 182 effectiveLocalAuthority, 183 effectiveCloudAuthority 184 ); 185 } else { 186 return queryAlbumMediaInternal( 187 appContext, 188 syncController, 189 query, 190 effectiveLocalAuthority, 191 effectiveCloudAuthority 192 ); 193 } 194 } 195 196 /** 197 * Query media from the database and prepare a cursor in response. 198 * 199 * We need to make multiple queries to prepare a response for the media query. 200 * {@link android.database.sqlite.SQLiteQueryBuilder} currently does not support the creation of 201 * a transaction in {@code DEFERRED} mode. This is why we'll perform the read queries in 202 * {@code IMMEDIATE} mode instead. 203 * 204 * @param appContext The application context. 205 * @param syncController Instance of the PickerSyncController singleton. 206 * @param query The MediaQuery object instance that tells us about the media query args. 207 * @param localAuthority The effective local authority that we need to consider for this 208 * transaction. If the local items should not be queries but the local 209 * authority has some value, the effective local authority would be null. 210 * @param cloudAuthority The effective cloud authority that we need to consider for this 211 * transaction. If the local items should not be queries but the local 212 * authority has some value, the effective local authority would 213 * be null. 214 * @return The cursor with the album media query results. 215 */ 216 @NonNull queryMediaInternal( @onNull Context appContext, @NonNull PickerSyncController syncController, @NonNull MediaQuery query, @Nullable String localAuthority, @Nullable String cloudAuthority )217 private static Cursor queryMediaInternal( 218 @NonNull Context appContext, 219 @NonNull PickerSyncController syncController, 220 @NonNull MediaQuery query, 221 @Nullable String localAuthority, 222 @Nullable String cloudAuthority 223 ) { 224 try { 225 final SQLiteDatabase database = syncController.getDbFacade().getDatabase(); 226 227 waitForOngoingSync(appContext, localAuthority, cloudAuthority); 228 229 try { 230 database.beginTransactionNonExclusive(); 231 Cursor pageData = database.rawQuery( 232 getMediaPageQuery( 233 query, 234 database, 235 PickerSQLConstants.Table.MEDIA, 236 localAuthority, 237 cloudAuthority 238 ), 239 /* selectionArgs */ null 240 ); 241 242 Bundle extraArgs = new Bundle(); 243 Cursor nextPageKeyCursor = database.rawQuery( 244 getMediaNextPageKeyQuery( 245 query, 246 database, 247 PickerSQLConstants.Table.MEDIA, 248 localAuthority, 249 cloudAuthority 250 ), 251 /* selectionArgs */ null 252 ); 253 addNextPageKey(extraArgs, nextPageKeyCursor); 254 255 Cursor prevPageKeyCursor = database.rawQuery( 256 getMediaPreviousPageQuery( 257 query, 258 database, 259 PickerSQLConstants.Table.MEDIA, 260 localAuthority, 261 cloudAuthority 262 ), 263 /* selectionArgs */ null 264 ); 265 addPrevPageKey(extraArgs, prevPageKeyCursor); 266 267 database.setTransactionSuccessful(); 268 269 pageData.setExtras(extraArgs); 270 Log.i(TAG, "Returning " + pageData.getCount() + " media metadata"); 271 return pageData; 272 } finally { 273 database.endTransaction(); 274 } 275 276 277 } catch (Exception e) { 278 throw new RuntimeException("Could not fetch media", e); 279 } 280 } 281 waitForOngoingSync( @onNull Context appContext, @Nullable String localAuthority, @Nullable String cloudAuthority)282 private static void waitForOngoingSync( 283 @NonNull Context appContext, 284 @Nullable String localAuthority, 285 @Nullable String cloudAuthority) { 286 if (localAuthority != null) { 287 SyncCompletionWaiter.waitForSync( 288 getWorkManager(appContext), 289 SyncTrackerRegistry.getLocalSyncTracker(), 290 IMMEDIATE_LOCAL_SYNC_WORK_NAME 291 ); 292 } 293 294 if (cloudAuthority != null) { 295 boolean syncIsComplete = SyncCompletionWaiter.waitForSyncWithTimeout( 296 SyncTrackerRegistry.getCloudSyncTracker(), 297 CLOUD_SYNC_TIMEOUT_MILLIS); 298 Log.i(TAG, "Finished waiting for cloud sync. Is cloud sync complete: " 299 + syncIsComplete); 300 } 301 } 302 303 /** 304 * @param appContext The application context. 305 * @param query The AlbumMediaQuery object instance that tells us about the media query args. 306 * @param localAuthority The effective local authority that we need to consider for this 307 * transaction. If the local items should not be queries but the local 308 * authority has some value, the effective local authority would be null. 309 */ waitForOngoingAlbumSync( @onNull Context appContext, @NonNull AlbumMediaQuery query, @Nullable String localAuthority)310 private static void waitForOngoingAlbumSync( 311 @NonNull Context appContext, 312 @NonNull AlbumMediaQuery query, 313 @Nullable String localAuthority) { 314 boolean isLocal = localAuthority != null 315 && localAuthority.equals(query.getAlbumAuthority()); 316 SyncCompletionWaiter.waitForSyncWithTimeout( 317 SyncTrackerRegistry.getAlbumSyncTracker(isLocal), 318 /* timeoutInMillis */ 500); 319 } 320 321 /** 322 * Adds the next page key to the cursor extras from the given cursor. 323 * 324 * This is not a part of the page data. Photo Picker UI uses the Paging library requires us to 325 * provide the previous page key and the next page key as part of a page load response. 326 * The page key in this case refers to the date taken and the picker id of the first item in 327 * the page. 328 */ addNextPageKey(Bundle extraArgs, Cursor nextPageKeyCursor)329 private static void addNextPageKey(Bundle extraArgs, Cursor nextPageKeyCursor) { 330 if (nextPageKeyCursor.moveToFirst()) { 331 final int pickerIdColumnIndex = nextPageKeyCursor.getColumnIndex( 332 PickerSQLConstants.MediaResponse.PICKER_ID.getProjectedName() 333 ); 334 335 if (pickerIdColumnIndex >= 0) { 336 extraArgs.putLong(PickerSQLConstants.MediaResponseExtras.NEXT_PAGE_ID.getKey(), 337 nextPageKeyCursor.getLong(pickerIdColumnIndex) 338 ); 339 } 340 341 final int dateTakenColumnIndex = nextPageKeyCursor.getColumnIndex( 342 PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getProjectedName() 343 ); 344 345 if (dateTakenColumnIndex >= 0) { 346 extraArgs.putLong(PickerSQLConstants.MediaResponseExtras 347 .NEXT_PAGE_DATE_TAKEN.getKey(), 348 nextPageKeyCursor.getLong(dateTakenColumnIndex) 349 ); 350 } 351 } 352 } 353 354 /** 355 * Adds the previous page key to the cursor extras from the given cursor. 356 * 357 * This is not a part of the page data. Photo Picker UI uses the Paging library requires us to 358 * provide the previous page key and the next page key as part of a page load response. 359 * The page key in this case refers to the date taken and the picker id of the first item in 360 * the page. 361 */ addPrevPageKey(Bundle extraArgs, Cursor prevPageKeyCursor)362 private static void addPrevPageKey(Bundle extraArgs, Cursor prevPageKeyCursor) { 363 if (prevPageKeyCursor.moveToLast()) { 364 final int pickerIdColumnIndex = prevPageKeyCursor.getColumnIndex( 365 PickerSQLConstants.MediaResponse.PICKER_ID.getProjectedName() 366 ); 367 368 if (pickerIdColumnIndex >= 0) { 369 extraArgs.putLong(PickerSQLConstants.MediaResponseExtras.PREV_PAGE_ID.getKey(), 370 prevPageKeyCursor.getLong(pickerIdColumnIndex) 371 ); 372 } 373 374 final int dateTakenColumnIndex = prevPageKeyCursor.getColumnIndex( 375 PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getProjectedName() 376 ); 377 378 if (dateTakenColumnIndex >= 0) { 379 extraArgs.putLong(PickerSQLConstants.MediaResponseExtras 380 .PREV_PAGE_DATE_TAKEN.getKey(), 381 prevPageKeyCursor.getLong(dateTakenColumnIndex) 382 ); 383 } 384 } 385 } 386 387 /** 388 * Builds and returns the SQL query to get the page contents from the Media table in Picker DB. 389 */ getMediaPageQuery( @onNull MediaQuery query, @NonNull SQLiteDatabase database, @NonNull PickerSQLConstants.Table table, @Nullable String localAuthority, @Nullable String cloudAuthority)390 private static String getMediaPageQuery( 391 @NonNull MediaQuery query, 392 @NonNull SQLiteDatabase database, 393 @NonNull PickerSQLConstants.Table table, 394 @Nullable String localAuthority, 395 @Nullable String cloudAuthority) { 396 SelectSQLiteQueryBuilder queryBuilder = new SelectSQLiteQueryBuilder(database) 397 .setTables(table.name()) 398 .setProjection(List.of( 399 PickerSQLConstants.MediaResponse.MEDIA_ID.getProjection(), 400 PickerSQLConstants.MediaResponse.PICKER_ID.getProjection(), 401 PickerSQLConstants.MediaResponse 402 .AUTHORITY.getProjection(localAuthority, cloudAuthority), 403 PickerSQLConstants.MediaResponse.MEDIA_SOURCE.getProjection(), 404 PickerSQLConstants.MediaResponse.WRAPPED_URI.getProjection( 405 localAuthority, cloudAuthority, query.getIntentAction()), 406 PickerSQLConstants.MediaResponse 407 .UNWRAPPED_URI.getProjection(localAuthority, cloudAuthority), 408 PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getProjection(), 409 PickerSQLConstants.MediaResponse.SIZE_IN_BYTES.getProjection(), 410 PickerSQLConstants.MediaResponse.MIME_TYPE.getProjection(), 411 PickerSQLConstants.MediaResponse.STANDARD_MIME_TYPE.getProjection(), 412 PickerSQLConstants.MediaResponse.DURATION_MS.getProjection() 413 )) 414 .setSortOrder( 415 String.format( 416 "%s DESC, %s DESC", 417 PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getColumnName(), 418 PickerSQLConstants.MediaResponse.PICKER_ID.getColumnName() 419 ) 420 ) 421 .setLimit(query.getPageSize()); 422 423 query.addWhereClause( 424 queryBuilder, 425 localAuthority, 426 cloudAuthority, 427 /* reverseOrder */ false 428 ); 429 430 return queryBuilder.buildQuery(); 431 } 432 433 /** 434 * Builds and returns the SQL query to get the next page key from the Media table in Picker DB. 435 */ 436 @Nullable getMediaNextPageKeyQuery( @onNull MediaQuery query, @NonNull SQLiteDatabase database, @NonNull PickerSQLConstants.Table table, @Nullable String localAuthority, @Nullable String cloudAuthority)437 private static String getMediaNextPageKeyQuery( 438 @NonNull MediaQuery query, 439 @NonNull SQLiteDatabase database, 440 @NonNull PickerSQLConstants.Table table, 441 @Nullable String localAuthority, 442 @Nullable String cloudAuthority) { 443 if (query.getPageSize() == Integer.MAX_VALUE) { 444 return null; 445 } 446 447 SelectSQLiteQueryBuilder queryBuilder = new SelectSQLiteQueryBuilder(database) 448 .setTables(table.name()) 449 .setProjection(List.of( 450 PickerSQLConstants.MediaResponse.PICKER_ID.getProjection(), 451 PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getProjection() 452 )) 453 .setSortOrder( 454 String.format( 455 "%s DESC, %s DESC", 456 PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getColumnName(), 457 PickerSQLConstants.MediaResponse.PICKER_ID.getColumnName() 458 ) 459 ) 460 .setLimit(1) 461 .setOffset(query.getPageSize()); 462 463 query.addWhereClause( 464 queryBuilder, 465 localAuthority, 466 cloudAuthority, 467 /* reverseOrder */ false 468 ); 469 470 return queryBuilder.buildQuery(); 471 } 472 473 /** 474 * Builds and returns the SQL query to get the previous page contents from the Media table in 475 * Picker DB. 476 * 477 * We fetch the whole page and not just one key because it is possible that the previous page 478 * is smaller than the page size. So, we get the whole page and only use the last row item to 479 * get the previous page key. 480 */ getMediaPreviousPageQuery( @onNull MediaQuery query, @NonNull SQLiteDatabase database, @NonNull PickerSQLConstants.Table table, @Nullable String localAuthority, @Nullable String cloudAuthority)481 private static String getMediaPreviousPageQuery( 482 @NonNull MediaQuery query, 483 @NonNull SQLiteDatabase database, 484 @NonNull PickerSQLConstants.Table table, 485 @Nullable String localAuthority, 486 @Nullable String cloudAuthority) { 487 SelectSQLiteQueryBuilder queryBuilder = new SelectSQLiteQueryBuilder(database) 488 .setTables(table.name()) 489 .setProjection(List.of( 490 PickerSQLConstants.MediaResponse.PICKER_ID.getProjection(), 491 PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getProjection() 492 )).setSortOrder( 493 String.format( 494 "%s ASC, %s ASC", 495 PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getColumnName(), 496 PickerSQLConstants.MediaResponse.PICKER_ID.getColumnName() 497 ) 498 ).setLimit(query.getPageSize()); 499 500 501 query.addWhereClause( 502 queryBuilder, 503 localAuthority, 504 cloudAuthority, 505 /* reverseOrder */ true 506 ); 507 508 return queryBuilder.buildQuery(); 509 } 510 511 /** 512 * Return merged albums cursor for the given merged album id. 513 * 514 * @param albumId Merged album id. 515 * @param queryArgs Query arguments bundle that will be used to filter albums. 516 * @param database Instance of Picker SQLiteDatabase. 517 * @param localAuthority The local authority if local albums should be returned, otherwise this 518 * argument should be null. 519 * @param cloudAuthority The cloud authority if cloud albums should be returned, otherwise this 520 * argument should be null. 521 */ getMergedAlbumsCursor( @onNull String albumId, @NonNull Bundle queryArgs, @NonNull SQLiteDatabase database, @Nullable String localAuthority, @Nullable String cloudAuthority)522 private static AlbumsCursorWrapper getMergedAlbumsCursor( 523 @NonNull String albumId, 524 @NonNull Bundle queryArgs, 525 @NonNull SQLiteDatabase database, 526 @Nullable String localAuthority, 527 @Nullable String cloudAuthority) { 528 final MediaQuery query; 529 if (albumId.equals(AlbumColumns.ALBUM_ID_VIDEOS)) { 530 VideoMediaQuery videoQuery = new VideoMediaQuery(queryArgs, 1); 531 if (!videoQuery.shouldDisplayVideosAlbum()) { 532 return null; 533 } 534 query = videoQuery; 535 } else if (albumId.equals(AlbumColumns.ALBUM_ID_FAVORITES)) { 536 query = new FavoritesMediaQuery(queryArgs, 1); 537 } else { 538 Log.e(TAG, "Cannot recognize merged album " + albumId); 539 return null; 540 } 541 542 try { 543 database.beginTransactionNonExclusive(); 544 Cursor pickerDBResponse = database.rawQuery( 545 getMediaPageQuery( 546 query, 547 database, 548 PickerSQLConstants.Table.MEDIA, 549 localAuthority, 550 cloudAuthority 551 ), 552 /* selectionArgs */ null 553 ); 554 555 if (pickerDBResponse.moveToFirst()) { 556 // Conform to the album response projection. Temporary code, this will change once 557 // we start caching album metadata. 558 final MatrixCursor result = new MatrixCursor(AlbumColumns.ALL_PROJECTION); 559 final String authority = pickerDBResponse.getString(pickerDBResponse.getColumnIndex( 560 PickerSQLConstants.MediaResponse.AUTHORITY.getProjectedName())); 561 final String[] projectionValue = new String[]{ 562 /* albumId */ albumId, 563 pickerDBResponse.getString(pickerDBResponse.getColumnIndex( 564 PickerSQLConstants.MediaResponse.DATE_TAKEN_MS.getProjectedName())), 565 /* displayName */ albumId, 566 pickerDBResponse.getString(pickerDBResponse.getColumnIndex( 567 PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName())), 568 /* count */ "0", // This value is not used anymore 569 authority, 570 }; 571 result.addRow(projectionValue); 572 return new AlbumsCursorWrapper(result, authority, localAuthority); 573 } 574 575 // Show merged albums even if no data is currently available in the DB when cloud media 576 // feature is enabled. 577 if (cloudAuthority != null) { 578 // Conform to the album response projection. Temporary code, this will change once 579 // we start caching album metadata. 580 final MatrixCursor result = new MatrixCursor(AlbumColumns.ALL_PROJECTION); 581 final String[] projectionValue = new String[]{ 582 /* albumId */ albumId, 583 /* dateTakenMillis */ Long.toString(Long.MAX_VALUE), 584 /* displayName */ albumId, 585 /* mediaId */ EMPTY_MEDIA_ID, 586 /* count */ "0", // This value is not used anymore 587 localAuthority, 588 }; 589 result.addRow(projectionValue); 590 return new AlbumsCursorWrapper(result, localAuthority, localAuthority); 591 } 592 593 return null; 594 } finally { 595 database.setTransactionSuccessful(); 596 database.endTransaction(); 597 } 598 599 } 600 601 /** 602 * Returns local albums cursor after fetching them from the local provider. 603 * 604 * @param appContext The application context. 605 * @param query Query arguments that will be used to filter albums. 606 * @param localAuthority Authority of the local media provider. 607 */ 608 @Nullable getLocalAlbumsCursor( @onNull Context appContext, @NonNull MediaQuery query, @NonNull String localAuthority)609 private static AlbumsCursorWrapper getLocalAlbumsCursor( 610 @NonNull Context appContext, 611 @NonNull MediaQuery query, 612 @NonNull String localAuthority) { 613 return getCMPAlbumsCursor(appContext, query, localAuthority, localAuthority); 614 } 615 616 /** 617 * Returns cloud albums cursor after fetching them from the local provider. 618 * 619 * @param appContext The application context. 620 * @param query Query arguments that will be used to filter albums. 621 * @param localAuthority Authority of the local media provider. 622 * @param cloudAuthority Authority of the cloud media provider. 623 */ 624 @Nullable getCloudAlbumsCursor( @onNull Context appContext, @NonNull MediaQuery query, @NonNull String localAuthority, @NonNull String cloudAuthority)625 private static AlbumsCursorWrapper getCloudAlbumsCursor( 626 @NonNull Context appContext, 627 @NonNull MediaQuery query, 628 @NonNull String localAuthority, 629 @NonNull String cloudAuthority) { 630 return getCMPAlbumsCursor(appContext, query, localAuthority, cloudAuthority); 631 } 632 633 /** 634 * Returns {@link AlbumsCursorWrapper} object that wraps the albums cursor response from the 635 * CMP. 636 * 637 * @param appContext The application context. 638 * @param query Query arguments that will be used to filter albums. 639 * @param localAuthority Authority of the local media provider. 640 * @param cmpAuthority Authority of the cloud media provider. 641 */ 642 @Nullable getCMPAlbumsCursor( @onNull Context appContext, @NonNull MediaQuery query, @NonNull String localAuthority, @NonNull String cmpAuthority)643 private static AlbumsCursorWrapper getCMPAlbumsCursor( 644 @NonNull Context appContext, 645 @NonNull MediaQuery query, 646 @NonNull String localAuthority, 647 @NonNull String cmpAuthority) { 648 final Cursor cursor = appContext.getContentResolver().query( 649 getAlbumUri(cmpAuthority), 650 /* projection */ null, 651 query.prepareCMPQueryArgs(), 652 /* cancellationSignal */ null); 653 return cursor == null 654 ? null 655 : new AlbumsCursorWrapper(cursor, cmpAuthority, localAuthority); 656 } 657 658 /** 659 * @param appContext The application context. 660 * @param syncController Instance of the PickerSyncController singleton. 661 * @param query The AlbumMediaQuery object instance that tells us about the media query args. 662 * @param localAuthority The effective local authority that we need to consider for this 663 * transaction. If the local items should not be queries but the local 664 * authority has some value, the effective local authority would be null. 665 * @param cloudAuthority The effective cloud authority that we need to consider for this 666 * transaction. If the local items should not be queries but the local 667 * authority has some value, the effective local authority would 668 * be null. 669 * @return The cursor with the album media query results. 670 */ 671 @NonNull queryAlbumMediaInternal( @onNull Context appContext, @NonNull PickerSyncController syncController, @NonNull AlbumMediaQuery query, @Nullable String localAuthority, @Nullable String cloudAuthority )672 private static Cursor queryAlbumMediaInternal( 673 @NonNull Context appContext, 674 @NonNull PickerSyncController syncController, 675 @NonNull AlbumMediaQuery query, 676 @Nullable String localAuthority, 677 @Nullable String cloudAuthority 678 ) { 679 try { 680 final SQLiteDatabase database = syncController.getDbFacade().getDatabase(); 681 682 waitForOngoingAlbumSync(appContext, query, localAuthority); 683 684 try { 685 database.beginTransactionNonExclusive(); 686 Cursor pageData = database.rawQuery( 687 getMediaPageQuery( 688 query, 689 database, 690 PickerSQLConstants.Table.ALBUM_MEDIA, 691 localAuthority, 692 cloudAuthority 693 ), 694 /* selectionArgs */ null 695 ); 696 697 Bundle extraArgs = new Bundle(); 698 Cursor nextPageKeyCursor = database.rawQuery( 699 getMediaNextPageKeyQuery( 700 query, 701 database, 702 PickerSQLConstants.Table.ALBUM_MEDIA, 703 localAuthority, 704 cloudAuthority 705 ), 706 /* selectionArgs */ null 707 ); 708 addNextPageKey(extraArgs, nextPageKeyCursor); 709 710 Cursor prevPageKeyCursor = database.rawQuery( 711 getMediaPreviousPageQuery( 712 query, 713 database, 714 PickerSQLConstants.Table.ALBUM_MEDIA, 715 localAuthority, 716 cloudAuthority 717 ), 718 /* selectionArgs */ null 719 ); 720 addPrevPageKey(extraArgs, prevPageKeyCursor); 721 722 database.setTransactionSuccessful(); 723 724 pageData.setExtras(extraArgs); 725 Log.i(TAG, "Returning " + pageData.getCount() + " album media items for album " 726 + query.getAlbumId()); 727 return pageData; 728 } finally { 729 database.endTransaction(); 730 } 731 732 733 } catch (Exception e) { 734 throw new RuntimeException("Could not fetch media", e); 735 } 736 } 737 738 /** 739 * @param albumId The album id of the request album media. 740 * @param appContext The application context. 741 * @param syncController Instance of the PickerSyncController singleton. 742 * @param queryArgs The Bundle with query args received with the request. 743 * @param localAuthority The effective local authority that we need to consider for this 744 * transaction. If the local items should not be queries but the local 745 * authority has some value, the effective local authority would be null. 746 * @param cloudAuthority The effective cloud authority that we need to consider for this 747 * transaction. If the local items should not be queries but the local 748 * authority has some value, the effective local authority would 749 * be null. 750 * @return The cursor with the album media query results. 751 */ 752 @NonNull queryMergedAlbumMedia( @onNull String albumId, @NonNull Context appContext, @NonNull PickerSyncController syncController, @NonNull Bundle queryArgs, @Nullable String localAuthority, @Nullable String cloudAuthority )753 private static Cursor queryMergedAlbumMedia( 754 @NonNull String albumId, 755 @NonNull Context appContext, 756 @NonNull PickerSyncController syncController, 757 @NonNull Bundle queryArgs, 758 @Nullable String localAuthority, 759 @Nullable String cloudAuthority 760 ) { 761 try { 762 MediaQuery query; 763 switch (albumId) { 764 case AlbumColumns.ALBUM_ID_FAVORITES: 765 query = new FavoritesMediaQuery(queryArgs); 766 break; 767 case AlbumColumns.ALBUM_ID_VIDEOS: 768 query = new VideoMediaQuery(queryArgs); 769 break; 770 default: 771 throw new IllegalArgumentException("Cannot recognize album " + albumId); 772 } 773 774 final SQLiteDatabase database = syncController.getDbFacade().getDatabase(); 775 776 waitForOngoingSync(appContext, localAuthority, cloudAuthority); 777 778 try { 779 database.beginTransactionNonExclusive(); 780 Cursor pageData = database.rawQuery( 781 getMediaPageQuery( 782 query, 783 database, 784 PickerSQLConstants.Table.MEDIA, 785 localAuthority, 786 cloudAuthority 787 ), 788 /* selectionArgs */ null 789 ); 790 791 Bundle extraArgs = new Bundle(); 792 Cursor nextPageKeyCursor = database.rawQuery( 793 getMediaNextPageKeyQuery( 794 query, 795 database, 796 PickerSQLConstants.Table.MEDIA, 797 localAuthority, 798 cloudAuthority 799 ), 800 /* selectionArgs */ null 801 ); 802 addNextPageKey(extraArgs, nextPageKeyCursor); 803 804 Cursor prevPageKeyCursor = database.rawQuery( 805 getMediaPreviousPageQuery( 806 query, 807 database, 808 PickerSQLConstants.Table.MEDIA, 809 localAuthority, 810 cloudAuthority 811 ), 812 /* selectionArgs */ null 813 ); 814 addPrevPageKey(extraArgs, prevPageKeyCursor); 815 816 database.setTransactionSuccessful(); 817 818 pageData.setExtras(extraArgs); 819 Log.i(TAG, "Returning " + pageData.getCount() + " album media items for album " 820 + albumId); 821 return pageData; 822 } finally { 823 database.endTransaction(); 824 } 825 } catch (Exception e) { 826 throw new RuntimeException("Could not fetch media", e); 827 } 828 } 829 830 /** 831 * @return a cursor with the available providers. 832 */ 833 @NonNull queryAvailableProviders(@onNull Context context)834 public static Cursor queryAvailableProviders(@NonNull Context context) { 835 try { 836 final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow(); 837 final String[] columnNames = Arrays 838 .stream(PickerSQLConstants.AvailableProviderResponse.values()) 839 .map(PickerSQLConstants.AvailableProviderResponse::getColumnName) 840 .toArray(String[]::new); 841 final MatrixCursor matrixCursor = new MatrixCursor(columnNames, /*initialCapacity */ 2); 842 final String localAuthority = syncController.getLocalProvider(); 843 addAvailableProvidersToCursor(matrixCursor, 844 localAuthority, 845 MediaSource.LOCAL, 846 Process.myUid()); 847 848 final String cloudAuthority = 849 syncController.getCloudProviderOrDefault(/* defaultValue */ null); 850 if (syncController.shouldQueryCloudMedia(cloudAuthority)) { 851 final PackageManager packageManager = context.getPackageManager(); 852 final ProviderInfo providerInfo = requireNonNull( 853 packageManager.resolveContentProvider(cloudAuthority, /* flags */ 0)); 854 final int uid = packageManager.getPackageUid( 855 providerInfo.packageName, 856 /* flags */ 0 857 ); 858 addAvailableProvidersToCursor( 859 matrixCursor, 860 cloudAuthority, 861 MediaSource.REMOTE, 862 uid); 863 } 864 865 return matrixCursor; 866 } catch (IllegalStateException | NameNotFoundException e) { 867 throw new RuntimeException("Unexpected internal error occurred", e); 868 } 869 } 870 addAvailableProvidersToCursor( @onNull MatrixCursor cursor, @NonNull String authority, @NonNull MediaSource source, @UserIdInt int uid)871 private static void addAvailableProvidersToCursor( 872 @NonNull MatrixCursor cursor, 873 @NonNull String authority, 874 @NonNull MediaSource source, 875 @UserIdInt int uid) { 876 cursor.newRow() 877 .add(PickerSQLConstants.AvailableProviderResponse.AUTHORITY.getColumnName(), 878 authority) 879 .add(PickerSQLConstants.AvailableProviderResponse.MEDIA_SOURCE.getColumnName(), 880 source.name()) 881 .add(PickerSQLConstants.AvailableProviderResponse.UID.getColumnName(), uid); 882 } 883 884 /** 885 * @param albumId Album identifier. 886 * @return True if the given album id matches the album id of any merged album. 887 */ isMergedAlbum(@onNull String albumId)888 private static boolean isMergedAlbum(@NonNull String albumId) { 889 return sMergedAlbumIds.contains(albumId); 890 } 891 892 /** 893 * @return a Bundle with the details of the requested cloud provider. 894 */ getCloudProviderDetails(Bundle queryArgs)895 public static Bundle getCloudProviderDetails(Bundle queryArgs) { 896 throw new UnsupportedOperationException("This method is not implemented yet."); 897 } 898 } 899