blob: de2d8646bdcadc60694916c4673c3944ca3ab5d1 [file] [log] [blame]
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.providers.media.photopicker;
import static android.content.ContentResolver.EXTRA_HONORED_ARGS;
import static android.provider.CloudMediaProviderContract.EXTRA_ALBUM_ID;
import static android.provider.CloudMediaProviderContract.EXTRA_MEDIA_COLLECTION_ID;
import static android.provider.CloudMediaProviderContract.EXTRA_PAGE_SIZE;
import static android.provider.CloudMediaProviderContract.EXTRA_PAGE_TOKEN;
import static android.provider.CloudMediaProviderContract.EXTRA_SYNC_GENERATION;
import static android.provider.CloudMediaProviderContract.MediaCollectionInfo.LAST_MEDIA_SYNC_GENERATION;
import static android.provider.CloudMediaProviderContract.MediaCollectionInfo.MEDIA_COLLECTION_ID;
import static com.android.providers.media.PickerUriResolver.getDeletedMediaUri;
import static com.android.providers.media.PickerUriResolver.getMediaCollectionInfoUri;
import static com.android.providers.media.PickerUriResolver.getMediaUri;
import android.annotation.IntDef;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Process;
import android.os.Trace;
import android.os.storage.StorageManager;
import android.provider.CloudMediaProvider;
import android.provider.CloudMediaProviderContract;
import android.text.TextUtils;
import android.util.ArraySet;
import android.util.Log;
import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.modules.utils.BackgroundThread;
import com.android.modules.utils.build.SdkLevel;
import com.android.providers.media.ConfigStore;
import com.android.providers.media.photopicker.data.CloudProviderInfo;
import com.android.providers.media.photopicker.data.PickerDbFacade;
import com.android.providers.media.photopicker.metrics.PhotoPickerUiEventLogger;
import com.android.providers.media.photopicker.util.CloudProviderUtils;
import com.android.providers.media.photopicker.util.exceptions.RequestObsoleteException;
import java.io.PrintWriter;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* Syncs the local and currently enabled cloud {@link CloudMediaProvider} instances on the device
* into the picker db.
*/
public class PickerSyncController {
private static final String TAG = "PickerSyncController";
private static final boolean DEBUG = false;
private static final String PREFS_KEY_CLOUD_PROVIDER_AUTHORITY = "cloud_provider_authority";
private static final String PREFS_KEY_CLOUD_PREFIX = "cloud_provider:";
private static final String PREFS_KEY_LOCAL_PREFIX = "local_provider:";
private static final String PREFS_KEY_RESUME = "resume";
private static final String PREFS_KEY_OPERATION_MEDIA_ADD_PREFIX = "media_add:";
private static final String PREFS_KEY_OPERATION_MEDIA_REMOVE_PREFIX = "media_remove:";
private static final String PREFS_KEY_OPERATION_ALBUM_ADD_PREFIX = "album_add:";
private static final String PICKER_USER_PREFS_FILE_NAME = "picker_user_prefs";
public static final String PICKER_SYNC_PREFS_FILE_NAME = "picker_sync_prefs";
public static final String LOCAL_PICKER_PROVIDER_AUTHORITY =
"com.android.providers.media.photopicker";
private static final String PREFS_VALUE_CLOUD_PROVIDER_UNSET = "-";
private static final int OPERATION_ADD_MEDIA = 1;
private static final int OPERATION_ADD_ALBUM = 2;
private static final int OPERATION_REMOVE_MEDIA = 3;
@IntDef(
flag = false,
value = {OPERATION_ADD_MEDIA, OPERATION_ADD_ALBUM, OPERATION_REMOVE_MEDIA})
@Retention(RetentionPolicy.SOURCE)
private @interface OperationType {}
private static final int SYNC_TYPE_NONE = 0;
private static final int SYNC_TYPE_MEDIA_INCREMENTAL = 1;
private static final int SYNC_TYPE_MEDIA_FULL = 2;
private static final int SYNC_TYPE_MEDIA_RESET = 3;
public static final int PAGE_SIZE = 3000;
@NonNull
private static final Handler sBgThreadHandler = BackgroundThread.getHandler();
@IntDef(flag = false, prefix = { "SYNC_TYPE_" }, value = {
SYNC_TYPE_NONE,
SYNC_TYPE_MEDIA_INCREMENTAL,
SYNC_TYPE_MEDIA_FULL,
SYNC_TYPE_MEDIA_RESET,
})
@Retention(RetentionPolicy.SOURCE)
private @interface SyncType {}
private final Context mContext;
private final ConfigStore mConfigStore;
private final PickerDbFacade mDbFacade;
private final SharedPreferences mSyncPrefs;
private final SharedPreferences mUserPrefs;
private final String mLocalProvider;
private final PhotoPickerUiEventLogger mLogger;
private final Object mCloudSyncLock = new Object();
// TODO(b/278562157): If there is a dependency on the sync process, always acquire the
// {@link mCloudSyncLock} before {@link mCloudProviderLock} to avoid deadlock.
private final Object mCloudProviderLock = new Object();
@GuardedBy("mCloudProviderLock")
private CloudProviderInfo mCloudProviderInfo;
@Nullable
private static PickerSyncController sInstance;
/**
* Initialize {@link PickerSyncController} object.{@link PickerSyncController} should only be
* initialized from {@link com.android.providers.media.MediaProvider#onCreate}.
*
* @param context the app context of type {@link Context}
* @param dbFacade instance of {@link PickerDbFacade} that will be used for DB queries.
* @param configStore {@link ConfigStore} that returns the sync config of the device.
* @return an instance of {@link PickerSyncController}
*/
@NonNull
public static PickerSyncController initialize(@NonNull Context context,
@NonNull PickerDbFacade dbFacade, @NonNull ConfigStore configStore) {
return initialize(context, dbFacade, configStore, LOCAL_PICKER_PROVIDER_AUTHORITY);
}
/**
* Initialize {@link PickerSyncController} object.{@link PickerSyncController} should only be
* initialized from {@link com.android.providers.media.MediaProvider#onCreate}.
*
* @param context the app context of type {@link Context}
* @param dbFacade instance of {@link PickerDbFacade} that will be used for DB queries.
* @param configStore {@link ConfigStore} that returns the sync config of the device.
* @param localProvider is the name of the local provider that is responsible for providing the
* local media items.
* @return an instance of {@link PickerSyncController}
*/
@NonNull
@VisibleForTesting
public static PickerSyncController initialize(@NonNull Context context,
@NonNull PickerDbFacade dbFacade, @NonNull ConfigStore configStore,
@NonNull String localProvider) {
sInstance = new PickerSyncController(context, dbFacade, configStore,
localProvider);
return sInstance;
}
/**
* @return a PickerSyncController object. The object may not the initialized and the
* return value could be null.
*/
@Nullable
public static PickerSyncController getInstance() {
return sInstance;
}
private PickerSyncController(@NonNull Context context, @NonNull PickerDbFacade dbFacade,
@NonNull ConfigStore configStore, @NonNull String localProvider) {
mContext = context;
mConfigStore = configStore;
mSyncPrefs = mContext.getSharedPreferences(PICKER_SYNC_PREFS_FILE_NAME,
Context.MODE_PRIVATE);
mUserPrefs = mContext.getSharedPreferences(PICKER_USER_PREFS_FILE_NAME,
Context.MODE_PRIVATE);
mDbFacade = dbFacade;
mLocalProvider = localProvider;
mLogger = new PhotoPickerUiEventLogger();
initCloudProvider();
}
private void initCloudProvider() {
synchronized (mCloudProviderLock) {
if (!mConfigStore.isCloudMediaInPhotoPickerEnabled()) {
Log.d(TAG, "Cloud-Media-in-Photo-Picker feature is disabled during " + TAG
+ " construction.");
persistCloudProviderInfo(CloudProviderInfo.EMPTY, /* shouldUnset */ false);
return;
}
final String cachedAuthority = mUserPrefs.getString(
PREFS_KEY_CLOUD_PROVIDER_AUTHORITY, null);
if (isCloudProviderUnset(cachedAuthority)) {
Log.d(TAG, "Cloud provider state is unset during " + TAG + " construction.");
setCurrentCloudProviderInfo(CloudProviderInfo.EMPTY);
return;
}
initCloudProviderLocked(cachedAuthority);
}
}
private void initCloudProviderLocked(@Nullable String cachedAuthority) {
final CloudProviderInfo defaultInfo = getDefaultCloudProviderInfo(cachedAuthority);
if (Objects.equals(defaultInfo.authority, cachedAuthority)) {
// Just set it without persisting since it's not changing and persisting would
// notify the user that cloud media is now available
setCurrentCloudProviderInfo(defaultInfo);
} else {
// Persist it so that we notify the user that cloud media is now available
persistCloudProviderInfo(defaultInfo, /* shouldUnset */ false);
}
Log.d(TAG, "Initialized cloud provider to: " + defaultInfo.authority);
}
/**
* Syncs the local and currently enabled cloud {@link CloudMediaProvider} instances
*/
public void syncAllMedia() {
Log.d(TAG, "syncAllMedia");
Trace.beginSection(traceSectionName("syncAllMedia"));
try {
syncAllMediaFromLocalProvider();
syncAllMediaFromCloudProvider();
} finally {
Trace.endSection();
}
}
/**
* Syncs the local media
*/
public void syncAllMediaFromLocalProvider() {
syncAllMediaFromProvider(mLocalProvider, /* isLocal */ true, /* retryOnFailure */ true,
/* enforcePagedSync*/ false);
}
/**
* Syncs the cloud media
*/
public void syncAllMediaFromCloudProvider() {
synchronized (mCloudSyncLock) {
final String cloudProvider = getCloudProvider();
// Disable cloud queries in the database. If any cloud related queries come through
// while cloud sync is in progress, all cloud items will be ignored and local items will
// be returned.
mDbFacade.setCloudProvider(null);
// Trigger a sync.
final boolean didSyncFinish = syncAllMediaFromProvider(cloudProvider,
/* isLocal */ false, /* retryOnFailure */ true, /* enforcePagedSync*/ false);
// Check if sync was completed successfully.
if (!didSyncFinish) {
Log.e(TAG, "Failed to fully complete sync with cloud provider - " + cloudProvider
+ ". The cloud provider may have changed during the sync, or only a"
+ " partial sync was completed.");
}
// Reset the album_media table every time we sync all media
// TODO(258765155): do we really need to reset for both providers?
resetAlbumMedia();
// Re-enable cloud queries in the database for the latest cloud provider.
synchronized (mCloudProviderLock) {
if (Objects.equals(mCloudProviderInfo.authority, cloudProvider)) {
mDbFacade.setCloudProvider(cloudProvider);
} else {
Log.e(TAG, "Failed to sync with cloud provider - " + cloudProvider
+ ". The cloud provider has changed to "
+ mCloudProviderInfo.authority);
}
}
}
}
/**
* Syncs album media from the local and currently enabled cloud {@link CloudMediaProvider}
* instances
*/
public void syncAlbumMedia(String albumId, boolean isLocal) {
if (isLocal) {
syncAlbumMediaFromLocalProvider(albumId);
} else {
syncAlbumMediaFromCloudProvider(albumId);
}
}
private void syncAlbumMediaFromLocalProvider(@NonNull String albumId) {
syncAlbumMediaFromProvider(mLocalProvider, /* isLocal */ true, albumId,
/* enforcePagedSync*/ false);
}
private void syncAlbumMediaFromCloudProvider(@NonNull String albumId) {
synchronized (mCloudSyncLock) {
syncAlbumMediaFromProvider(getCloudProvider(), /* isLocal */ false, albumId,
/* enforcePagedSync*/ false);
}
}
private void resetAlbumMedia() {
executeSyncAlbumReset(mLocalProvider, /* isLocal */ true, /* albumId */ null);
synchronized (mCloudSyncLock) {
executeSyncAlbumReset(getCloudProvider(), /* isLocal */ false, /* albumId */ null);
}
}
/**
* Resets media library previously synced from the current {@link CloudMediaProvider} as well
* as the {@link #mLocalProvider local provider}.
*/
public void resetAllMedia() {
resetAllMedia(mLocalProvider, /* isLocal */ true);
synchronized (mCloudSyncLock) {
resetAllMedia(getCloudProvider(), /* isLocal */ false);
}
}
private boolean resetAllMedia(@Nullable String authority, boolean isLocal) {
Trace.beginSection(traceSectionName("resetAllMedia", isLocal));
try {
executeSyncReset(authority, isLocal);
return resetCachedMediaCollectionInfo(authority, isLocal);
} finally {
Trace.endSection();
}
}
@NonNull
private CloudProviderInfo getCloudProviderInfo(String authority, boolean ignoreAllowlist) {
if (authority == null) {
return CloudProviderInfo.EMPTY;
}
final List<CloudProviderInfo> availableProviders = ignoreAllowlist
? CloudProviderUtils.getAllAvailableCloudProviders(mContext, mConfigStore)
: CloudProviderUtils.getAvailableCloudProviders(mContext, mConfigStore);
for (CloudProviderInfo info : availableProviders) {
if (Objects.equals(info.authority, authority)) {
return info;
}
}
return CloudProviderInfo.EMPTY;
}
/**
* @return list of available <b>and</b> allowlisted {@link CloudMediaProvider}-s.
*/
@VisibleForTesting
List<CloudProviderInfo> getAvailableCloudProviders() {
return CloudProviderUtils.getAvailableCloudProviders(mContext, mConfigStore);
}
/**
* Enables a provider with {@code authority} as the default cloud {@link CloudMediaProvider}.
* If {@code authority} is set to {@code null}, it simply clears the cloud provider.
*
* Note, that this doesn't sync the new provider after switching, however, no cloud items will
* be available from the picker db until the next sync. Callers should schedule a sync in the
* background after switching providers.
*
* @return {@code true} if the provider was successfully enabled or cleared, {@code false}
* otherwise.
*/
public boolean setCloudProvider(@Nullable String authority) {
Trace.beginSection(traceSectionName("setCloudProvider"));
try {
return setCloudProviderInternal(authority, /* ignoreAllowlist */ false);
} finally {
Trace.endSection();
}
}
/**
* Set cloud provider ignoring allowlist.
*
* @return {@code true} if the provider was successfully enabled or cleared, {@code false}
* otherwise.
*/
public boolean forceSetCloudProvider(@Nullable String authority) {
Trace.beginSection(traceSectionName("forceSetCloudProvider"));
try {
return setCloudProviderInternal(authority, /* ignoreAllowlist */ true);
} finally {
Trace.endSection();
}
}
private boolean setCloudProviderInternal(@Nullable String authority, boolean ignoreAllowList) {
Log.d(TAG, "setCloudProviderInternal() auth=" + authority + ", "
+ "ignoreAllowList=" + ignoreAllowList);
if (DEBUG) {
Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable());
}
if (!mConfigStore.isCloudMediaInPhotoPickerEnabled()) {
Log.w(TAG, "Ignoring a request to set the CloudMediaProvider (" + authority + ") "
+ "since the Cloud-Media-in-Photo-Picker feature is disabled");
return false;
}
synchronized (mCloudProviderLock) {
if (Objects.equals(mCloudProviderInfo.authority, authority)) {
Log.w(TAG, "Cloud provider already set: " + authority);
return true;
}
}
final CloudProviderInfo newProviderInfo = getCloudProviderInfo(authority, ignoreAllowList);
if (authority == null || !newProviderInfo.isEmpty()) {
synchronized (mCloudProviderLock) {
// Disable cloud provider queries on the db until next sync
// This will temporarily *clear* the cloud provider on the db facade and prevent
// any queries from seeing cloud media until a sync where the cloud provider will be
// reset on the facade
mDbFacade.setCloudProvider(null);
final String oldAuthority = mCloudProviderInfo.authority;
persistCloudProviderInfo(newProviderInfo, /* shouldUnset */ true);
// TODO(b/242897322): Log from PickerViewModel using its InstanceId when relevant
mLogger.logPickerCloudProviderChanged(newProviderInfo.uid,
newProviderInfo.packageName);
Log.i(TAG, "Cloud provider changed successfully. Old: "
+ oldAuthority + ". New: " + newProviderInfo.authority);
}
return true;
}
Log.w(TAG, "Cloud provider not supported: " + authority);
return false;
}
/**
* @return {@link CloudProviderInfo} for the current {@link CloudMediaProvider} or
* {@link CloudProviderInfo#EMPTY} if the {@link CloudMediaProvider} integration is not
* enabled.
*/
@NonNull
public CloudProviderInfo getCurrentCloudProviderInfo() {
synchronized (mCloudProviderLock) {
return mCloudProviderInfo;
}
}
/**
* Set {@link PickerSyncController#mCloudProviderInfo} as the current {@link CloudMediaProvider}
* or {@link CloudProviderInfo#EMPTY} if the {@link CloudMediaProvider} integration
* disabled by the user.
*/
private void setCurrentCloudProviderInfo(@NonNull CloudProviderInfo cloudProviderInfo) {
synchronized (mCloudProviderLock) {
mCloudProviderInfo = cloudProviderInfo;
}
}
/**
* @return {@link android.content.pm.ProviderInfo#authority authority} of the current
* {@link CloudMediaProvider} or {@code null} if the {@link CloudMediaProvider}
* integration is not enabled.
*/
@Nullable
public String getCloudProvider() {
synchronized (mCloudProviderLock) {
return mCloudProviderInfo.authority;
}
}
/**
* @return {@link android.content.pm.ProviderInfo#authority authority} of the local provider.
*/
@NonNull
public String getLocalProvider() {
return mLocalProvider;
}
public boolean isProviderEnabled(String authority) {
if (mLocalProvider.equals(authority)) {
return true;
}
synchronized (mCloudProviderLock) {
if (!mCloudProviderInfo.isEmpty()
&& Objects.equals(mCloudProviderInfo.authority, authority)) {
return true;
}
}
return false;
}
public boolean isProviderEnabled(String authority, int uid) {
if (uid == Process.myUid() && mLocalProvider.equals(authority)) {
return true;
}
synchronized (mCloudProviderLock) {
if (!mCloudProviderInfo.isEmpty() && uid == mCloudProviderInfo.uid
&& Objects.equals(mCloudProviderInfo.authority, authority)) {
return true;
}
}
return false;
}
public boolean isProviderSupported(String authority, int uid) {
if (uid == Process.myUid() && mLocalProvider.equals(authority)) {
return true;
}
// TODO(b/232738117): Enforce allow list here. This works around some CTS failure late in
// Android T. The current implementation is fine since cloud providers is only supported
// for app developers testing.
final List<CloudProviderInfo> infos =
CloudProviderUtils.getAllAvailableCloudProviders(mContext, mConfigStore);
for (CloudProviderInfo info : infos) {
if (info.uid == uid && Objects.equals(info.authority, authority)) {
return true;
}
}
return false;
}
/**
* Notifies about package removal
*/
public void notifyPackageRemoval(String packageName) {
synchronized (mCloudProviderLock) {
if (mCloudProviderInfo.matches(packageName)) {
Log.i(TAG, "Package " + packageName
+ " is the current cloud provider and got removed");
resetCloudProvider();
}
}
}
private void resetCloudProvider() {
synchronized (mCloudProviderLock) {
setCloudProvider(/* authority */ null);
/**
* {@link #setCloudProvider(String null)} sets the cloud provider state to UNSET.
* Clearing the persisted cloud provider authority to set the state as NOT_SET instead.
*/
clearPersistedCloudProviderAuthority();
initCloudProviderLocked(/* cachedAuthority */ null);
}
}
/**
* Syncs album media.
*
* @param enforcePagedSync Set to true data from provider needs to be synced in batches.
* If true, {@link CloudMediaProviderContract#EXTRA_PAGE_SIZE
* is passed during query to the provider.
*/
private void syncAlbumMediaFromProvider(String authority, boolean isLocal, String albumId,
boolean enforcePagedSync) {
final Bundle queryArgs = new Bundle();
queryArgs.putString(EXTRA_ALBUM_ID, albumId);
if (enforcePagedSync) {
queryArgs.putInt(EXTRA_PAGE_SIZE, PAGE_SIZE);
}
Trace.beginSection(traceSectionName("syncAlbumMediaFromProvider", isLocal));
try {
executeSyncAlbumReset(authority, isLocal, albumId);
if (authority != null) {
executeSyncAddAlbum(authority, isLocal, albumId, queryArgs);
}
} catch (RuntimeException e) {
// Unlike syncAllMediaFromProvider, we don't retry here because any errors would have
// occurred in fetching all the album_media since incremental sync is not supported.
// A full sync is therefore unlikely to resolve any issue
Log.e(TAG, "Failed to sync album media", e);
} finally {
Trace.endSection();
}
}
/**
* Returns true if the sync was successful and the latest collection info was persisted.
*
* @param enforcePagedSync Set to true data from provider needs to be synced in batches.
* If true, {@link CloudMediaProviderContract#EXTRA_PAGE_SIZE} is passed
* during query to the provider.
*/
private boolean syncAllMediaFromProvider(@Nullable String authority, boolean isLocal,
boolean retryOnFailure, boolean enforcePagedSync) {
Log.d(TAG, "syncAllMediaFromProvider() " + (isLocal ? "LOCAL" : "CLOUD")
+ ", auth=" + authority
+ ", retry=" + retryOnFailure);
if (DEBUG) {
Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable());
}
Trace.beginSection(traceSectionName("syncAllMediaFromProvider", isLocal));
try {
final SyncRequestParams params = getSyncRequestParams(authority, isLocal);
switch (params.syncType) {
case SYNC_TYPE_MEDIA_RESET:
// Can only happen when |authority| has been set to null and we need to clean up
return resetAllMedia(authority, isLocal);
case SYNC_TYPE_MEDIA_FULL:
if (!resetAllMedia(authority, isLocal)) {
return false;
}
final Bundle fullSyncQueryArgs = new Bundle();
if (enforcePagedSync) {
fullSyncQueryArgs.putInt(EXTRA_PAGE_SIZE, params.mPageSize);
}
// Pass a mutable empty bundle intentionally because it might be populated with
// the next page token as part of a query to a cloud provider supporting
// pagination
executeSyncAdd(authority, isLocal, params.getMediaCollectionId(),
/* isIncrementalSync */ false, enforcePagedSync, fullSyncQueryArgs);
// Commit sync position
return cacheMediaCollectionInfo(
authority, isLocal, params.latestMediaCollectionInfo);
case SYNC_TYPE_MEDIA_INCREMENTAL:
final Bundle queryArgs = new Bundle();
queryArgs.putLong(EXTRA_SYNC_GENERATION, params.syncGeneration);
if (enforcePagedSync) {
queryArgs.putInt(EXTRA_PAGE_SIZE, params.mPageSize);
}
executeSyncAdd(authority, isLocal, params.getMediaCollectionId(),
/* isIncrementalSync */ true, enforcePagedSync, queryArgs);
executeSyncRemove(authority, isLocal, params.getMediaCollectionId(), queryArgs);
// Commit sync position
return cacheMediaCollectionInfo(
authority, isLocal, params.latestMediaCollectionInfo);
case SYNC_TYPE_NONE:
return true;
default:
throw new IllegalArgumentException("Unexpected sync type: " + params.syncType);
}
} catch (RequestObsoleteException e) {
Log.e(TAG, "Failed to sync all media because authority has changed: ", e);
} catch (IllegalStateException e) {
// If we're in an illegal state, reset and start a full sync again.
resetAllMedia(authority, isLocal);
Log.e(TAG, "Failed to sync all media. Reset media and retry: " + retryOnFailure, e);
if (retryOnFailure) {
return syncAllMediaFromProvider(authority, isLocal, /* retryOnFailure */ false,
/* enforcePagedSync*/ false);
}
} catch (RuntimeException e) {
// Retry the failed operation to see if it was an intermittent problem. If this fails,
// the database will be in a partial state until the sync resumes from this point
// on next run.
Log.e(TAG, "Failed to sync all media. Reset media and retry: " + retryOnFailure, e);
if (retryOnFailure) {
return syncAllMediaFromProvider(authority, isLocal, /* retryOnFailure */ false,
/* enforcePagedSync*/ false);
}
} finally {
Trace.endSection();
}
return false;
}
private void executeSyncReset(String authority, boolean isLocal) {
Log.i(TAG, "Executing SyncReset. isLocal: " + isLocal + ". authority: " + authority);
Trace.beginSection(traceSectionName("executeSyncReset", isLocal));
try (PickerDbFacade.DbWriteOperation operation =
mDbFacade.beginResetMediaOperation(authority)) {
final int writeCount = operation.execute(null /* cursor */);
operation.setSuccess();
Log.i(TAG, "SyncReset. isLocal:" + isLocal + ". authority: " + authority
+ ". result count: " + writeCount);
} finally {
Trace.endSection();
}
}
private void executeSyncAlbumReset(String authority, boolean isLocal, String albumId) {
Log.i(TAG, "Executing SyncAlbumReset."
+ " isLocal: " + isLocal + ". authority: " + authority + ". albumId: " + albumId);
Trace.beginSection(traceSectionName("executeSyncAlbumReset", isLocal));
try (PickerDbFacade.DbWriteOperation operation =
mDbFacade.beginResetAlbumMediaOperation(authority, albumId)) {
final int writeCount = operation.execute(null /* cursor */);
operation.setSuccess();
Log.i(TAG, "Successfully executed SyncResetAlbum. authority: " + authority
+ ". albumId: " + albumId + ". Result count: " + writeCount);
} finally {
Trace.endSection();
}
}
/**
* Queries the provider and writes the data returned to the db.
*
* <p> Also validates the cursor returned from provider has expected extras of not.
*
* @param isIncrementalSync If true, {@link CloudMediaProviderContract#EXTRA_SYNC_GENERATION}
* should be honoured by the provider.
* @param enforcePagedSync If true, {@link CloudMediaProviderContract#EXTRA_PAGE_SIZE} should
* be honoured by the provider.
*/
private void executeSyncAdd(String authority, boolean isLocal,
String expectedMediaCollectionId, boolean isIncrementalSync, boolean enforcePagedSync,
Bundle queryArgs) {
final Uri uri = getMediaUri(authority);
final List<String> expectedHonoredArgs = new ArrayList<>();
if (isIncrementalSync) {
expectedHonoredArgs.add(EXTRA_SYNC_GENERATION);
}
//TODO(b/287003817): add pagesize extra when enforcePagedSync is true in expectedHonoredArgs
// later when ready.
Log.i(TAG, "Executing SyncAdd. isLocal: " + isLocal + ". authority: " + authority);
String resumeKey =
getPrefsKey(isLocal, PREFS_KEY_OPERATION_MEDIA_ADD_PREFIX + PREFS_KEY_RESUME);
Trace.beginSection(traceSectionName("executeSyncAdd", isLocal));
try {
executePagedSync(
uri,
expectedMediaCollectionId,
expectedHonoredArgs,
queryArgs,
resumeKey,
OPERATION_ADD_MEDIA,
authority);
} finally {
Trace.endSection();
}
}
private void executeSyncAddAlbum(String authority, boolean isLocal,
String albumId, Bundle queryArgs) {
final Uri uri = getMediaUri(authority);
Log.i(TAG, "Executing SyncAddAlbum. "
+ "isLocal: " + isLocal + ". authority: " + authority + ". albumId: " + albumId);
String resumeKey =
getPrefsKey(isLocal, PREFS_KEY_OPERATION_ALBUM_ADD_PREFIX + PREFS_KEY_RESUME);
Trace.beginSection(traceSectionName("executeSyncAddAlbum", isLocal));
try {
// We don't need to validate the mediaCollectionId for album_media sync since it's
// always a full sync
executePagedSync(uri, /* mediaCollectionId */ null, Arrays.asList(EXTRA_ALBUM_ID),
queryArgs, resumeKey, OPERATION_ADD_ALBUM, authority, albumId);
} finally {
Trace.endSection();
}
}
private void executeSyncRemove(String authority, boolean isLocal,
String mediaCollectionId, Bundle queryArgs) {
final Uri uri = getDeletedMediaUri(authority);
Log.i(TAG, "Executing SyncRemove. isLocal: " + isLocal + ". authority: " + authority);
String resumeKey =
getPrefsKey(isLocal, PREFS_KEY_OPERATION_MEDIA_REMOVE_PREFIX + PREFS_KEY_RESUME);
Trace.beginSection(traceSectionName("executeSyncRemove", isLocal));
try {
executePagedSync(uri, mediaCollectionId, Arrays.asList(EXTRA_SYNC_GENERATION),
queryArgs, resumeKey, OPERATION_REMOVE_MEDIA, authority);
} finally {
Trace.endSection();
}
}
/**
* Persist cloud provider info and send a sync request to the background thread.
*/
private void persistCloudProviderInfo(@NonNull CloudProviderInfo info, boolean shouldUnset) {
synchronized (mCloudProviderLock) {
setCurrentCloudProviderInfo(info);
final String authority = info.authority;
final SharedPreferences.Editor editor = mUserPrefs.edit();
final boolean isCloudProviderInfoNotEmpty = !info.isEmpty();
if (isCloudProviderInfoNotEmpty) {
editor.putString(PREFS_KEY_CLOUD_PROVIDER_AUTHORITY, authority);
} else if (shouldUnset) {
editor.putString(PREFS_KEY_CLOUD_PROVIDER_AUTHORITY,
PREFS_VALUE_CLOUD_PROVIDER_UNSET);
} else {
editor.remove(PREFS_KEY_CLOUD_PROVIDER_AUTHORITY);
}
editor.apply();
if (SdkLevel.isAtLeastT()) {
try {
StorageManager sm = mContext.getSystemService(StorageManager.class);
sm.setCloudMediaProvider(authority);
} catch (SecurityException e) {
// When run as part of the unit tests, the notification fails because only the
// MediaProvider uid can notify
Log.w(TAG, "Failed to notify the system of cloud provider update to: "
+ authority);
}
}
Log.d(TAG, "Updated cloud provider to: " + authority);
resetCachedMediaCollectionInfo(info.authority, /* isLocal */ false);
}
}
/**
* Clears the persisted cloud provider authority and sets the state to default (NOT_SET).
*/
@VisibleForTesting
void clearPersistedCloudProviderAuthority() {
Log.d(TAG, "Setting the cloud provider state to default (NOT_SET) by clearing the "
+ "persisted cloud provider authority");
mUserPrefs.edit().remove(PREFS_KEY_CLOUD_PROVIDER_AUTHORITY).apply();
}
/**
* Commit the latest media collection info when a sync operation is completed.
*/
private boolean cacheMediaCollectionInfo(@Nullable String authority, boolean isLocal,
@Nullable Bundle bundle) {
if (authority == null) {
Log.d(TAG, "Ignoring cache media info for null authority with bundle: " + bundle);
return true;
}
Trace.beginSection(traceSectionName("cacheMediaCollectionInfo", isLocal));
try {
if (isLocal) {
cacheMediaCollectionInfoInternal(isLocal, bundle);
return true;
} else {
synchronized (mCloudProviderLock) {
// Check if the media collection info belongs to the current cloud provider
// authority.
if (Objects.equals(authority, mCloudProviderInfo.authority)) {
cacheMediaCollectionInfoInternal(isLocal, bundle);
return true;
} else {
Log.e(TAG, "Do not cache collection info for "
+ authority + " because cloud provider changed to "
+ mCloudProviderInfo.authority);
return false;
}
}
}
} finally {
Trace.endSection();
}
}
private void cacheMediaCollectionInfoInternal(boolean isLocal,
@Nullable Bundle bundle) {
final SharedPreferences.Editor editor = mSyncPrefs.edit();
if (bundle == null) {
editor.remove(getPrefsKey(isLocal, MEDIA_COLLECTION_ID));
editor.remove(getPrefsKey(isLocal, LAST_MEDIA_SYNC_GENERATION));
// Clear any resume keys for page tokens.
editor.remove(
getPrefsKey(isLocal, PREFS_KEY_OPERATION_MEDIA_ADD_PREFIX + PREFS_KEY_RESUME));
editor.remove(
getPrefsKey(isLocal, PREFS_KEY_OPERATION_ALBUM_ADD_PREFIX + PREFS_KEY_RESUME));
editor.remove(
getPrefsKey(
isLocal, PREFS_KEY_OPERATION_MEDIA_REMOVE_PREFIX + PREFS_KEY_RESUME));
} else {
final String collectionId = bundle.getString(MEDIA_COLLECTION_ID);
final long generation = bundle.getLong(LAST_MEDIA_SYNC_GENERATION);
editor.putString(getPrefsKey(isLocal, MEDIA_COLLECTION_ID), collectionId);
editor.putLong(getPrefsKey(isLocal, LAST_MEDIA_SYNC_GENERATION), generation);
}
editor.apply();
}
/**
* Adds the given token to the saved sync preferences.
*
* @param token The token to remember. A null value will clear the preference.
* @param resumeKey The operation's key in sync preferences.
*/
private void rememberNextPageToken(@Nullable String token, String resumeKey) {
synchronized (mCloudSyncLock) {
final SharedPreferences.Editor editor = mSyncPrefs.edit();
if (token == null) {
Log.d(TAG, String.format("Clearing next page token for key: %s", resumeKey));
editor.remove(resumeKey);
} else {
Log.d(
TAG,
String.format("Saving next page token: %s for key: %s", token, resumeKey));
editor.putString(resumeKey, token);
}
editor.apply();
}
}
/**
* Fetches the next page token given a resume key. Returns null if no NextPage token was saved.
*
* @param resumeKey The operation's resume key.
* @return The PageToken to resume from, or {@code null} if there is no operation to resume.
*/
@Nullable
public String getPageTokenFromResumeKey(String resumeKey) {
synchronized (mCloudProviderLock) {
return mSyncPrefs.getString(resumeKey, /* defValue= */ null);
}
}
private boolean resetCachedMediaCollectionInfo(@Nullable String authority, boolean isLocal) {
return cacheMediaCollectionInfo(authority, isLocal, /* bundle */ null);
}
private Bundle getCachedMediaCollectionInfo(boolean isLocal) {
final Bundle bundle = new Bundle();
final String collectionId = mSyncPrefs.getString(
getPrefsKey(isLocal, MEDIA_COLLECTION_ID), /* default */ null);
final long generation = mSyncPrefs.getLong(
getPrefsKey(isLocal, LAST_MEDIA_SYNC_GENERATION), /* default */ -1);
bundle.putString(MEDIA_COLLECTION_ID, collectionId);
bundle.putLong(LAST_MEDIA_SYNC_GENERATION, generation);
return bundle;
}
private Bundle getLatestMediaCollectionInfo(String authority) {
return mContext.getContentResolver().call(getMediaCollectionInfoUri(authority),
CloudMediaProviderContract.METHOD_GET_MEDIA_COLLECTION_INFO, /* arg */ null,
/* extras */ null);
}
@NonNull
private SyncRequestParams getSyncRequestParams(@Nullable String authority,
boolean isLocal) throws RequestObsoleteException {
if (isLocal) {
return getSyncRequestParamsInternal(authority, isLocal);
} else {
// Ensure that we are fetching sync request params for the current cloud provider.
synchronized (mCloudProviderLock) {
if (Objects.equals(mCloudProviderInfo.authority, authority)) {
return getSyncRequestParamsInternal(authority, isLocal);
} else {
throw new RequestObsoleteException("Attempt to fetch sync request params for an"
+ " unknown cloud provider. Current provider: "
+ mCloudProviderInfo.authority + " Requested provider: " + authority);
}
}
}
}
@NonNull
private SyncRequestParams getSyncRequestParamsInternal(@Nullable String authority,
boolean isLocal) {
Log.d(TAG, "getSyncRequestParams() " + (isLocal ? "LOCAL" : "CLOUD")
+ ", auth=" + authority);
if (DEBUG) {
Log.v(TAG, "Thread=" + Thread.currentThread() + "; Stacktrace:", new Throwable());
}
final SyncRequestParams result;
if (authority == null) {
// Only cloud authority can be null
result = SyncRequestParams.forResetMedia();
} else {
final Bundle cachedMediaCollectionInfo = getCachedMediaCollectionInfo(isLocal);
final Bundle latestMediaCollectionInfo = getLatestMediaCollectionInfo(authority);
final String latestCollectionId =
latestMediaCollectionInfo.getString(MEDIA_COLLECTION_ID);
final long latestGeneration =
latestMediaCollectionInfo.getLong(LAST_MEDIA_SYNC_GENERATION);
Log.d(TAG, " Latest ID/Gen=" + latestCollectionId + "/" + latestGeneration);
final String cachedCollectionId =
cachedMediaCollectionInfo.getString(MEDIA_COLLECTION_ID);
final long cachedGeneration =
cachedMediaCollectionInfo.getLong(LAST_MEDIA_SYNC_GENERATION);
Log.d(TAG, " Cached ID/Gen=" + cachedCollectionId + "/" + cachedGeneration);
if (TextUtils.isEmpty(latestCollectionId) || latestGeneration < 0) {
throw new IllegalStateException("Unexpected Latest Media Collection Info: "
+ "ID/Gen=" + latestCollectionId + "/" + latestGeneration);
}
if (!Objects.equals(latestCollectionId, cachedCollectionId)) {
result = SyncRequestParams.forFullMedia(latestMediaCollectionInfo);
} else if (cachedGeneration == latestGeneration) {
result = SyncRequestParams.forNone();
} else {
result = SyncRequestParams.forIncremental(
cachedGeneration, latestMediaCollectionInfo);
}
}
Log.d(TAG, " RESULT=" + result);
return result;
}
private String getPrefsKey(boolean isLocal, String key) {
return (isLocal ? PREFS_KEY_LOCAL_PREFIX : PREFS_KEY_CLOUD_PREFIX) + key;
}
private Cursor query(Uri uri, Bundle extras) {
return mContext.getContentResolver().query(uri, /* projection */ null, extras,
/* cancellationSignal */ null);
}
/**
* Creates a matching {@link PickerDbFacade.DbWriteOperation} for the given
* {@link OperationType}.
*
* @param op {@link OperationType} Which type of paged operation to begin.
* @param authority The authority string of the sync provider.
* @param albumId An {@link Nullable} AlbumId for album related operations.
* @throws IllegalArgumentException When an unexpected op type is encountered.
*/
private PickerDbFacade.DbWriteOperation beginPagedOperation(
@OperationType int op, String authority, @Nullable String albumId)
throws IllegalArgumentException {
switch (op) {
case OPERATION_ADD_MEDIA:
return mDbFacade.beginAddMediaOperation(authority);
case OPERATION_ADD_ALBUM:
Objects.requireNonNull(
albumId, "Cannot begin an AddAlbum operation without albumId");
return mDbFacade.beginAddAlbumMediaOperation(authority, albumId);
case OPERATION_REMOVE_MEDIA:
return mDbFacade.beginRemoveMediaOperation(authority);
default:
throw new IllegalArgumentException(
"Cannot begin a paged operation without an expected operation type.");
}
}
/**
* Executes a page-by-page sync from the provider.
*
* @param uri The uri to query for a cursor.
* @param expectedMediaCollectionId The expected media collection id.
* @param expectedHonoredArgs The arguments that are expected to be present in cursors fetched
* from the provider.
* @param queryArgs Any query arguments that are to be passed to the provider when fetching the
* cursor.
* @param resumeKey The resumable operation key. This is used to check for previously failed
* operations so they can be resumed at the last successful page, and also to save progress
* between pages.
* @param op The DbWriteOperation type. {@link OperationType}
* @param authority The authority string of the provider to sync with.
*/
private void executePagedSync(
Uri uri,
String expectedMediaCollectionId,
List<String> expectedHonoredArgs,
Bundle queryArgs,
@Nullable String resumeKey,
@OperationType int op,
String authority) {
executePagedSync(
uri,
expectedMediaCollectionId,
expectedHonoredArgs,
queryArgs,
resumeKey,
op,
authority,
/* albumId=*/ null);
}
/**
* Executes a page-by-page sync from the provider.
*
* @param uri The uri to query for a cursor.
* @param expectedMediaCollectionId The expected media collection id.
* @param expectedHonoredArgs The arguments that are expected to be present in cursors fetched
* from the provider.
* @param queryArgs Any query arguments that are to be passed to the provider when fetching the
* cursor.
* @param resumeKey The resumable operation key. This is used to check for previously failed
* operations so they can be resumed at the last successful page, and also to save progress
* between pages.
* @param op The DbWriteOperation type. {@link OperationType}
* @param authority The authority string of the provider to sync with.
* @param albumId A {@link Nullable} albumId for album related operations.
*/
private void executePagedSync(
Uri uri,
String expectedMediaCollectionId,
List<String> expectedHonoredArgs,
Bundle queryArgs,
@Nullable String resumeKey,
@OperationType int op,
String authority,
@Nullable String albumId) {
Trace.beginSection(traceSectionName("executePagedSync"));
try {
int totalRowcount = 0;
// Set to check the uniqueness of tokens across pages.
Set<String> tokens = new ArraySet<>();
String nextPageToken = getPageTokenFromResumeKey(resumeKey);
if (nextPageToken != null) {
Log.i(
TAG,
String.format(
"Resumable operation found for %s, resuming with page token %s",
resumeKey, nextPageToken));
}
do {
try (PickerDbFacade.DbWriteOperation operation =
beginPagedOperation(op, authority, albumId)) {
if (nextPageToken != null) {
queryArgs.putString(EXTRA_PAGE_TOKEN, nextPageToken);
}
try (Cursor cursor = query(uri, queryArgs)) {
nextPageToken =
validateCursor(
cursor,
expectedMediaCollectionId,
expectedHonoredArgs,
tokens);
int writeCount = operation.execute(cursor);
totalRowcount += writeCount;
}
operation.setSuccess();
} catch (IllegalArgumentException ex) {
Log.e(TAG, String.format("Failed to open DbWriteOperation for op: %d", op), ex);
return;
}
// Keep track of the next page token in case this operation crashes and is
// later resumed.
rememberNextPageToken(nextPageToken, resumeKey);
} while (nextPageToken != null);
Log.i(
TAG,
"Paged sync successful. QueryArgs: "
+ queryArgs
+ " Total Rows: "
+ totalRowcount);
} finally {
Trace.endSection();
}
}
/**
* Get the default {@link CloudProviderInfo} at {@link PickerSyncController} construction
*/
@VisibleForTesting
CloudProviderInfo getDefaultCloudProviderInfo(@Nullable String lastProvider) {
final List<CloudProviderInfo> providers = getAvailableCloudProviders();
if (providers.size() == 1) {
Log.i(TAG, "Only 1 cloud provider found, hence " + providers.get(0).authority
+ " is the default");
return providers.get(0);
} else {
Log.i(TAG, "Found " + providers.size() + " available Cloud Media Providers.");
}
if (lastProvider != null) {
for (CloudProviderInfo provider : providers) {
if (Objects.equals(provider.authority, lastProvider)) {
return provider;
}
}
}
final String defaultProviderPkg = mConfigStore.getDefaultCloudProviderPackage();
if (defaultProviderPkg != null) {
Log.i(TAG, "Default Cloud-Media-Provider package is " + defaultProviderPkg);
for (CloudProviderInfo provider : providers) {
if (provider.matches(defaultProviderPkg)) {
return provider;
}
}
} else {
Log.i(TAG, "Default Cloud-Media-Provider is not set.");
}
// No default set or default not installed
return CloudProviderInfo.EMPTY;
}
private static String traceSectionName(@NonNull String method) {
return "PSC." + method;
}
private static String traceSectionName(@NonNull String method, boolean isLocal) {
return traceSectionName(method)
+ "[" + (isLocal ? "local" : "cloud") + ']';
}
private static String validateCursor(Cursor cursor, String expectedMediaCollectionId,
List<String> expectedHonoredArgs, Set<String> usedPageTokens) {
final Bundle bundle = cursor.getExtras();
if (bundle == null) {
throw new IllegalStateException("Unable to verify the media collection id");
}
final String mediaCollectionId = bundle.getString(EXTRA_MEDIA_COLLECTION_ID);
final String pageToken = bundle.getString(EXTRA_PAGE_TOKEN);
List<String> honoredArgs = bundle.getStringArrayList(EXTRA_HONORED_ARGS);
if (honoredArgs == null) {
honoredArgs = new ArrayList<>();
}
if (expectedMediaCollectionId != null
&& !expectedMediaCollectionId.equals(mediaCollectionId)) {
throw new IllegalStateException("Mismatched media collection id. Expected: "
+ expectedMediaCollectionId + ". Found: " + mediaCollectionId);
}
if (!honoredArgs.containsAll(expectedHonoredArgs)) {
throw new IllegalStateException("Unspecified honored args. Expected: "
+ Arrays.toString(expectedHonoredArgs.toArray())
+ ". Found: " + Arrays.toString(honoredArgs.toArray()));
}
if (usedPageTokens.contains(pageToken)) {
throw new IllegalStateException("Found repeated page token: " + pageToken);
} else {
usedPageTokens.add(pageToken);
}
return pageToken;
}
private static class SyncRequestParams {
static final SyncRequestParams SYNC_REQUEST_NONE = new SyncRequestParams(SYNC_TYPE_NONE);
static final SyncRequestParams SYNC_REQUEST_MEDIA_RESET =
new SyncRequestParams(SYNC_TYPE_MEDIA_RESET);
final int syncType;
// Only valid for SYNC_TYPE_INCREMENTAL
final long syncGeneration;
// Only valid for SYNC_TYPE_[INCREMENTAL|FULL]
final Bundle latestMediaCollectionInfo;
// Only valid for sync triggered by opening photopicker activity.
// Not valid for proactive syncs.
final int mPageSize;
SyncRequestParams(@SyncType int syncType) {
this(syncType, /* syncGeneration */ 0, /* latestMediaCollectionInfo */ null,
/*pageSize */ PAGE_SIZE);
}
SyncRequestParams(@SyncType int syncType, long syncGeneration,
Bundle latestMediaCollectionInfo, int pageSize) {
this.syncType = syncType;
this.syncGeneration = syncGeneration;
this.latestMediaCollectionInfo = latestMediaCollectionInfo;
this.mPageSize = pageSize;
}
String getMediaCollectionId() {
return latestMediaCollectionInfo.getString(MEDIA_COLLECTION_ID);
}
static SyncRequestParams forNone() {
return SYNC_REQUEST_NONE;
}
static SyncRequestParams forResetMedia() {
return SYNC_REQUEST_MEDIA_RESET;
}
static SyncRequestParams forFullMedia(Bundle latestMediaCollectionInfo) {
return new SyncRequestParams(SYNC_TYPE_MEDIA_FULL, /* generation */ 0,
latestMediaCollectionInfo, /*pageSize */ PAGE_SIZE);
}
static SyncRequestParams forIncremental(long generation, Bundle latestMediaCollectionInfo) {
return new SyncRequestParams(SYNC_TYPE_MEDIA_INCREMENTAL, generation,
latestMediaCollectionInfo, /*pageSize */ PAGE_SIZE);
}
@Override
public String toString() {
return "SyncRequestParams{type=" + syncTypeToString(syncType)
+ ", gen=" + syncGeneration + ", latest=" + latestMediaCollectionInfo
+ ", pageSize=" + mPageSize + '}';
}
}
private static String syncTypeToString(@SyncType int syncType) {
switch (syncType) {
case SYNC_TYPE_NONE:
return "NONE";
case SYNC_TYPE_MEDIA_INCREMENTAL:
return "MEDIA_INCREMENTAL";
case SYNC_TYPE_MEDIA_FULL:
return "MEDIA_FULL";
case SYNC_TYPE_MEDIA_RESET:
return "MEDIA_RESET";
default:
return "Unknown";
}
}
private static boolean isCloudProviderUnset(@Nullable String lastProviderAuthority) {
return Objects.equals(lastProviderAuthority, PREFS_VALUE_CLOUD_PROVIDER_UNSET);
}
/**
* Print the {@link PickerSyncController} state into the given stream.
*/
public void dump(PrintWriter writer) {
writer.println("Picker sync controller state:");
writer.println(" mLocalProvider=" + getLocalProvider());
writer.println(" mCloudProviderInfo=" + getCurrentCloudProviderInfo());
writer.println(" allAvailableCloudProviders="
+ CloudProviderUtils.getAllAvailableCloudProviders(mContext, mConfigStore));
writer.println(" cachedAuthority="
+ mUserPrefs.getString(PREFS_KEY_CLOUD_PROVIDER_AUTHORITY, /* defValue */ null));
writer.println(" cachedLocalMediaCollectionInfo="
+ getCachedMediaCollectionInfo(/* isLocal */ true));
writer.println(" cachedCloudMediaCollectionInfo="
+ getCachedMediaCollectionInfo(/* isLocal */ false));
}
}