summaryrefslogtreecommitdiffhomepage
path: root/android/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'android/app/src')
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ObfuscationModeCell.kt117
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt36
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt17
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialog.kt78
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ShadowsocksCustomPortDialog.kt46
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt78
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectObfuscationCellPreviewParameterProvider.kt23
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ShadowsocksSettingsScreen.kt131
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/Udp2TcpSettingsScreen.kt89
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt130
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ShadowsocksSettingsState.kt13
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/Udp2TcpSettingsState.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt25
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt15
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/WireguardConstant.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortExtensions.kt20
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ShadowsocksCustomPortDialogViewModel.kt83
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ShadowsocksSettingsViewModel.kt87
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/Udp2TcpSettingsViewModel.kt37
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt32
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt23
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,