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