BIT-470: Add support for fingerprint phrase dialog (#196)

This commit is contained in:
David Perez 2023-11-03 09:41:38 -05:00 committed by Álison Fernandes
parent d10e678bb3
commit b94cbf4683
9 changed files with 202 additions and 11 deletions

View file

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

View file

@ -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.
*/

View file

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

View file

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

Binary file not shown.

View file

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

View file

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

View file

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

View file

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