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