summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson@mullvad.net>2025-03-31 09:36:52 +0200
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2025-04-10 17:29:33 +0200
commit042820a80d994a09a58dfcdc7dce1ee1d891ac39 (patch)
treeb35efcceebded11d98cec4adc08a52febb2eedf8
parent1c5712f028250920fe34ce7686c77a7d80da9481 (diff)
downloadmullvadvpn-042820a80d994a09a58dfcdc7dce1ee1d891ac39.tar.xz
mullvadvpn-042820a80d994a09a58dfcdc7dce1ee1d891ac39.zip
Implement quick access to active features
- Add Daita: Multihop feature indicator - Make feature indicators clickable - Add animations when accessing the features through the indicators - Rework VpnSettings in order to support navigating to a feature in the list
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt46
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt136
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ObfuscationModeCell.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/DnsDialogResult.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/FeatureChip.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt51
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ServerIpOverridesUiStatePreviewParameterProvider.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/VpnSettingsUiStatePreviewParameterProvider.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt81
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DaitaScreen.kt32
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MultihopScreen.kt35
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt37
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt36
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt1060
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DaitaUiState.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterChip.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SplitTunnelingUiState.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingItem.kt136
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt76
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/CollectionExtensions.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt13
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DaitaViewModel.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModel.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt22
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt282
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt304
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DaitaViewModelTest.kt8
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModelTest.kt7
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModelTest.kt7
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt3
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt138
-rw-r--r--android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt41
-rw-r--r--android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt1
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/FeatureIndicator.kt1
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/IpVersion.kt9
-rw-r--r--android/lib/resource/src/main/res/values/strings_non_translatable.xml1
52 files changed, 1913 insertions, 824 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt
index 4b5ea29fd6..8fefd1e33d 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt
@@ -25,6 +25,7 @@ import net.mullvad.mullvadvpn.compose.test.TOP_BAR_ACCOUNT_BUTTON
import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect
import net.mullvad.mullvadvpn.lib.model.ErrorState
import net.mullvad.mullvadvpn.lib.model.ErrorStateCause
+import net.mullvad.mullvadvpn.lib.model.FeatureIndicator
import net.mullvad.mullvadvpn.lib.model.GeoIpLocation
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.model.TransportProtocol
@@ -70,6 +71,7 @@ class ConnectScreenTest {
onDismissNewDeviceClick: () -> Unit = {},
onChangelogClick: () -> Unit = {},
onDismissChangelogClick: () -> Unit = {},
+ onNavigateToFeature: (FeatureIndicator) -> Unit = {},
) {
setContentWithTheme {
ConnectScreen(
@@ -86,6 +88,7 @@ class ConnectScreenTest {
onDismissNewDeviceClick = onDismissNewDeviceClick,
onChangelogClick = onChangelogClick,
onDismissChangelogClick = onDismissChangelogClick,
+ onNavigateToFeature = onNavigateToFeature,
)
}
}
@@ -801,4 +804,47 @@ class ConnectScreenTest {
onNodeWithText(outIpv6).assertExists()
}
}
+
+ @Test
+ fun clickOnFeatureIndicator() {
+ composeExtension.use {
+ // Arrange
+ val mockLocation: GeoIpLocation = mockk(relaxed = true)
+ val mockTunnelEndpoint: TunnelEndpoint = mockk(relaxed = true)
+ val mockHostName = "Host-Name"
+ every { mockLocation.hostname } returns mockHostName
+ every { mockLocation.entryHostname } returns null
+
+ // In
+ every { mockTunnelEndpoint.obfuscation } returns null
+
+ val mockClickHandler = mockk<(FeatureIndicator) -> Unit>(relaxed = true)
+
+ initScreen(
+ state =
+ ConnectUiState(
+ location = mockLocation,
+ selectedRelayItemTitle = null,
+ tunnelState =
+ TunnelState.Connected(
+ mockTunnelEndpoint,
+ mockLocation,
+ listOf(FeatureIndicator.MULTIHOP),
+ ),
+ showLocation = false,
+ deviceName = "",
+ daysLeftUntilExpiry = null,
+ inAppNotification = null,
+ isPlayBuild = false,
+ ),
+ onNavigateToFeature = mockClickHandler,
+ )
+
+ // Act
+ onNodeWithText("Multihop").performClick()
+
+ // Assert
+ verify(exactly = 1) { mockClickHandler.invoke(FeatureIndicator.MULTIHOP) }
+ }
+ }
}
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt
index 963762c246..0eda50c8e6 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt
@@ -13,7 +13,6 @@ import io.mockk.mockk
import io.mockk.verify
import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension
import net.mullvad.mullvadvpn.compose.setContentWithTheme
-import net.mullvad.mullvadvpn.compose.state.VpnSettingsUiState
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
@@ -23,6 +22,7 @@ import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_
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.lib.model.Constraint
+import net.mullvad.mullvadvpn.lib.model.DefaultDnsOptions
import net.mullvad.mullvadvpn.lib.model.IpVersion
import net.mullvad.mullvadvpn.lib.model.Mtu
import net.mullvad.mullvadvpn.lib.model.ObfuscationMode
@@ -31,6 +31,7 @@ import net.mullvad.mullvadvpn.lib.model.PortRange
import net.mullvad.mullvadvpn.lib.model.QuantumResistantState
import net.mullvad.mullvadvpn.onNodeWithTagAndText
import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem
+import net.mullvad.mullvadvpn.viewmodel.VpnSettingsUiState
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
@@ -44,8 +45,49 @@ class VpnSettingsScreenTest {
MockKAnnotations.init(this)
}
+ private fun createDefaultUiState(
+ mtu: Mtu? = null,
+ isLocalNetworkSharingEnabled: Boolean = false,
+ isCustomDnsEnabled: Boolean = false,
+ customDnsItems: List<CustomDnsItem> = emptyList(),
+ contentBlockersOptions: DefaultDnsOptions = DefaultDnsOptions(),
+ obfuscationMode: ObfuscationMode = ObfuscationMode.Auto,
+ selectedUdp2TcpObfuscationPort: Constraint<Port> = Constraint.Any,
+ selectedShadowsocksObfuscationPort: Constraint<Port> = Constraint.Any,
+ quantumResistant: QuantumResistantState = QuantumResistantState.Auto,
+ selectedWireguardPort: Constraint<Port> = Constraint.Any,
+ customWireguardPort: Port? = null,
+ availablePortRanges: List<PortRange> = emptyList(),
+ systemVpnSettingsAvailable: Boolean = true,
+ autoStartAndConnectOnBoot: Boolean = false,
+ deviceIpVersion: Constraint<IpVersion> = Constraint.Any,
+ isIpv6Enabled: Boolean = true,
+ isContentBlockersExpanded: Boolean = false,
+ isModal: Boolean = false,
+ ) =
+ VpnSettingsUiState.Content.from(
+ mtu = mtu,
+ isLocalNetworkSharingEnabled = isLocalNetworkSharingEnabled,
+ isCustomDnsEnabled = isCustomDnsEnabled,
+ customDnsItems = customDnsItems,
+ contentBlockersOptions = contentBlockersOptions,
+ obfuscationMode = obfuscationMode,
+ selectedUdp2TcpObfuscationPort = selectedUdp2TcpObfuscationPort,
+ selectedShadowsocksObfuscationPort = selectedShadowsocksObfuscationPort,
+ quantumResistant = quantumResistant,
+ selectedWireguardPort = selectedWireguardPort,
+ customWireguardPort = customWireguardPort,
+ availablePortRanges = availablePortRanges,
+ systemVpnSettingsAvailable = systemVpnSettingsAvailable,
+ autoStartAndConnectOnBoot = autoStartAndConnectOnBoot,
+ deviceIpVersion = deviceIpVersion,
+ isIpv6Enabled = isIpv6Enabled,
+ isContentBlockersExpanded = isContentBlockersExpanded,
+ isModal = isModal,
+ )
+
private fun ComposeContext.initScreen(
- state: VpnSettingsUiState = VpnSettingsUiState.createDefault(),
+ state: VpnSettingsUiState = createDefaultUiState(),
navigateToContentBlockersInfo: () -> Unit = {},
navigateToAutoConnectScreen: () -> Unit = {},
navigateToCustomDnsInfo: () -> Unit = {},
@@ -54,7 +96,7 @@ class VpnSettingsScreenTest {
navigateToQuantumResistanceInfo: () -> Unit = {},
navigateToWireguardPortInfo: (availablePortRanges: List<PortRange>) -> Unit = {},
navigateToLocalNetworkSharingInfo: () -> Unit = {},
- navigateToWireguardPortDialog: () -> Unit = {},
+ navigateToWireguardPortDialog: (Port?, List<PortRange>) -> Unit = { _, _ -> },
navigateToServerIpOverrides: () -> Unit = {},
onToggleBlockTrackers: (Boolean) -> Unit = {},
onToggleBlockAds: (Boolean) -> Unit = {},
@@ -76,6 +118,7 @@ class VpnSettingsScreenTest {
onSelectDeviceIpVersion: (Constraint<IpVersion>) -> Unit = {},
onToggleIpv6: (Boolean) -> Unit = {},
navigateToIpv6Info: () -> Unit = {},
+ onToggleDnsContentBlockers: () -> Unit = {},
navigateToDeviceIpInfo: () -> Unit = {},
) {
setContentWithTheme {
@@ -111,7 +154,9 @@ class VpnSettingsScreenTest {
onSelectDeviceIpVersion = onSelectDeviceIpVersion,
onToggleIpv6 = onToggleIpv6,
navigateToIpv6Info = navigateToIpv6Info,
+ onToggleContentBlockersExpanded = onToggleDnsContentBlockers,
navigateToDeviceIpInfo = navigateToDeviceIpInfo,
+ initialScrollToFeature = null,
)
}
}
@@ -120,7 +165,7 @@ class VpnSettingsScreenTest {
fun testDefaultState() =
composeExtension.use {
// Arrange
- initScreen(state = VpnSettingsUiState.createDefault())
+ initScreen()
onNodeWithTag(LAZY_LIST_VPN_SETTINGS_TEST_TAG)
.performScrollToNode(hasTestTag(LAZY_LIST_LAST_ITEM_TEST_TAG))
@@ -138,9 +183,7 @@ class VpnSettingsScreenTest {
// Arrange
initScreen(
state =
- VpnSettingsUiState.createDefault(
- mtu = Mtu.fromString(VALID_DUMMY_MTU_VALUE).getOrNull()!!
- )
+ createDefaultUiState(mtu = Mtu.fromString(VALID_DUMMY_MTU_VALUE).getOrNull()!!)
)
onNodeWithTag(LAZY_LIST_VPN_SETTINGS_TEST_TAG)
@@ -156,7 +199,7 @@ class VpnSettingsScreenTest {
// Arrange
initScreen(
state =
- VpnSettingsUiState.createDefault(
+ createDefaultUiState(
isCustomDnsEnabled = true,
customDnsItems =
listOf(
@@ -180,7 +223,7 @@ class VpnSettingsScreenTest {
// Arrange
initScreen(
state =
- VpnSettingsUiState.createDefault(
+ createDefaultUiState(
isCustomDnsEnabled = false,
customDnsItems =
listOf(CustomDnsItem(address = DUMMY_DNS_ADDRESS, false, false)),
@@ -199,7 +242,7 @@ class VpnSettingsScreenTest {
// Arrange
initScreen(
state =
- VpnSettingsUiState.createDefault(
+ createDefaultUiState(
isCustomDnsEnabled = true,
isLocalNetworkSharingEnabled = true,
customDnsItems =
@@ -223,7 +266,7 @@ class VpnSettingsScreenTest {
// Arrange
initScreen(
state =
- VpnSettingsUiState.createDefault(
+ createDefaultUiState(
isCustomDnsEnabled = true,
customDnsItems =
listOf(
@@ -246,7 +289,7 @@ class VpnSettingsScreenTest {
// Arrange
initScreen(
state =
- VpnSettingsUiState.createDefault(
+ createDefaultUiState(
isCustomDnsEnabled = true,
customDnsItems =
listOf(
@@ -269,7 +312,7 @@ class VpnSettingsScreenTest {
// Arrange
initScreen(
state =
- VpnSettingsUiState.createDefault(
+ createDefaultUiState(
isCustomDnsEnabled = true,
customDnsItems =
listOf(
@@ -290,10 +333,7 @@ class VpnSettingsScreenTest {
fun testShowSelectedTunnelQuantumOption() =
composeExtension.use {
// Arrange
- initScreen(
- state =
- VpnSettingsUiState.createDefault(quantumResistant = QuantumResistantState.On)
- )
+ initScreen(state = createDefaultUiState(quantumResistant = QuantumResistantState.On))
onNodeWithTag(LAZY_LIST_VPN_SETTINGS_TEST_TAG)
.performScrollToNode(hasTestTag(LAZY_LIST_QUANTUM_ITEM_OFF_TEST_TAG))
@@ -309,8 +349,7 @@ class VpnSettingsScreenTest {
val mockSelectQuantumResistantSettingListener: (QuantumResistantState) -> Unit =
mockk(relaxed = true)
initScreen(
- state =
- VpnSettingsUiState.createDefault(quantumResistant = QuantumResistantState.Auto),
+ state = createDefaultUiState(quantumResistant = QuantumResistantState.Auto),
onSelectQuantumResistanceSetting = mockSelectQuantumResistantSettingListener,
)
onNodeWithTag(LAZY_LIST_VPN_SETTINGS_TEST_TAG)
@@ -329,10 +368,7 @@ class VpnSettingsScreenTest {
composeExtension.use {
// Arrange
initScreen(
- state =
- VpnSettingsUiState.createDefault(
- selectedWireguardPort = Constraint.Only(Port(53))
- )
+ state = createDefaultUiState(selectedWireguardPort = Constraint.Only(Port(53)))
)
// Act
@@ -356,10 +392,7 @@ class VpnSettingsScreenTest {
val mockSelectWireguardPortSelectionListener: (Constraint<Port>) -> Unit =
mockk(relaxed = true)
initScreen(
- state =
- VpnSettingsUiState.createDefault(
- selectedWireguardPort = Constraint.Only(Port(53))
- ),
+ state = createDefaultUiState(selectedWireguardPort = Constraint.Only(Port(53))),
onWireguardPortSelected = mockSelectWireguardPortSelectionListener,
)
@@ -384,7 +417,7 @@ class VpnSettingsScreenTest {
fun testShowWireguardCustomPort() =
composeExtension.use {
// Arrange
- initScreen(state = VpnSettingsUiState.createDefault(customWireguardPort = Port(4000)))
+ initScreen(state = createDefaultUiState(customWireguardPort = Port(4000)))
// Act
onNodeWithTag(LAZY_LIST_VPN_SETTINGS_TEST_TAG)
@@ -401,7 +434,7 @@ class VpnSettingsScreenTest {
val onWireguardPortSelected: (Constraint<Port>) -> Unit = mockk(relaxed = true)
initScreen(
state =
- VpnSettingsUiState.createDefault(
+ createDefaultUiState(
selectedWireguardPort = Constraint.Only(Port(4000)),
customWireguardPort = Port(4000),
),
@@ -424,10 +457,7 @@ class VpnSettingsScreenTest {
composeExtension.use {
// Arrange
val mockedClickHandler: (Mtu?) -> Unit = mockk(relaxed = true)
- initScreen(
- state = VpnSettingsUiState.createDefault(),
- navigateToMtuDialog = mockedClickHandler,
- )
+ initScreen(state = createDefaultUiState(), navigateToMtuDialog = mockedClickHandler)
onNodeWithTag(LAZY_LIST_VPN_SETTINGS_TEST_TAG)
.performScrollToNode(hasTestTag(LAZY_LIST_LAST_ITEM_TEST_TAG))
@@ -446,7 +476,7 @@ class VpnSettingsScreenTest {
val mockedClickHandler: (Int?, String?) -> Unit = mockk(relaxed = true)
initScreen(
state =
- VpnSettingsUiState.createDefault(
+ createDefaultUiState(
isCustomDnsEnabled = true,
customDnsItems = listOf(CustomDnsItem("1.1.1.1", false, false)),
),
@@ -467,7 +497,7 @@ class VpnSettingsScreenTest {
// Arrange
initScreen(
- state = VpnSettingsUiState.createDefault(),
+ state = createDefaultUiState(),
navigateToObfuscationInfo = mockedNavigateToObfuscationInfo,
)
@@ -487,7 +517,7 @@ class VpnSettingsScreenTest {
// Arrange
initScreen(
- state = VpnSettingsUiState.createDefault(),
+ state = createDefaultUiState(),
navigateToQuantumResistanceInfo = mockedShowTunnelQuantumInfoClick,
)
@@ -507,7 +537,7 @@ class VpnSettingsScreenTest {
// Arrange
initScreen(
- state = VpnSettingsUiState.createDefault(),
+ state = createDefaultUiState(),
navigateToWireguardPortInfo = mockedClickHandler,
)
@@ -519,11 +549,13 @@ class VpnSettingsScreenTest {
@Test
fun testShowWireguardCustomPortDialog() =
composeExtension.use {
- val mockedClickHandler: () -> Unit = mockk(relaxed = true)
+ val mockedClickHandler: (Port?, List<PortRange>) -> Unit = mockk(relaxed = true)
+
+ val availablePortRanges = listOf(Port(4000)..Port(5000))
// Arrange
initScreen(
- state = VpnSettingsUiState.createDefault(),
+ state = createDefaultUiState(availablePortRanges = availablePortRanges),
navigateToWireguardPortDialog = mockedClickHandler,
)
@@ -532,16 +564,17 @@ class VpnSettingsScreenTest {
onNodeWithText("Custom").performClick()
// Assert
- verify(exactly = 1) { mockedClickHandler.invoke() }
+ verify(exactly = 1) { mockedClickHandler.invoke(null, availablePortRanges) }
}
@Test
fun testClickWireguardCustomPortMainCell() =
composeExtension.use {
// Arrange
- val mockOnShowCustomPortDialog: () -> Unit = mockk(relaxed = true)
+ val mockOnShowCustomPortDialog: (Port?, List<PortRange>) -> Unit = mockk(relaxed = true)
+ val availablePortRanges = listOf(Port(4000)..Port(5000))
initScreen(
- state = VpnSettingsUiState.createDefault(),
+ state = createDefaultUiState(availablePortRanges = availablePortRanges),
navigateToWireguardPortDialog = mockOnShowCustomPortDialog,
)
@@ -551,18 +584,23 @@ class VpnSettingsScreenTest {
onNodeWithTag(LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG).performClick()
// Assert
- verify { mockOnShowCustomPortDialog.invoke() }
+ verify { mockOnShowCustomPortDialog.invoke(null, availablePortRanges) }
}
@Test
fun testClickWireguardCustomPortNumberCell() =
composeExtension.use {
// Arrange
- val mockOnShowCustomPortDialog: () -> Unit = mockk(relaxed = true)
+ val mockOnShowCustomPortDialog: (port: Port?, range: List<PortRange>) -> Unit =
+ mockk(relaxed = true)
+ val customPort = Port(4000)
+ val availablePortRanges = listOf(Port(4000)..Port(5000))
initScreen(
state =
- VpnSettingsUiState.createDefault(
- selectedWireguardPort = Constraint.Only(Port(4000))
+ createDefaultUiState(
+ selectedWireguardPort = Constraint.Only(customPort),
+ customWireguardPort = customPort,
+ availablePortRanges = availablePortRanges,
),
navigateToWireguardPortDialog = mockOnShowCustomPortDialog,
)
@@ -573,14 +611,14 @@ class VpnSettingsScreenTest {
onNodeWithTag(testTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG).performClick()
// Assert
- verify { mockOnShowCustomPortDialog.invoke() }
+ verify { mockOnShowCustomPortDialog.invoke(customPort, availablePortRanges) }
}
@Test
fun ensureConnectOnStartIsShownWhenSystemVpnSettingsAvailableIsFalse() =
composeExtension.use {
// Arrange
- initScreen(state = VpnSettingsUiState.createDefault(systemVpnSettingsAvailable = false))
+ initScreen(state = createDefaultUiState(systemVpnSettingsAvailable = false))
// Assert
onNodeWithText("Connect on device start-up").assertExists()
@@ -593,7 +631,7 @@ class VpnSettingsScreenTest {
val mockOnToggleAutoStartAndConnectOnBoot: (Boolean) -> Unit = mockk(relaxed = true)
initScreen(
state =
- VpnSettingsUiState.createDefault(
+ createDefaultUiState(
systemVpnSettingsAvailable = false,
autoStartAndConnectOnBoot = false,
),
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt
index 8e22be8b7e..c3f0a14f35 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt
@@ -148,7 +148,7 @@ fun BaseSubtitleCell(
start = Dimens.cellStartPadding,
top = Dimens.cellFooterTopPadding,
end = Dimens.cellEndPadding,
- bottom = Dimens.cellVerticalSpacing,
+ bottom = Dimens.cellVerticalSpacing
)
.fillMaxWidth()
.wrapContentHeight(),
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt
index bc6aebe5d0..2befd75123 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt
@@ -60,6 +60,7 @@ fun CustomPortCell(
title: String,
isSelected: Boolean,
port: Port?,
+ modifier: Modifier = Modifier,
mainTestTag: String = "",
numberTestTag: String = "",
isEnabled: Boolean = true,
@@ -69,7 +70,7 @@ fun CustomPortCell(
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
- modifier = Modifier.height(Dimens.cellHeight).fillMaxWidth(),
+ modifier = modifier.height(Dimens.cellHeight).fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt
index a8adcb2b39..5fbd2fe86c 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt
@@ -9,7 +9,6 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -114,7 +113,7 @@ private fun ExpandableComposeCellBody(
@Composable
fun ContentBlockersDisableModeCellSubtitle(modifier: Modifier) {
- Text(
+ BaseSubtitleCell(
text =
stringResource(
id = R.string.dns_content_blockers_subtitle,
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 594b4f1ccd..8fec4e48e2 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
@@ -39,6 +39,7 @@ private fun PreviewInformationComposeCell() {
@Composable
fun InformationComposeCell(
title: String,
+ modifier: Modifier = Modifier,
isEnabled: Boolean = true,
background: Color = MaterialTheme.colorScheme.primary,
onCellClicked: (() -> Unit)? = null,
@@ -49,7 +50,7 @@ fun InformationComposeCell(
val bodyViewModifier = Modifier
BaseCell(
- modifier = Modifier.focusProperties { canFocus = false },
+ modifier = modifier.focusProperties { canFocus = false },
headlineContent = {
BaseCellTitle(
title = title,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt
index ca864f66b3..64859b9cce 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt
@@ -5,6 +5,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import net.mullvad.mullvadvpn.R
@@ -20,13 +21,20 @@ private fun PreviewMtuComposeCell() {
}
@Composable
-fun MtuComposeCell(mtuValue: Mtu?, onEditMtu: () -> Unit) {
+fun MtuComposeCell(
+ mtuValue: Mtu?,
+ onEditMtu: () -> Unit,
+ modifier: Modifier = Modifier,
+ background: Color = MaterialTheme.colorScheme.primary,
+) {
val titleModifier = Modifier
BaseCell(
+ modifier = modifier,
headlineContent = { MtuTitle(modifier = titleModifier.weight(1f, true)) },
bodyView = { MtuBodyView(mtuValue = mtuValue, modifier = titleModifier) },
onCellClicked = { onEditMtu.invoke() },
+ background = background,
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
index b05b0f2cbe..3ec442d8ca 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
@@ -76,11 +76,12 @@ fun NavigationComposeCell(
testTag: String = "",
) {
BaseCell(
+ modifier = modifier,
onCellClicked = onClick,
headlineContent = {
NavigationTitleView(
title = title,
- modifier = modifier.weight(1f, true),
+ modifier = Modifier.weight(1f, true),
showWarning = showWarning,
)
},
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
index 37e582bb01..34b3c097e4 100644
--- 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
@@ -60,11 +60,13 @@ fun ObfuscationModeCell(
isSelected: Boolean,
onSelected: (ObfuscationMode) -> Unit,
onNavigate: () -> Unit,
+ modifier: Modifier = Modifier,
testTag: String? = null,
) {
Row(
modifier =
- Modifier.height(IntrinsicSize.Min)
+ modifier
+ .height(IntrinsicSize.Min)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.let { if (testTag != null) it.testTag(testTag) else it }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt
index ec2ff5e36f..8bbd729edd 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt
@@ -56,6 +56,7 @@ private fun PreviewSwitchComposeCell() {
fun NormalSwitchComposeCell(
title: String,
isToggled: Boolean,
+ modifier: Modifier = Modifier,
startPadding: Dp = Dimens.indentedCellStartPadding,
isEnabled: Boolean = true,
background: Color = MaterialTheme.colorScheme.surfaceContainerLow,
@@ -79,6 +80,7 @@ fun NormalSwitchComposeCell(
onBackground = onBackground,
onCellClicked = onCellClicked,
onInfoClicked = onInfoClicked,
+ modifier = modifier,
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/DnsDialogResult.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/DnsDialogResult.kt
index b95c3ef72e..99ec3c18a5 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/DnsDialogResult.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/DnsDialogResult.kt
@@ -7,6 +7,4 @@ interface DnsDialogResult : Parcelable {
@Parcelize data class Success(val isDnsListEmpty: Boolean) : DnsDialogResult
@Parcelize data object Error : DnsDialogResult
-
- @Parcelize data object Cancel : DnsDialogResult
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/FeatureChip.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/FeatureChip.kt
index 65a63dd089..be1139a44b 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/FeatureChip.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/FeatureChip.kt
@@ -1,12 +1,12 @@
package net.mullvad.mullvadvpn.compose.component
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@@ -16,18 +16,21 @@ import net.mullvad.mullvadvpn.lib.theme.shape.chipShape
@Preview
@Composable
private fun PreviewMullvadFeatureChip() {
- AppTheme { Row { MullvadFeatureChip(text = "DAITA") } }
+ AppTheme { Row { MullvadFeatureChip(text = "DAITA", onClick = {}) } }
}
@Composable
fun MullvadFeatureChip(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLowest,
borderColor: Color = MaterialTheme.colorScheme.primary,
labelColor: Color = MaterialTheme.colorScheme.onPrimary,
iconColor: Color = MaterialTheme.colorScheme.onPrimary,
- text: String,
) {
FilterChip(
+ modifier = modifier,
shape = MaterialTheme.shapes.chipShape,
colors =
FilterChipDefaults.filterChipColors(
@@ -43,8 +46,7 @@ fun MullvadFeatureChip(
selected = false,
),
selected = false,
- onClick = {},
- enabled = false,
+ onClick = onClick,
label = {
Text(
text = text,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt
index 44999cf9e6..1cfe26c27a 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt
@@ -1,5 +1,13 @@
+@file:OptIn(ExperimentalSharedTransitionApi::class)
+
package net.mullvad.mullvadvpn.compose.component.connectioninfo
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.core.EaseInQuart
+import androidx.compose.animation.core.EaseOutQuad
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ContextualFlowRow
import androidx.compose.foundation.layout.ContextualFlowRowOverflow
@@ -15,6 +23,8 @@ import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.component.MullvadFeatureChip
import net.mullvad.mullvadvpn.compose.component.MullvadMoreChip
import net.mullvad.mullvadvpn.compose.component.textResource
+import net.mullvad.mullvadvpn.compose.screen.LocalNavAnimatedVisibilityScope
+import net.mullvad.mullvadvpn.compose.screen.LocalSharedTransitionScope
import net.mullvad.mullvadvpn.lib.model.FeatureIndicator
import net.mullvad.mullvadvpn.lib.theme.Dimens
@@ -23,6 +33,7 @@ fun FeatureIndicatorsPanel(
featureIndicators: List<FeatureIndicator>,
expanded: Boolean,
onToggleExpand: () -> Unit,
+ onNavigateToFeature: (FeatureIndicator) -> Unit,
) {
if (featureIndicators.isNotEmpty()) {
if (expanded) {
@@ -31,16 +42,17 @@ fun FeatureIndicatorsPanel(
Modifier.fillMaxWidth(),
)
}
- FeatureIndicators(featureIndicators, expanded, onToggleExpand)
+ FeatureIndicators(featureIndicators, expanded, onToggleExpand, onNavigateToFeature)
}
}
-@OptIn(ExperimentalLayoutApi::class)
+@OptIn(ExperimentalLayoutApi::class, ExperimentalSharedTransitionApi::class)
@Composable
fun FeatureIndicators(
features: List<FeatureIndicator>,
expanded: Boolean,
onToggleExpand: () -> Unit,
+ onNavigateToFeature: (FeatureIndicator) -> Unit,
) {
ContextualFlowRow(
modifier = Modifier.fillMaxWidth(),
@@ -67,7 +79,39 @@ fun FeatureIndicators(
collapseIndicator = {},
),
) { index ->
- MullvadFeatureChip(text = features[index].text())
+ val featureIndicator = features[index]
+
+ val sharedTransitionScope = LocalSharedTransitionScope.current
+ val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current
+
+ with(sharedTransitionScope) {
+ MullvadFeatureChip(
+ text = featureIndicator.text(),
+ onClick = { onNavigateToFeature(featureIndicator) },
+ modifier =
+ Modifier.let {
+ if (this@with != null && animatedVisibilityScope != null) {
+ it.sharedBounds(
+ rememberSharedContentState(
+ key =
+ if (featureIndicator == FeatureIndicator.DAITA_MULTIHOP)
+ FeatureIndicator.DAITA
+ else featureIndicator
+ ),
+ animatedVisibilityScope = animatedVisibilityScope,
+ // This flag should be set to `true` (default), this would allow the
+ // element to animate above all other views. However, it causes the
+ // expand/collapse animation to become janky.
+ renderInOverlayDuringTransition = false,
+ enter = fadeIn(tween(easing = EaseInQuart)),
+ exit = fadeOut(tween(easing = EaseOutQuad)),
+ )
+ } else {
+ it
+ }
+ },
+ )
+ }
}
// Spacing are added to compensate for when there are no feature indicators, since each feature
@@ -92,6 +136,7 @@ private fun FeatureIndicator.text(): String {
FeatureIndicator.SERVER_IP_OVERRIDE -> R.string.server_ip_override
FeatureIndicator.CUSTOM_MTU -> R.string.feature_custom_mtu
FeatureIndicator.DAITA -> R.string.daita
+ FeatureIndicator.DAITA_MULTIHOP -> R.string.daita_multihop
FeatureIndicator.MULTIHOP -> R.string.multihop
}
return textResource(resource)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt
index 4fee24a8f9..1c1247de67 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt
@@ -64,8 +64,7 @@ fun Dns(resultNavigator: ResultBackNavigator<DnsDialogResult>) {
viewModel::onDnsInputChange,
onSaveDnsClick = viewModel::onSaveDnsClick,
onRemoveDnsClick = viewModel::onRemoveDnsClick,
- onDismiss =
- dropUnlessResumed { resultNavigator.navigateBack(result = DnsDialogResult.Cancel) },
+ onDismiss = dropUnlessResumed { resultNavigator.navigateBack() },
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ServerIpOverridesUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ServerIpOverridesUiStatePreviewParameterProvider.kt
index 2c2988e0da..a69d3a4432 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ServerIpOverridesUiStatePreviewParameterProvider.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ServerIpOverridesUiStatePreviewParameterProvider.kt
@@ -9,6 +9,6 @@ class ServerIpOverridesUiStatePreviewParameterProvider :
sequenceOf(
ServerIpOverridesUiState.Loaded(overridesActive = true),
ServerIpOverridesUiState.Loaded(overridesActive = false),
- ServerIpOverridesUiState.Loading,
+ ServerIpOverridesUiState.Loading(),
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/VpnSettingsUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/VpnSettingsUiStatePreviewParameterProvider.kt
index 55c8802c7f..060d67ace0 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/VpnSettingsUiStatePreviewParameterProvider.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/VpnSettingsUiStatePreviewParameterProvider.kt
@@ -1,13 +1,14 @@
package net.mullvad.mullvadvpn.compose.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-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.QuantumResistantState
import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem
+import net.mullvad.mullvadvpn.viewmodel.VpnSettingsUiState
private const val MTU = 1337
@Suppress("MagicNumber") private val PORT1 = Port(9001)
@@ -16,8 +17,8 @@ private const val MTU = 1337
class VpnSettingsUiStatePreviewParameterProvider : PreviewParameterProvider<VpnSettingsUiState> {
override val values =
sequenceOf(
- VpnSettingsUiState.createDefault(),
- VpnSettingsUiState.createDefault(
+ VpnSettingsUiState.Loading(),
+ VpnSettingsUiState.Content.from(
mtu = Mtu(MTU),
isLocalNetworkSharingEnabled = true,
isCustomDnsEnabled = true,
@@ -37,6 +38,13 @@ class VpnSettingsUiStatePreviewParameterProvider : PreviewParameterProvider<VpnS
availablePortRanges = listOf(PORT1..PORT2),
systemVpnSettingsAvailable = true,
autoStartAndConnectOnBoot = true,
+ isIpv6Enabled = true,
+ obfuscationMode = ObfuscationMode.Udp2Tcp,
+ selectedUdp2TcpObfuscationPort = Constraint.Any,
+ selectedShadowsocksObfuscationPort = Constraint.Any,
+ isContentBlockersExpanded = true,
+ deviceIpVersion = Constraint.Any,
+ isModal = false,
),
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt
index a774dfd668..3107574b58 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt
@@ -3,6 +3,7 @@ package net.mullvad.mullvadvpn.compose.screen
import android.content.Context
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
@@ -31,6 +32,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
@@ -60,10 +62,15 @@ import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.NavGraphs
import com.ramcosta.composedestinations.generated.destinations.AccountDestination
import com.ramcosta.composedestinations.generated.destinations.ChangelogDestination
+import com.ramcosta.composedestinations.generated.destinations.DaitaDestination
import com.ramcosta.composedestinations.generated.destinations.DeviceRevokedDestination
+import com.ramcosta.composedestinations.generated.destinations.MultihopDestination
import com.ramcosta.composedestinations.generated.destinations.OutOfTimeDestination
import com.ramcosta.composedestinations.generated.destinations.SelectLocationDestination
+import com.ramcosta.composedestinations.generated.destinations.ServerIpOverridesDestination
import com.ramcosta.composedestinations.generated.destinations.SettingsDestination
+import com.ramcosta.composedestinations.generated.destinations.SplitTunnelingDestination
+import com.ramcosta.composedestinations.generated.destinations.VpnSettingsDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.result.ResultRecipient
import kotlinx.coroutines.launch
@@ -81,6 +88,7 @@ import net.mullvad.mullvadvpn.compose.component.connectioninfo.toInAddress
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
import net.mullvad.mullvadvpn.compose.component.notificationbanner.NotificationBanner
import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook
+import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed
import net.mullvad.mullvadvpn.compose.extensions.safeOpenUri
import net.mullvad.mullvadvpn.compose.preview.ConnectUiStatePreviewParameterProvider
import net.mullvad.mullvadvpn.compose.state.ConnectUiState
@@ -152,6 +160,7 @@ private fun PreviewAccountScreen(
{},
{},
{},
+ {},
)
}
}
@@ -161,6 +170,7 @@ private fun PreviewAccountScreen(
@Composable
fun Connect(
navigator: DestinationsNavigator,
+ animatedVisibilityScope: AnimatedVisibilityScope,
selectLocationResultRecipient: ResultRecipient<SelectLocationDestination, Boolean>,
) {
val connectViewModel: ConnectViewModel = koinViewModel()
@@ -246,23 +256,32 @@ fun Connect(
}
}
- ConnectScreen(
- state = state,
- snackbarHostState = snackbarHostState,
- onDisconnectClick = connectViewModel::onDisconnectClick,
- onReconnectClick = connectViewModel::onReconnectClick,
- onConnectClick = connectViewModel::onConnectClick,
- onCancelClick = connectViewModel::onCancelClick,
- onSwitchLocationClick = dropUnlessResumed { navigator.navigate(SelectLocationDestination) },
- onOpenAppListing = connectViewModel::openAppListing,
- onManageAccountClick = connectViewModel::onManageAccountClick,
- onChangelogClick =
- dropUnlessResumed { navigator.navigate(ChangelogDestination(ChangelogNavArgs(true))) },
- onDismissChangelogClick = connectViewModel::dismissNewChangelogNotification,
- onSettingsClick = dropUnlessResumed { navigator.navigate(SettingsDestination) },
- onAccountClick = dropUnlessResumed { navigator.navigate(AccountDestination) },
- onDismissNewDeviceClick = connectViewModel::dismissNewDeviceNotification,
- )
+ CompositionLocalProvider(LocalNavAnimatedVisibilityScope provides animatedVisibilityScope) {
+ ConnectScreen(
+ state = state,
+ snackbarHostState = snackbarHostState,
+ onDisconnectClick = connectViewModel::onDisconnectClick,
+ onReconnectClick = connectViewModel::onReconnectClick,
+ onConnectClick = connectViewModel::onConnectClick,
+ onCancelClick = connectViewModel::onCancelClick,
+ onSwitchLocationClick =
+ dropUnlessResumed { navigator.navigate(SelectLocationDestination) },
+ onOpenAppListing = connectViewModel::openAppListing,
+ onManageAccountClick = connectViewModel::onManageAccountClick,
+ onChangelogClick =
+ dropUnlessResumed {
+ navigator.navigate(ChangelogDestination(ChangelogNavArgs(true)))
+ },
+ onDismissChangelogClick = connectViewModel::dismissNewChangelogNotification,
+ onSettingsClick = dropUnlessResumed { navigator.navigate(SettingsDestination) },
+ onAccountClick = dropUnlessResumed { navigator.navigate(AccountDestination) },
+ onDismissNewDeviceClick = connectViewModel::dismissNewDeviceNotification,
+ onNavigateToFeature =
+ dropUnlessResumed { feature: FeatureIndicator ->
+ navigator.navigate(feature.destination())
+ },
+ )
+ }
}
@Composable
@@ -281,6 +300,7 @@ fun ConnectScreen(
onSettingsClick: () -> Unit,
onAccountClick: () -> Unit,
onDismissNewDeviceClick: () -> Unit,
+ onNavigateToFeature: (FeatureIndicator) -> Unit,
) {
val content =
@Composable { padding: PaddingValues ->
@@ -297,6 +317,7 @@ fun ConnectScreen(
onChangelogClick,
onDismissChangelogClick,
onDismissNewDeviceClick,
+ onNavigateToFeature,
)
}
@@ -347,6 +368,7 @@ private fun Content(
onChangelogClick: () -> Unit,
onDismissChangelogClick: () -> Unit,
onDismissNewDeviceClick: () -> Unit,
+ onNavigateToFeature: (FeatureIndicator) -> Unit,
) {
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
@@ -405,6 +427,7 @@ private fun Content(
onReconnectClick = onReconnectClick,
onCancelClick = onCancelClick,
onConnectClick = onConnectClick,
+ onNavigateToFeature = onNavigateToFeature,
)
}
}
@@ -447,6 +470,7 @@ private fun ConnectionCard(
onReconnectClick: () -> Unit,
onCancelClick: () -> Unit,
onConnectClick: () -> Unit,
+ onNavigateToFeature: (FeatureIndicator) -> Unit,
) {
var expanded by rememberSaveable(state.tunnelState::class) { mutableStateOf(false) }
val containerColor =
@@ -476,6 +500,7 @@ private fun ConnectionCard(
(state.tunnelState as? TunnelState.Connected)?.toConnectionsDetails(),
exp,
onToggleExpand = { expanded = !exp },
+ onNavigateToFeature = onNavigateToFeature,
)
} else {
Spacer(Modifier.height(Dimens.smallSpacer))
@@ -573,6 +598,7 @@ private fun ConnectionInfo(
connectionDetails: ConnectionDetails?,
expanded: Boolean,
onToggleExpand: () -> Unit,
+ onNavigateToFeature: (FeatureIndicator) -> Unit,
) {
val scrollState = rememberScrollState()
Column {
@@ -591,7 +617,7 @@ private fun ConnectionInfo(
)
.verticalScroll(scrollState)
) {
- FeatureIndicatorsPanel(featureIndicators, expanded, onToggleExpand)
+ FeatureIndicatorsPanel(featureIndicators, expanded, onToggleExpand, onNavigateToFeature)
if (expanded && connectionDetails != null) {
ConnectionDetailPanel(connectionDetails)
@@ -704,3 +730,22 @@ private fun PrepareError.OtherLegacyAlwaysOnVpn.toMessage(context: Context) =
private fun PrepareError.OtherAlwaysOnApp.toMessage(context: Context) =
context.getString(R.string.always_on_vpn_error_notification_content, appName).removeHtmlTags()
+
+private fun FeatureIndicator.destination() =
+ when (this) {
+ FeatureIndicator.DAITA,
+ FeatureIndicator.DAITA_MULTIHOP -> DaitaDestination(isModal = true)
+ FeatureIndicator.MULTIHOP -> MultihopDestination(isModal = true)
+ FeatureIndicator.SPLIT_TUNNELING -> SplitTunnelingDestination(isModal = true)
+
+ FeatureIndicator.SERVER_IP_OVERRIDE -> ServerIpOverridesDestination(isModal = true)
+
+ FeatureIndicator.QUANTUM_RESISTANCE,
+ FeatureIndicator.UDP_2_TCP,
+ FeatureIndicator.SHADOWSOCKS,
+ FeatureIndicator.LAN_SHARING,
+ FeatureIndicator.DNS_CONTENT_BLOCKERS,
+ FeatureIndicator.CUSTOM_DNS,
+ FeatureIndicator.CUSTOM_MTU ->
+ VpnSettingsDestination(scrollToFeature = this, isModal = true)
+ }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DaitaScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DaitaScreen.kt
index b0e12eeaab..ac95a6c0b6 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DaitaScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DaitaScreen.kt
@@ -1,5 +1,9 @@
package net.mullvad.mullvadvpn.compose.screen
+import android.os.Parcelable
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -35,15 +39,18 @@ import com.ramcosta.composedestinations.generated.destinations.DaitaDirectOnlyCo
import com.ramcosta.composedestinations.generated.destinations.DaitaDirectOnlyInfoDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.result.ResultRecipient
+import kotlinx.parcelize.Parcelize
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.cell.HeaderSwitchComposeCell
import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell
import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
+import net.mullvad.mullvadvpn.compose.component.NavigateCloseIconButton
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
import net.mullvad.mullvadvpn.compose.state.DaitaUiState
import net.mullvad.mullvadvpn.compose.test.DAITA_SCREEN_TEST_TAG
import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition
import net.mullvad.mullvadvpn.compose.util.OnNavResultValue
+import net.mullvad.mullvadvpn.lib.model.FeatureIndicator
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.viewmodel.DaitaViewModel
@@ -63,10 +70,14 @@ private fun PreviewDaitaScreen() {
}
}
-@Destination<RootGraph>(style = SlideInFromRightTransition::class)
+@Parcelize data class DaitaNavArgs(val isModal: Boolean = false) : Parcelable
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Destination<RootGraph>(style = SlideInFromRightTransition::class, navArgs = DaitaNavArgs::class)
@Composable
-fun Daita(
+fun SharedTransitionScope.Daita(
navigator: DestinationsNavigator,
+ animatedVisibilityScope: AnimatedVisibilityScope,
daitaConfirmationDialogResult: ResultRecipient<DaitaDirectOnlyConfirmationDestination, Boolean>,
) {
val viewModel = koinViewModel<DaitaViewModel>()
@@ -80,6 +91,12 @@ fun Daita(
DaitaScreen(
state = state,
+ modifier =
+ Modifier.testTag(DAITA_SCREEN_TEST_TAG)
+ .sharedBounds(
+ rememberSharedContentState(key = FeatureIndicator.DAITA),
+ animatedVisibilityScope = animatedVisibilityScope,
+ ),
onDaitaEnabled = viewModel::setDaita,
onDirectOnlyClick = { enable ->
if (enable) {
@@ -101,11 +118,18 @@ fun DaitaScreen(
onDirectOnlyClick: (enable: Boolean) -> Unit,
onDirectOnlyInfoClick: () -> Unit,
onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
) {
ScaffoldWithMediumTopBar(
appBarTitle = stringResource(id = R.string.daita),
- navigationIcon = { NavigateBackIconButton { onBackClick() } },
- modifier = Modifier.testTag(DAITA_SCREEN_TEST_TAG),
+ modifier = modifier,
+ navigationIcon = {
+ if (state.isModal) {
+ NavigateCloseIconButton { onBackClick() }
+ } else {
+ NavigateBackIconButton { onBackClick() }
+ }
+ },
) { modifier ->
Column(modifier = modifier) {
val pagerState = rememberPagerState(pageCount = { DaitaPages.entries.size })
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt
index 220cb50471..033ae8a0e7 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt
@@ -1,9 +1,15 @@
package net.mullvad.mullvadvpn.compose.screen
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionLayout
+import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
@@ -14,13 +20,18 @@ import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.generated.NavGraphs
import com.ramcosta.composedestinations.generated.destinations.NoDaemonDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.dependency
import com.ramcosta.composedestinations.rememberNavHostEngine
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
import net.mullvad.mullvadvpn.viewmodel.DaemonScreenEvent
import net.mullvad.mullvadvpn.viewmodel.MullvadAppViewModel
import org.koin.androidx.compose.koinViewModel
-@OptIn(ExperimentalComposeUiApi::class)
+val LocalNavAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null }
+@OptIn(ExperimentalSharedTransitionApi::class)
+val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }
+
+@OptIn(ExperimentalComposeUiApi::class, ExperimentalSharedTransitionApi::class)
@Composable
fun MullvadApp() {
val engine = rememberNavHostEngine()
@@ -34,12 +45,17 @@ fun MullvadApp() {
onDispose { navHostController.removeOnDestinationChangedListener(mullvadAppViewModel) }
}
- DestinationsNavHost(
- modifier = Modifier.semantics { testTagsAsResourceId = true }.fillMaxSize(),
- engine = engine,
- navController = navHostController,
- navGraph = NavGraphs.root,
- )
+ SharedTransitionLayout {
+ CompositionLocalProvider(LocalSharedTransitionScope provides this@SharedTransitionLayout) {
+ DestinationsNavHost(
+ modifier = Modifier.semantics { testTagsAsResourceId = true }.fillMaxSize(),
+ engine = engine,
+ navController = navHostController,
+ navGraph = NavGraphs.root,
+ dependenciesContainerBuilder = { dependency(this@SharedTransitionLayout) },
+ )
+ }
+ }
// For the following LaunchedEffect we do not use CollectSideEffectWithLifecycle since we
// collect from StateFlow/SharedFlow with replay and don't want to trigger a navigation again.
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MultihopScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MultihopScreen.kt
index 0c7be2aadc..5ef028e15f 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MultihopScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MultihopScreen.kt
@@ -1,5 +1,11 @@
+@file:OptIn(ExperimentalSharedTransitionApi::class)
+
package net.mullvad.mullvadvpn.compose.screen
+import android.os.Parcelable
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@@ -18,12 +24,15 @@ import androidx.lifecycle.compose.dropUnlessResumed
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import kotlinx.parcelize.Parcelize
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.cell.HeaderSwitchComposeCell
import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell
import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
+import net.mullvad.mullvadvpn.compose.component.NavigateCloseIconButton
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition
+import net.mullvad.mullvadvpn.lib.model.FeatureIndicator
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.viewmodel.MultihopUiState
@@ -36,13 +45,25 @@ private fun PreviewMultihopScreen() {
AppTheme { MultihopScreen(state = MultihopUiState(false), {}, {}) }
}
-@Destination<RootGraph>(style = SlideInFromRightTransition::class)
+@Parcelize data class MultihopNavArgs(val isModal: Boolean = false) : Parcelable
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Destination<RootGraph>(style = SlideInFromRightTransition::class, navArgs = MultihopNavArgs::class)
@Composable
-fun Multihop(navigator: DestinationsNavigator) {
+fun SharedTransitionScope.Multihop(
+ animatedVisibilityScope: AnimatedVisibilityScope,
+ navigator: DestinationsNavigator,
+) {
val viewModel = koinViewModel<MultihopViewModel>()
val state by viewModel.uiState.collectAsStateWithLifecycle()
+
MultihopScreen(
state = state,
+ modifier =
+ Modifier.sharedBounds(
+ rememberSharedContentState(key = FeatureIndicator.MULTIHOP),
+ animatedVisibilityScope = animatedVisibilityScope,
+ ),
onMultihopClick = viewModel::setMultihop,
onBackClick = dropUnlessResumed { navigator.navigateUp() },
)
@@ -53,10 +74,18 @@ fun MultihopScreen(
state: MultihopUiState,
onMultihopClick: (enable: Boolean) -> Unit,
onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
) {
ScaffoldWithMediumTopBar(
+ modifier = modifier,
appBarTitle = stringResource(id = R.string.multihop),
- navigationIcon = { NavigateBackIconButton { onBackClick() } },
+ navigationIcon = {
+ if (state.isModal) {
+ NavigateCloseIconButton(onBackClick)
+ } else {
+ NavigateBackIconButton(onNavigateBack = onBackClick)
+ }
+ },
) { modifier ->
Column(modifier = modifier) {
// Scale image to fit width up to certain width
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt
index 5fe0daccac..9117767a71 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt
@@ -1,8 +1,12 @@
package net.mullvad.mullvadvpn.compose.screen
import android.content.Context
+import android.os.Parcelable
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
@@ -53,6 +57,7 @@ import com.ramcosta.composedestinations.generated.destinations.ServerIpOverrides
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.result.ResultRecipient
import kotlinx.coroutines.launch
+import kotlinx.parcelize.Parcelize
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.InfoIconButton
import net.mullvad.mullvadvpn.compose.button.PrimaryButton
@@ -62,6 +67,7 @@ import net.mullvad.mullvadvpn.compose.cell.ServerIpOverridesCell
import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomSheet
import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar
import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
+import net.mullvad.mullvadvpn.compose.component.NavigateCloseIconButton
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
import net.mullvad.mullvadvpn.compose.preview.ServerIpOverridesUiStatePreviewParameterProvider
import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDES_IMPORT_BY_FILE_TEST_TAG
@@ -74,6 +80,7 @@ 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.lib.model.FeatureIndicator
import net.mullvad.mullvadvpn.lib.model.SettingsPatchError
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
@@ -97,15 +104,22 @@ private fun PreviewServerIpOverridesScreen(
onResetOverridesClick = {},
onImportByFile = {},
onImportByText = {},
- SnackbarHostState(),
+ snackbarHostState = SnackbarHostState(),
)
}
}
-@Destination<RootGraph>(style = SlideInFromRightTransition::class)
+@Parcelize data class ServerIpOverridesNavArgs(val isModal: Boolean = false) : Parcelable
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Destination<RootGraph>(
+ style = SlideInFromRightTransition::class,
+ navArgs = ServerIpOverridesNavArgs::class,
+)
@Composable
-fun ServerIpOverrides(
+fun SharedTransitionScope.ServerIpOverrides(
navigator: DestinationsNavigator,
+ animatedVisibilityScope: AnimatedVisibilityScope,
importByTextResult: ResultRecipient<ImportOverridesByTextDestination, String>,
clearOverridesResult: ResultRecipient<ResetServerIpOverridesConfirmationDestination, Boolean>,
) {
@@ -159,7 +173,12 @@ fun ServerIpOverrides(
dropUnlessResumed { navigator.navigate(ResetServerIpOverridesConfirmationDestination) },
onImportByFile = dropUnlessResumed { openFileLauncher.launch("application/json") },
onImportByText = dropUnlessResumed { navigator.navigate(ImportOverridesByTextDestination) },
- snackbarHostState,
+ snackbarHostState = snackbarHostState,
+ modifier =
+ Modifier.sharedBounds(
+ rememberSharedContentState(key = FeatureIndicator.SERVER_IP_OVERRIDE),
+ animatedVisibilityScope = animatedVisibilityScope,
+ ),
)
}
@@ -172,6 +191,7 @@ fun ServerIpOverridesScreen(
onResetOverridesClick: () -> Unit,
onImportByFile: () -> Unit,
onImportByText: () -> Unit,
+ modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
@@ -180,7 +200,14 @@ fun ServerIpOverridesScreen(
ScaffoldWithMediumTopBar(
appBarTitle = stringResource(id = R.string.server_ip_override),
- navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) },
+ modifier = modifier,
+ navigationIcon = {
+ if (state.isModal) {
+ NavigateCloseIconButton(onBackClick)
+ } else {
+ NavigateBackIconButton(onNavigateBack = onBackClick)
+ }
+ },
actions = {
TopBarActions(
overridesActive = state.overridesActive,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt
index 0aad17a24e..e9bbe96bba 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt
@@ -69,15 +69,15 @@ fun Settings(navigator: DestinationsNavigator) {
val state by vm.uiState.collectAsStateWithLifecycle()
SettingsScreen(
state = state,
- onVpnSettingCellClick = dropUnlessResumed { navigator.navigate(VpnSettingsDestination) },
+ onVpnSettingCellClick = dropUnlessResumed { navigator.navigate(VpnSettingsDestination()) },
onSplitTunnelingCellClick =
- dropUnlessResumed { navigator.navigate(SplitTunnelingDestination) },
+ dropUnlessResumed { navigator.navigate(SplitTunnelingDestination()) },
onAppInfoClick = dropUnlessResumed { navigator.navigate(AppInfoDestination) },
onApiAccessClick = dropUnlessResumed { navigator.navigate(ApiAccessListDestination) },
onReportProblemCellClick =
dropUnlessResumed { navigator.navigate(ReportProblemDestination) },
- onMultihopClick = dropUnlessResumed { navigator.navigate(MultihopDestination) },
- onDaitaClick = dropUnlessResumed { navigator.navigate(DaitaDestination) },
+ onMultihopClick = dropUnlessResumed { navigator.navigate(MultihopDestination()) },
+ onDaitaClick = dropUnlessResumed { navigator.navigate(DaitaDestination()) },
onBackClick = dropUnlessResumed { navigator.navigateUp() },
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt
index bfd209b6ff..ec551ad39f 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt
@@ -1,6 +1,10 @@
package net.mullvad.mullvadvpn.compose.screen
import android.graphics.drawable.Drawable
+import android.os.Parcelable
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -27,6 +31,7 @@ import androidx.lifecycle.compose.dropUnlessResumed
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import kotlinx.parcelize.Parcelize
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.applist.AppData
import net.mullvad.mullvadvpn.compose.cell.HeaderCell
@@ -35,6 +40,7 @@ import net.mullvad.mullvadvpn.compose.cell.SplitTunnelingCell
import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell
import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge
import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
+import net.mullvad.mullvadvpn.compose.component.NavigateCloseIconButton
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
import net.mullvad.mullvadvpn.compose.component.textResource
import net.mullvad.mullvadvpn.compose.constant.CommonContentKey
@@ -45,6 +51,7 @@ import net.mullvad.mullvadvpn.compose.extensions.itemsIndexedWithDivider
import net.mullvad.mullvadvpn.compose.preview.SplitTunnelingUiStatePreviewParameterProvider
import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState
import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition
+import net.mullvad.mullvadvpn.lib.model.FeatureIndicator
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled
@@ -62,9 +69,18 @@ private fun PreviewSplitTunnelingScreen(
AppTheme { SplitTunnelingScreen(state = state, {}, {}, {}, {}, {}, { null }) }
}
-@Destination<RootGraph>(style = SlideInFromRightTransition::class)
+@Parcelize data class SplitTunnelingNavArgs(val isModal: Boolean = false) : Parcelable
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Destination<RootGraph>(
+ style = SlideInFromRightTransition::class,
+ navArgs = SplitTunnelingNavArgs::class,
+)
@Composable
-fun SplitTunneling(navigator: DestinationsNavigator) {
+fun SharedTransitionScope.SplitTunneling(
+ navigator: DestinationsNavigator,
+ animatedVisibilityScope: AnimatedVisibilityScope,
+) {
val viewModel = koinViewModel<SplitTunnelingViewModel>()
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
@@ -72,6 +88,11 @@ fun SplitTunneling(navigator: DestinationsNavigator) {
SplitTunnelingScreen(
state = state,
+ modifier =
+ Modifier.sharedBounds(
+ rememberSharedContentState(key = FeatureIndicator.SPLIT_TUNNELING),
+ animatedVisibilityScope = animatedVisibilityScope,
+ ),
onEnableSplitTunneling = viewModel::onEnableSplitTunneling,
onShowSystemAppsClick = viewModel::onShowSystemAppsClick,
onExcludeAppClick = viewModel::onExcludeAppClick,
@@ -90,13 +111,20 @@ fun SplitTunnelingScreen(
onIncludeAppClick: (packageName: String) -> Unit,
onBackClick: () -> Unit,
onResolveIcon: (String) -> Drawable?,
+ modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
ScaffoldWithMediumTopBar(
- modifier = Modifier.fillMaxSize(),
+ modifier = modifier.fillMaxSize(),
appBarTitle = stringResource(id = R.string.split_tunneling),
- navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) },
+ navigationIcon = {
+ if (state.isModal) {
+ NavigateCloseIconButton(onNavigateClose = onBackClick)
+ } else {
+ NavigateBackIconButton(onNavigateBack = onBackClick)
+ }
+ },
) { modifier, lazyListState ->
LazyColumn(
modifier = modifier.background(MaterialTheme.colorScheme.surface),
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 bf7dd56274..60368d7d0f 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
@@ -1,33 +1,46 @@
+@file:OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3Api::class)
+
package net.mullvad.mullvadvpn.compose.screen
import android.content.Context
+import android.os.Parcelable
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.animateContentSize
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.background
+import androidx.compose.animation.core.Animatable
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleEventObserver
-import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.dropUnlessResumed
import com.ramcosta.composedestinations.annotation.Destination
@@ -51,10 +64,11 @@ import com.ramcosta.composedestinations.generated.destinations.WireguardPortInfo
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.result.ResultRecipient
import kotlinx.coroutines.launch
+import kotlinx.parcelize.Parcelize
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.cell.BaseCell
+import net.mullvad.mullvadvpn.compose.cell.BaseSubtitleCell
import net.mullvad.mullvadvpn.compose.cell.ContentBlockersDisableModeCellSubtitle
-import net.mullvad.mullvadvpn.compose.cell.CustomDnsCellSubtitle
import net.mullvad.mullvadvpn.compose.cell.CustomPortCell
import net.mullvad.mullvadvpn.compose.cell.DnsCell
import net.mullvad.mullvadvpn.compose.cell.ExpandableComposeCell
@@ -68,16 +82,17 @@ 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.MullvadMediumTopBar
+import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar
import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
-import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
+import net.mullvad.mullvadvpn.compose.component.NavigateCloseIconButton
+import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
import net.mullvad.mullvadvpn.compose.component.textResource
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
-import net.mullvad.mullvadvpn.compose.extensions.itemsIndexedWithDivider
import net.mullvad.mullvadvpn.compose.preview.VpnSettingsUiStatePreviewParameterProvider
-import net.mullvad.mullvadvpn.compose.state.VpnSettingsUiState
+import net.mullvad.mullvadvpn.compose.state.VpnSettingItem
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
@@ -93,8 +108,9 @@ 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.WIREGUARD_PRESET_PORTS
+import net.mullvad.mullvadvpn.constant.SETTINGS_HIGHLIGHT_REPEAT_COUNT
import net.mullvad.mullvadvpn.lib.model.Constraint
+import net.mullvad.mullvadvpn.lib.model.FeatureIndicator
import net.mullvad.mullvadvpn.lib.model.IpVersion
import net.mullvad.mullvadvpn.lib.model.Mtu
import net.mullvad.mullvadvpn.lib.model.ObfuscationMode
@@ -103,7 +119,12 @@ import net.mullvad.mullvadvpn.lib.model.PortRange
import net.mullvad.mullvadvpn.lib.model.QuantumResistantState
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible
+import net.mullvad.mullvadvpn.util.indexOfFirstOrNull
import net.mullvad.mullvadvpn.viewmodel.VpnSettingsSideEffect
+import net.mullvad.mullvadvpn.viewmodel.VpnSettingsUiState
import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel
import org.koin.androidx.compose.koinViewModel
@@ -115,6 +136,7 @@ private fun PreviewVpnSettings(
AppTheme {
VpnSettingsScreen(
state = state,
+ initialScrollToFeature = null,
snackbarHostState = SnackbarHostState(),
onToggleBlockTrackers = {},
onToggleBlockAds = {},
@@ -141,21 +163,33 @@ private fun PreviewVpnSettings(
navigateToQuantumResistanceInfo = {},
navigateToWireguardPortInfo = {},
navigateToLocalNetworkSharingInfo = {},
- navigateToWireguardPortDialog = {},
+ navigateToWireguardPortDialog = { _, _ -> },
navigateToServerIpOverrides = {},
onSelectDeviceIpVersion = {},
onToggleIpv6 = {},
+ onToggleContentBlockersExpanded = {},
navigateToIpv6Info = {},
navigateToDeviceIpInfo = {},
)
}
}
-@Destination<RootGraph>(style = SlideInFromRightTransition::class)
+@Parcelize
+data class VpnSettingsNavArgs(
+ val scrollToFeature: FeatureIndicator? = null,
+ val isModal: Boolean = false,
+) : Parcelable
+
+@Destination<RootGraph>(
+ style = SlideInFromRightTransition::class,
+ navArgs = VpnSettingsNavArgs::class,
+)
@Composable
@Suppress("LongMethod")
-fun VpnSettings(
+fun SharedTransitionScope.VpnSettings(
navigator: DestinationsNavigator,
+ animatedVisibilityScope: AnimatedVisibilityScope,
+ navArgs: VpnSettingsNavArgs,
dnsDialogResult: ResultRecipient<DnsDestination, DnsDialogResult>,
customWgPortResult: ResultRecipient<WireguardCustomPortDestination, Port?>,
mtuDialogResult: ResultRecipient<MtuDestination, Boolean>,
@@ -167,14 +201,9 @@ fun VpnSettings(
when (result) {
is DnsDialogResult.Success -> {
vm.showApplySettingChangesWarningToast()
- if (result.isDnsListEmpty) {
- vm.onToggleCustomDns(false)
- }
}
- DnsDialogResult.Cancel -> vm.onDnsDialogDismissed()
DnsDialogResult.Error -> {
vm.showGenericErrorToast()
- vm.onDnsDialogDismissed()
}
}
}
@@ -204,19 +233,14 @@ fun VpnSettings(
}
}
- val lifecycleOwner = LocalLifecycleOwner.current
- DisposableEffect(lifecycleOwner) {
- val observer = LifecycleEventObserver { _, event ->
- if (event == Lifecycle.Event.ON_STOP) {
- vm.onStopEvent()
- }
- }
- lifecycleOwner.lifecycle.addObserver(observer)
- onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
- }
-
VpnSettingsScreen(
state = state,
+ initialScrollToFeature = navArgs.scrollToFeature,
+ modifier =
+ Modifier.sharedBounds(
+ rememberSharedContentState(key = navArgs.scrollToFeature ?: ""),
+ animatedVisibilityScope = animatedVisibilityScope,
+ ),
snackbarHostState = snackbarHostState,
navigateToContentBlockersInfo =
dropUnlessResumed { navigator.navigate(ContentBlockersInfoDestination) },
@@ -240,7 +264,8 @@ fun VpnSettings(
navigateToLocalNetworkSharingInfo =
dropUnlessResumed { navigator.navigate(LocalNetworkSharingInfoDestination) },
navigateToServerIpOverrides =
- dropUnlessResumed { navigator.navigate(ServerIpOverridesDestination) },
+ dropUnlessResumed { navigator.navigate(ServerIpOverridesDestination()) },
+ onToggleContentBlockersExpanded = vm::onToggleContentBlockersExpand,
onToggleBlockTrackers = vm::onToggleBlockTrackers,
onToggleBlockAds = vm::onToggleBlockAds,
onToggleBlockMalware = vm::onToggleBlockMalware,
@@ -255,12 +280,12 @@ fun VpnSettings(
navigator.navigate(DnsDestination(index, address))
},
navigateToWireguardPortDialog =
- dropUnlessResumed {
+ dropUnlessResumed { customPort, availablePortRanges ->
navigator.navigate(
WireguardCustomPortDestination(
CustomPortNavArgs(
- customPort = state.customWireguardPort,
- allowedPortRanges = state.availablePortRanges,
+ customPort = customPort,
+ allowedPortRanges = availablePortRanges,
)
)
)
@@ -282,11 +307,12 @@ fun VpnSettings(
)
}
-@Suppress("LongMethod", "LongParameterList")
-@OptIn(ExperimentalFoundationApi::class)
+@Suppress("LongParameterList")
@Composable
fun VpnSettingsScreen(
state: VpnSettingsUiState,
+ initialScrollToFeature: FeatureIndicator?,
+ modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
navigateToContentBlockersInfo: () -> Unit,
navigateToAutoConnectScreen: () -> Unit,
@@ -296,8 +322,10 @@ fun VpnSettingsScreen(
navigateToQuantumResistanceInfo: () -> Unit,
navigateToWireguardPortInfo: (availablePortRanges: List<PortRange>) -> Unit,
navigateToLocalNetworkSharingInfo: () -> Unit,
- navigateToWireguardPortDialog: () -> Unit,
+ navigateToWireguardPortDialog:
+ (customPort: Port?, availablePortRanges: List<PortRange>) -> Unit,
navigateToServerIpOverrides: () -> Unit,
+ onToggleContentBlockersExpanded: () -> Unit,
onToggleBlockTrackers: (Boolean) -> Unit,
onToggleBlockAds: (Boolean) -> Unit,
onToggleBlockMalware: (Boolean) -> Unit,
@@ -320,172 +348,211 @@ fun VpnSettingsScreen(
navigateToIpv6Info: () -> Unit,
navigateToDeviceIpInfo: () -> Unit,
) {
- var expandContentBlockersState by rememberSaveable { mutableStateOf(false) }
- val topPadding = 6.dp
+ val appBarState = rememberTopAppBarState()
+ val canScroll = remember { mutableStateOf(false) }
+ val scrollBehavior =
+ TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
+ appBarState,
+ canScroll = { canScroll.value },
+ )
+ Scaffold(
+ modifier = modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = {
+ MullvadMediumTopBar(
+ title = stringResource(id = R.string.settings_vpn),
+ navigationIcon = {
+ if (state.isModal) {
+ NavigateCloseIconButton(onNavigateClose = onBackClick)
+ } else {
+ NavigateBackIconButton(onNavigateBack = onBackClick)
+ }
+ },
+ scrollBehavior = scrollBehavior,
+ )
+ },
+ snackbarHost = {
+ SnackbarHost(
+ snackbarHostState,
+ snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) },
+ )
+ },
+ content = {
+ Box(modifier = Modifier.fillMaxSize().padding(it)) {
+ when (state) {
+ is VpnSettingsUiState.Loading ->
+ CircularProgressIndicator(modifier.align(Alignment.Center))
- ScaffoldWithMediumTopBar(
- appBarTitle = stringResource(id = R.string.settings_vpn),
- navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) },
- snackbarHostState = snackbarHostState,
- ) { modifier, lazyListState ->
- LazyColumn(
- modifier = modifier.testTag(LAZY_LIST_VPN_SETTINGS_TEST_TAG).animateContentSize(),
- state = lazyListState,
- ) {
- if (state.systemVpnSettingsAvailable) {
- item {
- NavigationComposeCell(
- title = stringResource(id = R.string.auto_connect_and_lockdown_mode),
- onClick = { navigateToAutoConnectScreen() },
- )
- }
- item {
- SwitchComposeSubtitleCell(
- text = stringResource(id = R.string.auto_connect_and_lockdown_mode_footer)
- )
- }
- } else {
- item {
- HeaderSwitchComposeCell(
- title = stringResource(R.string.connect_on_start),
- isToggled = state.autoStartAndConnectOnBoot,
- onCellClicked = { newValue -> onToggleAutoStartAndConnectOnBoot(newValue) },
- )
- SwitchComposeSubtitleCell(
- text =
- textResource(
- R.string.connect_on_start_footer,
- textResource(R.string.auto_connect_and_lockdown_mode),
- )
- )
+ is VpnSettingsUiState.Content ->
+ VpnSettingsContent(
+ state,
+ initialScrollToFeature,
+ canScroll,
+ navigateToContentBlockersInfo,
+ navigateToAutoConnectScreen,
+ navigateToCustomDnsInfo,
+ navigateToMalwareInfo,
+ navigateToObfuscationInfo,
+ navigateToQuantumResistanceInfo,
+ navigateToWireguardPortInfo,
+ navigateToLocalNetworkSharingInfo,
+ navigateToWireguardPortDialog,
+ navigateToServerIpOverrides,
+ onToggleContentBlockersExpanded,
+ onToggleBlockTrackers,
+ onToggleBlockAds,
+ onToggleBlockMalware,
+ onToggleLocalNetworkSharing,
+ onToggleBlockAdultContent,
+ onToggleBlockGambling,
+ onToggleBlockSocialMedia,
+ navigateToMtuDialog,
+ navigateToDns,
+ onToggleDnsClick,
+ onSelectObfuscationMode,
+ onSelectQuantumResistanceSetting,
+ onWireguardPortSelected,
+ navigateToShadowSocksSettings,
+ navigateToUdp2TcpSettings,
+ onToggleAutoStartAndConnectOnBoot,
+ onSelectDeviceIpVersion,
+ onToggleIpv6,
+ navigateToIpv6Info,
+ )
}
}
+ },
+ )
+}
- item {
- HeaderSwitchComposeCell(
- title = stringResource(R.string.local_network_sharing),
- isToggled = state.isLocalNetworkSharingEnabled,
- isEnabled = true,
- onCellClicked = { newValue -> onToggleLocalNetworkSharing(newValue) },
- onInfoClicked = navigateToLocalNetworkSharingInfo,
- )
- Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing))
- }
+@Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod")
+@Composable
+fun VpnSettingsContent(
+ state: VpnSettingsUiState.Content,
+ initialScrollToFeature: FeatureIndicator?,
+ canScroll: MutableState<Boolean>,
+ navigateToContentBlockersInfo: () -> Unit,
+ navigateToAutoConnectScreen: () -> Unit,
+ navigateToCustomDnsInfo: () -> Unit,
+ navigateToMalwareInfo: () -> Unit,
+ navigateToObfuscationInfo: () -> Unit,
+ navigateToQuantumResistanceInfo: () -> Unit,
+ navigateToWireguardPortInfo: (availablePortRanges: List<PortRange>) -> Unit,
+ navigateToLocalNetworkSharingInfo: () -> Unit,
+ navigateToWireguardPortDialog:
+ (customPort: Port?, availablePortRanges: List<PortRange>) -> Unit,
+ navigateToServerIpOverrides: () -> Unit,
+ onToggleContentBlockersExpanded: () -> Unit,
+ onToggleBlockTrackers: (Boolean) -> Unit,
+ onToggleBlockAds: (Boolean) -> Unit,
+ onToggleBlockMalware: (Boolean) -> Unit,
+ onToggleLocalNetworkSharing: (Boolean) -> Unit,
+ onToggleBlockAdultContent: (Boolean) -> Unit,
+ onToggleBlockGambling: (Boolean) -> Unit,
+ onToggleBlockSocialMedia: (Boolean) -> Unit,
+ navigateToMtuDialog: (mtu: Mtu?) -> Unit,
+ navigateToDns: (index: Int?, address: String?) -> Unit,
+ onToggleDnsClick: (Boolean) -> Unit,
+ onSelectObfuscationMode: (obfuscationMode: ObfuscationMode) -> Unit,
+ onSelectQuantumResistanceSetting: (quantumResistant: QuantumResistantState) -> Unit,
+ onWireguardPortSelected: (port: Constraint<Port>) -> Unit,
+ navigateToShadowSocksSettings: () -> Unit,
+ navigateToUdp2TcpSettings: () -> Unit,
+ onToggleAutoStartAndConnectOnBoot: (Boolean) -> Unit,
+ onSelectDeviceIpVersion: (ipVersion: Constraint<IpVersion>) -> Unit,
+ onToggleIpv6: (Boolean) -> Unit,
+ navigateToIpv6Info: () -> Unit,
+) {
+ val initialIndexFocus =
+ when (initialScrollToFeature) {
+ FeatureIndicator.UDP_2_TCP,
+ FeatureIndicator.SHADOWSOCKS -> VpnSettingItem.ObfuscationHeader::class
+ FeatureIndicator.LAN_SHARING -> VpnSettingItem.LocalNetworkSharingSetting::class
+ FeatureIndicator.QUANTUM_RESISTANCE -> VpnSettingItem.QuantumResistanceHeader::class
+ FeatureIndicator.DNS_CONTENT_BLOCKERS -> VpnSettingItem.DnsContentBlockersHeader::class
+ FeatureIndicator.CUSTOM_MTU -> VpnSettingItem.Mtu::class
+ else -> null
+ }?.let { clazz -> state.settings.indexOfFirstOrNull { it::class == clazz } } ?: 0
- itemWithDivider {
- ExpandableComposeCell(
- title = stringResource(R.string.dns_content_blockers),
- isExpanded = expandContentBlockersState,
- isEnabled = !state.isCustomDnsEnabled,
- onInfoClicked = { navigateToContentBlockersInfo() },
- onCellClicked = { expandContentBlockersState = !expandContentBlockersState },
- )
+ val highlightAnimation = remember { Animatable(AlphaVisible) }
+ if (initialScrollToFeature != null) {
+ LaunchedEffect(Unit) {
+ repeat(times = SETTINGS_HIGHLIGHT_REPEAT_COUNT) {
+ highlightAnimation.animateTo(AlphaInvisible)
+ highlightAnimation.animateTo(AlphaVisible)
}
+ }
+ }
- if (expandContentBlockersState) {
- itemWithDivider {
- NormalSwitchComposeCell(
- title = stringResource(R.string.block_ads_title),
- isToggled = state.contentBlockersOptions.blockAds,
- isEnabled = !state.isCustomDnsEnabled,
- onCellClicked = { onToggleBlockAds(it) },
- background = MaterialTheme.colorScheme.surfaceContainerLow,
- startPadding = Dimens.indentedCellStartPadding,
- )
- }
- itemWithDivider {
- NormalSwitchComposeCell(
- title = stringResource(R.string.block_trackers_title),
- isToggled = state.contentBlockersOptions.blockTrackers,
- isEnabled = !state.isCustomDnsEnabled,
- onCellClicked = { onToggleBlockTrackers(it) },
- background = MaterialTheme.colorScheme.surfaceContainerLow,
- startPadding = Dimens.indentedCellStartPadding,
- )
- }
- itemWithDivider {
- NormalSwitchComposeCell(
- title = stringResource(R.string.block_malware_title),
- isToggled = state.contentBlockersOptions.blockMalware,
- isEnabled = !state.isCustomDnsEnabled,
- onCellClicked = { onToggleBlockMalware(it) },
- onInfoClicked = { navigateToMalwareInfo() },
- background = MaterialTheme.colorScheme.surfaceContainerLow,
- startPadding = Dimens.indentedCellStartPadding,
- )
- }
- itemWithDivider {
- NormalSwitchComposeCell(
- title = stringResource(R.string.block_gambling_title),
- isToggled = state.contentBlockersOptions.blockGambling,
- isEnabled = !state.isCustomDnsEnabled,
- onCellClicked = { onToggleBlockGambling(it) },
- background = MaterialTheme.colorScheme.surfaceContainerLow,
- startPadding = Dimens.indentedCellStartPadding,
- )
- }
- itemWithDivider {
- NormalSwitchComposeCell(
- title = stringResource(R.string.block_adult_content_title),
- isToggled = state.contentBlockersOptions.blockAdultContent,
- isEnabled = !state.isCustomDnsEnabled,
- onCellClicked = { onToggleBlockAdultContent(it) },
- background = MaterialTheme.colorScheme.surfaceContainerLow,
- startPadding = Dimens.indentedCellStartPadding,
- )
- }
+ val highlightBackground: @Composable (featureIndicators: FeatureIndicator) -> Color =
+ { featureIndicator: FeatureIndicator ->
+ if (initialScrollToFeature == featureIndicator) {
+ MaterialTheme.colorScheme.primary.copy(alpha = highlightAnimation.value)
+ } else {
+ MaterialTheme.colorScheme.primary
+ }
+ }
- item {
- NormalSwitchComposeCell(
- title = stringResource(R.string.block_social_media_title),
- isToggled = state.contentBlockersOptions.blockSocialMedia,
- isEnabled = !state.isCustomDnsEnabled,
- onCellClicked = { onToggleBlockSocialMedia(it) },
- background = MaterialTheme.colorScheme.surfaceContainerLow,
- startPadding = Dimens.indentedCellStartPadding,
- )
- }
+ val lazyListState = rememberLazyListState(initialIndexFocus)
+ canScroll.value = lazyListState.canScrollForward || lazyListState.canScrollBackward
+ LazyColumn(
+ modifier =
+ Modifier.testTag(LAZY_LIST_VPN_SETTINGS_TEST_TAG)
+ .fillMaxSize()
+ .drawVerticalScrollbar(
+ state = lazyListState,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = AlphaScrollbar),
+ )
+ .animateContentSize(),
+ state = lazyListState,
+ ) {
+ state.settings.forEach {
+ when (it) {
+ VpnSettingItem.AutoConnectAndLockdownMode ->
+ item(key = it::class.simpleName) {
+ NavigationComposeCell(
+ modifier = Modifier.animateItem(),
+ title = stringResource(id = R.string.auto_connect_and_lockdown_mode),
+ onClick = { navigateToAutoConnectScreen() },
+ )
+ }
- if (state.isCustomDnsEnabled) {
- item {
- ContentBlockersDisableModeCellSubtitle(
- Modifier.background(MaterialTheme.colorScheme.surface)
- .padding(
- start = Dimens.cellStartPadding,
- top = topPadding,
- end = Dimens.cellEndPadding,
- bottom = Dimens.cellVerticalSpacing,
- )
+ VpnSettingItem.AutoConnectAndLockdownModeInfo ->
+ item(key = it::class.simpleName) {
+ SwitchComposeSubtitleCell(
+ modifier = Modifier.animateItem(),
+ text =
+ stringResource(id = R.string.auto_connect_and_lockdown_mode_footer),
)
}
- }
- }
- item {
- HeaderSwitchComposeCell(
- title = stringResource(R.string.enable_custom_dns),
- isToggled = state.isCustomDnsEnabled,
- isEnabled = state.contentBlockersOptions.isAnyBlockerEnabled().not(),
- onCellClicked = { newValue -> onToggleDnsClick(newValue) },
- onInfoClicked = { navigateToCustomDnsInfo() },
- )
- }
+ is VpnSettingItem.ConnectDeviceOnStartUpSetting ->
+ item(key = it::class.simpleName) {
+ HeaderSwitchComposeCell(
+ modifier = Modifier.animateItem(),
+ title = stringResource(R.string.connect_on_start),
+ isToggled = it.enabled,
+ onCellClicked = { newValue ->
+ onToggleAutoStartAndConnectOnBoot(newValue)
+ },
+ )
+ }
- if (state.isCustomDnsEnabled) {
- itemsIndexedWithDivider(state.customDnsItems) { index, item ->
- DnsCell(
- address = item.address,
- isUnreachableLocalDnsWarningVisible =
- item.isLocal && !state.isLocalNetworkSharingEnabled,
- isUnreachableIpv6DnsWarningVisible = item.isIpv6 && !state.isIpv6Enabled,
- onClick = { navigateToDns(index, item.address) },
- modifier = Modifier.animateItem(),
- )
- }
+ VpnSettingItem.ConnectDeviceOnStartUpInfo ->
+ item(key = it::class.simpleName) {
+ SwitchComposeSubtitleCell(
+ modifier = Modifier.animateItem(),
+ text =
+ textResource(
+ R.string.connect_on_start_footer,
+ textResource(R.string.auto_connect_and_lockdown_mode),
+ ),
+ )
+ }
- if (state.customDnsItems.isNotEmpty()) {
- itemWithDivider {
+ VpnSettingItem.CustomDnsAdd ->
+ item(key = it::class.simpleName) {
BaseCell(
+ modifier = Modifier.animateItem(),
onCellClicked = { navigateToDns(null, null) },
headlineContent = {
Text(
@@ -498,190 +565,162 @@ fun VpnSettingsScreen(
startPadding = Dimens.cellStartPaddingLarge,
)
}
- }
- }
- item {
- CustomDnsCellSubtitle(
- isCellClickable = state.contentBlockersOptions.isAnyBlockerEnabled().not(),
- modifier =
- Modifier.padding(
- start = Dimens.cellStartPadding,
- top = topPadding,
- end = Dimens.cellEndPadding,
- bottom = Dimens.cellVerticalSpacing,
- ),
- )
- }
+ is VpnSettingItem.CustomDnsEntry ->
+ item(key = it::class.simpleName + it.index) {
+ DnsCell(
+ address = it.customDnsItem.address,
+ isUnreachableLocalDnsWarningVisible = it.showUnreachableLocalDnsWarning,
+ isUnreachableIpv6DnsWarningVisible = it.showUnreachableIpv6DnsWarning,
+ onClick = { navigateToDns(it.index, it.customDnsItem.address) },
+ modifier = Modifier.animateItem(),
+ )
+ }
- item {
- HeaderSwitchComposeCell(
- title = stringResource(R.string.enable_ipv6),
- isToggled = state.isIpv6Enabled,
- isEnabled = true,
- onCellClicked = onToggleIpv6,
- onInfoClicked = navigateToIpv6Info,
- )
- Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing))
- }
+ VpnSettingItem.CustomDnsInfo ->
+ item(key = it::class.simpleName) {
+ BaseSubtitleCell(
+ text = textResource(id = R.string.custom_dns_footer),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.animateItem(),
+ )
+ }
- itemWithDivider {
- InformationComposeCell(
- title = stringResource(id = R.string.wireguard_port_title),
- onInfoClicked = { navigateToWireguardPortInfo(state.availablePortRanges) },
- onCellClicked = { navigateToWireguardPortInfo(state.availablePortRanges) },
- isEnabled = state.isWireguardPortEnabled,
- )
- }
+ is VpnSettingItem.CustomDnsServerSetting ->
+ item(key = it::class.simpleName) {
+ HeaderSwitchComposeCell(
+ title = stringResource(R.string.enable_custom_dns),
+ isToggled = it.enabled,
+ isEnabled = it.isOptionEnabled,
+ onCellClicked = { newValue -> onToggleDnsClick(newValue) },
+ onInfoClicked = { navigateToCustomDnsInfo() },
+ background = highlightBackground(FeatureIndicator.CUSTOM_DNS),
+ modifier = Modifier.animateItem(),
+ )
+ }
+ VpnSettingItem.CustomDnsUnavailable ->
+ item(key = it::class.simpleName) {
+ BaseSubtitleCell(
+ textResource(
+ id = R.string.custom_dns_disable_mode_subtitle,
+ textResource(id = R.string.dns_content_blockers),
+ ),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.animateItem(),
+ )
+ }
- itemWithDivider {
- SelectableCell(
- title = stringResource(id = R.string.automatic),
- isSelected = state.selectedWireguardPort == Constraint.Any,
- onCellClicked = { onWireguardPortSelected(Constraint.Any) },
- isEnabled = state.isWireguardPortEnabled,
- )
- }
+ VpnSettingItem.DeviceIpVersionHeader ->
+ item(key = it::class.simpleName) {
+ InformationComposeCell(
+ title = stringResource(R.string.device_ip_version_title),
+ modifier = Modifier.animateItem(),
+ )
+ }
- WIREGUARD_PRESET_PORTS.forEach { port ->
- itemWithDivider {
- SelectableCell(
- title = port.toString(),
- testTag =
- String.format(
- null,
- LAZY_LIST_WIREGUARD_PORT_ITEM_X_TEST_TAG,
- port.value,
- ),
- isSelected = state.selectedWireguardPort.getOrNull() == port,
- onCellClicked = { onWireguardPortSelected(Constraint.Only(port)) },
- isEnabled = state.isWireguardPortEnabled,
- )
- }
- }
+ is VpnSettingItem.DeviceIpVersionItem ->
+ item(key = it::class.simpleName + it.constraint.getOrNull().toString()) {
+ SelectableCell(
+ modifier = Modifier.animateItem(),
+ title =
+ when (it.constraint) {
+ Constraint.Any -> stringResource(id = R.string.automatic)
- itemWithDivider {
- CustomPortCell(
- title = stringResource(id = R.string.wireguard_custon_port_title),
- isSelected = state.isCustomWireguardPort,
- port = state.customWireguardPort,
- onMainCellClicked = {
- if (state.customWireguardPort != null) {
- onWireguardPortSelected(Constraint.Only(state.customWireguardPort))
- } else {
- navigateToWireguardPortDialog()
- }
- },
- onPortCellClicked = navigateToWireguardPortDialog,
- isEnabled = state.isWireguardPortEnabled,
- mainTestTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG,
- numberTestTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG,
- )
- }
+ is Constraint.Only ->
+ when (it.constraint.value) {
+ IpVersion.IPV4 -> stringResource(id = R.string.ipv4)
- if (!state.isWireguardPortEnabled) {
- item {
- Text(
- text =
- stringResource(
- id = R.string.wg_port_subtitle,
- stringResource(R.string.wireguard),
- ),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier =
- Modifier.padding(
- start = Dimens.cellStartPadding,
- top = topPadding,
- end = Dimens.cellEndPadding,
- ),
- )
+ IpVersion.IPV6 -> stringResource(id = R.string.ipv6)
+ }
+ },
+ isSelected = it.selected,
+ onCellClicked = { onSelectDeviceIpVersion(it.constraint) },
+ )
+ }
+
+ VpnSettingItem.DeviceIpVersionInfo -> {
+ item(key = it::class.simpleName) {
+ BaseSubtitleCell(
+ text = stringResource(R.string.device_ip_version_subtitle),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.animateItem(),
+ )
+ }
}
- }
- itemWithDivider {
- Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing))
- InformationComposeCell(
- 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.obfuscationMode == ObfuscationMode.Auto,
- onCellClicked = { onSelectObfuscationMode(ObfuscationMode.Auto) },
- )
- }
- itemWithDivider {
- ObfuscationModeCell(
- obfuscationMode = ObfuscationMode.Shadowsocks,
- isSelected = state.obfuscationMode == ObfuscationMode.Shadowsocks,
- port = state.selectedShadowsSocksObfuscationPort,
- onSelected = onSelectObfuscationMode,
- onNavigate = navigateToShadowSocksSettings,
- testTag = WIREGUARD_OBFUSCATION_SHADOWSOCKS_CELL,
- )
- }
- itemWithDivider {
- ObfuscationModeCell(
- obfuscationMode = ObfuscationMode.Udp2Tcp,
- isSelected = state.obfuscationMode == ObfuscationMode.Udp2Tcp,
- port = state.selectedUdp2TcpObfuscationPort,
- onSelected = onSelectObfuscationMode,
- onNavigate = navigateToUdp2TcpSettings,
- testTag = WIREGUARD_OBFUSCATION_UDP_OVER_TCP_CELL,
- )
- }
- itemWithDivider {
- SelectableCell(
- title = stringResource(id = R.string.off),
- isSelected = state.obfuscationMode == ObfuscationMode.Off,
- onCellClicked = { onSelectObfuscationMode(ObfuscationMode.Off) },
- testTag = WIREGUARD_OBFUSCATION_OFF_CELL,
- )
- }
+ VpnSettingItem.Divider -> {
+ item(contentType = it::class.simpleName) {
+ HorizontalDivider(
+ modifier = Modifier.animateItem(),
+ color = Color.Transparent,
+ )
+ }
+ }
+
+ is VpnSettingItem.DnsContentBlockerItem.Ads ->
+ item(key = it::class.simpleName) {
+ NormalSwitchComposeCell(
+ title = stringResource(R.string.block_ads_title),
+ isToggled = it.enabled,
+ isEnabled = it.featureEnabled,
+ onCellClicked = { onToggleBlockAds(it) },
+ background = MaterialTheme.colorScheme.surfaceContainerLow,
+ startPadding = Dimens.indentedCellStartPadding,
+ modifier = Modifier.animateItem(),
+ )
+ }
+
+ is VpnSettingItem.DnsContentBlockerItem.AdultContent ->
+ item(key = it::class.simpleName) {
+ NormalSwitchComposeCell(
+ title = stringResource(R.string.block_adult_content_title),
+ isToggled = it.enabled,
+ isEnabled = it.featureEnabled,
+ onCellClicked = { onToggleBlockAdultContent(it) },
+ background = MaterialTheme.colorScheme.surfaceContainerLow,
+ startPadding = Dimens.indentedCellStartPadding,
+ modifier = Modifier.animateItem(),
+ )
+ }
+<<<<<<< HEAD
itemWithDivider {
- Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing))
InformationComposeCell(
- title = stringResource(R.string.quantum_resistant_title),
- onInfoClicked = navigateToQuantumResistanceInfo,
- onCellClicked = navigateToQuantumResistanceInfo,
+ title = stringResource(R.string.device_ip_version_title),
+ onInfoClicked = navigateToDeviceIpInfo,
)
}
itemWithDivider {
SelectableCell(
title = stringResource(id = R.string.automatic),
- isSelected = state.quantumResistant == QuantumResistantState.Auto,
- onCellClicked = { onSelectQuantumResistanceSetting(QuantumResistantState.Auto) },
+ isSelected = state.deviceIpVersion == Constraint.Any,
+ onCellClicked = { onSelectDeviceIpVersion(Constraint.Any) },
)
}
itemWithDivider {
SelectableCell(
- title = stringResource(id = R.string.on),
- testTag = LAZY_LIST_QUANTUM_ITEM_ON_TEST_TAG,
- isSelected = state.quantumResistant == QuantumResistantState.On,
- onCellClicked = { onSelectQuantumResistanceSetting(QuantumResistantState.On) },
+ title = stringResource(id = R.string.ipv4),
+ isSelected = state.deviceIpVersion.getOrNull() == IpVersion.IPV4,
+ onCellClicked = { onSelectDeviceIpVersion(Constraint.Only(IpVersion.IPV4)) },
)
}
item {
SelectableCell(
- title = stringResource(id = R.string.off),
- testTag = LAZY_LIST_QUANTUM_ITEM_OFF_TEST_TAG,
- isSelected = state.quantumResistant == QuantumResistantState.Off,
- onCellClicked = { onSelectQuantumResistanceSetting(QuantumResistantState.Off) },
+ title = stringResource(id = R.string.ipv6),
+ isSelected = state.deviceIpVersion.getOrNull() == IpVersion.IPV6,
+ onCellClicked = { onSelectDeviceIpVersion(Constraint.Only(IpVersion.IPV6)) },
)
- Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing))
}
-
+ item {
+ MtuComposeCell(mtuValue = state.mtu, onEditMtu = { navigateToMtuDialog(state.mtu) })
+ }
+ item { MtuSubtitle(modifier = Modifier.testTag(LAZY_LIST_LAST_ITEM_TEST_TAG)) }
+||||||| parent of f714aa727f (Implement quick access to active features)
itemWithDivider {
- InformationComposeCell(
- title = stringResource(R.string.device_ip_version_title),
- onInfoClicked = navigateToDeviceIpInfo,
- )
+ InformationComposeCell(title = stringResource(R.string.device_ip_version_title))
}
itemWithDivider {
SelectableCell(
@@ -705,18 +744,339 @@ fun VpnSettingsScreen(
)
}
item {
+ Text(
+ text = stringResource(R.string.device_ip_version_subtitle),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier =
+ Modifier.padding(
+ start = Dimens.cellStartPadding,
+ top = topPadding,
+ end = Dimens.cellEndPadding,
+ ),
+ )
+ Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing))
+ }
+
+ item {
MtuComposeCell(mtuValue = state.mtu, onEditMtu = { navigateToMtuDialog(state.mtu) })
}
item { MtuSubtitle(modifier = Modifier.testTag(LAZY_LIST_LAST_ITEM_TEST_TAG)) }
+=======
+ is VpnSettingItem.DnsContentBlockerItem.Gambling ->
+ item(key = it::class.simpleName) {
+ NormalSwitchComposeCell(
+ title = stringResource(R.string.block_gambling_title),
+ isToggled = it.enabled,
+ isEnabled = it.featureEnabled,
+ onCellClicked = { onToggleBlockGambling(it) },
+ background = MaterialTheme.colorScheme.surfaceContainerLow,
+ startPadding = Dimens.indentedCellStartPadding,
+ modifier = Modifier.animateItem(),
+ )
+ }
+
+ is VpnSettingItem.DnsContentBlockerItem.Malware ->
+ item(key = it::class.simpleName) {
+ NormalSwitchComposeCell(
+ modifier = Modifier.animateItem(),
+ title = stringResource(R.string.block_malware_title),
+ isToggled = it.enabled,
+ isEnabled = it.featureEnabled,
+ onCellClicked = { onToggleBlockMalware(it) },
+ onInfoClicked = { navigateToMalwareInfo() },
+ background = MaterialTheme.colorScheme.surfaceContainerLow,
+ startPadding = Dimens.indentedCellStartPadding,
+ )
+ }
+>>>>>>> f714aa727f (Implement quick access to active features)
- item { ServerIpOverrides(navigateToServerIpOverrides) }
+ is VpnSettingItem.DnsContentBlockerItem.SocialMedia ->
+ item(key = it::class.simpleName) {
+ NormalSwitchComposeCell(
+ modifier = Modifier.animateItem(),
+ title = stringResource(R.string.block_social_media_title),
+ isToggled = it.enabled,
+ isEnabled = it.featureEnabled,
+ onCellClicked = { onToggleBlockSocialMedia(it) },
+ background = MaterialTheme.colorScheme.surfaceContainerLow,
+ startPadding = Dimens.indentedCellStartPadding,
+ )
+ }
+
+ is VpnSettingItem.DnsContentBlockerItem.Trackers ->
+ item(key = it::class.simpleName) {
+ NormalSwitchComposeCell(
+ modifier = Modifier.animateItem(),
+ title = stringResource(R.string.block_trackers_title),
+ isToggled = it.enabled,
+ isEnabled = it.featureEnabled,
+ onCellClicked = { onToggleBlockTrackers(it) },
+ background = MaterialTheme.colorScheme.surfaceContainerLow,
+ startPadding = Dimens.indentedCellStartPadding,
+ )
+ }
+
+ is VpnSettingItem.DnsContentBlockersHeader ->
+ item(key = it::class.simpleName) {
+ ExpandableComposeCell(
+ modifier = Modifier.animateItem(),
+ title = stringResource(R.string.dns_content_blockers),
+ background = highlightBackground(FeatureIndicator.DNS_CONTENT_BLOCKERS),
+ isExpanded = it.expanded,
+ isEnabled = it.featureEnabled,
+ onInfoClicked = { navigateToContentBlockersInfo() },
+ onCellClicked = { onToggleContentBlockersExpanded() },
+ )
+ }
+
+ VpnSettingItem.DnsContentBlockersUnavailable ->
+ item(key = it::class.simpleName) {
+ ContentBlockersDisableModeCellSubtitle(modifier = Modifier.animateItem())
+ }
+
+ is VpnSettingItem.EnableIpv6Setting ->
+ item(key = it::class.simpleName) {
+ HeaderSwitchComposeCell(
+ title = stringResource(R.string.enable_ipv6),
+ isToggled = it.enabled,
+ isEnabled = true,
+ onCellClicked = onToggleIpv6,
+ onInfoClicked = navigateToIpv6Info,
+ modifier = Modifier.animateItem(),
+ )
+ }
+
+ is VpnSettingItem.LocalNetworkSharingSetting ->
+ item(key = it::class.simpleName) {
+ HeaderSwitchComposeCell(
+ background = highlightBackground(FeatureIndicator.LAN_SHARING),
+ title = stringResource(R.string.local_network_sharing),
+ isToggled = it.enabled,
+ isEnabled = true,
+ modifier = Modifier.animateItem(),
+ onCellClicked = { newValue -> onToggleLocalNetworkSharing(newValue) },
+ onInfoClicked = navigateToLocalNetworkSharingInfo,
+ )
+ }
+
+ is VpnSettingItem.Mtu ->
+ item(key = it::class.simpleName) {
+ MtuComposeCell(
+ mtuValue = it.mtu,
+ onEditMtu = { navigateToMtuDialog(it.mtu) },
+ modifier = Modifier.animateItem(),
+ background = highlightBackground(FeatureIndicator.CUSTOM_MTU),
+ )
+ }
+
+ VpnSettingItem.MtuInfo ->
+ item(key = it::class.simpleName) {
+ MtuSubtitle(
+ modifier = Modifier.testTag(LAZY_LIST_LAST_ITEM_TEST_TAG).animateItem()
+ )
+ }
+
+ VpnSettingItem.ObfuscationHeader ->
+ item(key = it::class.simpleName) {
+ InformationComposeCell(
+ title = stringResource(R.string.obfuscation_title),
+ onInfoClicked = navigateToObfuscationInfo,
+ onCellClicked = navigateToObfuscationInfo,
+ background =
+ if (
+ initialScrollToFeature == FeatureIndicator.UDP_2_TCP ||
+ initialScrollToFeature == FeatureIndicator.SHADOWSOCKS
+ ) {
+ MaterialTheme.colorScheme.primary.copy(
+ alpha = highlightAnimation.value
+ )
+ } else {
+ MaterialTheme.colorScheme.primary
+ },
+ testTag = LAZY_LIST_WIREGUARD_OBFUSCATION_TITLE_TEST_TAG,
+ modifier = Modifier.animateItem(),
+ )
+ }
+
+ is VpnSettingItem.ObfuscationItem.Automatic ->
+ item(key = it::class.simpleName) {
+ SelectableCell(
+ title = stringResource(id = R.string.automatic),
+ isSelected = it.selected,
+ onCellClicked = { onSelectObfuscationMode(ObfuscationMode.Auto) },
+ modifier = Modifier.animateItem(),
+ )
+ }
+
+ is VpnSettingItem.ObfuscationItem.Off ->
+ item(key = it::class.simpleName) {
+ SelectableCell(
+ title = stringResource(id = R.string.off),
+ isSelected = it.selected,
+ onCellClicked = { onSelectObfuscationMode(ObfuscationMode.Off) },
+ testTag = WIREGUARD_OBFUSCATION_OFF_CELL,
+ modifier = Modifier.animateItem(),
+ )
+ }
+
+ is VpnSettingItem.ObfuscationItem.Shadowsocks ->
+ item(key = it::class.simpleName) {
+ ObfuscationModeCell(
+ obfuscationMode = ObfuscationMode.Shadowsocks,
+ isSelected = it.selected,
+ port = it.port,
+ onSelected = onSelectObfuscationMode,
+ onNavigate = navigateToShadowSocksSettings,
+ testTag = WIREGUARD_OBFUSCATION_SHADOWSOCKS_CELL,
+ modifier = Modifier.animateItem(),
+ )
+ }
+
+ is VpnSettingItem.ObfuscationItem.UdpOverTcp ->
+ item(key = it::class.simpleName) {
+ ObfuscationModeCell(
+ obfuscationMode = ObfuscationMode.Udp2Tcp,
+ isSelected = it.selected,
+ port = it.port,
+ onSelected = onSelectObfuscationMode,
+ onNavigate = navigateToUdp2TcpSettings,
+ testTag = WIREGUARD_OBFUSCATION_UDP_OVER_TCP_CELL,
+ modifier = Modifier.animateItem(),
+ )
+ }
+
+ is VpnSettingItem.QuantumItem ->
+ item(key = it::class.simpleName + it.quantumResistantState) {
+ SelectableCell(
+ title =
+ when (it.quantumResistantState) {
+ QuantumResistantState.Auto ->
+ stringResource(id = R.string.automatic)
+
+ QuantumResistantState.Off -> stringResource(id = R.string.off)
+
+ QuantumResistantState.On -> stringResource(id = R.string.on)
+ },
+ isSelected = it.selected,
+ modifier = Modifier.animateItem(),
+ onCellClicked = {
+ onSelectQuantumResistanceSetting(it.quantumResistantState)
+ },
+ testTag =
+ when (it.quantumResistantState) {
+ QuantumResistantState.Auto -> ""
+ QuantumResistantState.On -> LAZY_LIST_QUANTUM_ITEM_ON_TEST_TAG
+
+ QuantumResistantState.Off -> LAZY_LIST_QUANTUM_ITEM_OFF_TEST_TAG
+ },
+ )
+ }
+
+ VpnSettingItem.QuantumResistanceHeader ->
+ item(key = it::class.simpleName) {
+ InformationComposeCell(
+ title = stringResource(R.string.quantum_resistant_title),
+ background = highlightBackground(FeatureIndicator.QUANTUM_RESISTANCE),
+ onInfoClicked = navigateToQuantumResistanceInfo,
+ onCellClicked = navigateToQuantumResistanceInfo,
+ modifier = Modifier.animateItem(),
+ )
+ }
+
+ VpnSettingItem.ServerIpOverrides ->
+ item(key = it::class.simpleName) {
+ ServerIpOverrides(navigateToServerIpOverrides, Modifier.animateItem())
+ }
+
+ VpnSettingItem.Spacer ->
+ item(contentType = it::class.simpleName) {
+ Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing).animateItem())
+ }
+
+ is VpnSettingItem.WireguardPortHeader ->
+ item(key = it::class.simpleName) {
+ InformationComposeCell(
+ title = stringResource(id = R.string.wireguard_port_title),
+ onInfoClicked = { navigateToWireguardPortInfo(it.availablePortRanges) },
+ onCellClicked = { navigateToWireguardPortInfo(it.availablePortRanges) },
+ isEnabled = it.enabled,
+ modifier = Modifier.animateItem(),
+ )
+ }
+
+ is VpnSettingItem.WireguardPortItem.Constraint ->
+ item(key = it::class.simpleName + it.constraint) {
+ SelectableCell(
+ title =
+ when (it.constraint) {
+ is Constraint.Only -> it.constraint.value.toString()
+
+ is Constraint.Any -> stringResource(id = R.string.automatic)
+ },
+ testTag =
+ when (it.constraint) {
+ is Constraint.Only ->
+ String.format(
+ null,
+ LAZY_LIST_WIREGUARD_PORT_ITEM_X_TEST_TAG,
+ it.constraint.value.value,
+ )
+
+ is Constraint.Any -> ""
+ },
+ isSelected = it.selected,
+ onCellClicked = { onWireguardPortSelected(it.constraint) },
+ isEnabled = it.enabled,
+ modifier = Modifier.animateItem(),
+ )
+ }
+
+ is VpnSettingItem.WireguardPortItem.WireguardPortCustom ->
+ item(key = it::class.simpleName) {
+ CustomPortCell(
+ title = stringResource(id = R.string.wireguard_custon_port_title),
+ isSelected = it.selected,
+ port = it.customPort,
+ onMainCellClicked = {
+ if (it.customPort != null) {
+ onWireguardPortSelected(Constraint.Only(it.customPort))
+ } else {
+ navigateToWireguardPortDialog(null, it.availablePortRanges)
+ }
+ },
+ onPortCellClicked = {
+ navigateToWireguardPortDialog(it.customPort, it.availablePortRanges)
+ },
+ isEnabled = it.enabled,
+ modifier = Modifier.animateItem(),
+ mainTestTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG,
+ numberTestTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG,
+ )
+ }
+
+ VpnSettingItem.WireguardPortUnavailable ->
+ item(key = it::class.simpleName) {
+ BaseSubtitleCell(
+ text =
+ stringResource(
+ id = R.string.wg_port_subtitle,
+ stringResource(R.string.wireguard),
+ ),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.animateItem(),
+ )
+ }
+ }
}
}
}
@Composable
-private fun ServerIpOverrides(onServerIpOverridesClick: () -> Unit) {
+private fun ServerIpOverrides(onServerIpOverridesClick: () -> Unit, modifier: Modifier = Modifier) {
NavigationComposeCell(
+ modifier = modifier,
title = stringResource(id = R.string.server_ip_override),
onClick = onServerIpOverridesClick,
)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt
index cac37053ad..71401551b1 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt
@@ -218,7 +218,8 @@ fun SelectLocation(
)
},
onSelectRelayList = vm::selectRelayList,
- openDaitaSettings = dropUnlessResumed { navigator.navigate(DaitaDestination) },
+ openDaitaSettings =
+ dropUnlessResumed { navigator.navigate(DaitaDestination(isModal = true)) },
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DaitaUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DaitaUiState.kt
index 59f00d0347..cf61ec67e5 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DaitaUiState.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DaitaUiState.kt
@@ -1,3 +1,7 @@
package net.mullvad.mullvadvpn.compose.state
-data class DaitaUiState(val daitaEnabled: Boolean, val directOnly: Boolean)
+data class DaitaUiState(
+ val daitaEnabled: Boolean,
+ val directOnly: Boolean,
+ val isModal: Boolean = false,
+)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterChip.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterChip.kt
deleted file mode 100644
index 8439680500..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterChip.kt
+++ /dev/null
@@ -1 +0,0 @@
-package net.mullvad.mullvadvpn.compose.state
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SplitTunnelingUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SplitTunnelingUiState.kt
index c466dcc2da..795e69a62c 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SplitTunnelingUiState.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SplitTunnelingUiState.kt
@@ -4,13 +4,18 @@ import net.mullvad.mullvadvpn.applist.AppData
sealed interface SplitTunnelingUiState {
val enabled: Boolean
+ val isModal: Boolean
- data class Loading(override val enabled: Boolean = false) : SplitTunnelingUiState
+ data class Loading(
+ override val enabled: Boolean = false,
+ override val isModal: Boolean = false,
+ ) : SplitTunnelingUiState
data class ShowAppList(
override val enabled: Boolean = false,
val excludedApps: List<AppData> = emptyList(),
val includedApps: List<AppData> = emptyList(),
val showSystemApps: Boolean = false,
+ override val isModal: Boolean = false,
) : SplitTunnelingUiState
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingItem.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingItem.kt
new file mode 100644
index 0000000000..d953695848
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingItem.kt
@@ -0,0 +1,136 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import net.mullvad.mullvadvpn.lib.model.Constraint
+import net.mullvad.mullvadvpn.lib.model.IpVersion
+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.viewmodel.CustomDnsItem
+
+sealed interface VpnSettingItem {
+
+ // Not available on TV devices
+ data object AutoConnectAndLockdownMode : VpnSettingItem
+
+ data object AutoConnectAndLockdownModeInfo : VpnSettingItem
+
+ // Only used on TV devices
+ data class ConnectDeviceOnStartUpSetting(val enabled: Boolean) : VpnSettingItem
+
+ data object ConnectDeviceOnStartUpInfo : VpnSettingItem
+
+ data class LocalNetworkSharingSetting(val enabled: Boolean) : VpnSettingItem
+
+ data class DnsContentBlockersHeader(val featureEnabled: Boolean, val expanded: Boolean) :
+ VpnSettingItem
+
+ sealed interface DnsContentBlockerItem : VpnSettingItem {
+ val enabled: Boolean
+ val featureEnabled: Boolean
+
+ data class Ads(override val enabled: Boolean, override val featureEnabled: Boolean) :
+ DnsContentBlockerItem
+
+ data class Trackers(override val enabled: Boolean, override val featureEnabled: Boolean) :
+ DnsContentBlockerItem
+
+ data class Malware(override val enabled: Boolean, override val featureEnabled: Boolean) :
+ DnsContentBlockerItem
+
+ data class Gambling(override val enabled: Boolean, override val featureEnabled: Boolean) :
+ DnsContentBlockerItem
+
+ data class AdultContent(
+ override val enabled: Boolean,
+ override val featureEnabled: Boolean,
+ ) : DnsContentBlockerItem
+
+ data class SocialMedia(
+ override val enabled: Boolean,
+ override val featureEnabled: Boolean,
+ ) : DnsContentBlockerItem
+ }
+
+ data object DnsContentBlockersUnavailable : VpnSettingItem
+
+ data class CustomDnsServerSetting(val enabled: Boolean, val isOptionEnabled: Boolean) :
+ VpnSettingItem
+
+ data class CustomDnsEntry(
+ val index: Int,
+ val customDnsItem: CustomDnsItem,
+ val showUnreachableLocalDnsWarning: Boolean,
+ val showUnreachableIpv6DnsWarning: Boolean,
+ ) : VpnSettingItem
+
+ data object CustomDnsAdd : VpnSettingItem
+
+ data object CustomDnsUnavailable : VpnSettingItem
+
+ data object CustomDnsInfo : VpnSettingItem
+
+ data class EnableIpv6Setting(val enabled: Boolean) : VpnSettingItem
+
+ data class WireguardPortHeader(val enabled: Boolean, val availablePortRanges: List<PortRange>) :
+ VpnSettingItem
+
+ sealed interface WireguardPortItem : VpnSettingItem {
+ val enabled: Boolean
+ val selected: Boolean
+
+ data class Constraint(
+ override val enabled: Boolean,
+ override val selected: Boolean,
+ val constraint: net.mullvad.mullvadvpn.lib.model.Constraint<Port>,
+ ) : WireguardPortItem
+
+ data class WireguardPortCustom(
+ override val enabled: Boolean,
+ override val selected: Boolean,
+ val customPort: Port?,
+ val availablePortRanges: List<PortRange>,
+ ) : WireguardPortItem
+ }
+
+ data object WireguardPortUnavailable : VpnSettingItem
+
+ data object ObfuscationHeader : VpnSettingItem
+
+ sealed interface ObfuscationItem : VpnSettingItem {
+ val selected: Boolean
+
+ data class Automatic(override val selected: Boolean) : ObfuscationItem
+
+ data class Shadowsocks(override val selected: Boolean, val port: Constraint<Port>) :
+ ObfuscationItem
+
+ data class UdpOverTcp(override val selected: Boolean, val port: Constraint<Port>) :
+ ObfuscationItem
+
+ data class Off(override val selected: Boolean) : ObfuscationItem
+ }
+
+ data object QuantumResistanceHeader : VpnSettingItem
+
+ data class QuantumItem(
+ val quantumResistantState: QuantumResistantState,
+ val selected: Boolean,
+ ) : VpnSettingItem
+
+ data object DeviceIpVersionHeader : VpnSettingItem
+
+ data class DeviceIpVersionItem(val constraint: Constraint<IpVersion>, val selected: Boolean) :
+ VpnSettingItem
+
+ data object DeviceIpVersionInfo : VpnSettingItem
+
+ data class Mtu(val mtu: net.mullvad.mullvadvpn.lib.model.Mtu?) : VpnSettingItem
+
+ data object MtuInfo : VpnSettingItem
+
+ data object ServerIpOverrides : VpnSettingItem
+
+ data object Divider : VpnSettingItem
+
+ data object Spacer : VpnSettingItem
+}
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
deleted file mode 100644
index 3756d547f9..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-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.IpVersion
-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.viewmodel.CustomDnsItem
-
-data class VpnSettingsUiState(
- val mtu: Mtu?,
- val isLocalNetworkSharingEnabled: Boolean,
- val isCustomDnsEnabled: Boolean,
- val customDnsItems: List<CustomDnsItem>,
- val contentBlockersOptions: DefaultDnsOptions,
- val obfuscationMode: ObfuscationMode,
- val selectedUdp2TcpObfuscationPort: Constraint<Port>,
- val selectedShadowsSocksObfuscationPort: Constraint<Port>,
- val quantumResistant: QuantumResistantState,
- val selectedWireguardPort: Constraint<Port>,
- val customWireguardPort: Port?,
- val availablePortRanges: List<PortRange>,
- val systemVpnSettingsAvailable: Boolean,
- val autoStartAndConnectOnBoot: Boolean,
- val deviceIpVersion: Constraint<IpVersion>,
- val isIpv6Enabled: Boolean,
-) {
- val isCustomWireguardPort =
- selectedWireguardPort is Constraint.Only &&
- selectedWireguardPort.value == customWireguardPort
-
- val isWireguardPortEnabled =
- obfuscationMode == ObfuscationMode.Auto || obfuscationMode == ObfuscationMode.Off
-
- companion object {
- fun createDefault(
- mtu: Mtu? = null,
- isLocalNetworkSharingEnabled: Boolean = false,
- isCustomDnsEnabled: Boolean = false,
- customDnsItems: List<CustomDnsItem> = emptyList(),
- contentBlockersOptions: DefaultDnsOptions = DefaultDnsOptions(),
- obfuscationMode: ObfuscationMode = ObfuscationMode.Off,
- selectedUdp2TcpObfuscationPort: Constraint<Port> = Constraint.Any,
- selectedShadowsSocksObfuscationPort: Constraint<Port> = Constraint.Any,
- quantumResistant: QuantumResistantState = QuantumResistantState.Off,
- selectedWireguardPort: Constraint<Port> = Constraint.Any,
- customWireguardPort: Port? = null,
- availablePortRanges: List<PortRange> = emptyList(),
- systemVpnSettingsAvailable: Boolean = false,
- autoStartAndConnectOnBoot: Boolean = false,
- deviceIpVersion: Constraint<IpVersion> = Constraint.Any,
- isIpv6Enabled: Boolean = true,
- ) =
- VpnSettingsUiState(
- mtu,
- isLocalNetworkSharingEnabled,
- isCustomDnsEnabled,
- customDnsItems,
- contentBlockersOptions,
- obfuscationMode,
- selectedUdp2TcpObfuscationPort,
- selectedShadowsSocksObfuscationPort,
- quantumResistant,
- selectedWireguardPort,
- customWireguardPort,
- availablePortRanges,
- systemVpnSettingsAvailable,
- autoStartAndConnectOnBoot,
- deviceIpVersion,
- isIpv6Enabled,
- )
- }
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt
index c84935af12..e85f92dc59 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt
@@ -14,5 +14,7 @@ const val SECURE_ZOOM = 1.15f
const val UNSECURE_ZOOM = 1.20f
const val SECURE_ZOOM_ANIMATION_MILLIS = 2000
+const val SETTINGS_HIGHLIGHT_REPEAT_COUNT = 3
+
// Location of Gothenburg, Sweden
val fallbackLatLong = LatLong(Latitude(57.7065f), Longitude(11.967f))
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 0f836724b7..0e86fa0f55 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
@@ -122,7 +122,7 @@ val uiModule = module {
ComponentName(androidContext(), BootCompletedReceiver::class.java)
}
- viewModel { SplitTunnelingViewModel(get(), get(), Dispatchers.Default) }
+ viewModel { SplitTunnelingViewModel(get(), get(), get(), Dispatchers.Default) }
single { ApplicationsProvider(get(), get(named(SELF_PACKAGE_NAME))) }
scope<MainActivity> { scoped { ServiceConnectionManager(androidContext()) } }
@@ -233,7 +233,7 @@ val uiModule = module {
viewModel { SettingsViewModel(get(), get(), get(), get(), IS_PLAY_BUILD) }
viewModel { SplashViewModel(get(), get(), get(), get()) }
viewModel { VoucherDialogViewModel(get()) }
- viewModel { VpnSettingsViewModel(get(), get(), get(), get(), get()) }
+ viewModel { VpnSettingsViewModel(get(), get(), get(), get(), get(), get()) }
viewModel { WelcomeViewModel(get(), get(), get(), get(), isPlayBuild = IS_PLAY_BUILD) }
viewModel { ReportProblemViewModel(get(), get()) }
viewModel { ViewLogsViewModel(get()) }
@@ -246,7 +246,7 @@ val uiModule = module {
viewModel { EditCustomListNameDialogViewModel(get(), get()) }
viewModel { CustomListsViewModel(get(), get()) }
viewModel { DeleteCustomListConfirmationViewModel(get(), get()) }
- viewModel { ServerIpOverridesViewModel(get(), get()) }
+ viewModel { ServerIpOverridesViewModel(get(), get(), get()) }
viewModel { ResetServerIpOverridesConfirmationViewModel(get()) }
viewModel { ApiAccessListViewModel(get()) }
viewModel { EditApiAccessMethodViewModel(get(), get(), get()) }
@@ -256,7 +256,7 @@ val uiModule = module {
viewModel { Udp2TcpSettingsViewModel(get()) }
viewModel { ShadowsocksSettingsViewModel(get(), get()) }
viewModel { ShadowsocksCustomPortDialogViewModel(get()) }
- viewModel { MultihopViewModel(get()) }
+ viewModel { MultihopViewModel(get(), get()) }
viewModel {
SearchLocationViewModel(
get(),
@@ -275,7 +275,7 @@ val uiModule = module {
viewModel { (relayListType: RelayListType) ->
SelectLocationListViewModel(relayListType, get(), get(), get(), get(), get(), get(), get())
}
- viewModel { DaitaViewModel(get()) }
+ viewModel { DaitaViewModel(get(), get()) }
// This view model must be single so we correctly attach lifecycle and share it with activity
single { MullvadAppViewModel(get(), get()) }
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 e5453fe57b..17ed523b82 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
@@ -32,18 +32,21 @@ class SettingsRepository(
)
suspend fun setDnsOptions(
- isCustomDnsEnabled: Boolean,
+ state: DnsState,
dnsList: List<InetAddress>,
contentBlockersOptions: DefaultDnsOptions,
) =
managementService.setDnsOptions(
DnsOptions(
- state = if (isCustomDnsEnabled) DnsState.Custom else DnsState.Default,
+ state = state,
customOptions = CustomDnsOptions(ArrayList(dnsList)),
defaultOptions = contentBlockersOptions,
)
)
+ suspend fun updateContentBlockers(update: (DefaultDnsOptions) -> DefaultDnsOptions) =
+ managementService.updateDnsContentBlockers(update)
+
suspend fun setDnsState(state: DnsState) = managementService.setDnsState(state)
suspend fun deleteCustomDns(index: Int) = managementService.deleteCustomDns(index)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/CollectionExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/CollectionExtensions.kt
new file mode 100644
index 0000000000..739e98d31b
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/CollectionExtensions.kt
@@ -0,0 +1,4 @@
+package net.mullvad.mullvadvpn.util
+
+fun <T> Iterable<T>.indexOfFirstOrNull(predicate: (T) -> Boolean): Int? =
+ indexOfFirst(predicate).takeIf { it > 0 }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt
index e0fa1d29b4..de79d159e1 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt
@@ -7,6 +7,19 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
+inline fun <T> Flow<T>.onFirst(crossinline action: suspend (T) -> Unit): Flow<T> {
+ return flow {
+ var first = true
+ collect { value ->
+ if (first) {
+ action(value)
+ first = false
+ }
+ emit(value)
+ }
+ }
+}
+
inline fun <T1, T2, T3, T4, T5, T6, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DaitaViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DaitaViewModel.kt
index 3243239fed..c6caeb6973 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DaitaViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DaitaViewModel.kt
@@ -1,7 +1,9 @@
package net.mullvad.mullvadvpn.viewmodel
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.ramcosta.composedestinations.generated.destinations.DaitaDestination
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@@ -10,7 +12,12 @@ import net.mullvad.mullvadvpn.compose.state.DaitaUiState
import net.mullvad.mullvadvpn.lib.model.Settings
import net.mullvad.mullvadvpn.repository.SettingsRepository
-class DaitaViewModel(private val settingsRepository: SettingsRepository) : ViewModel() {
+class DaitaViewModel(
+ private val settingsRepository: SettingsRepository,
+ savedStateHandle: SavedStateHandle,
+) : ViewModel() {
+
+ private val navArgs = DaitaDestination.argsFrom(savedStateHandle)
val uiState =
settingsRepository.settingsUpdates
@@ -18,6 +25,7 @@ class DaitaViewModel(private val settingsRepository: SettingsRepository) : ViewM
DaitaUiState(
daitaEnabled = settings?.daitaSettings()?.enabled == true,
directOnly = settings?.daitaSettings()?.directOnly == true,
+ navArgs.isModal,
)
}
.stateIn(
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModel.kt
index 4ff63b8fe7..278d0ab2e6 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModel.kt
@@ -1,7 +1,9 @@
package net.mullvad.mullvadvpn.viewmodel
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.ramcosta.composedestinations.generated.destinations.MultihopDestination
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
@@ -10,12 +12,14 @@ import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository
class MultihopViewModel(
- private val wireguardConstraintsRepository: WireguardConstraintsRepository
+ private val wireguardConstraintsRepository: WireguardConstraintsRepository,
+ savedStateHandle: SavedStateHandle,
) : ViewModel() {
+ private val navArgs = MultihopDestination.argsFrom(savedStateHandle)
val uiState: StateFlow<MultihopUiState> =
wireguardConstraintsRepository.wireguardConstraints
- .map { MultihopUiState(it?.isMultihopEnabled ?: false) }
+ .map { MultihopUiState(it?.isMultihopEnabled ?: false, isModal = navArgs.isModal) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), MultihopUiState(false))
fun setMultihop(enable: Boolean) {
@@ -23,4 +27,4 @@ class MultihopViewModel(
}
}
-data class MultihopUiState(val enable: Boolean)
+data class MultihopUiState(val enable: Boolean, val isModal: Boolean = false)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt
index bf8999493c..16da4d23e5 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt
@@ -2,8 +2,10 @@ package net.mullvad.mullvadvpn.viewmodel
import android.content.ContentResolver
import android.net.Uri
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.ramcosta.composedestinations.generated.destinations.ServerIpOverridesDestination
import java.io.InputStreamReader
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.SharingStarted
@@ -20,7 +22,9 @@ import net.mullvad.mullvadvpn.repository.RelayOverridesRepository
class ServerIpOverridesViewModel(
private val relayOverridesRepository: RelayOverridesRepository,
private val contentResolver: ContentResolver,
+ savedStateHandle: SavedStateHandle,
) : ViewModel() {
+ private val navArgs = ServerIpOverridesDestination.argsFrom(savedStateHandle)
private val _uiSideEffect = Channel<ServerIpOverridesUiSideEffect>()
val uiSideEffect = merge(_uiSideEffect.receiveAsFlow())
@@ -28,11 +32,16 @@ class ServerIpOverridesViewModel(
val uiState: StateFlow<ServerIpOverridesUiState> =
relayOverridesRepository.relayOverrides
.filterNotNull()
- .map { ServerIpOverridesUiState.Loaded(overridesActive = it.isNotEmpty()) }
+ .map {
+ ServerIpOverridesUiState.Loaded(
+ overridesActive = it.isNotEmpty(),
+ isModal = navArgs.isModal,
+ )
+ }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(),
- ServerIpOverridesUiState.Loading,
+ ServerIpOverridesUiState.Loading(navArgs.isModal),
)
fun importFile(uri: Uri) =
@@ -70,7 +79,12 @@ sealed interface ServerIpOverridesUiState {
val overridesActive: Boolean?
get() = (this as? Loaded)?.overridesActive
- data object Loading : ServerIpOverridesUiState
+ val isModal: Boolean
+
+ data class Loading(override val isModal: Boolean = false) : ServerIpOverridesUiState
- data class Loaded(override val overridesActive: Boolean) : ServerIpOverridesUiState
+ data class Loaded(
+ override val overridesActive: Boolean,
+ override val isModal: Boolean = false,
+ ) : ServerIpOverridesUiState
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt
index 939de6a38a..07c0383480 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt
@@ -1,7 +1,9 @@
package net.mullvad.mullvadvpn.viewmodel
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.ramcosta.composedestinations.generated.destinations.SplitTunnelingDestination
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -19,8 +21,10 @@ import net.mullvad.mullvadvpn.repository.SplitTunnelingRepository
class SplitTunnelingViewModel(
private val appsProvider: ApplicationsProvider,
private val splitTunnelingRepository: SplitTunnelingRepository,
+ savedStateHandle: SavedStateHandle,
private val dispatcher: CoroutineDispatcher,
) : ViewModel() {
+ private val navArgs = SplitTunnelingDestination.argsFrom(savedStateHandle)
private val allApps = MutableStateFlow<List<AppData>?>(null)
private val showSystemApps = MutableStateFlow(false)
@@ -47,11 +51,11 @@ class SplitTunnelingViewModel(
val uiState =
vmState
- .map(SplitTunnelingViewModelState::toUiState)
+ .map { it.toUiState(navArgs.isModal) }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(),
- SplitTunnelingUiState.Loading(enabled = false),
+ SplitTunnelingUiState.Loading(enabled = false, isModal = navArgs.isModal),
)
init {
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt
index 391f29a3a0..ee96248f4b 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt
@@ -10,7 +10,7 @@ data class SplitTunnelingViewModelState(
val allApps: List<AppData>? = null,
val showSystemApps: Boolean = false,
) {
- fun toUiState(): SplitTunnelingUiState {
+ fun toUiState(isModal: Boolean): SplitTunnelingUiState {
return allApps
?.partition { appData ->
if (enabled) {
@@ -31,8 +31,9 @@ data class SplitTunnelingViewModelState(
}
.sort(),
showSystemApps = showSystemApps,
+ isModal = isModal,
)
- } ?: SplitTunnelingUiState.Loading(enabled = enabled)
+ } ?: SplitTunnelingUiState.Loading(enabled = enabled, isModal)
}
}
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 aa00ebdca3..6cdb9a73a3 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
@@ -1,25 +1,28 @@
package net.mullvad.mullvadvpn.viewmodel
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import arrow.core.None
+import arrow.core.Option
+import arrow.core.Some
import co.touchlab.kermit.Logger
+import com.ramcosta.composedestinations.generated.destinations.VpnSettingsDestination
import java.net.Inet6Address
import java.net.InetAddress
-import java.net.UnknownHostException
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.combine
+import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
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
@@ -34,6 +37,7 @@ import net.mullvad.mullvadvpn.repository.RelayListRepository
import net.mullvad.mullvadvpn.repository.SettingsRepository
import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository
import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsAvailableUseCase
+import net.mullvad.mullvadvpn.util.onFirst
sealed interface VpnSettingsSideEffect {
sealed interface ShowToast : VpnSettingsSideEffect {
@@ -47,177 +51,151 @@ sealed interface VpnSettingsSideEffect {
@Suppress("TooManyFunctions")
class VpnSettingsViewModel(
- private val repository: SettingsRepository,
+ private val settingsRepository: SettingsRepository,
relayListRepository: RelayListRepository,
private val systemVpnSettingsUseCase: SystemVpnSettingsAvailableUseCase,
private val autoStartAndConnectOnBootRepository: AutoStartAndConnectOnBootRepository,
private val wireguardConstraintsRepository: WireguardConstraintsRepository,
+ savedStateHandle: SavedStateHandle,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) : ViewModel() {
+ private val navArgs = VpnSettingsDestination.argsFrom(savedStateHandle)
+ private val _mutableIsContentBlockersExpanded = MutableStateFlow<Option<Boolean>>(None)
private val _uiSideEffect = Channel<VpnSettingsSideEffect>()
val uiSideEffect = _uiSideEffect.receiveAsFlow()
- private val customPort = MutableStateFlow<Port?>(null)
+ private val customPort = MutableStateFlow<Option<Port?>>(None)
- private val vmState =
+ val uiState =
combine(
- repository.settingsUpdates,
+ settingsRepository.settingsUpdates.filterNotNull().onFirst {
+ // Initialize wg port and content blockers state expand state
+ val initialPort = it.getWireguardPort().getOrNull()
+ customPort.value =
+ Some(
+ if (initialPort !in WIREGUARD_PRESET_PORTS) {
+ initialPort
+ } else {
+ null
+ }
+ )
+ _mutableIsContentBlockersExpanded.value =
+ Some(it.contentBlockersSettings().isAnyBlockerEnabled())
+ },
relayListRepository.portRanges,
- customPort,
+ customPort.filterIsInstance<Some<Port?>>().map { it.value },
autoStartAndConnectOnBootRepository.autoStartAndConnectOnBoot,
- ) { settings, portRanges, customWgPort, autoStartAndConnectOnBoot ->
- VpnSettingsViewModelState(
- mtuValue = settings?.tunnelOptions?.wireguard?.mtu,
- isLocalNetworkSharingEnabled = settings?.allowLan == true,
- isCustomDnsEnabled = settings?.isCustomDnsEnabled() == true,
- customDnsList = settings?.addresses()?.asStringAddressList() ?: listOf(),
- contentBlockersOptions =
- settings?.contentBlockersSettings() ?: DefaultDnsOptions(),
- obfuscationMode = settings?.selectedObfuscationMode() ?: ObfuscationMode.Off,
- selectedUdp2TcpObfuscationPort =
- settings?.obfuscationSettings?.udp2tcp?.port ?: Constraint.Any,
+ _mutableIsContentBlockersExpanded.filterIsInstance<Some<Boolean>>().map { it.value },
+ ) {
+ settings,
+ portRanges,
+ customWgPort,
+ autoStartAndConnectOnBoot,
+ isContentBlockersExpanded ->
+ VpnSettingsUiState.Content.from(
+ mtu = settings.tunnelOptions.wireguard.mtu,
+ isLocalNetworkSharingEnabled = settings.allowLan,
+ isCustomDnsEnabled = settings.isCustomDnsEnabled(),
+ customDnsItems = settings.addresses().asStringAddressList(),
+ contentBlockersOptions = settings.contentBlockersSettings(),
+ obfuscationMode = settings.selectedObfuscationMode(),
+ selectedUdp2TcpObfuscationPort = settings.obfuscationSettings.udp2tcp.port,
selectedShadowsocksObfuscationPort =
- settings?.obfuscationSettings?.shadowsocks?.port ?: Constraint.Any,
- quantumResistant = settings?.quantumResistant() ?: QuantumResistantState.Off,
- selectedWireguardPort = settings?.getWireguardPort() ?: Constraint.Any,
+ settings.obfuscationSettings.shadowsocks.port,
+ quantumResistant = settings.quantumResistant(),
+ selectedWireguardPort = settings.getWireguardPort(),
customWireguardPort = customWgPort,
availablePortRanges = portRanges,
systemVpnSettingsAvailable = systemVpnSettingsUseCase(),
autoStartAndConnectOnBoot = autoStartAndConnectOnBoot,
- deviceIpVersion = settings?.getDeviceIpVersion() ?: Constraint.Any,
- ipv6Enabled = settings?.tunnelOptions?.genericOptions?.enableIpv6 == true,
+ deviceIpVersion = settings.getDeviceIpVersion(),
+ isIpv6Enabled = settings.tunnelOptions.genericOptions.enableIpv6,
+ isContentBlockersExpanded = isContentBlockersExpanded,
+ isModal = navArgs.isModal,
)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(),
- VpnSettingsViewModelState.default(),
- )
-
- val uiState =
- vmState
- .map(VpnSettingsViewModelState::toUiState)
- .stateIn(
- viewModelScope,
- SharingStarted.WhileSubscribed(),
- VpnSettingsUiState.createDefault(),
+ VpnSettingsUiState.Loading(navArgs.isModal),
)
- init {
- viewModelScope.launch(dispatcher) {
- val initialSettings = repository.settingsUpdates.filterNotNull().first()
- customPort.update {
- val initialPort = initialSettings.getWireguardPort()
- if (initialPort.getOrNull() !in WIREGUARD_PRESET_PORTS) {
- initialPort.getOrNull()
- } else {
- null
- }
- }
- }
- }
-
fun onToggleLocalNetworkSharing(isEnabled: Boolean) {
viewModelScope.launch(dispatcher) {
- repository.setLocalNetworkSharing(isEnabled).onLeft {
+ settingsRepository.setLocalNetworkSharing(isEnabled).onLeft {
_uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError)
}
}
}
- fun onDnsDialogDismissed() {
- if (vmState.value.customDnsList.isEmpty()) {
- onToggleCustomDns(enable = false)
- }
- }
-
- fun onToggleCustomDns(enable: Boolean) {
+ fun onToggleCustomDns(enable: Boolean) =
viewModelScope.launch {
- repository
- .setDnsState(if (enable) DnsState.Custom else DnsState.Default)
- .fold(
- { _uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError) },
- {
- if (enable && vmState.value.customDnsList.isEmpty()) {
- viewModelScope.launch {
- _uiSideEffect.send(VpnSettingsSideEffect.NavigateToDnsDialog)
- }
- } else if (vmState.value.customDnsList.isNotEmpty()) {
- showApplySettingChangesWarningToast()
- }
- },
- )
+ val settings = settingsRepository.settingsUpdates.value
+ if (settings == null) {
+ showGenericErrorToast()
+ return@launch
+ }
+
+ val hasDnsEntries = settings.addresses().isNotEmpty()
+
+ if (hasDnsEntries) {
+ settingsRepository.setDnsState(if (enable) DnsState.Custom else DnsState.Default).fold({
+ showGenericErrorToast()
+ },{
+ showApplySettingChangesWarningToast()
+ })
+ } else {
+ // If they enable custom DNS and has no current entries we show the dialog
+ // to add one.
+ viewModelScope.launch {
+ _uiSideEffect.send(VpnSettingsSideEffect.NavigateToDnsDialog)
+ }
+ }
}
- }
- fun onToggleBlockAds(isEnabled: Boolean) {
- updateDefaultDnsOptionsViaRepository(
- vmState.value.contentBlockersOptions.copy(blockAds = isEnabled)
- )
- showApplySettingChangesWarningToast()
- }
+ fun onToggleContentBlockersExpand() =
+ _mutableIsContentBlockersExpanded.update { it.map { expand -> !expand } }
- fun onToggleBlockTrackers(isEnabled: Boolean) {
- updateDefaultDnsOptionsViaRepository(
- vmState.value.contentBlockersOptions.copy(blockTrackers = isEnabled)
- )
- showApplySettingChangesWarningToast()
+ fun onToggleBlockAds(isEnabled: Boolean) = updateContentBlockersAndNotify {
+ it.copy(blockAds = isEnabled)
}
- fun onToggleBlockMalware(isEnabled: Boolean) {
- updateDefaultDnsOptionsViaRepository(
- vmState.value.contentBlockersOptions.copy(blockMalware = isEnabled)
- )
- showApplySettingChangesWarningToast()
+ fun onToggleBlockTrackers(isEnabled: Boolean) = updateContentBlockersAndNotify {
+ it.copy(blockTrackers = isEnabled)
}
- fun onToggleBlockAdultContent(isEnabled: Boolean) {
- updateDefaultDnsOptionsViaRepository(
- vmState.value.contentBlockersOptions.copy(blockAdultContent = isEnabled)
- )
- showApplySettingChangesWarningToast()
+ fun onToggleBlockMalware(isEnabled: Boolean) = updateContentBlockersAndNotify {
+ it.copy(blockMalware = isEnabled)
}
- fun onToggleBlockGambling(isEnabled: Boolean) {
- updateDefaultDnsOptionsViaRepository(
- vmState.value.contentBlockersOptions.copy(blockGambling = isEnabled)
- )
- showApplySettingChangesWarningToast()
+ fun onToggleBlockAdultContent(isEnabled: Boolean) = updateContentBlockersAndNotify {
+ it.copy(blockAdultContent = isEnabled)
}
- fun onToggleBlockSocialMedia(isEnabled: Boolean) {
- updateDefaultDnsOptionsViaRepository(
- vmState.value.contentBlockersOptions.copy(blockSocialMedia = isEnabled)
- )
- showApplySettingChangesWarningToast()
+ fun onToggleBlockGambling(isEnabled: Boolean) = updateContentBlockersAndNotify {
+ it.copy(blockGambling = isEnabled)
}
- fun onStopEvent() {
- viewModelScope.launch {
- if (vmState.value.customDnsList.isEmpty()) {
- repository.setDnsState(DnsState.Default).onLeft {
- _uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError)
- }
- }
- }
+ fun onToggleBlockSocialMedia(isEnabled: Boolean) = updateContentBlockersAndNotify {
+ it.copy(blockSocialMedia = isEnabled)
}
fun onSelectObfuscationMode(obfuscationMode: ObfuscationMode) {
viewModelScope.launch(dispatcher) {
- repository.setObfuscation(obfuscationMode).onLeft {
+ settingsRepository.setObfuscation(obfuscationMode).onLeft {
_uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError)
}
}
}
fun onObfuscationPortSelected(port: Constraint<Port>) {
- viewModelScope.launch { repository.setCustomUdp2TcpObfuscationPort(port) }
+ viewModelScope.launch { settingsRepository.setCustomUdp2TcpObfuscationPort(port) }
}
fun onSelectQuantumResistanceSetting(quantumResistant: QuantumResistantState) {
viewModelScope.launch(dispatcher) {
- repository.setWireguardQuantumResistant(quantumResistant).onLeft {
+ settingsRepository.setWireguardQuantumResistant(quantumResistant).onLeft {
_uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError)
}
}
@@ -225,72 +203,60 @@ class VpnSettingsViewModel(
fun onWireguardPortSelected(port: Constraint<Port>) {
if (port is Constraint.Only && port.value !in WIREGUARD_PRESET_PORTS) {
- customPort.update { port.value }
+ customPort.update { Some(port.value) }
+ }
+ viewModelScope.launch {
+ wireguardConstraintsRepository.setWireguardPort(port = port).onLeft {
+ _uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError)
+ }
}
- viewModelScope.launch { wireguardConstraintsRepository.setWireguardPort(port = port) }
}
fun resetCustomPort() {
- val isCustom = vmState.value.isCustomWireguardPort
- customPort.update { null }
- // If custom port was selected, update selection to be any.
- if (isCustom) {
- viewModelScope.launch {
- wireguardConstraintsRepository.setWireguardPort(port = Constraint.Any)
- }
+ customPort.update { Some(null) }
+ viewModelScope.launch {
+ wireguardConstraintsRepository.setWireguardPort(port = Constraint.Any)
}
}
- fun onToggleAutoStartAndConnectOnBoot(autoStartAndConnect: Boolean) {
+ fun onToggleAutoStartAndConnectOnBoot(autoStartAndConnect: Boolean) =
viewModelScope.launch(dispatcher) {
autoStartAndConnectOnBootRepository.setAutoStartAndConnectOnBoot(autoStartAndConnect)
}
- }
- fun onDeviceIpVersionSelected(ipVersion: Constraint<IpVersion>) {
+ fun onDeviceIpVersionSelected(ipVersion: Constraint<IpVersion>) =
viewModelScope.launch(dispatcher) {
wireguardConstraintsRepository.setDeviceIpVersion(ipVersion).onLeft {
_uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError)
}
}
- }
- fun setIpv6Enabled(enable: Boolean) {
+ fun setIpv6Enabled(enable: Boolean) =
viewModelScope.launch(dispatcher) {
- repository.setIpv6Enabled(enable).onLeft {
+ settingsRepository.setIpv6Enabled(enable).onLeft {
_uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError)
}
}
- }
- private fun updateDefaultDnsOptionsViaRepository(contentBlockersOption: DefaultDnsOptions) =
+ private fun updateContentBlockersAndNotify(update: (DefaultDnsOptions) -> DefaultDnsOptions) =
viewModelScope.launch(dispatcher) {
- repository
- .setDnsOptions(
- isCustomDnsEnabled = vmState.value.isCustomDnsEnabled,
- dnsList = vmState.value.customDnsList.map { it.address }.asInetAddressList(),
- contentBlockersOptions = contentBlockersOption,
+ settingsRepository
+ .updateContentBlockers(update)
+ .fold(
+ {
+ Logger.e("Failed to update content blockers")
+ _uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError)
+ },
+ { showApplySettingChangesWarningToast() },
)
- .onLeft { _uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError) }
}
- private fun List<String>.asInetAddressList(): List<InetAddress> {
- return try {
- map { InetAddress.getByName(it) }
- } catch (_: UnknownHostException) {
- Logger.e("Error parsing the DNS address list.")
- emptyList()
- }
- }
-
- private fun List<InetAddress>.asStringAddressList(): List<CustomDnsItem> {
- return map {
- CustomDnsItem(
- address = it.hostAddress ?: EMPTY_STRING,
- isLocal = it.isLocalAddress(),
- isIpv6 = it is Inet6Address,
- )
- }
+ private fun List<InetAddress>.asStringAddressList(): List<CustomDnsItem> = map {
+ CustomDnsItem(
+ address = it.hostAddress ?: EMPTY_STRING,
+ isLocal = it.isLocalAddress(),
+ isIpv6 = it is Inet6Address,
+ )
}
private fun Settings.quantumResistant() = tunnelOptions.wireguard.quantumResistant
@@ -309,19 +275,15 @@ class VpnSettingsViewModel(
private fun Settings.getDeviceIpVersion() =
relaySettings.relayConstraints.wireguardConstraints.ipVersion
- private fun InetAddress.isLocalAddress(): Boolean {
- return isLinkLocalAddress || isSiteLocalAddress
- }
+ private fun InetAddress.isLocalAddress(): Boolean = isLinkLocalAddress || isSiteLocalAddress
- fun showApplySettingChangesWarningToast() {
+ fun showApplySettingChangesWarningToast() =
viewModelScope.launch {
_uiSideEffect.send(VpnSettingsSideEffect.ShowToast.ApplySettingsWarning)
}
- }
- fun showGenericErrorToast() {
+ fun showGenericErrorToast() =
viewModelScope.launch { _uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError) }
- }
companion object {
private const val EMPTY_STRING = ""
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 f4f1a8dbcd..d4f395a63d 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
@@ -1,6 +1,7 @@
package net.mullvad.mullvadvpn.viewmodel
-import net.mullvad.mullvadvpn.compose.state.VpnSettingsUiState
+import net.mullvad.mullvadvpn.compose.state.VpnSettingItem
+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.IpVersion
@@ -10,68 +11,249 @@ import net.mullvad.mullvadvpn.lib.model.Port
import net.mullvad.mullvadvpn.lib.model.PortRange
import net.mullvad.mullvadvpn.lib.model.QuantumResistantState
-data class VpnSettingsViewModelState(
- val mtuValue: Mtu?,
- val isLocalNetworkSharingEnabled: Boolean,
- val isCustomDnsEnabled: Boolean,
- val customDnsList: List<CustomDnsItem>,
- val contentBlockersOptions: DefaultDnsOptions,
- val obfuscationMode: ObfuscationMode,
- val selectedUdp2TcpObfuscationPort: Constraint<Port>,
- val selectedShadowsocksObfuscationPort: Constraint<Port>,
- val quantumResistant: QuantumResistantState,
- val selectedWireguardPort: Constraint<Port>,
- val customWireguardPort: Port?,
- val availablePortRanges: List<PortRange>,
- val systemVpnSettingsAvailable: Boolean,
- val autoStartAndConnectOnBoot: Boolean,
- val deviceIpVersion: Constraint<IpVersion>,
- val ipv6Enabled: Boolean,
-) {
- val isCustomWireguardPort =
- selectedWireguardPort is Constraint.Only &&
- selectedWireguardPort.value == customWireguardPort
+sealed interface VpnSettingsUiState {
+ val isModal: Boolean
- fun toUiState(): VpnSettingsUiState =
- VpnSettingsUiState(
- mtuValue,
- isLocalNetworkSharingEnabled,
- isCustomDnsEnabled,
- customDnsList,
- contentBlockersOptions,
- obfuscationMode,
- selectedUdp2TcpObfuscationPort,
- selectedShadowsocksObfuscationPort,
- quantumResistant,
- selectedWireguardPort,
- customWireguardPort,
- availablePortRanges,
- systemVpnSettingsAvailable,
- autoStartAndConnectOnBoot,
- deviceIpVersion,
- ipv6Enabled,
- )
+ data class Loading(override val isModal: Boolean = false) : VpnSettingsUiState
- companion object {
- fun default() =
- VpnSettingsViewModelState(
- mtuValue = null,
- isLocalNetworkSharingEnabled = false,
- isCustomDnsEnabled = false,
- customDnsList = listOf(),
- contentBlockersOptions = DefaultDnsOptions(),
- obfuscationMode = ObfuscationMode.Auto,
- selectedUdp2TcpObfuscationPort = Constraint.Any,
- selectedShadowsocksObfuscationPort = Constraint.Any,
- quantumResistant = QuantumResistantState.Off,
- selectedWireguardPort = Constraint.Any,
- customWireguardPort = null,
- availablePortRanges = emptyList(),
- systemVpnSettingsAvailable = false,
- autoStartAndConnectOnBoot = false,
- deviceIpVersion = Constraint.Any,
- ipv6Enabled = false,
- )
+ data class Content(val settings: List<VpnSettingItem>, override val isModal: Boolean = false) :
+ VpnSettingsUiState {
+ companion object {
+ @Suppress("LongParameterList", "CyclomaticComplexMethod", "LongMethod")
+ fun from(
+ mtu: Mtu?,
+ isLocalNetworkSharingEnabled: Boolean,
+ isCustomDnsEnabled: Boolean,
+ customDnsItems: List<CustomDnsItem>,
+ contentBlockersOptions: DefaultDnsOptions,
+ obfuscationMode: ObfuscationMode,
+ selectedUdp2TcpObfuscationPort: Constraint<Port>,
+ selectedShadowsocksObfuscationPort: Constraint<Port>,
+ quantumResistant: QuantumResistantState,
+ selectedWireguardPort: Constraint<Port>,
+ customWireguardPort: Port?,
+ availablePortRanges: List<PortRange>,
+ systemVpnSettingsAvailable: Boolean,
+ autoStartAndConnectOnBoot: Boolean,
+ deviceIpVersion: Constraint<IpVersion>,
+ isIpv6Enabled: Boolean,
+ isContentBlockersExpanded: Boolean,
+ isModal: Boolean,
+ ) =
+ Content(
+ buildList {
+ if (systemVpnSettingsAvailable) {
+ add(VpnSettingItem.AutoConnectAndLockdownMode)
+ add(VpnSettingItem.AutoConnectAndLockdownModeInfo)
+ } else {
+ add(
+ VpnSettingItem.ConnectDeviceOnStartUpSetting(
+ autoStartAndConnectOnBoot
+ )
+ )
+ add(VpnSettingItem.ConnectDeviceOnStartUpInfo)
+ }
+
+ // Local network sharing
+ add(VpnSettingItem.LocalNetworkSharingSetting(isLocalNetworkSharingEnabled))
+ add(VpnSettingItem.Spacer)
+
+ // Dns Content Blockers
+ add(
+ VpnSettingItem.DnsContentBlockersHeader(
+ !isCustomDnsEnabled,
+ isContentBlockersExpanded,
+ )
+ )
+ add(VpnSettingItem.Divider)
+
+ if (isContentBlockersExpanded) {
+ with(contentBlockersOptions) {
+ add(
+ VpnSettingItem.DnsContentBlockerItem.Ads(
+ blockAds,
+ !isCustomDnsEnabled,
+ )
+ )
+ add(VpnSettingItem.Divider)
+ add(
+ VpnSettingItem.DnsContentBlockerItem.Trackers(
+ blockTrackers,
+ !isCustomDnsEnabled,
+ )
+ )
+ add(VpnSettingItem.Divider)
+ add(
+ VpnSettingItem.DnsContentBlockerItem.Malware(
+ blockMalware,
+ !isCustomDnsEnabled,
+ )
+ )
+ add(VpnSettingItem.Divider)
+ add(
+ VpnSettingItem.DnsContentBlockerItem.Gambling(
+ blockGambling,
+ !isCustomDnsEnabled,
+ )
+ )
+ add(VpnSettingItem.Divider)
+ add(
+ VpnSettingItem.DnsContentBlockerItem.AdultContent(
+ blockAdultContent,
+ !isCustomDnsEnabled,
+ )
+ )
+ add(VpnSettingItem.Divider)
+ add(
+ VpnSettingItem.DnsContentBlockerItem.SocialMedia(
+ blockSocialMedia,
+ !isCustomDnsEnabled,
+ )
+ )
+ }
+ if (isCustomDnsEnabled) {
+ add(VpnSettingItem.DnsContentBlockersUnavailable)
+ }
+ }
+
+ // Custom DNS
+ add(
+ VpnSettingItem.CustomDnsServerSetting(
+ isCustomDnsEnabled,
+ !contentBlockersOptions.isAnyBlockerEnabled(),
+ )
+ )
+ if (isCustomDnsEnabled) {
+ customDnsItems.forEachIndexed { index, item ->
+ add(
+ VpnSettingItem.CustomDnsEntry(
+ index,
+ item,
+ showUnreachableLocalDnsWarning =
+ item.isLocal && !isLocalNetworkSharingEnabled,
+ showUnreachableIpv6DnsWarning =
+ item.isIpv6 && !isIpv6Enabled,
+ )
+ )
+ add(VpnSettingItem.Divider)
+ }
+ if (customDnsItems.isNotEmpty()) {
+ add(VpnSettingItem.CustomDnsAdd)
+ }
+ }
+
+ if (contentBlockersOptions.isAnyBlockerEnabled()) {
+ add(VpnSettingItem.CustomDnsUnavailable)
+ } else if(customDnsItems.isEmpty()) {
+ add(VpnSettingItem.CustomDnsInfo)
+ } else {
+ add(VpnSettingItem.Spacer)
+ }
+
+ // IPv6
+ add(VpnSettingItem.EnableIpv6Setting(isIpv6Enabled))
+
+ add(VpnSettingItem.Spacer)
+
+ // Wireguard Port
+ val isWireguardPortEnabled =
+ obfuscationMode == ObfuscationMode.Auto ||
+ obfuscationMode == ObfuscationMode.Off
+ add(
+ VpnSettingItem.WireguardPortHeader(
+ isWireguardPortEnabled,
+ availablePortRanges,
+ )
+ )
+ (listOf(Constraint.Any) +
+ WIREGUARD_PRESET_PORTS.map { Constraint.Only(it) })
+ .forEach {
+ add(VpnSettingItem.Divider)
+ add(
+ VpnSettingItem.WireguardPortItem.Constraint(
+ isWireguardPortEnabled,
+ it == selectedWireguardPort,
+ it,
+ )
+ )
+ }
+ add(VpnSettingItem.Divider)
+ add(
+ VpnSettingItem.WireguardPortItem.WireguardPortCustom(
+ isWireguardPortEnabled,
+ selectedWireguardPort is Constraint.Only &&
+ selectedWireguardPort.value == customWireguardPort,
+ customWireguardPort,
+ availablePortRanges,
+ )
+ )
+
+ if (!isWireguardPortEnabled) {
+ add(VpnSettingItem.WireguardPortUnavailable)
+ } else {
+ add(VpnSettingItem.Spacer)
+ }
+
+ // Wireguard Obfuscation
+ add(VpnSettingItem.ObfuscationHeader)
+ add(VpnSettingItem.Divider)
+ add(
+ VpnSettingItem.ObfuscationItem.Automatic(
+ obfuscationMode == ObfuscationMode.Auto
+ )
+ )
+ add(VpnSettingItem.Divider)
+ add(
+ VpnSettingItem.ObfuscationItem.Shadowsocks(
+ obfuscationMode == ObfuscationMode.Shadowsocks,
+ selectedShadowsocksObfuscationPort,
+ )
+ )
+ add(VpnSettingItem.Divider)
+ add(
+ VpnSettingItem.ObfuscationItem.UdpOverTcp(
+ obfuscationMode == ObfuscationMode.Udp2Tcp,
+ selectedUdp2TcpObfuscationPort,
+ )
+ )
+ add(VpnSettingItem.Divider)
+ add(
+ VpnSettingItem.ObfuscationItem.Off(
+ obfuscationMode == ObfuscationMode.Off
+ )
+ )
+
+ add(VpnSettingItem.Spacer)
+
+ // Quantum Resistance
+ add(VpnSettingItem.QuantumResistanceHeader)
+ QuantumResistantState.entries.forEach {
+ add(VpnSettingItem.Divider)
+ add(VpnSettingItem.QuantumItem(it, quantumResistant == it))
+ }
+
+ add(VpnSettingItem.Spacer)
+
+ // Device Ip Version
+ add(VpnSettingItem.DeviceIpVersionHeader)
+
+ IpVersion.constraints.forEach {
+ add(VpnSettingItem.Divider)
+ add(VpnSettingItem.DeviceIpVersionItem(it, deviceIpVersion == it))
+ }
+
+ add(VpnSettingItem.DeviceIpVersionInfo)
+
+ // MTU
+ add(VpnSettingItem.Mtu(mtu))
+ add(VpnSettingItem.MtuInfo)
+
+ add(VpnSettingItem.ServerIpOverrides)
+ add(VpnSettingItem.Spacer)
+ },
+ isModal = isModal,
+ )
+ }
}
}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DaitaViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DaitaViewModelTest.kt
index 8eb9770826..dcb06c76a7 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DaitaViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DaitaViewModelTest.kt
@@ -2,12 +2,14 @@ package net.mullvad.mullvadvpn.viewmodel
import app.cash.turbine.test
import arrow.core.right
+import com.ramcosta.composedestinations.generated.navargs.toSavedStateHandle
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.compose.screen.DaitaNavArgs
import net.mullvad.mullvadvpn.compose.state.DaitaUiState
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.lib.model.Settings
@@ -27,7 +29,11 @@ class DaitaViewModelTest {
@BeforeEach
fun setUp() {
every { mockSettingsRepository.settingsUpdates } returns settings
- viewModel = DaitaViewModel(mockSettingsRepository)
+ viewModel =
+ DaitaViewModel(
+ mockSettingsRepository,
+ savedStateHandle = DaitaNavArgs().toSavedStateHandle(),
+ )
}
@Test
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModelTest.kt
index c51d7e9f48..6332666c22 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModelTest.kt
@@ -2,12 +2,14 @@ package net.mullvad.mullvadvpn.viewmodel
import app.cash.turbine.test
import arrow.core.Either
+import com.ramcosta.composedestinations.generated.navargs.toSavedStateHandle
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.compose.screen.MultihopNavArgs
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.lib.model.Constraint
import net.mullvad.mullvadvpn.lib.model.WireguardConstraints
@@ -32,7 +34,10 @@ class MultihopViewModelTest {
wireguardConstraints
multihopViewModel =
- MultihopViewModel(wireguardConstraintsRepository = mockWireguardConstraintsRepository)
+ MultihopViewModel(
+ wireguardConstraintsRepository = mockWireguardConstraintsRepository,
+ savedStateHandle = MultihopNavArgs().toSavedStateHandle(),
+ )
}
@Test
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModelTest.kt
index 484a24fc29..d0d0a0a69c 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModelTest.kt
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import app.cash.turbine.test
import arrow.core.left
import arrow.core.right
+import com.ramcosta.composedestinations.generated.navargs.toSavedStateHandle
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@@ -18,6 +19,7 @@ import kotlin.test.assertEquals
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.compose.screen.ServerIpOverridesNavArgs
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.lib.model.RelayOverride
import net.mullvad.mullvadvpn.lib.model.SettingsPatchError
@@ -46,6 +48,7 @@ class ServerIpOverridesViewModelTest {
ServerIpOverridesViewModel(
relayOverridesRepository = mockRelayOverridesRepository,
contentResolver = mockContentResolver,
+ savedStateHandle = ServerIpOverridesNavArgs().toSavedStateHandle(),
)
}
@@ -57,13 +60,13 @@ class ServerIpOverridesViewModelTest {
@Test
fun `ensure state is loading by default`() = runTest {
- viewModel.uiState.test { assertEquals(ServerIpOverridesUiState.Loading, awaitItem()) }
+ viewModel.uiState.test { assertEquals(ServerIpOverridesUiState.Loading(), awaitItem()) }
}
@Test
fun `when server ip overrides are empty ui state overrides should be inactive`() = runTest {
viewModel.uiState.test {
- assertEquals(ServerIpOverridesUiState.Loading, awaitItem())
+ assertEquals(ServerIpOverridesUiState.Loading(), awaitItem())
relayOverrides.emit(emptyList())
assertEquals(ServerIpOverridesUiState.Loaded(false), awaitItem())
}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt
index 63a0907629..bfd1cb055c 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt
@@ -3,6 +3,7 @@ package net.mullvad.mullvadvpn.viewmodel
import androidx.lifecycle.viewModelScope
import app.cash.turbine.test
import arrow.core.right
+import com.ramcosta.composedestinations.generated.navargs.toSavedStateHandle
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@@ -18,6 +19,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import net.mullvad.mullvadvpn.applist.AppData
import net.mullvad.mullvadvpn.applist.ApplicationsProvider
+import net.mullvad.mullvadvpn.compose.screen.SplitTunnelingNavArgs
import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.lib.model.AppId
@@ -187,6 +189,7 @@ class SplitTunnelingViewModelTest {
SplitTunnelingViewModel(
mockedApplicationsProvider,
mockedSplitTunnelingRepository,
+ savedStateHandle = SplitTunnelingNavArgs().toSavedStateHandle(),
UnconfinedTestDispatcher(),
)
}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt
index c91d6d9a20..2dd2475ba2 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt
@@ -3,6 +3,7 @@ package net.mullvad.mullvadvpn.viewmodel
import androidx.lifecycle.viewModelScope
import app.cash.turbine.test
import arrow.core.right
+import com.ramcosta.composedestinations.generated.navargs.toSavedStateHandle
import io.mockk.Awaits
import io.mockk.Runs
import io.mockk.coEvery
@@ -13,24 +14,31 @@ import io.mockk.mockk
import io.mockk.unmockkAll
import io.mockk.verify
import kotlin.test.assertEquals
-import kotlin.test.assertIs
+import kotlin.test.assertTrue
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.compose.screen.VpnSettingsNavArgs
+import net.mullvad.mullvadvpn.compose.state.VpnSettingItem
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.lib.model.Constraint
import net.mullvad.mullvadvpn.lib.model.DaitaSettings
import net.mullvad.mullvadvpn.lib.model.IpVersion
import net.mullvad.mullvadvpn.lib.model.Mtu
+import net.mullvad.mullvadvpn.lib.model.ObfuscationMode
+import net.mullvad.mullvadvpn.lib.model.ObfuscationSettings
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.RelayConstraints
import net.mullvad.mullvadvpn.lib.model.RelaySettings
import net.mullvad.mullvadvpn.lib.model.Settings
+import net.mullvad.mullvadvpn.lib.model.ShadowsocksSettings
+import net.mullvad.mullvadvpn.lib.model.SplitTunnelSettings
import net.mullvad.mullvadvpn.lib.model.TunnelOptions
+import net.mullvad.mullvadvpn.lib.model.Udp2TcpObfuscationSettings
import net.mullvad.mullvadvpn.lib.model.WireguardConstraints
import net.mullvad.mullvadvpn.lib.model.WireguardTunnelOptions
import net.mullvad.mullvadvpn.repository.AutoStartAndConnectOnBootRepository
@@ -41,6 +49,7 @@ import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsAvailableUseCase
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertInstanceOf
import org.junit.jupiter.api.extension.ExtendWith
@ExperimentalCoroutinesApi
@@ -70,12 +79,13 @@ class VpnSettingsViewModelTest {
viewModel =
VpnSettingsViewModel(
- repository = mockSettingsRepository,
+ settingsRepository = mockSettingsRepository,
systemVpnSettingsUseCase = mockSystemVpnSettingsUseCase,
relayListRepository = mockRelayListRepository,
dispatcher = UnconfinedTestDispatcher(),
autoStartAndConnectOnBootRepository = mockAutoStartAndConnectOnBootRepository,
wireguardConstraintsRepository = mockWireguardConstraintsRepository,
+ savedStateHandle = VpnSettingsNavArgs().toSavedStateHandle(),
)
}
@@ -86,6 +96,11 @@ class VpnSettingsViewModelTest {
}
@Test
+ fun `initial state should be loading`() = runTest {
+ viewModel.uiState.test { assertEquals(VpnSettingsUiState.Loading(), awaitItem()) }
+ }
+
+ @Test
fun `onSelectCustomTcpOverUdpPort should invoke setCustomObfuscationPort on SettingsRepository`() =
runTest {
val customPort = Port(5001)
@@ -112,20 +127,8 @@ class VpnSettingsViewModelTest {
}
@Test
- fun `quantumResistant should be Off in uiState in initial state`() = runTest {
- // Arrange
- val expectedResistantState = QuantumResistantState.Off
-
- // Act, Assert
- viewModel.uiState.test {
- assertEquals(expectedResistantState, awaitItem().quantumResistant)
- }
- }
-
- @Test
fun `when SettingsRepository emits quantumResistant On uiState should emit quantumResistant On`() =
runTest {
- val defaultResistantState = QuantumResistantState.Off
val expectedResistantState = QuantumResistantState.On
val mockSettings: Settings = mockk(relaxed = true)
val mockTunnelOptions: TunnelOptions = mockk(relaxed = true)
@@ -144,9 +147,17 @@ class VpnSettingsViewModelTest {
Constraint.Any
viewModel.uiState.test {
- assertEquals(defaultResistantState, awaitItem().quantumResistant)
+ assertEquals(VpnSettingsUiState.Loading(), awaitItem())
mockSettingsUpdate.value = mockSettings
- assertEquals(expectedResistantState, awaitItem().quantumResistant)
+ val content = awaitItem()
+ assertInstanceOf<VpnSettingsUiState.Content>(content)
+
+ assertTrue(
+ content.settings
+ .filterIsInstance<VpnSettingItem.QuantumItem>()
+ .first { it.quantumResistantState == QuantumResistantState.On }
+ .selected
+ )
}
}
@@ -179,10 +190,23 @@ class VpnSettingsViewModelTest {
// Act, Assert
viewModel.uiState.test {
- assertIs<Constraint.Any>(awaitItem().selectedWireguardPort)
+ assertInstanceOf<VpnSettingsUiState.Loading>(awaitItem())
+
mockSettingsUpdate.value = mockSettings
- assertEquals(expectedPort.value, awaitItem().customWireguardPort)
- assertEquals(expectedPort, awaitItem().selectedWireguardPort)
+
+ with(awaitItem()) {
+ assertInstanceOf<VpnSettingsUiState.Content>(this)
+ val customPortSetting =
+ settings
+ .filterIsInstance<
+ VpnSettingItem.WireguardPortItem.WireguardPortCustom
+ >()
+ .first()
+
+ // Port should be what we expect and be selected
+ assertEquals(expectedPort.value.value, customPortSetting.customPort!!.value)
+ assertTrue(customPortSetting.selected)
+ }
}
}
@@ -218,7 +242,14 @@ class VpnSettingsViewModelTest {
every { mockSystemVpnSettingsUseCase() } returns systemVpnSettingsAvailable
viewModel.uiState.test {
- assertEquals(systemVpnSettingsAvailable, awaitItem().systemVpnSettingsAvailable)
+ assertInstanceOf<VpnSettingsUiState.Loading>(awaitItem())
+ mockSettingsUpdate.value = dummySettings
+
+ val content = awaitItem()
+ assertInstanceOf<VpnSettingsUiState.Content>(content)
+ assertTrue(
+ content.settings.any { it is VpnSettingItem.AutoConnectAndLockdownMode }
+ )
}
}
@@ -232,7 +263,12 @@ class VpnSettingsViewModelTest {
// Assert
viewModel.uiState.test {
- assertEquals(connectOnStart, awaitItem().autoStartAndConnectOnBoot)
+ assertInstanceOf<VpnSettingsUiState.Loading>(awaitItem())
+
+ mockSettingsUpdate.value = dummySettings
+ val content = awaitItem()
+ assertInstanceOf<VpnSettingsUiState.Content>(content)
+ assertTrue(content.settings.any { it is VpnSettingItem.ConnectDeviceOnStartUpSetting })
}
}
@@ -263,7 +299,7 @@ class VpnSettingsViewModelTest {
ipVersion
every { mockSettings.tunnelOptions.wireguard } returns
WireguardTunnelOptions(
- mtu = Mtu(0),
+ mtu = null,
quantumResistant = QuantumResistantState.Off,
daitaSettings = DaitaSettings(enabled = false, directOnly = false),
)
@@ -272,10 +308,18 @@ class VpnSettingsViewModelTest {
// Act, Assert
viewModel.uiState.test {
- // Default value
+ // Loading value
awaitItem()
mockSettingsUpdate.value = mockSettings
- assertEquals(ipVersion, awaitItem().deviceIpVersion)
+ val content = awaitItem()
+ assertInstanceOf<VpnSettingsUiState.Content>(content)
+ assertEquals(
+ ipVersion,
+ content.settings
+ .filterIsInstance<VpnSettingItem.DeviceIpVersionItem>()
+ .first { it.selected }
+ .constraint,
+ )
}
}
@@ -291,4 +335,50 @@ class VpnSettingsViewModelTest {
// Assert
coVerify(exactly = 1) { mockWireguardConstraintsRepository.setDeviceIpVersion(targetState) }
}
+
+ companion object {
+ val dummySettings: Settings =
+ Settings(
+ relaySettings =
+ RelaySettings(
+ relayConstraints =
+ RelayConstraints(
+ wireguardConstraints =
+ WireguardConstraints(
+ port = Constraint.Any,
+ isMultihopEnabled = false,
+ entryLocation = Constraint.Any,
+ ipVersion = Constraint.Any,
+ ),
+ providers = Constraint.Any,
+ ownership = Constraint.Any,
+ location = Constraint.Any,
+ )
+ ),
+ obfuscationSettings =
+ ObfuscationSettings(
+ selectedObfuscationMode = ObfuscationMode.Auto,
+ udp2tcp = Udp2TcpObfuscationSettings(Constraint.Any),
+ shadowsocks = ShadowsocksSettings(Constraint.Any),
+ ),
+ customLists = emptyList(),
+ allowLan = false,
+ tunnelOptions =
+ TunnelOptions(
+ wireguard =
+ WireguardTunnelOptions(
+ mtu = null,
+ quantumResistant = QuantumResistantState.Auto,
+ daitaSettings = DaitaSettings(enabled = false, directOnly = false),
+ ),
+ dnsOptions = mockk(relaxed = true),
+ genericOptions = mockk(relaxed = true),
+ ),
+ relayOverrides = emptyList(),
+ showBetaReleases = false,
+ splitTunnelSettings =
+ SplitTunnelSettings(enabled = false, excludedApps = emptySet()),
+ apiAccessMethodSettings = emptyList(),
+ )
+ }
}
diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt
index 4d1baab550..5e0077d1f8 100644
--- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt
+++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt
@@ -62,6 +62,7 @@ import net.mullvad.mullvadvpn.lib.model.CustomList as ModelCustomList
import net.mullvad.mullvadvpn.lib.model.CustomListAlreadyExists
import net.mullvad.mullvadvpn.lib.model.CustomListId
import net.mullvad.mullvadvpn.lib.model.CustomListName
+import net.mullvad.mullvadvpn.lib.model.DefaultDnsOptions
import net.mullvad.mullvadvpn.lib.model.DeleteCustomListError
import net.mullvad.mullvadvpn.lib.model.DeleteDeviceError
import net.mullvad.mullvadvpn.lib.model.Device
@@ -71,6 +72,7 @@ import net.mullvad.mullvadvpn.lib.model.DeviceUpdateError
import net.mullvad.mullvadvpn.lib.model.DnsOptions as ModelDnsOptions
import net.mullvad.mullvadvpn.lib.model.DnsOptions
import net.mullvad.mullvadvpn.lib.model.DnsState as ModelDnsState
+import net.mullvad.mullvadvpn.lib.model.DnsState
import net.mullvad.mullvadvpn.lib.model.GetAccountDataError
import net.mullvad.mullvadvpn.lib.model.GetAccountHistoryError
import net.mullvad.mullvadvpn.lib.model.GetDeviceListError
@@ -124,7 +126,7 @@ import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken
import net.mullvad.mullvadvpn.lib.model.WireguardEndpointData as ModelWireguardEndpointData
import net.mullvad.mullvadvpn.lib.model.addresses
import net.mullvad.mullvadvpn.lib.model.customOptions
-import net.mullvad.mullvadvpn.lib.model.enabled
+import net.mullvad.mullvadvpn.lib.model.defaultOptions
import net.mullvad.mullvadvpn.lib.model.entryLocation
import net.mullvad.mullvadvpn.lib.model.ipVersion
import net.mullvad.mullvadvpn.lib.model.isMultihopEnabled
@@ -139,7 +141,7 @@ import net.mullvad.mullvadvpn.lib.model.state
import net.mullvad.mullvadvpn.lib.model.udp2tcp
import net.mullvad.mullvadvpn.lib.model.wireguardConstraints
-@Suppress("TooManyFunctions")
+@Suppress("TooManyFunctions", "LargeClass")
class ManagementService(
rpcSocketFile: File,
private val extensiveLogging: Boolean,
@@ -389,6 +391,19 @@ class ManagementService(
.onLeft { Logger.e("Create account error") }
.mapLeft(CreateAccountError::Unknown)
+ suspend fun updateDnsContentBlockers(
+ update: (DefaultDnsOptions) -> DefaultDnsOptions
+ ): Either<SetDnsOptionsError, Unit> =
+ Either.catch {
+ val currentDnsOptions = getSettings().tunnelOptions.dnsOptions
+ val newDefaultDnsOptions = update(currentDnsOptions.defaultOptions)
+ val updated = DnsOptions.defaultOptions.set(currentDnsOptions, newDefaultDnsOptions)
+ grpc.setDnsOptions(updated.fromDomain())
+ }
+ .onLeft { Logger.e("Set dns state error") }
+ .mapLeft(SetDnsOptionsError::Unknown)
+ .mapEmpty()
+
suspend fun setDnsOptions(dnsOptions: ModelDnsOptions): Either<SetDnsOptionsError, Unit> =
Either.catch { grpc.setDnsOptions(dnsOptions.fromDomain()) }
.onLeft { Logger.e("Set dns options error") }
@@ -423,7 +438,14 @@ class ManagementService(
Either.catch {
val currentDnsOptions = getSettings().tunnelOptions.dnsOptions
val updatedDnsOptions =
- DnsOptions.customOptions.addresses.modify(currentDnsOptions) { it + address }
+ currentDnsOptions.copy {
+ DnsOptions.customOptions.addresses set
+ currentDnsOptions.customOptions.addresses + address
+ // If it is the first address, then turn on Custom Dns
+ DnsOptions.state set
+ if (currentDnsOptions.customOptions.addresses.isEmpty()) DnsState.Custom
+ else currentDnsOptions.state
+ }
grpc.setDnsOptions(updatedDnsOptions.fromDomain())
updatedDnsOptions.customOptions.addresses.lastIndex
}
@@ -433,11 +455,16 @@ class ManagementService(
suspend fun deleteCustomDns(index: Int): Either<SetDnsOptionsError, Unit> =
Either.catch {
val currentDnsOptions = getSettings().tunnelOptions.dnsOptions
+ val mutableAddresses = currentDnsOptions.customOptions.addresses.toMutableList()
+ mutableAddresses.removeAt(index)
+
val updatedDnsOptions =
- DnsOptions.customOptions.addresses.modify(currentDnsOptions) {
- val mutableAddresses = it.toMutableList()
- mutableAddresses.removeAt(index)
- mutableAddresses.toList()
+ currentDnsOptions.copy {
+ DnsOptions.customOptions.addresses set mutableAddresses.toList()
+ // If it is the last address, then turn off Custom Dns
+ DnsOptions.state set
+ if (mutableAddresses.isEmpty()) DnsState.Default
+ else currentDnsOptions.state
}
grpc.setDnsOptions(updatedDnsOptions.fromDomain())
}
diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt
index 10c417cf1c..eab0bc60a9 100644
--- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt
+++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt
@@ -708,6 +708,7 @@ internal fun ManagementInterface.FeatureIndicator.toDomain() =
ManagementInterface.FeatureIndicator.DAITA -> FeatureIndicator.DAITA
ManagementInterface.FeatureIndicator.SHADOWSOCKS -> FeatureIndicator.SHADOWSOCKS
ManagementInterface.FeatureIndicator.MULTIHOP -> FeatureIndicator.MULTIHOP
+ ManagementInterface.FeatureIndicator.DAITA_MULTIHOP -> FeatureIndicator.DAITA_MULTIHOP
ManagementInterface.FeatureIndicator.LOCKDOWN_MODE,
ManagementInterface.FeatureIndicator.BRIDGE_MODE,
ManagementInterface.FeatureIndicator.CUSTOM_MSS_FIX,
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/FeatureIndicator.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/FeatureIndicator.kt
index 0da5704b4b..0213c06cef 100644
--- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/FeatureIndicator.kt
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/FeatureIndicator.kt
@@ -3,6 +3,7 @@ package net.mullvad.mullvadvpn.lib.model
// The order of the variants match the priority order and can be sorted on.
enum class FeatureIndicator {
DAITA,
+ DAITA_MULTIHOP,
QUANTUM_RESISTANCE,
MULTIHOP,
SPLIT_TUNNELING,
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/IpVersion.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/IpVersion.kt
index 176809244c..4ca0a0be5f 100644
--- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/IpVersion.kt
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/IpVersion.kt
@@ -2,5 +2,12 @@ package net.mullvad.mullvadvpn.lib.model
enum class IpVersion {
IPV4,
- IPV6,
+ IPV6;
+
+ companion object {
+ val constraints: List<Constraint<IpVersion>> = buildList {
+ add(Constraint.Any)
+ addAll(IpVersion.entries.map { Constraint.Only(it) })
+ }
+ }
}
diff --git a/android/lib/resource/src/main/res/values/strings_non_translatable.xml b/android/lib/resource/src/main/res/values/strings_non_translatable.xml
index 837740aa47..c53332f663 100644
--- a/android/lib/resource/src/main/res/values/strings_non_translatable.xml
+++ b/android/lib/resource/src/main/res/values/strings_non_translatable.xml
@@ -15,5 +15,6 @@
<![CDATA[<ul><li>10.0.0.0/8</li><li>172.16.0.0/12</li><li>192.168.0.0/16</li><li>169.254.0.0/16</li><li>fe80::/10</li><li>fc00::/7</li></ul>]]>
</string>
<string name="daita">DAITA</string>
+ <string name="daita_multihop">DAITA: Multihop</string>
<string name="daita_full">Defence against AI-guided Traffic Analysis</string>
</resources>