diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt index 4140b206b2..3fe3091341 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt @@ -49,8 +49,10 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTimePickerDialog +import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager +import com.x8bit.bitwarden.ui.platform.theme.LocalBiometricsManager import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography @@ -72,6 +74,7 @@ fun AccountSecurityScreen( onNavigateToDeleteAccount: () -> Unit, onNavigateToPendingRequests: () -> Unit, viewModel: AccountSecurityViewModel = hiltViewModel(), + biometricsManager: BiometricsManager = LocalBiometricsManager.current, intentManager: IntentManager = LocalIntentManager.current, permissionsManager: PermissionsManager = LocalPermissionsManager.current, ) { @@ -187,19 +190,12 @@ fun AccountSecurityScreen( .fillMaxWidth() .padding(horizontal = 16.dp), ) - BitwardenWideSwitch( - label = stringResource( - id = R.string.unlock_with, - stringResource(id = R.string.biometrics), - ), + UnlockWithBiometricsRow( isChecked = state.isUnlockWithBiometricsEnabled, - onCheckedChange = remember(viewModel) { - { - viewModel.trySendAction( - AccountSecurityAction.UnlockWithBiometricToggle(it), - ) - } + onBiometricToggle = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.UnlockWithBiometricToggle(it)) } }, + biometricsManager = biometricsManager, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), @@ -309,6 +305,41 @@ fun AccountSecurityScreen( } } +@Composable +private fun UnlockWithBiometricsRow( + isChecked: Boolean, + onBiometricToggle: (Boolean) -> Unit, + biometricsManager: BiometricsManager, + modifier: Modifier = Modifier, +) { + if (!biometricsManager.isBiometricsSupported) return + var showBiometricsPrompt by rememberSaveable { mutableStateOf(false) } + BitwardenWideSwitch( + modifier = modifier, + label = stringResource( + id = R.string.unlock_with, + stringResource(id = R.string.biometrics), + ), + 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) + } + }, + ) +} + @Suppress("LongMethod") @Composable private fun UnlockWithPinRow( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt index ccb4c82f01..d6b1cdffd8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt @@ -99,7 +99,7 @@ class AccountSecurityViewModel @Inject constructor( is AccountSecurityAction.VaultTimeoutActionSelect -> handleVaultTimeoutActionSelect(action) AccountSecurityAction.TwoStepLoginClick -> handleTwoStepLoginClick() is AccountSecurityAction.UnlockWithBiometricToggle -> { - handleUnlockWithBiometricToggled(action) + handleUnlockWithBiometricToggle(action) } is AccountSecurityAction.UnlockWithPinToggle -> handleUnlockWithPinToggle(action) @@ -229,7 +229,7 @@ class AccountSecurityViewModel @Inject constructor( sendEvent(AccountSecurityEvent.NavigateToTwoStepLogin(webSettingsUrl)) } - private fun handleUnlockWithBiometricToggled( + private fun handleUnlockWithBiometricToggle( action: AccountSecurityAction.UnlockWithBiometricToggle, ) { // TODO Display alert diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManager.kt index 6898c853e5..1c90f451c2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManager.kt @@ -8,4 +8,14 @@ interface BiometricsManager { * Returns `true` if the device supports string biometric authentication, `false` otherwise. */ val isBiometricsSupported: Boolean + + /** + * Display a prompt for biometrics. + */ + fun promptBiometrics( + onSuccess: () -> Unit, + onCancel: () -> Unit, + onLockOut: () -> Unit, + onError: () -> Unit, + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManagerImpl.kt index 6ac18e7845..31857b6144 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManagerImpl.kt @@ -3,6 +3,10 @@ package com.x8bit.bitwarden.ui.platform.manager.biometrics import android.app.Activity import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage /** @@ -14,6 +18,8 @@ class BiometricsManagerImpl( ) : BiometricsManager { private val biometricManager: BiometricManager = BiometricManager.from(activity) + private val fragmentActivity: FragmentActivity get() = activity as FragmentActivity + override val isBiometricsSupported: Boolean get() = when (biometricManager.canAuthenticate(Authenticators.BIOMETRIC_STRONG)) { BiometricManager.BIOMETRIC_SUCCESS -> true @@ -27,4 +33,57 @@ class BiometricsManagerImpl( else -> false } + + override fun promptBiometrics( + onSuccess: () -> Unit, + onCancel: () -> Unit, + onLockOut: () -> Unit, + onError: () -> Unit, + ) { + val biometricPrompt = BiometricPrompt( + fragmentActivity, + ContextCompat.getMainExecutor(fragmentActivity), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult, + ) = onSuccess() + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + when (errorCode) { + BiometricPrompt.ERROR_HW_UNAVAILABLE, + BiometricPrompt.ERROR_UNABLE_TO_PROCESS, + BiometricPrompt.ERROR_TIMEOUT, + BiometricPrompt.ERROR_NO_SPACE, + BiometricPrompt.ERROR_CANCELED, + BiometricPrompt.ERROR_VENDOR, + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_NO_BIOMETRICS, + BiometricPrompt.ERROR_HW_NOT_PRESENT, + BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL, + -> onError() + + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> onCancel() + + BiometricPrompt.ERROR_LOCKOUT, + BiometricPrompt.ERROR_LOCKOUT_PERMANENT, + -> onLockOut() + } + } + + override fun onAuthenticationFailed() { + // Just keep on keepin' on, if there is a real issue it + // will come from the onAuthenticationError callback. + } + }, + ) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(R.string.bitwarden)) + .setDescription(activity.getString(R.string.biometrics_direction)) + .setNegativeButtonText(activity.getString(R.string.cancel)) + .setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG) + .build() + + biometricPrompt.authenticate(promptInfo) + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt index 4b86f309cf..ab82d9aa8a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt @@ -22,6 +22,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager import com.x8bit.bitwarden.ui.util.assertNoDialogExists @@ -29,6 +30,7 @@ import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.runs +import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -49,6 +51,21 @@ class AccountSecurityScreenTest : BaseComposeTest() { every { startApplicationDetailsSettingsActivity() } just runs } private val permissionsManager = FakePermissionManager() + private val captureBiometricsSuccess = slot<() -> Unit>() + private val captureBiometricsCancel = slot<() -> Unit>() + private val captureBiometricsLockOut = slot<() -> Unit>() + private val captureBiometricsError = slot<() -> Unit>() + private val biometricsManager: BiometricsManager = mockk { + every { isBiometricsSupported } returns true + every { + promptBiometrics( + onSuccess = capture(captureBiometricsSuccess), + onCancel = capture(captureBiometricsCancel), + onLockOut = capture(captureBiometricsLockOut), + onError = capture(captureBiometricsError), + ) + } just runs + } private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val viewModel = mockk(relaxed = true) { @@ -64,6 +81,7 @@ class AccountSecurityScreenTest : BaseComposeTest() { onNavigateToDeleteAccount = { onNavigateToDeleteAccountCalled = true }, onNavigateToPendingRequests = { onNavigateToPendingRequestsCalled = true }, viewModel = viewModel, + biometricsManager = biometricsManager, intentManager = intentManager, permissionsManager = permissionsManager, ) @@ -290,12 +308,99 @@ class AccountSecurityScreenTest : BaseComposeTest() { } @Test - fun `on unlock with biometrics toggle should send UnlockWithBiometricToggle`() { + fun `on unlock with biometrics toggle should send UnlockWithBiometricToggle on success`() { + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .assertIsOff() composeTestRule .onNodeWithText("Unlock with Biometrics") .performScrollTo() .performClick() - verify { viewModel.trySendAction(AccountSecurityAction.UnlockWithBiometricToggle(true)) } + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .assertIsOn() + captureBiometricsSuccess.captured() + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .assertIsOff() + verify(exactly = 1) { + viewModel.trySendAction(AccountSecurityAction.UnlockWithBiometricToggle(true)) + } + } + + @Test + fun `on unlock with biometrics toggle should un-toggle on cancel`() { + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .assertIsOff() + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .performClick() + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .assertIsOn() + captureBiometricsCancel.captured() + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .assertIsOff() + verify(exactly = 0) { + viewModel.trySendAction(any()) + } + } + + @Test + fun `on unlock with biometrics toggle should un-toggle on error`() { + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .assertIsOff() + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .performClick() + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .assertIsOn() + captureBiometricsError.captured() + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .assertIsOff() + verify(exactly = 0) { + viewModel.trySendAction(any()) + } + } + + @Test + fun `on unlock with biometrics toggle should un-toggle on lock out`() { + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .assertIsOff() + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .performClick() + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .assertIsOn() + captureBiometricsLockOut.captured() + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .assertIsOff() + verify(exactly = 0) { + viewModel.trySendAction(any()) + } } @Test