blob: 9b5ff0fe3b75ca2da5cdd0419e6900fd2d55b23c [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.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.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.util.Log;
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.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.viewmodel.PickerViewModel;
import com.android.providers.media.util.StringUtils;
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 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 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 = getContext();
// 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();
final PhotosTabAdapter adapter = new PhotosTabAdapter(showRecentSection, mSelection,
mImageLoader, mOnMediaItemClickListener, /* lifecycleOwner */ this,
mPickerViewModel.getCloudMediaProviderAppTitleLiveData(),
mPickerViewModel.getCloudMediaAccountNameLiveData(), showChooseAppBanner,
showCloudMediaAvailableBanner, showAccountUpdatedBanner, showChooseAccountBanner,
mOnChooseAppBannerEventListener, mOnCloudMediaAvailableBannerEventListener,
mOnAccountUpdatedBannerEventListener, mOnChooseAccountBannerEventListener);
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 */ -1,
/* 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 */ -1,
/* 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(adapter, GRID_COLUMN_COUNT);
mRecyclerView.setAdapter(adapter);
mRecyclerView.addItemDecoration(itemDecoration);
if (mIsCloudMediaInPhotoPickerEnabled) {
setOnScrollListenerForRecyclerView();
}
// uncheck the unavailable items at UI those are no longer available in the selection list
getPickerActivity().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(getContext());
layoutMode = MODE_ALBUM_PHOTOS_TAB;
shouldHideProfileButton = true;
}
getPickerActivity().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, -1, -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
updateVisibilityForEmptyView(/* shouldShowEmptyView */ itemList.getItems().size() == 0);
}
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) {
if (mSelection.canSelectMultiple()) {
final boolean isSelectedBefore = view.isSelected();
if (isSelectedBefore) {
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 {
final Item item = (Item) view.getTag();
mSelection.addSelectedItem(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);
getPickerActivity().setResultAndFinishSelf();
}
}
@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);
// Transition to PreviewFragment.
PreviewFragment.show(getActivity().getSupportFragmentManager(),
PreviewFragment.getArgsForPreviewOnLongPress());
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);
}
}
@Override
public void onDestroy() {
super.onDestroy();
mMainThreadHandler.removeCallbacksAndMessages(mHideProgressBarToken);
}
}