• 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.content.ContentResolver.EXTRA_HONORED_ARGS;
20 import static android.provider.CloudMediaProviderContract.EXTRA_ALBUM_ID;
21 import static android.provider.CloudMediaProviderContract.EXTRA_MEDIA_COLLECTION_ID;
22 import static android.provider.CloudMediaProviderContract.EXTRA_PAGE_TOKEN;
23 import static android.provider.CloudMediaProviderContract.EXTRA_SYNC_GENERATION;
24 import static android.provider.CloudMediaProviderContract.MediaCollectionInfo;
25 
26 import static com.android.providers.media.PickerUriResolver.getDeletedMediaUri;
27 import static com.android.providers.media.PickerUriResolver.getMediaCollectionInfoUri;
28 import static com.android.providers.media.PickerUriResolver.getMediaUri;
29 
30 import android.annotation.IntDef;
31 import android.content.Context;
32 import android.content.Intent;
33 import android.content.SharedPreferences;
34 import android.content.pm.ApplicationInfo;
35 import android.content.pm.PackageManager;
36 import android.content.pm.PackageManager.NameNotFoundException;
37 import android.content.pm.ProviderInfo;
38 import android.content.pm.ResolveInfo;
39 import android.database.Cursor;
40 import android.net.Uri;
41 import android.os.Bundle;
42 import android.os.Process;
43 import android.os.storage.StorageManager;
44 import android.provider.CloudMediaProvider;
45 import android.provider.CloudMediaProviderContract;
46 import android.text.TextUtils;
47 import android.util.ArraySet;
48 import android.util.Log;
49 import android.widget.Toast;
50 
51 import androidx.annotation.GuardedBy;
52 import androidx.annotation.VisibleForTesting;
53 
54 import com.android.modules.utils.BackgroundThread;
55 import com.android.modules.utils.build.SdkLevel;
56 import com.android.providers.media.R;
57 import com.android.providers.media.photopicker.data.PickerDbFacade;
58 import com.android.providers.media.util.ForegroundThread;
59 import com.android.providers.media.util.StringUtils;
60 
61 import java.lang.annotation.Retention;
62 import java.lang.annotation.RetentionPolicy;
63 import java.util.ArrayList;
64 import java.util.Arrays;
65 import java.util.List;
66 import java.util.Objects;
67 import java.util.Set;
68 
69 /**
70  * Syncs the local and currently enabled cloud {@link CloudMediaProvider} instances on the device
71  * into the picker db.
72  */
73 public class PickerSyncController {
74     private static final String TAG = "PickerSyncController";
75 
76     public static final String SYNC_DELAY_MS = "default_sync_delay_ms";
77     public static final String ALLOWED_CLOUD_PROVIDERS_KEY = "allowed_cloud_providers";
78 
79     private static final String PREFS_KEY_CLOUD_PROVIDER_AUTHORITY = "cloud_provider_authority";
80     private static final String PREFS_KEY_CLOUD_PROVIDER_PENDING_NOTIFICATTION =
81             "cloud_provider_pending_notification";
82     private static final String PREFS_KEY_CLOUD_PREFIX = "cloud_provider:";
83     private static final String PREFS_KEY_LOCAL_PREFIX = "local_provider:";
84 
85     private static final String PICKER_USER_PREFS_FILE_NAME = "picker_user_prefs";
86     public static final String PICKER_SYNC_PREFS_FILE_NAME = "picker_sync_prefs";
87     public static final String LOCAL_PICKER_PROVIDER_AUTHORITY =
88             "com.android.providers.media.photopicker";
89 
90     private static final int SYNC_TYPE_NONE = 0;
91     private static final int SYNC_TYPE_MEDIA_INCREMENTAL = 1;
92     private static final int SYNC_TYPE_MEDIA_FULL = 2;
93     private static final int SYNC_TYPE_MEDIA_RESET = 3;
94 
95     @IntDef(flag = false, prefix = { "SYNC_TYPE_" }, value = {
96                 SYNC_TYPE_NONE,
97             SYNC_TYPE_MEDIA_INCREMENTAL,
98             SYNC_TYPE_MEDIA_FULL,
99             SYNC_TYPE_MEDIA_RESET,
100     })
101     @Retention(RetentionPolicy.SOURCE)
102     private @interface SyncType {}
103 
104     private final Object mLock = new Object();
105     private final PickerDbFacade mDbFacade;
106     private final Context mContext;
107     private final SharedPreferences mSyncPrefs;
108     private final SharedPreferences mUserPrefs;
109     private final String mLocalProvider;
110     private final long mSyncDelayMs;
111     private final Runnable mSyncAllMediaCallback;
112     private final Set<String> mAllowedCloudProviders;
113 
114     @GuardedBy("mLock")
115     private CloudProviderInfo mCloudProviderInfo;
116 
PickerSyncController(Context context, PickerDbFacade dbFacade, String localProvider, String allowedCloudProviders, long syncDelayMs)117     public PickerSyncController(Context context, PickerDbFacade dbFacade,
118             String localProvider, String allowedCloudProviders, long syncDelayMs) {
119         mContext = context;
120         mSyncPrefs = mContext.getSharedPreferences(PICKER_SYNC_PREFS_FILE_NAME,
121                 Context.MODE_PRIVATE);
122         mUserPrefs = mContext.getSharedPreferences(PICKER_USER_PREFS_FILE_NAME,
123                 Context.MODE_PRIVATE);
124         mDbFacade = dbFacade;
125         mLocalProvider = localProvider;
126         mSyncDelayMs = syncDelayMs;
127         mSyncAllMediaCallback = this::syncAllMedia;
128 
129         final String cachedAuthority = mUserPrefs.getString(
130                 PREFS_KEY_CLOUD_PROVIDER_AUTHORITY, null);
131 
132         mAllowedCloudProviders = parseAllowedCloudProviders(allowedCloudProviders);
133 
134         final CloudProviderInfo defaultInfo = getDefaultCloudProviderInfo(cachedAuthority);
135 
136         if (Objects.equals(defaultInfo.authority, cachedAuthority)) {
137             // Just set it without persisting since it's not changing and persisting would
138             // notify the user that cloud media is now available
139             mCloudProviderInfo = defaultInfo;
140         } else {
141             // Persist it so that we notify the user that cloud media is now available
142             persistCloudProviderInfo(defaultInfo);
143         }
144 
145         Log.d(TAG, "Initialized cloud provider to: " + mCloudProviderInfo.authority);
146     }
147 
148     /**
149      * Syncs the local and currently enabled cloud {@link CloudMediaProvider} instances
150      */
syncAllMedia()151     public void syncAllMedia() {
152         syncAllMediaFromProvider(mLocalProvider, /* retryOnFailure */ true);
153 
154         synchronized (mLock) {
155             final String cloudProvider = mCloudProviderInfo.authority;
156 
157             syncAllMediaFromProvider(cloudProvider, /* retryOnFailure */ true);
158 
159             // Reset the album_media table every time we sync all media
160             resetAlbumMedia();
161 
162             // Set the latest cloud provider on the facade
163             mDbFacade.setCloudProvider(cloudProvider);
164         }
165     }
166 
167     /**
168      * Syncs album media from the local and currently enabled cloud {@link CloudMediaProvider}
169      * instances
170      */
syncAlbumMedia(String albumId, boolean isLocal)171     public void syncAlbumMedia(String albumId, boolean isLocal) {
172         if (isLocal) {
173             syncAlbumMediaFromProvider(mLocalProvider, albumId);
174         } else {
175             synchronized (mLock) {
176                 syncAlbumMediaFromProvider(mCloudProviderInfo.authority, albumId);
177             }
178         }
179     }
180 
resetAlbumMedia()181     private void resetAlbumMedia() {
182         executeSyncAlbumReset(mLocalProvider, /* albumId */ null);
183 
184         synchronized (mLock) {
185             final String cloudProvider = mCloudProviderInfo.authority;
186             executeSyncAlbumReset(cloudProvider, /* albumId */ null);
187         }
188     }
189 
resetAllMedia(String authority)190     private void resetAllMedia(String authority) {
191         executeSyncReset(authority);
192         resetCachedMediaCollectionInfo(authority);
193     }
194 
195     /**
196      * Returns the supported cloud {@link CloudMediaProvider} infos.
197      */
getCloudProviderInfo(String authority)198     public CloudProviderInfo getCloudProviderInfo(String authority) {
199         for (CloudProviderInfo info : getSupportedCloudProviders(/* ignoreAllowList */ false)) {
200             if (info.authority.equals(authority)) {
201                 return info;
202             }
203         }
204 
205         return CloudProviderInfo.EMPTY;
206     }
207 
208     /**
209      * Returns the supported cloud {@link CloudMediaProvider} authorities.
210      */
211     @VisibleForTesting
getSupportedCloudProviders()212     List<CloudProviderInfo> getSupportedCloudProviders() {
213         return getSupportedCloudProviders(/* ignoreAllowList */ false);
214     }
215 
getSupportedCloudProviders(boolean ignoreAllowList)216     private List<CloudProviderInfo> getSupportedCloudProviders(boolean ignoreAllowList) {
217         final List<CloudProviderInfo> result = new ArrayList<>();
218 
219         final PackageManager pm = mContext.getPackageManager();
220         final Intent intent = new Intent(CloudMediaProviderContract.PROVIDER_INTERFACE);
221         final List<ResolveInfo> providers = pm.queryIntentContentProviders(intent, /* flags */ 0);
222 
223         for (ResolveInfo info : providers) {
224             ProviderInfo providerInfo = info.providerInfo;
225             if (providerInfo.authority != null
226                     && CloudMediaProviderContract.MANAGE_CLOUD_MEDIA_PROVIDERS_PERMISSION.equals(
227                             providerInfo.readPermission)
228                     && (ignoreAllowList
229                             || mAllowedCloudProviders.contains(providerInfo.authority))) {
230                 result.add(new CloudProviderInfo(providerInfo.authority,
231                                 providerInfo.applicationInfo.packageName,
232                                 providerInfo.applicationInfo.uid));
233             }
234         }
235 
236         return result;
237     }
238 
239     /**
240      * Enables a provider with {@code authority} as the default cloud {@link CloudMediaProvider}.
241      * If {@code authority} is set to {@code null}, it simply clears the cloud provider.
242      *
243      * Note, that this doesn't sync the new provider after switching, however, no cloud items will
244      * be available from the picker db until the next sync. Callers should schedule a sync in the
245      * background after switching providers.
246      *
247      * @return {@code true} if the provider was successfully enabled or cleared, {@code false}
248      * otherwise
249      */
setCloudProvider(String authority)250     public boolean setCloudProvider(String authority) {
251         synchronized (mLock) {
252             if (Objects.equals(mCloudProviderInfo.authority, authority)) {
253                 Log.w(TAG, "Cloud provider already set: " + authority);
254                 return true;
255             }
256         }
257 
258         final CloudProviderInfo newProviderInfo = getCloudProviderInfo(authority);
259         if (authority == null || !newProviderInfo.isEmpty()) {
260             synchronized (mLock) {
261                 final String oldAuthority = mCloudProviderInfo.authority;
262                 persistCloudProviderInfo(newProviderInfo);
263                 resetCachedMediaCollectionInfo(newProviderInfo.authority);
264 
265                 // Disable cloud provider queries on the db until next sync
266                 // This will temporarily *clear* the cloud provider on the db facade and prevent
267                 // any queries from seeing cloud media until a sync where the cloud provider will be
268                 // reset on the facade
269                 mDbFacade.setCloudProvider(null);
270 
271                 Log.i(TAG, "Cloud provider changed successfully. Old: "
272                         + oldAuthority + ". New: " + newProviderInfo.authority);
273             }
274 
275             return true;
276         }
277 
278         Log.w(TAG, "Cloud provider not supported: " + authority);
279         return false;
280     }
281 
282     /**
283      * Set cloud provider and update allowed cloud providers
284      */
285     @VisibleForTesting
forceSetCloudProvider(String authority)286     public void forceSetCloudProvider(String authority) {
287         if (authority == null) {
288             mAllowedCloudProviders.clear();
289         } else {
290             mAllowedCloudProviders.add(authority);
291         }
292 
293         setCloudProvider(authority);
294     }
295 
getCloudProvider()296     public String getCloudProvider() {
297         synchronized (mLock) {
298             return mCloudProviderInfo.authority;
299         }
300     }
301 
getLocalProvider()302     public String getLocalProvider() {
303         return mLocalProvider;
304     }
305 
isProviderEnabled(String authority)306     public boolean isProviderEnabled(String authority) {
307         if (mLocalProvider.equals(authority)) {
308             return true;
309         }
310 
311         synchronized (mLock) {
312             if (!mCloudProviderInfo.isEmpty() && mCloudProviderInfo.authority.equals(authority)) {
313                 return true;
314             }
315         }
316 
317         return false;
318     }
319 
isProviderEnabled(String authority, int uid)320     public boolean isProviderEnabled(String authority, int uid) {
321         if (uid == Process.myUid() && mLocalProvider.equals(authority)) {
322             return true;
323         }
324 
325         synchronized (mLock) {
326             if (!mCloudProviderInfo.isEmpty() && uid == mCloudProviderInfo.uid
327                     && mCloudProviderInfo.authority.equals(authority)) {
328                 return true;
329             }
330         }
331 
332         return false;
333     }
334 
isProviderSupported(String authority, int uid)335     public boolean isProviderSupported(String authority, int uid) {
336         if (uid == Process.myUid() && mLocalProvider.equals(authority)) {
337             return true;
338         }
339 
340         // TODO(b/232738117): Enforce allow list here. This works around some CTS failure late in
341         // Android T. The current implementation is fine since cloud providers is only supported
342         // for app developers testing.
343         final List<CloudProviderInfo> infos = getSupportedCloudProviders(
344                 /* ignoreAllowList */ true);
345         for (CloudProviderInfo info : infos) {
346             if (info.uid == uid && info.authority.equals(authority)) {
347                 return true;
348             }
349         }
350 
351         return false;
352     }
353 
354     /**
355      * Notifies about media events like inserts/updates/deletes from cloud and local providers and
356      * syncs the changes in the background.
357      *
358      * There is a delay before executing the background sync to artificially throttle the burst
359      * notifications.
360      */
notifyMediaEvent()361     public void notifyMediaEvent() {
362         BackgroundThread.getHandler().removeCallbacks(mSyncAllMediaCallback);
363         BackgroundThread.getHandler().postDelayed(mSyncAllMediaCallback, mSyncDelayMs);
364     }
365 
366     /**
367      * Notifies about package removal
368      */
notifyPackageRemoval(String packageName)369     public void notifyPackageRemoval(String packageName) {
370         synchronized (mLock) {
371             if (mCloudProviderInfo.matches(packageName)) {
372                 Log.i(TAG, "Package " + packageName
373                         + " is the current cloud provider and got removed");
374                 setCloudProvider(null);
375             }
376         }
377     }
378 
379     /**
380      * Notifies about picker UI launched
381      */
notifyPickerLaunch()382     public void notifyPickerLaunch() {
383         final String packageName;
384         synchronized (mLock) {
385             packageName = mCloudProviderInfo.packageName;
386         }
387 
388         final boolean hasPendingNotification = mUserPrefs.getBoolean(
389                 PREFS_KEY_CLOUD_PROVIDER_PENDING_NOTIFICATTION, false);
390 
391         if (!hasPendingNotification || (packageName == null)) {
392             Log.d(TAG, "No pending UI notification");
393             return;
394         }
395 
396         // Offload showing the UI on a fg thread to avoid the expensive binder request
397         // to fetch the app name blocking the picker launch
398         ForegroundThread.getHandler().post(() -> {
399             Log.i(TAG, "Cloud media now available in the picker");
400 
401             final PackageManager pm = mContext.getPackageManager();
402             String appName = packageName;
403             try {
404                 ApplicationInfo appInfo = pm.getApplicationInfo(packageName, 0);
405                 appName = (String) pm.getApplicationLabel(appInfo);
406             } catch (final NameNotFoundException e) {
407                 Log.i(TAG, "Failed to get appName for package: " + packageName);
408             }
409 
410             final String message = mContext.getResources().getString(R.string.picker_cloud_sync,
411                     appName);
412             Toast.makeText(mContext, message, Toast.LENGTH_LONG).show();
413         });
414 
415         // Clear the notification
416         final SharedPreferences.Editor editor = mUserPrefs.edit();
417         editor.putBoolean(PREFS_KEY_CLOUD_PROVIDER_PENDING_NOTIFICATTION, false);
418         editor.apply();
419     }
420 
syncAlbumMediaFromProvider(String authority, String albumId)421     private void syncAlbumMediaFromProvider(String authority, String albumId) {
422         final Bundle queryArgs = new Bundle();
423         queryArgs.putString(EXTRA_ALBUM_ID, albumId);
424 
425         try {
426             executeSyncAlbumReset(authority, albumId);
427 
428             if (authority != null) {
429                 executeSyncAddAlbum(authority, albumId, queryArgs);
430             }
431         } catch (RuntimeException e) {
432             // Unlike syncAllMediaFromProvider, we don't retry here because any errors would have
433             // occurred in fetching all the album_media since incremental sync is not supported.
434             // A full sync is therefore unlikely to resolve any issue
435             Log.e(TAG, "Failed to sync album media", e);
436         }
437     }
438 
syncAllMediaFromProvider(String authority, boolean retryOnFailure)439     private void syncAllMediaFromProvider(String authority, boolean retryOnFailure) {
440         try {
441             final SyncRequestParams params = getSyncRequestParams(authority);
442 
443             switch (params.syncType) {
444                 case SYNC_TYPE_MEDIA_RESET:
445                     // Can only happen when |authority| has been set to null and we need to clean up
446                     resetAllMedia(authority);
447                     break;
448                 case SYNC_TYPE_MEDIA_FULL:
449                     resetAllMedia(authority);
450 
451                     // Pass a mutable empty bundle intentionally because it might be populated with
452                     // the next page token as part of a query to a cloud provider supporting
453                     // pagination
454                     executeSyncAdd(authority, params.getMediaCollectionId(),
455                             /* isIncrementalSync */ false, /* queryArgs */ new Bundle());
456 
457                     // Commit sync position
458                     cacheMediaCollectionInfo(authority, params.latestMediaCollectionInfo);
459                     break;
460                 case SYNC_TYPE_MEDIA_INCREMENTAL:
461                     final Bundle queryArgs = new Bundle();
462                     queryArgs.putLong(EXTRA_SYNC_GENERATION, params.syncGeneration);
463 
464                     executeSyncAdd(authority, params.getMediaCollectionId(),
465                             /* isIncrementalSync */ true, queryArgs);
466                     executeSyncRemove(authority, params.getMediaCollectionId(), queryArgs);
467 
468                     // Commit sync position
469                     cacheMediaCollectionInfo(authority, params.latestMediaCollectionInfo);
470                     break;
471                 case SYNC_TYPE_NONE:
472                     break;
473                 default:
474                     throw new IllegalArgumentException("Unexpected sync type: " + params.syncType);
475             }
476         } catch (RuntimeException e) {
477             // Reset all media for the cloud provider in case it never succeeds
478             resetAllMedia(authority);
479 
480             // Attempt a full sync. If this fails, the db table would have been reset,
481             // flushing all old content and leaving the picker UI empty.
482             Log.e(TAG, "Failed to sync all media. Reset media and retry: " + retryOnFailure, e);
483             if (retryOnFailure) {
484                 syncAllMediaFromProvider(authority, /* retryOnFailure */ false);
485             }
486         }
487     }
488 
executeSyncReset(String authority)489     private void executeSyncReset(String authority) {
490         Log.i(TAG, "Executing SyncReset. authority: " + authority);
491 
492         try (PickerDbFacade.DbWriteOperation operation =
493                      mDbFacade.beginResetMediaOperation(authority)) {
494             final int writeCount = operation.execute(null /* cursor */);
495             operation.setSuccess();
496 
497             Log.i(TAG, "SyncReset. Authority: " + authority +  ". Result count: " + writeCount);
498         }
499     }
500 
executeSyncAlbumReset(String authority, String albumId)501     private void executeSyncAlbumReset(String authority, String albumId) {
502         Log.i(TAG, "Executing SyncAlbumReset. authority: " + authority + ". albumId: "
503                 + albumId);
504 
505         try (PickerDbFacade.DbWriteOperation operation =
506                      mDbFacade.beginResetAlbumMediaOperation(authority, albumId)) {
507             final int writeCount = operation.execute(null /* cursor */);
508             operation.setSuccess();
509 
510             Log.i(TAG, "Successfully executed SyncResetAlbum. authority: " + authority
511                     + ". albumId: " + albumId + ". Result count: " + writeCount);
512         }
513     }
514 
executeSyncAdd(String authority, String expectedMediaCollectionId, boolean isIncrementalSync, Bundle queryArgs)515     private void executeSyncAdd(String authority, String expectedMediaCollectionId,
516             boolean isIncrementalSync, Bundle queryArgs) {
517         final Uri uri = getMediaUri(authority);
518         final List<String> expectedHonoredArgs = new ArrayList<>();
519         if (isIncrementalSync) {
520             expectedHonoredArgs.add(EXTRA_SYNC_GENERATION);
521         }
522 
523         Log.i(TAG, "Executing SyncAdd. authority: " + authority);
524         try (PickerDbFacade.DbWriteOperation operation =
525                      mDbFacade.beginAddMediaOperation(authority)) {
526             executePagedSync(uri, expectedMediaCollectionId, expectedHonoredArgs, queryArgs,
527                     operation);
528         }
529     }
530 
executeSyncAddAlbum(String authority, String albumId, Bundle queryArgs)531     private void executeSyncAddAlbum(String authority, String albumId, Bundle queryArgs) {
532         final Uri uri = getMediaUri(authority);
533 
534         Log.i(TAG, "Executing SyncAddAlbum. authority: " + authority + ". albumId: " + albumId);
535         try (PickerDbFacade.DbWriteOperation operation =
536                      mDbFacade.beginAddAlbumMediaOperation(authority, albumId)) {
537 
538             // We don't need to validate the mediaCollectionId for album_media sync since it's
539             // always a full sync
540             executePagedSync(uri, /* mediaCollectionId */ null, Arrays.asList(EXTRA_ALBUM_ID),
541                     queryArgs, operation);
542         }
543     }
544 
executeSyncRemove(String authority, String mediaCollectionId, Bundle queryArgs)545     private void executeSyncRemove(String authority, String mediaCollectionId, Bundle queryArgs) {
546         final Uri uri = getDeletedMediaUri(authority);
547 
548         Log.i(TAG, "Executing SyncRemove. authority: " + authority);
549         try (PickerDbFacade.DbWriteOperation operation =
550                      mDbFacade.beginRemoveMediaOperation(authority)) {
551             executePagedSync(uri, mediaCollectionId, Arrays.asList(EXTRA_SYNC_GENERATION),
552                     queryArgs, operation);
553         }
554     }
555 
persistCloudProviderInfo(CloudProviderInfo info)556     private void persistCloudProviderInfo(CloudProviderInfo info) {
557         synchronized (mLock) {
558             mCloudProviderInfo = info;
559         }
560 
561         final String authority = info.authority;
562         final SharedPreferences.Editor editor = mUserPrefs.edit();
563 
564         if (info.isEmpty()) {
565             editor.remove(PREFS_KEY_CLOUD_PROVIDER_AUTHORITY);
566             editor.putBoolean(PREFS_KEY_CLOUD_PROVIDER_PENDING_NOTIFICATTION, false);
567         } else {
568             editor.putString(PREFS_KEY_CLOUD_PROVIDER_AUTHORITY, authority);
569             editor.putBoolean(PREFS_KEY_CLOUD_PROVIDER_PENDING_NOTIFICATTION, true);
570         }
571 
572         editor.apply();
573 
574         if (SdkLevel.isAtLeastT()) {
575             try {
576                 StorageManager sm = mContext.getSystemService(StorageManager.class);
577                 sm.setCloudMediaProvider(authority);
578             } catch (SecurityException e) {
579                 // When run as part of the unit tests, the notification fails because only the
580                 // MediaProvider uid can notify
581                 Log.w(TAG, "Failed to notify the system of cloud provider update to: " + authority);
582             }
583         }
584 
585         Log.d(TAG, "Updated cloud provider to: " + authority);
586     }
587 
cacheMediaCollectionInfo(String authority, Bundle bundle)588     private void cacheMediaCollectionInfo(String authority, Bundle bundle) {
589         if (authority == null) {
590             Log.d(TAG, "Ignoring cache media info for null authority with bundle: " + bundle);
591             return;
592         }
593 
594         final SharedPreferences.Editor editor = mSyncPrefs.edit();
595 
596         if (bundle == null) {
597             editor.remove(getPrefsKey(authority, MediaCollectionInfo.MEDIA_COLLECTION_ID));
598             editor.remove(getPrefsKey(authority, MediaCollectionInfo.LAST_MEDIA_SYNC_GENERATION));
599         } else {
600             final String collectionId = bundle.getString(MediaCollectionInfo.MEDIA_COLLECTION_ID);
601             final long generation = bundle.getLong(
602                     MediaCollectionInfo.LAST_MEDIA_SYNC_GENERATION);
603 
604             editor.putString(getPrefsKey(authority, MediaCollectionInfo.MEDIA_COLLECTION_ID),
605                     collectionId);
606             editor.putLong(getPrefsKey(authority, MediaCollectionInfo.LAST_MEDIA_SYNC_GENERATION),
607                     generation);
608         }
609 
610         editor.apply();
611     }
612 
resetCachedMediaCollectionInfo(String authority)613     private void resetCachedMediaCollectionInfo(String authority) {
614         cacheMediaCollectionInfo(authority, /* bundle */ null);
615     }
616 
getCachedMediaCollectionInfo(String authority)617     private Bundle getCachedMediaCollectionInfo(String authority) {
618         final Bundle bundle = new Bundle();
619 
620         final String collectionId = mSyncPrefs.getString(
621                 getPrefsKey(authority, MediaCollectionInfo.MEDIA_COLLECTION_ID),
622                 /* default */ null);
623         final long generation = mSyncPrefs.getLong(
624                 getPrefsKey(authority, MediaCollectionInfo.LAST_MEDIA_SYNC_GENERATION),
625                 /* default */ -1);
626 
627         bundle.putString(MediaCollectionInfo.MEDIA_COLLECTION_ID, collectionId);
628         bundle.putLong(MediaCollectionInfo.LAST_MEDIA_SYNC_GENERATION, generation);
629 
630         return bundle;
631     }
632 
getLatestMediaCollectionInfo(String authority)633     private Bundle getLatestMediaCollectionInfo(String authority) {
634         return mContext.getContentResolver().call(getMediaCollectionInfoUri(authority),
635                 CloudMediaProviderContract.METHOD_GET_MEDIA_COLLECTION_INFO, /* arg */ null,
636                 /* extras */ null);
637     }
638 
639     @SyncType
getSyncRequestParams(String authority)640     private SyncRequestParams getSyncRequestParams(String authority) {
641         if (authority == null) {
642             // Only cloud authority can be null
643             Log.d(TAG, "Fetching SyncRequestParams. Null cloud authority. Result: SYNC_TYPE_RESET");
644             return SyncRequestParams.forResetMedia();
645         }
646 
647         final Bundle cachedMediaCollectionInfo = getCachedMediaCollectionInfo(authority);
648         final Bundle latestMediaCollectionInfo = getLatestMediaCollectionInfo(authority);
649 
650         final String latestCollectionId =
651                 latestMediaCollectionInfo.getString(MediaCollectionInfo.MEDIA_COLLECTION_ID);
652         final long latestGeneration =
653                 latestMediaCollectionInfo.getLong(MediaCollectionInfo.LAST_MEDIA_SYNC_GENERATION);
654 
655         final String cachedCollectionId =
656                 cachedMediaCollectionInfo.getString(MediaCollectionInfo.MEDIA_COLLECTION_ID);
657         final long cachedGeneration = cachedMediaCollectionInfo.getLong(
658                 MediaCollectionInfo.LAST_MEDIA_SYNC_GENERATION);
659 
660         Log.d(TAG, "Fetching SyncRequestParams. Authority: " + authority
661                 + ". LatestMediaCollectionInfo: " + latestMediaCollectionInfo
662                 + ". CachedMediaCollectionInfo: " + cachedMediaCollectionInfo);
663 
664         if (TextUtils.isEmpty(latestCollectionId) || latestGeneration < 0) {
665             throw new IllegalStateException("Unexpected media collection info. mediaCollectionId: "
666                     + latestCollectionId + ". lastMediaSyncGeneration: " + latestGeneration);
667         }
668 
669         if (!Objects.equals(latestCollectionId, cachedCollectionId)) {
670             Log.d(TAG, "SyncRequestParams. Authority: " + authority + ". Result: SYNC_TYPE_FULL");
671             return SyncRequestParams.forFullMedia(latestMediaCollectionInfo);
672         }
673 
674         if (cachedGeneration == latestGeneration) {
675             Log.d(TAG, "SyncRequestParams. Authority: " + authority + ". Result: SYNC_TYPE_NONE");
676             return SyncRequestParams.forNone();
677         }
678 
679         Log.d(TAG, "SyncRequestParams. Authority: " + authority
680                 + ". Result: SYNC_TYPE_INCREMENTAL");
681         return SyncRequestParams.forIncremental(cachedGeneration, latestMediaCollectionInfo);
682     }
683 
getPrefsKey(String authority, String key)684     private String getPrefsKey(String authority, String key) {
685         return (isLocal(authority) ? PREFS_KEY_LOCAL_PREFIX : PREFS_KEY_CLOUD_PREFIX) + key;
686     }
687 
isLocal(String authority)688     private boolean isLocal(String authority) {
689         return mLocalProvider.equals(authority);
690     }
691 
query(Uri uri, Bundle extras)692     private Cursor query(Uri uri, Bundle extras) {
693         return mContext.getContentResolver().query(uri, /* projection */ null, extras,
694                 /* cancellationSignal */ null);
695     }
696 
executePagedSync(Uri uri, String expectedMediaCollectionId, List<String> expectedHonoredArgs, Bundle queryArgs, PickerDbFacade.DbWriteOperation dbWriteOperation)697     private void executePagedSync(Uri uri, String expectedMediaCollectionId,
698             List<String> expectedHonoredArgs, Bundle queryArgs,
699             PickerDbFacade.DbWriteOperation dbWriteOperation) {
700         int cursorCount = 0;
701         int totalRowcount = 0;
702         // Set to check the uniqueness of tokens across pages.
703         Set<String> tokens = new ArraySet<>();
704 
705         String nextPageToken = null;
706         do {
707             if (nextPageToken != null) {
708                 queryArgs.putString(EXTRA_PAGE_TOKEN, nextPageToken);
709             }
710 
711             try (Cursor cursor = query(uri, queryArgs)) {
712                 nextPageToken = validateCursor(cursor, expectedMediaCollectionId,
713                         expectedHonoredArgs, tokens);
714 
715                 int writeCount = dbWriteOperation.execute(cursor);
716 
717                 totalRowcount += writeCount;
718                 cursorCount += cursor.getCount();
719             }
720         } while (nextPageToken != null);
721 
722         dbWriteOperation.setSuccess();
723         Log.i(TAG, "Paged sync successful. QueryArgs: " + queryArgs + ". Result count: "
724                 + totalRowcount + ". Cursor count: " + cursorCount);
725     }
726 
getDefaultCloudProviderInfo(String cachedProvider)727     private CloudProviderInfo getDefaultCloudProviderInfo(String cachedProvider) {
728         final List<CloudProviderInfo> infos =
729                 getSupportedCloudProviders(/* ignoreAllowList */ false);
730 
731         if (infos.size() == 1) {
732             Log.i(TAG, "Only 1 cloud provider found, hence "
733                     + infos.get(0).authority + " is the default");
734             return infos.get(0);
735         } else {
736             final String defaultCloudProviderAuthority = StringUtils.getStringConfig(
737                 mContext, R.string.config_default_cloud_provider_authority);
738             Log.i(TAG, "Found multiple cloud providers but OEM default is: "
739                     + defaultCloudProviderAuthority);
740 
741             if (cachedProvider != null) {
742                 for (CloudProviderInfo info : infos) {
743                     if (info.authority.equals(defaultCloudProviderAuthority)) {
744                         return info;
745                     }
746                 }
747             }
748 
749             if (defaultCloudProviderAuthority != null) {
750                 for (CloudProviderInfo info : infos) {
751                     if (info.authority.equals(defaultCloudProviderAuthority)) {
752                         return info;
753                     }
754                 }
755             }
756         }
757 
758         // No default set or default not installed
759         return CloudProviderInfo.EMPTY;
760     }
761 
parseAllowedCloudProviders(String config)762     private Set<String> parseAllowedCloudProviders(String config) {
763         Set<String> allowedProviders = new ArraySet<>();
764         final String[] allowedProvidersConfig = config.split(",");
765 
766         if (allowedProvidersConfig.length == 0 || allowedProvidersConfig[0].isEmpty()) {
767             Log.i(TAG, "Empty allowed cloud providers");
768             return allowedProviders;
769         }
770 
771         for (String cloudProvider : allowedProvidersConfig) {
772             Log.d(TAG, "Parsed allowed cloud provider: " + cloudProvider + " from device config");
773             allowedProviders.add(cloudProvider);
774         }
775 
776         Log.i(TAG, "Parsed " + allowedProviders.size() + " allowed providers from device config");
777         return allowedProviders;
778     }
779 
validateCursor(Cursor cursor, String expectedMediaCollectionId, List<String> expectedHonoredArgs, Set<String> usedPageTokens)780     private static String validateCursor(Cursor cursor, String expectedMediaCollectionId,
781             List<String> expectedHonoredArgs, Set<String> usedPageTokens) {
782         final Bundle bundle = cursor.getExtras();
783 
784         if (bundle == null) {
785             throw new IllegalStateException("Unable to verify the media collection id");
786         }
787 
788         final String mediaCollectionId = bundle.getString(EXTRA_MEDIA_COLLECTION_ID);
789         final String pageToken = bundle.getString(EXTRA_PAGE_TOKEN);
790         List<String> honoredArgs = bundle.getStringArrayList(EXTRA_HONORED_ARGS);
791         if (honoredArgs == null) {
792             honoredArgs = new ArrayList<>();
793         }
794 
795         if (expectedMediaCollectionId != null
796                 && !expectedMediaCollectionId.equals(mediaCollectionId)) {
797             throw new IllegalStateException("Mismatched media collection id. Expected: "
798                     + expectedMediaCollectionId + ". Found: " + mediaCollectionId);
799         }
800 
801         if (!honoredArgs.containsAll(expectedHonoredArgs)) {
802             throw new IllegalStateException("Unspecified honored args. Expected: "
803                     + Arrays.toString(expectedHonoredArgs.toArray())
804                     + ". Found: " + Arrays.toString(honoredArgs.toArray()));
805         }
806 
807         if (usedPageTokens.contains(pageToken)) {
808             throw new IllegalStateException("Found repeated page token: " + pageToken);
809         } else {
810             usedPageTokens.add(pageToken);
811         }
812 
813         return pageToken;
814     }
815 
816     @VisibleForTesting
817     static class CloudProviderInfo {
818         static final CloudProviderInfo EMPTY = new CloudProviderInfo();
819         private final String authority;
820         private final String packageName;
821         private final int uid;
822 
CloudProviderInfo()823         private CloudProviderInfo() {
824             this.authority = null;
825             this.packageName = null;
826             this.uid = -1;
827         }
828 
CloudProviderInfo(String authority, String packageName, int uid)829         CloudProviderInfo(String authority, String packageName, int uid) {
830             Objects.requireNonNull(authority);
831             Objects.requireNonNull(packageName);
832 
833             this.authority = authority;
834             this.packageName = packageName;
835             this.uid = uid;
836         }
837 
isEmpty()838         boolean isEmpty() {
839             return equals(EMPTY);
840         }
841 
matches(String packageName)842         boolean matches(String packageName) {
843             return !isEmpty() && this.packageName.equals(packageName);
844         }
845 
846         @Override
equals(Object obj)847         public boolean equals(Object obj) {
848             if (this == obj) return true;
849             if (obj == null || getClass() != obj.getClass()) return false;
850 
851             CloudProviderInfo that = (CloudProviderInfo) obj;
852 
853             return Objects.equals(authority, that.authority) &&
854                     Objects.equals(packageName, that.packageName) &&
855                     Objects.equals(uid, that.uid);
856         }
857 
858         @Override
hashCode()859         public int hashCode() {
860             return Objects.hash(authority, packageName, uid);
861         }
862     }
863 
864     private static class SyncRequestParams {
865         private static final SyncRequestParams SYNC_REQUEST_NONE =
866                 new SyncRequestParams(SYNC_TYPE_NONE);
867         private static final SyncRequestParams SYNC_REQUEST_MEDIA_RESET =
868                 new SyncRequestParams(SYNC_TYPE_MEDIA_RESET);
869 
870         private final int syncType;
871         // Only valid for SYNC_TYPE_INCREMENTAL
872         private final long syncGeneration;
873         // Only valid for SYNC_TYPE_[INCREMENTAL|FULL]
874         private final Bundle latestMediaCollectionInfo;
875 
SyncRequestParams(@yncType int syncType)876         private SyncRequestParams(@SyncType int syncType) {
877             this(syncType, /* syncGeneration */ 0, /* latestMediaCollectionInfo */ null);
878         }
879 
SyncRequestParams(@yncType int syncType, long syncGeneration, Bundle latestMediaCollectionInfo)880         private SyncRequestParams(@SyncType int syncType, long syncGeneration,
881                 Bundle latestMediaCollectionInfo) {
882             this.syncType = syncType;
883             this.syncGeneration = syncGeneration;
884             this.latestMediaCollectionInfo = latestMediaCollectionInfo;
885         }
886 
getMediaCollectionId()887         String getMediaCollectionId() {
888             return latestMediaCollectionInfo.getString(MediaCollectionInfo.MEDIA_COLLECTION_ID);
889         }
890 
forNone()891         static SyncRequestParams forNone() {
892             return SYNC_REQUEST_NONE;
893         }
894 
forResetMedia()895         static SyncRequestParams forResetMedia() {
896             return SYNC_REQUEST_MEDIA_RESET;
897         }
898 
forFullMedia(Bundle latestMediaCollectionInfo)899         static SyncRequestParams forFullMedia(Bundle latestMediaCollectionInfo) {
900             return new SyncRequestParams(SYNC_TYPE_MEDIA_FULL, /* generation */ 0,
901                     latestMediaCollectionInfo);
902         }
903 
forIncremental(long generation, Bundle latestMediaCollectionInfo)904         static SyncRequestParams forIncremental(long generation, Bundle latestMediaCollectionInfo) {
905             return new SyncRequestParams(SYNC_TYPE_MEDIA_INCREMENTAL, generation,
906                     latestMediaCollectionInfo);
907         }
908     }
909 }
910