• Home
  • History
  • Annotate
  • 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