diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt index 634fe82fab..4ffccb4cfc 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt @@ -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 } } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/SaveManualCodeButtons.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/SaveManualCodeButtons.kt index abdfac93eb..7516b63de3 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/SaveManualCodeButtons.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/SaveManualCodeButtons.kt @@ -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(), ) } } diff --git a/authenticator/src/main/res/values/strings.xml b/authenticator/src/main/res/values/strings.xml index 6bf18030ea..f51a2cea2a 100644 --- a/authenticator/src/main/res/values/strings.xml +++ b/authenticator/src/main/res/values/strings.xml @@ -17,7 +17,7 @@ Add Item Rotation Scan a QR code Enter a setup key - Scan QR code + Scan QR code Point your camera at the QR code. Cannot scan QR code. Enter key manually diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreenTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreenTest.kt index e22cb75b4a..b8997ec485 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreenTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreenTest.kt @@ -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() @@ -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(