diff options
Diffstat (limited to 'android/app/src')
26 files changed, 906 insertions, 246 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt index 230f28a194..adfa64585c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt @@ -41,6 +41,7 @@ fun InformationComposeCell( background: Color = MaterialTheme.colorScheme.primary, onCellClicked: () -> Unit = {}, onInfoClicked: (() -> Unit)? = null, + testTag: String = "", ) { val titleModifier = Modifier.alpha(if (isEnabled) AlphaVisible else AlphaInactive) val bodyViewModifier = Modifier @@ -60,6 +61,7 @@ fun InformationComposeCell( InformationComposeCellBody(modifier = bodyViewModifier, onInfoClicked = onInfoClicked) }, onCellClicked = onCellClicked, + testTag = testTag, ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ObfuscationModeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ObfuscationModeCell.kt new file mode 100644 index 0000000000..495b9d61b3 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ObfuscationModeCell.kt @@ -0,0 +1,117 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.preview.SelectObfuscationCellPreviewParameterProvider +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.ObfuscationMode +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.selected +import net.mullvad.mullvadvpn.lib.theme.typeface.listItemSubText +import net.mullvad.mullvadvpn.lib.theme.typeface.listItemText + +@Preview +@Composable +private fun PreviewObfuscationCell( + @PreviewParameter(SelectObfuscationCellPreviewParameterProvider::class) + selectedObfuscationCellData: Triple<ObfuscationMode, Constraint<Port>, Boolean> +) { + AppTheme { + ObfuscationModeCell( + obfuscationMode = selectedObfuscationCellData.first, + port = selectedObfuscationCellData.second, + isSelected = selectedObfuscationCellData.third, + onSelected = {}, + onNavigate = {}, + ) + } +} + +@Composable +fun ObfuscationModeCell( + obfuscationMode: ObfuscationMode, + port: Constraint<Port>, + isSelected: Boolean, + onSelected: (ObfuscationMode) -> Unit, + onNavigate: () -> Unit = {}, +) { + Row( + modifier = + Modifier.height(IntrinsicSize.Min) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerLow) + ) { + TwoRowCell( + modifier = Modifier.weight(1f), + titleStyle = MaterialTheme.typography.listItemText, + titleColor = MaterialTheme.colorScheme.onSurface, + subtitleStyle = MaterialTheme.typography.listItemSubText, + subtitleColor = MaterialTheme.colorScheme.onSurface, + titleText = obfuscationMode.toTitle(), + subtitleText = stringResource(id = R.string.port_x, port.toSubTitle()), + onCellClicked = { onSelected(obfuscationMode) }, + minHeight = Dimens.cellHeight, + background = + if (isSelected) { + MaterialTheme.colorScheme.selected + } else { + Color.Transparent + }, + iconView = { + SelectableIcon( + iconContentDescription = null, + isSelected = isSelected, + isEnabled = true, + ) + }, + ) + VerticalDivider( + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxHeight().padding(vertical = Dimens.verticalDividerPadding), + ) + Icon( + painterResource(id = R.drawable.icon_chevron), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = + Modifier.fillMaxHeight() + .clickable { onNavigate() } + .padding(horizontal = Dimens.obfuscationNavigationPadding), + ) + } +} + +@Composable +private fun ObfuscationMode.toTitle() = + when (this) { + ObfuscationMode.Auto -> stringResource(id = R.string.automatic) + ObfuscationMode.Off -> stringResource(id = R.string.off) + ObfuscationMode.Udp2Tcp -> stringResource(id = R.string.upd_over_tcp) + ObfuscationMode.Shadowsocks -> stringResource(id = R.string.shadowsocks) + } + +@Composable +private fun Constraint<Port>.toSubTitle() = + when (this) { + Constraint.Any -> stringResource(id = R.string.automatic) + is Constraint.Only -> this.value.toString() + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt index 0864f99cb1..f958bec319 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt @@ -42,16 +42,10 @@ fun SelectableCell( isEnabled: Boolean = true, iconContentDescription: String? = null, selectedIcon: @Composable RowScope.() -> Unit = { - Icon( - painter = painterResource(id = R.drawable.icon_tick), - contentDescription = iconContentDescription, - tint = MaterialTheme.colorScheme.onSelected, - modifier = - Modifier.padding(end = Dimens.selectableCellTextMargin) - .alpha( - if (isSelected && !isEnabled) AlphaDisabled - else if (isSelected) AlphaVisible else AlphaInvisible - ), + SelectableIcon( + iconContentDescription = iconContentDescription, + isSelected = isSelected, + isEnabled = isEnabled, ) }, titleStyle: TextStyle = MaterialTheme.typography.labelLarge, @@ -98,3 +92,25 @@ fun SelectableCell( testTag = testTag, ) } + +@Composable +fun RowScope.SelectableIcon( + iconContentDescription: String?, + isSelected: Boolean, + isEnabled: Boolean, +) { + Icon( + painter = painterResource(id = R.drawable.icon_tick), + contentDescription = iconContentDescription, + tint = MaterialTheme.colorScheme.onSelected, + modifier = + Modifier.padding(end = Dimens.selectableCellTextMargin) + .alpha( + when { + isSelected && !isEnabled -> AlphaDisabled + isSelected -> AlphaVisible + else -> AlphaInvisible + } + ), + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt index b14063a1ea..4c86a33452 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt @@ -1,7 +1,9 @@ package net.mullvad.mullvadvpn.compose.cell +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -11,6 +13,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens @@ -24,15 +27,20 @@ private fun PreviewTwoRowCell() { fun TwoRowCell( titleText: String, subtitleText: String, + modifier: Modifier = Modifier, bodyView: @Composable ColumnScope.() -> Unit = {}, - onCellClicked: () -> Unit = {}, + iconView: @Composable RowScope.() -> Unit = {}, + onCellClicked: (() -> Unit)? = null, titleColor: Color = MaterialTheme.colorScheme.onPrimary, subtitleColor: Color = MaterialTheme.colorScheme.onPrimary, titleStyle: TextStyle = MaterialTheme.typography.labelLarge, subtitleStyle: TextStyle = MaterialTheme.typography.labelLarge, background: Color = MaterialTheme.colorScheme.primary, + endPadding: Dp = Dimens.cellEndPadding, + minHeight: Dp = Dimens.cellHeightTwoRows, ) { BaseCell( + modifier = modifier, headlineContent = { Column(modifier = Modifier.weight(1f)) { Text( @@ -54,8 +62,11 @@ fun TwoRowCell( } }, bodyView = bodyView, - onCellClicked = onCellClicked, + iconView = iconView, + onCellClicked = onCellClicked ?: {}, background = background, - minHeight = Dimens.cellHeightTwoRows, + isRowEnabled = onCellClicked != null, + minHeight = minHeight, + endPadding = endPadding, ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialog.kt new file mode 100644 index 0000000000..b71e8d6774 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialog.kt @@ -0,0 +1,78 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import android.os.Parcelable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.parcelize.Parcelize +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.test.CUSTOM_PORT_DIALOG_INPUT_TEST_TAG +import net.mullvad.mullvadvpn.compose.textfield.CustomPortTextField +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.PortRange +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.util.asString + +@Preview +@Composable +private fun PreviewWireguardCustomPortDialog() { + AppTheme { + CustomPortDialog( + title = "Custom port", + portInput = "", + isValidInput = false, + allowedPortRanges = listOf(PortRange(10..10), PortRange(40..50)), + showResetToDefault = false, + onInputChanged = {}, + onSavePort = {}, + onResetPort = {}, + onDismiss = {}, + ) + } +} + +@Parcelize +data class CustomPortNavArgs(val customPort: Port?, val allowedPortRanges: List<PortRange>) : + Parcelable + +@Composable +fun CustomPortDialog( + title: String, + portInput: String, + isValidInput: Boolean, + allowedPortRanges: List<PortRange>, + showResetToDefault: Boolean, + onInputChanged: (String) -> Unit, + onSavePort: (String) -> Unit, + onResetPort: () -> Unit, + onDismiss: () -> Unit, +) { + InputDialog( + title = title, + input = { + CustomPortTextField( + value = portInput, + onValueChanged = onInputChanged, + onSubmit = onSavePort, + isValidValue = isValidInput, + maxCharLength = 5, + modifier = Modifier.testTag(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG).fillMaxWidth(), + ) + }, + message = + stringResource( + id = R.string.custom_port_dialog_valid_ranges, + allowedPortRanges.asString(), + ), + confirmButtonEnabled = isValidInput, + confirmButtonText = stringResource(id = R.string.custom_port_dialog_submit), + onResetButtonText = stringResource(R.string.custom_port_dialog_remove), + onBack = onDismiss, + onReset = if (showResetToDefault) onResetPort else null, + onConfirm = { onSavePort(portInput) }, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ShadowsocksCustomPortDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ShadowsocksCustomPortDialog.kt new file mode 100644 index 0000000000..a19d89ed8b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ShadowsocksCustomPortDialog.kt @@ -0,0 +1,46 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.viewmodel.ShadowsocksCustomPortDialogSideEffect +import net.mullvad.mullvadvpn.viewmodel.ShadowsocksCustomPortDialogViewModel +import org.koin.androidx.compose.koinViewModel + +@Destination<RootGraph>(style = DestinationStyle.Dialog::class) +@Composable +fun ShadowsocksCustomPort( + @Suppress("UNUSED_PARAMETER") navArg: CustomPortNavArgs, + backNavigator: ResultBackNavigator<Port?>, +) { + val viewModel = koinViewModel<ShadowsocksCustomPortDialogViewModel>() + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + CollectSideEffectWithLifecycle(viewModel.uiSideEffect) { + when (it) { + is ShadowsocksCustomPortDialogSideEffect.Success -> backNavigator.navigateBack(it.port) + } + } + CustomPortDialog( + title = + stringResource(R.string.custom_port_dialog_title, stringResource(R.string.shadowsocks)), + portInput = uiState.portInput, + isValidInput = uiState.isValidInput, + showResetToDefault = uiState.showResetToDefault, + allowedPortRanges = uiState.allowedPortRanges, + onInputChanged = viewModel::onInputChanged, + onSavePort = viewModel::onSaveClick, + onResetPort = viewModel::onResetClick, + onDismiss = dropUnlessResumed { backNavigator.navigateBack() }, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt index 9bf8b8bf3b..7d0db3c37b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt @@ -1,58 +1,25 @@ package net.mullvad.mullvadvpn.compose.dialog -import android.os.Parcelable -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.dropUnlessResumed import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph -import com.ramcosta.composedestinations.result.EmptyResultBackNavigator import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.spec.DestinationStyle -import kotlinx.parcelize.Parcelize import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.test.CUSTOM_PORT_DIALOG_INPUT_TEST_TAG -import net.mullvad.mullvadvpn.compose.textfield.CustomPortTextField import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle import net.mullvad.mullvadvpn.lib.model.Port -import net.mullvad.mullvadvpn.lib.model.PortRange -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.util.asString import net.mullvad.mullvadvpn.viewmodel.WireguardCustomPortDialogSideEffect -import net.mullvad.mullvadvpn.viewmodel.WireguardCustomPortDialogUiState import net.mullvad.mullvadvpn.viewmodel.WireguardCustomPortDialogViewModel import org.koin.androidx.compose.koinViewModel -@Preview -@Composable -private fun PreviewWireguardCustomPortDialog() { - AppTheme { - WireguardCustomPort( - WireguardCustomPortNavArgs( - customPort = null, - allowedPortRanges = listOf(PortRange(10..10), PortRange(40..50)), - ), - EmptyResultBackNavigator(), - ) - } -} - -@Parcelize -data class WireguardCustomPortNavArgs( - val customPort: Port?, - val allowedPortRanges: List<PortRange>, -) : Parcelable - @Destination<RootGraph>(style = DestinationStyle.Dialog::class) @Composable fun WireguardCustomPort( - @Suppress("UNUSED_PARAMETER") navArg: WireguardCustomPortNavArgs, + @Suppress("UNUSED_PARAMETER") navArg: CustomPortNavArgs, backNavigator: ResultBackNavigator<Port?>, ) { val viewModel = koinViewModel<WireguardCustomPortDialogViewModel>() @@ -65,45 +32,16 @@ fun WireguardCustomPort( } } - WireguardCustomPortDialog( - uiState, + CustomPortDialog( + title = + stringResource(R.string.custom_port_dialog_title, stringResource(R.string.wireguard)), + portInput = uiState.portInput, + isValidInput = uiState.isValidInput, + showResetToDefault = uiState.showResetToDefault, + allowedPortRanges = uiState.allowedPortRanges, onInputChanged = viewModel::onInputChanged, onSavePort = viewModel::onSaveClick, onResetPort = viewModel::onResetClick, onDismiss = dropUnlessResumed { backNavigator.navigateBack() }, ) } - -@Composable -fun WireguardCustomPortDialog( - state: WireguardCustomPortDialogUiState, - onInputChanged: (String) -> Unit, - onSavePort: (String) -> Unit, - onResetPort: () -> Unit, - onDismiss: () -> Unit, -) { - InputDialog( - title = stringResource(id = R.string.custom_port_dialog_title), - input = { - CustomPortTextField( - value = state.portInput, - onValueChanged = onInputChanged, - onSubmit = onSavePort, - isValidValue = state.isValidInput, - maxCharLength = 5, - modifier = Modifier.testTag(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG).fillMaxWidth(), - ) - }, - message = - stringResource( - id = R.string.custom_port_dialog_valid_ranges, - state.allowedPortRanges.asString(), - ), - confirmButtonEnabled = state.isValidInput, - confirmButtonText = stringResource(id = R.string.custom_port_dialog_submit), - onResetButtonText = stringResource(R.string.custom_port_dialog_remove), - onBack = onDismiss, - onReset = if (state.showResetToDefault) onResetPort else null, - onConfirm = { onSavePort(state.portInput) }, - ) -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectObfuscationCellPreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectObfuscationCellPreviewParameterProvider.kt new file mode 100644 index 0000000000..646d4eb6e4 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectObfuscationCellPreviewParameterProvider.kt @@ -0,0 +1,23 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.ObfuscationMode +import net.mullvad.mullvadvpn.lib.model.Port + +class SelectObfuscationCellPreviewParameterProvider : + PreviewParameterProvider<Triple<ObfuscationMode, Constraint<Port>, Boolean>> { + override val values: Sequence<Triple<ObfuscationMode, Constraint<Port>, Boolean>> = + sequenceOf( + Triple(ObfuscationMode.Shadowsocks, Constraint.Any, false), + Triple(ObfuscationMode.Shadowsocks, Constraint.Any, true), + Triple(ObfuscationMode.Shadowsocks, Constraint.Only(Port(PORT)), false), + Triple(ObfuscationMode.Shadowsocks, Constraint.Only(Port(PORT)), true), + Triple(ObfuscationMode.Udp2Tcp, Constraint.Any, false), + Triple(ObfuscationMode.Udp2Tcp, Constraint.Any, true), + Triple(ObfuscationMode.Udp2Tcp, Constraint.Only(Port(PORT)), false), + Triple(ObfuscationMode.Udp2Tcp, Constraint.Only(Port(PORT)), true), + ) +} + +private const val PORT = 44 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt index 48b2d62839..56219e62d3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt @@ -43,7 +43,7 @@ import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.button.VariantButton -import net.mullvad.mullvadvpn.compose.cell.BaseCell +import net.mullvad.mullvadvpn.compose.cell.TwoRowCell import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar @@ -297,25 +297,13 @@ private fun ColumnScope.DeviceListHeader(state: DeviceListUiState) { @Composable private fun DeviceListItem(device: Device, isLoading: Boolean, onDeviceRemovalClicked: () -> Unit) { - BaseCell( - isRowEnabled = false, - headlineContent = { - Column(modifier = Modifier.weight(1f)) { - Text( - modifier = Modifier.fillMaxWidth(), - text = device.displayName(), - style = MaterialTheme.typography.listItemText, - color = MaterialTheme.colorScheme.onPrimary, - ) - Text( - modifier = Modifier.fillMaxWidth(), - text = - stringResource(id = R.string.created_x, device.creationDate.formatDate()), - style = MaterialTheme.typography.listItemSubText, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, + TwoRowCell( + titleStyle = MaterialTheme.typography.listItemText, + titleColor = MaterialTheme.colorScheme.onPrimary, + subtitleStyle = MaterialTheme.typography.listItemSubText, + subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant, + titleText = device.displayName(), + subtitleText = stringResource(id = R.string.created_x, device.creationDate.formatDate()), bodyView = { if (isLoading) { MullvadCircularProgressIndicatorMedium( @@ -332,7 +320,9 @@ private fun DeviceListItem(device: Device, isLoading: Boolean, onDeviceRemovalCl } } }, + onCellClicked = null, endPadding = Dimens.smallPadding, + minHeight = Dimens.cellHeight, ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ShadowsocksSettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ShadowsocksSettingsScreen.kt new file mode 100644 index 0000000000..0ea4b7fbb0 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ShadowsocksSettingsScreen.kt @@ -0,0 +1,131 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.ShadowsocksCustomPortDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.ResultRecipient +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.CustomPortCell +import net.mullvad.mullvadvpn.compose.cell.InformationComposeCell +import net.mullvad.mullvadvpn.compose.cell.SelectableCell +import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.dialog.CustomPortNavArgs +import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider +import net.mullvad.mullvadvpn.compose.state.ShadowsocksSettingsState +import net.mullvad.mullvadvpn.compose.test.SHADOWSOCKS_CUSTOM_PORT_TEXT_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SHADOWSOCKS_PORT_ITEM_AUTOMATIC_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SHADOWSOCKS_PORT_ITEM_X_TEST_TAG +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition +import net.mullvad.mullvadvpn.compose.util.OnNavResultValue +import net.mullvad.mullvadvpn.constant.SHADOWSOCKS_AVAILABLE_PORTS +import net.mullvad.mullvadvpn.constant.SHADOWSOCKS_PRESET_PORTS +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.viewmodel.ShadowsocksSettingsViewModel +import org.koin.androidx.compose.koinViewModel + +@Preview +@Composable +private fun PreviewShadowsocksSettingsScreen() { + AppTheme { + ShadowsocksSettingsScreen( + state = ShadowsocksSettingsState(port = Constraint.Any, validPortRanges = emptyList()) + ) + } +} + +@Destination<RootGraph>(style = SlideInFromRightTransition::class) +@Composable +fun ShadowsocksSettings( + navigator: DestinationsNavigator, + customPortResult: ResultRecipient<ShadowsocksCustomPortDestination, Port?>, +) { + val viewModel = koinViewModel<ShadowsocksSettingsViewModel>() + val state by viewModel.uiState.collectAsStateWithLifecycle() + + customPortResult.OnNavResultValue { port -> + if (port != null) { + viewModel.onObfuscationPortSelected(Constraint.Only(port)) + } else { + viewModel.resetCustomPort() + } + } + + ShadowsocksSettingsScreen( + state = state, + navigateToCustomPortDialog = + dropUnlessResumed { + navigator.navigate( + ShadowsocksCustomPortDestination( + CustomPortNavArgs( + customPort = state.customPort, + allowedPortRanges = SHADOWSOCKS_AVAILABLE_PORTS, + ) + ) + ) + }, + onObfuscationPortSelected = viewModel::onObfuscationPortSelected, + onBackClick = dropUnlessResumed { navigator.navigateUp() }, + ) +} + +@Composable +fun ShadowsocksSettingsScreen( + state: ShadowsocksSettingsState, + navigateToCustomPortDialog: () -> Unit = {}, + onObfuscationPortSelected: (Constraint<Port>) -> Unit = {}, + onBackClick: () -> Unit = {}, +) { + ScaffoldWithMediumTopBar( + appBarTitle = stringResource(id = R.string.shadowsocks), + navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) }, + ) { modifier, lazyListState -> + LazyColumn(modifier = modifier, state = lazyListState) { + itemWithDivider { InformationComposeCell(title = stringResource(R.string.port)) } + itemWithDivider { + SelectableCell( + title = stringResource(id = R.string.automatic), + isSelected = state.port is Constraint.Any, + onCellClicked = { onObfuscationPortSelected(Constraint.Any) }, + testTag = SHADOWSOCKS_PORT_ITEM_AUTOMATIC_TEST_TAG, + ) + } + itemWithDivider { + SHADOWSOCKS_PRESET_PORTS.forEach { port -> + SelectableCell( + title = port.toString(), + isSelected = state.port.getOrNull() == port, + onCellClicked = { onObfuscationPortSelected(Constraint.Only(port)) }, + testTag = String.format(null, SHADOWSOCKS_PORT_ITEM_X_TEST_TAG, port.value), + ) + } + } + itemWithDivider { + CustomPortCell( + title = stringResource(id = R.string.wireguard_custon_port_title), + isSelected = state.isCustom, + port = state.customPort, + onMainCellClicked = { + if (state.customPort != null) { + onObfuscationPortSelected(Constraint.Only(state.customPort)) + } else { + navigateToCustomPortDialog() + } + }, + onPortCellClicked = navigateToCustomPortDialog, + mainTestTag = SHADOWSOCKS_CUSTOM_PORT_TEXT_TEST_TAG, + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/Udp2TcpSettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/Udp2TcpSettingsScreen.kt new file mode 100644 index 0000000000..b77a8016bf --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/Udp2TcpSettingsScreen.kt @@ -0,0 +1,89 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.UdpOverTcpPortInfoDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.InformationComposeCell +import net.mullvad.mullvadvpn.compose.cell.SelectableCell +import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider +import net.mullvad.mullvadvpn.compose.state.Udp2TcpSettingsState +import net.mullvad.mullvadvpn.compose.test.UDP_OVER_TCP_PORT_ITEM_AUTOMATIC_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.UDP_OVER_TCP_PORT_ITEM_X_TEST_TAG +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition +import net.mullvad.mullvadvpn.constant.UDP2TCP_PRESET_PORTS +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.viewmodel.Udp2TcpSettingsViewModel +import org.koin.androidx.compose.koinViewModel + +@Preview +@Composable +private fun PreviewUdp2TcpSettingsScreen() { + AppTheme { Udp2TcpSettingsScreen(state = Udp2TcpSettingsState(port = Constraint.Any)) } +} + +@Destination<RootGraph>(style = SlideInFromRightTransition::class) +@Composable +fun Udp2TcpSettings(navigator: DestinationsNavigator) { + val viewModel = koinViewModel<Udp2TcpSettingsViewModel>() + val state by viewModel.uiState.collectAsStateWithLifecycle() + Udp2TcpSettingsScreen( + state = state, + onObfuscationPortSelected = viewModel::onObfuscationPortSelected, + navigateUdp2TcpInfo = + dropUnlessResumed { navigator.navigate(UdpOverTcpPortInfoDestination) }, + onBackClick = dropUnlessResumed { navigator.navigateUp() }, + ) +} + +@Composable +fun Udp2TcpSettingsScreen( + state: Udp2TcpSettingsState, + onObfuscationPortSelected: (Constraint<Port>) -> Unit = {}, + navigateUdp2TcpInfo: () -> Unit = {}, + onBackClick: () -> Unit = {}, +) { + ScaffoldWithMediumTopBar( + appBarTitle = stringResource(id = R.string.upd_over_tcp), + navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) }, + ) { modifier, lazyListState -> + LazyColumn(modifier = modifier, state = lazyListState) { + itemWithDivider { + InformationComposeCell( + title = stringResource(R.string.port), + onInfoClicked = navigateUdp2TcpInfo, + ) + } + itemWithDivider { + SelectableCell( + title = stringResource(id = R.string.automatic), + isSelected = state.port is Constraint.Any, + onCellClicked = { onObfuscationPortSelected(Constraint.Any) }, + testTag = UDP_OVER_TCP_PORT_ITEM_AUTOMATIC_TEST_TAG, + ) + } + itemWithDivider { + UDP2TCP_PRESET_PORTS.forEach { port -> + SelectableCell( + title = port.toString(), + isSelected = state.port.getOrNull() == port, + onCellClicked = { onObfuscationPortSelected(Constraint.Only(port)) }, + testTag = String.format(null, UDP_OVER_TCP_PORT_ITEM_X_TEST_TAG, port.value), + ) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt index e77dbf00ef..d5416ad0e7 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt @@ -45,7 +45,8 @@ import com.ramcosta.composedestinations.generated.destinations.MtuDestination import com.ramcosta.composedestinations.generated.destinations.ObfuscationInfoDestination import com.ramcosta.composedestinations.generated.destinations.QuantumResistanceInfoDestination import com.ramcosta.composedestinations.generated.destinations.ServerIpOverridesDestination -import com.ramcosta.composedestinations.generated.destinations.UdpOverTcpPortInfoDestination +import com.ramcosta.composedestinations.generated.destinations.ShadowsocksSettingsDestination +import com.ramcosta.composedestinations.generated.destinations.Udp2TcpSettingsDestination import com.ramcosta.composedestinations.generated.destinations.WireguardCustomPortDestination import com.ramcosta.composedestinations.generated.destinations.WireguardPortInfoDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator @@ -64,13 +65,14 @@ import net.mullvad.mullvadvpn.compose.cell.MtuComposeCell import net.mullvad.mullvadvpn.compose.cell.MtuSubtitle import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell import net.mullvad.mullvadvpn.compose.cell.NormalSwitchComposeCell +import net.mullvad.mullvadvpn.compose.cell.ObfuscationModeCell import net.mullvad.mullvadvpn.compose.cell.SelectableCell import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell import net.mullvad.mullvadvpn.compose.communication.DnsDialogResult import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar import net.mullvad.mullvadvpn.compose.component.textResource -import net.mullvad.mullvadvpn.compose.dialog.WireguardCustomPortNavArgs +import net.mullvad.mullvadvpn.compose.dialog.CustomPortNavArgs import net.mullvad.mullvadvpn.compose.dialog.info.WireguardPortInfoDialogArgument import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider @@ -81,29 +83,23 @@ import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_LAST_ITEM_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_QUANTUM_ITEM_OFF_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_QUANTUM_ITEM_ON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_TEST_TAG -import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_UDP_OVER_TCP_PORT_ITEM_AUTOMATIC_TEST_TAG -import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_UDP_OVER_TCP_PORT_ITEM_X_TEST_TAG -import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_UDP_OVER_TCP_PORT_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_OBFUSCATION_TITLE_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_PORT_ITEM_X_TEST_TAG import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle import net.mullvad.mullvadvpn.compose.util.OnNavResultValue import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately -import net.mullvad.mullvadvpn.constant.UDP2TCP_PRESET_PORTS import net.mullvad.mullvadvpn.constant.WIREGUARD_PRESET_PORTS import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.Mtu +import net.mullvad.mullvadvpn.lib.model.ObfuscationMode import net.mullvad.mullvadvpn.lib.model.Port import net.mullvad.mullvadvpn.lib.model.PortRange import net.mullvad.mullvadvpn.lib.model.QuantumResistantState -import net.mullvad.mullvadvpn.lib.model.SelectedObfuscation import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.util.hasValue -import net.mullvad.mullvadvpn.util.isCustom -import net.mullvad.mullvadvpn.util.toPortOrNull import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem import net.mullvad.mullvadvpn.viewmodel.VpnSettingsSideEffect import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel @@ -134,7 +130,7 @@ private fun PreviewVpnSettings() { navigateToDns = { _, _ -> }, onToggleDnsClick = {}, onBackClick = {}, - onSelectObfuscationSetting = {}, + onSelectObfuscationMode = {}, onSelectQuantumResistanceSetting = {}, onWireguardPortSelected = {}, ) @@ -221,8 +217,6 @@ fun VpnSettings( dropUnlessResumed { navigator.navigate(ObfuscationInfoDestination) }, navigateToQuantumResistanceInfo = dropUnlessResumed { navigator.navigate(QuantumResistanceInfoDestination) }, - navigateUdp2TcpInfo = - dropUnlessResumed { navigator.navigate(UdpOverTcpPortInfoDestination) }, navigateToWireguardPortInfo = dropUnlessResumed { availablePortRanges: List<PortRange> -> navigator.navigate( @@ -255,19 +249,24 @@ fun VpnSettings( }, navigateToWireguardPortDialog = dropUnlessResumed { - val args = - WireguardCustomPortNavArgs( - state.customWireguardPort?.toPortOrNull(), - state.availablePortRanges, + navigator.navigate( + WireguardCustomPortDestination( + CustomPortNavArgs( + customPort = state.customWireguardPort, + allowedPortRanges = state.availablePortRanges, + ) ) - navigator.navigate(WireguardCustomPortDestination(args)) + ) }, onToggleDnsClick = vm::onToggleCustomDns, onBackClick = dropUnlessResumed { navigator.navigateUp() }, - onSelectObfuscationSetting = vm::onSelectObfuscationSetting, + onSelectObfuscationMode = vm::onSelectObfuscationMode, onSelectQuantumResistanceSetting = vm::onSelectQuantumResistanceSetting, onWireguardPortSelected = vm::onWireguardPortSelected, - onObfuscationPortSelected = vm::onObfuscationPortSelected, + navigateToShadowSocksSettings = + dropUnlessResumed { navigator.navigate(ShadowsocksSettingsDestination) }, + navigateToUdp2TcpSettings = + dropUnlessResumed { navigator.navigate(Udp2TcpSettingsDestination) }, ) } @@ -283,7 +282,6 @@ fun VpnSettingsScreen( navigateToMalwareInfo: () -> Unit = {}, navigateToObfuscationInfo: () -> Unit = {}, navigateToQuantumResistanceInfo: () -> Unit = {}, - navigateUdp2TcpInfo: () -> Unit = {}, navigateToWireguardPortInfo: (availablePortRanges: List<PortRange>) -> Unit = {}, navigateToLocalNetworkSharingInfo: () -> Unit = {}, navigateToDaitaInfo: () -> Unit = {}, @@ -303,13 +301,13 @@ fun VpnSettingsScreen( navigateToDns: (index: Int?, address: String?) -> Unit = { _, _ -> }, onToggleDnsClick: (Boolean) -> Unit = {}, onBackClick: () -> Unit = {}, - onSelectObfuscationSetting: (selectedObfuscation: SelectedObfuscation) -> Unit = {}, + onSelectObfuscationMode: (obfuscationMode: ObfuscationMode) -> Unit = {}, onSelectQuantumResistanceSetting: (quantumResistant: QuantumResistantState) -> Unit = {}, onWireguardPortSelected: (port: Constraint<Port>) -> Unit = {}, - onObfuscationPortSelected: (port: Constraint<Port>) -> Unit = {}, + navigateToShadowSocksSettings: () -> Unit = {}, + navigateToUdp2TcpSettings: () -> Unit = {}, ) { var expandContentBlockersState by rememberSaveable { mutableStateOf(false) } - var expandUdp2TcpPortSettings by rememberSaveable { mutableStateOf(false) } val biggerPadding = 54.dp val topPadding = 6.dp @@ -551,9 +549,13 @@ fun VpnSettingsScreen( SelectableCell( title = port.toString(), testTag = - String.format(null, LAZY_LIST_WIREGUARD_PORT_ITEM_X_TEST_TAG, port), - isSelected = state.selectedWireguardPort.hasValue(port), - onCellClicked = { onWireguardPortSelected(Constraint.Only(Port(port))) }, + String.format( + null, + LAZY_LIST_WIREGUARD_PORT_ITEM_X_TEST_TAG, + port.value, + ), + isSelected = state.selectedWireguardPort.getOrNull() == port, + onCellClicked = { onWireguardPortSelected(Constraint.Only(port)) }, ) } } @@ -561,11 +563,11 @@ fun VpnSettingsScreen( itemWithDivider { CustomPortCell( title = stringResource(id = R.string.wireguard_custon_port_title), - isSelected = state.selectedWireguardPort.isCustom(), - port = state.customWireguardPort?.toPortOrNull(), + isSelected = state.isCustomWireguardPort, + port = state.customWireguardPort, onMainCellClicked = { if (state.customWireguardPort != null) { - onWireguardPortSelected(state.customWireguardPort) + onWireguardPortSelected(Constraint.Only(state.customWireguardPort)) } else { navigateToWireguardPortDialog() } @@ -582,72 +584,42 @@ fun VpnSettingsScreen( title = stringResource(R.string.obfuscation_title), onInfoClicked = navigateToObfuscationInfo, onCellClicked = navigateToObfuscationInfo, + testTag = LAZY_LIST_WIREGUARD_OBFUSCATION_TITLE_TEST_TAG, ) } itemWithDivider { SelectableCell( title = stringResource(id = R.string.automatic), - isSelected = state.selectedObfuscation == SelectedObfuscation.Auto, - onCellClicked = { onSelectObfuscationSetting(SelectedObfuscation.Auto) }, + isSelected = state.obfuscationMode == ObfuscationMode.Auto, + onCellClicked = { onSelectObfuscationMode(ObfuscationMode.Auto) }, ) } itemWithDivider { - SelectableCell( - title = stringResource(id = R.string.obfuscation_on_udp_over_tcp), - isSelected = state.selectedObfuscation == SelectedObfuscation.Udp2Tcp, - onCellClicked = { onSelectObfuscationSetting(SelectedObfuscation.Udp2Tcp) }, + ObfuscationModeCell( + obfuscationMode = ObfuscationMode.Shadowsocks, + isSelected = state.obfuscationMode == ObfuscationMode.Shadowsocks, + port = state.selectedShadowsSocksObfuscationPort, + onSelected = onSelectObfuscationMode, + onNavigate = navigateToShadowSocksSettings, ) } itemWithDivider { - SelectableCell( - title = stringResource(id = R.string.off), - isSelected = state.selectedObfuscation == SelectedObfuscation.Off, - onCellClicked = { onSelectObfuscationSetting(SelectedObfuscation.Off) }, + ObfuscationModeCell( + obfuscationMode = ObfuscationMode.Udp2Tcp, + isSelected = state.obfuscationMode == ObfuscationMode.Udp2Tcp, + port = state.selectedUdp2TcpObfuscationPort, + onSelected = onSelectObfuscationMode, + onNavigate = navigateToUdp2TcpSettings, ) } - itemWithDivider { - ExpandableComposeCell( - title = stringResource(R.string.udp_over_tcp_port_title), - isExpanded = expandUdp2TcpPortSettings, - isEnabled = state.selectedObfuscation != SelectedObfuscation.Off, - onInfoClicked = navigateUdp2TcpInfo, - onCellClicked = { expandUdp2TcpPortSettings = !expandUdp2TcpPortSettings }, - testTag = LAZY_LIST_UDP_OVER_TCP_PORT_TEST_TAG, + SelectableCell( + title = stringResource(id = R.string.off), + isSelected = state.obfuscationMode == ObfuscationMode.Off, + onCellClicked = { onSelectObfuscationMode(ObfuscationMode.Off) }, ) } - if (expandUdp2TcpPortSettings) { - itemWithDivider { - SelectableCell( - title = stringResource(id = R.string.automatic), - isSelected = state.selectedObfuscationPort is Constraint.Any, - isEnabled = state.selectObfuscationPortEnabled, - onCellClicked = { onObfuscationPortSelected(Constraint.Any) }, - testTag = LAZY_LIST_UDP_OVER_TCP_PORT_ITEM_AUTOMATIC_TEST_TAG, - ) - } - - UDP2TCP_PRESET_PORTS.forEach { port -> - itemWithDivider { - SelectableCell( - title = port.toString(), - isSelected = state.selectedObfuscationPort.hasValue(port), - isEnabled = state.selectObfuscationPortEnabled, - onCellClicked = { - onObfuscationPortSelected(Constraint.Only(Port(port))) - }, - testTag = - String.format( - null, - LAZY_LIST_UDP_OVER_TCP_PORT_ITEM_X_TEST_TAG, - port, - ), - ) - } - } - } - itemWithDivider { Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) InformationComposeCell( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ShadowsocksSettingsState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ShadowsocksSettingsState.kt new file mode 100644 index 0000000000..7a5a0f86d5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ShadowsocksSettingsState.kt @@ -0,0 +1,13 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.PortRange + +data class ShadowsocksSettingsState( + val port: Constraint<Port> = Constraint.Any, + val customPort: Port? = null, + val validPortRanges: List<PortRange> = emptyList(), +) { + val isCustom = port is Constraint.Only && port.value == customPort +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/Udp2TcpSettingsState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/Udp2TcpSettingsState.kt new file mode 100644 index 0000000000..1eb9c3ebd6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/Udp2TcpSettingsState.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Port + +data class Udp2TcpSettingsState(val port: Constraint<Port> = Constraint.Any) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt index b57de21bc5..7884f199f2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt @@ -3,10 +3,10 @@ package net.mullvad.mullvadvpn.compose.state import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.DefaultDnsOptions import net.mullvad.mullvadvpn.lib.model.Mtu +import net.mullvad.mullvadvpn.lib.model.ObfuscationMode import net.mullvad.mullvadvpn.lib.model.Port import net.mullvad.mullvadvpn.lib.model.PortRange import net.mullvad.mullvadvpn.lib.model.QuantumResistantState -import net.mullvad.mullvadvpn.lib.model.SelectedObfuscation import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem data class VpnSettingsUiState( @@ -17,15 +17,18 @@ data class VpnSettingsUiState( val isCustomDnsEnabled: Boolean, val customDnsItems: List<CustomDnsItem>, val contentBlockersOptions: DefaultDnsOptions, - val selectedObfuscation: SelectedObfuscation, - val selectedObfuscationPort: Constraint<Port>, + val obfuscationMode: ObfuscationMode, + val selectedUdp2TcpObfuscationPort: Constraint<Port>, + val selectedShadowsSocksObfuscationPort: Constraint<Port>, val quantumResistant: QuantumResistantState, val selectedWireguardPort: Constraint<Port>, - val customWireguardPort: Constraint<Port>?, + val customWireguardPort: Port?, val availablePortRanges: List<PortRange>, val systemVpnSettingsAvailable: Boolean, ) { - val selectObfuscationPortEnabled = selectedObfuscation != SelectedObfuscation.Off + val isCustomWireguardPort = + selectedWireguardPort is Constraint.Only && + selectedWireguardPort.value == customWireguardPort companion object { fun createDefault( @@ -36,11 +39,12 @@ data class VpnSettingsUiState( isCustomDnsEnabled: Boolean = false, customDnsItems: List<CustomDnsItem> = emptyList(), contentBlockersOptions: DefaultDnsOptions = DefaultDnsOptions(), - selectedObfuscation: SelectedObfuscation = SelectedObfuscation.Off, - selectedObfuscationPort: Constraint<Port> = Constraint.Any, + obfuscationMode: ObfuscationMode = ObfuscationMode.Off, + selectedUdp2TcpObfuscationPort: Constraint<Port> = Constraint.Any, + selectedShadowsSocksObfuscationPort: Constraint<Port> = Constraint.Any, quantumResistant: QuantumResistantState = QuantumResistantState.Off, selectedWireguardPort: Constraint<Port> = Constraint.Any, - customWireguardPort: Constraint.Only<Port>? = null, + customWireguardPort: Port? = null, availablePortRanges: List<PortRange> = emptyList(), systemVpnSettingsAvailable: Boolean = false, ) = @@ -52,8 +56,9 @@ data class VpnSettingsUiState( isCustomDnsEnabled, customDnsItems, contentBlockersOptions, - selectedObfuscation, - selectedObfuscationPort, + obfuscationMode, + selectedUdp2TcpObfuscationPort, + selectedShadowsSocksObfuscationPort, quantumResistant, selectedWireguardPort, customWireguardPort, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt index 47c109d353..299c99190d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt @@ -14,11 +14,9 @@ const val LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG = "lazy_list_wireguard_custom_port_text_test_tag" const val LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG = "lazy_list_wireguard_custom_port_number_test_tag" -const val LAZY_LIST_UDP_OVER_TCP_PORT_TEST_TAG = "lazy_list_udp_over_tcp_port_test_tag" -const val LAZY_LIST_UDP_OVER_TCP_PORT_ITEM_AUTOMATIC_TEST_TAG = - "lazy_list_udp_over_tcp_item_automatic_test_tag" -const val LAZY_LIST_UDP_OVER_TCP_PORT_ITEM_X_TEST_TAG = "lazy_list_udp_over_tcp_item_%d_test_tag" const val CUSTOM_PORT_DIALOG_INPUT_TEST_TAG = "custom_port_dialog_input_test_tag" +const val LAZY_LIST_WIREGUARD_OBFUSCATION_TITLE_TEST_TAG = + "lazy_list_wireguard_obfuscation_title_test_tag" // SelectLocationScreen, ConnectScreen, CustomListLocationsScreen const val CIRCULAR_PROGRESS_INDICATOR = "circular_progress_indicator" @@ -102,3 +100,12 @@ const val API_ACCESS_TEST_METHOD_BUTTON = "api_access_details_test_method_test_t // EditApiAccessMethodScreen const val EDIT_API_ACCESS_NAME_INPUT = "edit_api_access_name_input" + +// Udp2TcpSettingScreen +const val UDP_OVER_TCP_PORT_ITEM_AUTOMATIC_TEST_TAG = "udp_over_tcp_item_automatic_test_tag" +const val UDP_OVER_TCP_PORT_ITEM_X_TEST_TAG = "udp_over_tcp_item_%d_test_tag" + +// ShadowsocksSettingsScreen +const val SHADOWSOCKS_PORT_ITEM_AUTOMATIC_TEST_TAG = "shadowsocks_item_automatic_test_tag" +const val SHADOWSOCKS_PORT_ITEM_X_TEST_TAG = "shadowsocks_item_%d_test_tag" +const val SHADOWSOCKS_CUSTOM_PORT_TEXT_TEST_TAG = "shadowsocks_custom_port_text_test_tag" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/WireguardConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/WireguardConstant.kt index 6f6cb5a79b..6f1a753b9d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/WireguardConstant.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/WireguardConstant.kt @@ -1,4 +1,11 @@ package net.mullvad.mullvadvpn.constant -val WIREGUARD_PRESET_PORTS = listOf(51820, 53) -val UDP2TCP_PRESET_PORTS = listOf(80, 5001) +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.PortRange + +val WIREGUARD_PRESET_PORTS = listOf(Port(51820), Port(53)) +val UDP2TCP_PRESET_PORTS = listOf(Port(80), Port(5001)) +val SHADOWSOCKS_PRESET_PORTS = emptyList<Port>() +val SHADOWSOCKS_AVAILABLE_PORTS = + // Currently we consider all ports to be available + listOf(PortRange(Port.MIN_VALUE..Port.MAX_VALUE)) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 32fad5614b..b08708826d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -76,8 +76,11 @@ import net.mullvad.mullvadvpn.viewmodel.SaveApiAccessMethodViewModel import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesViewModel import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel +import net.mullvad.mullvadvpn.viewmodel.ShadowsocksCustomPortDialogViewModel +import net.mullvad.mullvadvpn.viewmodel.ShadowsocksSettingsViewModel import net.mullvad.mullvadvpn.viewmodel.SplashViewModel import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel +import net.mullvad.mullvadvpn.viewmodel.Udp2TcpSettingsViewModel import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel import net.mullvad.mullvadvpn.viewmodel.VpnPermissionViewModel @@ -213,6 +216,9 @@ val uiModule = module { viewModel { SaveApiAccessMethodViewModel(get(), get()) } viewModel { ApiAccessMethodDetailsViewModel(get(), get()) } viewModel { DeleteApiAccessMethodConfirmationViewModel(get(), get()) } + viewModel { Udp2TcpSettingsViewModel(get()) } + viewModel { ShadowsocksSettingsViewModel(get(), get()) } + viewModel { ShadowsocksCustomPortDialogViewModel(get()) } // This view model must be single so we correctly attach lifecycle and share it with activity single { NoDaemonViewModel(get()) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt index 1dd18fc71a..4bdde9fec5 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt @@ -76,6 +76,9 @@ class RelayListRepository( val portRanges: Flow<List<PortRange>> = wireguardEndpointData.map { it.portRanges }.distinctUntilChanged() + val shadowsocksPortRanges: Flow<List<PortRange>> = + wireguardEndpointData.map { it.shadowsocksPortRanges }.distinctUntilChanged() + suspend fun updateSelectedRelayLocation(value: RelayItemId) = managementService.setRelayLocation(value) @@ -84,5 +87,5 @@ class RelayListRepository( fun find(geoLocationId: GeoLocationId) = relayList.value.findByGeoLocationId(geoLocationId) - private fun defaultWireguardEndpointData() = WireguardEndpointData(emptyList()) + private fun defaultWireguardEndpointData() = WireguardEndpointData(emptyList(), emptyList()) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt index d66d4a5c00..0fa5e69940 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt @@ -14,11 +14,12 @@ import net.mullvad.mullvadvpn.lib.model.DefaultDnsOptions import net.mullvad.mullvadvpn.lib.model.DnsOptions import net.mullvad.mullvadvpn.lib.model.DnsState import net.mullvad.mullvadvpn.lib.model.Mtu +import net.mullvad.mullvadvpn.lib.model.ObfuscationMode import net.mullvad.mullvadvpn.lib.model.Port import net.mullvad.mullvadvpn.lib.model.QuantumResistantState -import net.mullvad.mullvadvpn.lib.model.SelectedObfuscation import net.mullvad.mullvadvpn.lib.model.Settings +@Suppress("TooManyFunctions") class SettingsRepository( private val managementService: ManagementService, dispatcher: CoroutineDispatcher = Dispatchers.IO, @@ -52,8 +53,11 @@ class SettingsRepository( suspend fun addCustomDns(address: InetAddress) = managementService.addCustomDns(address) - suspend fun setCustomObfuscationPort(constraint: Constraint<Port>) = - managementService.setObfuscationPort(constraint) + suspend fun setCustomUdp2TcpObfuscationPort(constraint: Constraint<Port>) = + managementService.setUdp2TcpObfuscationPort(constraint) + + suspend fun setCustomShadowsocksObfuscationPort(constraint: Constraint<Port>) = + managementService.setShadowsocksObfuscationPort(constraint) suspend fun setWireguardMtu(mtu: Mtu) = managementService.setWireguardMtu(mtu.value) @@ -62,7 +66,7 @@ class SettingsRepository( suspend fun setWireguardQuantumResistant(value: QuantumResistantState) = managementService.setWireguardQuantumResistant(value) - suspend fun setObfuscation(value: SelectedObfuscation) = managementService.setObfuscation(value) + suspend fun setObfuscation(value: ObfuscationMode) = managementService.setObfuscation(value) suspend fun setAutoConnect(isEnabled: Boolean) = managementService.setAutoConnect(isEnabled) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortExtensions.kt index ac93b60d00..c32ddf7c31 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortExtensions.kt @@ -1,28 +1,8 @@ package net.mullvad.mullvadvpn.util -import net.mullvad.mullvadvpn.constant.WIREGUARD_PRESET_PORTS -import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.Port import net.mullvad.mullvadvpn.lib.model.PortRange -fun Constraint<Port>.hasValue(value: Int) = - when (this) { - is Constraint.Any -> false - is Constraint.Only -> this.value.value == value - } - -fun Constraint<Port>.isCustom() = - when (this) { - is Constraint.Any -> false - is Constraint.Only -> !WIREGUARD_PRESET_PORTS.contains(this.value.value) - } - -fun Constraint<Port>.toPortOrNull() = - when (this) { - is Constraint.Any -> null - is Constraint.Only -> this.value - } - fun Port.inAnyOf(portRanges: List<PortRange>): Boolean = portRanges.any { portRange -> this in portRange } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ShadowsocksCustomPortDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ShadowsocksCustomPortDialogViewModel.kt new file mode 100644 index 0000000000..a3ce03428f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ShadowsocksCustomPortDialogViewModel.kt @@ -0,0 +1,83 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ramcosta.composedestinations.generated.destinations.ShadowsocksCustomPortDestination +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.PortRange +import net.mullvad.mullvadvpn.util.inAnyOf + +class ShadowsocksCustomPortDialogViewModel( + savedStateHandle: SavedStateHandle, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ViewModel() { + private val navArgs = ShadowsocksCustomPortDestination.argsFrom(savedStateHandle).navArg + + private val _portInput = MutableStateFlow(navArgs.customPort?.value?.toString() ?: "") + private val _isValidPort = MutableStateFlow(_portInput.value.isValidPort()) + + val uiState: StateFlow<ShadowsocksCustomPortDialogUiState> = + combine(_portInput, _isValidPort, ::createState) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + createState(_portInput.value, _isValidPort.value), + ) + + private val _uiSideEffect = Channel<ShadowsocksCustomPortDialogSideEffect>() + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + private fun createState(portInput: String, isValidPortInput: Boolean) = + ShadowsocksCustomPortDialogUiState( + portInput = portInput, + isValidInput = isValidPortInput, + allowedPortRanges = navArgs.allowedPortRanges, + showResetToDefault = navArgs.customPort != null, + ) + + fun onInputChanged(value: String) { + _portInput.value = value + _isValidPort.value = value.isValidPort() + } + + fun onSaveClick(portValue: String) = + viewModelScope.launch(dispatcher) { + val port = portValue.parseValidPort() ?: return@launch + _uiSideEffect.send(ShadowsocksCustomPortDialogSideEffect.Success(port)) + } + + fun onResetClick() { + viewModelScope.launch(dispatcher) { + _uiSideEffect.send(ShadowsocksCustomPortDialogSideEffect.Success(null)) + } + } + + private fun String.isValidPort(): Boolean = parseValidPort() != null + + private fun String.parseValidPort(): Port? = + Port.fromString(this).getOrNull()?.takeIf { port -> + port.inAnyOf(navArgs.allowedPortRanges) + } +} + +sealed interface ShadowsocksCustomPortDialogSideEffect { + data class Success(val port: Port?) : ShadowsocksCustomPortDialogSideEffect +} + +data class ShadowsocksCustomPortDialogUiState( + val portInput: String, + val isValidInput: Boolean, + val allowedPortRanges: List<PortRange>, + val showResetToDefault: Boolean, +) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ShadowsocksSettingsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ShadowsocksSettingsViewModel.kt new file mode 100644 index 0000000000..18197e2e42 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ShadowsocksSettingsViewModel.kt @@ -0,0 +1,87 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.state.ShadowsocksSettingsState +import net.mullvad.mullvadvpn.constant.SHADOWSOCKS_PRESET_PORTS +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.Settings +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.repository.SettingsRepository + +class ShadowsocksSettingsViewModel( + private val settingsRepository: SettingsRepository, + relayListRepository: RelayListRepository, +) : ViewModel() { + + private val customPort = MutableStateFlow<Port?>(null) + + val uiState: StateFlow<ShadowsocksSettingsState> = + combine( + settingsRepository.settingsUpdates.filterNotNull(), + customPort, + relayListRepository.shadowsocksPortRanges, + ) { settings, customPort, portRanges -> + ShadowsocksSettingsState( + port = settings.getShadowSocksPort(), + customPort = customPort, + validPortRanges = portRanges, + ) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = ShadowsocksSettingsState(), + ) + + init { + viewModelScope.launch { + val initialSettings = settingsRepository.settingsUpdates.filterNotNull().first() + customPort.update { + val initialPort = initialSettings.getShadowSocksPort() + if (initialPort.getOrNull() !in SHADOWSOCKS_PRESET_PORTS) { + initialPort.getOrNull() + } else { + null + } + } + } + } + + fun onObfuscationPortSelected(port: Constraint<Port>) { + viewModelScope.launch { + settingsRepository + .setCustomShadowsocksObfuscationPort(port) + .onLeft { Logger.e("Select shadowsocks port error $it") } + .onRight { + if (port is Constraint.Only && port.value !in SHADOWSOCKS_PRESET_PORTS) { + customPort.update { port.getOrNull() } + } + } + } + } + + fun resetCustomPort() { + val isCustom = uiState.value.isCustom + customPort.update { null } + // If custom port was selected, update selection to be any. + if (isCustom) { + viewModelScope.launch { + settingsRepository.setCustomShadowsocksObfuscationPort(Constraint.Any) + } + } + } + + private fun Settings.getShadowSocksPort() = obfuscationSettings.shadowsocks.port +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/Udp2TcpSettingsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/Udp2TcpSettingsViewModel.kt new file mode 100644 index 0000000000..bafe3ff76a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/Udp2TcpSettingsViewModel.kt @@ -0,0 +1,37 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.state.Udp2TcpSettingsState +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.repository.SettingsRepository + +class Udp2TcpSettingsViewModel(private val repository: SettingsRepository) : ViewModel() { + val uiState: StateFlow<Udp2TcpSettingsState> = + repository.settingsUpdates + .filterNotNull() + .map { settings -> + Udp2TcpSettingsState(port = settings.obfuscationSettings.udp2tcp.port) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = Udp2TcpSettingsState(), + ) + + fun onObfuscationPortSelected(port: Constraint<Port>) { + viewModelScope.launch { + repository.setCustomUdp2TcpObfuscationPort(port).onLeft { + Logger.e("Select udp to tcp port error $it") + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt index af2ea72e4e..3baeda244a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt @@ -19,18 +19,18 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.VpnSettingsUiState +import net.mullvad.mullvadvpn.constant.WIREGUARD_PRESET_PORTS import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.DefaultDnsOptions import net.mullvad.mullvadvpn.lib.model.DnsState +import net.mullvad.mullvadvpn.lib.model.ObfuscationMode import net.mullvad.mullvadvpn.lib.model.Port import net.mullvad.mullvadvpn.lib.model.QuantumResistantState -import net.mullvad.mullvadvpn.lib.model.SelectedObfuscation import net.mullvad.mullvadvpn.lib.model.Settings import net.mullvad.mullvadvpn.lib.model.WireguardConstraints import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.repository.SettingsRepository import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsAvailableUseCase -import net.mullvad.mullvadvpn.util.isCustom sealed interface VpnSettingsSideEffect { sealed interface ShowToast : VpnSettingsSideEffect { @@ -52,7 +52,7 @@ class VpnSettingsViewModel( private val _uiSideEffect = Channel<VpnSettingsSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() - private val customPort = MutableStateFlow<Constraint<Port>?>(null) + private val customPort = MutableStateFlow<Port?>(null) private val vmState = combine(repository.settingsUpdates, relayListRepository.portRanges, customPort) { @@ -68,10 +68,11 @@ class VpnSettingsViewModel( customDnsList = settings?.addresses()?.asStringAddressList() ?: listOf(), contentBlockersOptions = settings?.contentBlockersSettings() ?: DefaultDnsOptions(), - selectedObfuscation = - settings?.selectedObfuscationSettings() ?: SelectedObfuscation.Off, - selectedObfuscationPort = + obfuscationMode = settings?.selectedObfuscationMode() ?: ObfuscationMode.Off, + selectedUdp2TcpObfuscationPort = settings?.obfuscationSettings?.udp2tcp?.port ?: Constraint.Any, + selectedShadowsocksObfuscationPort = + settings?.obfuscationSettings?.shadowsocks?.port ?: Constraint.Any, quantumResistant = settings?.quantumResistant() ?: QuantumResistantState.Off, selectedWireguardPort = settings?.getWireguardPort() ?: Constraint.Any, customWireguardPort = customWgPort, @@ -99,8 +100,8 @@ class VpnSettingsViewModel( val initialSettings = repository.settingsUpdates.filterNotNull().first() customPort.update { val initialPort = initialSettings.getWireguardPort() - if (initialPort.isCustom()) { - initialPort + if (initialPort.getOrNull() !in WIREGUARD_PRESET_PORTS) { + initialPort.getOrNull() } else { null } @@ -209,16 +210,16 @@ class VpnSettingsViewModel( } } - fun onSelectObfuscationSetting(selectedObfuscation: SelectedObfuscation) { + fun onSelectObfuscationMode(obfuscationMode: ObfuscationMode) { viewModelScope.launch(dispatcher) { - repository.setObfuscation(selectedObfuscation).onLeft { + repository.setObfuscation(obfuscationMode).onLeft { _uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError) } } } fun onObfuscationPortSelected(port: Constraint<Port>) { - viewModelScope.launch { repository.setCustomObfuscationPort(port) } + viewModelScope.launch { repository.setCustomUdp2TcpObfuscationPort(port) } } fun onSelectQuantumResistanceSetting(quantumResistant: QuantumResistantState) { @@ -230,8 +231,8 @@ class VpnSettingsViewModel( } fun onWireguardPortSelected(port: Constraint<Port>) { - if (port.isCustom()) { - customPort.update { port } + if (port is Constraint.Only && port.value !in WIREGUARD_PRESET_PORTS) { + customPort.update { port.value } } viewModelScope.launch { relayListRepository.updateSelectedWireguardConstraints( @@ -241,9 +242,10 @@ class VpnSettingsViewModel( } fun resetCustomPort() { + val isCustom = vmState.value.isCustomWireguardPort customPort.update { null } // If custom port was selected, update selection to be any. - if (vmState.value.selectedWireguardPort.isCustom()) { + if (isCustom) { viewModelScope.launch { relayListRepository.updateSelectedWireguardConstraints( WireguardConstraints(port = Constraint.Any) @@ -286,7 +288,7 @@ class VpnSettingsViewModel( private fun Settings.contentBlockersSettings() = tunnelOptions.dnsOptions.defaultOptions - private fun Settings.selectedObfuscationSettings() = obfuscationSettings.selectedObfuscation + private fun Settings.selectedObfuscationMode() = obfuscationSettings.selectedObfuscationMode private fun Settings.getWireguardPort() = relaySettings.relayConstraints.wireguardConstraints.port diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt index 676a10cd70..31d5515a3c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt @@ -4,10 +4,10 @@ import net.mullvad.mullvadvpn.compose.state.VpnSettingsUiState import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.DefaultDnsOptions import net.mullvad.mullvadvpn.lib.model.Mtu +import net.mullvad.mullvadvpn.lib.model.ObfuscationMode import net.mullvad.mullvadvpn.lib.model.Port import net.mullvad.mullvadvpn.lib.model.PortRange import net.mullvad.mullvadvpn.lib.model.QuantumResistantState -import net.mullvad.mullvadvpn.lib.model.SelectedObfuscation data class VpnSettingsViewModelState( val mtuValue: Mtu?, @@ -17,14 +17,19 @@ data class VpnSettingsViewModelState( val isCustomDnsEnabled: Boolean, val customDnsList: List<CustomDnsItem>, val contentBlockersOptions: DefaultDnsOptions, - val selectedObfuscation: SelectedObfuscation, - val selectedObfuscationPort: Constraint<Port>, + val obfuscationMode: ObfuscationMode, + val selectedUdp2TcpObfuscationPort: Constraint<Port>, + val selectedShadowsocksObfuscationPort: Constraint<Port>, val quantumResistant: QuantumResistantState, val selectedWireguardPort: Constraint<Port>, - val customWireguardPort: Constraint<Port>?, + val customWireguardPort: Port?, val availablePortRanges: List<PortRange>, val systemVpnSettingsAvailable: Boolean, ) { + val isCustomWireguardPort = + selectedWireguardPort is Constraint.Only && + selectedWireguardPort.value == customWireguardPort + fun toUiState(): VpnSettingsUiState = VpnSettingsUiState( mtuValue, @@ -34,8 +39,9 @@ data class VpnSettingsViewModelState( isCustomDnsEnabled, customDnsList, contentBlockersOptions, - selectedObfuscation, - selectedObfuscationPort, + obfuscationMode, + selectedUdp2TcpObfuscationPort, + selectedShadowsocksObfuscationPort, quantumResistant, selectedWireguardPort, customWireguardPort, @@ -53,8 +59,9 @@ data class VpnSettingsViewModelState( isCustomDnsEnabled = false, customDnsList = listOf(), contentBlockersOptions = DefaultDnsOptions(), - selectedObfuscation = SelectedObfuscation.Auto, - selectedObfuscationPort = Constraint.Any, + obfuscationMode = ObfuscationMode.Auto, + selectedUdp2TcpObfuscationPort = Constraint.Any, + selectedShadowsocksObfuscationPort = Constraint.Any, quantumResistant = QuantumResistantState.Off, selectedWireguardPort = Constraint.Any, customWireguardPort = null, |
