Loading packages/SystemUI/multivalentTests/src/com/android/systemui/lifecycle/HydratedActivatableTest.kt +48 −0 Original line number Diff line number Diff line Loading @@ -32,15 +32,19 @@ import com.android.systemui.kosmos.testScope import com.android.systemui.testKosmos import com.android.systemui.ui.viewmodel.FakeHydratedViewModel import com.google.common.truth.Truth.assertThat import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @OptIn(ExperimentalCoroutinesApi::class) @SmallTest @RunWith(AndroidJUnit4::class) class HydratedActivatableTest : SysuiTestCase() { Loading Loading @@ -162,4 +166,48 @@ class HydratedActivatableTest : SysuiTestCase() { .assertTextEquals("upstreamStateFlow=false") composeRule.onNode(hasTestTag("upstreamFlow")).assertTextEquals("upstreamFlow=true") } @Test fun enqueueBeforeActivation_reactivated() = testScope.runTest { var runCount = 0 // Not executed because Activatable is not active assertThat(underTest.publicEnqueueOnActivatedScope { runCount++ }).isNull() runCurrent() val job = testScope.backgroundScope.launch(EmptyCoroutineContext) { underTest.activate() } runCurrent() // This counts underTest.publicEnqueueOnActivatedScope { runCount++ } runCurrent() assertThat(runCount).isEqualTo(1) // These are put into the channel but they are never executed as the job is canceled // before execution happened. When we reactivate the Activatable a new Channel is // setup so these are not getting replayed. underTest.publicEnqueueOnActivatedScope { runCount++ } underTest.publicEnqueueOnActivatedScope { runCount++ } job.cancel() runCurrent() assertThat(runCount).isEqualTo(1) // Not executed because Activatable is not active assertThat(underTest.publicEnqueueOnActivatedScope { runCount++ }).isNull() underTest.activateIn(testScope) runCurrent() // This counts, all invocations are buffered and executed underTest.publicEnqueueOnActivatedScope { runCount++ } underTest.publicEnqueueOnActivatedScope { runCount++ } underTest.publicEnqueueOnActivatedScope { runCount++ } underTest.publicEnqueueOnActivatedScope { runCount++ } runCurrent() assertThat(runCount).isEqualTo(5) } } packages/SystemUI/src/com/android/systemui/lifecycle/ExclusiveActivatable.kt +5 −4 Original line number Diff line number Diff line Loading @@ -17,6 +17,7 @@ package com.android.systemui.lifecycle import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.awaitCancellation /** * A base [Activatable] that can only be activated by a single owner (hence "exclusive"). A previous Loading @@ -27,7 +28,7 @@ abstract class ExclusiveActivatable : Activatable { private val _isActive = AtomicBoolean(false) protected var isActive: Boolean var isActive: Boolean get() = _isActive.get() private set(value) { _isActive.set(value) Loading @@ -39,6 +40,7 @@ abstract class ExclusiveActivatable : Activatable { try { onActivated() awaitCancellation() } finally { isActive = false } Loading @@ -56,17 +58,16 @@ abstract class ExclusiveActivatable : Activatable { * * Implementations could follow this pattern: * ```kotlin * override suspend fun onActivated(): Nothing { * override suspend fun onActivated() { * coroutineScope { * launch { ... } * launch { ... } * launch { ... } * awaitCancellation() * } * } * ``` * * @see activate */ protected abstract suspend fun onActivated(): Nothing protected abstract suspend fun onActivated() } packages/SystemUI/src/com/android/systemui/lifecycle/HydratedActivatable.kt +41 −12 Original line number Diff line number Diff line Loading @@ -18,29 +18,48 @@ package com.android.systemui.lifecycle import androidx.compose.runtime.State import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.BUFFERED import kotlinx.coroutines.channels.ChannelResult import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch /** * An [Activatable] which manages an internal [Hydrator] which is activated accordingly. Adds * convenience methods to easily transform upstream [Flow]s into downstream snapshot-backed [State]s * based on the [Hydrator]. * * The activation of this is also guaranteed to be exclusive since the [Hydrator] is an * [ExclusiveActivatable] itself. * An [Activatable] with convenience methods to easily transform upstream [Flow]s into downstream * snapshot-backed [State]s. Also allows non-suspend code to run suspend code. * * @see [ExclusiveActivatable] */ abstract class HydratedActivatable : Activatable { abstract class HydratedActivatable( /** Enable this to use [enqueueOnActivatedScope] */ val enableEnqueuedActivations: Boolean = false ) : Activatable { private val hydrator = Hydrator("${this::class.simpleName}.hydrator") private var requestChannel: Channel<suspend () -> Unit>? = null final override suspend fun activate(): Nothing { coroutineScope { launch { hydrator.activate() } if (enableEnqueuedActivations) { launch { requestChannel = Channel<suspend () -> Unit>(BUFFERED) requestChannel!!.receiveAsFlow().collect { it.invoke() } } } try { onActivated() awaitCancellation() } finally { requestChannel?.cancel() requestChannel = null } } } Loading @@ -56,20 +75,30 @@ abstract class HydratedActivatable : Activatable { * * Implementations could follow this pattern: * ```kotlin * override suspend fun onActivated(): Nothing { * override suspend fun onActivated() { * coroutineScope { * launch { ... } * launch { ... } * launch { ... } * awaitCancellation() * } * } * ``` * * @see activate */ protected open suspend fun onActivated(): Nothing { awaitCancellation() protected open suspend fun onActivated() {} /** * Queues [block] for execution on the activated scope. Requests are executed sequentially. * * @return [null] when the [Activatable] is not active. Otherwise, returns the [ChannelResult]. * A success Channel result means the request is queued but it does not guarantee that [block] * will be executed as the Activatable can still be deactivated before [block] had a chance to * be processed. */ protected fun enqueueOnActivatedScope(block: suspend () -> Unit): ChannelResult<Unit>? { if (!enableEnqueuedActivations) error("enableEnqueuedActivations needs to be enabled.") return requestChannel?.trySend(block) } /** @see [Hydrator.hydratedStateOf] */ Loading packages/SystemUI/tests/utils/src/com/android/systemui/ui/viewmodel/FakeHydratedViewModel.kt +4 −1 Original line number Diff line number Diff line Loading @@ -30,7 +30,7 @@ class FakeHydratedViewModel( private val onDeactivation: () -> Unit = {}, upstreamFlow: Flow<Boolean> = flowOf(true), upstreamStateFlow: StateFlow<Boolean> = MutableStateFlow(true).asStateFlow(), ) : HydratedActivatable() { ) : HydratedActivatable(enableEnqueuedActivations = true) { var activationCount = 0 var cancellationCount = 0 Loading @@ -39,6 +39,9 @@ class FakeHydratedViewModel( val stateBackedByStateFlow: Boolean by upstreamStateFlow.hydratedStateOf(traceName = "test") fun publicEnqueueOnActivatedScope(runnable: suspend () -> Unit) = enqueueOnActivatedScope(runnable) override suspend fun onActivated(): Nothing { activationCount++ onActivation() Loading Loading
packages/SystemUI/multivalentTests/src/com/android/systemui/lifecycle/HydratedActivatableTest.kt +48 −0 Original line number Diff line number Diff line Loading @@ -32,15 +32,19 @@ import com.android.systemui.kosmos.testScope import com.android.systemui.testKosmos import com.android.systemui.ui.viewmodel.FakeHydratedViewModel import com.google.common.truth.Truth.assertThat import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @OptIn(ExperimentalCoroutinesApi::class) @SmallTest @RunWith(AndroidJUnit4::class) class HydratedActivatableTest : SysuiTestCase() { Loading Loading @@ -162,4 +166,48 @@ class HydratedActivatableTest : SysuiTestCase() { .assertTextEquals("upstreamStateFlow=false") composeRule.onNode(hasTestTag("upstreamFlow")).assertTextEquals("upstreamFlow=true") } @Test fun enqueueBeforeActivation_reactivated() = testScope.runTest { var runCount = 0 // Not executed because Activatable is not active assertThat(underTest.publicEnqueueOnActivatedScope { runCount++ }).isNull() runCurrent() val job = testScope.backgroundScope.launch(EmptyCoroutineContext) { underTest.activate() } runCurrent() // This counts underTest.publicEnqueueOnActivatedScope { runCount++ } runCurrent() assertThat(runCount).isEqualTo(1) // These are put into the channel but they are never executed as the job is canceled // before execution happened. When we reactivate the Activatable a new Channel is // setup so these are not getting replayed. underTest.publicEnqueueOnActivatedScope { runCount++ } underTest.publicEnqueueOnActivatedScope { runCount++ } job.cancel() runCurrent() assertThat(runCount).isEqualTo(1) // Not executed because Activatable is not active assertThat(underTest.publicEnqueueOnActivatedScope { runCount++ }).isNull() underTest.activateIn(testScope) runCurrent() // This counts, all invocations are buffered and executed underTest.publicEnqueueOnActivatedScope { runCount++ } underTest.publicEnqueueOnActivatedScope { runCount++ } underTest.publicEnqueueOnActivatedScope { runCount++ } underTest.publicEnqueueOnActivatedScope { runCount++ } runCurrent() assertThat(runCount).isEqualTo(5) } }
packages/SystemUI/src/com/android/systemui/lifecycle/ExclusiveActivatable.kt +5 −4 Original line number Diff line number Diff line Loading @@ -17,6 +17,7 @@ package com.android.systemui.lifecycle import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.awaitCancellation /** * A base [Activatable] that can only be activated by a single owner (hence "exclusive"). A previous Loading @@ -27,7 +28,7 @@ abstract class ExclusiveActivatable : Activatable { private val _isActive = AtomicBoolean(false) protected var isActive: Boolean var isActive: Boolean get() = _isActive.get() private set(value) { _isActive.set(value) Loading @@ -39,6 +40,7 @@ abstract class ExclusiveActivatable : Activatable { try { onActivated() awaitCancellation() } finally { isActive = false } Loading @@ -56,17 +58,16 @@ abstract class ExclusiveActivatable : Activatable { * * Implementations could follow this pattern: * ```kotlin * override suspend fun onActivated(): Nothing { * override suspend fun onActivated() { * coroutineScope { * launch { ... } * launch { ... } * launch { ... } * awaitCancellation() * } * } * ``` * * @see activate */ protected abstract suspend fun onActivated(): Nothing protected abstract suspend fun onActivated() }
packages/SystemUI/src/com/android/systemui/lifecycle/HydratedActivatable.kt +41 −12 Original line number Diff line number Diff line Loading @@ -18,29 +18,48 @@ package com.android.systemui.lifecycle import androidx.compose.runtime.State import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.BUFFERED import kotlinx.coroutines.channels.ChannelResult import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch /** * An [Activatable] which manages an internal [Hydrator] which is activated accordingly. Adds * convenience methods to easily transform upstream [Flow]s into downstream snapshot-backed [State]s * based on the [Hydrator]. * * The activation of this is also guaranteed to be exclusive since the [Hydrator] is an * [ExclusiveActivatable] itself. * An [Activatable] with convenience methods to easily transform upstream [Flow]s into downstream * snapshot-backed [State]s. Also allows non-suspend code to run suspend code. * * @see [ExclusiveActivatable] */ abstract class HydratedActivatable : Activatable { abstract class HydratedActivatable( /** Enable this to use [enqueueOnActivatedScope] */ val enableEnqueuedActivations: Boolean = false ) : Activatable { private val hydrator = Hydrator("${this::class.simpleName}.hydrator") private var requestChannel: Channel<suspend () -> Unit>? = null final override suspend fun activate(): Nothing { coroutineScope { launch { hydrator.activate() } if (enableEnqueuedActivations) { launch { requestChannel = Channel<suspend () -> Unit>(BUFFERED) requestChannel!!.receiveAsFlow().collect { it.invoke() } } } try { onActivated() awaitCancellation() } finally { requestChannel?.cancel() requestChannel = null } } } Loading @@ -56,20 +75,30 @@ abstract class HydratedActivatable : Activatable { * * Implementations could follow this pattern: * ```kotlin * override suspend fun onActivated(): Nothing { * override suspend fun onActivated() { * coroutineScope { * launch { ... } * launch { ... } * launch { ... } * awaitCancellation() * } * } * ``` * * @see activate */ protected open suspend fun onActivated(): Nothing { awaitCancellation() protected open suspend fun onActivated() {} /** * Queues [block] for execution on the activated scope. Requests are executed sequentially. * * @return [null] when the [Activatable] is not active. Otherwise, returns the [ChannelResult]. * A success Channel result means the request is queued but it does not guarantee that [block] * will be executed as the Activatable can still be deactivated before [block] had a chance to * be processed. */ protected fun enqueueOnActivatedScope(block: suspend () -> Unit): ChannelResult<Unit>? { if (!enableEnqueuedActivations) error("enableEnqueuedActivations needs to be enabled.") return requestChannel?.trySend(block) } /** @see [Hydrator.hydratedStateOf] */ Loading
packages/SystemUI/tests/utils/src/com/android/systemui/ui/viewmodel/FakeHydratedViewModel.kt +4 −1 Original line number Diff line number Diff line Loading @@ -30,7 +30,7 @@ class FakeHydratedViewModel( private val onDeactivation: () -> Unit = {}, upstreamFlow: Flow<Boolean> = flowOf(true), upstreamStateFlow: StateFlow<Boolean> = MutableStateFlow(true).asStateFlow(), ) : HydratedActivatable() { ) : HydratedActivatable(enableEnqueuedActivations = true) { var activationCount = 0 var cancellationCount = 0 Loading @@ -39,6 +39,9 @@ class FakeHydratedViewModel( val stateBackedByStateFlow: Boolean by upstreamStateFlow.hydratedStateOf(traceName = "test") fun publicEnqueueOnActivatedScope(runnable: suspend () -> Unit) = enqueueOnActivatedScope(runnable) override suspend fun onActivated(): Nothing { activationCount++ onActivation() Loading