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