1 /* 2 * Copyright (C) 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.providers.media.photopicker.ui; 17 18 import static com.android.providers.media.photopicker.ui.DevicePolicyResources.Drawables.Style.OUTLINE; 19 import static com.android.providers.media.photopicker.ui.DevicePolicyResources.Drawables.WORK_PROFILE_ICON; 20 import static com.android.providers.media.photopicker.ui.DevicePolicyResources.Strings.SWITCH_TO_PERSONAL_MESSAGE; 21 import static com.android.providers.media.photopicker.ui.DevicePolicyResources.Strings.SWITCH_TO_WORK_MESSAGE; 22 import static com.android.providers.media.photopicker.ui.TabAdapter.ITEM_TYPE_BANNER; 23 import static com.android.providers.media.photopicker.ui.TabAdapter.ITEM_TYPE_SECTION; 24 25 import android.app.admin.DevicePolicyManager; 26 import android.content.Context; 27 import android.content.res.ColorStateList; 28 import android.content.res.TypedArray; 29 import android.graphics.drawable.Drawable; 30 import android.os.Build; 31 import android.os.Bundle; 32 import android.text.TextUtils; 33 import android.view.LayoutInflater; 34 import android.view.View; 35 import android.view.ViewGroup; 36 import android.view.accessibility.AccessibilityManager; 37 import android.view.animation.Animation; 38 import android.view.animation.AnimationUtils; 39 import android.widget.Button; 40 import android.widget.TextView; 41 42 import androidx.annotation.ColorInt; 43 import androidx.annotation.NonNull; 44 import androidx.annotation.Nullable; 45 import androidx.annotation.RequiresApi; 46 import androidx.fragment.app.Fragment; 47 import androidx.lifecycle.LiveData; 48 import androidx.lifecycle.MutableLiveData; 49 import androidx.lifecycle.ViewModelProvider; 50 import androidx.recyclerview.widget.GridLayoutManager; 51 import androidx.recyclerview.widget.RecyclerView; 52 53 import com.android.modules.utils.build.SdkLevel; 54 import com.android.providers.media.R; 55 import com.android.providers.media.photopicker.PhotoPickerActivity; 56 import com.android.providers.media.photopicker.data.Selection; 57 import com.android.providers.media.photopicker.data.UserIdManager; 58 import com.android.providers.media.photopicker.viewmodel.PickerViewModel; 59 60 import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton; 61 62 import java.text.NumberFormat; 63 import java.util.Locale; 64 65 /** 66 * The base abstract Tab fragment 67 */ 68 public abstract class TabFragment extends Fragment { 69 70 protected PickerViewModel mPickerViewModel; 71 protected Selection mSelection; 72 protected ImageLoader mImageLoader; 73 protected AutoFitRecyclerView mRecyclerView; 74 75 private ExtendedFloatingActionButton mProfileButton; 76 private UserIdManager mUserIdManager; 77 private boolean mHideProfileButton; 78 private View mEmptyView; 79 private TextView mEmptyTextView; 80 private boolean mIsAccessibilityEnabled; 81 82 private Button mAddButton; 83 private View mBottomBar; 84 private Animation mSlideUpAnimation; 85 private Animation mSlideDownAnimation; 86 87 @ColorInt 88 private int mButtonIconAndTextColor; 89 90 @ColorInt 91 private int mButtonBackgroundColor; 92 93 @ColorInt 94 private int mButtonDisabledIconAndTextColor; 95 96 @ColorInt 97 private int mButtonDisabledBackgroundColor; 98 99 private int mRecyclerViewBottomPadding; 100 101 private final MutableLiveData<Boolean> mIsBottomBarVisible = new MutableLiveData<>(false); 102 private final MutableLiveData<Boolean> mIsProfileButtonVisible = new MutableLiveData<>(false); 103 104 @Override 105 @NonNull onCreateView(@onNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)106 public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, 107 Bundle savedInstanceState) { 108 super.onCreateView(inflater, container, savedInstanceState); 109 return inflater.inflate(R.layout.fragment_picker_tab, container, false); 110 } 111 112 @Override onViewCreated(@onNull View view, @Nullable Bundle savedInstanceState)113 public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { 114 super.onViewCreated(view, savedInstanceState); 115 116 final Context context = getContext(); 117 mImageLoader = new ImageLoader(context); 118 mRecyclerView = view.findViewById(R.id.picker_tab_recyclerview); 119 mRecyclerView.setHasFixedSize(true); 120 final ViewModelProvider viewModelProvider = new ViewModelProvider(requireActivity()); 121 mPickerViewModel = viewModelProvider.get(PickerViewModel.class); 122 mSelection = mPickerViewModel.getSelection(); 123 mRecyclerViewBottomPadding = getResources().getDimensionPixelSize( 124 R.dimen.picker_recycler_view_bottom_padding); 125 126 mIsBottomBarVisible.observe(this, val -> updateRecyclerViewBottomPadding()); 127 mIsProfileButtonVisible.observe(this, val -> updateRecyclerViewBottomPadding()); 128 129 mEmptyView = view.findViewById(android.R.id.empty); 130 mEmptyTextView = mEmptyView.findViewById(R.id.empty_text_view); 131 132 final int[] attrsDisabled = 133 new int[]{R.attr.pickerDisabledProfileButtonColor, 134 R.attr.pickerDisabledProfileButtonTextColor}; 135 final TypedArray taDisabled = context.obtainStyledAttributes(attrsDisabled); 136 mButtonDisabledBackgroundColor = taDisabled.getColor(/* index */ 0, /* defValue */ -1); 137 mButtonDisabledIconAndTextColor = taDisabled.getColor(/* index */ 1, /* defValue */ -1); 138 taDisabled.recycle(); 139 140 final int[] attrs = 141 new int[]{R.attr.pickerProfileButtonColor, R.attr.pickerProfileButtonTextColor}; 142 final TypedArray ta = context.obtainStyledAttributes(attrs); 143 mButtonBackgroundColor = ta.getColor(/* index */ 0, /* defValue */ -1); 144 mButtonIconAndTextColor = ta.getColor(/* index */ 1, /* defValue */ -1); 145 ta.recycle(); 146 147 mProfileButton = getActivity().findViewById(R.id.profile_button); 148 mUserIdManager = mPickerViewModel.getUserIdManager(); 149 150 final boolean canSelectMultiple = mSelection.canSelectMultiple(); 151 if (canSelectMultiple) { 152 mAddButton = getActivity().findViewById(R.id.button_add); 153 mAddButton.setOnClickListener(v -> { 154 ((PhotoPickerActivity) getActivity()).setResultAndFinishSelf(); 155 }); 156 157 final Button viewSelectedButton = getActivity().findViewById(R.id.button_view_selected); 158 // Transition to PreviewFragment on clicking "View Selected". 159 viewSelectedButton.setOnClickListener(v -> { 160 mSelection.prepareSelectedItemsForPreviewAll(); 161 PreviewFragment.show(getActivity().getSupportFragmentManager(), 162 PreviewFragment.getArgsForPreviewOnViewSelected()); 163 }); 164 165 mBottomBar = getActivity().findViewById(R.id.picker_bottom_bar); 166 mSlideUpAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.slide_up); 167 mSlideDownAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.slide_down); 168 169 mSelection.getSelectedItemCount().observe(this, selectedItemListSize -> { 170 updateProfileButtonVisibility(); 171 updateVisibilityAndAnimateBottomBar(selectedItemListSize); 172 }); 173 } 174 175 // Initial setup 176 setUpProfileButtonWithListeners(mUserIdManager.isMultiUserProfiles()); 177 178 // Observe for cross profile access changes. 179 final LiveData<Boolean> crossProfileAllowed = mUserIdManager.getCrossProfileAllowed(); 180 if (crossProfileAllowed != null) { 181 crossProfileAllowed.observe(this, isCrossProfileAllowed -> { 182 setUpProfileButton(); 183 }); 184 } 185 186 // Observe for multi-user changes. 187 final LiveData<Boolean> isMultiUserProfiles = mUserIdManager.getIsMultiUserProfiles(); 188 if (isMultiUserProfiles != null) { 189 isMultiUserProfiles.observe(this, this::setUpProfileButtonWithListeners); 190 } 191 192 final AccessibilityManager accessibilityManager = 193 context.getSystemService(AccessibilityManager.class); 194 mIsAccessibilityEnabled = accessibilityManager.isEnabled(); 195 accessibilityManager.addAccessibilityStateChangeListener(enabled -> { 196 mIsAccessibilityEnabled = enabled; 197 updateProfileButtonVisibility(); 198 }); 199 } 200 updateRecyclerViewBottomPadding()201 private void updateRecyclerViewBottomPadding() { 202 final int recyclerViewBottomPadding; 203 if (mIsProfileButtonVisible.getValue() || mIsBottomBarVisible.getValue()) { 204 recyclerViewBottomPadding = mRecyclerViewBottomPadding; 205 } else { 206 recyclerViewBottomPadding = 0; 207 } 208 209 mRecyclerView.setPadding(0, 0, 0, recyclerViewBottomPadding); 210 } 211 updateVisibilityAndAnimateBottomBar(int selectedItemListSize)212 private void updateVisibilityAndAnimateBottomBar(int selectedItemListSize) { 213 if (!mSelection.canSelectMultiple()) { 214 return; 215 } 216 217 if (selectedItemListSize == 0) { 218 if (mBottomBar.getVisibility() == View.VISIBLE) { 219 mBottomBar.setVisibility(View.GONE); 220 mBottomBar.startAnimation(mSlideDownAnimation); 221 } 222 } else { 223 if (mBottomBar.getVisibility() == View.GONE) { 224 mBottomBar.setVisibility(View.VISIBLE); 225 mBottomBar.startAnimation(mSlideUpAnimation); 226 } 227 mAddButton.setText(generateAddButtonString(getContext(), selectedItemListSize)); 228 } 229 mIsBottomBarVisible.setValue(selectedItemListSize > 0); 230 } 231 setUpListenersForProfileButton()232 private void setUpListenersForProfileButton() { 233 mProfileButton.setOnClickListener(v -> onClickProfileButton()); 234 mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { 235 @Override 236 public void onScrolled(RecyclerView recyclerView, int dx, int dy) { 237 super.onScrolled(recyclerView, dx, dy); 238 239 // Do not change profile button visibility on scroll if Accessibility mode is 240 // enabled. This is done to enhance button visibility in Accessibility mode. 241 if (mIsAccessibilityEnabled) { 242 return; 243 } 244 245 if (dy > 0) { 246 mProfileButton.hide(); 247 } else { 248 updateProfileButtonVisibility(); 249 } 250 } 251 }); 252 } 253 254 @Override onDestroy()255 public void onDestroy() { 256 super.onDestroy(); 257 if (mRecyclerView != null) { 258 mRecyclerView.clearOnScrollListeners(); 259 } 260 } 261 setUpProfileButtonWithListeners(boolean isMultiUserProfile)262 private void setUpProfileButtonWithListeners(boolean isMultiUserProfile) { 263 if (isMultiUserProfile) { 264 setUpListenersForProfileButton(); 265 } else { 266 mRecyclerView.clearOnScrollListeners(); 267 } 268 setUpProfileButton(); 269 } 270 setUpProfileButton()271 private void setUpProfileButton() { 272 updateProfileButtonVisibility(); 273 if (!mUserIdManager.isMultiUserProfiles()) { 274 return; 275 } 276 277 updateProfileButtonContent(mUserIdManager.isManagedUserSelected()); 278 updateProfileButtonColor(/* isDisabled */ !mUserIdManager.isCrossProfileAllowed()); 279 } 280 shouldShowProfileButton()281 private boolean shouldShowProfileButton() { 282 return mUserIdManager.isMultiUserProfiles() 283 && !mHideProfileButton 284 && !mPickerViewModel.isUserSelectForApp() 285 && (!mSelection.canSelectMultiple() 286 || mSelection.getSelectedItemCount().getValue() == 0); 287 } 288 onClickProfileButton()289 private void onClickProfileButton() { 290 if (!mUserIdManager.isCrossProfileAllowed()) { 291 ProfileDialogFragment.show(getActivity().getSupportFragmentManager()); 292 } else { 293 changeProfile(); 294 } 295 } 296 changeProfile()297 private void changeProfile() { 298 if (mUserIdManager.isManagedUserSelected()) { 299 // TODO(b/190024747): Add caching for performance before switching data to and fro 300 // work profile 301 mUserIdManager.setPersonalAsCurrentUserProfile(); 302 303 } else { 304 // TODO(b/190024747): Add caching for performance before switching data to and fro 305 // work profile 306 mUserIdManager.setManagedAsCurrentUserProfile(); 307 } 308 309 updateProfileButtonContent(mUserIdManager.isManagedUserSelected()); 310 311 mPickerViewModel.onUserSwitchedProfile(); 312 } 313 updateProfileButtonContent(boolean isManagedUserSelected)314 private void updateProfileButtonContent(boolean isManagedUserSelected) { 315 final Drawable icon; 316 final String text; 317 if (isManagedUserSelected) { 318 icon = getContext().getDrawable(R.drawable.ic_personal_mode); 319 text = getSwitchToPersonalMessage(); 320 } else { 321 icon = getWorkProfileIcon(); 322 text = getSwitchToWorkMessage(); 323 } 324 mProfileButton.setIcon(icon); 325 mProfileButton.setText(text); 326 } 327 getSwitchToPersonalMessage()328 private String getSwitchToPersonalMessage() { 329 if (SdkLevel.isAtLeastT()) { 330 return getUpdatedEnterpriseString( 331 SWITCH_TO_PERSONAL_MESSAGE, R.string.picker_personal_profile); 332 } else { 333 return getContext().getString(R.string.picker_personal_profile); 334 } 335 } 336 getSwitchToWorkMessage()337 private String getSwitchToWorkMessage() { 338 if (SdkLevel.isAtLeastT()) { 339 return getUpdatedEnterpriseString( 340 SWITCH_TO_WORK_MESSAGE, R.string.picker_work_profile); 341 } else { 342 return getContext().getString(R.string.picker_work_profile); 343 } 344 } 345 346 @RequiresApi(Build.VERSION_CODES.TIRAMISU) getUpdatedEnterpriseString(String updatableStringId, int defaultStringId)347 private String getUpdatedEnterpriseString(String updatableStringId, int defaultStringId) { 348 final DevicePolicyManager dpm = getContext().getSystemService(DevicePolicyManager.class); 349 return dpm.getResources().getString(updatableStringId, () -> getString(defaultStringId)); 350 } 351 getWorkProfileIcon()352 private Drawable getWorkProfileIcon() { 353 if (SdkLevel.isAtLeastT()) { 354 return getUpdatedWorkProfileIcon(); 355 } else { 356 return getContext().getDrawable(R.drawable.ic_work_outline); 357 } 358 } 359 360 @RequiresApi(Build.VERSION_CODES.TIRAMISU) getUpdatedWorkProfileIcon()361 private Drawable getUpdatedWorkProfileIcon() { 362 DevicePolicyManager dpm = getContext().getSystemService(DevicePolicyManager.class); 363 return dpm.getResources().getDrawable(WORK_PROFILE_ICON, OUTLINE, () -> 364 getContext().getDrawable(R.drawable.ic_work_outline)); 365 } 366 updateProfileButtonColor(boolean isDisabled)367 private void updateProfileButtonColor(boolean isDisabled) { 368 final int textAndIconColor = 369 isDisabled ? mButtonDisabledIconAndTextColor : mButtonIconAndTextColor; 370 final int backgroundTintColor = 371 isDisabled ? mButtonDisabledBackgroundColor : mButtonBackgroundColor; 372 373 mProfileButton.setTextColor(ColorStateList.valueOf(textAndIconColor)); 374 mProfileButton.setIconTint(ColorStateList.valueOf(textAndIconColor)); 375 mProfileButton.setBackgroundTintList(ColorStateList.valueOf(backgroundTintColor)); 376 } 377 hideProfileButton(boolean hide)378 protected void hideProfileButton(boolean hide) { 379 mHideProfileButton = hide; 380 updateProfileButtonVisibility(); 381 } 382 updateProfileButtonVisibility()383 private void updateProfileButtonVisibility() { 384 final boolean shouldShowProfileButton = shouldShowProfileButton(); 385 if (shouldShowProfileButton) { 386 mProfileButton.show(); 387 } else { 388 mProfileButton.hide(); 389 } 390 mIsProfileButtonVisible.setValue(shouldShowProfileButton); 391 } 392 setEmptyMessage(int resId)393 protected void setEmptyMessage(int resId) { 394 mEmptyTextView.setText(resId); 395 } 396 397 /** 398 * If we show the {@link #mEmptyView}, hide the {@link #mRecyclerView}. If we don't hide the 399 * {@link #mEmptyView}, show the {@link #mRecyclerView} 400 */ updateVisibilityForEmptyView(boolean shouldShowEmptyView)401 protected void updateVisibilityForEmptyView(boolean shouldShowEmptyView) { 402 mEmptyView.setVisibility(shouldShowEmptyView ? View.VISIBLE : View.GONE); 403 mRecyclerView.setVisibility(shouldShowEmptyView ? View.GONE : View.VISIBLE); 404 } 405 406 /** 407 * Generates the Button Label for the {@link TabFragment#mAddButton}. 408 * 409 * @param context The current application context. 410 * @param size The current size of the selection. 411 * @return Localized, formatted string. 412 */ generateAddButtonString(Context context, int size)413 private String generateAddButtonString(Context context, int size) { 414 final String sizeString = NumberFormat.getInstance(Locale.getDefault()).format(size); 415 final String template = 416 mPickerViewModel.isUserSelectForApp() 417 ? context.getString(R.string.picker_add_button_multi_select_permissions) 418 : context.getString(R.string.picker_add_button_multi_select); 419 420 return TextUtils.expandTemplate(template, sizeString).toString(); 421 } 422 getPickerActivity()423 protected final PhotoPickerActivity getPickerActivity() { 424 return (PhotoPickerActivity) getActivity(); 425 } 426 setLayoutManager(@onNull TabAdapter adapter, int spanCount)427 protected final void setLayoutManager(@NonNull TabAdapter adapter, int spanCount) { 428 final GridLayoutManager layoutManager = 429 new GridLayoutManager(getContext(), spanCount); 430 final GridLayoutManager.SpanSizeLookup lookup = new GridLayoutManager.SpanSizeLookup() { 431 @Override 432 public int getSpanSize(int position) { 433 final int itemViewType = adapter.getItemViewType(position); 434 // For the item view types ITEM_TYPE_BANNER and ITEM_TYPE_SECTION, it is full 435 // span, return the span count of the layoutManager. 436 if (itemViewType == ITEM_TYPE_BANNER || itemViewType == ITEM_TYPE_SECTION) { 437 return layoutManager.getSpanCount(); 438 } else { 439 return 1; 440 } 441 } 442 }; 443 layoutManager.setSpanSizeLookup(lookup); 444 mRecyclerView.setLayoutManager(layoutManager); 445 } 446 447 private abstract class OnBannerEventListener implements TabAdapter.OnBannerEventListener { 448 @Override onActionButtonClick()449 public void onActionButtonClick() { 450 dismissBanner(); 451 getPickerActivity().startSettingsActivity(); 452 } 453 454 @Override onDismissButtonClick()455 public void onDismissButtonClick() { 456 dismissBanner(); 457 } 458 459 @Override onBannerAdded()460 public void onBannerAdded() { 461 // Should scroll to the banner only if the first completely visible item is the one 462 // just below it. The possible adapter item positions of such an item are 0 and 1. 463 // During onViewCreated, before restoring the state, the first visible item position 464 // is -1, and we should not scroll to position 0 in such cases, else the previously 465 // saved recycler view position may get overridden. 466 int firstItemPosition = -1; 467 468 final RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager(); 469 if (layoutManager instanceof GridLayoutManager) { 470 firstItemPosition = ((GridLayoutManager) layoutManager) 471 .findFirstCompletelyVisibleItemPosition(); 472 } 473 474 if (firstItemPosition == 0 || firstItemPosition == 1) { 475 mRecyclerView.scrollToPosition(/* position */ 0); 476 } 477 } 478 dismissBanner()479 abstract void dismissBanner(); 480 } 481 482 protected final OnBannerEventListener mOnChooseAppBannerEventListener = 483 new OnBannerEventListener() { 484 @Override 485 void dismissBanner() { 486 mPickerViewModel.onUserDismissedChooseAppBanner(); 487 } 488 }; 489 490 protected final OnBannerEventListener mOnCloudMediaAvailableBannerEventListener = 491 new OnBannerEventListener() { 492 @Override 493 void dismissBanner() { 494 mPickerViewModel.onUserDismissedCloudMediaAvailableBanner(); 495 } 496 }; 497 498 protected final OnBannerEventListener mOnAccountUpdatedBannerEventListener = 499 new OnBannerEventListener() { 500 @Override 501 void dismissBanner() { 502 mPickerViewModel.onUserDismissedAccountUpdatedBanner(); 503 } 504 }; 505 506 protected final OnBannerEventListener mOnChooseAccountBannerEventListener = 507 new OnBannerEventListener() { 508 @Override 509 void dismissBanner() { 510 mPickerViewModel.onUserDismissedChooseAccountBanner(); 511 } 512 }; 513 } 514