| /* |
| * Copyright (C) 2018 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.documentsui.queries; |
| |
| import android.animation.ObjectAnimator; |
| import android.content.Context; |
| import android.graphics.drawable.Drawable; |
| import android.os.Bundle; |
| import android.provider.DocumentsContract; |
| import android.view.LayoutInflater; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.accessibility.AccessibilityEvent; |
| import android.widget.HorizontalScrollView; |
| |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.VisibleForTesting; |
| |
| import com.android.documentsui.IconUtils; |
| import com.android.documentsui.MetricConsts; |
| import com.android.documentsui.R; |
| import com.android.documentsui.base.MimeTypes; |
| import com.android.documentsui.base.Shared; |
| import com.android.documentsui.util.VersionUtils; |
| |
| import com.google.android.material.chip.Chip; |
| import com.google.common.primitives.Ints; |
| |
| import java.time.LocalDate; |
| import java.time.ZoneId; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Collections; |
| import java.util.Comparator; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| |
| /** |
| * Manages search chip behavior. |
| */ |
| public class SearchChipViewManager { |
| private static final int CHIP_MOVE_ANIMATION_DURATION = 250; |
| // Defined large file as the size is larger than 10 MB. |
| private static final long LARGE_FILE_SIZE_BYTES = 10000000L; |
| // Defined a week ago as now in millis. |
| private static final long A_WEEK_AGO_MILLIS = |
| LocalDate.now().minusDays(7).atStartOfDay(ZoneId.systemDefault()) |
| .toInstant() |
| .toEpochMilli(); |
| |
| private static final int TYPE_IMAGES = MetricConsts.TYPE_CHIP_IMAGES; |
| private static final int TYPE_DOCUMENTS = MetricConsts.TYPE_CHIP_DOCS; |
| private static final int TYPE_AUDIO = MetricConsts.TYPE_CHIP_AUDIOS; |
| private static final int TYPE_VIDEOS = MetricConsts.TYPE_CHIP_VIDEOS; |
| private static final int TYPE_LARGE_FILES = MetricConsts.TYPE_CHIP_LARGE_FILES; |
| private static final int TYPE_FROM_THIS_WEEK = MetricConsts.TYPE_CHIP_FROM_THIS_WEEK; |
| |
| private static final ChipComparator CHIP_COMPARATOR = new ChipComparator(); |
| |
| // we will get the icon drawable with the first mimeType |
| private static final String[] IMAGES_MIMETYPES = new String[]{"image/*"}; |
| private static final String[] VIDEOS_MIMETYPES = new String[]{"video/*"}; |
| private static final String[] AUDIO_MIMETYPES = |
| new String[]{"audio/*", "application/ogg", "application/x-flac"}; |
| private static final String[] DOCUMENTS_MIMETYPES = MimeTypes.getDocumentMimeTypeArray(); |
| private static final String[] EMPTY_MIMETYPES = new String[]{""}; |
| |
| private static final Map<Integer, SearchChipData> sMimeTypesChipItems = new HashMap<>(); |
| private static final Map<Integer, SearchChipData> sDefaultChipItems = new HashMap<>(); |
| |
| private final ViewGroup mChipGroup; |
| private final List<Integer> mDefaultChipTypes = new ArrayList<>(); |
| private SearchChipViewManagerListener mListener; |
| private String[] mCurrentUpdateMimeTypes; |
| private boolean mIsFirstUpdateChipsReady; |
| |
| @VisibleForTesting |
| Set<SearchChipData> mCheckedChipItems = new HashSet<>(); |
| |
| static { |
| sMimeTypesChipItems.put(TYPE_IMAGES, |
| new SearchChipData(TYPE_IMAGES, R.string.chip_title_images, IMAGES_MIMETYPES)); |
| if (VersionUtils.isAtLeastR()) { |
| sMimeTypesChipItems.put(TYPE_DOCUMENTS, |
| new SearchChipData(TYPE_DOCUMENTS, R.string.chip_title_documents, |
| DOCUMENTS_MIMETYPES)); |
| } |
| sMimeTypesChipItems.put(TYPE_AUDIO, |
| 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)); |
| sDefaultChipItems.put(TYPE_LARGE_FILES, |
| new SearchChipData(TYPE_LARGE_FILES, |
| R.string.chip_title_large_files, |
| EMPTY_MIMETYPES)); |
| sDefaultChipItems.put(TYPE_FROM_THIS_WEEK, |
| new SearchChipData(TYPE_FROM_THIS_WEEK, |
| R.string.chip_title_from_this_week, |
| EMPTY_MIMETYPES)); |
| } |
| |
| public SearchChipViewManager(@NonNull ViewGroup chipGroup) { |
| mChipGroup = chipGroup; |
| } |
| |
| /** |
| * Restore the checked chip items by the saved state. |
| * |
| * @param savedState the saved state to restore. |
| */ |
| public void restoreCheckedChipItems(Bundle savedState) { |
| final int[] chipTypes = savedState.getIntArray(Shared.EXTRA_QUERY_CHIPS); |
| if (chipTypes != null) { |
| clearCheckedChips(); |
| for (int chipType : chipTypes) { |
| SearchChipData chipData = null; |
| if (sMimeTypesChipItems.containsKey(chipType)) { |
| chipData = sMimeTypesChipItems.get(chipType); |
| } else { |
| chipData = sDefaultChipItems.get(chipType); |
| } |
| |
| mCheckedChipItems.add(chipData); |
| setCheckedChip(chipData.getChipType()); |
| } |
| } |
| } |
| |
| /** |
| * Set the visibility of the chips row. If the count of chips is less than 2, |
| * we will hide the chips row. |
| * |
| * @param show the value to show/hide the chips row. |
| */ |
| public void setChipsRowVisible(boolean show) { |
| // if there is only one matched chip, hide the chip group. |
| mChipGroup.setVisibility(show && mChipGroup.getChildCount() > 1 ? View.VISIBLE : View.GONE); |
| } |
| |
| /** |
| * Check Whether the checked item list has contents. |
| * |
| * @return True, if the checked item list is not empty. Otherwise, return false. |
| */ |
| public boolean hasCheckedItems() { |
| return !mCheckedChipItems.isEmpty(); |
| } |
| |
| /** |
| * Clear the checked state of Chips and the checked list. |
| */ |
| public void clearCheckedChips() { |
| final int count = mChipGroup.getChildCount(); |
| for (int i = 0; i < count; i++) { |
| Chip child = (Chip) mChipGroup.getChildAt(i); |
| setChipChecked(child, false /* isChecked */); |
| } |
| mCheckedChipItems.clear(); |
| } |
| |
| /** |
| * Get the query arguments of the checked chips. |
| * |
| * @return the bundle of query arguments |
| */ |
| public Bundle getCheckedChipQueryArgs() { |
| final Bundle queryArgs = new Bundle(); |
| final ArrayList<String> checkedMimeTypes = new ArrayList<>(); |
| for (SearchChipData data : mCheckedChipItems) { |
| if (data.getChipType() == MetricConsts.TYPE_CHIP_LARGE_FILES) { |
| queryArgs.putLong(DocumentsContract.QUERY_ARG_FILE_SIZE_OVER, |
| LARGE_FILE_SIZE_BYTES); |
| } else if (data.getChipType() == MetricConsts.TYPE_CHIP_FROM_THIS_WEEK) { |
| queryArgs.putLong(DocumentsContract.QUERY_ARG_LAST_MODIFIED_AFTER, |
| A_WEEK_AGO_MILLIS); |
| } else { |
| for (String mimeType : data.getMimeTypes()) { |
| checkedMimeTypes.add(mimeType); |
| } |
| } |
| } |
| |
| if (!checkedMimeTypes.isEmpty()) { |
| queryArgs.putStringArray(DocumentsContract.QUERY_ARG_MIME_TYPES, |
| checkedMimeTypes.toArray(new String[0])); |
| } |
| |
| return queryArgs; |
| } |
| |
| /** |
| * Called when owning activity is saving state to be used to restore state during creation. |
| * |
| * @param state Bundle to save state |
| */ |
| public void onSaveInstanceState(Bundle state) { |
| List<Integer> checkedChipList = new ArrayList<>(); |
| |
| for (SearchChipData item : mCheckedChipItems) { |
| checkedChipList.add(item.getChipType()); |
| } |
| |
| if (checkedChipList.size() > 0) { |
| state.putIntArray(Shared.EXTRA_QUERY_CHIPS, Ints.toArray(checkedChipList)); |
| } |
| } |
| |
| /** |
| * Initialize the search chips base on the mime types. |
| * |
| * @param acceptMimeTypes use this values to filter chips |
| */ |
| public void initChipSets(String[] acceptMimeTypes) { |
| mDefaultChipTypes.clear(); |
| for (SearchChipData chipData : sMimeTypesChipItems.values()) { |
| final String[] mimeTypes = chipData.getMimeTypes(); |
| final boolean isMatched = MimeTypes.mimeMatches(acceptMimeTypes, mimeTypes); |
| if (isMatched) { |
| mDefaultChipTypes.add(chipData.getChipType()); |
| } |
| } |
| } |
| |
| /** |
| * Update the search chips base on the mime types. |
| * |
| * @param acceptMimeTypes use this values to filter chips |
| */ |
| public void updateChips(String[] acceptMimeTypes) { |
| if (mIsFirstUpdateChipsReady && Arrays.equals(mCurrentUpdateMimeTypes, acceptMimeTypes)) { |
| return; |
| } |
| |
| final Context context = mChipGroup.getContext(); |
| mChipGroup.removeAllViews(); |
| |
| final List<SearchChipData> mimeChipDataList = new ArrayList<>(); |
| for (int i = 0; i < mDefaultChipTypes.size(); i++) { |
| final SearchChipData chipData = sMimeTypesChipItems.get(mDefaultChipTypes.get(i)); |
| final String[] mimeTypes = chipData.getMimeTypes(); |
| final boolean isMatched = MimeTypes.mimeMatches(acceptMimeTypes, mimeTypes); |
| if (isMatched) { |
| mimeChipDataList.add(chipData); |
| } |
| } |
| |
| final LayoutInflater inflater = LayoutInflater.from(context); |
| if (mimeChipDataList.size() > 1) { |
| for (int i = 0; i < mimeChipDataList.size(); i++) { |
| addChipToGroup(mChipGroup, mimeChipDataList.get(i), inflater); |
| } |
| } |
| |
| for (SearchChipData chipData : sDefaultChipItems.values()) { |
| addChipToGroup(mChipGroup, chipData, inflater); |
| } |
| |
| reorderCheckedChips(null /* clickedChip */, false /* hasAnim */); |
| mIsFirstUpdateChipsReady = true; |
| mCurrentUpdateMimeTypes = acceptMimeTypes; |
| } |
| |
| private void addChipToGroup(ViewGroup group, SearchChipData data, LayoutInflater inflater) { |
| Chip chip = (Chip) inflater.inflate(R.layout.search_chip_item, mChipGroup, false); |
| bindChip(chip, data); |
| group.addView(chip); |
| } |
| |
| /** |
| * Mirror chip group here for another chip group |
| * |
| * @param chipGroup target view group for mirror |
| */ |
| public void bindMirrorGroup(ViewGroup chipGroup) { |
| final int size = mChipGroup.getChildCount(); |
| if (size <= 1) { |
| chipGroup.setVisibility(View.GONE); |
| return; |
| } |
| |
| chipGroup.setVisibility(View.VISIBLE); |
| chipGroup.removeAllViews(); |
| final LayoutInflater inflater = LayoutInflater.from(chipGroup.getContext()); |
| for (int i = 0; i < size; i++) { |
| Chip child = (Chip) mChipGroup.getChildAt(i); |
| SearchChipData item = (SearchChipData) child.getTag(); |
| addChipToGroup(chipGroup, item, inflater); |
| } |
| } |
| |
| /** |
| * Click behavior handle here when mirror chip clicked. |
| * |
| * @param data SearchChipData synced in mirror group |
| */ |
| public void onMirrorChipClick(SearchChipData data) { |
| for (int i = 0, size = mChipGroup.getChildCount(); i < size; i++) { |
| Chip chip = (Chip) mChipGroup.getChildAt(i); |
| if (chip.getTag().equals(data)) { |
| chip.setChecked(!chip.isChecked()); |
| onChipClick(chip); |
| return; |
| } |
| } |
| } |
| |
| /** |
| * Set the listener. |
| * |
| * @param listener the listener |
| */ |
| public void setSearchChipViewManagerListener(SearchChipViewManagerListener listener) { |
| mListener = listener; |
| } |
| |
| private static void setChipChecked(Chip chip, boolean isChecked) { |
| chip.setChecked(isChecked); |
| chip.setChipIconVisible(!isChecked); |
| } |
| |
| private void setCheckedChip(int chipType) { |
| final int count = mChipGroup.getChildCount(); |
| for (int i = 0; i < count; i++) { |
| Chip child = (Chip) mChipGroup.getChildAt(i); |
| SearchChipData item = (SearchChipData) child.getTag(); |
| if (item.getChipType() == chipType) { |
| setChipChecked(child, true /* isChecked */); |
| break; |
| } |
| } |
| } |
| |
| private void onChipClick(View v) { |
| final Chip chip = (Chip) v; |
| |
| // We need to show/hide the chip icon in our design. |
| // When we show/hide the chip icon or do reorder animation, |
| // the ripple effect will be interrupted. So, skip ripple |
| // effect when the chip is clicked. |
| chip.getBackground().setVisible(false /* visible */, false /* restart */); |
| |
| final SearchChipData item = (SearchChipData) chip.getTag(); |
| if (chip.isChecked()) { |
| mCheckedChipItems.add(item); |
| } else { |
| mCheckedChipItems.remove(item); |
| } |
| |
| setChipChecked(chip, chip.isChecked()); |
| reorderCheckedChips(chip, true /* hasAnim */); |
| |
| if (mListener != null) { |
| mListener.onChipCheckStateChanged(v); |
| } |
| } |
| |
| private void bindChip(Chip chip, SearchChipData chipData) { |
| final Context context = mChipGroup.getContext(); |
| chip.setTag(chipData); |
| chip.setText(context.getString(chipData.getTitleRes())); |
| Drawable chipIcon; |
| if (chipData.getChipType() == TYPE_LARGE_FILES) { |
| chipIcon = context.getDrawable(R.drawable.ic_chip_large_files); |
| } else if (chipData.getChipType() == TYPE_FROM_THIS_WEEK) { |
| chipIcon = context.getDrawable(R.drawable.ic_chip_from_this_week); |
| } else if (chipData.getChipType() == TYPE_DOCUMENTS) { |
| chipIcon = IconUtils.loadMimeIcon(context, MimeTypes.GENERIC_TYPE); |
| } else { |
| // get the icon drawable with the first mimeType in chipData |
| chipIcon = IconUtils.loadMimeIcon(context, chipData.getMimeTypes()[0]); |
| } |
| chip.setChipIcon(chipIcon); |
| chip.setOnClickListener(this::onChipClick); |
| |
| if (mCheckedChipItems.contains(chipData)) { |
| setChipChecked(chip, true); |
| } |
| } |
| |
| /** |
| * Reorder the chips in chip group. The checked chip has higher order. |
| * |
| * @param clickedChip the clicked chip, may be null. |
| * @param hasAnim if true, play move animation. Otherwise, not. |
| */ |
| private void reorderCheckedChips(@Nullable Chip clickedChip, boolean hasAnim) { |
| final ArrayList<Chip> chipList = new ArrayList<>(); |
| final int count = mChipGroup.getChildCount(); |
| |
| // if the size of chips is less than 2, no need to reorder chips |
| if (count < 2) { |
| return; |
| } |
| |
| Chip item; |
| // get the default order |
| for (int i = 0; i < count; i++) { |
| item = (Chip) mChipGroup.getChildAt(i); |
| chipList.add(item); |
| } |
| |
| // sort chips |
| Collections.sort(chipList, CHIP_COMPARATOR); |
| |
| if (isChipOrderMatched(mChipGroup, chipList)) { |
| // the order of chips is not changed |
| return; |
| } |
| |
| final int chipSpacing = mChipGroup.getResources().getDimensionPixelSize( |
| R.dimen.search_chip_spacing); |
| final boolean isRtl = mChipGroup.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL; |
| float lastX = isRtl ? mChipGroup.getWidth() - chipSpacing / 2 : chipSpacing / 2; |
| |
| // remove all chips except current clicked chip to avoid losing |
| // accessibility focus. |
| for (int i = count - 1; i >= 0; i--) { |
| item = (Chip) mChipGroup.getChildAt(i); |
| if (!item.equals(clickedChip)) { |
| mChipGroup.removeView(item); |
| } |
| } |
| |
| // add sorted chips |
| for (int i = 0; i < count; i++) { |
| item = chipList.get(i); |
| if (!item.equals(clickedChip)) { |
| mChipGroup.addView(item, i); |
| } |
| } |
| |
| if (hasAnim && mChipGroup.isAttachedToWindow()) { |
| // start animation |
| for (Chip chip : chipList) { |
| if (isRtl) { |
| lastX -= chip.getMeasuredWidth(); |
| } |
| |
| ObjectAnimator animator = ObjectAnimator.ofFloat(chip, "x", chip.getX(), lastX); |
| |
| if (isRtl) { |
| lastX -= chipSpacing; |
| } else { |
| lastX += chip.getMeasuredWidth() + chipSpacing; |
| } |
| animator.setDuration(CHIP_MOVE_ANIMATION_DURATION); |
| animator.start(); |
| } |
| |
| // Let the first checked chip can be shown. |
| View parent = (View) mChipGroup.getParent(); |
| if (parent instanceof HorizontalScrollView) { |
| final int scrollToX = isRtl ? parent.getWidth() : 0; |
| ((HorizontalScrollView) parent).smoothScrollTo(scrollToX, 0); |
| if (mChipGroup.getChildCount() > 0) { |
| mChipGroup.getChildAt(0) |
| .sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED); |
| } |
| } |
| } |
| } |
| |
| private static boolean isChipOrderMatched(ViewGroup chipGroup, ArrayList<Chip> chipList) { |
| if (chipGroup == null || chipList == null) { |
| return false; |
| } |
| |
| final int chipCount = chipList.size(); |
| if (chipGroup.getChildCount() != chipCount) { |
| return false; |
| } |
| for (int i = 0; i < chipCount; i++) { |
| if (!chipList.get(i).equals(chipGroup.getChildAt(i))) { |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| /** |
| * The listener of SearchChipViewManager. |
| */ |
| public interface SearchChipViewManagerListener { |
| /** |
| * It will be triggered when the checked state of chips changes. |
| */ |
| void onChipCheckStateChanged(View v); |
| } |
| |
| private static class ChipComparator implements Comparator<Chip> { |
| |
| @Override |
| public int compare(Chip lhs, Chip rhs) { |
| return (lhs.isChecked() == rhs.isChecked()) ? 0 : (lhs.isChecked() ? -1 : 1); |
| } |
| } |
| } |