summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson@mullvad.net>2024-03-20 09:10:23 +0100
committerDavid Göransson <david.goransson@mullvad.net>2024-03-20 09:10:23 +0100
commit2b40cfa0cd087490ef0061fd6cfa24ed246d8556 (patch)
treea0cd9d0bc982d2794e4ae33306247c5d6fc63e20
parentfc7a0c22152c411a0bf00f20ac6ed6fb993d961b (diff)
parentcb19d35111887b07dbafb97edfcd180835f2bc5e (diff)
downloadmullvadvpn-2b40cfa0cd087490ef0061fd6cfa24ed246d8556.tar.xz
mullvadvpn-2b40cfa0cd087490ef0061fd6cfa24ed246d8556.zip
Merge branch 'create-server-ip-overrides-composable-droid-709'
-rw-r--r--CHANGELOG.md1
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIPOverridesConfirmationDialogTest.kt67
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreenTest.kt173
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/InfoIconButton.kt26
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ServerIpOverridesCell.kt82
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIpOverridesConfirmationDialog.kt85
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ServerIpOverridesInfoDialog.kt32
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ImportOverridesByTextScreen.kt92
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt50
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt351
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt15
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt13
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightLeafTransition.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Navigation.kt17
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Snackbar.kt19
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt9
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayOverridesRepository.kt44
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt17
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModel.kt25
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt89
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModelTest.kt63
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModelTest.kt118
-rw-r--r--android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Event.kt5
-rw-r--r--android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Request.kt9
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayOverride.kt12
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Settings.kt3
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/SettingsPatchError.kt24
-rw-r--r--android/lib/resource/src/main/res/drawable/icon_text_fields.xml9
-rw-r--r--android/lib/resource/src/main/res/drawable/icon_upload_file.xml9
-rw-r--r--android/lib/resource/src/main/res/values-da/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-de/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-es/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-fi/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-fr/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-it/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-ja/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-ko/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-my/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-nb/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-nl/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-pl/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-pt/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-ru/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-sv/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-th/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-tr/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-zh-rCN/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values-zh-rTW/strings.xml5
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml26
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt25
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/JsonSettings.kt48
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayOverrides.kt37
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt4
-rw-r--r--gui/locales/messages.pot60
-rw-r--r--mullvad-jni/src/classes.rs2
-rw-r--r--mullvad-jni/src/daemon_interface.rs47
-rw-r--r--mullvad-jni/src/lib.rs140
-rw-r--r--mullvad-types/src/relay_constraints.rs2
-rw-r--r--mullvad-types/src/settings/mod.rs1
64 files changed, 1969 insertions, 42 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bcaa34d73a..4f0ae72831 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,7 @@ Line wrap the file at 100 chars. Th
- Add auto connect and lockdown mode guide on platforms that has system vpn settings.
- Add 3D map to Connect screen.
- Add the ability to create and manage custom lists of relays.
+- Add Server IP overrides feature.
### Changed
- Change default obfuscation setting to `auto`.
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIPOverridesConfirmationDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIPOverridesConfirmationDialogTest.kt
new file mode 100644
index 0000000000..df06f00fc7
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIPOverridesConfirmationDialogTest.kt
@@ -0,0 +1,67 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import io.mockk.MockKAnnotations
+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.test.RESET_SERVER_IP_OVERRIDE_CANCEL_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.RESET_SERVER_IP_OVERRIDE_RESET_TEST_TAG
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.RegisterExtension
+
+class ResetServerIPOverridesConfirmationDialogTest {
+ @OptIn(ExperimentalTestApi::class)
+ @JvmField
+ @RegisterExtension
+ val composeExtension = createEdgeToEdgeComposeExtension()
+
+ @BeforeEach
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ @Test
+ fun ensure_cancel_click_works() =
+ composeExtension.use {
+ val clickHandler: () -> Unit = mockk(relaxed = true)
+
+ // Arrange
+ setContentWithTheme {
+ ResetServerIpOverridesConfirmationDialog(
+ onNavigateBack = clickHandler,
+ onClearAllOverrides = {}
+ )
+ }
+
+ // Act
+ onNodeWithTag(RESET_SERVER_IP_OVERRIDE_CANCEL_TEST_TAG).performClick()
+
+ // Assert
+ verify { clickHandler() }
+ }
+
+ @Test
+ fun ensure_reset_click_works() =
+ composeExtension.use {
+ val clickHandler: () -> Unit = mockk(relaxed = true)
+
+ // Arrange
+ setContentWithTheme {
+ ResetServerIpOverridesConfirmationDialog(
+ onNavigateBack = {},
+ onClearAllOverrides = clickHandler
+ )
+ }
+
+ // Act
+ onNodeWithTag(RESET_SERVER_IP_OVERRIDE_RESET_TEST_TAG).performClick()
+
+ // Assert
+ verify { clickHandler() }
+ }
+}
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreenTest.kt
new file mode 100644
index 0000000000..32bab72de2
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreenTest.kt
@@ -0,0 +1,173 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import io.mockk.MockKAnnotations
+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.test.SERVER_IP_OVERRIDES_IMPORT_BY_FILE_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDES_IMPORT_BY_TEXT_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDE_IMPORT_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDE_INFO_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDE_MORE_VERT_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDE_RESET_OVERRIDES_TEST_TAG
+import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesViewState
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.RegisterExtension
+
+@ExperimentalTestApi
+class ServerIpOverridesScreenTest {
+ @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension()
+
+ @BeforeEach
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ @Suppress("TestFunctionName")
+ @Composable
+ private fun ScreenWithDefault(
+ state: ServerIpOverridesViewState,
+ onBackClick: () -> Unit = {},
+ onInfoClick: () -> Unit = {},
+ onResetOverridesClick: () -> Unit = {},
+ onImportByFile: () -> Unit = {},
+ onImportByText: () -> Unit = {},
+ ) {
+ ServerIpOverridesScreen(
+ state = state,
+ onBackClick = onBackClick,
+ onInfoClick = onInfoClick,
+ onResetOverridesClick = onResetOverridesClick,
+ onImportByFile = onImportByFile,
+ onImportByText = onImportByText
+ )
+ }
+
+ @Test
+ fun ensure_overrides_inactive_is_displayed() =
+ composeExtension.use {
+ // Arrange
+ setContentWithTheme {
+ ScreenWithDefault(state = ServerIpOverridesViewState.Loaded(false))
+ }
+
+ // Assert
+ onNodeWithText("Overrides inactive").assertExists()
+ }
+
+ @Test
+ fun ensure_overrides_active_is_displayed() =
+ composeExtension.use {
+ // Arrange
+ setContentWithTheme {
+ ScreenWithDefault(state = ServerIpOverridesViewState.Loaded(true))
+ }
+
+ // Assert
+ onNodeWithText("Overrides active").assertExists()
+ }
+
+ @Test
+ fun ensure_overrides_active_shows_warning_on_import() =
+ composeExtension.use {
+ // Arrange
+ setContentWithTheme {
+ ScreenWithDefault(state = ServerIpOverridesViewState.Loaded(true))
+ }
+
+ // Act
+ onNodeWithTag(testTag = SERVER_IP_OVERRIDE_IMPORT_TEST_TAG).performClick()
+
+ // Assert
+ onNodeWithText(
+ "Importing new overrides might replace some previously imported overrides."
+ )
+ .assertExists()
+ }
+
+ @Test
+ fun ensure_info_click_works() =
+ composeExtension.use {
+ // Arrange
+ val clickHandler: () -> Unit = mockk(relaxed = true)
+ setContentWithTheme {
+ ScreenWithDefault(
+ state = ServerIpOverridesViewState.Loaded(false),
+ onInfoClick = clickHandler
+ )
+ }
+
+ // Act
+ onNodeWithTag(SERVER_IP_OVERRIDE_INFO_TEST_TAG).performClick()
+
+ // Assert
+ verify { clickHandler() }
+ }
+
+ @Test
+ fun ensure_reset_click_works() =
+ composeExtension.use {
+ // Arrange
+ val clickHandler: () -> Unit = mockk(relaxed = true)
+ setContentWithTheme {
+ ScreenWithDefault(
+ state = ServerIpOverridesViewState.Loaded(true),
+ onResetOverridesClick = clickHandler
+ )
+ }
+
+ // Act
+ onNodeWithTag(SERVER_IP_OVERRIDE_MORE_VERT_TEST_TAG).performClick()
+ onNodeWithTag(SERVER_IP_OVERRIDE_RESET_OVERRIDES_TEST_TAG).performClick()
+
+ // Assert
+ verify { clickHandler() }
+ }
+
+ @Test
+ fun ensure_import_by_file_works() =
+ composeExtension.use {
+ // Arrange
+ val clickHandler: () -> Unit = mockk(relaxed = true)
+ setContentWithTheme {
+ ScreenWithDefault(
+ state = ServerIpOverridesViewState.Loaded(false),
+ onImportByFile = clickHandler
+ )
+ }
+
+ // Act
+ onNodeWithTag(SERVER_IP_OVERRIDE_IMPORT_TEST_TAG).performClick()
+ onNodeWithTag(SERVER_IP_OVERRIDES_IMPORT_BY_FILE_TEST_TAG).performClick()
+
+ // Assert
+ verify { clickHandler() }
+ }
+
+ @Test
+ fun ensure_import_by_text() =
+ composeExtension.use {
+ // Arrange
+ val clickHandler: () -> Unit = mockk(relaxed = true)
+ setContentWithTheme {
+ ScreenWithDefault(
+ state = ServerIpOverridesViewState.Loaded(false),
+ onImportByText = clickHandler
+ )
+ }
+
+ // Act
+ onNodeWithTag(SERVER_IP_OVERRIDE_IMPORT_TEST_TAG).performClick()
+ onNodeWithTag(SERVER_IP_OVERRIDES_IMPORT_BY_TEXT_TEST_TAG).performClick()
+
+ // Assert
+ verify { clickHandler() }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/InfoIconButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/InfoIconButton.kt
new file mode 100644
index 0000000000..5c28069c52
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/InfoIconButton.kt
@@ -0,0 +1,26 @@
+package net.mullvad.mullvadvpn.compose.button
+
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import net.mullvad.mullvadvpn.R
+
+@Composable
+fun InfoIconButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ contentDescription: String? = null,
+ iconTint: Color = MaterialTheme.colorScheme.onPrimary
+) {
+ IconButton(modifier = modifier, onClick = onClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.icon_info),
+ contentDescription = contentDescription,
+ tint = iconTint
+ )
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt
index faf537fb7f..3b68e42e45 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt
@@ -25,13 +25,14 @@ private fun PreviewIconCell() {
@Composable
fun IconCell(
iconId: Int?,
- contentDescription: String? = null,
title: String,
+ modifier: Modifier = Modifier,
+ contentDescription: String? = null,
titleStyle: TextStyle = MaterialTheme.typography.labelLarge,
titleColor: Color = MaterialTheme.colorScheme.onPrimary,
onClick: () -> Unit = {},
background: Color = MaterialTheme.colorScheme.primary,
- enabled: Boolean = true,
+ enabled: Boolean = true
) {
BaseCell(
headlineContent = {
@@ -49,6 +50,7 @@ fun IconCell(
},
onCellClicked = onClick,
background = background,
- isRowEnabled = enabled
+ isRowEnabled = enabled,
+ modifier = modifier
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ServerIpOverridesCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ServerIpOverridesCell.kt
new file mode 100644
index 0000000000..acd785e1c3
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ServerIpOverridesCell.kt
@@ -0,0 +1,82 @@
+package net.mullvad.mullvadvpn.compose.cell
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorSmall
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible
+import net.mullvad.mullvadvpn.lib.theme.color.selected
+
+@Preview
+@Composable
+private fun PreviewServerIpOverridesCell() {
+ AppTheme { ServerIpOverridesCell(active = true) }
+}
+
+@Composable
+fun ServerIpOverridesCell(
+ active: Boolean?,
+ modifier: Modifier = Modifier,
+ activeColor: Color = MaterialTheme.colorScheme.selected,
+ inactiveColor: Color = MaterialTheme.colorScheme.error,
+) {
+ BaseCell(
+ modifier = modifier,
+ iconView = {
+ if (active == null) {
+ MullvadCircularProgressIndicatorSmall()
+ } else {
+ Box(
+ modifier =
+ Modifier.size(Dimens.relayCircleSize)
+ .background(
+ color =
+ when {
+ active -> activeColor
+ else -> inactiveColor
+ },
+ shape = CircleShape
+ )
+ )
+ }
+ },
+ headlineContent = {
+ if (active != null) {
+ Text(
+ text =
+ if (active) stringResource(id = R.string.server_ip_overrides_active)
+ else stringResource(id = R.string.server_ip_overrides_inactive),
+ color = MaterialTheme.colorScheme.onPrimary,
+ modifier =
+ Modifier.weight(1f)
+ .alpha(
+ if (active) {
+ AlphaVisible
+ } else {
+ AlphaInactive
+ }
+ )
+ .padding(
+ horizontal = Dimens.smallPadding,
+ vertical = Dimens.mediumPadding
+ )
+ )
+ }
+ },
+ isRowEnabled = false
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt
index 1f8fb46cd7..edd697dfec 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt
@@ -37,7 +37,7 @@ private fun PreviewMullvadModalBottomSheet() {
title = "Select",
)
},
- closeBottomSheet = {}
+ onDismissRequest = {}
)
}
}
@@ -49,13 +49,13 @@ fun MullvadModalBottomSheet(
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer,
onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface,
- closeBottomSheet: () -> Unit,
+ onDismissRequest: () -> Unit,
sheetContent: @Composable ColumnScope.() -> Unit
) {
// This is to avoid weird colors in the status bar and the navigation bar
val paddingValues = BottomSheetDefaults.windowInsets.asPaddingValues()
ModalBottomSheet(
- onDismissRequest = closeBottomSheet,
+ onDismissRequest = onDismissRequest,
sheetState = sheetState,
containerColor = backgroundColor,
modifier = modifier,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt
index b9a6306413..585855cb1d 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt
@@ -107,8 +107,9 @@ fun ScaffoldWithTopBarAndDeviceName(
}
@Composable
-fun MullvadSnackbar(snackbarData: SnackbarData) {
+fun MullvadSnackbar(modifier: Modifier = Modifier, snackbarData: SnackbarData) {
Snackbar(
+ modifier = modifier,
snackbarData = snackbarData,
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIpOverridesConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIpOverridesConfirmationDialog.kt
new file mode 100644
index 0000000000..c90c22ead4
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIpOverridesConfirmationDialog.kt
@@ -0,0 +1,85 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.result.ResultBackNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.NegativeButton
+import net.mullvad.mullvadvpn.compose.button.PrimaryButton
+import net.mullvad.mullvadvpn.compose.test.RESET_SERVER_IP_OVERRIDE_CANCEL_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.RESET_SERVER_IP_OVERRIDE_RESET_TEST_TAG
+import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.viewmodel.ResetServerIpOverridesConfirmationUiSideEffect
+import net.mullvad.mullvadvpn.viewmodel.ResetServerIpOverridesConfirmationViewModel
+import org.koin.androidx.compose.koinViewModel
+
+@Preview
+@Composable
+private fun PreviewResetServerIpOverridesConfirmationDialog() {
+ AppTheme { ResetServerIpOverridesConfirmationDialog({}, {}) }
+}
+
+@Destination(style = DestinationStyle.Dialog::class)
+@Composable
+fun ResetServerIpOverridesConfirmation(resultBackNavigator: ResultBackNavigator<Boolean>) {
+ val vm: ResetServerIpOverridesConfirmationViewModel = koinViewModel()
+ CollectSideEffectWithLifecycle(vm.uiSideEffect) {
+ when (it) {
+ ResetServerIpOverridesConfirmationUiSideEffect.OverridesCleared ->
+ resultBackNavigator.navigateBack(result = true)
+ }
+ }
+ ResetServerIpOverridesConfirmationDialog(
+ onClearAllOverrides = vm::clearAllOverrides,
+ resultBackNavigator::navigateBack
+ )
+}
+
+@Composable
+fun ResetServerIpOverridesConfirmationDialog(
+ onClearAllOverrides: () -> Unit,
+ onNavigateBack: () -> Unit
+) {
+ AlertDialog(
+ containerColor = MaterialTheme.colorScheme.background,
+ confirmButton = {
+ NegativeButton(
+ modifier = Modifier.fillMaxWidth().testTag(RESET_SERVER_IP_OVERRIDE_RESET_TEST_TAG),
+ text = stringResource(id = R.string.server_ip_overrides_reset_reset_button),
+ onClick = onClearAllOverrides
+ )
+ },
+ dismissButton = {
+ PrimaryButton(
+ modifier =
+ Modifier.fillMaxWidth().testTag(RESET_SERVER_IP_OVERRIDE_CANCEL_TEST_TAG),
+ text = stringResource(R.string.cancel),
+ onClick = onNavigateBack
+ )
+ },
+ title = {
+ Text(
+ text = stringResource(id = R.string.server_ip_overrides_reset_title),
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ },
+ text = {
+ Text(
+ text = stringResource(id = R.string.server_ip_overrides_reset_body),
+ color = MaterialTheme.colorScheme.onBackground,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ },
+ onDismissRequest = onNavigateBack
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ServerIpOverridesInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ServerIpOverridesInfoDialog.kt
new file mode 100644
index 0000000000..9b6054f1f0
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ServerIpOverridesInfoDialog.kt
@@ -0,0 +1,32 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import net.mullvad.mullvadvpn.R
+
+@Preview
+@Composable
+private fun PreviewServerIpOverridesInfoDialog() {
+ ServerIpOverridesInfoDialog(EmptyDestinationsNavigator)
+}
+
+@Destination(style = DestinationStyle.Dialog::class)
+@Composable
+fun ServerIpOverridesInfoDialog(navigator: DestinationsNavigator) {
+ InfoDialog(
+ message =
+ buildString {
+ appendLine(stringResource(id = R.string.server_ip_overrides_info_first_paragraph))
+ appendLine()
+ appendLine(stringResource(id = R.string.server_ip_overrides_info_second_paragraph))
+ appendLine()
+ append(stringResource(id = R.string.server_ip_overrides_info_third_paragraph))
+ },
+ onDismiss = navigator::navigateUp
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ImportOverridesByTextScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ImportOverridesByTextScreen.kt
new file mode 100644
index 0000000000..7ab063703c
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ImportOverridesByTextScreen.kt
@@ -0,0 +1,92 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.result.ResultBackNavigator
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.component.MullvadSmallTopBar
+import net.mullvad.mullvadvpn.compose.textfield.mullvadWhiteTextFieldColors
+import net.mullvad.mullvadvpn.compose.transitions.DefaultTransition
+
+@Preview
+@Composable
+private fun PreviewImportOverridesByText() {
+ ImportOverridesByTextScreen({}, {})
+}
+
+@Destination(style = DefaultTransition::class)
+@Composable
+fun ImportOverridesByText(
+ resultNavigator: ResultBackNavigator<String>,
+) {
+ ImportOverridesByTextScreen(
+ onNavigateBack = resultNavigator::navigateBack,
+ onImportClicked = { resultNavigator.navigateBack(result = it) }
+ )
+}
+
+@Composable
+fun ImportOverridesByTextScreen(
+ onNavigateBack: () -> Unit,
+ onImportClicked: (String) -> Unit,
+) {
+ var text by remember { mutableStateOf("") }
+
+ Scaffold(
+ topBar = {
+ MullvadSmallTopBar(
+ title = stringResource(R.string.import_overrides_text_title),
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(imageVector = Icons.Default.Close, contentDescription = null)
+ }
+ },
+ actions = {
+ TextButton(
+ enabled = text.isNotEmpty(),
+ colors =
+ ButtonDefaults.textButtonColors()
+ .copy(contentColor = MaterialTheme.colorScheme.onPrimary),
+ onClick = { onImportClicked(text) }
+ ) {
+ Text(
+ text = stringResource(R.string.import_overrides_import),
+ )
+ }
+ }
+ )
+ },
+ ) {
+ Column(modifier = Modifier.padding(it)) {
+ TextField(
+ modifier = Modifier.fillMaxSize(),
+ value = text,
+ onValueChange = { text = it },
+ placeholder = {
+ Text(text = stringResource(R.string.import_override_textfield_placeholder))
+ },
+ colors = mullvadWhiteTextFieldColors()
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt
index 594c657cdb..a7e802e89c 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt
@@ -545,7 +545,7 @@ private fun CustomListsBottomSheet(
) {
MullvadModalBottomSheet(
sheetState = sheetState,
- closeBottomSheet = { closeBottomSheet(false) },
+ onDismissRequest = { closeBottomSheet(false) },
modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG)
) { ->
HeaderCell(
@@ -556,21 +556,16 @@ private fun CustomListsBottomSheet(
IconCell(
iconId = R.drawable.icon_add,
title = stringResource(id = R.string.new_list),
+ titleColor = onBackgroundColor,
onClick = {
onCreateCustomList()
closeBottomSheet(true)
},
- background = Color.Unspecified,
- titleColor = onBackgroundColor
+ background = Color.Unspecified
)
IconCell(
iconId = R.drawable.icon_edit,
title = stringResource(id = R.string.edit_lists),
- onClick = {
- onEditCustomLists()
- closeBottomSheet(true)
- },
- background = Color.Unspecified,
titleColor =
onBackgroundColor.copy(
alpha =
@@ -580,6 +575,11 @@ private fun CustomListsBottomSheet(
AlphaInactive
}
),
+ onClick = {
+ onEditCustomLists()
+ closeBottomSheet(true)
+ },
+ background = Color.Unspecified,
enabled = bottomSheetState.editListEnabled
)
}
@@ -598,7 +598,7 @@ private fun LocationBottomSheet(
) {
MullvadModalBottomSheet(
sheetState = sheetState,
- closeBottomSheet = { closeBottomSheet(false) },
+ onDismissRequest = { closeBottomSheet(false) },
modifier = Modifier.testTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG)
) { ->
HeaderCell(
@@ -609,13 +609,6 @@ private fun LocationBottomSheet(
customLists.forEach {
val enabled = it.canAddLocation(item)
IconCell(
- background = Color.Unspecified,
- titleColor =
- if (enabled) {
- onBackgroundColor
- } else {
- MaterialTheme.colorScheme.onSecondary
- },
iconId = null,
title =
if (enabled) {
@@ -623,22 +616,29 @@ private fun LocationBottomSheet(
} else {
stringResource(id = R.string.location_added, it.name)
},
+ titleColor =
+ if (enabled) {
+ onBackgroundColor
+ } else {
+ MaterialTheme.colorScheme.onSecondary
+ },
onClick = {
onAddLocationToList(item, it)
closeBottomSheet(true)
},
+ background = Color.Unspecified,
enabled = enabled
)
}
IconCell(
iconId = R.drawable.icon_add,
title = stringResource(id = R.string.new_list),
+ titleColor = onBackgroundColor,
onClick = {
onCreateCustomList(item)
closeBottomSheet(true)
},
- background = Color.Unspecified,
- titleColor = onBackgroundColor
+ background = Color.Unspecified
)
}
}
@@ -656,39 +656,39 @@ private fun EditCustomListBottomSheet(
) {
MullvadModalBottomSheet(
sheetState = sheetState,
- closeBottomSheet = { closeBottomSheet(false) }
+ onDismissRequest = { closeBottomSheet(false) }
) {
HeaderCell(text = customList.name, background = Color.Unspecified)
IconCell(
iconId = R.drawable.icon_edit,
title = stringResource(id = R.string.edit_name),
+ titleColor = onBackgroundColor,
onClick = {
onEditName(customList)
closeBottomSheet(true)
},
- background = Color.Unspecified,
- titleColor = onBackgroundColor
+ background = Color.Unspecified
)
IconCell(
iconId = R.drawable.icon_add,
title = stringResource(id = R.string.edit_locations),
+ titleColor = onBackgroundColor,
onClick = {
onEditLocations(customList)
closeBottomSheet(true)
},
- background = Color.Unspecified,
- titleColor = onBackgroundColor
+ background = Color.Unspecified
)
HorizontalDivider(color = onBackgroundColor)
IconCell(
iconId = R.drawable.icon_delete,
title = stringResource(id = R.string.delete),
+ titleColor = onBackgroundColor,
onClick = {
onDeleteCustomList(customList)
closeBottomSheet(true)
},
- background = Color.Unspecified,
- titleColor = onBackgroundColor
+ background = Color.Unspecified
)
}
}
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
new file mode 100644
index 0000000000..33b8419b9c
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt
@@ -0,0 +1,351 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import android.content.Context
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MenuDefaults
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+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.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.result.ResultRecipient
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.InfoIconButton
+import net.mullvad.mullvadvpn.compose.button.PrimaryButton
+import net.mullvad.mullvadvpn.compose.cell.HeaderCell
+import net.mullvad.mullvadvpn.compose.cell.IconCell
+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.ScaffoldWithMediumTopBar
+import net.mullvad.mullvadvpn.compose.destinations.ImportOverridesByTextDestination
+import net.mullvad.mullvadvpn.compose.destinations.ResetServerIpOverridesConfirmationDestination
+import net.mullvad.mullvadvpn.compose.destinations.ServerIpOverridesInfoDialogDestination
+import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDES_IMPORT_BY_FILE_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDES_IMPORT_BY_TEXT_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDE_IMPORT_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDE_INFO_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDE_MORE_VERT_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDE_RESET_OVERRIDES_TEST_TAG
+import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightLeafTransition
+import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect
+import net.mullvad.mullvadvpn.compose.util.OnNavResultValue
+import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled
+import net.mullvad.mullvadvpn.model.SettingsPatchError
+import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesUiSideEffect
+import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesViewModel
+import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesViewState
+import org.koin.androidx.compose.koinViewModel
+
+@Preview
+@Composable
+private fun PreviewServerIpOverridesScreen() {
+ AppTheme {
+ ServerIpOverridesScreen(
+ ServerIpOverridesViewState.Loaded(false),
+ onBackClick = {},
+ onInfoClick = {},
+ onResetOverridesClick = {},
+ onImportByFile = {},
+ onImportByText = {},
+ SnackbarHostState()
+ )
+ }
+}
+
+@Destination(style = SlideInFromRightLeafTransition::class)
+@Composable
+fun ServerIpOverrides(
+ navigator: DestinationsNavigator,
+ importByTextResult: ResultRecipient<ImportOverridesByTextDestination, String>,
+ clearOverridesResult: ResultRecipient<ResetServerIpOverridesConfirmationDestination, Boolean>,
+) {
+ val vm = koinViewModel<ServerIpOverridesViewModel>()
+ val state by vm.uiState.collectAsStateWithLifecycle()
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ val context = LocalContext.current
+ LaunchedEffectCollect(vm.uiSideEffect) { sideEffect ->
+ when (sideEffect) {
+ is ServerIpOverridesUiSideEffect.ImportResult ->
+ snackbarHostState.showSnackbarImmediately(
+ this,
+ message = sideEffect.error.toString(context),
+ actionLabel = null
+ )
+ }
+ }
+
+ importByTextResult.OnNavResultValue(vm::importText)
+
+ // On successful clear of overrides, show snackbar
+ val scope = rememberCoroutineScope()
+ clearOverridesResult.OnNavResultValue {
+ scope.launch {
+ snackbarHostState.showSnackbarImmediately(
+ this,
+ message = context.getString(R.string.overrides_cleared),
+ actionLabel = null
+ )
+ }
+ }
+
+ val openFileLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
+ if (it != null) {
+ vm.importFile(it)
+ }
+ }
+
+ ServerIpOverridesScreen(
+ state,
+ onBackClick = navigator::navigateUp,
+ onInfoClick = {
+ navigator.navigate(ServerIpOverridesInfoDialogDestination, onlyIfResumed = true)
+ },
+ onResetOverridesClick = {
+ navigator.navigate(ResetServerIpOverridesConfirmationDestination, onlyIfResumed = true)
+ },
+ onImportByFile = { openFileLauncher.launch("application/json") },
+ onImportByText = {
+ navigator.navigate(ImportOverridesByTextDestination, onlyIfResumed = true)
+ },
+ snackbarHostState
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ServerIpOverridesScreen(
+ state: ServerIpOverridesViewState,
+ onBackClick: () -> Unit,
+ onInfoClick: () -> Unit,
+ onResetOverridesClick: () -> Unit,
+ onImportByFile: () -> Unit,
+ onImportByText: () -> Unit,
+ snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
+) {
+
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ var showBottomSheet by remember { mutableStateOf(false) }
+
+ ScaffoldWithMediumTopBar(
+ appBarTitle = stringResource(id = R.string.server_ip_overrides),
+ navigationIcon = { NavigateBackIconButton(onBackClick) },
+ actions = {
+ TopBarActions(
+ overridesActive = state.overridesActive,
+ onInfoClick = onInfoClick,
+ onResetOverridesClick = onResetOverridesClick
+ )
+ }
+ ) { modifier ->
+ if (showBottomSheet && state.overridesActive != null) {
+ ImportOverridesByBottomSheet(
+ sheetState,
+ { showBottomSheet = it },
+ state.overridesActive!!,
+ onImportByFile,
+ onImportByText
+ )
+ }
+
+ Column(
+ modifier = modifier.animateContentSize(),
+ ) {
+ ServerIpOverridesCell(active = state.overridesActive)
+
+ Spacer(modifier = Modifier.weight(1f))
+ PrimaryButton(
+ onClick = { showBottomSheet = true },
+ text = stringResource(R.string.server_ip_overrides_import_button),
+ modifier =
+ Modifier.padding(horizontal = Dimens.sideMargin)
+ .padding(bottom = Dimens.screenVerticalMargin)
+ .testTag(SERVER_IP_OVERRIDE_IMPORT_TEST_TAG),
+ )
+ SnackbarHost(hostState = snackbarHostState, modifier = Modifier.animateContentSize()) {
+ MullvadSnackbar(snackbarData = it)
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ImportOverridesByBottomSheet(
+ sheetState: SheetState,
+ showBottomSheet: (Boolean) -> Unit,
+ overridesActive: Boolean,
+ onImportByFile: () -> Unit,
+ onImportByText: () -> Unit
+) {
+ val scope = rememberCoroutineScope()
+ val onCloseSheet = {
+ scope
+ .launch { sheetState.hide() }
+ .invokeOnCompletion {
+ if (!sheetState.isVisible) {
+ showBottomSheet(false)
+ }
+ }
+ }
+
+ MullvadModalBottomSheet(
+ sheetState = sheetState,
+ onDismissRequest = { showBottomSheet(false) },
+ ) { ->
+ HeaderCell(
+ text = stringResource(id = R.string.server_ip_overrides_import_by),
+ background = Color.Unspecified
+ )
+ HorizontalDivider(color = MaterialTheme.colorScheme.onBackground)
+ IconCell(
+ iconId = R.drawable.icon_upload_file,
+ title = stringResource(id = R.string.server_ip_overrides_import_by_file),
+ onClick = {
+ onImportByFile()
+ onCloseSheet()
+ },
+ background = Color.Unspecified,
+ modifier = Modifier.testTag(SERVER_IP_OVERRIDES_IMPORT_BY_FILE_TEST_TAG)
+ )
+ IconCell(
+ iconId = R.drawable.icon_text_fields,
+ title = stringResource(id = R.string.server_ip_overrides_import_by_text),
+ onClick = {
+ onImportByText()
+ onCloseSheet()
+ },
+ background = Color.Unspecified,
+ modifier = Modifier.testTag(SERVER_IP_OVERRIDES_IMPORT_BY_TEXT_TEST_TAG)
+ )
+ if (overridesActive) {
+ HorizontalDivider(color = MaterialTheme.colorScheme.onBackground)
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ modifier = Modifier.padding(Dimens.mediumPadding),
+ painter = painterResource(id = R.drawable.icon_info),
+ tint = MaterialTheme.colorScheme.errorContainer,
+ contentDescription = null
+ )
+ Text(
+ modifier =
+ Modifier.padding(
+ top = Dimens.smallPadding,
+ end = Dimens.mediumPadding,
+ bottom = Dimens.smallPadding
+ ),
+ text = stringResource(R.string.import_overrides_bottom_sheet_override_warning),
+ maxLines = 2,
+ style = MaterialTheme.typography.bodySmall,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TopBarActions(
+ overridesActive: Boolean?,
+ onInfoClick: () -> Unit,
+ onResetOverridesClick: () -> Unit
+) {
+ var showMenu by remember { mutableStateOf(false) }
+ InfoIconButton(
+ onClick = onInfoClick,
+ modifier = Modifier.testTag(SERVER_IP_OVERRIDE_INFO_TEST_TAG)
+ )
+ IconButton(
+ onClick = { showMenu = !showMenu },
+ modifier = Modifier.testTag(SERVER_IP_OVERRIDE_MORE_VERT_TEST_TAG)
+ ) {
+ Icon(painterResource(id = R.drawable.icon_more_vert), contentDescription = null)
+ }
+ DropdownMenu(
+ modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer),
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.server_ip_overrides_reset)) },
+ onClick = {
+ showMenu = false
+ onResetOverridesClick()
+ },
+ enabled = overridesActive ?: false,
+ colors =
+ MenuDefaults.itemColors(
+ leadingIconColor = MaterialTheme.colorScheme.onPrimary,
+ disabledLeadingIconColor =
+ MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaDisabled)
+ ),
+ leadingIcon = {
+ Icon(
+ Icons.Filled.Delete,
+ contentDescription = null,
+ )
+ },
+ modifier = Modifier.testTag(SERVER_IP_OVERRIDE_RESET_OVERRIDES_TEST_TAG)
+ )
+ }
+}
+
+private fun SettingsPatchError?.toString(context: Context) =
+ when (this) {
+ SettingsPatchError.DeserializePatched ->
+ context.getString(R.string.patch_not_matching_specification)
+ is SettingsPatchError.InvalidOrMissingValue ->
+ context.getString(R.string.settings_patch_error_invalid_or_missing_value, value)
+ SettingsPatchError.ParsePatch ->
+ context.getString(R.string.settings_patch_error_unable_to_parse)
+ is SettingsPatchError.UnknownOrProhibitedKey ->
+ context.getString(R.string.settings_patch_error_unknown_or_prohibited_key, value)
+ SettingsPatchError.ApplyPatch ->
+ context.getString(R.string.settings_patch_error_failed_to_apply_patch)
+ SettingsPatchError.RecursionLimit ->
+ context.getString(R.string.settings_patch_error_recursion_limit)
+ null -> context.getString(R.string.settings_patch_success)
+ }
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 bd8809b00f..e926e2e97f 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
@@ -61,6 +61,7 @@ import net.mullvad.mullvadvpn.compose.destinations.MalwareInfoDialogDestination
import net.mullvad.mullvadvpn.compose.destinations.MtuDialogDestination
import net.mullvad.mullvadvpn.compose.destinations.ObfuscationInfoDialogDestination
import net.mullvad.mullvadvpn.compose.destinations.QuantumResistanceInfoDialogDestination
+import net.mullvad.mullvadvpn.compose.destinations.ServerIpOverridesDestination
import net.mullvad.mullvadvpn.compose.destinations.UdpOverTcpPortInfoDialogDestination
import net.mullvad.mullvadvpn.compose.destinations.WireguardCustomPortDialogDestination
import net.mullvad.mullvadvpn.compose.destinations.WireguardPortInfoDialogDestination
@@ -219,6 +220,9 @@ fun VpnSettings(
navigateToLocalNetworkSharingInfo = {
navigator.navigate(LocalNetworkSharingInfoDialogDestination) { launchSingleTop = true }
},
+ navigateToServerIpOverrides = {
+ navigator.navigate(ServerIpOverridesDestination) { launchSingleTop = true }
+ },
onToggleBlockTrackers = vm::onToggleBlockTrackers,
onToggleBlockAds = vm::onToggleBlockAds,
onToggleBlockMalware = vm::onToggleBlockMalware,
@@ -267,6 +271,7 @@ fun VpnSettingsScreen(
navigateToWireguardPortInfo: (availablePortRanges: List<PortRange>) -> Unit = {},
navigateToLocalNetworkSharingInfo: () -> Unit = {},
navigateToWireguardPortDialog: () -> Unit = {},
+ navigateToServerIpOverrides: () -> Unit = {},
onToggleBlockTrackers: (Boolean) -> Unit = {},
onToggleBlockAds: (Boolean) -> Unit = {},
onToggleBlockMalware: (Boolean) -> Unit = {},
@@ -614,6 +619,16 @@ fun VpnSettingsScreen(
MtuSubtitle(modifier = Modifier.testTag(LAZY_LIST_LAST_ITEM_TEST_TAG))
Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
}
+
+ item { ServerIpOverrides(navigateToServerIpOverrides) }
}
}
}
+
+@Composable
+private fun ServerIpOverrides(onServerIpOverridesClick: () -> Unit) {
+ NavigationComposeCell(
+ title = stringResource(id = R.string.server_ip_overrides),
+ onClick = onServerIpOverridesClick
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
index efd8e34250..8ebdaede33 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
@@ -64,3 +64,16 @@ const val SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG =
"select_location_custom_list_bottom_sheet_test_tag"
const val SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG =
"select_location_location_bottom_sheet_test_tag"
+
+// ServerIpOverridesScreen
+const val SERVER_IP_OVERRIDE_IMPORT_TEST_TAG = "server_ip_override_import_button_test_tag"
+const val SERVER_IP_OVERRIDE_INFO_TEST_TAG = "server_ip_override_info_button_test_tag"
+const val SERVER_IP_OVERRIDE_MORE_VERT_TEST_TAG = "server_ip_override_more_vert_button_test_tag"
+const val SERVER_IP_OVERRIDE_RESET_OVERRIDES_TEST_TAG = "server_ip_override_reset_button_test_tag"
+const val SERVER_IP_OVERRIDES_IMPORT_BY_FILE_TEST_TAG = "server_ip_override_import_by_file_test_tag"
+const val SERVER_IP_OVERRIDES_IMPORT_BY_TEXT_TEST_TAG = "server_ip_override_import_by_text_test_tag"
+
+// ResetServerIpOverridesConfirmationDialog
+const val RESET_SERVER_IP_OVERRIDE_RESET_TEST_TAG = "reset_server_ip_override_reset_button_test_tag"
+const val RESET_SERVER_IP_OVERRIDE_CANCEL_TEST_TAG =
+ "reset_server_ip_override_cancel_button_test_tag"
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightLeafTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightLeafTransition.kt
new file mode 100644
index 0000000000..45ea74931a
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightLeafTransition.kt
@@ -0,0 +1,30 @@
+package net.mullvad.mullvadvpn.compose.transitions
+
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.navigation.NavBackStackEntry
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import com.ramcosta.composedestinations.utils.destination
+import net.mullvad.mullvadvpn.compose.destinations.NoDaemonScreenDestination
+import net.mullvad.mullvadvpn.constant.SCREEN_ANIMATION_TIME_MILLIS
+
+object SlideInFromRightLeafTransition : DestinationStyle.Animated {
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition() =
+ slideInHorizontally(initialOffsetX = { it })
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() =
+ when (targetState.destination()) {
+ NoDaemonScreenDestination -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS))
+ else -> fadeOut()
+ }
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() =
+ fadeIn(snap(0))
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() =
+ slideOutHorizontally(targetOffsetX = { it })
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Navigation.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Navigation.kt
new file mode 100644
index 0000000000..9566bc0da2
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Navigation.kt
@@ -0,0 +1,17 @@
+package net.mullvad.mullvadvpn.compose.util
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisallowComposableCalls
+import com.ramcosta.composedestinations.result.NavResult
+import com.ramcosta.composedestinations.result.ResultRecipient
+import net.mullvad.mullvadvpn.compose.destinations.DirectionDestination
+
+@Composable
+fun <D : DirectionDestination, V> ResultRecipient<D, V>.OnNavResultValue(
+ onValue: @DisallowComposableCalls (value: V) -> Unit
+) = onNavResult {
+ when (it) {
+ NavResult.Canceled -> Unit
+ is NavResult.Value -> onValue(it.value)
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Snackbar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Snackbar.kt
new file mode 100644
index 0000000000..3e5b7e1618
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Snackbar.kt
@@ -0,0 +1,19 @@
+package net.mullvad.mullvadvpn.compose.util
+
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHostState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+suspend fun SnackbarHostState.showSnackbarImmediately(
+ coroutineScope: CoroutineScope,
+ message: String,
+ actionLabel: String? = null,
+ withDismissAction: Boolean = false,
+ duration: SnackbarDuration =
+ if (actionLabel == null) SnackbarDuration.Short else SnackbarDuration.Indefinite
+) =
+ coroutineScope.launch {
+ currentSnackbarData?.dismiss()
+ showSnackbar(message, actionLabel, withDismissAction, duration)
+ }
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 c3eb19b270..fe02cf5b7a 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
@@ -20,6 +20,7 @@ import net.mullvad.mullvadvpn.repository.DeviceRepository
import net.mullvad.mullvadvpn.repository.InAppNotificationController
import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository
import net.mullvad.mullvadvpn.repository.ProblemReportRepository
+import net.mullvad.mullvadvpn.repository.RelayOverridesRepository
import net.mullvad.mullvadvpn.repository.SettingsRepository
import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
@@ -60,7 +61,9 @@ import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel
import net.mullvad.mullvadvpn.viewmodel.PaymentViewModel
import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel
import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel
+import net.mullvad.mullvadvpn.viewmodel.ResetServerIpOverridesConfirmationViewModel
import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel
+import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesViewModel
import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel
import net.mullvad.mullvadvpn.viewmodel.SplashViewModel
import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel
@@ -95,6 +98,7 @@ val uiModule = module {
single { InetAddressValidator.getInstance() }
single { androidContext().resources }
single { androidContext().assets }
+ single { androidContext().contentResolver }
single { ChangelogRepository(get(named(APP_PREFERENCES_NAME)), get()) }
@@ -105,8 +109,9 @@ val uiModule = module {
androidContext().getSharedPreferences(APP_PREFERENCES_NAME, Context.MODE_PRIVATE)
)
}
- single { SettingsRepository(get()) }
+ single { SettingsRepository(get(), get()) }
single { MullvadProblemReport(get()) }
+ single { RelayOverridesRepository(get(), get()) }
single { CustomListsRepository(get(), get(), get()) }
single { AccountExpiryNotificationUseCase(get()) }
@@ -178,6 +183,8 @@ val uiModule = module {
}
viewModel { CustomListsViewModel(get(), get()) }
viewModel { parameters -> DeleteCustomListConfirmationViewModel(parameters.get(), get()) }
+ viewModel { ServerIpOverridesViewModel(get(), get(), get(), get()) }
+ viewModel { ResetServerIpOverridesConfirmationViewModel(get()) }
// This view model must be single so we correctly attach lifecycle and share it with activity
single { NoDaemonViewModel(get()) }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayOverridesRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayOverridesRepository.kt
new file mode 100644
index 0000000000..835cab4710
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayOverridesRepository.kt
@@ -0,0 +1,44 @@
+package net.mullvad.mullvadvpn.repository
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.mapNotNull
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+import net.mullvad.mullvadvpn.lib.ipc.MessageHandler
+import net.mullvad.mullvadvpn.lib.ipc.Request
+import net.mullvad.mullvadvpn.model.RelayOverride
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.ui.serviceconnection.settingsListener
+import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier
+import net.mullvad.mullvadvpn.util.flatMapReadyConnectionOrDefault
+
+class RelayOverridesRepository(
+ private val serviceConnectionManager: ServiceConnectionManager,
+ private val messageHandler: MessageHandler,
+ dispatcher: CoroutineDispatcher = Dispatchers.IO,
+) {
+ fun clearAllOverrides() {
+ messageHandler.trySendRequest(Request.ClearAllRelayOverrides)
+ }
+
+ val relayOverrides: StateFlow<List<RelayOverride>?> =
+ serviceConnectionManager.connectionState
+ .flatMapReadyConnectionOrDefault(flowOf()) { state ->
+ callbackFlowFromNotifier(state.container.settingsListener.settingsNotifier)
+ }
+ .mapNotNull { it?.relayOverrides?.toList() }
+ .onStart {
+ serviceConnectionManager
+ .settingsListener()
+ ?.settingsNotifier
+ ?.latestEvent
+ ?.relayOverrides
+ ?.toList()
+ }
+ .stateIn(CoroutineScope(dispatcher), SharingStarted.Eagerly, null)
+}
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 81c4b85b88..7d61feaf0c 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
@@ -4,11 +4,18 @@ import java.net.InetAddress
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.withContext
+import net.mullvad.mullvadvpn.lib.ipc.Event.ApplyJsonSettingsResult
+import net.mullvad.mullvadvpn.lib.ipc.MessageHandler
+import net.mullvad.mullvadvpn.lib.ipc.Request
+import net.mullvad.mullvadvpn.lib.ipc.events
import net.mullvad.mullvadvpn.model.CustomDnsOptions
import net.mullvad.mullvadvpn.model.DefaultDnsOptions
import net.mullvad.mullvadvpn.model.DnsOptions
@@ -24,7 +31,8 @@ import net.mullvad.mullvadvpn.util.flatMapReadyConnectionOrDefault
class SettingsRepository(
private val serviceConnectionManager: ServiceConnectionManager,
- dispatcher: CoroutineDispatcher = Dispatchers.IO
+ private val messageHandler: MessageHandler,
+ private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
val settingsUpdates: StateFlow<Settings?> =
serviceConnectionManager.connectionState
@@ -92,4 +100,11 @@ class SettingsRepository(
fun setLocalNetworkSharing(isEnabled: Boolean) {
serviceConnectionManager.settingsListener()?.allowLan = isEnabled
}
+
+ suspend fun applySettingsPatch(json: String) =
+ withContext(dispatcher) {
+ val deferred = async { messageHandler.events<ApplyJsonSettingsResult>().first() }
+ messageHandler.trySendRequest(Request.ApplyJsonSettings(json))
+ deferred.await()
+ }
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
index a0841c0746..c7a9be2ff9 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
@@ -92,7 +92,13 @@ class MainActivity : ComponentActivity() {
}
override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
- serviceConnectionManager.onVpnPermissionResult(resultCode == Activity.RESULT_OK)
+ // super call is needed for return value when opening file.
+ super.onActivityResult(requestCode, resultCode, resultData)
+
+ // Ensure we are responding to the correct request
+ if (requestCode == REQUEST_VPN_PERMISSION_RESULT_CODE) {
+ serviceConnectionManager.onVpnPermissionResult(resultCode == Activity.RESULT_OK)
+ }
}
override fun onStop() {
@@ -111,6 +117,10 @@ class MainActivity : ComponentActivity() {
private fun requestVpnPermission() {
val intent = VpnService.prepare(this)
- startActivityForResult(intent, 0)
+ startActivityForResult(intent, REQUEST_VPN_PERMISSION_RESULT_CODE)
+ }
+
+ companion object {
+ private const val REQUEST_VPN_PERMISSION_RESULT_CODE = 0
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt
index d996c432ad..e2ccc2e470 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt
@@ -68,4 +68,8 @@ class SettingsListener(private val connection: Messenger, eventDispatcher: Event
settings = newSettings
}
+
+ fun applySettingsPatch(json: String) {
+ connection.send(Request.ApplyJsonSettings(json).message)
+ }
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModel.kt
new file mode 100644
index 0000000000..4afa12219a
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModel.kt
@@ -0,0 +1,25 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.repository.RelayOverridesRepository
+
+class ResetServerIpOverridesConfirmationViewModel(
+ private val relayOverridesRepository: RelayOverridesRepository,
+) : ViewModel() {
+ private val _uiSideEffect = Channel<ResetServerIpOverridesConfirmationUiSideEffect>()
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+
+ fun clearAllOverrides() =
+ viewModelScope.launch {
+ relayOverridesRepository.clearAllOverrides()
+ _uiSideEffect.send(ResetServerIpOverridesConfirmationUiSideEffect.OverridesCleared)
+ }
+}
+
+sealed class ResetServerIpOverridesConfirmationUiSideEffect {
+ data object OverridesCleared : ResetServerIpOverridesConfirmationUiSideEffect()
+}
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
new file mode 100644
index 0000000000..5a77727b18
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt
@@ -0,0 +1,89 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import android.content.ContentResolver
+import android.net.Uri
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import java.io.InputStreamReader
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+import net.mullvad.mullvadvpn.model.SettingsPatchError
+import net.mullvad.mullvadvpn.repository.RelayOverridesRepository
+import net.mullvad.mullvadvpn.repository.SettingsRepository
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
+
+class ServerIpOverridesViewModel(
+ private val serviceConnectionManager: ServiceConnectionManager,
+ relayOverridesRepository: RelayOverridesRepository,
+ private val settingsRepository: SettingsRepository,
+ private val contentResolver: ContentResolver,
+) : ViewModel() {
+
+ private val _uiSideEffect = Channel<ServerIpOverridesUiSideEffect>()
+ val uiSideEffect = merge(_uiSideEffect.receiveAsFlow())
+
+ val uiState: StateFlow<ServerIpOverridesViewState> =
+ relayOverridesRepository.relayOverrides
+ .filterNotNull()
+ .map { ServerIpOverridesViewState.Loaded(overridesActive = it.isNotEmpty()) }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ ServerIpOverridesViewState.Loading
+ )
+
+ fun importFile(uri: Uri) =
+ viewModelScope.launch {
+ // Read json from file
+ val inputStream = contentResolver.openInputStream(uri)!!
+ val json = InputStreamReader(inputStream, Charsets.UTF_8).readText()
+
+ applySettingsPatch(json)
+ }
+
+ fun importText(json: String) = viewModelScope.launch { applySettingsPatch(json) }
+
+ private suspend fun applySettingsPatch(json: String) {
+ // Wait for daemon to come online since we might be disconnected (due to File picker being
+ // open
+ // and we disconnect from daemon in paused state)
+ val connResult =
+ withTimeoutOrNull(5.seconds) {
+ serviceConnectionManager.connectionState
+ .filterIsInstance(ServiceConnectionState.ConnectedReady::class)
+ .first()
+ }
+ if (connResult != null) {
+ // Apply patch
+ val result = settingsRepository.applySettingsPatch(json)
+ _uiSideEffect.send(ServerIpOverridesUiSideEffect.ImportResult(result.error))
+ } else {
+ // Service never came online, at this point we should already display daemon overlay
+ }
+ }
+}
+
+sealed interface ServerIpOverridesUiSideEffect {
+ data class ImportResult(val error: SettingsPatchError?) : ServerIpOverridesUiSideEffect
+}
+
+sealed interface ServerIpOverridesViewState {
+ val overridesActive: Boolean?
+ get() = (this as? Loaded)?.overridesActive
+
+ data object Loading : ServerIpOverridesViewState
+
+ data class Loaded(override val overridesActive: Boolean) : ServerIpOverridesViewState
+}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModelTest.kt
new file mode 100644
index 0000000000..9be365e7ae
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModelTest.kt
@@ -0,0 +1,63 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.viewModelScope
+import app.cash.turbine.test
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlin.test.assertEquals
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.model.RelayOverride
+import net.mullvad.mullvadvpn.repository.RelayOverridesRepository
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+
+@ExtendWith(TestCoroutineRule::class)
+class ResetServerIpOverridesConfirmationViewModelTest {
+ private lateinit var viewModel: ResetServerIpOverridesConfirmationViewModel
+
+ private val mockRelayOverridesRepository: RelayOverridesRepository = mockk()
+ private val relayOverrides = MutableStateFlow<List<RelayOverride>?>(null)
+
+ @BeforeEach
+ fun setup() {
+ coEvery { mockRelayOverridesRepository.relayOverrides } returns relayOverrides
+
+ viewModel =
+ ResetServerIpOverridesConfirmationViewModel(
+ relayOverridesRepository = mockRelayOverridesRepository,
+ )
+ }
+
+ @AfterEach
+ fun teardown() {
+ viewModel.viewModelScope.coroutineContext.cancel()
+ unmockkAll()
+ }
+
+ @Test
+ fun `successful clear of override should result in side effect`() = runTest {
+ every { mockRelayOverridesRepository.clearAllOverrides() } returns Unit
+ viewModel.uiSideEffect.test {
+ viewModel.clearAllOverrides()
+ assertEquals(
+ ResetServerIpOverridesConfirmationUiSideEffect.OverridesCleared,
+ awaitItem()
+ )
+ }
+ }
+
+ @Test
+ fun `clear overrides should invoke repository`() = runTest {
+ every { mockRelayOverridesRepository.clearAllOverrides() } returns Unit
+ viewModel.clearAllOverrides()
+ verify { mockRelayOverridesRepository.clearAllOverrides() }
+ }
+}
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
new file mode 100644
index 0000000000..16e89ac20b
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModelTest.kt
@@ -0,0 +1,118 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import android.content.ContentResolver
+import android.net.Uri
+import androidx.lifecycle.viewModelScope
+import app.cash.turbine.test
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import java.io.InputStream
+import java.io.InputStreamReader
+import kotlin.test.assertEquals
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.model.RelayOverride
+import net.mullvad.mullvadvpn.model.SettingsPatchError
+import net.mullvad.mullvadvpn.repository.RelayOverridesRepository
+import net.mullvad.mullvadvpn.repository.SettingsRepository
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+
+@ExtendWith(TestCoroutineRule::class)
+class ServerIpOverridesViewModelTest {
+ private lateinit var viewModel: ServerIpOverridesViewModel
+
+ private val mockServiceConnectionManager: ServiceConnectionManager = mockk()
+ private val mockRelayOverridesRepository: RelayOverridesRepository = mockk()
+ private val mockSettingsRepository: SettingsRepository = mockk(relaxed = true)
+ private val mockContentResolver: ContentResolver = mockk()
+
+ private val relayOverrides = MutableStateFlow<List<RelayOverride>?>(null)
+ private val serviceConnectionState =
+ MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.ConnectedReady(mockk()))
+
+ @BeforeEach
+ fun setup() {
+ coEvery { mockRelayOverridesRepository.relayOverrides } returns relayOverrides
+ coEvery { mockServiceConnectionManager.connectionState } returns serviceConnectionState
+
+ mockkStatic(READ_TEXT)
+
+ viewModel =
+ ServerIpOverridesViewModel(
+ serviceConnectionManager = mockServiceConnectionManager,
+ relayOverridesRepository = mockRelayOverridesRepository,
+ settingsRepository = mockSettingsRepository,
+ contentResolver = mockContentResolver
+ )
+ }
+
+ @AfterEach
+ fun teardown() {
+ viewModel.viewModelScope.coroutineContext.cancel()
+ unmockkAll()
+ }
+
+ @Test
+ fun `ensure state is loading by default`() = runTest {
+ viewModel.uiState.test { assertEquals(ServerIpOverridesViewState.Loading, awaitItem()) }
+ }
+
+ @Test
+ fun `when server ip overrides are empty ui state overrides should be inactive`() = runTest {
+ viewModel.uiState.test {
+ assertEquals(ServerIpOverridesViewState.Loading, awaitItem())
+ relayOverrides.emit(emptyList())
+ assertEquals(ServerIpOverridesViewState.Loaded(false), awaitItem())
+ }
+ }
+
+ @Test
+ fun `when import is finished we should get side effect`() = runTest {
+ val mockkResult: SettingsPatchError = mockk()
+ coEvery { mockSettingsRepository.applySettingsPatch(TEXT_INPUT) } returns
+ Event.ApplyJsonSettingsResult(mockkResult)
+
+ viewModel.uiSideEffect.test {
+ viewModel.importText(TEXT_INPUT)
+ assertEquals(ServerIpOverridesUiSideEffect.ImportResult(mockkResult), awaitItem())
+ }
+ }
+
+ @Test
+ fun `ensure import text invokes repository`() = runTest {
+ viewModel.importText(TEXT_INPUT)
+
+ coVerify { mockSettingsRepository.applySettingsPatch(TEXT_INPUT) }
+ }
+
+ @Test
+ fun `ensure import file invokes repository`() = runTest {
+ val uri: Uri = mockk()
+
+ val mockInputStream: InputStream = mockk()
+ every { mockContentResolver.openInputStream(uri) } returns mockInputStream
+ every { any<InputStreamReader>().readText() } returns TEXT_INPUT
+
+ viewModel.importFile(uri)
+
+ coVerify { mockSettingsRepository.applySettingsPatch(TEXT_INPUT) }
+ }
+
+ companion object {
+ private const val TEXT_INPUT = "My cool json patch"
+
+ private const val READ_TEXT = "kotlin.io.TextStreamsKt"
+ }
+}
diff --git a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Event.kt b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Event.kt
index cce2ab1f87..36ea17036e 100644
--- a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Event.kt
+++ b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Event.kt
@@ -14,6 +14,7 @@ import net.mullvad.mullvadvpn.model.PlayPurchaseVerifyResult
import net.mullvad.mullvadvpn.model.RelayList
import net.mullvad.mullvadvpn.model.RemoveDeviceResult
import net.mullvad.mullvadvpn.model.Settings
+import net.mullvad.mullvadvpn.model.SettingsPatchError
import net.mullvad.mullvadvpn.model.TunnelState
import net.mullvad.mullvadvpn.model.UpdateCustomListResult
@@ -71,6 +72,10 @@ sealed class Event : Message.EventMessage() {
@Parcelize data class UpdateCustomListResultEvent(val result: UpdateCustomListResult) : Event()
+ @Parcelize data class ExportJsonSettingsResult(val json: String) : Event()
+
+ @Parcelize data class ApplyJsonSettingsResult(val error: SettingsPatchError?) : Event()
+
companion object {
private const val MESSAGE_KEY = "event"
diff --git a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Request.kt b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Request.kt
index fe9d3b46d9..4bcf871acc 100644
--- a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Request.kt
+++ b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Request.kt
@@ -13,6 +13,7 @@ import net.mullvad.mullvadvpn.model.Ownership
import net.mullvad.mullvadvpn.model.PlayPurchase
import net.mullvad.mullvadvpn.model.Providers
import net.mullvad.mullvadvpn.model.QuantumResistantState
+import net.mullvad.mullvadvpn.model.RelayOverride
import net.mullvad.mullvadvpn.model.WireguardConstraints
// Requests that the service can handle
@@ -117,6 +118,14 @@ sealed class Request : Message.RequestMessage() {
@Parcelize data class UpdateCustomList(val customList: CustomList) : Request()
+ @Parcelize data object ClearAllRelayOverrides : Request()
+
+ @Parcelize data class ApplyJsonSettings(val json: String) : Request()
+
+ @Parcelize data object ExportJsonSettings : Request()
+
+ @Parcelize data class SetRelayOverride(val override: RelayOverride) : Request()
+
companion object {
private const val MESSAGE_KEY = "request"
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayOverride.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayOverride.kt
new file mode 100644
index 0000000000..f738218ee7
--- /dev/null
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayOverride.kt
@@ -0,0 +1,12 @@
+package net.mullvad.mullvadvpn.model
+
+import android.os.Parcelable
+import java.net.InetAddress
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class RelayOverride(
+ val hostname: String,
+ val ipv4AddressIn: InetAddress?,
+ val ipv6AddressIn: InetAddress?
+) : Parcelable
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Settings.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Settings.kt
index 304edc404a..847b80cd70 100644
--- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Settings.kt
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Settings.kt
@@ -11,5 +11,6 @@ data class Settings(
val allowLan: Boolean,
val autoConnect: Boolean,
val tunnelOptions: TunnelOptions,
- val showBetaReleases: Boolean
+ val relayOverrides: ArrayList<RelayOverride>,
+ val showBetaReleases: Boolean,
) : Parcelable
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/SettingsPatchError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/SettingsPatchError.kt
new file mode 100644
index 0000000000..5e3cb29911
--- /dev/null
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/SettingsPatchError.kt
@@ -0,0 +1,24 @@
+package net.mullvad.mullvadvpn.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+sealed class SettingsPatchError : Parcelable {
+ // E.g hostname is number instead of String
+ data class InvalidOrMissingValue(val value: String) : SettingsPatchError()
+
+ // E.g. Unexpected top-level key?
+ data class UnknownOrProhibitedKey(val value: String) : SettingsPatchError()
+
+ // Bad JSON
+ data object ParsePatch : SettingsPatchError()
+
+ data object RecursionLimit : SettingsPatchError()
+
+ // Patch was deserialized but was not valid domain data?
+ data object DeserializePatched : SettingsPatchError()
+
+ // Failed to apply patch
+ data object ApplyPatch : SettingsPatchError()
+}
diff --git a/android/lib/resource/src/main/res/drawable/icon_text_fields.xml b/android/lib/resource/src/main/res/drawable/icon_text_fields.xml
new file mode 100644
index 0000000000..ecc6072999
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable/icon_text_fields.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M280,800v-520L80,280v-120h520v120L400,280v520L280,800ZM640,800v-320L520,480v-120h360v120L760,480v320L640,800Z"/>
+</vector>
diff --git a/android/lib/resource/src/main/res/drawable/icon_upload_file.xml b/android/lib/resource/src/main/res/drawable/icon_upload_file.xml
new file mode 100644
index 0000000000..4f812f7fc5
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable/icon_upload_file.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M440,760h80v-167l64,64 56,-57 -160,-160 -160,160 57,56 63,-63v167ZM240,880q-33,0 -56.5,-23.5T160,800v-640q0,-33 23.5,-56.5T240,80h320l240,240v480q0,33 -23.5,56.5T720,880L240,880ZM520,360v-200L240,160v640h480v-440L520,360ZM240,160v200,-200 640,-640Z"/>
+</vector>
diff --git a/android/lib/resource/src/main/res/values-da/strings.xml b/android/lib/resource/src/main/res/values-da/strings.xml
index 1b6088b643..71b65ffd14 100644
--- a/android/lib/resource/src/main/res/values-da/strings.xml
+++ b/android/lib/resource/src/main/res/values-da/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">Her er dit kontonummer. Gem det!</string>
<string name="hide_account_number">Skjul kontonummer</string>
<string name="hint_default">Standard</string>
+ <string name="import_overrides_import">Importer</string>
+ <string name="import_overrides_text_title">Importer via tekst</string>
<string name="in_address">Ind</string>
<string name="invalid_dns_servers">Tilpassede DNS-serveradresser %1$s er ugyldige</string>
<string name="invalid_voucher">Kuponkode er ugyldig.</string>
@@ -213,6 +215,9 @@
<string name="sent">Sendt</string>
<string name="sent_contact">Hvis det er nødvendigt, kontakter vi dig på %1$s</string>
<string name="sent_thanks">Tak!</string>
+ <string name="server_ip_overrides_info_first_paragraph">På nogle netværk, hvor der bruges forskellige typer censur, er vores server IP-adresser nogle gange blokeret.</string>
+ <string name="server_ip_overrides_info_second_paragraph">For at omgå dette kan du importere en fil eller en tekst, leveret af vores supportteam, med nye IP-adresser, der tilsidesætter standardadresserne på serverne i visningen Vælg placering.</string>
+ <string name="server_ip_overrides_info_third_paragraph">Hvis du har problemer med at oprette forbindelse til VPN-servere, bedes du kontakte support.</string>
<string name="set_dns_error">Kan ikke indstille systemets DNS-server. Indsend en problemrapport.</string>
<string name="set_firewall_policy_error">Kan ikke anvende firewallregler. Fejlfind eller send en problemrapport.</string>
<string name="settings">Indstillinger</string>
diff --git a/android/lib/resource/src/main/res/values-de/strings.xml b/android/lib/resource/src/main/res/values-de/strings.xml
index 0b72c89620..62f7336e57 100644
--- a/android/lib/resource/src/main/res/values-de/strings.xml
+++ b/android/lib/resource/src/main/res/values-de/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">Hier ist Ihre Kontonummer. Verlieren Sie sie nicht!</string>
<string name="hide_account_number">Kontonummer verbergen</string>
<string name="hint_default">Standard</string>
+ <string name="import_overrides_import">Importieren</string>
+ <string name="import_overrides_text_title">Import via Text</string>
<string name="in_address">Eingehend</string>
<string name="invalid_dns_servers">Eigene DNS-Server Adressen %1$s sind ungültig</string>
<string name="invalid_voucher">Der Gutscheincode ist ungültig.</string>
@@ -213,6 +215,9 @@
<string name="sent">Gesendet</string>
<string name="sent_contact">Bei Bedarf werden wir Sie über %1$s kontaktieren</string>
<string name="sent_thanks">Danke!</string>
+ <string name="server_ip_overrides_info_first_paragraph">In einigen Netzwerken, in denen verschiedene Arten der Zensur eingesetzt werden, werden die IP-Adressen unserer Server manchmal blockiert.</string>
+ <string name="server_ip_overrides_info_second_paragraph">Um dies zu umgehen, können Sie eine Datei oder einen von unserem Support-Team bereitgestellten Text mit neuen IP-Adressen importieren, die die Standardadressen der Server in der Ortsauswahl außer Kraft setzen.</string>
+ <string name="server_ip_overrides_info_third_paragraph">Wenn Sie Probleme mit der Verbindung zu VPN-Servern haben, wenden Sie sich bitte an den Support.</string>
<string name="set_dns_error">Der DNS-Server des Systems konnte nicht eingestellt werden. Bitte senden Sie einen Problembericht.</string>
<string name="set_firewall_policy_error">Firewall-Regeln können nicht angewendet werden. Bitte beheben Sie das Problem oder senden Sie einen Problembericht.</string>
<string name="settings">Einstellungen</string>
diff --git a/android/lib/resource/src/main/res/values-es/strings.xml b/android/lib/resource/src/main/res/values-es/strings.xml
index 0a2369af88..646e9069a4 100644
--- a/android/lib/resource/src/main/res/values-es/strings.xml
+++ b/android/lib/resource/src/main/res/values-es/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">Este es un número de cuenta. ¡Guárdelo bien!</string>
<string name="hide_account_number">Ocultar número de cuenta</string>
<string name="hint_default">Predeterminado</string>
+ <string name="import_overrides_import">Importar</string>
+ <string name="import_overrides_text_title">Importación a través de texto</string>
<string name="in_address">Entrada</string>
<string name="invalid_dns_servers">Las direcciones del servidor DNS personalizado %1$s no son válidas</string>
<string name="invalid_voucher">El código del cupón no es válido.</string>
@@ -213,6 +215,9 @@
<string name="sent">Enviado</string>
<string name="sent_contact">Si es necesario, le enviaremos un correo electrónico a %1$s</string>
<string name="sent_thanks">¡Gracias!</string>
+ <string name="server_ip_overrides_info_first_paragraph">En algunas redes, donde se aplican diversos tipos de censura, a veces se bloquean las direcciones IP de nuestro servidor.</string>
+ <string name="server_ip_overrides_info_second_paragraph">Para eludir esto, puede importar un archivo o texto, suministrado por nuestro equipo de asistencia, con nuevas direcciones IP que anulan las direcciones predeterminadas de los servidores en la vista Seleccionar ubicación.</string>
+ <string name="server_ip_overrides_info_third_paragraph">Si tiene problemas para conectarse a los servidores VPN, póngase en contacto con el servicio de asistencia.</string>
<string name="set_dns_error">No se puede configurar el servidor DNS del sistema. Envíe un informe de problemas.</string>
<string name="set_firewall_policy_error">No se pueden aplicar las reglas del firewall. Intente solucionar el problema o envíe un informe de problemas.</string>
<string name="settings">Configuración</string>
diff --git a/android/lib/resource/src/main/res/values-fi/strings.xml b/android/lib/resource/src/main/res/values-fi/strings.xml
index 2df0abd210..94f2785792 100644
--- a/android/lib/resource/src/main/res/values-fi/strings.xml
+++ b/android/lib/resource/src/main/res/values-fi/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">Tässä tulee tilisi numero. Laita se talteen!</string>
<string name="hide_account_number">Piilota tilin numero</string>
<string name="hint_default">Oletus</string>
+ <string name="import_overrides_import">Tuo</string>
+ <string name="import_overrides_text_title">Tuo tekstinä</string>
<string name="in_address">Saapuva</string>
<string name="invalid_dns_servers">Mukautetut DNS-palvelimen osoitteet %1$s ovat virheellisiä</string>
<string name="invalid_voucher">Kuponkikoodi ei kelpaa.</string>
@@ -213,6 +215,9 @@
<string name="sent">Lähetetty</string>
<string name="sent_contact">Tarvittaessa otamme sinuun yhteyttä osoitteeseen %1$s</string>
<string name="sent_thanks">Kiitos!</string>
+ <string name="server_ip_overrides_info_first_paragraph">Palvelimiemme IP-osoitteet estetään toisinaan joissakin useita erityyppistä sensurointimenetelmiä käyttävissä verkoissa.</string>
+ <string name="server_ip_overrides_info_second_paragraph">Voit kiertää estot tuomalla tukitiimimme toimittaman tiedoston tai tekstin, josta löytyy uusia, palvelimien oletusosoitteet sijainnin valintanäkymässä ohittavia IP-osoitteita.</string>
+ <string name="server_ip_overrides_info_third_paragraph">Jos sinulla on ongelmia yhteyden muodostamisessa VPN-palvelimiin, ota yhteyttä tukeen.</string>
<string name="set_dns_error">Järjestelmän DNS-palvelimen asettaminen ei onnistu. Lähetä ongelmaraportti.</string>
<string name="set_firewall_policy_error">Palomuurisääntöjä ei voida käyttää. Suorita vianetsintä tai lähetä ongelmaraportti.</string>
<string name="settings">Asetukset</string>
diff --git a/android/lib/resource/src/main/res/values-fr/strings.xml b/android/lib/resource/src/main/res/values-fr/strings.xml
index e834a0209e..d87595df89 100644
--- a/android/lib/resource/src/main/res/values-fr/strings.xml
+++ b/android/lib/resource/src/main/res/values-fr/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">Voici votre numéro de compte. Gardez-le !</string>
<string name="hide_account_number">Masquer le numéro de compte</string>
<string name="hint_default">Par défaut</string>
+ <string name="import_overrides_import">Importer</string>
+ <string name="import_overrides_text_title">Importer par texte</string>
<string name="in_address">Entrante</string>
<string name="invalid_dns_servers">Les adresses de serveur DNS personnalisées %1$s ne sont pas valides</string>
<string name="invalid_voucher">Le code du bon n\'est pas valide.</string>
@@ -213,6 +215,9 @@
<string name="sent">Envoyé</string>
<string name="sent_contact">Si nécessaire, nous vous contacterons à l\'adresse %1$s</string>
<string name="sent_thanks">Merci !</string>
+ <string name="server_ip_overrides_info_first_paragraph">Sur certains réseaux, où divers types de censure sont utilisés, les adresses IP de notre serveur sont parfois bloquées.</string>
+ <string name="server_ip_overrides_info_second_paragraph">Pour contourner ce problème, vous pouvez importer un fichier ou du texte fourni par notre équipe d\'assistance, avec de nouvelles adresses IP qui remplacent les adresses par défaut des serveurs dans la vue Sélectionner un emplacement.</string>
+ <string name="server_ip_overrides_info_third_paragraph">Si vous rencontrez des problèmes de connexion aux serveurs VPN, veuillez contacter l\'assistance.</string>
<string name="set_dns_error">Impossible de définir le serveur DNS système. Veuillez envoyer un rapport de problème.</string>
<string name="set_firewall_policy_error">Impossible d\'appliquer les règles du pare-feu. Merci de résoudre le problème ou d\'envoyer un rapport de problème.</string>
<string name="settings">Paramètres</string>
diff --git a/android/lib/resource/src/main/res/values-it/strings.xml b/android/lib/resource/src/main/res/values-it/strings.xml
index bc707e2b96..ec215b16f2 100644
--- a/android/lib/resource/src/main/res/values-it/strings.xml
+++ b/android/lib/resource/src/main/res/values-it/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">Ecco il tuo numero di account. Salvalo!</string>
<string name="hide_account_number">Nascondi numero di account</string>
<string name="hint_default">Predefinito</string>
+ <string name="import_overrides_import">Importa</string>
+ <string name="import_overrides_text_title">Importa tramite testo</string>
<string name="in_address">Ricezione</string>
<string name="invalid_dns_servers">Gli indirizzi del server DNS personalizzato %1$s non sono validi</string>
<string name="invalid_voucher">Il codice voucher non è valido.</string>
@@ -213,6 +215,9 @@
<string name="sent">Inviato</string>
<string name="sent_contact">Se necessario, ti contatteremo all\'indirizzo %1$s</string>
<string name="sent_thanks">Grazie!</string>
+ <string name="server_ip_overrides_info_first_paragraph">Su alcune reti, dove vengono utilizzati vari tipi di censura, gli indirizzi IP dei nostri server vengono talvolta bloccati.</string>
+ <string name="server_ip_overrides_info_second_paragraph">Per aggirare questo problema, puoi importare un file o un testo, fornito dal nostro team di supporto, con nuovi indirizzi IP che sovrascrivono gli indirizzi predefiniti dei server nella vista Seleziona posizione.</string>
+ <string name="server_ip_overrides_info_third_paragraph">Se riscontri problemi di connessione ai server VPN, contatta l\'assistenza.</string>
<string name="set_dns_error">Impossibile impostare il server DNS di sistema. Invia una segnalazione del problema.</string>
<string name="set_firewall_policy_error">Impossibile applicare le regole del firewall. Consulta la risoluzione dei problemi o invia una segnalazione del problema.</string>
<string name="settings">Impostazioni</string>
diff --git a/android/lib/resource/src/main/res/values-ja/strings.xml b/android/lib/resource/src/main/res/values-ja/strings.xml
index 71e1907d9e..1684daed38 100644
--- a/android/lib/resource/src/main/res/values-ja/strings.xml
+++ b/android/lib/resource/src/main/res/values-ja/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">これがあなたのアカウント番号です。保存してください!</string>
<string name="hide_account_number">アカウント番号の非表示</string>
<string name="hint_default">デフォルト</string>
+ <string name="import_overrides_import">インポート</string>
+ <string name="import_overrides_text_title">テキストでインポート</string>
<string name="in_address">内側</string>
<string name="invalid_dns_servers">カスタムDNSサーバーアドレス %1$s は無効です</string>
<string name="invalid_voucher">バウチャーコードが無効です。</string>
@@ -213,6 +215,9 @@
<string name="sent">送信済み</string>
<string name="sent_contact">必要に応じて %1$s 宛にご連絡します </string>
<string name="sent_thanks">ありがとうございます!</string>
+ <string name="server_ip_overrides_info_first_paragraph">各種の検閲が使用されている一部のネットワークでは、サーバーIPアドレスがブロックされる場合があります。</string>
+ <string name="server_ip_overrides_info_second_paragraph">これを回避するには、「場所を選択」ビューでサポートチームが提供したサーバーのデフォルトアドレスをオーバーライドする新しいIPアドレスを含むファイルまたはテキストをインポートできます。</string>
+ <string name="server_ip_overrides_info_third_paragraph">VPNサーバーへの接続に問題が生じている場合は、サポートにお問い合わせください。</string>
<string name="set_dns_error">システムのDNSサーバーを設定できません。問題の報告を送信してください。</string>
<string name="set_firewall_policy_error">ファイアウォールのルールを適用できません。問題に対処するか、問題の報告を送信してください。</string>
<string name="settings">設定</string>
diff --git a/android/lib/resource/src/main/res/values-ko/strings.xml b/android/lib/resource/src/main/res/values-ko/strings.xml
index 7700ad87dc..95315a4050 100644
--- a/android/lib/resource/src/main/res/values-ko/strings.xml
+++ b/android/lib/resource/src/main/res/values-ko/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">계정 번호는 다음과 같습니다. 저장하세요!</string>
<string name="hide_account_number">계정 번호 숨기기</string>
<string name="hint_default">기본값</string>
+ <string name="import_overrides_import">가져오기</string>
+ <string name="import_overrides_text_title">텍스트를 통해 가져오기</string>
<string name="in_address">인</string>
<string name="invalid_dns_servers">사용자 지정 DNS 서버 주소 %1$s이(가) 잘못되었습니다.</string>
<string name="invalid_voucher">유효하지 않은 바우처 코드입니다.</string>
@@ -213,6 +215,9 @@
<string name="sent">전송 완료</string>
<string name="sent_contact">필요한 경우 %1$s(으)로 연락드리겠습니다.</string>
<string name="sent_thanks">감사합니다!</string>
+ <string name="server_ip_overrides_info_first_paragraph">다양한 유형의 검열이 사용되고 있는 일부 네트워크에서는 때때로 당사 서버 IP 주소가 차단됩니다.</string>
+ <string name="server_ip_overrides_info_second_paragraph">이를 우회하려면 \'위치 선택\' 보기에서 서버의 기본 주소를 재정의하는 새 IP 주소를 사용하여 지원 팀에서 제공한 파일이나 텍스트를 가져올 수 있습니다.</string>
+ <string name="server_ip_overrides_info_third_paragraph">VPN 서버 연결에 문제가 있는 경우 지원 팀에 문의하세요.</string>
<string name="set_dns_error">시스템 DNS 서버를 설정할 수 없습니다. 문제 보고서를 보내주세요.</string>
<string name="set_firewall_policy_error">방화벽 규칙을 적용할 수 없습니다. 문제를 해결하거나 문제 보고서를 보내주세요.</string>
<string name="settings">설정</string>
diff --git a/android/lib/resource/src/main/res/values-my/strings.xml b/android/lib/resource/src/main/res/values-my/strings.xml
index 34b27d3cf8..b87369ff77 100644
--- a/android/lib/resource/src/main/res/values-my/strings.xml
+++ b/android/lib/resource/src/main/res/values-my/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">ဤသည်မှာ သင့်အကောင့်နံပါတ် ဖြစ်ပါသည်။ သိမ်းမှတ်ထားပါ။</string>
<string name="hide_account_number">အကောင့်နံပါတ်ကို ဝှက်ရန်</string>
<string name="hint_default">ပုံသေ</string>
+ <string name="import_overrides_import">ထည့်ရန်</string>
+ <string name="import_overrides_text_title">စာသားမှတစ်ဆင့် ထည့်သွင်းရန်</string>
<string name="in_address">အဝင်</string>
<string name="invalid_dns_servers">စိတ်ကြိုက် DNS ဆာဗာလိပ်စာများ %1$s မှားနေပါသည်</string>
<string name="invalid_voucher">ဘောက်ချာကုဒ် မှားနေပါသည်။</string>
@@ -213,6 +215,9 @@
<string name="sent">ပို့ပြီး</string>
<string name="sent_contact">လိုအပ်ပါက %1$s မှတစ်ဆင့် ကျွန်ုပ်တို့ထံ ဆက်သွယ်ပါ</string>
<string name="sent_thanks">ကျေးဇူးတင်ပါသည်။</string>
+ <string name="server_ip_overrides_info_first_paragraph">အမျိုးအမျိုးသော စိစစ်ဖြတ်တောက်မှု အမျိုးအစားများ အသုံးပြုသည့် ကွန်ရက်အချို့တွင် ကျွန်ုပ်တို့၏ ဆာဗာ IP လိပ်စာများကို တစ်ခါတစ်ရံ ပိတ်ဆို့ထားပါသည်။</string>
+ <string name="server_ip_overrides_info_second_paragraph">ဤသည်ကို ရှောင်လွှဲရန် ကျွန်ုပ်တို့ အကူအညီပေးရေးအဖွဲ့မှ ပေးထားသော တည်နေရာ ရွေးရန် ပြသမှုအတွင်းရှိ ဆာဗာများ၏ ပုံသေ လိပ်စာများကို ကျော်လွန် ပယ်ဖျက်သည့် IP လိပ်စာအသစ်များဖြင့် ဖိုင် သို့မဟုတ် စာသားကို သင် ထည့်သွင်းနိုင်ပါသည်။</string>
+ <string name="server_ip_overrides_info_third_paragraph">VPN ဆာဗာများကို ချိတ်ဆက်ရာတွင် ပြဿနာများရှိနေပါက အကူအညီပေးရေးအဖွဲ့ကို ဆက်သွယ်ပါ။</string>
<string name="set_dns_error">စနစ် DNS ဆာဗာကို သတ်မှတ်၍ မရနိုင်ပါ။ ပြဿနာ ရီပို့တ်တစ်ခု ပေးပို့ပေးပါ။</string>
<string name="set_firewall_policy_error">Firewall စည်းမျဉ်းများကို အသုံးချ၍ မရနိုင်ပါ။ ပြစ်ချက် ရှာဖွေဖယ်ရှာပေးပါ သို့မဟုတ် ပြဿနာ ရီပို့တ် ပေးပို့ပေးပါ။</string>
<string name="settings">ဆက်တင်</string>
diff --git a/android/lib/resource/src/main/res/values-nb/strings.xml b/android/lib/resource/src/main/res/values-nb/strings.xml
index e227790588..0b5f8b9e9f 100644
--- a/android/lib/resource/src/main/res/values-nb/strings.xml
+++ b/android/lib/resource/src/main/res/values-nb/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">Dette er kontonummeret ditt. Ta vare på det!</string>
<string name="hide_account_number">Skjul kontonummer</string>
<string name="hint_default">Standard</string>
+ <string name="import_overrides_import">Importer</string>
+ <string name="import_overrides_text_title">Importer via tekst</string>
<string name="in_address">Inngående</string>
<string name="invalid_dns_servers">Egendefinerte DNS-serveradresser %1$s er ugyldige</string>
<string name="invalid_voucher">Ugyldig kupongkode.</string>
@@ -213,6 +215,9 @@
<string name="sent">Sendt</string>
<string name="sent_contact">Vi vil kontakte deg på %1$s ved behov</string>
<string name="sent_thanks">Takk!</string>
+ <string name="server_ip_overrides_info_first_paragraph">På enkelte nettverk der det brukes ulike typer sensur, kan server-IP-adressene av og til være blokkerte.</string>
+ <string name="server_ip_overrides_info_second_paragraph">For å omgå dette kan du importere en fil eller tekst, som du har fått fra kundestøtteteamet, med nye IP-adresser som overstyrer standardadressene til serverne i «Velg plassering».</string>
+ <string name="server_ip_overrides_info_third_paragraph">Hvis du har mistet tilkoblingen til VPN-serverne, kan du ta kontak med kundestøtten.</string>
<string name="set_dns_error">Kunne ikke angi DNS-server for systemet. Send inn en problemrapport.</string>
<string name="set_firewall_policy_error">Kunne ikke bruke brannmur-regler. Feilsøk eller send inn en problemrapport.</string>
<string name="settings">Innstillinger</string>
diff --git a/android/lib/resource/src/main/res/values-nl/strings.xml b/android/lib/resource/src/main/res/values-nl/strings.xml
index eeae7d2718..e6b986ead8 100644
--- a/android/lib/resource/src/main/res/values-nl/strings.xml
+++ b/android/lib/resource/src/main/res/values-nl/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">Hier is uw accountnummer. Sla het op!</string>
<string name="hide_account_number">Accountnummer verbergen</string>
<string name="hint_default">Standaard</string>
+ <string name="import_overrides_import">Importeren</string>
+ <string name="import_overrides_text_title">Importeren via tekst</string>
<string name="in_address">In</string>
<string name="invalid_dns_servers">Aangepaste DNS-serveradressen %1$s zijn ongeldig</string>
<string name="invalid_voucher">Vouchercode is ongeldig.</string>
@@ -213,6 +215,9 @@
<string name="sent">Verzonden</string>
<string name="sent_contact">Indien nodig nemen we u contact op via %1$s</string>
<string name="sent_thanks">Bedankt!</string>
+ <string name="server_ip_overrides_info_first_paragraph">Op sommige netwerken, waar verschillende soorten censuur worden gebruikt, worden onze server-IP-adressen soms geblokkeerd.</string>
+ <string name="server_ip_overrides_info_second_paragraph">Om dit te omzeilen, kunt u een door ons ondersteuningsteam verstrekt bestand of tekst importeren, met nieuwe IP-adressen die de standaardadressen van de servers in de weergave Locatie selecteren overschrijven.</string>
+ <string name="server_ip_overrides_info_third_paragraph">Als u problemen hebt met het verbinden met VPN-servers, neem dan contact op met de ondersteuning.</string>
<string name="set_dns_error">Kan DNS-server van systeem niet instellen. Stuur een probleemrapport.</string>
<string name="set_firewall_policy_error">Kan firewallregels niet toepassen. Los problemen op of stuur een probleemmelding.</string>
<string name="settings">Instellingen</string>
diff --git a/android/lib/resource/src/main/res/values-pl/strings.xml b/android/lib/resource/src/main/res/values-pl/strings.xml
index c03157ab0b..3b12b61b5e 100644
--- a/android/lib/resource/src/main/res/values-pl/strings.xml
+++ b/android/lib/resource/src/main/res/values-pl/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">Oto Twój numer konta. Zachowaj go!</string>
<string name="hide_account_number">Ukryj numer konta</string>
<string name="hint_default">Domyślnie</string>
+ <string name="import_overrides_import">Importuj</string>
+ <string name="import_overrides_text_title">Import tekstowy</string>
<string name="in_address">Wejście</string>
<string name="invalid_dns_servers">Niestandardowe adresy serwerów DNS %1$s są nieprawidłowe</string>
<string name="invalid_voucher">Nieprawidłowy kod kuponu.</string>
@@ -213,6 +215,9 @@
<string name="sent">Wysłano</string>
<string name="sent_contact">W razie potrzeby skontaktujemy się z Tobą pod adresem %1$s</string>
<string name="sent_thanks">Dziękujemy!</string>
+ <string name="server_ip_overrides_info_first_paragraph">W niektórych sieciach, w których stosowane są różnego rodzaju cenzury, adresy IP naszych serwerów są czasami blokowane.</string>
+ <string name="server_ip_overrides_info_second_paragraph">Aby obejść ten problem, można zaimportować plik lub tekst dostarczony przez nasz zespół pomocy technicznej, zawierający nowe adresy IP, które zastępują domyślne adresy serwerów w widoku Wybierz lokalizację.</string>
+ <string name="server_ip_overrides_info_third_paragraph">Jeśli masz problemy z łączeniem się z serwerami VPN, skontaktuj się z pomocą techniczną.</string>
<string name="set_dns_error">Nie można ustawić systemowego serwera DNS. Wyślij zgłoszenie problemu.</string>
<string name="set_firewall_policy_error">Nie można zastosować reguł zapory. Rozwiąż problem lub wyślij zgłoszenie problemu.</string>
<string name="settings">Ustawienia</string>
diff --git a/android/lib/resource/src/main/res/values-pt/strings.xml b/android/lib/resource/src/main/res/values-pt/strings.xml
index ddecdfde52..b4dd272f0d 100644
--- a/android/lib/resource/src/main/res/values-pt/strings.xml
+++ b/android/lib/resource/src/main/res/values-pt/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">Aqui tem o seu número de conta. Guarde-o!</string>
<string name="hide_account_number">Ocultar número de conta</string>
<string name="hint_default">Padrão</string>
+ <string name="import_overrides_import">Importar</string>
+ <string name="import_overrides_text_title">Importar através de texto</string>
<string name="in_address">Entrada</string>
<string name="invalid_dns_servers">Os endereços do servidor DNS personalizado %1$s são inválidos</string>
<string name="invalid_voucher">Código do voucher inválido.</string>
@@ -213,6 +215,9 @@
<string name="sent">Enviado</string>
<string name="sent_contact">Se necessário, iremos contactá-lo através de %1$s</string>
<string name="sent_thanks">Obrigado!</string>
+ <string name="server_ip_overrides_info_first_paragraph">Em algumas redes, onde são utilizados vários tipos de censura, os endereços IP dos nossos servidores são por vezes bloqueados.</string>
+ <string name="server_ip_overrides_info_second_paragraph">Para contornar esta situação, pode importar um ficheiro ou texto, fornecido pela nossa equipa de apoio, com novos endereços IP que substituem os endereços padrão dos servidores na vista Selecionar local.</string>
+ <string name="server_ip_overrides_info_third_paragraph">Se tiver problemas em ligar-se aos servidores VPN, contacte o apoio.</string>
<string name="set_dns_error">Não foi possível definir o servidor DNS do sistema. Envie um relatório do problema.</string>
<string name="set_firewall_policy_error">Não foi possível aplicar as regras de firewall. Experimente a resolução de problemas ou envie um relatório do problema.</string>
<string name="settings">Definições</string>
diff --git a/android/lib/resource/src/main/res/values-ru/strings.xml b/android/lib/resource/src/main/res/values-ru/strings.xml
index 088ad7625f..b0d7b1b63e 100644
--- a/android/lib/resource/src/main/res/values-ru/strings.xml
+++ b/android/lib/resource/src/main/res/values-ru/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">Вот номер вашей учетной записи. Сохраните его!</string>
<string name="hide_account_number">Скрыть номер учетной записи</string>
<string name="hint_default">По умолчанию</string>
+ <string name="import_overrides_import">Импортировать</string>
+ <string name="import_overrides_text_title">Импортировать через текст</string>
<string name="in_address">Вход</string>
<string name="invalid_dns_servers">Пользовательские адреса DNS-серверов %1$s недопустимы</string>
<string name="invalid_voucher">Код ваучера недействителен.</string>
@@ -213,6 +215,9 @@
<string name="sent">Отправлено</string>
<string name="sent_contact">При необходимости мы свяжемся с вами по адресу %1$s</string>
<string name="sent_thanks">Спасибо!</string>
+ <string name="server_ip_overrides_info_first_paragraph">В некоторых сетях, где используются различные виды цензуры, IP-адреса наших серверов иногда блокируются.</string>
+ <string name="server_ip_overrides_info_second_paragraph">Чтобы обойти эту проблему, можно импортировать файл или текст, предоставленный нашей службой поддержки, с новыми IP-адресами, которые заменяют адреса серверов по умолчанию в представлении «Выбор местоположения».</string>
+ <string name="server_ip_overrides_info_third_paragraph">Если у вас возникли проблемы с подключением к VPN-серверам, обратитесь в службу поддержки.</string>
<string name="set_dns_error">Не удалось установить системный DNS-сервер. Отправьте сообщение о проблеме.</string>
<string name="set_firewall_policy_error">Невозможно применить правила брандмауэра. Устраните неполадки или отправьте сообщение о проблеме.</string>
<string name="settings">Настройки</string>
diff --git a/android/lib/resource/src/main/res/values-sv/strings.xml b/android/lib/resource/src/main/res/values-sv/strings.xml
index f5a5012025..cebf01b315 100644
--- a/android/lib/resource/src/main/res/values-sv/strings.xml
+++ b/android/lib/resource/src/main/res/values-sv/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">Här är ditt kontonummer. Spara det!</string>
<string name="hide_account_number">Dölj kontonummer</string>
<string name="hint_default">Standard</string>
+ <string name="import_overrides_import">Importera</string>
+ <string name="import_overrides_text_title">Importera via text</string>
<string name="in_address">In</string>
<string name="invalid_dns_servers">Anpassade DNS-serveradresser %1$s är ogiltiga</string>
<string name="invalid_voucher">Kupongkoden är ogiltig.</string>
@@ -213,6 +215,9 @@
<string name="sent">Skickat</string>
<string name="sent_contact">Om det behövs kontaktar vi dig på %1$s</string>
<string name="sent_thanks">Tack!</string>
+ <string name="server_ip_overrides_info_first_paragraph">På vissa nätverk där olika typer av censureringar används blockeras blir ibland vår servers IP-adresser blockerade.</string>
+ <string name="server_ip_overrides_info_second_paragraph">För att kringgå detta kan du importera en fil eller text, som tillhandahålls av vårt supportteam, med nya IP-adresser som åsidosätter servrarnas standardadresser i Välj platsvy.</string>
+ <string name="server_ip_overrides_info_third_paragraph">Kontakta supporten om du har problem med att ansluta till VPN-servrar.</string>
<string name="set_dns_error">Det går inte att konfigurera DNS-server. Skicka en problemrapport.</string>
<string name="set_firewall_policy_error">Det går inte att tillämpa brandväggsregler. Felsök eller skicka en problemrapport.</string>
<string name="settings">Inställningar</string>
diff --git a/android/lib/resource/src/main/res/values-th/strings.xml b/android/lib/resource/src/main/res/values-th/strings.xml
index 935ca673e4..02961f67d7 100644
--- a/android/lib/resource/src/main/res/values-th/strings.xml
+++ b/android/lib/resource/src/main/res/values-th/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">นี่คือหมายเลขบัญชีของคุณ จดบันทึกไว้ด้วยนะ!</string>
<string name="hide_account_number">ซ่อนหมายเลขบัญชี</string>
<string name="hint_default">ค่าเริ่มต้น</string>
+ <string name="import_overrides_import">นำเข้า</string>
+ <string name="import_overrides_text_title">นำเข้าผ่านข้อความ</string>
<string name="in_address">เข้า</string>
<string name="invalid_dns_servers">ที่อยู่เซิร์ฟเวอร์ DNS %1$s ที่กำหนดเองไม่ถูกต้อง</string>
<string name="invalid_voucher">รหัสบัตรกำนัลไม่ถูกต้อง</string>
@@ -213,6 +215,9 @@
<string name="sent">ส่ง</string>
<string name="sent_contact">เราจะติดต่อคุณไปทาง %1$s ในกรณีจำเป็น</string>
<string name="sent_thanks">ขอบคุณ!</string>
+ <string name="server_ip_overrides_info_first_paragraph">บางครั้งที่อยู่ IP เซิร์ฟเวอร์ของเราอาจถูกบล็อก ในบางเครือข่ายที่มีการใช้งานเซ็นเซอร์หลายประเภท</string>
+ <string name="server_ip_overrides_info_second_paragraph">ในการหลีกเลี่ยงปัญหานี้ คุณสามารถนำเข้าไฟล์หรือข้อความที่ได้รับจากทีมสนับสนุนของเรา พร้อมด้วยที่อยู่ IP ใหม่ที่โอเวอร์ไรด์ที่อยู่เริ่มต้นของเซิร์ฟเวอร์ในมุมมองเลือกตำแหน่งที่ตั้ง</string>
+ <string name="server_ip_overrides_info_third_paragraph">หากคุณประสบปัญหาในการเชื่อมต่อกับเซิร์ฟเวอร์ VPN โปรดติดต่อฝ่ายสนับสนุน</string>
<string name="set_dns_error">ไม่สามารถตั้งค่าเซิร์ฟเวอร์ DNS ของระบบได้ โปรดส่งรายงานปัญหา</string>
<string name="set_firewall_policy_error">ไม่สามารถใช้กฎไฟร์วอลล์ได้ โปรดแก้ไขปัญหา หรือส่งรายงานปัญหา</string>
<string name="settings">การตั้งค่า</string>
diff --git a/android/lib/resource/src/main/res/values-tr/strings.xml b/android/lib/resource/src/main/res/values-tr/strings.xml
index 4d3a0e4b45..45f8ac0eb4 100644
--- a/android/lib/resource/src/main/res/values-tr/strings.xml
+++ b/android/lib/resource/src/main/res/values-tr/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">İşte hesap numaranız. Kaydedin!</string>
<string name="hide_account_number">Hesap numarasını gizle</string>
<string name="hint_default">Varsayılan</string>
+ <string name="import_overrides_import">İçe aktar</string>
+ <string name="import_overrides_text_title">Metin yoluyla içe aktar</string>
<string name="in_address">Giriş</string>
<string name="invalid_dns_servers">Özel DNS sunucu adresleri (%1$s) geçersiz</string>
<string name="invalid_voucher">Kupon kodu geçersiz.</string>
@@ -213,6 +215,9 @@
<string name="sent">Gönderildi</string>
<string name="sent_contact">Gerektiğinde sizinle %1$s adresinden iletişime geçeceğiz</string>
<string name="sent_thanks">Teşekkürler!</string>
+ <string name="server_ip_overrides_info_first_paragraph">Farklı sansür türlerinin kullanıldığı bazı ağlarda sunucu IP adreslerimiz zaman zaman engellenir.</string>
+ <string name="server_ip_overrides_info_second_paragraph">Bu sınırlamadan kaçınmak için Konum Seç görünümündeki varsayılan sunucu adreslerini geçersiz kılan yeni IP adreslerine sahip bir dosyayı veya metni (destek ekibimiz tarafından sağlanır) içe aktarabilirsiniz.</string>
+ <string name="server_ip_overrides_info_third_paragraph">VPN sunucularına bağlanırken sorun yaşıyorsanız lütfen destek ekibiyle iletişime geçin.</string>
<string name="set_dns_error">Sistem DNS sunucusu ayarlanamıyor. Lütfen bir hata raporu gönderin.</string>
<string name="set_firewall_policy_error">Güvenlik duvarı kuralları uygulanamıyor. Lütfen sorunu çözmeye çalışın veya bir hata raporu gönderin.</string>
<string name="settings">Ayarlar</string>
diff --git a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml
index f9e3e30ea6..d5c4c3dd1c 100644
--- a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml
+++ b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">以下是您的帐号。请妥善保存!</string>
<string name="hide_account_number">隐藏帐号</string>
<string name="hint_default">默认</string>
+ <string name="import_overrides_import">导入</string>
+ <string name="import_overrides_text_title">通过文本导入</string>
<string name="in_address">内部</string>
<string name="invalid_dns_servers">自定义 DNS 服务器地址 %1$s 无效</string>
<string name="invalid_voucher">该优惠券码无效。</string>
@@ -213,6 +215,9 @@
<string name="sent">已发送</string>
<string name="sent_contact">如果需要,我们将通过 %1$s 与您联系</string>
<string name="sent_thanks">谢谢!</string>
+ <string name="server_ip_overrides_info_first_paragraph">在某些使用各类审查的网络上,我们的服务器 IP 地址有时会被阻止。</string>
+ <string name="server_ip_overrides_info_second_paragraph">为了避免这种情况,您可以导入由我们的支持团队提供的文件或文本,其中的新 IP 地址会覆盖“选择位置”视图中服务器的默认地址。</string>
+ <string name="server_ip_overrides_info_third_paragraph">如果您在连接到 VPN 服务器时遇到问题,请联系支持团队。</string>
<string name="set_dns_error">无法设置系统 DNS 服务器。请发送问题报告。</string>
<string name="set_firewall_policy_error">无法应用防火墙规则。请排查问题或发送问题报告。</string>
<string name="settings">设置</string>
diff --git a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml
index a250e018e4..765dcecc4c 100644
--- a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml
+++ b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml
@@ -121,6 +121,8 @@
<string name="here_is_your_account_number">以下是您的帳號。請妥善保管!</string>
<string name="hide_account_number">隱藏帳號</string>
<string name="hint_default">預設</string>
+ <string name="import_overrides_import">匯入</string>
+ <string name="import_overrides_text_title">透過文字匯入</string>
<string name="in_address">入境</string>
<string name="invalid_dns_servers">自訂 DNS 伺服器位址 %1$s 無效</string>
<string name="invalid_voucher">憑證兌換碼無效。</string>
@@ -213,6 +215,9 @@
<string name="sent">已傳送</string>
<string name="sent_contact">如有需要,我們將透過 %1$s 與您聯絡</string>
<string name="sent_thanks">謝謝!</string>
+ <string name="server_ip_overrides_info_first_paragraph">在某些採用了各類審查功能的網路上,我們的伺服器 IP 位址有時會遭到封鎖。</string>
+ <string name="server_ip_overrides_info_second_paragraph">為了避免這種情況,您可以匯入由我們支援團隊所提供的檔案或文字,其中的新 IP 位址會覆蓋「選取位置」視圖中伺服器的預設位址。</string>
+ <string name="server_ip_overrides_info_third_paragraph">如果您在連線至 VPN 伺服器時遇到問題,請聯絡支援人員。</string>
<string name="set_dns_error">無法設定系統 DNS 伺服器。請傳送問題回報。</string>
<string name="set_firewall_policy_error">無法套用防火牆規則。請排除故障或傳送問題回報。</string>
<string name="settings">設定</string>
diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml
index 88c38adc7f..1f2a966d16 100644
--- a/android/lib/resource/src/main/res/values/strings.xml
+++ b/android/lib/resource/src/main/res/values/strings.xml
@@ -317,4 +317,30 @@
<string name="name_was_changed_to">Name was changed to %s</string>
<string name="locations_were_changed_for">Locations were changed for \"%s\"</string>
<string name="not_found">Not found</string>
+ <string name="server_ip_overrides_import_button">Import</string>
+ <string name="server_ip_overrides">Server Ip overrides</string>
+ <string name="server_ip_overrides_active">Overrides active</string>
+ <string name="server_ip_overrides_inactive">Overrides inactive</string>
+ <string name="server_ip_overrides_info_first_paragraph">On some networks, where various types of censorship are being used, our server IP addresses are sometimes blocked.</string>
+ <string name="server_ip_overrides_info_second_paragraph">To circumvent this you can import a file or a text, provided by our support team, with new IP addresses that override the default addresses of the servers in the Select location view.</string>
+ <string name="server_ip_overrides_info_third_paragraph">If you are having issues connecting to VPN servers, please contact support.</string>
+ <string name="server_ip_overrides_reset">Reset overrides</string>
+ <string name="server_ip_overrides_reset_title">Reset all overrides</string>
+ <string name="server_ip_overrides_reset_body">All overrides will be reset and servers IP addresses, in the Select location view, will go back to default.</string>
+ <string name="server_ip_overrides_reset_reset_button">Reset</string>
+ <string name="server_ip_overrides_import_by">Import new overrides by</string>
+ <string name="server_ip_overrides_import_by_file">File</string>
+ <string name="server_ip_overrides_import_by_text">Text</string>
+ <string name="import_overrides_import">Import</string>
+ <string name="import_override_textfield_placeholder">Paste or write overrides to be imported</string>
+ <string name="import_overrides_text_title">Import via text</string>
+ <string name="import_overrides_bottom_sheet_override_warning">Importing new overrides might replace some previously imported overrides.</string>
+ <string name="patch_not_matching_specification">Patch not matching specification</string>
+ <string name="settings_patch_error_invalid_or_missing_value">Invalid or missing value \"%1$s\"</string>
+ <string name="settings_patch_error_unable_to_parse">Unable to parse patch</string>
+ <string name="settings_patch_error_unknown_or_prohibited_key">Unknown or prohibited key \"%1$s\"</string>
+ <string name="settings_patch_error_failed_to_apply_patch">Failed to apply patch</string>
+ <string name="settings_patch_error_recursion_limit">Recursion limit</string>
+ <string name="settings_patch_success">Import successful, overrides active</string>
+ <string name="overrides_cleared">Overrides cleared</string>
</resources>
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt
index f99d36c679..1d87987cf3 100644
--- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt
@@ -20,10 +20,12 @@ import net.mullvad.mullvadvpn.model.PlayPurchaseInitResult
import net.mullvad.mullvadvpn.model.PlayPurchaseVerifyResult
import net.mullvad.mullvadvpn.model.QuantumResistantState
import net.mullvad.mullvadvpn.model.RelayList
+import net.mullvad.mullvadvpn.model.RelayOverride
import net.mullvad.mullvadvpn.model.RelaySettings
import net.mullvad.mullvadvpn.model.RemoveDeviceEvent
import net.mullvad.mullvadvpn.model.RemoveDeviceResult
import net.mullvad.mullvadvpn.model.Settings
+import net.mullvad.mullvadvpn.model.SettingsPatchError
import net.mullvad.mullvadvpn.model.TunnelState
import net.mullvad.mullvadvpn.model.UpdateCustomListResult
import net.mullvad.mullvadvpn.model.VoucherSubmissionResult
@@ -202,6 +204,15 @@ class MullvadDaemon(
fun updateCustomList(customList: CustomList): UpdateCustomListResult =
updateCustomList(daemonInterfaceAddress, customList)
+ fun clearAllRelayOverrides() = clearAllRelayOverrides(daemonInterfaceAddress)
+
+ fun applyJsonSettings(json: String) = applyJsonSettings(daemonInterfaceAddress, json)
+
+ fun exportJsonSettings(): String = exportJsonSettings(daemonInterfaceAddress)
+
+ fun setRelayOverride(relayOverride: RelayOverride) =
+ setRelayOverride(daemonInterfaceAddress, relayOverride)
+
fun onDestroy() {
onSettingsChange.unsubscribeAll()
onTunnelStateChange.unsubscribeAll()
@@ -323,6 +334,20 @@ class MullvadDaemon(
customList: CustomList
): UpdateCustomListResult
+ private external fun clearAllRelayOverrides(daemonInterfaceAddress: Long)
+
+ private external fun applyJsonSettings(
+ daemonInterfaceAddress: Long,
+ json: String
+ ): SettingsPatchError
+
+ private external fun exportJsonSettings(daemonInterfaceAddress: Long): String
+
+ private external fun setRelayOverride(
+ daemonInterfaceAddress: Long,
+ relayOverride: RelayOverride
+ )
+
@Suppress("unused")
private fun notifyAppVersionInfoEvent(appVersionInfo: AppVersionInfo) {
onAppVersionInfoChange?.invoke(appVersionInfo)
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/JsonSettings.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/JsonSettings.kt
new file mode 100644
index 0000000000..65d7b6cff0
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/JsonSettings.kt
@@ -0,0 +1,48 @@
+package net.mullvad.mullvadvpn.service.endpoint
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.lib.ipc.Request
+
+class JsonSettings(
+ private val endpoint: ServiceEndpoint,
+ dispatcher: CoroutineDispatcher = Dispatchers.IO
+) {
+ private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
+ private val daemon
+ get() = endpoint.intermittentDaemon
+
+ init {
+ scope.launch {
+ endpoint.dispatcher.parsedMessages
+ .filterIsInstance<Request.ApplyJsonSettings>()
+ .collect { applyJsonSettings(it.json) }
+ }
+
+ scope.launch {
+ endpoint.dispatcher.parsedMessages
+ .filterIsInstance<Request.ExportJsonSettings>()
+ .collect { exportJsonSettings() }
+ }
+ }
+
+ private suspend fun applyJsonSettings(json: String) {
+ val result = daemon.await().applyJsonSettings(json)
+ endpoint.sendEvent(Event.ApplyJsonSettingsResult(result))
+ }
+
+ private suspend fun exportJsonSettings() {
+ val json = daemon.await().exportJsonSettings()
+ endpoint.sendEvent(Event.ExportJsonSettingsResult(json))
+ }
+
+ fun onDestroy() {
+ scope.cancel()
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayOverrides.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayOverrides.kt
new file mode 100644
index 0000000000..cda7a5b94b
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayOverrides.kt
@@ -0,0 +1,37 @@
+package net.mullvad.mullvadvpn.service.endpoint
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.lib.ipc.Request
+
+class RelayOverrides(
+ private val endpoint: ServiceEndpoint,
+ dispatcher: CoroutineDispatcher = Dispatchers.IO
+) {
+ private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
+ private val daemon
+ get() = endpoint.intermittentDaemon
+
+ init {
+ scope.launch {
+ endpoint.dispatcher.parsedMessages
+ .filterIsInstance<Request.SetRelayOverride>()
+ .collect { daemon.await().setRelayOverride(it.override) }
+ }
+
+ scope.launch {
+ endpoint.dispatcher.parsedMessages
+ .filterIsInstance<Request.ClearAllRelayOverrides>()
+ .collect { daemon.await().clearAllRelayOverrides() }
+ }
+ }
+
+ fun onDestroy() {
+ scope.cancel()
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt
index 5485c528b0..f8fc6aaf64 100644
--- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt
@@ -46,6 +46,8 @@ class ServiceEndpoint(
val appVersionInfoCache = AppVersionInfoCache(this)
val authTokenCache = AuthTokenCache(this)
val customDns = CustomDns(this)
+ val relayOverrides = RelayOverrides(this)
+ val jsonSettings = JsonSettings(this)
val relayListListener = RelayListListener(this)
val splitTunneling = SplitTunneling(SplitTunnelingPersistence(context), this)
val voucherRedeemer = VoucherRedeemer(this, accountCache)
@@ -83,6 +85,8 @@ class ServiceEndpoint(
voucherRedeemer.onDestroy()
playPurchaseHandler.onDestroy()
customLists.onDestroy()
+ relayOverrides.onDestroy()
+ jsonSettings.onDestroy()
}
internal fun sendEvent(event: Event) {
diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot
index 58acfd3b51..33d8f28a7c 100644
--- a/gui/locales/messages.pot
+++ b/gui/locales/messages.pot
@@ -2055,6 +2055,9 @@ msgstr ""
msgid "All applications"
msgstr ""
+msgid "All overrides will be reset and servers IP addresses, in the Select location view, will go back to default."
+msgstr ""
+
msgid "Allows access to other devices on the same network for sharing, printing etc."
msgstr ""
@@ -2148,6 +2151,12 @@ msgstr ""
msgid "Excluded applications"
msgstr ""
+msgid "Failed to apply patch"
+msgstr ""
+
+msgid "File"
+msgstr ""
+
msgid "Go to VPN settings"
msgstr ""
@@ -2163,9 +2172,21 @@ msgstr ""
msgid "If the split tunneling feature is used, then the app queries your system for a list of all installed applications. This list is only retrieved in the split tunneling view. The list of installed applications is never sent from the device."
msgstr ""
+msgid "Import new overrides by"
+msgstr ""
+
+msgid "Import successful, overrides active"
+msgstr ""
+
+msgid "Importing new overrides might replace some previously imported overrides."
+msgstr ""
+
msgid "Install Mullvad VPN (%s) to stay up to date"
msgstr ""
+msgid "Invalid or missing value \"%s\""
+msgstr ""
+
msgid "List name"
msgstr ""
@@ -2205,6 +2226,21 @@ msgstr ""
msgid "Not found"
msgstr ""
+msgid "Overrides active"
+msgstr ""
+
+msgid "Overrides cleared"
+msgstr ""
+
+msgid "Overrides inactive"
+msgstr ""
+
+msgid "Paste or write overrides to be imported"
+msgstr ""
+
+msgid "Patch not matching specification"
+msgstr ""
+
msgid "Please use the <b>Always-on</b> system setting instead by following the guide in <b>%s</b> above."
msgstr ""
@@ -2217,18 +2253,33 @@ msgstr ""
msgid "Privacy policy"
msgstr ""
+msgid "Recursion limit"
+msgstr ""
+
msgid "Remove"
msgstr ""
msgid "Remove custom port"
msgstr ""
+msgid "Reset"
+msgstr ""
+
+msgid "Reset all overrides"
+msgstr ""
+
+msgid "Reset overrides"
+msgstr ""
+
msgid "Reset to default"
msgstr ""
msgid "Secured"
msgstr ""
+msgid "Server Ip overrides"
+msgstr ""
+
msgid "Set WireGuard MTU value. Valid range: %d - %d."
msgstr ""
@@ -2250,6 +2301,9 @@ msgstr ""
msgid "Submit"
msgstr ""
+msgid "Text"
+msgstr ""
+
msgid "The Auto-connect and Lockdown mode settings can be found in the Android system settings, follow this guide to enable one or both."
msgstr ""
@@ -2280,12 +2334,18 @@ msgstr ""
msgid "Unable to apply firewall rules. Please troubleshoot or send a problem report."
msgstr ""
+msgid "Unable to parse patch"
+msgstr ""
+
msgid "Unable to start tunnel connection. Please disable Always-on VPN for <b>%s</b> before using Mullvad VPN."
msgstr ""
msgid "Undo"
msgstr ""
+msgid "Unknown or prohibited key \"%s\""
+msgstr ""
+
msgid "Unsecured"
msgstr ""
diff --git a/mullvad-jni/src/classes.rs b/mullvad-jni/src/classes.rs
index 969c7a5057..fb11412c26 100644
--- a/mullvad-jni/src/classes.rs
+++ b/mullvad-jni/src/classes.rs
@@ -57,10 +57,12 @@ pub const CLASSES: &[&str] = &[
"net/mullvad/mullvadvpn/model/RelayList",
"net/mullvad/mullvadvpn/model/RelayListCity",
"net/mullvad/mullvadvpn/model/RelayListCountry",
+ "net/mullvad/mullvadvpn/model/RelayOverride",
"net/mullvad/mullvadvpn/model/RelaySettings$CustomTunnelEndpoint",
"net/mullvad/mullvadvpn/model/RelaySettings$Normal",
"net/mullvad/mullvadvpn/model/SelectedObfuscation",
"net/mullvad/mullvadvpn/model/Settings",
+ "net/mullvad/mullvadvpn/model/SettingsPatchError",
"net/mullvad/mullvadvpn/model/TunnelState$Error",
"net/mullvad/mullvadvpn/model/TunnelState$Connected",
"net/mullvad/mullvadvpn/model/TunnelState$Connecting",
diff --git a/mullvad-jni/src/daemon_interface.rs b/mullvad-jni/src/daemon_interface.rs
index 66cc8c3eb2..4d9cf3ad48 100644
--- a/mullvad-jni/src/daemon_interface.rs
+++ b/mullvad-jni/src/daemon_interface.rs
@@ -1,10 +1,10 @@
use futures::{channel::oneshot, executor::block_on};
-use mullvad_daemon::{device, DaemonCommand, DaemonCommandSender};
+use mullvad_daemon::{device, settings::patch, DaemonCommand, DaemonCommandSender};
use mullvad_types::{
account::{AccountData, AccountToken, PlayPurchase, VoucherSubmission},
custom_list::CustomList,
device::{Device, DeviceState},
- relay_constraints::{ObfuscationSettings, RelaySettings},
+ relay_constraints::{ObfuscationSettings, RelayOverride, RelaySettings},
relay_list::RelayList,
settings::{DnsOptions, Settings},
states::{TargetState, TunnelState},
@@ -30,6 +30,9 @@ pub enum Error {
#[error("Failed to update settings")]
UpdateSettings,
+ #[error("Patch error")]
+ Patch(#[from] patch::Error),
+
#[error("Daemon returned an error")]
Other(#[source] mullvad_daemon::Error),
}
@@ -384,6 +387,46 @@ impl DaemonInterface {
.map_err(Error::from)
}
+ pub fn apply_json_settings(&self, json: String) -> Result<()> {
+ let (tx, rx) = oneshot::channel();
+
+ self.send_command(DaemonCommand::ApplyJsonSettings(tx, json))?;
+
+ block_on(rx)
+ .map_err(|_| Error::NoResponse)?
+ .map_err(Error::from)
+ }
+
+ pub fn export_json_settings(&self) -> Result<String> {
+ let (tx, rx) = oneshot::channel();
+
+ self.send_command(DaemonCommand::ExportJsonSettings(tx))?;
+
+ block_on(rx)
+ .map_err(|_| Error::NoResponse)?
+ .map_err(Error::from)
+ }
+
+ pub fn set_relay_override(&self, relay_override: RelayOverride) -> Result<()> {
+ let (tx, rx) = oneshot::channel();
+
+ self.send_command(DaemonCommand::SetRelayOverride(tx, relay_override))?;
+
+ block_on(rx)
+ .map_err(|_| Error::NoResponse)?
+ .map_err(|_| Error::UpdateSettings)
+ }
+
+ pub fn clear_all_relay_overrides(&self) -> Result<()> {
+ let (tx, rx) = oneshot::channel();
+
+ self.send_command(DaemonCommand::ClearAllRelayOverrides(tx))?;
+
+ block_on(rx)
+ .map_err(|_| Error::NoResponse)?
+ .map_err(|_| Error::UpdateSettings)
+ }
+
fn send_command(&self, command: DaemonCommand) -> Result<()> {
self.command_sender.send(command).map_err(Error::NoDaemon)
}
diff --git a/mullvad-jni/src/lib.rs b/mullvad-jni/src/lib.rs
index 9139f1f435..9264b5e895 100644
--- a/mullvad-jni/src/lib.rs
+++ b/mullvad-jni/src/lib.rs
@@ -19,12 +19,13 @@ use jnix::{
};
use mullvad_api::{rest::Error as RestError, StatusCode};
use mullvad_daemon::{
- device, exception_logging, logging, runtime::new_runtime_builder, version, Daemon,
- DaemonCommandChannel,
+ device, exception_logging, logging, runtime::new_runtime_builder,
+ settings::patch::Error as PatchError, version, Daemon, DaemonCommandChannel,
};
use mullvad_types::{
account::{AccountData, PlayPurchase, VoucherSubmission},
custom_list::CustomList,
+ relay_constraints::RelayOverride,
settings::DnsOptions,
};
use std::{
@@ -192,6 +193,46 @@ impl From<daemon_interface::Error> for VoucherSubmissionError {
#[derive(IntoJava)]
#[jnix(package = "net.mullvad.mullvadvpn.model")]
+pub enum SettingsPatchError {
+ InvalidOrMissingValue(String),
+ UnknownOrProhibitedKey(String),
+ ParsePatch,
+ DeserializePatched,
+ RecursionLimit,
+ ApplyPatch,
+}
+
+impl From<daemon_interface::Error> for SettingsPatchError {
+ fn from(error: daemon_interface::Error) -> Self {
+ match error {
+ daemon_interface::Error::Patch(PatchError::InvalidOrMissingValue(str)) => {
+ SettingsPatchError::InvalidOrMissingValue(str.to_string())
+ }
+ daemon_interface::Error::Patch(PatchError::UnknownOrProhibitedKey(string)) => {
+ SettingsPatchError::UnknownOrProhibitedKey(string)
+ }
+ daemon_interface::Error::Patch(PatchError::ParsePatch(_)) => {
+ SettingsPatchError::ParsePatch
+ }
+ daemon_interface::Error::Patch(PatchError::DeserializePatched(_)) => {
+ SettingsPatchError::DeserializePatched
+ }
+ daemon_interface::Error::Patch(PatchError::SerializeSettings(_)) => {
+ SettingsPatchError::ApplyPatch
+ }
+ daemon_interface::Error::Patch(PatchError::SerializeValue(_)) => {
+ SettingsPatchError::ApplyPatch
+ }
+ daemon_interface::Error::Patch(PatchError::RecursionLimit) => {
+ SettingsPatchError::RecursionLimit
+ }
+ _ => SettingsPatchError::ApplyPatch,
+ }
+ }
+}
+
+#[derive(IntoJava)]
+#[jnix(package = "net.mullvad.mullvadvpn.model")]
pub enum PlayPurchaseInitResult {
Ok(String),
Error(PlayPurchaseInitError),
@@ -1465,6 +1506,101 @@ pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_updateC
}
}
+#[no_mangle]
+#[allow(non_snake_case)]
+pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_applyJsonSettings<'env>(
+ env: JNIEnv<'env>,
+ _: JObject<'_>,
+ daemon_interface_address: jlong,
+ json: JString<'_>,
+) -> JObject<'env> {
+ let env = JnixEnv::from(env);
+
+ // SAFETY: The address points to an instance valid for the duration of this function call
+ if let Some(daemon_interface) = unsafe { get_daemon_interface(daemon_interface_address) } {
+ let jsonSettings = String::from_java(&env, json);
+ match daemon_interface.apply_json_settings(jsonSettings) {
+ Ok(()) => JObject::null(),
+ Err(error) => {
+ log_request_error("apply json settings", &error);
+ SettingsPatchError::from(error).into_java(&env).forget()
+ }
+ }
+ } else {
+ log::warn!("Daemon was unreachable");
+ JObject::null()
+ }
+}
+
+#[no_mangle]
+#[allow(non_snake_case)]
+pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_exportJsonSettings<
+ 'env,
+>(
+ env: JNIEnv<'env>,
+ _: JObject<'_>,
+ daemon_interface_address: jlong,
+ _: JObject<'_>,
+) -> JObject<'env> {
+ let env = JnixEnv::from(env);
+
+ // SAFETY: The address points to an instance valid for the duration of this function call
+ if let Some(daemon_interface) = unsafe { get_daemon_interface(daemon_interface_address) } {
+ match daemon_interface.export_json_settings() {
+ Ok(exported_json) => exported_json.into_java(&env).forget(),
+ Err(error) => {
+ log_request_error("export json settings", &error);
+ JObject::null()
+ }
+ }
+ } else {
+ log::warn!("Daemon was unreachable");
+ JObject::null()
+ }
+}
+
+#[no_mangle]
+#[allow(non_snake_case)]
+pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_setRelayOverride(
+ env: JNIEnv<'_>,
+ _: JObject<'_>,
+ daemon_interface_address: jlong,
+ relay_override: JObject<'_>,
+) {
+ let env = JnixEnv::from(env);
+
+ // SAFETY: The address points to an instance valid for the duration of this function call
+ if let Some(daemon_interface) = unsafe { get_daemon_interface(daemon_interface_address) } {
+ let r_override = RelayOverride::from_java(&env, relay_override);
+
+ match daemon_interface.set_relay_override(r_override) {
+ Ok(()) => (),
+ Err(error) => {
+ log_request_error("set relay override", &error);
+ }
+ }
+ }
+}
+
+#[no_mangle]
+#[allow(non_snake_case)]
+pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_clearAllRelayOverrides(
+ _: JNIEnv<'_>,
+ _: JObject<'_>,
+ daemon_interface_address: jlong,
+ _: JObject<'_>,
+) {
+ // SAFETY: The address points to an instance valid for the duration of this function call
+ if let Some(daemon_interface) = unsafe { get_daemon_interface(daemon_interface_address) } {
+ match daemon_interface.clear_all_relay_overrides() {
+ Ok(()) => (),
+ Err(error) => {
+ log_request_error("clear all relay overrides", &error);
+ }
+ }
+ }
+}
+
fn log_request_error(request: &str, error: &daemon_interface::Error) {
match error {
daemon_interface::Error::Api(RestError::Aborted) => {
diff --git a/mullvad-types/src/relay_constraints.rs b/mullvad-types/src/relay_constraints.rs
index b37c3ea0a1..d0a42e2303 100644
--- a/mullvad-types/src/relay_constraints.rs
+++ b/mullvad-types/src/relay_constraints.rs
@@ -1039,6 +1039,8 @@ pub struct InternalBridgeConstraints {
/// Options to override for a particular relay to use instead of the ones specified in the relay
/// list
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
+#[cfg_attr(target_os = "android", derive(FromJava, IntoJava))]
+#[cfg_attr(target_os = "android", jnix(package = "net.mullvad.mullvadvpn.model"))]
pub struct RelayOverride {
/// Hostname for which to override the given options
pub hostname: Hostname,
diff --git a/mullvad-types/src/settings/mod.rs b/mullvad-types/src/settings/mod.rs
index b8fec8de2f..c0048a7e62 100644
--- a/mullvad-types/src/settings/mod.rs
+++ b/mullvad-types/src/settings/mod.rs
@@ -95,7 +95,6 @@ pub struct Settings {
/// might be located.
pub tunnel_options: TunnelOptions,
/// Overrides for relays
- #[cfg_attr(target_os = "android", jnix(skip))]
pub relay_overrides: Vec<RelayOverride>,
/// Whether to notify users of beta updates.
pub show_beta_releases: bool,