1 /*
2  * Copyright (C) 2018 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.documentsui.queries;
18 
19 import android.animation.ObjectAnimator;
20 import android.content.Context;
21 import android.graphics.drawable.Drawable;
22 import android.os.Bundle;
23 import android.provider.DocumentsContract;
24 import android.view.LayoutInflater;
25 import android.view.View;
26 import android.view.ViewGroup;
27 import android.widget.HorizontalScrollView;
28 
29 import androidx.annotation.NonNull;
30 import androidx.annotation.Nullable;
31 import androidx.annotation.VisibleForTesting;
32 
33 import com.android.documentsui.IconUtils;
34 import com.android.documentsui.MetricConsts;
35 import com.android.documentsui.R;
36 import com.android.documentsui.base.MimeTypes;
37 import com.android.documentsui.base.Shared;
38 
39 import com.google.android.material.chip.Chip;
40 import com.google.common.primitives.Ints;
41 
42 import java.time.LocalDate;
43 import java.time.ZoneId;
44 import java.util.ArrayList;
45 import java.util.Arrays;
46 import java.util.Collections;
47 import java.util.Comparator;
48 import java.util.HashMap;
49 import java.util.HashSet;
50 import java.util.List;
51 import java.util.Map;
52 import java.util.Set;
53 
54 /**
55  * Manages search chip behavior.
56  */
57 public class SearchChipViewManager {
58     private static final int CHIP_MOVE_ANIMATION_DURATION = 250;
59     // Defined large file as the size is larger than 10 MB.
60     private static final long LARGE_FILE_SIZE_BYTES = 10000000L;
61     // Defined a week ago as now in millis.
62     private static final long A_WEEK_AGO_MILLIS =
63             LocalDate.now().minusDays(7).atStartOfDay(ZoneId.systemDefault())
64                     .toInstant()
65                     .toEpochMilli();
66 
67     private static final int TYPE_IMAGES = MetricConsts.TYPE_CHIP_IMAGES;
68     private static final int TYPE_DOCUMENTS = MetricConsts.TYPE_CHIP_DOCS;
69     private static final int TYPE_AUDIO = MetricConsts.TYPE_CHIP_AUDIOS;
70     private static final int TYPE_VIDEOS = MetricConsts.TYPE_CHIP_VIDEOS;
71     private static final int TYPE_LARGE_FILES = MetricConsts.TYPE_CHIP_LARGE_FILES;
72     private static final int TYPE_FROM_THIS_WEEK = MetricConsts.TYPE_CHIP_FROM_THIS_WEEK;
73 
74     private static final ChipComparator CHIP_COMPARATOR = new ChipComparator();
75 
76     // we will get the icon drawable with the first mimeType
77     private static final String[] IMAGES_MIMETYPES = new String[]{"image/*"};
78     private static final String[] VIDEOS_MIMETYPES = new String[]{"video/*"};
79     private static final String[] AUDIO_MIMETYPES =
80             new String[]{"audio/*", "application/ogg", "application/x-flac"};
81     private static final String[] DOCUMENTS_MIMETYPES = MimeTypes.getDocumentMimeTypeArray();
82     private static final String[] EMPTY_MIMETYPES = new String[]{""};
83 
84     private static final Map<Integer, SearchChipData> sMimeTypesChipItems = new HashMap<>();
85     private static final Map<Integer, SearchChipData> sDefaultChipItems = new HashMap<>();
86 
87     private final ViewGroup mChipGroup;
88     private final List<Integer> mDefaultChipTypes = new ArrayList<>();
89     private SearchChipViewManagerListener mListener;
90     private String[] mCurrentUpdateMimeTypes;
91     private boolean mIsFirstUpdateChipsReady;
92 
93     @VisibleForTesting
94     Set<SearchChipData> mCheckedChipItems = new HashSet<>();
95 
96     static {
sMimeTypesChipItems.put(TYPE_IMAGES, new SearchChipData(TYPE_IMAGES, R.string.chip_title_images, IMAGES_MIMETYPES))97         sMimeTypesChipItems.put(TYPE_IMAGES,
98                 new SearchChipData(TYPE_IMAGES, R.string.chip_title_images, IMAGES_MIMETYPES));
sMimeTypesChipItems.put(TYPE_DOCUMENTS, new SearchChipData(TYPE_DOCUMENTS, R.string.chip_title_documents, DOCUMENTS_MIMETYPES))99         sMimeTypesChipItems.put(TYPE_DOCUMENTS,
100                 new SearchChipData(TYPE_DOCUMENTS, R.string.chip_title_documents,
101                         DOCUMENTS_MIMETYPES));
sMimeTypesChipItems.put(TYPE_AUDIO, new SearchChipData(TYPE_AUDIO, R.string.chip_title_audio, AUDIO_MIMETYPES))102         sMimeTypesChipItems.put(TYPE_AUDIO,
103                 new SearchChipData(TYPE_AUDIO, R.string.chip_title_audio, AUDIO_MIMETYPES));
sMimeTypesChipItems.put(TYPE_VIDEOS, new SearchChipData(TYPE_VIDEOS, R.string.chip_title_videos, VIDEOS_MIMETYPES))104         sMimeTypesChipItems.put(TYPE_VIDEOS,
105                 new SearchChipData(TYPE_VIDEOS, R.string.chip_title_videos, VIDEOS_MIMETYPES));
sDefaultChipItems.put(TYPE_LARGE_FILES, new SearchChipData(TYPE_LARGE_FILES, R.string.chip_title_large_files, EMPTY_MIMETYPES))106         sDefaultChipItems.put(TYPE_LARGE_FILES,
107                 new SearchChipData(TYPE_LARGE_FILES,
108                         R.string.chip_title_large_files,
109                         EMPTY_MIMETYPES));
sDefaultChipItems.put(TYPE_FROM_THIS_WEEK, new SearchChipData(TYPE_FROM_THIS_WEEK, R.string.chip_title_from_this_week, EMPTY_MIMETYPES))110         sDefaultChipItems.put(TYPE_FROM_THIS_WEEK,
111                 new SearchChipData(TYPE_FROM_THIS_WEEK,
112                         R.string.chip_title_from_this_week,
113                         EMPTY_MIMETYPES));
114     }
115 
SearchChipViewManager(@onNull ViewGroup chipGroup)116     public SearchChipViewManager(@NonNull ViewGroup chipGroup) {
117         mChipGroup = chipGroup;
118     }
119 
120     /**
121      * Restore the checked chip items by the saved state.
122      *
123      * @param savedState the saved state to restore.
124      */
restoreCheckedChipItems(Bundle savedState)125     public void restoreCheckedChipItems(Bundle savedState) {
126         final int[] chipTypes = savedState.getIntArray(Shared.EXTRA_QUERY_CHIPS);
127         if (chipTypes != null) {
128             clearCheckedChips();
129             for (int chipType : chipTypes) {
130                 SearchChipData chipData = null;
131                 if (sMimeTypesChipItems.containsKey(chipType)) {
132                     chipData = sMimeTypesChipItems.get(chipType);
133                 } else {
134                     chipData = sDefaultChipItems.get(chipType);
135                 }
136 
137                 mCheckedChipItems.add(chipData);
138                 setCheckedChip(chipData.getChipType());
139             }
140         }
141     }
142 
143     /**
144      * Set the visibility of the chips row. If the count of chips is less than 2,
145      * we will hide the chips row.
146      *
147      * @param show the value to show/hide the chips row.
148      */
setChipsRowVisible(boolean show)149     public void setChipsRowVisible(boolean show) {
150         // if there is only one matched chip, hide the chip group.
151         mChipGroup.setVisibility(show && mChipGroup.getChildCount() > 1 ? View.VISIBLE : View.GONE);
152     }
153 
154     /**
155      * Check Whether the checked item list has contents.
156      *
157      * @return True, if the checked item list is not empty. Otherwise, return false.
158      */
hasCheckedItems()159     public boolean hasCheckedItems() {
160         return !mCheckedChipItems.isEmpty();
161     }
162 
163     /**
164      * Clear the checked state of Chips and the checked list.
165      */
clearCheckedChips()166     public void clearCheckedChips() {
167         final int count = mChipGroup.getChildCount();
168         for (int i = 0; i < count; i++) {
169             Chip child = (Chip) mChipGroup.getChildAt(i);
170             setChipChecked(child, false /* isChecked */);
171         }
172         mCheckedChipItems.clear();
173     }
174 
175     /**
176      * Get the query arguments of the checked chips.
177      *
178      * @return the bundle of query arguments
179      */
getCheckedChipQueryArgs()180     public Bundle getCheckedChipQueryArgs() {
181         final Bundle queryArgs = new Bundle();
182         final ArrayList<String> checkedMimeTypes = new ArrayList<>();
183         for (SearchChipData data : mCheckedChipItems) {
184             if (data.getChipType() == MetricConsts.TYPE_CHIP_LARGE_FILES) {
185                 queryArgs.putLong(DocumentsContract.QUERY_ARG_FILE_SIZE_OVER,
186                         LARGE_FILE_SIZE_BYTES);
187             } else if (data.getChipType() == MetricConsts.TYPE_CHIP_FROM_THIS_WEEK) {
188                 queryArgs.putLong(DocumentsContract.QUERY_ARG_LAST_MODIFIED_AFTER,
189                         A_WEEK_AGO_MILLIS);
190             } else {
191                 for (String mimeType : data.getMimeTypes()) {
192                     checkedMimeTypes.add(mimeType);
193                 }
194             }
195         }
196 
197         if (!checkedMimeTypes.isEmpty()) {
198             queryArgs.putStringArray(DocumentsContract.QUERY_ARG_MIME_TYPES,
199                     checkedMimeTypes.toArray(new String[0]));
200         }
201 
202         return queryArgs;
203     }
204 
205     /**
206      * Called when owning activity is saving state to be used to restore state during creation.
207      *
208      * @param state Bundle to save state
209      */
onSaveInstanceState(Bundle state)210     public void onSaveInstanceState(Bundle state) {
211         List<Integer> checkedChipList = new ArrayList<>();
212 
213         for (SearchChipData item : mCheckedChipItems) {
214             checkedChipList.add(item.getChipType());
215         }
216 
217         if (checkedChipList.size() > 0) {
218             state.putIntArray(Shared.EXTRA_QUERY_CHIPS, Ints.toArray(checkedChipList));
219         }
220     }
221 
222     /**
223      * Initialize the search chips base on the mime types.
224      *
225      * @param acceptMimeTypes use this values to filter chips
226      */
initChipSets(String[] acceptMimeTypes)227     public void initChipSets(String[] acceptMimeTypes) {
228         mDefaultChipTypes.clear();
229         for (SearchChipData chipData : sMimeTypesChipItems.values()) {
230             final String[] mimeTypes = chipData.getMimeTypes();
231             final boolean isMatched = MimeTypes.mimeMatches(acceptMimeTypes, mimeTypes);
232             if (isMatched) {
233                 mDefaultChipTypes.add(chipData.getChipType());
234             }
235         }
236     }
237 
238     /**
239      * Update the search chips base on the mime types.
240      *
241      * @param acceptMimeTypes use this values to filter chips
242      */
updateChips(String[] acceptMimeTypes)243     public void updateChips(String[] acceptMimeTypes) {
244         if (mIsFirstUpdateChipsReady && Arrays.equals(mCurrentUpdateMimeTypes, acceptMimeTypes)) {
245             return;
246         }
247 
248         final Context context = mChipGroup.getContext();
249         mChipGroup.removeAllViews();
250 
251         final List<SearchChipData> mimeChipDataList = new ArrayList<>();
252         for (int i = 0; i < mDefaultChipTypes.size(); i++) {
253             final SearchChipData chipData = sMimeTypesChipItems.get(mDefaultChipTypes.get(i));
254             final String[] mimeTypes = chipData.getMimeTypes();
255             final boolean isMatched = MimeTypes.mimeMatches(acceptMimeTypes, mimeTypes);
256             if (isMatched) {
257                 mimeChipDataList.add(chipData);
258             }
259         }
260 
261         final LayoutInflater inflater = LayoutInflater.from(context);
262         if (mimeChipDataList.size() > 1) {
263             for (int i = 0; i < mimeChipDataList.size(); i++) {
264                 addChipToGroup(mChipGroup, mimeChipDataList.get(i), inflater);
265             }
266         }
267 
268         for (SearchChipData chipData : sDefaultChipItems.values()) {
269             addChipToGroup(mChipGroup, chipData, inflater);
270         }
271 
272         reorderCheckedChips(null /* clickedChip */, false /* hasAnim */);
273         mIsFirstUpdateChipsReady = true;
274         mCurrentUpdateMimeTypes = acceptMimeTypes;
275     }
276 
addChipToGroup(ViewGroup group, SearchChipData data, LayoutInflater inflater)277     private void addChipToGroup(ViewGroup group, SearchChipData data, LayoutInflater inflater) {
278         Chip chip = (Chip) inflater.inflate(R.layout.search_chip_item, mChipGroup, false);
279         bindChip(chip, data);
280         group.addView(chip);
281     }
282 
283     /**
284      * Mirror chip group here for another chip group
285      *
286      * @param chipGroup target view group for mirror
287      */
bindMirrorGroup(ViewGroup chipGroup)288     public void bindMirrorGroup(ViewGroup chipGroup) {
289         final int size = mChipGroup.getChildCount();
290         if (size <= 1) {
291             chipGroup.setVisibility(View.GONE);
292             return;
293         }
294 
295         chipGroup.setVisibility(View.VISIBLE);
296         chipGroup.removeAllViews();
297         final LayoutInflater inflater = LayoutInflater.from(chipGroup.getContext());
298         for (int i = 0; i < size; i++) {
299             Chip child = (Chip) mChipGroup.getChildAt(i);
300             SearchChipData item = (SearchChipData) child.getTag();
301             addChipToGroup(chipGroup, item, inflater);
302         }
303     }
304 
305     /**
306      * Click behavior handle here when mirror chip clicked.
307      *
308      * @param data SearchChipData synced in mirror group
309      */
onMirrorChipClick(SearchChipData data)310     public void onMirrorChipClick(SearchChipData data) {
311         for (int i = 0, size = mChipGroup.getChildCount(); i < size; i++) {
312             Chip chip = (Chip) mChipGroup.getChildAt(i);
313             if (chip.getTag().equals(data)) {
314                 chip.setChecked(!chip.isChecked());
315                 onChipClick(chip);
316                 return;
317             }
318         }
319     }
320 
321     /**
322      * Set the listener.
323      *
324      * @param listener the listener
325      */
setSearchChipViewManagerListener(SearchChipViewManagerListener listener)326     public void setSearchChipViewManagerListener(SearchChipViewManagerListener listener) {
327         mListener = listener;
328     }
329 
setChipChecked(Chip chip, boolean isChecked)330     private static void setChipChecked(Chip chip, boolean isChecked) {
331         chip.setChecked(isChecked);
332         chip.setChipIconVisible(!isChecked);
333     }
334 
setCheckedChip(int chipType)335     private void setCheckedChip(int chipType) {
336         final int count = mChipGroup.getChildCount();
337         for (int i = 0; i < count; i++) {
338             Chip child = (Chip) mChipGroup.getChildAt(i);
339             SearchChipData item = (SearchChipData) child.getTag();
340             if (item.getChipType() == chipType) {
341                 setChipChecked(child, true /* isChecked */);
342                 break;
343             }
344         }
345     }
346 
onChipClick(View v)347     private void onChipClick(View v) {
348         final Chip chip = (Chip) v;
349 
350         // We need to show/hide the chip icon in our design.
351         // When we show/hide the chip icon or do reorder animation,
352         // the ripple effect will be interrupted. So, skip ripple
353         // effect when the chip is clicked.
354         chip.getBackground().setVisible(false /* visible */, false /* restart */);
355 
356         final SearchChipData item = (SearchChipData) chip.getTag();
357         if (chip.isChecked()) {
358             mCheckedChipItems.add(item);
359         } else {
360             mCheckedChipItems.remove(item);
361         }
362 
363         setChipChecked(chip, chip.isChecked());
364         reorderCheckedChips(chip, true /* hasAnim */);
365 
366         if (mListener != null) {
367             mListener.onChipCheckStateChanged(v);
368         }
369     }
370 
bindChip(Chip chip, SearchChipData chipData)371     private void bindChip(Chip chip, SearchChipData chipData) {
372         final Context context = mChipGroup.getContext();
373         chip.setTag(chipData);
374         chip.setText(context.getString(chipData.getTitleRes()));
375         Drawable chipIcon;
376         if (chipData.getChipType() == TYPE_LARGE_FILES) {
377             chipIcon = context.getDrawable(R.drawable.ic_chip_large_files);
378         } else if (chipData.getChipType() == TYPE_FROM_THIS_WEEK) {
379             chipIcon = context.getDrawable(R.drawable.ic_chip_from_this_week);
380         } else if (chipData.getChipType() == TYPE_DOCUMENTS) {
381             chipIcon = IconUtils.loadMimeIcon(context, MimeTypes.GENERIC_TYPE);
382         } else {
383             // get the icon drawable with the first mimeType in chipData
384             chipIcon = IconUtils.loadMimeIcon(context, chipData.getMimeTypes()[0]);
385         }
386         chip.setChipIcon(chipIcon);
387         chip.setOnClickListener(this::onChipClick);
388 
389         if (mCheckedChipItems.contains(chipData)) {
390             setChipChecked(chip, true);
391         }
392     }
393 
394     /**
395      * Reorder the chips in chip group. The checked chip has higher order.
396      *
397      * @param clickedChip the clicked chip, may be null.
398      * @param hasAnim if true, play move animation. Otherwise, not.
399      */
reorderCheckedChips(@ullable Chip clickedChip, boolean hasAnim)400     private void reorderCheckedChips(@Nullable Chip clickedChip, boolean hasAnim) {
401         final ArrayList<Chip> chipList = new ArrayList<>();
402         final int count = mChipGroup.getChildCount();
403 
404         // if the size of chips is less than 2, no need to reorder chips
405         if (count < 2) {
406             return;
407         }
408 
409         Chip item;
410         // get the default order
411         for (int i = 0; i < count; i++) {
412             item = (Chip) mChipGroup.getChildAt(i);
413             chipList.add(item);
414         }
415 
416         // sort chips
417         Collections.sort(chipList, CHIP_COMPARATOR);
418 
419         if (isChipOrderMatched(mChipGroup, chipList)) {
420             // the order of chips is not changed
421             return;
422         }
423 
424         final int chipSpacing = mChipGroup.getPaddingEnd();
425         final boolean isRtl = mChipGroup.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
426         float lastX = isRtl ? mChipGroup.getWidth() - chipSpacing : chipSpacing;
427 
428         // remove all chips except current clicked chip to avoid losing
429         // accessibility focus.
430         for (int i = count - 1; i >= 0; i--) {
431             item = (Chip) mChipGroup.getChildAt(i);
432             if (!item.equals(clickedChip)) {
433                 mChipGroup.removeView(item);
434             }
435         }
436 
437         // add sorted chips
438         for (int i = 0; i < count; i++) {
439             item = chipList.get(i);
440             if (!item.equals(clickedChip)) {
441                 mChipGroup.addView(item, i);
442             }
443         }
444 
445         if (hasAnim && mChipGroup.isAttachedToWindow()) {
446             // start animation
447             for (Chip chip : chipList) {
448                 if (isRtl) {
449                     lastX -= chip.getMeasuredWidth();
450                 }
451 
452                 ObjectAnimator animator = ObjectAnimator.ofFloat(chip, "x", chip.getX(), lastX);
453 
454                 if (isRtl) {
455                     lastX -= chipSpacing;
456                 } else {
457                     lastX += chip.getMeasuredWidth() + chipSpacing;
458                 }
459                 animator.setDuration(CHIP_MOVE_ANIMATION_DURATION);
460                 animator.start();
461             }
462 
463             // Let the first checked chip can be shown.
464             View parent = (View) mChipGroup.getParent();
465             if (parent instanceof HorizontalScrollView) {
466                 final int scrollToX = isRtl ? parent.getWidth() : 0;
467                 ((HorizontalScrollView) parent).smoothScrollTo(scrollToX, 0);
468             }
469         }
470     }
471 
isChipOrderMatched(ViewGroup chipGroup, ArrayList<Chip> chipList)472     private static boolean isChipOrderMatched(ViewGroup chipGroup, ArrayList<Chip> chipList) {
473         if (chipGroup == null || chipList == null) {
474             return false;
475         }
476 
477         final int chipCount = chipList.size();
478         if (chipGroup.getChildCount() != chipCount) {
479             return false;
480         }
481         for (int i = 0; i < chipCount; i++) {
482             if (!chipList.get(i).equals(chipGroup.getChildAt(i))) {
483                 return false;
484             }
485         }
486         return true;
487     }
488 
489     /**
490      * The listener of SearchChipViewManager.
491      */
492     public interface SearchChipViewManagerListener {
493         /**
494          * It will be triggered when the checked state of chips changes.
495          */
onChipCheckStateChanged(View v)496         void onChipCheckStateChanged(View v);
497     }
498 
499     private static class ChipComparator implements Comparator<Chip> {
500 
501         @Override
compare(Chip lhs, Chip rhs)502         public int compare(Chip lhs, Chip rhs) {
503             return (lhs.isChecked() == rhs.isChecked()) ? 0 : (lhs.isChecked() ? -1 : 1);
504         }
505     }
506 }
507