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