Ensure TestNavigatorState updates the Lifecycle correctly
The TestNavigatorState serves the role of
"NavController" when building isolated tests
for a Navigator. As such, it owns the logic for
updating and maintaining the state of each
NavBackStackEntry added to it.
As such, the TestNavigatorState now updates
the Lifecycle of each NavBackStackEntry
as it is added and popped from the state's
back stack, mirroring the behavior of
NavController.
Relnote: N/A
Test: new TestNavigatorStateTest
BUG: 80029773
Change-Id: I92b09989a7d9bc63d52747eef40f04d75a43cc0d
diff --git a/navigation/navigation-testing/api/current.txt b/navigation/navigation-testing/api/current.txt
index f9ce0d4..10d8654 100644
--- a/navigation/navigation-testing/api/current.txt
+++ b/navigation/navigation-testing/api/current.txt
@@ -10,6 +10,7 @@
}
public final class TestNavigatorState extends androidx.navigation.NavigatorState {
+ ctor public TestNavigatorState(optional android.content.Context? context, optional kotlinx.coroutines.CoroutineDispatcher coroutineDispatcher);
ctor public TestNavigatorState(optional android.content.Context? context);
method public androidx.navigation.NavBackStackEntry createBackStackEntry(androidx.navigation.NavDestination destination, android.os.Bundle? arguments);
}
diff --git a/navigation/navigation-testing/api/public_plus_experimental_current.txt b/navigation/navigation-testing/api/public_plus_experimental_current.txt
index f9ce0d4..10d8654 100644
--- a/navigation/navigation-testing/api/public_plus_experimental_current.txt
+++ b/navigation/navigation-testing/api/public_plus_experimental_current.txt
@@ -10,6 +10,7 @@
}
public final class TestNavigatorState extends androidx.navigation.NavigatorState {
+ ctor public TestNavigatorState(optional android.content.Context? context, optional kotlinx.coroutines.CoroutineDispatcher coroutineDispatcher);
ctor public TestNavigatorState(optional android.content.Context? context);
method public androidx.navigation.NavBackStackEntry createBackStackEntry(androidx.navigation.NavDestination destination, android.os.Bundle? arguments);
}
diff --git a/navigation/navigation-testing/api/restricted_current.txt b/navigation/navigation-testing/api/restricted_current.txt
index f9ce0d4..10d8654 100644
--- a/navigation/navigation-testing/api/restricted_current.txt
+++ b/navigation/navigation-testing/api/restricted_current.txt
@@ -10,6 +10,7 @@
}
public final class TestNavigatorState extends androidx.navigation.NavigatorState {
+ ctor public TestNavigatorState(optional android.content.Context? context, optional kotlinx.coroutines.CoroutineDispatcher coroutineDispatcher);
ctor public TestNavigatorState(optional android.content.Context? context);
method public androidx.navigation.NavBackStackEntry createBackStackEntry(androidx.navigation.NavDestination destination, android.os.Bundle? arguments);
}
diff --git a/navigation/navigation-testing/src/androidTest/java/androidx/navigation/testing/TestNavigatorStateTest.kt b/navigation/navigation-testing/src/androidTest/java/androidx/navigation/testing/TestNavigatorStateTest.kt
new file mode 100644
index 0000000..b20964e
--- /dev/null
+++ b/navigation/navigation-testing/src/androidTest/java/androidx/navigation/testing/TestNavigatorStateTest.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.navigation.testing
+
+import androidx.lifecycle.Lifecycle
+import androidx.navigation.FloatingWindow
+import androidx.navigation.NavDestination
+import androidx.navigation.Navigator
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class TestNavigatorStateTest {
+ private lateinit var state: TestNavigatorState
+
+ @Before
+ fun setUp() {
+ state = TestNavigatorState()
+ }
+
+ @Test
+ fun testLifecycle() {
+ val navigator = TestNavigator()
+ navigator.onAttach(state)
+ val firstEntry = state.createBackStackEntry(navigator.createDestination(), null)
+ assertThat(firstEntry.lifecycle.currentState)
+ .isEqualTo(Lifecycle.State.INITIALIZED)
+
+ navigator.navigate(listOf(firstEntry), null, null)
+ assertThat(firstEntry.lifecycle.currentState)
+ .isEqualTo(Lifecycle.State.RESUMED)
+
+ val secondEntry = state.createBackStackEntry(navigator.createDestination(), null)
+ navigator.navigate(listOf(secondEntry), null, null)
+ assertThat(firstEntry.lifecycle.currentState)
+ .isEqualTo(Lifecycle.State.CREATED)
+ assertThat(secondEntry.lifecycle.currentState)
+ .isEqualTo(Lifecycle.State.RESUMED)
+
+ navigator.popBackStack(secondEntry, false)
+ assertThat(firstEntry.lifecycle.currentState)
+ .isEqualTo(Lifecycle.State.RESUMED)
+ assertThat(secondEntry.lifecycle.currentState)
+ .isEqualTo(Lifecycle.State.DESTROYED)
+ }
+
+ @Test
+ fun testFloatingWindowLifecycle() {
+ val navigator = FloatingWindowTestNavigator()
+ navigator.onAttach(state)
+ val firstEntry = state.createBackStackEntry(navigator.createDestination(), null)
+ assertThat(firstEntry.lifecycle.currentState)
+ .isEqualTo(Lifecycle.State.INITIALIZED)
+
+ navigator.navigate(listOf(firstEntry), null, null)
+ assertThat(firstEntry.lifecycle.currentState)
+ .isEqualTo(Lifecycle.State.RESUMED)
+
+ val secondEntry = state.createBackStackEntry(navigator.createDestination(), null)
+ navigator.navigate(listOf(secondEntry), null, null)
+ assertThat(firstEntry.lifecycle.currentState)
+ .isEqualTo(Lifecycle.State.STARTED)
+ assertThat(secondEntry.lifecycle.currentState)
+ .isEqualTo(Lifecycle.State.RESUMED)
+
+ navigator.popBackStack(secondEntry, false)
+ assertThat(firstEntry.lifecycle.currentState)
+ .isEqualTo(Lifecycle.State.RESUMED)
+ assertThat(secondEntry.lifecycle.currentState)
+ .isEqualTo(Lifecycle.State.DESTROYED)
+ }
+
+ @Navigator.Name("test")
+ internal class TestNavigator : Navigator<NavDestination>() {
+ override fun createDestination(): NavDestination = NavDestination(this)
+ }
+
+ @Navigator.Name("test")
+ internal class FloatingWindowTestNavigator : Navigator<FloatingTestDestination>() {
+ override fun createDestination(): FloatingTestDestination = FloatingTestDestination(this)
+ }
+
+ internal class FloatingTestDestination(
+ navigator: Navigator<out NavDestination>
+ ) : NavDestination(navigator), FloatingWindow
+}
\ No newline at end of file
diff --git a/navigation/navigation-testing/src/main/java/androidx/navigation/testing/TestNavigatorState.kt b/navigation/navigation-testing/src/main/java/androidx/navigation/testing/TestNavigatorState.kt
index d91c6d2..301f731 100644
--- a/navigation/navigation-testing/src/main/java/androidx/navigation/testing/TestNavigatorState.kt
+++ b/navigation/navigation-testing/src/main/java/androidx/navigation/testing/TestNavigatorState.kt
@@ -22,10 +22,15 @@
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.testing.TestLifecycleOwner
+import androidx.navigation.FloatingWindow
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
import androidx.navigation.NavViewModelStoreProvider
import androidx.navigation.NavigatorState
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
import java.util.UUID
/**
@@ -36,12 +41,20 @@
* An optional [context] can be provided to allow for the usages of
* [androidx.lifecycle.AndroidViewModel] within the created [NavBackStackEntry]
* instances.
+ *
+ * The [Lifecycle] of all [NavBackStackEntry] instances added to this TestNavigatorState
+ * will be updated as they are added and removed from the state. This work is kicked off
+ * on the [coroutineDispatcher].
*/
public class TestNavigatorState @JvmOverloads constructor(
- private val context: Context? = null
+ private val context: Context? = null,
+ private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate
) : NavigatorState() {
- private val lifecycleOwner: LifecycleOwner = TestLifecycleOwner(Lifecycle.State.RESUMED)
+ private val lifecycleOwner: LifecycleOwner = TestLifecycleOwner(
+ Lifecycle.State.RESUMED,
+ coroutineDispatcher
+ )
private val viewModelStoreProvider = object : NavViewModelStoreProvider {
private val viewModelStores = mutableMapOf<UUID, ViewModelStore>()
@@ -58,4 +71,41 @@
): NavBackStackEntry = NavBackStackEntry.create(
context, destination, arguments, lifecycleOwner, viewModelStoreProvider
)
+
+ override fun add(backStackEntry: NavBackStackEntry) {
+ super.add(backStackEntry)
+ updateMaxLifecycle()
+ }
+
+ override fun pop(popUpTo: NavBackStackEntry, saveState: Boolean) {
+ val beforePopList = backStack.value
+ val poppedList = beforePopList.subList(beforePopList.indexOf(popUpTo), beforePopList.size)
+ super.pop(popUpTo, saveState)
+ updateMaxLifecycle(poppedList)
+ }
+
+ private fun updateMaxLifecycle(poppedList: List<NavBackStackEntry> = emptyList()) {
+ runBlocking(coroutineDispatcher) {
+ // NavBackStackEntry Lifecycles must be updated on the main thread
+ // as per the contract within Lifecycle, so we explicitly swap to the main thread
+ // no matter what CoroutineDispatcher was passed to us.
+ withContext(Dispatchers.Main.immediate) {
+ // Mark all removed NavBackStackEntries as DESTROYED
+ for (entry in poppedList.reversed()) {
+ entry.maxLifecycle = Lifecycle.State.DESTROYED
+ }
+ // Now go through the current list of destinations, updating their Lifecycle state
+ val currentList = backStack.value
+ var previousEntry: NavBackStackEntry? = null
+ for (entry in currentList.reversed()) {
+ entry.maxLifecycle = when {
+ previousEntry == null -> Lifecycle.State.RESUMED
+ previousEntry.destination is FloatingWindow -> Lifecycle.State.STARTED
+ else -> Lifecycle.State.CREATED
+ }
+ previousEntry = entry
+ }
+ }
+ }
+ }
}
diff --git a/testutils/testutils-navigation/build.gradle b/testutils/testutils-navigation/build.gradle
index a1494e1..61664a7 100644
--- a/testutils/testutils-navigation/build.gradle
+++ b/testutils/testutils-navigation/build.gradle
@@ -30,6 +30,7 @@
api(projectOrArtifact(":navigation:navigation-common"))
testImplementation(projectOrArtifact(":navigation:navigation-testing"))
+ testImplementation("androidx.arch.core:core-testing:2.1.0")
testImplementation(JUNIT)
testImplementation(MOCKITO_CORE)
diff --git a/testutils/testutils-navigation/src/test/java/androidx/testutils/TestNavigatorTest.kt b/testutils/testutils-navigation/src/test/java/androidx/testutils/TestNavigatorTest.kt
index 454883a..ce8b38d 100644
--- a/testutils/testutils-navigation/src/test/java/androidx/testutils/TestNavigatorTest.kt
+++ b/testutils/testutils-navigation/src/test/java/androidx/testutils/TestNavigatorTest.kt
@@ -17,8 +17,10 @@
package androidx.testutils
import android.os.Bundle
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.navigation.testing.TestNavigatorState
import org.junit.Assert.assertEquals
+import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
@@ -26,6 +28,9 @@
@RunWith(JUnit4::class)
class TestNavigatorTest {
+ @get:Rule
+ val instantTaskExecutorRule = InstantTaskExecutorRule()
+
@Test
fun backStack() {
val testNavigator = TestNavigator()