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

Commit 602ecafb authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Introduce {LaunchedEffect,DisposableEffect,produceState}WithLifecycle()

This CL introduces lifecycle-aware versions of LaunchedEffect,
DisposableEffect and produceState. This will be used by Flexiglass
contents that are always composed, in combination with ag/34811901.
See go/flexi-alwaysCompose for details.

Bug: 433309418
Test: atest EffectWithLifecycleTest
Flag: com.android.systemui.scene_container
Change-Id: I2cc16c38b4e23f40026ee3e639ced28b94ec6d37
parent 5efce14c
Loading
Loading
Loading
Loading
+166 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.compose.lifecycle

import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.DisposableEffectResult
import androidx.compose.runtime.DisposableEffectScope
import androidx.compose.runtime.NonRestartableComposable
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.CoroutineScope

// This deprecated-error function shadows the varargs overload so that the varargs version
// is not used without key parameters.
@Deprecated(DisposableEffectNoParamError, level = DeprecationLevel.ERROR)
@Composable
fun DisposableEffectWithLifecycle(block: suspend CoroutineScope.() -> Unit) {
    error(DisposableEffectNoParamError)
}

private const val DisposableEffectNoParamError =
    "DisposableEffectWithLifecycle must provide one or more 'key' parameters that define the " +
        "identity of the DisposableEffect and determine when its previous effect coroutine should" +
        " be cancelled and a new effect Disposable for the new key."

/**
 * A [DisposableEffect] that is lifecycle-aware.
 *
 * This effect is triggered every time the [lifecycle] reaches the [minActiveState], and will be
 * **re-launched** whenever [DisposableEffectWithLifecycle] is recomposed with a different [key1],
 * [lifecycle] or [minActiveState].
 *
 * @see androidx.compose.runtime.DisposableEffect
 */
@Composable
@NonRestartableComposable
fun DisposableEffectWithLifecycle(
    key1: Any?,
    lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    effect: DisposableEffectScope.() -> DisposableEffectResult,
) {
    DisposableEffect(key1, lifecycle, minActiveState) {
        disposableEffectWithLifecycle(lifecycle, minActiveState, effect)
    }
}

/**
 * A [DisposableEffect] that is lifecycle-aware.
 *
 * This effect is triggered every time the [lifecycle] reaches the [minActiveState], and will be
 * **re-launched** whenever [DisposableEffectWithLifecycle] is recomposed with a different [key1],
 * [key2], [lifecycle] or [minActiveState].
 *
 * @see androidx.compose.runtime.DisposableEffect
 */
@Composable
@NonRestartableComposable
fun DisposableEffectWithLifecycle(
    key1: Any?,
    key2: Any?,
    lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    effect: DisposableEffectScope.() -> DisposableEffectResult,
) {
    DisposableEffect(key1, key2, lifecycle, minActiveState) {
        disposableEffectWithLifecycle(lifecycle, minActiveState, effect)
    }
}

/**
 * A [DisposableEffect] that is lifecycle-aware.
 *
 * This effect is triggered every time the [lifecycle] reaches the [minActiveState], and will be
 * **re-launched** whenever [DisposableEffectWithLifecycle] is recomposed with a different [key1],
 * [key2], [lifecycle] or [minActiveState].
 *
 * @see androidx.compose.runtime.DisposableEffect
 */
@Composable
@NonRestartableComposable
fun DisposableEffectWithLifecycle(
    key1: Any?,
    key2: Any?,
    key3: Any?,
    lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    effect: DisposableEffectScope.() -> DisposableEffectResult,
) {
    DisposableEffect(key1, key2, key3, lifecycle, minActiveState) {
        disposableEffectWithLifecycle(lifecycle, minActiveState, effect)
    }
}

/**
 * A [DisposableEffect] that is lifecycle-aware.
 *
 * This effect is triggered every time the [lifecycle] reaches the [minActiveState], and will be
 * **re-launched** whenever [DisposableEffectWithLifecycle] is recomposed with a different [key1],
 * [key2], [key3], [lifecycle] or [minActiveState].
 *
 * @see androidx.compose.runtime.DisposableEffect
 */
