From a9068b69372657e52fa508684f934ff8c2ab7153 Mon Sep 17 00:00:00 2001 From: David Perez Date: Mon, 6 Nov 2023 16:00:28 -0600 Subject: [PATCH] BIT-927: Add auto-fill UI (#208) --- .../base/util/PaddingValuesExtensions.kt | 48 ++++ .../platform/components/BitwardenTextRow.kt | 23 +- .../components/BitwardenWideSwitch.kt | 23 +- .../settings/autofill/AutoFillScreen.kt | 154 +++++++++++- .../settings/autofill/AutoFillViewModel.kt | 176 +++++++++++++- .../VaultUnlockedNavBarScreen.kt | 10 +- .../settings/autofill/AutoFillScreenTest.kt | 229 ++++++++++++++++-- .../autofill/AutoFillViewModelTest.kt | 130 +++++++++- 8 files changed, 754 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/PaddingValuesExtensions.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/PaddingValuesExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/PaddingValuesExtensions.kt new file mode 100644 index 0000000000..72cc6bf164 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/PaddingValuesExtensions.kt @@ -0,0 +1,48 @@ +package com.x8bit.bitwarden.ui.platform.base.util + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection + +/** + * Compares the top, bottom, start, and end values to another [PaddingValues] and returns a new + * 'PaddingValues' using the maximum values of each property respectively. + * + * @param other The other values to compare against. + */ +fun PaddingValues.max( + other: PaddingValues, + direction: LayoutDirection, +): PaddingValues = PaddingValues( + top = maxOf(calculateTopPadding(), other.calculateTopPadding()), + bottom = maxOf(calculateBottomPadding(), other.calculateBottomPadding()), + start = maxOf(calculateStartPadding(direction), other.calculateStartPadding(direction)), + end = maxOf(calculateEndPadding(direction), other.calculateEndPadding(direction)), +) + +/** + * Compares the top, bottom, start, and end values to a [WindowInsets] and returns a new + * 'PaddingValues' using the maximum values of each property respectively. + * + * @param windowInsets The [WindowInsets] to compare against. + */ +@Composable +fun PaddingValues.max( + windowInsets: WindowInsets, +): PaddingValues = max(windowInsets.asPaddingValues()) + +/** + * Compares the top, bottom, start, and end values to another [PaddingValues] and returns a new + * 'PaddingValues' using the maximum values of each property respectively. + * + * @param other The other [PaddingValues] to compare against. + */ +@Composable +fun PaddingValues.max( + other: PaddingValues, +): PaddingValues = max(other, LocalLayoutDirection.current) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextRow.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextRow.kt index 5292ad6b8f..48d514f561 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextRow.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextRow.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth @@ -26,6 +27,7 @@ import androidx.compose.ui.unit.dp * @param text The label for the row as a [String]. * @param onClick The callback when the row is clicked. * @param modifier The modifier to be applied to the layout. + * @param description An optional description label to be displayed below the [text]. * @param withDivider Indicates if a divider should be drawn on the bottom of the row, defaults * to `false`. * @param content The content of the [BitwardenTextRow]. @@ -35,6 +37,7 @@ fun BitwardenTextRow( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, + description: String? = null, withDivider: Boolean = false, content: (@Composable () -> Unit)? = null, ) { @@ -56,14 +59,24 @@ fun BitwardenTextRow( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text( + Column( modifier = Modifier .padding(end = 16.dp) .weight(1f), - text = text, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } content?.invoke() } if (withDivider) { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenWideSwitch.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenWideSwitch.kt index b2deeec0f7..7a538dd204 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenWideSwitch.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenWideSwitch.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.platform.components import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -32,6 +33,7 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme * @param isChecked The current state of the switch (either checked or unchecked). * @param onCheckedChange A lambda that is invoked when the switch's state changes. * @param modifier A [Modifier] that you can use to apply custom modifications to the composable. + * @param description An optional description label to be displayed below the [label]. * @param contentDescription A description of the switch's UI for accessibility purposes. */ @Composable @@ -40,6 +42,7 @@ fun BitwardenWideSwitch( isChecked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, modifier: Modifier = Modifier, + description: String? = null, contentDescription: String? = null, ) { Row( @@ -58,14 +61,24 @@ fun BitwardenWideSwitch( } .then(modifier), ) { - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + Column( modifier = Modifier .weight(1f) .padding(vertical = 8.dp), - ) + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } Spacer(modifier = Modifier.width(16.dp)) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt index 6712f952e4..7e5b6513e3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt @@ -1,41 +1,66 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.autofill +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +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.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +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.hilt.navigation.compose.hiltViewModel import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +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.BitwardenTextRow import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch /** * Displays the auto-fill screen. */ +@Suppress("LongMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable fun AutoFillScreen( onNavigateBack: () -> Unit, viewModel: AutoFillViewModel = hiltViewModel(), ) { + val state by viewModel.stateFlow.collectAsState() + val context = LocalContext.current + val resources = context.resources EventsEffect(viewModel = viewModel) { event -> when (event) { AutoFillEvent.NavigateBack -> onNavigateBack.invoke() + + is AutoFillEvent.ShowToast -> { + Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show() + } } } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) Scaffold( modifier = Modifier @@ -60,7 +85,134 @@ fun AutoFillScreen( .background(color = MaterialTheme.colorScheme.surface) .verticalScroll(rememberScrollState()), ) { - // TODO: BIT-927 Display auto-fill UI + BitwardenListHeaderText( + label = stringResource(id = R.string.autofill), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + BitwardenWideSwitch( + label = stringResource(id = R.string.autofill_services), + description = stringResource(id = R.string.autofill_services_explanation_long), + isChecked = state.isAutoFillServicesEnabled, + onCheckedChange = remember(viewModel) { + { viewModel.trySendAction(AutoFillAction.AutoFillServicesClick(it)) } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + BitwardenWideSwitch( + label = stringResource(id = R.string.inline_autofill), + description = stringResource(id = R.string.use_inline_autofill_explanation_long), + isChecked = state.isUseInlineAutoFillEnabled, + onCheckedChange = remember(viewModel) { + { viewModel.trySendAction(AutoFillAction.UseInlineAutofillClick(it)) } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + BitwardenWideSwitch( + label = stringResource(id = R.string.accessibility), + description = stringResource(id = R.string.accessibility_description4), + isChecked = state.isUseAccessibilityEnabled, + onCheckedChange = remember(viewModel) { + { viewModel.trySendAction(AutoFillAction.UseAccessibilityClick(it)) } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + BitwardenWideSwitch( + label = stringResource(id = R.string.draw_over), + description = stringResource(id = R.string.draw_over_description3), + isChecked = state.isUseDrawOverEnabled, + onCheckedChange = remember(viewModel) { + { viewModel.trySendAction(AutoFillAction.UseDrawOverClick(it)) } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + BitwardenListHeaderText( + label = stringResource(id = R.string.additional_options), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + BitwardenWideSwitch( + label = stringResource(id = R.string.copy_totp_automatically), + description = stringResource(id = R.string.copy_totp_automatically_description), + isChecked = state.isCopyTotpAutomaticallyEnabled, + onCheckedChange = remember(viewModel) { + { viewModel.trySendAction(AutoFillAction.CopyTotpAutomaticallyClick(it)) } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + BitwardenWideSwitch( + label = stringResource(id = R.string.ask_to_add_login), + description = stringResource(id = R.string.ask_to_add_login_description), + isChecked = state.isAskToAddLoginEnabled, + onCheckedChange = remember(viewModel) { + { viewModel.trySendAction(AutoFillAction.AskToAddLoginClick(it)) } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + UriMatchDetectionDialog( + selectedUriDetection = state.uriDetectionMethod, + onDetectionSelect = remember(viewModel) { + { viewModel.trySendAction(AutoFillAction.UriDetectionMethodSelect(it)) } + }, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun UriMatchDetectionDialog( + selectedUriDetection: AutoFillState.UriDetectionMethod, + onDetectionSelect: (AutoFillState.UriDetectionMethod) -> Unit, +) { + var shouldShowDialog by remember { mutableStateOf(false) } + + BitwardenTextRow( + text = stringResource(id = R.string.default_uri_match_detection), + description = stringResource(id = R.string.default_uri_match_detection_description), + onClick = { shouldShowDialog = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = selectedUriDetection.text(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (shouldShowDialog) { + BitwardenSelectionDialog( + title = stringResource(id = R.string.default_uri_match_detection), + onDismissRequest = { shouldShowDialog = false }, + ) { + AutoFillState.UriDetectionMethod.values().forEach { option -> + BitwardenSelectionRow( + text = option.text, + isSelected = option == selectedUriDetection, + onClick = { + shouldShowDialog = false + onDetectionSelect( + AutoFillState.UriDetectionMethod.values().first { it == option }, + ) + }, + ) + } } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt index 76fa9e0e18..c0db93fe62 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt @@ -1,18 +1,132 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.autofill +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize import javax.inject.Inject +private const val KEY_STATE = "state" + /** * View model for the auto-fill screen. */ +@Suppress("TooManyFunctions") @HiltViewModel -class AutoFillViewModel @Inject constructor() : BaseViewModel( - initialState = Unit, +class AutoFillViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] + ?: AutoFillState( + isAskToAddLoginEnabled = false, + isAutoFillServicesEnabled = false, + isCopyTotpAutomaticallyEnabled = false, + isUseAccessibilityEnabled = false, + isUseDrawOverEnabled = false, + isUseInlineAutoFillEnabled = false, + uriDetectionMethod = AutoFillState.UriDetectionMethod.DEFAULT, + ), ) { + + init { + stateFlow + .onEach { savedStateHandle[KEY_STATE] = it } + .launchIn(viewModelScope) + } + override fun handleAction(action: AutoFillAction): Unit = when (action) { - AutoFillAction.BackClick -> sendEvent(AutoFillEvent.NavigateBack) + is AutoFillAction.AskToAddLoginClick -> handleAskToAddLoginClick(action) + is AutoFillAction.AutoFillServicesClick -> handleAutoFillServicesClick(action) + AutoFillAction.BackClick -> handleBackClick() + is AutoFillAction.CopyTotpAutomaticallyClick -> handleCopyTotpAutomaticallyClick(action) + is AutoFillAction.UriDetectionMethodSelect -> handleUriDetectionMethodSelect(action) + is AutoFillAction.UseAccessibilityClick -> handleUseAccessibilityClick(action) + is AutoFillAction.UseDrawOverClick -> handleUseDrawOverClick(action) + is AutoFillAction.UseInlineAutofillClick -> handleUseInlineAutofillClick(action) + } + + private fun handleAskToAddLoginClick(action: AutoFillAction.AskToAddLoginClick) { + // TODO BIT-1092: Persist selection + sendEvent(AutoFillEvent.ShowToast("Not yet implemented.".asText())) + mutableStateFlow.update { it.copy(isAskToAddLoginEnabled = action.isEnabled) } + } + + private fun handleAutoFillServicesClick(action: AutoFillAction.AutoFillServicesClick) { + // TODO BIT-828: Persist selection + sendEvent(AutoFillEvent.ShowToast("Not yet implemented.".asText())) + mutableStateFlow.update { it.copy(isAutoFillServicesEnabled = action.isEnabled) } + } + + private fun handleBackClick() { + sendEvent(AutoFillEvent.NavigateBack) + } + + private fun handleCopyTotpAutomaticallyClick( + action: AutoFillAction.CopyTotpAutomaticallyClick, + ) { + // TODO BIT-1093: Persist selection + sendEvent(AutoFillEvent.ShowToast("Not yet implemented.".asText())) + mutableStateFlow.update { it.copy(isCopyTotpAutomaticallyEnabled = action.isEnabled) } + } + + private fun handleUseAccessibilityClick(action: AutoFillAction.UseAccessibilityClick) { + // TODO BIT-843: Persist selection + sendEvent(AutoFillEvent.ShowToast("Not yet implemented.".asText())) + mutableStateFlow.update { it.copy(isUseAccessibilityEnabled = action.isEnabled) } + } + + private fun handleUseDrawOverClick(action: AutoFillAction.UseDrawOverClick) { + // TODO BIT-835: Persist selection + sendEvent(AutoFillEvent.ShowToast("Not yet implemented.".asText())) + mutableStateFlow.update { it.copy(isUseDrawOverEnabled = action.isEnabled) } + } + + private fun handleUseInlineAutofillClick(action: AutoFillAction.UseInlineAutofillClick) { + // TODO BIT-833: Persist selection + sendEvent(AutoFillEvent.ShowToast("Not yet implemented.".asText())) + mutableStateFlow.update { it.copy(isUseInlineAutoFillEnabled = action.isEnabled) } + } + + private fun handleUriDetectionMethodSelect(action: AutoFillAction.UriDetectionMethodSelect) { + // TODO BIT-1094: Persist selection + sendEvent(AutoFillEvent.ShowToast("Not yet implemented.".asText())) + mutableStateFlow.update { + it.copy(uriDetectionMethod = action.uriDetectionMethod) + } + } +} + +/** + * Models state for the Auto-fill screen. + */ +@Parcelize +data class AutoFillState( + val isAskToAddLoginEnabled: Boolean, + val isAutoFillServicesEnabled: Boolean, + val isCopyTotpAutomaticallyEnabled: Boolean, + val isUseAccessibilityEnabled: Boolean, + val isUseDrawOverEnabled: Boolean, + val isUseInlineAutoFillEnabled: Boolean, + val uriDetectionMethod: UriDetectionMethod, +) : Parcelable { + /** + * A representation of the URI detection methods. + */ + enum class UriDetectionMethod(val text: Text) { + DEFAULT(text = R.string.default_text.asText()), + BASE_DOMAIN(text = R.string.base_domain.asText()), + STARTS_WITH(text = R.string.starts_with.asText()), + REGULAR_EXPRESSION(text = R.string.reg_ex.asText()), + EXACT(text = R.string.exact.asText()), + NEVER(text = R.string.never.asText()), } } @@ -24,14 +138,70 @@ sealed class AutoFillEvent { * Navigate back. */ data object NavigateBack : AutoFillEvent() + + /** + * Displays a toast with the given [Text]. + */ + data class ShowToast( + val text: Text, + ) : AutoFillEvent() } /** * Models actions for the auto-fill screen. */ sealed class AutoFillAction { + /** + * User clicked ask to add login button. + */ + data class AskToAddLoginClick( + val isEnabled: Boolean, + ) : AutoFillAction() + + /** + * User clicked auto-fill services button. + */ + data class AutoFillServicesClick( + val isEnabled: Boolean, + ) : AutoFillAction() + /** * User clicked back button. */ data object BackClick : AutoFillAction() + + /** + * User clicked copy TOTP automatically button. + */ + data class CopyTotpAutomaticallyClick( + val isEnabled: Boolean, + ) : AutoFillAction() + + /** + * User selected a [AutoFillState.UriDetectionMethod]. + */ + data class UriDetectionMethodSelect( + val uriDetectionMethod: AutoFillState.UriDetectionMethod, + ) : AutoFillAction() + + /** + * User clicked use accessibility button. + */ + data class UseAccessibilityClick( + val isEnabled: Boolean, + ) : AutoFillAction() + + /** + * User clicked use draw-over button. + */ + data class UseDrawOverClick( + val isEnabled: Boolean, + ) : AutoFillAction() + + /** + * User clicked use inline autofill button. + */ + data class UseInlineAutofillClick( + val isEnabled: Boolean, + ) : AutoFillAction() } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index d6b95fd084..bb75ebd166 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -4,6 +4,7 @@ import android.os.Parcelable import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars @@ -31,6 +32,7 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.base.util.max import com.x8bit.bitwarden.ui.platform.feature.settings.SETTINGS_GRAPH_ROUTE import com.x8bit.bitwarden.ui.platform.feature.settings.navigateToSettingsGraph import com.x8bit.bitwarden.ui.platform.feature.settings.settingsGraph @@ -171,14 +173,16 @@ private fun VaultUnlockedNavBarScaffold( } }, ) { innerPadding -> - // This NavHost will consume the navigation bars insets since the bottom bar - // is handling them and we do not want the child to receive them. + // Because this Scaffold has a bottom navigation bar, the NavHost will: + // - consume the navigation bar insets. + // - consume the IME insets. NavHost( navController = navController, startDestination = VAULT_ROUTE, modifier = Modifier .consumeWindowInsets(WindowInsets.navigationBars) - .padding(innerPadding), + .consumeWindowInsets(WindowInsets.ime) + .padding(innerPadding.max(WindowInsets.ime)), enterTransition = RootTransitionProviders.Enter.fadeIn, exitTransition = RootTransitionProviders.Exit.fadeOut, popEnterTransition = RootTransitionProviders.Enter.fadeIn, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt index 66251a41df..ed9c18b628 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt @@ -1,46 +1,233 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.autofill +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithText 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.x8bit.bitwarden.ui.platform.base.BaseComposeTest import io.mockk.every import io.mockk.mockk import io.mockk.verify -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test class AutoFillScreenTest : BaseComposeTest() { - @Test - fun `on back click should send BackClick`() { - val viewModel: AutoFillViewModel = mockk { - every { eventFlow } returns emptyFlow() - every { trySendAction(AutoFillAction.BackClick) } returns Unit - } + private var onNavigateBackCalled = false + + private val mutableEventFlow = MutableSharedFlow( + extraBufferCapacity = Int.MAX_VALUE, + ) + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + every { stateFlow } returns mutableStateFlow + } + + @Before + fun setUp() { composeTestRule.setContent { AutoFillScreen( + onNavigateBack = { onNavigateBackCalled = true }, viewModel = viewModel, - onNavigateBack = { }, ) } + } + + @Test + fun `on auto fill services toggle should send AutoFillServicesClick`() { + composeTestRule + .onNodeWithText("Auto-fill services") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(AutoFillAction.AutoFillServicesClick(true)) } + } + + @Test + fun `auto fill services should be toggled on or off according to state`() { + composeTestRule + .onNodeWithText("Auto-fill services") + .performScrollTo() + .assertIsOff() + mutableStateFlow.update { it.copy(isAutoFillServicesEnabled = true) } + composeTestRule + .onNodeWithText("Auto-fill services") + .performScrollTo() + .assertIsOn() + } + + @Test + fun `on use inline auto fill toggle should send UseInlineAutofillClick`() { + composeTestRule + .onNodeWithText("Use inline autofill") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(AutoFillAction.UseInlineAutofillClick(true)) } + } + + @Test + fun `use inline autofill should be toggled on or off according to state`() { + composeTestRule + .onNodeWithText("Use inline autofill") + .performScrollTo() + .assertIsOff() + mutableStateFlow.update { it.copy(isUseInlineAutoFillEnabled = true) } + composeTestRule + .onNodeWithText("Use inline autofill") + .performScrollTo() + .assertIsOn() + } + + @Test + fun `on use accessibility toggle should send UseAccessibilityClick`() { + composeTestRule + .onNodeWithText("Use accessibility") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(AutoFillAction.UseAccessibilityClick(true)) } + } + + @Test + fun `use accessibility should be toggled on or off according to state`() { + composeTestRule + .onNodeWithText("Use accessibility") + .performScrollTo() + .assertIsOff() + mutableStateFlow.update { it.copy(isUseAccessibilityEnabled = true) } + composeTestRule + .onNodeWithText("Use accessibility") + .performScrollTo() + .assertIsOn() + } + + @Test + fun `on use draw over toggle should send UseDrawOverClick`() { + composeTestRule + .onNodeWithText("Use draw-over") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(AutoFillAction.UseDrawOverClick(true)) } + } + + @Test + fun `use draw-over should be toggled on or off according to state`() { + composeTestRule + .onNodeWithText("Use draw-over") + .performScrollTo() + .assertIsOff() + mutableStateFlow.update { it.copy(isUseDrawOverEnabled = true) } + composeTestRule + .onNodeWithText("Use draw-over") + .performScrollTo() + .assertIsOn() + } + + @Test + fun `on copy TOTP automatically toggle should send CopyTotpAutomaticallyClick`() { + composeTestRule + .onNodeWithText("Copy TOTP automatically") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(AutoFillAction.CopyTotpAutomaticallyClick(true)) } + } + + @Test + fun `copy TOTP automatically should be toggled on or off according to state`() { + composeTestRule + .onNodeWithText("Copy TOTP automatically") + .performScrollTo() + .assertIsOff() + mutableStateFlow.update { it.copy(isCopyTotpAutomaticallyEnabled = true) } + composeTestRule + .onNodeWithText("Copy TOTP automatically") + .performScrollTo() + .assertIsOn() + } + + @Test + fun `on ask to add login toggle should send AskToAddLoginClick`() { + composeTestRule + .onNodeWithText("Ask to add login") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(AutoFillAction.AskToAddLoginClick(true)) } + } + + @Test + fun `ask to add login should be toggled on or off according to state`() { + composeTestRule + .onNodeWithText("Ask to add login") + .performScrollTo() + .assertIsOff() + mutableStateFlow.update { it.copy(isAskToAddLoginEnabled = true) } + composeTestRule + .onNodeWithText("Ask to add login") + .performScrollTo() + .assertIsOn() + } + + @Test + fun `on default URI match detection toggle should display dialog`() { + composeTestRule + .onNodeWithText("Default URI match detection") + .performScrollTo() + .assert(!hasAnyAncestor(isDialog())) + .performClick() + composeTestRule + .onAllNodesWithText("Default URI match detection") + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + } + + @Test + fun `default URI match detection add login should be updated on or off according to state`() { + composeTestRule + .onNodeWithText("Default") + .assertExists() + composeTestRule + .onNodeWithText("Starts with") + .assertDoesNotExist() + mutableStateFlow.update { + it.copy(uriDetectionMethod = AutoFillState.UriDetectionMethod.STARTS_WITH) + } + composeTestRule + .onNodeWithText("Default") + .assertDoesNotExist() + composeTestRule + .onNodeWithText("Starts with") + .assertExists() + } + + @Test + fun `on back click should send BackClick`() { composeTestRule.onNodeWithContentDescription("Back").performClick() verify { viewModel.trySendAction(AutoFillAction.BackClick) } } @Test - fun `on NavigateAbout should call onNavigateToAutoFill`() { - var haveCalledNavigateBack = false - val viewModel = mockk { - every { eventFlow } returns flowOf(AutoFillEvent.NavigateBack) - } - composeTestRule.setContent { - AutoFillScreen( - viewModel = viewModel, - onNavigateBack = { haveCalledNavigateBack = true }, - ) - } - assertTrue(haveCalledNavigateBack) + fun `on NavigateBack should call onNavigateBack`() { + mutableEventFlow.tryEmit(AutoFillEvent.NavigateBack) + assertTrue(onNavigateBackCalled) } } + +private val DEFAULT_STATE: AutoFillState = AutoFillState( + isAskToAddLoginEnabled = false, + isAutoFillServicesEnabled = false, + isCopyTotpAutomaticallyEnabled = false, + isUseAccessibilityEnabled = false, + isUseDrawOverEnabled = false, + isUseInlineAutoFillEnabled = false, + uriDetectionMethod = AutoFillState.UriDetectionMethod.DEFAULT, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt index db88dfaffc..72257121c4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt @@ -1,19 +1,147 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.autofill +import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.asText import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class AutoFillViewModelTest : BaseViewModelTest() { + @Test + fun `initial state should be correct when not set`() { + val viewModel = createViewModel(state = null) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + fun `initial state should be correct when set`() { + val state = DEFAULT_STATE.copy( + isAutoFillServicesEnabled = true, + uriDetectionMethod = AutoFillState.UriDetectionMethod.REGULAR_EXPRESSION, + ) + val viewModel = createViewModel(state = state) + assertEquals(state, viewModel.stateFlow.value) + } + + @Test + fun `on AskToAddLoginClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AutoFillAction.AskToAddLoginClick(true)) + assertEquals(AutoFillEvent.ShowToast("Not yet implemented.".asText()), awaitItem()) + } + assertEquals( + DEFAULT_STATE.copy(isAskToAddLoginEnabled = true), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on AutoFillServicesClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AutoFillAction.AutoFillServicesClick(true)) + assertEquals(AutoFillEvent.ShowToast("Not yet implemented.".asText()), awaitItem()) + } + assertEquals( + DEFAULT_STATE.copy(isAutoFillServicesEnabled = true), + viewModel.stateFlow.value, + ) + } + @Test fun `on BackClick should emit NavigateBack`() = runTest { - val viewModel = AutoFillViewModel() + val viewModel = createViewModel() viewModel.eventFlow.test { viewModel.trySendAction(AutoFillAction.BackClick) assertEquals(AutoFillEvent.NavigateBack, awaitItem()) } } + + @Test + fun `on CopyTotpAutomaticallyClick should update the isCopyTotpAutomaticallyEnabled state`() = + runTest { + val viewModel = createViewModel() + val isEnabled = true + viewModel.eventFlow.test { + viewModel.trySendAction(AutoFillAction.CopyTotpAutomaticallyClick(isEnabled)) + assertEquals(AutoFillEvent.ShowToast("Not yet implemented.".asText()), awaitItem()) + } + assertEquals( + DEFAULT_STATE.copy(isCopyTotpAutomaticallyEnabled = isEnabled), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on UseAccessibilityClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AutoFillAction.UseAccessibilityClick(true)) + assertEquals(AutoFillEvent.ShowToast("Not yet implemented.".asText()), awaitItem()) + } + assertEquals( + DEFAULT_STATE.copy(isUseAccessibilityEnabled = true), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on UseDrawOverClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AutoFillAction.UseDrawOverClick(true)) + assertEquals(AutoFillEvent.ShowToast("Not yet implemented.".asText()), awaitItem()) + } + assertEquals( + DEFAULT_STATE.copy(isUseDrawOverEnabled = true), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on UseInlineAutofillClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AutoFillAction.UseInlineAutofillClick(true)) + assertEquals(AutoFillEvent.ShowToast("Not yet implemented.".asText()), awaitItem()) + } + assertEquals( + DEFAULT_STATE.copy(isUseInlineAutoFillEnabled = true), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on UriDetectionMethodSelect should emit ShowToast`() = runTest { + val viewModel = createViewModel() + val method = AutoFillState.UriDetectionMethod.EXACT + viewModel.eventFlow.test { + viewModel.trySendAction(AutoFillAction.UriDetectionMethodSelect(method)) + assertEquals(AutoFillEvent.ShowToast("Not yet implemented.".asText()), awaitItem()) + } + assertEquals( + DEFAULT_STATE.copy(uriDetectionMethod = method), + viewModel.stateFlow.value, + ) + } + + private fun createViewModel( + state: AutoFillState? = DEFAULT_STATE, + ): AutoFillViewModel = AutoFillViewModel( + savedStateHandle = SavedStateHandle().apply { set("state", state) }, + ) } + +private val DEFAULT_STATE: AutoFillState = AutoFillState( + isAskToAddLoginEnabled = false, + isAutoFillServicesEnabled = false, + isCopyTotpAutomaticallyEnabled = false, + isUseAccessibilityEnabled = false, + isUseDrawOverEnabled = false, + isUseInlineAutoFillEnabled = false, + uriDetectionMethod = AutoFillState.UriDetectionMethod.DEFAULT, +)