[PM-28468] Updated validation and navigation for MigrateToMyItems (#6279)

Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
Co-authored-by: Patrick Honkonen <phonkonen@bitwarden.com>
This commit is contained in:
aj-rosado
2026-01-08 21:29:28 +00:00
committed by GitHub
co-authored by Patrick Honkonen Patrick Honkonen
parent 577e3c04e3
commit eff4ce7abb
22 changed files with 1370 additions and 123 deletions
@@ -0,0 +1,279 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "61353072161e3101ade140e2c4b65495",
"entities": [
{
"tableName": "ciphers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `has_totp` INTEGER NOT NULL DEFAULT 1, `cipher_type` TEXT NOT NULL, `cipher_json` TEXT NOT NULL, `organization_id` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "hasTotp",
"columnName": "has_totp",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "cipherType",
"columnName": "cipher_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "cipherJson",
"columnName": "cipher_json",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "organizationId",
"columnName": "organization_id",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_ciphers_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id` ON `${TABLE_NAME}` (`user_id`)"
},
{
"name": "index_ciphers_user_id_organization_id",
"unique": false,
"columnNames": [
"user_id",
"organization_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id_organization_id` ON `${TABLE_NAME}` (`user_id`, `organization_id`)"
}
]
},
{
"tableName": "collections",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `organization_id` TEXT NOT NULL, `should_hide_passwords` INTEGER NOT NULL, `name` TEXT NOT NULL, `external_id` TEXT, `read_only` INTEGER NOT NULL, `manage` INTEGER, `default_user_collection_email` TEXT, `type` TEXT NOT NULL DEFAULT '0', PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "organizationId",
"columnName": "organization_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shouldHidePasswords",
"columnName": "should_hide_passwords",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "externalId",
"columnName": "external_id",
"affinity": "TEXT"
},
{
"fieldPath": "isReadOnly",
"columnName": "read_only",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canManage",
"columnName": "manage",
"affinity": "INTEGER"
},
{
"fieldPath": "defaultUserCollectionEmail",
"columnName": "default_user_collection_email",
"affinity": "TEXT"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'0'"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_collections_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
]
},
{
"tableName": "domains",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `domains_json` TEXT, PRIMARY KEY(`user_id`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "domainsJson",
"columnName": "domains_json",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"user_id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `name` TEXT, `revision_date` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "revisionDate",
"columnName": "revision_date",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_folders_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_folders_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
]
},
{
"tableName": "sends",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `send_type` TEXT NOT NULL, `send_json` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sendType",
"columnName": "send_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sendJson",
"columnName": "send_json",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_sends_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_sends_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '61353072161e3101ade140e2c4b65495')"
]
}
}
@@ -24,6 +24,16 @@ interface VaultDiskSource {
*/
suspend fun getCiphers(userId: String): List<SyncResponseJson.Cipher>
/**
* Checks if the user has any personal ciphers (ciphers not belonging to an organization).
*
* This is an optimized query that checks only the indexed organizationId column
* without loading full cipher JSON data. Intended for vault migration state checks.
*
* @return Flow that emits true if user has personal ciphers, false otherwise
*/
fun hasPersonalCiphersFlow(userId: String): Flow<Boolean>
/**
* Retrieves all ciphers with the given [cipherIds] from the data source for a given [userId].
*/
@@ -55,6 +55,7 @@ class VaultDiskSourceImpl(
hasTotp = cipher.login?.totp != null,
cipherType = json.encodeToString(cipher.type),
cipherJson = json.encodeToString(cipher),
organizationId = cipher.organizationId,
),
),
)
@@ -97,6 +98,9 @@ class VaultDiskSourceImpl(
}
}
override fun hasPersonalCiphersFlow(userId: String): Flow<Boolean> =
ciphersDao.hasPersonalCiphersFlow(userId = userId)
override suspend fun getSelectedCiphers(
userId: String,
cipherIds: List<String>,
@@ -295,6 +299,7 @@ class VaultDiskSourceImpl(
hasTotp = cipher.login?.totp != null,
cipherType = json.encodeToString(cipher.type),
cipherJson = json.encodeToString(cipher),
organizationId = cipher.organizationId,
)
},
)
@@ -88,4 +88,21 @@ interface CiphersDao {
insertCiphers(ciphers)
return deletedCiphersCount > 0 || ciphers.isNotEmpty()
}
/**
* Checks if the user has any personal ciphers (ciphers with null organizationId).
* Returns a Flow that emits true if personal ciphers exist, false otherwise.
*
* This query is optimized for vault migration checks and uses the indexed
* organization_id column to avoid loading full cipher JSON.
*/
@Query("""
SELECT EXISTS(
SELECT 1 FROM ciphers
WHERE user_id = :userId
AND organization_id IS NULL
LIMIT 1
)
""")
fun hasPersonalCiphersFlow(userId: String): Flow<Boolean>
}
@@ -27,7 +27,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.entity.SendEntity
FolderEntity::class,
SendEntity::class,
],
version = 8,
version = 9,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 6, to = 7),
@@ -2,18 +2,25 @@ package com.x8bit.bitwarden.data.vault.datasource.disk.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Entity representing a cipher in the database.
*/
@Entity(tableName = "ciphers")
@Entity(
tableName = "ciphers",
indices = [
Index(value = ["user_id"]),
Index(value = ["user_id", "organization_id"]),
],
)
data class CipherEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "id")
val id: String,
@ColumnInfo(name = "user_id", index = true)
@ColumnInfo(name = "user_id")
val userId: String,
// Default to true for initial migration.
@@ -26,4 +33,9 @@ data class CipherEntity(
@ColumnInfo(name = "cipher_json")
val cipherJson: String,
// Extracted organizationId for query optimization to avoid loading full cipher JSON.
// Enables lightweight queries for vault migration checks and organization filtering.
@ColumnInfo(name = "organization_id")
val organizationId: String?,
)
@@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.vault.manager
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
import kotlinx.coroutines.flow.StateFlow
/**
* Manages the migration of personal vault items to organization collections.
* This interface provides a way to check if migration is needed and track migration state.
*
* The manager reactively observes vault cipher data and automatically updates the migration state
* when conditions change (e.g., after sync, after vault unlock, policy changes).
*/
interface VaultMigrationManager {
/**
* Flow that emits when conditions are met for the user to migrate their personal vault.
* Automatically updated when cipher data, policies, or feature flags change.
*/
val vaultMigrationDataStateFlow: StateFlow<VaultMigrationData>
}
@@ -0,0 +1,155 @@
package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndUnlocked
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
/**
* Default implementation of [VaultMigrationManager].
*
* Reactively observes vault cipher data and automatically updates migration state when:
* - Vault is unlocked
* - Sync has occurred at least once
* - Cipher data changes
* - Network connectivity changes
*/
@Suppress("LongParameterList")
class VaultMigrationManagerImpl(
private val authDiskSource: AuthDiskSource,
private val vaultDiskSource: VaultDiskSource,
private val settingsDiskSource: SettingsDiskSource,
private val policyManager: PolicyManager,
private val featureFlagManager: FeatureFlagManager,
private val connectionManager: NetworkConnectionManager,
vaultLockManager: VaultLockManager,
dispatcherManager: DispatcherManager,
) : VaultMigrationManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val mutableVaultMigrationDataStateFlow =
MutableStateFlow<VaultMigrationData>(value = VaultMigrationData.NoMigrationRequired)
override val vaultMigrationDataStateFlow: StateFlow<VaultMigrationData>
get() = mutableVaultMigrationDataStateFlow.asStateFlow()
init {
// Observe cipher data changes and automatically verify migration state
mutableVaultMigrationDataStateFlow
.observeWhenSubscribedAndUnlocked(
userStateFlow = authDiskSource.userStateFlow,
vaultUnlockFlow = vaultLockManager.vaultUnlockDataStateFlow,
) { activeUserId ->
observeCipherDataAndUpdateMigrationState(userId = activeUserId)
}
.launchIn(unconfinedScope)
}
/**
* Observes cipher data, sync state, and network connectivity for the given user and updates
* migration state when changes occur. Only emits updates after the user has synced at least
* once to ensure data freshness.
*
* Uses optimized [VaultDiskSource.hasPersonalCiphersFlow] query that checks only the
* indexed organizationId column without loading full cipher JSON data.
*
* Combines cipher data with [SettingsDiskSource.getLastSyncTimeFlow] to handle multi-account
* scenarios where lastSyncTime may be cleared without clearing cipher data. This ensures
* migration state updates when sync completes, not just when cipher data changes.
*
* Also combines with [NetworkConnectionManager.isNetworkConnectedFlow] to ensure migration
* state updates reactively when network connectivity changes.
*/
private fun observeCipherDataAndUpdateMigrationState(userId: String) =
combine(
vaultDiskSource.hasPersonalCiphersFlow(userId = userId),
settingsDiskSource.getLastSyncTimeFlow(userId = userId),
connectionManager.isNetworkConnectedFlow,
) { hasPersonalCiphers, lastSyncTime, isNetworkConnected ->
// Only process after sync has occurred at least once
lastSyncTime ?: return@combine null
hasPersonalCiphers to isNetworkConnected
}
.filterNotNull()
.onEach { (hasPersonalCiphers, isNetworkConnected) ->
verifyAndUpdateMigrationState(
userId = userId,
hasPersonalCiphers = hasPersonalCiphers,
isNetworkConnected = isNetworkConnected,
)
}
/**
* Verifies if the user should migrate their personal vault to organization collections
* based on active policies, feature flags, network connectivity, and whether they have
* personal ciphers.
*
* @param userId The ID of the user to check for migration.
* @param hasPersonalCiphers Boolean indicating if the user has any personal ciphers.
* @param isNetworkConnected Boolean indicating if the device has network connectivity.
*/
private fun verifyAndUpdateMigrationState(
userId: String,
hasPersonalCiphers: Boolean,
isNetworkConnected: Boolean,
) {
mutableVaultMigrationDataStateFlow.update {
if (!shouldMigrateVault(
hasPersonalCiphers = hasPersonalCiphers,
isNetworkConnected = isNetworkConnected,
)
) {
return@update VaultMigrationData.NoMigrationRequired
}
val orgId = policyManager.getPersonalOwnershipPolicyOrganizationId()
?: return@update VaultMigrationData.NoMigrationRequired
val orgName = authDiskSource
.getOrganizations(userId = userId)
?.firstOrNull { it.id == orgId }
?.name
?: return@update VaultMigrationData.NoMigrationRequired
VaultMigrationData.MigrationRequired(
organizationId = orgId,
organizationName = orgName,
)
}
}
/**
* Checks if the user should migrate their vault based on policies, feature flags,
* network connectivity, and whether they have personal items.
*
* @param hasPersonalCiphers Boolean indicating if the user has any personal ciphers.
* @param isNetworkConnected Boolean indicating if the device has network connectivity.
* @return true if migration conditions are met, false otherwise.
*/
private fun shouldMigrateVault(
hasPersonalCiphers: Boolean,
isNetworkConnected: Boolean,
): Boolean =
policyManager
.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
.any() &&
featureFlagManager.getFeatureFlag(FlagKey.MigrateMyVaultToMyItems) &&
isNetworkConnected &&
hasPersonalCiphers
}
@@ -17,9 +17,11 @@ import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
@@ -37,6 +39,8 @@ import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManagerImpl
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManagerImpl
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManagerImpl
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManagerImpl
import dagger.Module
@@ -55,6 +59,28 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object VaultManagerModule {
@Provides
@Singleton
fun provideVaultMigrationManager(
authDiskSource: AuthDiskSource,
vaultDiskSource: VaultDiskSource,
settingsDiskSource: SettingsDiskSource,
vaultLockManager: VaultLockManager,
policyManager: PolicyManager,
featureFlagManager: FeatureFlagManager,
connectionManager: NetworkConnectionManager,
dispatcherManager: DispatcherManager,
): VaultMigrationManager = VaultMigrationManagerImpl(
authDiskSource = authDiskSource,
vaultDiskSource = vaultDiskSource,
settingsDiskSource = settingsDiskSource,
vaultLockManager = vaultLockManager,
policyManager = policyManager,
featureFlagManager = featureFlagManager,
connectionManager = connectionManager,
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun provideCipherManager(
@@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.vault.manager.model
/**
* Represents vault migration state with organization metadata.
*/
sealed class VaultMigrationData {
/**
* User should migrate personal vault items to the specified organization.
*/
data class MigrationRequired(
val organizationId: String,
val organizationName: String,
) : VaultMigrationData()
/**
* No migration required.
*/
data object NoMigrationRequired : VaultMigrationData()
}
@@ -167,11 +167,4 @@ interface VaultRepository :
* `null` if the item cannot be found.
*/
fun getVaultListItemStateFlow(itemId: String): StateFlow<DataState<CipherListView?>>
/**
* Checks if there are any personal vault items (items without an organization ID) in the vault.
*
* @return `true` if there are personal vault items, `false` otherwise.
*/
fun hasPersonalVaultItems(): Boolean
}
@@ -557,9 +557,4 @@ class VaultRepositoryImpl(
organizationKeys = organizationKeys,
)
}
override fun hasPersonalVaultItems(): Boolean {
val vaultData = vaultSyncManager.vaultDataStateFlow.value.data ?: return false
return vaultData.decryptCipherListResult.successes.any { it.organizationId.isNullOrEmpty() }
}
}
@@ -70,6 +70,8 @@ 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.model.VaultAddEditType
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
@@ -153,6 +155,13 @@ 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,
)
}
}
val currentRoute = navController.currentDestination?.rootLevelRoute()
@@ -204,6 +213,14 @@ fun RootNavScreen(
navController.navigateToExpiredRegistrationLinkScreen()
}
is RootNavState.MigrateToMyItems -> {
navController.navigateToMigrateToMyItems(
organizationName = currentState.organizationName,
organizationId = currentState.organizationId,
navOptions = rootNavOptions,
)
}
RootNavState.RemovePassword -> navController.navigateToRemovePassword(rootNavOptions)
RootNavState.ResetPassword -> {
navController.navigateToResetPasswordScreen(rootNavOptions)
@@ -19,6 +19,8 @@ import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.combine
@@ -35,6 +37,7 @@ import javax.inject.Inject
class RootNavViewModel @Inject constructor(
private val authRepository: AuthRepository,
specialCircumstanceManager: SpecialCircumstanceManager,
vaultMigrationManager: VaultMigrationManager,
) : BaseViewModel<RootNavState, Unit, RootNavAction>(
initialState = RootNavState.Splash,
) {
@@ -43,11 +46,13 @@ class RootNavViewModel @Inject constructor(
authRepository.authStateFlow,
authRepository.userStateFlow,
specialCircumstanceManager.specialCircumstanceStateFlow,
) { authState, userState, specialCircumstance ->
vaultMigrationManager.vaultMigrationDataStateFlow,
) { authState, userState, specialCircumstance, vaultMigrationData ->
RootNavAction.Internal.UserStateUpdateReceive(
authState = authState,
userState = userState,
specialCircumstance = specialCircumstance,
vaultMigrationData = vaultMigrationData,
)
}
.onEach(::handleAction)
@@ -66,6 +71,7 @@ class RootNavViewModel @Inject constructor(
) {
val userState = action.userState
val specialCircumstance = action.specialCircumstance
val updatedRootNavState = when {
userState?.activeAccount?.trustedDevice?.isDeviceTrusted == false &&
authRepository.tdeLoginComplete != true &&
@@ -110,21 +116,32 @@ class RootNavViewModel @Inject constructor(
getOnboardingNavState(onboardingStatus = userState.activeAccount.onboardingStatus)
}
userState.activeAccount.isVaultUnlocked &&
specialCircumstance is SpecialCircumstance.AutofillSave -> {
RootNavState.VaultUnlockedForAutofillSave(
autofillSaveItem = specialCircumstance.autofillSaveItem,
)
}
userState.activeAccount.isVaultUnlocked &&
specialCircumstance is SpecialCircumstance.AutofillSelection -> {
RootNavState.VaultUnlockedForAutofillSelection(
activeUserId = userState.activeAccount.userId,
type = specialCircumstance.autofillSelectionData.type,
)
}
userState.activeAccount.isVaultUnlocked &&
specialCircumstance == null &&
action.vaultMigrationData is VaultMigrationData.MigrationRequired -> {
RootNavState.MigrateToMyItems(
organizationId = action.vaultMigrationData.organizationId,
organizationName = action.vaultMigrationData.organizationName,
)
}
userState.activeAccount.isVaultUnlocked -> {
when (specialCircumstance) {
is SpecialCircumstance.AutofillSave -> {
RootNavState.VaultUnlockedForAutofillSave(
autofillSaveItem = specialCircumstance.autofillSaveItem,
)
}
is SpecialCircumstance.AutofillSelection -> {
RootNavState.VaultUnlockedForAutofillSelection(
activeUserId = userState.activeAccount.userId,
type = specialCircumstance.autofillSelectionData.type,
)
}
is SpecialCircumstance.AddTotpLoginItem -> {
RootNavState.VaultUnlockedForNewTotp(
activeUserId = userState.activeAccount.userId,
@@ -207,6 +224,8 @@ class RootNavViewModel @Inject constructor(
is SpecialCircumstance.CredentialExchangeExport,
is SpecialCircumstance.RegistrationEvent,
is SpecialCircumstance.AutofillSave,
is SpecialCircumstance.AutofillSelection,
-> {
throw IllegalStateException(
"Special circumstance should have been already handled.",
@@ -317,6 +336,15 @@ sealed class RootNavState : Parcelable {
@Parcelize
data object VaultLocked : RootNavState()
/**
* App should show MigrateToMyItems screen.
*/
@Parcelize
data class MigrateToMyItems(
val organizationId: String,
val organizationName: String,
) : RootNavState()
/**
* App should show vault unlocked nav graph for the given [activeUserId].
*/
@@ -494,6 +522,7 @@ sealed class RootNavAction {
val authState: AuthState,
val userState: UserState?,
val specialCircumstance: SpecialCircumstance?,
val vaultMigrationData: VaultMigrationData,
) : RootNavAction()
}
}
@@ -59,6 +59,7 @@ 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.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
@@ -263,6 +264,11 @@ fun NavGraphBuilder.vaultUnlockedGraph(
importLoginsScreenDestination(
onNavigateBack = { navController.popBackStack() },
)
migrateToMyItemsDestination(
onNavigateToVault = { navController.popBackStack() },
onNavigateToLeaveOrganization = { },
)
}
}
@@ -9,6 +9,7 @@ import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
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.VaultSyncManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
@@ -26,6 +27,7 @@ private const val KEY_STATE = "state"
@HiltViewModel
class MigrateToMyItemsViewModel @Inject constructor(
private val organizationEventManager: OrganizationEventManager,
vaultSyncManager: VaultSyncManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<MigrateToMyItemsState, MigrateToMyItemsEvent, MigrateToMyItemsAction>(
initialState = savedStateHandle[KEY_STATE] ?: run {
@@ -42,6 +44,9 @@ class MigrateToMyItemsViewModel @Inject constructor(
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
// We need to ensure we have the most recent data. No need to do anything
// RootNavViewModel will take care of navigating if needed
vaultSyncManager.sync(forced = true)
}
override fun handleAction(action: MigrateToMyItemsAction) {
@@ -336,6 +336,32 @@ class VaultDiskSourceTest {
assertTrue(foldersDao.deleteFoldersCalled)
assertTrue(sendsDao.deleteSendsCalled)
}
@Test
fun `hasPersonalCiphersFlow should emit true when personal ciphers exist`() = runTest {
val personalCipher = CIPHER_ENTITY.copy(organizationId = null)
vaultDiskSource
.hasPersonalCiphersFlow(USER_ID)
.test {
assertEquals(false, awaitItem())
ciphersDao.insertCiphers(listOf(personalCipher))
assertEquals(true, awaitItem())
}
}
@Test
fun `hasPersonalCiphersFlow should emit false when only org ciphers exist`() = runTest {
val orgCipher = CIPHER_ENTITY.copy(id = "orgCipherId", organizationId = "org-123")
vaultDiskSource
.hasPersonalCiphersFlow(USER_ID)
.test {
assertEquals(false, awaitItem())
ciphersDao.insertCiphers(listOf(orgCipher))
assertEquals(false, awaitItem())
}
}
}
private const val USER_ID: String = "test_user_id"
@@ -486,6 +512,7 @@ private val CIPHER_ENTITY = CipherEntity(
hasTotp = true,
cipherType = "1",
cipherJson = CIPHER_JSON,
organizationId = "mockOrganizationId-1",
)
private val COLLECTION_ENTITY = CollectionEntity(
@@ -65,4 +65,9 @@ class FakeCiphersDao : CiphersDao {
ciphersFlow.tryEmit(ciphers.toList())
return removed || ciphers.isNotEmpty()
}
override fun hasPersonalCiphersFlow(userId: String): Flow<Boolean> =
ciphersFlow.map { ciphers ->
ciphers.any { it.userId == userId && it.organizationId == null }
}
}
@@ -0,0 +1,657 @@
package com.x8bit.bitwarden.data.vault.manager
import app.cash.turbine.test
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.createMockPolicy
import com.bitwarden.network.model.createMockSyncResponse
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.model.NetworkConnection
import com.x8bit.bitwarden.data.platform.manager.model.NetworkSignalStrength
import com.x8bit.bitwarden.data.platform.manager.util.FakeNetworkConnectionManager
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.Instant
import java.time.ZonedDateTime
class VaultMigrationManagerTest {
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val fakeDispatcherManager = FakeDispatcherManager()
private val mutableHasPersonalCiphersFlow = MutableStateFlow(false)
private val vaultDiskSource: VaultDiskSource = mockk {
every { hasPersonalCiphersFlow(any()) } returns mutableHasPersonalCiphersFlow
}
private val mutableVaultUnlockDataStateFlow =
MutableStateFlow<List<VaultUnlockData>>(emptyList())
private val vaultLockManager: VaultLockManager = mockk {
every { vaultUnlockDataStateFlow } returns mutableVaultUnlockDataStateFlow
}
private val mutableLastSyncTimeFlow =
MutableStateFlow<Instant?>(Instant.parse("2023-10-27T12:00:00Z"))
private val settingsDiskSource: SettingsDiskSource = mockk {
every { getLastSyncTimeFlow(any()) } returns mutableLastSyncTimeFlow
}
private val policyManager: PolicyManager = mockk {
every { getActivePolicies(any()) } returns emptyList()
every { getPersonalOwnershipPolicyOrganizationId() } returns null
}
private val mockFeatureFlagManager = mockk<FeatureFlagManager>(relaxed = true) {
every { getFeatureFlag(FlagKey.MigrateMyVaultToMyItems) } returns true
}
private val fakeNetworkConnectionManager = FakeNetworkConnectionManager(
isNetworkConnected = true,
networkConnection = NetworkConnection.Wifi(strength = NetworkSignalStrength.GOOD),
)
private fun createVaultMigrationManager(): VaultMigrationManager =
VaultMigrationManagerImpl(
authDiskSource = fakeAuthDiskSource,
vaultDiskSource = vaultDiskSource,
settingsDiskSource = settingsDiskSource,
vaultLockManager = vaultLockManager,
policyManager = policyManager,
featureFlagManager = mockFeatureFlagManager,
connectionManager = fakeNetworkConnectionManager,
dispatcherManager = fakeDispatcherManager,
)
@Test
fun `vaultMigrationDataStateFlow should initially emit NoMigrationRequired`() = runTest {
val vaultMigrationManager = createVaultMigrationManager()
assertEquals(
VaultMigrationData.NoMigrationRequired,
vaultMigrationManager.vaultMigrationDataStateFlow.value,
)
}
@Test
fun `should emit MigrationRequired when vault unlocks with all conditions met`() = runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
// Setup conditions for migration
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns listOf(mockPolicy)
every {
policyManager.getPersonalOwnershipPolicyOrganizationId()
} returns "mockId-1"
// Store organizations
val syncResponse = createMockSyncResponse(number = 1)
fakeAuthDiskSource.storeOrganizations(
userId = userId,
organizations = syncResponse.profile.organizations,
)
val vaultMigrationManager = createVaultMigrationManager()
vaultMigrationManager.vaultMigrationDataStateFlow.test {
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
// Simulate vault unlock
mutableVaultUnlockDataStateFlow.value = listOf(
VaultUnlockData(
userId = userId,
status = VaultUnlockData.Status.UNLOCKED,
),
)
// Emit personal ciphers exist
mutableHasPersonalCiphersFlow.value = true
assertEquals(
VaultMigrationData.MigrationRequired(
organizationId = "mockId-1",
organizationName = "mockName-1",
),
awaitItem(),
)
}
}
@Test
fun `should emit NoMigrationRequired when no personal ownership policy`() = runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns emptyList()
val vaultMigrationManager = createVaultMigrationManager()
vaultMigrationManager.vaultMigrationDataStateFlow.test {
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
// Simulate vault unlock
mutableVaultUnlockDataStateFlow.value = listOf(
VaultUnlockData(
userId = userId,
status = VaultUnlockData.Status.UNLOCKED,
),
)
// Emit personal ciphers exist
mutableHasPersonalCiphersFlow.value = true
// Should remain NoMigrationRequired since no policy
expectNoEvents()
}
}
@Test
fun `should emit NoMigrationRequired when feature flag is disabled`() = runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns listOf(mockPolicy)
every {
mockFeatureFlagManager.getFeatureFlag(FlagKey.MigrateMyVaultToMyItems)
} returns false
val vaultMigrationManager = createVaultMigrationManager()
vaultMigrationManager.vaultMigrationDataStateFlow.test {
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
// Simulate vault unlock
mutableVaultUnlockDataStateFlow.value = listOf(
VaultUnlockData(
userId = userId,
status = VaultUnlockData.Status.UNLOCKED,
),
)
// Emit personal ciphers exist
mutableHasPersonalCiphersFlow.value = true
// Should remain NoMigrationRequired since feature flag is disabled
expectNoEvents()
}
}
@Test
fun `should emit NoMigrationRequired when no network connection`() = runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
fakeNetworkConnectionManager.fakeIsNetworkConnected = false
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns listOf(mockPolicy)
every {
policyManager.getPersonalOwnershipPolicyOrganizationId()
} returns "mockId-1"
val syncResponse = createMockSyncResponse(number = 1)
fakeAuthDiskSource.storeOrganizations(
userId = userId,
organizations = syncResponse.profile.organizations,
)
val vaultMigrationManager = createVaultMigrationManager()
vaultMigrationManager.vaultMigrationDataStateFlow.test {
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
// Simulate vault unlock
mutableVaultUnlockDataStateFlow.value = listOf(
VaultUnlockData(
userId = userId,
status = VaultUnlockData.Status.UNLOCKED,
),
)
// Emit personal ciphers exist
mutableHasPersonalCiphersFlow.value = true
// Should remain NoMigrationRequired since no network
expectNoEvents()
}
}
@Test
fun `should emit NoMigrationRequired when no personal ciphers exist`() = runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns listOf(mockPolicy)
val vaultMigrationManager = createVaultMigrationManager()
vaultMigrationManager.vaultMigrationDataStateFlow.test {
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
// Simulate vault unlock
mutableVaultUnlockDataStateFlow.value = listOf(
VaultUnlockData(
userId = userId,
status = VaultUnlockData.Status.UNLOCKED,
),
)
// Emit no personal ciphers
mutableHasPersonalCiphersFlow.value = false
// Should remain NoMigrationRequired since no personal ciphers
expectNoEvents()
}
}
@Test
fun `should emit NoMigrationRequired when organization ID is null`() = runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns listOf(mockPolicy)
every {
policyManager.getPersonalOwnershipPolicyOrganizationId()
} returns null
val vaultMigrationManager = createVaultMigrationManager()
vaultMigrationManager.vaultMigrationDataStateFlow.test {
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
// Simulate vault unlock
mutableVaultUnlockDataStateFlow.value = listOf(
VaultUnlockData(
userId = userId,
status = VaultUnlockData.Status.UNLOCKED,
),
)
// Emit personal ciphers exist
mutableHasPersonalCiphersFlow.value = true
// Should remain NoMigrationRequired since organization ID is null
expectNoEvents()
}
}
@Test
fun `should emit NoMigrationRequired when organizations list is null`() = runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns listOf(mockPolicy)
every {
policyManager.getPersonalOwnershipPolicyOrganizationId()
} returns "mockId-1"
// Store organizations as null in authDiskSource
fakeAuthDiskSource.storeOrganizations(userId = userId, organizations = null)
val vaultMigrationManager = createVaultMigrationManager()
vaultMigrationManager.vaultMigrationDataStateFlow.test {
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
// Simulate vault unlock
mutableVaultUnlockDataStateFlow.value = listOf(
VaultUnlockData(
userId = userId,
status = VaultUnlockData.Status.UNLOCKED,
),
)
// Emit personal ciphers exist
mutableHasPersonalCiphersFlow.value = true
// Should remain NoMigrationRequired since organizations list is null
expectNoEvents()
}
}
@Test
fun `should emit NoMigrationRequired when organization not found`() = runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns listOf(mockPolicy)
// Return a different org ID that doesn't match the one in the sync response
every {
policyManager.getPersonalOwnershipPolicyOrganizationId()
} returns "non-existent-org-id"
// Store organizations with a different ID
val syncResponse = createMockSyncResponse(number = 1)
fakeAuthDiskSource.storeOrganizations(
userId = userId,
organizations = syncResponse.profile.organizations,
)
val vaultMigrationManager = createVaultMigrationManager()
vaultMigrationManager.vaultMigrationDataStateFlow.test {
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
// Simulate vault unlock
mutableVaultUnlockDataStateFlow.value = listOf(
VaultUnlockData(
userId = userId,
status = VaultUnlockData.Status.UNLOCKED,
),
)
// Emit personal ciphers exist
mutableHasPersonalCiphersFlow.value = true
// Should remain NoMigrationRequired since organization is not found
expectNoEvents()
}
}
@Test
fun `should not emit when vault is locked`() = runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns listOf(mockPolicy)
every {
policyManager.getPersonalOwnershipPolicyOrganizationId()
} returns "mockId-1"
val syncResponse = createMockSyncResponse(number = 1)
fakeAuthDiskSource.storeOrganizations(
userId = userId,
organizations = syncResponse.profile.organizations,
)
val vaultMigrationManager = createVaultMigrationManager()
vaultMigrationManager.vaultMigrationDataStateFlow.test {
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
// Vault is locked - no unlock data
mutableVaultUnlockDataStateFlow.value = emptyList()
// Emit personal ciphers exist - should not trigger migration check since
// vault is locked
mutableHasPersonalCiphersFlow.value = true
// Should remain NoMigrationRequired since vault is locked
expectNoEvents()
}
}
@Test
fun `should not emit when sync has not occurred yet`() = runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
// Set sync time as null (never synced)
mutableLastSyncTimeFlow.value = null
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns listOf(mockPolicy)
every {
policyManager.getPersonalOwnershipPolicyOrganizationId()
} returns "mockId-1"
val syncResponse = createMockSyncResponse(number = 1)
fakeAuthDiskSource.storeOrganizations(
userId = userId,
organizations = syncResponse.profile.organizations,
)
val vaultMigrationManager = createVaultMigrationManager()
vaultMigrationManager.vaultMigrationDataStateFlow.test {
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
// Simulate vault unlock
mutableVaultUnlockDataStateFlow.value = listOf(
VaultUnlockData(
userId = userId,
status = VaultUnlockData.Status.UNLOCKED,
),
)
// Emit personal ciphers exist - should not trigger since no sync has occurred
mutableHasPersonalCiphersFlow.value = true
// Should remain NoMigrationRequired since sync hasn't happened
expectNoEvents()
}
}
@Test
fun `should emit MigrationRequired when sync completes after vault unlock`() = runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
// Start with sync time as null (never synced) - simulates multi-account scenario
// where lastSyncTime was cleared without clearing cipher data
mutableLastSyncTimeFlow.value = null
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns listOf(mockPolicy)
every {
policyManager.getPersonalOwnershipPolicyOrganizationId()
} returns "mockId-1"
val syncResponse = createMockSyncResponse(number = 1)
fakeAuthDiskSource.storeOrganizations(
userId = userId,
organizations = syncResponse.profile.organizations,
)
val vaultMigrationManager = createVaultMigrationManager()
vaultMigrationManager.vaultMigrationDataStateFlow.test {
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
// Simulate vault unlock
mutableVaultUnlockDataStateFlow.value = listOf(
VaultUnlockData(
userId = userId,
status = VaultUnlockData.Status.UNLOCKED,
),
)
// Emit personal ciphers exist - should not trigger yet since no sync
mutableHasPersonalCiphersFlow.value = true
// Should remain NoMigrationRequired since sync hasn't happened
expectNoEvents()
// Sync completes - should now trigger migration check
mutableLastSyncTimeFlow.value = Instant.parse("2023-10-27T12:00:00Z")
assertEquals(
VaultMigrationData.MigrationRequired(
organizationId = "mockId-1",
organizationName = "mockName-1",
),
awaitItem(),
)
}
}
@Test
fun `should emit MigrationRequired when network connectivity changes from offline to online`() =
runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
fakeNetworkConnectionManager.fakeIsNetworkConnected = false
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns listOf(mockPolicy)
every {
policyManager.getPersonalOwnershipPolicyOrganizationId()
} returns "mockId-1"
val syncResponse = createMockSyncResponse(number = 1)
fakeAuthDiskSource.storeOrganizations(
userId = userId,
organizations = syncResponse.profile.organizations,
)
val vaultMigrationManager = createVaultMigrationManager()
vaultMigrationManager.vaultMigrationDataStateFlow.test {
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
// Simulate vault unlock while offline
mutableVaultUnlockDataStateFlow.value = listOf(
VaultUnlockData(
userId = userId,
status = VaultUnlockData.Status.UNLOCKED,
),
)
// Emit personal ciphers exist - still offline
mutableHasPersonalCiphersFlow.value = true
// Should remain NoMigrationRequired since no network
expectNoEvents()
// Network comes online - should now emit MigrationRequired
fakeNetworkConnectionManager.fakeIsNetworkConnected = true
assertEquals(
VaultMigrationData.MigrationRequired(
organizationId = "mockId-1",
organizationName = "mockName-1",
),
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `should emit NoMigrationRequired when network connectivity changes from online to offline`() =
runTest {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val mockPolicy = createMockPolicy(number = 1, type = PolicyTypeJson.PERSONAL_OWNERSHIP)
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns listOf(mockPolicy)
every {
policyManager.getPersonalOwnershipPolicyOrganizationId()
} returns "mockId-1"
val syncResponse = createMockSyncResponse(number = 1)
fakeAuthDiskSource.storeOrganizations(
userId = userId,
organizations = syncResponse.profile.organizations,
)
val vaultMigrationManager = createVaultMigrationManager()
vaultMigrationManager.vaultMigrationDataStateFlow.test {
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
// Simulate vault unlock while online
mutableVaultUnlockDataStateFlow.value = listOf(
VaultUnlockData(
userId = userId,
status = VaultUnlockData.Status.UNLOCKED,
),
)
// Emit personal ciphers exist
mutableHasPersonalCiphersFlow.value = true
// Should emit MigrationRequired since all conditions met
assertEquals(
VaultMigrationData.MigrationRequired(
organizationId = "mockId-1",
organizationName = "mockName-1",
),
awaitItem(),
)
// Network goes offline - should revert to NoMigrationRequired
fakeNetworkConnectionManager.fakeIsNetworkConnected = false
assertEquals(VaultMigrationData.NoMigrationRequired, awaitItem())
}
}
}
private val MOCK_USER_STATE = UserStateJson(
activeUserId = "mockId-1",
accounts = mapOf(
"mockId-1" to AccountJson(
profile = AccountJson.Profile(
userId = "mockId-1",
email = "test@bitwarden.com",
isEmailVerified = true,
name = "Bitwarden Tester",
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremium = false,
forcePasswordResetReason = null,
kdfType = null,
kdfIterations = null,
kdfMemory = null,
kdfParallelism = null,
userDecryptionOptions = null,
isTwoFactorEnabled = false,
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
),
tokens = AccountTokensJson(
accessToken = "accessToken",
refreshToken = "refreshToken",
),
settings = AccountJson.Settings(
environmentUrlData = null,
),
),
),
)
@@ -1440,100 +1440,6 @@ class VaultRepositoryTest {
}
}
@Test
fun `hasPersonalVaultItems returns false when vault data is loading`() {
mutableVaultDataStateFlow.value = DataState.Loading
val result = vaultRepository.hasPersonalVaultItems()
assertEquals(false, result)
}
@Test
fun `hasPersonalVaultItems returns false when all items belong to organizations`() {
mutableVaultDataStateFlow.value = DataState.Loaded(
data = VaultData(
decryptCipherListResult = DecryptCipherListResult(
successes = listOf(
createMockCipherListView(number = 1, organizationId = "org-1"),
createMockCipherListView(number = 2, organizationId = "org-2"),
),
failures = emptyList(),
),
collectionViewList = emptyList(),
folderViewList = emptyList(),
sendViewList = emptyList(),
),
)
val result = vaultRepository.hasPersonalVaultItems()
assertEquals(false, result)
}
@Test
fun `hasPersonalVaultItems returns true when there are items without organization ID`() {
mutableVaultDataStateFlow.value = DataState.Loaded(
data = VaultData(
decryptCipherListResult = DecryptCipherListResult(
successes = listOf(
createMockCipherListView(number = 1, organizationId = null),
createMockCipherListView(number = 2, organizationId = "org-2"),
),
failures = emptyList(),
),
collectionViewList = emptyList(),
folderViewList = emptyList(),
sendViewList = emptyList(),
),
)
val result = vaultRepository.hasPersonalVaultItems()
assertEquals(true, result)
}
@Test
fun `hasPersonalVaultItems returns true when there are items with empty organization ID`() {
mutableVaultDataStateFlow.value = DataState.Loaded(
data = VaultData(
decryptCipherListResult = DecryptCipherListResult(
successes = listOf(
createMockCipherListView(number = 1, organizationId = ""),
createMockCipherListView(number = 2, organizationId = "org-2"),
),
failures = emptyList(),
),
collectionViewList = emptyList(),
folderViewList = emptyList(),
sendViewList = emptyList(),
),
)
val result = vaultRepository.hasPersonalVaultItems()
assertEquals(true, result)
}
@Test
fun `hasPersonalVaultItems returns false when successes list is empty`() {
mutableVaultDataStateFlow.value = DataState.Loaded(
data = VaultData(
decryptCipherListResult = DecryptCipherListResult(
successes = emptyList(),
failures = emptyList(),
),
collectionViewList = emptyList(),
folderViewList = emptyList(),
sendViewList = emptyList(),
),
)
val result = vaultRepository.hasPersonalVaultItems()
assertEquals(false, result)
}
//region Helper functions
/**
@@ -28,6 +28,8 @@ import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
import io.mockk.every
import io.mockk.mockk
@@ -61,6 +63,12 @@ class RootNavViewModelTest : BaseViewModelTest() {
dispatcherManager = FakeDispatcherManager(),
)
private val mutableVaultMigrationDataStateFlow =
MutableStateFlow<VaultMigrationData>(VaultMigrationData.NoMigrationRequired)
private val vaultMigrationManager = mockk<VaultMigrationManager> {
every { vaultMigrationDataStateFlow } returns mutableVaultMigrationDataStateFlow
}
@BeforeEach
fun setup() {
mockkStatic(::parseJwtTokenDataOrNull)
@@ -1579,10 +1587,59 @@ class RootNavViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `when vaultMigrationDataStateFlow emits true the nav state should be MigrateToMyItems`() {
mutableVaultMigrationDataStateFlow.value = MOCK_VAULT_MIGRATION_DATA
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE)
val viewModel = createViewModel()
assertEquals(
RootNavState.MigrateToMyItems(
organizationId = "mockOrganizationId-1",
organizationName = "organizationName",
),
viewModel.stateFlow.value,
)
}
@Test
fun `when vault is locked MigrateToMyItems should not show even if flow emits true`() {
mutableVaultMigrationDataStateFlow.value = MOCK_VAULT_MIGRATION_DATA
// Vault is locked
mutableUserStateFlow.tryEmit(
MOCK_VAULT_UNLOCKED_USER_STATE.copy(
accounts = listOf(
MOCK_VAULT_UNLOCKED_USER_STATE.activeAccount.copy(isVaultUnlocked = false),
),
),
)
val viewModel = createViewModel()
assertEquals(
RootNavState.VaultLocked,
viewModel.stateFlow.value,
)
}
@Test
fun `when vaultMigrationDataStateFlow emits false MigrateToMyItems should not show`() {
mutableVaultMigrationDataStateFlow.value = VaultMigrationData.NoMigrationRequired
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE)
val viewModel = createViewModel()
assertEquals(
RootNavState.VaultUnlocked("activeUserId"),
viewModel.stateFlow.value,
)
}
private fun createViewModel(): RootNavViewModel =
RootNavViewModel(
authRepository = authRepository,
specialCircumstanceManager = specialCircumstanceManager,
vaultMigrationManager = vaultMigrationManager,
)
}
@@ -1665,3 +1722,8 @@ private val MOCK_VAULT_UNLOCKED_USER_MULTIPLE_ACCOUNTS_STATE = UserState(
),
),
)
private val MOCK_VAULT_MIGRATION_DATA = VaultMigrationData.MigrationRequired(
organizationName = "organizationName",
organizationId = "mockOrganizationId-1",
)
@@ -7,6 +7,7 @@ import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
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.VaultSyncManager
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@@ -26,6 +27,7 @@ class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
private val mockOrganizationEventManager: OrganizationEventManager = mockk {
every { trackEvent(any()) } just runs
}
private val mockVaultSyncManager: VaultSyncManager = mockk(relaxed = true)
@BeforeEach
fun setup() {
@@ -176,6 +178,7 @@ class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
)
return MigrateToMyItemsViewModel(
organizationEventManager = mockOrganizationEventManager,
vaultSyncManager = mockVaultSyncManager,
savedStateHandle = savedStateHandle,
)
}