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.widget.Toast.LENGTH_LONG; 22 23 import static com.android.providers.media.PickerUriResolver.PICKER_INTERNAL_URI; 24 25 import android.content.ContentProvider; 26 import android.content.ContentProviderClient; 27 import android.content.ContentResolver; 28 import android.content.Context; 29 import android.content.pm.PackageManager.NameNotFoundException; 30 import android.database.Cursor; 31 import android.net.Uri; 32 import android.os.Bundle; 33 import android.os.Handler; 34 import android.os.Looper; 35 import android.os.Message; 36 import android.os.RemoteException; 37 import android.os.Trace; 38 import android.os.UserHandle; 39 import android.provider.CloudMediaProviderContract.AlbumColumns; 40 import android.provider.MediaStore; 41 import android.text.TextUtils; 42 import android.util.Log; 43 import android.widget.Toast; 44 45 import androidx.annotation.NonNull; 46 import androidx.annotation.Nullable; 47 48 import com.android.modules.utils.build.SdkLevel; 49 import com.android.providers.media.PickerUriResolver; 50 import com.android.providers.media.photopicker.PickerSyncController; 51 import com.android.providers.media.photopicker.data.model.Category; 52 import com.android.providers.media.photopicker.data.model.UserId; 53 54 import java.util.Arrays; 55 56 /** 57 * Provides image and video items from {@link MediaStore} collection to the Photo Picker. 58 */ 59 public class ItemsProvider { 60 private static final String TAG = ItemsProvider.class.getSimpleName(); 61 private static final boolean DEBUG = false; 62 private static final boolean DEBUG_DUMP_CURSORS = false; 63 64 private final Context mContext; 65 ItemsProvider(Context context)66 public ItemsProvider(Context context) { 67 mContext = context; 68 ensureNotificationHandler(context); 69 } 70 71 private static final Uri URI_MEDIA_ALL; 72 private static final Uri URI_MEDIA_LOCAL; 73 private static final Uri URI_ALBUMS_ALL; 74 private static final Uri URI_ALBUMS_LOCAL; 75 76 static { 77 final Uri media = PICKER_INTERNAL_URI.buildUpon() 78 .appendPath(PickerUriResolver.MEDIA_PATH).build(); 79 URI_MEDIA_ALL = media.buildUpon().appendPath(PickerUriResolver.ALL_PATH).build(); 80 URI_MEDIA_LOCAL = media.buildUpon().appendPath(PickerUriResolver.LOCAL_PATH).build(); 81 82 final Uri albums = PICKER_INTERNAL_URI.buildUpon() 83 .appendPath(PickerUriResolver.ALBUM_PATH).build(); 84 URI_ALBUMS_ALL = albums.buildUpon().appendPath(PickerUriResolver.ALL_PATH).build(); 85 URI_ALBUMS_LOCAL = albums.buildUpon().appendPath(PickerUriResolver.LOCAL_PATH).build(); 86 } 87 88 /** 89 * Returns a {@link Cursor} to all(local + cloud) images/videos based on the param passed for 90 * {@code category}, {@code limit}, {@code mimeTypes} and {@code userId}. 91 * 92 * <p> 93 * By default, the returned {@link Cursor} sorts by latest date taken. 94 * 95 * @param category the category of items to return. May be cloud, local or merged albums like 96 * favorites or videos. 97 * @param limit the limit of number of items to return. 98 * @param mimeTypes the mime type of item. {@code null} returns all images/videos that are 99 * scanned by {@link MediaStore}. 100 * @param userId the {@link UserId} of the user to get items as. 101 * {@code null} defaults to {@link UserId#CURRENT_USER} 102 * 103 * @return {@link Cursor} to images/videos on external storage that are scanned by 104 * {@link MediaStore} or returned by cloud provider. The returned cursor is filtered based on 105 * params passed, it {@code null} if there are no such images/videos. The Cursor for each item 106 * contains {@link android.provider.CloudMediaProviderContract.MediaColumns} 107 */ 108 @Nullable getAllItems(Category category, int limit, @Nullable String[] mimeTypes, @Nullable UserId userId)109 public Cursor getAllItems(Category category, int limit, @Nullable String[] mimeTypes, 110 @Nullable UserId userId) throws IllegalArgumentException { 111 if (DEBUG) { 112 Log.d(TAG, "getAllItems() userId=" + userId + " cat=" + category 113 + " mimeTypes=" + Arrays.toString(mimeTypes) + " limit=" + limit); 114 Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable()); 115 } 116 117 Trace.beginSection("ItemsProvider.getAllItems"); 118 try { 119 sNotificationHandler.onLoadingStarted(); 120 121 return queryMedia(URI_MEDIA_ALL, limit, mimeTypes, category, userId); 122 } finally { 123 sNotificationHandler.onLoadingFinished(); 124 Trace.endSection(); 125 } 126 } 127 128 /** 129 * Returns a {@link Cursor} to local images/videos based on the param passed for 130 * {@code category}, {@code limit}, {@code mimeTypes} and {@code userId}. 131 * 132 * <p> 133 * By default, the returned {@link Cursor} sorts by latest date taken. 134 * 135 * @param category the category of items to return. May be local or merged albums like 136 * favorites or videos. 137 * @param limit the limit of number of items to return. 138 * @param mimeTypes the mime type of item. {@code null} returns all images/videos that are 139 * scanned by {@link MediaStore}. 140 * @param userId the {@link UserId} of the user to get items as. 141 * {@code null} defaults to {@link UserId#CURRENT_USER} 142 * 143 * @return {@link Cursor} to images/videos on external storage that are scanned by 144 * {@link MediaStore}. The returned cursor is filtered based on params passed, it {@code null} 145 * if there are no such images/videos. The Cursor for each item contains 146 * {@link android.provider.CloudMediaProviderContract.MediaColumns} 147 * 148 * NOTE: We don't validate the given category is a local album. The behavior is undefined if 149 * this method is called with a non-local album. 150 */ 151 @Nullable getLocalItems(Category category, int limit, @Nullable String[] mimeTypes, @Nullable UserId userId)152 public Cursor getLocalItems(Category category, int limit, @Nullable String[] mimeTypes, 153 @Nullable UserId userId) throws IllegalArgumentException { 154 if (DEBUG) { 155 Log.d(TAG, "getLocalItems() userId=" + userId + " cat=" + category 156 + " mimeTypes=" + Arrays.toString(mimeTypes) + " limit=" + limit); 157 Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable()); 158 } 159 160 Trace.beginSection("ItemsProvider.getLocalItems"); 161 try { 162 return queryMedia(URI_MEDIA_LOCAL, limit, mimeTypes, category, userId); 163 } finally { 164 Trace.endSection(); 165 } 166 } 167 168 /** 169 * Returns a {@link Cursor} to all non-empty categories in which images/videos are categorised. 170 * This includes: 171 * * A constant list of local categories for on-device images/videos: {@link Category} 172 * * Albums provided by selected cloud provider 173 * 174 * @param mimeTypes the mime type of item. {@code null} returns all images/videos that are 175 * scanned by {@link MediaStore}. 176 * @param userId the {@link UserId} of the user to get categories as. 177 * {@code null} defaults to {@link UserId#CURRENT_USER}. 178 * 179 * @return {@link Cursor} for each category would contain {@link AlbumColumns#ALL_PROJECTION} 180 * in the relative order. 181 */ 182 @Nullable getAllCategories(@ullable String[] mimeTypes, @Nullable UserId userId)183 public Cursor getAllCategories(@Nullable String[] mimeTypes, @Nullable UserId userId) { 184 if (DEBUG) { 185 Log.d(TAG, "getAllCategories() userId=" + userId 186 + " mimeTypes=" + Arrays.toString(mimeTypes)); 187 Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable()); 188 } 189 190 Trace.beginSection("ItemsProvider.getAllCategories"); 191 try { 192 sNotificationHandler.onLoadingStarted(); 193 194 return queryAlbums(URI_ALBUMS_ALL, mimeTypes, userId); 195 } finally { 196 sNotificationHandler.onLoadingFinished(); 197 Trace.endSection(); 198 } 199 } 200 201 /** 202 * Returns a {@link Cursor} to all non-empty categories in which images/videos are categorised. 203 * This includes a constant list of local categories for on-device images/videos. 204 * 205 * @param mimeTypes the mime type of item. {@code null} returns all images/videos that are 206 * scanned by {@link MediaStore}. 207 * @param userId the {@link UserId} of the user to get categories as. 208 * {@code null} defaults to {@link UserId#CURRENT_USER}. 209 * 210 * @return {@link Cursor} for each category would contain {@link AlbumColumns#ALL_PROJECTION} 211 * in the relative order. 212 */ 213 @Nullable getLocalCategories(@ullable String[] mimeTypes, @Nullable UserId userId)214 public Cursor getLocalCategories(@Nullable String[] mimeTypes, @Nullable UserId userId) { 215 if (DEBUG) { 216 Log.d(TAG, "getLocalCategories() userId=" + userId 217 + " mimeTypes=" + Arrays.toString(mimeTypes)); 218 Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable()); 219 } 220 221 Trace.beginSection("ItemsProvider.getLocalCategories"); 222 try { 223 return queryAlbums(URI_ALBUMS_LOCAL, mimeTypes, userId); 224 } finally { 225 Trace.endSection(); 226 } 227 } 228 229 @Nullable queryMedia(@onNull Uri uri, int limit, String[] mimeTypes, @NonNull Category category, @Nullable UserId userId)230 private Cursor queryMedia(@NonNull Uri uri, int limit, String[] mimeTypes, 231 @NonNull Category category, @Nullable UserId userId) throws IllegalStateException { 232 if (userId == null) { 233 userId = UserId.CURRENT_USER; 234 } 235 236 if (DEBUG) { 237 Log.d(TAG, "queryMedia() userId=" + userId + " uri=" + uri + " cat=" + category 238 + " mimeTypes=" + Arrays.toString(mimeTypes) + " limit=" + limit); 239 Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable()); 240 } 241 Trace.beginSection("ItemsProvider.queryMedia"); 242 243 final Bundle extras = new Bundle(); 244 Cursor result = null; 245 try (ContentProviderClient client = userId.getContentResolver(mContext) 246 .acquireUnstableContentProviderClient(MediaStore.AUTHORITY)) { 247 if (client == null) { 248 Log.e(TAG, "Unable to acquire unstable content provider for " 249 + MediaStore.AUTHORITY); 250 return null; 251 } 252 extras.putInt(QUERY_ARG_LIMIT, limit); 253 if (mimeTypes != null) { 254 extras.putStringArray(MediaStore.QUERY_ARG_MIME_TYPE, mimeTypes); 255 } 256 extras.putString(MediaStore.QUERY_ARG_ALBUM_ID, category.getId()); 257 extras.putString(MediaStore.QUERY_ARG_ALBUM_AUTHORITY, category.getAuthority()); 258 259 result = client.query(uri, /* projection */ null, extras, 260 /* cancellationSignal */ null); 261 return result; 262 } catch (RemoteException | NameNotFoundException ignored) { 263 // Do nothing, return null. 264 Log.e(TAG, "Failed to query merged media with extras: " 265 + extras + ". userId = " + userId, ignored); 266 return null; 267 } finally { 268 Trace.endSection(); 269 if (DEBUG) { 270 if (result == null) { 271 Log.d(TAG, "queryMedia()'s result is null"); 272 } else { 273 Log.d(TAG, "queryMedia() loaded " + result.getCount() + " items"); 274 if (DEBUG_DUMP_CURSORS) { 275 Log.v(TAG, dumpCursorToString(result)); 276 } 277 } 278 } 279 } 280 } 281 282 @Nullable queryAlbums(@onNull Uri uri, @Nullable String[] mimeTypes, @Nullable UserId userId)283 private Cursor queryAlbums(@NonNull Uri uri, @Nullable String[] mimeTypes, 284 @Nullable UserId userId) { 285 if (userId == null) { 286 userId = UserId.CURRENT_USER; 287 } 288 289 if (DEBUG) { 290 Log.d(TAG, "queryAlbums() userId=" + userId + " uri=" + uri 291 + " mimeTypes=" + Arrays.toString(mimeTypes)); 292 Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable()); 293 } 294 Trace.beginSection("ItemsProvider.queryAlbums"); 295 296 final Bundle extras = new Bundle(); 297 Cursor result = null; 298 try (ContentProviderClient client = userId.getContentResolver(mContext) 299 .acquireUnstableContentProviderClient(MediaStore.AUTHORITY)) { 300 if (client == null) { 301 Log.e(TAG, "Unable to acquire unstable content provider for " 302 + MediaStore.AUTHORITY); 303 return null; 304 } 305 if (mimeTypes != null) { 306 extras.putStringArray(MediaStore.QUERY_ARG_MIME_TYPE, mimeTypes); 307 } 308 309 result = client.query(uri, /* projection */ null, extras, 310 /* cancellationSignal */ null); 311 return result; 312 } catch (RemoteException | NameNotFoundException ignored) { 313 // Do nothing, return null. 314 Log.w(TAG, "Failed to query merged albums with extras: " 315 + extras + ". userId = " + userId, ignored); 316 return null; 317 } finally { 318 Trace.endSection(); 319 if (DEBUG) { 320 if (result == null) { 321 Log.d(TAG, "queryAlbums()'s result is null"); 322 } else { 323 Log.d(TAG, "queryAlbums() loaded " + result.getCount() + " items"); 324 if (DEBUG_DUMP_CURSORS) { 325 Log.v(TAG, dumpCursorToString(result)); 326 } 327 } 328 } 329 } 330 } 331 getItemsUri(String id, String authority, UserId userId)332 public static Uri getItemsUri(String id, String authority, UserId userId) { 333 final Uri uri = PickerUriResolver.getMediaUri(authority).buildUpon() 334 .appendPath(id).build(); 335 336 if (userId.equals(UserId.CURRENT_USER)) { 337 return uri; 338 } 339 340 return createContentUriForUser(uri, userId.getUserHandle()); 341 } 342 createContentUriForUser(Uri uri, UserHandle userHandle)343 private static Uri createContentUriForUser(Uri uri, UserHandle userHandle) { 344 if (SdkLevel.isAtLeastS()) { 345 return ContentProvider.createContentUriForUser(uri, userHandle); 346 } 347 348 return createContentUriForUserImpl(uri, userHandle); 349 } 350 351 /** 352 * This method is a copy of {@link ContentProvider#createContentUriForUser(Uri, UserHandle)} 353 * which is a System API added in Android S. 354 */ createContentUriForUserImpl(Uri uri, UserHandle userHandle)355 private static Uri createContentUriForUserImpl(Uri uri, UserHandle userHandle) { 356 if (!ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) { 357 throw new IllegalArgumentException(String.format( 358 "Given URI [%s] is not a content URI: ", uri)); 359 } 360 361 int userId = userHandle.getIdentifier(); 362 if (uriHasUserId(uri)) { 363 if (String.valueOf(userId).equals(uri.getUserInfo())) { 364 return uri; 365 } 366 throw new IllegalArgumentException(String.format( 367 "Given URI [%s] already has a user ID, different from given user handle [%s]", 368 uri, 369 userId)); 370 } 371 372 Uri.Builder builder = uri.buildUpon(); 373 builder.encodedAuthority( 374 "" + userHandle.getIdentifier() + "@" + uri.getEncodedAuthority()); 375 return builder.build(); 376 } 377 uriHasUserId(Uri uri)378 private static boolean uriHasUserId(Uri uri) { 379 if (uri == null) return false; 380 return !TextUtils.isEmpty(uri.getUserInfo()); 381 } 382 383 // TODO(b/257887919): Build proper UI and remove all this monstrosity below! 384 private static volatile @Nullable NotificationHandler sNotificationHandler; 385 ensureNotificationHandler(@onNull Context context)386 private static void ensureNotificationHandler(@NonNull Context context) { 387 if (sNotificationHandler == null) { 388 synchronized (PickerSyncController.class) { 389 if (sNotificationHandler == null) { 390 sNotificationHandler = new NotificationHandler(context); 391 } 392 } 393 } 394 } 395 396 private static class NotificationHandler extends Handler { 397 static final int MESSAGE_CODE_STARTED_LOADING = 1; 398 static final int MESSAGE_CODE_TICK = 2; 399 static final int MESSAGE_CODE_FINISHED_LOADING = 3; 400 401 static final int FIRST_TICK_DELAY = 1_000; // 1 second 402 static final int TICK_DELAY = 30_000; // 30 seconds 403 404 final Context mContext; 405 NotificationHandler(@onNull Context context)406 NotificationHandler(@NonNull Context context) { 407 // It will be running on the UI thread. 408 super(Looper.getMainLooper()); 409 mContext = context.getApplicationContext(); 410 } 411 412 @Override handleMessage(@onNull Message msg)413 public void handleMessage(@NonNull Message msg) { 414 switch (msg.what) { 415 case MESSAGE_CODE_STARTED_LOADING: 416 if (hasMessages(MESSAGE_CODE_TICK)) { 417 // Already have scheduled ticks - do nothing. 418 return; 419 } 420 // Wait 1 sec before actually showing the first notification (so that we don't 421 // annoy users with our Toasts if the loading actually takes less than 1 sec). 422 sendTickMessageDelayed(/* seqNum */ 1, FIRST_TICK_DELAY); 423 break; 424 425 case MESSAGE_CODE_TICK: 426 final int seqNum = msg.arg1; 427 428 // These Strings are intentionally hardcoded here instead of being added to 429 // the res/values/strings.xml. 430 // They are to be used in droidfood only, not to be translated, and must be 431 // removed very soon! 432 final String text; 433 if (seqNum == 1) { 434 text = "Syncing your cloud media library..."; 435 } else { 436 text = "Still syncing your cloud media library..."; 437 } 438 Toast.makeText(mContext, "[Dogfood: known issue] " + text, LENGTH_LONG).show(); 439 440 // Do not show more than 10 of these. 441 if (seqNum < 10) { 442 // Show next tick in 30 seconds. 443 sendTickMessageDelayed(/* seqNum */ seqNum + 1, TICK_DELAY); 444 } 445 break; 446 447 case MESSAGE_CODE_FINISHED_LOADING: 448 removeMessages(MESSAGE_CODE_STARTED_LOADING); 449 removeMessages(MESSAGE_CODE_TICK); 450 break; 451 452 default: 453 super.handleMessage(msg); 454 } 455 } 456 onLoadingStarted()457 void onLoadingStarted() { 458 sendEmptyMessage(MESSAGE_CODE_STARTED_LOADING); 459 } 460 onLoadingFinished()461 void onLoadingFinished() { 462 sendEmptyMessage(MESSAGE_CODE_FINISHED_LOADING); 463 } 464 sendTickMessageDelayed(int seqNum, int delay)465 private void sendTickMessageDelayed(int seqNum, int delay) { 466 final Message message = obtainMessage(MESSAGE_CODE_TICK); 467 message.arg1 = seqNum; 468 469 sendMessageDelayed(message, delay); 470 } 471 } 472 } 473