blob: 0917b550851e4c7e196b9b8d7cc82425cfbd73d0 [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.ItemsAction.ACTION_LOAD_NEXT_PAGE;
import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_REFRESH_ITEMS;
import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_VIEW_CREATED;
import static com.android.providers.media.photopicker.ui.TabAdapter.ITEM_TYPE_MEDIA_ITEM;
import static com.android.providers.media.photopicker.util.LayoutModeUtils.MODE_ALBUM_PHOTOS_TAB;
import static com.android.providers.media.photopicker.util.LayoutModeUtils.MODE_PHOTOS_TAB;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.android.providers.media.R;
import com.android.providers.media.photopicker.data.PaginationParameters;
import com.android.providers.media.photopicker.data.glide.PickerPreloadModelProvider;
import com.android.providers.media.photopicker.data.model.Category;
import com.android.providers.media.photopicker.data.model.Item;
import com.android.providers.media.photopicker.util.LayoutModeUtils;
import com.android.providers.media.photopicker.util.MimeFilterUtils;
import com.android.providers.media.photopicker.viewmodel.PickerViewModel;
import com.android.providers.media.util.StringUtils;
import com.bumptech.glide.Glide;
import com.bumptech.glide.RequestManager;
import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader;
import com.bumptech.glide.util.ViewPreloadSizeProvider;
import com.google.android.material.snackbar.Snackbar;
import org.jetbrains.annotations.NotNull;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Locale;
import java.util.Objects;
/**
* Photos tab fragment for showing the photos
*/
public class PhotosTabFragment extends TabFragment {
private static final String TAG = PhotosTabFragment.class.getSimpleName();
private static final int MINIMUM_SPAN_COUNT = 3;
private static final int GRID_COLUMN_COUNT = 3;
private static final String FRAGMENT_TAG = "PhotosTabFragment";
private Category mCategory = Category.DEFAULT;
private boolean mIsCurrentPageLoading = false;
private boolean mAtLeastOnePageLoaded = false;
private boolean mIsCloudMediaInPhotoPickerEnabled;
private int mPageSize;
private PickerPreloadModelProvider mPreloadModelProvider;
@Nullable
private RequestManager mGlideRequestManager = null;
private ProgressBar mProgressBar;
private TextView mLoadingTextView;
private ObjectAnimator mObjectAnimator = new ObjectAnimator();
private int mRecyclerViewTopPadding;
private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper());
private final Object mHideProgressBarToken = new Object();
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// After the configuration is changed, if the fragment is now shown, onViewCreated will not
// be triggered. We need to restore the savedInstanceState in onCreate.
// E.g. Click the albums -> preview one item -> rotate the device
if (savedInstanceState != null) {
mCategory = Category.fromBundle(savedInstanceState);
}
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
final Context context = requireContext();
// Init is only required for album content tab fragments when the fragment is not being
// recreated from a previous state.
if (savedInstanceState == null && !mCategory.isDefault()) {
mPickerViewModel.initPhotoPickerData(mCategory);
}
// We only add the RECENT header on the PhotosTabFragment with CATEGORY_DEFAULT. In this
// case, we call this method {loadItems} with null category. When the category is not
// empty, we don't show the RECENT header.
final boolean showRecentSection = mCategory.isDefault();
// We only show the Banners on the PhotosTabFragment with CATEGORY_DEFAULT (Main grid).
final boolean shouldShowBanners = mCategory.isDefault();
final LiveData<Boolean> doNotShowBanner = new MutableLiveData<>(false);
final LiveData<Boolean> showChooseAppBanner = shouldShowBanners
? mPickerViewModel.shouldShowChooseAppBannerLiveData() : doNotShowBanner;
final LiveData<Boolean> showCloudMediaAvailableBanner = shouldShowBanners
? mPickerViewModel.shouldShowCloudMediaAvailableBannerLiveData() : doNotShowBanner;
final LiveData<Boolean> showAccountUpdatedBanner = shouldShowBanners
? mPickerViewModel.shouldShowAccountUpdatedBannerLiveData() : doNotShowBanner;
final LiveData<Boolean> showChooseAccountBanner = shouldShowBanners
? mPickerViewModel.shouldShowChooseAccountBannerLiveData() : doNotShowBanner;
mIsCloudMediaInPhotoPickerEnabled =
mPickerViewModel.getConfigStore().isCloudMediaInPhotoPickerEnabled();
if (savedInstanceState == null) {
initProgressBar(view);
}
mSelection.clearCheckedItemList();
ViewPreloadSizeProvider viewSizeProvider = new ViewPreloadSizeProvider();
final PhotosTabAdapter adapter =
new PhotosTabAdapter(
showRecentSection,
mSelection,
mImageLoader,
mOnMediaItemClickListener,
this, /* lifecycleOwner */
mPickerViewModel.getCloudMediaProviderAppTitleLiveData(),
mPickerViewModel.getCloudMediaAccountNameLiveData(),
showChooseAppBanner,
showCloudMediaAvailableBanner,
showAccountUpdatedBanner,
showChooseAccountBanner,
mOnChooseAppBannerEventListener,
mOnCloudMediaAvailableBannerEventListener,
mOnAccountUpdatedBannerEventListener,
mOnChooseAccountBannerEventListener,
mOnMediaItemHoverListener,
viewSizeProvider);
mPreloadModelProvider = new PickerPreloadModelProvider(getContext(), adapter);
mGlideRequestManager = Glide.with(this);
RecyclerViewPreloader<Item> preloader =
new RecyclerViewPreloader<>(
Glide.with(getContext()),
mPreloadModelProvider,
viewSizeProvider,
/* maxPreload= */ 8);
mRecyclerView.addOnScrollListener(preloader);
// initialise pre-granted items is necessary.
Intent activityIntent = requireActivity().getIntent();
mPickerViewModel.initialisePreGrantsIfNecessary(mPickerViewModel.getSelection(),
activityIntent.getExtras(), MimeFilterUtils.getMimeTypeFilters(activityIntent));
if (mCategory.isDefault()) {
mPageSize = mIsCloudMediaInPhotoPickerEnabled
? PaginationParameters.PAGINATION_PAGE_SIZE_ITEMS : -1;
setEmptyMessage(R.string.picker_photos_empty_message);
// Set the pane title for A11y
view.setAccessibilityPaneTitle(getString(R.string.picker_photos));
// Get items with pagination parameters representing the first page.
mPickerViewModel.getPaginatedItemsForAction(
ACTION_VIEW_CREATED,
new PaginationParameters(
mPageSize,
/* dateBeforeMs */ Long.MIN_VALUE,
/* rowId */ -1))
.observe(this, itemListResult -> {
onChangeMediaItems(itemListResult, adapter);
});
} else {
mPageSize = mIsCloudMediaInPhotoPickerEnabled
? PaginationParameters.PAGINATION_PAGE_SIZE_ALBUM_ITEMS : -1;
setEmptyMessage(R.string.picker_album_media_empty_message);
// Set the pane title for A11y
view.setAccessibilityPaneTitle(mCategory.getDisplayName(context));
// Get items with pagination parameters representing the first page.
mPickerViewModel.getPaginatedCategoryItemsForAction(
mCategory,
ACTION_VIEW_CREATED,
new PaginationParameters(
mPageSize,
/* dateBeforeMs */ Long.MIN_VALUE,
/* rowId */ -1))
.observe(this, itemListResult -> {
onChangeMediaItems(itemListResult, adapter);
});
}
final PhotosTabItemDecoration itemDecoration = new PhotosTabItemDecoration(context);
final int spacing = getResources().getDimensionPixelSize(R.dimen.picker_photo_item_spacing);
final int photoSize = getResources().getDimensionPixelSize(R.dimen.picker_photo_size);
mRecyclerView.setColumnWidth(photoSize + spacing);
mRecyclerView.setMinimumSpanCount(MINIMUM_SPAN_COUNT);
setLayoutManager(context, adapter, GRID_COLUMN_COUNT);
mRecyclerView.setAdapter(adapter);
mRecyclerView.addItemDecoration(itemDecoration);
mRecyclerView.addRecyclerListener(
new RecyclerView.RecyclerListener() {
@Override
public void onViewRecycled(RecyclerView.ViewHolder holder) {
if (mGlideRequestManager != null
&& holder.getItemViewType() == ITEM_TYPE_MEDIA_ITEM) {
// This cast is safe as we've already checked the view type is
MediaItemGridViewHolder vh = (MediaItemGridViewHolder) holder;
// Cancel pending glide load requests on recycling, to prevent a large
// backlog of requests building up in the event of large scrolls.
cancelGlideLoadForViewHolder(vh);
vh.release();
}
}
});
mRecyclerView.setItemViewCacheSize(10);
if (mIsCloudMediaInPhotoPickerEnabled) {
setOnScrollListenerForRecyclerView();
}
// uncheck the unavailable items at UI those are no longer available in the selection list
requirePickerActivity().isItemPhotoGridViewChanged()
.observe(this, isItemViewChanged -> {
if (isItemViewChanged) {
// To re-bind the view just to uncheck the unavailable media items at UI
// Size of mCheckItems is going to be constant ( Iterating over mCheckItems
// is not a heavy operation)
for (Integer index : mSelection.getCheckedItemsIndexes()) {
adapter.notifyItemChanged(index);
}
}
});
}
private void initProgressBar(@NonNull View view) {
// Check feature flag for cloud media and if it is not true then hide progress bar and
// loading text.
if (mIsCloudMediaInPhotoPickerEnabled) {
mLoadingTextView = view.findViewById(R.id.loading_text_view);
mProgressBar = view.findViewById(R.id.progress_bar);
mRecyclerViewTopPadding = getResources().getDimensionPixelSize(
R.dimen.picker_recycler_view_top_padding);
if (mCategory == Category.DEFAULT) {
mPickerViewModel.isSyncInProgress().observe(this, inProgress -> {
if (inProgress) {
bringProgressBarAndLoadingTextInView();
}
});
} else {
bringProgressBarAndLoadingTextInView();
}
}
}
private void setOnScrollListenerForRecyclerView() {
mRecyclerView.addOnScrollListener(
new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull @NotNull RecyclerView recyclerView, int dx,
int dy) {
super.onScrolled(recyclerView, dx, dy);
// check to ensure that the current page is not still loading and the last
// page has not been loaded.
if (!mIsCurrentPageLoading) {
LinearLayoutManager layoutManager =
(LinearLayoutManager) mRecyclerView.getLayoutManager();
assert layoutManager != null;
// Total items visible at the screen at any current time.
int visibleItemCount = layoutManager.getChildCount();
// Total items in the layout.
int totalItemCount = layoutManager.getItemCount();
// The position of the first visible view
int firstVisibleItemPosition =
layoutManager.findFirstVisibleItemPosition();
// If the number of items have exceeded the threshold, a call will be
// triggered to load the next page.
int thresholdNumberOfItems = totalItemCount - mPageSize;
if (visibleItemCount + firstVisibleItemPosition
>= thresholdNumberOfItems
&& firstVisibleItemPosition >= 0
) {
Log.d(FRAGMENT_TAG, "Scrolled beyond page threshold, sending a"
+ " call to load the next page.");
// setting this to true ensures that only one call is sent on
// crossing the threshold and only required number of pages are
// loaded.
mIsCurrentPageLoading = true;
if (mCategory.isDefault()) {
mPickerViewModel.getPaginatedItemsForAction(
ACTION_LOAD_NEXT_PAGE,
null);
} else {
mPickerViewModel.getPaginatedCategoryItemsForAction(
mCategory,
ACTION_LOAD_NEXT_PAGE,
null);
}
}
}
}
});
}
/**
* Called when owning activity is saving state to be used to restore state during creation.
*
* @param state Bundle to save state
*/
public void onSaveInstanceState(Bundle state) {
super.onSaveInstanceState(state);
mCategory.toBundle(state);
}
@Override
public void onResume() {
super.onResume();
final String title;
final LayoutModeUtils.Mode layoutMode;
final boolean shouldHideProfileButton;
if (mCategory.isDefault()) {
title = "";
layoutMode = MODE_PHOTOS_TAB;
shouldHideProfileButton = false;
} else {
title = mCategory.getDisplayName(requireContext());
layoutMode = MODE_ALBUM_PHOTOS_TAB;
shouldHideProfileButton = true;
}
requirePickerActivity().updateCommonLayouts(layoutMode, title);
hideProfileButton(shouldHideProfileButton);
if (mIsCloudMediaInPhotoPickerEnabled
&& mCategory == Category.DEFAULT
&& mAtLeastOnePageLoaded) {
// mAtLeastOnePageLoaded is checked to avoid calling this method while the view is
// being created
LinearLayoutManager layoutManager =
(LinearLayoutManager) mRecyclerView.getLayoutManager();
if (layoutManager != null) {
int firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition();
mPickerViewModel.getPaginatedItemsForAction(
ACTION_REFRESH_ITEMS,
new PaginationParameters(firstVisibleItemPosition
+ PaginationParameters.PAGINATION_PAGE_SIZE_ITEMS,
/*dateBeforeMs*/ Long.MIN_VALUE, -1));
}
}
}
private void onChangeMediaItems(@NonNull PickerViewModel.PaginatedItemsResult itemList,
@NonNull PhotosTabAdapter adapter) {
Objects.requireNonNull(itemList);
if (isClearGridAction(itemList)) {
adapter.setMediaItems(new ArrayList<>(), itemList.getAction());
updateVisibilityForEmptyView(false);
} else {
adapter.setMediaItems(itemList.getItems(), itemList.getAction());
// Handle emptyView's visibility
boolean shouldShowEmptyView = (itemList.getItems().size() == 0);
updateVisibilityForEmptyView(shouldShowEmptyView);
if (shouldShowEmptyView) {
mPickerViewModel.setEmptyPageDisplayed(true);
}
}
mIsCurrentPageLoading = false;
mAtLeastOnePageLoaded = true;
hideProgressBarAndLoadingText();
}
private boolean isClearGridAction(@NonNull PickerViewModel.PaginatedItemsResult itemList) {
return itemList.getItems() != null
&& itemList.getItems().size() == 1
&& itemList.getItems().get(0).getId().equals("EMPTY_VIEW");
}
private final PhotosTabAdapter.OnMediaItemClickListener mOnMediaItemClickListener =
new PhotosTabAdapter.OnMediaItemClickListener() {
@Override
public void onItemClick(
@NonNull View view, int position, MediaItemGridViewHolder viewHolder) {
if (mSelection.canSelectMultiple()) {
final boolean isSelectedBefore =
mSelection.isItemSelected((Item) view.getTag())
&& view.isSelected();
Item item = (Item) view.getTag();
if (isSelectedBefore) {
if (mSelection.isSelectionOrdered()) {
viewHolder.setSelectionOrder(null);
}
mSelection.removeSelectedItem((Item) view.getTag());
mSelection.removeCheckedItemIndex((Item) view.getTag());
} else {
mSelection.addCheckedItemIndex((Item) view.getTag(), position);
if (!mSelection.isSelectionAllowed()) {
final int maxCount = mSelection.getMaxSelectionLimit();
final CharSequence quantityText =
StringUtils.getICUFormatString(
getResources(), maxCount, R.string.select_up_to);
final String itemCountString =
NumberFormat.getInstance(Locale.getDefault())
.format(maxCount);
final CharSequence message =
TextUtils.expandTemplate(quantityText, itemCountString);
Snackbar.make(view, message, Snackbar.LENGTH_SHORT).show();
return;
} else {
mSelection.addSelectedItem(item);
if (mSelection.isSelectionOrdered()) {
viewHolder.setSelectionOrder(
mSelection.getSelectedItemOrder(item));
}
mPickerViewModel.logMediaItemSelected(item, mCategory, position);
}
}
view.setSelected(!isSelectedBefore);
// There is an issue b/223695510 about not selected in Accessibility mode.
// It only says selected state, but it doesn't say not selected state.
// Add the not selected only to avoid that it says selected twice.
view.setStateDescription(
isSelectedBefore ? getString(R.string.not_selected) : null);
} else {
final Item item = (Item) view.getTag();
mSelection.setSelectedItem(item);
mPickerViewModel.logMediaItemSelected(item, mCategory, position);
try {
requirePickerActivity().setResultAndFinishSelf();
} catch (RuntimeException e) {
Log.e(TAG, "Fragment is likely not attached to an activity. ", e);
}
}
}
@Override
public boolean onItemLongClick(@NonNull View view, int position) {
final Item item = (Item) view.getTag();
if (!mSelection.canSelectMultiple()) {
// In single select mode, if the item is previewed, we set it as selected
// item. This assists in "Add" button click to return all selected items.
// For multi select, long click only previews the item, and until user
// selects the item, it doesn't get added to selected items. Also, there is
// no "Add" button in the preview layout that can return selected items.
mSelection.setSelectedItem(item);
}
mSelection.prepareItemForPreviewOnLongPress(item);
mPickerViewModel.logMediaItemPreviewed(item, mCategory, position);
try {
// Transition to PreviewFragment.
PreviewFragment.show(
requireActivity().getSupportFragmentManager(),
PreviewFragment.getArgsForPreviewOnLongPress());
} catch (RuntimeException e) {
Log.e(TAG, "Fragment is likely not attached to an activity. ", e);
}
// Consume the long click so that it doesn't propagate in the View hierarchy.
return true;
}
};
public View.OnHoverListener mOnMediaItemHoverListener = (v, event) -> {
// When a cursor is hovered over an item the item should appear selected and when the
// cursor moves out of the bounds of the view, it should go back to being unselected.
if (event.getAction() == MotionEvent.ACTION_HOVER_ENTER) {
v.setSelected(true);
} else if (event.getAction() == MotionEvent.ACTION_HOVER_EXIT) {
if (!mSelection.isItemSelected((Item) v.getTag())) {
v.setSelected(false);
}
}
return true;
};
/**
* Create the fragment with the category and add it into the FragmentManager
*
* @param fm the fragment manager
* @param category the category
*/
public static void show(FragmentManager fm, Category category) {
final FragmentTransaction ft = fm.beginTransaction();
final PhotosTabFragment fragment = new PhotosTabFragment();
fragment.mCategory = category;
ft.replace(R.id.fragment_container, fragment, FRAGMENT_TAG);
if (!fragment.mCategory.isDefault()) {
ft.addToBackStack(FRAGMENT_TAG);
}
ft.commitAllowingStateLoss();
}
/**
* Get the fragment in the FragmentManager
*
* @param fm The fragment manager
*/
public static Fragment get(FragmentManager fm) {
return fm.findFragmentByTag(FRAGMENT_TAG);
}
/**
* Hides progress bar and the loading photos message.
* <p>This is executed with a delay of 0.6ms.
* This is done so that for the cases where the loading happens very quickly the user will not
* see the progressBar flicker.</p>
*
* <p>This results in progressBar and loadingText to remain in view for loadingTime + 0.6ms.</p>
*/
private synchronized void hideProgressBarAndLoadingText() {
if (mProgressBar != null
&& mLoadingTextView != null
&& mProgressBar.getVisibility() == View.VISIBLE
&& mLoadingTextView.getVisibility() == View.VISIBLE) {
// clear previous calls, extra caution.
mMainThreadHandler.removeCallbacksAndMessages(mHideProgressBarToken);
Runnable runnable = new Runnable() {
@Override
public void run() {
if (mProgressBar != null
&& mLoadingTextView != null
&& mProgressBar.getVisibility() == View.VISIBLE
&& mLoadingTextView.getVisibility() == View.VISIBLE) {
mProgressBar.setVisibility(View.GONE);
mLoadingTextView.setVisibility(View.GONE);
// Move recyclerView up to cover up the space taken up by progressBar and
// loadingTest.
if (mRecyclerView != null
&& mRecyclerView.getVisibility() == View.VISIBLE) {
mObjectAnimator.ofFloat(
mRecyclerView,
/* property name */ "y",
/* final position */0f)
.setDuration(300).start();
}
}
}
};
// With this runnable the hiding of progress bar is delayed by 600ms.
mMainThreadHandler.postDelayed(runnable, mHideProgressBarToken, /* delay duration */
600);
}
}
private void bringProgressBarAndLoadingTextInView() {
if (mIsCloudMediaInPhotoPickerEnabled) {
if (mObjectAnimator != null) {
// stop any pending/ongoing animations.
mObjectAnimator.cancel();
}
if (mRecyclerView.getVisibility() == View.VISIBLE) {
// move recycler view down to make space for progress bar and loading text.
mObjectAnimator.ofFloat(
mRecyclerView,
/* property name */ "y",
/* final position */mRecyclerViewTopPadding)
.setDuration(1).start();
}
// bring progressBar and Loading text in view.
mLoadingTextView.setVisibility(View.VISIBLE);
mProgressBar.setVisibility(View.VISIBLE);
}
}
/**
* Attempts to cancel any outstanding Glide requests for the given ViewHolder.
*
* @param holder The View holder in the RecyclerView to cancel requests for.
*/
private void cancelGlideLoadForViewHolder(MediaItemGridViewHolder vh) {
// Attempt to clear the potential pending load out of glide's request
// manager.
mGlideRequestManager.clear(vh.getThumbnailImageView());
}
@Override
public void onDestroy() {
super.onDestroy();
mMainThreadHandler.removeCallbacksAndMessages(mHideProgressBarToken);
mGlideRequestManager = null;
}
}