@Composable
@NonRestartableComposable
fun DisposableEffectWithLifecycle(
    vararg keys: Any?,
    lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    effect: DisposableEffectScope.() -> DisposableEffectResult,
) {
    DisposableEffect(keys, lifecycle, minActiveState) {
        disposableEffectWithLifecycle(lifecycle, minActiveState, effect)
    }
}

private fun DisposableEffectScope.disposableEffectWithLifecycle(
    lifecycle: Lifecycle,
    minActiveState: Lifecycle.State,
    effect: DisposableEffectScope.() -> DisposableEffectResult,
): DisposableEffectResult {
    var effectResult: DisposableEffectResult? = null

    fun maybeLaunch() {
        if (effectResult != null) return
        effectResult = effect()
    }

    fun maybeDispose() {
        effectResult?.dispose()
        effectResult = null
    }

    fun update() {
        if (lifecycle.currentState.isAtLeast(minActiveState)) {
            maybeLaunch()
        } else {
            maybeDispose()
        }
    }

    val observer = LifecycleEventObserver { _, _ -> update() }
    lifecycle.addObserver(observer)
    update()

    return onDispose {
        lifecycle.removeObserver(observer)
        maybeDispose()
    }
}
+129 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.compose.lifecycle

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.NonRestartableComposable
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CoroutineScope

// This deprecated-error function shadows the varargs overload so that the varargs version
// is not used without key parameters.
@Deprecated(LaunchedEffectNoParamError, level = DeprecationLevel.ERROR)
@Composable
fun LaunchedEffectWithLifecycle(block: suspend CoroutineScope.() -> Unit) {
    error(LaunchedEffectNoParamError)
}

private const val LaunchedEffectNoParamError =
    "LaunchedEffectWithLifecycle must provide one or more 'key' parameters that define the " +
        "identity of the LaunchedEffect and determine when its previous effect coroutine should " +
        "be cancelled and a new effect launched for the new key."

/**
 * A [LaunchedEffect] that is lifecycle-aware.
 *
 * This effect is triggered every time the [lifecycle] reaches the [minActiveState], and will be
 * **re-launched** whenever [LaunchedEffectWithLifecycle] is recomposed with a different [key1],
 * [lifecycle] or [minActiveState].
 *
 * @see androidx.compose.runtime.LaunchedEffect
 */
@Composable
@NonRestartableComposable
fun LaunchedEffectWithLifecycle(
    key1: Any?,
    lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    block: suspend CoroutineScope.() -> Unit,
) {
    LaunchedEffect(key1, lifecycle, minActiveState) {
        lifecycle.repeatOnLifecycle(minActiveState, block)
    }
}

/**
 * A [LaunchedEffect] that is lifecycle-aware.
 *
 * This effect is triggered every time the [lifecycle] reaches the [minActiveState], and will be
 * **re-launched** whenever [LaunchedEffectWithLifecycle] is recomposed with a different [key1],
 * [key2], [lifecycle] or [minActiveState].
 *
 * @see androidx.compose.runtime.LaunchedEffect
 */
@Composable
@NonRestartableComposable
fun LaunchedEffectWithLifecycle(
    key1: Any?,
    key2: Any?,
    lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    block: suspend CoroutineScope.() -> Unit,
) {
    LaunchedEffect(key1, key2, lifecycle, minActiveState) {
        lifecycle.repeatOnLifecycle(minActiveState, block)
    }
}

/**
 * A [LaunchedEffect] that is lifecycle-aware.
 *
 * This effect is triggered every time the [lifecycle] reaches the [minActiveState], and will be
 * **re-launched** whenever [LaunchedEffectWithLifecycle] is recomposed with a different [key1],
 * [key2], [lifecycle] or [minActiveState].
 *
 * @see androidx.compose.runtime.LaunchedEffect
 */
@Composable
@NonRestartableComposable
fun LaunchedEffectWithLifecycle(
    key1: Any?,
    key2: Any?,
    key3: Any?,
    lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    block: suspend CoroutineScope.() -> Unit,
) {
    LaunchedEffect(key1, key2, key3, lifecycle, minActiveState) {
        lifecycle.repeatOnLifecycle(minActiveState, block)
    }
}

