blob: b2fc873a9e3de86fb00291f33bae751d45404d91 [file] [log] [blame]
/*
* Copyright (C) 2022 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.viewmodel;
import static android.provider.MediaStore.getCurrentCloudProvider;
import static com.android.providers.media.photopicker.util.CloudProviderUtils.getAvailableCloudProviders;
import static com.android.providers.media.photopicker.util.CloudProviderUtils.getCloudMediaAccountName;
import static com.android.providers.media.photopicker.util.CloudProviderUtils.getProviderLabelForUser;
import android.content.ContentResolver;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Looper;
import android.os.UserHandle;
import android.text.TextUtils;
import android.util.AtomicFile;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.providers.media.ConfigStore;
import com.android.providers.media.photopicker.data.model.UserId;
import com.android.providers.media.util.XmlUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
/**
* Banner Controller to store and handle the banner data per user for
* {@link com.android.providers.media.photopicker.PhotoPickerActivity}.
*/
class BannerController {
private static final String TAG = "BannerController";
private static final String DATA_MEDIA_DIRECTORY_PATH = "/data/media/";
private static final String LAST_CLOUD_PROVIDER_DATA_FILE_PATH_IN_USER_MEDIA_DIR =
"/.transforms/picker/last_cloud_provider_info";
/**
* {@link #mCloudProviderDataMap} key to the last fetched
* {@link android.provider.CloudMediaProvider} authority.
*/
private static final String AUTHORITY = "authority";
/**
* {@link #mCloudProviderDataMap} key to the last fetched account name in the then fetched
* {@link android.provider.CloudMediaProvider}.
*/
private static final String ACCOUNT_NAME = "account_name";
private static final long GET_ACCOUNT_NAME_TIMEOUT_IN_MILLIS = 100L;
private final Context mContext;
private final UserHandle mUserHandle;
private final ConfigStore mConfigStore;
/**
* {@link File} for persisting the last fetched {@link android.provider.CloudMediaProvider}
* data.
*/
private final File mLastCloudProviderDataFile;
/**
* Last fetched {@link android.provider.CloudMediaProvider} data.
*/
private final Map<String, String> mCloudProviderDataMap = new HashMap<>();
// Label of the current cloud media provider
private String mCmpLabel;
// Boolean 'Choose App' banner visibility
private boolean mShowChooseAppBanner;
// Boolean 'Cloud Media Available' banner visibility
private boolean mShowCloudMediaAvailableBanner;
// Boolean 'Account Updated' banner visibility
private boolean mShowAccountUpdatedBanner;
// Boolean 'Choose Account' banner visibility
private boolean mShowChooseAccountBanner;
BannerController(@NonNull Context context, @NonNull UserHandle userHandle,
@NonNull ConfigStore configStore) {
Log.d(TAG, "Constructing the BannerController for user " + userHandle.getIdentifier());
mContext = context;
mUserHandle = userHandle;
mConfigStore = configStore;
final String lastCloudProviderDataFilePath = DATA_MEDIA_DIRECTORY_PATH
+ userHandle.getIdentifier() + LAST_CLOUD_PROVIDER_DATA_FILE_PATH_IN_USER_MEDIA_DIR;
mLastCloudProviderDataFile = new File(lastCloudProviderDataFilePath);
loadCloudProviderInfo();
initialise();
}
/**
* Same as {@link #initialise()}, renamed for readability.
*/
void reset() {
Log.d(TAG, "Resetting the BannerController for user " + mUserHandle.getIdentifier());
initialise();
}
/**
* Initialise the banner controller data
*
* 0. Assert non-main thread.
* 1. Fetch the latest cloud provider info.
* 2. {@link #onChangeCloudMediaInfo(String, String)} with the newly fetched authority and
* account name.
*
* Note : This method is expected to be called only in a non-main thread since we shouldn't
* block the UI thread on the heavy Binder calls to fetch the cloud media provider info.
*/
private void initialise() {
String cmpAuthority = null, cmpAccountName = null;
mCmpLabel = null;
// TODO(b/245746037): Remove try-catch for the RuntimeException.
// Under the hood MediaStore.getCurrentCloudProvider() makes an IPC call to the primary
// MediaProvider process, where we currently perform a UID check (making sure that
// the call both sender and receiver belong to the same UID).
// This setup works for our "regular" PhotoPickerActivity (running in :PhotoPicker
// process), but does not work for our test applications (installed to a different
// UID), that provide a mock PhotoPickerActivity which will also run this code.
// SOLUTION: replace the UID check on the receiving end (in MediaProvider) with a
// check for MANAGE_CLOUD_MEDIA_PROVIDER permission.
try {
// 0. Assert non-main thread.
assertNonMainThread();
// 1. Fetch the latest cloud provider info.
final ContentResolver contentResolver =
UserId.of(mUserHandle).getContentResolver(mContext);
cmpAuthority = getCurrentCloudProvider(contentResolver);
mCmpLabel = getProviderLabelForUser(mContext, mUserHandle, cmpAuthority);
cmpAccountName = getCloudMediaAccountName(contentResolver, cmpAuthority,
GET_ACCOUNT_NAME_TIMEOUT_IN_MILLIS);
// Not logging the account name due to privacy concerns
Log.d(TAG, "Current CloudMediaProvider authority: " + cmpAuthority + ", label: "
+ mCmpLabel);
} catch (PackageManager.NameNotFoundException | RuntimeException | ExecutionException
| InterruptedException | TimeoutException e) {
Log.w(TAG, "Could not fetch the current CloudMediaProvider", e);
updateCloudProviderDataMap(cmpAuthority, cmpAccountName);
clearBanners();
return;
}
onChangeCloudMediaInfo(cmpAuthority, cmpAccountName);
}
/**
* On Change Cloud Media Info
*
* @param cmpAuthority Current {@link android.provider.CloudMediaProvider} authority.
* @param cmpAccountName Current {@link android.provider.CloudMediaProvider} account name.
*
* 1. If the previous & new cloud provider infos are the same, No-op.
* 2. Reset should show banners.
* 3. Update the saved and cached cloud provider info with the latest info.
*/
@VisibleForTesting
void onChangeCloudMediaInfo(@Nullable String cmpAuthority, @Nullable String cmpAccountName) {
// 1. If the previous & new cloud provider infos are the same, No-op.
final String lastCmpAuthority = mCloudProviderDataMap.get(AUTHORITY);
final String lastCmpAccountName = mCloudProviderDataMap.get(ACCOUNT_NAME);
Log.d(TAG, "Last CloudMediaProvider authority: " + lastCmpAuthority);
if (TextUtils.equals(lastCmpAuthority, cmpAuthority)
&& TextUtils.equals(lastCmpAccountName, cmpAccountName)) {
// no-op
return;
}
// 2. Update banner visibilities.
clearBanners();
if (cmpAuthority == null) {
// mShowChooseAppBanner is true iff the new authority is null and the available cloud
// providers list is not empty.
mShowChooseAppBanner = areCloudProviderOptionsAvailable();
} else if (cmpAccountName == null) {
// mShowChooseAccountBanner is true iff the new account name is null while the new
// authority is NOT null.
mShowChooseAccountBanner = true;
} else if (TextUtils.equals(lastCmpAuthority, cmpAuthority)) {
// mShowAccountUpdatedBanner is true iff the new authority AND account name are NOT null
// AND the authority is unchanged.
mShowAccountUpdatedBanner = true;
} else {
// mShowCloudMediaAvailableBanner is true iff the new authority AND account name are
// NOT null AND the authority has changed.
mShowCloudMediaAvailableBanner = true;
}
// 3. Update the saved and cached cloud provider info with the latest info.
persistCloudProviderInfo(cmpAuthority, cmpAccountName);
}
/**
* Clear all banners
*
* Reset all should show banner {@code boolean} values to {@code false}.
*/
private void clearBanners() {
mShowChooseAppBanner = false;
mShowCloudMediaAvailableBanner = false;
mShowAccountUpdatedBanner = false;
mShowChooseAccountBanner = false;
}
@VisibleForTesting
boolean areCloudProviderOptionsAvailable() {
return !getAvailableCloudProviders(mContext, mConfigStore, mUserHandle).isEmpty();
}
/**
* @return the authority of the current {@link android.provider.CloudMediaProvider}.
*/
@Nullable
String getCloudMediaProviderAuthority() {
return mCloudProviderDataMap.get(AUTHORITY);
}
/**
* @return the label of the current {@link android.provider.CloudMediaProvider}.
*/
@Nullable
String getCloudMediaProviderLabel() {
return mCmpLabel;
}
/**
* @return the account name of the current {@link android.provider.CloudMediaProvider}.
*/
@Nullable
String getCloudMediaProviderAccountName() {
return mCloudProviderDataMap.get(ACCOUNT_NAME);
}
/**
* @return the 'Choose App' banner visibility {@link #mShowChooseAppBanner}.
*/
boolean shouldShowChooseAppBanner() {
return mShowChooseAppBanner;
}
/**
* @return the 'Cloud Media Available' banner visibility
* {@link #mShowCloudMediaAvailableBanner}.
*/
boolean shouldShowCloudMediaAvailableBanner() {
return mShowCloudMediaAvailableBanner;
}
/**
* @return the 'Account Updated' banner visibility {@link #mShowAccountUpdatedBanner}.
*/
boolean shouldShowAccountUpdatedBanner() {
return mShowAccountUpdatedBanner;
}
/**
* @return the 'Choose Account' banner visibility {@link #mShowChooseAccountBanner}.
*/
boolean shouldShowChooseAccountBanner() {
return mShowChooseAccountBanner;
}
/**
* Dismiss (hide) the 'Choose App' banner
*
* Set the 'Choose App' banner visibility {@link #mShowChooseAppBanner} as {@code false}.
*/
void onUserDismissedChooseAppBanner() {
if (!mShowChooseAppBanner) {
Log.d(TAG, "Choose app banner visibility for current user is false on dismiss");
} else {
mShowChooseAppBanner = false;
}
}
/**
* Dismiss (hide) the 'Cloud Media Available' banner
*
* Set the 'Cloud Media Available' banner visibility {@link #mShowCloudMediaAvailableBanner}
* as {@code false}.
*/
void onUserDismissedCloudMediaAvailableBanner() {
if (!mShowCloudMediaAvailableBanner) {
Log.d(TAG, "Cloud media available banner visibility for current user is false on "
+ "dismiss");
} else {
mShowCloudMediaAvailableBanner = false;
}
}
/**
* Dismiss (hide) the 'Account Updated' banner
*
* Set the 'Account Updated' banner visibility {@link #mShowAccountUpdatedBanner} as
* {@code false}.
*/
void onUserDismissedAccountUpdatedBanner() {
if (!mShowAccountUpdatedBanner) {
Log.d(TAG, "Account Updated banner visibility for current user is false on dismiss");
} else {
mShowAccountUpdatedBanner = false;
}
}
/**
* Dismiss (hide) the 'Choose Account' banner
*
* Set the 'Choose Account' banner visibility {@link #mShowChooseAccountBanner} as
* {@code false}.
*/
void onUserDismissedChooseAccountBanner() {
if (!mShowChooseAccountBanner) {
Log.d(TAG, "Choose Account banner visibility for current user is false on dismiss");
} else {
mShowChooseAccountBanner = false;
}
}
private static void assertNonMainThread() {
if (!Looper.getMainLooper().isCurrentThread()) {
return;
}
throw new IllegalStateException("Expected to NOT be called from the main thread."
+ " Current thread: " + Thread.currentThread());
}
private void loadCloudProviderInfo() {
FileInputStream fis = null;
final Map<String, String> lastCloudProviderDataMap = new HashMap<>();
try {
if (!mLastCloudProviderDataFile.exists()) {
return;
}
final AtomicFile atomicLastCloudProviderDataFile = new AtomicFile(
mLastCloudProviderDataFile);
fis = atomicLastCloudProviderDataFile.openRead();
lastCloudProviderDataMap.putAll(XmlUtils.readMapXml(fis));
} catch (Exception e) {
Log.w(TAG, "Could not load the cloud provider info.", e);
} finally {
if (fis != null) {
try {
fis.close();
} catch (Exception e) {
Log.w(TAG, "Failed to close the FileInputStream.", e);
}
}
mCloudProviderDataMap.clear();
mCloudProviderDataMap.putAll(lastCloudProviderDataMap);
}
}
private void persistCloudProviderInfo(@Nullable String cmpAuthority,
@Nullable String cmpAccountName) {
updateCloudProviderDataMap(cmpAuthority, cmpAccountName);
updateCloudProviderDataFile();
}
private void updateCloudProviderDataMap(@Nullable String cmpAuthority,
@Nullable String cmpAccountName) {
mCloudProviderDataMap.clear();
if (cmpAuthority != null) {
mCloudProviderDataMap.put(AUTHORITY, cmpAuthority);
}
if (cmpAccountName != null) {
mCloudProviderDataMap.put(ACCOUNT_NAME, cmpAccountName);
}
}
@VisibleForTesting
void updateCloudProviderDataFile() {
FileOutputStream fos = null;
final AtomicFile atomicLastCloudProviderDataFile = new AtomicFile(
mLastCloudProviderDataFile);
try {
fos = atomicLastCloudProviderDataFile.startWrite();
XmlUtils.writeMapXml(mCloudProviderDataMap, fos);
atomicLastCloudProviderDataFile.finishWrite(fos);
} catch (Exception e) {
atomicLastCloudProviderDataFile.failWrite(fos);
Log.w(TAG, "Could not persist the cloud provider info.", e);
}
}
}