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