From 70e6a9c249e031d9a10b272d46505e35ff0363ec Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Wed, 4 Feb 2026 17:13:59 +0700 Subject: [PATCH 01/17] Added visual indication for lifecycle state on top of screens --- .../screens/base/ButtonsScreenContent.kt | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt index e78b413d..f0251226 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt @@ -22,18 +22,25 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.IntState import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.currentStateAsState import com.github.terrakok.modo.ExperimentalModoApi import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey @@ -49,6 +56,7 @@ import com.github.terrakok.modo.stack.LocalStackNavigation import com.github.terrakok.modo.stack.back import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import logcat.logcat internal const val COUNTER_DELAY_MS = 100L @@ -179,9 +187,33 @@ internal fun SampleScreenContent( topRightButtonSlot: @Composable () -> Unit = {}, content: @Composable ColumnScope.() -> Unit ) { + val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateAsState() + val topLineColor by remember { + derivedStateOf { + when (lifecycleState) { + Lifecycle.State.RESUMED -> Color.Green + Lifecycle.State.STARTED -> Color.Yellow + Lifecycle.State.CREATED, + Lifecycle.State.INITIALIZED, + Lifecycle.State.DESTROYED -> { + // TODO: fing out if it is fixible. For now we start with created state. + logcat("") {"Should not happen, state ${lifecycleState.name}" } + Color.Black + } + } + } + } Box( modifier = modifier .randomBackground() + .drawWithContent { + val strokeWidth = 8.dp.toPx() + drawRect( + color = topLineColor, + size = Size(width = size.width, height = strokeWidth) + ) + drawContent() + } .windowInsetsPadding(windowInsets), ) { Column(Modifier.padding(8.dp)) { From 4e5a5bc704a251c58880a95c34e7bf8efde16afc Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Wed, 4 Feb 2026 17:15:07 +0700 Subject: [PATCH 02/17] Covered ModoScreenAndroidAdapter with basic tests --- gradle/libs.versions.toml | 1 + modo-compose/build.gradle.kts | 7 +++ .../ModoScreenAndroidAdapterBasicTest.kt | 58 ++++++++++++++++++ ...doScreenAndroidAdapterNeedSkipEventTest.kt | 59 +++++++++++++++++++ .../ModoScreenAndroidAdapterTestUtils.kt | 34 +++++++++++ 5 files changed, 159 insertions(+) create mode 100644 modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterBasicTest.kt create mode 100644 modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterNeedSkipEventTest.kt create mode 100644 modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 621c1275..9e786be5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,6 +48,7 @@ detekt-composeRules = { module = "io.nlopez.compose.rules:detekt", version.ref = detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detektVersion" } leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" } test-junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version = "5.10.1" } +test-androidx-arch-core = { group = "androidx.arch.core", name = "core-testing", version = "2.2.0" } debug-logcat = { group = "com.squareup.logcat", name = "logcat", version = "0.1" } diff --git a/modo-compose/build.gradle.kts b/modo-compose/build.gradle.kts index 30dc892f..7aec9f6e 100644 --- a/modo-compose/build.gradle.kts +++ b/modo-compose/build.gradle.kts @@ -13,6 +13,12 @@ dependencyGuard { android { namespace = "com.github.terrakok.modo.android.compose" + + testOptions { + unitTests { + isReturnDefaultValues = true + } + } } dependencies { @@ -31,6 +37,7 @@ dependencies { testImplementation(libs.test.junit.jupiter) testImplementation(kotlin("test")) + testImplementation(libs.test.androidx.arch.core) } tasks.withType(Test::class) { diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterBasicTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterBasicTest.kt new file mode 100644 index 00000000..3cd1a337 --- /dev/null +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterBasicTest.kt @@ -0,0 +1,58 @@ +package com.github.terrakok.modo.android + +import androidx.lifecycle.Lifecycle +import com.github.terrakok.modo.MockScreen +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.android.ModoScreenAndroidAdapterTestUtils.cleanupArchTaskExecutor +import com.github.terrakok.modo.android.ModoScreenAndroidAdapterTestUtils.cleanupScreenModelStore +import com.github.terrakok.modo.android.ModoScreenAndroidAdapterTestUtils.setupArchTaskExecutor +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import kotlin.test.assertEquals + +class ModoScreenAndroidAdapterBasicTest { + + private lateinit var screen: MockScreen + private lateinit var adapter: ModoScreenAndroidAdapter + + @BeforeEach + fun setup() { + setupArchTaskExecutor() + cleanupScreenModelStore() + screen = MockScreen(ScreenKey("test-screen")) + adapter = ModoScreenAndroidAdapter.get(screen) + } + + @AfterEach + fun tearDown() { + cleanupArchTaskExecutor() + cleanupScreenModelStore() + } + + @Test + fun `When adapter is created - Then lifecycle is in INITIALIZED state`() { + assertEquals(Lifecycle.State.INITIALIZED, adapter.lifecycle.currentState) + } + + // TODO: Add ModoScreenAndroidAdapterLifecycleTest for integration tests requiring Compose initialization: + // - showTransitionFinished, hideTransitionStarted, onPreDispose + // - parent lifecycle propagation + // - manualResumePause mode + + @Test + fun `When adapter is created - Then viewModelStore is available`() { + assertDoesNotThrow { adapter.viewModelStore } + } + + @Test + fun `When adapter is created - Then savedStateRegistry is available`() { + assertDoesNotThrow { adapter.savedStateRegistry } + } + + @Test + fun `When adapter is created - Then defaultViewModelProviderFactory is available`() { + assertDoesNotThrow { adapter.defaultViewModelProviderFactory } + } +} diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterNeedSkipEventTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterNeedSkipEventTest.kt new file mode 100644 index 00000000..5d7e13a6 --- /dev/null +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterNeedSkipEventTest.kt @@ -0,0 +1,59 @@ +package com.github.terrakok.modo.android + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.Event.* +import androidx.lifecycle.Lifecycle.State.* +import com.github.terrakok.modo.android.ModoScreenAndroidAdapter.Companion.needSkipEvent +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import kotlin.test.assertEquals + +class ModoScreenAndroidAdapterNeedSkipEventTest { + + @ParameterizedTest(name = "State={0}, Event={1} -> shouldSkip={2}") + @MethodSource("testCases") + fun `needSkipEvent returns correct result`( + state: Lifecycle.State, + event: Lifecycle.Event, + shouldSkip: Boolean + ) { + assertEquals(shouldSkip, needSkipEvent(state, event)) + } + + companion object { + @JvmStatic + fun testCases() = listOf( + Arguments.of(INITIALIZED, ON_CREATE, false), + Arguments.of(INITIALIZED, ON_START, false), + Arguments.of(INITIALIZED, ON_RESUME, false), + Arguments.of(INITIALIZED, ON_PAUSE, true), + Arguments.of(INITIALIZED, ON_STOP, true), + Arguments.of(INITIALIZED, ON_DESTROY, false), + Arguments.of(CREATED, ON_CREATE, true), + Arguments.of(CREATED, ON_START, false), + Arguments.of(CREATED, ON_RESUME, false), + Arguments.of(CREATED, ON_PAUSE, true), + Arguments.of(CREATED, ON_STOP, true), + Arguments.of(CREATED, ON_DESTROY, false), + Arguments.of(STARTED, ON_CREATE, true), + Arguments.of(STARTED, ON_START, true), + Arguments.of(STARTED, ON_RESUME, false), + Arguments.of(STARTED, ON_PAUSE, true), + Arguments.of(STARTED, ON_STOP, false), + Arguments.of(STARTED, ON_DESTROY, false), + Arguments.of(RESUMED, ON_CREATE, true), + Arguments.of(RESUMED, ON_START, true), + Arguments.of(RESUMED, ON_RESUME, true), + Arguments.of(RESUMED, ON_PAUSE, false), + Arguments.of(RESUMED, ON_STOP, false), + Arguments.of(RESUMED, ON_DESTROY, false), + Arguments.of(DESTROYED, ON_CREATE, true), + Arguments.of(DESTROYED, ON_START, true), + Arguments.of(DESTROYED, ON_RESUME, true), + Arguments.of(DESTROYED, ON_PAUSE, true), + Arguments.of(DESTROYED, ON_STOP, true), + Arguments.of(DESTROYED, ON_DESTROY, true), + ) + } +} diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt new file mode 100644 index 00000000..09e4ae95 --- /dev/null +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt @@ -0,0 +1,34 @@ +package com.github.terrakok.modo.android + +import androidx.arch.core.executor.ArchTaskExecutor +import androidx.arch.core.executor.TaskExecutor +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.github.terrakok.modo.model.ScreenModelStore + +object ModoScreenAndroidAdapterTestUtils { + + fun setupArchTaskExecutor() { + ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() { + override fun executeOnDiskIO(runnable: Runnable) = runnable.run() + override fun postToMainThread(runnable: Runnable) = runnable.run() + override fun isMainThread() = true + }) + } + + fun cleanupArchTaskExecutor() { + ArchTaskExecutor.getInstance().setDelegate(null) + } + + fun cleanupScreenModelStore() { + ScreenModelStore.removedScreenKeys.clear() + ScreenModelStore.screenModels.clear() + ScreenModelStore.dependencies.clear() + } +} + +class TestLifecycleOwner : LifecycleOwner { + private val lifecycleRegistry = LifecycleRegistry(this) + override val lifecycle: Lifecycle get() = lifecycleRegistry +} From bcfc974832f64288cb8428798811db936b0ff607 Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Sun, 8 Feb 2026 15:54:56 +0700 Subject: [PATCH 03/17] Covered refactored ModoScreenAndroidAdapter and covered it with some tests --- .gitignore | 1 + .../modo/android/ModoScreenAndroidAdapter.kt | 86 ++++--- .../ModoScreenAndroidAdapterBasicTest.kt | 5 - ...doScreenAndroidAdapterNeedSkipEventTest.kt | 13 +- ...reenAndroidAdapterParentPropagationTest.kt | 233 ++++++++++++++++++ .../ModoScreenAndroidAdapterTestUtils.kt | 73 ++++++ 6 files changed, 373 insertions(+), 38 deletions(-) create mode 100644 modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt diff --git a/.gitignore b/.gitignore index cb518114..aba67d8c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ # Properties local.properties +local.env settings.xml # Idea diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt index 1fc863b4..69c9a2fa 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt @@ -3,6 +3,7 @@ package com.github.terrakok.modo.android import android.app.Application import android.content.Context import android.os.Bundle +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocal import androidx.compose.runtime.CompositionLocalProvider @@ -102,7 +103,9 @@ class ModoScreenAndroidAdapter private constructor( // Atomic references for cases when we unable take it directly from the composition. private val atomicContext = AtomicReference() - private val atomicParentLifecycleOwner = AtomicReference() + + @VisibleForTesting + internal val atomicParentLifecycleOwner = AtomicReference() private val application: Application? get() = atomicContext.get()?.applicationContext?.getApplication() /** @@ -242,14 +245,11 @@ class ModoScreenAndroidAdapter private constructor( } val savedState = rememberSaveable { Bundle() } if (!isCreated) { - onCreate(savedState) // do this in the UI thread to force it to be called before anything else + onCreate(savedState) } DisposableEffect(this) { - safeHandleLifecycleEvent(ON_START) - if (!manualResumePause) { - safeHandleLifecycleEvent(ON_RESUME) - } + handleLifecycleOnCompositionEnter(manualResumePause) onDispose { } } @@ -260,40 +260,64 @@ class ModoScreenAndroidAdapter private constructor( DisposableEffect(this) { screen.devLogV(TAG) { "LifecycleDisposableEffect parentLifecycleOwner: $parentLifecycleOwner" } - val unregisterLifecycle = registerParentLifecycleListener(parentLifecycleOwner) { - LifecycleEventObserver { _, event -> - // when the Application goes to background, perform save - if (event == ON_STOP) { - performSave(savedState) - } - if ( - needPropagateLifecycleEventFromParent( - event, - screenTransitionState = screenTransitionState, - isActivityFinishing = activity?.isFinishing, - isChangingConfigurations = activity?.isChangingConfigurations - ) - ) { - safeHandleLifecycleEvent(event) - } - } - } + val unregisterLifecycle = subscribeToParentLifecycle( + parentLifecycleOwner = parentLifecycleOwner, + savedState = savedState, + isActivityFinishing = { activity?.isFinishing }, + isChangingConfigurations = { activity?.isChangingConfigurations } + ) onDispose { screen.devLogD(TAG) { "LifecycleDisposableEffect after content DisposableEffect.onDispose ${lifecycle.currentState}" } unregisterLifecycle() - // when the screen goes to stack, perform save performSave(savedState) - // notify lifecycle screen listeners - if (!manualResumePause) { - safeHandleLifecycleEvent(ON_PAUSE) - } - safeHandleLifecycleEvent(ON_STOP) + handleLifecycleOnCompositionExit(manualResumePause) + } + } + } + + @VisibleForTesting + internal fun handleLifecycleOnCompositionEnter(manualResumePause: Boolean) { + safeHandleLifecycleEvent(ON_START) + if (!manualResumePause) { + safeHandleLifecycleEvent(ON_RESUME) + } + } + + @VisibleForTesting + internal fun handleLifecycleOnCompositionExit(manualResumePause: Boolean) { + if (!manualResumePause) { + safeHandleLifecycleEvent(ON_PAUSE) + } + safeHandleLifecycleEvent(ON_STOP) + } + + @VisibleForTesting + internal fun subscribeToParentLifecycle( + parentLifecycleOwner: LifecycleOwner, + savedState: Bundle? = null, + isActivityFinishing: () -> Boolean? = { null }, + isChangingConfigurations: () -> Boolean? = { null } + ): () -> Unit = registerParentLifecycleListener(parentLifecycleOwner) { + LifecycleEventObserver { _, event -> + if (event == ON_STOP && savedState != null) { + performSave(savedState) + } + if ( + needPropagateLifecycleEventFromParent( + event, + screenTransitionState = screenTransitionState, + isActivityFinishing = isActivityFinishing(), + isChangingConfigurations = isChangingConfigurations() + ) + ) { + safeHandleLifecycleEvent(event) } } } - private fun safeHandleLifecycleEvent(event: Lifecycle.Event) { + @VisibleForTesting + internal fun safeHandleLifecycleEvent(event: Lifecycle.Event) { val skippEvent = needSkipEvent(lifecycle.currentState, event) if (!skippEvent) { screen.devLogD(TAG) { "safeHandleLifecycleEvent send $event" } diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterBasicTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterBasicTest.kt index 3cd1a337..e5e40f73 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterBasicTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterBasicTest.kt @@ -36,11 +36,6 @@ class ModoScreenAndroidAdapterBasicTest { assertEquals(Lifecycle.State.INITIALIZED, adapter.lifecycle.currentState) } - // TODO: Add ModoScreenAndroidAdapterLifecycleTest for integration tests requiring Compose initialization: - // - showTransitionFinished, hideTransitionStarted, onPreDispose - // - parent lifecycle propagation - // - manualResumePause mode - @Test fun `When adapter is created - Then viewModelStore is available`() { assertDoesNotThrow { adapter.viewModelStore } diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterNeedSkipEventTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterNeedSkipEventTest.kt index 5d7e13a6..b828a976 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterNeedSkipEventTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterNeedSkipEventTest.kt @@ -1,8 +1,17 @@ package com.github.terrakok.modo.android import androidx.lifecycle.Lifecycle -import androidx.lifecycle.Lifecycle.Event.* -import androidx.lifecycle.Lifecycle.State.* +import androidx.lifecycle.Lifecycle.Event.ON_CREATE +import androidx.lifecycle.Lifecycle.Event.ON_DESTROY +import androidx.lifecycle.Lifecycle.Event.ON_PAUSE +import androidx.lifecycle.Lifecycle.Event.ON_RESUME +import androidx.lifecycle.Lifecycle.Event.ON_START +import androidx.lifecycle.Lifecycle.Event.ON_STOP +import androidx.lifecycle.Lifecycle.State.CREATED +import androidx.lifecycle.Lifecycle.State.DESTROYED +import androidx.lifecycle.Lifecycle.State.INITIALIZED +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.Lifecycle.State.STARTED import com.github.terrakok.modo.android.ModoScreenAndroidAdapter.Companion.needSkipEvent import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt new file mode 100644 index 00000000..ad2ca1c9 --- /dev/null +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt @@ -0,0 +1,233 @@ +package com.github.terrakok.modo.android + +import androidx.lifecycle.Lifecycle.State.CREATED +import androidx.lifecycle.Lifecycle.State.DESTROYED +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.Lifecycle.State.STARTED +import com.github.terrakok.modo.MockScreen +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.android.ModoScreenAndroidAdapterTestUtils.cleanupArchTaskExecutor +import com.github.terrakok.modo.android.ModoScreenAndroidAdapterTestUtils.cleanupScreenModelStore +import com.github.terrakok.modo.android.ModoScreenAndroidAdapterTestUtils.setupArchTaskExecutor +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class ModoScreenAndroidAdapterParentPropagationTest { + + private lateinit var screen: MockScreen + private lateinit var adapter: ModoScreenAndroidAdapter + private lateinit var parent: TestLifecycleOwner + private lateinit var emulator: CompositionLifecycleEmulator + + @BeforeEach + fun setup() { + setupArchTaskExecutor() + cleanupScreenModelStore() + screen = MockScreen(ScreenKey("test-screen")) + adapter = ModoScreenAndroidAdapter.get(screen) + parent = TestLifecycleOwner() + emulator = CompositionLifecycleEmulator(adapter, parent) + } + + @AfterEach + fun tearDown() { + cleanupArchTaskExecutor() + cleanupScreenModelStore() + } + + // region Parent RESUMED, screen enter composition + + @Test + fun `Given parent RESUMED and auto resume When enter composition - Then screen RESUMED`() { + parent.lifecycleState = RESUMED + emulator.enterComposition() + + assertEquals(RESUMED, emulator.lifecycleState) + } + + @Test + fun `Given parent RESUMED and manual resume When enter composition - Then screen STARTED`() { + parent.lifecycleState = RESUMED + emulator.enterComposition(manualResumePause = true) + + assertEquals(STARTED, emulator.lifecycleState) + } + + // endregion + + // region Parent STARTED (animation in progress), screen enter composition + + // TODO: fix it? should be STARTED + @Test + fun `Given parent STARTED and auto resume When enter composition without manual resume - Then screen RESUMED`() { + parent.lifecycleState = STARTED + + emulator.enterComposition() + assertEquals(RESUMED, emulator.lifecycleState) + } + + @Test + fun `Given parent STARTED and manual resume When enter composition without manual resume - Then screen RESUMED`() { + parent.lifecycleState = STARTED + + emulator.enterComposition(manualResumePause = true) + assertEquals(STARTED, emulator.lifecycleState) + } + + // endregion + + // region lifecycle propagation + + // region Screen is not in transition, parent changes state + + @Test + fun `Given auto resume When parent moves RESUMED to STARTED to RESUMED - Then adapter follows parent`() { + parent.lifecycleState = RESUMED + + emulator.enterComposition() + assertEquals(RESUMED, emulator.lifecycleState) + + parent.lifecycleState = STARTED + assertEquals(STARTED, emulator.lifecycleState) + + parent.lifecycleState = RESUMED + assertEquals(RESUMED, emulator.lifecycleState) + } + + @Test + fun `Given manual resume When parent moves RESUMED to STARTED to RESUMED - Then adapter follows parent`() { + parent.lifecycleState = RESUMED + + emulator.enterComposition(manualResumePause = true) + assertEquals(STARTED, emulator.lifecycleState) + + emulator.showTransitionFinished() + assertEquals(RESUMED, emulator.lifecycleState) + + parent.lifecycleState = STARTED + assertEquals(STARTED, emulator.lifecycleState) + + parent.lifecycleState = RESUMED + assertEquals(RESUMED, emulator.lifecycleState) + } + + @Test + fun `Given auto resume Test parent moves from RESUMED to CREATED to RESUMED`() { + parent.lifecycleState = RESUMED + + emulator.enterComposition() + assertEquals(RESUMED, emulator.lifecycleState) + + parent.lifecycleState = CREATED + assertEquals(CREATED, emulator.lifecycleState) + + parent.lifecycleState = RESUMED + assertEquals(RESUMED, emulator.lifecycleState) + } + + // endregion + + @Test + fun `Given manual resume and parent animating Verify states When transition finished after parent moves to RESUMED`() { + parent.lifecycleState = STARTED + + emulator.enterComposition(manualResumePause = true) + assertEquals(STARTED, emulator.lifecycleState) + + parent.lifecycleState = RESUMED + assertEquals(STARTED, emulator.lifecycleState) + + adapter.showTransitionFinished() + assertEquals(RESUMED, emulator.lifecycleState) + } + + @Test + fun `Given manual resume and STARTED parent Test transition finished before parent moves to RESUMED`() { + parent.lifecycleState = STARTED + + emulator.enterComposition(manualResumePause = true) + assertEquals(STARTED, emulator.lifecycleState) + + adapter.showTransitionFinished() + assertEquals(STARTED, emulator.lifecycleState) + + parent.lifecycleState = RESUMED + assertEquals(RESUMED, emulator.lifecycleState) + } + + // endregion + + // Parent resumed all time + // Child animating in and animating out + @Test + fun `Given manual resume parent RESUMED Test screen transition showing and hiding`() { + parent.lifecycleState = RESUMED + + emulator.enterComposition(manualResumePause = true) + assertEquals(STARTED, emulator.lifecycleState) + + adapter.showTransitionFinished() + assertEquals(RESUMED, emulator.lifecycleState) + + adapter.hideTransitionStarted() + assertEquals(STARTED, emulator.lifecycleState) + + emulator.exitComposition() + assertEquals(CREATED, emulator.lifecycleState) + } + + // Parent animating (it is STARTED) + // Child idle and then animating out during parent animation + // Parent animation finishes + // Child animation finishes + @Test + fun `Given manual resume parent STARTED Test showTransitionFinished then hideTransitionStarted`() { + parent.lifecycleState = STARTED + + emulator.enterComposition(manualResumePause = true) + assertEquals(STARTED, emulator.lifecycleState) + + adapter.showTransitionFinished() + assertEquals(STARTED, emulator.lifecycleState) + + adapter.hideTransitionStarted() + assertEquals(STARTED, emulator.lifecycleState) + + parent.lifecycleState = RESUMED + assertEquals(STARTED, emulator.lifecycleState) + + emulator.exitComposition() + assertEquals(CREATED, emulator.lifecycleState) + } + + @Test + fun `Given in composition When exit composition - Then adapter moves to CREATED`() { + parent.lifecycleState = RESUMED + + emulator.enterComposition() + assertEquals(RESUMED, emulator.lifecycleState) + + emulator.exitComposition() + assertEquals(CREATED, emulator.lifecycleState) + } + + @Test + fun `When screen is not in composition - Then parent lifecycle changes has no effect`() { + parent.lifecycleState = RESUMED + assertEquals(CREATED, emulator.lifecycleState) + + parent.lifecycleState = STARTED + assertEquals(CREATED, emulator.lifecycleState) + + parent.lifecycleState = RESUMED + assertEquals(CREATED, emulator.lifecycleState) + + parent.lifecycleState = CREATED + assertEquals(CREATED, emulator.lifecycleState) + + parent.lifecycleState = DESTROYED + assertEquals(CREATED, emulator.lifecycleState) + } +} diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt index 09e4ae95..02f82a65 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt @@ -1,5 +1,6 @@ package com.github.terrakok.modo.android +import android.os.Bundle import androidx.arch.core.executor.ArchTaskExecutor import androidx.arch.core.executor.TaskExecutor import androidx.lifecycle.Lifecycle @@ -31,4 +32,76 @@ object ModoScreenAndroidAdapterTestUtils { class TestLifecycleOwner : LifecycleOwner { private val lifecycleRegistry = LifecycleRegistry(this) override val lifecycle: Lifecycle get() = lifecycleRegistry + + var lifecycleState: Lifecycle.State + set(value) { + lifecycleRegistry.currentState = value + } + get() = lifecycleRegistry.currentState + +} + +/** + * Test helper that emulates the behavior of LifecycleDisposableEffect composable. + * Allows manual control over composition lifecycle and parent state for testing. + */ +class CompositionLifecycleEmulator( + private val adapter: ModoScreenAndroidAdapter, + private val parentLifecycleOwner: TestLifecycleOwner, +) { + + private var manualResumePause: Boolean = false + private val savedState = Bundle() + private var unsubscribeFromParent: (() -> Unit)? = null + private var isInComposition = false + + init { + initializeAdapter() + adapter.atomicParentLifecycleOwner.set(parentLifecycleOwner) + } + + fun enterComposition(manualResumePause: Boolean = false) { + check(!isInComposition) { "Already in composition" } + isInComposition = true + + this.manualResumePause = manualResumePause + + adapter.handleLifecycleOnCompositionEnter(manualResumePause) + + unsubscribeFromParent = adapter.subscribeToParentLifecycle( + parentLifecycleOwner = parentLifecycleOwner, + savedState = savedState, + isActivityFinishing = { false }, + isChangingConfigurations = { false } + ) + } + + fun exitComposition() { + check(isInComposition) { "Not in composition" } + isInComposition = false + + unsubscribeFromParent?.invoke() + adapter.handleLifecycleOnCompositionExit(manualResumePause) + } + + fun showTransitionFinished() { + adapter.showTransitionFinished() + } + + fun hideTransitionStarted() { + adapter.hideTransitionStarted() + } + + val lifecycleState: Lifecycle.State + get() = adapter.lifecycle.currentState + + val parentState: Lifecycle.State + get() = parentLifecycleOwner.lifecycle.currentState + + private fun initializeAdapter() { + val onCreate = ModoScreenAndroidAdapter::class.java + .getDeclaredMethod("onCreate", Bundle::class.java) + onCreate.isAccessible = true + onCreate.invoke(adapter, savedState) + } } From 345e827db302fa83acbbf6e95613d7346933acda Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Mon, 9 Feb 2026 21:40:12 +0700 Subject: [PATCH 04/17] Refactored logic of handling lifecycle in ModoScreenAndroidAdapter.kt --- .../modo/android/ModoScreenAndroidAdapter.kt | 198 +++++++++++------- ...reenAndroidAdapterParentPropagationTest.kt | 83 +++++++- ...nAndroidAdapterStateAlreadyReachedTest.kt} | 13 +- .../ModoScreenAndroidAdapterTestUtils.kt | 10 +- 4 files changed, 213 insertions(+), 91 deletions(-) rename modo-compose/src/test/java/com/github/terrakok/modo/android/{ModoScreenAndroidAdapterNeedSkipEventTest.kt => ModoScreenAndroidAdapterStateAlreadyReachedTest.kt} (82%) diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt index 69c9a2fa..9812d1de 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt @@ -22,6 +22,8 @@ import androidx.lifecycle.Lifecycle.Event.ON_PAUSE import androidx.lifecycle.Lifecycle.Event.ON_RESUME import androidx.lifecycle.Lifecycle.Event.ON_START import androidx.lifecycle.Lifecycle.Event.ON_STOP +import androidx.lifecycle.Lifecycle.State.CREATED +import androidx.lifecycle.Lifecycle.State.DESTROYED import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -54,6 +56,7 @@ import com.github.terrakok.modo.model.ScreenModelStore.remove import com.github.terrakok.modo.util.getActivity import com.github.terrakok.modo.util.getApplication import java.util.concurrent.atomic.AtomicReference +import kotlin.math.abs /** * Adapter for Screen that provides android-related features support using Modo, such as: @@ -112,8 +115,7 @@ class ModoScreenAndroidAdapter private constructor( * Holding transition state of the screen to be able to handle lifecycle events from parent properly. * Check out [needPropagateLifecycleEventFromParent] for more details. */ - @Volatile - private var screenTransitionState: ScreenTransitionState = ScreenTransitionState.HIDDEN + private val screenTransitionState: ScreenTransitionState = ScreenTransitionState(readyToBeResumed = false) init { controller.performAttach() @@ -127,9 +129,9 @@ class ModoScreenAndroidAdapter private constructor( ) { val context: Context = LocalContext.current val parentLifecycleOwner = LocalLifecycleOwner.current + DisposableAtomicReference(LocalContext, atomicContext) + DisposableAtomicReference(LocalLifecycleOwner, atomicParentLifecycleOwner) LifecycleDisposableEffect(context, parentLifecycleOwner, manualResumePause) { - DisposableAtomicReference(LocalContext, atomicContext) - DisposableAtomicReference(LocalLifecycleOwner, atomicParentLifecycleOwner) ProvideCompositionLocals(content) } } @@ -141,25 +143,19 @@ class ModoScreenAndroidAdapter private constructor( */ override fun onPreDispose() { ModoDevOptions.onScreenPreDisposeListener?.invoke(screen) - safeHandleLifecycleEvent(ON_DESTROY) + updateLifecycleIfNeed(ON_DESTROY) } override fun hideTransitionStarted() { screen.devLogD(TAG) { "hideTransitionStarted ${lifecycle.currentState}" } - screenTransitionState = ScreenTransitionState.HIDING - safeHandleLifecycleEvent(ON_PAUSE) + screenTransitionState.readyToBeResumed = false + updateLifecycleIfNeed(ON_PAUSE) } override fun showTransitionFinished() { screen.devLogD(TAG) { "showTransitionFinished ${lifecycle.currentState}" } - screenTransitionState = ScreenTransitionState.SHOWN - val parentState = atomicParentLifecycleOwner.get()?.lifecycle?.currentState - // It's crucial to check parent state, because our state can't be greater than a parent state. - // If this condition is not met, resuming will be done when parent will be resumed and screenTransitionState == ScreenTransitionState.SHOWN. - // It can happen when we display this screen as a first screen inside container, that is animating. - if (parentState != null && parentState == Lifecycle.State.RESUMED) { - safeHandleLifecycleEvent(ON_RESUME) - } + screenTransitionState.readyToBeResumed = true + updateLifecycleIfNeed(ON_RESUME) } override fun toString(): String = "${ModoScreenAndroidAdapter::class.simpleName}, screenKey: ${screen.screenKey}" @@ -173,7 +169,7 @@ class ModoScreenAndroidAdapter private constructor( check(!isCreated) { "onCreate already called" } isCreated = true controller.performRestore(savedState) - safeHandleLifecycleEvent(ON_CREATE) + updateLifecycleIfNeed(ON_CREATE) } private fun performSave(outState: Bundle) { @@ -278,18 +274,19 @@ class ModoScreenAndroidAdapter private constructor( @VisibleForTesting internal fun handleLifecycleOnCompositionEnter(manualResumePause: Boolean) { - safeHandleLifecycleEvent(ON_START) + updateLifecycleIfNeed(ON_START) if (!manualResumePause) { - safeHandleLifecycleEvent(ON_RESUME) + screenTransitionState.readyToBeResumed = true + updateLifecycleIfNeed(ON_RESUME) } } @VisibleForTesting internal fun handleLifecycleOnCompositionExit(manualResumePause: Boolean) { if (!manualResumePause) { - safeHandleLifecycleEvent(ON_PAUSE) + updateLifecycleIfNeed(ON_PAUSE) } - safeHandleLifecycleEvent(ON_STOP) + updateLifecycleIfNeed(ON_STOP) } @VisibleForTesting @@ -299,6 +296,7 @@ class ModoScreenAndroidAdapter private constructor( isActivityFinishing: () -> Boolean? = { null }, isChangingConfigurations: () -> Boolean? = { null } ): () -> Unit = registerParentLifecycleListener(parentLifecycleOwner) { + // If we still subscribed to parent lifecycle, then we in composition and content is visible LifecycleEventObserver { _, event -> if (event == ON_STOP && savedState != null) { performSave(savedState) @@ -306,45 +304,53 @@ class ModoScreenAndroidAdapter private constructor( if ( needPropagateLifecycleEventFromParent( event, - screenTransitionState = screenTransitionState, isActivityFinishing = isActivityFinishing(), isChangingConfigurations = isChangingConfigurations() ) ) { - safeHandleLifecycleEvent(event) + updateLifecycleIfNeed(event) } } } + /** + * Attempts to update the screen's lifecycle state with the given event if all conditions are met. + * + * This method enforces several rules to ensure lifecycle integrity: + * 1. **No resurrection**: Once DESTROYED, the lifecycle cannot move to any other state + * 2. **No redundant events**: Events that would lead to an already-reached state are skipped + * 3. **Transition readiness**: ON_RESUME requires [ScreenTransitionState.readyToBeResumed] to be true + * 4. **Parent constraints**: Child state cannot exceed parent state (enforced by [parentStateAllowMove]) + * 5. **Single-step transitions**: State changes must be sequential (except CREATED -> DESTROYED) + * + * @param event The lifecycle event to potentially dispatch + */ @VisibleForTesting - internal fun safeHandleLifecycleEvent(event: Lifecycle.Event) { - val skippEvent = needSkipEvent(lifecycle.currentState, event) - if (!skippEvent) { - screen.devLogD(TAG) { "safeHandleLifecycleEvent send $event" } - screenTransitionState = when { - // Whenever we receive ON_RESUME event, we need to move screen to SHOWN state to indicate that there is no transition of this screen. - event == ON_RESUME -> ScreenTransitionState.SHOWN - // Whe need to check transition state to distinguish between - // 1. finishing screen hiding transition. In this case, we need to move screen to hidden state. - // 2. hiding screen, because of lifecycle event. In this case we don't need to change animation state. - event == ON_STOP && screenTransitionState == ScreenTransitionState.HIDING -> ScreenTransitionState.HIDDEN - // Pause by itself doesn't mean that screen is hidden, it can be visible, but not active. F.e. when system dialog is shown. - else -> screenTransitionState + internal fun updateLifecycleIfNeed(event: Lifecycle.Event) { + val parentState = atomicParentLifecycleOwner.get()?.lifecycle?.currentState + if ( + // ignore any state updates if already destroyed, it cannot be moved up + lifecycle.currentState != DESTROYED && + !stateAlreadyReached(lifecycle.currentState, event) && + // if ON_RESUME, then should be ready for it + (event != ON_RESUME || screenTransitionState.readyToBeResumed) && + parentStateAllowMove(parentState, event) + ) { + assert( + abs(lifecycle.currentState.ordinal - event.targetState.ordinal) == 1 || + lifecycle.currentState == CREATED && event == ON_DESTROY + ) { + "Lifecycle state transition must be one step, but was ${lifecycle.currentState} -> $event" } + screen.devLogD(TAG) { "safeHandleLifecycleEvent send $event" } lifecycle.handleLifecycleEvent(event) } } - /** - * Enum that represents - */ - private enum class ScreenTransitionState { - HIDING, - HIDDEN, - SHOWN - // There is no SHOWING state, because we cannot distinguish it by using lifecycle events and - // [hideTransitionStarted] and [showTransitionFinished] methods. - } + private data class ScreenTransitionState( + @Volatile + var readyToBeResumed: Boolean + ) companion object { @@ -380,44 +386,86 @@ class ModoScreenAndroidAdapter private constructor( name = LifecycleDependency.KEY, ) + /** + * Determines whether a lifecycle event from the parent should be propagated to the screen. + * + * Rules for propagation: + * - **ON_DESTROY**: Only propagate when the activity is truly finishing, not during: + * - Configuration changes (isChangingConfigurations = true) + * - System-initiated process death (isActivityFinishing = false) + * This prevents SavedStateHandle crashes when the screen will be restored. + * + * - **ON_START**: Always propagate - if we're in composition, we should be at least STARTED + * + * - **ON_RESUME**: Always propagate to parent subscription, but [updateLifecycleIfNeed] + * makes the final decision based on [ScreenTransitionState.readyToBeResumed] + * + * - **Downward events** (ON_PAUSE, ON_STOP): Always propagate to ensure child state + * never exceeds parent state + * + * @param event The lifecycle event from the parent + * @param isActivityFinishing True if the activity is finishing (user navigation back, finish() called) + * @param isChangingConfigurations True if the activity is being recreated due to config change + * @return true if the event should be propagated to the screen's lifecycle + */ @JvmStatic private fun needPropagateLifecycleEventFromParent( event: Lifecycle.Event, - screenTransitionState: ScreenTransitionState, isActivityFinishing: Boolean?, isChangingConfigurations: Boolean? ) = - /* - * Instance of the screen isn't recreated during config changes so skip this event - * to avoid crash while accessing to ViewModel with SavedStateHandle, because after - * ON_DESTROY, [androidx.lifecycle.SavedStateHandleController] is marked as not - * attached and next call of [registerSavedStateProvider] after recreating Activity - * on the same instance causing the crash. - * - * Also, when activity is destroyed, but not finished, screen is not destroyed. - * - * In the case of Fragments, we unsubscribe before ON_DESTROY event, so there is no problem with this. - */ - when { - event == ON_DESTROY && (isActivityFinishing == false || isChangingConfigurations == true) -> { - false - } - screenTransitionState == ScreenTransitionState.SHOWN && event == ON_RESUME -> { - true - } - else -> { - // Except previous condition, parent can only move lifecycle state down. - // Because parent cant be already resumed, but child is not, because of running animation. - event in moveLifecycleStateDownEvents - } - } + // Propagate ON_DESTROY only when finishing happening. + (event != ON_DESTROY || (isActivityFinishing != false && isChangingConfigurations != true)) && + // We can propagate ON_START because we are in composition, meaning we should be at least started + (event == ON_START || + // propagate ON_RESUME, but the final decision is up to updateLifecycleIfNeed + event == ON_RESUME || + // Parent can always move down lifecycle to ensure children state is never greater than parent. + event in moveLifecycleStateDownEvents) + /** + * Checks if the target state of the given event has already been reached by the current state. + * + * This prevents redundant lifecycle events from being dispatched: + * - For upward transitions (ON_CREATE, ON_START, ON_RESUME): Skip if target state <= current state + * Example: Skip ON_START when currentState is RESUMED + * - For downward transitions (ON_PAUSE, ON_STOP, ON_DESTROY): Skip if target state >= current state + * Example: Skip ON_PAUSE when currentState is CREATED + * + * @param currentState The current lifecycle state + * @param event The lifecycle event to check + * @return true if the event's target state has already been reached and should be skipped + */ @JvmStatic - internal fun needSkipEvent(currentState: Lifecycle.State, event: Lifecycle.Event) = - !currentState.isAtLeast(Lifecycle.State.INITIALIZED) || - // Skipping events that moves lifecycle state up, but this state is already reached. - (event in moveLifecycleStateUpEvents && event.targetState <= currentState) || - // Skipping events that moves lifecycle state down, but this state is already reached. + internal fun stateAlreadyReached(currentState: Lifecycle.State, event: Lifecycle.Event) = + // Skipping events that move the lifecycle state up, but this state is already reached. + (event in moveLifecycleStateUpEvents && event.targetState <= currentState) || + // Skipping events that move the lifecycle state down, but this state is already reached. (event in moveLifecycleStateDownEvents && event.targetState >= currentState) + + /** + * Validates that the parent's lifecycle state permits the requested state transition. + * + * Ensures the fundamental rule: **child state <= parent state** + * + * Always allows: + * - Downward transitions (ON_PAUSE, ON_STOP): Parent can always downgrade children + * - Transitions to CREATED or below: Initial states before parent dependency matters + * + * For upward transitions beyond CREATED (ON_START, ON_RESUME): + * - Requires parent state >= target state + * - Example: Cannot move to RESUMED if parent is only STARTED + * + * @param parentState The current state of the parent lifecycle owner, or null if not in composition + * @param event The lifecycle event requesting a state change + * @return true if the parent's state allows this transition + */ + private fun parentStateAllowMove( + parentState: Lifecycle.State?, + event: Lifecycle.Event, + ) = + event in moveLifecycleStateDownEvents || + event.targetState <= CREATED || + parentState != null && parentState >= event.targetState } } \ No newline at end of file diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt index ad2ca1c9..9a722fe5 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt @@ -59,13 +59,12 @@ class ModoScreenAndroidAdapterParentPropagationTest { // region Parent STARTED (animation in progress), screen enter composition - // TODO: fix it? should be STARTED @Test - fun `Given parent STARTED and auto resume When enter composition without manual resume - Then screen RESUMED`() { + fun `Given parent STARTED and auto resume When enter composition without manual resume - Then screen STARTED`() { parent.lifecycleState = STARTED emulator.enterComposition() - assertEquals(RESUMED, emulator.lifecycleState) + assertEquals(STARTED, emulator.lifecycleState) } @Test @@ -230,4 +229,82 @@ class ModoScreenAndroidAdapterParentPropagationTest { parent.lifecycleState = DESTROYED assertEquals(CREATED, emulator.lifecycleState) } + + @Test + fun `Given parent STARTED When entering composition and finish transition - Then screen STARTED`() { + parent.lifecycleState = STARTED + emulator.enterComposition(manualResumePause = true) + assertEquals(STARTED, emulator.lifecycleState) + + emulator.showTransitionFinished() + assertEquals(STARTED, emulator.lifecycleState) + } + + // TODO: should it be like this? Maybe screen should be created to follow global logic of lifecycle hierarchy? Is it possible in real use? + @Test + fun `Given parent CREATED When entering composition and finish transition - Then screen STARTED`() { + parent.lifecycleState = STARTED + emulator.enterComposition(manualResumePause = true) + assertEquals(STARTED, emulator.lifecycleState) + + emulator.showTransitionFinished() + assertEquals(STARTED, emulator.lifecycleState) + } + + // region Parent ON_DESTROY propagation - Activity lifecycle scenarios + + @Test + fun `When activity finishes normally - Then screen propagates ON_DESTROY and moves to DESTROYED`() { + parent.lifecycleState = RESUMED + emulator.enterComposition( + isActivityFinishing = { true }, + isChangingConfigurations = { false } + ) + assertEquals(RESUMED, emulator.lifecycleState) + + parent.lifecycleState = DESTROYED + assertEquals(DESTROYED, emulator.lifecycleState) + } + + @Test + fun `When activity killed by system but not finishing - Then screen skips ON_DESTROY and persists in CREATED`() { + parent.lifecycleState = RESUMED + emulator.enterComposition( + isActivityFinishing = { false }, + isChangingConfigurations = { false } + ) + assertEquals(RESUMED, emulator.lifecycleState) + + parent.lifecycleState = DESTROYED + assertEquals(CREATED, emulator.lifecycleState) + } + + @Test + fun `When activity recreating due to config change - Then screen skips ON_DESTROY to avoid SavedStateHandle crash`() { + parent.lifecycleState = RESUMED + emulator.enterComposition( + isActivityFinishing = { false }, + isChangingConfigurations = { true } + ) + assertEquals(RESUMED, emulator.lifecycleState) + + parent.lifecycleState = DESTROYED + assertEquals(CREATED, emulator.lifecycleState) + } + + @Test + fun `When activity killed by system from STARTED state - Then screen skips ON_DESTROY and stays in CREATED`() { + parent.lifecycleState = STARTED + emulator.enterComposition( + isActivityFinishing = { false }, + isChangingConfigurations = { false } + ) + assertEquals(STARTED, emulator.lifecycleState) + + parent.lifecycleState = DESTROYED + assertEquals(CREATED, emulator.lifecycleState) + } + + // endregion + } diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterNeedSkipEventTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt similarity index 82% rename from modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterNeedSkipEventTest.kt rename to modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt index b828a976..81f448b8 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterNeedSkipEventTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt @@ -8,17 +8,16 @@ import androidx.lifecycle.Lifecycle.Event.ON_RESUME import androidx.lifecycle.Lifecycle.Event.ON_START import androidx.lifecycle.Lifecycle.Event.ON_STOP import androidx.lifecycle.Lifecycle.State.CREATED -import androidx.lifecycle.Lifecycle.State.DESTROYED import androidx.lifecycle.Lifecycle.State.INITIALIZED import androidx.lifecycle.Lifecycle.State.RESUMED import androidx.lifecycle.Lifecycle.State.STARTED -import com.github.terrakok.modo.android.ModoScreenAndroidAdapter.Companion.needSkipEvent +import com.github.terrakok.modo.android.ModoScreenAndroidAdapter.Companion.stateAlreadyReached import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import kotlin.test.assertEquals -class ModoScreenAndroidAdapterNeedSkipEventTest { +class ModoScreenAndroidAdapterStateAlreadyReachedTest { @ParameterizedTest(name = "State={0}, Event={1} -> shouldSkip={2}") @MethodSource("testCases") @@ -27,7 +26,7 @@ class ModoScreenAndroidAdapterNeedSkipEventTest { event: Lifecycle.Event, shouldSkip: Boolean ) { - assertEquals(shouldSkip, needSkipEvent(state, event)) + assertEquals(shouldSkip, stateAlreadyReached(state, event)) } companion object { @@ -57,12 +56,6 @@ class ModoScreenAndroidAdapterNeedSkipEventTest { Arguments.of(RESUMED, ON_PAUSE, false), Arguments.of(RESUMED, ON_STOP, false), Arguments.of(RESUMED, ON_DESTROY, false), - Arguments.of(DESTROYED, ON_CREATE, true), - Arguments.of(DESTROYED, ON_START, true), - Arguments.of(DESTROYED, ON_RESUME, true), - Arguments.of(DESTROYED, ON_PAUSE, true), - Arguments.of(DESTROYED, ON_STOP, true), - Arguments.of(DESTROYED, ON_DESTROY, true), ) } } diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt index 02f82a65..3621f007 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt @@ -60,7 +60,11 @@ class CompositionLifecycleEmulator( adapter.atomicParentLifecycleOwner.set(parentLifecycleOwner) } - fun enterComposition(manualResumePause: Boolean = false) { + fun enterComposition( + manualResumePause: Boolean = false, + isActivityFinishing: () -> Boolean? = { false }, + isChangingConfigurations: () -> Boolean? = { false } + ) { check(!isInComposition) { "Already in composition" } isInComposition = true @@ -71,8 +75,8 @@ class CompositionLifecycleEmulator( unsubscribeFromParent = adapter.subscribeToParentLifecycle( parentLifecycleOwner = parentLifecycleOwner, savedState = savedState, - isActivityFinishing = { false }, - isChangingConfigurations = { false } + isActivityFinishing = isActivityFinishing, + isChangingConfigurations = isChangingConfigurations ) } From 95fa0bc720a9a4e51961c1e5d849ffe5a63612b7 Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Tue, 10 Feb 2026 12:13:02 +0700 Subject: [PATCH 05/17] Moved logic related to lifecycle from ModoScreenAndroidAdapter into ScreenLifecycleManager --- .../modo/android/ModoScreenAndroidAdapter.kt | 229 ++---------------- .../modo/android/ScreenLifecycleManager.kt | 182 ++++++++++++++ ...enAndroidAdapterStateAlreadyReachedTest.kt | 3 +- .../ModoScreenAndroidAdapterTestUtils.kt | 6 +- 4 files changed, 203 insertions(+), 217 deletions(-) create mode 100644 modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt index 9812d1de..4399bd32 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt @@ -16,18 +16,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.HasDefaultViewModelProviderFactory import androidx.lifecycle.Lifecycle -import androidx.lifecycle.Lifecycle.Event.ON_CREATE -import androidx.lifecycle.Lifecycle.Event.ON_DESTROY -import androidx.lifecycle.Lifecycle.Event.ON_PAUSE -import androidx.lifecycle.Lifecycle.Event.ON_RESUME -import androidx.lifecycle.Lifecycle.Event.ON_START import androidx.lifecycle.Lifecycle.Event.ON_STOP -import androidx.lifecycle.Lifecycle.State.CREATED -import androidx.lifecycle.Lifecycle.State.DESTROYED -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY import androidx.lifecycle.SavedStateViewModelFactory import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY @@ -46,7 +36,6 @@ import androidx.savedstate.compose.LocalSavedStateRegistryOwner import com.github.terrakok.modo.ModoDevOptions import com.github.terrakok.modo.Screen import com.github.terrakok.modo.SetupPreDispose -import com.github.terrakok.modo.android.ModoScreenAndroidAdapter.Companion.needPropagateLifecycleEventFromParent import com.github.terrakok.modo.lifecycle.LifecycleDependency import com.github.terrakok.modo.logs.devLogD import com.github.terrakok.modo.logs.devLogI @@ -56,7 +45,6 @@ import com.github.terrakok.modo.model.ScreenModelStore.remove import com.github.terrakok.modo.util.getActivity import com.github.terrakok.modo.util.getApplication import java.util.concurrent.atomic.AtomicReference -import kotlin.math.abs /** * Adapter for Screen that provides android-related features support using Modo, such as: @@ -76,7 +64,10 @@ class ModoScreenAndroidAdapter private constructor( HasDefaultViewModelProviderFactory, LifecycleDependency { - override val lifecycle: LifecycleRegistry = LifecycleRegistry(this) + @VisibleForTesting + internal val lifecycleManager = ScreenLifecycleManager(this) + + override val lifecycle get() = lifecycleManager.lifecycle override val viewModelStore: ViewModelStore = ViewModelStore() @@ -106,17 +97,8 @@ class ModoScreenAndroidAdapter private constructor( // Atomic references for cases when we unable take it directly from the composition. private val atomicContext = AtomicReference() - - @VisibleForTesting - internal val atomicParentLifecycleOwner = AtomicReference() private val application: Application? get() = atomicContext.get()?.applicationContext?.getApplication() - /** - * Holding transition state of the screen to be able to handle lifecycle events from parent properly. - * Check out [needPropagateLifecycleEventFromParent] for more details. - */ - private val screenTransitionState: ScreenTransitionState = ScreenTransitionState(readyToBeResumed = false) - init { controller.performAttach() enableSavedStateHandles() @@ -130,7 +112,7 @@ class ModoScreenAndroidAdapter private constructor( val context: Context = LocalContext.current val parentLifecycleOwner = LocalLifecycleOwner.current DisposableAtomicReference(LocalContext, atomicContext) - DisposableAtomicReference(LocalLifecycleOwner, atomicParentLifecycleOwner) + DisposableAtomicReference(LocalLifecycleOwner, lifecycleManager.parentLifecycleOwner) LifecycleDisposableEffect(context, parentLifecycleOwner, manualResumePause) { ProvideCompositionLocals(content) } @@ -143,19 +125,17 @@ class ModoScreenAndroidAdapter private constructor( */ override fun onPreDispose() { ModoDevOptions.onScreenPreDisposeListener?.invoke(screen) - updateLifecycleIfNeed(ON_DESTROY) + lifecycleManager.updateLifecycleIfNeeded(Lifecycle.Event.ON_DESTROY) } override fun hideTransitionStarted() { screen.devLogD(TAG) { "hideTransitionStarted ${lifecycle.currentState}" } - screenTransitionState.readyToBeResumed = false - updateLifecycleIfNeed(ON_PAUSE) + lifecycleManager.hideTransitionStarted() } override fun showTransitionFinished() { screen.devLogD(TAG) { "showTransitionFinished ${lifecycle.currentState}" } - screenTransitionState.readyToBeResumed = true - updateLifecycleIfNeed(ON_RESUME) + lifecycleManager.showTransitionFinished() } override fun toString(): String = "${ModoScreenAndroidAdapter::class.simpleName}, screenKey: ${screen.screenKey}" @@ -169,7 +149,7 @@ class ModoScreenAndroidAdapter private constructor( check(!isCreated) { "onCreate already called" } isCreated = true controller.performRestore(savedState) - updateLifecycleIfNeed(ON_CREATE) + lifecycleManager.updateLifecycleIfNeeded(Lifecycle.Event.ON_CREATE) } private fun performSave(outState: Bundle) { @@ -210,25 +190,6 @@ class ModoScreenAndroidAdapter private constructor( } } - /** - * Returns a unregister callback - */ - private fun registerParentLifecycleListener( - lifecycleOwner: LifecycleOwner?, - observerFactory: () -> LifecycleObserver - ): () -> Unit { - if (lifecycleOwner != null) { - val parentLifecycleObserver = observerFactory() - val lifecycle = lifecycleOwner.lifecycle - lifecycle.addObserver(parentLifecycleObserver) - return { - lifecycle.removeObserver(parentLifecycleObserver) - } - } else { - return { } - } - } - @Composable private fun LifecycleDisposableEffect( context: Context, @@ -245,7 +206,7 @@ class ModoScreenAndroidAdapter private constructor( } DisposableEffect(this) { - handleLifecycleOnCompositionEnter(manualResumePause) + lifecycleManager.handleCompositionEnter(manualResumePause) onDispose { } } @@ -267,105 +228,31 @@ class ModoScreenAndroidAdapter private constructor( screen.devLogD(TAG) { "LifecycleDisposableEffect after content DisposableEffect.onDispose ${lifecycle.currentState}" } unregisterLifecycle() performSave(savedState) - handleLifecycleOnCompositionExit(manualResumePause) + lifecycleManager.handleCompositionExit(manualResumePause) } } } - @VisibleForTesting - internal fun handleLifecycleOnCompositionEnter(manualResumePause: Boolean) { - updateLifecycleIfNeed(ON_START) - if (!manualResumePause) { - screenTransitionState.readyToBeResumed = true - updateLifecycleIfNeed(ON_RESUME) - } - } - - @VisibleForTesting - internal fun handleLifecycleOnCompositionExit(manualResumePause: Boolean) { - if (!manualResumePause) { - updateLifecycleIfNeed(ON_PAUSE) - } - updateLifecycleIfNeed(ON_STOP) - } - @VisibleForTesting internal fun subscribeToParentLifecycle( parentLifecycleOwner: LifecycleOwner, savedState: Bundle? = null, isActivityFinishing: () -> Boolean? = { null }, isChangingConfigurations: () -> Boolean? = { null } - ): () -> Unit = registerParentLifecycleListener(parentLifecycleOwner) { - // If we still subscribed to parent lifecycle, then we in composition and content is visible - LifecycleEventObserver { _, event -> + ): () -> Unit = lifecycleManager.subscribeToParentLifecycle( + parentLifecycleOwner = parentLifecycleOwner, + isActivityFinishing = isActivityFinishing, + isChangingConfigurations = isChangingConfigurations, + onEventBeforePropagation = { event -> + // Handle SavedState side-effect (adapter's responsibility) if (event == ON_STOP && savedState != null) { performSave(savedState) } - if ( - needPropagateLifecycleEventFromParent( - event, - isActivityFinishing = isActivityFinishing(), - isChangingConfigurations = isChangingConfigurations() - ) - ) { - updateLifecycleIfNeed(event) - } - } - } - - /** - * Attempts to update the screen's lifecycle state with the given event if all conditions are met. - * - * This method enforces several rules to ensure lifecycle integrity: - * 1. **No resurrection**: Once DESTROYED, the lifecycle cannot move to any other state - * 2. **No redundant events**: Events that would lead to an already-reached state are skipped - * 3. **Transition readiness**: ON_RESUME requires [ScreenTransitionState.readyToBeResumed] to be true - * 4. **Parent constraints**: Child state cannot exceed parent state (enforced by [parentStateAllowMove]) - * 5. **Single-step transitions**: State changes must be sequential (except CREATED -> DESTROYED) - * - * @param event The lifecycle event to potentially dispatch - */ - @VisibleForTesting - internal fun updateLifecycleIfNeed(event: Lifecycle.Event) { - val parentState = atomicParentLifecycleOwner.get()?.lifecycle?.currentState - if ( - // ignore any state updates if already destroyed, it cannot be moved up - lifecycle.currentState != DESTROYED && - !stateAlreadyReached(lifecycle.currentState, event) && - // if ON_RESUME, then should be ready for it - (event != ON_RESUME || screenTransitionState.readyToBeResumed) && - parentStateAllowMove(parentState, event) - ) { - assert( - abs(lifecycle.currentState.ordinal - event.targetState.ordinal) == 1 || - lifecycle.currentState == CREATED && event == ON_DESTROY - ) { - "Lifecycle state transition must be one step, but was ${lifecycle.currentState} -> $event" - } - screen.devLogD(TAG) { "safeHandleLifecycleEvent send $event" } - lifecycle.handleLifecycleEvent(event) } - } - - private data class ScreenTransitionState( - @Volatile - var readyToBeResumed: Boolean ) companion object { - private val moveLifecycleStateUpEvents = setOf( - ON_CREATE, - ON_START, - ON_RESUME - ) - - private val moveLifecycleStateDownEvents = setOf( - ON_STOP, - ON_PAUSE, - ON_DESTROY - ) - private val TAG = ModoScreenAndroidAdapter::class.simpleName /** @@ -385,87 +272,5 @@ class ModoScreenAndroidAdapter private constructor( screen = screen, name = LifecycleDependency.KEY, ) - - /** - * Determines whether a lifecycle event from the parent should be propagated to the screen. - * - * Rules for propagation: - * - **ON_DESTROY**: Only propagate when the activity is truly finishing, not during: - * - Configuration changes (isChangingConfigurations = true) - * - System-initiated process death (isActivityFinishing = false) - * This prevents SavedStateHandle crashes when the screen will be restored. - * - * - **ON_START**: Always propagate - if we're in composition, we should be at least STARTED - * - * - **ON_RESUME**: Always propagate to parent subscription, but [updateLifecycleIfNeed] - * makes the final decision based on [ScreenTransitionState.readyToBeResumed] - * - * - **Downward events** (ON_PAUSE, ON_STOP): Always propagate to ensure child state - * never exceeds parent state - * - * @param event The lifecycle event from the parent - * @param isActivityFinishing True if the activity is finishing (user navigation back, finish() called) - * @param isChangingConfigurations True if the activity is being recreated due to config change - * @return true if the event should be propagated to the screen's lifecycle - */ - @JvmStatic - private fun needPropagateLifecycleEventFromParent( - event: Lifecycle.Event, - isActivityFinishing: Boolean?, - isChangingConfigurations: Boolean? - ) = - // Propagate ON_DESTROY only when finishing happening. - (event != ON_DESTROY || (isActivityFinishing != false && isChangingConfigurations != true)) && - // We can propagate ON_START because we are in composition, meaning we should be at least started - (event == ON_START || - // propagate ON_RESUME, but the final decision is up to updateLifecycleIfNeed - event == ON_RESUME || - // Parent can always move down lifecycle to ensure children state is never greater than parent. - event in moveLifecycleStateDownEvents) - - /** - * Checks if the target state of the given event has already been reached by the current state. - * - * This prevents redundant lifecycle events from being dispatched: - * - For upward transitions (ON_CREATE, ON_START, ON_RESUME): Skip if target state <= current state - * Example: Skip ON_START when currentState is RESUMED - * - For downward transitions (ON_PAUSE, ON_STOP, ON_DESTROY): Skip if target state >= current state - * Example: Skip ON_PAUSE when currentState is CREATED - * - * @param currentState The current lifecycle state - * @param event The lifecycle event to check - * @return true if the event's target state has already been reached and should be skipped - */ - @JvmStatic - internal fun stateAlreadyReached(currentState: Lifecycle.State, event: Lifecycle.Event) = - // Skipping events that move the lifecycle state up, but this state is already reached. - (event in moveLifecycleStateUpEvents && event.targetState <= currentState) || - // Skipping events that move the lifecycle state down, but this state is already reached. - (event in moveLifecycleStateDownEvents && event.targetState >= currentState) - - /** - * Validates that the parent's lifecycle state permits the requested state transition. - * - * Ensures the fundamental rule: **child state <= parent state** - * - * Always allows: - * - Downward transitions (ON_PAUSE, ON_STOP): Parent can always downgrade children - * - Transitions to CREATED or below: Initial states before parent dependency matters - * - * For upward transitions beyond CREATED (ON_START, ON_RESUME): - * - Requires parent state >= target state - * - Example: Cannot move to RESUMED if parent is only STARTED - * - * @param parentState The current state of the parent lifecycle owner, or null if not in composition - * @param event The lifecycle event requesting a state change - * @return true if the parent's state allows this transition - */ - private fun parentStateAllowMove( - parentState: Lifecycle.State?, - event: Lifecycle.Event, - ) = - event in moveLifecycleStateDownEvents || - event.targetState <= CREATED || - parentState != null && parentState >= event.targetState } } \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt b/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt new file mode 100644 index 00000000..9d034d3c --- /dev/null +++ b/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt @@ -0,0 +1,182 @@ +package com.github.terrakok.modo.android + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.Event.ON_CREATE +import androidx.lifecycle.Lifecycle.Event.ON_DESTROY +import androidx.lifecycle.Lifecycle.Event.ON_PAUSE +import androidx.lifecycle.Lifecycle.Event.ON_RESUME +import androidx.lifecycle.Lifecycle.Event.ON_START +import androidx.lifecycle.Lifecycle.Event.ON_STOP +import androidx.lifecycle.Lifecycle.State.CREATED +import androidx.lifecycle.Lifecycle.State.DESTROYED +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.Lifecycle.State.STARTED +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.abs + +/** + * Manages screen lifecycle with coordination between parent lifecycle, composition state, and screen transitions. + * + * ## Core Contracts + * + * **1. Parent-Child Coordination** + * - Child state never exceeds parent state (e.g., screen can't be [RESUMED] if parent is [STARTED]) + * - **Event propagation from parent:** + * - [ON_CREATE]: Never propagated (subscription happens after screen creation) + * - [ON_START]: Always propagated (parent going up brings child up) + * - [ON_RESUME]: Propagated but gated by [canResumeAfterTransition] (requires visible screen with completed transitions) + * - [ON_PAUSE]/[ON_STOP]: Always propagated (parent going down forces child down immediately) + * - [ON_DESTROY]: Conditionally propagated (blocked during config changes to preserve SavedStateRegistry) + * + * **2. Transition Readiness ([canResumeAfterTransition])** + * - [ON_RESUME] blocked until show transition completes via [showTransitionFinished] + * - Hide transition via [hideTransitionStarted] triggers immediate pause + * - Prevents screen from being [RESUMED] during hide animations + * + * **3. Sequential Progression** + * - Lifecycle events must be called in sequential order ([CREATED] -> [STARTED] -> [RESUMED]) + * - Single-step transitions enforced (except [CREATED] -> [DESTROYED]) + * - Redundant events skipped automatically (e.g., [ON_START] when already [RESUMED]) + * + * **4. No Resurrection** + * - Once [DESTROYED], no further lifecycle events accepted + * - Ensures proper cleanup and prevents use-after-destroy bugs + * + * ## Usage + * + * Typical lifecycle flow: + * 1. Screen created: `updateLifecycleIfNeeded(`[ON_CREATE]`)` + * 2. Enters composition: `handleCompositionEnter(manualResumePause)` + * 3. Subscribe to parent: `subscribeToParentLifecycle(...)` + * 4. Transition complete: `showTransitionFinished()` -> moves to [RESUMED] + * 5. Exits composition: `handleCompositionExit(manualResumePause)` + * 6. Screen destroyed: `updateLifecycleIfNeeded(`[ON_DESTROY]`)` + */ +internal class ScreenLifecycleManager( + lifecycleOwner: LifecycleOwner +) { + val lifecycle: LifecycleRegistry = LifecycleRegistry(lifecycleOwner) + + internal val parentLifecycleOwner = AtomicReference() + + /** + * `true` - when related screen is not animating and visible + * `false` - when related screen animating or idle but after hide transition + */ + @Volatile + private var canResumeAfterTransition: Boolean = false + + private val currentParentState: Lifecycle.State? + get() = parentLifecycleOwner.get()?.lifecycle?.currentState + + fun handleCompositionEnter(manualResumePause: Boolean) { + updateLifecycleIfNeeded(ON_START) + if (!manualResumePause) { + canResumeAfterTransition = true + updateLifecycleIfNeeded(ON_RESUME) + } + } + + fun handleCompositionExit(manualResumePause: Boolean) { + if (!manualResumePause) { + updateLifecycleIfNeeded(ON_PAUSE) + } + updateLifecycleIfNeeded(ON_STOP) + } + + fun showTransitionFinished() { + canResumeAfterTransition = true + updateLifecycleIfNeeded(ON_RESUME) + } + + fun hideTransitionStarted() { + canResumeAfterTransition = false + updateLifecycleIfNeeded(ON_PAUSE) + } + + /** + * @param onEventBeforePropagation Callback for side-effects (e.g., SavedState persistence) before lifecycle propagation + * @return Unregister callback + */ + fun subscribeToParentLifecycle( + parentLifecycleOwner: LifecycleOwner, + isActivityFinishing: () -> Boolean? = { null }, + isChangingConfigurations: () -> Boolean? = { null }, + onEventBeforePropagation: ((Lifecycle.Event) -> Unit)? = null + ): () -> Unit { + val observer = LifecycleEventObserver { _, event -> + // Allow adapter to handle side-effects (like SavedState) + onEventBeforePropagation?.invoke(event) + + // Propagate lifecycle events using manager's logic + // [ON_DESTROY] blocked during config changes to preserve SavedStateRegistry. + if (event != ON_DESTROY || (isActivityFinishing() != false && isChangingConfigurations() != true)) { + updateLifecycleIfNeeded(event) + } + } + + parentLifecycleOwner.lifecycle.addObserver(observer) + return { + parentLifecycleOwner.lifecycle.removeObserver(observer) + } + } + + /** + * Enforces lifecycle rules: + * - No resurrection after [DESTROYED] + * - [ON_RESUME] blocked until [canResumeAfterTransition] + * - Child state never exceeds parent state + * - Single-step transitions only (except [CREATED] -> [DESTROYED]) + */ + fun updateLifecycleIfNeeded(event: Lifecycle.Event) { + @Suppress("ComplexCondition") + if ( + lifecycle.currentState != DESTROYED && + !stateAlreadyReached(lifecycle.currentState, event) && + (event != ON_RESUME || canResumeAfterTransition) && + parentStateAllowsTransition(currentParentState, event) + ) { + assert( + abs(lifecycle.currentState.ordinal - event.targetState.ordinal) == 1 || + lifecycle.currentState == CREATED && event == ON_DESTROY + ) { + "Lifecycle state transition must be one step, but was ${lifecycle.currentState} -> $event" + } + lifecycle.handleLifecycleEvent(event) + } + } + + companion object { + private val MOVE_LIFECYCLE_STATE_UP_EVENTS = setOf( + ON_CREATE, + ON_START, + ON_RESUME + ) + + private val MOVE_LIFECYCLE_STATE_DOWN_EVENTS = setOf( + ON_STOP, + ON_PAUSE, + ON_DESTROY + ) + + /** + * Skips redundant events (e.g., [ON_START] when already [RESUMED], or [ON_PAUSE] when already [CREATED]). + */ + internal fun stateAlreadyReached(currentState: Lifecycle.State, event: Lifecycle.Event): Boolean = + // Skipping events that move the lifecycle state up, but this state is already reached. + (event in MOVE_LIFECYCLE_STATE_UP_EVENTS && event.targetState <= currentState) || + // Skipping events that move the lifecycle state down, but this state is already reached. + (event in MOVE_LIFECYCLE_STATE_DOWN_EVENTS && event.targetState >= currentState) + + private fun parentStateAllowsTransition( + parentState: Lifecycle.State?, + event: Lifecycle.Event, + ): Boolean = + event in MOVE_LIFECYCLE_STATE_DOWN_EVENTS || + event.targetState <= CREATED || + parentState != null && parentState >= event.targetState + } +} diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt index 81f448b8..7f823a05 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt @@ -11,7 +11,6 @@ import androidx.lifecycle.Lifecycle.State.CREATED import androidx.lifecycle.Lifecycle.State.INITIALIZED import androidx.lifecycle.Lifecycle.State.RESUMED import androidx.lifecycle.Lifecycle.State.STARTED -import com.github.terrakok.modo.android.ModoScreenAndroidAdapter.Companion.stateAlreadyReached import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource @@ -26,7 +25,7 @@ class ModoScreenAndroidAdapterStateAlreadyReachedTest { event: Lifecycle.Event, shouldSkip: Boolean ) { - assertEquals(shouldSkip, stateAlreadyReached(state, event)) + assertEquals(shouldSkip, ScreenLifecycleManager.stateAlreadyReached(state, event)) } companion object { diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt index 3621f007..ea6064eb 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt @@ -57,7 +57,7 @@ class CompositionLifecycleEmulator( init { initializeAdapter() - adapter.atomicParentLifecycleOwner.set(parentLifecycleOwner) + adapter.lifecycleManager.parentLifecycleOwner.set(parentLifecycleOwner) } fun enterComposition( @@ -70,7 +70,7 @@ class CompositionLifecycleEmulator( this.manualResumePause = manualResumePause - adapter.handleLifecycleOnCompositionEnter(manualResumePause) + adapter.lifecycleManager.handleCompositionEnter(manualResumePause) unsubscribeFromParent = adapter.subscribeToParentLifecycle( parentLifecycleOwner = parentLifecycleOwner, @@ -85,7 +85,7 @@ class CompositionLifecycleEmulator( isInComposition = false unsubscribeFromParent?.invoke() - adapter.handleLifecycleOnCompositionExit(manualResumePause) + adapter.lifecycleManager.handleCompositionExit(manualResumePause) } fun showTransitionFinished() { From b0c0ecd18a61107e0d84839c67a6d290d59caf60 Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Tue, 10 Feb 2026 14:26:57 +0700 Subject: [PATCH 06/17] Fixed crush when several permanent dialogs forwarded with screen causing wrong lifecycle events order --- .../terrakok/modo/android/ModoScreenAndroidAdapter.kt | 2 +- .../github/terrakok/modo/android/ScreenLifecycleManager.kt | 7 +++---- .../modo/android/ModoScreenAndroidAdapterTestUtils.kt | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt index 4399bd32..c259319d 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt @@ -228,7 +228,7 @@ class ModoScreenAndroidAdapter private constructor( screen.devLogD(TAG) { "LifecycleDisposableEffect after content DisposableEffect.onDispose ${lifecycle.currentState}" } unregisterLifecycle() performSave(savedState) - lifecycleManager.handleCompositionExit(manualResumePause) + lifecycleManager.handleCompositionExit() } } } diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt b/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt index 9d034d3c..7a7dcfb6 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt @@ -80,10 +80,9 @@ internal class ScreenLifecycleManager( } } - fun handleCompositionExit(manualResumePause: Boolean) { - if (!manualResumePause) { - updateLifecycleIfNeeded(ON_PAUSE) - } + fun handleCompositionExit() { + // sending pause anyway, it will be ignored if it is already handled + updateLifecycleIfNeeded(ON_PAUSE) updateLifecycleIfNeeded(ON_STOP) } diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt index ea6064eb..caddbba6 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt @@ -85,7 +85,7 @@ class CompositionLifecycleEmulator( isInComposition = false unsubscribeFromParent?.invoke() - adapter.lifecycleManager.handleCompositionExit(manualResumePause) + adapter.lifecycleManager.handleCompositionExit() } fun showTransitionFinished() { From d32b7e21926fb66f80d78203518ef74048441555 Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Wed, 11 Feb 2026 22:58:43 +0700 Subject: [PATCH 07/17] Removed explicit parameter from SaveableContent and migrated it to an internal CompositionLocal mechanism --- .../com/github/terrakok/modo/ComposeRender.kt | 39 ++++++++------- .../modo/android/ModoScreenAndroidAdapter.kt | 8 ++-- .../modo/android/ScreenLifecycleManager.kt | 8 ++-- .../modo/animation/MultiScreenTransitions.kt | 2 +- .../modo/animation/ScreenTransitions.kt | 47 +++++++++---------- .../modo/animation/StackScreenTransitions.kt | 2 +- .../modo/sample/screens/MainScreen.kt | 1 + .../screens/containers/CustomStackSample.kt | 17 +++++-- 8 files changed, 69 insertions(+), 55 deletions(-) diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt index 13c92941..60089b68 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt @@ -13,10 +13,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier -import androidx.lifecycle.Lifecycle.Event.ON_PAUSE -import androidx.lifecycle.Lifecycle.Event.ON_RESUME -import androidx.lifecycle.Lifecycle.Event.ON_START -import androidx.lifecycle.Lifecycle.Event.ON_STOP import com.github.terrakok.modo.android.ModoScreenAndroidAdapter import com.github.terrakok.modo.android.overlaySaveableStateKey import com.github.terrakok.modo.animation.ScreenTransition @@ -47,6 +43,16 @@ private val LocalPreDispose = staticCompositionLocalOf<() -> Unit> { private const val TAG = "ComposeRenderer" +/** + * Internal CompositionLocal that signals whether a screen is being rendered within a transition context. + * - `true`: Screen lifecycle should be manually controlled by transition (paused/resumed based on animation state) + * - `false`: Screen lifecycle auto-resumes immediately (no animation control needed) + * + * This is automatically provided by [ScreenTransition] and consumed by [SaveableContent]. + * Each [SaveableContent] call resets this to `false` for its children to prevent false positives. + */ +internal val LocalInTransitionContext = staticCompositionLocalOf { false } + internal inline val Screen.saveableStateKey: String get() = screenKey.value /** @@ -55,24 +61,23 @@ internal inline val Screen.saveableStateKey: String get() = screenKey.value * 2. Adds support of Android-related features, such as ViewModel, LifeCycle and SavedStateHandle. * 3. Handles lifecycle of [Screen] by adding [DisposableEffect] before and after content, in order to notify [ComposeRenderer] * when [Screen.Content] is about to leave composition and when it has left composition. - * @param modifier is a modifier that will be passed into [Screen.Content] - * @param manualResumePause define whenever we are going to manually call [LifecycleDependency.showTransitionFinished] and [LifecycleDependency.hideTransitionStarted] - * to emmit [ON_RESUME] and [ON_PAUSE]. Otherwise, [ON_RESUME] will be called straight after [ON_START] and [ON_PAUSE] will be called straight - * before [ON_STOP]. * - * F.e. it is used by [ScreenTransition]: - * + [ON_RESUME] emitted when animation of showing screen is finished - * + [ON_PAUSE] emitted when animation of hiding screen is started + * @param modifier is a modifier that will be passed into [Screen.Content] */ @Composable fun Screen.SaveableContent( - modifier: Modifier = Modifier, - manualResumePause: Boolean = false + modifier: Modifier = Modifier ) { - LocalSaveableStateHolder.currentOrThrow.SaveableStateProvider(key = saveableStateKey) { - SetupScreenCleanup() - ModoScreenAndroidAdapter.get(this).ProvideAndroidIntegration(manualResumePause) { - Content(modifier) + // Read transition context from parent before resetting for children + val usesTransitionLifecycle = LocalInTransitionContext.current + + // Reset for children to prevent propagation beyond this screen + CompositionLocalProvider(LocalInTransitionContext provides false) { + LocalSaveableStateHolder.currentOrThrow.SaveableStateProvider(key = saveableStateKey) { + SetupScreenCleanup() + ModoScreenAndroidAdapter.get(this).ProvideAndroidIntegration(usesTransitionLifecycle) { + Content(modifier) + } } } } diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt index c259319d..b34bb33c 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt @@ -106,14 +106,14 @@ class ModoScreenAndroidAdapter private constructor( @Composable fun ProvideAndroidIntegration( - manualResumePause: Boolean = false, + usesTransitionLifecycle: Boolean = false, content: @Composable () -> Unit, ) { val context: Context = LocalContext.current val parentLifecycleOwner = LocalLifecycleOwner.current DisposableAtomicReference(LocalContext, atomicContext) DisposableAtomicReference(LocalLifecycleOwner, lifecycleManager.parentLifecycleOwner) - LifecycleDisposableEffect(context, parentLifecycleOwner, manualResumePause) { + LifecycleDisposableEffect(context, parentLifecycleOwner, usesTransitionLifecycle) { ProvideCompositionLocals(content) } } @@ -194,7 +194,7 @@ class ModoScreenAndroidAdapter private constructor( private fun LifecycleDisposableEffect( context: Context, parentLifecycleOwner: LifecycleOwner, - manualResumePause: Boolean, + usesTransitionLifecycle: Boolean, content: @Composable () -> Unit ) { val activity = remember(context) { @@ -206,7 +206,7 @@ class ModoScreenAndroidAdapter private constructor( } DisposableEffect(this) { - lifecycleManager.handleCompositionEnter(manualResumePause) + lifecycleManager.handleCompositionEnter(usesTransitionLifecycle) onDispose { } } diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt b/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt index 7a7dcfb6..e964c430 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt @@ -49,10 +49,10 @@ import kotlin.math.abs * * Typical lifecycle flow: * 1. Screen created: `updateLifecycleIfNeeded(`[ON_CREATE]`)` - * 2. Enters composition: `handleCompositionEnter(manualResumePause)` + * 2. Enters composition: `handleCompositionEnter(usesTransitionLifecycle)` * 3. Subscribe to parent: `subscribeToParentLifecycle(...)` * 4. Transition complete: `showTransitionFinished()` -> moves to [RESUMED] - * 5. Exits composition: `handleCompositionExit(manualResumePause)` + * 5. Exits composition: `handleCompositionExit()` * 6. Screen destroyed: `updateLifecycleIfNeeded(`[ON_DESTROY]`)` */ internal class ScreenLifecycleManager( @@ -72,9 +72,9 @@ internal class ScreenLifecycleManager( private val currentParentState: Lifecycle.State? get() = parentLifecycleOwner.get()?.lifecycle?.currentState - fun handleCompositionEnter(manualResumePause: Boolean) { + fun handleCompositionEnter(usesTransitionLifecycle: Boolean) { updateLifecycleIfNeeded(ON_START) - if (!manualResumePause) { + if (!usesTransitionLifecycle) { canResumeAfterTransition = true updateLifecycleIfNeeded(ON_RESUME) } diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/animation/MultiScreenTransitions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/animation/MultiScreenTransitions.kt index 6ce9fa7d..abe45d9f 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/animation/MultiScreenTransitions.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/animation/MultiScreenTransitions.kt @@ -22,7 +22,7 @@ fun ComposeRendererScope.SlideTransition( screenModifier: Modifier = Modifier, slideAnimationSpec: FiniteAnimationSpec = tween(durationMillis = 700), fadeAnimationSpec: FiniteAnimationSpec = tween(durationMillis = 700), - content: ScreenTransitionContent = { it.SaveableContent(screenModifier, manualResumePause = true) } + content: ScreenTransitionContent = { it.SaveableContent(screenModifier) } ) { ScreenTransition( modifier = modifier, diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/animation/ScreenTransitions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/animation/ScreenTransitions.kt index a362d405..def4d4f9 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/animation/ScreenTransitions.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/animation/ScreenTransitions.kt @@ -11,10 +11,12 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableStateMapOf import androidx.compose.ui.Modifier import com.github.terrakok.modo.ComposeRendererScope +import com.github.terrakok.modo.LocalInTransitionContext import com.github.terrakok.modo.SaveableContent import com.github.terrakok.modo.Screen import com.github.terrakok.modo.model.lifecycleDependency @@ -34,9 +36,8 @@ typealias ScreenTransitionContent = @Composable AnimatedVisibilityScope.(Screen) * @param modifier - the modifier for the [AnimatedContent]. * @param screenModifier - the modifier for the [Screen.Content]. * @param transitionSpec - the transition spec for the [AnimatedContent]. - * @param content - the content that is going to be placed inside [AnimatedContent]. - * You can use it to decorate or customize the content, - * but you must apparently use [SaveableContent] with [manualResumePause] = true to guarantee that the lifecycle will be paused and resumed correctly. + * @param content - the content that is going to be placed inside [AnimatedContent]. You can use it to decorate or customize the content. + * Screens rendered by this transition will automatically have their lifecycle controlled by the animation. */ @Suppress("MagicNumber") @Composable @@ -48,31 +49,27 @@ fun ComposeRendererScope<*>.ScreenTransition( scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) togetherWith fadeOut(animationSpec = tween(90)) }, - content: ScreenTransitionContent = { it.SaveableContent(screenModifier, manualResumePause = true) } + content: ScreenTransitionContent = { it.SaveableContent(screenModifier) } ) { val transition = updateTransition(targetState = screen, label = "ScreenTransition") - transition.AnimatedContent( - transitionSpec = transitionSpec, - modifier = modifier, - contentKey = { it.screenKey }, - ) { screen -> - content(screen) - DisposableEffect(transition.currentState, transition.targetState) { -// Log.d( -// "LifecycleDebug", -// "target = ${screen.screenKey}, " + -// "transition.currentState = ${transition.currentState.screenKey}," + -// "transition.targetState = ${transition.targetState.screenKey}" -// ) - if (screen == transition.currentState && screen != transition.targetState) { - // Start of animation that hides this screen, so we should pause lifecycle - screen.lifecycleDependency()?.hideTransitionStarted() + CompositionLocalProvider(LocalInTransitionContext provides true) { + transition.AnimatedContent( + transitionSpec = transitionSpec, + modifier = modifier, + contentKey = { it.screenKey }, + ) { screen -> + content(screen) + DisposableEffect(transition.currentState, transition.targetState) { + if (screen == transition.currentState && screen != transition.targetState) { + // Start of animation that hides this screen, so we should pause lifecycle + screen.lifecycleDependency()?.hideTransitionStarted() + } + if (transition.currentState == transition.targetState && screen == transition.currentState) { + // Finish of animation that shows this screen, so we should resume lifecycle + screen.lifecycleDependency()?.showTransitionFinished() + } + onDispose { } } - if (transition.currentState == transition.targetState && screen == transition.currentState) { - // Finish of animation that shows this screen, so we should resume lifecycle - screen.lifecycleDependency()?.showTransitionFinished() - } - onDispose { } } } } \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/animation/StackScreenTransitions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/animation/StackScreenTransitions.kt index 90b095ed..88cbcf1e 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/animation/StackScreenTransitions.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/animation/StackScreenTransitions.kt @@ -30,7 +30,7 @@ fun ComposeRendererScope.SlideTransition( popDirection: AnimatedContentTransitionScope.SlideDirection = pushDirection.opposite(), slideAnimationSpec: FiniteAnimationSpec = tween(durationMillis = 700), fadeAnimationSpec: FiniteAnimationSpec = tween(durationMillis = 700), - content: ScreenTransitionContent = { it.SaveableContent(screenModifier, manualResumePause = true) } + content: ScreenTransitionContent = { it.SaveableContent(screenModifier) } ) { ScreenTransition( modifier = modifier, diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt index daf68e53..4080f85f 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt @@ -141,6 +141,7 @@ private fun rememberButtons( ModoButtonSpec("Stack actions") { navigation?.forward(StackActionsScreen(i + 1)) }, ModoButtonSpec("HorizontalPager") { navigation?.forward(HorizontalPagerScreen()) }, ModoButtonSpec("Custom Stack") { navigation?.forward(CustomStackSample(i + 1)) }, + ModoButtonSpec("No animation Stack") { navigation?.forward(CustomStackSample(i + 1, hasAnimation = false)) }, ModoButtonSpec("Stacks in LazyColumn") { navigation?.forward(StackInLazyColumnScreen()) }, ModoButtonSpec("Dialogs & BottomSheets") { navigation?.forward(DialogsPlayground(i + 1)) }, ModoButtonSpec("Multiscreen") { navigation?.forward(SampleMultiScreen()) }, diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/CustomStackSample.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/CustomStackSample.kt index ec89a4fe..677ae6a6 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/CustomStackSample.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/CustomStackSample.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.github.terrakok.modo.ExperimentalModoApi import com.github.terrakok.modo.NavModel +import com.github.terrakok.modo.SaveableContent import com.github.terrakok.modo.lifecycle.OnScreenRemoved import com.github.terrakok.modo.sample.SlideTransition import com.github.terrakok.modo.sample.components.CancelButton @@ -32,13 +33,19 @@ import logcat.logcat @Parcelize class CustomStackSample( private val i: Int, - private val navModel: StackNavModel + private val navModel: StackNavModel, + private val hasAnimation: Boolean = true, ) : SampleStack(navModel) { constructor( i: Int, + hasAnimation: Boolean = true, sampleNavigationState: StackState = StackState(MainScreen(1)) - ) : this(i, NavModel(sampleNavigationState)) + ) : this( + i = i, + hasAnimation = hasAnimation, + navModel = NavModel(sampleNavigationState) + ) @Suppress("ModifierNotUsedAtRoot") @OptIn(ExperimentalModoApi::class) @@ -60,7 +67,11 @@ class CustomStackSample( .fillMaxSize() .clip(RoundedCornerShape(16.dp)), ) { modifier -> - SlideTransition(modifier) + if (hasAnimation) { + SlideTransition(modifier) + } else { + screen.SaveableContent(modifier) + } } } } From 2bca6f657ae96c1bfe9960be6a7599faf13b029f Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Thu, 12 Feb 2026 10:53:02 +0700 Subject: [PATCH 08/17] Improved tests and documentation --- .../terrakok/modo/android/ScreenLifecycleManager.kt | 8 ++++---- ...doScreenAndroidAdapterStateAlreadyReachedTest.kt | 13 ++++++++++++- .../sample/screens/base/ButtonsScreenContent.kt | 7 +------ 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt b/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt index e964c430..fc21a7d5 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt @@ -22,7 +22,7 @@ import kotlin.math.abs * * ## Core Contracts * - * **1. Parent-Child Coordination** + * **Parent-Child Coordination** * - Child state never exceeds parent state (e.g., screen can't be [RESUMED] if parent is [STARTED]) * - **Event propagation from parent:** * - [ON_CREATE]: Never propagated (subscription happens after screen creation) @@ -31,17 +31,17 @@ import kotlin.math.abs * - [ON_PAUSE]/[ON_STOP]: Always propagated (parent going down forces child down immediately) * - [ON_DESTROY]: Conditionally propagated (blocked during config changes to preserve SavedStateRegistry) * - * **2. Transition Readiness ([canResumeAfterTransition])** + * **Transition Readiness ([canResumeAfterTransition])** * - [ON_RESUME] blocked until show transition completes via [showTransitionFinished] * - Hide transition via [hideTransitionStarted] triggers immediate pause * - Prevents screen from being [RESUMED] during hide animations * - * **3. Sequential Progression** + * **Sequential Progression** * - Lifecycle events must be called in sequential order ([CREATED] -> [STARTED] -> [RESUMED]) * - Single-step transitions enforced (except [CREATED] -> [DESTROYED]) * - Redundant events skipped automatically (e.g., [ON_START] when already [RESUMED]) * - * **4. No Resurrection** + * **No Resurrection** * - Once [DESTROYED], no further lifecycle events accepted * - Ensures proper cleanup and prevents use-after-destroy bugs * diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt index 7f823a05..1de2846e 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.Lifecycle.Event.ON_RESUME import androidx.lifecycle.Lifecycle.Event.ON_START import androidx.lifecycle.Lifecycle.Event.ON_STOP import androidx.lifecycle.Lifecycle.State.CREATED +import androidx.lifecycle.Lifecycle.State.DESTROYED import androidx.lifecycle.Lifecycle.State.INITIALIZED import androidx.lifecycle.Lifecycle.State.RESUMED import androidx.lifecycle.Lifecycle.State.STARTED @@ -25,7 +26,11 @@ class ModoScreenAndroidAdapterStateAlreadyReachedTest { event: Lifecycle.Event, shouldSkip: Boolean ) { - assertEquals(shouldSkip, ScreenLifecycleManager.stateAlreadyReached(state, event)) + assertEquals( + shouldSkip, + ScreenLifecycleManager.stateAlreadyReached(state, event), + "State=$state, Event=$event" + ) } companion object { @@ -55,6 +60,12 @@ class ModoScreenAndroidAdapterStateAlreadyReachedTest { Arguments.of(RESUMED, ON_PAUSE, false), Arguments.of(RESUMED, ON_STOP, false), Arguments.of(RESUMED, ON_DESTROY, false), + Arguments.of(DESTROYED, ON_CREATE, false), + Arguments.of(DESTROYED, ON_START, false), + Arguments.of(DESTROYED, ON_RESUME, false), + Arguments.of(DESTROYED, ON_PAUSE, true), + Arguments.of(DESTROYED, ON_STOP, true), + Arguments.of(DESTROYED, ON_DESTROY, true), ) } } diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt index f0251226..c13a7a92 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt @@ -56,7 +56,6 @@ import com.github.terrakok.modo.stack.LocalStackNavigation import com.github.terrakok.modo.stack.back import kotlinx.coroutines.delay import kotlinx.coroutines.isActive -import logcat.logcat internal const val COUNTER_DELAY_MS = 100L @@ -195,11 +194,7 @@ internal fun SampleScreenContent( Lifecycle.State.STARTED -> Color.Yellow Lifecycle.State.CREATED, Lifecycle.State.INITIALIZED, - Lifecycle.State.DESTROYED -> { - // TODO: fing out if it is fixible. For now we start with created state. - logcat("") {"Should not happen, state ${lifecycleState.name}" } - Color.Black - } + Lifecycle.State.DESTROYED -> Color.Black } } } From 33dd46064cad107558edde9e42de0f07dd006e7f Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Thu, 12 Feb 2026 13:48:05 +0700 Subject: [PATCH 09/17] Updated lifecycle documentation --- Writerside/topics/Lifecycle.md | 285 ++++++++++++++++++++++++++------- 1 file changed, 229 insertions(+), 56 deletions(-) diff --git a/Writerside/topics/Lifecycle.md b/Writerside/topics/Lifecycle.md index ba61bb7f..96a1676f 100644 --- a/Writerside/topics/Lifecycle.md +++ b/Writerside/topics/Lifecycle.md @@ -1,90 +1,263 @@ # Lifecycle + + This article covers the lifetime of screen instances and their integration with the Android Lifecycle. ## Screen Instance Lifecycle -The lifetime of a screen instance is guaranteed to match the application (process) lifetime when you integrate Modo using built-in functions such as -`Modo.rememberRootScreen`. Regardless of how many times a screen is recomposed, or whether the activity and/or fragment is recreated, the screen -instance remains consistent. This allows you to safely inject the screen instance into your DI container. +Screen instances in Modo have a long lifetime: -## Android Lifecycle Integration +- Screen instances live as long as your app process (not tied to Activity or Fragment lifecycle) +- The same instance survives recomposition and configuration changes (rotation, language change, etc.) +- Safe to inject into your DI container if its lifetime is shorter or equal to the screen -Modo provides seamless [integration](%github_code_url%/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt) -with a Android Lifecycle for your screens. +This is guaranteed when using `Modo.rememberRootScreen()` and similar built-in functions. -You can use `LocalLifecycleOwner` inside `Screen.Content` to access the lifecycle of a screen. This will return the nearest screen's lifecycle owner. +## Lifecycle Basics + +Modo provides seamless [integration](%github_code_url%/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt) +with Android Lifecycle. Each screen gets its own `LifecycleOwner` that can be retrieved by using `LocalLifecycleOwner`: ```kotlin -class SampleScreen : Screen { - override fun Content(modifier: Modifier) { - val lifecycleOwner = LocalLifecycleOwner.current - // Use lifecycleOwner to observe lifecycle events +// Access via LocalLifecycleOwner (standard Compose API) +val lifecycleOwner = LocalLifecycleOwner.current +val lifecycleState by lifecycleOwner.lifecycle.currentStateAsState() + +// Observe lifecycle events +DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + // Screen is redy for interactions + } + Lifecycle.Event.ON_PAUSE -> { + // Screen is hiding + } + else -> {} + } } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } ``` -### Lifecycle States and Events +{ collapsible="true" collapsed-title="How to access lifecycle in your screen" } + +Screen lifecycle is controlled by three main factors: + +- **Composition state**: Lifecycle responds to entering/leaving composition +- **Parent lifecycle**: Child state never exceeds parent state and can be updated by a parent (see + [Parent-Child Lifecycle Coordination](#parent-child-lifecycle-coordination)) +- **Transitions**: Screen can be resumed only after animations are complete (see [Transition Lifecycle Control](#transition-lifecycle-control)) + +Each screen progresses through the standard Android Lifecycle states: + + + + + + + + + + + + + + + + + + + + + + + + + + +
StateMeaning
INITIALIZEDThe screen is constructed (instance created) but has never been displayed.
CREATEDThe screen was displayed at least once.
STARTEDThe screen is in composition.
RESUMEDReady for user interaction. The screen is STARTED, visible, and all transitions are complete.
DESTROYEDThe screen is removed from the navigation graph, and all resources are cleaned up.
+ +Screens move through states sequentially. Each transition happens when something changes in the screen's lifecycle: + +```mermaid +sequenceDiagram + participant DESTROYED + participant INITIALIZED + participant CREATED + participant STARTED + participant RESUMED + + INITIALIZED->>CREATED: First composition + CREATED->>STARTED: Entering composition + STARTED->>RESUMED: Show animation completes
(immediate if no animation) + RESUMED->>STARTED: Hide transition starts + STARTED->>CREATED: Leaving composition + CREATED->>DESTROYED: Removed from navigation +``` + +> **Note**: Parent lifecycle can propagate events and limit child states. +> See [Parent-Child Lifecycle Coordination](#parent-child-lifecycle-coordination) for details. + +## Parent-Child Lifecycle Coordination + +The lifecycle of parent and child screens follows strict rules to ensure consistency: + +### Rules + +**Rule**: A child's lifecycle state never exceeds its parent's state. + +``` +Parent: RESUMED → Child can reach: RESUMED +Parent: STARTED → Child can reach: STARTED (blocked from RESUMED) +Parent: CREATED → Child can reach: CREATED (blocked from STARTED) +``` + +**Event Propagation from Parent**: + +> **Note**: When a screen enters composition, it subscribes to its parent's lifecycle. Events are propagated from parent to child while the +> subscription is active (screen is in composition). + +| **Parent Event** | **Propagation Behavior** | +|------------------|------------------------------------------------------------------------------------------------------------| +| `ON_CREATE` | Never propagated (child subscribes after its own creation) | +| `ON_START` | Always propagated → Child moves to STARTED | +| `ON_RESUME` | Propagated but gated by transitions and activation → Child moves to RESUMED only if all conditions are met | +| `ON_PAUSE` | Always propagated → Child immediately moves to STARTED | +| `ON_STOP` | Always propagated → Child immediately moves to CREATED | +| `ON_DESTROY` | Conditionally propagated (blocked during config changes to preserve SavedStateRegistry) | + +### Examples + +#### Parent with transition, child without transition { collapsible="true" } + +```mermaid +sequenceDiagram + participant Parent + participant Child + + Note over Parent: CREATED + Note over Parent: Entering composition + Note over Parent: STARTED + Note over Parent: Animating... + Parent->>Child: Child enters composition + Note over Child: CREATED → STARTED + Note over Child: Waiting for parent... + Note over Parent: Animation completes + Note over Parent: RESUMED + Parent->>Child: Parent resumed + Note over Child: RESUMED +``` + +Even though the child has no animation, it waits at STARTED until the parent reaches RESUMED. -Here’s an overview of lifecycle states and their meanings in the context of the screen lifecycle: +#### Screen Rotation (Configuration Change) { collapsible="true" } -| **State** | **Meaning** | -|-----------------|-----------------------------------------------------------------------------------------------------------------------------| -| **INITIALIZED** | The screen is constructed (instance created) but has never been displayed. | -| **CREATED** | The screen is in the navigation hierarchy and can be reached from the `RootScreen` integrated with an Activity or Fragment. | -| **STARTED** | `Screen.Content` is in composition. | -| **RESUMED** | The screen is **STARTED**, and there are no unfinished transitions for this screen or its parent. | -| **DESTROYED** | The screen is removed from the navigation graph. | +```mermaid +sequenceDiagram + participant Activity + participant StackScreen + participant Screen -> `ON_CREATE` and `ON_DESTROY` are dispatched once per screen instance. + Note over Activity,Screen: All RESUMED -### Screen Transitions and Lifecycle + Note right of Activity: Rotation starts + Note over Activity: STARTED + Activity->>StackScreen: ON_PAUSE + Note over StackScreen: STARTED + StackScreen->>Screen: ON_PAUSE + Note over Screen: STARTED -Modo provides a convenient way to track when screen transitions start and finish. These events are tied to the `ON_RESUME` and `ON_PAUSE` lifecycle -events. Here’s a summary: + Note over Activity: CREATED + Activity->>StackScreen: ON_STOP + Note over StackScreen: CREATED + StackScreen->>Screen: ON_STOP + Note over Screen: CREATED -| **Event** | **With Transition** | **Without Transition** | -|---------------|------------------------------------------------------------------------------------------------|---------------------------------------------| -| **ON_RESUME** | Dispatched when there are no unfinished transitions, and the parent is in the `RESUMED` state. | Dispatched when the parent is in `RESUMED`. | -| **ON_PAUSE** | Dispatched when a hiding transition starts. | Dispatched immediately before `ON_STOP`. | + Note over Activity: DESTROYED + Note right of Activity: ON_DESTROY NOT propagated
(screens preserved) -### Parent-Child Lifecycle Propagation + Note right of Activity: Activity recreated + Note over Activity: CREATED -The lifecycle of parent and child screens follows a set of rules, ensuring consistency and predictability: + Note over Activity: STARTED + Activity->>StackScreen: ON_START + Note over StackScreen: STARTED + StackScreen->>Screen: ON_START + Note over Screen: STARTED + + Note over Activity: RESUMED + Activity->>StackScreen: ON_RESUME + Note over StackScreen: RESUMED + StackScreen->>Screen: ON_RESUME + Note over Screen: RESUMED +``` -1. A screen's `Lifecycle.State` is always less than or equal to (`<=`) its parent's state. -2. A child screen is not moved to the `RESUMED` state until its parent is also in the `RESUMED` state. -3. When a screen's lifecycle state is downgraded, its child screens are also moved to the same state. -4. When a screen reaches the `RESUMED` state and its child screens are ready to resume, the children's lifecycles are also moved to `RESUMED`. +During configuration changes, screens are preserved (not destroyed) and reattach to the new Activity instance. -### Practical Example: Keyboard Management +## Transition Lifecycle Control -A practical use case for these lifecycle events is managing the keyboard. For example, you can show and hide the keyboard using `ON_RESUME` and -`ON_PAUSE` events: +Screens animated by `ScreenTransition` cannot reach RESUMED state until the animation completes: -* `ON_RESUME` indicates that the screen is ready for user input (transitions are finished) -* `ON_PAUSE` indicates that the screen is not ready for user input (transitions are starting) +- **Without transition**: STARTED → RESUMED (immediate) +- **With transition**: STARTED → (animation playing) → RESUMED (after animation completes) + +This indirectly affects nested screens because of [parent-child lifecycle propagation](#parent-child-lifecycle-coordination). + +## Practical Examples + +### Managing Keyboard + +Show/hide keyboard based on screen visibility: ```kotlin -val lifecycleOwner = LocalLifecycleOwner.current -val keyboardController = LocalSoftwareKeyboardController.current -DisposableEffect(this) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> { - focusRequester.requestFocus() - } - Lifecycle.Event.ON_PAUSE -> { - focusRequester.freeFocus() - keyboardController?.hide() +@Composable +fun LoginScreenContent(modifier: Modifier) { + val lifecycleOwner = LocalLifecycleOwner.current + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + // Screen is fully visible, focus input and show keyboard + focusRequester.requestFocus() + } + Lifecycle.Event.ON_PAUSE -> { + // Screen is hiding, clear focus and hide keyboard + focusRequester.freeFocus() + keyboardController?.hide() + } + else -> {} } - else -> {} + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) } } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } + + TextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.focusRequester(focusRequester) + ) +} +``` + +## Debugging Screens Lifecycle + +Enable logging to see lifecycle events: + +```kotlin +ModoDevOptions.onScreenPreDisposeListener = { screen -> + Log.d("Modo", "Screen pre-dispose: ${screen.screenKey}") +} + +ModoDevOptions.onScreenDisposeListener = { screen -> + Log.d("Modo", "Screen disposed: ${screen.screenKey}") } -TextField(text, setText, modifier = Modifier.focusRequester(focusRequester)) ``` \ No newline at end of file From 3de9b058e284cf8555a7338e9309299554eff54a Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Thu, 12 Feb 2026 14:28:58 +0700 Subject: [PATCH 10/17] Reformated writerside configs --- Writerside/cfg/buildprofiles.xml | 21 ++++++++++++--------- Writerside/v.list | 12 ++++++------ 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/Writerside/cfg/buildprofiles.xml b/Writerside/cfg/buildprofiles.xml index 43c4b333..c930b0fb 100644 --- a/Writerside/cfg/buildprofiles.xml +++ b/Writerside/cfg/buildprofiles.xml @@ -1,19 +1,22 @@ - + - false - https://github.com/ikarenkov/Modo + + false - true - https://github.com/ikarenkov/Modo/blob/dev/Writerside/ + + https://github.com/ikarenkov/Modo - true - GitHub - %github_url% - + + true + https://github.com/ikarenkov/Modo/edit/dev/Writerside/ + + true + GitHub + %github_url% diff --git a/Writerside/v.list b/Writerside/v.list index c7cc6ff5..90f3bd1b 100644 --- a/Writerside/v.list +++ b/Writerside/v.list @@ -1,9 +1,9 @@ - - - - - - + + + + + + From 96f4aae9eda55e2ee98d81220a5bbf387317a458 Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Sun, 15 Feb 2026 20:32:52 +0700 Subject: [PATCH 11/17] Migrated assertion to ValidationFailedStrategy --- .../github/terrakok/modo/ModoDevOptions.kt | 4 ++++ .../modo/android/ScreenLifecycleManager.kt | 23 +++++++++++++------ .../modo/sample/ModoSampleApplication.kt | 3 +++ .../github/ikarenkov/workshop/WorkshopApp.kt | 3 +++ 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ModoDevOptions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ModoDevOptions.kt index 79a78cbc..733878dc 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ModoDevOptions.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ModoDevOptions.kt @@ -13,6 +13,10 @@ object ModoDevOptions { Log.e("Modo", "Modo internal error", throwable) } + var onIllegalLifecycleUpdate: ValidationFailedStrategy = ValidationFailedStrategy { throwable -> + Log.e("Modo", "Modo internal error", throwable) + } + var onScreenDisposeListener: ((Screen) -> Unit)? = null var onScreenPreDisposeListener: ((Screen) -> Unit)? = null diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt b/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt index fc21a7d5..6910f60f 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt @@ -14,6 +14,7 @@ import androidx.lifecycle.Lifecycle.State.STARTED import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry +import com.github.terrakok.modo.ModoDevOptions import java.util.concurrent.atomic.AtomicReference import kotlin.math.abs @@ -138,12 +139,7 @@ internal class ScreenLifecycleManager( (event != ON_RESUME || canResumeAfterTransition) && parentStateAllowsTransition(currentParentState, event) ) { - assert( - abs(lifecycle.currentState.ordinal - event.targetState.ordinal) == 1 || - lifecycle.currentState == CREATED && event == ON_DESTROY - ) { - "Lifecycle state transition must be one step, but was ${lifecycle.currentState} -> $event" - } + validateLifecycleUpdateOneStep(lifecycle.currentState, event) lifecycle.handleLifecycleEvent(event) } } @@ -170,12 +166,25 @@ internal class ScreenLifecycleManager( // Skipping events that move the lifecycle state down, but this state is already reached. (event in MOVE_LIFECYCLE_STATE_DOWN_EVENTS && event.targetState >= currentState) - private fun parentStateAllowsTransition( + internal fun parentStateAllowsTransition( parentState: Lifecycle.State?, event: Lifecycle.Event, ): Boolean = event in MOVE_LIFECYCLE_STATE_DOWN_EVENTS || event.targetState <= CREATED || parentState != null && parentState >= event.targetState + + internal fun validateLifecycleUpdateOneStep(currentState: Lifecycle.State, event: Lifecycle.Event) { + val isValidUpdate = abs(currentState.ordinal - event.targetState.ordinal) == 1 || + currentState == CREATED && event == ON_DESTROY + if (!isValidUpdate) { + ModoDevOptions.onIllegalLifecycleUpdate.validationFailed( + IllegalStateException( + "Lifecycle state transition must be one step, but was $currentState -> $event. " + + "Please report an issue at ${ModoDevOptions.REPORT_ISSUE_URL}" + ) + ) + } + } } } diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt b/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt index 0feb531b..a79bce02 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt @@ -17,6 +17,9 @@ class ModoSampleApplication : Application() { ModoDevOptions.onIllegalClearState = ModoDevOptions.ValidationFailedStrategy { throwable -> throw throwable } + ModoDevOptions.onIllegalLifecycleUpdate = ModoDevOptions.ValidationFailedStrategy { throwable -> + throw throwable + } ModoDevOptions.onScreenDisposeListener = { it.logcat { "Screen disposed" } } diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopApp.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopApp.kt index 81c8eae0..584c6903 100644 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopApp.kt +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopApp.kt @@ -21,6 +21,9 @@ class WorkshopApp : Application() { ModoDevOptions.onIllegalClearState = ModoDevOptions.ValidationFailedStrategy { throwable -> logcat(priority = LogPriority.ERROR) { "Cleaning state of composable, which still can be visible for user." } } + ModoDevOptions.onIllegalLifecycleUpdate = ModoDevOptions.ValidationFailedStrategy { throwable -> + throw throwable + } startKoin { androidLogger() From b7761e1da78e43b3c584fc2f2c8f43659e9efa10 Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Sun, 15 Feb 2026 21:14:49 +0700 Subject: [PATCH 12/17] Fixed recompositions in sample caused by counter --- .../modo/sample/screens/MainScreen.kt | 6 ++- .../screens/base/ButtonsScreenContent.kt | 42 ++++++++++++++----- .../lifecycle/LifecycleSampleScreen.kt | 6 ++- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt index 4080f85f..0ab33f9e 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt @@ -3,6 +3,7 @@ package com.github.terrakok.modo.sample.screens import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -94,6 +95,8 @@ internal fun Screen.MainScreenContent( modifier: Modifier = Modifier, canOpenFragment: Boolean = false, ) { + val counterState = remember { mutableIntStateOf(0) } + counterState.intValue = counter ButtonsScreenContent( screenIndex = screenIndex, screenName = "MainScreen", @@ -104,7 +107,8 @@ internal fun Screen.MainScreenContent( i = screenIndex, canOpenFragment = canOpenFragment ), - counter = counter, + counterState = counterState, + enableCounter = true, modifier = modifier ) } diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt index c13a7a92..a3ca27e8 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt @@ -74,11 +74,12 @@ internal fun Screen.ButtonsScreenContent( if (logLifecycle) { LogLifecycle() } - val counter by rememberCounterState() + val counterState = rememberCounterState() ButtonsScreenContent( screenIndex = screenIndex, screenName = screenName, - counter = if (enableCounter) counter else 0, + counterState = counterState, + enableCounter = enableCounter, screenKey = screenKey, state = state, topRightButtonSlot = topRightButtonSlot, @@ -105,7 +106,8 @@ fun rememberCounterState(): IntState { internal fun ButtonsScreenContent( screenIndex: Int, screenName: String, - counter: Int, + counterState: IntState, + enableCounter: Boolean, screenKey: ScreenKey, state: GroupedButtonsState, modifier: Modifier = Modifier, @@ -116,7 +118,8 @@ internal fun ButtonsScreenContent( SampleScreenContent( screenIndex = screenIndex, screenName = screenName, - counter = counter, + counterState = counterState, + enableCounter = enableCounter, screenKey = screenKey, topRightButtonSlot = topRightButtonSlot, windowInsets = windowInsets, @@ -139,11 +142,12 @@ internal fun Screen.SampleScreenContent( content: @Composable ColumnScope.() -> Unit ) { LogLifecycle() - val counter by rememberCounterState() + val counterState = rememberCounterState() SampleScreenContent( screenIndex = screenIndex, screenName = screenName, - counter = counter, + counterState = counterState, + enableCounter = true, screenKey = screenKey, modifier = modifier, windowInsets = windowInsets, @@ -151,6 +155,20 @@ internal fun Screen.SampleScreenContent( ) } +@Composable +private fun CounterText( + counterState: IntState, + enableCounter: Boolean, + modifier: Modifier = Modifier +) { + if (enableCounter) { + Text( + text = counterState.intValue.toString(), + modifier = modifier + ) + } +} + @OptIn(ExperimentalModoApi::class) @Composable fun Screen.LogLifecycle(prefix: String = "") { @@ -179,7 +197,8 @@ fun Screen.LogLifecycle(prefix: String = "") { internal fun SampleScreenContent( screenIndex: Int, screenName: String, - counter: Int, + counterState: IntState, + enableCounter: Boolean, screenKey: ScreenKey, modifier: Modifier = Modifier, windowInsets: WindowInsets = WindowInsets.systemBars, @@ -219,8 +238,9 @@ internal fun SampleScreenContent( BackButton( onClick = { stackNavigation?.back() }, ) - Text( - text = counter.toString(), + CounterText( + counterState = counterState, + enableCounter = enableCounter, modifier = Modifier.weight(1f) ) topRightButtonSlot() @@ -247,9 +267,11 @@ internal fun SampleScreenContent( @Preview @Composable private fun ButtonsPreview() { + val counterState = remember { mutableIntStateOf(666) } ButtonsScreenContent( screenIndex = 0, - counter = 666, + counterState = counterState, + enableCounter = true, screenName = "ButtonsPreview", screenKey = ScreenKey("ScreenKey"), state = ButtonsState( diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/lifecycle/LifecycleSampleScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/lifecycle/LifecycleSampleScreen.kt index ed60c2fd..45bee343 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/lifecycle/LifecycleSampleScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/lifecycle/LifecycleSampleScreen.kt @@ -46,7 +46,8 @@ class LifecycleSampleScreen( @OptIn(ExperimentalModoApi::class, ExperimentalStdlibApi::class) @Composable override fun Content(modifier: Modifier) { - val counter by rememberCounterState() + val counterState = rememberCounterState() + val counter by counterState val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(this) { @@ -87,7 +88,8 @@ class LifecycleSampleScreen( screenIndex = screenIndex, screenName = "ScreenEffectsSampleScreen", screenKey = screenKey, - counter = counter, + counterState = counterState, + enableCounter = true, modifier = modifier, ) { GroupedButtonsList( From 27677795b556c1ab307ec2e45a7fcc22c414ef2c Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Tue, 17 Feb 2026 20:57:23 +0700 Subject: [PATCH 13/17] Fix typo --- Writerside/topics/Lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Writerside/topics/Lifecycle.md b/Writerside/topics/Lifecycle.md index 96a1676f..aa133895 100644 --- a/Writerside/topics/Lifecycle.md +++ b/Writerside/topics/Lifecycle.md @@ -29,7 +29,7 @@ DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_RESUME -> { - // Screen is redy for interactions + // Screen is ready for interactions } Lifecycle.Event.ON_PAUSE -> { // Screen is hiding From 34a25162037bc94c22c0449909b1df0189633a45 Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Tue, 17 Feb 2026 20:57:58 +0700 Subject: [PATCH 14/17] Fix reflection access and parameter name --- .../modo/android/ModoScreenAndroidAdapter.kt | 7 ++++--- ...reenAndroidAdapterParentPropagationTest.kt | 19 +++++++++---------- .../ModoScreenAndroidAdapterTestUtils.kt | 13 +++++-------- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt index b34bb33c..f9a6fbed 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt @@ -145,7 +145,8 @@ class ModoScreenAndroidAdapter private constructor( viewModelStore.clear() } - private fun onCreate(savedState: Bundle?) { + @VisibleForTesting + internal fun onCreate(savedState: Bundle?) { check(!isCreated) { "onCreate already called" } isCreated = true controller.performRestore(savedState) @@ -236,7 +237,7 @@ class ModoScreenAndroidAdapter private constructor( @VisibleForTesting internal fun subscribeToParentLifecycle( parentLifecycleOwner: LifecycleOwner, - savedState: Bundle? = null, + savedState: Bundle, isActivityFinishing: () -> Boolean? = { null }, isChangingConfigurations: () -> Boolean? = { null } ): () -> Unit = lifecycleManager.subscribeToParentLifecycle( @@ -245,7 +246,7 @@ class ModoScreenAndroidAdapter private constructor( isChangingConfigurations = isChangingConfigurations, onEventBeforePropagation = { event -> // Handle SavedState side-effect (adapter's responsibility) - if (event == ON_STOP && savedState != null) { + if (event == ON_STOP) { performSave(savedState) } } diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt index 9a722fe5..599d1b4b 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt @@ -50,7 +50,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { @Test fun `Given parent RESUMED and manual resume When enter composition - Then screen STARTED`() { parent.lifecycleState = RESUMED - emulator.enterComposition(manualResumePause = true) + emulator.enterComposition(usesTransitionLifecycle = true) assertEquals(STARTED, emulator.lifecycleState) } @@ -71,7 +71,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { fun `Given parent STARTED and manual resume When enter composition without manual resume - Then screen RESUMED`() { parent.lifecycleState = STARTED - emulator.enterComposition(manualResumePause = true) + emulator.enterComposition(usesTransitionLifecycle = true) assertEquals(STARTED, emulator.lifecycleState) } @@ -99,7 +99,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { fun `Given manual resume When parent moves RESUMED to STARTED to RESUMED - Then adapter follows parent`() { parent.lifecycleState = RESUMED - emulator.enterComposition(manualResumePause = true) + emulator.enterComposition(usesTransitionLifecycle = true) assertEquals(STARTED, emulator.lifecycleState) emulator.showTransitionFinished() @@ -132,7 +132,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { fun `Given manual resume and parent animating Verify states When transition finished after parent moves to RESUMED`() { parent.lifecycleState = STARTED - emulator.enterComposition(manualResumePause = true) + emulator.enterComposition(usesTransitionLifecycle = true) assertEquals(STARTED, emulator.lifecycleState) parent.lifecycleState = RESUMED @@ -146,7 +146,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { fun `Given manual resume and STARTED parent Test transition finished before parent moves to RESUMED`() { parent.lifecycleState = STARTED - emulator.enterComposition(manualResumePause = true) + emulator.enterComposition(usesTransitionLifecycle = true) assertEquals(STARTED, emulator.lifecycleState) adapter.showTransitionFinished() @@ -164,7 +164,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { fun `Given manual resume parent RESUMED Test screen transition showing and hiding`() { parent.lifecycleState = RESUMED - emulator.enterComposition(manualResumePause = true) + emulator.enterComposition(usesTransitionLifecycle = true) assertEquals(STARTED, emulator.lifecycleState) adapter.showTransitionFinished() @@ -185,7 +185,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { fun `Given manual resume parent STARTED Test showTransitionFinished then hideTransitionStarted`() { parent.lifecycleState = STARTED - emulator.enterComposition(manualResumePause = true) + emulator.enterComposition(usesTransitionLifecycle = true) assertEquals(STARTED, emulator.lifecycleState) adapter.showTransitionFinished() @@ -233,18 +233,17 @@ class ModoScreenAndroidAdapterParentPropagationTest { @Test fun `Given parent STARTED When entering composition and finish transition - Then screen STARTED`() { parent.lifecycleState = STARTED - emulator.enterComposition(manualResumePause = true) + emulator.enterComposition(usesTransitionLifecycle = true) assertEquals(STARTED, emulator.lifecycleState) emulator.showTransitionFinished() assertEquals(STARTED, emulator.lifecycleState) } - // TODO: should it be like this? Maybe screen should be created to follow global logic of lifecycle hierarchy? Is it possible in real use? @Test fun `Given parent CREATED When entering composition and finish transition - Then screen STARTED`() { parent.lifecycleState = STARTED - emulator.enterComposition(manualResumePause = true) + emulator.enterComposition(usesTransitionLifecycle = true) assertEquals(STARTED, emulator.lifecycleState) emulator.showTransitionFinished() diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt index caddbba6..7f5890ed 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt @@ -50,7 +50,7 @@ class CompositionLifecycleEmulator( private val parentLifecycleOwner: TestLifecycleOwner, ) { - private var manualResumePause: Boolean = false + private var usesTransitionLifecycle: Boolean = false private val savedState = Bundle() private var unsubscribeFromParent: (() -> Unit)? = null private var isInComposition = false @@ -61,16 +61,16 @@ class CompositionLifecycleEmulator( } fun enterComposition( - manualResumePause: Boolean = false, + usesTransitionLifecycle: Boolean = false, isActivityFinishing: () -> Boolean? = { false }, isChangingConfigurations: () -> Boolean? = { false } ) { check(!isInComposition) { "Already in composition" } isInComposition = true - this.manualResumePause = manualResumePause + this.usesTransitionLifecycle = usesTransitionLifecycle - adapter.lifecycleManager.handleCompositionEnter(manualResumePause) + adapter.lifecycleManager.handleCompositionEnter(usesTransitionLifecycle) unsubscribeFromParent = adapter.subscribeToParentLifecycle( parentLifecycleOwner = parentLifecycleOwner, @@ -103,9 +103,6 @@ class CompositionLifecycleEmulator( get() = parentLifecycleOwner.lifecycle.currentState private fun initializeAdapter() { - val onCreate = ModoScreenAndroidAdapter::class.java - .getDeclaredMethod("onCreate", Bundle::class.java) - onCreate.isAccessible = true - onCreate.invoke(adapter, savedState) + adapter.onCreate(savedState) } } From b011b0c3158d4741fa4d37ad92dc495d1d376eee Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Tue, 17 Feb 2026 21:14:45 +0700 Subject: [PATCH 15/17] Fix test namings --- ...reenAndroidAdapterParentPropagationTest.kt | 22 +++++++++---------- ...enAndroidAdapterStateAlreadyReachedTest.kt | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt index 599d1b4b..a9fe1b22 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt @@ -40,7 +40,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { // region Parent RESUMED, screen enter composition @Test - fun `Given parent RESUMED and auto resume When enter composition - Then screen RESUMED`() { + fun `Given parent RESUMED and no transition When enter composition - Then screen RESUMED`() { parent.lifecycleState = RESUMED emulator.enterComposition() @@ -48,7 +48,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { } @Test - fun `Given parent RESUMED and manual resume When enter composition - Then screen STARTED`() { + fun `Given parent RESUMED and transition lifecycle When enter composition - Then screen STARTED`() { parent.lifecycleState = RESUMED emulator.enterComposition(usesTransitionLifecycle = true) @@ -60,7 +60,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { // region Parent STARTED (animation in progress), screen enter composition @Test - fun `Given parent STARTED and auto resume When enter composition without manual resume - Then screen STARTED`() { + fun `Given parent STARTED and no transition When enter composition - Then screen STARTED`() { parent.lifecycleState = STARTED emulator.enterComposition() @@ -68,7 +68,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { } @Test - fun `Given parent STARTED and manual resume When enter composition without manual resume - Then screen RESUMED`() { + fun `Given parent STARTED and transition lifecycle When enter composition - Then screen STARTED`() { parent.lifecycleState = STARTED emulator.enterComposition(usesTransitionLifecycle = true) @@ -82,7 +82,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { // region Screen is not in transition, parent changes state @Test - fun `Given auto resume When parent moves RESUMED to STARTED to RESUMED - Then adapter follows parent`() { + fun `Given no transition When parent moves RESUMED to STARTED to RESUMED - Then adapter follows parent`() { parent.lifecycleState = RESUMED emulator.enterComposition() @@ -96,7 +96,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { } @Test - fun `Given manual resume When parent moves RESUMED to STARTED to RESUMED - Then adapter follows parent`() { + fun `Given transition lifecycle When parent moves RESUMED to STARTED to RESUMED - Then adapter follows parent`() { parent.lifecycleState = RESUMED emulator.enterComposition(usesTransitionLifecycle = true) @@ -113,7 +113,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { } @Test - fun `Given auto resume Test parent moves from RESUMED to CREATED to RESUMED`() { + fun `Given no transition When parent moves from RESUMED to CREATED to RESUMED - Then adapter follows parent`() { parent.lifecycleState = RESUMED emulator.enterComposition() @@ -129,7 +129,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { // endregion @Test - fun `Given manual resume and parent animating Verify states When transition finished after parent moves to RESUMED`() { + fun `Given transition lifecycle and parent STARTED When parent moves to RESUMED then transition finishes - Then screen RESUMED`() { parent.lifecycleState = STARTED emulator.enterComposition(usesTransitionLifecycle = true) @@ -143,7 +143,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { } @Test - fun `Given manual resume and STARTED parent Test transition finished before parent moves to RESUMED`() { + fun `Given transition lifecycle and parent STARTED When transition finishes before parent moves to RESUMED - Then screen stays STARTED until parent RESUMED`() { parent.lifecycleState = STARTED emulator.enterComposition(usesTransitionLifecycle = true) @@ -161,7 +161,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { // Parent resumed all time // Child animating in and animating out @Test - fun `Given manual resume parent RESUMED Test screen transition showing and hiding`() { + fun `Given transition lifecycle and parent RESUMED When show transition finishes then hide transition starts - Then screen moves RESUMED to STARTED`() { parent.lifecycleState = RESUMED emulator.enterComposition(usesTransitionLifecycle = true) @@ -182,7 +182,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { // Parent animation finishes // Child animation finishes @Test - fun `Given manual resume parent STARTED Test showTransitionFinished then hideTransitionStarted`() { + fun `Given transition lifecycle and parent STARTED When show transition finishes then hide transition starts - Then screen stays STARTED`() { parent.lifecycleState = STARTED emulator.enterComposition(usesTransitionLifecycle = true) diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt index 1de2846e..cde44e6d 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt @@ -21,7 +21,7 @@ class ModoScreenAndroidAdapterStateAlreadyReachedTest { @ParameterizedTest(name = "State={0}, Event={1} -> shouldSkip={2}") @MethodSource("testCases") - fun `needSkipEvent returns correct result`( + fun `stateAlreadyReached returns correct result`( state: Lifecycle.State, event: Lifecycle.Event, shouldSkip: Boolean From 6437a747c4109c3ecd1edfb1b5802fd1a7d5a54d Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Tue, 17 Feb 2026 22:49:36 +0700 Subject: [PATCH 16/17] Refactored `isActivityFinishing` and `isChangingConfigurations` to return non-nullable booleans and improved lifecycle update logic to prevent invalid state transitions. --- .../modo/android/ModoScreenAndroidAdapter.kt | 8 +++---- .../modo/android/ScreenLifecycleManager.kt | 10 ++++---- ...reenAndroidAdapterParentPropagationTest.kt | 24 +++++++++++++++---- .../ModoScreenAndroidAdapterTestUtils.kt | 4 ++-- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt index f9a6fbed..0b0ddfdb 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt @@ -221,8 +221,8 @@ class ModoScreenAndroidAdapter private constructor( val unregisterLifecycle = subscribeToParentLifecycle( parentLifecycleOwner = parentLifecycleOwner, savedState = savedState, - isActivityFinishing = { activity?.isFinishing }, - isChangingConfigurations = { activity?.isChangingConfigurations } + isActivityFinishing = { activity?.isFinishing ?: false }, + isChangingConfigurations = { activity?.isChangingConfigurations ?: false } ) onDispose { @@ -238,8 +238,8 @@ class ModoScreenAndroidAdapter private constructor( internal fun subscribeToParentLifecycle( parentLifecycleOwner: LifecycleOwner, savedState: Bundle, - isActivityFinishing: () -> Boolean? = { null }, - isChangingConfigurations: () -> Boolean? = { null } + isActivityFinishing: () -> Boolean, + isChangingConfigurations: () -> Boolean ): () -> Unit = lifecycleManager.subscribeToParentLifecycle( parentLifecycleOwner = parentLifecycleOwner, isActivityFinishing = isActivityFinishing, diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt b/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt index 6910f60f..27af4a9b 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt @@ -89,7 +89,9 @@ internal class ScreenLifecycleManager( fun showTransitionFinished() { canResumeAfterTransition = true - updateLifecycleIfNeeded(ON_RESUME) + if (lifecycle.currentState == STARTED) { + updateLifecycleIfNeeded(ON_RESUME) + } } fun hideTransitionStarted() { @@ -103,8 +105,8 @@ internal class ScreenLifecycleManager( */ fun subscribeToParentLifecycle( parentLifecycleOwner: LifecycleOwner, - isActivityFinishing: () -> Boolean? = { null }, - isChangingConfigurations: () -> Boolean? = { null }, + isActivityFinishing: () -> Boolean = { false }, + isChangingConfigurations: () -> Boolean = { false }, onEventBeforePropagation: ((Lifecycle.Event) -> Unit)? = null ): () -> Unit { val observer = LifecycleEventObserver { _, event -> @@ -113,7 +115,7 @@ internal class ScreenLifecycleManager( // Propagate lifecycle events using manager's logic // [ON_DESTROY] blocked during config changes to preserve SavedStateRegistry. - if (event != ON_DESTROY || (isActivityFinishing() != false && isChangingConfigurations() != true)) { + if (event != ON_DESTROY || (isActivityFinishing() && !isChangingConfigurations())) { updateLifecycleIfNeeded(event) } } diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt index a9fe1b22..f71e2041 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt @@ -241,15 +241,31 @@ class ModoScreenAndroidAdapterParentPropagationTest { } @Test - fun `Given parent CREATED When entering composition and finish transition - Then screen STARTED`() { - parent.lifecycleState = STARTED + fun `Given parent CREATED When entering composition and finish transition - Then screen stays CREATED`() { + parent.lifecycleState = CREATED emulator.enterComposition(usesTransitionLifecycle = true) - assertEquals(STARTED, emulator.lifecycleState) + assertEquals(CREATED, emulator.lifecycleState) emulator.showTransitionFinished() - assertEquals(STARTED, emulator.lifecycleState) + assertEquals(CREATED, emulator.lifecycleState) } + // region Edge cases + + @Test + fun `When showTransitionFinished called before enterComposition - Then it has no effect`() { + parent.lifecycleState = RESUMED + // Simulate a race: showTransitionFinished fires before composition enters + adapter.showTransitionFinished() + assertEquals(CREATED, emulator.lifecycleState) + + // Normal composition entry afterward still works correctly (no transition lifecycle) + emulator.enterComposition(usesTransitionLifecycle = false) + assertEquals(RESUMED, emulator.lifecycleState) + } + + // endregion + // region Parent ON_DESTROY propagation - Activity lifecycle scenarios @Test diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt index 7f5890ed..26d88d76 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt @@ -62,8 +62,8 @@ class CompositionLifecycleEmulator( fun enterComposition( usesTransitionLifecycle: Boolean = false, - isActivityFinishing: () -> Boolean? = { false }, - isChangingConfigurations: () -> Boolean? = { false } + isActivityFinishing: () -> Boolean = { false }, + isChangingConfigurations: () -> Boolean = { false } ) { check(!isInComposition) { "Already in composition" } isInComposition = true From 4f4acb3cb5e63e43a8d2b97312ec8b9c1bcaf527 Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Tue, 17 Feb 2026 23:05:34 +0700 Subject: [PATCH 17/17] Shorten names for tests --- .../android/ModoScreenAndroidAdapterParentPropagationTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt index f71e2041..4ad7cf46 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt @@ -143,7 +143,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { } @Test - fun `Given transition lifecycle and parent STARTED When transition finishes before parent moves to RESUMED - Then screen stays STARTED until parent RESUMED`() { + fun `Given transition lifecycle and parent STARTED When transition finishes first - Then stays STARTED until parent RESUMED`() { parent.lifecycleState = STARTED emulator.enterComposition(usesTransitionLifecycle = true) @@ -161,7 +161,7 @@ class ModoScreenAndroidAdapterParentPropagationTest { // Parent resumed all time // Child animating in and animating out @Test - fun `Given transition lifecycle and parent RESUMED When show transition finishes then hide transition starts - Then screen moves RESUMED to STARTED`() { + fun `Given parent RESUMED and transition lifecycle When hide starts after show - Then screen moves to STARTED`() { parent.lifecycleState = RESUMED emulator.enterComposition(usesTransitionLifecycle = true)