diff options
| author | David Göransson <david.goransson@mullvad.net> | 2025-03-31 09:36:52 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-04-10 17:29:33 +0200 |
| commit | 042820a80d994a09a58dfcdc7dce1ee1d891ac39 (patch) | |
| tree | b35efcceebded11d98cec4adc08a52febb2eedf8 | |
| parent | 1c5712f028250920fe34ce7686c77a7d80da9481 (diff) | |
| download | mullvadvpn-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
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> |
