diff --git a/app/schemas/com.x8bit.bitwarden.data.vault.datasource.disk.database.VaultDatabase/9.json b/app/schemas/com.x8bit.bitwarden.data.vault.datasource.disk.database.VaultDatabase/9.json new file mode 100644 index 0000000000..a68f1f183f --- /dev/null +++ b/app/schemas/com.x8bit.bitwarden.data.vault.datasource.disk.database.VaultDatabase/9.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt index c2e794900d..c6c2b2552d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt @@ -24,6 +24,16 @@ interface VaultDiskSource { */ suspend fun getCiphers(userId: String): List + /** + * 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 + /** * Retrieves all ciphers with the given [cipherIds] from the data source for a given [userId]. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt index dd09d83864..df208214bc 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt @@ -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 = + ciphersDao.hasPersonalCiphersFlow(userId = userId) + override suspend fun getSelectedCiphers( userId: String, cipherIds: List, @@ -295,6 +299,7 @@ class VaultDiskSourceImpl( hasTotp = cipher.login?.totp != null, cipherType = json.encodeToString(cipher.type), cipherJson = json.encodeToString(cipher), + organizationId = cipher.organizationId, ) }, ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/dao/CiphersDao.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/dao/CiphersDao.kt index 4e5e7119ed..d29ede83ec 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/dao/CiphersDao.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/dao/CiphersDao.kt @@ -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 } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/database/VaultDatabase.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/database/VaultDatabase.kt index 5985fac005..edfd719001 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/database/VaultDatabase.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/database/VaultDatabase.kt @@ -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), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/entity/CipherEntity.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/entity/CipherEntity.kt index d200d94a21..87cbbc511a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/entity/CipherEntity.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/entity/CipherEntity.kt @@ -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?, ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManager.kt new file mode 100644 index 0000000000..329ba38c2d --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManager.kt @@ -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 +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManagerImpl.kt new file mode 100644 index 0000000000..d1469e27c4 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManagerImpl.kt @@ -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(value = VaultMigrationData.NoMigrationRequired) + + override val vaultMigrationDataStateFlow: StateFlow + 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 +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt index 0932ddc275..343de89294 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt @@ -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( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/model/VaultMigrationData.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/model/VaultMigrationData.kt new file mode 100644 index 0000000000..0f8f44a865 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/model/VaultMigrationData.kt @@ -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() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt index 6f0e9caccd..cb0273f73c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt @@ -167,11 +167,4 @@ interface VaultRepository : * `null` if the item cannot be found. */ fun getVaultListItemStateFlow(itemId: String): StateFlow> - - /** - * 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 } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index ceb636e7af..a2f0cbc11b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -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() } - } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 5d05d6fe6c..2e00724402 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -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) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index ec7b3fb0a5..f1e3b6efce 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -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( 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() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index 2533e61fa1..1ae133a434 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -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 = { }, + ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModel.kt index 10812d2a3a..272e31763f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModel.kt @@ -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( 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) { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceTest.kt index 4a791157cb..05c61b2341 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceTest.kt @@ -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( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeCiphersDao.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeCiphersDao.kt index 07948be445..c65ca99b7b 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeCiphersDao.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeCiphersDao.kt @@ -65,4 +65,9 @@ class FakeCiphersDao : CiphersDao { ciphersFlow.tryEmit(ciphers.toList()) return removed || ciphers.isNotEmpty() } + + override fun hasPersonalCiphersFlow(userId: String): Flow = + ciphersFlow.map { ciphers -> + ciphers.any { it.userId == userId && it.organizationId == null } + } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManagerTest.kt new file mode 100644 index 0000000000..2fdb29bec0 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManagerTest.kt @@ -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>(emptyList()) + private val vaultLockManager: VaultLockManager = mockk { + every { vaultUnlockDataStateFlow } returns mutableVaultUnlockDataStateFlow + } + + private val mutableLastSyncTimeFlow = + MutableStateFlow(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(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, + ), + ), + ), +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index 588f23570e..38f2f01adc 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -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 /** diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index 598c782a3c..e40d8a175a 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -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.NoMigrationRequired) + private val vaultMigrationManager = mockk { + 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", +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModelTest.kt index d64d1227ae..0b83cbfdd5 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModelTest.kt @@ -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, ) }