1 /*
<lambda>null2 * 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 androidx.xr.compose.platform
18
19 import android.content.Context
20 import android.database.ContentObserver
21 import android.net.Uri
22 import android.os.Looper
23 import android.provider.Settings
24 import androidx.compose.runtime.MonotonicFrameClock
25 import androidx.compose.runtime.PausableMonotonicFrameClock
26 import androidx.compose.runtime.Recomposer
27 import androidx.compose.runtime.getValue
28 import androidx.compose.runtime.mutableFloatStateOf
29 import androidx.compose.runtime.setValue
30 import androidx.compose.ui.ExperimentalComposeUiApi
31 import androidx.compose.ui.InternalComposeUiApi
32 import androidx.compose.ui.MotionDurationScale
33 import androidx.compose.ui.platform.AndroidUiDispatcher
34 import androidx.core.os.HandlerCompat
35 import androidx.lifecycle.Lifecycle
36 import androidx.lifecycle.LifecycleEventObserver
37 import androidx.lifecycle.LifecycleOwner
38 import java.util.concurrent.atomic.AtomicReference
39 import kotlin.coroutines.ContinuationInterceptor
40 import kotlin.coroutines.CoroutineContext
41 import kotlin.coroutines.EmptyCoroutineContext
42 import kotlinx.coroutines.CoroutineScope
43 import kotlinx.coroutines.CoroutineStart
44 import kotlinx.coroutines.Job
45 import kotlinx.coroutines.MainScope
46 import kotlinx.coroutines.channels.Channel
47 import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
48 import kotlinx.coroutines.flow.SharingStarted
49 import kotlinx.coroutines.flow.StateFlow
50 import kotlinx.coroutines.flow.callbackFlow
51 import kotlinx.coroutines.flow.stateIn
52 import kotlinx.coroutines.launch
53
54 /** See [androidx.compose.ui.platform.WindowRecomposerPolicy] */
55 @InternalComposeUiApi
56 internal object SubspaceRecomposerPolicy {
57 private val factory = AtomicReference(SubspaceRecomposerFactory.LifecycleAware)
58
59 internal fun createAndInstallSubspaceRecomposer(
60 rootElement: AbstractComposeElement
61 ): Recomposer {
62 val newRecomposer = factory.get().createRecomposer(rootElement)
63 rootElement.compositionContext = newRecomposer
64
65 // If the Recomposer shuts down, unregister it so that a future request for a subspace
66 // recomposer will consult the factory for a new one.
67 // TODO: update to GlobalScope.launch(AndroidUiDispatcher.CurrentThread) when migrating
68 // to androidx-main
69 val scope = MainScope()
70 val unsetJob =
71 scope.launch {
72 try {
73 newRecomposer.join()
74 } finally {
75 // Unset if the element is detached. (See below for the attach state change
76 // listener). Since this is in a finally in this coroutine, even if this job is
77 // cancelled we will resume on the window's UI thread and perform this
78 // manipulation
79 // there.
80 if (rootElement.compositionContext === newRecomposer) {
81 rootElement.compositionContext = null
82 }
83 }
84 }
85
86 // If the root element is detached, cancel the await for recomposer shutdown above.
87 // This will also unset the tag reference to this recomposer during its cleanup.
88 rootElement.addOnAttachStateChangeListener(
89 object : SpatialElement.OnAttachStateChangeListener {
90 override fun onElementAttachedToSubspace(
91 spatialComposeScene: SpatialComposeScene
92 ) {}
93
94 override fun onElementDetachedFromSubspace(
95 spatialComposeScene: SpatialComposeScene
96 ) {
97 rootElement.removeOnAttachStateChangeListener(this)
98 // Cancel the job to clean up the composition context reference in the element.
99 unsetJob.cancel()
100
101 // Also cancel the recomposer as it is not shared with another tree.
102 newRecomposer.cancel()
103 }
104 }
105 )
106
107 return newRecomposer
108 }
109 }
110
111 /**
112 * A factory for creating an subspace-scoped [Recomposer].
113 *
114 * See [createRecomposer] for more info.
115 */
116 @InternalComposeUiApi
interfacenull117 private fun interface SubspaceRecomposerFactory {
118 /**
119 * Creates a [Recomposer] for a subspace with [rootElement] being the root of the Compose
120 * hierarchy.
121 *
122 * The factory is responsible for establishing a policy for [shutting down][Recomposer.cancel]
123 * the returned [Recomposer]. [rootElement] will hold a hard reference to the returned
124 * [Recomposer] until it [joins][Recomposer.join] after shutting down.
125 */
126 fun createRecomposer(rootElement: AbstractComposeElement): Recomposer
127
128 companion object {
129 /**
130 * A [SubspaceRecomposerFactory] that creates **lifecycle-aware** [Recomposer]s.
131 *
132 * Returned [Recomposer]s will be bound to the [SpatialComposeScene] of the 'rootElement'
133 * argument of [createRecomposer] and will be destroyed once the [SpatialComposeScene]
134 * lifecycle ends.
135 *
136 * The recomposer will run [recomposition][Recomposer.runRecomposeAndApplyChanges] and
137 * composition effects on the [AndroidUiDispatcher.CurrentThread]. The associated
138 * [MonotonicFrameClock] will only produce frames when the [Lifecycle] is at least
139 * [Lifecycle.State.STARTED], causing animations and other uses of [MonotonicFrameClock]
140 * APIs to suspend until a **visible** frame will be produced.
141 */
142 @OptIn(ExperimentalComposeUiApi::class)
143 val LifecycleAware: SubspaceRecomposerFactory = SubspaceRecomposerFactory { rootElement ->
144 createLifecycleAwareSubspaceRecomposer(rootElement)
145 }
146 }
147 }
148
149 /**
150 * Create a [Lifecycle] and
151 * [subspace attachment][SpatialElement.isAttachedToSpatialComposeScene]-aware [Recomposer] for the
152 * [rootElement] with the same behavior as [SubspaceRecomposerFactory.LifecycleAware].
153 *
154 * [coroutineContext] will override any [CoroutineContext] elements from the default configuration
155 * normally used for this content element. The default [CoroutineContext] contains
156 * [AndroidUiDispatcher.CurrentThread];
157 *
158 * This function should only be called from the UI thread of this [SpatialElement] or its intended
159 * UI thread if it is currently detached. It must also only be called when the element is attached
160 * to a subspace, i.e., [SpatialElement.spatialComposeScene] must not be `null`. If the
161 * [SpatialElement.spatialComposeScene] is `null`, an [IllegalStateException] will be thrown.
162 *
163 * The returned [Recomposer] will be [cancelled][Recomposer.cancel] when the [rootElement] is
164 * detached from its subspace or if its determined that the subspace is destroyed and its
165 * [Lifecycle] has [ended][Lifecycle.Event.ON_DESTROY].
166 *
167 * Recomposition and associated [frame-based][MonotonicFrameClock] effects may be throttled or
168 * paused while the [Lifecycle] is not at least [Lifecycle.State.STARTED].
169 */
170 @ExperimentalComposeUiApi
createLifecycleAwareSubspaceRecomposernull171 private fun createLifecycleAwareSubspaceRecomposer(
172 rootElement: AbstractComposeElement,
173 coroutineContext: CoroutineContext = EmptyCoroutineContext,
174 ): Recomposer {
175 val subspace =
176 checkNotNull(rootElement.spatialComposeScene) {
177 "Element $rootElement is not attached to a subspace."
178 }
179
180 // Only access AndroidUiDispatcher.CurrentThread if we would use an element from it,
181 // otherwise prevent lazy initialization.
182 val baseContext =
183 if (
184 coroutineContext[ContinuationInterceptor] == null ||
185 coroutineContext[MonotonicFrameClock] == null
186 ) {
187 AndroidUiDispatcher.CurrentThread + coroutineContext
188 } else {
189 coroutineContext
190 }
191
192 val pausableClock =
193 baseContext[MonotonicFrameClock]?.let { PausableMonotonicFrameClock(it).apply { pause() } }
194
195 var systemDurationScaleSettingConsumer: MotionDurationScaleImpl? = null
196 val motionDurationScale =
197 baseContext[MotionDurationScale]
198 ?: MotionDurationScaleImpl().also { systemDurationScaleSettingConsumer = it }
199
200 val contextWithClockAndMotionScale =
201 baseContext + (pausableClock ?: EmptyCoroutineContext) + motionDurationScale
202 val recomposer =
203 Recomposer(contextWithClockAndMotionScale).also { it.pauseCompositionFrameClock() }
204 val runRecomposeScope = CoroutineScope(contextWithClockAndMotionScale)
205
206 // Removing the element that holds the spatial scene graph means it may never be reattached
207 // again.
208 // Since this factory function is used to create a new recomposer for each invocation and
209 // does not reuse a single instance like other factories might, shut it down whenever it
210 // becomes detached. This can easily happen as part of subspace content.
211 rootElement.onDetachedFromSubspaceOnce { recomposer.cancel() }
212
213 subspace.lifecycle.addObserver(
214 object : LifecycleEventObserver {
215 override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
216 val self = this
217 when (event) {
218 Lifecycle.Event.ON_CREATE -> {
219 // UNDISPATCHED launch since we've configured this scope to be on the UI
220 // thread.
221 runRecomposeScope.launch(start = CoroutineStart.UNDISPATCHED) {
222 var durationScaleJob: Job? = null
223 try {
224 durationScaleJob =
225 systemDurationScaleSettingConsumer?.let {
226 val durationScaleStateFlow =
227 getAnimationScaleFlowFor(
228 subspace.ownerActivity.applicationContext
229 )
230 it.scaleFactor = durationScaleStateFlow.value
231 launch {
232 durationScaleStateFlow.collect { scaleFactor ->
233 it.scaleFactor = scaleFactor
234 }
235 }
236 }
237 recomposer.runRecomposeAndApplyChanges()
238 } finally {
239 durationScaleJob?.cancel()
240 // If runRecomposeAndApplyChanges returns or this coroutine is
241 // cancelled
242 // it means we no longer care about this lifecycle. Clean up the
243 // dangling references tied to this observer.
244 source.lifecycle.removeObserver(self)
245 }
246 }
247 }
248 Lifecycle.Event.ON_START -> {
249 // The clock starts life as paused so resume it when starting. If it is
250 // already
251 // running (this ON_START is after an ON_STOP) then the resume is ignored.
252 pausableClock?.resume()
253
254 // Resumes the frame clock dispatching if this is an ON_START after an
255 // ON_STOP
256 // that paused it. If the recomposer is not paused calling
257 // `resumeFrameClock()`
258 // is ignored.
259 recomposer.resumeCompositionFrameClock()
260 }
261 Lifecycle.Event.ON_STOP -> {
262 // Pause the recomposer's frame clock which will pause all calls to
263 // `withFrameNanos` (e.g. animations) while the window is stopped.
264 recomposer.pauseCompositionFrameClock()
265 }
266 Lifecycle.Event.ON_DESTROY -> {
267 recomposer.cancel()
268 }
269 Lifecycle.Event.ON_PAUSE -> {
270 // Nothing
271 }
272 Lifecycle.Event.ON_RESUME -> {
273 // Nothing
274 }
275 Lifecycle.Event.ON_ANY -> {
276 // Nothing
277 }
278 }
279 }
280 }
281 )
282
283 return recomposer
284 }
285
286 private class MotionDurationScaleImpl : MotionDurationScale {
287 override var scaleFactor by mutableFloatStateOf(1f)
288 }
289
290 private val animationScale = mutableMapOf<Context, StateFlow<Float>>()
291
292 // Callers of this function should pass an application context. Passing an activity context might
293 // result in activity leaks.
getAnimationScaleFlowFornull294 private fun getAnimationScaleFlowFor(applicationContext: Context): StateFlow<Float> {
295 return synchronized(animationScale) {
296 animationScale.getOrPut(applicationContext) {
297 val resolver = applicationContext.contentResolver
298 val animationScaleUri =
299 Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE)
300 val channel = Channel<Unit>(CONFLATED)
301 val contentObserver =
302 object : ContentObserver(HandlerCompat.createAsync(Looper.getMainLooper())) {
303 override fun onChange(selfChange: Boolean, uri: Uri?) {
304 @Suppress("UNUSED_VARIABLE") val unused = channel.trySend(Unit)
305 }
306 }
307
308 callbackFlow {
309 resolver.registerContentObserver(animationScaleUri, false, contentObserver)
310 try {
311 for (value in channel) {
312 val newValue =
313 Settings.Global.getFloat(
314 applicationContext.contentResolver,
315 Settings.Global.ANIMATOR_DURATION_SCALE,
316 1f,
317 )
318 send(newValue)
319 }
320 } finally {
321 resolver.unregisterContentObserver(contentObserver)
322 }
323 }
324 .stateIn(
325 MainScope(),
326 SharingStarted.WhileSubscribed(),
327 Settings.Global.getFloat(
328 applicationContext.contentResolver,
329 Settings.Global.ANIMATOR_DURATION_SCALE,
330 1f,
331 ),
332 )
333 }
334 }
335 }
336