PM-29172: Update Authenticator biometric encryption

This commit is contained in:
David Perez 2025-12-05 16:28:43 -06:00
parent cd27fe339d
commit 3c88c69f92
No known key found for this signature in database
GPG Key ID: 3E29BD8B1BF090AC
27 changed files with 917 additions and 323 deletions

View File

@ -223,6 +223,7 @@ dependencies {
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization)
implementation(libs.timber)
// For now we are restricted to running Compose tests for debug builds only
debugImplementation(libs.androidx.compose.ui.test.manifest)

View File

@ -39,6 +39,11 @@ interface AuthDiskSource : AppIdProvider {
*/
fun storeUserBiometricUnlockKey(biometricsKey: String?)
/**
* Gets and sets the biometrics initialization vector.
*/
var userBiometricKeyInitVector: ByteArray?
/**
* Stores the symmetric key data used for encrypting TOTP data.
*/

View File

@ -10,6 +10,7 @@ import java.util.UUID
private const val AUTHENTICATOR_SYNC_SYMMETRIC_KEY = "authenticatorSyncSymmetricKey"
private const val LAST_ACTIVE_TIME_KEY = "lastActiveTime"
private const val BIOMETRICS_UNLOCK_KEY = "userKeyBiometricUnlock"
private const val BIOMETRICS_INIT_VECTOR_KEY = "biometricInitializationVector"
private const val UNIQUE_APP_ID_KEY = "appId"
/**
@ -60,6 +61,16 @@ class AuthDiskSourceImpl(
mutableUserBiometricUnlockKeyFlow.tryEmit(biometricsKey)
}
override var userBiometricKeyInitVector: ByteArray?
get() = getEncryptedString(key = BIOMETRICS_INIT_VECTOR_KEY)
?.toByteArray(Charsets.ISO_8859_1)
set(value) {
putEncryptedString(
key = BIOMETRICS_INIT_VECTOR_KEY,
value = value?.toString(Charsets.ISO_8859_1),
)
}
override var authenticatorBridgeSymmetricSyncKey: ByteArray?
set(value) {
val asString = value?.let { value.toString(Charsets.ISO_8859_1) }

View File

@ -1,9 +1,36 @@
package com.bitwarden.authenticator.data.auth.repository
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult
import com.bitwarden.authenticator.data.platform.repository.model.BiometricsUnlockResult
import kotlinx.coroutines.flow.StateFlow
import javax.crypto.Cipher
/**
* Provides and API for modifying authentication state.
*/
interface AuthRepository {
interface AuthRepository : BiometricsEncryptionManager {
/**
* Whether or not biometric unlocking is enabled for the current user.
*/
val isUnlockWithBiometricsEnabled: Boolean
/**
* Tracks whether or not biometric unlocking is enabled for the current user.
*/
val isUnlockWithBiometricsEnabledFlow: StateFlow<Boolean>
/**
* Stores the encrypted user key for biometrics, allowing it to be used to unlock the current
* user's vault.
*/
suspend fun setupBiometricsKey(cipher: Cipher): BiometricsKeyResult
/**
* Attempt to unlock the vault using the stored biometric key.
*/
suspend fun unlockWithBiometrics(cipher: Cipher): BiometricsUnlockResult
/**
* Updates the "last active time" for the current user.

View File

@ -1,7 +1,21 @@
package com.bitwarden.authenticator.data.auth.repository
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult
import com.bitwarden.authenticator.data.platform.repository.model.BiometricsUnlockResult
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.realtime.RealtimeManager
import com.bitwarden.core.data.repository.error.MissingPropertyException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import timber.log.Timber
import java.security.GeneralSecurityException
import javax.crypto.Cipher
import javax.inject.Inject
/**
@ -9,12 +23,89 @@ import javax.inject.Inject
*/
class AuthRepositoryImpl @Inject constructor(
private val authDiskSource: AuthDiskSource,
private val authenticatorSdkSource: AuthenticatorSdkSource,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
private val realtimeManager: RealtimeManager,
) : AuthRepository {
dispatcherManager: DispatcherManager,
) : AuthRepository,
BiometricsEncryptionManager by biometricsEncryptionManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
override val isUnlockWithBiometricsEnabled: Boolean
get() = authDiskSource.getUserBiometricUnlockKey() != null
override val isUnlockWithBiometricsEnabledFlow: StateFlow<Boolean>
get() = authDiskSource
.userBiometricUnlockKeyFlow
.map { it != null }
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = isUnlockWithBiometricsEnabled,
)
override suspend fun setupBiometricsKey(cipher: Cipher): BiometricsKeyResult =
authenticatorSdkSource
.generateBiometricsKey()
.onSuccess { biometricsKey ->
authDiskSource.storeUserBiometricUnlockKey(
biometricsKey = try {
cipher
.doFinal(biometricsKey.encodeToByteArray())
.toString(Charsets.ISO_8859_1)
} catch (e: GeneralSecurityException) {
Timber.w(e, "setupBiometricsKey failed encrypt the biometric key")
return BiometricsKeyResult.Error(error = e)
},
)
authDiskSource.userBiometricKeyInitVector = cipher.iv
}
.fold(
onSuccess = { BiometricsKeyResult.Success },
onFailure = { BiometricsKeyResult.Error(error = it) },
)
override suspend fun unlockWithBiometrics(cipher: Cipher): BiometricsUnlockResult {
val biometricsKey = authDiskSource
.getUserBiometricUnlockKey()
?: return BiometricsUnlockResult.InvalidStateError(
error = MissingPropertyException("Biometric key"),
)
val iv = authDiskSource.userBiometricKeyInitVector
val decryptedUserKey = iv
?.let {
try {
cipher
.doFinal(biometricsKey.toByteArray(Charsets.ISO_8859_1))
.decodeToString()
} catch (e: GeneralSecurityException) {
Timber.w(e, "unlockWithBiometrics failed when decrypting biometrics key")
return BiometricsUnlockResult.BiometricDecodingError(error = e)
}
}
?: biometricsKey
if (iv == null) {
// Attempting to setup an encrypted pin before unlocking, if this fails we send back
// the biometrics error and users will need to sign in another way and re-setup
// biometrics.
val encryptedBiometricsKey = try {
cipher
.doFinal(decryptedUserKey.encodeToByteArray())
.toString(Charsets.ISO_8859_1)
} catch (e: GeneralSecurityException) {
Timber.w(e, "unlockWithBiometrics failed to migrate the user to IV encryption")
return BiometricsUnlockResult.BiometricDecodingError(error = e)
}
// We now store the newly encrypted key and the associated IV for future use
// since we want to migrate the user to a more secure form of biometrics.
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = encryptedBiometricsKey)
authDiskSource.userBiometricKeyInitVector = cipher.iv
}
return BiometricsUnlockResult.Success
}
/**
* Updates the "last active time" for the current user.
*/
override fun updateLastActiveTime() {
authDiskSource.storeLastActiveTimeMillis(
lastActiveTimeMillis = realtimeManager.elapsedRealtimeMs,

View File

@ -3,6 +3,9 @@ package com.bitwarden.authenticator.data.auth.repository.di
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
import com.bitwarden.authenticator.data.auth.repository.AuthRepository
import com.bitwarden.authenticator.data.auth.repository.AuthRepositoryImpl
import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.realtime.RealtimeManager
import dagger.Module
import dagger.Provides
@ -19,9 +22,15 @@ object AuthRepositoryModule {
@Provides
fun provideAuthRepository(
authDiskSource: AuthDiskSource,
authenticatorSdkSource: AuthenticatorSdkSource,
biometricsEncryptionManager: BiometricsEncryptionManager,
realtimeManager: RealtimeManager,
dispatcherManager: DispatcherManager,
): AuthRepository = AuthRepositoryImpl(
authDiskSource = authDiskSource,
authenticatorSdkSource = authenticatorSdkSource,
biometricsEncryptionManager = biometricsEncryptionManager,
realtimeManager = realtimeManager,
dispatcherManager = dispatcherManager,
)
}

View File

@ -1,19 +1,35 @@
package com.bitwarden.authenticator.data.platform.manager
import javax.crypto.Cipher
/**
* Responsible for managing Android keystore encryption and decryption.
*/
interface BiometricsEncryptionManager {
/**
* Sets up biometrics to ensure future integrity checks work properly. If this method has never
* been called [isBiometricIntegrityValid] will return false.
* Creates a [Cipher] built from a keystore.
*/
fun setupBiometrics()
fun createCipherOrNull(): Cipher?
/**
* Clears the data associated with the users biometrics.
*/
fun clearBiometrics()
/**
* Gets the [Cipher] built from a keystore, or creates one if it doesn't already exist.
*/
fun getOrCreateCipher(): Cipher?
/**
* Checks to verify that the biometrics integrity is still valid. This returns `true` if the
* biometrics data has not change since the app setup biometrics, `false` will be returned if
* biometrics data has not changed since the app setup biometrics; `false` will be returned if
* it has changed.
*/
fun isBiometricIntegrityValid(): Boolean
/**
* Returns a boolean indicating whether the system reflects biometric availability.
*/
fun isAccountBiometricIntegrityValid(): Boolean
}

View File

@ -3,20 +3,34 @@ package com.bitwarden.authenticator.data.platform.manager
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.authenticator.BuildConfig
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
import timber.log.Timber
import java.security.InvalidAlgorithmParameterException
import java.security.InvalidKeyException
import java.security.KeyStore
import java.security.KeyStoreException
import java.security.NoSuchAlgorithmException
import java.security.NoSuchProviderException
import java.security.ProviderException
import java.security.UnrecoverableKeyException
import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.NoSuchPaddingException
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
/**
* Default implementation of [BiometricsEncryptionManager] for managing Android keystore encryption
* and decryption.
*/
@Suppress("TooManyFunctions")
@OmitFromCoverage
class BiometricsEncryptionManagerImpl(
private val authDiskSource: AuthDiskSource,
private val settingsDiskSource: SettingsDiskSource,
) : BiometricsEncryptionManager {
private val keystore = KeyStore
@ -35,14 +49,59 @@ class BiometricsEncryptionManagerImpl(
.setInvalidatedByBiometricEnrollment(true)
.build()
override fun setupBiometrics() {
override fun createCipherOrNull(): Cipher? {
val secretKey: SecretKey = generateKeyOrNull() ?: run {
// user removed all biometrics from the device
destroyBiometrics()
return null
}
val cipher = try {
Cipher.getInstance(CIPHER_TRANSFORMATION)
} catch (nsae: NoSuchAlgorithmException) {
Timber.w(nsae, "createCipherOrNull failed to get cipher instance")
return null
} catch (nspe: NoSuchPaddingException) {
Timber.w(nspe, "createCipherOrNull failed to get cipher instance")
return null
}
// Instantiate integrity values.
createIntegrityValues()
// This should never fail to initialize / return false because the cipher is newly generated
cipher.initializeCipher(secretKey = secretKey)
return cipher
}
override fun clearBiometrics() {
settingsDiskSource.systemBiometricIntegritySource?.let { systemBioIntegrityState ->
settingsDiskSource.storeAccountBiometricIntegrityValidity(
systemBioIntegrityState = systemBioIntegrityState,
value = null,
)
}
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = null)
authDiskSource.userBiometricKeyInitVector = null
keystore.deleteEntry(ENCRYPTION_KEY_NAME)
}
override fun getOrCreateCipher(): Cipher? {
// Attempt to get the key. If that fails, then we need to generate a new one.
val secretKey: SecretKey = getSecretKeyOrNull()
?: generateKeyOrNull()
?: run {
// user removed all biometrics from the device
destroyBiometrics()
return null
}
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
val isCipherInitialized = cipher.initializeCipher(secretKey = secretKey)
return cipher?.takeIf { isCipherInitialized }
}
override fun isBiometricIntegrityValid(): Boolean =
isSystemBiometricIntegrityValid() && isAccountBiometricIntegrityValid()
private fun isAccountBiometricIntegrityValid(): Boolean {
override fun isAccountBiometricIntegrityValid(): Boolean {
val systemBioIntegrityState = settingsDiskSource
.systemBiometricIntegritySource
?: return false
@ -53,28 +112,99 @@ class BiometricsEncryptionManagerImpl(
?: false
}
private fun isSystemBiometricIntegrityValid(): Boolean =
/**
* Generates a [SecretKey] from which the [Cipher] will be generated, or `null` if a key cannot
* be generated.
*/
private fun generateKeyOrNull(): SecretKey? {
val keyGen = try {
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ENCRYPTION_KEYSTORE_NAME)
} catch (nsae: NoSuchAlgorithmException) {
Timber.w(nsae, "generateKeyOrNull failed to get key generator instance")
return null
} catch (nspe: NoSuchProviderException) {
Timber.w(nspe, "generateKeyOrNull failed to get key generator instance")
return null
} catch (iae: IllegalArgumentException) {
Timber.w(iae, "generateKeyOrNull failed to get key generator instance")
return null
}
return try {
keyGen.init(keyGenParameterSpec)
keyGen.generateKey()
} catch (iape: InvalidAlgorithmParameterException) {
Timber.w(iape, "generateKeyOrNull failed to initialize and generate key")
null
} catch (pe: ProviderException) {
Timber.w(pe, "generateKeyOrNull failed to initialize and generate key")
null
}
}
/**
* Returns the [SecretKey] stored in the keystore, or null if there isn't one.
*/
private fun getSecretKeyOrNull(): SecretKey? =
try {
keystore.load(null)
keystore
.getKey(ENCRYPTION_KEY_NAME, null)
?.let { Cipher.getInstance(CIPHER_TRANSFORMATION).init(Cipher.ENCRYPT_MODE, it) }
keystore.getKey(ENCRYPTION_KEY_NAME, null)?.let { it as SecretKey }
} catch (kse: KeyStoreException) {
// keystore was not loaded
Timber.w(kse, "getSecretKeyOrNull failed to retrieve secret key")
null
} catch (nsae: NoSuchAlgorithmException) {
// keystore algorithm cannot be found
Timber.w(nsae, "getSecretKeyOrNull failed to retrieve secret key")
null
} catch (uke: UnrecoverableKeyException) {
// key could not be recovered
Timber.w(uke, "getSecretKeyOrNull failed to retrieve secret key")
null
}
/**
* Initialize a [Cipher] and return a boolean indicating whether it is valid.
*/
private fun Cipher.initializeCipher(secretKey: SecretKey): Boolean =
try {
authDiskSource
.userBiometricKeyInitVector
?.let { init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(it)) }
?: init(Cipher.ENCRYPT_MODE, secretKey)
true
} catch (e: KeyPermanentlyInvalidatedException) {
} catch (kpie: KeyPermanentlyInvalidatedException) {
// Biometric has changed
settingsDiskSource.systemBiometricIntegritySource = null
Timber.w(kpie, "initializeCipher failed to initialize cipher")
destroyBiometrics()
false
} catch (e: UnrecoverableKeyException) {
} catch (uke: UnrecoverableKeyException) {
// Biometric was disabled and re-enabled
settingsDiskSource.systemBiometricIntegritySource = null
Timber.w(uke, "initializeCipher failed to initialize cipher")
destroyBiometrics()
false
} catch (e: InvalidKeyException) {
// Fallback for old bitwarden users without a key
createIntegrityValues()
} catch (ike: InvalidKeyException) {
// User has no key
Timber.w(ike, "initializeCipher failed to initialize cipher")
destroyBiometrics()
true
}
@Suppress("TooGenericExceptionCaught")
/**
* Validates the keystore key and decrypts it, if decryption is successful `true` is returned,
* `false` otherwise.
*/
private fun isSystemBiometricIntegrityValid(): Boolean {
// Attempt to get the user scoped key. If that fails, we check to see if a legacy key
// is available.
val cipher = getOrCreateCipher()
val secretKey = getSecretKeyOrNull()
return if (cipher != null && secretKey != null) {
cipher.initializeCipher(secretKey = secretKey)
} else {
false
}
}
private fun createIntegrityValues() {
val systemBiometricIntegritySource = settingsDiskSource
.systemBiometricIntegritySource
@ -84,18 +214,11 @@ class BiometricsEncryptionManagerImpl(
systemBioIntegrityState = systemBiometricIntegritySource,
value = true,
)
}
try {
val keyGen = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
ENCRYPTION_KEYSTORE_NAME,
)
keyGen.init(keyGenParameterSpec)
keyGen.generateKey()
} catch (e: Exception) {
// Catch silently to allow biometrics to function on devices that are in
// a state where key generation is not functioning
}
private fun destroyBiometrics() {
clearBiometrics()
settingsDiskSource.systemBiometricIntegritySource = null
}
}

View File

@ -1,6 +1,7 @@
package com.bitwarden.authenticator.data.platform.manager.di
import android.content.Context
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSource
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
@ -74,8 +75,12 @@ object PlatformManagerModule {
@Provides
@Singleton
fun provideBiometricsEncryptionManager(
authDiskSource: AuthDiskSource,
settingsDiskSource: SettingsDiskSource,
): BiometricsEncryptionManager = BiometricsEncryptionManagerImpl(settingsDiskSource)
): BiometricsEncryptionManager = BiometricsEncryptionManagerImpl(
authDiskSource = authDiskSource,
settingsDiskSource = settingsDiskSource,
)
@Provides
@Singleton

View File

@ -1,6 +1,5 @@
package com.bitwarden.authenticator.data.platform.repository
import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
@ -52,16 +51,6 @@ interface SettingsRepository {
*/
val defaultSaveOptionFlow: Flow<DefaultSaveOption>
/**
* Whether or not biometric unlocking is enabled for the current user.
*/
val isUnlockWithBiometricsEnabled: Boolean
/**
* Tracks whether or not biometric unlocking is enabled for the current user.
*/
val isUnlockWithBiometricsEnabledFlow: StateFlow<Boolean>
/**
* Tracks changes to the expiration alert threshold.
*/
@ -92,17 +81,6 @@ interface SettingsRepository {
*/
var previouslySyncedBitwardenAccountIds: Set<String>
/**
* Clears any previously stored encrypted user key used with biometrics for the current user.
*/
fun clearBiometricsKey()
/**
* Stores the encrypted user key for biometrics, allowing it to be used to unlock the current
* user's vault.
*/
suspend fun setupBiometricsKey(): BiometricsKeyResult
/**
* The current setting for crash logging.
*/

View File

@ -1,11 +1,7 @@
package com.bitwarden.authenticator.data.platform.repository
import com.bitwarden.authenticator.BuildConfig
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
@ -24,9 +20,6 @@ private val DEFAULT_IS_SCREEN_CAPTURE_ALLOWED = BuildConfig.DEBUG
*/
class SettingsRepositoryImpl(
private val settingsDiskSource: SettingsDiskSource,
private val authDiskSource: AuthDiskSource,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
private val authenticatorSdkSource: AuthenticatorSdkSource,
dispatcherManager: DispatcherManager,
) : SettingsRepository {
@ -63,20 +56,6 @@ class SettingsRepositoryImpl(
initialValue = isDynamicColorsEnabled,
)
override val isUnlockWithBiometricsEnabled: Boolean
get() = authDiskSource.getUserBiometricUnlockKey() != null
override val isUnlockWithBiometricsEnabledFlow: StateFlow<Boolean>
get() =
authDiskSource
.userBiometricUnlockKeyFlow
.map { it != null }
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = isUnlockWithBiometricsEnabled,
)
override val appThemeStateFlow: StateFlow<AppTheme>
get() = settingsDiskSource
.appThemeFlow
@ -119,6 +98,7 @@ class SettingsRepositoryImpl(
isScreenCaptureAllowed = value,
)
}
override var previouslySyncedBitwardenAccountIds: Set<String> by
settingsDiskSource::previouslySyncedBitwardenAccountIds
@ -132,23 +112,6 @@ class SettingsRepositoryImpl(
?: DEFAULT_IS_SCREEN_CAPTURE_ALLOWED,
)
override suspend fun setupBiometricsKey(): BiometricsKeyResult {
biometricsEncryptionManager.setupBiometrics()
return authenticatorSdkSource
.generateBiometricsKey()
.onSuccess {
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = it)
}
.fold(
onSuccess = { BiometricsKeyResult.Success },
onFailure = { BiometricsKeyResult.Error },
)
}
override fun clearBiometricsKey() {
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = null)
}
override var isCrashLoggingEnabled: Boolean
get() = settingsDiskSource.isCrashLoggingEnabled ?: true
set(value) {

View File

@ -1,10 +1,7 @@
package com.bitwarden.authenticator.data.platform.repository.di
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource
import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepository
import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepositoryImpl
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
@ -28,17 +25,11 @@ object PlatformRepositoryModule {
@Singleton
fun provideSettingsRepository(
settingsDiskSource: SettingsDiskSource,
authDiskSource: AuthDiskSource,
dispatcherManager: DispatcherManager,
biometricsEncryptionManager: BiometricsEncryptionManager,
authenticatorSdkSource: AuthenticatorSdkSource,
): SettingsRepository =
SettingsRepositoryImpl(
settingsDiskSource = settingsDiskSource,
authDiskSource = authDiskSource,
dispatcherManager = dispatcherManager,
biometricsEncryptionManager = biometricsEncryptionManager,
authenticatorSdkSource = authenticatorSdkSource,
)
@Provides

View File

@ -12,5 +12,7 @@ sealed class BiometricsKeyResult {
/**
* Generic error while setting up the biometrics key.
*/
data object Error : BiometricsKeyResult()
data class Error(
val error: Throwable,
) : BiometricsKeyResult()
}

View File

@ -0,0 +1,26 @@
package com.bitwarden.authenticator.data.platform.repository.model
/**
* Models result of unlocking the app.
*/
sealed class BiometricsUnlockResult {
/**
* Vault successfully unlocked.
*/
data object Success : BiometricsUnlockResult()
/**
* Unable to decode biometrics key.
*/
data class BiometricDecodingError(
val error: Throwable?,
) : BiometricsUnlockResult()
/**
* Unable to access user state information.
*/
data class InvalidStateError(
val error: Throwable?,
) : BiometricsUnlockResult()
}

View File

@ -13,9 +13,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@ -32,6 +30,7 @@ import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import javax.crypto.Cipher
/**
* Top level composable for the unlock screen.
@ -45,58 +44,40 @@ fun UnlockScreen(
onUnlocked: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
var showBiometricsPrompt by remember { mutableStateOf(true) }
EventsEffect(viewModel = viewModel) { event ->
when (event) {
UnlockEvent.NavigateToItemListing -> onUnlocked()
}
}
when (val dialog = state.dialog) {
is UnlockState.Dialog.Error -> BitwardenBasicDialog(
title = stringResource(id = BitwardenString.an_error_has_occurred),
message = dialog.message(),
onDismissRequest = remember(viewModel) {
{
viewModel.trySendAction(UnlockAction.DismissDialog)
}
},
)
UnlockState.Dialog.Loading -> BitwardenLoadingDialog(
text = stringResource(id = BitwardenString.loading),
)
null -> Unit
}
val onBiometricsUnlock: () -> Unit = remember(viewModel) {
{ viewModel.trySendAction(UnlockAction.BiometricsUnlock) }
val onBiometricsUnlockSuccess: (cipher: Cipher) -> Unit = remember(viewModel) {
{ viewModel.trySendAction(UnlockAction.BiometricsUnlockSuccess(it)) }
}
val onBiometricsLockOut: () -> Unit = remember(viewModel) {
{ viewModel.trySendAction(UnlockAction.BiometricsLockout) }
}
if (showBiometricsPrompt) {
biometricsManager.promptBiometrics(
onSuccess = {
showBiometricsPrompt = false
onBiometricsUnlock()
},
onCancel = {
showBiometricsPrompt = false
},
onError = {
showBiometricsPrompt = false
},
onLockOut = {
showBiometricsPrompt = false
onBiometricsLockOut()
},
)
EventsEffect(viewModel = viewModel) { event ->
when (event) {
UnlockEvent.NavigateToItemListing -> onUnlocked()
is UnlockEvent.PromptForBiometrics -> {
biometricsManager.promptBiometrics(
onSuccess = onBiometricsUnlockSuccess,
onCancel = {
// no-op
},
onError = {
// no-op
},
onLockOut = onBiometricsLockOut,
cipher = event.cipher,
)
}
}
}
UnlockDialogs(
dialog = state.dialog,
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(UnlockAction.DismissDialog) }
},
)
BitwardenScaffold(
modifier = Modifier
.fillMaxSize(),
@ -118,17 +99,8 @@ fun UnlockScreen(
Spacer(modifier = Modifier.height(32.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.unlock),
onClick = {
biometricsManager.promptBiometrics(
onSuccess = onBiometricsUnlock,
onCancel = {
// no-op
},
onError = {
// no-op
},
onLockOut = onBiometricsLockOut,
)
onClick = remember(viewModel) {
{ viewModel.trySendAction(UnlockAction.BiometricsUnlockClick) }
},
modifier = Modifier
.padding(horizontal = 16.dp)
@ -140,3 +112,26 @@ fun UnlockScreen(
}
}
}
@Composable
private fun UnlockDialogs(
dialog: UnlockState.Dialog?,
onDismissRequest: () -> Unit,
) {
when (dialog) {
is UnlockState.Dialog.Error -> {
BitwardenBasicDialog(
title = dialog.title(),
message = dialog.message(),
throwable = dialog.throwable,
onDismissRequest = onDismissRequest,
)
}
UnlockState.Dialog.Loading -> {
BitwardenLoadingDialog(text = stringResource(id = BitwardenString.loading))
}
null -> Unit
}
}

View File

@ -3,8 +3,9 @@ package com.bitwarden.authenticator.ui.auth.unlock
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.authenticator.data.auth.repository.AuthRepository
import com.bitwarden.authenticator.data.platform.repository.model.BiometricsUnlockResult
import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
@ -13,7 +14,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.crypto.Cipher
import javax.inject.Inject
private const val KEY_STATE = "state"
@ -24,13 +27,13 @@ private const val KEY_STATE = "state"
@HiltViewModel
class UnlockViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val settingsRepository: SettingsRepository,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
private val authRepository: AuthRepository,
) : BaseViewModel<UnlockState, UnlockEvent, UnlockAction>(
initialState = savedStateHandle[KEY_STATE] ?: run {
UnlockState(
isBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled,
isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid(),
isBiometricsEnabled = authRepository.isUnlockWithBiometricsEnabled,
isBiometricsValid = authRepository.isBiometricIntegrityValid(),
showBiometricInvalidatedMessage = false,
dialog = null,
)
},
@ -40,29 +43,87 @@ class UnlockViewModel @Inject constructor(
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
authRepository.getOrCreateCipher()?.let {
sendEvent(UnlockEvent.PromptForBiometrics(cipher = it))
}
}
override fun handleAction(action: UnlockAction) {
when (action) {
UnlockAction.BiometricsUnlock -> {
handleBiometricsUnlock()
}
is UnlockAction.BiometricsUnlockSuccess -> handleBiometricsUnlockSuccess(action)
UnlockAction.DismissDialog -> handleDismissDialog()
UnlockAction.BiometricsLockout -> handleBiometricsLockout()
UnlockAction.BiometricsUnlockClick -> handleBiometricsUnlockClick()
is UnlockAction.Internal -> handleInternalAction(action)
}
}
UnlockAction.DismissDialog -> {
handleDismissDialog()
}
UnlockAction.BiometricsLockout -> {
handleBiometricsLockout()
private fun handleInternalAction(action: UnlockAction.Internal) {
when (action) {
is UnlockAction.Internal.ReceiveVaultUnlockResult -> {
handleReceiveVaultUnlockResult(action)
}
}
}
private fun handleBiometricsUnlock() {
if (state.isBiometricsEnabled && !state.isBiometricsValid) {
biometricsEncryptionManager.setupBiometrics()
private fun handleReceiveVaultUnlockResult(
action: UnlockAction.Internal.ReceiveVaultUnlockResult,
) {
when (val result = action.vaultUnlockResult) {
is BiometricsUnlockResult.BiometricDecodingError -> {
authRepository.clearBiometrics()
mutableStateFlow.update {
it.copy(
isBiometricsValid = false,
dialog = UnlockState.Dialog.Error(
title = BitwardenString.biometrics_failed.asText(),
message = BitwardenString.biometrics_decoding_failure.asText(),
),
)
}
}
is BiometricsUnlockResult.InvalidStateError -> {
mutableStateFlow.update {
it.copy(
dialog = UnlockState.Dialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
throwable = result.error,
),
)
}
}
BiometricsUnlockResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) }
sendEvent(UnlockEvent.NavigateToItemListing)
}
}
}
private fun handleBiometricsUnlockClick() {
authRepository
.getOrCreateCipher()
?.let { sendEvent(UnlockEvent.PromptForBiometrics(cipher = it)) }
?: run {
mutableStateFlow.update {
it.copy(
isBiometricsValid = false,
showBiometricInvalidatedMessage = !authRepository
.isAccountBiometricIntegrityValid(),
dialog = null,
)
}
}
}
private fun handleBiometricsUnlockSuccess(action: UnlockAction.BiometricsUnlockSuccess) {
mutableStateFlow.update { it.copy(dialog = UnlockState.Dialog.Loading) }
viewModelScope.launch {
val vaultUnlockResult = authRepository.unlockWithBiometrics(cipher = action.cipher)
sendAction(UnlockAction.Internal.ReceiveVaultUnlockResult(vaultUnlockResult))
}
sendEvent(UnlockEvent.NavigateToItemListing)
}
private fun handleDismissDialog() {
@ -73,6 +134,7 @@ class UnlockViewModel @Inject constructor(
mutableStateFlow.update {
it.copy(
dialog = UnlockState.Dialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.too_many_failed_biometric_attempts.asText(),
),
)
@ -87,6 +149,7 @@ class UnlockViewModel @Inject constructor(
data class UnlockState(
val isBiometricsEnabled: Boolean,
val isBiometricsValid: Boolean,
val showBiometricInvalidatedMessage: Boolean,
val dialog: Dialog?,
) : Parcelable {
@ -99,7 +162,9 @@ data class UnlockState(
* Displays a generic error dialog to the user.
*/
data class Error(
val title: Text,
val message: Text,
val throwable: Throwable? = null,
) : Dialog()
/**
@ -113,6 +178,10 @@ data class UnlockState(
* Models events for the Unlock screen.
*/
sealed class UnlockEvent {
/**
* Prompts the user for biometrics unlock.
*/
data class PromptForBiometrics(val cipher: Cipher) : UnlockEvent(), BackgroundEvent
/**
* Navigates to the item listing screen.
@ -135,8 +204,25 @@ sealed class UnlockAction {
*/
data object BiometricsLockout : UnlockAction()
/**
* The user has clicked the biometrics button.
*/
data object BiometricsUnlockClick : UnlockAction()
/**
* The user has successfully unlocked the app with biometrics.
*/
data object BiometricsUnlock : UnlockAction()
data class BiometricsUnlockSuccess(val cipher: Cipher) : UnlockAction()
/**
* Models actions that the [UnlockViewModel] itself might send.
*/
sealed class Internal : UnlockAction() {
/**
* Indicates a vault unlock result has been received.
*/
data class ReceiveVaultUnlockResult(
val vaultUnlockResult: BiometricsUnlockResult,
) : Internal()
}
}

View File

@ -3,7 +3,6 @@ package com.bitwarden.authenticator.ui.platform.feature.rootnav
import android.os.Parcelable
import androidx.lifecycle.viewModelScope
import com.bitwarden.authenticator.data.auth.repository.AuthRepository
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
@ -22,7 +21,6 @@ import javax.inject.Inject
class RootNavViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val settingsRepository: SettingsRepository,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
) : BaseViewModel<RootNavState, Unit, RootNavAction>(
initialState = RootNavState(
hasSeenWelcomeGuide = settingsRepository.hasSeenWelcomeTutorial,
@ -76,8 +74,8 @@ class RootNavViewModel @Inject constructor(
) {
settingsRepository.hasSeenWelcomeTutorial = action.hasSeenWelcomeGuide
if (action.hasSeenWelcomeGuide) {
if (settingsRepository.isUnlockWithBiometricsEnabled &&
biometricsEncryptionManager.isBiometricIntegrityValid()
if (authRepository.isUnlockWithBiometricsEnabled &&
authRepository.isBiometricIntegrityValid()
) {
mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Locked) }
} else {
@ -111,7 +109,7 @@ class RootNavViewModel @Inject constructor(
action: RootNavAction.BiometricSupportChanged,
) {
if (!action.isBiometricsSupported) {
settingsRepository.clearBiometricsKey()
authRepository.clearBiometrics()
// If currently locked, navigate to unlocked since biometrics are no longer available
if (mutableStateFlow.value.navState is RootNavState.NavState.Locked) {

View File

@ -76,6 +76,7 @@ import com.bitwarden.ui.platform.util.displayLabel
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import kotlinx.collections.immutable.toImmutableList
import javax.crypto.Cipher
/**
* Display the settings screen.
@ -92,8 +93,15 @@ fun SettingsScreen(
onNavigateToImport: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val snackbarState = rememberBitwardenSnackbarHostState()
var showBiometricsPrompt by rememberSaveable { mutableStateOf(false) }
val unlockWithBiometricToggle: (cipher: Cipher) -> Unit = remember(viewModel) {
{
viewModel.trySendAction(
SettingsAction.SecurityClick.UnlockWithBiometricToggleEnabled(cipher = it),
)
}
}
EventsEffect(viewModel = viewModel) { event ->
when (event) {
SettingsEvent.NavigateToTutorial -> onNavigateToTutorial()
@ -135,6 +143,20 @@ fun SettingsScreen(
}
is SettingsEvent.ShowSnackbar -> snackbarState.showSnackbar(event.data)
is SettingsEvent.ShowBiometricsPrompt -> {
showBiometricsPrompt = true
biometricsManager.promptBiometrics(
onSuccess = {
unlockWithBiometricToggle(it)
showBiometricsPrompt = false
},
onCancel = { showBiometricsPrompt = false },
onLockOut = { showBiometricsPrompt = false },
onError = { showBiometricsPrompt = false },
cipher = event.cipher,
)
}
}
}
@ -147,6 +169,7 @@ fun SettingsScreen(
},
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
@ -452,7 +475,6 @@ private fun UnlockWithBiometricsRow(
modifier: Modifier = Modifier,
) {
if (!biometricsManager.isBiometricsSupported) return
var showBiometricsPrompt by rememberSaveable { mutableStateOf(false) }
BitwardenSwitch(
modifier = modifier,
cardStyle = CardStyle.Top(),
@ -460,23 +482,8 @@ private fun UnlockWithBiometricsRow(
subtext = stringResource(
id = BitwardenString.use_your_devices_lock_method_to_unlock_the_app,
),
isChecked = isChecked || showBiometricsPrompt,
onCheckedChange = { toggled ->
if (toggled) {
showBiometricsPrompt = true
biometricsManager.promptBiometrics(
onSuccess = {
onBiometricToggle(true)
showBiometricsPrompt = false
},
onCancel = { showBiometricsPrompt = false },
onLockOut = { showBiometricsPrompt = false },
onError = { showBiometricsPrompt = false },
)
} else {
onBiometricToggle(false)
}
},
isChecked = isChecked,
onCheckedChange = onBiometricToggle,
)
}

View File

@ -7,6 +7,7 @@ import androidx.core.os.LocaleListCompat
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.authenticator.BuildConfig
import com.bitwarden.authenticator.data.auth.repository.AuthRepository
import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository
import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState
import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled
@ -37,6 +38,7 @@ import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.time.Clock
import java.time.Year
import javax.crypto.Cipher
import javax.inject.Inject
private const val KEY_STATE = "state"
@ -52,6 +54,7 @@ class SettingsViewModel @Inject constructor(
authenticatorRepository: AuthenticatorRepository,
snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
private val authenticatorBridgeManager: AuthenticatorBridgeManager,
private val authRepository: AuthRepository,
private val settingsRepository: SettingsRepository,
private val clipboardManager: BitwardenClipboardManager,
) : BaseViewModel<SettingsState, SettingsEvent, SettingsAction>(
@ -60,7 +63,7 @@ class SettingsViewModel @Inject constructor(
clock = clock,
appLanguage = settingsRepository.appLanguage,
appTheme = settingsRepository.appTheme,
unlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled,
unlockWithBiometricsEnabled = authRepository.isUnlockWithBiometricsEnabled,
isSubmitCrashLogsEnabled = settingsRepository.isCrashLoggingEnabled,
accountSyncState = authenticatorBridgeManager.accountSyncStateFlow.value,
defaultSaveOption = settingsRepository.defaultSaveOption,
@ -86,7 +89,7 @@ class SettingsViewModel @Inject constructor(
.map { SettingsAction.Internal.DefaultSaveOptionUpdated(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
settingsRepository
authRepository
.isUnlockWithBiometricsEnabledFlow
.map { SettingsAction.Internal.UnlockWithBiometricsUpdated(it) }
.onEach(::sendAction)
@ -179,6 +182,10 @@ class SettingsViewModel @Inject constructor(
is SettingsAction.SecurityClick.AllowScreenCaptureToggle -> {
handleAllowScreenCaptureToggle(action)
}
is SettingsAction.SecurityClick.UnlockWithBiometricToggleEnabled -> {
handleUnlockWithBiometricToggleEnabled(action)
}
}
}
@ -186,18 +193,24 @@ class SettingsViewModel @Inject constructor(
action: SettingsAction.SecurityClick.UnlockWithBiometricToggle,
) {
if (action.enabled) {
mutableStateFlow.update {
it.copy(
dialog = SettingsState.Dialog.Loading(BitwardenString.saving.asText()),
isUnlockWithBiometricsEnabled = true,
)
}
viewModelScope.launch {
val result = settingsRepository.setupBiometricsKey()
sendAction(SettingsAction.Internal.BiometricsKeyResultReceive(result))
}
authRepository
.createCipherOrNull()
?.let {
// Generate a new key in case the previous one was invalidated
sendEvent(SettingsEvent.ShowBiometricsPrompt(cipher = it))
}
?: run {
mutableStateFlow.update {
it.copy(
dialog = SettingsState.Dialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
),
)
}
}
} else {
settingsRepository.clearBiometricsKey()
authRepository.clearBiometrics()
mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = false) }
}
}
@ -206,7 +219,7 @@ class SettingsViewModel @Inject constructor(
action: SettingsAction.Internal.BiometricsKeyResultReceive,
) {
when (action.result) {
BiometricsKeyResult.Error -> {
is BiometricsKeyResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = null,
@ -233,6 +246,21 @@ class SettingsViewModel @Inject constructor(
mutableStateFlow.update { it.copy(allowScreenCapture = action.enabled) }
}
private fun handleUnlockWithBiometricToggleEnabled(
action: SettingsAction.SecurityClick.UnlockWithBiometricToggleEnabled,
) {
mutableStateFlow.update {
it.copy(
dialog = SettingsState.Dialog.Loading(BitwardenString.saving.asText()),
isUnlockWithBiometricsEnabled = true,
)
}
viewModelScope.launch {
val result = authRepository.setupBiometricsKey(cipher = action.cipher)
sendAction(SettingsAction.Internal.BiometricsKeyResultReceive(result = result))
}
}
private fun handleVaultClick(action: SettingsAction.DataClick) {
when (action) {
SettingsAction.DataClick.ExportClick -> handleExportClick()
@ -453,6 +481,13 @@ data class SettingsState(
*/
@Parcelize
sealed class Dialog : Parcelable {
/**
* Displays an error dialog with a title and message.
*/
data class Error(
val title: Text,
val message: Text,
) : Dialog()
/**
* Displays a loading dialog with a [message].
@ -544,28 +579,19 @@ sealed class SettingsEvent {
),
)
}
/**
* Shows the prompt for biometrics using with the given [cipher].
*/
data class ShowBiometricsPrompt(
val cipher: Cipher,
) : SettingsEvent()
}
/**
* Models actions for the settings screen.
*/
sealed class SettingsAction(
val dialog: Dialog? = null,
) {
/**
* Represents dialogs that may be displayed by the Settings screen.
*/
sealed class Dialog {
/**
* Display the loading screen with a [message].
*/
data class Loading(
val message: Text,
) : Dialog()
}
sealed class SettingsAction {
/**
* Indicates an update on device biometrics support.
*/
@ -580,6 +606,13 @@ sealed class SettingsAction(
*/
data class UnlockWithBiometricToggle(val enabled: Boolean) : SecurityClick()
/**
* User toggled the unlock with biometrics switch to on.
*/
data class UnlockWithBiometricToggleEnabled(
val cipher: Cipher,
) : SecurityClick()
/**
* Indicates the user clicked allow screen capture toggle.
*/

View File

@ -1,6 +1,7 @@
package com.bitwarden.authenticator.ui.platform.manager.biometrics
import androidx.compose.runtime.Immutable
import javax.crypto.Cipher
/**
* Interface to manage biometrics within the app.
@ -16,9 +17,10 @@ interface BiometricsManager {
* Display a prompt for biometrics.
*/
fun promptBiometrics(
onSuccess: () -> Unit,
onSuccess: (Cipher) -> Unit,
onCancel: () -> Unit,
onLockOut: () -> Unit,
onError: () -> Unit,
cipher: Cipher,
)
}

View File

@ -7,11 +7,14 @@ import androidx.biometric.BiometricManager.Authenticators
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.resource.BitwardenString
import javax.crypto.Cipher
/**
* Default implementation of the [BiometricsManager] to manage biometrics within the app.
*/
@OmitFromCoverage
class BiometricsManagerImpl(
private val activity: Activity,
) : BiometricsManager {
@ -46,10 +49,11 @@ class BiometricsManagerImpl(
}
override fun promptBiometrics(
onSuccess: () -> Unit,
onSuccess: (Cipher) -> Unit,
onCancel: () -> Unit,
onLockOut: () -> Unit,
onError: () -> Unit,
cipher: Cipher,
) {
val biometricPrompt = BiometricPrompt(
fragmentActivity,
@ -57,7 +61,7 @@ class BiometricsManagerImpl(
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult,
) = onSuccess()
) = result.cryptoObject?.cipher?.let(onSuccess) ?: onError()
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
when (errorCode) {
@ -91,9 +95,10 @@ class BiometricsManagerImpl(
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(activity.getString(BitwardenString.bitwarden_authenticator))
.setDescription(activity.getString(BitwardenString.device_verification))
.setConfirmationRequired(false)
.setAllowedAuthenticators(allowedAuthenticators)
.build()
biometricPrompt.authenticate(promptInfo)
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
}

View File

@ -39,6 +39,28 @@ class AuthDiskSourceTest {
assertTrue(authDiskSource.authenticatorBridgeSymmetricSyncKey.contentEquals(symmetricKey))
}
@Test
fun `userBiometricKeyInitVector should store and update from EncryptedSharedPreferences`() {
val sharedPrefsKey = "bwSecureStorage:biometricInitializationVector"
// Shared preferences and the repository start with the same value:
assertNull(authDiskSource.userBiometricKeyInitVector)
assertNull(fakeEncryptedSharedPreferences.getString(sharedPrefsKey, null))
// Updating the repository updates shared preferences:
val userBiometricKeyInitVector = byteArrayOf(3, 4)
authDiskSource.userBiometricKeyInitVector = userBiometricKeyInitVector
assertEquals(
userBiometricKeyInitVector.toString(Charsets.ISO_8859_1),
fakeEncryptedSharedPreferences.getString(sharedPrefsKey, null),
)
// Retrieving the key from repository should give same byte array despite String conversion:
assertTrue(
authDiskSource.userBiometricKeyInitVector.contentEquals(userBiometricKeyInitVector),
)
}
@Test
fun `uniqueAppId should generate a new ID and update SharedPreferences if none exists`() {
val rememberedUniqueAppIdKey = "bwPreferencesStorage:appId"

View File

@ -4,36 +4,53 @@ import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onSubscription
import org.junit.jupiter.api.Assertions.assertEquals
import java.util.UUID
class FakeAuthDiskSource : AuthDiskSource {
private var lastActiveTimeMillis: Long? = null
private var userBiometricUnlockKey: String? = null
private var storedLastActiveTimeMillis: Long? = null
private var storedUserBiometricUnlockKey: String? = null
private var storedUserBiometricInitVector: ByteArray? = null
private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow<String?>(replay = 1)
override val uniqueAppId: String
get() = UUID.randomUUID().toString()
override fun getLastActiveTimeMillis(): Long? = lastActiveTimeMillis
override fun getLastActiveTimeMillis(): Long? = storedLastActiveTimeMillis
override fun storeLastActiveTimeMillis(lastActiveTimeMillis: Long?) {
this@FakeAuthDiskSource.lastActiveTimeMillis = lastActiveTimeMillis
storedLastActiveTimeMillis = lastActiveTimeMillis
}
override fun getUserBiometricUnlockKey(): String? = userBiometricUnlockKey
fun assertLastActiveTimeMillis(lastActiveTimeMillis: Long?) {
assertEquals(lastActiveTimeMillis, storedLastActiveTimeMillis)
}
override fun getUserBiometricUnlockKey(): String? = storedUserBiometricUnlockKey
override val userBiometricUnlockKeyFlow: Flow<String?>
get() =
mutableUserBiometricUnlockKeyFlow
.onSubscription {
emit(getUserBiometricUnlockKey())
}
get() = mutableUserBiometricUnlockKeyFlow
.onSubscription { emit(getUserBiometricUnlockKey()) }
override fun storeUserBiometricUnlockKey(biometricsKey: String?) {
mutableUserBiometricUnlockKeyFlow.tryEmit(biometricsKey)
this@FakeAuthDiskSource.userBiometricUnlockKey = biometricsKey
this@FakeAuthDiskSource.storedUserBiometricUnlockKey = biometricsKey
}
fun assertUserBiometricUnlockKey(biometricsKey: String?) {
assertEquals(biometricsKey, storedUserBiometricUnlockKey)
}
override var authenticatorBridgeSymmetricSyncKey: ByteArray? = null
override var userBiometricKeyInitVector: ByteArray?
get() = storedUserBiometricInitVector
set(value) {
storedUserBiometricInitVector = value
}
fun assertUserBiometricKeyInitVector(iv: ByteArray?) {
assertEquals(iv, storedUserBiometricInitVector)
}
}

View File

@ -0,0 +1,205 @@
package com.bitwarden.authenticator.data.auth.repository
import app.cash.turbine.test
import com.bitwarden.authenticator.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult
import com.bitwarden.authenticator.data.platform.repository.model.BiometricsUnlockResult
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
import com.bitwarden.core.data.manager.realtime.RealtimeManager
import com.bitwarden.core.data.repository.error.MissingPropertyException
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.security.GeneralSecurityException
import javax.crypto.Cipher
class AuthRepositoryTest {
private val authDiskSource: FakeAuthDiskSource = FakeAuthDiskSource()
private val authenticatorSdkSource: AuthenticatorSdkSource = mockk()
private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk()
private val realtimeManager: RealtimeManager = mockk()
private val authRepository: AuthRepository = AuthRepositoryImpl(
authDiskSource = authDiskSource,
authenticatorSdkSource = authenticatorSdkSource,
biometricsEncryptionManager = biometricsEncryptionManager,
realtimeManager = realtimeManager,
dispatcherManager = FakeDispatcherManager(),
)
@BeforeEach
fun setup() {
mockkConstructor(MissingPropertyException::class)
every {
anyConstructed<MissingPropertyException>() == any<MissingPropertyException>()
} returns true
}
@Test
fun `isUnlockWithBiometricsEnabled should update based on AuthDiskSource`() {
assertFalse(authRepository.isUnlockWithBiometricsEnabled)
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = "biometricsKey")
assertTrue(authRepository.isUnlockWithBiometricsEnabled)
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = null)
assertFalse(authRepository.isUnlockWithBiometricsEnabled)
}
@Test
fun `isUnlockWithBiometricsEnabledFlow should react to changes in AuthDiskSource`() = runTest {
authRepository.isUnlockWithBiometricsEnabledFlow.test {
assertFalse(awaitItem())
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = "biometricsKey")
assertTrue(awaitItem())
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = null)
assertFalse(awaitItem())
}
}
@Test
fun `setupBiometricsKey on generateBiometricsKey error returns Error`() = runTest {
val error = Throwable("Fail!")
coEvery { authenticatorSdkSource.generateBiometricsKey() } returns error.asFailure()
val result = authRepository.setupBiometricsKey(cipher = CIPHER)
assertEquals(BiometricsKeyResult.Error(error = error), result)
coVerify(exactly = 1) {
authenticatorSdkSource.generateBiometricsKey()
}
}
@Test
fun `setupBiometricsKey on cipher encryption failure returns Error`() = runTest {
val biometricsKey = "biometricsKey"
val error = GeneralSecurityException("Fail!")
coEvery { authenticatorSdkSource.generateBiometricsKey() } returns biometricsKey.asSuccess()
every { CIPHER.doFinal(any()) } throws error
val result = authRepository.setupBiometricsKey(cipher = CIPHER)
assertEquals(BiometricsKeyResult.Error(error = error), result)
coVerify(exactly = 1) {
authenticatorSdkSource.generateBiometricsKey()
}
}
@Test
fun `setupBiometricsKey on cipher encryption success returns Success`() = runTest {
val biometricsKey = "biometricsKey"
val encryptedBytes = byteArrayOf(1, 1)
val iv = byteArrayOf(2, 2)
coEvery { authenticatorSdkSource.generateBiometricsKey() } returns biometricsKey.asSuccess()
every { CIPHER.doFinal(any()) } returns encryptedBytes
every { CIPHER.iv } returns iv
val result = authRepository.setupBiometricsKey(cipher = CIPHER)
assertEquals(BiometricsKeyResult.Success, result)
authDiskSource.assertUserBiometricUnlockKey(encryptedBytes.toString(Charsets.ISO_8859_1))
authDiskSource.assertUserBiometricKeyInitVector(iv)
coVerify(exactly = 1) {
authenticatorSdkSource.generateBiometricsKey()
}
}
@Test
fun `unlockWithBiometrics without stored biometrics key returns InvalidStateError`() = runTest {
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = null)
val result = authRepository.unlockWithBiometrics(cipher = CIPHER)
assertEquals(
BiometricsUnlockResult.InvalidStateError(
error = MissingPropertyException("Biometric key"),
),
result,
)
}
@Test
fun `unlockWithBiometrics with iv and decryption error returns BiometricDecodingError`() =
runTest {
val biometricsKey = "biometricsKey"
val initVector = byteArrayOf(1, 2)
val error = GeneralSecurityException("Fail!")
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = biometricsKey)
authDiskSource.userBiometricKeyInitVector = initVector
every { CIPHER.doFinal(any()) } throws error
val result = authRepository.unlockWithBiometrics(cipher = CIPHER)
assertEquals(BiometricsUnlockResult.BiometricDecodingError(error), result)
}
@Test
fun `unlockWithBiometrics with iv and decryption success returns Success`() = runTest {
val biometricsKey = "biometricsKey"
val initVector = byteArrayOf(1, 2)
val encryptedBytes = byteArrayOf(1, 1)
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = biometricsKey)
authDiskSource.userBiometricKeyInitVector = initVector
every { CIPHER.doFinal(any()) } returns encryptedBytes
val result = authRepository.unlockWithBiometrics(cipher = CIPHER)
assertEquals(BiometricsUnlockResult.Success, result)
}
@Test
fun `unlockWithBiometrics without iv and encryption failure returns BiometricDecodingError`() =
runTest {
val biometricsKey = "biometricsKey"
val error = GeneralSecurityException("Fail!")
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = biometricsKey)
authDiskSource.userBiometricKeyInitVector = null
every { CIPHER.doFinal(any()) } throws error
val result = authRepository.unlockWithBiometrics(cipher = CIPHER)
assertEquals(BiometricsUnlockResult.BiometricDecodingError(error), result)
}
@Test
fun `unlockWithBiometrics without iv and encryption success returns Success`() = runTest {
val biometricsKey = "biometricsKey"
val encryptedBytes = byteArrayOf(2, 2)
val initVector = byteArrayOf(3, 3)
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = biometricsKey)
authDiskSource.userBiometricKeyInitVector = null
every { CIPHER.doFinal(any()) } returns encryptedBytes
every { CIPHER.iv } returns initVector
val result = authRepository.unlockWithBiometrics(cipher = CIPHER)
assertEquals(BiometricsUnlockResult.Success, result)
authDiskSource.assertUserBiometricUnlockKey(encryptedBytes.toString(Charsets.ISO_8859_1))
authDiskSource.assertUserBiometricKeyInitVector(initVector)
}
@Test
fun `updateLastActiveTime should store the current elapsedRealtimeMs`() {
val elapsedMs = 1234L
every { realtimeManager.elapsedRealtimeMs } returns elapsedMs
authRepository.updateLastActiveTime()
authDiskSource.assertLastActiveTimeMillis(elapsedMs)
coVerify(exactly = 1) {
realtimeManager.elapsedRealtimeMs
}
}
}
private val CIPHER: Cipher = mockk()

View File

@ -1,11 +1,7 @@
package com.bitwarden.authenticator.data.platform.repository
import app.cash.turbine.test
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
import com.bitwarden.authenticator.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
@ -26,15 +22,9 @@ class SettingsRepositoryTest {
private val settingsDiskSource: SettingsDiskSource = mockk {
every { getAlertThresholdSeconds() } returns 7
}
private val authDiskSource: AuthDiskSource = FakeAuthDiskSource()
private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk()
private val authenticatorSdkSource: AuthenticatorSdkSource = mockk()
private val settingsRepository = SettingsRepositoryImpl(
settingsDiskSource = settingsDiskSource,
authDiskSource = authDiskSource,
biometricsEncryptionManager = biometricsEncryptionManager,
authenticatorSdkSource = authenticatorSdkSource,
dispatcherManager = FakeDispatcherManager(),
)
@ -165,19 +155,4 @@ class SettingsRepositoryTest {
settingsRepository.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3")
verify { settingsDiskSource.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3") }
}
@Test
fun `isUnlockWithBiometricsEnabledFlow should react to changes in AuthDiskSource`() = runTest {
settingsRepository.isUnlockWithBiometricsEnabledFlow.test {
assertFalse(awaitItem())
authDiskSource.storeUserBiometricUnlockKey(
biometricsKey = "biometricsKey",
)
assertTrue(awaitItem())
authDiskSource.storeUserBiometricUnlockKey(
biometricsKey = null,
)
assertFalse(awaitItem())
}
}
}

View File

@ -2,7 +2,6 @@ package com.bitwarden.authenticator.ui.platform.feature.rootnav
import app.cash.turbine.test
import com.bitwarden.authenticator.data.auth.repository.AuthRepository
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every
@ -20,15 +19,14 @@ class RootNavViewModelTest : BaseViewModelTest() {
private val mutableHasSeenWelcomeTutorialFlow = MutableStateFlow(false)
private val authRepository: AuthRepository = mockk {
every { updateLastActiveTime() } just runs
every { isUnlockWithBiometricsEnabled } returns false
every { clearBiometrics() } just runs
}
private val settingsRepository: SettingsRepository = mockk {
every { hasSeenWelcomeTutorial } returns false
every { hasSeenWelcomeTutorial = any() } just runs
every { hasSeenWelcomeTutorialFlow } returns mutableHasSeenWelcomeTutorialFlow
every { isUnlockWithBiometricsEnabled } returns false
every { clearBiometricsKey() } just runs
}
private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk()
@Test
fun `initialState should be correct when hasSeenWelcomeTutorial is false`() = runTest {
@ -70,8 +68,8 @@ class RootNavViewModelTest : BaseViewModelTest() {
@Test
@Suppress("MaxLineLength")
fun `on HasSeenWelcomeTutorialChange with true and biometrics enabled and valid should navigate to Locked`() {
every { settingsRepository.isUnlockWithBiometricsEnabled } returns true
every { biometricsEncryptionManager.isBiometricIntegrityValid() } returns true
every { authRepository.isUnlockWithBiometricsEnabled } returns true
every { authRepository.isBiometricIntegrityValid() } returns true
val viewModel = createViewModel()
viewModel.trySendAction(
@ -91,8 +89,8 @@ class RootNavViewModelTest : BaseViewModelTest() {
@Test
@Suppress("MaxLineLength")
fun `on HasSeenWelcomeTutorialChange with true and biometrics enabled but invalid should navigate to Unlocked`() {
every { settingsRepository.isUnlockWithBiometricsEnabled } returns true
every { biometricsEncryptionManager.isBiometricIntegrityValid() } returns false
every { authRepository.isUnlockWithBiometricsEnabled } returns true
every { authRepository.isBiometricIntegrityValid() } returns false
val viewModel = createViewModel()
viewModel.trySendAction(
@ -112,7 +110,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
@Test
@Suppress("MaxLineLength")
fun `on HasSeenWelcomeTutorialChange with true and biometrics disabled should navigate to Unlocked`() {
every { settingsRepository.isUnlockWithBiometricsEnabled } returns false
every { authRepository.isUnlockWithBiometricsEnabled } returns false
val viewModel = createViewModel()
viewModel.trySendAction(
@ -255,7 +253,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(false))
verify(exactly = 1) { settingsRepository.clearBiometricsKey() }
verify(exactly = 1) { authRepository.clearBiometrics() }
}
@Test
@ -264,40 +262,40 @@ class RootNavViewModelTest : BaseViewModelTest() {
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(true))
verify(exactly = 0) { settingsRepository.clearBiometricsKey() }
verify(exactly = 0) { authRepository.clearBiometrics() }
}
@Test
@Suppress("MaxLineLength")
fun `on BiometricSupportChanged with false when Locked should navigate to Unlocked`() = runTest {
every { settingsRepository.hasSeenWelcomeTutorial } returns true
every { settingsRepository.isUnlockWithBiometricsEnabled } returns true
every { biometricsEncryptionManager.isBiometricIntegrityValid() } returns true
mutableHasSeenWelcomeTutorialFlow.value = true
val viewModel = createViewModel()
fun `on BiometricSupportChanged with false when Locked should navigate to Unlocked`() =
runTest {
every { settingsRepository.hasSeenWelcomeTutorial } returns true
every { authRepository.isUnlockWithBiometricsEnabled } returns true
every { authRepository.isBiometricIntegrityValid() } returns true
mutableHasSeenWelcomeTutorialFlow.value = true
val viewModel = createViewModel()
// Verify initial state is Locked
assertEquals(
RootNavState(
hasSeenWelcomeGuide = true,
navState = RootNavState.NavState.Locked,
),
viewModel.stateFlow.value,
)
// Verify initial state is Locked
assertEquals(
RootNavState(
hasSeenWelcomeGuide = true,
navState = RootNavState.NavState.Locked,
),
viewModel.stateFlow.value,
)
// Send BiometricSupportChanged with false
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(false))
// Send BiometricSupportChanged with false
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(false))
// Should navigate to Unlocked and clear biometric key
assertEquals(
RootNavState(
hasSeenWelcomeGuide = true,
navState = RootNavState.NavState.Unlocked,
),
viewModel.stateFlow.value,
)
verify(exactly = 1) { settingsRepository.clearBiometricsKey() }
}
// Should navigate to Unlocked and clear biometric key
assertEquals(
RootNavState(
hasSeenWelcomeGuide = true,
navState = RootNavState.NavState.Unlocked,
),
viewModel.stateFlow.value,
)
verify(exactly = 1) { authRepository.clearBiometrics() }
}
@Test
@Suppress("MaxLineLength")
@ -327,14 +325,14 @@ class RootNavViewModelTest : BaseViewModelTest() {
),
viewModel.stateFlow.value,
)
verify(exactly = 1) { settingsRepository.clearBiometricsKey() }
verify(exactly = 1) { authRepository.clearBiometrics() }
}
@Test
@Suppress("MaxLineLength")
fun `hasSeenWelcomeTutorialFlow updates should trigger HasSeenWelcomeTutorialChange action`() =
runTest {
every { settingsRepository.isUnlockWithBiometricsEnabled } returns false
every { authRepository.isUnlockWithBiometricsEnabled } returns false
val viewModel = createViewModel()
viewModel.stateFlow.test {
@ -361,9 +359,8 @@ class RootNavViewModelTest : BaseViewModelTest() {
}
}
private fun createViewModel() = RootNavViewModel(
private fun createViewModel(): RootNavViewModel = RootNavViewModel(
authRepository = authRepository,
settingsRepository = settingsRepository,
biometricsEncryptionManager = biometricsEncryptionManager,
)
}

View File

@ -4,6 +4,7 @@ import android.os.Build
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.authenticator.BuildConfig
import com.bitwarden.authenticator.data.auth.repository.AuthRepository
import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository
import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState
import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled
@ -55,12 +56,15 @@ class SettingsViewModelTest : BaseViewModelTest() {
private val mutableScreenCaptureAllowedStateFlow = MutableStateFlow(false)
private val mutableIsDynamicColorsEnabledFlow = MutableStateFlow(false)
private val mutableIsUnlockWithBiometricsEnabledFlow = MutableStateFlow(true)
private val authRepository: AuthRepository = mockk {
every { isUnlockWithBiometricsEnabled } returns true
every { isUnlockWithBiometricsEnabledFlow } returns mutableIsUnlockWithBiometricsEnabledFlow
}
private val settingsRepository: SettingsRepository = mockk {
every { appLanguage } returns APP_LANGUAGE
every { appTheme } returns APP_THEME
every { defaultSaveOption } returns DEFAULT_SAVE_OPTION
every { defaultSaveOptionFlow } returns mutableDefaultSaveOptionFlow
every { isUnlockWithBiometricsEnabled } returns true
every { isCrashLoggingEnabled } returns true
every { isScreenCaptureAllowedStateFlow } returns mutableScreenCaptureAllowedStateFlow
every { isScreenCaptureAllowed } answers { mutableScreenCaptureAllowedStateFlow.value }
@ -68,7 +72,6 @@ class SettingsViewModelTest : BaseViewModelTest() {
every { isDynamicColorsEnabled } answers { mutableIsDynamicColorsEnabledFlow.value }
every { isDynamicColorsEnabled = any() } just runs
every { isDynamicColorsEnabledFlow } returns mutableIsDynamicColorsEnabledFlow
every { isUnlockWithBiometricsEnabledFlow } returns mutableIsUnlockWithBiometricsEnabledFlow
}
private val clipboardManager: BitwardenClipboardManager = mockk()
private val mutableSnackbarFlow = bufferedMutableSharedFlow<BitwardenSnackbarData>()
@ -303,9 +306,10 @@ class SettingsViewModelTest : BaseViewModelTest() {
private fun createViewModel(
savedState: SettingsState? = DEFAULT_STATE,
) = SettingsViewModel(
): SettingsViewModel = SettingsViewModel(
savedStateHandle = SavedStateHandle().apply { this["state"] = savedState },
clock = CLOCK,
authRepository = authRepository,
authenticatorBridgeManager = authenticatorBridgeManager,
authenticatorRepository = authenticatorRepository,
settingsRepository = settingsRepository,