mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
BIT-927: Add auto-fill UI (#208)
This commit is contained in:
parent
3755b0ed07
commit
ec17264e59
8 changed files with 754 additions and 39 deletions
|
@ -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)
|
|
@ -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) {
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
|
@ -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 },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue