Item migration flow has been moved into a graph (#6427)

This commit is contained in:
David Perez
2026-01-29 09:16:02 -06:00
committed by GitHub
parent ebfe293c81
commit 0d0a5cb292
9 changed files with 133 additions and 144 deletions

View File

@@ -12,9 +12,11 @@ import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.browser.auth.AuthTabIntent
import androidx.compose.foundation.background
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.core.app.ActivityCompat
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@@ -120,6 +122,8 @@ class MainActivity : AppCompatActivity() {
NavHost(
navController = navController,
startDestination = RootNavigationRoute,
modifier = Modifier
.background(color = BitwardenTheme.colorScheme.background.primary),
) {
// Both root navigation and debug menu exist at this top level.
// The debug menu can appear on top of the rest of the app without

View File

@@ -70,8 +70,9 @@ import com.x8bit.bitwarden.ui.vault.feature.exportitems.exportItemsGraph
import com.x8bit.bitwarden.ui.vault.feature.exportitems.navigateToExportItemsGraph
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.navigateToVerifyPassword
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.navigateToVaultItemListingAsRoot
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.MigrateToMyItemsRoute
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.navigateToMigrateToMyItems
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.MigrateToMyItemsGraphRoute
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.migrateToMyItemsGraph
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.navigateToMigrateToMyItemsGraph
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
@@ -119,6 +120,7 @@ fun RootNavScreen(
setupAutoFillDestinationAsRoot()
setupCompleteDestination()
exportItemsGraph(navController)
migrateToMyItemsGraph(navController)
}
val targetRoute = when (state) {
@@ -155,13 +157,7 @@ fun RootNavScreen(
RootNavState.OnboardingAutoFillSetup -> SetupAutofillRoute.AsRoot
RootNavState.OnboardingBrowserAutofillSetup -> SetupBrowserAutofillRoute.AsRoot
RootNavState.OnboardingStepsComplete -> SetupCompleteRoute
is RootNavState.MigrateToMyItems -> {
val migrateState = state as RootNavState.MigrateToMyItems
MigrateToMyItemsRoute(
organizationId = migrateState.organizationId,
organizationName = migrateState.organizationName,
)
}
is RootNavState.MigrateToMyItems -> MigrateToMyItemsGraphRoute
}
val currentRoute = navController.currentDestination?.rootLevelRoute()
@@ -214,11 +210,7 @@ fun RootNavScreen(
}
is RootNavState.MigrateToMyItems -> {
navController.navigateToMigrateToMyItems(
organizationName = currentState.organizationName,
organizationId = currentState.organizationId,
navOptions = rootNavOptions,
)
navController.navigateToMigrateToMyItemsGraph(rootNavOptions)
}
RootNavState.RemovePassword -> navController.navigateToRemovePassword(rootNavOptions)
@@ -354,31 +346,57 @@ private fun NavDestination?.rootLevelRoute(): String? {
* Define the enter transition for each route.
*/
@Suppress("MaxLineLength")
private fun AnimatedContentTransitionScope<NavBackStackEntry>.toEnterTransition(): NonNullEnterTransitionProvider =
when (targetState.destination.rootLevelRoute()) {
ResetPasswordRoute.toObjectNavigationRoute() -> RootTransitionProviders.Enter.slideUp
else -> when (initialState.destination.rootLevelRoute()) {
// Disable transitions when coming from the splash screen
SplashRoute.toObjectNavigationRoute() -> RootTransitionProviders.Enter.none
// The RESET_PASSWORD_ROUTE animation should be stay but due to an issue when combining
// certain animations, we are just using a fadeIn instead.
ResetPasswordRoute.toObjectNavigationRoute() -> RootTransitionProviders.Enter.fadeIn
else -> RootTransitionProviders.Enter.fadeIn
private fun AnimatedContentTransitionScope<NavBackStackEntry>.toEnterTransition(): NonNullEnterTransitionProvider {
val initialRoute = initialState.destination.rootLevelRoute()
val targetRoute = targetState.destination.rootLevelRoute()
return if (initialRoute == targetRoute) {
RootTransitionProviders.Enter.none
} else {
when (targetState.destination.rootLevelRoute()) {
MigrateToMyItemsGraphRoute.toObjectNavigationRoute(),
ResetPasswordRoute.toObjectNavigationRoute(),
-> RootTransitionProviders.Enter.slideUp
else -> when (initialState.destination.rootLevelRoute()) {
// Disable transitions when coming from the splash screen
SplashRoute.toObjectNavigationRoute() -> RootTransitionProviders.Enter.none
// The MigrateToMyItemsGraphRoute and ResetPasswordRoute animation should be stay
// but due to an issue when combining certain animations, we are just using a
// fadeIn instead.
MigrateToMyItemsGraphRoute.toObjectNavigationRoute(),
ResetPasswordRoute.toObjectNavigationRoute(),
-> RootTransitionProviders.Enter.fadeIn
else -> RootTransitionProviders.Enter.fadeIn
}
}
}
}
/**
* Define the exit transition for each route.
*/
@Suppress("MaxLineLength")
private fun AnimatedContentTransitionScope<NavBackStackEntry>.toExitTransition(): NonNullExitTransitionProvider {
return when (initialState.destination.rootLevelRoute()) {
// Disable transitions when coming from the splash screen
SplashRoute.toObjectNavigationRoute() -> RootTransitionProviders.Exit.none
ResetPasswordRoute.toObjectNavigationRoute() -> RootTransitionProviders.Exit.slideDown
else -> when (targetState.destination.rootLevelRoute()) {
ResetPasswordRoute.toObjectNavigationRoute() -> RootTransitionProviders.Exit.stay
else -> RootTransitionProviders.Exit.fadeOut
val initialRoute = initialState.destination.rootLevelRoute()
val targetRoute = targetState.destination.rootLevelRoute()
return if (initialRoute == targetRoute) {
RootTransitionProviders.Exit.none
} else {
when (initialRoute) {
// Disable transitions when coming from the splash screen
SplashRoute.toObjectNavigationRoute() -> RootTransitionProviders.Exit.none
MigrateToMyItemsGraphRoute.toObjectNavigationRoute(),
ResetPasswordRoute.toObjectNavigationRoute(),
-> RootTransitionProviders.Exit.slideDown
else -> when (targetRoute) {
MigrateToMyItemsGraphRoute.toObjectNavigationRoute(),
ResetPasswordRoute.toObjectNavigationRoute(),
-> RootTransitionProviders.Exit.stay
else -> RootTransitionProviders.Exit.fadeOut
}
}
}
}

View File

@@ -55,7 +55,7 @@ class RootNavViewModel @Inject constructor(
vaultMigrationData = vaultMigrationData,
)
}
.onEach(::handleAction)
.onEach(::sendAction)
.launchIn(viewModelScope)
}
@@ -118,12 +118,7 @@ class RootNavViewModel @Inject constructor(
userState.activeAccount.isVaultUnlocked &&
action.vaultMigrationData is VaultMigrationData.MigrationRequired &&
shouldShowVaultMigration(specialCircumstance) -> {
RootNavState.MigrateToMyItems(
organizationId = action.vaultMigrationData.organizationId,
organizationName = action.vaultMigrationData.organizationName,
)
}
shouldShowVaultMigration(specialCircumstance) -> RootNavState.MigrateToMyItems
userState.activeAccount.isVaultUnlocked -> {
when (specialCircumstance) {
@@ -363,13 +358,10 @@ sealed class RootNavState : Parcelable {
data object VaultLocked : RootNavState()
/**
* App should show MigrateToMyItems screen.
* App should show MigrateToMyItems graph.
*/
@Parcelize
data class MigrateToMyItems(
val organizationId: String,
val organizationName: String,
) : RootNavState()
data object MigrateToMyItems : RootNavState()
/**
* App should show vault unlocked nav graph for the given [activeUserId].

View File

@@ -57,11 +57,8 @@ import com.x8bit.bitwarden.ui.vault.feature.importlogins.navigateToImportLoginsS
import com.x8bit.bitwarden.ui.vault.feature.item.navigateToVaultItem
import com.x8bit.bitwarden.ui.vault.feature.item.vaultItemDestination
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.vaultItemListingDestinationAsRoot
import com.x8bit.bitwarden.ui.vault.feature.leaveorganization.leaveOrganizationDestination
import com.x8bit.bitwarden.ui.vault.feature.leaveorganization.navigateToLeaveOrganization
import com.x8bit.bitwarden.ui.vault.feature.manualcodeentry.navigateToManualCodeEntryScreen
import com.x8bit.bitwarden.ui.vault.feature.manualcodeentry.vaultManualCodeEntryDestination
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.migrateToMyItemsDestination
import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.navigateToVaultMoveToOrganization
import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.vaultMoveToOrganizationDestination
import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.navigateToQrCodeScanScreen
@@ -266,19 +263,6 @@ fun NavGraphBuilder.vaultUnlockedGraph(
importLoginsScreenDestination(
onNavigateBack = { navController.popBackStack() },
)
migrateToMyItemsDestination(
onNavigateToLeaveOrganization = { organizationId, organizationName ->
navController.navigateToLeaveOrganization(
organizationId = organizationId,
organizationName = organizationName,
)
},
)
leaveOrganizationDestination(
onNavigateBack = { navController.popBackStack() },
)
}
}

View File

@@ -0,0 +1,51 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.navigation
import com.bitwarden.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.vault.feature.leaveorganization.leaveOrganizationDestination
import com.x8bit.bitwarden.ui.vault.feature.leaveorganization.navigateToLeaveOrganization
import kotlinx.serialization.Serializable
/**
* The type-safe route for the migrate to my items graph.
*/
@OmitFromCoverage
@Serializable
data object MigrateToMyItemsGraphRoute
/**
* Navigate to the migrate to my items graph.
*/
fun NavController.navigateToMigrateToMyItemsGraph(
navOptions: NavOptions? = null,
) {
navigate(route = MigrateToMyItemsGraphRoute, navOptions = navOptions)
}
/**
* Add the migrate to my items graph to the nav graph.
*/
fun NavGraphBuilder.migrateToMyItemsGraph(
navController: NavController,
) {
navigation<MigrateToMyItemsGraphRoute>(
startDestination = MigrateToMyItemsRoute,
) {
migrateToMyItemsDestination(
onNavigateToLeaveOrganization = { organizationId, organizationName ->
navController.navigateToLeaveOrganization(
organizationId = organizationId,
organizationName = organizationName,
)
},
)
leaveOrganizationDestination(
onNavigateBack = { navController.popBackStack() },
)
}
}

View File

@@ -2,69 +2,17 @@
package com.x8bit.bitwarden.ui.vault.feature.migratetomyitems
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.toRoute
import androidx.navigation.compose.composable
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import kotlinx.serialization.Serializable
/**
* The type-safe route for the migrate to my items screen.
*
* @property organizationId The ID of the organization requiring migration.
* @property organizationName The name of the organization requiring migration.
*/
@OmitFromCoverage
@Serializable
data class MigrateToMyItemsRoute(
val organizationId: String,
val organizationName: String,
)
/**
* Class to retrieve migrate to my items arguments from the [SavedStateHandle].
*
* @property organizationId The ID of the organization requiring migration.
* @property organizationName The name of the organization requiring migration.
*/
data class MigrateToMyItemsArgs(
val organizationId: String,
val organizationName: String,
)
/**
* Constructs a [MigrateToMyItemsArgs] from the [SavedStateHandle] and internal route data.
*/
fun SavedStateHandle.toMigrateToMyItemsArgs(): MigrateToMyItemsArgs {
val route = this.toRoute<MigrateToMyItemsRoute>()
return MigrateToMyItemsArgs(
organizationId = route.organizationId,
organizationName = route.organizationName,
)
}
/**
* Navigate to the migrate to my items screen.
*
* @param organizationId The ID of the organization requiring migration.
* @param organizationName The name of the organization requiring migration.
*/
fun NavController.navigateToMigrateToMyItems(
organizationId: String,
organizationName: String,
navOptions: NavOptions? = null,
) {
this.navigate(
route = MigrateToMyItemsRoute(
organizationId = organizationId,
organizationName = organizationName,
),
navOptions = navOptions,
)
}
data object MigrateToMyItemsRoute
/**
* Add the migrate to my items screen to the nav graph.
@@ -72,7 +20,7 @@ fun NavController.navigateToMigrateToMyItems(
fun NavGraphBuilder.migrateToMyItemsDestination(
onNavigateToLeaveOrganization: (organizationId: String, organizationName: String) -> Unit,
) {
composableWithSlideTransitions<MigrateToMyItemsRoute> {
composable<MigrateToMyItemsRoute> {
MigrateToMyItemsScreen(
onNavigateToLeaveOrganization = onNavigateToLeaveOrganization,
)

View File

@@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
import com.x8bit.bitwarden.data.vault.repository.model.MigratePersonalVaultResult
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -43,10 +44,12 @@ class MigrateToMyItemsViewModel @Inject constructor(
private val snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
) : BaseViewModel<MigrateToMyItemsState, MigrateToMyItemsEvent, MigrateToMyItemsAction>(
initialState = savedStateHandle[KEY_STATE] ?: run {
val args = savedStateHandle.toMigrateToMyItemsArgs()
// This must be true or we would have never navigated here.
val migrationData = (vaultMigrationManager.vaultMigrationDataStateFlow.value
as VaultMigrationData.MigrationRequired)
MigrateToMyItemsState(
organizationId = args.organizationId,
organizationName = args.organizationName,
organizationId = migrationData.organizationId,
organizationName = migrationData.organizationName,
dialog = null,
)
},

View File

@@ -1586,7 +1586,6 @@ class RootNavViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `when vaultMigrationDataStateFlow emits true the nav state should be MigrateToMyItems`() {
mutableVaultMigrationDataStateFlow.value = MOCK_VAULT_MIGRATION_DATA
@@ -1594,10 +1593,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel()
assertEquals(
RootNavState.MigrateToMyItems(
organizationId = "mockOrganizationId-1",
organizationName = "organizationName",
),
RootNavState.MigrateToMyItems,
viewModel.stateFlow.value,
)
}
@@ -1676,28 +1672,22 @@ class RootNavViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `when migration required with ShareNewSend shortcut should show migration screen`() {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ShareNewSend(
data = mockk<ShareData.TextSend>(),
shouldFinishWhenComplete = true,
)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.ShareNewSend(
data = mockk<ShareData.TextSend>(),
shouldFinishWhenComplete = true,
)
mutableVaultMigrationDataStateFlow.value = MOCK_VAULT_MIGRATION_DATA
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE)
val viewModel = createViewModel()
assertEquals(
RootNavState.MigrateToMyItems(
organizationId = "mockOrganizationId-1",
organizationName = "organizationName",
),
RootNavState.MigrateToMyItems,
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `when migration required with VaultShortcut should show migration screen`() {
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.VaultShortcut
@@ -1706,10 +1696,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel()
assertEquals(
RootNavState.MigrateToMyItems(
organizationId = "mockOrganizationId-1",
organizationName = "organizationName",
),
RootNavState.MigrateToMyItems,
viewModel.stateFlow.value,
)
}

View File

@@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
import com.x8bit.bitwarden.data.vault.repository.model.MigratePersonalVaultResult
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import io.mockk.coEvery
@@ -22,10 +23,8 @@ import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkConstructor
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
@@ -45,10 +44,15 @@ class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
every { activeUserId } returns "test-user-id"
},
)
private val mutableVaultMigrationDataStateFlow = MutableStateFlow<VaultMigrationData>(
VaultMigrationData.MigrationRequired(
organizationId = ORGANIZATION_ID,
organizationName = ORGANIZATION_NAME,
),
)
private val mockVaultMigrationManager: VaultMigrationManager = mockk {
coEvery {
migratePersonalVault(any(), any())
} returns MigratePersonalVaultResult.Success
coEvery { vaultMigrationDataStateFlow } returns mutableVaultMigrationDataStateFlow
coEvery { migratePersonalVault(any(), any()) } returns MigratePersonalVaultResult.Success
every { clearMigrationState() } just runs
}
private val mockVaultSyncManager: VaultSyncManager = mockk(relaxed = true)
@@ -62,7 +66,6 @@ class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
@BeforeEach
fun setup() {
mockkStatic(SavedStateHandle::toMigrateToMyItemsArgs)
mockkConstructor(NoActiveUserException::class)
every {
anyConstructed<NoActiveUserException>() == any<NoActiveUserException>()
@@ -71,7 +74,6 @@ class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
@AfterEach
fun tearDown() {
unmockkStatic(SavedStateHandle::toMigrateToMyItemsArgs)
unmockkConstructor(NoActiveUserException::class)
}