• 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;
18 
19 import static android.database.DatabaseUtils.dumpCursorToString;
20 import static android.provider.CloudMediaProviderContract.AlbumColumns.ALL_PROJECTION;
21 import static android.provider.CloudMediaProviderContract.AlbumColumns.AUTHORITY;
22 import static android.provider.CloudMediaProviderContract.METHOD_GET_MEDIA_COLLECTION_INFO;
23 import static android.provider.CloudMediaProviderContract.MediaCollectionInfo.ACCOUNT_CONFIGURATION_INTENT;
24 import static android.provider.CloudMediaProviderContract.MediaCollectionInfo.ACCOUNT_NAME;
25 
26 import static com.android.providers.media.PickerUriResolver.getAlbumUri;
27 import static com.android.providers.media.PickerUriResolver.getMediaCollectionInfoUri;
28 
29 import static java.util.Objects.requireNonNull;
30 
31 import android.content.Context;
32 import android.content.Intent;
33 import android.database.Cursor;
34 import android.database.CursorWrapper;
35 import android.database.MergeCursor;
36 import android.os.Bundle;
37 import android.os.Trace;
38 import android.provider.MediaStore;
39 import android.text.TextUtils;
40 import android.util.Log;
41 
42 import androidx.annotation.NonNull;
43 import androidx.annotation.Nullable;
44 
45 import com.android.providers.media.photopicker.data.CloudProviderQueryExtras;
46 import com.android.providers.media.photopicker.data.PickerDbFacade;
47 
48 import java.util.ArrayList;
49 import java.util.Arrays;
50 import java.util.HashMap;
51 import java.util.List;
52 import java.util.Map;
53 
54 /**
55  * Fetches data for the picker UI from the db and cloud/local providers
56  */
57 public class PickerDataLayer {
58     private static final String TAG = "PickerDataLayer";
59     private static final boolean DEBUG = false;
60     private static final boolean DEBUG_DUMP_CURSORS = false;
61 
62     public static final String QUERY_ARG_LOCAL_ONLY = "android:query-arg-local-only";
63 
64     private final Context mContext;
65     private final PickerDbFacade mDbFacade;
66     private final PickerSyncController mSyncController;
67     private final String mLocalProvider;
68 
PickerDataLayer(Context context, PickerDbFacade dbFacade, PickerSyncController syncController)69     public PickerDataLayer(Context context, PickerDbFacade dbFacade,
70             PickerSyncController syncController) {
71         mContext = context;
72         mDbFacade = dbFacade;
73         mSyncController = syncController;
74         mLocalProvider = dbFacade.getLocalProvider();
75     }
76 
77     /**
78      * Returns {@link Cursor} with all local media part of the given album in {@code queryArgs}
79      */
fetchLocalMedia(Bundle queryArgs)80     public Cursor fetchLocalMedia(Bundle queryArgs) {
81         queryArgs.putBoolean(QUERY_ARG_LOCAL_ONLY, true);
82         return fetchMediaInternal(queryArgs);
83     }
84 
85     /**
86      * Returns {@link Cursor} with all local+cloud media part of the given album in
87      * {@code queryArgs}
88      */
fetchAllMedia(Bundle queryArgs)89     public Cursor fetchAllMedia(Bundle queryArgs) {
90         queryArgs.putBoolean(QUERY_ARG_LOCAL_ONLY, false);
91         return fetchMediaInternal(queryArgs);
92     }
93 
fetchMediaInternal(Bundle queryArgs)94     private Cursor fetchMediaInternal(Bundle queryArgs) {
95         if (DEBUG) {
96             Log.d(TAG, "fetchMediaInternal() "
97                     + (queryArgs.getBoolean(QUERY_ARG_LOCAL_ONLY) ? "LOCAL_ONLY" : "ALL")
98                     + " args=" + queryArgs);
99             Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable());
100         }
101 
102         final CloudProviderQueryExtras queryExtras =
103                 CloudProviderQueryExtras.fromMediaStoreBundle(queryArgs);
104         final String albumAuthority = queryExtras.getAlbumAuthority();
105 
106         Trace.beginSection(traceSectionName("fetchMediaInternal", albumAuthority));
107 
108         Cursor result = null;
109         try {
110             final boolean isLocalOnly = queryExtras.isLocalOnly();
111             final String albumId = queryExtras.getAlbumId();
112             // Use media table for all media except albums. Merged categories like,
113             // favorites and video are tagged in the media table and are not a part of
114             // album_media.
115             if (TextUtils.isEmpty(albumId) || isMergedAlbum(queryExtras)) {
116                 // Refresh the 'media' table
117                 syncAllMedia(isLocalOnly);
118 
119                 if (!isLocalOnly && TextUtils.isEmpty(albumId)) {
120                     // TODO(b/257887919): Build proper UI and remove this.
121                     // Notify that the picker is launched in case there's any pending UI
122                     // notification
123                     mSyncController.notifyPickerLaunch();
124                 }
125 
126                 // Fetch all merged and deduped cloud and local media from 'media' table
127                 // This also matches 'merged' albums like Favorites because |authority| will
128                 // be null, hence we have to fetch the data from the picker db
129                 result = mDbFacade.queryMediaForUi(queryExtras.toQueryFilter());
130             } else {
131                 if (isLocalOnly && !isLocal(albumAuthority)) {
132                     // This is error condition because when cloud content is disabled, we shouldn't
133                     // send any cloud albums in available albums list.
134                     throw new IllegalStateException(
135                             "Can't exclude cloud contents in cloud album " + albumAuthority);
136                 }
137 
138                 // The album type here can only be local or cloud because merged categories like,
139                 // Favorites and Videos would hit the first condition.
140                 // Refresh the 'album_media' table
141                 mSyncController.syncAlbumMedia(albumId, isLocal(albumAuthority));
142 
143                 // Fetch album specific media for local or cloud from 'album_media' table
144                 result = mDbFacade.queryAlbumMediaForUi(
145                         queryExtras.toQueryFilter(), albumAuthority);
146             }
147             return result;
148         } finally {
149             Trace.endSection();
150             if (DEBUG) {
151                 if (result == null) {
152                     Log.d(TAG, "fetchMediaInternal()'s result is null");
153                 } else {
154                     Log.d(TAG, "fetchMediaInternal() loaded " + result.getCount() + " items");
155                     if (DEBUG_DUMP_CURSORS) {
156                         Log.v(TAG, dumpCursorToString(result));
157                     }
158                 }
159             }
160         }
161     }
162 
syncAllMedia(boolean isLocalOnly)163     private void syncAllMedia(boolean isLocalOnly) {
164         if (isLocalOnly) {
165             mSyncController.syncAllMediaFromLocalProvider();
166         } else {
167             mSyncController.syncAllMedia();
168         }
169     }
170 
171     /**
172      * Checks if the query is for a merged album type.
173      * Some albums are not cloud only, they are merged from files on devices and the cloudprovider.
174      */
isMergedAlbum(CloudProviderQueryExtras queryExtras)175     private boolean isMergedAlbum(CloudProviderQueryExtras queryExtras) {
176         final boolean isFavorite = queryExtras.isFavorite();
177         final boolean isVideo = queryExtras.isVideo();
178         return isFavorite || isVideo;
179     }
180 
181     /**
182      * Returns {@link Cursor} with all local and merged albums with local items.
183      */
fetchLocalAlbums(Bundle queryArgs)184     public Cursor fetchLocalAlbums(Bundle queryArgs) {
185         queryArgs.putBoolean(QUERY_ARG_LOCAL_ONLY, true);
186         return fetchAlbumsInternal(queryArgs);
187     }
188 
189     /**
190      * Returns {@link Cursor} with all local, merged and cloud albums
191      */
fetchAllAlbums(Bundle queryArgs)192     public Cursor fetchAllAlbums(Bundle queryArgs) {
193         queryArgs.putBoolean(QUERY_ARG_LOCAL_ONLY, false);
194         return fetchAlbumsInternal(queryArgs);
195     }
196 
fetchAlbumsInternal(Bundle queryArgs)197     private Cursor fetchAlbumsInternal(Bundle queryArgs) {
198         if (DEBUG) {
199             Log.d(TAG, "fetchAlbums() "
200                     + (queryArgs.getBoolean(QUERY_ARG_LOCAL_ONLY) ? "LOCAL_ONLY" : "ALL")
201                     + " args=" + queryArgs);
202             Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable());
203         }
204 
205         Trace.beginSection(traceSectionName("fetchAlbums"));
206 
207         Cursor result = null;
208         try {
209             final boolean isLocalOnly = queryArgs.getBoolean(QUERY_ARG_LOCAL_ONLY, false);
210             // Refresh the 'media' table so that 'merged' albums (Favorites and Videos) are
211             // up-to-date
212             syncAllMedia(isLocalOnly);
213 
214             final String cloudProvider = mDbFacade.getCloudProvider();
215             final CloudProviderQueryExtras queryExtras =
216                     CloudProviderQueryExtras.fromMediaStoreBundle(queryArgs);
217             final Bundle cloudMediaArgs = queryExtras.toCloudMediaBundle();
218             final List<Cursor> cursors = new ArrayList<>();
219             final Bundle cursorExtra = new Bundle();
220             cursorExtra.putString(MediaStore.EXTRA_CLOUD_PROVIDER, cloudProvider);
221             cursorExtra.putString(MediaStore.EXTRA_LOCAL_PROVIDER, mLocalProvider);
222 
223             // Favorites and Videos are merged albums.
224             final Cursor mergedAlbums = mDbFacade.getMergedAlbums(queryExtras.toQueryFilter());
225             if (mergedAlbums != null) {
226                 cursors.add(mergedAlbums);
227             }
228 
229             final Cursor localAlbums = queryProviderAlbums(mLocalProvider, cloudMediaArgs);
230             if (localAlbums != null) {
231                 cursors.add(new AlbumsCursorWrapper(localAlbums, mLocalProvider));
232             }
233 
234             if (!isLocalOnly) {
235                 final Cursor cloudAlbums = queryProviderAlbums(cloudProvider, cloudMediaArgs);
236                 if (cloudAlbums != null) {
237                     // There's a bug in the Merge Cursor code (b/241096151) such that if the cursors
238                     // being merged have different projections, the data gets corrupted post IPC.
239                     // Fixing this bug requires a dessert release and will not be compatible with
240                     // android T-. Hence, we're using {@link AlbumsCursorWrapper} that unifies the
241                     // local and cloud album cursors' projections to {@link ALL_PROJECTION}
242                     cursors.add(new AlbumsCursorWrapper(cloudAlbums, cloudProvider));
243                 }
244             }
245 
246             if (cursors.isEmpty()) {
247                 return null;
248             }
249 
250             result = new MergeCursor(cursors.toArray(new Cursor[cursors.size()]));
251             result.setExtras(cursorExtra);
252             return result;
253         } finally {
254             Trace.endSection();
255             if (DEBUG) {
256                 if (result == null) {
257                     Log.d(TAG, "fetchAlbumsInternal()'s result is null");
258                 } else {
259                     Log.d(TAG, "fetchAlbumsInternal() loaded " + result.getCount() + " items");
260                     if (DEBUG_DUMP_CURSORS) {
261                         Log.v(TAG, dumpCursorToString(result));
262                     }
263                 }
264             }
265         }
266     }
267 
268     @Nullable
fetchCloudAccountInfo()269     public AccountInfo fetchCloudAccountInfo() {
270         if (DEBUG) {
271             Log.d(TAG, "fetchCloudAccountInfo()");
272             Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable());
273         }
274 
275         final String cloudProvider = mDbFacade.getCloudProvider();
276         if (cloudProvider == null) {
277             return null;
278         }
279 
280         Trace.beginSection(traceSectionName("fetchCloudAccountInfo"));
281         try {
282             return fetchCloudAccountInfoInternal(cloudProvider);
283         } catch (Exception e) {
284             Log.w(TAG, "Failed to fetch account info from cloud provider: " + cloudProvider, e);
285             return null;
286         } finally {
287             Trace.endSection();
288         }
289     }
290 
291     @Nullable
fetchCloudAccountInfoInternal(@onNull String cloudProvider)292     private AccountInfo fetchCloudAccountInfoInternal(@NonNull String cloudProvider) {
293         final Bundle accountBundle = mContext.getContentResolver()
294                 .call(getMediaCollectionInfoUri(cloudProvider), METHOD_GET_MEDIA_COLLECTION_INFO,
295                         /* arg */ null, /* extras */ null);
296 
297         final String accountName = accountBundle.getString(ACCOUNT_NAME);
298         if (accountName == null) {
299             return null;
300         }
301         final Intent configIntent = accountBundle.getParcelable(ACCOUNT_CONFIGURATION_INTENT);
302 
303         return new AccountInfo(accountName, configIntent);
304     }
305 
queryProviderAlbums(@ullable String authority, Bundle queryArgs)306     private Cursor queryProviderAlbums(@Nullable String authority, Bundle queryArgs) {
307         if (authority == null) {
308             // Can happen if there is no cloud provider
309             return null;
310         }
311 
312         Trace.beginSection(traceSectionName("queryProviderAlbums", authority));
313         try {
314             return queryProviderAlbumsInternal(authority, queryArgs);
315         } finally {
316             Trace.endSection();
317         }
318     }
319 
queryProviderAlbumsInternal(@onNull String authority, Bundle queryArgs)320     private Cursor queryProviderAlbumsInternal(@NonNull String authority, Bundle queryArgs) {
321         try {
322             return mContext.getContentResolver().query(getAlbumUri(authority),
323                     /* projection */ null, queryArgs, /* cancellationSignal */ null);
324         } catch (Exception e) {
325             Log.w(TAG, "Failed to fetch cloud albums for: " + authority, e);
326             return null;
327         }
328     }
329 
isLocal(String authority)330     private boolean isLocal(String authority) {
331         return mLocalProvider.equals(authority);
332     }
333 
traceSectionName(@onNull String method)334     private String traceSectionName(@NonNull String method) {
335         return traceSectionName(method, null);
336     }
337 
traceSectionName(@onNull String method, @Nullable String authority)338     private String traceSectionName(@NonNull String method, @Nullable String authority) {
339         final StringBuilder sb = new StringBuilder("PDL.")
340                 .append(method);
341         if (authority != null) {
342             sb.append('[').append(isLocal(authority) ? "local" : "cloud").append(']');
343         }
344         return sb.toString();
345     }
346 
347     public static class AccountInfo {
348         public final String accountName;
349         public final Intent accountConfigurationIntent;
350 
AccountInfo(String accountName, Intent accountConfigurationIntent)351         public AccountInfo(String accountName, Intent accountConfigurationIntent) {
352             this.accountName = accountName;
353             this.accountConfigurationIntent = accountConfigurationIntent;
354         }
355     }
356 
357     /**
358      * A {@link CursorWrapper} that exposes the data stored in the underlying {@link Cursor} in the
359      * {@link ALL_PROJECTION} "format", additionally overriding the {@link AUTHORITY} column.
360      * Columns from the underlying that are not in the {@link ALL_PROJECTION} are ignored.
361      * Missing columns (except {@link AUTHORITY}) are set with default value of {@code null}.
362      */
363     private static class AlbumsCursorWrapper extends CursorWrapper {
364         static final String TAG = "AlbumsCursorWrapper";
365 
366         @NonNull static final Map<String, Integer> COLUMN_NAME_TO_INDEX_MAP;
367         static final int AUTHORITY_COLUMN_INDEX;
368         static {
369             final Map<String, Integer> map = new HashMap<>();
370             for (int columnIndex = 0; columnIndex < ALL_PROJECTION.length; columnIndex++) {
map.put(ALL_PROJECTION[columnIndex], columnIndex)371                 map.put(ALL_PROJECTION[columnIndex], columnIndex);
372             }
373             COLUMN_NAME_TO_INDEX_MAP = map;
374             AUTHORITY_COLUMN_INDEX = map.get(AUTHORITY);
375         }
376 
377         @NonNull final String mAuthority;
378         @NonNull final int[] mColumnIndexToCursorColumnIndexArray;
379 
380         boolean mAuthorityMismatchLogged = false;
381 
AlbumsCursorWrapper(@onNull Cursor cursor, @NonNull String authority)382         AlbumsCursorWrapper(@NonNull Cursor cursor, @NonNull String authority) {
383             super(requireNonNull(cursor));
384             mAuthority = requireNonNull(authority);
385 
386             mColumnIndexToCursorColumnIndexArray = new int[ALL_PROJECTION.length];
387             for (int columnIndex = 0; columnIndex < ALL_PROJECTION.length; columnIndex++) {
388                 final String columnName = ALL_PROJECTION[columnIndex];
389                 final int cursorColumnIndex = cursor.getColumnIndex(columnName);
390                 mColumnIndexToCursorColumnIndexArray[columnIndex] = cursorColumnIndex;
391             }
392         }
393 
394         @Override
getColumnCount()395         public int getColumnCount() {
396             return ALL_PROJECTION.length;
397         }
398 
399         @Override
getColumnIndex(String columnName)400         public int getColumnIndex(String columnName) {
401             return COLUMN_NAME_TO_INDEX_MAP.get(columnName);
402         }
403 
404         @Override
getColumnIndexOrThrow(String columnName)405         public int getColumnIndexOrThrow(String columnName)
406                 throws IllegalArgumentException {
407             final int columnIndex = getColumnIndex(columnName);
408             if (columnIndex < 0) {
409                 throw new IllegalArgumentException("column '" + columnName
410                         + "' does not exist. Available columns: "
411                         + Arrays.toString(getColumnNames()));
412             }
413             return columnIndex;
414         }
415 
416         @Override
getColumnName(int columnIndex)417         public String getColumnName(int columnIndex) {
418             return ALL_PROJECTION[columnIndex];
419         }
420 
421         @Override
getColumnNames()422         public String[] getColumnNames() {
423             return ALL_PROJECTION;
424         }
425 
426         @Override
getString(int columnIndex)427         public String getString(int columnIndex) {
428             // 1. Get value from the underlying cursor.
429             final int cursorColumnIndex = mColumnIndexToCursorColumnIndexArray[columnIndex];
430             final String cursorValue = cursorColumnIndex != -1
431                     ? getWrappedCursor().getString(cursorColumnIndex) : null;
432 
433             // 2a. If this is NOT the AUTHORITY column: just return the value.
434             if (columnIndex != AUTHORITY_COLUMN_INDEX) {
435                 return cursorValue;
436             }
437 
438             // Validity check: the cursor's authority value, if present, is expected to match the
439             // mAuthority. Don't throw though, just log (at WARN). Also, only log once for the
440             // cursor (we don't need 10,000 of these lines in the log).
441             if (!mAuthorityMismatchLogged
442                     && cursorValue != null && !cursorValue.equals(mAuthority)) {
443                 Log.w(TAG, "Cursor authority - '" + cursorValue + "' - is different from the "
444                         + "expected authority '" + mAuthority + "'");
445                 mAuthorityMismatchLogged = true;
446             }
447 
448             // 2b. If this IS the AUTHORITY column: "override" whatever value (which may be null)
449             // is stored in the cursor.
450             return mAuthority;
451         }
452     }
453 }
454