1 /*
2  * Copyright 2024 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.photopicker.core.selection
18 
19 import androidx.annotation.GuardedBy
20 import com.android.photopicker.core.configuration.PhotopickerConfiguration
21 import com.android.photopicker.core.selection.SelectionModifiedResult.FAILURE_SELECTION_LIMIT_EXCEEDED
22 import com.android.photopicker.core.selection.SelectionModifiedResult.SUCCESS
23 import com.android.photopicker.data.model.Grantable
24 import com.android.photopicker.data.model.Media
25 import kotlinx.coroutines.CoroutineScope
26 import kotlinx.coroutines.flow.MutableStateFlow
27 import kotlinx.coroutines.flow.SharingStarted
28 import kotlinx.coroutines.flow.StateFlow
29 import kotlinx.coroutines.flow.stateIn
30 import kotlinx.coroutines.flow.update
31 import kotlinx.coroutines.sync.Mutex
32 import kotlinx.coroutines.sync.withLock
33 
34 /**
35  * A class which manages a ordered selection of data objects during a Photopicker session and also
36  * handles actions on preGranted media.
37  *
38  * [Selection] is the source of truth for the current selection state at all times. Features and
39  * elements should not try to guess at selection state or maintain their own state related to
40  * selection logic.
41  *
42  * To that end, Selection exposes its data in multiple ways, however UI elements should generally
43  * collect and observe the provided flow since the APIs are subject to a [Mutex] and can suspend
44  * until the lock can be acquired.
45  *
46  * Additionally, there is a flow exposed for classes that are interested in observing the selection
47  * state as it changes over time. Since it is expected that UI elements will be listening to this
48  * state, and changes to selection state will cause recomposition, it is highly recommended to use
49  * the bulk APIs when updating more than one element in the selection to avoid multiple state
50  * emissions since selection will emit immediately after a change is complete.
51  *
52  * Snapshot can be used to take a frozen state of the selection, as needed.
53  *
54  * @param T The type of object this selection holds.
55  * @property scope A [CoroutineScope] that the flow is shared and updated in.
56  * @property initialSelection A collection to include initial selection value.
57  * @property configuration a collectable [StateFlow] of configuration changes
58  * @property preGrantedItemsCount represents the total number of grants help by the current package.
59  */
60 class GrantsAwareSelectionImpl<T : Grantable>(
61     val scope: CoroutineScope,
62     val initialSelection: Collection<T>? = null,
63     private val configuration: StateFlow<PhotopickerConfiguration>,
64     val preGrantedItemsCount: Int = 0,
65 ) : Selection<T> {
66 
67     // An internal mutex is used to enforce thread-safe access of the selection set.
68     private val mutex = Mutex()
69     private val _deSelection: LinkedHashSet<T> = LinkedHashSet()
70     private val _selection: LinkedHashSet<T> = LinkedHashSet()
71 
72     private val _flow: MutableStateFlow<GrantsAwareSet<T>>
73     override val flow: StateFlow<GrantsAwareSet<T>>
74 
75     init {
76         if (initialSelection != null) {
77             _selection.addAll(initialSelection)
78         }
79         _flow = MutableStateFlow(GrantsAwareSet(_selection, _deSelection, preGrantedItemsCount))
80         flow =
81             _flow.stateIn(
82                 scope,
83                 SharingStarted.WhileSubscribed(),
84                 initialValue = _flow.value,
85             )
86     }
87 
88     /**
89      * Add the requested item to the selection.
90      *
91      * For preGranted Media items, reaching here would mean that the item was deselected and now is
92      * being selected again, in this case it needs to be removed from the de-selection set.
93      *
94      * For non preGranted Media items, if the item is already present in the selection, a duplicate
95      * will not be added, and it's relative position in the selection will not be affected.
96      *
97      * Afterwards, will emit the new selection into the exposed flow.
98      *
99      * @param item the item to add
100      * @return [SelectionModifiedResult] of the outcome of the addition.
101      */
102     @GuardedBy("mutex")
addnull103     override suspend fun add(item: T): SelectionModifiedResult {
104         mutex.withLock {
105             if (item.isPreGranted) {
106                 _deSelection.remove(item)
107                 updateFlow()
108                 return SUCCESS
109             }
110             val itemCanFit = ensureSelectionLimitLocked(/* size= */ 1)
111             if (itemCanFit) {
112                 _selection.add(item)
113                 updateFlow()
114                 return SUCCESS
115             } else {
116                 return FAILURE_SELECTION_LIMIT_EXCEEDED
117             }
118         }
119     }
120 
121     /**
122      * Adds all of the requested items to the selection. If one or more are already in the
123      * selection, this will not add duplicate items. Afterwards, will emit the new selection into
124      * the exposed flow.
125      *
126      * This method only succeeds if all of the items will fit in the current selection.
127      *
128      * @param items the item to add
129      * @return [SelectionModifiedResult] of the outcome of the addition.
130      */
131     @GuardedBy("mutex")
addAllnull132     override suspend fun addAll(items: Collection<T>): SelectionModifiedResult {
133         mutex.withLock {
134             val itemsWithPregrants = LinkedHashSet<T>()
135             val itemsToAdd = LinkedHashSet<T>()
136 
137             for (item in items){
138                 if (item.isPreGranted){
139                     itemsWithPregrants.add(item)
140                 } else {
141                     itemsToAdd.add(item)
142                 }
143             }
144             val itemsCanFit = ensureSelectionLimitLocked(itemsToAdd.size)
145             if (itemsCanFit) {
146                 _selection.addAll(itemsToAdd)
147                 _deSelection.removeAll(itemsWithPregrants)
148                 updateFlow()
149                 return SUCCESS
150             } else {
151                 return FAILURE_SELECTION_LIMIT_EXCEEDED
152             }
153         }
154     }
155 
156     /** Empties the current selection of objects, returning the selection to an empty state.
157      *
158      * Also, any pre-granted item that was de-selected will now reset i.e. no grants will be
159      * revoked.
160      */
161     @GuardedBy("mutex")
clearnull162     override suspend fun clear() {
163         mutex.withLock {
164             _selection.clear()
165             _deSelection.clear()
166             updateFlow()
167         }
168     }
169 
170     /** @return Whether the selection contains the requested item. */
171     @GuardedBy("mutex")
containsnull172     override suspend fun contains(item: T): Boolean {
173         return mutex.withLock {
174             _selection.contains(item) ||
175                     (item.isPreGranted && !_deSelection.contains(item))
176         }
177     }
178 
179     /** @return Whether the selection currently contains all of the requested items. */
180     @GuardedBy("mutex")
containsAllnull181     override suspend fun containsAll(items: Collection<T>): Boolean {
182         return mutex.withLock {
183             for (item in items) {
184                 if (!contains(item)) {
185                     return@withLock false
186                 }
187             }
188             true
189         }
190     }
191 
192     /**
193      * Fetches the 0-based position of the item in the selection.
194      *
195      * @return The position (index) of the item in the selection list. Will return -1 if the item is
196      *   not present in the selection.
197      */
198     @GuardedBy("mutex")
getPositionnull199     override suspend fun getPosition(item: T): Int {
200         return mutex.withLock { _selection.indexOf(item) }
201     }
202 
203     /**
204      * Removes the requested item from the selection. If the item is not in the selection, this has
205      * no effect. Afterwards, will emit the new selection into the exposed flow.
206      * @return [SelectionModifiedResult] of the outcome of the removal.
207      */
208     @GuardedBy("mutex")
removenull209     override suspend fun remove(item: T): SelectionModifiedResult {
210         return mutex.withLock {
211             if (item.isPreGranted) {
212                 _deSelection.add(item)
213                 updateFlow()
214             } else {
215                 _selection.remove(item)
216                 updateFlow()
217             }
218             SUCCESS
219         }
220     }
221 
222     /**
223      * Removes all of the items from the selection.
224      *
225      * If one or more items are not present in the selection, this has no effect. Afterwards, will
226      * emit the new selection into the exposed flow.
227      * @return [SelectionModifiedResult] of the outcome of the removal.
228      */
229     @GuardedBy("mutex")
removeAllnull230     override suspend fun removeAll(items: Collection<T>): SelectionModifiedResult {
231         return mutex.withLock {
232             _selection.removeAll(items)
233             for (item in items) {
234                 if (item.isPreGranted)
235                     _deSelection.add(item)
236             }
237             updateFlow()
238             SUCCESS
239         }
240     }
241 
242     /**
243      * Take an immutable copy of the current selection. This copy is a snapshot of the current
244      * selection and is not updated if the selection changes.
245      *
246      * @return A frozen copy of the current selection set.
247      */
248     @GuardedBy("mutex")
snapshotnull249     override suspend fun snapshot(): Set<T> {
250         return mutex.withLock {
251             // Create a new [grantsSet] to emit updated values.
252             GrantsAwareSet(_selection.toSet(), _deSelection.toSet(), preGrantedItemsCount)
253         }
254     }
255 
256     /**
257      * Toggles the requested item in the selection.
258      *
259      * If the item is of type [Media] and is preGranted i.e. [Media.isPreGranted] is true then when
260      * such an item is toggled, if it is not part of _deSelection then it is added to _deselection
261      * otherwise removed from it.
262      *
263      * For non preGranted items: if the item is already in the selection, it is removed.
264      * If the item is not in the selection, it is added.
265      *
266      * Afterwards, will emit the new selection into the exposed flow.
267      *
268      * @param item the item to add
269      * @param onSelectionLimitExceeded optional error handler if the item cannot fit into the
270      *   current selection, given the current [PhotopickerConfiguration.selectionLimit]
271      * @return [SelectionModifiedResult] of the outcome of the toggle.
272      */
273     @GuardedBy("mutex")
togglenull274     override suspend fun toggle(item: T): SelectionModifiedResult {
275         mutex.withLock {
276             if (item.isPreGranted) {
277                 if (_deSelection.contains(item)) {
278                     _deSelection.remove(item)
279                 } else {
280                     _deSelection.add(item)
281                 }
282             } else {
283                 when (_selection.contains(item)) {
284                     true -> _selection.remove(item) // if item present in selection then remove it.
285                     false -> { // if item is not present in selection then add it.
286                         val itemCanFit = ensureSelectionLimitLocked(/* size= */ 1)
287                         if (itemCanFit) {
288                             _selection.add(item)
289                         } else {
290                             // When the max limit for the number of items in selection is exceeded
291                             // then return back with a FAILURE_SELECTION_LIMIT_EXCEEDED result.
292                             return FAILURE_SELECTION_LIMIT_EXCEEDED
293                         }
294                     }
295                 }
296             }
297 
298             // update the flow and return back result as SUCCESS.
299             updateFlow()
300             return SUCCESS
301         }
302     }
303 
304     /**
305      * Toggles all of the requested items in the selection. This is the same as calling toggle(item)
306      * on each item in the set, it will act on each item in the provided list independently.
307      * Afterwards, will emit the new selection into the exposed flow.
308      *
309      * Note: Since this toggle acts on items individually, it may fail part way through if the
310      * selection becomes full.
311      *
312      * @param items to toggle in the selection
313      * @param onSelectionLimitExceeded optional error handler if the item cannot fit into the
314      *   current selection, given the current [PhotopickerConfiguration.selectionLimit]
315      * @return [SelectionModifiedResult] of the outcome of the toggleAll operation.
316      */
317     @GuardedBy("mutex")
toggleAllnull318     override suspend fun toggleAll(items: Collection<T>): SelectionModifiedResult {
319         mutex.withLock {
320             for (item in items) {
321                 if (item.isPreGranted) {
322                     if (_deSelection.contains(item)) {
323                         _deSelection.remove(item)
324                     } else {
325                         _deSelection.add(item)
326                     }
327                 } else {
328                     when (_selection.contains(item)) {
329                         // if item present in selection then remove it.
330                         true -> _selection.remove(item)
331                         // if item is not present in selection then add it.
332                         false -> {
333                             val itemCanFit = ensureSelectionLimitLocked(/* size= */ 1)
334                             if (itemCanFit) {
335                                 _selection.add(item)
336                             } else {
337                                 // When the max limit for the number of items in selection is
338                                 // exceeded then return back with a FAILURE_SELECTION_LIMIT_EXCEEDED
339                                 // result.
340                                 return FAILURE_SELECTION_LIMIT_EXCEEDED
341                             }
342                         }
343                     }
344                 }
345             }
346             updateFlow()
347             return SUCCESS
348         }
349     }
350 
351     /**
352      * Returns a ReadOnly object contains items which were preGranted but de-selected by the user in
353      * the current session.
354      */
355     @GuardedBy("mutex")
getDeselectionnull356     override suspend fun getDeselection(): Collection<T> {
357         return mutex.withLock {
358             _deSelection.toSet()
359         }
360     }
361 
362     /** Internal method that snapshots the current selection and emits it to the exposed flow. */
updateFlownull363     private suspend fun updateFlow() {
364         _flow.update { GrantsAwareSet(_selection, _deSelection, preGrantedItemsCount) }
365     }
366 
367     /**
368      * Method for checking if the given [size] will fit in the current selection, considering the
369      * [PhotopickerConfiguration.selectionLimit].
370      *
371      * IMPORTANT: This method should always be checked after acquiring the [Mutex] selection lock
372      * but prior adding any items to the selection.
373      *
374      * @return true if the item can fit in the selection. false otherwise.
375      */
ensureSelectionLimitLockednull376     private suspend fun ensureSelectionLimitLocked(size: Int): Boolean {
377         return _selection.size + size <= configuration.value.selectionLimit
378     }
379 }
380