• 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 import static android.provider.CloudMediaProviderContract.EXTRA_MEDIA_COLLECTION_ID;
26 import static android.provider.MediaStore.MY_UID;
27 
28 import static com.android.providers.media.PickerUriResolver.getAlbumUri;
29 import static com.android.providers.media.PickerUriResolver.getMediaCollectionInfoUri;
30 import static com.android.providers.media.photopicker.sync.PickerSyncManager.IMMEDIATE_ALBUM_SYNC_WORK_NAME;
31 import static com.android.providers.media.photopicker.sync.PickerSyncManager.IMMEDIATE_LOCAL_SYNC_WORK_NAME;
32 import static com.android.providers.media.photopicker.sync.WorkManagerInitializer.getWorkManager;
33 
34 import static java.util.Objects.requireNonNull;
35 
36 import android.content.Context;
37 import android.content.Intent;
38 import android.database.Cursor;
39 import android.database.CursorWrapper;
40 import android.database.MergeCursor;
41 import android.os.Bundle;
42 import android.os.Trace;
43 import android.provider.MediaStore;
44 import android.text.TextUtils;
45 import android.util.Log;
46 
47 import androidx.annotation.NonNull;
48 import androidx.annotation.Nullable;
49 
50 import com.android.internal.annotations.VisibleForTesting;
51 import com.android.internal.logging.InstanceId;
52 import com.android.providers.media.ConfigStore;
53 import com.android.providers.media.photopicker.data.CloudProviderQueryExtras;
54 import com.android.providers.media.photopicker.data.PickerDbFacade;
55 import com.android.providers.media.photopicker.data.PickerSyncRequestExtras;
56 import com.android.providers.media.photopicker.metrics.NonUiEventLogger;
57 import com.android.providers.media.photopicker.sync.PickerSyncManager;
58 import com.android.providers.media.photopicker.sync.SyncCompletionWaiter;
59 import com.android.providers.media.photopicker.sync.SyncTrackerRegistry;
60 import com.android.providers.media.util.ForegroundThread;
61 
62 import java.util.ArrayList;
63 import java.util.Arrays;
64 import java.util.HashMap;
65 import java.util.List;
66 import java.util.Map;
67 import java.util.Objects;
68 
69 /**
70  * Fetches data for the picker UI from the db and cloud/local providers
71  */
72 public class PickerDataLayer {
73     private static final String TAG = "PickerDataLayer";
74     private static final boolean DEBUG = false;
75     private static final boolean DEBUG_DUMP_CURSORS = false;
76     private static final int CLOUD_SYNC_TIMEOUT_MILLIS = 500;
77 
78     public static final String QUERY_ARG_LOCAL_ONLY = "android:query-arg-local-only";
79 
80     public static final String QUERY_DATE_TAKEN_BEFORE_MS = "android:query-date-taken-before-ms";
81 
82     public static final String QUERY_ID_SELECTION = "android:query-id-selection";
83     public static final String QUERY_LOCAL_ID_SELECTION = "android:query-local-id-selection";
84     public static final String QUERY_CLOUD_ID_SELECTION = "android:query-cloud-id-selection";
85     // This should be used to indicate if the ids passed in the query arguments should be checked
86     // for permission and authority or not. This shall be used for pre-selection uris passed in
87     // picker db query operations.
88     public static final String QUERY_SHOULD_SCREEN_SELECTION_URIS =
89             "android:query-should-screen-selection-uris";
90     public static final String QUERY_ROW_ID = "android:query-row-id";
91 
92     @NonNull
93     private final Context mContext;
94     @NonNull
95     private final PickerDbFacade mDbFacade;
96     @NonNull
97     private final PickerSyncController mSyncController;
98     @NonNull
99     private final PickerSyncManager mSyncManager;
100     @NonNull
101     private final String mLocalProvider;
102     @NonNull
103     private final ConfigStore mConfigStore;
104 
105     @VisibleForTesting
PickerDataLayer(@onNull Context context, @NonNull PickerDbFacade dbFacade, @NonNull PickerSyncController syncController, @NonNull ConfigStore configStore, @NonNull PickerSyncManager syncManager)106     public PickerDataLayer(@NonNull Context context, @NonNull PickerDbFacade dbFacade,
107             @NonNull PickerSyncController syncController, @NonNull ConfigStore configStore,
108             @NonNull PickerSyncManager syncManager) {
109         mContext = requireNonNull(context);
110         mDbFacade = requireNonNull(dbFacade);
111         mSyncController = requireNonNull(syncController);
112         mLocalProvider = requireNonNull(dbFacade.getLocalProvider());
113         mConfigStore = requireNonNull(configStore);
114         mSyncManager = syncManager;
115 
116         // Add a subscriber to config store changes to monitor the allowlist.
117         mConfigStore.addOnChangeListener(
118                 ForegroundThread.getExecutor(),
119                 this::validateCurrentCloudProviderOnAllowlistChange);
120     }
121 
122     /**
123      * Create a new instance of PickerDataLayer.
124      */
create(@onNull Context context, @NonNull PickerDbFacade dbFacade, @NonNull PickerSyncController syncController, @NonNull ConfigStore configStore)125     public static PickerDataLayer create(@NonNull Context context, @NonNull PickerDbFacade dbFacade,
126             @NonNull PickerSyncController syncController, @NonNull ConfigStore configStore) {
127         PickerSyncManager syncManager = new PickerSyncManager(getWorkManager(context), context);
128         syncManager.schedulePeriodicSync(configStore);
129         return new PickerDataLayer(context, dbFacade, syncController, configStore, syncManager);
130     }
131 
132     /**
133      * Returns {@link Cursor} with all local media part of the given album in {@code queryArgs}
134      */
fetchLocalMedia(Bundle queryArgs)135     public Cursor fetchLocalMedia(Bundle queryArgs) {
136         queryArgs.putBoolean(QUERY_ARG_LOCAL_ONLY, true);
137         return fetchMediaInternal(queryArgs);
138     }
139 
140     /**
141      * Returns {@link Cursor} with all local+cloud media part of the given album in
142      * {@code queryArgs}
143      */
fetchAllMedia(Bundle queryArgs)144     public Cursor fetchAllMedia(Bundle queryArgs) {
145         queryArgs.putBoolean(QUERY_ARG_LOCAL_ONLY, false);
146         return fetchMediaInternal(queryArgs);
147     }
148 
fetchMediaInternal(Bundle queryArgs)149     private Cursor fetchMediaInternal(Bundle queryArgs) {
150         if (DEBUG) {
151             Log.d(TAG, "fetchMediaInternal() "
152                     + (queryArgs.getBoolean(QUERY_ARG_LOCAL_ONLY) ? "LOCAL_ONLY" : "ALL")
153                     + " args=" + queryArgs);
154             Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable());
155         }
156 
157         final CloudProviderQueryExtras queryExtras =
158                 CloudProviderQueryExtras.fromMediaStoreBundle(queryArgs);
159         final String albumAuthority = queryExtras.getAlbumAuthority();
160 
161         Trace.beginSection(traceSectionName("fetchMediaInternal", albumAuthority));
162 
163         Cursor result = null;
164         try {
165             final boolean isLocalOnly = queryExtras.isLocalOnly();
166             final String albumId = queryExtras.getAlbumId();
167             // Use media table for all media except albums. Merged categories like,
168             // favorites and video are tagged in the media table and are not a part of
169             // album_media.
170             if (TextUtils.isEmpty(albumId) || queryExtras.isMergedAlbum()) {
171                 // Refresh the 'media' table
172                 if (shouldSyncBeforePickerQuery()) {
173                     syncAllMedia(isLocalOnly);
174                 } else {
175                     // Wait for local sync to finish indefinitely
176                     SyncCompletionWaiter.waitForSync(
177                             getWorkManager(mContext),
178                             SyncTrackerRegistry.getLocalSyncTracker(),
179                             IMMEDIATE_LOCAL_SYNC_WORK_NAME);
180                     Log.i(TAG, "Grants sync and Local sync is complete");
181 
182                     // Wait for on cloud sync with timeout
183                     if (!isLocalOnly) {
184                         boolean syncIsComplete = SyncCompletionWaiter.waitForSyncWithTimeout(
185                                 SyncTrackerRegistry.getCloudSyncTracker(),
186                                 CLOUD_SYNC_TIMEOUT_MILLIS);
187                         Log.i(TAG, "Finished waiting for cloud sync.  Is cloud sync complete: "
188                                 + syncIsComplete);
189                     }
190                 }
191 
192                 // Fetch all merged and deduped cloud and local media from 'media' table
193                 // This also matches 'merged' albums like Favorites because |authority| will
194                 // be null, hence we have to fetch the data from the picker db
195                 result = mDbFacade.queryMediaForUi(queryExtras.toQueryFilter());
196             } else {
197                 if (isLocalOnly && !isLocal(albumAuthority)) {
198                     // This is error condition because when cloud content is disabled, we shouldn't
199                     // send any cloud albums in available albums list.
200                     throw new IllegalStateException(
201                             "Can't exclude cloud contents in cloud album " + albumAuthority);
202                 }
203 
204                 // The album type here can only be local or cloud because merged categories like,
205                 // Favorites and Videos would hit the first condition.
206                 // Refresh the 'album_media' table
207                 if (shouldSyncBeforePickerQuery()) {
208                     mSyncController.syncAlbumMedia(albumId, isLocal(albumAuthority));
209                 } else {
210                     SyncCompletionWaiter.waitForSync(
211                             getWorkManager(mContext),
212                             SyncTrackerRegistry.getAlbumSyncTracker(isLocal(albumAuthority)),
213                             IMMEDIATE_ALBUM_SYNC_WORK_NAME);
214                     Log.i(TAG, "Album sync is complete");
215                 }
216 
217                 // Fetch album specific media for local or cloud from 'album_media' table
218                 result = mDbFacade.queryAlbumMediaForUi(
219                         queryExtras.toQueryFilter(), albumAuthority);
220             }
221             return result;
222         } finally {
223             Trace.endSection();
224             if (DEBUG) {
225                 if (result == null) {
226                     Log.d(TAG, "fetchMediaInternal()'s result is null");
227                 } else {
228                     Log.d(TAG, "fetchMediaInternal() loaded " + result.getCount() + " items");
229                     if (DEBUG_DUMP_CURSORS) {
230                         Log.v(TAG, dumpCursorToString(result));
231                     }
232                 }
233             }
234         }
235     }
236 
syncAllMedia(boolean isLocalOnly)237     private void syncAllMedia(boolean isLocalOnly) {
238         if (isLocalOnly) {
239             mSyncController.syncAllMediaFromLocalProvider(/* cancellationSignal= */ null);
240         } else {
241             mSyncController.syncAllMedia();
242         }
243     }
244 
245     /**
246      * Returns {@link Cursor} with all local and merged albums with local items.
247      */
fetchLocalAlbums(Bundle queryArgs)248     public Cursor fetchLocalAlbums(Bundle queryArgs) {
249         queryArgs.putBoolean(QUERY_ARG_LOCAL_ONLY, true);
250         return fetchAlbumsInternal(queryArgs);
251     }
252 
253     /**
254      * Returns {@link Cursor} with all local, merged and cloud albums
255      */
fetchAllAlbums(Bundle queryArgs)256     public Cursor fetchAllAlbums(Bundle queryArgs) {
257         queryArgs.putBoolean(QUERY_ARG_LOCAL_ONLY, false);
258         return fetchAlbumsInternal(queryArgs);
259     }
260 
fetchAlbumsInternal(Bundle queryArgs)261     private Cursor fetchAlbumsInternal(Bundle queryArgs) {
262         if (DEBUG) {
263             Log.d(TAG, "fetchAlbums() "
264                     + (queryArgs.getBoolean(QUERY_ARG_LOCAL_ONLY) ? "LOCAL_ONLY" : "ALL")
265                     + " args=" + queryArgs);
266             Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable());
267         }
268 
269         Trace.beginSection(traceSectionName("fetchAlbums"));
270 
271         Cursor result = null;
272         try {
273             final boolean isLocalOnly = queryArgs.getBoolean(QUERY_ARG_LOCAL_ONLY, false);
274             // Refresh the 'media' table so that 'merged' albums (Favorites and Videos) are
275             // up-to-date
276             if (shouldSyncBeforePickerQuery()) {
277                 syncAllMedia(isLocalOnly);
278             }
279 
280             final String cloudProvider = mSyncController.getCloudProvider();
281             final CloudProviderQueryExtras queryExtras =
282                     CloudProviderQueryExtras.fromMediaStoreBundle(queryArgs);
283             final Bundle cloudMediaArgs = queryExtras.toCloudMediaBundle();
284             final List<Cursor> cursors = new ArrayList<>();
285             final Bundle cursorExtra = new Bundle();
286             cursorExtra.putString(MediaStore.EXTRA_CLOUD_PROVIDER, cloudProvider);
287             cursorExtra.putString(MediaStore.EXTRA_LOCAL_PROVIDER, mLocalProvider);
288 
289             // Favorites and Videos are merged albums.
290             final Cursor mergedAlbums = mDbFacade.getMergedAlbums(queryExtras.toQueryFilter(),
291                     cloudProvider);
292             if (mergedAlbums != null) {
293                 cursors.add(mergedAlbums);
294             }
295 
296             final Cursor localAlbums = queryProviderAlbums(mLocalProvider, cloudMediaArgs);
297             if (localAlbums != null) {
298                 cursors.add(new AlbumsCursorWrapper(localAlbums, mLocalProvider));
299             }
300 
301             if (!isLocalOnly) {
302                 final Cursor cloudAlbums = queryProviderAlbums(cloudProvider, cloudMediaArgs);
303                 if (cloudAlbums != null) {
304                     // There's a bug in the Merge Cursor code (b/241096151) such that if the cursors
305                     // being merged have different projections, the data gets corrupted post IPC.
306                     // Fixing this bug requires a dessert release and will not be compatible with
307                     // android T-. Hence, we're using {@link AlbumsCursorWrapper} that unifies the
308                     // local and cloud album cursors' projections to {@link ALL_PROJECTION}
309                     cursors.add(new AlbumsCursorWrapper(cloudAlbums, cloudProvider));
310                 }
311             }
312 
313             if (cursors.isEmpty()) {
314                 return null;
315             }
316 
317             result = new MergeCursor(cursors.toArray(new Cursor[cursors.size()]));
318             result.setExtras(cursorExtra);
319             return result;
320         } finally {
321             Trace.endSection();
322             if (DEBUG) {
323                 if (result == null) {
324                     Log.d(TAG, "fetchAlbumsInternal()'s result is null");
325                 } else {
326                     Log.d(TAG, "fetchAlbumsInternal() loaded " + result.getCount() + " items");
327                     if (DEBUG_DUMP_CURSORS) {
328                         Log.v(TAG, dumpCursorToString(result));
329                     }
330                 }
331             }
332         }
333     }
334 
335     @Nullable
fetchCloudAccountInfo()336     public AccountInfo fetchCloudAccountInfo() {
337         if (DEBUG) {
338             Log.d(TAG, "fetchCloudAccountInfo()");
339             Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable());
340         }
341 
342         final String cloudProvider = mDbFacade.getCloudProvider();
343         if (cloudProvider == null) {
344             return null;
345         }
346 
347         Trace.beginSection(traceSectionName("fetchCloudAccountInfo"));
348         try {
349             return fetchCloudAccountInfoInternal(cloudProvider);
350         } catch (Exception e) {
351             Log.w(TAG, "Failed to fetch account info from cloud provider: " + cloudProvider, e);
352             return null;
353         } finally {
354             Trace.endSection();
355         }
356     }
357 
358     @Nullable
fetchCloudAccountInfoInternal(@onNull String cloudProvider)359     private AccountInfo fetchCloudAccountInfoInternal(@NonNull String cloudProvider) {
360         final Bundle accountBundle = mContext.getContentResolver()
361                 .call(getMediaCollectionInfoUri(cloudProvider), METHOD_GET_MEDIA_COLLECTION_INFO,
362                         /* arg */ null, /* extras */ new Bundle());
363         if (accountBundle == null) {
364             Log.e(TAG,
365                     "Media collection info received is null. Failed to fetch Cloud account "
366                             + "information.");
367             return null;
368         }
369         final String accountName = accountBundle.getString(ACCOUNT_NAME);
370         if (accountName == null) {
371             return null;
372         }
373         final Intent configIntent = accountBundle.getParcelable(ACCOUNT_CONFIGURATION_INTENT);
374 
375         return new AccountInfo(accountName, configIntent);
376     }
377 
queryProviderAlbums(@ullable String authority, Bundle queryArgs)378     private Cursor queryProviderAlbums(@Nullable String authority, Bundle queryArgs) {
379         if (authority == null) {
380             // Can happen if there is no cloud provider
381             return null;
382         }
383 
384         Trace.beginSection(traceSectionName("queryProviderAlbums", authority));
385         try {
386             return queryProviderAlbumsInternal(authority, queryArgs);
387         } finally {
388             Trace.endSection();
389         }
390     }
391 
queryProviderAlbumsInternal(@onNull String authority, Bundle queryArgs)392     private Cursor queryProviderAlbumsInternal(@NonNull String authority, Bundle queryArgs) {
393         final InstanceId instanceId = NonUiEventLogger.generateInstanceId();
394         int numberOfAlbumsFetched = -1;
395         NonUiEventLogger.logPickerGetAlbumsStart(instanceId, MY_UID, authority);
396         try {
397             final Cursor res = mContext.getContentResolver().query(getAlbumUri(authority),
398                     /* projection */ null, queryArgs, /* cancellationSignal */ null);
399             if (res != null) {
400                 numberOfAlbumsFetched = res.getCount();
401             }
402             return res;
403         } catch (Exception e) {
404             Log.w(TAG, "Failed to fetch cloud albums for: " + authority, e);
405             return null;
406         } finally {
407             NonUiEventLogger.logPickerGetAlbumsEnd(instanceId, MY_UID, authority,
408                     numberOfAlbumsFetched);
409         }
410     }
411 
isLocal(String authority)412     private boolean isLocal(String authority) {
413         return mLocalProvider.equals(authority);
414     }
415 
traceSectionName(@onNull String method)416     private String traceSectionName(@NonNull String method) {
417         return traceSectionName(method, null);
418     }
419 
traceSectionName(@onNull String method, @Nullable String authority)420     private String traceSectionName(@NonNull String method, @Nullable String authority) {
421         final StringBuilder sb = new StringBuilder("PDL.")
422                 .append(method);
423         if (authority != null) {
424             sb.append('[').append(isLocal(authority) ? "local" : "cloud").append(']');
425         }
426         return sb.toString();
427     }
428 
429     /**
430      * Triggers a sync operation based on the parameters.
431      */
initMediaData(@onNull PickerSyncRequestExtras syncRequestExtras)432     public void initMediaData(@NonNull PickerSyncRequestExtras syncRequestExtras) {
433         if (syncRequestExtras.shouldSyncMediaData()) {
434             // Sync media data
435             Log.i(TAG, "Init data request for the main photo grid i.e. media data."
436                     + " Should sync with local provider only: "
437                     + syncRequestExtras.shouldSyncLocalOnlyData());
438 
439             mSyncManager.syncMediaImmediately(syncRequestExtras, mConfigStore);
440         } else {
441             // Sync album media data
442             Log.i(TAG, String.format("Init data request for album content of: %s"
443                             + " Should sync with local provider only: %b",
444                     syncRequestExtras.getAlbumId(),
445                     syncRequestExtras.shouldSyncLocalOnlyData()));
446 
447             validateAlbumMediaSyncArgs(syncRequestExtras);
448 
449             // We don't need to sync in case of merged albums
450             if (!syncRequestExtras.shouldSyncMergedAlbum()) {
451                 mSyncManager.syncAlbumMediaForProviderImmediately(
452                         syncRequestExtras.getAlbumId(),
453                         syncRequestExtras.getAlbumAuthority(),
454                         isLocal(syncRequestExtras.getAlbumAuthority()));
455             }
456         }
457     }
458 
validateAlbumMediaSyncArgs(PickerSyncRequestExtras syncRequestExtras)459     private void validateAlbumMediaSyncArgs(PickerSyncRequestExtras syncRequestExtras) {
460         if (!syncRequestExtras.shouldSyncMediaData()) {
461             Objects.requireNonNull(syncRequestExtras.getAlbumId(),
462                     "Album Id can't be null for an album sync request.");
463             Objects.requireNonNull(syncRequestExtras.getAlbumAuthority(),
464                     "Album authority can't be null for an album sync request.");
465         }
466         if (!syncRequestExtras.shouldSyncMediaData()
467                 && !syncRequestExtras.shouldSyncMergedAlbum()
468                 && syncRequestExtras.shouldSyncLocalOnlyData()
469                 && !isLocal(syncRequestExtras.getAlbumAuthority())) {
470             throw new IllegalStateException(
471                     "Can't exclude cloud contents in cloud album "
472                             + syncRequestExtras.getAlbumAuthority());
473         }
474     }
475 
476 
477     /**
478      * Handles notification about media events like inserts/updates/deletes received from cloud or
479      * local providers.
480      * @param localOnly True if the media event is coming from the local provider, otherwise false.
481      * @param authority Authority of the media event notification sender.
482      * @param extras Bundle containing additional arguments.
483      */
handleMediaEventNotification( boolean localOnly, @NonNull String authority, @Nullable Bundle extras)484     public void handleMediaEventNotification(
485             boolean localOnly,
486             @NonNull String authority,
487             @Nullable Bundle extras) {
488         try {
489             requireNonNull(authority);
490             mSyncManager.syncMediaProactively(localOnly);
491 
492             final String mediaCollectionId =
493                     (extras == null)
494                             ? null
495                             : extras.getString(EXTRA_MEDIA_COLLECTION_ID);
496             mSyncController.handleMediaEventNotification(localOnly, authority, mediaCollectionId);
497         } catch (RuntimeException e) {
498             // Catch any unchecked exceptions so that critical paths in MP that call this method are
499             // not affected by Picker related issues.
500             Log.e(TAG, "Could not handle media event notification ", e);
501         }
502     }
503 
504     public static class AccountInfo {
505         public final String accountName;
506         public final Intent accountConfigurationIntent;
507 
AccountInfo(String accountName, Intent accountConfigurationIntent)508         public AccountInfo(String accountName, Intent accountConfigurationIntent) {
509             this.accountName = accountName;
510             this.accountConfigurationIntent = accountConfigurationIntent;
511         }
512     }
513 
514     /**
515      * A {@link CursorWrapper} that exposes the data stored in the underlying {@link Cursor} in the
516      * {@link ALL_PROJECTION} "format", additionally overriding the {@link AUTHORITY} column.
517      * Columns from the underlying that are not in the {@link ALL_PROJECTION} are ignored.
518      * Missing columns (except {@link AUTHORITY}) are set with default value of {@code null}.
519      */
520     private static class AlbumsCursorWrapper extends CursorWrapper {
521         static final String TAG = "AlbumsCursorWrapper";
522 
523         @NonNull static final Map<String, Integer> COLUMN_NAME_TO_INDEX_MAP;
524         static final int AUTHORITY_COLUMN_INDEX;
525 
526         static {
527             final Map<String, Integer> map = new HashMap<>();
528             for (int columnIndex = 0; columnIndex < ALL_PROJECTION.length; columnIndex++) {
map.put(ALL_PROJECTION[columnIndex], columnIndex)529                 map.put(ALL_PROJECTION[columnIndex], columnIndex);
530             }
531             COLUMN_NAME_TO_INDEX_MAP = map;
532             AUTHORITY_COLUMN_INDEX = map.get(AUTHORITY);
533         }
534 
535         @NonNull final String mAuthority;
536         @NonNull final int[] mColumnIndexToCursorColumnIndexArray;
537 
538         boolean mAuthorityMismatchLogged = false;
539 
AlbumsCursorWrapper(@onNull Cursor cursor, @NonNull String authority)540         AlbumsCursorWrapper(@NonNull Cursor cursor, @NonNull String authority) {
541             super(requireNonNull(cursor));
542             mAuthority = requireNonNull(authority);
543 
544             mColumnIndexToCursorColumnIndexArray = new int[ALL_PROJECTION.length];
545             for (int columnIndex = 0; columnIndex < ALL_PROJECTION.length; columnIndex++) {
546                 final String columnName = ALL_PROJECTION[columnIndex];
547                 final int cursorColumnIndex = cursor.getColumnIndex(columnName);
548                 mColumnIndexToCursorColumnIndexArray[columnIndex] = cursorColumnIndex;
549             }
550         }
551 
552         @Override
getColumnCount()553         public int getColumnCount() {
554             return ALL_PROJECTION.length;
555         }
556 
557         @Override
getColumnIndex(String columnName)558         public int getColumnIndex(String columnName) {
559             return COLUMN_NAME_TO_INDEX_MAP.get(columnName);
560         }
561 
562         @Override
getColumnIndexOrThrow(String columnName)563         public int getColumnIndexOrThrow(String columnName)
564                 throws IllegalArgumentException {
565             final int columnIndex = getColumnIndex(columnName);
566             if (columnIndex < 0) {
567                 throw new IllegalArgumentException("column '" + columnName
568                         + "' does not exist. Available columns: "
569                         + Arrays.toString(getColumnNames()));
570             }
571             return columnIndex;
572         }
573 
574         @Override
getColumnName(int columnIndex)575         public String getColumnName(int columnIndex) {
576             return ALL_PROJECTION[columnIndex];
577         }
578 
579         @Override
getColumnNames()580         public String[] getColumnNames() {
581             return ALL_PROJECTION;
582         }
583 
584         @Override
getString(int columnIndex)585         public String getString(int columnIndex) {
586             // 1. Get value from the underlying cursor.
587             final int cursorColumnIndex = mColumnIndexToCursorColumnIndexArray[columnIndex];
588             final String cursorValue = cursorColumnIndex != -1
589                     ? getWrappedCursor().getString(cursorColumnIndex) : null;
590 
591             // 2a. If this is NOT the AUTHORITY column: just return the value.
592             if (columnIndex != AUTHORITY_COLUMN_INDEX) {
593                 return cursorValue;
594             }
595 
596             // Validity check: the cursor's authority value, if present, is expected to match the
597             // mAuthority. Don't throw though, just log (at WARN). Also, only log once for the
598             // cursor (we don't need 10,000 of these lines in the log).
599             if (!mAuthorityMismatchLogged
600                     && cursorValue != null && !cursorValue.equals(mAuthority)) {
601                 Log.w(TAG, "Cursor authority - '" + cursorValue + "' - is different from the "
602                         + "expected authority '" + mAuthority + "'");
603                 mAuthorityMismatchLogged = true;
604             }
605 
606             // 2b. If this IS the AUTHORITY column: "override" whatever value (which may be null)
607             // is stored in the cursor.
608             return mAuthority;
609         }
610 
611         @Override
getType(int columnIndex)612         public int getType(int columnIndex) {
613             // 1. Get value from the underlying cursor.
614             final int cursorColumnIndex = mColumnIndexToCursorColumnIndexArray[columnIndex];
615             final int cursorValue = cursorColumnIndex != -1
616                     ? getWrappedCursor().getType(cursorColumnIndex) : Cursor.FIELD_TYPE_NULL;
617 
618             // 2a. If this is NOT the AUTHORITY column: just return the value.
619             if (columnIndex != AUTHORITY_COLUMN_INDEX) {
620                 return cursorValue;
621             }
622 
623             // 2b. If this IS the AUTHORITY column: "override" whatever value (which may be 0)
624             // is stored in the cursor.
625             return Cursor.FIELD_TYPE_STRING;
626         }
627     }
628 
629     /**
630      * For cloud feature enabled scenarios, sync request is sent from the
631      * MediaStore.PICKER_MEDIA_INIT_CALL method call once when a fresh grid needs to be filled
632      * populated data. This is because UI paginated queries are supported when cloud feature
633      * enabled. This avoids triggering a sync for the same dataset for each paged query received
634      * from the UI.
635      */
shouldSyncBeforePickerQuery()636     private boolean shouldSyncBeforePickerQuery() {
637         return !mConfigStore.isCloudMediaInPhotoPickerEnabled();
638     }
639 
640     /**
641      * Checks the current allowed list of Cloud Provider packages, and ensures that the currently
642      * set provider is a member of the allowlist. In the event the current Cloud Provider is not on
643      * the list, the current Cloud Provider is removed.
644      */
validateCurrentCloudProviderOnAllowlistChange()645     private void validateCurrentCloudProviderOnAllowlistChange() {
646 
647         List<String> currentAllowlist = mConfigStore.getAllowedCloudProviderPackages();
648         String currentCloudProvider = mSyncController.getCurrentCloudProviderInfo().packageName;
649 
650         if (!currentAllowlist.contains(currentCloudProvider)) {
651             Log.d(
652                     TAG,
653                     String.format(
654                             "Cloud provider allowlist was changed, and the current cloud provider"
655                                     + " is no longer on the allowlist."
656                                     + " Allowlist: %s"
657                                     + " Current Provider: %s",
658                             currentAllowlist.toString(), currentCloudProvider));
659             mSyncController.notifyPackageRemoval(currentCloudProvider);
660         }
661     }
662 }
663