• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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