1 /* 2 * Copyright 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.navigation.testing 18 19 import android.content.Context 20 import androidx.lifecycle.Lifecycle 21 import androidx.lifecycle.ViewModelStore 22 import androidx.navigation.FloatingWindow 23 import androidx.navigation.NavBackStackEntry 24 import androidx.navigation.NavDestination 25 import androidx.navigation.NavViewModelStoreProvider 26 import androidx.navigation.NavigatorState 27 import androidx.navigation.SupportingPane 28 import androidx.navigation.internal.NavContext 29 import androidx.savedstate.SavedState 30 import androidx.savedstate.savedState 31 import kotlinx.coroutines.CoroutineDispatcher 32 import kotlinx.coroutines.Dispatchers 33 import kotlinx.coroutines.runBlocking 34 import kotlinx.coroutines.withContext 35 36 /** 37 * An implementation of [NavigatorState] that allows testing a [androidx.navigation.Navigator] in 38 * isolation (i.e., without requiring a [androidx.navigation.NavController]). 39 * 40 * An optional [context] can be provided to allow for the usages of 41 * [androidx.lifecycle.AndroidViewModel] within the created [NavBackStackEntry] instances. 42 * 43 * The [Lifecycle] of all [NavBackStackEntry] instances added to this TestNavigatorState will be 44 * updated as they are added and removed from the state. This work is kicked off on the 45 * [coroutineDispatcher]. 46 */ 47 public class TestNavigatorState 48 @JvmOverloads 49 constructor( 50 private val context: Context? = null, 51 private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate 52 ) : NavigatorState() { 53 internal val navContext = NavContext(context) 54 55 private val viewModelStoreProvider = 56 object : NavViewModelStoreProvider { 57 private val viewModelStores = mutableMapOf<String, ViewModelStore>() 58 getViewModelStorenull59 override fun getViewModelStore(backStackEntryId: String) = 60 viewModelStores.getOrPut(backStackEntryId) { ViewModelStore() } 61 } 62 63 private val savedStates = mutableMapOf<String, SavedState>() 64 private val entrySavedState = mutableMapOf<NavBackStackEntry, Boolean>() 65 createBackStackEntrynull66 override fun createBackStackEntry( 67 destination: NavDestination, 68 arguments: SavedState? 69 ): NavBackStackEntry = 70 NavBackStackEntry.create( 71 navContext, 72 destination, 73 arguments, 74 Lifecycle.State.RESUMED, 75 viewModelStoreProvider 76 ) 77 78 /** 79 * Restore a previously saved [NavBackStackEntry]. You must have previously called [pop] with 80 * [previouslySavedEntry] and `true`. 81 */ 82 public fun restoreBackStackEntry(previouslySavedEntry: NavBackStackEntry): NavBackStackEntry { 83 val savedState = 84 checkNotNull(savedStates[previouslySavedEntry.id]) { 85 "restoreBackStackEntry(previouslySavedEntry) must be passed a NavBackStackEntry " + 86 "that was previously popped with popBackStack(previouslySavedEntry, true)" 87 } 88 return NavBackStackEntry.create( 89 navContext, 90 previouslySavedEntry.destination, 91 previouslySavedEntry.arguments, 92 Lifecycle.State.RESUMED, 93 viewModelStoreProvider, 94 previouslySavedEntry.id, 95 savedState 96 ) 97 } 98 pushnull99 override fun push(backStackEntry: NavBackStackEntry) { 100 super.push(backStackEntry) 101 updateMaxLifecycle() 102 } 103 popnull104 override fun pop(popUpTo: NavBackStackEntry, saveState: Boolean) { 105 val beforePopList = backStack.value 106 val poppedList = beforePopList.subList(beforePopList.indexOf(popUpTo), beforePopList.size) 107 super.pop(popUpTo, saveState) 108 updateMaxLifecycle(poppedList, saveState) 109 } 110 popWithTransitionnull111 override fun popWithTransition(popUpTo: NavBackStackEntry, saveState: Boolean) { 112 super.popWithTransition(popUpTo, saveState) 113 entrySavedState[popUpTo] = saveState 114 } 115 markTransitionCompletenull116 override fun markTransitionComplete(entry: NavBackStackEntry) { 117 val savedState = entrySavedState[entry] == true 118 super.markTransitionComplete(entry) 119 entrySavedState.remove(entry) 120 if (!backStack.value.contains(entry)) { 121 updateMaxLifecycle(listOf(entry), savedState) 122 } else { 123 updateMaxLifecycle() 124 } 125 } 126 prepareForTransitionnull127 override fun prepareForTransition(entry: NavBackStackEntry) { 128 super.prepareForTransition(entry) 129 runBlocking(coroutineDispatcher) { 130 // NavBackStackEntry Lifecycles must be updated on the main thread 131 // as per the contract within Lifecycle, so we explicitly swap to the main thread 132 // no matter what CoroutineDispatcher was passed to us. 133 withContext(Dispatchers.Main.immediate) { entry.maxLifecycle = Lifecycle.State.STARTED } 134 } 135 } 136 updateMaxLifecyclenull137 private fun updateMaxLifecycle( 138 poppedList: List<NavBackStackEntry> = emptyList(), 139 saveState: Boolean = false 140 ) { 141 runBlocking(coroutineDispatcher) { 142 // NavBackStackEntry Lifecycles must be updated on the main thread 143 // as per the contract within Lifecycle, so we explicitly swap to the main thread 144 // no matter what CoroutineDispatcher was passed to us. 145 withContext(Dispatchers.Main.immediate) { 146 // Mark all removed NavBackStackEntries as DESTROYED 147 for (entry in poppedList.reversed()) { 148 if ( 149 saveState && entry.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) 150 ) { 151 // Move the NavBackStackEntry to the stopped state, then save its state 152 entry.maxLifecycle = Lifecycle.State.CREATED 153 val savedState = savedState() 154 entry.saveState(savedState) 155 savedStates[entry.id] = savedState 156 } 157 val transitioning = transitionsInProgress.value.contains(entry) 158 if (!transitioning) { 159 entry.maxLifecycle = Lifecycle.State.DESTROYED 160 if (!saveState) { 161 savedStates.remove(entry.id) 162 viewModelStoreProvider.getViewModelStore(entry.id).clear() 163 } 164 } else { 165 entry.maxLifecycle = Lifecycle.State.CREATED 166 } 167 } 168 // Now go through the current list of destinations, updating their Lifecycle state 169 val currentList = backStack.value 170 var previousEntry: NavBackStackEntry? = null 171 for (entry in currentList.reversed()) { 172 val transitioning = transitionsInProgress.value.contains(entry) 173 entry.maxLifecycle = 174 when { 175 previousEntry == null -> 176 if (!transitioning) { 177 Lifecycle.State.RESUMED 178 } else { 179 Lifecycle.State.STARTED 180 } 181 previousEntry.destination is SupportingPane -> { 182 // Match the previous entry's destination, making sure 183 // a transitioning destination does not go to resumed 184 previousEntry.maxLifecycle.coerceAtMost( 185 if (!transitioning) { 186 Lifecycle.State.RESUMED 187 } else { 188 Lifecycle.State.STARTED 189 } 190 ) 191 } 192 previousEntry.destination is FloatingWindow -> Lifecycle.State.STARTED 193 else -> Lifecycle.State.CREATED 194 } 195 previousEntry = entry 196 } 197 } 198 } 199 } 200 } 201