mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
Add support to prompt for biometrics setup (#817)
This commit is contained in:
parent
0e9241d54c
commit
365e4e5dd9
5 changed files with 220 additions and 15 deletions
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue