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