blob: a0b0175adf1ac9b2d1f755321f697e36634042e6 [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.viewmodel;
import static android.content.Intent.ACTION_GET_CONTENT;
import static android.content.Intent.EXTRA_LOCAL_ONLY;
import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED;
import static com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED;
import android.annotation.SuppressLint;
import android.app.Application;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.provider.MediaStore;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.annotation.VisibleForTesting;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import com.android.internal.logging.InstanceId;
import com.android.internal.logging.InstanceIdSequence;
import com.android.modules.utils.build.SdkLevel;
import com.android.providers.media.ConfigStore;
import com.android.providers.media.MediaApplication;
import com.android.providers.media.photopicker.data.ItemsProvider;
import com.android.providers.media.photopicker.data.MuteStatus;
import com.android.providers.media.photopicker.data.Selection;
import com.android.providers.media.photopicker.data.UserIdManager;
import com.android.providers.media.photopicker.data.model.Category;
import com.android.providers.media.photopicker.data.model.Item;
import com.android.providers.media.photopicker.data.model.UserId;
import com.android.providers.media.photopicker.metrics.PhotoPickerUiEventLogger;
import com.android.providers.media.photopicker.util.MimeFilterUtils;
import com.android.providers.media.util.ForegroundThread;
import com.android.providers.media.util.MimeUtils;
import java.util.ArrayList;
import java.util.List;
/**
* PickerViewModel to store and handle data for PhotoPickerActivity.
*/
public class PickerViewModel extends AndroidViewModel {
public static final String TAG = "PhotoPicker";
private static final int RECENT_MINIMUM_COUNT = 12;
private static final int INSTANCE_ID_MAX = 1 << 15;
@NonNull
@SuppressLint("StaticFieldLeak")
private final Context mAppContext;
private final Selection mSelection;
private final MuteStatus mMuteStatus;
// TODO(b/193857982): We keep these four data sets now, we may need to find a way to reduce the
// data set to reduce memories.
// The list of Items with all photos and videos
private MutableLiveData<List<Item>> mItemList;
// The list of Items with all photos and videos in category
private MutableLiveData<List<Item>> mCategoryItemList;
// The list of categories.
private MutableLiveData<List<Category>> mCategoryList;
private ItemsProvider mItemsProvider;
private UserIdManager mUserIdManager;
private BannerManager mBannerManager;
private InstanceId mInstanceId;
private PhotoPickerUiEventLogger mLogger;
private String[] mMimeTypeFilters = null;
private int mBottomSheetState;
private Category mCurrentCategory;
// Note - Must init banner manager on mIsUserSelectForApp / mIsLocalOnly updates
private boolean mIsUserSelectForApp;
private boolean mIsLocalOnly;
public PickerViewModel(@NonNull Application application) {
super(application);
mAppContext = application.getApplicationContext();
mItemsProvider = new ItemsProvider(mAppContext);
mSelection = new Selection();
mUserIdManager = UserIdManager.create(mAppContext);
mMuteStatus = new MuteStatus();
mInstanceId = new InstanceIdSequence(INSTANCE_ID_MAX).newInstanceId();
mLogger = new PhotoPickerUiEventLogger();
mIsUserSelectForApp = false;
mIsLocalOnly = false;
// Must init banner manager on mIsUserSelectForApp / mIsLocalOnly updates
initBannerManager();
}
@VisibleForTesting
public void setItemsProvider(@NonNull ItemsProvider itemsProvider) {
mItemsProvider = itemsProvider;
}
@VisibleForTesting
public void setUserIdManager(@NonNull UserIdManager userIdManager) {
mUserIdManager = userIdManager;
}
/**
* @return {@link UserIdManager} for this context.
*/
public UserIdManager getUserIdManager() {
return mUserIdManager;
}
/**
* @return {@code mSelection} that manages the selection
*/
public Selection getSelection() {
return mSelection;
}
/**
* @return {@code mMuteStatus} that tracks the volume mute status of the video preview
*/
public MuteStatus getMuteStatus() {
return mMuteStatus;
}
/**
* @return {@code mIsUserSelectForApp} if the picker is currently being used
* for the {@link MediaStore#ACTION_USER_SELECT_IMAGES_FOR_APP} action.
*/
public boolean isUserSelectForApp() {
return mIsUserSelectForApp;
}
/**
* @return a {@link LiveData} that holds the value (once it's fetched) of the
* {@link android.content.ContentProvider#mAuthority authority} of the current
* {@link android.provider.CloudMediaProvider}.
*/
@NonNull
public LiveData<String> getCloudMediaProviderAuthorityLiveData() {
return mBannerManager.getCloudMediaProviderAuthorityLiveData();
}
/**
* @return a {@link LiveData} that holds the value (once it's fetched) of the label
* of the current {@link android.provider.CloudMediaProvider}.
*/
@NonNull
public LiveData<String> getCloudMediaProviderAppTitleLiveData() {
return mBannerManager.getCloudMediaProviderAppTitleLiveData();
}
/**
* @return a {@link LiveData} that holds the value (once it's fetched) of the account name
* of the current {@link android.provider.CloudMediaProvider}.
*/
@NonNull
public LiveData<String> getCloudMediaAccountNameLiveData() {
return mBannerManager.getCloudMediaAccountNameLiveData();
}
/**
* Reset PickerViewModel.
* @param switchToPersonalProfile is true then set personal profile as current profile.
*/
@UiThread
public void reset(boolean switchToPersonalProfile) {
// 1. Clear Selected items
mSelection.clearSelectedItems();
// 2. Change profile to personal user
if (switchToPersonalProfile) {
mUserIdManager.setPersonalAsCurrentUserProfile();
}
// 3. Update Item and Category lists
updateItems();
updateCategories();
// 4. Update Banners
// Note - Banners should always be updated after the items & categories to ensure a
// consistent UI.
mBannerManager.maybeResetAllBannerData();
mBannerManager.maybeUpdateBannerLiveDatas();
}
/**
* Update items, categories & banners on profile switched by the user.
*/
@UiThread
public void onUserSwitchedProfile() {
updateItems();
updateCategories();
// Note - Banners should always be updated after the items & categories to ensure a
// consistent UI.
mBannerManager.maybeUpdateBannerLiveDatas();
}
/**
* @return the list of Items with all photos and videos {@link #mItemList} on the device.
*/
public LiveData<List<Item>> getItems() {
if (mItemList == null) {
updateItems();
}
return mItemList;
}
private List<Item> loadItems(Category category, UserId userId) {
final List<Item> items = new ArrayList<>();
try (Cursor cursor = fetchItems(category, userId)) {
if (cursor == null || cursor.getCount() == 0) {
Log.d(TAG, "Didn't receive any items for " + category
+ ", either cursor is null or cursor count is zero");
return items;
}
while (cursor.moveToNext()) {
// TODO(b/188394433): Return userId in the cursor so that we do not need to pass it
// here again.
items.add(Item.fromCursor(cursor, userId));
}
}
Log.d(TAG, "Loaded " + items.size() + " items in " + category + " for user "
+ userId.toString());
return items;
}
private Cursor fetchItems(Category category, UserId userId) {
if (shouldShowOnlyLocalFeatures()) {
return mItemsProvider.getLocalItems(category, /* limit */ -1, mMimeTypeFilters, userId);
} else {
return mItemsProvider.getAllItems(category, /* limit */ -1, mMimeTypeFilters, userId);
}
}
private void loadItemsAsync() {
final UserId userId = mUserIdManager.getCurrentUserProfileId();
ForegroundThread.getExecutor().execute(() -> {
mItemList.postValue(loadItems(Category.DEFAULT, userId));
});
}
/**
* Update the item List {@link #mItemList}
*/
public void updateItems() {
if (mItemList == null) {
mItemList = new MutableLiveData<>();
}
loadItemsAsync();
}
/**
* Get the list of all photos and videos with the specific {@code category} on the device.
*
* In our use case, we only keep the list of current category {@link #mCurrentCategory} in
* {@link #mCategoryItemList}. If the {@code category} and {@link #mCurrentCategory} are
* different, we will create the new LiveData to {@link #mCategoryItemList}.
*
* @param category the category we want to be queried
* @return the list of all photos and videos with the specific {@code category}
* {@link #mCategoryItemList}
*/
public LiveData<List<Item>> getCategoryItems(@NonNull Category category) {
if (mCategoryItemList == null || !TextUtils.equals(mCurrentCategory.getId(),
category.getId())) {
mCategoryItemList = new MutableLiveData<>();
mCurrentCategory = category;
}
updateCategoryItems();
return mCategoryItemList;
}
private void loadCategoryItemsAsync() {
final UserId userId = mUserIdManager.getCurrentUserProfileId();
ForegroundThread.getExecutor().execute(() -> {
mCategoryItemList.postValue(loadItems(mCurrentCategory, userId));
});
}
/**
* Update the item List with the {@link #mCurrentCategory} {@link #mCategoryItemList}
*
* @throws IllegalStateException category and category items is not initiated before calling
* this method
*/
@VisibleForTesting
public void updateCategoryItems() {
if (mCategoryItemList == null || mCurrentCategory == null) {
throw new IllegalStateException("mCurrentCategory and mCategoryItemList are not"
+ " initiated. Please call getCategoryItems before calling this method");
}
loadCategoryItemsAsync();
}
/**
* @return the list of Categories {@link #mCategoryList}
*/
public LiveData<List<Category>> getCategories() {
if (mCategoryList == null) {
updateCategories();
}
return mCategoryList;
}
private List<Category> loadCategories(UserId userId) {
final List<Category> categoryList = new ArrayList<>();
try (Cursor cursor = fetchCategories(userId)) {
if (cursor == null || cursor.getCount() == 0) {
Log.d(TAG, "Didn't receive any categories, either cursor is null or"
+ " cursor count is zero");
return categoryList;
}
while (cursor.moveToNext()) {
final Category category = Category.fromCursor(cursor, userId);
categoryList.add(category);
}
Log.d(TAG,
"Loaded " + categoryList.size() + " categories for user " + userId.toString());
}
return categoryList;
}
private Cursor fetchCategories(UserId userId) {
if (shouldShowOnlyLocalFeatures()) {
return mItemsProvider.getLocalCategories(mMimeTypeFilters, userId);
} else {
return mItemsProvider.getAllCategories(mMimeTypeFilters, userId);
}
}
private void loadCategoriesAsync() {
final UserId userId = mUserIdManager.getCurrentUserProfileId();
ForegroundThread.getExecutor().execute(() -> {
mCategoryList.postValue(loadCategories(userId));
});
}
/**
* Update the category List {@link #mCategoryList}
*/
public void updateCategories() {
if (mCategoryList == null) {
mCategoryList = new MutableLiveData<>();
}
loadCategoriesAsync();
}
/**
* Return whether the {@link #mMimeTypeFilters} is {@code null} or not
*/
public boolean hasMimeTypeFilters() {
return mMimeTypeFilters != null && mMimeTypeFilters.length > 0;
}
private boolean isAllImagesFilter() {
return mMimeTypeFilters != null && mMimeTypeFilters.length == 1
&& MimeUtils.isAllImagesMimeType(mMimeTypeFilters[0]);
}
private boolean isAllVideosFilter() {
return mMimeTypeFilters != null && mMimeTypeFilters.length == 1
&& MimeUtils.isAllVideosMimeType(mMimeTypeFilters[0]);
}
/**
* Parse values from {@code intent} and set corresponding fields
*/
public void parseValuesFromIntent(Intent intent) throws IllegalArgumentException {
mUserIdManager.setIntentAndCheckRestrictions(intent);
mMimeTypeFilters = MimeFilterUtils.getMimeTypeFilters(intent);
mSelection.parseSelectionValuesFromIntent(intent);
mIsLocalOnly = intent.getBooleanExtra(EXTRA_LOCAL_ONLY, false);
mIsUserSelectForApp =
MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP.equals(intent.getAction());
if (!SdkLevel.isAtLeastU() && mIsUserSelectForApp) {
throw new IllegalArgumentException("ACTION_USER_SELECT_IMAGES_FOR_APP is not enabled "
+ " for this OS version");
}
// Ensure that if Photopicker is being used for permissions the target app UID is present
// in the extras.
if (mIsUserSelectForApp
&& (intent.getExtras() == null
|| !intent.getExtras()
.containsKey(Intent.EXTRA_UID))) {
throw new IllegalArgumentException(
"EXTRA_UID is required for" + " ACTION_USER_SELECT_IMAGES_FOR_APP");
}
// Must init banner manager on mIsUserSelectForApp / mIsLocalOnly updates
initBannerManager();
}
private void initBannerManager() {
mBannerManager = shouldShowOnlyLocalFeatures()
? new BannerManager(mAppContext, mUserIdManager)
: new BannerManager.CloudBannerManager(mAppContext, mUserIdManager);
}
/**
* Set BottomSheet state
*/
public void setBottomSheetState(int state) {
mBottomSheetState = state;
}
/**
* @return BottomSheet state
*/
public int getBottomSheetState() {
return mBottomSheetState;
}
/**
* Log picker opened metrics
*/
public void logPickerOpened(int callingUid, String callingPackage, String intentAction) {
if (getUserIdManager().isManagedUserSelected()) {
mLogger.logPickerOpenWork(mInstanceId, callingUid, callingPackage);
} else {
mLogger.logPickerOpenPersonal(mInstanceId, callingUid, callingPackage);
}
// TODO(b/235326735): Optimise logging multiple times on picker opened
// TODO(b/235326736): Check if we should add a metric for PICK_IMAGES intent to simplify
// metrics reading
if (ACTION_GET_CONTENT.equals(intentAction)) {
mLogger.logPickerOpenViaGetContent(mInstanceId, callingUid, callingPackage);
}
if (mBottomSheetState == STATE_COLLAPSED) {
mLogger.logPickerOpenInHalfScreen(mInstanceId, callingUid, callingPackage);
} else if (mBottomSheetState == STATE_EXPANDED) {
mLogger.logPickerOpenInFullScreen(mInstanceId, callingUid, callingPackage);
}
if (mSelection != null && mSelection.canSelectMultiple()) {
mLogger.logPickerOpenInMultiSelect(mInstanceId, callingUid, callingPackage);
} else {
mLogger.logPickerOpenInSingleSelect(mInstanceId, callingUid, callingPackage);
}
if (isAllImagesFilter()) {
mLogger.logPickerOpenWithFilterAllImages(mInstanceId, callingUid, callingPackage);
} else if (isAllVideosFilter()) {
mLogger.logPickerOpenWithFilterAllVideos(mInstanceId, callingUid, callingPackage);
} else if (hasMimeTypeFilters()) {
mLogger.logPickerOpenWithAnyOtherFilter(mInstanceId, callingUid, callingPackage);
}
maybeLogPickerOpenedWithCloudProvider();
}
// TODO(b/245745412): Fix log params (uid & package name)
// TODO(b/245745424): Solve for active cloud provider without a logged in account
private void maybeLogPickerOpenedWithCloudProvider() {
if (shouldShowOnlyLocalFeatures()) {
return;
}
final LiveData<String> cloudMediaProviderAuthorityLiveData =
getCloudMediaProviderAuthorityLiveData();
cloudMediaProviderAuthorityLiveData.observeForever(new Observer<String>() {
@Override
public void onChanged(@Nullable String providerAuthority) {
Log.d(TAG, "logPickerOpenedWithCloudProvider() provider=" + providerAuthority
+ ", log=" + (providerAuthority != null));
if (providerAuthority != null) {
mLogger.logPickerOpenWithActiveCloudProvider(
mInstanceId, /* cloudProviderUid */ -1, providerAuthority);
}
// We only need to get the value once.
cloudMediaProviderAuthorityLiveData.removeObserver(this);
}
});
}
/**
* Log metrics to notify that the user has clicked Browse to open DocumentsUi
*/
public void logBrowseToDocumentsUi(int callingUid, String callingPackage) {
mLogger.logBrowseToDocumentsUi(mInstanceId, callingUid, callingPackage);
}
/**
* Log metrics to notify that the user has confirmed selection
*/
public void logPickerConfirm(int callingUid, String callingPackage, int countOfItemsConfirmed) {
if (getUserIdManager().isManagedUserSelected()) {
mLogger.logPickerConfirmWork(mInstanceId, callingUid, callingPackage,
countOfItemsConfirmed);
} else {
mLogger.logPickerConfirmPersonal(mInstanceId, callingUid, callingPackage,
countOfItemsConfirmed);
}
}
/**
* Log metrics to notify that the user has exited Picker without any selection
*/
public void logPickerCancel(int callingUid, String callingPackage) {
if (getUserIdManager().isManagedUserSelected()) {
mLogger.logPickerCancelWork(mInstanceId, callingUid, callingPackage);
} else {
mLogger.logPickerCancelPersonal(mInstanceId, callingUid, callingPackage);
}
}
public InstanceId getInstanceId() {
return mInstanceId;
}
public void setInstanceId(InstanceId parcelable) {
mInstanceId = parcelable;
}
// Return whether hotopicker's launch intent has extra {@link EXTRA_LOCAL_ONLY} set to true
// or not.
@VisibleForTesting
boolean isLocalOnly() {
return mIsLocalOnly;
}
/**
* Return whether only the local features should be shown (the cloud features should be hidden).
*
* Show only the local features in the following cases -
* 1. Photo Picker is launched by the {@link MediaStore#ACTION_USER_SELECT_IMAGES_FOR_APP}
* action for the permission flow.
* 2. Photo Picker is launched with the {@link Intent#EXTRA_LOCAL_ONLY} as {@code true} in the
* {@link Intent#ACTION_GET_CONTENT} or {@link MediaStore#ACTION_PICK_IMAGES} action.
* 3. Cloud Media in Photo picker is disabled, i.e.,
* {@link ConfigStore#isCloudMediaInPhotoPickerEnabled()} is {@code false}.
*
* @return {@code true} iff either {@link #isUserSelectForApp()} or {@link #isLocalOnly()} is
* {@code true}, OR if {@link ConfigStore#isCloudMediaInPhotoPickerEnabled()} is {@code false}.
*/
public boolean shouldShowOnlyLocalFeatures() {
return isUserSelectForApp() || isLocalOnly()
|| !getConfigStore().isCloudMediaInPhotoPickerEnabled();
}
@VisibleForTesting
protected ConfigStore getConfigStore() {
return MediaApplication.getConfigStore();
}
/**
* @return the {@link LiveData} of the 'Choose App' banner visibility.
*/
@NonNull
public LiveData<Boolean> shouldShowChooseAppBannerLiveData() {
return mBannerManager.shouldShowChooseAppBannerLiveData();
}
/**
* @return the {@link LiveData} of the 'Cloud Media Available' banner visibility.
*/
@NonNull
public LiveData<Boolean> shouldShowCloudMediaAvailableBannerLiveData() {
return mBannerManager.shouldShowCloudMediaAvailableBannerLiveData();
}
/**
* @return the {@link LiveData} of the 'Account Updated' banner visibility.
*/
@NonNull
public LiveData<Boolean> shouldShowAccountUpdatedBannerLiveData() {
return mBannerManager.shouldShowAccountUpdatedBannerLiveData();
}
/**
* @return the {@link LiveData} of the 'Choose Account' banner visibility.
*/
@NonNull
public LiveData<Boolean> shouldShowChooseAccountBannerLiveData() {
return mBannerManager.shouldShowChooseAccountBannerLiveData();
}
/**
* Dismiss (hide) the 'Choose App' banner for the current user.
*/
@UiThread
public void onUserDismissedChooseAppBanner() {
mBannerManager.onUserDismissedChooseAppBanner();
}
/**
* Dismiss (hide) the 'Cloud Media Available' banner for the current user.
*/
@UiThread
public void onUserDismissedCloudMediaAvailableBanner() {
mBannerManager.onUserDismissedCloudMediaAvailableBanner();
}
/**
* Dismiss (hide) the 'Account Updated' banner for the current user.
*/
@UiThread
public void onUserDismissedAccountUpdatedBanner() {
mBannerManager.onUserDismissedAccountUpdatedBanner();
}
/**
* Dismiss (hide) the 'Choose Account' banner for the current user.
*/
@UiThread
public void onUserDismissedChooseAccountBanner() {
mBannerManager.onUserDismissedChooseAccountBanner();
}
}