/**
 * A [LaunchedEffect] that is lifecycle-aware.
 *
 * This effect is triggered every time the [lifecycle] reaches the [minActiveState], and will be
 * **re-launched** whenever [LaunchedEffectWithLifecycle] is recomposed with a different [key1],
 * [key2], [key3], [lifecycle] or [minActiveState].
 *
 * @see androidx.compose.runtime.LaunchedEffect
 */
@Composable
@NonRestartableComposable
fun LaunchedEffectWithLifecycle(
    vararg keys: Any?,
    lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    block: suspend CoroutineScope.() -> Unit,
) {
    LaunchedEffect(keys, lifecycle, minActiveState) {
        lifecycle.repeatOnLifecycle(minActiveState, block)
    }
}
+179 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.compose.lifecycle

import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.ProduceStateScope
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.suspendCancellableCoroutine

/**
 * A [produceState] that is lifecycle-aware.
 *
 * [producer] is launched when the [lifecycle] reaches the [minActiveState], and will be
 * **re-launched** whenever [produceStateWithLifecycle] is recomposed with a different [lifecycle]
 * or [minActiveState].
 *
 * @see androidx.compose.runtime.produceState
 */
@Composable
fun <T> produceStateWithLifecycle(
    initialValue: T,
    lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    producer: suspend ProduceStateScope<T>.() -> Unit,
): State<T> {
    val result = remember { mutableStateOf(initialValue) }
    LaunchedEffectWithLifecycle(lifecycle = lifecycle, minActiveState = minActiveState) {
        ProduceStateScopeImpl(result, coroutineContext).producer()
    }
    return result
}

/**
 * A [produceState] that is lifecycle-aware.
 *
 * [producer] is launched when the [lifecycle] reaches the [minActiveState], and will be
 * **re-launched** whenever [produceStateWithLifecycle] is recomposed with a different [key1],
 * [lifecycle] or [minActiveState].
 *
 * @see androidx.compose.runtime.produceState
 */
@Composable
fun <T> produceStateWithLifecycle(
    initialValue: T,
    key1: Any?,
    lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    producer: suspend ProduceStateScope<T>.() -> Unit,
): State<T> {
    val result = remember { mutableStateOf(initialValue) }
    LaunchedEffectWithLifecycle(key1, lifecycle = lifecycle, minActiveState = minActiveState) {
        ProduceStateScopeImpl(result, coroutineContext).producer()
    }
    return result
}

/**
 * A [produceState] that is lifecycle-aware.
 *
 * [producer] is launched when the [lifecycle] reaches the [minActiveState], and will be
 * **re-launched** whenever [produceStateWithLifecycle] is recomposed with a different [key1],
 * [key2], [lifecycle] or [minActiveState].
 *
 * @see androidx.compose.runtime.produceState
 */
@Composable
fun <T> produceStateWithLifecycle(
    initialValue: T,
    key1: Any?,
    key2: Any?,
    lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    producer: suspend ProduceStateScope<T>.() -> Unit,
): State<T> {
    val result = remember { mutableStateOf(initialValue) }
    LaunchedEffectWithLifecycle(
        key1,
        key2,
        lifecycle = lifecycle,
        minActiveState = minActiveState,
    ) {
        ProduceStateScopeImpl(result, coroutineContext).producer()
    }
    return result
}

/**
 * A [produceState] that is lifecycle-aware.
 *
 * [producer] is launched when the [lifecycle] reaches the [minActiveState], and will be
 * **re-launched** whenever [produceStateWithLifecycle] is recomposed with a different [key1],
 * [key2], [key3], [lifecycle] or [minActiveState].
 *
 * @see androidx.compose.runtime.produceState
 */
@Composable
fun <T> produceStateWithLifecycle(
    initialValue: T,
    key1: Any?,
    key2: Any?,
    key3: Any?,
    lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    producer: suspend ProduceStateScope<T>.() -> Unit,
): State<T> {
    val result = remember { mutableStateOf(initialValue) }
    LaunchedEffectWithLifecycle(
        key1,
        key2,
        key3,
        lifecycle = lifecycle,
        minActiveState = minActiveState,
    ) {
        ProduceStateScopeImpl(result, coroutineContext).producer()
    }
    return result
}

