BIT-267 make sure root navigation state doesn't get blown away after … (#32)

This commit is contained in:
Andrew Haisting 2023-09-06 16:52:31 -05:00 committed by Álison Fernandes
parent 7ba13a771f
commit 9ce88e2222
5 changed files with 103 additions and 27 deletions

View file

@ -34,5 +34,5 @@ fun NavGraphBuilder.authDestinations(navController: NavHostController) {
fun NavController.navigateToAuth( fun NavController.navigateToAuth(
navOptions: NavOptions? = null, navOptions: NavOptions? = null,
) { ) {
navigate(LANDING_ROUTE, navOptions) navigate(AUTH_ROUTE, navOptions)
} }

View file

@ -5,15 +5,18 @@ import androidx.compose.runtime.getValue
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions import androidx.navigation.navOptions
import com.x8bit.bitwarden.ui.auth.feature.auth.AUTH_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.auth.authDestinations import com.x8bit.bitwarden.ui.auth.feature.auth.authDestinations
import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuth import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuth
import com.x8bit.bitwarden.ui.platform.components.PlaceholderComposable import com.x8bit.bitwarden.ui.platform.components.PlaceholderComposable
import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.VAULT_UNLOCKED_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.navigateToVaultUnlocked import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.navigateToVaultUnlocked
import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.vaultUnlockedDestinations import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.vaultUnlockedDestinations
@ -29,7 +32,7 @@ fun RootNavScreen(
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = SplashRoute, startDestination = SPLASH_ROUTE,
) { ) {
splashDestinations() splashDestinations()
authDestinations(navController) authDestinations(navController)
@ -40,9 +43,32 @@ fun RootNavScreen(
val rootNavOptions = navOptions { val rootNavOptions = navOptions {
// When changing root navigation state, pop everything else off the back stack: // When changing root navigation state, pop everything else off the back stack:
popUpTo(navController.graph.id) { popUpTo(navController.graph.id) {
inclusive = true inclusive = false
saveState = true
} }
launchSingleTop = true
restoreState = true
} }
// This workaround is for an issue where "launchSingleTop" flag does not work correctly
// and we "re-navigate" after rotation or process death. To prevent this, we are currently
// checking the root level route and no-opping the navigation, because we are already there.
// When upgrading to the latest compose nav version (which we currently aren't doing for
// other reasons), we can test that this workaround is no longer needed and remove it.
// To test, remove the (currentRoute == targetRoute) and test that state is saved
// on process death and rotation (BIT-201).
val targetRoute = when (state) {
RootNavState.Auth -> AUTH_ROUTE
RootNavState.Splash -> SPLASH_ROUTE
RootNavState.VaultUnlocked -> VAULT_UNLOCKED_ROUTE
}
val currentRoute = navController.currentDestination?.routeLevelRoute()
// Don't navigate if we are already at the correct root:
if (currentRoute == targetRoute) {
return
}
when (state) { when (state) {
RootNavState.Auth -> navController.navigateToAuth(rootNavOptions) RootNavState.Auth -> navController.navigateToAuth(rootNavOptions)
RootNavState.Splash -> navController.navigateToSplash(rootNavOptions) RootNavState.Splash -> navController.navigateToSplash(rootNavOptions)
@ -50,6 +76,23 @@ fun RootNavScreen(
} }
} }
/**
* Helper method that returns the highest level route for the given [NavDestination].
*
* As noted above, this can be removed after upgrading to latest compose navigation, since
* the nav args can prevent us from having to do this check.
*/
@Suppress("ReturnCount")
private fun NavDestination?.routeLevelRoute(): String? {
if (this == null) {
return null
}
if (parent?.route == null) {
return route
}
return parent.routeLevelRoute()
}
/** /**
* The functions below should be moved to their respective feature packages once they exist. * The functions below should be moved to their respective feature packages once they exist.
* *
@ -59,8 +102,7 @@ fun RootNavScreen(
/** /**
* TODO: move to splash package (BIT-147) * TODO: move to splash package (BIT-147)
*/ */
@Suppress("TopLevelPropertyNaming") private const val SPLASH_ROUTE = "splash"
private const val SplashRoute = "splash"
/** /**
* Add splash destinations to the nav graph. * Add splash destinations to the nav graph.
@ -68,7 +110,7 @@ private const val SplashRoute = "splash"
* TODO: move to splash package (BIT-147) * TODO: move to splash package (BIT-147)
*/ */
private fun NavGraphBuilder.splashDestinations() { private fun NavGraphBuilder.splashDestinations() {
composable(SplashRoute) { composable(SPLASH_ROUTE) {
PlaceholderComposable(text = "Splash") PlaceholderComposable(text = "Splash")
} }
} }
@ -83,5 +125,5 @@ private fun NavGraphBuilder.splashDestinations() {
private fun NavController.navigateToSplash( private fun NavController.navigateToSplash(
navOptions: NavOptions? = null, navOptions: NavOptions? = null,
) { ) {
navigate(SplashRoute, navOptions) navigate(SPLASH_ROUTE, navOptions)
} }

View file

@ -1,26 +1,48 @@
package com.x8bit.bitwarden.ui.platform.feature.rootnav package com.x8bit.bitwarden.ui.platform.feature.rootnav
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject import javax.inject.Inject
private const val KEY_NAV_DESTINATION = "nav_state"
/** /**
* Manages root level navigation state of the application. * Manages root level navigation state of the application.
*/ */
@HiltViewModel @HiltViewModel
class RootNavViewModel @Inject constructor() : class RootNavViewModel @Inject constructor(
BaseViewModel<RootNavState, Unit, Unit>( private val savedStateHandle: SavedStateHandle,
initialState = RootNavState.Splash, ) : BaseViewModel<RootNavState, Unit, Unit>(
) { initialState = RootNavState.Splash,
) {
private var savedRootNavState: RootNavState?
get() = savedStateHandle[KEY_NAV_DESTINATION]
set(value) {
savedStateHandle[KEY_NAV_DESTINATION] = value
}
init { init {
savedRootNavState?.let { savedState: RootNavState ->
mutableStateFlow.update { savedState }
}
// Every time the nav state changes, update saved state handle:
stateFlow
.onEach { savedRootNavState = it }
.launchIn(viewModelScope)
viewModelScope.launch { viewModelScope.launch {
@Suppress("MagicNumber") @Suppress("MagicNumber")
delay(1000) delay(1000)
mutableStateFlow.value = RootNavState.Auth mutableStateFlow.update { RootNavState.Auth }
} }
} }
@ -28,21 +50,24 @@ class RootNavViewModel @Inject constructor() :
} }
/** /**
* Models state of the root level navigation of the app. * Models root level destinations for the app.
*/ */
sealed class RootNavState { sealed class RootNavState : Parcelable {
/** /**
* Show the vault unlocked screen. * App should show auth nav graph.
*/
data object VaultUnlocked : RootNavState()
/**
* Show the auth screens.
*/ */
@Parcelize
data object Auth : RootNavState() data object Auth : RootNavState()
/** /**
* Show the splash screen. * App should show splash nav graph.
*/ */
@Parcelize
data object Splash : RootNavState() data object Splash : RootNavState()
/**
* App should show vault unlocked nav graph.
*/
@Parcelize
data object VaultUnlocked : RootNavState()
} }

View file

@ -7,7 +7,7 @@ import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination
private const val VAULT_UNLOCKED_ROUTE = "VaultUnlocked" const val VAULT_UNLOCKED_ROUTE: String = "VaultUnlocked"
/** /**
* Navigate to the vault unlocked screen. Note this will only work if vault unlocked destinations were added * Navigate to the vault unlocked screen. Note this will only work if vault unlocked destinations were added

View file

@ -1,24 +1,33 @@
package com.x8bit.bitwarden.ui.platform.feature.rootnav package com.x8bit.bitwarden.ui.platform.feature.rootnav
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class RootNavViewModelTest : BaseViewModelTest() { class RootNavViewModelTest : BaseViewModelTest() {
@Test @Test
fun `initial state should be splash`() { fun `initial state should be splash`() {
val viewModel = RootNavViewModel() val viewModel = RootNavViewModel(SavedStateHandle())
assert(viewModel.stateFlow.value is RootNavState.Splash) assertEquals(viewModel.stateFlow.value, RootNavState.Splash)
}
@Test
fun `initial state should be the state in savedStateHandle`() {
val handle = SavedStateHandle(mapOf(("nav_state" to RootNavState.VaultUnlocked)))
val viewModel = RootNavViewModel(handle)
assertEquals(viewModel.stateFlow.value, RootNavState.VaultUnlocked)
} }
@Test @Test
fun `state should move from splash to auth`() = runTest { fun `state should move from splash to auth`() = runTest {
val viewModel = RootNavViewModel() val viewModel = RootNavViewModel(SavedStateHandle())
viewModel.stateFlow.test { viewModel.stateFlow.test {
assert(awaitItem() is RootNavState.Splash) assertEquals(awaitItem(), RootNavState.Splash)
assert(awaitItem() is RootNavState.Auth) assertEquals(awaitItem(), RootNavState.Auth)
} }
} }
} }