Add support to prompt for biometrics setup (#817)

This commit is contained in:
David Perez 2024-01-27 18:40:24 -06:00 committed by Álison Fernandes
parent 0e9241d54c
commit 365e4e5dd9
5 changed files with 220 additions and 15 deletions

View file

@ -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(

View file

@ -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

View file

@ -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,
)
}

View file

@ -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)
}
}

View file

@ -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<AccountSecurityEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<AccountSecurityViewModel>(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