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.features 18 19 import android.util.Log 20 import androidx.compose.runtime.Composable 21 import androidx.compose.ui.Modifier 22 import com.android.photopicker.core.configuration.PhotopickerConfiguration 23 import com.android.photopicker.core.events.Event 24 import com.android.photopicker.core.events.RegisteredEventClass 25 import com.android.photopicker.features.albumgrid.AlbumGridFeature 26 import com.android.photopicker.features.cloudmedia.CloudMediaFeature 27 import com.android.photopicker.features.navigationbar.NavigationBarFeature 28 import com.android.photopicker.features.overflowmenu.OverflowMenuFeature 29 import com.android.photopicker.features.photogrid.PhotoGridFeature 30 import com.android.photopicker.features.preview.PreviewFeature 31 import com.android.photopicker.features.profileselector.ProfileSelectorFeature 32 import com.android.photopicker.features.selectionbar.SelectionBarFeature 33 import com.android.photopicker.features.snackbar.SnackbarFeature 34 import kotlinx.coroutines.CoroutineScope 35 import kotlinx.coroutines.flow.StateFlow 36 import kotlinx.coroutines.flow.drop 37 import kotlinx.coroutines.flow.first 38 import kotlinx.coroutines.launch 39 40 /** 41 * The core class in the feature framework, the FeatureManager manages the registration, 42 * initialiation and compose calls for the compose UI. 43 * 44 * The feature manager is responsible for calling Features via the [PhotopickerFeature] interface 45 * framework, for various lifecycles, as well as providing the APIs for callers to inspect feature 46 * state, change configuration, and generate composable units for various UI [Location]s. 47 * 48 * @property configuration a collectable [StateFlow] of configuration changes 49 * @property scope A CoroutineScope that PhotopickerConfiguration updates are collected in. 50 * @property registeredFeatures A set of Registrations that correspond to (potentially) enabled 51 * features. 52 */ 53 class FeatureManager( 54 private val configuration: StateFlow<PhotopickerConfiguration>, 55 private val scope: CoroutineScope, 56 // This is in the constructor to allow tests to swap in test features. 57 private val registeredFeatures: Set<FeatureRegistration> = 58 FeatureManager.KNOWN_FEATURE_REGISTRATIONS, 59 // These are in the constructor to allow tests to swap in core event overrides. 60 private val coreEventsConsumed: Set<RegisteredEventClass> = FeatureManager.CORE_EVENTS_CONSUMED, 61 private val coreEventsProduced: Set<RegisteredEventClass> = FeatureManager.CORE_EVENTS_PRODUCED, 62 ) { 63 companion object { 64 val TAG: String = "PhotopickerFeatureManager" 65 66 /* 67 * The list of known [FeatureRegistration]s. 68 * Any features that include their registration here, are subject to be enabled by the 69 * [FeatureManager] when their [FeatureRegistration#isEnabled] returns true. 70 */ 71 val KNOWN_FEATURE_REGISTRATIONS: Set<FeatureRegistration> = 72 setOf( 73 PhotoGridFeature.Registration, 74 SelectionBarFeature.Registration, 75 NavigationBarFeature.Registration, 76 PreviewFeature.Registration, 77 ProfileSelectorFeature.Registration, 78 AlbumGridFeature.Registration, 79 SnackbarFeature.Registration, 80 CloudMediaFeature.Registration, 81 OverflowMenuFeature.Registration, 82 ) 83 84 /* The list of events that the core library consumes. */ 85 val CORE_EVENTS_CONSUMED: Set<RegisteredEventClass> = 86 setOf( 87 Event.MediaSelectionConfirmed::class.java, 88 ) 89 90 /* The list of events that the core library produces. */ 91 val CORE_EVENTS_PRODUCED: Set<RegisteredEventClass> = 92 setOf( 93 Event.MediaSelectionConfirmed::class.java, 94 Event.ShowSnackbarMessage::class.java, 95 ) 96 } 97 98 // The internal mutable set of enabled features. 99 private val _enabledFeatures: MutableSet<PhotopickerFeature> = mutableSetOf() 100 101 // The internal map of claimed [FeatureToken] to the claiming [PhotopickerFeature] 102 private val _tokenMap: HashMap<String, PhotopickerFeature> = HashMap() 103 104 /* Returns an immutable copy rather than the actual set. */ 105 val enabledFeatures: Set<PhotopickerFeature> 106 get() = _enabledFeatures.toSet() 107 108 val enabledUiFeatures: Set<PhotopickerUiFeature> 109 get() = _enabledFeatures.filterIsInstance<PhotopickerUiFeature>().toSet() 110 111 /* 112 * The location registry for [PhotopickerUiFeature]. 113 * 114 * The key in this map is the UI [Location] 115 * The value is a *always* a sorted "priority-descending" set of Pairs 116 * 117 * Each pair represents a Feature which would like to draw UI at this Location, and the Priority 118 * with which it would like to do so. 119 * 120 * It is critical that the list always remains sorted to avoid drawing the wrong element for a 121 * Location with a limited number of slots. It can be sorted with [PriorityDescendingComparator] 122 * to keep features sorted in order of Priority, then Registration (insertion) order. 123 * 124 * For Features who set the default Location [Priority.REGISTRATION_ORDER] they will 125 * be drawn in order of registration in the [FeatureManager.KNOWN_FEATURE_REGISTRATIONS]. 126 * 127 */ 128 private val locationRegistry: HashMap<Location, MutableList<Pair<PhotopickerUiFeature, Int>>> = 129 HashMap() 130 131 /* Instantiate a shared single instance of our custom priority sorter to save memory */ 132 private val priorityDescending: Comparator<Pair<Any, Int>> = PriorityDescendingComparator() 133 134 init { 135 initializeFeatureSet() 136 137 // Begin collecting the PhotopickerConfiguration and update the feature configuration 138 // accordingly. <lambda>null139 scope.launch { 140 // Drop the first value here to prevent initializing twice. 141 // (initializeFeatureSet will pick up the first value on its own.) 142 configuration.drop(1).collect { onConfigurationChanged(it) } 143 } 144 } 145 146 /** 147 * Set a new configuration value in the FeatureManager. 148 * 149 * Warning: This is an expensive operation, and should be batched if multiple configuration 150 * updates are expected in the near future. 151 * 1. Notify all existing features of the pending configuration change, 152 * 2. Wipe existing features 153 * 3. Re-initialize Feature set with new configuration 154 */ onConfigurationChangednull155 private fun onConfigurationChanged(newConfig: PhotopickerConfiguration) { 156 Log.d(TAG, """Configuration has changed, re-initializing. $newConfig""") 157 158 // Notify all active features of the incoming config change. 159 _enabledFeatures.forEach { it.onConfigurationChanged(newConfig) } 160 161 // Drop all registrations and prepare to reinitialize. 162 resetAllRegistrations() 163 164 // Re-initialize. 165 initializeFeatureSet(newConfig) 166 } 167 168 /** Drops all known registrations and returns to a pre-initialization state */ resetAllRegistrationsnull169 private fun resetAllRegistrations() { 170 _enabledFeatures.clear() 171 _tokenMap.clear() 172 locationRegistry.clear() 173 } 174 175 /** 176 * For the provided set of [FeatureRegistration]s, attempt to initialize the runtime Feature set 177 * with the current [PhotopickerConfiguration]. 178 * 179 * @param config The configuration to use for initialization. Defaults to the current 180 * configuration. 181 * @throws [IllegalStateException] if multiple features attempt to claim the same 182 * [FeatureToken]. 183 */ initializeFeatureSetnull184 private fun initializeFeatureSet(config: PhotopickerConfiguration = configuration.value) { 185 Log.d(TAG, "Beginning feature initialization with config: ${configuration.value}") 186 187 for (featureCompanion in registeredFeatures) { 188 if (featureCompanion.isEnabled(config)) { 189 val feature = featureCompanion.build(this) 190 _enabledFeatures.add(feature) 191 if (_tokenMap.contains(feature.token)) 192 throw IllegalStateException( 193 "A feature has already claimed ${feature.token}. " + 194 "Tokens must be unique for any given configuration." 195 ) 196 _tokenMap.put(feature.token, feature) 197 if (feature is PhotopickerUiFeature) registerLocationsForFeature(feature) 198 } 199 } 200 201 validateEventRegistrations() 202 203 Log.d( 204 TAG, 205 "Feature initialization complete. Features: ${_enabledFeatures.map { it.token }}" 206 ) 207 } 208 209 /** 210 * Inspect the event registrations for consumed and produced events based on the core library 211 * and the current set of enabledFeatures. 212 * 213 * This check ensures that all events that need to be consumed have at least one possible 214 * producer (it does not guarantee the event will actually be produced). 215 * 216 * In the event consumed events are not produced, this behaves differently depending on the 217 * [PhotopickerConfiguration]. 218 * - If [PhotopickerConfiguration.deviceIsDebuggable] this will throw [IllegalStateException] 219 * This is done to try to prevent bad configurations from escaping test and dev builds. 220 * - Else This will Log a warning, but allow initialization to proceed to avoid a runtime crash. 221 */ validateEventRegistrationsnull222 private fun validateEventRegistrations() { 223 // Include the events the CORE library expects to consume in the list of consumed events, 224 // along with all enabledFeatures. 225 val consumedEvents: Set<RegisteredEventClass> = 226 listOf(coreEventsConsumed, *_enabledFeatures.map { it.eventsConsumed }.toTypedArray()) 227 .flatten() 228 .toSet() 229 230 // Include the events the CORE library expects to produce in the list of produced events, 231 // along with all enabledFeatures. 232 val producedEvents: Set<RegisteredEventClass> = 233 listOf(coreEventsProduced, *_enabledFeatures.map { it.eventsProduced }.toTypedArray()) 234 .flatten() 235 .toSet() 236 237 val consumedButNotProduced = (consumedEvents subtract producedEvents) 238 239 if (consumedButNotProduced.isNotEmpty()) { 240 if (configuration.value.deviceIsDebuggable) { 241 // If the device is a debuggable build, throw an [IllegalStateException] to ensure 242 // that unregistered events don't introduce un-intentional side-effects. 243 throw IllegalStateException( 244 "Events are expected to be consumed that are not produced: " + 245 "$consumedButNotProduced" 246 ) 247 } else { 248 // If this is a production build, this is still a bad state, but avoid crashing, and 249 // put a note in the logs that the event registration is potentially problematic. 250 Log.w( 251 TAG, 252 "Events are expected to be consumed that are not produced: " + 253 "$consumedButNotProduced" 254 ) 255 } 256 } 257 } 258 259 /** 260 * Adds the [PhotopickerUiFeature]'s registered locations to the internal location registry. 261 * 262 * To minimize memory footprint, the location is only initialized if at least one feature has it 263 * in its list of registeredLocations. This avoids the underlying registry carrying empty lists 264 * for location that no feature wishes to use. 265 * 266 * The list that is initialized uses the local [PriorityDescendingComparator] to keep the 267 * features at that location sorted by priority. 268 */ registerLocationsForFeaturenull269 private fun registerLocationsForFeature(feature: PhotopickerUiFeature) { 270 val locationPairs = feature.registerLocations() 271 272 for ((first, second) in locationPairs) { 273 // Try to add the feature to this location's registry. 274 locationRegistry.get(first)?.let { 275 it.add(Pair(feature, second)) 276 it.sortWith(priorityDescending) 277 } 278 // If this is the first registration for this location, initialize the list and add 279 // the current feature to the registry for this location. 280 ?: locationRegistry.put(first, mutableListOf(Pair(feature, second))) 281 } 282 } 283 284 /** 285 * Whether or not a requested feature is enabled 286 * 287 * @param featureClass - The class of the feature (doesn't require an instance to be created) 288 * @return true if the requested feature is enabled in the current session. 289 */ isFeatureEnablednull290 fun isFeatureEnabled(featureClass: Class<out PhotopickerFeature>): Boolean { 291 return _enabledFeatures.any { it::class.java == featureClass } 292 } 293 294 /** 295 * Check if a provided event can be dispatched with the current enabled feature set. 296 * 297 * This is called when an event is dispatched to ensure that features cannot dispatch events 298 * that they do not include in their [PhotopickerFeature.eventsProduced] event registry. 299 * 300 * This checks the claiming [dispatcherToken] in the Event and checks the corresponding 301 * feature's event registry to ensure the event has claimed it dispatches the particular Event 302 * class. In the event of a CORE library event, check the internal mapping owned by 303 * [FeatureManager]. 304 * 305 * @return Whether the event complies with the event registry. 306 */ isEventDispatchablenull307 fun isEventDispatchable(event: Event): Boolean { 308 if (event.dispatcherToken == FeatureToken.CORE.token) 309 return coreEventsProduced.contains(event::class.java) 310 return _tokenMap.get(event.dispatcherToken)?.eventsProduced?.contains(event::class.java) 311 ?: false 312 } 313 314 /** 315 * Checks the run-time (current) maximum size (in terms of number of children created) of the 316 * provided [Location] in the [FeatureManager] internal [locationRegistry]. 317 * 318 * This allows features to determine if a given [composeLocation] call will actually create any 319 * child elements at the location. 320 * 321 * The size returned is always stable for the current [PhotopickerConfiguration] but may change 322 * if the configuration is changed, since features could be added or removed under the new 323 * configuration. 324 * 325 * NOTE: This only returns the number of children, there is no way to directly interact with the 326 * feature classes registered at the given location. 327 * 328 * @param location The location to check the size of. 329 * @return the max number of children of the location. Cannot be negative. 330 * @see [composeLocation] for rendering the children of a [Location] in the compose tree. 331 */ getSizeOfLocationInRegistrynull332 fun getSizeOfLocationInRegistry(location: Location): Int { 333 // There is no guarantee the [Location] exists in the registry, since it is initialized 334 // lazily, its possible that features have not been registered for the current 335 // configuration. 336 return locationRegistry.get(location)?.size ?: 0 337 } 338 339 /** 340 * Calls all of the relevant compose methods for all enabled [PhotopickerUiFeature] that have 341 * the [Location] in their registered locations, in their declared priority descending order. 342 * 343 * Features with a higher priority are composed first. 344 * 345 * This is the primary API for features to compose UI using the [Location] framework. 346 * 347 * This can result in an empty [Composable] if no features have the provided [Location] in their 348 * list of registered locations. 349 * 350 * Additional parameters can be passed via the [LocationParams] interface for providing 351 * functionality such as click handlers or passing primitive data. 352 * 353 * @param location The UI location that needs to be composed 354 * @param maxSlots (Optional, default unlimited) The maximum number of features that can compose 355 * at this location. If set, this will call features in priority order until all slots of been 356 * exhausted. 357 * @param modifier (Optional) A [Modifier] to pass in the compose call. 358 * @param params (Optional) A [LocationParams] to pass in the compose call. 359 * @see [LocationParams] 360 * 361 * Note: Be careful where this is called in the UI tree. Calling this inside of a composable 362 * that is regularly re-composed will result in the entire sub tree being re-composed, which can 363 * impact performance. 364 */ 365 @Composable composeLocationnull366 fun composeLocation( 367 location: Location, 368 maxSlots: Int? = null, 369 modifier: Modifier = Modifier, 370 params: LocationParams = LocationParams.None, 371 ) { 372 val featurePairs = locationRegistry.get(location) 373 374 // There is no guarantee the [Location] exists in the registry, since it is initialized 375 // lazily, its possible that features have not been registered. 376 featurePairs?.let { 377 for (feature in featurePairs.take(maxSlots ?: featurePairs.size)) { 378 Log.d(TAG, "Composing for $location for $feature") 379 feature.first.compose(location, modifier, params) 380 } 381 } 382 } 383 } 384