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