diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManager.kt index c70fc33c5..308fafd82 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManager.kt @@ -35,4 +35,9 @@ interface BiometricsEncryptionManager { userId: String, cipher: Cipher?, ): Boolean + + /** + * Returns a boolean indicating whether the system reflects biometric availability. + */ + fun isAccountBiometricIntegrityValid(userId: String): Boolean } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManagerImpl.kt index 481cb7802..ebed03484 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManagerImpl.kt @@ -75,7 +75,7 @@ class BiometricsEncryptionManagerImpl( override fun isBiometricIntegrityValid(userId: String, cipher: Cipher?): Boolean = isSystemBiometricIntegrityValid(userId, cipher) && isAccountBiometricIntegrityValid(userId) - private fun isAccountBiometricIntegrityValid(userId: String): Boolean { + override fun isAccountBiometricIntegrityValid(userId: String): Boolean { val systemBioIntegrityState = settingsDiskSource .systemBiometricIntegritySource ?: return false diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt index c47785e46..83eace961 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -232,6 +233,15 @@ fun VaultUnlockScreen( .fillMaxWidth(), ) Spacer(modifier = Modifier.height(12.dp)) + } else if (state.showBiometricInvalidatedMessage) { + Text( + text = stringResource(R.string.account_biometric_invalidated), + textAlign = TextAlign.Start, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(12.dp)) } if (!state.hideInput) { BitwardenFilledButton( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt index 1a32de59f..20622ce9c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt @@ -74,6 +74,7 @@ class VaultUnlockViewModel @Inject constructor( isBiometricEnabled = isBiometricsEnabled, isBiometricsValid = isBiometricsValid, showAccountMenu = VaultUnlockArgs(savedStateHandle).unlockType == UnlockType.STANDARD, + showBiometricInvalidatedMessage = false, vaultUnlockType = vaultUnlockType, userId = userState.activeUserId, ) @@ -168,8 +169,13 @@ class VaultUnlockViewModel @Inject constructor( ), ) } else { - mutableStateFlow.update { it.copy(isBiometricsValid = false) } - // TODO BIT-2345 show failure message when user added a new fingerprint + mutableStateFlow.update { + it.copy( + isBiometricsValid = false, + showBiometricInvalidatedMessage = !biometricsEncryptionManager + .isAccountBiometricIntegrityValid(state.userId), + ) + } } } @@ -320,6 +326,7 @@ data class VaultUnlockState( val isBiometricsValid: Boolean, val isBiometricEnabled: Boolean, val showAccountMenu: Boolean, + val showBiometricInvalidatedMessage: Boolean, val vaultUnlockType: VaultUnlockType, val userId: String, ) : Parcelable { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/text/BitwardenPolicyWarning.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/text/BitwardenPolicyWarning.kt index 3bb5436fa..f00a64874 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/text/BitwardenPolicyWarning.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/text/BitwardenPolicyWarning.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp /** @@ -40,3 +41,11 @@ fun BitwardenPolicyWarningText( .padding(8.dp), ) } + +@Preview +@Composable +private fun BitwardenPolicyWarningText_preview() { + BitwardenPolicyWarningText( + text = "text", + ) +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt index c49c3617b..cd808483b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt @@ -440,6 +440,25 @@ class VaultUnlockScreenTest : BaseComposeTest() { } } + @Suppress("MaxLineLength") + @Test + fun `biometric invalidated message should display according to state`() { + mutableStateFlow.update { + it.copy( + isBiometricsValid = false, + showBiometricInvalidatedMessage = true, + ) + } + composeTestRule + .onNodeWithText("Biometric unlock for this account is disabled pending verification of master password.") + .assertIsDisplayed() + + mutableStateFlow.update { it.copy(showBiometricInvalidatedMessage = false) } + composeTestRule + .onNodeWithText("Biometric unlock for this account is disabled pending verification of master password.") + .assertDoesNotExist() + } + @Test fun `account button should update according to state`() { mutableStateFlow.update { it.copy(showAccountMenu = true) } @@ -509,6 +528,7 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState( isBiometricsValid = true, isBiometricEnabled = true, showAccountMenu = true, + showBiometricInvalidatedMessage = false, userId = ACTIVE_ACCOUNT_SUMMARY.userId, vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt index e99e0530b..555f70efb 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt @@ -309,15 +309,39 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { verify { encryptionManager.getOrCreateCipher(USER_ID) } } + @Suppress("MaxLineLength") @Test - fun `on BiometricsUnlockClick should disable isBiometricsValid when cipher is null`() { + fun `on BiometricsUnlockClick should disable isBiometricsValid and show message when cipher is null and integrity check returns false`() { val initialState = DEFAULT_STATE.copy(isBiometricsValid = true) val viewModel = createViewModel(state = initialState) every { encryptionManager.getOrCreateCipher(USER_ID) } returns null + every { encryptionManager.isAccountBiometricIntegrityValid(USER_ID) } returns false viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) assertEquals( - initialState.copy(isBiometricsValid = false), + initialState.copy( + isBiometricsValid = false, + showBiometricInvalidatedMessage = true, + ), + viewModel.stateFlow.value, + ) + verify { encryptionManager.getOrCreateCipher(USER_ID) } + } + + @Suppress("MaxLineLength") + @Test + fun `on BiometricsUnlockClick should disable isBiometricsValid and not show message when cipher is null and integrity check returns true`() { + val initialState = DEFAULT_STATE.copy(isBiometricsValid = true) + val viewModel = createViewModel(state = initialState) + every { encryptionManager.getOrCreateCipher(USER_ID) } returns null + every { encryptionManager.isAccountBiometricIntegrityValid(USER_ID) } returns true + + viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) + assertEquals( + initialState.copy( + isBiometricsValid = false, + showBiometricInvalidatedMessage = false, + ), viewModel.stateFlow.value, ) verify { encryptionManager.getOrCreateCipher(USER_ID) } @@ -890,6 +914,7 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState( isBiometricsValid = true, isBiometricEnabled = false, showAccountMenu = true, + showBiometricInvalidatedMessage = false, userId = USER_ID, vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, )