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.sqlite; 18 19 import static android.provider.MediaStore.MY_USER_ID; 20 21 import static java.util.Objects.requireNonNull; 22 23 import android.database.Cursor; 24 import android.database.MatrixCursor; 25 import android.net.Uri; 26 import android.provider.CloudMediaProviderContract; 27 import android.util.Log; 28 29 import androidx.annotation.NonNull; 30 import androidx.annotation.Nullable; 31 import androidx.annotation.VisibleForTesting; 32 33 import com.android.providers.media.PickerUriResolver; 34 import com.android.providers.media.photopicker.PickerSyncController; 35 import com.android.providers.media.photopicker.data.PickerDbFacade; 36 import com.android.providers.media.photopicker.v2.model.MediaGroup; 37 38 import java.util.ArrayList; 39 import java.util.HashMap; 40 import java.util.HashSet; 41 import java.util.List; 42 import java.util.Locale; 43 import java.util.Map; 44 import java.util.Objects; 45 import java.util.Set; 46 47 /** 48 * Utility class that prepares cursor response in the format 49 * {@link PickerSQLConstants.MediaGroupResponseColumns}. 50 */ 51 public class MediaGroupCursorUtils { 52 private static final String TAG = "MediaGroupCursorUtils"; 53 54 private static final String[] ALL_MEDIA_GROUP_RESPONSE_PROJECTION = new String[]{ 55 PickerSQLConstants.MediaGroupResponseColumns.MEDIA_GROUP.getColumnName(), 56 PickerSQLConstants.MediaGroupResponseColumns.GROUP_ID.getColumnName(), 57 PickerSQLConstants.MediaGroupResponseColumns.PICKER_ID.getColumnName(), 58 PickerSQLConstants.MediaGroupResponseColumns.DISPLAY_NAME.getColumnName(), 59 PickerSQLConstants.MediaGroupResponseColumns.AUTHORITY.getColumnName(), 60 PickerSQLConstants.MediaGroupResponseColumns.UNWRAPPED_COVER_URI.getColumnName(), 61 PickerSQLConstants.MediaGroupResponseColumns 62 .ADDITIONAL_UNWRAPPED_COVER_URI_1.getColumnName(), 63 PickerSQLConstants.MediaGroupResponseColumns 64 .ADDITIONAL_UNWRAPPED_COVER_URI_2.getColumnName(), 65 PickerSQLConstants.MediaGroupResponseColumns 66 .ADDITIONAL_UNWRAPPED_COVER_URI_3.getColumnName(), 67 PickerSQLConstants.MediaGroupResponseColumns.CATEGORY_TYPE.getColumnName(), 68 PickerSQLConstants.MediaGroupResponseColumns.IS_LEAF_CATEGORY.getColumnName(), 69 }; 70 71 private static final String[] MEDIA_SET_RESPONSE_PROJECTION = new String[] { 72 PickerSQLConstants.MediaGroupResponseColumns.GROUP_ID.getColumnName(), 73 PickerSQLConstants.MediaGroupResponseColumns.PICKER_ID.getColumnName(), 74 PickerSQLConstants.MediaGroupResponseColumns.DISPLAY_NAME.getColumnName(), 75 PickerSQLConstants.MediaGroupResponseColumns.AUTHORITY.getColumnName(), 76 PickerSQLConstants.MediaGroupResponseColumns.UNWRAPPED_COVER_URI.getColumnName() 77 }; 78 79 /** 80 * @param cursor Input 81 * {@link CloudMediaProviderContract.MediaSetColumns} cursor. 82 * @return Cursor with the columns {@link PickerSQLConstants.MediaGroupResponseColumns}. 83 */ getMediaGroupCursorForMediaSets(@ullable Cursor cursor)84 public static Cursor getMediaGroupCursorForMediaSets(@Nullable Cursor cursor) { 85 if (cursor == null) { 86 return null; 87 } 88 89 MatrixCursor mediaSetsResponse = new MatrixCursor(MEDIA_SET_RESPONSE_PROJECTION); 90 91 // Get the list of Uris from the cursor. 92 final List<String> uris = new ArrayList<>(); 93 if (cursor.moveToFirst()) { 94 do { 95 String authority = cursor.getString(cursor.getColumnIndexOrThrow( 96 PickerSQLConstants.MediaSetsTableColumns.MEDIA_SET_AUTHORITY.getColumnName() 97 )); 98 String coverId = cursor.getString(cursor.getColumnIndexOrThrow( 99 PickerSQLConstants.MediaSetsTableColumns.COVER_ID.getColumnName() 100 )); 101 String coverUri = getUri(coverId, authority).toString(); 102 if (coverUri != null) { 103 uris.add(coverUri); 104 } 105 } while (cursor.moveToNext()); 106 } 107 108 // Get list of local ids if local copy exists for corresponding cloud ids. 109 final Map<String, String> cloudToLocalIdMap = getLocalIds(uris); 110 111 if (cursor.moveToFirst()) { 112 do { 113 String mediaSetId = cursor.getString(cursor.getColumnIndexOrThrow( 114 PickerSQLConstants.MediaSetsTableColumns.MEDIA_SET_ID.getColumnName() 115 )); 116 String mediaSetPickerId = cursor.getString(cursor.getColumnIndexOrThrow( 117 PickerSQLConstants.MediaSetsTableColumns.PICKER_ID.getColumnName() 118 )); 119 String displayName = cursor.getString(cursor.getColumnIndexOrThrow( 120 PickerSQLConstants.MediaSetsTableColumns.DISPLAY_NAME.getColumnName() 121 )); 122 String authority = cursor.getString(cursor.getColumnIndexOrThrow( 123 PickerSQLConstants.MediaSetsTableColumns.MEDIA_SET_AUTHORITY.getColumnName() 124 )); 125 String coverId = cursor.getString(cursor.getColumnIndexOrThrow( 126 PickerSQLConstants.MediaSetsTableColumns.COVER_ID.getColumnName() 127 )); 128 String coverUri = getUri(coverId, authority).toString(); 129 String unwrappedCoverUri = maybeGetLocalUri(coverUri, cloudToLocalIdMap); 130 131 mediaSetsResponse.addRow(new Object[] { 132 mediaSetId, 133 mediaSetPickerId, 134 displayName, 135 authority, 136 unwrappedCoverUri 137 }); 138 } while (cursor.moveToNext()); 139 } 140 return mediaSetsResponse; 141 } 142 143 /** 144 * @param cursor Input 145 * {@link com.android.providers.media.photopicker.v2.model.AlbumsCursorWrapper} 146 * @param index The index for the first album in the given albums cursor. 147 * The index value can be used to generate unique picker id for albums. 148 * @return Cursor with the columns {@link PickerSQLConstants.MediaGroupResponseColumns}. 149 */ 150 @Nullable getMediaGroupCursorForAlbums(@ullable Cursor cursor, long index)151 public static Cursor getMediaGroupCursorForAlbums(@Nullable Cursor cursor, long index) { 152 if (cursor == null) { 153 return null; 154 } 155 156 final MatrixCursor response = new MatrixCursor(ALL_MEDIA_GROUP_RESPONSE_PROJECTION); 157 158 // Get the list of Uris from the cursor. 159 final List<String> uris = new ArrayList<>(); 160 if (cursor.moveToFirst()) { 161 do { 162 final String unwrappedCoverUri = 163 cursor.getString(cursor.getColumnIndexOrThrow(PickerSQLConstants 164 .AlbumResponse.UNWRAPPED_COVER_URI.getColumnName())); 165 if (unwrappedCoverUri != null) { 166 uris.add(unwrappedCoverUri); 167 } 168 } while (cursor.moveToNext()); 169 } 170 171 // Get list of local ids if local copy exists for corresponding cloud ids. 172 final Map<String, String> cloudToLocalIdMap = getLocalIds(uris); 173 174 if (cursor.moveToFirst()) { 175 do { 176 final String albumId = cursor.getString(cursor.getColumnIndexOrThrow( 177 PickerSQLConstants.AlbumResponse.ALBUM_ID.getColumnName())); 178 179 // Sets the picker id of the current album and increments the index for the 180 // next album. 181 final long pickerId = index++; 182 183 final String displayName = cursor.getString(cursor.getColumnIndexOrThrow( 184 PickerSQLConstants.AlbumResponse.ALBUM_NAME.getColumnName())); 185 186 final String authority = cursor.getString(cursor.getColumnIndexOrThrow( 187 PickerSQLConstants.AlbumResponse.AUTHORITY.getColumnName())); 188 189 final String unwrappedCoverUri = maybeGetLocalUri( 190 cursor.getString(cursor.getColumnIndexOrThrow(PickerSQLConstants 191 .AlbumResponse.UNWRAPPED_COVER_URI.getColumnName())), 192 cloudToLocalIdMap); 193 194 response.addRow(new Object[]{ 195 MediaGroup.ALBUM.name(), 196 albumId, 197 pickerId, 198 displayName, 199 authority, 200 unwrappedCoverUri, 201 /* MediaGroupResponseColumns.ADDITIONAL_UNWRAPPED_COVER_URI_1 */ null, 202 /* MediaGroupResponseColumns.ADDITIONAL_UNWRAPPED_COVER_URI_2 */ null, 203 /* MediaGroupResponseColumns.ADDITIONAL_UNWRAPPED_COVER_URI_3 */ null, 204 /* MediaGroupResponseColumns.CATEGORY_TYPE */ null, 205 /* MediaGroupResponseColumns.IS_LEAF_CATEGORY */ null 206 }); 207 } while (cursor.moveToNext()); 208 } 209 210 return response; 211 } 212 213 /** 214 * @param cursor Input 215 * {@link CloudMediaProviderContract.MediaCategoryColumns} cursor. 216 * @param authority The authority of the category's CMP. 217 * @param index The index for the first category in the given categories cursor. 218 * The index value can be used to generate unique picker id for categories. 219 * @return Cursor with the columns {@link PickerSQLConstants.MediaGroupResponseColumns}. 220 */ 221 @Nullable getMediaGroupCursorForCategories( @ullable Cursor cursor, @NonNull String authority, long index)222 public static Cursor getMediaGroupCursorForCategories( 223 @Nullable Cursor cursor, 224 @NonNull String authority, 225 long index) { 226 if (cursor == null) { 227 return null; 228 } 229 230 final MatrixCursor response = new MatrixCursor(ALL_MEDIA_GROUP_RESPONSE_PROJECTION); 231 232 final List<String> uris = new ArrayList<>(); 233 final List<String> mediaCoverIdColumns = List.of( 234 CloudMediaProviderContract.MediaCategoryColumns.MEDIA_COVER_ID1, 235 CloudMediaProviderContract.MediaCategoryColumns.MEDIA_COVER_ID2, 236 CloudMediaProviderContract.MediaCategoryColumns.MEDIA_COVER_ID3, 237 CloudMediaProviderContract.MediaCategoryColumns.MEDIA_COVER_ID4 238 ); 239 if (cursor.moveToFirst()) { 240 do { 241 for (String columnName : mediaCoverIdColumns) { 242 final String mediaCoverId = cursor.getString( 243 cursor.getColumnIndexOrThrow(columnName)); 244 if (mediaCoverId != null) { 245 uris.add(getUri(mediaCoverId, authority).toString()); 246 } 247 } 248 } while (cursor.moveToNext()); 249 } 250 251 // Get list of local ids if local copy exists for corresponding cloud ids. 252 final Map<String, String> cloudToLocalIdMap = getLocalIds(uris); 253 if (cursor.moveToFirst()) { 254 if (cursor.getCount() > 1) { 255 Log.e(TAG, "Only one category of type PEOPLE AND PETS is expected but received " 256 + cursor.getCount()); 257 } 258 259 final String categoryType = cursor.getString(cursor.getColumnIndexOrThrow( 260 CloudMediaProviderContract.MediaCategoryColumns.MEDIA_CATEGORY_TYPE)); 261 262 if (!CloudMediaProviderContract.MEDIA_CATEGORY_TYPE_PEOPLE_AND_PETS 263 .equals(categoryType)) { 264 Log.e(TAG, "Could not recognize category type. Skipping it: " + categoryType); 265 return response; 266 } 267 268 final String categoryId = requireNonNull( 269 cursor.getString(cursor.getColumnIndexOrThrow( 270 CloudMediaProviderContract.MediaCategoryColumns.ID))); 271 272 final String displayName = cursor.getString(cursor.getColumnIndexOrThrow( 273 CloudMediaProviderContract.MediaCategoryColumns.DISPLAY_NAME)); 274 275 final String mediaCoverId1 = cursor.getString( 276 cursor.getColumnIndexOrThrow( 277 CloudMediaProviderContract.MediaCategoryColumns.MEDIA_COVER_ID1)); 278 final String coverUri1 = maybeGetLocalUri( 279 getUri(mediaCoverId1, authority).toString(), 280 cloudToLocalIdMap); 281 282 final String mediaCoverId2 = cursor.getString( 283 cursor.getColumnIndexOrThrow( 284 CloudMediaProviderContract.MediaCategoryColumns.MEDIA_COVER_ID2)); 285 final String coverUri2 = maybeGetLocalUri( 286 getUri(mediaCoverId2, authority).toString(), 287 cloudToLocalIdMap); 288 289 final String mediaCoverId3 = cursor.getString( 290 cursor.getColumnIndexOrThrow( 291 CloudMediaProviderContract.MediaCategoryColumns.MEDIA_COVER_ID3)); 292 final String coverUri3 = maybeGetLocalUri( 293 getUri(mediaCoverId3, authority).toString(), 294 cloudToLocalIdMap); 295 296 final String mediaCoverId4 = cursor.getString( 297 cursor.getColumnIndexOrThrow( 298 CloudMediaProviderContract.MediaCategoryColumns.MEDIA_COVER_ID4)); 299 final String coverUri4 = maybeGetLocalUri( 300 getUri(mediaCoverId4, authority).toString(), 301 cloudToLocalIdMap); 302 303 response.addRow(new Object[]{ 304 MediaGroup.CATEGORY.name(), 305 categoryId, 306 index, 307 displayName, 308 authority, 309 coverUri1, 310 coverUri2, 311 coverUri3, 312 coverUri4, 313 categoryType, 314 // Default is 1, we don't have recursive categories yet. 315 /* MediaGroupResponseColumns.IS_LEAF_CATEGORY */ 1 316 }); 317 } 318 319 return response; 320 } 321 322 /** 323 * @param uris List of Uris received in a cursor. These could be local Uris, or cloud Uris. 324 * @return A map of valid cloud id -> local ids. Cloud ids will be extracted from input list of 325 * uris. 326 */ getLocalIds(@onNull List<String> uris)327 public static Map<String, String> getLocalIds(@NonNull List<String> uris) { 328 final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow(); 329 final String localAuthority = syncController.getLocalProvider(); 330 331 // Filter cloud Ids from the input list of Uris. 332 final Map<String, String> cloudToLocalIdMap = new HashMap<>(); 333 334 try { 335 requireNonNull(uris); 336 337 final List<String> cloudUris = new ArrayList<>(); 338 for (String inputUri : uris) { 339 final Uri coverUri = Uri.parse(inputUri); 340 final String authority = coverUri.getAuthority(); 341 342 if (Objects.equals(localAuthority, authority)) { 343 Log.d(TAG, "Cover uri already refers to a local media item."); 344 } else { 345 cloudUris.add(coverUri.getLastPathSegment()); 346 } 347 } 348 349 // Get a map of local ids for their corresponding cloud ids from the database. 350 final SelectSQLiteQueryBuilder localUriQueryBuilder = 351 new SelectSQLiteQueryBuilder(syncController.getDbFacade().getDatabase()); 352 localUriQueryBuilder.setTables(PickerSQLConstants.Table.MEDIA.name()) 353 .setProjection(new String[]{ 354 PickerDbFacade.KEY_LOCAL_ID, 355 PickerDbFacade.KEY_CLOUD_ID}); 356 localUriQueryBuilder.appendWhereStandalone(String.format( 357 Locale.ROOT, "%s IN ('%s')", PickerDbFacade.KEY_CLOUD_ID, 358 String.join("','", cloudUris))); 359 localUriQueryBuilder.appendWhereStandalone(String.format( 360 Locale.ROOT, "%s IS NULL", PickerDbFacade.KEY_IS_VISIBLE)); 361 localUriQueryBuilder.appendWhereStandalone(String.format( 362 Locale.ROOT, "%s IS NOT NULL", PickerDbFacade.KEY_LOCAL_ID)); 363 364 try (Cursor cursor = syncController.getDbFacade().getDatabase() 365 .rawQuery(localUriQueryBuilder.buildQuery(), /*selectionArgs*/ null)) { 366 if (cursor.moveToFirst()) { 367 do { 368 final String localId = cursor.getString(cursor.getColumnIndexOrThrow( 369 PickerDbFacade.KEY_LOCAL_ID)); 370 final String cloudId = cursor.getString(cursor.getColumnIndexOrThrow( 371 PickerDbFacade.KEY_CLOUD_ID)); 372 cloudToLocalIdMap.put(cloudId, localId); 373 } while (cursor.moveToNext()); 374 } 375 } 376 377 // Validate that local ids correspond to a valid local media item on the device. 378 final SelectSQLiteQueryBuilder validateLocalIdQueryBuilder = 379 new SelectSQLiteQueryBuilder(syncController.getDbFacade().getDatabase()); 380 validateLocalIdQueryBuilder.setTables(PickerSQLConstants.Table.MEDIA.name()) 381 .setProjection(new String[]{PickerDbFacade.KEY_LOCAL_ID}); 382 validateLocalIdQueryBuilder.appendWhereStandalone(String.format( 383 Locale.ROOT, "%s IS NULL", PickerDbFacade.KEY_CLOUD_ID)); 384 validateLocalIdQueryBuilder.appendWhereStandalone(String.format( 385 Locale.ROOT, "%s = 1", PickerDbFacade.KEY_IS_VISIBLE)); 386 validateLocalIdQueryBuilder.appendWhereStandalone(String.format( 387 Locale.ROOT, "%s IN ('%s')", PickerDbFacade.KEY_LOCAL_ID, 388 String.join("','", cloudToLocalIdMap.values()))); 389 390 final Set<String> validLocalIds = new HashSet<>(); 391 try (Cursor cursor = syncController.getDbFacade().getDatabase() 392 .rawQuery(validateLocalIdQueryBuilder.buildQuery(), /*selectionArgs*/ null)) { 393 if (cursor.moveToFirst()) { 394 do { 395 final String localId = cursor.getString(cursor.getColumnIndexOrThrow( 396 PickerDbFacade.KEY_LOCAL_ID)); 397 validLocalIds.add(localId); 398 } while (cursor.moveToNext()); 399 } 400 } 401 402 // Filter map so that it only contains valid local Ids. 403 cloudToLocalIdMap.keySet().removeIf( 404 cloudId -> !validLocalIds.contains(cloudToLocalIdMap.get(cloudId))); 405 } catch (Exception e) { 406 Log.e(TAG, "Could not get local ids for cloud items", e); 407 } 408 409 return cloudToLocalIdMap; 410 } 411 412 /** 413 * Checks if the input coverUri points to a cloud media object. If it does, then tries to 414 * find the local copy of it and returns the URI of the local copy. Otherwise returns the input 415 * coverUri as it is. 416 */ 417 @VisibleForTesting maybeGetLocalUri( @ullable String rawCoverUri, @NonNull Map<String, String> cloudToLocalIdMap)418 public static String maybeGetLocalUri( 419 @Nullable String rawCoverUri, 420 @NonNull Map<String, String> cloudToLocalIdMap) { 421 if (rawCoverUri == null) { 422 return null; 423 } 424 425 final String localAuthority = PickerSyncController.getInstanceOrThrow().getLocalProvider(); 426 try { 427 final Uri coverUri = Uri.parse(rawCoverUri); 428 final String mediaId = coverUri.getLastPathSegment(); 429 if (cloudToLocalIdMap.containsKey(mediaId)) { 430 return getUri(cloudToLocalIdMap.get(mediaId), localAuthority).toString(); 431 } else { 432 return rawCoverUri; 433 } 434 } catch (RuntimeException e) { 435 Log.e(TAG, "Error occurred in parsing Uri received from CMP", e); 436 } 437 438 return rawCoverUri; 439 } 440 getUri(String mediaId, String authority)441 private static Uri getUri(String mediaId, String authority) { 442 return PickerUriResolver 443 .getMediaUri(getEncodedUserAuthority(authority)) 444 .buildUpon() 445 .appendPath(mediaId) 446 .build(); 447 } 448 getEncodedUserAuthority(String authority)449 private static String getEncodedUserAuthority(String authority) { 450 if (authority.contains("@")) { 451 return authority; 452 } else { 453 return MY_USER_ID + "@" + authority; 454 } 455 } 456 } 457