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 
17 package com.android.providers.media.photopicker.ui;
18 
19 import android.content.Context;
20 import android.content.res.ColorStateList;
21 import android.content.res.Configuration;
22 import android.graphics.Color;
23 import android.os.Bundle;
24 import android.text.TextUtils;
25 import android.util.Log;
26 import android.view.LayoutInflater;
27 import android.view.Menu;
28 import android.view.MenuInflater;
29 import android.view.MenuItem;
30 import android.view.View;
31 import android.view.ViewGroup;
32 import android.widget.Button;
33 
34 import androidx.annotation.NonNull;
35 import androidx.annotation.Nullable;
36 import androidx.fragment.app.Fragment;
37 import androidx.fragment.app.FragmentManager;
38 import androidx.lifecycle.ViewModelProvider;
39 import androidx.viewpager2.widget.ViewPager2;
40 
41 import com.android.providers.media.R;
42 import com.android.providers.media.photopicker.PhotoPickerActivity;
43 import com.android.providers.media.photopicker.data.MuteStatus;
44 import com.android.providers.media.photopicker.data.Selection;
45 import com.android.providers.media.photopicker.data.model.Item;
46 import com.android.providers.media.photopicker.util.AccentColorResources;
47 import com.android.providers.media.photopicker.util.LayoutModeUtils;
48 import com.android.providers.media.photopicker.viewmodel.PickerViewModel;
49 
50 import java.text.NumberFormat;
51 import java.util.List;
52 import java.util.Locale;
53 import java.util.Objects;
54 
55 /**
56  * Displays a selected items in one up view. Supports deselecting items.
57  */
58 public class PreviewFragment extends Fragment {
59     private static String TAG = "PreviewFragment";
60 
61     private static final String PREVIEW_TYPE = "preview_type";
62     private static final int PREVIEW_ON_LONG_PRESS = 1;
63     private static final int PREVIEW_ON_VIEW_SELECTED = 2;
64 
65     private static final Bundle sPreviewOnLongPressArgs = new Bundle();
66     static {
sPreviewOnLongPressArgs.putInt(PREVIEW_TYPE, PREVIEW_ON_LONG_PRESS)67         sPreviewOnLongPressArgs.putInt(PREVIEW_TYPE, PREVIEW_ON_LONG_PRESS);
68     }
69     private static final Bundle sPreviewOnViewSelectedArgs = new Bundle();
70     static {
sPreviewOnViewSelectedArgs.putInt(PREVIEW_TYPE, PREVIEW_ON_VIEW_SELECTED)71         sPreviewOnViewSelectedArgs.putInt(PREVIEW_TYPE, PREVIEW_ON_VIEW_SELECTED);
72     }
73 
74     private Selection mSelection;
75     private PickerViewModel mPickerViewModel;
76     private ViewPager2Wrapper mViewPager2Wrapper;
77     private boolean mShouldShowGifBadge;
78     private boolean mShouldShowMotionPhotoBadge;
79     private MuteStatus mMuteStatus;
80     private boolean mIsCustomPickerColorSet = false;
81 
82     @Override
onCreate(Bundle savedInstanceState)83     public void onCreate(Bundle savedInstanceState) {
84         super.onCreate(savedInstanceState);
85         // Register with the activity to inform the system that the app bar fragment is
86         // participating in the population of the options menu
87         setHasOptionsMenu(true);
88     }
89 
90     @Override
onCreateOptionsMenu(@onNull Menu menu, @NonNull MenuInflater inflater)91     public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
92         inflater.inflate(R.menu.picker_preview_menu, menu);
93     }
94 
95     @Override
onPrepareOptionsMenu(@onNull Menu menu)96     public void onPrepareOptionsMenu(@NonNull Menu menu) {
97         super.onPrepareOptionsMenu(menu);
98         // All logic to hide/show an item in the menu must be in this method
99         final MenuItem gifItem = menu.findItem(R.id.preview_gif);
100         final MenuItem motionPhotoItem = menu.findItem(R.id.preview_motion_photo);
101         gifItem.setVisible(mShouldShowGifBadge);
102         motionPhotoItem.setVisible(mShouldShowMotionPhotoBadge);
103     }
104 
105     @Override
onCreateView(LayoutInflater inflater, ViewGroup parent, Bundle savedInstanceState)106     public View onCreateView(LayoutInflater inflater, ViewGroup parent,
107             Bundle savedInstanceState) {
108         mPickerViewModel = new ViewModelProvider(requireActivity()).get(PickerViewModel.class);
109         mIsCustomPickerColorSet =
110                 mPickerViewModel.getPickerAccentColorParameters().isCustomPickerColorSet();
111         mSelection = mPickerViewModel.getSelection();
112         mMuteStatus = mPickerViewModel.getMuteStatus();
113         return inflater.inflate(R.layout.fragment_preview, parent, /* attachToRoot */ false);
114     }
115 
116     @Override
onViewCreated(@onNull View view, @Nullable Bundle savedInstanceState)117     public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
118         // Set the pane title for A11y.
119         view.setAccessibilityPaneTitle(getString(R.string.picker_preview));
120         final List<Item> selectedItemsList = mSelection.getSelectedItemsForPreview();
121         final int selectedItemsListSize = selectedItemsList.size();
122 
123         if (selectedItemsListSize > 1 && !mSelection.canSelectMultiple()) {
124             // This should never happen
125             throw new IllegalStateException("Found more than one preview items in single select"
126                     + " mode. Selected items count: " + selectedItemsListSize);
127         }
128 
129         // Initialize ViewPager2 to swipe between multiple pictures/videos in preview
130         final ViewPager2 viewPager = view.findViewById(R.id.preview_viewPager);
131         if (viewPager == null) {
132             throw new IllegalStateException("Expected to find ViewPager2 in " + view
133                     + ", but found null");
134         }
135         mViewPager2Wrapper = new ViewPager2Wrapper(viewPager, selectedItemsList, mMuteStatus,
136                 mOnCreateSurfaceController, mPickerViewModel::logVideoPreviewMuteButtonClick);
137 
138         setUpPreviewLayout(view, getArguments());
139         setupScrimLayerAndBottomBar(view);
140         // Don't add any code post this line. The lazy loading setup should be the last thing we do
141         // to avoid the UI getting overwritten.
142         setUpUIForLazyLoading(view, selectedItemsListSize);
143     }
144 
setUpUIForLazyLoading(View view, int selectedItemsListSize)145     private void setUpUIForLazyLoading(View view, int selectedItemsListSize) {
146         final Button selectedCheckButton = view.findViewById(R.id.preview_selected_check_button);
147         Objects.requireNonNull(selectedCheckButton);
148         if (selectedItemsListSize == 0) {
149             // This can happen in two cases -
150             // 1. ACTION_USER_SELECT_IMAGES_FOR_APP launched the Photo Picker UI, and we are waiting
151             //    for items that's not preloaded due to pagination
152             // 2. PreviewFragment was launched from SavedPreference state but PickerViewModel was
153             //    killed and hence there is no selected items.
154             // In both these cases, user will see a blank UI with only Add/Allow button
155             selectedCheckButton.setVisibility(View.GONE);
156             Log.i(TAG, "No items to preview yet" + selectedCheckButton.getVisibility());
157         }
158 
159         if (mPickerViewModel.isManagedSelectionEnabled()) {
160             mPickerViewModel.getIsAllPreGrantedMediaLoaded().observe(this, (isLoadComplete) -> {
161                 if (!isLoadComplete) return;
162 
163                 selectedCheckButton.setVisibility(View.VISIBLE);
164 
165                 mSelection.prepareSelectedItemsForPreviewAll();
166                 mViewPager2Wrapper.updateList(mSelection.getSelectedItemsForPreview());
167             });
168         }
169     }
170 
setupScrimLayerAndBottomBar(View fragmentView)171     private void setupScrimLayerAndBottomBar(View fragmentView) {
172         final boolean isLandscape = getResources().getConfiguration().orientation
173                 == Configuration.ORIENTATION_LANDSCAPE;
174 
175         // Show the scrim layers in Landscape mode. The default visibility is GONE.
176         if (isLandscape) {
177             final View topScrim = fragmentView.findViewById(R.id.preview_top_scrim);
178             topScrim.setVisibility(View.VISIBLE);
179 
180             final View bottomScrim = fragmentView.findViewById(R.id.preview_bottom_scrim);
181             bottomScrim.setVisibility(View.VISIBLE);
182         }
183 
184         // Set appropriate background color for the bottom bar
185         final int bottomBarColor;
186         if (isLandscape) {
187             bottomBarColor = Color.TRANSPARENT;
188         } else {
189             bottomBarColor = getContext().getColor(R.color.preview_scrim_solid_color);
190         }
191         final View bottomBar = fragmentView.findViewById(R.id.preview_bottom_bar);
192         bottomBar.setBackgroundColor(bottomBarColor);
193     }
194 
setUpPreviewLayout(@onNull View view, @Nullable Bundle args)195     private void setUpPreviewLayout(@NonNull View view, @Nullable Bundle args) {
196         if (args == null) {
197             // We are willing to crash PhotoPickerActivity because this error might only happen
198             // during development.
199             throw new IllegalArgumentException("Can't determine the type of the Preview, arguments"
200                     + " is not set");
201         }
202 
203         final int previewType = args.getInt(PREVIEW_TYPE, -1);
204         if (previewType == PREVIEW_ON_LONG_PRESS) {
205             setUpPreviewLayoutForLongPress(view);
206         } else if (previewType == PREVIEW_ON_VIEW_SELECTED) {
207             setUpPreviewLayoutForViewSelected(view);
208         } else {
209             // We are willing to crash PhotoPickerActivity because this error might only happen
210             // during development.
211             throw new IllegalArgumentException("No preview type specified");
212         }
213     }
214 
215     /**
216      * Adjusts the select/add button layout for preview on LongPress
217      */
setUpPreviewLayoutForLongPress(@onNull View view)218     private void setUpPreviewLayoutForLongPress(@NonNull View view) {
219         final Button addOrSelectButton = view.findViewById(R.id.preview_add_or_select_button);
220         if (mIsCustomPickerColorSet) {
221             setCustomButtonColorsInLongPressPreviewMode(
222                     addOrSelectButton,
223                     mPickerViewModel.getPickerAccentColorParameters().getPickerAccentColor());
224         }
225 
226         // Preview on Long Press will reuse AddOrSelect button as
227         // * Add button - Button with text "Add" - for single select mode
228         // * Select button - Button with text "Select"/"Deselect" based on the selection state of
229         //                   the item - for multi select mode
230         if (!mSelection.canSelectMultiple()) {
231             // On clicking add button we return the picker result to calling app.
232             // This destroys PickerActivity and all fragments.
233             addOrSelectButton.setOnClickListener(v -> {
234                 ((PhotoPickerActivity) getActivity()).setResultAndFinishSelf();
235             });
236         } else {
237             // For preview on long press, we always preview only one item.
238             // Selection#getSelectedItemsForPreview is guaranteed to return only one item. Hence,
239             // we can always use position=0 as current position.
240             updateSelectButtonTextAndVisibility(addOrSelectButton,
241                     mSelection.isItemSelected(mViewPager2Wrapper.getItemAt(/* position */ 0)));
242             addOrSelectButton.setOnClickListener(v -> onClickSelectButton(addOrSelectButton));
243         }
244 
245         // Set the appropriate special format icon based on the item in the preview
246         updateSpecialFormatIcon(mViewPager2Wrapper.getItemAt(/* position */ 0));
247     }
248 
setCustomButtonColorsInLongPressPreviewMode( Button addOrSelectButton, int buttonBackgroundColor)249     private void setCustomButtonColorsInLongPressPreviewMode(
250             Button addOrSelectButton, int buttonBackgroundColor) {
251         String textColor = mPickerViewModel.getPickerAccentColorParameters().isAccentColorBright()
252                 ? AccentColorResources.DARK_TEXT_COLOR : AccentColorResources.LIGHT_TEXT_COLOR;
253         addOrSelectButton.setBackgroundColor(buttonBackgroundColor);
254         addOrSelectButton.setTextColor(Color.parseColor(textColor));
255     }
256 
257     /**
258      * Adjusts the layout based on Multi select and adds appropriate onClick listeners
259      */
setUpPreviewLayoutForViewSelected(@onNull View view)260     private void setUpPreviewLayoutForViewSelected(@NonNull View view) {
261         // Hide addOrSelect button of long press, we have a separate add button for view selected
262         final Button addOrSelectButton = view.findViewById(R.id.preview_add_or_select_button);
263         addOrSelectButton.setVisibility(View.GONE);
264 
265         final Button viewSelectedAddButton = view.findViewById(R.id.preview_add_button);
266         viewSelectedAddButton.setVisibility(View.VISIBLE);
267         // On clicking add button we return the picker result to calling app.
268         // This destroys PickerActivity and all fragments.
269         viewSelectedAddButton.setOnClickListener(v -> {
270             ((PhotoPickerActivity) getActivity()).setResultAndFinishSelf();
271         });
272 
273         final Button selectedCheckButton = view.findViewById(R.id.preview_selected_check_button);
274         selectedCheckButton.setVisibility(View.VISIBLE);
275 
276         if (mIsCustomPickerColorSet) {
277             setCustomButtonColorsInViewSelectedPreviewMode(
278                     viewSelectedAddButton, selectedCheckButton,
279                     mPickerViewModel.getPickerAccentColorParameters().getPickerAccentColor(),
280                     AccentColorResources.LIGHT_TEXT_COLOR);
281         }
282         // Update the select icon and text according to the state of selection while swiping
283         // between photos
284         mViewPager2Wrapper.addOnPageChangeCallback(new OnPageChangeCallback(selectedCheckButton));
285 
286         // Update add button text to include number of items selected.
287         mSelection
288                 .getSelectedItemCount()
289                 .observe(
290                         this,
291                         selectedItemCount -> {
292                             viewSelectedAddButton.setText(
293                                     generateAddButtonString(
294                                             /* context= */ getContext(),
295                                             /* size= */ selectedItemCount,
296                                             /* isUserSelectForApp= */ mPickerViewModel
297                                                     .isUserSelectForApp(),
298                                             /* isManagedSelectionEnabled */
299                                             mPickerViewModel.isManagedSelectionEnabled()));
300                         });
301 
302         selectedCheckButton.setOnClickListener(
303                 v -> onClickSelectedCheckButton(selectedCheckButton));
304     }
305 
setCustomButtonColorsInViewSelectedPreviewMode( Button addButton, Button viewSelectedButton, int buttonFillColor, String viewSelectedButtonTextColor)306     private void setCustomButtonColorsInViewSelectedPreviewMode(
307             Button addButton, Button viewSelectedButton, int buttonFillColor,
308             String viewSelectedButtonTextColor) {
309         // Set add button colors
310         String addButtonTextColor =
311                 mPickerViewModel.getPickerAccentColorParameters().isAccentColorBright()
312                         ? AccentColorResources.DARK_TEXT_COLOR
313                         : AccentColorResources.LIGHT_TEXT_COLOR;
314         addButton.setBackgroundColor(buttonFillColor);
315         addButton.setTextColor(Color.parseColor(addButtonTextColor));
316         // Set view-selected button colors
317         viewSelectedButton.setTextColor(Color.parseColor(viewSelectedButtonTextColor));
318         viewSelectedButton.setCompoundDrawableTintList(
319                 ColorStateList.valueOf(buttonFillColor));
320     }
321 
322     @Override
onResume()323     public void onResume() {
324         super.onResume();
325 
326         ((PhotoPickerActivity) getActivity()).updateCommonLayouts(LayoutModeUtils.MODE_PREVIEW,
327                 /* title */"");
328     }
329 
330     @Override
onStop()331     public void onStop() {
332         super.onStop();
333 
334         if (mViewPager2Wrapper != null) {
335             mViewPager2Wrapper.onStop();
336         }
337     }
338 
339     @Override
onStart()340     public void onStart() {
341         super.onStart();
342 
343         if (mViewPager2Wrapper != null) {
344             mViewPager2Wrapper.onStart();
345         }
346     }
347 
348     @Override
onDestroy()349     public void onDestroy() {
350         super.onDestroy();
351         if (mViewPager2Wrapper != null) {
352             mViewPager2Wrapper.onDestroy();
353         }
354     }
355 
onClickSelectButton(@onNull Button selectButton)356     private void onClickSelectButton(@NonNull Button selectButton) {
357         final boolean isSelectedNow = updateSelectionAndGetState();
358         updateSelectButtonTextAndVisibility(selectButton, isSelectedNow);
359     }
360 
onClickSelectedCheckButton(@onNull Button selectedCheckButton)361     private void onClickSelectedCheckButton(@NonNull Button selectedCheckButton) {
362         final boolean isSelectedNow = updateSelectionAndGetState();
363         updateSelectedCheckButtonStateAndText(selectedCheckButton, isSelectedNow);
364     }
365 
updateSelectionAndGetState()366     private boolean updateSelectionAndGetState() {
367         final Item currentItem = mViewPager2Wrapper.getCurrentItem();
368         final boolean wasSelectedBefore = mSelection.isItemSelected(currentItem);
369 
370         if (wasSelectedBefore) {
371             // If the item is previously selected, current user action is to deselect the item
372             mSelection.removeSelectedItem(currentItem);
373         } else {
374             // If the item is not previously selected, current user action is to select the item
375             mSelection.addSelectedItem(currentItem);
376         }
377 
378         // After the user has clicked the button, current state of the button should be opposite of
379         // the previous state.
380         // If the previous state was to "Select" the item, and user clicks "Select" button,
381         // wasSelectedBefore = false. And item will be added to selected items. Now, user can only
382         // deselect the item. Hence, isSelectedNow is opposite of previous state,
383         // i.e., isSelectedNow = true.
384         return !wasSelectedBefore;
385     }
386 
387     private class OnPageChangeCallback extends ViewPager2.OnPageChangeCallback {
388         private final Button mSelectedCheckButton;
389 
OnPageChangeCallback(@onNull Button selectedCheckButton)390         public OnPageChangeCallback(@NonNull Button selectedCheckButton) {
391             mSelectedCheckButton = selectedCheckButton;
392         }
393 
394         @Override
onPageSelected(int position)395         public void onPageSelected(int position) {
396             // No action to take as we don't have deselect view here.
397             if (!mSelection.canSelectMultiple()) return;
398 
399             final Item item = mViewPager2Wrapper.getItemAt(position);
400             // Set the appropriate select/deselect state for each item in each page based on the
401             // selection list.
402             updateSelectedCheckButtonStateAndText(mSelectedCheckButton,
403                     mSelection.isItemSelected(item));
404 
405             // Set the appropriate special format icon based on the item in the preview
406             updateSpecialFormatIcon(item);
407         }
408     }
409 
updateSelectButtonTextAndVisibility(@onNull Button selectButton, boolean isSelected)410     private void updateSelectButtonTextAndVisibility(@NonNull Button selectButton,
411             boolean isSelected) {
412         selectButton.setText(isSelected ? R.string.deselect : R.string.select);
413         selectButton.setVisibility(
414                 (isSelected || mSelection.isSelectionAllowed()) ? View.VISIBLE : View.GONE);
415     }
416 
updateSelectedCheckButtonStateAndText(@onNull Button selectedCheckButton, boolean isSelected)417     private static void updateSelectedCheckButtonStateAndText(@NonNull Button selectedCheckButton,
418             boolean isSelected) {
419         selectedCheckButton.setText(isSelected ? R.string.selected : R.string.deselected);
420         selectedCheckButton.setSelected(isSelected);
421     }
422 
updateSpecialFormatIcon(Item item)423     private void updateSpecialFormatIcon(Item item) {
424         mShouldShowGifBadge = item.isGifOrAnimatedWebp();
425         mShouldShowMotionPhotoBadge = item.isMotionPhoto();
426         // Invalidating options menu calls onPrepareOptionsMenu() where the logic for
427         // hiding/showing menu items is placed.
428         requireActivity().invalidateOptionsMenu();
429     }
430 
show(@onNull FragmentManager fm, @NonNull Bundle args)431     public static void show(@NonNull FragmentManager fm, @NonNull Bundle args) {
432         if (fm.isStateSaved()) {
433             Log.d(TAG, "Skip show preview fragment because state saved");
434             return;
435         }
436 
437         final PreviewFragment fragment = new PreviewFragment();
438         fragment.setArguments(args);
439         fm.beginTransaction()
440                 .replace(R.id.fragment_container, fragment, TAG)
441                 .addToBackStack(TAG)
442                 .commitAllowingStateLoss();
443     }
444 
445     /**
446      * Get the fragment in the FragmentManager
447      * @param fm the fragment manager
448      */
get(@onNull FragmentManager fm)449     public static Fragment get(@NonNull FragmentManager fm) {
450         return fm.findFragmentByTag(TAG);
451     }
452 
getArgsForPreviewOnLongPress()453     public static Bundle getArgsForPreviewOnLongPress() {
454         return sPreviewOnLongPressArgs;
455     }
456 
getArgsForPreviewOnViewSelected()457     public static Bundle getArgsForPreviewOnViewSelected() {
458         return sPreviewOnViewSelectedArgs;
459     }
460 
461     // TODO: There is a same method in TabFragment. To find a way to reuse it.
generateAddButtonString( @onNull Context context, int size, boolean isUserSelectForApp, boolean isManagedSelection)462     private static String generateAddButtonString(
463             @NonNull Context context, int size, boolean isUserSelectForApp,
464             boolean isManagedSelection) {
465         if (isManagedSelection && size == 0) {
466             return context.getString(R.string.picker_add_button_allow_none_option);
467         }
468         final String sizeString = NumberFormat.getInstance(Locale.getDefault()).format(size);
469         final String template =
470                 isUserSelectForApp
471                         ? context.getString(R.string.picker_add_button_multi_select_permissions)
472                         : context.getString(R.string.picker_add_button_multi_select);
473         return TextUtils.expandTemplate(template, sizeString).toString();
474     }
475 
476     private final PreviewAdapter.OnCreateSurfaceController mOnCreateSurfaceController =
477             new PreviewAdapter.OnCreateSurfaceController() {
478                 @Override
479                 public void logStart(String authority) {
480                     mPickerViewModel.logCreateSurfaceControllerStart(authority);
481                 }
482 
483                 @Override
484                 public void logEnd(String authority) {
485                     mPickerViewModel.logCreateSurfaceControllerEnd(authority);
486                 }
487             };
488 }
489