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