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