mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 07:05:35 +03:00
BIT-470: Add support for fingerprint phrase dialog (#196)
This commit is contained in:
parent
d10e678bb3
commit
b94cbf4683
9 changed files with 202 additions and 11 deletions
|
@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
|
@ -26,19 +27,24 @@ import androidx.compose.ui.platform.LocalContext
|
|||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenExternalLinkRow
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionRow
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextRow
|
||||
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.theme.LocalNonMaterialColors
|
||||
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
|
||||
|
||||
/**
|
||||
* Displays the account security screen.
|
||||
|
@ -58,6 +64,10 @@ fun AccountSecurityScreen(
|
|||
when (event) {
|
||||
AccountSecurityEvent.NavigateBack -> onNavigateBack()
|
||||
|
||||
AccountSecurityEvent.NavigateToFingerprintPhrase -> {
|
||||
intentHandler.launchUri("http://bitwarden.com/help/fingerprint-phrase".toUri())
|
||||
}
|
||||
|
||||
is AccountSecurityEvent.ShowToast -> {
|
||||
Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
@ -74,6 +84,16 @@ fun AccountSecurityScreen(
|
|||
},
|
||||
)
|
||||
|
||||
AccountSecurityDialog.FingerprintPhrase -> FingerPrintPhraseDialog(
|
||||
fingerprintPhrase = state.fingerprintPhrase,
|
||||
onDismissRequest = remember(viewModel) {
|
||||
{ viewModel.trySendAction(AccountSecurityAction.DismissDialog) }
|
||||
},
|
||||
onLearnMore = remember(viewModel) {
|
||||
{ viewModel.trySendAction(AccountSecurityAction.FingerPrintLearnMoreClick) }
|
||||
},
|
||||
)
|
||||
|
||||
AccountSecurityDialog.SessionTimeoutAction -> SessionTimeoutActionDialog(
|
||||
selectedSessionTimeoutAction = state.sessionTimeoutAction,
|
||||
onDismissRequest = remember(viewModel) {
|
||||
|
@ -276,6 +296,55 @@ private fun ConfirmLogoutDialog(
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FingerPrintPhraseDialog(
|
||||
fingerprintPhrase: Text,
|
||||
onDismissRequest: () -> Unit,
|
||||
onLearnMore: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
dismissButton = {
|
||||
BitwardenTextButton(
|
||||
label = stringResource(id = R.string.close),
|
||||
onClick = onDismissRequest,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
BitwardenTextButton(
|
||||
label = stringResource(id = R.string.learn_more),
|
||||
onClick = onLearnMore,
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.fingerprint_phrase),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(id = R.string.your_accounts_fingerprint),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = fingerprintPhrase(),
|
||||
color = LocalNonMaterialColors.current.fingerprint,
|
||||
style = LocalNonMaterialTypography.current.fingerprint,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionTimeoutActionDialog(
|
||||
selectedSessionTimeoutAction: SessionTimeoutAction,
|
||||
|
|
|
@ -29,6 +29,7 @@ class AccountSecurityViewModel @Inject constructor(
|
|||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: AccountSecurityState(
|
||||
dialog = null,
|
||||
fingerprintPhrase = "fingerprint-placeholder".asText(),
|
||||
isApproveLoginRequestsEnabled = false,
|
||||
isUnlockWithBiometricsEnabled = false,
|
||||
isUnlockWithPinEnabled = false,
|
||||
|
@ -50,6 +51,7 @@ class AccountSecurityViewModel @Inject constructor(
|
|||
AccountSecurityAction.ConfirmLogoutClick -> handleConfirmLogoutClick()
|
||||
AccountSecurityAction.DeleteAccountClick -> handleDeleteAccountClick()
|
||||
AccountSecurityAction.DismissDialog -> handleDismissDialog()
|
||||
AccountSecurityAction.FingerPrintLearnMoreClick -> handleFingerPrintLearnMoreClick()
|
||||
AccountSecurityAction.LockNowClick -> handleLockNowClick()
|
||||
is AccountSecurityAction.LoginRequestToggle -> handleLoginRequestToggle(action)
|
||||
AccountSecurityAction.LogoutClick -> handleLogoutClick()
|
||||
|
@ -69,8 +71,7 @@ class AccountSecurityViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun handleAccountFingerprintPhraseClick() {
|
||||
// TODO BIT-470: Display fingerprint phrase
|
||||
sendEvent(AccountSecurityEvent.ShowToast("Display fingerprint phrase.".asText()))
|
||||
mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.FingerprintPhrase) }
|
||||
}
|
||||
|
||||
private fun handleBackClick() = sendEvent(AccountSecurityEvent.NavigateBack)
|
||||
|
@ -94,6 +95,10 @@ class AccountSecurityViewModel @Inject constructor(
|
|||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
}
|
||||
|
||||
private fun handleFingerPrintLearnMoreClick() {
|
||||
sendEvent(AccountSecurityEvent.NavigateToFingerprintPhrase)
|
||||
}
|
||||
|
||||
private fun handleLockNowClick() {
|
||||
// TODO BIT-467: Lock the app
|
||||
sendEvent(AccountSecurityEvent.ShowToast("Lock the app.".asText()))
|
||||
|
@ -162,6 +167,7 @@ class AccountSecurityViewModel @Inject constructor(
|
|||
@Parcelize
|
||||
data class AccountSecurityState(
|
||||
val dialog: AccountSecurityDialog?,
|
||||
val fingerprintPhrase: Text,
|
||||
val isApproveLoginRequestsEnabled: Boolean,
|
||||
val isUnlockWithBiometricsEnabled: Boolean,
|
||||
val isUnlockWithPinEnabled: Boolean,
|
||||
|
@ -179,6 +185,12 @@ sealed class AccountSecurityDialog : Parcelable {
|
|||
@Parcelize
|
||||
data object ConfirmLogout : AccountSecurityDialog()
|
||||
|
||||
/**
|
||||
* Allows the user to view their fingerprint phrase.
|
||||
*/
|
||||
@Parcelize
|
||||
data object FingerprintPhrase : AccountSecurityDialog()
|
||||
|
||||
/**
|
||||
* Allows the user to select a session timeout action.
|
||||
*/
|
||||
|
@ -203,6 +215,11 @@ sealed class AccountSecurityEvent {
|
|||
*/
|
||||
data object NavigateBack : AccountSecurityEvent()
|
||||
|
||||
/**
|
||||
* Navigate to fingerprint phrase information.
|
||||
*/
|
||||
data object NavigateToFingerprintPhrase : AccountSecurityEvent()
|
||||
|
||||
/**
|
||||
* Displays a toast with the given [Text].
|
||||
*/
|
||||
|
@ -246,6 +263,11 @@ sealed class AccountSecurityAction {
|
|||
*/
|
||||
data object DismissDialog : AccountSecurityAction()
|
||||
|
||||
/**
|
||||
* User clicked fingerprint phrase.
|
||||
*/
|
||||
data object FingerPrintLearnMoreClick : AccountSecurityAction()
|
||||
|
||||
/**
|
||||
* User clicked lock now.
|
||||
*/
|
||||
|
|
|
@ -61,7 +61,10 @@ fun BitwardenTheme(
|
|||
lightNonMaterialColors(context)
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalNonMaterialColors provides nonMaterialColors) {
|
||||
CompositionLocalProvider(
|
||||
LocalNonMaterialColors provides nonMaterialColors,
|
||||
LocalNonMaterialTypography provides nonMaterialTypography,
|
||||
) {
|
||||
// Set overall theme based on color scheme and typography settings
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
|
@ -147,6 +150,12 @@ private fun lightColorScheme(context: Context): ColorScheme =
|
|||
private fun Int.toColor(context: Context): Color =
|
||||
Color(context.getColor(this))
|
||||
|
||||
/**
|
||||
* Provides access to non material theme typography throughout the app.
|
||||
*/
|
||||
val LocalNonMaterialTypography: ProvidableCompositionLocal<NonMaterialTypography> =
|
||||
compositionLocalOf { nonMaterialTypography }
|
||||
|
||||
/**
|
||||
* Provides access to non material theme colors throughout the app.
|
||||
*/
|
||||
|
@ -154,25 +163,28 @@ val LocalNonMaterialColors: ProvidableCompositionLocal<NonMaterialColors> =
|
|||
compositionLocalOf {
|
||||
// Default value here will immediately be overridden in BitwardenTheme, similar
|
||||
// to how MaterialTheme works.
|
||||
NonMaterialColors(Color.Transparent, Color.Transparent)
|
||||
NonMaterialColors(Color.Transparent, Color.Transparent, Color.Transparent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Models colors that live outside of the Material Theme spec.
|
||||
*/
|
||||
data class NonMaterialColors(
|
||||
val fingerprint: Color,
|
||||
val passwordWeak: Color,
|
||||
val passwordStrong: Color,
|
||||
)
|
||||
|
||||
private fun lightNonMaterialColors(context: Context): NonMaterialColors =
|
||||
NonMaterialColors(
|
||||
fingerprint = R.color.light_fingerprint.toColor(context),
|
||||
passwordWeak = R.color.light_password_strength_weak.toColor(context),
|
||||
passwordStrong = R.color.light_password_strength_strong.toColor(context),
|
||||
)
|
||||
|
||||
private fun darkNonMaterialColors(context: Context): NonMaterialColors =
|
||||
NonMaterialColors(
|
||||
fingerprint = R.color.dark_fingerprint.toColor(context),
|
||||
passwordWeak = R.color.dark_password_strength_weak.toColor(context),
|
||||
passwordStrong = R.color.dark_password_strength_strong.toColor(context),
|
||||
)
|
||||
|
|
|
@ -192,3 +192,25 @@ val Typography: Typography = Typography(
|
|||
platformStyle = PlatformTextStyle(includeFontPadding = false),
|
||||
),
|
||||
)
|
||||
|
||||
val nonMaterialTypography: NonMaterialTypography = NonMaterialTypography(
|
||||
fingerprint = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
fontFamily = FontFamily(Font(R.font.roboto_regular_mono)),
|
||||
fontWeight = FontWeight.W400,
|
||||
letterSpacing = 0.5.sp,
|
||||
lineHeightStyle = LineHeightStyle(
|
||||
alignment = LineHeightStyle.Alignment.Center,
|
||||
trim = LineHeightStyle.Trim.None,
|
||||
),
|
||||
platformStyle = PlatformTextStyle(includeFontPadding = false),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Models typography that live outside of the Material Theme spec.
|
||||
*/
|
||||
data class NonMaterialTypography(
|
||||
val fingerprint: TextStyle,
|
||||
)
|
||||
|
|
BIN
app/src/main/res/font/roboto_regular_mono.ttf
Normal file
BIN
app/src/main/res/font/roboto_regular_mono.ttf
Normal file
Binary file not shown.
|
@ -104,6 +104,8 @@
|
|||
<color name="gray">@color/grey_738182</color>
|
||||
<color name="light_gray">@color/grey_EFEFF4</color>
|
||||
<color name="white">@color/white_FFFFFF</color>
|
||||
<color name="light_fingerprint">@color/magenta_C01176</color>
|
||||
<color name="dark_fingerprint">@color/magenta_F08DC7</color>
|
||||
<color name="light_password_strength_weak">@color/orange_B27400</color>
|
||||
<color name="dark_password_strength_weak">@color/orange_C9914F</color>
|
||||
<color name="light_password_strength_strong">@color/green_009A38</color>
|
||||
|
|
|
@ -66,5 +66,7 @@
|
|||
<color name="orange_C9914F">#FFC9914F</color>
|
||||
<color name="green_009A38">#FF009A38</color>
|
||||
<color name="green_41B06D">#FF41B06D</color>
|
||||
<color name="magenta_F08DC7">#FFF08DC7</color>
|
||||
<color name="magenta_C01176">#FFC01176</color>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -14,6 +14,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
|
|||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.core.net.toUri
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
|
@ -257,9 +258,60 @@ class AccountSecurityScreenTest : BaseComposeTest() {
|
|||
verify { viewModel.trySendAction(AccountSecurityAction.DismissDialog) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fingerprint phrase dialog should be shown or hidden according to the state`() {
|
||||
composeTestRule.onNode(isDialog()).assertDoesNotExist()
|
||||
mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.FingerprintPhrase) }
|
||||
composeTestRule
|
||||
.onNodeWithText("Fingerprint phrase")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Learn more")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Close")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("fingerprint-placeholder")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on close click should send DismissDialog`() {
|
||||
mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.FingerprintPhrase) }
|
||||
composeTestRule
|
||||
.onNodeWithText("Close")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
verify { viewModel.trySendAction(AccountSecurityAction.DismissDialog) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on learn more click should send FingerPrintLearnMoreClick`() {
|
||||
mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.FingerprintPhrase) }
|
||||
composeTestRule
|
||||
.onNodeWithText("Learn more")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
verify { viewModel.trySendAction(AccountSecurityAction.FingerPrintLearnMoreClick) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on NavigateToFingerprintPhrase should call launchUri on intentHandler`() {
|
||||
mutableEventFlow.tryEmit(AccountSecurityEvent.NavigateToFingerprintPhrase)
|
||||
verify {
|
||||
intentHandler.launchUri("http://bitwarden.com/help/fingerprint-phrase".toUri())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEFAULT_STATE = AccountSecurityState(
|
||||
dialog = null,
|
||||
fingerprintPhrase = "fingerprint-placeholder".asText(),
|
||||
isApproveLoginRequestsEnabled = false,
|
||||
isUnlockWithBiometricsEnabled = false,
|
||||
isUnlockWithPinEnabled = false,
|
||||
|
|
|
@ -22,14 +22,21 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `on AccountFingerprintPhraseClick should emit ShowToast`() = runTest {
|
||||
fun `on AccountFingerprintPhraseClick should show the fingerprint phrase dialog`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(AccountSecurityAction.AccountFingerprintPhraseClick)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(dialog = AccountSecurityDialog.FingerprintPhrase),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on FingerPrintLearnMoreClick should emit NavigateToFingerprintPhrase`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(AccountSecurityAction.AccountFingerprintPhraseClick)
|
||||
assertEquals(
|
||||
AccountSecurityEvent.ShowToast("Display fingerprint phrase.".asText()),
|
||||
awaitItem(),
|
||||
)
|
||||
viewModel.trySendAction(AccountSecurityAction.FingerPrintLearnMoreClick)
|
||||
assertEquals(AccountSecurityEvent.NavigateToFingerprintPhrase, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -226,7 +233,9 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
|
|||
|
||||
private fun createViewModel(
|
||||
authRepository: AuthRepository = mockk(relaxed = true),
|
||||
savedStateHandle: SavedStateHandle = SavedStateHandle(),
|
||||
savedStateHandle: SavedStateHandle = SavedStateHandle().apply {
|
||||
set("state", DEFAULT_STATE)
|
||||
},
|
||||
): AccountSecurityViewModel = AccountSecurityViewModel(
|
||||
authRepository = authRepository,
|
||||
savedStateHandle = savedStateHandle,
|
||||
|
@ -235,6 +244,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
|
|||
companion object {
|
||||
private val DEFAULT_STATE = AccountSecurityState(
|
||||
dialog = null,
|
||||
fingerprintPhrase = "fingerprint-placeholder".asText(),
|
||||
isApproveLoginRequestsEnabled = false,
|
||||
isUnlockWithBiometricsEnabled = false,
|
||||
isUnlockWithPinEnabled = false,
|
||||
|
|
Loading…
Reference in a new issue