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.content.ContentResolver.QUERY_ARG_LIMIT; 20 import static android.database.DatabaseUtils.dumpCursorToString; 21 import static android.provider.MediaStore.AUTHORITY; 22 import static android.provider.MediaStore.EXTRA_CALLING_PACKAGE_UID; 23 24 import static com.android.providers.media.MediaGrants.FILE_ID_COLUMN; 25 import static com.android.providers.media.MediaGrants.PACKAGE_USER_ID_COLUMN; 26 import static com.android.providers.media.PickerUriResolver.DEFAULT_UID; 27 import static com.android.providers.media.PickerUriResolver.PICKER_INTERNAL_URI; 28 import static com.android.providers.media.photopicker.PickerDataLayer.QUERY_DATE_TAKEN_BEFORE_MS; 29 import static com.android.providers.media.photopicker.PickerDataLayer.QUERY_ID_SELECTION; 30 import static com.android.providers.media.photopicker.PickerDataLayer.QUERY_ROW_ID; 31 import static com.android.providers.media.photopicker.PickerDataLayer.QUERY_SHOULD_SCREEN_SELECTION_URIS; 32 import static com.android.providers.media.photopicker.util.CloudProviderUtils.sendInitPhotoPickerDataNotification; 33 34 import android.content.ContentProvider; 35 import android.content.ContentProviderClient; 36 import android.content.ContentResolver; 37 import android.content.Context; 38 import android.content.Intent; 39 import android.content.pm.PackageManager.NameNotFoundException; 40 import android.database.Cursor; 41 import android.net.Uri; 42 import android.os.Bundle; 43 import android.os.CancellationSignal; 44 import android.os.RemoteException; 45 import android.os.Trace; 46 import android.os.UserHandle; 47 import android.provider.CloudMediaProviderContract.AlbumColumns; 48 import android.provider.MediaStore; 49 import android.text.TextUtils; 50 import android.util.Log; 51 52 import androidx.annotation.NonNull; 53 import androidx.annotation.Nullable; 54 55 import com.android.modules.utils.build.SdkLevel; 56 import com.android.providers.media.PickerUriResolver; 57 import com.android.providers.media.photopicker.PickerSyncController; 58 import com.android.providers.media.photopicker.data.model.Category; 59 import com.android.providers.media.photopicker.data.model.UserId; 60 61 import java.util.ArrayList; 62 import java.util.Arrays; 63 import java.util.List; 64 import java.util.stream.Collectors; 65 66 /** 67 * Provides image and video items from {@link MediaStore} collection to the Photo Picker. 68 */ 69 public class ItemsProvider { 70 private static final String TAG = ItemsProvider.class.getSimpleName(); 71 private static final boolean DEBUG = true; 72 private static final boolean DEBUG_DUMP_CURSORS = false; 73 74 private final Context mContext; 75 ItemsProvider(Context context)76 public ItemsProvider(Context context) { 77 mContext = context; 78 } 79 80 private static final Uri URI_MEDIA_ALL; 81 private static final Uri URI_MEDIA_LOCAL; 82 private static final Uri URI_ALBUMS_ALL; 83 private static final Uri URI_ALBUMS_LOCAL; 84 85 private static final String MEDIA_GRANTS_URI_PATH = "content://media/media_grants"; 86 public static final String EXTRA_MIME_TYPE_SELECTION = "media_grant_mime_type_selection"; 87 88 89 static { 90 final Uri media = PICKER_INTERNAL_URI.buildUpon() 91 .appendPath(PickerUriResolver.MEDIA_PATH).build(); 92 URI_MEDIA_ALL = media.buildUpon().appendPath(PickerUriResolver.ALL_PATH).build(); 93 URI_MEDIA_LOCAL = media.buildUpon().appendPath(PickerUriResolver.LOCAL_PATH).build(); 94 95 final Uri albums = PICKER_INTERNAL_URI.buildUpon() 96 .appendPath(PickerUriResolver.ALBUM_PATH).build(); 97 URI_ALBUMS_ALL = albums.buildUpon().appendPath(PickerUriResolver.ALL_PATH).build(); 98 URI_ALBUMS_LOCAL = albums.buildUpon().appendPath(PickerUriResolver.LOCAL_PATH).build(); 99 } 100 101 /** 102 * Returns a {@link Cursor} to all(local + cloud) images/videos based on the param passed for 103 * {@code category}, {@code limit}, {@code mimeTypes} and {@code userId}. 104 * 105 * <p> 106 * By default, the returned {@link Cursor} sorts by latest date taken. 107 * 108 * @param category the category of items to return. May be cloud, local or merged albums like 109 * favorites or videos. 110 * @param pagingParameters parameters to represent the page for which the items need to be 111 * returned. 112 * @param mimeTypes the mime type of item. {@code null} returns all images/videos that are 113 * scanned by {@link MediaStore}. 114 * @param userId the {@link UserId} of the user to get items as. 115 * {@code null} defaults to {@link UserId#CURRENT_USER} 116 * 117 * @return {@link Cursor} to images/videos on external storage that are scanned by 118 * {@link MediaStore} or returned by cloud provider. The returned cursor is filtered based on 119 * params passed, it {@code null} if there are no such images/videos. The Cursor for each item 120 * contains {@link android.provider.CloudMediaProviderContract.MediaColumns} 121 */ 122 @Nullable getAllItems(Category category, PaginationParameters pagingParameters, @Nullable String[] mimeTypes, @Nullable UserId userId, @Nullable CancellationSignal cancellationSignal)123 public Cursor getAllItems(Category category, PaginationParameters pagingParameters, 124 @Nullable String[] mimeTypes, 125 @Nullable UserId userId, 126 @Nullable CancellationSignal cancellationSignal) throws IllegalArgumentException { 127 Trace.beginSection("ItemsProvider.getAllItems"); 128 try { 129 return queryMedia(URI_MEDIA_ALL, pagingParameters, mimeTypes, category, userId, 130 cancellationSignal); 131 } finally { 132 Trace.endSection(); 133 } 134 } 135 136 /** 137 * Returns a {@link Cursor} to local images/videos based on the param passed for 138 * {@code category}, {@code limit}, {@code mimeTypes} and {@code userId}. 139 * 140 * <p> 141 * By default, the returned {@link Cursor} sorts by latest date taken. 142 * 143 * @param category the category of items to return. May be local or merged albums like 144 * favorites or videos. 145 * @param pagingParameters parameters to represent the page for which the items need to be 146 * returned. 147 * @param mimeTypes the mime type of item. {@code null} returns all images/videos that are 148 * scanned by {@link MediaStore}. 149 * @param userId the {@link UserId} of the user to get items as. 150 * {@code null} defaults to {@link UserId#CURRENT_USER} 151 * 152 * @return {@link Cursor} to images/videos on external storage that are scanned by 153 * {@link MediaStore}. The returned cursor is filtered based on params passed, it {@code null} 154 * if there are no such images/videos. The Cursor for each item contains 155 * {@link android.provider.CloudMediaProviderContract.MediaColumns} 156 * 157 * NOTE: We don't validate the given category is a local album. The behavior is undefined if 158 * this method is called with a non-local album. 159 */ 160 @Nullable getLocalItems(Category category, PaginationParameters pagingParameters, @Nullable String[] mimeTypes, @Nullable UserId userId, @Nullable CancellationSignal cancellationSignal)161 public Cursor getLocalItems(Category category, PaginationParameters pagingParameters, 162 @Nullable String[] mimeTypes, 163 @Nullable UserId userId, 164 @Nullable CancellationSignal cancellationSignal) throws IllegalArgumentException { 165 Trace.beginSection("ItemsProvider.getLocalItems"); 166 try { 167 return queryMedia(URI_MEDIA_LOCAL, pagingParameters, mimeTypes, category, userId, 168 cancellationSignal); 169 } finally { 170 Trace.endSection(); 171 } 172 } 173 174 /** 175 * Gets cursor for items corresponding to the ids passed as an argument. 176 * 177 * @param category the category of items to return. 178 * @param preselectedUris list of Uris for which the item objects are required 179 * @param mimeTypes the mime type of item. {@code null} returns all images/videos that are 180 * scanned by {@link MediaStore}. 181 * @param userId the {@link UserId} of the user to get items as. 182 * {@code null} defaults to {@link UserId#CURRENT_USER} 183 * @param isLocalOnly indicates if only local items are required 184 * @param callingPackageUid uid for the calling package 185 * @param shouldScreenSelectionUris flag to represent if the preselectedUris passed should 186 * be validated and checked for permission 187 */ getItemsForPreselectedMedia(Category category, @Nullable List<Uri> preselectedUris, @Nullable String[] mimeTypes, @Nullable UserId userId, boolean isLocalOnly, int callingPackageUid, boolean shouldScreenSelectionUris, @Nullable CancellationSignal cancellationSignal)188 public Cursor getItemsForPreselectedMedia(Category category, 189 @Nullable List<Uri> preselectedUris, 190 @Nullable String[] mimeTypes, 191 @Nullable UserId userId, 192 boolean isLocalOnly, 193 int callingPackageUid, 194 boolean shouldScreenSelectionUris, 195 @Nullable CancellationSignal cancellationSignal) throws IllegalArgumentException { 196 return queryMedia(isLocalOnly ? URI_MEDIA_LOCAL : URI_MEDIA_ALL, 197 new PaginationParameters(), mimeTypes, category, userId, 198 preselectedUris, callingPackageUid, shouldScreenSelectionUris, cancellationSignal); 199 } 200 201 /** 202 * Returns a {@link Cursor} to all non-empty categories in which images/videos are categorised. 203 * This includes: 204 * * A constant list of local categories for on-device images/videos: {@link Category} 205 * * Albums provided by selected cloud provider 206 * 207 * @param mimeTypes the mime type of item. {@code null} returns all images/videos that are 208 * scanned by {@link MediaStore}. 209 * @param userId the {@link UserId} of the user to get categories as. 210 * {@code null} defaults to {@link UserId#CURRENT_USER}. 211 * 212 * @return {@link Cursor} for each category would contain {@link AlbumColumns#ALL_PROJECTION} 213 * in the relative order. 214 */ 215 @Nullable getAllCategories(@ullable String[] mimeTypes, @Nullable UserId userId, @Nullable CancellationSignal cancellationSignal)216 public Cursor getAllCategories(@Nullable String[] mimeTypes, @Nullable UserId userId, 217 @Nullable CancellationSignal cancellationSignal) { 218 Trace.beginSection("ItemsProvider.getAllCategories"); 219 try { 220 return queryAlbums(URI_ALBUMS_ALL, mimeTypes, userId, cancellationSignal); 221 } finally { 222 Trace.endSection(); 223 } 224 } 225 226 /** 227 * Returns a {@link Cursor} to all non-empty categories in which images/videos are categorised. 228 * This includes a constant list of local categories for on-device images/videos. 229 * 230 * @param mimeTypes the mime type of item. {@code null} returns all images/videos that are 231 * scanned by {@link MediaStore}. 232 * @param userId the {@link UserId} of the user to get categories as. 233 * {@code null} defaults to {@link UserId#CURRENT_USER}. 234 * 235 * @return {@link Cursor} for each category would contain {@link AlbumColumns#ALL_PROJECTION} 236 * in the relative order. 237 */ 238 @Nullable getLocalCategories(@ullable String[] mimeTypes, @Nullable UserId userId, @Nullable CancellationSignal cancellationSignal)239 public Cursor getLocalCategories(@Nullable String[] mimeTypes, @Nullable UserId userId, 240 @Nullable CancellationSignal cancellationSignal) { 241 Trace.beginSection("ItemsProvider.getLocalCategories"); 242 try { 243 return queryAlbums(URI_ALBUMS_LOCAL, mimeTypes, userId, cancellationSignal); 244 } finally { 245 Trace.endSection(); 246 } 247 } 248 249 @Nullable queryMedia(@onNull Uri uri, PaginationParameters paginationParameters, String[] mimeTypes, @NonNull Category category, @Nullable UserId userId, @Nullable CancellationSignal cancellationSignal)250 private Cursor queryMedia(@NonNull Uri uri, PaginationParameters paginationParameters, 251 String[] mimeTypes, @NonNull Category category, @Nullable UserId userId, 252 @Nullable CancellationSignal cancellationSignal) { 253 return queryMedia(uri, paginationParameters, mimeTypes, category, userId, 254 /* preselectedUris */ null, /* callingPackageUid */ DEFAULT_UID, 255 /* shouldScreenSelectionUris */false, cancellationSignal); 256 } 257 258 @Nullable queryMedia(@onNull Uri uri, PaginationParameters paginationParameters, String[] mimeTypes, @NonNull Category category, @Nullable UserId userId, @Nullable List<Uri> preselectedUris, int callingPackageUid, boolean shouldScreenSelectionUris, @Nullable CancellationSignal cancellationSignal)259 private Cursor queryMedia(@NonNull Uri uri, PaginationParameters paginationParameters, 260 String[] mimeTypes, @NonNull Category category, @Nullable UserId userId, 261 @Nullable List<Uri> preselectedUris, int callingPackageUid, 262 boolean shouldScreenSelectionUris, @Nullable CancellationSignal cancellationSignal) 263 throws IllegalStateException { 264 if (userId == null) { 265 userId = UserId.CURRENT_USER; 266 } 267 268 if (DEBUG) { 269 Log.d(TAG, "queryMedia() uri=" + uri 270 + " cat=" + category 271 + " mimeTypes=" + Arrays.toString(mimeTypes) 272 + " limit=" + paginationParameters.getPageSize() 273 + " date_taken_before_ms = " + paginationParameters.getDateBeforeMs() 274 + " row_id = " + paginationParameters.getRowId()); 275 } 276 Trace.beginSection("ItemsProvider.queryMedia"); 277 278 final Bundle extras = new Bundle(); 279 Cursor result = null; 280 try (ContentProviderClient client = userId.getContentResolver(mContext) 281 .acquireUnstableContentProviderClient(MediaStore.AUTHORITY)) { 282 if (client == null) { 283 Log.e(TAG, "Unable to acquire unstable content provider for " 284 + MediaStore.AUTHORITY); 285 return null; 286 } 287 extras.putInt(QUERY_ARG_LIMIT, paginationParameters.getPageSize()); 288 if (mimeTypes != null) { 289 extras.putStringArray(MediaStore.QUERY_ARG_MIME_TYPE, mimeTypes); 290 } 291 extras.putString(MediaStore.QUERY_ARG_ALBUM_ID, category.getId()); 292 extras.putString(MediaStore.QUERY_ARG_ALBUM_AUTHORITY, category.getAuthority()); 293 294 if (paginationParameters.getRowId() >= 0 295 && paginationParameters.getDateBeforeMs() > Long.MIN_VALUE) { 296 extras.putInt(QUERY_ROW_ID, paginationParameters.getRowId()); 297 extras.putLong(QUERY_DATE_TAKEN_BEFORE_MS, paginationParameters.getDateBeforeMs()); 298 } 299 if (preselectedUris != null) { 300 extras.putStringArrayList(QUERY_ID_SELECTION, preselectedUris.stream() 301 .map(Uri::toString).collect(Collectors.toCollection(ArrayList::new))); 302 } 303 extras.putInt(EXTRA_CALLING_PACKAGE_UID, callingPackageUid); 304 extras.putBoolean(QUERY_SHOULD_SCREEN_SELECTION_URIS, 305 shouldScreenSelectionUris); 306 307 result = client.query(uri, /* projection */ null, extras, 308 /* cancellationSignal */ cancellationSignal); 309 return result; 310 } catch (RemoteException | NameNotFoundException ignored) { 311 // Do nothing, return null. 312 Log.e(TAG, "Failed to query merged media with extras: " 313 + extras + ". userId = " + userId, ignored); 314 return null; 315 } finally { 316 Trace.endSection(); 317 if (DEBUG) { 318 if (result == null) { 319 Log.d(TAG, "queryMedia()'s result is null"); 320 } else { 321 Log.d(TAG, "queryMedia() loaded " + result.getCount() + " items"); 322 if (DEBUG_DUMP_CURSORS) { 323 Log.v(TAG, dumpCursorToString(result)); 324 } 325 } 326 } 327 } 328 } 329 330 @Nullable queryAlbums(@onNull Uri uri, @Nullable String[] mimeTypes, @Nullable UserId userId, @Nullable CancellationSignal cancellationSignal)331 private Cursor queryAlbums(@NonNull Uri uri, @Nullable String[] mimeTypes, 332 @Nullable UserId userId, @Nullable CancellationSignal cancellationSignal) { 333 if (userId == null) { 334 userId = UserId.CURRENT_USER; 335 } 336 337 if (DEBUG) { 338 Log.d(TAG, "queryAlbums() uri=" + uri 339 + " mimeTypes=" + Arrays.toString(mimeTypes)); 340 } 341 Trace.beginSection("ItemsProvider.queryAlbums"); 342 343 final Bundle extras = new Bundle(); 344 Cursor result = null; 345 try (ContentProviderClient client = userId.getContentResolver(mContext) 346 .acquireUnstableContentProviderClient(MediaStore.AUTHORITY)) { 347 if (client == null) { 348 Log.e(TAG, "Unable to acquire unstable content provider for " 349 + MediaStore.AUTHORITY); 350 return null; 351 } 352 if (mimeTypes != null) { 353 extras.putStringArray(MediaStore.QUERY_ARG_MIME_TYPE, mimeTypes); 354 } 355 356 result = client.query(uri, /* projection */ null, extras, 357 /* cancellationSignal */ cancellationSignal); 358 return result; 359 } catch (RemoteException | NameNotFoundException ignored) { 360 // Do nothing, return null. 361 Log.w(TAG, "Failed to query merged albums with extras: " 362 + extras + ". userId = " + userId, ignored); 363 return null; 364 } finally { 365 Trace.endSection(); 366 if (DEBUG) { 367 if (result == null) { 368 Log.d(TAG, "queryAlbums()'s result is null"); 369 } else { 370 Log.d(TAG, "queryAlbums() loaded " + result.getCount() + " items"); 371 if (DEBUG_DUMP_CURSORS) { 372 Log.v(TAG, dumpCursorToString(result)); 373 } 374 } 375 } 376 } 377 } 378 getItemsUri(String id, String authority, UserId userId)379 public static Uri getItemsUri(String id, String authority, UserId userId) { 380 final Uri uri = PickerUriResolver.getMediaUri(authority).buildUpon() 381 .appendPath(id).build(); 382 383 if (userId.equals(UserId.CURRENT_USER)) { 384 return uri; 385 } 386 387 return createContentUriForUser(uri, userId.getUserHandle()); 388 } 389 createContentUriForUser(Uri uri, UserHandle userHandle)390 private static Uri createContentUriForUser(Uri uri, UserHandle userHandle) { 391 if (SdkLevel.isAtLeastS()) { 392 return ContentProvider.createContentUriForUser(uri, userHandle); 393 } 394 395 return createContentUriForUserImpl(uri, userHandle); 396 } 397 398 /** 399 * This method is a copy of {@link ContentProvider#createContentUriForUser(Uri, UserHandle)} 400 * which is a System API added in Android S. 401 */ createContentUriForUserImpl(Uri uri, UserHandle userHandle)402 private static Uri createContentUriForUserImpl(Uri uri, UserHandle userHandle) { 403 if (!ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) { 404 throw new IllegalArgumentException(String.format( 405 "Given URI [%s] is not a content URI: ", uri)); 406 } 407 408 int userId = userHandle.getIdentifier(); 409 if (uriHasUserId(uri)) { 410 if (String.valueOf(userId).equals(uri.getUserInfo())) { 411 return uri; 412 } 413 throw new IllegalArgumentException(String.format( 414 "Given URI [%s] already has a user ID, different from given user handle [%s]", 415 uri, 416 userId)); 417 } 418 419 Uri.Builder builder = uri.buildUpon(); 420 builder.encodedAuthority( 421 "" + userHandle.getIdentifier() + "@" + uri.getEncodedAuthority()); 422 return builder.build(); 423 } 424 uriHasUserId(Uri uri)425 private static boolean uriHasUserId(Uri uri) { 426 if (uri == null) return false; 427 return !TextUtils.isEmpty(uri.getUserInfo()); 428 } 429 430 /** 431 * Fetches file Uris for items having {@link com.android.providers.media.MediaGrants} for the 432 * given package. Returns an empty list if no grants are present. 433 */ 434 @NonNull fetchReadGrantedItemsUrisForPackage(int packageUid, String[] mimeTypes)435 public List<Uri> fetchReadGrantedItemsUrisForPackage(int packageUid, String[] mimeTypes) { 436 final ContentResolver resolver = mContext.getContentResolver(); 437 try (ContentProviderClient client = resolver.acquireContentProviderClient(AUTHORITY)) { 438 assert client != null; 439 final Bundle extras = new Bundle(); 440 extras.putInt(Intent.EXTRA_UID, packageUid); 441 extras.putStringArray(EXTRA_MIME_TYPE_SELECTION, mimeTypes); 442 List<Uri> filesUriList = new ArrayList<>(); 443 try (Cursor c = client.query(Uri.parse(MEDIA_GRANTS_URI_PATH), 444 /* projection= */ null, 445 /* queryArgs= */ extras, 446 null)) { 447 while (c.moveToNext()) { 448 final Integer file_id = c.getInt(c.getColumnIndexOrThrow(FILE_ID_COLUMN)); 449 final Integer userId = c.getInt( 450 c.getColumnIndexOrThrow(PACKAGE_USER_ID_COLUMN)); 451 // transforming ids to Item uris to use as a key in selection based features. 452 filesUriList.add(getItemsUri(String.valueOf(file_id), 453 PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY, 454 UserId.of(UserHandle.of(userId)))); 455 } 456 } 457 return filesUriList; 458 } catch (RemoteException e) { 459 throw e.rethrowAsRuntimeException(); 460 } 461 } 462 463 /** 464 * Sends a data init notification to the MP process. 465 */ initPhotoPickerData(@ullable String albumId, @Nullable String albumAuthority, boolean initLocalOnlyData, @Nullable UserId userId)466 public void initPhotoPickerData(@Nullable String albumId, 467 @Nullable String albumAuthority, 468 boolean initLocalOnlyData, 469 @Nullable UserId userId) { 470 if (userId == null) { 471 Log.e(TAG, "Could not determine the current active user id in Picker. " 472 + "Init media call cannot go through."); 473 return; 474 } 475 476 try (ContentProviderClient client = getContentProviderClient(userId)) { 477 if (client == null) { 478 throw new IllegalStateException("ContentProviderClient is null."); 479 } 480 sendInitPhotoPickerDataNotification(client, albumId, albumAuthority, initLocalOnlyData); 481 } catch (RuntimeException | NameNotFoundException | RemoteException e) { 482 Log.e(TAG, "Could not send init media call to Media Provider", e); 483 } 484 } 485 486 @Nullable getContentProviderClient(@onNull UserId userId)487 private ContentProviderClient getContentProviderClient(@NonNull UserId userId) 488 throws NameNotFoundException { 489 return userId 490 .getContentResolver(mContext) 491 .acquireContentProviderClient(AUTHORITY); 492 } 493 } 494