Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit f11455f8 authored by Andreas Miko's avatar Andreas Miko
Browse files

Add requestChannel to HydratedActivatable

Provide convenience method for the actor pattern.

Bug: b/420591935
Test: Refactor only
Flag: com.android.systemui.scene_container
Change-Id: Ie244dc5bab5deb6cc8a34568387aa4d6a68b297a
parent 4da10276
Loading
Loading
Loading
Loading
+48 −0
Original line number Diff line number Diff line
@@ -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() {
@@ -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)
        }
}
+2 −2
Original line number Diff line number Diff line
@@ -28,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)
@@ -40,9 +40,9 @@ abstract class ExclusiveActivatable : Activatable {

        try {
            onActivated()
            awaitCancellation()
        } finally {
            isActive = false
            awaitCancellation()
        }
    }

+40 −9
Original line number Diff line number Diff line
@@ -18,30 +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
            }
        }
    }

@@ -70,6 +88,19 @@ abstract class HydratedActivatable : Activatable {
     */
    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] */
    protected fun <T> StateFlow<T>.hydratedStateOf(traceName: String): State<T> =
        hydrator.hydratedStateOf(traceName, this)
+4 −1
Original line number Diff line number Diff line
@@ -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

@@ -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()