From 54879dec93a6737e00f64ca2fc08ffdbcec04eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B6ransson?= Date: Wed, 13 Mar 2024 16:21:22 +0100 Subject: [PATCH] Add Server IP Overrides feature --- CHANGELOG.md | 1 + .../compose/button/InfoIconButton.kt | 20 ++ .../compose/cell/ServerIpOverridesCell.kt | 81 +++++ .../component/MullvadModalBottomSheet.kt | 6 +- .../compose/component/Scaffolding.kt | 3 +- ...esetServerIpOverridesConfirmationDialog.kt | 66 ++++ .../dialog/ServerIpOverridesInfoDialog.kt | 32 ++ .../screen/ImportOverridesByTextScreen.kt | 92 +++++ .../compose/screen/SelectLocationScreen.kt | 6 +- .../compose/screen/ServerIpOverridesScreen.kt | 340 ++++++++++++++++++ .../compose/screen/VpnSettingsScreen.kt | 15 + .../SlideInFromRightLeafTransition.kt | 30 ++ .../mullvadvpn/compose/util/Navigation.kt | 17 + .../mullvadvpn/compose/util/Snackbar.kt | 19 + .../net/mullvad/mullvadvpn/di/UiModule.kt | 7 +- .../repository/RelayOverridesRepository.kt | 44 +++ .../repository/SettingsRepository.kt | 17 +- .../net/mullvad/mullvadvpn/ui/MainActivity.kt | 8 +- .../ui/serviceconnection/SettingsListener.kt | 4 + .../viewmodel/ServerIpOverridesViewModel.kt | 85 +++++ .../net/mullvad/mullvadvpn/lib/ipc/Event.kt | 5 + .../net/mullvad/mullvadvpn/lib/ipc/Request.kt | 9 + .../mullvad/mullvadvpn/model/RelayOverride.kt | 12 + .../net/mullvad/mullvadvpn/model/Settings.kt | 3 +- .../mullvadvpn/model/SettingsPatchError.kt | 24 ++ .../main/res/drawable/icon_text_fields.xml | 9 + .../main/res/drawable/icon_upload_file.xml | 9 + .../src/main/res/values-da/strings.xml | 5 + .../src/main/res/values-de/strings.xml | 5 + .../src/main/res/values-es/strings.xml | 5 + .../src/main/res/values-fi/strings.xml | 5 + .../src/main/res/values-fr/strings.xml | 5 + .../src/main/res/values-it/strings.xml | 5 + .../src/main/res/values-ja/strings.xml | 5 + .../src/main/res/values-ko/strings.xml | 5 + .../src/main/res/values-my/strings.xml | 5 + .../src/main/res/values-nb/strings.xml | 5 + .../src/main/res/values-nl/strings.xml | 5 + .../src/main/res/values-pl/strings.xml | 5 + .../src/main/res/values-pt/strings.xml | 5 + .../src/main/res/values-ru/strings.xml | 5 + .../src/main/res/values-sv/strings.xml | 5 + .../src/main/res/values-th/strings.xml | 5 + .../src/main/res/values-tr/strings.xml | 5 + .../src/main/res/values-zh-rCN/strings.xml | 5 + .../src/main/res/values-zh-rTW/strings.xml | 5 + .../resource/src/main/res/values/strings.xml | 26 ++ .../mullvadvpn/service/MullvadDaemon.kt | 25 ++ .../service/endpoint/JsonSettings.kt | 48 +++ .../service/endpoint/RelayOverrides.kt | 37 ++ .../service/endpoint/ServiceEndpoint.kt | 4 + gui/locales/messages.pot | 60 ++++ mullvad-jni/src/classes.rs | 2 + mullvad-jni/src/daemon_interface.rs | 53 ++- mullvad-jni/src/lib.rs | 140 +++++++- mullvad-types/src/relay_constraints.rs | 2 + mullvad-types/src/settings/mod.rs | 1 - 57 files changed, 1441 insertions(+), 16 deletions(-) create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/InfoIconButton.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ServerIpOverridesCell.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIpOverridesConfirmationDialog.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ServerIpOverridesInfoDialog.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ImportOverridesByTextScreen.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightLeafTransition.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Navigation.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Snackbar.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayOverridesRepository.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt create mode 100644 android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayOverride.kt create mode 100644 android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/SettingsPatchError.kt create mode 100644 android/lib/resource/src/main/res/drawable/icon_text_fields.xml create mode 100644 android/lib/resource/src/main/res/drawable/icon_upload_file.xml create mode 100644 android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/JsonSettings.kt create mode 100644 android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayOverrides.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 82aafb2d59e1..793e3b494bec 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/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 000000000000..efd90b0ac770 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/InfoIconButton.kt @@ -0,0 +1,20 @@ +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.res.painterResource +import net.mullvad.mullvadvpn.R + +@Composable +fun InfoIconButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + IconButton(modifier = modifier, onClick = onClick) { + Icon( + painter = painterResource(id = R.drawable.icon_info), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + } +} 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 000000000000..12f500f6107c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ServerIpOverridesCell.kt @@ -0,0 +1,81 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +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.Alignment +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.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, +) { + Row( + modifier = + modifier + .wrapContentHeight() + .height(IntrinsicSize.Min) + .background(MaterialTheme.colorScheme.primary) + .padding(horizontal = Dimens.sideMargin) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = + Modifier.size(Dimens.relayCircleSize) + .background( + color = + when { + active -> activeColor + else -> inactiveColor + }, + shape = CircleShape + ) + ) + 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) + ) + } +} 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 1f8fb46cd79f..edd697dfece1 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 b9a630641321..585855cb1d13 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 000000000000..30139f648fd4 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIpOverridesConfirmationDialog.kt @@ -0,0 +1,66 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.result.EmptyResultBackNavigator +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.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Preview +@Composable +private fun PreviewResetServerIpOverridesConfirmationDialog() { + AppTheme { ResetServerIpOverridesConfirmationDialog(EmptyResultBackNavigator()) } +} + +@Destination(style = DestinationStyle.Dialog::class) +@Composable +fun ResetServerIpOverridesConfirmationDialog( + resultNavigator: ResultBackNavigator, +) { + AlertDialog( + containerColor = MaterialTheme.colorScheme.background, + confirmButton = { + Column(verticalArrangement = Arrangement.spacedBy(Dimens.buttonSpacing)) { + NegativeButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.server_ip_overrides_reset_reset_button), + onClick = { resultNavigator.navigateBack(result = true) } + ) + + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.cancel), + onClick = resultNavigator::navigateBack + ) + } + }, + title = { + Text( + text = stringResource(id = R.string.server_ip_overrides_reset_title), + color = MaterialTheme.colorScheme.onPrimary + ) + }, + text = { + Text( + text = stringResource(id = R.string.server_ip_overrides_reset_body), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.bodySmall, + ) + }, + onDismissRequest = resultNavigator::navigateBack + ) +} 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 000000000000..9b6054f1f0f8 --- /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 000000000000..7ab063703c72 --- /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, +) { + 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 594c657cdbd4..35f934295167 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( @@ -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( @@ -656,7 +656,7 @@ private fun EditCustomListBottomSheet( ) { MullvadModalBottomSheet( sheetState = sheetState, - closeBottomSheet = { closeBottomSheet(false) } + onDismissRequest = { closeBottomSheet(false) } ) { HeaderCell(text = customList.name, background = Color.Unspecified) IconCell( 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 000000000000..34e4d18fe6a1 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt @@ -0,0 +1,340 @@ +package net.mullvad.mullvadvpn.compose.screen + +import android.content.Context +import android.util.Log +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.automirrored.filled.ArrowBack +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.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.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.destinations.ImportOverridesByTextDestination +import net.mullvad.mullvadvpn.compose.destinations.ResetServerIpOverridesConfirmationDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.ServerIpOverridesInfoDialogDestination +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.showSnackBarDirect +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.ServerIpOverridesViewModel +import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesViewState +import org.koin.androidx.compose.koinViewModel + +@Preview +@Composable +private fun PreviewServerIpOverridesScreen() { + AppTheme { + ServerIpOverridesScreen( + ServerIpOverridesViewState(false), + SnackbarHostState(), + onBackClick = {}, + onInfoClick = {}, + onResetOverridesClick = {}, + onImportByFile = {}, + onImportByText = {} + ) + } +} + +@Destination(style = SlideInFromRightLeafTransition::class) +@Composable +fun ServerIpOverrides( + navigator: DestinationsNavigator, + importByTextResult: ResultRecipient, + clearAllOverrides: + ResultRecipient, +) { + val vm = koinViewModel() + val state by vm.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + val context = LocalContext.current + LaunchedEffectCollect(vm.uiSideEffect) { sideEffect -> + when (sideEffect) { + is ServerIpOverridesViewModel.UiSideEffect.ImportResult -> + snackbarHostState.showSnackBarDirect( + this, + message = sideEffect.error.toString(context), + actionLabel = null + ) + ServerIpOverridesViewModel.UiSideEffect.OverridesCleared -> + snackbarHostState.showSnackBarDirect( + this, + message = context.getString(R.string.overrides_cleared), + actionLabel = null + ) + } + } + + importByTextResult.OnNavResultValue(vm::importText) + clearAllOverrides.OnNavResultValue { vm.clearAllOverrides() } + + val openFileLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { + if (it != null) { + vm.importFile(it) + } + } + + ServerIpOverridesScreen( + state, + snackbarHostState, + onBackClick = navigator::navigateUp, + onInfoClick = { + navigator.navigate(ServerIpOverridesInfoDialogDestination, onlyIfResumed = true) + }, + onResetOverridesClick = { + navigator.navigate( + ResetServerIpOverridesConfirmationDialogDestination, + onlyIfResumed = true + ) + }, + onImportByFile = { openFileLauncher.launch("application/json") }, + onImportByText = { + navigator.navigate(ImportOverridesByTextDestination, onlyIfResumed = true) + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ServerIpOverridesScreen( + state: ServerIpOverridesViewState, + snackbarHostState: SnackbarHostState, + onBackClick: () -> Unit, + onInfoClick: () -> Unit, + onResetOverridesClick: () -> Unit, + onImportByFile: () -> Unit, + onImportByText: () -> Unit +) { + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showBottomSheet by remember { mutableStateOf(false) } + + ScaffoldWithMediumTopBar( + appBarTitle = stringResource(id = R.string.server_ip_overrides), + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(imageVector = Icons.AutoMirrored.Default.ArrowBack, contentDescription = null) + } + }, + actions = { + TopBarActions( + state.overridesActive, + onInfoClick = onInfoClick, + onResetOverridesClick = onResetOverridesClick + ) + } + ) { modifier -> + if (showBottomSheet) { + 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), + ) + 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 = { + Log.d("ServerIpOverridesScreen", "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, + ) + IconCell( + iconId = R.drawable.icon_text_fields, + title = stringResource(id = R.string.server_ip_overrides_import_by_text), + onClick = { + onImportByText() + onCloseSheet() + }, + background = Color.Unspecified, + ) + 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) + IconButton(onClick = { showMenu = !showMenu }) { + 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, + colors = + MenuDefaults.itemColors( + leadingIconColor = MaterialTheme.colorScheme.onPrimary, + disabledLeadingIconColor = + MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaDisabled) + ), + leadingIcon = { + Icon( + Icons.Filled.Delete, + contentDescription = null, + ) + } + ) + } +} + +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 bd8809b00f85..e926e2e97f35 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) -> 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/transitions/SlideInFromRightLeafTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightLeafTransition.kt new file mode 100644 index 000000000000..45ea74931a38 --- /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.enterTransition() = + slideInHorizontally(initialOffsetX = { it }) + + override fun AnimatedContentTransitionScope.exitTransition() = + when (targetState.destination()) { + NoDaemonScreenDestination -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS)) + else -> fadeOut() + } + + override fun AnimatedContentTransitionScope.popEnterTransition() = + fadeIn(snap(0)) + + override fun AnimatedContentTransitionScope.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 000000000000..9566bc0da2ed --- /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 ResultRecipient.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 000000000000..61b90fb52eaa --- /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.showSnackBarDirect( + 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 c3eb19b27001..c0f9a4912ac2 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 @@ -61,6 +62,7 @@ import net.mullvad.mullvadvpn.viewmodel.PaymentViewModel import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel 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 +97,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 +108,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 +182,7 @@ val uiModule = module { } viewModel { CustomListsViewModel(get(), get()) } viewModel { parameters -> DeleteCustomListConfirmationViewModel(parameters.get(), get()) } + viewModel { ServerIpOverridesViewModel(get(), get(), get(), 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 000000000000..835cab4710a0 --- /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?> = + 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 81c4b85b882c..d223ece266ef 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, + dispatcher: CoroutineDispatcher = Dispatchers.IO, ) { val settingsUpdates: StateFlow = serviceConnectionManager.connectionState @@ -92,4 +100,11 @@ class SettingsRepository( fun setLocalNetworkSharing(isEnabled: Boolean) { serviceConnectionManager.settingsListener()?.allowLan = isEnabled } + + suspend fun applySettingsPatch(json: String) = + withContext(Dispatchers.IO) { + val deferred = async { messageHandler.events().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 a0841c0746a7..85dfb0f99725 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 == 0) { + serviceConnectionManager.onVpnPermissionResult(resultCode == Activity.RESULT_OK) + } } override fun onStop() { 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 d996c432ad3c..e2ccc2e47069 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/ServerIpOverridesViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt new file mode 100644 index 000000000000..b5c9d606349d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt @@ -0,0 +1,85 @@ +package net.mullvad.mullvadvpn.viewmodel + +import android.content.ContentResolver +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import java.nio.charset.Charset +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.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 contentResolver: ContentResolver, + private val serviceConnectionManager: ServiceConnectionManager, + private val relayOverridesRepository: RelayOverridesRepository, + private val settingsRepository: SettingsRepository +) : ViewModel() { + + private val _uiSideEffect = Channel() + val uiSideEffect = merge(_uiSideEffect.receiveAsFlow()) + + val uiState: StateFlow = + relayOverridesRepository.relayOverrides + .map { ServerIpOverridesViewState(overridesActive = it?.isNotEmpty() ?: false) } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + ServerIpOverridesViewState(overridesActive = false) + ) + + fun importFile(uri: Uri) = + viewModelScope.launch { + // Read json from file + val json = + contentResolver.openInputStream(uri)!!.reader(Charset.defaultCharset()).readText() + applySettingsPatch(json) + } + + fun importText(config: String) = viewModelScope.launch { applySettingsPatch(config) } + + private suspend fun applySettingsPatch(json: String) { + // Wait for daemon to come online since we might be paused + val connResult = + withTimeoutOrNull(5.seconds) { + serviceConnectionManager.connectionState + .filterIsInstance(ServiceConnectionState.ConnectedReady::class) + .first() + } + if (connResult != null) { + // Apply patch + val result = settingsRepository.applySettingsPatch(json) + _uiSideEffect.send(UiSideEffect.ImportResult(result.error)) + } else { + // Service never came online, at this point we should already display daemon overlay + } + } + + fun clearAllOverrides() = + viewModelScope.launch { + relayOverridesRepository.clearAllOverrides() + _uiSideEffect.send(UiSideEffect.OverridesCleared) + } + + sealed interface UiSideEffect { + data class ImportResult(val error: SettingsPatchError?) : UiSideEffect + + data object OverridesCleared : UiSideEffect + } +} + +data class ServerIpOverridesViewState(val overridesActive: Boolean) 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 cce2ab1f87b4..36ea17036ee5 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 fe9d3b46d9e5..4bcf871acc1a 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 000000000000..f738218ee727 --- /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 304edc404a2a..847b80cd7021 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, + 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 000000000000..5e3cb29911a8 --- /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 000000000000..ecc60729990e --- /dev/null +++ b/android/lib/resource/src/main/res/drawable/icon_text_fields.xml @@ -0,0 +1,9 @@ + + + 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 000000000000..4f812f7fc580 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable/icon_upload_file.xml @@ -0,0 +1,9 @@ + + + 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 1b6088b643e1..71b65ffd14f8 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 @@ Her er dit kontonummer. Gem det! Skjul kontonummer Standard + Importer + Importer via tekst Ind Tilpassede DNS-serveradresser %1$s er ugyldige Kuponkode er ugyldig. @@ -213,6 +215,9 @@ Sendt Hvis det er nødvendigt, kontakter vi dig på %1$s Tak! + På nogle netværk, hvor der bruges forskellige typer censur, er vores server IP-adresser nogle gange blokeret. + 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. + Hvis du har problemer med at oprette forbindelse til VPN-servere, bedes du kontakte support. Kan ikke indstille systemets DNS-server. Indsend en problemrapport. Kan ikke anvende firewallregler. Fejlfind eller send en problemrapport. Indstillinger 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 0b72c89620b7..62f7336e574b 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 @@ Hier ist Ihre Kontonummer. Verlieren Sie sie nicht! Kontonummer verbergen Standard + Importieren + Import via Text Eingehend Eigene DNS-Server Adressen %1$s sind ungültig Der Gutscheincode ist ungültig. @@ -213,6 +215,9 @@ Gesendet Bei Bedarf werden wir Sie über %1$s kontaktieren Danke! + In einigen Netzwerken, in denen verschiedene Arten der Zensur eingesetzt werden, werden die IP-Adressen unserer Server manchmal blockiert. + 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. + Wenn Sie Probleme mit der Verbindung zu VPN-Servern haben, wenden Sie sich bitte an den Support. Der DNS-Server des Systems konnte nicht eingestellt werden. Bitte senden Sie einen Problembericht. Firewall-Regeln können nicht angewendet werden. Bitte beheben Sie das Problem oder senden Sie einen Problembericht. Einstellungen 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 0a2369af8840..646e9069a495 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 @@ Este es un número de cuenta. ¡Guárdelo bien! Ocultar número de cuenta Predeterminado + Importar + Importación a través de texto Entrada Las direcciones del servidor DNS personalizado %1$s no son válidas El código del cupón no es válido. @@ -213,6 +215,9 @@ Enviado Si es necesario, le enviaremos un correo electrónico a %1$s ¡Gracias! + En algunas redes, donde se aplican diversos tipos de censura, a veces se bloquean las direcciones IP de nuestro servidor. + 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. + Si tiene problemas para conectarse a los servidores VPN, póngase en contacto con el servicio de asistencia. No se puede configurar el servidor DNS del sistema. Envíe un informe de problemas. No se pueden aplicar las reglas del firewall. Intente solucionar el problema o envíe un informe de problemas. Configuración 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 2df0abd2103e..94f2785792df 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 @@ Tässä tulee tilisi numero. Laita se talteen! Piilota tilin numero Oletus + Tuo + Tuo tekstinä Saapuva Mukautetut DNS-palvelimen osoitteet %1$s ovat virheellisiä Kuponkikoodi ei kelpaa. @@ -213,6 +215,9 @@ Lähetetty Tarvittaessa otamme sinuun yhteyttä osoitteeseen %1$s Kiitos! + Palvelimiemme IP-osoitteet estetään toisinaan joissakin useita erityyppistä sensurointimenetelmiä käyttävissä verkoissa. + Voit kiertää estot tuomalla tukitiimimme toimittaman tiedoston tai tekstin, josta löytyy uusia, palvelimien oletusosoitteet sijainnin valintanäkymässä ohittavia IP-osoitteita. + Jos sinulla on ongelmia yhteyden muodostamisessa VPN-palvelimiin, ota yhteyttä tukeen. Järjestelmän DNS-palvelimen asettaminen ei onnistu. Lähetä ongelmaraportti. Palomuurisääntöjä ei voida käyttää. Suorita vianetsintä tai lähetä ongelmaraportti. Asetukset 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 e834a0209ebe..d87595df89ed 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 @@ Voici votre numéro de compte. Gardez-le ! Masquer le numéro de compte Par défaut + Importer + Importer par texte Entrante Les adresses de serveur DNS personnalisées %1$s ne sont pas valides Le code du bon n\'est pas valide. @@ -213,6 +215,9 @@ Envoyé Si nécessaire, nous vous contacterons à l\'adresse %1$s Merci ! + Sur certains réseaux, où divers types de censure sont utilisés, les adresses IP de notre serveur sont parfois bloquées. + 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. + Si vous rencontrez des problèmes de connexion aux serveurs VPN, veuillez contacter l\'assistance. Impossible de définir le serveur DNS système. Veuillez envoyer un rapport de problème. Impossible d\'appliquer les règles du pare-feu. Merci de résoudre le problème ou d\'envoyer un rapport de problème. Paramètres 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 bc707e2b96a0..ec215b16f2ff 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 @@ Ecco il tuo numero di account. Salvalo! Nascondi numero di account Predefinito + Importa + Importa tramite testo Ricezione Gli indirizzi del server DNS personalizzato %1$s non sono validi Il codice voucher non è valido. @@ -213,6 +215,9 @@ Inviato Se necessario, ti contatteremo all\'indirizzo %1$s Grazie! + Su alcune reti, dove vengono utilizzati vari tipi di censura, gli indirizzi IP dei nostri server vengono talvolta bloccati. + 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. + Se riscontri problemi di connessione ai server VPN, contatta l\'assistenza. Impossibile impostare il server DNS di sistema. Invia una segnalazione del problema. Impossibile applicare le regole del firewall. Consulta la risoluzione dei problemi o invia una segnalazione del problema. Impostazioni 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 71e1907d9e94..1684daed38a4 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 @@ これがあなたのアカウント番号です。保存してください! アカウント番号の非表示 デフォルト + インポート + テキストでインポート 内側 カスタムDNSサーバーアドレス %1$s は無効です バウチャーコードが無効です。 @@ -213,6 +215,9 @@ 送信済み 必要に応じて %1$s 宛にご連絡します  ありがとうございます! + 各種の検閲が使用されている一部のネットワークでは、サーバーIPアドレスがブロックされる場合があります。 + これを回避するには、「場所を選択」ビューでサポートチームが提供したサーバーのデフォルトアドレスをオーバーライドする新しいIPアドレスを含むファイルまたはテキストをインポートできます。 + VPNサーバーへの接続に問題が生じている場合は、サポートにお問い合わせください。 システムのDNSサーバーを設定できません。問題の報告を送信してください。 ファイアウォールのルールを適用できません。問題に対処するか、問題の報告を送信してください。 設定 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 7700ad87dc17..95315a405043 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 @@ 계정 번호는 다음과 같습니다. 저장하세요! 계정 번호 숨기기 기본값 + 가져오기 + 텍스트를 통해 가져오기 사용자 지정 DNS 서버 주소 %1$s이(가) 잘못되었습니다. 유효하지 않은 바우처 코드입니다. @@ -213,6 +215,9 @@ 전송 완료 필요한 경우 %1$s(으)로 연락드리겠습니다. 감사합니다! + 다양한 유형의 검열이 사용되고 있는 일부 네트워크에서는 때때로 당사 서버 IP 주소가 차단됩니다. + 이를 우회하려면 \'위치 선택\' 보기에서 서버의 기본 주소를 재정의하는 새 IP 주소를 사용하여 지원 팀에서 제공한 파일이나 텍스트를 가져올 수 있습니다. + VPN 서버 연결에 문제가 있는 경우 지원 팀에 문의하세요. 시스템 DNS 서버를 설정할 수 없습니다. 문제 보고서를 보내주세요. 방화벽 규칙을 적용할 수 없습니다. 문제를 해결하거나 문제 보고서를 보내주세요. 설정 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 34b27d3cf85e..b87369ff7706 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 @@ ဤသည်မှာ သင့်အကောင့်နံပါတ် ဖြစ်ပါသည်။ သိမ်းမှတ်ထားပါ။ အကောင့်နံပါတ်ကို ဝှက်ရန် ပုံသေ + ထည့်ရန် + စာသားမှတစ်ဆင့် ထည့်သွင်းရန် အဝင် စိတ်ကြိုက် DNS ဆာဗာလိပ်စာများ %1$s မှားနေပါသည် ဘောက်ချာကုဒ် မှားနေပါသည်။ @@ -213,6 +215,9 @@ ပို့ပြီး လိုအပ်ပါက %1$s မှတစ်ဆင့် ကျွန်ုပ်တို့ထံ ဆက်သွယ်ပါ ကျေးဇူးတင်ပါသည်။ + အမျိုးအမျိုးသော စိစစ်ဖြတ်တောက်မှု အမျိုးအစားများ အသုံးပြုသည့် ကွန်ရက်အချို့တွင် ကျွန်ုပ်တို့၏ ဆာဗာ IP လိပ်စာများကို တစ်ခါတစ်ရံ ပိတ်ဆို့ထားပါသည်။ + ဤသည်ကို ရှောင်လွှဲရန် ကျွန်ုပ်တို့ အကူအညီပေးရေးအဖွဲ့မှ ပေးထားသော တည်နေရာ ရွေးရန် ပြသမှုအတွင်းရှိ ဆာဗာများ၏ ပုံသေ လိပ်စာများကို ကျော်လွန် ပယ်ဖျက်သည့် IP လိပ်စာအသစ်များဖြင့် ဖိုင် သို့မဟုတ် စာသားကို သင် ထည့်သွင်းနိုင်ပါသည်။ + VPN ဆာဗာများကို ချိတ်ဆက်ရာတွင် ပြဿနာများရှိနေပါက အကူအညီပေးရေးအဖွဲ့ကို ဆက်သွယ်ပါ။ စနစ် DNS ဆာဗာကို သတ်မှတ်၍ မရနိုင်ပါ။ ပြဿနာ ရီပို့တ်တစ်ခု ပေးပို့ပေးပါ။ Firewall စည်းမျဉ်းများကို အသုံးချ၍ မရနိုင်ပါ။ ပြစ်ချက် ရှာဖွေဖယ်ရှာပေးပါ သို့မဟုတ် ပြဿနာ ရီပို့တ် ပေးပို့ပေးပါ။ ဆက်တင် 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 e227790588cc..0b5f8b9e9f85 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 @@ Dette er kontonummeret ditt. Ta vare på det! Skjul kontonummer Standard + Importer + Importer via tekst Inngående Egendefinerte DNS-serveradresser %1$s er ugyldige Ugyldig kupongkode. @@ -213,6 +215,9 @@ Sendt Vi vil kontakte deg på %1$s ved behov Takk! + På enkelte nettverk der det brukes ulike typer sensur, kan server-IP-adressene av og til være blokkerte. + 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». + Hvis du har mistet tilkoblingen til VPN-serverne, kan du ta kontak med kundestøtten. Kunne ikke angi DNS-server for systemet. Send inn en problemrapport. Kunne ikke bruke brannmur-regler. Feilsøk eller send inn en problemrapport. Innstillinger 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 eeae7d271815..e6b986ead818 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 @@ Hier is uw accountnummer. Sla het op! Accountnummer verbergen Standaard + Importeren + Importeren via tekst In Aangepaste DNS-serveradressen %1$s zijn ongeldig Vouchercode is ongeldig. @@ -213,6 +215,9 @@ Verzonden Indien nodig nemen we u contact op via %1$s Bedankt! + Op sommige netwerken, waar verschillende soorten censuur worden gebruikt, worden onze server-IP-adressen soms geblokkeerd. + 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. + Als u problemen hebt met het verbinden met VPN-servers, neem dan contact op met de ondersteuning. Kan DNS-server van systeem niet instellen. Stuur een probleemrapport. Kan firewallregels niet toepassen. Los problemen op of stuur een probleemmelding. Instellingen 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 c03157ab0b95..3b12b61b5ed1 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 @@ Oto Twój numer konta. Zachowaj go! Ukryj numer konta Domyślnie + Importuj + Import tekstowy Wejście Niestandardowe adresy serwerów DNS %1$s są nieprawidłowe Nieprawidłowy kod kuponu. @@ -213,6 +215,9 @@ Wysłano W razie potrzeby skontaktujemy się z Tobą pod adresem %1$s Dziękujemy! + W niektórych sieciach, w których stosowane są różnego rodzaju cenzury, adresy IP naszych serwerów są czasami blokowane. + 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ę. + Jeśli masz problemy z łączeniem się z serwerami VPN, skontaktuj się z pomocą techniczną. Nie można ustawić systemowego serwera DNS. Wyślij zgłoszenie problemu. Nie można zastosować reguł zapory. Rozwiąż problem lub wyślij zgłoszenie problemu. Ustawienia 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 ddecdfde521a..b4dd272f0d7d 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 @@ Aqui tem o seu número de conta. Guarde-o! Ocultar número de conta Padrão + Importar + Importar através de texto Entrada Os endereços do servidor DNS personalizado %1$s são inválidos Código do voucher inválido. @@ -213,6 +215,9 @@ Enviado Se necessário, iremos contactá-lo através de %1$s Obrigado! + Em algumas redes, onde são utilizados vários tipos de censura, os endereços IP dos nossos servidores são por vezes bloqueados. + 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. + Se tiver problemas em ligar-se aos servidores VPN, contacte o apoio. Não foi possível definir o servidor DNS do sistema. Envie um relatório do problema. Não foi possível aplicar as regras de firewall. Experimente a resolução de problemas ou envie um relatório do problema. Definições 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 088ad7625ff3..b0d7b1b63ea6 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 @@ Вот номер вашей учетной записи. Сохраните его! Скрыть номер учетной записи По умолчанию + Импортировать + Импортировать через текст Вход Пользовательские адреса DNS-серверов %1$s недопустимы Код ваучера недействителен. @@ -213,6 +215,9 @@ Отправлено При необходимости мы свяжемся с вами по адресу %1$s Спасибо! + В некоторых сетях, где используются различные виды цензуры, IP-адреса наших серверов иногда блокируются. + Чтобы обойти эту проблему, можно импортировать файл или текст, предоставленный нашей службой поддержки, с новыми IP-адресами, которые заменяют адреса серверов по умолчанию в представлении «Выбор местоположения». + Если у вас возникли проблемы с подключением к VPN-серверам, обратитесь в службу поддержки. Не удалось установить системный DNS-сервер. Отправьте сообщение о проблеме. Невозможно применить правила брандмауэра. Устраните неполадки или отправьте сообщение о проблеме. Настройки 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 f5a5012025d6..cebf01b315db 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 @@ Här är ditt kontonummer. Spara det! Dölj kontonummer Standard + Importera + Importera via text In Anpassade DNS-serveradresser %1$s är ogiltiga Kupongkoden är ogiltig. @@ -213,6 +215,9 @@ Skickat Om det behövs kontaktar vi dig på %1$s Tack! + På vissa nätverk där olika typer av censureringar används blockeras blir ibland vår servers IP-adresser blockerade. + 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. + Kontakta supporten om du har problem med att ansluta till VPN-servrar. Det går inte att konfigurera DNS-server. Skicka en problemrapport. Det går inte att tillämpa brandväggsregler. Felsök eller skicka en problemrapport. Inställningar 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 935ca673e43c..02961f67d7e3 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 @@ นี่คือหมายเลขบัญชีของคุณ จดบันทึกไว้ด้วยนะ! ซ่อนหมายเลขบัญชี ค่าเริ่มต้น + นำเข้า + นำเข้าผ่านข้อความ เข้า ที่อยู่เซิร์ฟเวอร์ DNS %1$s ที่กำหนดเองไม่ถูกต้อง รหัสบัตรกำนัลไม่ถูกต้อง @@ -213,6 +215,9 @@ ส่ง เราจะติดต่อคุณไปทาง %1$s ในกรณีจำเป็น ขอบคุณ! + บางครั้งที่อยู่ IP เซิร์ฟเวอร์ของเราอาจถูกบล็อก ในบางเครือข่ายที่มีการใช้งานเซ็นเซอร์หลายประเภท + ในการหลีกเลี่ยงปัญหานี้ คุณสามารถนำเข้าไฟล์หรือข้อความที่ได้รับจากทีมสนับสนุนของเรา พร้อมด้วยที่อยู่ IP ใหม่ที่โอเวอร์ไรด์ที่อยู่เริ่มต้นของเซิร์ฟเวอร์ในมุมมองเลือกตำแหน่งที่ตั้ง + หากคุณประสบปัญหาในการเชื่อมต่อกับเซิร์ฟเวอร์ VPN โปรดติดต่อฝ่ายสนับสนุน ไม่สามารถตั้งค่าเซิร์ฟเวอร์ DNS ของระบบได้ โปรดส่งรายงานปัญหา ไม่สามารถใช้กฎไฟร์วอลล์ได้ โปรดแก้ไขปัญหา หรือส่งรายงานปัญหา การตั้งค่า 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 4d3a0e4b456a..45f8ac0eb484 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 @@ İşte hesap numaranız. Kaydedin! Hesap numarasını gizle Varsayılan + İçe aktar + Metin yoluyla içe aktar Giriş Özel DNS sunucu adresleri (%1$s) geçersiz Kupon kodu geçersiz. @@ -213,6 +215,9 @@ Gönderildi Gerektiğinde sizinle %1$s adresinden iletişime geçeceğiz Teşekkürler! + Farklı sansür türlerinin kullanıldığı bazı ağlarda sunucu IP adreslerimiz zaman zaman engellenir. + 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. + VPN sunucularına bağlanırken sorun yaşıyorsanız lütfen destek ekibiyle iletişime geçin. Sistem DNS sunucusu ayarlanamıyor. Lütfen bir hata raporu gönderin. Güvenlik duvarı kuralları uygulanamıyor. Lütfen sorunu çözmeye çalışın veya bir hata raporu gönderin. Ayarlar 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 f9e3e30ea6f5..d5c4c3dd1c0a 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 @@ 以下是您的帐号。请妥善保存! 隐藏帐号 默认 + 导入 + 通过文本导入 内部 自定义 DNS 服务器地址 %1$s 无效 该优惠券码无效。 @@ -213,6 +215,9 @@ 已发送 如果需要,我们将通过 %1$s 与您联系 谢谢! + 在某些使用各类审查的网络上,我们的服务器 IP 地址有时会被阻止。 + 为了避免这种情况,您可以导入由我们的支持团队提供的文件或文本,其中的新 IP 地址会覆盖“选择位置”视图中服务器的默认地址。 + 如果您在连接到 VPN 服务器时遇到问题,请联系支持团队。 无法设置系统 DNS 服务器。请发送问题报告。 无法应用防火墙规则。请排查问题或发送问题报告。 设置 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 a250e018e440..765dcecc4c97 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 @@ 以下是您的帳號。請妥善保管! 隱藏帳號 預設 + 匯入 + 透過文字匯入 入境 自訂 DNS 伺服器位址 %1$s 無效 憑證兌換碼無效。 @@ -213,6 +215,9 @@ 已傳送 如有需要,我們將透過 %1$s 與您聯絡 謝謝! + 在某些採用了各類審查功能的網路上,我們的伺服器 IP 位址有時會遭到封鎖。 + 為了避免這種情況,您可以匯入由我們支援團隊所提供的檔案或文字,其中的新 IP 位址會覆蓋「選取位置」視圖中伺服器的預設位址。 + 如果您在連線至 VPN 伺服器時遇到問題,請聯絡支援人員。 無法設定系統 DNS 伺服器。請傳送問題回報。 無法套用防火牆規則。請排除故障或傳送問題回報。 設定 diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index 88c38adc7fea..e8aaa197cdb8 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 @@ Name was changed to %s Locations were changed for \"%s\" Not found + Import + Server Ip overrides + Overrides active + Overrides inactive + On some networks, where various types of censorship are being used, our server IP addresses are sometimes blocked. + 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. + If you are having issues connecting to VPN servers, please contact support. + Reset overrides + Reset all overrides + All overrides will be reset and servers IP addresses, in the Select location view, will go back to default. + Reset + Import new overrides by + File + Text + Import + Paste or write overrides to be imported + Import via text + Importing new overrides might replace some previously imported overrides. + Patch not matching specification + Invalid or missing value: \'%1$s\' + Unable to parse patch + Unknown or prohibited key \'%1$s\' + Failed to apply patch + Recursion limit + Success + Overrides cleared 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 f99d36c6790b..1d87987cf3de 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 000000000000..65d7b6cff0b1 --- /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() + .collect { applyJsonSettings(it.json) } + } + + scope.launch { + endpoint.dispatcher.parsedMessages + .filterIsInstance() + .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 000000000000..cda7a5b94b45 --- /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() + .collect { daemon.await().setRelayOverride(it.override) } + } + + scope.launch { + endpoint.dispatcher.parsedMessages + .filterIsInstance() + .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 5485c528b079..f8fc6aaf6452 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 58acfd3b5175..10cd401b4cc4 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,18 @@ 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 "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 +2223,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 Always-on system setting instead by following the guide in %s above." msgstr "" @@ -2217,18 +2250,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 +2298,12 @@ msgstr "" msgid "Submit" msgstr "" +msgid "Success" +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 %s 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 969c7a505743..fb11412c26c7 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 66cc8c3eb273..c7e810621914 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(#[source] patch::Error), + #[error("Daemon returned an error")] Other(#[source] mullvad_daemon::Error), } @@ -49,6 +52,12 @@ impl From for Error { } } +impl From for Error { + fn from(error: patch::Error) -> Error { + Error::Patch(error) + } +} + type Result = std::result::Result; pub struct DaemonInterface { @@ -384,6 +393,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 { + 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 9139f1f435ed..9264b5e8957b 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::{ @@ -190,6 +191,46 @@ impl From for VoucherSubmissionError { } } +#[derive(IntoJava)] +#[jnix(package = "net.mullvad.mullvadvpn.model")] +pub enum SettingsPatchError { + InvalidOrMissingValue(String), + UnknownOrProhibitedKey(String), + ParsePatch, + DeserializePatched, + RecursionLimit, + ApplyPatch, +} + +impl From 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 { @@ -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 b37c3ea0a1c5..d0a42e230359 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 b8fec8de2f10..c0048a7e6214 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, /// Whether to notify users of beta updates. pub show_beta_releases: bool,