/**
 * A [produceState] that is lifecycle-aware.
 *
 * [producer] is launched when the [lifecycle] reaches the [minActiveState], and will be
 * **re-launched** whenever [produceStateWithLifecycle] is recomposed with a different [keys],
 * [lifecycle] or [minActiveState].
 *
 * @see androidx.compose.runtime.produceState
 */
@Composable
fun <T> produceStateWithLifecycle(
    initialValue: T,
    vararg keys: Any?,
    lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    producer: suspend ProduceStateScope<T>.() -> Unit,
): State<T> {
    val result = remember { mutableStateOf(initialValue) }
    LaunchedEffectWithLifecycle(
        keys = keys,
        lifecycle = lifecycle,
        minActiveState = minActiveState,
    ) {
        ProduceStateScopeImpl(result, coroutineContext).producer()
    }
    return result
}

private class ProduceStateScopeImpl<T>(
    state: MutableState<T>,
    override val coroutineContext: CoroutineContext,
) : ProduceStateScope<T>, MutableState<T> by state {
    override suspend fun awaitDispose(onDispose: () -> Unit): Nothing {
        try {
            suspendCancellableCoroutine<Nothing> {}
        } finally {
            onDispose()
        }
    }
}
+127 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.compose.lifecycle

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.awaitCancellation
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class EffectWithLifecycleTest {
    @get:Rule val rule = createComposeRule()

    @Test
    fun launchedEffect() {
        testEffectWithLifecycle { minActiveState, isEffectRunning ->
            LaunchedEffectWithLifecycle(Unit, minActiveState = minActiveState) {
                try {
                    isEffectRunning(true)
                    awaitCancellation()
                } finally {
                    isEffectRunning(false)
                }
            }
        }
    }

    @Test
    fun disposableEffect() {
        testEffectWithLifecycle { minActiveState, isEffectRunning ->
            DisposableEffectWithLifecycle(minActiveState = minActiveState) {
                isEffectRunning(true)
                onDispose { isEffectRunning(false) }
            }
        }
    }

    @Test
    fun produceState() {
        testEffectWithLifecycle { minActiveState, isEffectRunning ->
            produceStateWithLifecycle(initialValue = 0, minActiveState = minActiveState) {
                try {
                    isEffectRunning(true)
                    awaitCancellation()
                } finally {
                    isEffectRunning(false)
                }
            }
        }
    }

    private fun testEffectWithLifecycle(
        effect:
            @Composable
            (minActiveState: Lifecycle.State, isEffectRunning: (Boolean) -> Unit) -> Unit
    ) {
        val lifecycle =
            rule.runOnUiThread {
                object : LifecycleOwner {
                    override val lifecycle = LifecycleRegistry(this)

                    init {
                        lifecycle.currentState = Lifecycle.State.CREATED
                    }
                }
            }

        var isEffectRunning = false
        var minActiveState by mutableStateOf(Lifecycle.State.STARTED)

        rule.setContent {
            CompositionLocalProvider(LocalLifecycleOwner provides lifecycle) {
                effect(minActiveState) { isEffectRunning = it }
            }
        }

        // currentState = CREATED / minActivateState = STARTED.
        assertThat(isEffectRunning).isFalse()

        // currentState = CREATED / minActivateState = CREATED.
        minActiveState = Lifecycle.State.CREATED
        rule.waitForIdle()
        assertThat(isEffectRunning).isTrue()

        // currentState = STARTED / minActivateState = RESUMED.
        minActiveState = Lifecycle.State.RESUMED
        rule.runOnUiThread { lifecycle.lifecycle.currentState = Lifecycle.State.STARTED }
        rule.waitForIdle()
        assertThat(isEffectRunning).isFalse()

        // currentState = RESUMED / minActivateState = RESUMED.
        rule.runOnUiThread { lifecycle.lifecycle.currentState = Lifecycle.State.RESUMED }
        rule.waitForIdle()
        assertThat(isEffectRunning).isTrue()

        // currentState = DESTROYED / minActivateState = RESUMED.
        rule.runOnUiThread { lifecycle.lifecycle.currentState = Lifecycle.State.DESTROYED }
        rule.waitForIdle()
        assertThat(isEffectRunning).isFalse()
    }
}