diff --git a/workflow-testing/api/workflow-testing.api b/workflow-testing/api/workflow-testing.api index cd02fab7fa..2245d35993 100644 --- a/workflow-testing/api/workflow-testing.api +++ b/workflow-testing/api/workflow-testing.api @@ -17,6 +17,7 @@ public final class com/squareup/workflow1/testing/RealRenderTester : com/squareu public fun getActionSink ()Lcom/squareup/workflow1/Sink; public fun render (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/testing/RenderTestResult; public fun renderChild (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public fun requireExplicitSideEffectExpectations ()Lcom/squareup/workflow1/testing/RenderTester; public fun requireExplicitWorkerExpectations ()Lcom/squareup/workflow1/testing/RenderTester; public fun runningSideEffect (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)V public fun send (Lcom/squareup/workflow1/WorkflowAction;)V diff --git a/workflow-ui/container-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/BackstackContainerTest.kt b/workflow-ui/container-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/BackstackContainerTest.kt index fed23dcc47..e62aa0ec64 100644 --- a/workflow-ui/container-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/BackstackContainerTest.kt +++ b/workflow-ui/container-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/BackstackContainerTest.kt @@ -2,6 +2,7 @@ package com.squareup.workflow1.ui.backstack.test import android.os.Build import android.view.View +import android.view.ViewGroup import androidx.lifecycle.Lifecycle.State.CREATED import androidx.lifecycle.Lifecycle.State.RESUMED import androidx.lifecycle.Lifecycle.State.STARTED @@ -12,6 +13,7 @@ import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.backstack.test.fixtures.BackStackContainerLifecycleActivity import com.squareup.workflow1.ui.backstack.test.fixtures.BackStackContainerLifecycleActivity.TestRendering.LeafRendering import com.squareup.workflow1.ui.backstack.test.fixtures.BackStackContainerLifecycleActivity.TestRendering.RecurseRendering +import com.squareup.workflow1.ui.backstack.test.fixtures.ViewStateTestView import com.squareup.workflow1.ui.backstack.test.fixtures.viewForScreen import com.squareup.workflow1.ui.backstack.test.fixtures.waitForScreen import org.junit.Rule @@ -60,6 +62,59 @@ internal class BackstackContainerTest { } } + @Test fun state_restores_after_recreate_without_crashing() { + assertThat(scenario.state).isEqualTo(RESUMED) + + fun BackStackContainerLifecycleActivity.nestedTestView(): ViewStateTestView { + val root = rootRenderedView as ViewGroup + val backStack = root.getChildAt(0) as ViewGroup + return backStack.getChildAt(0) as ViewStateTestView + } + + scenario.onActivity { + it.consumeLifecycleEvents() + it.setRendering(RecurseRendering(listOf(LeafRendering("nested")))) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()) + .containsExactly( + "nested onViewCreated viewState=", + "nested onShowRendering viewState=", + "nested onAttach viewState=", + "LeafView nested ON_CREATE", + "LeafView nested ON_START", + "LeafView nested ON_RESUME" + ).inOrder() + } + + scenario.onActivity { + // Set some view state to be saved and restored. + it.nestedTestView().viewState = "some state" + } + + // Recreating the activity sends one ON_CREATE event, + // but the views will step through both INITIALIZED and CREATED before the observer is removed. + // We're testing that we don't try to restore SavedState when the view is already CREATED. + scenario.recreate() + + scenario.onActivity { + // If we get through this, nothing crashed while recreating. + assertThat(it.consumeLifecycleEvents()) + .containsExactly( + "activity onCreate", + "activity onStart", + "activity onResume" + ).inOrder() + } + + scenario.onActivity { + // We didn't crash, which means we didn't try to restore while CREATED, + // but make sure that we still restored while the view was in the INITIALIZED state. + assertThat(it.nestedTestView().viewState).isEqualTo("some state") + } + } + @Test fun restores_current_view_after_config_change() { val firstScreen = LeafRendering("initial") diff --git a/workflow-ui/container-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/fixtures/BackStackContainerLifecycleActivity.kt b/workflow-ui/container-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/fixtures/BackStackContainerLifecycleActivity.kt index ca84e4bf15..eb0c277f8e 100644 --- a/workflow-ui/container-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/fixtures/BackStackContainerLifecycleActivity.kt +++ b/workflow-ui/container-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/fixtures/BackStackContainerLifecycleActivity.kt @@ -17,6 +17,7 @@ import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.WorkflowViewStub import com.squareup.workflow1.ui.backstack.BackStackScreen import com.squareup.workflow1.ui.backstack.test.fixtures.BackStackContainerLifecycleActivity.TestRendering.LeafRendering +import com.squareup.workflow1.ui.backstack.test.fixtures.BackStackContainerLifecycleActivity.TestRendering.OuterRendering import com.squareup.workflow1.ui.backstack.test.fixtures.BackStackContainerLifecycleActivity.TestRendering.RecurseRendering import com.squareup.workflow1.ui.bindShowRendering import com.squareup.workflow1.ui.internal.test.AbstractLifecycleTestActivity @@ -49,6 +50,11 @@ internal class BackStackContainerLifecycleActivity : AbstractLifecycleTestActivi } data class RecurseRendering(val wrappedBackstack: List) : TestRendering() + + @OptIn(WorkflowUiExperimentalApi::class) + data class OuterRendering(val name: String) : TestRendering() { + val backStack = BackStackScreen(LeafRendering("nested leaf in $name")) + } } private val viewObserver = @@ -122,6 +128,20 @@ internal class BackStackContainerLifecycleActivity : AbstractLifecycleTestActivi } } }, + BuilderViewFactory(OuterRendering::class) { initialRendering, + initialViewEnvironment, + contextForNewView, _ -> + FrameLayout(contextForNewView).also { container -> + + val stub = WorkflowViewStub(contextForNewView) + container.addView(stub) + container.bindShowRendering( + initialRendering, initialViewEnvironment + ) { rendering, env -> + stub.update(rendering.backStack, env) + } + } + }, ) /** Returns the view that is the current screen. */ diff --git a/workflow-ui/container-android/src/main/java/com/squareup/workflow1/ui/backstack/ViewStateCache.kt b/workflow-ui/container-android/src/main/java/com/squareup/workflow1/ui/backstack/ViewStateCache.kt index 0cbddc48de..d8a9426067 100644 --- a/workflow-ui/container-android/src/main/java/com/squareup/workflow1/ui/backstack/ViewStateCache.kt +++ b/workflow-ui/container-android/src/main/java/com/squareup/workflow1/ui/backstack/ViewStateCache.kt @@ -8,6 +8,7 @@ import android.view.View import android.view.View.BaseSavedState import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting.PRIVATE +import androidx.lifecycle.Lifecycle import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.ViewTreeSavedStateRegistryOwner import com.squareup.workflow1.ui.Named @@ -50,9 +51,14 @@ internal constructor( } }, onRestored = { aggregator -> - currentOwner?.let { owner -> - aggregator.restoreRegistryControllerIfReady(owner.key, owner.controller) - } + currentOwner + // We're only allowed to restore from an INITIALIZED state, but this callback can also be + // invoked while the owner is already CREATED. + // https://github.com/square/workflow-kotlin/issues/570 + ?.takeIf { it.lifecycle.currentState == Lifecycle.State.INITIALIZED } + ?.let { owner -> + aggregator.restoreRegistryControllerIfReady(owner.key, owner.controller) + } } )