mirror of
https://github.com/bitwarden/android.git
synced 2026-05-09 05:20:24 -05:00
Compare commits
4 Commits
debug-rele
...
release/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0413cdc70d | ||
|
|
019bf8d0fa | ||
|
|
366c86da41 | ||
|
|
a1881ce4d9 |
@@ -2,8 +2,10 @@ package com.x8bit.bitwarden.data.auth.datasource.disk.model
|
||||
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
/**
|
||||
* Represents the current account information for a given user.
|
||||
@@ -45,6 +47,7 @@ data class AccountJson(
|
||||
* @property kdfParallelism The number of threads to use when calculating a password hash.
|
||||
* @property userDecryptionOptions The options available to a user for decryption.
|
||||
*/
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class Profile(
|
||||
@SerialName("userId")
|
||||
@@ -86,7 +89,8 @@ data class AccountJson(
|
||||
@SerialName("kdfParallelism")
|
||||
val kdfParallelism: Int?,
|
||||
|
||||
@SerialName("accountDecryptionOptions")
|
||||
@SerialName("userDecryptionOptions")
|
||||
@JsonNames("accountDecryptionOptions")
|
||||
val userDecryptionOptions: UserDecryptionOptionsJson?,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package com.x8bit.bitwarden.data.auth.datasource.network.model
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
/**
|
||||
* Decryption options related to a user's key connector.
|
||||
*
|
||||
* @property keyConnectorUrl URL to the user's key connector.
|
||||
*/
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class KeyConnectorUserDecryptionOptionsJson(
|
||||
@SerialName("KeyConnectorUrl")
|
||||
@SerialName("keyConnectorUrl")
|
||||
@JsonNames("KeyConnectorUrl")
|
||||
val keyConnectorUrl: String,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.x8bit.bitwarden.data.auth.datasource.network.model
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
/**
|
||||
* Decryption options related to a user's trusted device.
|
||||
@@ -13,20 +15,26 @@ import kotlinx.serialization.Serializable
|
||||
* @property hasManageResetPasswordPermission Whether or not the user has manage reset password
|
||||
* permission.
|
||||
*/
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class TrustedDeviceUserDecryptionOptionsJson(
|
||||
@SerialName("EncryptedPrivateKey")
|
||||
@SerialName("encryptedPrivateKey")
|
||||
@JsonNames("EncryptedPrivateKey")
|
||||
val encryptedPrivateKey: String?,
|
||||
|
||||
@SerialName("EncryptedUserKey")
|
||||
@SerialName("encryptedUserKey")
|
||||
@JsonNames("EncryptedUserKey")
|
||||
val encryptedUserKey: String?,
|
||||
|
||||
@SerialName("HasAdminApproval")
|
||||
@SerialName("hasAdminApproval")
|
||||
@JsonNames("HasAdminApproval")
|
||||
val hasAdminApproval: Boolean,
|
||||
|
||||
@SerialName("HasLoginApprovingDevice")
|
||||
@SerialName("hasLoginApprovingDevice")
|
||||
@JsonNames("HasLoginApprovingDevice")
|
||||
val hasLoginApprovingDevice: Boolean,
|
||||
|
||||
@SerialName("HasManageResetPasswordPermission")
|
||||
@SerialName("hasManageResetPasswordPermission")
|
||||
@JsonNames("HasManageResetPasswordPermission")
|
||||
val hasManageResetPasswordPermission: Boolean,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.x8bit.bitwarden.data.auth.datasource.network.model
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
/**
|
||||
* The options available to a user for decryption.
|
||||
@@ -12,14 +14,18 @@ import kotlinx.serialization.Serializable
|
||||
* device.
|
||||
* @property keyConnectorUserDecryptionOptions Decryption options related to a user's key connector.
|
||||
*/
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class UserDecryptionOptionsJson(
|
||||
@SerialName("HasMasterPassword")
|
||||
@SerialName("hasMasterPassword")
|
||||
@JsonNames("HasMasterPassword")
|
||||
val hasMasterPassword: Boolean,
|
||||
|
||||
@SerialName("TrustedDeviceOption")
|
||||
@SerialName("trustedDeviceOption")
|
||||
@JsonNames("TrustedDeviceOption")
|
||||
val trustedDeviceUserDecryptionOptions: TrustedDeviceUserDecryptionOptionsJson?,
|
||||
|
||||
@SerialName("KeyConnectorOption")
|
||||
@SerialName("keyConnectorOption")
|
||||
@JsonNames("KeyConnectorOption")
|
||||
val keyConnectorUserDecryptionOptions: KeyConnectorUserDecryptionOptionsJson?,
|
||||
)
|
||||
|
||||
@@ -37,7 +37,6 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.Clock
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
@@ -74,7 +73,6 @@ object PlatformDiskModule {
|
||||
fun provideEventDatabase(
|
||||
app: Application,
|
||||
databaseSchemeManager: DatabaseSchemeManager,
|
||||
clock: Clock,
|
||||
): PlatformDatabase =
|
||||
Room
|
||||
.databaseBuilder(
|
||||
@@ -84,12 +82,7 @@ object PlatformDiskModule {
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.addTypeConverter(ZonedDateTimeTypeConverter())
|
||||
.addCallback(
|
||||
DatabaseSchemeCallback(
|
||||
databaseSchemeManager = databaseSchemeManager,
|
||||
clock = clock,
|
||||
),
|
||||
)
|
||||
.addCallback(DatabaseSchemeCallback(databaseSchemeManager = databaseSchemeManager))
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Manager for tracking changes to database scheme(s).
|
||||
*/
|
||||
interface DatabaseSchemeManager {
|
||||
/**
|
||||
* Clears the sync state for all users and emits on the [databaseSchemeChangeFlow].
|
||||
*/
|
||||
fun clearSyncState()
|
||||
|
||||
/**
|
||||
* The instant of the last database schema change performed on the database, if any.
|
||||
*
|
||||
* There is only a single scheme change instant tracked for all database schemes. It is expected
|
||||
* that a scheme change to any database will update this value and trigger a sync.
|
||||
* Emits whenever the sync state hs been cleared.
|
||||
*/
|
||||
var lastDatabaseSchemeChangeInstant: Instant?
|
||||
|
||||
/**
|
||||
* A flow of the last database schema change instant.
|
||||
*/
|
||||
val lastDatabaseSchemeChangeInstantFlow: Flow<Instant?>
|
||||
val databaseSchemeChangeFlow: Flow<Unit>
|
||||
}
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager
|
||||
|
||||
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.dispatcher.DispatcherManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.time.Instant
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
/**
|
||||
* Primary implementation of [DatabaseSchemeManager].
|
||||
*/
|
||||
class DatabaseSchemeManagerImpl(
|
||||
val authDiskSource: AuthDiskSource,
|
||||
val settingsDiskSource: SettingsDiskSource,
|
||||
val dispatcherManager: DispatcherManager,
|
||||
) : DatabaseSchemeManager {
|
||||
private val mutableSharedFlow: MutableSharedFlow<Unit> = bufferedMutableSharedFlow()
|
||||
|
||||
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
|
||||
|
||||
override var lastDatabaseSchemeChangeInstant: Instant?
|
||||
get() = settingsDiskSource.lastDatabaseSchemeChangeInstant
|
||||
set(value) {
|
||||
settingsDiskSource.lastDatabaseSchemeChangeInstant = value
|
||||
override fun clearSyncState() {
|
||||
authDiskSource.userState?.accounts?.forEach { (userId, _) ->
|
||||
settingsDiskSource.storeLastSyncTime(userId = userId, lastSyncTime = null)
|
||||
}
|
||||
mutableSharedFlow.tryEmit(Unit)
|
||||
}
|
||||
|
||||
override val lastDatabaseSchemeChangeInstantFlow =
|
||||
settingsDiskSource
|
||||
.lastDatabaseSchemeChangeInstantFlow
|
||||
.stateIn(
|
||||
scope = unconfinedScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = settingsDiskSource.lastDatabaseSchemeChangeInstant,
|
||||
)
|
||||
override val databaseSchemeChangeFlow: Flow<Unit> = mutableSharedFlow.asSharedFlow()
|
||||
}
|
||||
|
||||
@@ -307,10 +307,10 @@ object PlatformManagerModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabaseSchemeManager(
|
||||
authDiskSource: AuthDiskSource,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): DatabaseSchemeManager = DatabaseSchemeManagerImpl(
|
||||
authDiskSource = authDiskSource,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.Clock
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
@@ -51,7 +50,6 @@ object GeneratorDiskModule {
|
||||
fun providePasswordHistoryDatabase(
|
||||
app: Application,
|
||||
databaseSchemeManager: DatabaseSchemeManager,
|
||||
clock: Clock,
|
||||
): PasswordHistoryDatabase {
|
||||
return Room
|
||||
.databaseBuilder(
|
||||
@@ -59,12 +57,7 @@ object GeneratorDiskModule {
|
||||
klass = PasswordHistoryDatabase::class.java,
|
||||
name = "passcode_history_database",
|
||||
)
|
||||
.addCallback(
|
||||
DatabaseSchemeCallback(
|
||||
databaseSchemeManager = databaseSchemeManager,
|
||||
clock = clock,
|
||||
),
|
||||
)
|
||||
.addCallback(DatabaseSchemeCallback(databaseSchemeManager = databaseSchemeManager))
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,14 @@ package com.x8bit.bitwarden.data.vault.datasource.disk.callback
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
|
||||
import java.time.Clock
|
||||
|
||||
/**
|
||||
* A [RoomDatabase.Callback] for tracking database scheme changes.
|
||||
*/
|
||||
class DatabaseSchemeCallback(
|
||||
private val databaseSchemeManager: DatabaseSchemeManager,
|
||||
private val clock: Clock,
|
||||
) : RoomDatabase.Callback() {
|
||||
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
|
||||
databaseSchemeManager.lastDatabaseSchemeChangeInstant = clock.instant()
|
||||
databaseSchemeManager.clearSyncState()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.Clock
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
@@ -34,7 +33,6 @@ class VaultDiskModule {
|
||||
fun provideVaultDatabase(
|
||||
app: Application,
|
||||
databaseSchemeManager: DatabaseSchemeManager,
|
||||
clock: Clock,
|
||||
): VaultDatabase =
|
||||
Room
|
||||
.databaseBuilder(
|
||||
@@ -43,7 +41,7 @@ class VaultDiskModule {
|
||||
name = "vault_database",
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.addCallback(DatabaseSchemeCallback(databaseSchemeManager, clock))
|
||||
.addCallback(DatabaseSchemeCallback(databaseSchemeManager = databaseSchemeManager))
|
||||
.addTypeConverter(ZonedDateTimeTypeConverter())
|
||||
.build()
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ interface VaultRepository : CipherManager, VaultLockManager {
|
||||
* Unlike [syncIfNecessary], this will always perform the requested sync and should only be
|
||||
* utilized in cases where the user specifically requested the action.
|
||||
*/
|
||||
fun sync()
|
||||
fun sync(forced: Boolean = false)
|
||||
|
||||
/**
|
||||
* Checks if conditions have been met to perform a sync request and, if so, syncs the vault
|
||||
|
||||
@@ -99,7 +99,6 @@ import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
@@ -326,9 +325,8 @@ class VaultRepositoryImpl(
|
||||
.launchIn(ioScope)
|
||||
|
||||
databaseSchemeManager
|
||||
.lastDatabaseSchemeChangeInstantFlow
|
||||
.filterNotNull()
|
||||
.onEach { sync() }
|
||||
.databaseSchemeChangeFlow
|
||||
.onEach { sync(forced = true) }
|
||||
.launchIn(ioScope)
|
||||
}
|
||||
|
||||
@@ -345,7 +343,7 @@ class VaultRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun sync() {
|
||||
override fun sync(forced: Boolean) {
|
||||
val userId = activeUserId ?: return
|
||||
if (!syncJob.isCompleted) return
|
||||
mutableCiphersStateFlow.updateToPendingOrLoading()
|
||||
@@ -353,7 +351,7 @@ class VaultRepositoryImpl(
|
||||
mutableFoldersStateFlow.updateToPendingOrLoading()
|
||||
mutableCollectionsStateFlow.updateToPendingOrLoading()
|
||||
mutableSendDataStateFlow.updateToPendingOrLoading()
|
||||
syncJob = ioScope.launch { syncInternal(userId) }
|
||||
syncJob = ioScope.launch { syncInternal(userId = userId, forced = forced) }
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@@ -361,23 +359,20 @@ class VaultRepositoryImpl(
|
||||
val userId = activeUserId ?: return
|
||||
val currentInstant = clock.instant()
|
||||
val lastSyncInstant = settingsDiskSource.getLastSyncTime(userId = userId)
|
||||
val lastDatabaseSchemeChangeInstant = databaseSchemeManager.lastDatabaseSchemeChangeInstant
|
||||
|
||||
// Sync if we have never done so, the last time was at last 30 minutes ago, or the database
|
||||
// scheme changed since the last sync.
|
||||
if (lastSyncInstant == null ||
|
||||
currentInstant.isAfter(lastSyncInstant.plus(30, ChronoUnit.MINUTES)) ||
|
||||
lastDatabaseSchemeChangeInstant?.isAfter(lastSyncInstant) == true
|
||||
currentInstant.isAfter(lastSyncInstant.plus(30, ChronoUnit.MINUTES))
|
||||
) {
|
||||
sync()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun syncForResult(): SyncVaultDataResult {
|
||||
val userId = activeUserId
|
||||
?: return SyncVaultDataResult.Error(throwable = null)
|
||||
val userId = activeUserId ?: return SyncVaultDataResult.Error(throwable = null)
|
||||
syncJob = ioScope
|
||||
.async { syncInternal(userId) }
|
||||
.async { syncInternal(userId = userId, forced = false) }
|
||||
.also {
|
||||
return try {
|
||||
it.await()
|
||||
@@ -1339,51 +1334,49 @@ class VaultRepositoryImpl(
|
||||
//endregion Push Notification helpers
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private suspend fun syncInternal(userId: String): SyncVaultDataResult {
|
||||
val lastSyncInstant = settingsDiskSource
|
||||
.getLastSyncTime(userId = userId)
|
||||
?.toEpochMilli()
|
||||
?: 0
|
||||
private suspend fun syncInternal(
|
||||
userId: String,
|
||||
forced: Boolean,
|
||||
): SyncVaultDataResult {
|
||||
if (!forced) {
|
||||
// Skip this check if we are forcing the request.
|
||||
val lastSyncInstant = settingsDiskSource
|
||||
.getLastSyncTime(userId = userId)
|
||||
?.toEpochMilli()
|
||||
lastSyncInstant?.let { lastSyncTimeMs ->
|
||||
// If the lasSyncState is null we just sync, no checks required.
|
||||
syncService
|
||||
.getAccountRevisionDateMillis()
|
||||
.fold(
|
||||
onSuccess = { serverRevisionDate ->
|
||||
if (serverRevisionDate < lastSyncTimeMs) {
|
||||
// We can skip the actual sync call if there is no new data or
|
||||
// database scheme changes since the last sync.
|
||||
vaultDiskSource.resyncVaultData(userId = userId)
|
||||
settingsDiskSource.storeLastSyncTime(
|
||||
userId = userId,
|
||||
lastSyncTime = clock.instant(),
|
||||
)
|
||||
val itemsAvailable = vaultDiskSource
|
||||
.getCiphers(userId)
|
||||
.firstOrNull()
|
||||
?.isNotEmpty() == true
|
||||
return SyncVaultDataResult.Success(itemsAvailable = itemsAvailable)
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
updateVaultStateFlowsToError(throwable = it)
|
||||
return SyncVaultDataResult.Error(throwable = it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val lastDatabaseSchemeChangeInstant = databaseSchemeManager
|
||||
.lastDatabaseSchemeChangeInstant
|
||||
?.toEpochMilli()
|
||||
?: 0
|
||||
|
||||
syncService
|
||||
.getAccountRevisionDateMillis()
|
||||
.fold(
|
||||
onSuccess = { serverRevisionDate ->
|
||||
if (serverRevisionDate < lastSyncInstant &&
|
||||
lastDatabaseSchemeChangeInstant < lastSyncInstant
|
||||
) {
|
||||
// We can skip the actual sync call if there is no new data or database
|
||||
// scheme changes since the last sync.
|
||||
vaultDiskSource.resyncVaultData(userId)
|
||||
settingsDiskSource.storeLastSyncTime(
|
||||
userId = userId,
|
||||
lastSyncTime = clock.instant(),
|
||||
)
|
||||
val itemsAvailable = vaultDiskSource
|
||||
.getCiphers(userId)
|
||||
.firstOrNull()
|
||||
?.isNotEmpty()
|
||||
?: false
|
||||
return SyncVaultDataResult.Success(itemsAvailable = itemsAvailable)
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
updateVaultStateFlowsToError(it)
|
||||
return SyncVaultDataResult.Error(it)
|
||||
},
|
||||
)
|
||||
|
||||
syncService
|
||||
return syncService
|
||||
.sync()
|
||||
.fold(
|
||||
onSuccess = { syncResponse ->
|
||||
val localSecurityStamp =
|
||||
authDiskSource.userState?.activeAccount?.profile?.stamp
|
||||
val localSecurityStamp = authDiskSource.userState?.activeAccount?.profile?.stamp
|
||||
val serverSecurityStamp = syncResponse.profile.securityStamp
|
||||
|
||||
// Log the user out if the stamps do not match
|
||||
@@ -1395,11 +1388,9 @@ class VaultRepositoryImpl(
|
||||
}
|
||||
|
||||
// Update user information with additional information from sync response
|
||||
authDiskSource.userState = authDiskSource
|
||||
.userState
|
||||
?.toUpdatedUserStateJson(
|
||||
syncResponse = syncResponse,
|
||||
)
|
||||
authDiskSource.userState = authDiskSource.userState?.toUpdatedUserStateJson(
|
||||
syncResponse = syncResponse,
|
||||
)
|
||||
|
||||
unlockVaultForOrganizationsIfNecessary(syncResponse = syncResponse)
|
||||
storeProfileData(syncResponse = syncResponse)
|
||||
@@ -1414,12 +1405,12 @@ class VaultRepositoryImpl(
|
||||
lastSyncTime = clock.instant(),
|
||||
)
|
||||
vaultDiskSource.replaceVaultData(userId = userId, vault = syncResponse)
|
||||
val itemsAvailable = syncResponse.ciphers?.isNotEmpty() ?: false
|
||||
return SyncVaultDataResult.Success(itemsAvailable = itemsAvailable)
|
||||
val itemsAvailable = syncResponse.ciphers?.isNotEmpty() == true
|
||||
SyncVaultDataResult.Success(itemsAvailable = itemsAvailable)
|
||||
},
|
||||
onFailure = { throwable ->
|
||||
updateVaultStateFlowsToError(throwable)
|
||||
return SyncVaultDataResult.Error(throwable)
|
||||
updateVaultStateFlowsToError(throwable = throwable)
|
||||
SyncVaultDataResult.Error(throwable = throwable)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -85,6 +86,12 @@ fun VaultUnlockScreen(
|
||||
val context = LocalContext.current
|
||||
val resources = context.resources
|
||||
|
||||
LaunchedEffect(state.requiresBiometricsLogin) {
|
||||
if (state.requiresBiometricsLogin && !biometricsManager.isBiometricsSupported) {
|
||||
viewModel.trySendAction(VaultUnlockAction.BiometricsNoLongerSupported)
|
||||
}
|
||||
}
|
||||
|
||||
val onBiometricsUnlockSuccess: (cipher: Cipher?) -> Unit = remember(viewModel) {
|
||||
{ viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(it)) }
|
||||
}
|
||||
@@ -148,6 +155,22 @@ fun VaultUnlockScreen(
|
||||
visibilityState = LoadingDialogState.Shown(R.string.loading.asText()),
|
||||
)
|
||||
|
||||
VaultUnlockState.VaultUnlockDialog.BiometricsNoLongerSupported -> {
|
||||
BitwardenBasicDialog(
|
||||
visibilityState = BasicDialogState.Shown(
|
||||
title = R.string.biometrics_no_longer_supported_title.asText(),
|
||||
message = R.string.biometrics_no_longer_supported.asText(),
|
||||
),
|
||||
onDismissRequest = remember {
|
||||
{
|
||||
viewModel.trySendAction(
|
||||
VaultUnlockAction.DismissBiometricsNoLongerSupportedDialog,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ class VaultUnlockViewModel @Inject constructor(
|
||||
fido2GetCredentialsRequest = null,
|
||||
// TODO: [PM-13076] Handle Fido2CredentialAssertionRequest special circumstance
|
||||
fido2CredentialAssertionRequest = null,
|
||||
hasMasterPassword = activeAccount.hasMasterPassword,
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -138,9 +139,32 @@ class VaultUnlockViewModel @Inject constructor(
|
||||
is VaultUnlockAction.BiometricsUnlockSuccess -> handleBiometricsUnlockSuccess(action)
|
||||
VaultUnlockAction.UnlockClick -> handleUnlockClick()
|
||||
is VaultUnlockAction.Internal -> handleInternalAction(action)
|
||||
VaultUnlockAction.BiometricsNoLongerSupported -> {
|
||||
handleBiometricsNoLongerSupported()
|
||||
}
|
||||
|
||||
VaultUnlockAction.DismissBiometricsNoLongerSupportedDialog -> {
|
||||
handleDismissBiometricsNoLongerSupportedDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBiometricsNoLongerSupported() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = VaultUnlockState.VaultUnlockDialog.BiometricsNoLongerSupported,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDismissBiometricsNoLongerSupportedDialog() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = null)
|
||||
}
|
||||
authRepository.logout()
|
||||
authRepository.hasPendingAccountAddition = true
|
||||
}
|
||||
|
||||
private fun handleAddAccountClick() {
|
||||
authRepository.hasPendingAccountAddition = true
|
||||
}
|
||||
@@ -362,6 +386,7 @@ class VaultUnlockViewModel @Inject constructor(
|
||||
isBiometricEnabled = userState.activeAccount.isBiometricsEnabled,
|
||||
vaultUnlockType = userState.activeAccount.vaultUnlockType,
|
||||
input = "",
|
||||
hasMasterPassword = userState.activeAccount.hasMasterPassword,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -403,6 +428,7 @@ data class VaultUnlockState(
|
||||
val userId: String,
|
||||
val fido2GetCredentialsRequest: Fido2GetCredentialsRequest? = null,
|
||||
val fido2CredentialAssertionRequest: Fido2CredentialAssertionRequest? = null,
|
||||
private val hasMasterPassword: Boolean,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
@@ -434,6 +460,15 @@ data class VaultUnlockState(
|
||||
val fido2RequestUserId: String?
|
||||
get() = fido2GetCredentialsRequest?.userId ?: fido2CredentialAssertionRequest?.userId
|
||||
|
||||
/**
|
||||
* If the user requires biometrics to be able to unlock the account.
|
||||
*/
|
||||
val requiresBiometricsLogin: Boolean
|
||||
get() = when (vaultUnlockType) {
|
||||
VaultUnlockType.MASTER_PASSWORD -> !hasMasterPassword && isBiometricEnabled
|
||||
VaultUnlockType.PIN -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the various dialogs the vault unlock screen can display.
|
||||
*/
|
||||
@@ -452,6 +487,12 @@ data class VaultUnlockState(
|
||||
*/
|
||||
@Parcelize
|
||||
data object Loading : VaultUnlockDialog()
|
||||
|
||||
/**
|
||||
* Show dialog for when biometrics the user has is no longer supported.
|
||||
*/
|
||||
@Parcelize
|
||||
data object BiometricsNoLongerSupported : VaultUnlockDialog()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,6 +537,11 @@ sealed class VaultUnlockAction {
|
||||
*/
|
||||
data object DismissDialog : VaultUnlockAction()
|
||||
|
||||
/**
|
||||
* The user has dismissed the biometrics not supported dialog
|
||||
*/
|
||||
data object DismissBiometricsNoLongerSupportedDialog : VaultUnlockAction()
|
||||
|
||||
/**
|
||||
* The user has clicked on the logout confirmation button.
|
||||
*/
|
||||
@@ -548,6 +594,11 @@ sealed class VaultUnlockAction {
|
||||
*/
|
||||
data object BiometricsLockOut : VaultUnlockAction()
|
||||
|
||||
/**
|
||||
* The user has biometric unlock setup that is no longer valid.
|
||||
*/
|
||||
data object BiometricsNoLongerSupported : VaultUnlockAction()
|
||||
|
||||
/**
|
||||
* The user has clicked the unlock button.
|
||||
*/
|
||||
|
||||
@@ -99,7 +99,7 @@ class OtherViewModel @Inject constructor(
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = OtherState.DialogState.Loading(R.string.syncing.asText()))
|
||||
}
|
||||
vaultRepo.sync()
|
||||
vaultRepo.sync(forced = true)
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: OtherAction.Internal) {
|
||||
|
||||
@@ -255,7 +255,7 @@ class SendViewModel @Inject constructor(
|
||||
|
||||
private fun handleRefreshClick() {
|
||||
// No need to update the view state, the vault repo will emit a new state during this time.
|
||||
vaultRepo.sync()
|
||||
vaultRepo.sync(forced = true)
|
||||
}
|
||||
|
||||
private fun handleSearchClick() {
|
||||
@@ -266,7 +266,7 @@ class SendViewModel @Inject constructor(
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = SendState.DialogState.Loading(R.string.syncing.asText()))
|
||||
}
|
||||
vaultRepo.sync()
|
||||
vaultRepo.sync(forced = true)
|
||||
}
|
||||
|
||||
private fun handleCopyClick(action: SendAction.CopyClick) {
|
||||
@@ -321,7 +321,7 @@ class SendViewModel @Inject constructor(
|
||||
mutableStateFlow.update { it.copy(isRefreshing = true) }
|
||||
// The Pull-To-Refresh composable is already in the refreshing state.
|
||||
// We will reset that state when sendDataStateFlow emits later on.
|
||||
vaultRepo.sync()
|
||||
vaultRepo.sync(forced = false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ class VaultItemViewModel @Inject constructor(
|
||||
|
||||
private fun handleRefreshClick() {
|
||||
// No need to update the view state, the vault repo will emit a new state during this time
|
||||
vaultRepository.sync()
|
||||
vaultRepository.sync(forced = true)
|
||||
}
|
||||
|
||||
private fun handleCopyCustomHiddenFieldClick(
|
||||
|
||||
@@ -300,14 +300,14 @@ class VaultItemListingViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun handleRefreshClick() {
|
||||
vaultRepository.sync()
|
||||
vaultRepository.sync(forced = true)
|
||||
}
|
||||
|
||||
private fun handleRefreshPull() {
|
||||
mutableStateFlow.update { it.copy(isRefreshing = true) }
|
||||
// The Pull-To-Refresh composable is already in the refreshing state.
|
||||
// We will reset that state when sendDataStateFlow emits later on.
|
||||
vaultRepository.sync()
|
||||
vaultRepository.sync(forced = false)
|
||||
}
|
||||
|
||||
private fun handleConfirmOverwriteExistingPasskeyClick(
|
||||
@@ -877,7 +877,7 @@ class VaultItemListingViewModel @Inject constructor(
|
||||
),
|
||||
)
|
||||
}
|
||||
vaultRepository.sync()
|
||||
vaultRepository.sync(forced = true)
|
||||
}
|
||||
|
||||
private fun handleSearchIconClick() {
|
||||
|
||||
@@ -299,7 +299,7 @@ class VaultViewModel @Inject constructor(
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = VaultState.DialogState.Syncing)
|
||||
}
|
||||
vaultRepository.sync()
|
||||
vaultRepository.sync(forced = true)
|
||||
}
|
||||
|
||||
private fun handleLockClick() {
|
||||
@@ -346,7 +346,7 @@ class VaultViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun handleTryAgainClick() {
|
||||
vaultRepository.sync()
|
||||
vaultRepository.sync(forced = true)
|
||||
}
|
||||
|
||||
private fun handleDialogDismiss() {
|
||||
@@ -359,7 +359,7 @@ class VaultViewModel @Inject constructor(
|
||||
mutableStateFlow.update { it.copy(isRefreshing = true) }
|
||||
// The Pull-To-Refresh composable is already in the refreshing state.
|
||||
// We will reset that state when sendDataStateFlow emits later on.
|
||||
vaultRepository.sync()
|
||||
vaultRepository.sync(forced = false)
|
||||
}
|
||||
|
||||
private fun handleOverflowOptionClick(action: VaultAction.OverflowOptionClick) {
|
||||
|
||||
@@ -120,14 +120,14 @@ class VerificationCodeViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun handleRefreshClick() {
|
||||
vaultRepository.sync()
|
||||
vaultRepository.sync(forced = true)
|
||||
}
|
||||
|
||||
private fun handleRefreshPull() {
|
||||
mutableStateFlow.update { it.copy(isRefreshing = true) }
|
||||
// The Pull-To-Refresh composable is already in the refreshing state.
|
||||
// We will reset that state when sendDataStateFlow emits later on.
|
||||
vaultRepository.sync()
|
||||
vaultRepository.sync(forced = false)
|
||||
}
|
||||
|
||||
private fun handleSearchIconClick() {
|
||||
@@ -144,7 +144,7 @@ class VerificationCodeViewModel @Inject constructor(
|
||||
),
|
||||
)
|
||||
}
|
||||
vaultRepository.sync()
|
||||
vaultRepository.sync(forced = true)
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: VerificationCodeAction.Internal) {
|
||||
|
||||
@@ -1087,4 +1087,7 @@ Do you want to switch to this account?</string>
|
||||
<string name="bitwarden_can_notify_you_each_time_you_receive_a_new_login_request_from_another_device">Bitwarden can notify you each time you receive a new login request from another device.</string>
|
||||
<string name="skip_for_now">Skip for now</string>
|
||||
<string name="done_text">Done</string>
|
||||
<string name="biometrics_no_longer_supported_title">Biometrics are no longer supported on this device</string>
|
||||
<string name="biometrics_no_longer_supported">You’ve been logged out because your device’s biometrics don’t meet the latest security requirements. To update settings, log in once again or contact your administrator for access.</string>
|
||||
|
||||
</resources>
|
||||
|
||||
@@ -1266,17 +1266,17 @@ private const val USER_STATE_JSON = """
|
||||
"kdfIterations": 600000,
|
||||
"kdfMemory": 16,
|
||||
"kdfParallelism": 4,
|
||||
"accountDecryptionOptions": {
|
||||
"HasMasterPassword": true,
|
||||
"TrustedDeviceOption": {
|
||||
"EncryptedPrivateKey": "encryptedPrivateKey",
|
||||
"EncryptedUserKey": "encryptedUserKey",
|
||||
"HasAdminApproval": true,
|
||||
"HasLoginApprovingDevice": true,
|
||||
"HasManageResetPasswordPermission": true
|
||||
"userDecryptionOptions": {
|
||||
"hasMasterPassword": true,
|
||||
"trustedDeviceOption": {
|
||||
"encryptedPrivateKey": "encryptedPrivateKey",
|
||||
"encryptedUserKey": "encryptedUserKey",
|
||||
"hasAdminApproval": true,
|
||||
"hasLoginApprovingDevice": true,
|
||||
"hasManageResetPasswordPermission": true
|
||||
},
|
||||
"KeyConnectorOption": {
|
||||
"KeyConnectorUrl": "keyConnectorUrl"
|
||||
"keyConnectorOption": {
|
||||
"keyConnectorUrl": "keyConnectorUrl"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,74 +1,62 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import io.mockk.every
|
||||
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.util.FakeSettingsDiskSource
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class DatabaseSchemeManagerTest {
|
||||
|
||||
private val mutableLastDatabaseSchemeChangeInstantFlow = MutableStateFlow<Instant?>(null)
|
||||
private val mockSettingsDiskSource: SettingsDiskSource = mockk {
|
||||
every {
|
||||
lastDatabaseSchemeChangeInstant
|
||||
} returns mutableLastDatabaseSchemeChangeInstantFlow.value
|
||||
every { lastDatabaseSchemeChangeInstant = any() } answers {
|
||||
mutableLastDatabaseSchemeChangeInstantFlow.value = firstArg()
|
||||
}
|
||||
every {
|
||||
lastDatabaseSchemeChangeInstantFlow
|
||||
} returns mutableLastDatabaseSchemeChangeInstantFlow
|
||||
}
|
||||
private val dispatcherManager = FakeDispatcherManager()
|
||||
private val fakeAuthDiskSource = FakeAuthDiskSource()
|
||||
private val fakeSettingsDiskSource = FakeSettingsDiskSource()
|
||||
private val databaseSchemeManager = DatabaseSchemeManagerImpl(
|
||||
settingsDiskSource = mockSettingsDiskSource,
|
||||
dispatcherManager = dispatcherManager,
|
||||
authDiskSource = fakeAuthDiskSource,
|
||||
settingsDiskSource = fakeSettingsDiskSource,
|
||||
)
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `setLastDatabaseSchemeChangeInstant persists value in settingsDiskSource`() {
|
||||
databaseSchemeManager.lastDatabaseSchemeChangeInstant = FIXED_CLOCK.instant()
|
||||
verify {
|
||||
mockSettingsDiskSource.lastDatabaseSchemeChangeInstant = FIXED_CLOCK.instant()
|
||||
}
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
fakeAuthDiskSource.userState = USER_STATE
|
||||
fakeSettingsDiskSource.storeLastSyncTime(USER_ID_1, FIXED_CLOCK.instant())
|
||||
fakeSettingsDiskSource.storeLastSyncTime(USER_ID_2, FIXED_CLOCK.instant())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setLastDatabaseSchemeChangeInstant does emit value`() = runTest {
|
||||
databaseSchemeManager.lastDatabaseSchemeChangeInstantFlow.test {
|
||||
// Assert the value is initialized to null
|
||||
assertEquals(
|
||||
null,
|
||||
awaitItem(),
|
||||
)
|
||||
// Assert the new value is emitted
|
||||
databaseSchemeManager.lastDatabaseSchemeChangeInstant = FIXED_CLOCK.instant()
|
||||
assertEquals(
|
||||
FIXED_CLOCK.instant(),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
fun `clearSyncState clears lastSyncTimes and emit`() = runTest {
|
||||
assertNotNull(fakeSettingsDiskSource.getLastSyncTime(USER_ID_1))
|
||||
assertNotNull(fakeSettingsDiskSource.getLastSyncTime(USER_ID_2))
|
||||
|
||||
@Test
|
||||
fun `getLastDatabaseSchemeChangeInstant retrieves stored value from settingsDiskSource`() {
|
||||
databaseSchemeManager.lastDatabaseSchemeChangeInstant
|
||||
verify {
|
||||
mockSettingsDiskSource.lastDatabaseSchemeChangeInstant
|
||||
databaseSchemeManager.databaseSchemeChangeFlow.test {
|
||||
databaseSchemeManager.clearSyncState()
|
||||
awaitItem()
|
||||
expectNoEvents()
|
||||
}
|
||||
|
||||
assertNull(fakeSettingsDiskSource.getLastSyncTime(USER_ID_1))
|
||||
assertNull(fakeSettingsDiskSource.getLastSyncTime(USER_ID_2))
|
||||
}
|
||||
}
|
||||
|
||||
private const val USER_ID_1: String = "USER_ID_1"
|
||||
private const val USER_ID_2: String = "USER_ID_2"
|
||||
|
||||
private val USER_STATE: UserStateJson = UserStateJson(
|
||||
activeUserId = USER_ID_1,
|
||||
accounts = mapOf(
|
||||
USER_ID_1 to mockk(),
|
||||
USER_ID_2 to mockk(),
|
||||
),
|
||||
)
|
||||
|
||||
private val FIXED_CLOCK: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
|
||||
@@ -7,26 +7,17 @@ import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.verify
|
||||
import org.junit.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class DatabaseSchemeCallbackTest {
|
||||
|
||||
private val databaseSchemeManager: DatabaseSchemeManager = mockk {
|
||||
every { lastDatabaseSchemeChangeInstant = any() } just runs
|
||||
every { clearSyncState() } just runs
|
||||
}
|
||||
private val callback = DatabaseSchemeCallback(databaseSchemeManager, FIXED_CLOCK)
|
||||
private val callback = DatabaseSchemeCallback(databaseSchemeManager)
|
||||
|
||||
@Test
|
||||
fun `onDestructiveMigration updates lastDatabaseSchemeChangeInstant`() {
|
||||
fun `onDestructiveMigration calls clearSyncState`() {
|
||||
callback.onDestructiveMigration(mockk())
|
||||
|
||||
verify { databaseSchemeManager.lastDatabaseSchemeChangeInstant = FIXED_CLOCK.instant() }
|
||||
verify(exactly = 1) { databaseSchemeManager.clearSyncState() }
|
||||
}
|
||||
}
|
||||
|
||||
private val FIXED_CLOCK: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
@@ -193,14 +193,9 @@ class VaultRepositoryTest {
|
||||
mutableUnlockedUserIdsStateFlow.first { userId in it }
|
||||
}
|
||||
}
|
||||
private val mutableLastDatabaseSchemeChangeInstantFlow = MutableStateFlow<Instant?>(null)
|
||||
private val mutableDatabaseSchemeChangeFlow = bufferedMutableSharedFlow<Unit>()
|
||||
private val databaseSchemeManager: DatabaseSchemeManager = mockk {
|
||||
every {
|
||||
lastDatabaseSchemeChangeInstant
|
||||
} returns mutableLastDatabaseSchemeChangeInstantFlow.value
|
||||
every {
|
||||
lastDatabaseSchemeChangeInstantFlow
|
||||
} returns mutableLastDatabaseSchemeChangeInstantFlow
|
||||
every { databaseSchemeChangeFlow } returns mutableDatabaseSchemeChangeFlow
|
||||
}
|
||||
|
||||
private val mutableFullSyncFlow = bufferedMutableSharedFlow<Unit>()
|
||||
@@ -787,34 +782,29 @@ class VaultRepositoryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lastDatabaseSchemeChangeInstantFlow should trigger sync when new value is not null`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
every {
|
||||
databaseSchemeManager.lastDatabaseSchemeChangeInstant
|
||||
} returns mutableLastDatabaseSchemeChangeInstantFlow.value
|
||||
coEvery { syncService.sync() } just awaits
|
||||
fun `databaseSchemeChangeFlow should trigger sync on emission`() = runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
coEvery { syncService.sync() } just awaits
|
||||
|
||||
mutableLastDatabaseSchemeChangeInstantFlow.value = clock.instant()
|
||||
mutableDatabaseSchemeChangeFlow.tryEmit(Unit)
|
||||
|
||||
coVerify(exactly = 1) { syncService.sync() }
|
||||
}
|
||||
coVerify(exactly = 1) { syncService.sync() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lastDatabaseSchemeChangeInstantFlow should not sync when new value is null`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
fun `sync with forced should skip checks and call the syncService sync`() {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
coEvery { syncService.sync() } returns Throwable("failure").asFailure()
|
||||
|
||||
every {
|
||||
databaseSchemeManager.lastDatabaseSchemeChangeInstant
|
||||
} returns mutableLastDatabaseSchemeChangeInstantFlow.value
|
||||
vaultRepository.sync(forced = true)
|
||||
|
||||
coEvery { syncService.sync() } just awaits
|
||||
|
||||
mutableLastDatabaseSchemeChangeInstantFlow.value = null
|
||||
|
||||
coVerify(exactly = 0) { syncService.sync() }
|
||||
coVerify(exactly = 0) {
|
||||
syncService.getAccountRevisionDateMillis()
|
||||
}
|
||||
coVerify(exactly = 1) {
|
||||
syncService.sync()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
@@ -1108,60 +1098,6 @@ class VaultRepositoryTest {
|
||||
coVerify(exactly = 0) { syncService.sync() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `syncIfNecessary when there is no last scheme change should not sync the vault`() {
|
||||
val userId = "mockId-1"
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
every {
|
||||
settingsDiskSource.getLastSyncTime(userId)
|
||||
} returns clock.instant().minus(1, ChronoUnit.MINUTES)
|
||||
every {
|
||||
databaseSchemeManager.lastDatabaseSchemeChangeInstant
|
||||
} returns null
|
||||
coEvery { syncService.sync() } just awaits
|
||||
|
||||
vaultRepository.syncIfNecessary()
|
||||
|
||||
coVerify(exactly = 0) { syncService.sync() }
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `syncIfNecessary when the last scheme change is before the last sync time should not sync the vault`() {
|
||||
val userId = "mockId-1"
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
every {
|
||||
settingsDiskSource.getLastSyncTime(userId)
|
||||
} returns clock.instant().plus(1, ChronoUnit.MINUTES)
|
||||
every {
|
||||
databaseSchemeManager.lastDatabaseSchemeChangeInstant
|
||||
} returns clock.instant().minus(1, ChronoUnit.MINUTES)
|
||||
|
||||
coEvery { syncService.sync() } just awaits
|
||||
|
||||
vaultRepository.syncIfNecessary()
|
||||
|
||||
coVerify(exactly = 0) { syncService.sync() }
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `syncIfNecessary when the last scheme change is after the last sync time should sync the vault`() {
|
||||
val userId = "mockId-1"
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
every {
|
||||
settingsDiskSource.getLastSyncTime(userId)
|
||||
} returns clock.instant().minus(1, ChronoUnit.MINUTES)
|
||||
every {
|
||||
databaseSchemeManager.lastDatabaseSchemeChangeInstant
|
||||
} returns clock.instant().plus(1, ChronoUnit.MINUTES)
|
||||
coEvery { syncService.sync() } just awaits
|
||||
|
||||
vaultRepository.syncIfNecessary()
|
||||
|
||||
coVerify { syncService.sync() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync when the last sync time is older than the revision date should sync the vault`() {
|
||||
val userId = "mockId-1"
|
||||
|
||||
@@ -541,6 +541,51 @@ class VaultUnlockScreenTest : BaseComposeTest() {
|
||||
.assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("Unlock").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `biometrics not supported dialog shows correctly`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = VaultUnlockState.VaultUnlockDialog.BiometricsNoLongerSupported)
|
||||
}
|
||||
composeTestRule
|
||||
.onNodeWithText("Biometrics are no longer supported on this device")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DismissBiometricsNoLongerSupportedDialog should be sent when dialog is dismissed`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = VaultUnlockState.VaultUnlockDialog.BiometricsNoLongerSupported)
|
||||
}
|
||||
composeTestRule
|
||||
.onNodeWithText("Biometrics are no longer supported on this device")
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Ok")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(VaultUnlockAction.DismissBiometricsNoLongerSupportedDialog)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when biometric is needed but no longer supported BiometricsNoLongerSupported action is sent`() {
|
||||
every { biometricsManager.isBiometricsSupported } returns false
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isBiometricEnabled = true,
|
||||
hasMasterPassword = false,
|
||||
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
|
||||
)
|
||||
}
|
||||
composeTestRule.waitForIdle()
|
||||
verify {
|
||||
viewModel.trySendAction(VaultUnlockAction.BiometricsNoLongerSupported)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_ENVIRONMENT_URL: String = "vault.bitwarden.com"
|
||||
@@ -588,4 +633,5 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState(
|
||||
showBiometricInvalidatedMessage = false,
|
||||
userId = ACTIVE_ACCOUNT_SUMMARY.userId,
|
||||
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
|
||||
hasMasterPassword = true,
|
||||
)
|
||||
|
||||
@@ -1227,6 +1227,35 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
|
||||
verify { fido2CredentialManager.isUserVerified = false }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on BiometricsNoLongerSupported should show correct dialog state`() {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(VaultUnlockAction.BiometricsNoLongerSupported)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = VaultUnlockState.VaultUnlockDialog.BiometricsNoLongerSupported,
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `on DismissBiometricsNoLongerSupportedDialog should dismiss dialog state and log the user out`() {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(VaultUnlockAction.DismissBiometricsNoLongerSupportedDialog)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = null,
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
verify(exactly = 1) {
|
||||
authRepository.logout()
|
||||
authRepository.hasPendingAccountAddition = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
state: VaultUnlockState? = null,
|
||||
unlockType: UnlockType = UnlockType.STANDARD,
|
||||
@@ -1275,6 +1304,7 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState(
|
||||
showBiometricInvalidatedMessage = false,
|
||||
userId = USER_ID,
|
||||
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
|
||||
hasMasterPassword = true,
|
||||
)
|
||||
|
||||
private val TRUSTED_DEVICE: UserState.TrustedDevice = UserState.TrustedDevice(
|
||||
|
||||
@@ -100,7 +100,6 @@ class SearchViewModelTest : BaseViewModelTest() {
|
||||
private val vaultRepository: VaultRepository = mockk {
|
||||
every { vaultFilterType } returns VaultFilterType.AllVaults
|
||||
every { vaultDataStateFlow } returns mutableVaultDataStateFlow
|
||||
every { sync() } just runs
|
||||
}
|
||||
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
|
||||
private val authRepository: AuthRepository = mockk {
|
||||
|
||||
@@ -149,7 +149,7 @@ class OtherViewModelTest : BaseViewModelTest() {
|
||||
|
||||
@Test
|
||||
fun `on SyncNowButtonClick should sync repo`() = runTest {
|
||||
every { vaultRepository.sync() } just runs
|
||||
every { vaultRepository.sync(forced = true) } just runs
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
@@ -163,7 +163,7 @@ class OtherViewModelTest : BaseViewModelTest() {
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
verify { vaultRepository.sync() }
|
||||
verify { vaultRepository.sync(forced = true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -105,12 +105,12 @@ class SendViewModelTest : BaseViewModelTest() {
|
||||
@Test
|
||||
fun `RefreshClick should call sync`() {
|
||||
val viewModel = createViewModel()
|
||||
every { vaultRepo.sync() } just runs
|
||||
every { vaultRepo.sync(forced = true) } just runs
|
||||
|
||||
viewModel.trySendAction(SendAction.RefreshClick)
|
||||
|
||||
verify {
|
||||
vaultRepo.sync()
|
||||
vaultRepo.sync(forced = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ class SendViewModelTest : BaseViewModelTest() {
|
||||
@Test
|
||||
fun `SyncClick should call sync`() {
|
||||
val viewModel = createViewModel()
|
||||
every { vaultRepo.sync() } just runs
|
||||
every { vaultRepo.sync(forced = true) } just runs
|
||||
|
||||
viewModel.trySendAction(SendAction.SyncClick)
|
||||
|
||||
@@ -234,7 +234,7 @@ class SendViewModelTest : BaseViewModelTest() {
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
verify {
|
||||
vaultRepo.sync()
|
||||
vaultRepo.sync(forced = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,13 +419,13 @@ class SendViewModelTest : BaseViewModelTest() {
|
||||
|
||||
@Test
|
||||
fun `RefreshPull should call vault repository sync`() {
|
||||
every { vaultRepo.sync() } just runs
|
||||
every { vaultRepo.sync(forced = false) } just runs
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(SendAction.RefreshPull)
|
||||
|
||||
verify(exactly = 1) {
|
||||
vaultRepo.sync()
|
||||
vaultRepo.sync(forced = false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -950,13 +950,13 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
||||
|
||||
@Test
|
||||
fun `on RefreshClick should sync`() = runTest {
|
||||
every { vaultRepo.sync() } just runs
|
||||
every { vaultRepo.sync(forced = true) } just runs
|
||||
val viewModel = createViewModel(state = DEFAULT_STATE)
|
||||
|
||||
viewModel.trySendAction(VaultItemAction.Common.RefreshClick)
|
||||
|
||||
verify(exactly = 1) {
|
||||
vaultRepo.sync()
|
||||
vaultRepo.sync(forced = true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
||||
every { vaultFilterType } returns VaultFilterType.AllVaults
|
||||
every { vaultDataStateFlow } returns mutableVaultDataStateFlow
|
||||
every { lockVault(any()) } just runs
|
||||
every { sync() } just runs
|
||||
every { sync(forced = any()) } just runs
|
||||
coEvery {
|
||||
getDecryptedFido2CredentialAutofillViews(any())
|
||||
} returns DecryptFido2CredentialAutofillViewResult.Error
|
||||
@@ -351,7 +351,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
verify(exactly = 1) {
|
||||
vaultRepository.sync()
|
||||
vaultRepository.sync(forced = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1029,7 +1029,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
||||
fun `RefreshClick should sync`() = runTest {
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
viewModel.trySendAction(VaultItemListingsAction.RefreshClick)
|
||||
verify { vaultRepository.sync() }
|
||||
verify { vaultRepository.sync(forced = true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -2127,7 +2127,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
||||
viewModel.trySendAction(VaultItemListingsAction.RefreshPull)
|
||||
|
||||
verify(exactly = 1) {
|
||||
vaultRepository.sync()
|
||||
vaultRepository.sync(forced = false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||
mockk {
|
||||
every { vaultFilterType = any() } just runs
|
||||
every { vaultDataStateFlow } returns mutableVaultDataStateFlow
|
||||
every { sync() } just runs
|
||||
every { sync(forced = any()) } just runs
|
||||
every { syncIfNecessary() } just runs
|
||||
every { lockVaultForCurrentUser() } just runs
|
||||
every { lockVault(any()) } just runs
|
||||
@@ -478,7 +478,7 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
verify {
|
||||
vaultRepository.sync()
|
||||
vaultRepository.sync(forced = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1323,7 +1323,7 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||
|
||||
viewModel.trySendAction(VaultAction.TryAgainClick)
|
||||
|
||||
verify { vaultRepository.sync() }
|
||||
verify { vaultRepository.sync(forced = true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1365,7 +1365,7 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||
viewModel.trySendAction(VaultAction.RefreshPull)
|
||||
|
||||
verify(exactly = 1) {
|
||||
vaultRepository.sync()
|
||||
vaultRepository.sync(forced = false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1830,6 +1830,7 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||
snackbarRelayManager.clearRelayBuffer(SnackbarRelay.MY_VAULT_RELAY)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(): VaultViewModel =
|
||||
VaultViewModel(
|
||||
authRepository = authRepository,
|
||||
|
||||
@@ -47,7 +47,7 @@ class VerificationCodeViewModelTest : BaseViewModelTest() {
|
||||
private val vaultRepository: VaultRepository = mockk {
|
||||
every { vaultFilterType } returns VaultFilterType.AllVaults
|
||||
every { getAuthCodesFlow() } returns mutableAuthCodeFlow.asStateFlow()
|
||||
every { sync() } just runs
|
||||
every { sync(forced = any()) } just runs
|
||||
}
|
||||
|
||||
private val environmentRepository: EnvironmentRepository = mockk {
|
||||
@@ -140,7 +140,7 @@ class VerificationCodeViewModelTest : BaseViewModelTest() {
|
||||
fun `RefreshClick should sync`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(VerificationCodeAction.RefreshClick)
|
||||
verify { vaultRepository.sync() }
|
||||
verify { vaultRepository.sync(forced = true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -167,7 +167,7 @@ class VerificationCodeViewModelTest : BaseViewModelTest() {
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
verify(exactly = 1) {
|
||||
vaultRepository.sync()
|
||||
vaultRepository.sync(forced = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,7 +456,7 @@ class VerificationCodeViewModelTest : BaseViewModelTest() {
|
||||
viewModel.trySendAction(VerificationCodeAction.RefreshPull)
|
||||
|
||||
verify(exactly = 1) {
|
||||
vaultRepository.sync()
|
||||
vaultRepository.sync(forced = false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user