blob: 14159c29310da7cd09b3aea80a4757b6cbeb6212 [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.ui;
import static com.android.providers.media.photopicker.ui.DevicePolicyResources.Drawables.Style.OUTLINE;
import static com.android.providers.media.photopicker.ui.DevicePolicyResources.Drawables.WORK_PROFILE_ICON;
import static com.android.providers.media.photopicker.ui.DevicePolicyResources.Strings.SWITCH_TO_PERSONAL_MESSAGE;
import static com.android.providers.media.photopicker.ui.DevicePolicyResources.Strings.SWITCH_TO_WORK_MESSAGE;
import static com.android.providers.media.photopicker.ui.TabAdapter.ITEM_TYPE_BANNER;
import static com.android.providers.media.photopicker.ui.TabAdapter.ITEM_TYPE_SECTION;
import android.app.admin.DevicePolicyManager;
import android.content.Context;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.content.res.TypedArray;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityManager;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.android.modules.utils.build.SdkLevel;
import com.android.providers.media.R;
import com.android.providers.media.photopicker.PhotoPickerActivity;
import com.android.providers.media.photopicker.data.Selection;
import com.android.providers.media.photopicker.data.UserIdManager;
import com.android.providers.media.photopicker.viewmodel.PickerViewModel;
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton;
import java.text.NumberFormat;
import java.util.Locale;
/**
* The base abstract Tab fragment
*/
public abstract class TabFragment extends Fragment {
private static final String TAG = TabFragment.class.getSimpleName();
protected PickerViewModel mPickerViewModel;
protected Selection mSelection;
protected ImageLoader mImageLoader;
protected AutoFitRecyclerView mRecyclerView;
private ExtendedFloatingActionButton mProfileButton;
private UserIdManager mUserIdManager;
private boolean mHideProfileButton;
private View mEmptyView;
private TextView mEmptyTextView;
private boolean mIsAccessibilityEnabled;
private Button mAddButton;
private Button mViewSelectedButton;
private View mBottomBar;
private Animation mSlideUpAnimation;
private Animation mSlideDownAnimation;
@ColorInt
private int mButtonIconAndTextColor;
@ColorInt
private int mButtonBackgroundColor;
@ColorInt
private int mButtonDisabledIconAndTextColor;
@ColorInt
private int mButtonDisabledBackgroundColor;
private int mRecyclerViewBottomPadding;
private RecyclerView.OnScrollListener mOnScrollListenerForMultiProfileButton;
private final MutableLiveData<Boolean> mIsBottomBarVisible = new MutableLiveData<>(false);
private final MutableLiveData<Boolean> mIsProfileButtonVisible = new MutableLiveData<>(false);
@Override
@NonNull
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
super.onCreateView(inflater, container, savedInstanceState);
return inflater.inflate(R.layout.fragment_picker_tab, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
final Context context = requireContext();
final FragmentActivity activity = requireActivity();
mImageLoader = new ImageLoader(context);
mRecyclerView = view.findViewById(R.id.picker_tab_recyclerview);
mRecyclerView.setHasFixedSize(true);
final ViewModelProvider viewModelProvider = new ViewModelProvider(activity);
mPickerViewModel = viewModelProvider.get(PickerViewModel.class);
mSelection = mPickerViewModel.getSelection();
mRecyclerViewBottomPadding = getResources().getDimensionPixelSize(
R.dimen.picker_recycler_view_bottom_padding);
mIsBottomBarVisible.observe(this, val -> updateRecyclerViewBottomPadding());
mIsProfileButtonVisible.observe(this, val -> updateRecyclerViewBottomPadding());
mEmptyView = view.findViewById(android.R.id.empty);
mEmptyTextView = mEmptyView.findViewById(R.id.empty_text_view);
final int[] attrsDisabled =
new int[]{R.attr.pickerDisabledProfileButtonColor,
R.attr.pickerDisabledProfileButtonTextColor};
final TypedArray taDisabled = context.obtainStyledAttributes(attrsDisabled);
mButtonDisabledBackgroundColor = taDisabled.getColor(/* index */ 0, /* defValue */ -1);
mButtonDisabledIconAndTextColor = taDisabled.getColor(/* index */ 1, /* defValue */ -1);
taDisabled.recycle();
final int[] attrs =
new int[]{R.attr.pickerProfileButtonColor, R.attr.pickerProfileButtonTextColor};
final TypedArray ta = context.obtainStyledAttributes(attrs);
mButtonBackgroundColor = ta.getColor(/* index */ 0, /* defValue */ -1);
mButtonIconAndTextColor = ta.getColor(/* index */ 1, /* defValue */ -1);
ta.recycle();
mProfileButton = activity.findViewById(R.id.profile_button);
mUserIdManager = mPickerViewModel.getUserIdManager();
final boolean canSelectMultiple = mSelection.canSelectMultiple();
if (canSelectMultiple) {
mAddButton = activity.findViewById(R.id.button_add);
mViewSelectedButton = activity.findViewById(R.id.button_view_selected);
mAddButton.setOnClickListener(v -> {
try {
requirePickerActivity().setResultAndFinishSelf();
} catch (RuntimeException e) {
Log.e(TAG, "Fragment is likely not attached to an activity. ", e);
}
});
// Transition to PreviewFragment on clicking "View Selected".
mViewSelectedButton.setOnClickListener(v -> {
// Load items for preview that are pre granted but not yet loaded for UI. This is an
// async call. Until the items are loaded, we can still preview already available
// items
mPickerViewModel.getRemainingPreGrantedItems();
mSelection.prepareSelectedItemsForPreviewAll();
int selectedItemCount = mSelection.getSelectedItemCount().getValue();
mPickerViewModel.logPreviewAllSelected(selectedItemCount);
try {
PreviewFragment.show(requireActivity().getSupportFragmentManager(),
PreviewFragment.getArgsForPreviewOnViewSelected());
} catch (RuntimeException e) {
Log.e(TAG, "Fragment is likely not attached to an activity. ", e);
}
});
mBottomBar = activity.findViewById(R.id.picker_bottom_bar);
// consume the event so that it doesn't get passed through to the next view b/287661737
mBottomBar.setOnClickListener(v -> {});
mSlideUpAnimation = AnimationUtils.loadAnimation(context, R.anim.slide_up);
mSlideDownAnimation = AnimationUtils.loadAnimation(context, R.anim.slide_down);
mSelection.getSelectedItemCount().observe(this, selectedItemListSize -> {
// Fetch activity or context again instead of capturing existing variable in lambdas
// to avoid memory leaks.
try {
updateProfileButtonVisibility();
updateVisibilityAndAnimateBottomBar(requireContext(), selectedItemListSize);
} catch (RuntimeException e) {
Log.e(TAG, "Fragment is likely not attached to an activity. ", e);
}
});
}
// Observe for cross profile access changes.
final LiveData<Boolean> crossProfileAllowed = mUserIdManager.getCrossProfileAllowed();
if (crossProfileAllowed != null) {
crossProfileAllowed.observe(this, isCrossProfileAllowed -> {
setUpProfileButton();
if (Boolean.TRUE.equals(mIsProfileButtonVisible.getValue())) {
if (isCrossProfileAllowed) {
mPickerViewModel.logProfileSwitchButtonEnabled();
} else {
mPickerViewModel.logProfileSwitchButtonDisabled();
}
}
});
}
final AccessibilityManager accessibilityManager =
context.getSystemService(AccessibilityManager.class);
mIsAccessibilityEnabled = accessibilityManager.isEnabled();
accessibilityManager.addAccessibilityStateChangeListener(enabled -> {
mIsAccessibilityEnabled = enabled;
setUpProfileButtonWithListeners(mUserIdManager.isMultiUserProfiles());
});
// Observe for multi-user changes.
final LiveData<Boolean> isMultiUserProfiles = mUserIdManager.getIsMultiUserProfiles();
if (isMultiUserProfiles != null) {
isMultiUserProfiles.observe(this, this::setUpProfileButtonWithListeners);
}
// Initial setup
setUpProfileButtonWithListeners(mUserIdManager.isMultiUserProfiles());
}
private void updateRecyclerViewBottomPadding() {
final int recyclerViewBottomPadding;
if (mIsProfileButtonVisible.getValue() || mIsBottomBarVisible.getValue()) {
recyclerViewBottomPadding = mRecyclerViewBottomPadding;
} else {
recyclerViewBottomPadding = 0;
}
mRecyclerView.setPadding(0, 0, 0, recyclerViewBottomPadding);
}
private void updateVisibilityAndAnimateBottomBar(@NonNull Context context,
int selectedItemListSize) {
if (!mSelection.canSelectMultiple()) {
return;
}
if (mPickerViewModel.isManagedSelectionEnabled()) {
animateAndShowBottomBar(context, selectedItemListSize);
if (selectedItemListSize == 0) {
mViewSelectedButton.setVisibility(View.GONE);
// Update the add button to show "Allow none".
mAddButton.setText(R.string.picker_add_button_allow_none_option);
}
} else {
if (selectedItemListSize == 0) {
animateAndHideBottomBar();
} else {
animateAndShowBottomBar(context, selectedItemListSize);
}
}
mIsBottomBarVisible.setValue(
mPickerViewModel.isManagedSelectionEnabled() || selectedItemListSize > 0);
}
private void animateAndShowBottomBar(Context context, int selectedItemListSize) {
if (mBottomBar.getVisibility() == View.GONE) {
mBottomBar.setVisibility(View.VISIBLE);
mBottomBar.startAnimation(mSlideUpAnimation);
}
mViewSelectedButton.setVisibility(View.VISIBLE);
mAddButton.setText(generateAddButtonString(context, selectedItemListSize));
}
private void animateAndHideBottomBar() {
if (mBottomBar.getVisibility() == View.VISIBLE) {
mBottomBar.setVisibility(View.GONE);
mBottomBar.startAnimation(mSlideDownAnimation);
}
}
private void setUpListenersForProfileButton() {
mProfileButton.setOnClickListener(v -> onClickProfileButton());
mOnScrollListenerForMultiProfileButton = new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
// Do not change profile button visibility on scroll if Accessibility mode is
// enabled. This is done to enhance button visibility in Accessibility mode.
if (mIsAccessibilityEnabled) {
return;
}
if (dy > 0) {
mProfileButton.hide();
} else {
updateProfileButtonVisibility();
}
}
};
mRecyclerView.addOnScrollListener(mOnScrollListenerForMultiProfileButton);
}
@Override
public void onDestroy() {
super.onDestroy();
if (mRecyclerView != null) {
mRecyclerView.clearOnScrollListeners();
}
}
private void setUpProfileButtonWithListeners(boolean isMultiUserProfile) {
if (mOnScrollListenerForMultiProfileButton != null) {
mRecyclerView.removeOnScrollListener(mOnScrollListenerForMultiProfileButton);
}
if (isMultiUserProfile) {
setUpListenersForProfileButton();
}
setUpProfileButton();
}
private void setUpProfileButton() {
updateProfileButtonVisibility();
if (!mUserIdManager.isMultiUserProfiles()) {
return;
}
updateProfileButtonContent(mUserIdManager.isManagedUserSelected());
updateProfileButtonColor(/* isDisabled */ !mUserIdManager.isCrossProfileAllowed());
}
private boolean shouldShowProfileButton() {
return mUserIdManager.isMultiUserProfiles()
&& !mHideProfileButton
&& !mPickerViewModel.isUserSelectForApp()
&& (!mSelection.canSelectMultiple()
|| mSelection.getSelectedItemCount().getValue() == 0);
}
private void onClickProfileButton() {
mPickerViewModel.logProfileSwitchButtonClick();
if (!mUserIdManager.isCrossProfileAllowed()) {
try {
ProfileDialogFragment.show(requireActivity().getSupportFragmentManager());
} catch (RuntimeException e) {
Log.e(TAG, "Fragment is likely not attached to an activity. ", e);
}
} else {
changeProfile();
}
}
private void changeProfile() {
if (mUserIdManager.isManagedUserSelected()) {
// TODO(b/190024747): Add caching for performance before switching data to and fro
// work profile
mUserIdManager.setPersonalAsCurrentUserProfile();
} else {
// TODO(b/190024747): Add caching for performance before switching data to and fro
// work profile
mUserIdManager.setManagedAsCurrentUserProfile();
}
updateProfileButtonContent(mUserIdManager.isManagedUserSelected());
mPickerViewModel.onSwitchedProfile();
}
private void updateProfileButtonContent(boolean isManagedUserSelected) {
final Drawable icon;
final String text;
final Context context;
try {
context = requireContext();
} catch (RuntimeException e) {
Log.e(TAG, "Could not update profile button content because the fragment is not"
+ " attached.");
return;
}
if (isManagedUserSelected) {
icon = context.getDrawable(R.drawable.ic_personal_mode);
text = getSwitchToPersonalMessage(context);
} else {
icon = getWorkProfileIcon(context);
text = getSwitchToWorkMessage(context);
}
mProfileButton.setIcon(icon);
mProfileButton.setText(text);
}
private String getSwitchToPersonalMessage(@NonNull Context context) {
if (SdkLevel.isAtLeastT()) {
return getUpdatedEnterpriseString(
context, SWITCH_TO_PERSONAL_MESSAGE, R.string.picker_personal_profile);
} else {
return context.getString(R.string.picker_personal_profile);
}
}
private String getSwitchToWorkMessage(@NonNull Context context) {
if (SdkLevel.isAtLeastT()) {
return getUpdatedEnterpriseString(
context, SWITCH_TO_WORK_MESSAGE, R.string.picker_work_profile);
} else {
return context.getString(R.string.picker_work_profile);
}
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private String getUpdatedEnterpriseString(@NonNull Context context,
@NonNull String updatableStringId,
int defaultStringId) {
final DevicePolicyManager dpm = context.getSystemService(DevicePolicyManager.class);
return dpm.getResources().getString(updatableStringId, () -> getString(defaultStringId));
}
private Drawable getWorkProfileIcon(@NonNull Context context) {
if (SdkLevel.isAtLeastT()) {
return getUpdatedWorkProfileIcon(context);
} else {
return context.getDrawable(R.drawable.ic_work_outline);
}
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private Drawable getUpdatedWorkProfileIcon(@NonNull Context context) {
DevicePolicyManager dpm = context.getSystemService(DevicePolicyManager.class);
return dpm.getResources().getDrawable(WORK_PROFILE_ICON, OUTLINE, () -> {
// Fetch activity or context again instead of capturing existing variable in
// lambdas to avoid memory leaks.
try {
return requireContext().getDrawable(R.drawable.ic_work_outline);
} catch (RuntimeException e) {
Log.e(TAG, "Fragment is likely not attached to an activity. ", e);
return null;
}
});
}
private void updateProfileButtonColor(boolean isDisabled) {
final int textAndIconColor =
isDisabled ? mButtonDisabledIconAndTextColor : mButtonIconAndTextColor;
final int backgroundTintColor =
isDisabled ? mButtonDisabledBackgroundColor : mButtonBackgroundColor;
mProfileButton.setTextColor(ColorStateList.valueOf(textAndIconColor));
mProfileButton.setIconTint(ColorStateList.valueOf(textAndIconColor));
mProfileButton.setBackgroundTintList(ColorStateList.valueOf(backgroundTintColor));
}
protected void hideProfileButton(boolean hide) {
mHideProfileButton = hide;
updateProfileButtonVisibility();
}
private void updateProfileButtonVisibility() {
final boolean shouldShowProfileButton = shouldShowProfileButton();
if (shouldShowProfileButton) {
mProfileButton.show();
} else {
mProfileButton.hide();
}
mIsProfileButtonVisible.setValue(shouldShowProfileButton);
}
protected void setEmptyMessage(int resId) {
mEmptyTextView.setText(resId);
}
/**
* If we show the {@link #mEmptyView}, hide the {@link #mRecyclerView}. If we don't hide the
* {@link #mEmptyView}, show the {@link #mRecyclerView}
* when user switches the profile ,till the time when updated profile data is loading,
* on the UI we hide {@link #mEmptyView} and show Empty {@link #mRecyclerView}
*/
protected void updateVisibilityForEmptyView(boolean shouldShowEmptyView) {
mEmptyView.setVisibility(shouldShowEmptyView ? View.VISIBLE : View.GONE);
mRecyclerView.setVisibility(shouldShowEmptyView ? View.GONE : View.VISIBLE);
}
/**
* Generates the Button Label for the {@link TabFragment#mAddButton}.
*
* @param context The current application context.
* @param size The current size of the selection.
* @return Localized, formatted string.
*/
private String generateAddButtonString(Context context, int size) {
final String sizeString = NumberFormat.getInstance(Locale.getDefault()).format(size);
final String template =
mPickerViewModel.isUserSelectForApp()
? context.getString(R.string.picker_add_button_multi_select_permissions)
: context.getString(R.string.picker_add_button_multi_select);
return TextUtils.expandTemplate(template, sizeString).toString();
}
/**
* Returns {@link PhotoPickerActivity} if the fragment is attached to one. Otherwise, throws an
* {@link IllegalStateException}.
*/
protected final PhotoPickerActivity requirePickerActivity() throws IllegalStateException {
return (PhotoPickerActivity) requireActivity();
}
protected final void setLayoutManager(@NonNull Context context,
@NonNull TabAdapter adapter, int spanCount) {
final GridLayoutManager layoutManager =
new GridLayoutManager(context, spanCount);
final GridLayoutManager.SpanSizeLookup lookup = new GridLayoutManager.SpanSizeLookup() {
@Override
public int getSpanSize(int position) {
final int itemViewType = adapter.getItemViewType(position);
// For the item view types ITEM_TYPE_BANNER and ITEM_TYPE_SECTION, it is full
// span, return the span count of the layoutManager.
if (itemViewType == ITEM_TYPE_BANNER || itemViewType == ITEM_TYPE_SECTION) {
return layoutManager.getSpanCount();
} else {
return 1;
}
}
};
layoutManager.setSpanSizeLookup(lookup);
mRecyclerView.setLayoutManager(layoutManager);
}
private abstract class OnBannerEventListener implements TabAdapter.OnBannerEventListener {
@Override
public void onActionButtonClick() {
mPickerViewModel.logBannerActionButtonClicked();
dismissBanner();
launchCloudProviderSettings();
}
@Override
public void onDismissButtonClick() {
mPickerViewModel.logBannerDismissed();
dismissBanner();
}
@Override
public void onBannerClick() {
mPickerViewModel.logBannerClicked();
dismissBanner();
launchCloudProviderSettings();
}
@Override
public void onBannerAdded(@NonNull String name) {
mPickerViewModel.logBannerAdded(name);
// Should scroll to the banner only if the first completely visible item is the one
// just below it. The possible adapter item positions of such an item are 0 and 1.
// During onViewCreated, before restoring the state, the first visible item position
// is -1, and we should not scroll to position 0 in such cases, else the previously
// saved recycler view position may get overridden.
int firstItemPosition = -1;
final RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
if (layoutManager instanceof GridLayoutManager) {
firstItemPosition = ((GridLayoutManager) layoutManager)
.findFirstCompletelyVisibleItemPosition();
}
if (firstItemPosition == 0 || firstItemPosition == 1) {
mRecyclerView.scrollToPosition(/* position */ 0);
}
}
abstract void dismissBanner();
private void launchCloudProviderSettings() {
final Intent accountChangeIntent =
mPickerViewModel.getChooseCloudMediaAccountActivityIntent();
try {
if (accountChangeIntent != null) {
requirePickerActivity().startActivity(accountChangeIntent);
} else {
requirePickerActivity().startSettingsActivity();
}
} catch (RuntimeException e) {
Log.e(TAG, "Fragment is likely not attached to an activity. ", e);
}
}
}
protected final OnBannerEventListener mOnChooseAppBannerEventListener =
new OnBannerEventListener() {
@Override
void dismissBanner() {
mPickerViewModel.onUserDismissedChooseAppBanner();
}
};
protected final OnBannerEventListener mOnCloudMediaAvailableBannerEventListener =
new OnBannerEventListener() {
@Override
void dismissBanner() {
mPickerViewModel.onUserDismissedCloudMediaAvailableBanner();
}
@Override
public boolean shouldShowActionButton() {
return mPickerViewModel.getChooseCloudMediaAccountActivityIntent() != null;
}
};
protected final OnBannerEventListener mOnAccountUpdatedBannerEventListener =
new OnBannerEventListener() {
@Override
void dismissBanner() {
mPickerViewModel.onUserDismissedAccountUpdatedBanner();
}
};
protected final OnBannerEventListener mOnChooseAccountBannerEventListener =
new OnBannerEventListener() {
@Override
void dismissBanner() {
mPickerViewModel.onUserDismissedChooseAccountBanner();
}
@Override
public boolean shouldShowActionButton() {
return mPickerViewModel.getChooseCloudMediaAccountActivityIntent() != null;
}
};
}