mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 00:06:22 -06:00
PM-29172: Update Authenticator biometric encryption
This commit is contained in:
parent
cd27fe339d
commit
3c88c69f92
@ -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)
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user