1 /* 2 * Copyright (C) 2021 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.providers.media.photopicker.data; 18 19 import android.annotation.Nullable; 20 import android.content.Intent; 21 import android.net.Uri; 22 import android.os.Bundle; 23 import android.provider.MediaStore; 24 import android.util.Log; 25 26 import androidx.lifecycle.LiveData; 27 import androidx.lifecycle.MutableLiveData; 28 29 import com.android.providers.media.photopicker.data.model.Item; 30 31 import java.util.ArrayList; 32 import java.util.Collection; 33 import java.util.Collections; 34 import java.util.HashMap; 35 import java.util.HashSet; 36 import java.util.LinkedHashMap; 37 import java.util.List; 38 import java.util.Map; 39 import java.util.Set; 40 import java.util.stream.Collectors; 41 42 /** 43 * A class that tracks Selection 44 */ 45 public class Selection { 46 /** 47 * Contains positions of checked Item at UI. {@link #mCheckedItemIndexes} may have more number 48 * of indexes , from the number of items present in {@link #mSelectedItems}. The index in 49 * {@link #mCheckedItemIndexes} is a potential index that needs to be rechecked in 50 * notifyItemChanged() at the time of deselecting the unavailable item at UI when user is 51 * offline and tries adding unavailable non cached items. the item corresponding to the index in 52 * {@link #mCheckedItemIndexes} may no longer be selected. 53 */ 54 private final Map<Item, Integer> mCheckedItemIndexes = new HashMap<>(); 55 56 // The list of selected items. 57 private Map<Uri, Item> mSelectedItems = new LinkedHashMap<>(); 58 private Map<Uri, MutableLiveData<Integer>> mSelectedItemsOrder = new HashMap<>(); 59 60 private MutableLiveData<Integer> mSelectedItemSize = new MutableLiveData<>(); 61 // The list of selected items for preview. This needs to be saved separately so that if activity 62 // gets killed, we will still have deselected items for preview. 63 private List<Item> mSelectedItemsForPreview = new ArrayList<>(); 64 private boolean mIsSelectionOrdered = false; 65 private boolean mSelectMultiple = false; 66 private boolean mIsUserSelectAction = false; 67 private int mMaxSelectionLimit = 1; 68 // This is set to false when max selection limit is reached. 69 private boolean mIsSelectionAllowed = true; 70 71 private int mTotalNumberOfPreGrantedItems = 0; 72 private Set<Uri> mPreGrantedUris; 73 private Map<Uri, Item> mItemGrantRevocationMap = new HashMap<>(); 74 75 private static final String TAG = "PhotoPickerSelection"; 76 77 /** 78 * @return {@link #mSelectedItems} - A {@link List} of selected {@link Item} 79 */ getSelectedItems()80 public List<Item> getSelectedItems() { 81 ArrayList<Item> result = new ArrayList<>(mSelectedItems.values()); 82 return Collections.unmodifiableList(result); 83 } 84 85 /** 86 * @return A {@link Set} of selected uris. 87 */ getSelectedItemsUris()88 public Set<Uri> getSelectedItemsUris() { 89 return mSelectedItems.values().stream().map(Item::getContentUri).collect( 90 Collectors.toSet()); 91 } 92 93 /** 94 * @return Indexes - A {@link List} of checked {@link Item} positions. 95 */ getCheckedItemsIndexes()96 public Collection<Integer> getCheckedItemsIndexes() { 97 return mCheckedItemIndexes.values(); 98 } 99 100 /** 101 * Updates the list of pre granted items uris and the count of selected items. 102 */ setPreGrantedItems(@ullable List<Uri> preGrantedUris)103 public void setPreGrantedItems(@Nullable List<Uri> preGrantedUris) { 104 if (preGrantedUris != null) { 105 mPreGrantedUris = preGrantedUris.stream().collect(Collectors.toSet()); 106 setTotalNumberOfPreGrantedItems(preGrantedUris.size()); 107 Log.d(TAG, "Pre-Granted items have been loaded. Number of items:" 108 + preGrantedUris.size()); 109 } else { 110 mPreGrantedUris = new HashSet<>(0); 111 Log.d(TAG, "No Pre-Granted items present"); 112 } 113 } 114 115 /** 116 * @return a set of item uris that are pre granted for the current package and user. 117 */ 118 @Nullable getPreGrantedUris()119 public Set<Uri> getPreGrantedUris() { 120 return mPreGrantedUris; 121 } 122 123 /** 124 * @return A {@link Set} of items for which the grants need to be revoked. 125 */ getDeselectedItemsToBeRevoked()126 public Set<Item> getDeselectedItemsToBeRevoked() { 127 return mItemGrantRevocationMap.values().stream().collect(Collectors.toSet()); 128 } 129 130 /** 131 * @return A {@link Set} of uris for which the grants need to be revoked. 132 */ getDeselectedUrisToBeRevoked()133 public Set<Uri> getDeselectedUrisToBeRevoked() { 134 return mItemGrantRevocationMap.keySet().stream().collect(Collectors.toSet()); 135 } 136 137 /** 138 * @return A {@link List} of selected {@link Item} that do not hold a READ_GRANT. 139 */ getNewlySelectedItems()140 public List<Item> getNewlySelectedItems() { 141 return mSelectedItems.values().stream().filter((Item item) -> !item.isPreGranted()) 142 .collect(Collectors.toList()); 143 } 144 145 /** 146 * Sets the count of pre granted items to ensure that the correct number is displayed in 147 * preview and on the add button. 148 */ setTotalNumberOfPreGrantedItems(int totalNumberOfPreGrantedItems)149 public void setTotalNumberOfPreGrantedItems(int totalNumberOfPreGrantedItems) { 150 mTotalNumberOfPreGrantedItems = totalNumberOfPreGrantedItems; 151 mSelectedItemSize.postValue(getTotalItemsCount()); 152 } 153 154 /** 155 * @return {@link LiveData} of count of selected items in {@link #mSelectedItems} 156 */ getSelectedItemCount()157 public LiveData<Integer> getSelectedItemCount() { 158 if (mSelectedItemSize.getValue() == null) { 159 mSelectedItemSize.setValue(getTotalItemsCount()); 160 } 161 return mSelectedItemSize; 162 } 163 164 /** 165 * @return {@link LiveData} of the item selection order. 166 */ getSelectedItemOrder(Item item)167 public LiveData<Integer> getSelectedItemOrder(Item item) { 168 return mSelectedItemsOrder.get(item.getContentUri()); 169 } 170 getTotalItemsCount()171 private int getTotalItemsCount() { 172 return mSelectedItems.size() - countOfPreGrantedItems() + mTotalNumberOfPreGrantedItems 173 - mItemGrantRevocationMap.size(); 174 } 175 176 /** 177 * Add the selected {@code item} into {@link #mSelectedItems}. 178 */ addSelectedItem(Item item)179 public void addSelectedItem(Item item) { 180 if (mIsSelectionOrdered) { 181 mSelectedItemsOrder.put( 182 item.getContentUri(), new MutableLiveData(getTotalItemsCount() + 1)); 183 } 184 if (item.isPreGranted()) { 185 if (mPreGrantedUris == null) { 186 mPreGrantedUris = new HashSet<>(); 187 } 188 mPreGrantedUris.add(item.getContentUri()); 189 setTotalNumberOfPreGrantedItems(mPreGrantedUris.size()); 190 if (mItemGrantRevocationMap.containsKey(item.getContentUri())) { 191 mItemGrantRevocationMap.remove(item.getContentUri()); 192 } 193 } 194 mSelectedItems.put(item.getContentUri(), item); 195 mSelectedItemSize.postValue(getTotalItemsCount()); 196 updateSelectionAllowed(); 197 } 198 199 /** 200 * Add the checked {@code item} index into {@link #mCheckedItemIndexes}. 201 */ addCheckedItemIndex(Item item, Integer index)202 public void addCheckedItemIndex(Item item, Integer index) { 203 mCheckedItemIndexes.put(item, index); 204 } 205 206 /** 207 * Clears {@link #mSelectedItems} and sets the selected item as given {@code item} 208 */ setSelectedItem(Item item)209 public void setSelectedItem(Item item) { 210 mSelectedItemsOrder.clear(); 211 mSelectedItems.clear(); 212 mSelectedItems.put(item.getContentUri(), item); 213 if (mIsSelectionOrdered) { 214 mSelectedItemsOrder.put( 215 item.getContentUri(), new MutableLiveData(getTotalItemsCount())); 216 } 217 mSelectedItemSize.postValue(getTotalItemsCount()); 218 updateSelectionAllowed(); 219 } 220 221 /** 222 * Remove the {@code item} from the selected item list {@link #mSelectedItems} 223 * 224 * @param item the item to be removed from the selected item list 225 */ removeSelectedItem(Item item)226 public void removeSelectedItem(Item item) { 227 if (item.isPreGranted()) { 228 // Maintain a list of items that were pre-granted but the user has deselected them in 229 // the current session. This list will be used to revoke existing grants for these 230 // items. 231 mItemGrantRevocationMap.put(item.getContentUri(), item); 232 } 233 if (mIsSelectionOrdered) { 234 MutableLiveData<Integer> removedItem = mSelectedItemsOrder.remove(item.getContentUri()); 235 int removedItemOrder = removedItem.getValue().intValue(); 236 mSelectedItemsOrder.values().stream() 237 .filter(order -> order.getValue().intValue() > removedItemOrder) 238 .forEach( 239 order -> { 240 order.setValue(order.getValue().intValue() - 1); 241 }); 242 } 243 mSelectedItems.remove(item.getContentUri()); 244 mSelectedItemSize.postValue(getTotalItemsCount()); 245 updateSelectionAllowed(); 246 } 247 248 /** 249 * Remove the {@code item} index from the checked item index list {@link #mCheckedItemIndexes}. 250 * 251 * @param item the item to be removed from the selected item list 252 */ removeCheckedItemIndex(Item item)253 public void removeCheckedItemIndex(Item item) { 254 mCheckedItemIndexes.remove(item); 255 } 256 257 /** 258 * Clear all selected items and checked positions 259 */ clearSelectedItems()260 public void clearSelectedItems() { 261 mSelectedItemsOrder.clear(); 262 mSelectedItems.clear(); 263 mCheckedItemIndexes.clear(); 264 mSelectedItemSize.postValue(getTotalItemsCount()); 265 updateSelectionAllowed(); 266 } 267 268 /** 269 * Clear all checked items 270 */ clearCheckedItemList()271 public void clearCheckedItemList() { 272 mCheckedItemIndexes.clear(); 273 } 274 275 /** 276 * @return {@code true} if give {@code item} is present in selected items 277 * {@link #mSelectedItems}, {@code false} otherwise 278 */ isItemSelected(Item item)279 public boolean isItemSelected(Item item) { 280 return mSelectedItems.containsKey(item.getContentUri()); 281 } 282 updateSelectionAllowed()283 private void updateSelectionAllowed() { 284 int size = mSelectedItems.size(); 285 // In ACTION_USER_SELECT_IMAGES_FOR_APP mode the total count of selected media can 286 // exceed the mMaxSelectionLimit because in that scenario previous selection plus new 287 // selection are incrementally merged together. But for other cases 288 // (i.e. ACTION_PICK_IMAGES and ACTION_GET_CONTENT) it should remain 289 // mMaxSelectionLimit. 290 if (mIsUserSelectAction) { 291 size = size - countOfPreGrantedItems(); 292 } 293 if (size >= mMaxSelectionLimit) { 294 if (mIsSelectionAllowed) { 295 mIsSelectionAllowed = false; 296 } 297 } else { 298 // size < mMaxSelectionLimit 299 if (!mIsSelectionAllowed) { 300 mIsSelectionAllowed = true; 301 } 302 } 303 } 304 countOfPreGrantedItems()305 private int countOfPreGrantedItems() { 306 if (mSelectedItems.values() != null) { 307 return (int) mSelectedItems.values().stream().filter(Item::isPreGranted).count(); 308 } else { 309 return 0; 310 } 311 } 312 313 /** 314 * @return returns whether more items can be selected or not. {@code true} if the number of 315 * selected items is lower than or equal to {@code mMaxLimit}, {@code false} otherwise. 316 */ isSelectionAllowed()317 public boolean isSelectionAllowed() { 318 return mIsSelectionAllowed; 319 } 320 321 /** 322 * Prepares current selected items for previewing all selected items in multi-select preview. 323 * If ordered selection is not enabled, the method also sorts the selected items 324 * by {@link Item#compareTo} method which sorts based on dateTaken values. 325 */ prepareSelectedItemsForPreviewAll()326 public void prepareSelectedItemsForPreviewAll() { 327 mSelectedItemsForPreview = new ArrayList<>(mSelectedItems.values()); 328 if (!mIsSelectionOrdered) { 329 mSelectedItemsForPreview.sort(Collections.reverseOrder(Item::compareTo)); 330 } 331 } 332 333 /** 334 * Sets the given {@code item} as the item for previewing. This method will be used while 335 * previewing on long press. 336 */ prepareItemForPreviewOnLongPress(Item item)337 public void prepareItemForPreviewOnLongPress(Item item) { 338 mSelectedItemsForPreview = Collections.singletonList(item); 339 } 340 341 /** 342 * @return {@link #mSelectedItemsForPreview} - selected items for preview. 343 */ getSelectedItemsForPreview()344 public List<Item> getSelectedItemsForPreview() { 345 return Collections.unmodifiableList(mSelectedItemsForPreview); 346 } 347 348 /** Parse values from {@code intent} and set corresponding fields */ parseSelectionValuesFromIntent(Intent intent)349 public void parseSelectionValuesFromIntent(Intent intent) { 350 final Bundle extras = intent.getExtras(); 351 final boolean isExtraPickImagesMaxSet = 352 extras != null && extras.containsKey(MediaStore.EXTRA_PICK_IMAGES_MAX); 353 final boolean isExtraOrderedSelectionSet = 354 extras != null && extras.containsKey(MediaStore.EXTRA_PICK_IMAGES_IN_ORDER); 355 356 if (intent.getAction() != null 357 && intent.getAction().equals(MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP)) { 358 mIsUserSelectAction = true; 359 // If this is picking media for an app, enable multiselect. 360 mSelectMultiple = true; 361 // disable ordered selection. 362 mIsSelectionOrdered = false; 363 // Allow selections up to the limit. 364 // TODO(b/255301849): Update max limit after discussing with product team. 365 mMaxSelectionLimit = MediaStore.getPickImagesMaxLimit(); 366 367 return; 368 } else if (intent.getAction() != null 369 // Support Intent.EXTRA_ALLOW_MULTIPLE flag only for ACTION_GET_CONTENT 370 && intent.getAction().equals(Intent.ACTION_GET_CONTENT)) { 371 if (isExtraPickImagesMaxSet) { 372 throw new IllegalArgumentException( 373 "EXTRA_PICK_IMAGES_MAX is not supported for " + "ACTION_GET_CONTENT"); 374 } 375 376 if (isExtraOrderedSelectionSet) { 377 throw new IllegalArgumentException( 378 "EXTRA_PICK_IMAGES_IN_ORDER is not supported for ACTION_GET_CONTENT"); 379 } 380 381 mSelectMultiple = intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false); 382 if (mSelectMultiple) { 383 mMaxSelectionLimit = MediaStore.getPickImagesMaxLimit(); 384 } 385 386 return; 387 } 388 389 if (isExtraOrderedSelectionSet) { 390 mIsSelectionOrdered = extras.getBoolean(MediaStore.EXTRA_PICK_IMAGES_IN_ORDER); 391 } 392 393 // Check EXTRA_PICK_IMAGES_MAX value only if the flag is set. 394 if (isExtraPickImagesMaxSet) { 395 final int extraMax = 396 intent.getIntExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, 397 /* defaultValue */ -1); 398 // Multi selection max limit should always be greater than 1 and less than or equal 399 // to PICK_IMAGES_MAX_LIMIT. 400 if (extraMax <= 1 || extraMax > MediaStore.getPickImagesMaxLimit()) { 401 throw new IllegalArgumentException("Invalid EXTRA_PICK_IMAGES_MAX value"); 402 } 403 mSelectMultiple = true; 404 mMaxSelectionLimit = extraMax; 405 } 406 407 } 408 409 /** 410 * Return whether supports multiple select {@link #mSelectMultiple} or not 411 */ canSelectMultiple()412 public boolean canSelectMultiple() { 413 return mSelectMultiple; 414 } 415 416 /** Return whether ordered selection is enabled or not. */ isSelectionOrdered()417 public boolean isSelectionOrdered() { 418 return mIsSelectionOrdered; 419 } 420 421 /** 422 * Return maximum limit of items that can be selected 423 */ getMaxSelectionLimit()424 public int getMaxSelectionLimit() { 425 return mMaxSelectionLimit; 426 } 427 } 428