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