BIT-927: Add auto-fill UI (#208)

This commit is contained in:
David Perez 2023-11-06 16:00:28 -06:00 committed by Álison Fernandes
parent 3755b0ed07
commit ec17264e59
8 changed files with 754 additions and 39 deletions

View file

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

View file

@ -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 = 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) {

View file

@ -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(
@ -57,15 +60,25 @@ fun BitwardenWideSwitch(
contentDescription?.let { this.contentDescription = it }
}
.then(modifier),
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp),
)
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.width(16.dp))

View file

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

View file

@ -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<Unit, AutoFillEvent, AutoFillAction>(
initialState = Unit,
class AutoFillViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
) : BaseViewModel<AutoFillState, AutoFillEvent, AutoFillAction>(
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()
}

View file

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

View file

@ -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<AutoFillEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<AutoFillViewModel>(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<AutoFillViewModel> {
every { eventFlow } returns flowOf(AutoFillEvent.NavigateBack)
fun `on NavigateBack should call onNavigateBack`() {
mutableEventFlow.tryEmit(AutoFillEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
composeTestRule.setContent {
AutoFillScreen(
viewModel = viewModel,
onNavigateBack = { haveCalledNavigateBack = true },
}
private val DEFAULT_STATE: AutoFillState = AutoFillState(
isAskToAddLoginEnabled = false,
isAutoFillServicesEnabled = false,
isCopyTotpAutomaticallyEnabled = false,
isUseAccessibilityEnabled = false,
isUseDrawOverEnabled = false,
isUseInlineAutoFillEnabled = false,
uriDetectionMethod = AutoFillState.UriDetectionMethod.DEFAULT,
)
}
assertTrue(haveCalledNavigateBack)
}
}

View file

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