diff --git a/.buildscript/configure-android-defaults.gradle b/.buildscript/configure-android-defaults.gradle index c10a38cd7..a2bb16397 100644 --- a/.buildscript/configure-android-defaults.gradle +++ b/.buildscript/configure-android-defaults.gradle @@ -21,5 +21,7 @@ android { exclude 'META-INF/common.kotlin_module' exclude 'META-INF/android_debug.kotlin_module' exclude 'META-INF/android_release.kotlin_module' + exclude 'META-INF/AL2.0' + exclude 'META-INF/LGPL2.1' } } diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index e49d07542..f8ec137ba 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -16,6 +16,11 @@ object Dependencies { const val activityKtx = "androidx.activity:activity-ktx:1.3.0" const val appcompat = "androidx.appcompat:appcompat:1.3.1" + object Compose { + const val foundation = "androidx.compose.foundation:foundation:1.0.1" + const val ui = "androidx.compose.ui:ui:1.0.1" + } + const val constraint_layout = "androidx.constraintlayout:constraintlayout:2.1.0" const val fragment = "androidx.fragment:fragment:1.3.6" const val fragmentKtx = "androidx.fragment:fragment-ktx:1.3.6" @@ -23,6 +28,7 @@ object Dependencies { object Lifecycle { const val ktx = "androidx.lifecycle:lifecycle-runtime-ktx:2.3.1" + const val viewModel = "androidx.lifecycle:lifecycle-viewmodel:2.3.1" const val viewModelKtx = "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1" const val viewModelSavedState = "androidx.lifecycle:lifecycle-viewmodel-savedstate:1.1.0" } @@ -113,6 +119,7 @@ object Dependencies { object Test { object AndroidX { + const val compose = "androidx.compose.ui:ui-test-junit4:1.0.1" const val core = "androidx.test:core:1.3.0" object Espresso { diff --git a/samples/tictactoe/app/src/androidTest/java/com/squareup/sample/TicTacToeEspressoTest.kt b/samples/tictactoe/app/src/androidTest/java/com/squareup/sample/TicTacToeEspressoTest.kt index 451addd2b..3b3d07a8d 100644 --- a/samples/tictactoe/app/src/androidTest/java/com/squareup/sample/TicTacToeEspressoTest.kt +++ b/samples/tictactoe/app/src/androidTest/java/com/squareup/sample/TicTacToeEspressoTest.kt @@ -3,8 +3,6 @@ package com.squareup.sample import android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE import android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT import android.view.View -import androidx.test.core.app.ActivityScenario -import androidx.test.core.app.ActivityScenario.launch import androidx.test.espresso.IdlingRegistry import androidx.test.espresso.ViewInteraction import androidx.test.espresso.action.ViewActions.click @@ -15,6 +13,7 @@ import androidx.test.espresso.matcher.ViewMatchers.hasDescendant import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.squareup.sample.gameworkflow.GamePlayScreen @@ -26,10 +25,11 @@ import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.environment import com.squareup.workflow1.ui.getRendering -import com.squareup.workflow1.ui.internal.test.actuallyPressBack import com.squareup.workflow1.ui.internal.test.inAnyView +import com.squareup.workflow1.ui.internal.test.actuallyPressBack import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import java.util.concurrent.atomic.AtomicReference @@ -38,25 +38,23 @@ import java.util.concurrent.atomic.AtomicReference @RunWith(AndroidJUnit4::class) class TicTacToeEspressoTest { - private lateinit var scenario: ActivityScenario + @Rule @JvmField var scenarioRule = ActivityScenarioRule(TicTacToeActivity::class.java) + private val scenario get() = scenarioRule.scenario @Before fun setUp() { - scenario = launch(TicTacToeActivity::class.java) - .apply { - onActivity { activity -> - IdlingRegistry.getInstance() - .register(activity.idlingResource) - activity.requestedOrientation = SCREEN_ORIENTATION_PORTRAIT - } - } + scenario.onActivity { activity -> + IdlingRegistry.getInstance() + .register(activity.idlingResource) + activity.requestedOrientation = SCREEN_ORIENTATION_PORTRAIT + } } @After fun tearDown() { scenario.onActivity { activity -> IdlingRegistry.getInstance() - .unregister(activity.idlingResource) + .unregister(activity.idlingResource) } } @@ -93,7 +91,7 @@ class TicTacToeEspressoTest { // lambda above and it all worked just fine. But that seems like a land mine.) inAnyView(withId(R.id.game_play_toolbar)) - .check(matches(hasDescendant(withText("O, place your ${Player.O.symbol}")))) + .check(matches(hasDescendant(withText("O, place your ${Player.O.symbol}")))) // Now that we're confident the views have updated, back to the activity // to mess with what should be the updated rendering. @@ -134,7 +132,7 @@ class TicTacToeEspressoTest { // email should have been restored from view state. inAnyView(withId(R.id.login_email)).check(matches(withText("foo@bar"))) inAnyView(withId(R.id.login_error_message)) - .check(matches(withText("Unknown email or invalid password"))) + .check(matches(withText("Unknown email or invalid password"))) } @Test fun dialogSurvivesConfigChange() { diff --git a/settings.gradle.kts b/settings.gradle.kts index d1e61b13f..de0cd3cf4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,6 +30,7 @@ include( ":workflow-tracing", ":workflow-ui:backstack-common", ":workflow-ui:backstack-android", + ":workflow-ui:compose", ":workflow-ui:core-common", ":workflow-ui:core-android", ":workflow-ui:internal-testing-android", diff --git a/workflow-ui/backstack-android/src/androidTest/AndroidManifest.xml b/workflow-ui/backstack-android/src/androidTest/AndroidManifest.xml index d53edef3b..5624edbb8 100644 --- a/workflow-ui/backstack-android/src/androidTest/AndroidManifest.xml +++ b/workflow-ui/backstack-android/src/androidTest/AndroidManifest.xml @@ -2,6 +2,8 @@ xmlns:android="http://schemas.android.com/apk/res/android"> - + diff --git a/workflow-ui/backstack-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/BackStackContainerTest.kt b/workflow-ui/backstack-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/BackStackContainerTest.kt deleted file mode 100644 index 998cc4785..000000000 --- a/workflow-ui/backstack-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/BackStackContainerTest.kt +++ /dev/null @@ -1,210 +0,0 @@ -package com.squareup.workflow1.ui.backstack.test - -import android.content.Context -import android.os.Bundle -import android.view.View -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withTagValue -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backstack.test.fixtures.BackStackTestActivity -import com.squareup.workflow1.ui.backstack.test.fixtures.BackStackTestActivity.TestRendering -import com.squareup.workflow1.ui.backstack.test.fixtures.ViewStateTestView -import com.squareup.workflow1.ui.backstack.test.fixtures.ViewStateTestView.ViewHooks -import com.squareup.workflow1.ui.backstack.toBackStackScreen -import org.hamcrest.Matchers.equalTo -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@OptIn(WorkflowUiExperimentalApi::class) -@RunWith(AndroidJUnit4::class) -internal class BackStackContainerTest { - - @Rule @JvmField val scenarioRule = ActivityScenarioRule(BackStackTestActivity::class.java) - private val scenario get() = scenarioRule.scenario - - @Test fun restores_view_on_pop_without_config_change() { - lateinit var firstScreen: TestRendering - lateinit var firstView: View - - scenario.onActivity { activity -> - // Set some view state to be saved and restored. - activity.currentTestView.viewState = "hello world" - firstView = activity.currentTestView - firstScreen = activity.backstack!!.top - } - - // Navigate to another screen. - setBackstack(firstScreen, TestRendering("new screen")) - assertThat(viewForScreen("new screen").viewState).isEqualTo("") - - // Navigate back. - setBackstack(firstScreen) - - viewForScreen("initial").let { - // Ensure that the view instance wasn't re-used. - assertThat(it).isNotSameInstanceAs(firstView) - - // Check that the view state was actually restored. - assertThat(it.viewState).isEqualTo("hello world") - } - } - - @Test fun restores_view_after_config_change() { - scenario.onActivity { activity -> - // Set some view state to be saved and restored. - activity.currentTestView.viewState = "hello world" - } - - // Destroy and recreate the activity. - scenario.recreate() - - // Check that the view state was actually restored. - assertThat(viewForScreen("initial").viewState).isEqualTo("hello world") - } - - @Test fun ignores_restored_view_state_of_unknown_type() { - var saved = false - - class WeirdView(context: Context) : View(context) { - override fun onSaveInstanceState(): Bundle { - saved = true - super.onSaveInstanceState() - return Bundle() - } - } - - scenario.onActivity { activity -> - // Set some view state to be saved and never restored. - activity.currentTestView.viewState = "hello world" - - // Clobber our view *and its id* with something completely different. - val weirdView = WeirdView(activity).apply { - id = activity.backstackContainer!!.id - } - activity.setContentView(weirdView) - } - - // Destroy and recreate the activity. - scenario.recreate() - - // Test the test. - assertThat(saved).isTrue() - - // We haven't crashed, good start. - // Check that the view state really was dropped. - assertThat(viewForScreen("initial").viewState).isEmpty() - } - - @Test fun restores_view_on_pop_after_config_change() { - lateinit var firstScreen: TestRendering - - scenario.onActivity { activity -> - // Set some view state to be saved and restored. - activity.currentTestView.viewState = "hello world" - firstScreen = activity.backstack!!.top - } - - // Navigate to another screen. - setBackstack(firstScreen, TestRendering("new screen")) - assertThat(viewForScreen("new screen").viewState).isEqualTo("") - - // Destroy and recreate the activity. - scenario.recreate() - - // Navigate back. - setBackstack(firstScreen) - - // Check that the view state was actually restored. - assertThat(viewForScreen("initial").viewState).isEqualTo("hello world") - } - - @Test fun state_rendering_and_attach_ordering() { - val events = mutableListOf() - fun log( - name: String, - event: String, - view: ViewStateTestView - ) { - events += "$name $event viewState=${view.viewState}" - } - - fun consumeEvents() = events.toList().also { events.clear() } - - fun testRendering(name: String) = TestRendering( - name = name, - onViewCreated = { view -> log(name, "onViewCreated", view) }, - onShowRendering = { view -> log(name, "onShowRendering", view) }, - viewHooks = object : ViewHooks { - override fun onSaveInstanceState(view: ViewStateTestView) = log(name, "onSave", view) - override fun onRestoreInstanceState(view: ViewStateTestView) = log(name, "onRestore", view) - override fun onAttach(view: ViewStateTestView) = log(name, "onAttach", view) - override fun onDetach(view: ViewStateTestView) = log(name, "onDetach", view) - } - ) - - val firstRendering = testRendering("first") - val secondRendering = testRendering("second") - - // Setup initial screen. - setBackstack(firstRendering) - waitForScreen(firstRendering.name) - scenario.onActivity { - it.currentTestView.viewState = "hello" - } - assertThat(consumeEvents()).containsExactly( - "first onViewCreated viewState=", - "first onShowRendering viewState=", - "first onShowRendering viewState=", - "first onAttach viewState=" - ) - - // Navigate forward. - setBackstack(firstRendering, secondRendering) - waitForScreen(secondRendering.name) - assertThat(consumeEvents()).containsExactly( - "second onViewCreated viewState=", - "second onShowRendering viewState=", - "second onShowRendering viewState=", - "first onSave viewState=hello", - "first onDetach viewState=hello", - "second onAttach viewState=" - ) - - // Navigate back. - setBackstack(firstRendering) - waitForScreen(firstRendering.name) - assertThat(consumeEvents()).containsExactly( - "first onViewCreated viewState=", - "first onShowRendering viewState=", - "first onShowRendering viewState=", - "first onRestore viewState=hello", - "second onDetach viewState=", - "first onAttach viewState=hello" - ) - } - - private fun setBackstack(vararg renderings: TestRendering) { - scenario.onActivity { - it.backstack = renderings.asList().toBackStackScreen() - } - } - - private fun viewForScreen(name: String): ViewStateTestView { - waitForScreen(name) - lateinit var view: ViewStateTestView - scenario.onActivity { - view = it.currentTestView - } - return view - } - - private fun waitForScreen(name: String) { - onView(withTagValue(equalTo(name))).check(matches(isCompletelyDisplayed())) - } -} diff --git a/workflow-ui/backstack-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/BackstackContainerTest.kt b/workflow-ui/backstack-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/BackstackContainerTest.kt new file mode 100644 index 000000000..926f9e780 --- /dev/null +++ b/workflow-ui/backstack-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/BackstackContainerTest.kt @@ -0,0 +1,491 @@ +package com.squareup.workflow1.ui.backstack.test + +import android.view.View +import androidx.lifecycle.Lifecycle.State.CREATED +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.Lifecycle.State.STARTED +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.google.common.truth.Truth.assertThat +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.viewForScreen +import com.squareup.workflow1.ui.backstack.test.fixtures.waitForScreen +import org.junit.Rule +import org.junit.Test + +internal class BackstackContainerTest { + + @Rule @JvmField internal val scenarioRule = + ActivityScenarioRule(BackStackContainerLifecycleActivity::class.java) + private val scenario get() = scenarioRule.scenario + + // region Basic instance state save/restore tests + + @Test fun restores_view_on_pop_without_config_change() { + val firstScreen = LeafRendering("initial") + lateinit var firstView: View + + scenario.onActivity { + it.update(firstScreen) + } + + scenario.onActivity { + // Set some view state to be saved and restored. + it.currentTestView.viewState = "hello world" + firstView = it.currentTestView + } + + // Navigate to another screen. + scenario.onActivity { + it.update(firstScreen, LeafRendering("new screen")) + } + assertThat(scenario.viewForScreen("new screen").viewState).isEqualTo("") + + // Navigate back. + scenario.onActivity { + it.update(firstScreen) + } + + scenario.viewForScreen("initial").let { + // Ensure that the view instance wasn't re-used. + assertThat(it).isNotSameInstanceAs(firstView) + + // Check that the view state was actually restored. + assertThat(it.viewState).isEqualTo("hello world") + } + } + + @Test fun restores_current_view_after_config_change() { + val firstScreen = LeafRendering("initial") + + scenario.onActivity { + it.update(firstScreen) + } + + scenario.onActivity { + // Set some view state to be saved and restored. + it.currentTestView.viewState = "hello world" + } + + // Destroy and recreate the activity. + scenario.recreate() + + // Check that the view state was actually restored. + scenario.viewForScreen(("initial")).let { + assertThat(it.viewState).isEqualTo("hello world") + } + } + + @Test fun restores_view_on_pop_after_config_change() { + val firstScreen = LeafRendering("initial") + + scenario.onActivity { + it.update(firstScreen) + } + + scenario.onActivity { + // Set some view state to be saved and restored. + it.currentTestView.viewState = "hello world" + } + + // Navigate to another screen. + // Navigate to another screen. + scenario.onActivity { + it.update(firstScreen, LeafRendering("new screen")) + } + assertThat(scenario.viewForScreen("new screen").viewState).isEqualTo("") + + // Destroy and recreate the activity. + scenario.recreate() + + // Navigate back. + scenario.onActivity { + it.update(firstScreen) + } + + // Check that the view state was actually restored. + assertThat(scenario.viewForScreen("initial").viewState).isEqualTo("hello world") + } + + @Test fun state_rendering_and_attach_ordering() { + val firstRendering = LeafRendering("first") + val secondRendering = LeafRendering("second") + + // Setup initial screen. + scenario.onActivity { + it.update(firstRendering) + } + waitForScreen(firstRendering.name) + scenario.onActivity { + it.currentTestView.viewState = "hello" + + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "first onViewCreated viewState=", + "first onShowRendering viewState=", + "first onAttach viewState=" + ).inOrder() + } + + // Navigate forward. + scenario.onActivity { + it.update(firstRendering, secondRendering) + } + waitForScreen(secondRendering.name) + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "second onViewCreated viewState=", + "second onShowRendering viewState=", + "first onSave viewState=hello", + "first onDetach viewState=hello", + "second onAttach viewState=" + ).inOrder() + } + + // Navigate back. + scenario.onActivity { + it.update(firstRendering) + } + waitForScreen(firstRendering.name) + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "first onViewCreated viewState=", + "first onShowRendering viewState=", + "first onRestore viewState=hello", + "second onDetach viewState=", + "first onAttach viewState=hello" + ).inOrder() + } + } + + // endregion + // region Lifecycle tests + + /** + * We test stop instead of pause because on older Android versions (e.g. level 21), + * `moveToState(STARTED)` will also stop the lifecycle, not just pause it. By just using stopped, + * which is consistent across all the versions we care about, we don't need to special-case our + * assertions, but we're still testing fundamentally the same thing (moving between non-terminal + * lifecycle states). + */ + @Test fun lifecycle_stop_then_resume() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + it.update(LeafRendering("initial")) + } + + scenario.onActivity { activity -> + assertThat(activity.consumeLifecycleEvents()).containsAtLeast( + "activity onCreate", + "activity onStart", + "activity onResume", + "initial onAttach viewState=", + "LeafView initial ON_CREATE", + "LeafView initial ON_START", + "LeafView initial ON_RESUME", + ).inOrder() + } + + scenario.moveToState(CREATED) + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "LeafView initial ON_PAUSE", + "activity onPause", + "LeafView initial ON_STOP", + "activity onStop", + ).inOrder() + } + + scenario.moveToState(RESUMED) + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "activity onStart", + "LeafView initial ON_START", + "activity onResume", + "LeafView initial ON_RESUME", + ) + } + } + + @Test fun lifecycle_recreate_rendering() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + it.update(LeafRendering("initial")) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "activity onCreate", + "activity onStart", + "activity onResume", + "initial onAttach viewState=", + "LeafView initial ON_CREATE", + "LeafView initial ON_START", + "LeafView initial ON_RESUME", + ).inOrder() + } + + scenario.onActivity { + it.recreateViewsOnNextRendering() + it.update(LeafRendering("recreated")) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "initial onDetach viewState=", + "LeafView initial ON_PAUSE", + "LeafView initial ON_STOP", + "LeafView initial ON_DESTROY", + "recreated onAttach viewState=", + "LeafView recreated ON_CREATE", + "LeafView recreated ON_START", + "LeafView recreated ON_RESUME", + ).inOrder() + } + } + + @Test fun lifecycle_recreate_activity() { + lateinit var initialActivity: BackStackContainerLifecycleActivity + + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + it.update(LeafRendering("initial")) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "activity onCreate", + "activity onStart", + "activity onResume", + "initial onAttach viewState=", + "LeafView initial ON_CREATE", + "LeafView initial ON_START", + "LeafView initial ON_RESUME", + ).inOrder() + + // Store a reference to the activity so we can get events from it after destroying. + initialActivity = it + + // Don't call update automatically after restoring, we want to set our own screen with a + // different rendering. + it.restoreRenderingAfterConfigChange = false + } + + scenario.recreate() + scenario.onActivity { + it.update(LeafRendering("recreated")) + } + + scenario.onActivity { + assertThat(initialActivity.consumeLifecycleEvents()).containsAtLeast( + "LeafView initial ON_PAUSE", + "activity onPause", + "LeafView initial ON_STOP", + "activity onStop", + "LeafView initial ON_DESTROY", + "activity onDestroy", + "initial onDetach viewState=", + ).inOrder() + + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "activity onCreate", + "activity onStart", + "activity onResume", + "recreated onAttach viewState=", + "LeafView recreated ON_CREATE", + "LeafView recreated ON_START", + "LeafView recreated ON_RESUME", + ).inOrder() + } + } + + @Test fun lifecycle_replace_screen() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + it.update(LeafRendering("initial")) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "activity onCreate", + "activity onStart", + "activity onResume", + "initial onAttach viewState=", + "LeafView initial ON_CREATE", + "LeafView initial ON_START", + "LeafView initial ON_RESUME", + ).inOrder() + } + + scenario.onActivity { + it.update(LeafRendering("next")) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "initial onDetach viewState=", + "next onAttach viewState=", + "LeafView next ON_CREATE", + "LeafView next ON_START", + "LeafView next ON_RESUME", + "LeafView initial ON_PAUSE", + "LeafView initial ON_STOP", + "LeafView initial ON_DESTROY", + ).inOrder() + } + } + + @Test fun lifecycle_replace_after_pause() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + it.update(LeafRendering("initial")) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "activity onCreate", + "activity onStart", + "activity onResume", + "initial onAttach viewState=", + "LeafView initial ON_CREATE", + "LeafView initial ON_START", + "LeafView initial ON_RESUME", + ).inOrder() + } + + scenario.moveToState(STARTED) + + scenario.onActivity { + it.update(LeafRendering("next")) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "LeafView initial ON_PAUSE", + "activity onPause", + "initial onDetach viewState=", + "next onAttach viewState=", + "LeafView next ON_CREATE", + "LeafView next ON_START", + "LeafView initial ON_STOP", + "LeafView initial ON_DESTROY", + ).inOrder() + } + } + + @Test fun lifecycle_nested_lifecycle() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + it.consumeLifecycleEvents() + it.update(RecurseRendering(listOf(LeafRendering("wrapped")))) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "wrapped onAttach viewState=", + "LeafView wrapped ON_CREATE", + "LeafView wrapped ON_START", + "LeafView wrapped ON_RESUME", + ).inOrder() + } + + scenario.onActivity { + it.update(LeafRendering("unwrapped")) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "wrapped onDetach viewState=", + "unwrapped onAttach viewState=", + "LeafView unwrapped ON_CREATE", + "LeafView unwrapped ON_START", + "LeafView unwrapped ON_RESUME", + "LeafView wrapped ON_PAUSE", + "LeafView wrapped ON_STOP", + "LeafView wrapped ON_DESTROY", + ).inOrder() + } + } + + @Test fun lifecycle_is_destroyed_when_navigating_forward() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + it.consumeLifecycleEvents() + it.update( + LeafRendering("first"), + ) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "first onAttach viewState=", + "LeafView first ON_CREATE", + "LeafView first ON_START", + "LeafView first ON_RESUME", + ).inOrder() + } + + scenario.onActivity { + it.update( + LeafRendering("first"), + LeafRendering("second"), + ) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "first onDetach viewState=", + "second onAttach viewState=", + "LeafView second ON_CREATE", + "LeafView second ON_START", + "LeafView second ON_RESUME", + "LeafView first ON_PAUSE", + "LeafView first ON_STOP", + "LeafView first ON_DESTROY", + ).inOrder() + } + } + + @Test fun lifecycle_is_destroyed_when_navigating_backwards() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + it.consumeLifecycleEvents() + it.update( + LeafRendering("first"), + LeafRendering("second"), + ) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "second onAttach viewState=", + "LeafView second ON_CREATE", + "LeafView second ON_START", + "LeafView second ON_RESUME", + ).inOrder() + } + + scenario.onActivity { + it.update( + LeafRendering("first"), + ) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsAtLeast( + "second onDetach viewState=", + "first onAttach viewState=", + "LeafView first ON_CREATE", + "LeafView first ON_START", + "LeafView first ON_RESUME", + "LeafView second ON_PAUSE", + "LeafView second ON_STOP", + "LeafView second ON_DESTROY", + ).inOrder() + } + } + + // endregion +} diff --git a/workflow-ui/backstack-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/fixtures/BackStackContainerLifecycleActivity.kt b/workflow-ui/backstack-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/fixtures/BackStackContainerLifecycleActivity.kt new file mode 100644 index 000000000..f30b7ba41 --- /dev/null +++ b/workflow-ui/backstack-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/fixtures/BackStackContainerLifecycleActivity.kt @@ -0,0 +1,156 @@ +package com.squareup.workflow1.ui.backstack.test.fixtures + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withTagValue +import com.squareup.workflow1.ui.BuilderViewFactory +import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewFactory +import com.squareup.workflow1.ui.ViewRegistry +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.RecurseRendering +import com.squareup.workflow1.ui.bindShowRendering +import com.squareup.workflow1.ui.internal.test.AbstractLifecycleTestActivity +import com.squareup.workflow1.ui.internal.test.inAnyView +import org.hamcrest.Matcher +import org.hamcrest.Matchers.equalTo +import kotlin.reflect.KClass + +@OptIn(WorkflowUiExperimentalApi::class) +internal class BackStackContainerLifecycleActivity : AbstractLifecycleTestActivity() { + + /** + * Default rendering always shown in the backstack to simplify test configuration. + */ + object BaseRendering : ViewFactory { + override val type: KClass = BaseRendering::class + override fun buildView( + initialRendering: BaseRendering, + initialViewEnvironment: ViewEnvironment, + contextForNewView: Context, + container: ViewGroup? + ): View = View(contextForNewView).apply { + bindShowRendering(initialRendering, initialViewEnvironment) { _, _ -> /* Noop */ } + } + } + + sealed class TestRendering { + data class LeafRendering(val name: String) : TestRendering(), Compatible { + override val compatibilityKey: String get() = name + } + + data class RecurseRendering(val wrappedBackstack: List) : TestRendering() + } + + private val viewObserver = + object : ViewObserver by lifecycleLoggingViewObserver({ it.name }) { + override fun onViewCreated( + view: View, + rendering: LeafRendering + ) { + view.tag = rendering.name + + // Need to set the view to enable view persistence. + view.id = rendering.name.hashCode() + + logEvent("${rendering.name} onViewCreated viewState=${view.viewState}") + } + + override fun onShowRendering( + view: View, + rendering: LeafRendering + ) { + check(view.tag == rendering.name) + logEvent("${rendering.name} onShowRendering viewState=${view.viewState}") + } + + override fun onAttachedToWindow( + view: View, + rendering: LeafRendering + ) { + logEvent("${rendering.name} onAttach viewState=${view.viewState}") + } + + override fun onDetachedFromWindow( + view: View, + rendering: LeafRendering + ) { + logEvent("${rendering.name} onDetach viewState=${view.viewState}") + } + + override fun onSaveInstanceState( + view: View, + rendering: LeafRendering + ) { + logEvent("${rendering.name} onSave viewState=${view.viewState}") + } + + override fun onRestoreInstanceState( + view: View, + rendering: LeafRendering + ) { + logEvent("${rendering.name} onRestore viewState=${view.viewState}") + } + + private val View.viewState get() = (this as ViewStateTestView).viewState + } + + override val viewRegistry: ViewRegistry = ViewRegistry( + NoTransitionBackStackContainer, + BaseRendering, + leafViewBinding(LeafRendering::class, viewObserver, viewConstructor = ::ViewStateTestView), + BuilderViewFactory(RecurseRendering::class) { initialRendering, + initialViewEnvironment, + contextForNewView, _ -> + FrameLayout(contextForNewView).also { container -> + val stub = WorkflowViewStub(contextForNewView) + container.addView(stub) + container.bindShowRendering( + initialRendering, + initialViewEnvironment + ) { rendering, env -> + stub.update(rendering.wrappedBackstack.toBackstackWithBase(), env) + } + } + }, + ) + + /** Returns the view that is the current screen. */ + val currentTestView: ViewStateTestView + get() { + val backstackContainer = rootRenderedView as ViewGroup + check(backstackContainer.childCount == 1) + return backstackContainer.getChildAt(0) as ViewStateTestView + } + + fun update(vararg backstack: TestRendering) = + setRendering(backstack.asList().toBackstackWithBase()) + + private fun List.toBackstackWithBase() = + BackStackScreen(BaseRendering, this) +} + +internal fun ActivityScenario.viewForScreen( + name: String +): ViewStateTestView { + waitForScreen(name) + lateinit var view: ViewStateTestView + onActivity { + view = it.currentTestView + } + return view +} + +internal fun waitForScreen(name: String) { + inAnyView(withTagValue(equalTo(name)) as Matcher) + .check(matches(isCompletelyDisplayed())) +} diff --git a/workflow-ui/backstack-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/fixtures/BackStackTestActivity.kt b/workflow-ui/backstack-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/fixtures/BackStackTestActivity.kt deleted file mode 100644 index a082f8b0a..000000000 --- a/workflow-ui/backstack-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/fixtures/BackStackTestActivity.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.squareup.workflow1.ui.backstack.test.fixtures - -import android.app.Activity -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import com.squareup.workflow1.ui.BuilderViewFactory -import com.squareup.workflow1.ui.Compatible -import com.squareup.workflow1.ui.NamedViewFactory -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewFactory -import com.squareup.workflow1.ui.ViewRegistry -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backstack.BackStackContainer -import com.squareup.workflow1.ui.backstack.BackStackScreen -import com.squareup.workflow1.ui.backstack.test.fixtures.BackStackTestActivity.TestRendering -import com.squareup.workflow1.ui.backstack.test.fixtures.ViewStateTestView.ViewHooks -import com.squareup.workflow1.ui.bindShowRendering -import com.squareup.workflow1.ui.showRendering - -/** - * Basic activity that only contains a [BackStackContainer] for [BackStackContainerTest]. - * You can "navigate" by setting the [backstack] property. The backstack consists of named - * [TestRendering] objects. The backstack value will be preserved across configuration changes via - * [onRetainNonConfigurationInstance]. - */ -@OptIn(WorkflowUiExperimentalApi::class) -internal class BackStackTestActivity : Activity() { - - /** - * A simple string holder that creates [ViewStateTestView]s with their ID and tag derived from - * [name]. This rendering implements [Compatible] and is keyed off [name], so that renderings with - * different names will cause new views to be created. - * - * @param onViewCreated An optional function that will be called by the view factory after the - * view is created but before [bindShowRendering]. - */ - internal class TestRendering( - val name: String, - val onViewCreated: (ViewStateTestView) -> Unit = {}, - val onShowRendering: (ViewStateTestView) -> Unit = {}, - val viewHooks: ViewHooks? = null - ) : Compatible { - override val compatibilityKey: String = name - - /** - * Creates [ViewStateTestView]s with the following attributes: - * - [id][View.getId] is set to the hashcode of [name]. - * - [tag][View.getTag] is set to [name] for easy Espresso matching. - */ - companion object : ViewFactory by ( - BuilderViewFactory( - TestRendering::class - ) { initialRendering, initialViewEnvironment, context, _ -> - ViewStateTestView(context).apply { - id = initialRendering.name.hashCode() - // For espresso matching. - tag = initialRendering.name - viewHooks = initialRendering.viewHooks - initialRendering.onViewCreated(this) - bindShowRendering(initialRendering, initialViewEnvironment) { rendering, _ -> - rendering.onShowRendering(this) - viewHooks = rendering.viewHooks - } - } - }) - } - - private val viewEnvironment = ViewEnvironment( - mapOf(ViewRegistry to ViewRegistry(NamedViewFactory, TestRendering)) - ) - var backstackContainer: View? = null - private set - - var backstack: BackStackScreen? = null - set(value) { - requireNotNull(value) - if (value != field) { - field = value - backstackContainer?.showRendering(value, viewEnvironment) - } - } - - val currentTestView: ViewStateTestView - get() { - val container = backstackContainer as ViewGroup - check(container.childCount == 1) - return container.getChildAt(0) as ViewStateTestView - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - @Suppress("UNCHECKED_CAST") - backstack = lastNonConfigurationInstance as BackStackScreen? - ?: BackStackScreen(TestRendering("initial")) - - check(backstackContainer == null) - backstackContainer = - NoTransitionBackStackContainer.buildView(backstack!!, viewEnvironment, this) - backstackContainer!!.showRendering(backstack!!, viewEnvironment) - setContentView(backstackContainer) - } - - override fun onRetainNonConfigurationInstance(): Any = backstack!! -} diff --git a/workflow-ui/backstack-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/fixtures/ViewStateTestView.kt b/workflow-ui/backstack-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/fixtures/ViewStateTestView.kt index 335da830b..c4ba553c6 100644 --- a/workflow-ui/backstack-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/fixtures/ViewStateTestView.kt +++ b/workflow-ui/backstack-android/src/androidTest/java/com/squareup/workflow1/ui/backstack/test/fixtures/ViewStateTestView.kt @@ -4,47 +4,29 @@ import android.content.Context import android.os.Parcel import android.os.Parcelable import android.os.Parcelable.Creator -import android.view.View +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.backstack.test.fixtures.BackStackContainerLifecycleActivity.TestRendering.LeafRendering +import com.squareup.workflow1.ui.internal.test.AbstractLifecycleTestActivity.LeafView /** * Simple view that has a string [viewState] property that will be saved and restored by the * [onSaveInstanceState] and [onRestoreInstanceState] methods. */ -internal class ViewStateTestView(context: Context) : View(context) { +@OptIn(WorkflowUiExperimentalApi::class) +internal class ViewStateTestView(context: Context) : LeafView(context) { var viewState: String = "" - /** Hooks that will be invoked when the same-named methods are called. */ - interface ViewHooks { - fun onSaveInstanceState(view: ViewStateTestView) - fun onRestoreInstanceState(view: ViewStateTestView) - fun onAttach(view: ViewStateTestView) - fun onDetach(view: ViewStateTestView) - } - - var viewHooks: ViewHooks? = null - override fun onSaveInstanceState(): Parcelable { - viewHooks?.onSaveInstanceState(this) - return SavedState(super.onSaveInstanceState(), viewState) + val superState = super.onSaveInstanceState() + return SavedState(superState, viewState) } - override fun onRestoreInstanceState(state: Parcelable) { + override fun onRestoreInstanceState(state: Parcelable?) { (state as? SavedState)?.let { viewState = it.viewState super.onRestoreInstanceState(state.superState) - } - viewHooks?.onRestoreInstanceState(this) - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - viewHooks?.onAttach(this) - } - - override fun onDetachedFromWindow() { - viewHooks?.onDetach(this) - super.onDetachedFromWindow() + } ?: super.onRestoreInstanceState(state) } private class SavedState : BaseSavedState { diff --git a/workflow-ui/backstack-android/src/main/java/com/squareup/workflow1/ui/backstack/BackStackContainer.kt b/workflow-ui/backstack-android/src/main/java/com/squareup/workflow1/ui/backstack/BackStackContainer.kt index 57c83eab8..ece00fa63 100644 --- a/workflow-ui/backstack-android/src/main/java/com/squareup/workflow1/ui/backstack/BackStackContainer.kt +++ b/workflow-ui/backstack-android/src/main/java/com/squareup/workflow1/ui/backstack/BackStackContainer.kt @@ -18,6 +18,7 @@ import com.squareup.workflow1.ui.Named import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.ViewFactory import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.WorkflowLifecycleOwner import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.backstack.BackStackConfig.First import com.squareup.workflow1.ui.backstack.BackStackConfig.Other @@ -25,6 +26,7 @@ import com.squareup.workflow1.ui.bindShowRendering import com.squareup.workflow1.ui.buildView import com.squareup.workflow1.ui.canShowRendering import com.squareup.workflow1.ui.compatible +import com.squareup.workflow1.ui.showFirstRendering import com.squareup.workflow1.ui.showRendering /** @@ -70,13 +72,20 @@ public open class BackStackContainer @JvmOverloads constructor( initialRendering = named.top, initialViewEnvironment = environment, contextForNewView = this.context, - container = this + container = this, + initializeView = { + WorkflowLifecycleOwner.installOn(this) + showFirstRendering() + } ) viewStateCache.update(named.backStack, oldViewMaybe, newView) val popped = currentRendering?.backStack?.any { compatible(it, named.top) } == true performTransition(oldViewMaybe, newView, popped) + // Notify the view we're about to replace that it's going away. + oldViewMaybe?.let(WorkflowLifecycleOwner::get)?.destroyOnDetach() + currentRendering = named } diff --git a/workflow-ui/backstack-android/src/main/java/com/squareup/workflow1/ui/backstack/ViewStateCache.kt b/workflow-ui/backstack-android/src/main/java/com/squareup/workflow1/ui/backstack/ViewStateCache.kt index 84ad16d26..cfcdd2a21 100644 --- a/workflow-ui/backstack-android/src/main/java/com/squareup/workflow1/ui/backstack/ViewStateCache.kt +++ b/workflow-ui/backstack-android/src/main/java/com/squareup/workflow1/ui/backstack/ViewStateCache.kt @@ -8,8 +8,8 @@ import android.view.View import android.view.View.BaseSavedState import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting.PRIVATE -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.Named +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.backstack.ViewStateCache.SavedState import com.squareup.workflow1.ui.getRendering @@ -67,25 +67,25 @@ internal constructor( ) { val newKey = newView.namedKey val hiddenKeys = retainedRenderings.asSequence() - .map { it.compatibilityKey } - .toSet() - .apply { - require(retainedRenderings.size == size) { - "Duplicate entries not allowed in $retainedRenderings." - } + .map { it.compatibilityKey } + .toSet() + .apply { + require(retainedRenderings.size == size) { + "Duplicate entries not allowed in $retainedRenderings." } + } viewStates.remove(newKey) - ?.let { newView.restoreHierarchyState(it.viewState) } + ?.let { newView.restoreHierarchyState(it.viewState) } if (oldViewMaybe != null) { oldViewMaybe.namedKey.takeIf { hiddenKeys.contains(it) } - ?.let { savedKey -> - val saved = SparseArray().apply { - oldViewMaybe.saveHierarchyState(this) - } - viewStates += savedKey to ViewStateFrame(savedKey, saved) + ?.let { savedKey -> + val saved = SparseArray().apply { + oldViewMaybe.saveHierarchyState(this) } + viewStates += savedKey to ViewStateFrame(savedKey, saved) + } } pruneKeys(hiddenKeys) @@ -145,14 +145,21 @@ internal constructor( parcel: Parcel, flags: Int ) { - parcel.writeMap(viewStates as Map<*, *>) + @Suppress("UNCHECKED_CAST") + parcel.writeMap(viewStates as MutableMap) } public companion object CREATOR : Creator { override fun createFromParcel(parcel: Parcel): ViewStateCache { + @Suppress("UNCHECKED_CAST") return mutableMapOf() - .apply { parcel.readMap(this as Map<*, *>, ViewStateCache::class.java.classLoader) } - .let { ViewStateCache(it) } + .apply { + parcel.readMap( + this as MutableMap, + ViewStateCache::class.java.classLoader + ) + } + .let { ViewStateCache(it) } } override fun newArray(size: Int): Array = arrayOfNulls(size) diff --git a/workflow-ui/compose/README.md b/workflow-ui/compose/README.md new file mode 100644 index 000000000..9805ec159 --- /dev/null +++ b/workflow-ui/compose/README.md @@ -0,0 +1,6 @@ +# compose + +This module currently only hosts integration tests to verify that Compose can be used with the +workflow navigation infrastructure (ViewTreeOwners etc). Since it is only tests, it is not shipped +as an artifact. Eventually, it will host actual Compose integration as well, at which point we will +start shipping it. diff --git a/workflow-ui/compose/api/compose.api b/workflow-ui/compose/api/compose.api new file mode 100644 index 000000000..e69de29bb diff --git a/workflow-ui/compose/build.gradle.kts b/workflow-ui/compose/build.gradle.kts new file mode 100644 index 000000000..c7ddcd550 --- /dev/null +++ b/workflow-ui/compose/build.gradle.kts @@ -0,0 +1,50 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("com.android.library") + kotlin("android") + id("org.jetbrains.dokka") +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +apply(from = rootProject.file(".buildscript/configure-android-defaults.gradle")) +apply(from = rootProject.file(".buildscript/android-ui-tests.gradle")) + +android { + // See https://github.com/Kotlin/kotlinx.coroutines/issues/1064#issuecomment-479412940 + packagingOptions.excludes += "**/*.kotlin_*" + + buildFeatures.compose = true + composeOptions { + kotlinCompilerExtensionVersion = "1.0.1" + } +} + +tasks.withType { + kotlinOptions { + @Suppress("SuspiciousCollectionReassignment") + freeCompilerArgs += listOf( + "-Xopt-in=kotlin.RequiresOptIn" + ) + } +} + +dependencies { + api(project(":workflow-core")) + api(project(":workflow-ui:backstack-android")) + api(project(":workflow-ui:core-android")) + + api(Dependencies.Kotlin.Stdlib.jdk8) + + androidTestImplementation(project(":workflow-runtime")) + androidTestImplementation(Dependencies.AndroidX.activity) + androidTestImplementation(Dependencies.AndroidX.Compose.foundation) + androidTestImplementation(Dependencies.AndroidX.Compose.ui) + androidTestImplementation(Dependencies.Test.AndroidX.core) + androidTestImplementation(Dependencies.Test.AndroidX.truthExt) + androidTestImplementation(Dependencies.Test.AndroidX.compose) +} diff --git a/workflow-ui/compose/src/androidTest/AndroidManifest.xml b/workflow-ui/compose/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..3e283317b --- /dev/null +++ b/workflow-ui/compose/src/androidTest/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt new file mode 100644 index 000000000..2a8fa1ae0 --- /dev/null +++ b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt @@ -0,0 +1,208 @@ +package com.squareup.workflow1.ui.compose + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnDetachedFromWindow +import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.google.common.truth.Truth.assertThat +import com.squareup.workflow1.ui.AndroidViewRendering +import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.NamedViewFactory +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewFactory +import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.backstack.BackStackScreen +import com.squareup.workflow1.ui.bindShowRendering +import com.squareup.workflow1.ui.internal.test.WorkflowUiTestActivity +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.reflect.KClass + +@OptIn(WorkflowUiExperimentalApi::class) +internal class ComposeViewTreeIntegrationTest { + + @Rule @JvmField val composeRule = createAndroidComposeRule() + private val scenario get() = composeRule.activityRule.scenario + + @Before fun setUp() { + scenario.onActivity { + it.viewEnvironment = ViewEnvironment( + mapOf( + ViewRegistry to ViewRegistry( + NoTransitionBackStackContainer, + NamedViewFactory, + ) + ) + ) + } + } + + @Test fun compose_view_assertions_work() { + val firstScreen = ComposeRendering("first") { + BasicText("First Screen") + } + val secondScreen = ComposeRendering("second") {} + + scenario.onActivity { + it.setBackstack(firstScreen) + } + + composeRule.onNodeWithText("First Screen").assertIsDisplayed() + + // Navigate away from the first screen. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + composeRule.onNodeWithText("First Screen").assertDoesNotExist() + } + + @Test fun composition_is_disposed_when_navigated_away_dispose_on_detach_strategy() { + var composedCount = 0 + var disposedCount = 0 + val firstScreen = ComposeRendering("first", disposeStrategy = DisposeOnDetachedFromWindow) { + DisposableEffect(Unit) { + composedCount++ + onDispose { + disposedCount++ + } + } + } + val secondScreen = ComposeRendering("second") {} + + scenario.onActivity { + it.setBackstack(firstScreen) + } + + composeRule.runOnIdle { + assertThat(composedCount).isEqualTo(1) + assertThat(disposedCount).isEqualTo(0) + } + + // Navigate away. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + composeRule.runOnIdle { + assertThat(composedCount).isEqualTo(1) + assertThat(disposedCount).isEqualTo(1) + } + } + + @Test fun composition_is_disposed_when_navigated_away_dispose_on_destroy_strategy() { + var composedCount = 0 + var disposedCount = 0 + val firstScreen = + ComposeRendering("first", disposeStrategy = DisposeOnViewTreeLifecycleDestroyed) { + DisposableEffect(Unit) { + composedCount++ + onDispose { + disposedCount++ + } + } + } + val secondScreen = ComposeRendering("second") {} + + scenario.onActivity { + it.setBackstack(firstScreen) + } + + composeRule.runOnIdle { + assertThat(composedCount).isEqualTo(1) + assertThat(disposedCount).isEqualTo(0) + } + + // Navigate away. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + composeRule.runOnIdle { + assertThat(composedCount).isEqualTo(1) + assertThat(disposedCount).isEqualTo(1) + } + } + + @Test fun composition_state_is_restored_after_config_change() { + var state: MutableState? = null + val firstScreen = ComposeRendering("first") { + val innerState = rememberSaveable { mutableStateOf("hello world") } + DisposableEffect(Unit) { + state = innerState + onDispose { state = null } + } + } + + // Show first screen to initialize state. + scenario.onActivity { + it.setBackstack(firstScreen) + } + composeRule.runOnIdle { + assertThat(state!!.value).isEqualTo("hello world") + } + state!!.value = "saved" + + // Simulate config change. + scenario.recreate() + + composeRule.runOnIdle { + assertThat(state!!.value).isEqualTo("saved") + } + } + + private fun WorkflowUiTestActivity.setBackstack(vararg backstack: ComposeRendering) { + setRendering(BackStackScreen(EmptyRendering, backstack.asList())) + } + + data class ComposeRendering( + override val compatibilityKey: String, + val disposeStrategy: ViewCompositionStrategy? = null, + val content: @Composable () -> Unit + ) : Compatible, AndroidViewRendering, ViewFactory { + override val type: KClass = ComposeRendering::class + override val viewFactory: ViewFactory get() = this + + override fun buildView( + initialRendering: ComposeRendering, + initialViewEnvironment: ViewEnvironment, + contextForNewView: Context, + container: ViewGroup? + ): View { + var lastCompositionStrategy = initialRendering.disposeStrategy + + return ComposeView(contextForNewView).apply { + lastCompositionStrategy?.let(::setViewCompositionStrategy) + + bindShowRendering(initialRendering, initialViewEnvironment) { rendering, _ -> + if (rendering.disposeStrategy != lastCompositionStrategy) { + lastCompositionStrategy = rendering.disposeStrategy + lastCompositionStrategy?.let(::setViewCompositionStrategy) + } + + setContent(rendering.content) + } + } + } + } + + companion object { + // Use a ComposeView here because the Compose test infra doesn't like it if there are no + // Compose views at all. See https://issuetracker.google.com/issues/179455327. + val EmptyRendering = ComposeRendering(compatibilityKey = "") {} + } +} diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt new file mode 100644 index 000000000..b10658f61 --- /dev/null +++ b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt @@ -0,0 +1,40 @@ +package com.squareup.workflow1.ui.compose + +import android.content.Context +import android.view.View +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import com.squareup.workflow1.ui.BuilderViewFactory +import com.squareup.workflow1.ui.ViewFactory +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.backstack.BackStackContainer +import com.squareup.workflow1.ui.backstack.BackStackScreen +import com.squareup.workflow1.ui.backstack.R +import com.squareup.workflow1.ui.bindShowRendering + +/** + * A subclass of [BackStackContainer] that disables transitions to make it simpler to test the + * actual backstack logic. Views are just swapped instantly. + */ +// TODO (https://github.com/square/workflow-kotlin/issues/306) Remove once BackStackContainer is +// transition-ignorant. +@OptIn(WorkflowUiExperimentalApi::class) +internal class NoTransitionBackStackContainer(context: Context) : BackStackContainer(context) { + + override fun performTransition(oldViewMaybe: View?, newView: View, popped: Boolean) { + oldViewMaybe?.let(::removeView) + addView(newView) + } + + companion object : ViewFactory> + by BuilderViewFactory( + type = BackStackScreen::class, + viewConstructor = { initialRendering, initialEnv, context, _ -> + NoTransitionBackStackContainer(context) + .apply { + id = R.id.workflow_back_stack_container + layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) + bindShowRendering(initialRendering, initialEnv, ::update) + } + } + ) +} diff --git a/workflow-ui/compose/src/main/AndroidManifest.xml b/workflow-ui/compose/src/main/AndroidManifest.xml new file mode 100644 index 000000000..bc5487327 --- /dev/null +++ b/workflow-ui/compose/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/workflow-ui/core-android/api/core-android.api b/workflow-ui/core-android/api/core-android.api index 14ca81bce..6bf307d37 100644 --- a/workflow-ui/core-android/api/core-android.api +++ b/workflow-ui/core-android/api/core-android.api @@ -144,6 +144,11 @@ public final class com/squareup/workflow1/ui/ViewShowRenderingKt { public static final fun showRendering (Landroid/view/View;Ljava/lang/Object;Lcom/squareup/workflow1/ui/ViewEnvironment;)V } +public final class com/squareup/workflow1/ui/WorkflowAndroidXSupport { + public static final field INSTANCE Lcom/squareup/workflow1/ui/WorkflowAndroidXSupport; + public final fun lifecycleOwnerFromViewTreeOrContext (Landroid/view/View;)Landroidx/lifecycle/LifecycleOwner; +} + public abstract class com/squareup/workflow1/ui/WorkflowFragment : androidx/fragment/app/Fragment { public fun ()V protected fun getLayoutParamsOverride ()Landroid/view/ViewGroup$LayoutParams; @@ -163,6 +168,17 @@ public final class com/squareup/workflow1/ui/WorkflowLayout : android/widget/Fra public static synthetic fun start$default (Lcom/squareup/workflow1/ui/WorkflowLayout;Lkotlinx/coroutines/flow/Flow;Lcom/squareup/workflow1/ui/ViewEnvironment;ILjava/lang/Object;)V } +public abstract interface class com/squareup/workflow1/ui/WorkflowLifecycleOwner : androidx/lifecycle/LifecycleOwner { + public static final field Companion Lcom/squareup/workflow1/ui/WorkflowLifecycleOwner$Companion; + public abstract fun destroyOnDetach ()V +} + +public final class com/squareup/workflow1/ui/WorkflowLifecycleOwner$Companion { + public final fun get (Landroid/view/View;)Lcom/squareup/workflow1/ui/WorkflowLifecycleOwner; + public final fun installOn (Landroid/view/View;Lkotlin/jvm/functions/Function0;)V + public static synthetic fun installOn$default (Lcom/squareup/workflow1/ui/WorkflowLifecycleOwner$Companion;Landroid/view/View;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V +} + public abstract interface class com/squareup/workflow1/ui/WorkflowRunner { public static final field Companion Lcom/squareup/workflow1/ui/WorkflowRunner$Companion; public abstract fun getRenderings ()Lkotlinx/coroutines/flow/StateFlow; diff --git a/workflow-ui/core-android/build.gradle.kts b/workflow-ui/core-android/build.gradle.kts index 8645bf199..40070cde9 100644 --- a/workflow-ui/core-android/build.gradle.kts +++ b/workflow-ui/core-android/build.gradle.kts @@ -39,5 +39,7 @@ dependencies { testImplementation(Dependencies.Test.AndroidX.core) testImplementation(Dependencies.Test.robolectric) + androidTestImplementation(Dependencies.AndroidX.appcompat) + androidTestImplementation(Dependencies.AndroidX.Lifecycle.viewModel) androidTestImplementation(Dependencies.Test.truth) } diff --git a/workflow-ui/core-android/src/androidTest/AndroidManifest.xml b/workflow-ui/core-android/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..f09bff947 --- /dev/null +++ b/workflow-ui/core-android/src/androidTest/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/WorkflowViewStubLifecycleActivity.kt b/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/WorkflowViewStubLifecycleActivity.kt new file mode 100644 index 000000000..c095a6ed3 --- /dev/null +++ b/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/WorkflowViewStubLifecycleActivity.kt @@ -0,0 +1,44 @@ +package com.squareup.workflow1.ui + +import android.os.Bundle +import android.widget.FrameLayout +import com.squareup.workflow1.ui.WorkflowViewStubLifecycleActivity.TestRendering.LeafRendering +import com.squareup.workflow1.ui.WorkflowViewStubLifecycleActivity.TestRendering.RecurseRendering +import com.squareup.workflow1.ui.internal.test.AbstractLifecycleTestActivity + +@OptIn(WorkflowUiExperimentalApi::class) +internal class WorkflowViewStubLifecycleActivity : AbstractLifecycleTestActivity() { + + sealed class TestRendering { + data class LeafRendering(val name: String) : TestRendering(), Compatible { + override val compatibilityKey: String get() = name + } + + data class RecurseRendering(val wrapped: LeafRendering) : TestRendering() + } + + override val viewRegistry: ViewRegistry = ViewRegistry( + leafViewBinding(LeafRendering::class, lifecycleLoggingViewObserver { it.name }), + BuilderViewFactory(RecurseRendering::class) { initialRendering, + initialViewEnvironment, + contextForNewView, _ -> + FrameLayout(contextForNewView).also { container -> + val stub = WorkflowViewStub(contextForNewView) + container.addView(stub) + container.bindShowRendering( + initialRendering, + initialViewEnvironment + ) { rendering, env -> + stub.update(rendering.wrapped, env) + } + } + }, + ) + + fun update(rendering: TestRendering) = super.setRendering(rendering) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + update(LeafRendering("initial")) + } +} diff --git a/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/WorkflowViewStubLifecycleTest.kt b/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/WorkflowViewStubLifecycleTest.kt new file mode 100644 index 000000000..e4672f8a2 --- /dev/null +++ b/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/WorkflowViewStubLifecycleTest.kt @@ -0,0 +1,202 @@ +package com.squareup.workflow1.ui + +import androidx.lifecycle.Lifecycle.State.CREATED +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.LifecycleOwner +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.google.common.truth.Truth.assertThat +import com.squareup.workflow1.ui.WorkflowViewStubLifecycleActivity.TestRendering.LeafRendering +import com.squareup.workflow1.ui.WorkflowViewStubLifecycleActivity.TestRendering.RecurseRendering +import org.junit.Rule +import org.junit.Test + +/** + * Tests for [WorkflowViewStub]'s [LifecycleOwner] integration. + */ +internal class WorkflowViewStubLifecycleTest { + + @Rule @JvmField internal val scenarioRule = + ActivityScenarioRule(WorkflowViewStubLifecycleActivity::class.java) + private val scenario get() = scenarioRule.scenario + + /** + * We test stop instead of pause because on older Android versions (e.g. level 21), + * `moveToState(STARTED)` will also stop the lifecycle, not just pause it. By just using stopped, + * which is consistent across all the versions we care about, we don't need to special-case our + * assertions, but we're still testing fundamentally the same thing (moving between non-terminal + * lifecycle states). + */ + @Test fun stop_then_resume() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "activity onCreate", + "activity onStart", + "activity onResume", + "LeafView initial onAttached", + "LeafView initial ON_CREATE", + "LeafView initial ON_START", + "LeafView initial ON_RESUME", + ) + } + + scenario.moveToState(CREATED) + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "LeafView initial ON_PAUSE", + "activity onPause", + "LeafView initial ON_STOP", + "activity onStop", + ) + } + + scenario.moveToState(RESUMED) + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "activity onStart", + "activity onResume", + "LeafView initial ON_START", + "LeafView initial ON_RESUME", + ) + } + } + + @Test fun recreate() { + lateinit var initialActivity: WorkflowViewStubLifecycleActivity + + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "activity onCreate", + "activity onStart", + "activity onResume", + "LeafView initial onAttached", + "LeafView initial ON_CREATE", + "LeafView initial ON_START", + "LeafView initial ON_RESUME", + ) + + // Store a reference to the activity so we can get events from it after destroying. + initialActivity = it + it.restoreRenderingAfterConfigChange = false + } + + scenario.recreate() + + scenario.onActivity { + assertThat(initialActivity.consumeLifecycleEvents()).containsExactly( + "LeafView initial ON_PAUSE", + "activity onPause", + "LeafView initial ON_STOP", + "activity onStop", + "LeafView initial ON_DESTROY", + "activity onDestroy", + "LeafView initial onDetached", + ) + + assertThat(it.consumeLifecycleEvents()).containsExactly( + "activity onCreate", + "activity onStart", + "activity onResume", + "LeafView initial onAttached", + "LeafView initial ON_CREATE", + "LeafView initial ON_START", + "LeafView initial ON_RESUME", + ) + } + } + + @Test fun replace_child() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "activity onCreate", + "activity onStart", + "activity onResume", + "LeafView initial onAttached", + "LeafView initial ON_CREATE", + "LeafView initial ON_START", + "LeafView initial ON_RESUME", + ) + + it.update(LeafRendering("next")) + + assertThat(it.consumeLifecycleEvents()).containsExactly( + "LeafView initial onDetached", + "LeafView initial ON_PAUSE", + "LeafView initial ON_STOP", + "LeafView initial ON_DESTROY", + "LeafView next onAttached", + "LeafView next ON_CREATE", + "LeafView next ON_START", + "LeafView next ON_RESUME", + ) + } + } + + @Test fun replace_after_stop() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "activity onCreate", + "activity onStart", + "activity onResume", + "LeafView initial onAttached", + "LeafView initial ON_CREATE", + "LeafView initial ON_START", + "LeafView initial ON_RESUME", + ) + } + + scenario.moveToState(CREATED) + + scenario.onActivity { + it.update(LeafRendering("next")) + + assertThat(it.consumeLifecycleEvents()).containsExactly( + "LeafView initial ON_PAUSE", + "activity onPause", + "LeafView initial ON_STOP", + "activity onStop", + "LeafView initial onDetached", + "LeafView initial ON_DESTROY", + "LeafView next onAttached", + "LeafView next ON_CREATE", + ) + } + } + + @Test fun nested_lifecycle() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + it.consumeLifecycleEvents() + it.update(RecurseRendering(LeafRendering("wrapped"))) + + assertThat(it.consumeLifecycleEvents()).containsExactly( + "LeafView initial onDetached", + "LeafView initial ON_PAUSE", + "LeafView initial ON_STOP", + "LeafView initial ON_DESTROY", + "LeafView wrapped onAttached", + "LeafView wrapped ON_CREATE", + "LeafView wrapped ON_START", + "LeafView wrapped ON_RESUME", + ) + + it.update(LeafRendering("unwrapped")) + + assertThat(it.consumeLifecycleEvents()).containsExactly( + "LeafView wrapped onDetached", + "LeafView wrapped ON_PAUSE", + "LeafView wrapped ON_STOP", + "LeafView wrapped ON_DESTROY", + "LeafView unwrapped onAttached", + "LeafView unwrapped ON_CREATE", + "LeafView unwrapped ON_START", + "LeafView unwrapped ON_RESUME", + ) + } + } +} diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ViewRegistry.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ViewRegistry.kt index b0364dbed..4814e0e58 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ViewRegistry.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ViewRegistry.kt @@ -109,6 +109,13 @@ public fun ViewRegistry(): ViewRegistry = TypedViewRegistry() * [AndroidViewRendering.viewFactory], if there is one. Note that this means that a * compile time [AndroidViewRendering.viewFactory] binding can be overridden at runtime. * + * The returned view will have a [WorkflowLifecycleOwner] set on it. The returned view must EITHER: + * + * 1. Be attached at least once to ensure that the lifecycle eventually gets destroyed (because its + * parent is destroyed), or + * 2. Have its [WorkflowLifecycleOwner.destroyOnDetach] called, which will either schedule the + * lifecycle to be destroyed if the view is attached, or destroy it immediately if it's detached. + * * @throws IllegalArgumentException if no factory can be find for type [RenderingT] */ @WorkflowUiExperimentalApi @@ -132,6 +139,12 @@ public fun * can be updated via calls to [View.showRendering] -- that is, it is guaranteed that * [bindShowRendering] has been called on this view. * + * The returned view will have a [WorkflowLifecycleOwner] set on it, the caller _must_ ensure the + * view gets attached to a window at least once, and call its + * [WorkflowLifecycleOwner.destroyOnDetach] method before detaching the view for the final time + * when replacing with another built view. Failing to do this can result in memory and other + * resource leaks. + * * @param initializeView Optional function invoked immediately after the [View] is * created (that is, immediately after the call to [ViewFactory.buildView]). * [showRendering], [getRendering] and [environment] are all available when this is called. @@ -157,7 +170,6 @@ public fun ViewRegistry.buildView( "View.bindShowRendering should have been called for $view, typically by the " + "${ViewFactory::class.java.name} that created it." } - @Suppress("UNCHECKED_CAST") initializeView.invoke(view) } } diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowAndroidXSupport.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowAndroidXSupport.kt new file mode 100644 index 000000000..576c78d3e --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowAndroidXSupport.kt @@ -0,0 +1,28 @@ +package com.squareup.workflow1.ui + +import android.content.Context +import android.content.ContextWrapper +import android.view.View +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewTreeLifecycleOwner + +/** + * Namespace for some helper functions for interacting with the AndroidX libraries. + */ +public object WorkflowAndroidXSupport { + + /** + * Tries to get the parent lifecycle from the current view via [ViewTreeLifecycleOwner], if that + * fails it looks up the context chain for a [LifecycleOwner], and if that fails it just returns + * null. This differs from [ViewTreeLifecycleOwner.get] because it will check the + * [View.getContext] if no owner is found in the view tree. + */ + @WorkflowUiExperimentalApi + public fun lifecycleOwnerFromViewTreeOrContext(view: View): LifecycleOwner? = + ViewTreeLifecycleOwner.get(view) ?: view.context.lifecycleOwnerOrNull() + + private tailrec fun Context.lifecycleOwnerOrNull(): LifecycleOwner? = when (this) { + is LifecycleOwner -> this + else -> (this as? ContextWrapper)?.baseContext?.lifecycleOwnerOrNull() + } +} diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowLifecycleOwner.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowLifecycleOwner.kt new file mode 100644 index 000000000..ffc049be4 --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowLifecycleOwner.kt @@ -0,0 +1,221 @@ +package com.squareup.workflow1.ui + +import android.view.View +import android.view.View.OnAttachStateChangeListener +import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.PRIVATE +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.Event +import androidx.lifecycle.Lifecycle.State.DESTROYED +import androidx.lifecycle.Lifecycle.State.INITIALIZED +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.LifecycleRegistry.createUnsafe +import androidx.lifecycle.ViewTreeLifecycleOwner +import com.squareup.workflow1.ui.WorkflowAndroidXSupport.lifecycleOwnerFromViewTreeOrContext +import com.squareup.workflow1.ui.WorkflowLifecycleOwner.Companion.get +import com.squareup.workflow1.ui.WorkflowLifecycleOwner.Companion.installOn + +/** + * An extension of [LifecycleOwner] that is always owned by a [View], is logically a child lifecycle + * of the next-nearest [ViewTreeLifecycleOwner] above it (it mirrors its parent's lifecycle until + * it's destroyed), and can be [asked to destroy][destroyOnDetach] itself early. + * + * This type is meant to help integrate with [ViewTreeLifecycleOwner] by allowing the creation of a + * tree of [LifecycleOwner]s that mirrors the view tree. + * + * Custom container views that use [ViewRegistry.buildView] to create their children _must_ ensure + * they call [destroyOnDetach] on the outgoing view before they replace children with new views. + * If this is not done, then certain processes that are started by that view's subtree may continue + * to run long after the view has been detached, and memory and other resources may be leaked. + * Note that [WorkflowViewStub] takes care of this chore itself. + * + * Set a [WorkflowLifecycleOwner] on a view by calling [installOn], and read it back using [get]. + */ +@WorkflowUiExperimentalApi +public interface WorkflowLifecycleOwner : LifecycleOwner { + + /** + * If the owning view is attached, flags this [lifecycle][Lifecycle] to be set to [DESTROYED] as + * soon as the owning view is [detached][View.onDetachedFromWindow]. If the view is not attached + * (either because it's never been attached, or because it was attached and then detached), then + * it will destroy the lifecycle immediately. + */ + public fun destroyOnDetach() + + public companion object { + /** + * Creates a new [WorkflowLifecycleOwner] and sets it on [view]'s tags so it can be later + * retrieved with [get]. + * + * It's very important that, once this function is called with a given view, that EITHER: + * + * 1. The view gets attached at least once to ensure that the lifecycle eventually gets + * destroyed (because its parent is destroyed), or + * 2. Someone eventually calls [destroyOnDetach], which will either schedule the lifecycle to + * destroyed if the view is attached, or destroy it immediately if it's detached. + * + * If this is not done, any observers registered with the [Lifecycle] may be leaked as they will + * never see the destroy event. + * + * @param findParentLifecycle A function that is called whenever [view] is attached, and should + * return the [Lifecycle] to use as the parent lifecycle. If not specified, defaults to looking + * up the view tree by calling [ViewTreeLifecycleOwner.get] on [view]'s parent, and if none is + * found, then looking up [view]'s context wrapper chain for something that implements + * [LifecycleOwner]. This only needs to be passed if [view] will be used as the root of a new + * view hierarchy, e.g. for a new dialog. If no parent lifecycle is found, then the lifecycle + * will become [RESUMED] when it's attached for the first time, and stay in that state until + * it is re-attached with a non-null parent or [destroyOnDetach] is called. + */ + public fun installOn( + view: View, + findParentLifecycle: () -> Lifecycle? = { findParentViewTreeLifecycle(view) } + ) { + RealWorkflowLifecycleOwner(view, findParentLifecycle).also { + ViewTreeLifecycleOwner.set(view, it) + view.addOnAttachStateChangeListener(it) + } + } + + /** + * Looks for the nearest [ViewTreeLifecycleOwner] on [view] and returns it if it's an instance + * of [WorkflowLifecycleOwner]. Convenience function for retrieving the owner set by + * [installOn]. + */ + public fun get(view: View): WorkflowLifecycleOwner? = + ViewTreeLifecycleOwner.get(view) as? WorkflowLifecycleOwner + + private fun findParentViewTreeLifecycle(view: View): Lifecycle? { + // Start at our view's parent – if we look on our own view, we'll just get this back. + return (view.parent as? View)?.let(::lifecycleOwnerFromViewTreeOrContext)?.lifecycle + } + } +} + +/** + * @param enforceMainThread Allows disabling the main thread check for testing. + */ +@OptIn(WorkflowUiExperimentalApi::class) +@VisibleForTesting(otherwise = PRIVATE) +internal class RealWorkflowLifecycleOwner( + private val view: View, + private val findParentLifecycle: () -> Lifecycle?, + enforceMainThread: Boolean = true, +) : WorkflowLifecycleOwner, + LifecycleOwner, + OnAttachStateChangeListener, + LifecycleEventObserver { + + private val localLifecycle = + if (enforceMainThread) LifecycleRegistry(this) else createUnsafe(this) + + /** + * The parent lifecycle found by calling [ViewTreeLifecycleOwner.get] on the owning view's parent + * (once it's attached), or if no [ViewTreeLifecycleOwner] is set, then by trying to find a + * [LifecycleOwner] on the view's context. + * + * When the view is detached, we keep the reference to the previous parent + * lifecycle, and keep observing it, to ensure we get destroyed correctly if the parent is + * destroyed while we're detached. The next time we're attached, we search for a parent again, in + * case we're attached in a different subtree that has a different parent. + * + * This is only null in two cases: + * 1. The view hasn't been attached yet, ever. + * 2. The lifecycle has been destroyed. + */ + private var parentLifecycle: Lifecycle? = null + private var destroyOnDetach = false + + override fun onViewAttachedToWindow(v: View) { + // Always check for a new parent, in case we're attached to different part of the view tree. + val oldLifecycle = parentLifecycle + parentLifecycle = checkNotNull(findParentLifecycle()) { + "Expected to find either a ViewTreeLifecycleOwner in the view tree, or for the view's" + + " context to be a LifecycleOwner." + } + + if (parentLifecycle !== oldLifecycle) { + oldLifecycle?.removeObserver(this) + parentLifecycle?.addObserver(this) + } + updateLifecycle(isAttached = true) + } + + override fun onViewDetachedFromWindow(v: View) { + updateLifecycle(isAttached = false) + } + + /** Called when the [parentLifecycle] changes state. */ + override fun onStateChanged( + source: LifecycleOwner, + event: Event + ) { + updateLifecycle() + } + + override fun destroyOnDetach() { + if (!destroyOnDetach) { + destroyOnDetach = true + updateLifecycle() + } + } + + override fun getLifecycle(): Lifecycle = localLifecycle + + /** + * @param isAttached Whether the view is [attached][View.isAttachedToWindow]. Must be passed + * explicitly when called from the attach/detach callbacks, since the view property's value won't + * reflect the new state until after they return. + */ + @VisibleForTesting(otherwise = PRIVATE) + internal fun updateLifecycle(isAttached: Boolean = view.isAttachedToWindow) { + val parentState = parentLifecycle?.currentState + val localState = localLifecycle.currentState + + if (localState == DESTROYED) { + // Local destruction is a terminal state. + return + } + + localLifecycle.currentState = when { + destroyOnDetach && !isAttached -> { + // We've been enqueued for destruction. + // Stay attached to the parent's lifecycle until we re-attach, since the parent could be + // destroyed while we're detached. + DESTROYED + } + parentState != null -> { + // We may or may not be attached, but we have a parent lifecycle so we just blindly follow + // it. + parentState + } + localState == INITIALIZED -> { + // We have no parent and we're not destroyed, which means we have never been attached, so + // the only valid state we can be in is INITIALIZED. + INITIALIZED + } + else -> { + // We don't have a parent and we're neither in DESTROYED or INITIALIZED: this is an invalid + // state. Throw an AssertionError instead of IllegalStateException because there's no API to + // get into this state, so this means the library has a bug. + throw AssertionError( + "Must have a parent lifecycle after attaching and until being destroyed." + ) + } + }.also { newState -> + if (newState == DESTROYED) { + // We just transitioned to a terminal DESTROY state. Be a good citizen and make sure to + // detach from our parent. + // + // Note that if localState is INITIALIZED, this is not a valid transition and + // LifecycleRegistry will throw when we try setting currentState. This is not a situation + // that it should be possible to get in unless there's a bug in this library, which is why + // we don't explicitly check for it. + parentLifecycle?.removeObserver(this) + parentLifecycle = null + } + } + } +} diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowViewStub.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowViewStub.kt index 1c43b7a67..83d12ab2a 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowViewStub.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowViewStub.kt @@ -203,12 +203,30 @@ public class WorkflowViewStub @JvmOverloads constructor( val parent = actual.parent as? ViewGroup ?: throw IllegalStateException("WorkflowViewStub must have a non-null ViewGroup parent") + // If we have a delegate view (i.e. this !== actual), then the old delegate is going to + // eventually be detached by replaceOldViewInParent. When that happens, it's not just a regular + // detach, it's a navigation event that effectively says that view will never come back. Thus, + // we want its Lifecycle to move to permanently destroyed, even though the parent lifecycle is + // still probably alive. + // + // If actual === this, then this stub hasn't been initialized with a real delegate view yet. If + // we're a child of another container which set a WorkflowLifecycleOwner on this view, this + // get() call will return the WLO owned by that parent. We noop in that case since destroying + // that lifecycle is our parent's responsibility in that case, not ours. + if (actual !== this) { + WorkflowLifecycleOwner.get(actual)?.destroyOnDetach() + } + return viewEnvironment[ViewRegistry] .buildView( rendering, viewEnvironment, parent.context, - parent + parent, + initializeView = { + WorkflowLifecycleOwner.installOn(this) + showFirstRendering() + } ) .also { newView -> if (inflatedId != NO_ID) newView.id = inflatedId diff --git a/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/RealWorkflowLifecycleOwnerTest.kt b/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/RealWorkflowLifecycleOwnerTest.kt new file mode 100644 index 000000000..1a6ff6471 --- /dev/null +++ b/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/RealWorkflowLifecycleOwnerTest.kt @@ -0,0 +1,186 @@ +package com.squareup.workflow1.ui + +import android.content.Context +import android.view.View +import androidx.lifecycle.Lifecycle +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 androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.google.common.truth.Truth.assertThat +import com.nhaarman.mockito_kotlin.doReturn +import com.nhaarman.mockito_kotlin.mock +import com.nhaarman.mockito_kotlin.whenever +import org.junit.Test +import kotlin.test.assertFailsWith + +class RealWorkflowLifecycleOwnerTest { + + private val rootContext = mock() + private val view = mock { + on { context } doReturn rootContext + } + private var parentLifecycle: LifecycleRegistry? = null + private val owner = RealWorkflowLifecycleOwner( + view, + enforceMainThread = false, + findParentLifecycle = { parentLifecycle } + ) + + @Test fun `lifecycle starts initialized`() { + assertThat(owner.lifecycle.currentState).isEqualTo(INITIALIZED) + } + + @Test fun `attach throws when no parent lifecycle found`() { + val error = assertFailsWith { + makeViewAttached() + } + assertThat(error.message).isEqualTo( + "Expected to find either a ViewTreeLifecycleOwner in the view tree, or for the" + + " view's context to be a LifecycleOwner." + ) + } + + @Test fun `lifecycle not destroyed when detached`() { + ensureParentLifecycle() + makeViewAttached() + makeViewDetached() + assertThat(owner.lifecycle.currentState).isEqualTo(INITIALIZED) + } + + @Test fun `lifecycle is not destroyed after detachOnDestroy while attached`() { + ensureParentLifecycle() + makeViewAttached() + owner.destroyOnDetach() + assertThat(owner.lifecycle.currentState).isEqualTo(INITIALIZED) + } + + @Test fun `lifecycle is destroyed after detachOnDestroy and detach`() { + ensureParentLifecycle() + makeViewAttached() + owner.destroyOnDetach() + makeViewDetached() + assertThat(owner.lifecycle.currentState).isEqualTo(DESTROYED) + } + + @Test fun `lifecycle is destroyed after detachOnDestroy when already detached`() { + owner.destroyOnDetach() + assertThat(owner.lifecycle.currentState).isEqualTo(DESTROYED) + } + + @Test fun `lifecycle doesn't resume after destroy`() { + ensureParentLifecycle() + owner.destroyOnDetach() + assertThat(owner.lifecycle.currentState).isEqualTo(DESTROYED) + + makeViewAttached() + assertThat(owner.lifecycle.currentState).isEqualTo(DESTROYED) + } + + @Test fun `lifecycle moves to parent state when attached`() { + ensureParentLifecycle().currentState = STARTED + makeViewAttached() + assertThat(owner.lifecycle.currentState).isEqualTo(STARTED) + } + + @Test fun `lifecycle follows parent state while attached`() { + ensureParentLifecycle().currentState = INITIALIZED + makeViewAttached() + + listOf( + // Going up… + CREATED, + STARTED, + RESUMED, + // …and back down. + CREATED, + DESTROYED, + ).forEach { state -> + ensureParentLifecycle().currentState = state + assertThat(owner.lifecycle.currentState).isEqualTo(state) + } + } + + @Test fun `lifecycle follows parent state while detached`() { + ensureParentLifecycle().currentState = INITIALIZED + makeViewAttached() + makeViewDetached() + + listOf( + // Going up… + CREATED, + STARTED, + RESUMED, + // …and back down. + CREATED, + DESTROYED, + ).forEach { state -> + ensureParentLifecycle().currentState = state + assertThat(owner.lifecycle.currentState).isEqualTo(state) + } + } + + @Test fun `lifecycle stays destroyed after parent destroyed`() { + ensureParentLifecycle().currentState = RESUMED + makeViewAttached() + + ensureParentLifecycle().currentState = DESTROYED + ensureParentLifecycle().currentState = RESUMED + + assertThat(owner.lifecycle.currentState).isEqualTo(DESTROYED) + } + + @Test fun `lifecycle stops observing parent when destroyed`() { + ensureParentLifecycle().currentState = RESUMED + makeViewAttached() + assertThat(ensureParentLifecycle().observerCount).isEqualTo(1) + + owner.destroyOnDetach() + makeViewDetached() + + assertThat(owner.lifecycle.currentState).isEqualTo(DESTROYED) + assertThat(ensureParentLifecycle().observerCount).isEqualTo(0) + } + + @Test fun `lifecycle switches subscription to new parent when reattached`() { + val originalParent = ensureParentLifecycle() + originalParent.currentState = CREATED + makeViewAttached() + originalParent.currentState = RESUMED + assertThat(owner.lifecycle.currentState).isEqualTo(RESUMED) + + makeViewDetached() + // Force the parent to be recreated. + parentLifecycle = null + ensureParentLifecycle().currentState = STARTED + makeViewAttached() + + // Should have unsubscribed, so this should be a no-op. + originalParent.currentState = DESTROYED + assertThat(owner.lifecycle.currentState).isEqualTo(STARTED) + } + + private fun makeViewAttached() { + owner.onViewAttachedToWindow(view) + whenever(view.isAttachedToWindow).thenReturn(true) + } + + private fun makeViewDetached() { + owner.onViewDetachedFromWindow(view) + whenever(view.isAttachedToWindow).thenReturn(false) + } + + private fun ensureParentLifecycle(): LifecycleRegistry { + if (parentLifecycle == null) { + val owner = object : LifecycleOwner { + val lifecycle = LifecycleRegistry.createUnsafe(this) + override fun getLifecycle(): Lifecycle = lifecycle + } + parentLifecycle = owner.lifecycle + } + return parentLifecycle!! + } +} diff --git a/workflow-ui/internal-testing-android/api/internal-testing-android.api b/workflow-ui/internal-testing-android/api/internal-testing-android.api index 1c5739528..73aa62335 100644 --- a/workflow-ui/internal-testing-android/api/internal-testing-android.api +++ b/workflow-ui/internal-testing-android/api/internal-testing-android.api @@ -1,5 +1,65 @@ +public abstract class com/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity : com/squareup/workflow1/ui/internal/test/WorkflowUiTestActivity { + public fun ()V + public final fun consumeLifecycleEvents ()Ljava/util/List; + protected abstract fun getViewRegistry ()Lcom/squareup/workflow1/ui/ViewRegistry; + protected final fun leafViewBinding (Lkotlin/reflect/KClass;Lcom/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity$ViewObserver;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/ViewFactory; + public static synthetic fun leafViewBinding$default (Lcom/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity;Lkotlin/reflect/KClass;Lcom/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity$ViewObserver;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/squareup/workflow1/ui/ViewFactory; + protected final fun lifecycleLoggingViewObserver (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity$ViewObserver; + protected final fun logEvent (Ljava/lang/String;)V + protected fun onCreate (Landroid/os/Bundle;)V + protected fun onDestroy ()V + protected fun onPause ()V + protected fun onResume ()V + protected fun onStart ()V + protected fun onStop ()V +} + +public class com/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity$LeafView : android/widget/FrameLayout { + public fun (Landroid/content/Context;)V + public final fun getRendering ()Ljava/lang/Object; + protected fun onAttachedToWindow ()V + protected fun onDetachedFromWindow ()V + protected fun onRestoreInstanceState (Landroid/os/Parcelable;)V + protected fun onSaveInstanceState ()Landroid/os/Parcelable; +} + +public abstract interface class com/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity$ViewObserver { + public abstract fun onAttachedToWindow (Landroid/view/View;Ljava/lang/Object;)V + public abstract fun onDetachedFromWindow (Landroid/view/View;Ljava/lang/Object;)V + public abstract fun onRestoreInstanceState (Landroid/view/View;Ljava/lang/Object;)V + public abstract fun onSaveInstanceState (Landroid/view/View;Ljava/lang/Object;)V + public abstract fun onShowRendering (Landroid/view/View;Ljava/lang/Object;)V + public abstract fun onViewCreated (Landroid/view/View;Ljava/lang/Object;)V + public abstract fun onViewTreeLifecycleStateChanged (Ljava/lang/Object;Landroidx/lifecycle/Lifecycle$Event;)V +} + +public final class com/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity$ViewObserver$DefaultImpls { + public static fun onAttachedToWindow (Lcom/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity$ViewObserver;Landroid/view/View;Ljava/lang/Object;)V + public static fun onDetachedFromWindow (Lcom/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity$ViewObserver;Landroid/view/View;Ljava/lang/Object;)V + public static fun onRestoreInstanceState (Lcom/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity$ViewObserver;Landroid/view/View;Ljava/lang/Object;)V + public static fun onSaveInstanceState (Lcom/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity$ViewObserver;Landroid/view/View;Ljava/lang/Object;)V + public static fun onShowRendering (Lcom/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity$ViewObserver;Landroid/view/View;Ljava/lang/Object;)V + public static fun onViewCreated (Lcom/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity$ViewObserver;Landroid/view/View;Ljava/lang/Object;)V + public static fun onViewTreeLifecycleStateChanged (Lcom/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity$ViewObserver;Ljava/lang/Object;Landroidx/lifecycle/Lifecycle$Event;)V +} + public final class com/squareup/workflow1/ui/internal/test/EspressoKt { public static final fun actuallyPressBack ()V public static final fun inAnyView (Lorg/hamcrest/Matcher;)Landroidx/test/espresso/ViewInteraction; } +public class com/squareup/workflow1/ui/internal/test/WorkflowUiTestActivity : androidx/appcompat/app/AppCompatActivity { + public field viewEnvironment Lcom/squareup/workflow1/ui/ViewEnvironment; + public fun ()V + public final fun getCustomNonConfigurationData ()Ljava/util/Map; + public final fun getRestoreRenderingAfterConfigChange ()Z + public final fun getRootRenderedView ()Landroid/view/View; + public final fun getViewEnvironment ()Lcom/squareup/workflow1/ui/ViewEnvironment; + protected fun onCreate (Landroid/os/Bundle;)V + public final fun onRetainCustomNonConfigurationInstance ()Ljava/lang/Object; + public final fun recreateViewsOnNextRendering ()V + public final fun setRendering (Ljava/lang/Object;)Landroid/view/View; + public final fun setRestoreRenderingAfterConfigChange (Z)V + public final fun setViewEnvironment (Lcom/squareup/workflow1/ui/ViewEnvironment;)V +} + diff --git a/workflow-ui/internal-testing-android/src/main/AndroidManifest.xml b/workflow-ui/internal-testing-android/src/main/AndroidManifest.xml index 58871206c..2876a1e5f 100644 --- a/workflow-ui/internal-testing-android/src/main/AndroidManifest.xml +++ b/workflow-ui/internal-testing-android/src/main/AndroidManifest.xml @@ -1,3 +1,8 @@ + + + diff --git a/workflow-ui/internal-testing-android/src/main/java/com/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity.kt b/workflow-ui/internal-testing-android/src/main/java/com/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity.kt new file mode 100644 index 000000000..4b1f811b1 --- /dev/null +++ b/workflow-ui/internal-testing-android/src/main/java/com/squareup/workflow1/ui/internal/test/AbstractLifecycleTestActivity.kt @@ -0,0 +1,216 @@ +package com.squareup.workflow1.ui.internal.test + +import android.content.Context +import android.os.Bundle +import android.os.Parcelable +import android.view.View +import android.widget.FrameLayout +import androidx.lifecycle.Lifecycle.Event +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewTreeLifecycleOwner +import com.squareup.workflow1.ui.BuilderViewFactory +import com.squareup.workflow1.ui.NamedViewFactory +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewFactory +import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.WorkflowViewStub +import com.squareup.workflow1.ui.bindShowRendering +import com.squareup.workflow1.ui.plus +import kotlin.reflect.KClass + +/** + * Base activity class to help test container view implementations' [LifecycleOwner] behaviors. + * + * Create an `ActivityScenarioRule` in your test that launches your subclass of this activity, and + * then have your subclass expose a method that calls [setRendering] with whatever rendering type your + * test wants to use. Then call [consumeLifecycleEvents] to get a list of strings back that describe + * what lifecycle-related events occurred since the last call. + * + * Subclasses must override [viewRegistry] to specify the [ViewFactory]s they require. All views + * will be hosted inside a [WorkflowViewStub]. + */ +@WorkflowUiExperimentalApi +public abstract class AbstractLifecycleTestActivity : WorkflowUiTestActivity() { + + private val lifecycleEvents = mutableListOf() + + protected abstract val viewRegistry: ViewRegistry + + /** + * Returns a list of strings describing what lifecycle-related events occurred since the last + * call to this method. Use this list to validate the ordering of lifecycle events in your tests. + * + * Hint: Start by expecting this list to be empty, then copy-paste the actual strings from the + * test failure into your test and making sure they look reasonable. + */ + public fun consumeLifecycleEvents(): List = lifecycleEvents.toList().also { + lifecycleEvents.clear() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + logEvent("activity onCreate") + + // This will override WorkflowUiTestActivity's retention of the environment across config + // changes. This is intentional, since our ViewRegistry probably contains a leafBinding which + // captures the events list. + viewEnvironment = ViewEnvironment(mapOf(ViewRegistry to viewRegistry + NamedViewFactory)) + } + + override fun onStart() { + super.onStart() + logEvent("activity onStart") + } + + override fun onResume() { + super.onResume() + logEvent("activity onResume") + } + + override fun onPause() { + logEvent("activity onPause") + super.onPause() + } + + override fun onStop() { + logEvent("activity onStop") + super.onStop() + } + + override fun onDestroy() { + logEvent("activity onDestroy") + super.onDestroy() + } + + protected fun logEvent(message: String) { + lifecycleEvents += message + } + + protected fun leafViewBinding( + type: KClass, + viewObserver: ViewObserver, + viewConstructor: (Context) -> LeafView = ::LeafView + ): ViewFactory = + BuilderViewFactory(type) { initialRendering, initialViewEnvironment, contextForNewView, _ -> + viewConstructor(contextForNewView).apply { + this.viewObserver = viewObserver + viewObserver.onViewCreated(this, initialRendering) + + bindShowRendering(initialRendering, initialViewEnvironment) { rendering, _ -> + this.rendering = rendering + viewObserver.onShowRendering(this, rendering) + } + } + } + + protected fun lifecycleLoggingViewObserver( + describeRendering: (R) -> String + ): ViewObserver = object : ViewObserver { + override fun onAttachedToWindow( + view: View, + rendering: R + ) { + logEvent("LeafView ${describeRendering(rendering)} onAttached") + } + + override fun onDetachedFromWindow( + view: View, + rendering: R + ) { + logEvent("LeafView ${describeRendering(rendering)} onDetached") + } + + override fun onViewTreeLifecycleStateChanged( + rendering: R, + event: Event + ) { + logEvent("LeafView ${describeRendering(rendering)} $event") + } + } + + public interface ViewObserver { + public fun onViewCreated( + view: View, + rendering: R + ) { + } + + public fun onShowRendering( + view: View, + rendering: R + ) { + } + + public fun onAttachedToWindow( + view: View, + rendering: R + ) { + } + + public fun onDetachedFromWindow( + view: View, + rendering: R + ) { + } + + public fun onViewTreeLifecycleStateChanged( + rendering: R, + event: Event + ) { + } + + public fun onSaveInstanceState( + view: View, + rendering: R + ) { + } + + public fun onRestoreInstanceState( + view: View, + rendering: R + ) { + } + } + + public open class LeafView( + context: Context + ) : FrameLayout(context) { + + internal var viewObserver: ViewObserver? = null + + // We can't rely on getRendering() in case it's wrapped with Named. + public lateinit var rendering: R + internal set + + private val lifecycleObserver = LifecycleEventObserver { _, event -> + viewObserver?.onViewTreeLifecycleStateChanged(rendering, event) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + viewObserver?.onAttachedToWindow(this, rendering) + + ViewTreeLifecycleOwner.get(this)!!.lifecycle.removeObserver(lifecycleObserver) + ViewTreeLifecycleOwner.get(this)!!.lifecycle.addObserver(lifecycleObserver) + } + + override fun onDetachedFromWindow() { + // Don't remove the lifecycle observer here, since we need to observe events after detach. + viewObserver?.onDetachedFromWindow(this, rendering) + super.onDetachedFromWindow() + } + + override fun onSaveInstanceState(): Parcelable? { + return super.onSaveInstanceState().apply { + viewObserver?.onSaveInstanceState(this@LeafView, rendering) + } + } + + override fun onRestoreInstanceState(state: Parcelable?) { + super.onRestoreInstanceState(state) + viewObserver?.onRestoreInstanceState(this@LeafView, rendering) + } + } +} diff --git a/workflow-ui/internal-testing-android/src/main/java/com/squareup/workflow1/ui/internal/test/WorkflowUiTestActivity.kt b/workflow-ui/internal-testing-android/src/main/java/com/squareup/workflow1/ui/internal/test/WorkflowUiTestActivity.kt new file mode 100644 index 000000000..71bd552c6 --- /dev/null +++ b/workflow-ui/internal-testing-android/src/main/java/com/squareup/workflow1/ui/internal/test/WorkflowUiTestActivity.kt @@ -0,0 +1,113 @@ +package com.squareup.workflow1.ui.internal.test + +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import com.squareup.workflow1.ui.Named +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.WorkflowViewStub + +/** + * Helper for testing workflow-ui code in UI tests. + * + * The content view of the activity is a [WorkflowViewStub], which you can control by calling + * [setRendering]. + * + * Typical usage: + * 1. Create an `ActivityScenarioRule` or `AndroidComposeRule` and pass this activity type. + * 2. In your `@Before` method, set the [viewEnvironment]. + * 3. In your tests, call [setRendering] to update the stub. + * + * You can also test configuration changes by calling `ActivityScenarioRule.recreate()`. By default, + * the [viewEnvironment] and last rendering will be restored when the view is re-created. You can + * also retain your own data by mutating [customNonConfigurationData]. If you don't want the + * rendering to be automatically restored, set [restoreRenderingAfterConfigChange] to false before + * calling `recreate()`. + */ +@WorkflowUiExperimentalApi +public open class WorkflowUiTestActivity : AppCompatActivity() { + + private val rootStub by lazy { WorkflowViewStub(this) } + private var renderingCounter = 0 + private lateinit var lastRendering: Any + + /** + * The [ViewEnvironment] used to create views for renderings passed to [setRendering]. + * This *must* be set before the first call to [setRendering]. + * Once set, the value is retained across configuration changes. + */ + public lateinit var viewEnvironment: ViewEnvironment + + /** + * The [View] that was created to display the last rendering passed to [setRendering]. + */ + public val rootRenderedView: View get() = rootStub.actual + + /** + * Key-value store for custom values that should be retained across configuration changes. + * Use this instead of using [getLastNonConfigurationInstance] or + * [getLastCustomNonConfigurationInstance] directly. + */ + public val customNonConfigurationData: MutableMap = mutableMapOf() + + /** + * Simulates the effect of having the activity backed by a real workflow runtime – remembers the + * actual render instance across recreation and will immediately set it on the new container in + * [onCreate]. + * + * True by default. If you need to change, do so before calling `recreate()`. + */ + public var restoreRenderingAfterConfigChange: Boolean = true + + /** + * Causes the next [setRendering] call to force a new view to be created, even if it otherwise wouldn't + * be (i.e. because the rendering is compatible with the previous one). + */ + public fun recreateViewsOnNextRendering() { + renderingCounter++ + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(rootStub) + + @Suppress("DEPRECATION") + (lastCustomNonConfigurationInstance as NonConfigurationData?)?.let { data -> + viewEnvironment = data.viewEnvironment + customNonConfigurationData.apply { + clear() + putAll(data.customData) + } + // setRendering must be called last since it may consume the other values. + data.lastRendering?.let(::setRendering) + } + } + + final override fun onRetainCustomNonConfigurationInstance(): Any = NonConfigurationData( + viewEnvironment = viewEnvironment, + lastRendering = lastRendering.takeIf { restoreRenderingAfterConfigChange }, + customData = customNonConfigurationData, + ) + + /** + * Updates the [WorkflowViewStub] to a new rendering value. + * + * If [recreateViewsOnNextRendering] was previously called, the old view tree will be torn down + * and re-created from scratch. + */ + public fun setRendering(rendering: Any): View { + lastRendering = rendering + val named = Named( + wrapped = rendering, + name = renderingCounter.toString() + ) + return rootStub.update(named, viewEnvironment) + } + + private class NonConfigurationData( + val viewEnvironment: ViewEnvironment, + val lastRendering: Any?, + val customData: MutableMap, + ) +} diff --git a/workflow-ui/modal-android/build.gradle.kts b/workflow-ui/modal-android/build.gradle.kts index 7524eabef..5582f4e61 100644 --- a/workflow-ui/modal-android/build.gradle.kts +++ b/workflow-ui/modal-android/build.gradle.kts @@ -10,12 +10,8 @@ java { } apply(from = rootProject.file(".buildscript/configure-maven-publish.gradle")) - apply(from = rootProject.file(".buildscript/configure-android-defaults.gradle")) - -android { - testOptions.animationsDisabled = true -} +apply(from = rootProject.file(".buildscript/android-ui-tests.gradle")) dependencies { api(project(":workflow-core")) diff --git a/workflow-ui/modal-android/src/androidTest/AndroidManifest.xml b/workflow-ui/modal-android/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..ce72796df --- /dev/null +++ b/workflow-ui/modal-android/src/androidTest/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/workflow-ui/modal-android/src/androidTest/java/com/squareup/workflow1/ui/modal/test/ModalViewContainerLifecycleActivity.kt b/workflow-ui/modal-android/src/androidTest/java/com/squareup/workflow1/ui/modal/test/ModalViewContainerLifecycleActivity.kt new file mode 100644 index 000000000..05fc271c0 --- /dev/null +++ b/workflow-ui/modal-android/src/androidTest/java/com/squareup/workflow1/ui/modal/test/ModalViewContainerLifecycleActivity.kt @@ -0,0 +1,72 @@ +package com.squareup.workflow1.ui.modal.test + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import com.squareup.workflow1.ui.BuilderViewFactory +import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewFactory +import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.WorkflowViewStub +import com.squareup.workflow1.ui.bindShowRendering +import com.squareup.workflow1.ui.internal.test.AbstractLifecycleTestActivity +import com.squareup.workflow1.ui.modal.HasModals +import com.squareup.workflow1.ui.modal.ModalViewContainer +import com.squareup.workflow1.ui.modal.test.ModalViewContainerLifecycleActivity.TestRendering.LeafRendering +import com.squareup.workflow1.ui.modal.test.ModalViewContainerLifecycleActivity.TestRendering.RecurseRendering +import kotlin.reflect.KClass + +@OptIn(WorkflowUiExperimentalApi::class) +internal class ModalViewContainerLifecycleActivity : AbstractLifecycleTestActivity() { + + object BaseRendering : ViewFactory { + override val type: KClass = BaseRendering::class + override fun buildView( + initialRendering: BaseRendering, + initialViewEnvironment: ViewEnvironment, + contextForNewView: Context, + container: ViewGroup? + ): View = View(contextForNewView).apply { + bindShowRendering(initialRendering, initialViewEnvironment) { _, _ -> /* Noop */ } + } + } + + data class TestModals( + override val modals: List + ) : HasModals { + override val beneathModals: BaseRendering get() = BaseRendering + } + + sealed class TestRendering { + data class LeafRendering(val name: String) : TestRendering(), Compatible { + override val compatibilityKey: String get() = name + } + + data class RecurseRendering(val wrapped: LeafRendering) : TestRendering() + } + + override val viewRegistry: ViewRegistry = ViewRegistry( + ModalViewContainer.binding(), + BaseRendering, + leafViewBinding(LeafRendering::class, lifecycleLoggingViewObserver { it.name }), + BuilderViewFactory(RecurseRendering::class) { initialRendering, + initialViewEnvironment, + contextForNewView, _ -> + FrameLayout(contextForNewView).also { container -> + val stub = WorkflowViewStub(contextForNewView) + container.addView(stub) + container.bindShowRendering( + initialRendering, + initialViewEnvironment + ) { rendering, env -> + stub.update(TestModals(listOf(rendering.wrapped)), env) + } + } + }, + ) + + fun update(vararg modals: TestRendering) = setRendering(TestModals(modals.asList())) +} diff --git a/workflow-ui/modal-android/src/androidTest/java/com/squareup/workflow1/ui/modal/test/ModalViewContainerLifecycleTest.kt b/workflow-ui/modal-android/src/androidTest/java/com/squareup/workflow1/ui/modal/test/ModalViewContainerLifecycleTest.kt new file mode 100644 index 000000000..1a4a7f2a8 --- /dev/null +++ b/workflow-ui/modal-android/src/androidTest/java/com/squareup/workflow1/ui/modal/test/ModalViewContainerLifecycleTest.kt @@ -0,0 +1,392 @@ +package com.squareup.workflow1.ui.modal.test + +import androidx.lifecycle.Lifecycle.State.CREATED +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.LifecycleOwner +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.google.common.truth.Truth.assertThat +import com.squareup.workflow1.ui.modal.ModalViewContainer +import com.squareup.workflow1.ui.modal.test.ModalViewContainerLifecycleActivity.TestRendering.LeafRendering +import com.squareup.workflow1.ui.modal.test.ModalViewContainerLifecycleActivity.TestRendering.RecurseRendering +import org.junit.Rule +import org.junit.Test + +/** + * Tests for [ModalViewContainer]'s [LifecycleOwner] integration. + */ +internal class ModalViewContainerLifecycleTest { + + @Rule @JvmField internal val scenarioRule = + ActivityScenarioRule(ModalViewContainerLifecycleActivity::class.java) + private val scenario get() = scenarioRule.scenario + + /** + * We test stop instead of pause because on older Android versions (e.g. level 21), + * `moveToState(STARTED)` will also stop the lifecycle, not just pause it. By just using stopped, + * which is consistent across all the versions we care about, we don't need to special-case our + * assertions, but we're still testing fundamentally the same thing (moving between non-terminal + * lifecycle states). + */ + @Test fun stop_then_resume() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + it.update(LeafRendering("initial")) + } + + scenario.onActivity { activity -> + assertThat(activity.consumeLifecycleEvents()).containsExactly( + "activity onCreate", + "activity onStart", + "activity onResume", + "LeafView initial onAttached", + "LeafView initial ON_CREATE", + "LeafView initial ON_START", + "LeafView initial ON_RESUME", + ) + } + + scenario.moveToState(CREATED) + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "LeafView initial ON_PAUSE", + "activity onPause", + "LeafView initial ON_STOP", + "activity onStop", + ) + } + + scenario.moveToState(RESUMED) + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "activity onStart", + "LeafView initial ON_START", + "activity onResume", + "LeafView initial ON_RESUME", + ) + } + } + + @Test fun recreate_rendering() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + it.update(LeafRendering("initial")) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "activity onCreate", + "activity onStart", + "activity onResume", + "LeafView initial onAttached", + "LeafView initial ON_CREATE", + "LeafView initial ON_START", + "LeafView initial ON_RESUME", + ) + } + + scenario.onActivity { + it.recreateViewsOnNextRendering() + it.update(LeafRendering("recreated")) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "LeafView initial ON_PAUSE", + "LeafView initial ON_STOP", + "LeafView initial ON_DESTROY", + "LeafView initial onDetached", + "LeafView recreated onAttached", + "LeafView recreated ON_CREATE", + "LeafView recreated ON_START", + "LeafView recreated ON_RESUME", + ) + } + } + + @Test fun recreate_activity() { + lateinit var initialActivity: ModalViewContainerLifecycleActivity + + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + it.update(LeafRendering("initial")) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "activity onCreate", + "activity onStart", + "activity onResume", + "LeafView initial onAttached", + "LeafView initial ON_CREATE", + "LeafView initial ON_START", + "LeafView initial ON_RESUME", + ) + + // Store a reference to the activity so we can get events from it after destroying. + initialActivity = it + it.restoreRenderingAfterConfigChange = false + } + + scenario.recreate() + scenario.onActivity { + assertThat(it).isNotSameInstanceAs(initialActivity) + it.update(LeafRendering("recreated")) + } + + scenario.onActivity { + assertThat(initialActivity.consumeLifecycleEvents()).containsExactly( + "LeafView initial ON_PAUSE", + "activity onPause", + "LeafView initial ON_STOP", + "activity onStop", + "LeafView initial onDetached", + "LeafView initial ON_DESTROY", + "activity onDestroy", + ) + + assertThat(it.consumeLifecycleEvents()).containsExactly( + "activity onCreate", + "activity onStart", + "activity onResume", + "LeafView recreated onAttached", + "LeafView recreated ON_CREATE", + "LeafView recreated ON_START", + "LeafView recreated ON_RESUME", + ) + } + } + + @Test fun replace_modal() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + it.update(LeafRendering("initial")) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "activity onCreate", + "activity onStart", + "activity onResume", + "LeafView initial onAttached", + "LeafView initial ON_CREATE", + "LeafView initial ON_START", + "LeafView initial ON_RESUME", + ) + } + + scenario.onActivity { + it.update(LeafRendering("next")) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "LeafView initial onDetached", + "LeafView initial ON_PAUSE", + "LeafView initial ON_STOP", + "LeafView initial ON_DESTROY", + "LeafView next onAttached", + "LeafView next ON_CREATE", + "LeafView next ON_START", + "LeafView next ON_RESUME", + ) + } + } + + @Test fun replace_after_stop() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + it.update(LeafRendering("initial")) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "activity onCreate", + "activity onStart", + "activity onResume", + "LeafView initial onAttached", + "LeafView initial ON_CREATE", + "LeafView initial ON_START", + "LeafView initial ON_RESUME", + ) + } + + scenario.moveToState(CREATED) + + scenario.onActivity { + it.update(LeafRendering("next")) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "LeafView initial ON_PAUSE", + "activity onPause", + "LeafView initial ON_STOP", + "activity onStop", + "LeafView initial onDetached", + "LeafView initial ON_DESTROY", + "LeafView next onAttached", + "LeafView next ON_CREATE", + ) + } + } + + @Test fun nested_lifecycle() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + it.consumeLifecycleEvents().let { println("OMG $it") } + it.update(RecurseRendering(LeafRendering("wrapped"))) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "LeafView wrapped onAttached", + "LeafView wrapped ON_CREATE", + "LeafView wrapped ON_START", + "LeafView wrapped ON_RESUME", + ) + } + + scenario.onActivity { + it.update(LeafRendering("unwrapped")) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "LeafView wrapped ON_PAUSE", + "LeafView wrapped ON_STOP", + "LeafView wrapped ON_DESTROY", + "LeafView wrapped onDetached", + "LeafView unwrapped onAttached", + "LeafView unwrapped ON_CREATE", + "LeafView unwrapped ON_START", + "LeafView unwrapped ON_RESUME", + ) + } + } + + @Test + fun separate_modal_lifecycles_are_independent() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + it.update( + LeafRendering("1 initial"), + LeafRendering("2 initial"), + ) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "activity onCreate", + "activity onStart", + "activity onResume", + "LeafView 1 initial onAttached", + "LeafView 1 initial ON_CREATE", + "LeafView 1 initial ON_START", + "LeafView 1 initial ON_RESUME", + "LeafView 2 initial onAttached", + "LeafView 2 initial ON_CREATE", + "LeafView 2 initial ON_START", + "LeafView 2 initial ON_RESUME", + ) + } + + // Change the rendering on only one of the modals. + scenario.onActivity { + it.update( + LeafRendering("1 initial"), + LeafRendering("2 next"), + ) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "LeafView 2 initial onDetached", + "LeafView 2 initial ON_PAUSE", + "LeafView 2 initial ON_STOP", + "LeafView 2 initial ON_DESTROY", + "LeafView 2 next onAttached", + "LeafView 2 next ON_CREATE", + "LeafView 2 next ON_START", + "LeafView 2 next ON_RESUME", + ) + } + + // Change the rendering on the other modal. + scenario.onActivity { + it.update( + LeafRendering("1 next"), + LeafRendering("2 next"), + ) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "LeafView 1 initial onDetached", + "LeafView 1 initial ON_PAUSE", + "LeafView 1 initial ON_STOP", + "LeafView 1 initial ON_DESTROY", + "LeafView 1 next onAttached", + "LeafView 1 next ON_CREATE", + "LeafView 1 next ON_START", + "LeafView 1 next ON_RESUME", + ) + } + } + + @Test fun all_modals_share_parent_lifecycle() { + assertThat(scenario.state).isEqualTo(RESUMED) + scenario.onActivity { + it.update( + LeafRendering("1 initial"), + LeafRendering("2 initial"), + ) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "activity onCreate", + "activity onStart", + "activity onResume", + "LeafView 1 initial onAttached", + "LeafView 1 initial ON_CREATE", + "LeafView 1 initial ON_START", + "LeafView 1 initial ON_RESUME", + "LeafView 2 initial onAttached", + "LeafView 2 initial ON_CREATE", + "LeafView 2 initial ON_START", + "LeafView 2 initial ON_RESUME", + ) + } + + scenario.onActivity { + it.recreateViewsOnNextRendering() + it.update( + LeafRendering("1 recreated"), + LeafRendering("2 recreated"), + ) + } + + scenario.onActivity { + assertThat(it.consumeLifecycleEvents()).containsExactly( + "LeafView 2 initial ON_PAUSE", + "LeafView 2 initial ON_STOP", + "LeafView 2 initial ON_DESTROY", + "LeafView 2 initial onDetached", + "LeafView 1 initial ON_PAUSE", + "LeafView 1 initial ON_STOP", + "LeafView 1 initial ON_DESTROY", + "LeafView 1 initial onDetached", + "LeafView 1 recreated onAttached", + "LeafView 1 recreated ON_CREATE", + "LeafView 1 recreated ON_START", + "LeafView 1 recreated ON_RESUME", + "LeafView 2 recreated onAttached", + "LeafView 2 recreated ON_CREATE", + "LeafView 2 recreated ON_START", + "LeafView 2 recreated ON_RESUME", + ) + } + } +} diff --git a/workflow-ui/modal-android/src/main/java/com/squareup/workflow1/ui/modal/ModalContainer.kt b/workflow-ui/modal-android/src/main/java/com/squareup/workflow1/ui/modal/ModalContainer.kt index bb3d31b4e..5dd937e34 100644 --- a/workflow-ui/modal-android/src/main/java/com/squareup/workflow1/ui/modal/ModalContainer.kt +++ b/workflow-ui/modal-android/src/main/java/com/squareup/workflow1/ui/modal/ModalContainer.kt @@ -2,7 +2,6 @@ package com.squareup.workflow1.ui.modal import android.app.Dialog import android.content.Context -import android.content.ContextWrapper import android.os.Bundle import android.os.Parcel import android.os.Parcelable @@ -15,13 +14,14 @@ import android.widget.FrameLayout import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle.Event.ON_DESTROY import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.OnLifecycleEvent import com.squareup.workflow1.ui.Compatible import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowLifecycleOwner import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.WorkflowViewStub import com.squareup.workflow1.ui.compatible +import kotlin.LazyThreadSafetyMode.NONE /** * Base class for containers that show [HasModals.modals] in [Dialog] windows. @@ -42,6 +42,10 @@ public abstract class ModalContainer @JvmOverloads constr private var dialogs: List> = emptyList() + private val parentLifecycleOwner by lazy(mode = LazyThreadSafetyMode.NONE) { + WorkflowLifecycleOwner.get(this) + } + protected fun update( newScreen: HasModals<*, ModalRenderingT>, viewEnvironment: ViewEnvironment @@ -52,15 +56,27 @@ public abstract class ModalContainer @JvmOverloads constr for ((i, modal) in newScreen.modals.withIndex()) { newDialogs += if (i < dialogs.size && compatible(dialogs[i].modalRendering, modal)) { dialogs[i].copy(modalRendering = modal, viewEnvironment = viewEnvironment) - .also { updateDialog(it) } + .also { updateDialog(it) } } else { - buildDialog(modal, viewEnvironment).apply { - dialog.window?.decorView?.addOnAttachStateChangeListener( + buildDialog(modal, viewEnvironment).also { ref -> + ref.dialog.decorView?.let { dialogView -> + // Implementations of buildDialog may set their own WorkflowLifecycleOwner on the + // content view, so to avoid interfering with them we also set it here. When the views + // are attached, this will become the parent lifecycle of the one from buildDialog if + // any, and so we can use our lifecycle to destroy-on-detach the dialog hierarchy. + WorkflowLifecycleOwner.installOn( + dialogView, + findParentLifecycle = { parentLifecycleOwner?.lifecycle } + ) + + dialogView.addOnAttachStateChangeListener( object : OnAttachStateChangeListener { - val onDestroy = OnDestroy { dialog.dismiss() } + val onDestroy = OnDestroy { ref.dismiss() } var lifecycle: Lifecycle? = null override fun onViewAttachedToWindow(v: View) { - lifecycle = dialog.lifecycleOrNull() + // Note this is a different lifecycle than the WorkflowLifecycleOwner – it will + // probably be the owning AppCompatActivity. + lifecycle = parentLifecycleOwner?.lifecycle // Android makes a lot of logcat noise if it has to close the window for us. :/ // https://github.com/square/workflow/issues/51 lifecycle?.addObserver(onDestroy) @@ -71,13 +87,14 @@ public abstract class ModalContainer @JvmOverloads constr lifecycle = null } } - ) - dialog.show() + ) + } + ref.dialog.show() } } } - (dialogs - newDialogs).forEach { it.dialog.dismiss() } + (dialogs - newDialogs).forEach { it.dismiss() } dialogs = newDialogs } @@ -93,20 +110,19 @@ public abstract class ModalContainer @JvmOverloads constr override fun onSaveInstanceState(): Parcelable { return SavedState( - super.onSaveInstanceState()!!, - dialogs.map { it.save() } + super.onSaveInstanceState()!!, + dialogs.map { it.save() } ) } override fun onRestoreInstanceState(state: Parcelable) { (state as? SavedState) - ?.let { - if (it.dialogBundles.size == dialogs.size) { - it.dialogBundles.zip(dialogs) { viewState, dialogRef -> dialogRef.restore(viewState) } - } - super.onRestoreInstanceState(state.superState) + ?.let { + if (it.dialogBundles.size == dialogs.size) { + it.dialogBundles.zip(dialogs) { viewState, dialogRef -> dialogRef.restore(viewState) } } - + super.onRestoreInstanceState(state.superState) + } // Some other class wrote state, but we're not allowed to skip // the call to super. Make a no-op call. ?: super.onRestoreInstanceState(super.onSaveInstanceState()) @@ -159,6 +175,18 @@ public abstract class ModalContainer @JvmOverloads constr } } + /** + * Call this instead of calling `dialog.dismiss()` directly – this method ensures that the modal's + * [WorkflowLifecycleOwner] is destroyed correctly. + */ + internal fun dismiss() { + // The dialog's views are about to be detached, and when that happens we want to transition + // the dialog view's lifecycle to a terminal state even though the parent is probably still + // alive. + dialog.decorView?.let(WorkflowLifecycleOwner::get)?.destroyOnDetach() + dialog.dismiss() + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -215,16 +243,5 @@ private class OnDestroy(private val block: () -> Unit) : LifecycleObserver { fun onDestroy() = block() } -@WorkflowUiExperimentalApi -private fun Dialog.lifecycleOrNull(): Lifecycle? = decorView?.context?.lifecycleOrNull() - private val Dialog.decorView: View? get() = window?.decorView - -/** - * The [Lifecycle] for this context, or null if one can't be found. - */ -private tailrec fun Context.lifecycleOrNull(): Lifecycle? = when (this) { - is LifecycleOwner -> this.lifecycle - else -> (this as? ContextWrapper)?.baseContext?.lifecycleOrNull() -} diff --git a/workflow-ui/modal-android/src/main/java/com/squareup/workflow1/ui/modal/ModalViewContainer.kt b/workflow-ui/modal-android/src/main/java/com/squareup/workflow1/ui/modal/ModalViewContainer.kt index cbbb9c74e..6df9057e1 100644 --- a/workflow-ui/modal-android/src/main/java/com/squareup/workflow1/ui/modal/ModalViewContainer.kt +++ b/workflow-ui/modal-android/src/main/java/com/squareup/workflow1/ui/modal/ModalViewContainer.kt @@ -62,12 +62,15 @@ public open class ModalViewContainer @JvmOverloads constructor( initialViewEnvironment: ViewEnvironment ): DialogRef { val view = initialViewEnvironment[ViewRegistry] - .buildView( - initialRendering = initialModalRendering, - initialViewEnvironment = initialViewEnvironment, - contextForNewView = this.context, - container = this - ) + // Notice that we don't pass a custom initializeView function to set the + // WorkflowLifecycleOwner here. ModalContainer will do that itself, on the parent of the view + // created here. + .buildView( + initialRendering = initialModalRendering, + initialViewEnvironment = initialViewEnvironment, + contextForNewView = this.context, + container = this + ) .apply { // If the modal's root view has no backPressedHandler, add a no-op one to // ensure that the `onBackPressed` call below will not leak up to handlers