BWA-159: Update the ManualCodeEntryScreen to allow scrolling (#5287)

This commit is contained in:
David Perez
2025-05-30 08:58:23 -05:00
committed by GitHub
parent f7c1278805
commit 65d1a4f12a
4 changed files with 348 additions and 147 deletions

View File

@@ -2,7 +2,6 @@ package com.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.widget.Toast
import androidx.compose.foundation.layout.Column
@@ -10,8 +9,10 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -25,12 +26,15 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.authenticator.R
@@ -48,7 +52,9 @@ import com.bitwarden.authenticator.ui.platform.composition.LocalPermissionsManag
import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager
import com.bitwarden.authenticator.ui.platform.manager.permissions.PermissionsManager
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.toAnnotatedString
import com.bitwarden.ui.platform.base.util.annotatedStringResource
import com.bitwarden.ui.platform.base.util.spanStyleOf
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.resource.BitwardenDrawable
/**
@@ -81,7 +87,7 @@ fun ManualCodeEntryScreen(
when (event) {
is ManualCodeEntryEvent.NavigateToAppSettings -> {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.parse("package:" + context.packageName)
intent.data = "package:${context.packageName}".toUri()
intentManager.startActivity(intent = intent)
}
@@ -116,32 +122,12 @@ fun ManualCodeEntryScreen(
)
}
when (val dialog = state.dialog) {
is ManualCodeEntryState.DialogState.Error -> {
BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = dialog.title,
message = dialog.message,
),
onDismissRequest = remember(state) {
{ viewModel.trySendAction(ManualCodeEntryAction.DismissDialog) }
},
)
}
is ManualCodeEntryState.DialogState.Loading -> {
BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(
dialog.message,
),
)
}
null -> {
Unit
}
}
ManualCodeEntryDialogs(
dialog = state.dialog,
onDismissRequest = remember(state) {
{ viewModel.trySendAction(ManualCodeEntryAction.DismissDialog) }
},
)
BitwardenScaffold(
modifier = Modifier.fillMaxSize(),
@@ -157,92 +143,156 @@ fun ManualCodeEntryScreen(
)
},
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
Text(
text = stringResource(id = R.string.enter_key_manually),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.name),
value = state.issuer,
onValueChange = remember(viewModel) {
{
viewModel.trySendAction(
ManualCodeEntryAction.IssuerTextChange(it),
)
ManualCodeEntryContent(
state = state,
onNameChange = remember(viewModel) {
{ viewModel.trySendAction(ManualCodeEntryAction.IssuerTextChange(it)) }
},
onKeyChange = remember(viewModel) {
{ viewModel.trySendAction(ManualCodeEntryAction.CodeTextChange(it)) }
},
onSaveLocallyClick = remember(viewModel) {
{ viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) }
},
onSaveToBitwardenClick = remember(viewModel) {
{ viewModel.trySendAction(ManualCodeEntryAction.SaveToBitwardenClick) }
},
onScanQrCodeClick = remember(viewModel) {
{
if (permissionsManager.checkPermission(Manifest.permission.CAMERA)) {
viewModel.trySendAction(ManualCodeEntryAction.ScanQrCodeTextClick)
} else {
launcher.launch(Manifest.permission.CAMERA)
}
},
modifier = Modifier
.semantics { testTag = "NameTextField" }
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenPasswordField(
singleLine = false,
label = stringResource(id = R.string.key),
value = state.code,
onValueChange = remember(viewModel) {
{
viewModel.trySendAction(
ManualCodeEntryAction.CodeTextChange(it),
)
}
},
capitalization = KeyboardCapitalization.Characters,
modifier = Modifier
.semantics { testTag = "KeyTextField" }
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
SaveManualCodeButtons(
state = state.buttonState,
onSaveLocallyClick = remember(viewModel) {
{
viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick)
}
},
onSaveToBitwardenClick = remember(viewModel) {
{
viewModel.trySendAction(ManualCodeEntryAction.SaveToBitwardenClick)
}
},
)
Text(
text = stringResource(id = R.string.cannot_add_authenticator_key),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.padding(
vertical = 8.dp,
horizontal = 16.dp,
),
)
ClickableText(
text = stringResource(id = R.string.scan_qr_code).toAnnotatedString(),
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.primary,
),
modifier = Modifier
.padding(horizontal = 16.dp),
onClick = remember(viewModel) {
{
if (permissionsManager.checkPermission(Manifest.permission.CAMERA)) {
viewModel.trySendAction(ManualCodeEntryAction.ScanQrCodeTextClick)
} else {
launcher.launch(Manifest.permission.CAMERA)
}
}
},
)
}
}
},
modifier = Modifier.padding(paddingValues = paddingValues),
)
}
}
@Composable
private fun ManualCodeEntryContent(
state: ManualCodeEntryState,
onNameChange: (name: String) -> Unit,
onKeyChange: (key: String) -> Unit,
onSaveLocallyClick: () -> Unit,
onSaveToBitwardenClick: () -> Unit,
onScanQrCodeClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.verticalScroll(state = rememberScrollState())) {
Text(
text = stringResource(id = R.string.enter_key_manually),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenTextField(
label = stringResource(id = R.string.name),
value = state.issuer,
onValueChange = onNameChange,
modifier = Modifier
.testTag(tag = "NameTextField")
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenPasswordField(
singleLine = false,
label = stringResource(id = R.string.key),
value = state.code,
onValueChange = onKeyChange,
capitalization = KeyboardCapitalization.Characters,
modifier = Modifier
.testTag(tag = "KeyTextField")
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(16.dp))
SaveManualCodeButtons(
state = state.buttonState,
onSaveLocallyClick = onSaveLocallyClick,
onSaveToBitwardenClick = onSaveToBitwardenClick,
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
Text(
text = stringResource(id = R.string.cannot_add_authenticator_key),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
ScanQrCodeText(
onClick = onScanQrCodeClick,
modifier = Modifier.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Composable
private fun ScanQrCodeText(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val accessibilityString = stringResource(id = R.string.scan_qr_code)
Text(
text = annotatedStringResource(
id = R.string.scan_qr_code,
emphasisHighlightStyle = spanStyleOf(
color = MaterialTheme.colorScheme.primary,
textStyle = MaterialTheme.typography.bodyMedium,
),
onAnnotationClick = {
when (it) {
"scanQrCode" -> onClick()
}
},
),
modifier = modifier.semantics {
customActions = listOf(
CustomAccessibilityAction(
label = accessibilityString,
action = {
onClick()
true
},
),
)
},
)
}
@Composable
private fun ManualCodeEntryDialogs(
dialog: ManualCodeEntryState.DialogState?,
onDismissRequest: () -> Unit,
) {
when (val dialogString = dialog) {
is ManualCodeEntryState.DialogState.Error -> {
BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = dialogString.title,
message = dialogString.message,
),
onDismissRequest = onDismissRequest,
)
}
is ManualCodeEntryState.DialogState.Loading -> {
BitwardenLoadingDialog(visibilityState = LoadingDialogState.Shown(dialog.message))
}
null -> Unit
}
}

View File

@@ -2,13 +2,10 @@ package com.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.unit.dp
import com.bitwarden.authenticator.R
import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledTonalButton
@@ -20,60 +17,50 @@ import com.bitwarden.authenticator.ui.platform.components.button.BitwardenOutlin
* @param state State of the buttons to show.
* @param onSaveLocallyClick Callback invoked when the user clicks save locally.
* @param onSaveToBitwardenClick Callback invoked when the user clicks save to Bitwarden.
* @param modifier The modifier to be applied to the composable.
*/
@Composable
fun SaveManualCodeButtons(
state: ManualCodeEntryState.ButtonState,
onSaveLocallyClick: () -> Unit,
onSaveToBitwardenClick: () -> Unit,
modifier: Modifier = Modifier,
) {
when (state) {
ManualCodeEntryState.ButtonState.LocalOnly -> {
BitwardenFilledTonalButton(
label = stringResource(id = R.string.add_code),
onClick = onSaveLocallyClick,
modifier = Modifier
.semantics { testTag = "AddCodeButton" }
.fillMaxWidth()
.padding(horizontal = 16.dp),
modifier = modifier.testTag(tag = "AddCodeButton"),
)
}
ManualCodeEntryState.ButtonState.SaveLocallyPrimary -> {
Column {
Column(modifier = modifier) {
BitwardenFilledButton(
label = stringResource(id = R.string.save_here),
onClick = onSaveLocallyClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
modifier = Modifier.fillMaxWidth(),
)
BitwardenOutlinedButton(
label = stringResource(R.string.save_to_bitwarden),
onClick = onSaveToBitwardenClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
modifier = Modifier.fillMaxWidth(),
)
}
}
ManualCodeEntryState.ButtonState.SaveToBitwardenPrimary -> {
Column {
Column(modifier = modifier) {
BitwardenFilledButton(
label = stringResource(id = R.string.save_to_bitwarden),
onClick = onSaveToBitwardenClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
modifier = Modifier.fillMaxWidth(),
)
BitwardenOutlinedButton(
label = stringResource(R.string.save_here),
onClick = onSaveLocallyClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
modifier = Modifier.fillMaxWidth(),
)
}
}

View File

@@ -17,7 +17,7 @@
<string name="add_item_rotation">Add Item Rotation</string>
<string name="scan_a_qr_code">Scan a QR code</string>
<string name="enter_a_setup_key">Enter a setup key</string>
<string name="scan_qr_code">Scan QR code</string>
<string name="scan_qr_code"><annotation link="scanQrCode">Scan QR code</annotation></string>
<string name="point_your_camera_at_the_qr_code">Point your camera at the QR code.</string>
<string name="cannot_scan_qr_code">Cannot scan QR code.</string>
<string name="enter_key_manually">Enter key manually</string>

View File

@@ -1,12 +1,20 @@
package com.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
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 com.bitwarden.authenticator.ui.platform.base.AuthenticatorComposeTest
import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager
import com.bitwarden.authenticator.ui.platform.manager.permissions.FakePermissionManager
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.assertNoDialogExists
import com.bitwarden.ui.util.performCustomAccessibilityAction
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@@ -14,11 +22,15 @@ import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class ManualCodeEntryScreenTest : AuthenticatorComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToQrCodeScreenCalled = false
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mutableEventFlow = bufferedMutableSharedFlow<ManualCodeEntryEvent>()
@@ -28,7 +40,9 @@ class ManualCodeEntryScreenTest : AuthenticatorComposeTest() {
every { trySendAction(any()) } just runs
}
private val intentManager: IntentManager = mockk()
private val intentManager: IntentManager = mockk {
every { startActivity(intent = any()) } just runs
}
private val permissionsManager = FakePermissionManager()
@Before
@@ -38,34 +52,70 @@ class ManualCodeEntryScreenTest : AuthenticatorComposeTest() {
permissionsManager = permissionsManager,
) {
ManualCodeEntryScreen(
onNavigateBack = {},
onNavigateToQrCodeScreen = {},
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToQrCodeScreen = { onNavigateToQrCodeScreenCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `on NavigateBack should call onNavigateBack`() {
mutableEventFlow.tryEmit(ManualCodeEntryEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `on NavigateToQrCodeScreen should call onNavigateToQrCodeScreen`() {
mutableEventFlow.tryEmit(ManualCodeEntryEvent.NavigateToQrCodeScreen)
assertTrue(onNavigateToQrCodeScreenCalled)
}
@Test
fun `on NavigateToAppSettings should call intentManager`() {
mutableEventFlow.tryEmit(ManualCodeEntryEvent.NavigateToAppSettings)
verify(exactly = 1) {
intentManager.startActivity(intent = any())
}
}
@Test
fun `on Close click should emit `() {
composeTestRule
.onNodeWithContentDescription(label = "Close")
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(ManualCodeEntryAction.CloseClick)
}
}
@Test
fun `on Add code click should emit SaveLocallyClick`() {
composeTestRule
.onNodeWithText("Add code")
.performScrollTo()
.performClick()
// Make sure save to bitwaren isn't showing:
// Make sure save to bitwarden isn't showing:
composeTestRule
.onNodeWithText("Add code to Bitwarden")
.assertDoesNotExist()
composeTestRule
.onNodeWithText(text = "Save here")
.assertDoesNotExist()
verify { viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) }
}
@Test
fun `on Add code to Bitwarden click should emit SaveToBitwardenClick`() {
fun `on Save here click should emit SaveToBitwardenClick`() {
mutableStateFlow.update {
it.copy(buttonState = ManualCodeEntryState.ButtonState.SaveToBitwardenPrimary)
}
composeTestRule
.onNodeWithText("Save to Bitwarden")
.performScrollTo()
.performClick()
// Make sure locally only save isn't showing:
@@ -82,7 +132,7 @@ class ManualCodeEntryScreenTest : AuthenticatorComposeTest() {
}
@Test
fun `on Add code locally click should emit SaveLocallyClick`() {
fun `on Save here click should emit SaveLocallyClick`() {
mutableStateFlow.update {
it.copy(buttonState = ManualCodeEntryState.ButtonState.SaveLocallyPrimary)
}
@@ -102,6 +152,120 @@ class ManualCodeEntryScreenTest : AuthenticatorComposeTest() {
verify { viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) }
}
@Test
fun `on Scan QR code click with permission should emit ScanQrCodeTextClick`() {
permissionsManager.checkPermissionResult = true
composeTestRule
.onNodeWithText(text = "Scan QR code")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(ManualCodeEntryAction.ScanQrCodeTextClick)
}
}
@Suppress("MaxLineLength")
@Test
fun `on Scan QR code click without permission and permission is not granted should display dialog`() {
permissionsManager.checkPermissionResult = false
permissionsManager.getPermissionsResult = false
composeTestRule
.onNodeWithText(text = "Scan QR code")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText(text = "Enable camera permission to use the scanner")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "No thanks")
.assert(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule.assertNoDialogExists()
verify(exactly = 0) {
viewModel.trySendAction(any())
}
}
@Test
fun `on permission dialog Settings clock should emit SettingsClick`() {
permissionsManager.checkPermissionResult = false
permissionsManager.getPermissionsResult = false
composeTestRule
.onNodeWithText(text = "Scan QR code")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText(text = "Settings")
.assert(hasAnyAncestor(isDialog()))
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(ManualCodeEntryAction.SettingsClick)
}
}
@Suppress("MaxLineLength")
@Test
fun `on Scan QR code click without permission and permission is granted should emit ScanQrCodeTextClick`() {
permissionsManager.checkPermissionResult = false
permissionsManager.getPermissionsResult = true
composeTestRule
.onNodeWithText(text = "Scan QR code")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(ManualCodeEntryAction.ScanQrCodeTextClick)
}
}
@Suppress("MaxLineLength")
@Test
fun `on Scan QR code accessibility action without permission and permission is granted should emit ScanQrCodeTextClick`() {
permissionsManager.checkPermissionResult = false
permissionsManager.getPermissionsResult = true
composeTestRule
.onNodeWithText(text = "Scan QR code")
.performScrollTo()
.performCustomAccessibilityAction(label = "Scan QR code")
verify(exactly = 1) {
viewModel.trySendAction(ManualCodeEntryAction.ScanQrCodeTextClick)
}
}
@Test
fun `on dialog should updates according to state`() {
composeTestRule.assertNoDialogExists()
val loadingMessage = "Loading!"
mutableStateFlow.update {
it.copy(
dialog = ManualCodeEntryState.DialogState.Loading(
message = loadingMessage.asText(),
),
)
}
composeTestRule.onNodeWithText(text = loadingMessage).assert(hasAnyAncestor(isDialog()))
val errorMessage = "Error!"
mutableStateFlow.update {
it.copy(
dialog = ManualCodeEntryState.DialogState.Error(message = errorMessage.asText()),
)
}
composeTestRule.onNodeWithText(text = errorMessage).assert(hasAnyAncestor(isDialog()))
mutableStateFlow.update { it.copy(dialog = null) }
composeTestRule.assertNoDialogExists()
}
}
private val DEFAULT_STATE = ManualCodeEntryState(