summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson@mullvad.net>2024-03-19 15:04:42 +0100
committerDavid Göransson <david.goransson@mullvad.net>2024-03-19 16:15:43 +0100
commit7791177cbbda3a23c7bb908784352c9deb6e8aeb (patch)
treef283c0c88c43a2cdab1a86303555c1d648dbf7c9 /android
parent1c1fb51bde0bb59963292441935f60eab91b6d03 (diff)
downloadmullvadvpn-7791177cbbda3a23c7bb908784352c9deb6e8aeb.tar.xz
mullvadvpn-7791177cbbda3a23c7bb908784352c9deb6e8aeb.zip
Add Server IP Overrides UI
Diffstat (limited to 'android')
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/InfoIconButton.kt26
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ServerIpOverridesCell.kt82
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIpOverridesConfirmationDialog.kt85
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ServerIpOverridesInfoDialog.kt32
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ImportOverridesByTextScreen.kt92
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt50
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt351
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt15
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt13
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightLeafTransition.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Navigation.kt17
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Snackbar.kt19
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModel.kt25
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt89
-rw-r--r--android/lib/resource/src/main/res/drawable/icon_text_fields.xml9
-rw-r--r--android/lib/resource/src/main/res/drawable/icon_upload_file.xml9
21 files changed, 947 insertions, 34 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/InfoIconButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/InfoIconButton.kt
new file mode 100644
index 0000000000..5c28069c52
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/InfoIconButton.kt
@@ -0,0 +1,26 @@
+package net.mullvad.mullvadvpn.compose.button
+
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import net.mullvad.mullvadvpn.R
+
+@Composable
+fun InfoIconButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ contentDescription: String? = null,
+ iconTint: Color = MaterialTheme.colorScheme.onPrimary
+) {
+ IconButton(modifier = modifier, onClick = onClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.icon_info),
+ contentDescription = contentDescription,
+ tint = iconTint
+ )
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt
index faf537fb7f..3b68e42e45 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt
@@ -25,13 +25,14 @@ private fun PreviewIconCell() {
@Composable
fun IconCell(
iconId: Int?,
- contentDescription: String? = null,
title: String,
+ modifier: Modifier = Modifier,
+ contentDescription: String? = null,
titleStyle: TextStyle = MaterialTheme.typography.labelLarge,
titleColor: Color = MaterialTheme.colorScheme.onPrimary,
onClick: () -> Unit = {},
background: Color = MaterialTheme.colorScheme.primary,
- enabled: Boolean = true,
+ enabled: Boolean = true
) {
BaseCell(
headlineContent = {
@@ -49,6 +50,7 @@ fun IconCell(
},
onCellClicked = onClick,
background = background,
- isRowEnabled = enabled
+ isRowEnabled = enabled,
+ modifier = modifier
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ServerIpOverridesCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ServerIpOverridesCell.kt
new file mode 100644
index 0000000000..acd785e1c3
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ServerIpOverridesCell.kt
@@ -0,0 +1,82 @@
+package net.mullvad.mullvadvpn.compose.cell
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorSmall
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible
+import net.mullvad.mullvadvpn.lib.theme.color.selected
+
+@Preview
+@Composable
+private fun PreviewServerIpOverridesCell() {
+ AppTheme { ServerIpOverridesCell(active = true) }
+}
+
+@Composable
+fun ServerIpOverridesCell(
+ active: Boolean?,
+ modifier: Modifier = Modifier,
+ activeColor: Color = MaterialTheme.colorScheme.selected,
+ inactiveColor: Color = MaterialTheme.colorScheme.error,
+) {
+ BaseCell(
+ modifier = modifier,
+ iconView = {
+ if (active == null) {
+ MullvadCircularProgressIndicatorSmall()
+ } else {
+ Box(
+ modifier =
+ Modifier.size(Dimens.relayCircleSize)
+ .background(
+ color =
+ when {
+ active -> activeColor
+ else -> inactiveColor
+ },
+ shape = CircleShape
+ )
+ )
+ }
+ },
+ headlineContent = {
+ if (active != null) {
+ Text(
+ text =
+ if (active) stringResource(id = R.string.server_ip_overrides_active)
+ else stringResource(id = R.string.server_ip_overrides_inactive),
+ color = MaterialTheme.colorScheme.onPrimary,
+ modifier =
+ Modifier.weight(1f)
+ .alpha(
+ if (active) {
+ AlphaVisible
+ } else {
+ AlphaInactive
+ }
+ )
+ .padding(
+ horizontal = Dimens.smallPadding,
+ vertical = Dimens.mediumPadding
+ )
+ )
+ }
+ },
+ isRowEnabled = false
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt
index 1f8fb46cd7..edd697dfec 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt
@@ -37,7 +37,7 @@ private fun PreviewMullvadModalBottomSheet() {
title = "Select",
)
},
- closeBottomSheet = {}
+ onDismissRequest = {}
)
}
}
@@ -49,13 +49,13 @@ fun MullvadModalBottomSheet(
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer,
onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface,
- closeBottomSheet: () -> Unit,
+ onDismissRequest: () -> Unit,
sheetContent: @Composable ColumnScope.() -> Unit
) {
// This is to avoid weird colors in the status bar and the navigation bar
val paddingValues = BottomSheetDefaults.windowInsets.asPaddingValues()
ModalBottomSheet(
- onDismissRequest = closeBottomSheet,
+ onDismissRequest = onDismissRequest,
sheetState = sheetState,
containerColor = backgroundColor,
modifier = modifier,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt
index b9a6306413..585855cb1d 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt
@@ -107,8 +107,9 @@ fun ScaffoldWithTopBarAndDeviceName(
}
@Composable
-fun MullvadSnackbar(snackbarData: SnackbarData) {
+fun MullvadSnackbar(modifier: Modifier = Modifier, snackbarData: SnackbarData) {
Snackbar(
+ modifier = modifier,
snackbarData = snackbarData,
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIpOverridesConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIpOverridesConfirmationDialog.kt
new file mode 100644
index 0000000000..c90c22ead4
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIpOverridesConfirmationDialog.kt
@@ -0,0 +1,85 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.result.ResultBackNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.NegativeButton
+import net.mullvad.mullvadvpn.compose.button.PrimaryButton
+import net.mullvad.mullvadvpn.compose.test.RESET_SERVER_IP_OVERRIDE_CANCEL_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.RESET_SERVER_IP_OVERRIDE_RESET_TEST_TAG
+import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.viewmodel.ResetServerIpOverridesConfirmationUiSideEffect
+import net.mullvad.mullvadvpn.viewmodel.ResetServerIpOverridesConfirmationViewModel
+import org.koin.androidx.compose.koinViewModel
+
+@Preview
+@Composable
+private fun PreviewResetServerIpOverridesConfirmationDialog() {
+ AppTheme { ResetServerIpOverridesConfirmationDialog({}, {}) }
+}
+
+@Destination(style = DestinationStyle.Dialog::class)
+@Composable
+fun ResetServerIpOverridesConfirmation(resultBackNavigator: ResultBackNavigator<Boolean>) {
+ val vm: ResetServerIpOverridesConfirmationViewModel = koinViewModel()
+ CollectSideEffectWithLifecycle(vm.uiSideEffect) {
+ when (it) {
+ ResetServerIpOverridesConfirmationUiSideEffect.OverridesCleared ->
+ resultBackNavigator.navigateBack(result = true)
+ }
+ }
+ ResetServerIpOverridesConfirmationDialog(
+ onClearAllOverrides = vm::clearAllOverrides,
+ resultBackNavigator::navigateBack
+ )
+}
+
+@Composable
+fun ResetServerIpOverridesConfirmationDialog(
+ onClearAllOverrides: () -> Unit,
+ onNavigateBack: () -> Unit
+) {
+ AlertDialog(
+ containerColor = MaterialTheme.colorScheme.background,
+ confirmButton = {
+ NegativeButton(
+ modifier = Modifier.fillMaxWidth().testTag(RESET_SERVER_IP_OVERRIDE_RESET_TEST_TAG),
+ text = stringResource(id = R.string.server_ip_overrides_reset_reset_button),
+ onClick = onClearAllOverrides
+ )
+ },
+ dismissButton = {
+ PrimaryButton(
+ modifier =
+ Modifier.fillMaxWidth().testTag(RESET_SERVER_IP_OVERRIDE_CANCEL_TEST_TAG),
+ text = stringResource(R.string.cancel),
+ onClick = onNavigateBack
+ )
+ },
+ title = {
+ Text(
+ text = stringResource(id = R.string.server_ip_overrides_reset_title),
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ },
+ text = {
+ Text(
+ text = stringResource(id = R.string.server_ip_overrides_reset_body),
+ color = MaterialTheme.colorScheme.onBackground,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ },
+ onDismissRequest = onNavigateBack
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ServerIpOverridesInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ServerIpOverridesInfoDialog.kt
new file mode 100644
index 0000000000..9b6054f1f0
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ServerIpOverridesInfoDialog.kt
@@ -0,0 +1,32 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import net.mullvad.mullvadvpn.R
+
+@Preview
+@Composable
+private fun PreviewServerIpOverridesInfoDialog() {
+ ServerIpOverridesInfoDialog(EmptyDestinationsNavigator)
+}
+
+@Destination(style = DestinationStyle.Dialog::class)
+@Composable
+fun ServerIpOverridesInfoDialog(navigator: DestinationsNavigator) {
+ InfoDialog(
+ message =
+ buildString {
+ appendLine(stringResource(id = R.string.server_ip_overrides_info_first_paragraph))
+ appendLine()
+ appendLine(stringResource(id = R.string.server_ip_overrides_info_second_paragraph))
+ appendLine()
+ append(stringResource(id = R.string.server_ip_overrides_info_third_paragraph))
+ },
+ onDismiss = navigator::navigateUp
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ImportOverridesByTextScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ImportOverridesByTextScreen.kt
new file mode 100644
index 0000000000..7ab063703c
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ImportOverridesByTextScreen.kt
@@ -0,0 +1,92 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.result.ResultBackNavigator
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.component.MullvadSmallTopBar
+import net.mullvad.mullvadvpn.compose.textfield.mullvadWhiteTextFieldColors
+import net.mullvad.mullvadvpn.compose.transitions.DefaultTransition
+
+@Preview
+@Composable
+private fun PreviewImportOverridesByText() {
+ ImportOverridesByTextScreen({}, {})
+}
+
+@Destination(style = DefaultTransition::class)
+@Composable
+fun ImportOverridesByText(
+ resultNavigator: ResultBackNavigator<String>,
+) {
+ ImportOverridesByTextScreen(
+ onNavigateBack = resultNavigator::navigateBack,
+ onImportClicked = { resultNavigator.navigateBack(result = it) }
+ )
+}
+
+@Composable
+fun ImportOverridesByTextScreen(
+ onNavigateBack: () -> Unit,
+ onImportClicked: (String) -> Unit,
+) {
+ var text by remember { mutableStateOf("") }
+
+ Scaffold(
+ topBar = {
+ MullvadSmallTopBar(
+ title = stringResource(R.string.import_overrides_text_title),
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(imageVector = Icons.Default.Close, contentDescription = null)
+ }
+ },
+ actions = {
+ TextButton(
+ enabled = text.isNotEmpty(),
+ colors =
+ ButtonDefaults.textButtonColors()
+ .copy(contentColor = MaterialTheme.colorScheme.onPrimary),
+ onClick = { onImportClicked(text) }
+ ) {
+ Text(
+ text = stringResource(R.string.import_overrides_import),
+ )
+ }
+ }
+ )
+ },
+ ) {
+ Column(modifier = Modifier.padding(it)) {
+ TextField(
+ modifier = Modifier.fillMaxSize(),
+ value = text,
+ onValueChange = { text = it },
+ placeholder = {
+ Text(text = stringResource(R.string.import_override_textfield_placeholder))
+ },
+ colors = mullvadWhiteTextFieldColors()
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt
index 594c657cdb..a7e802e89c 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt
@@ -545,7 +545,7 @@ private fun CustomListsBottomSheet(
) {
MullvadModalBottomSheet(
sheetState = sheetState,
- closeBottomSheet = { closeBottomSheet(false) },
+ onDismissRequest = { closeBottomSheet(false) },
modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG)
) { ->
HeaderCell(
@@ -556,21 +556,16 @@ private fun CustomListsBottomSheet(
IconCell(
iconId = R.drawable.icon_add,
title = stringResource(id = R.string.new_list),
+ titleColor = onBackgroundColor,
onClick = {
onCreateCustomList()
closeBottomSheet(true)
},
- background = Color.Unspecified,
- titleColor = onBackgroundColor
+ background = Color.Unspecified
)
IconCell(
iconId = R.drawable.icon_edit,
title = stringResource(id = R.string.edit_lists),
- onClick = {
- onEditCustomLists()
- closeBottomSheet(true)
- },
- background = Color.Unspecified,
titleColor =
onBackgroundColor.copy(
alpha =
@@ -580,6 +575,11 @@ private fun CustomListsBottomSheet(
AlphaInactive
}
),
+ onClick = {
+ onEditCustomLists()
+ closeBottomSheet(true)
+ },
+ background = Color.Unspecified,
enabled = bottomSheetState.editListEnabled
)
}
@@ -598,7 +598,7 @@ private fun LocationBottomSheet(
) {
MullvadModalBottomSheet(
sheetState = sheetState,
- closeBottomSheet = { closeBottomSheet(false) },
+ onDismissRequest = { closeBottomSheet(false) },
modifier = Modifier.testTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG)
) { ->
HeaderCell(
@@ -609,13 +609,6 @@ private fun LocationBottomSheet(
customLists.forEach {
val enabled = it.canAddLocation(item)
IconCell(
- background = Color.Unspecified,
- titleColor =
- if (enabled) {
- onBackgroundColor
- } else {
- MaterialTheme.colorScheme.onSecondary
- },
iconId = null,
title =
if (enabled) {
@@ -623,22 +616,29 @@ private fun LocationBottomSheet(
} else {
stringResource(id = R.string.location_added, it.name)
},
+ titleColor =
+ if (enabled) {
+ onBackgroundColor
+ } else {
+ MaterialTheme.colorScheme.onSecondary
+ },
onClick = {
onAddLocationToList(item, it)
closeBottomSheet(true)
},
+ background = Color.Unspecified,
enabled = enabled
)
}
IconCell(
iconId = R.drawable.icon_add,
title = stringResource(id = R.string.new_list),
+ titleColor = onBackgroundColor,
onClick = {
onCreateCustomList(item)
closeBottomSheet(true)
},
- background = Color.Unspecified,
- titleColor = onBackgroundColor
+ background = Color.Unspecified
)
}
}
@@ -656,39 +656,39 @@ private fun EditCustomListBottomSheet(
) {
MullvadModalBottomSheet(
sheetState = sheetState,
- closeBottomSheet = { closeBottomSheet(false) }
+ onDismissRequest = { closeBottomSheet(false) }
) {
HeaderCell(text = customList.name, background = Color.Unspecified)
IconCell(
iconId = R.drawable.icon_edit,
title = stringResource(id = R.string.edit_name),
+ titleColor = onBackgroundColor,
onClick = {
onEditName(customList)
closeBottomSheet(true)
},
- background = Color.Unspecified,
- titleColor = onBackgroundColor
+ background = Color.Unspecified
)
IconCell(
iconId = R.drawable.icon_add,
title = stringResource(id = R.string.edit_locations),
+ titleColor = onBackgroundColor,
onClick = {
onEditLocations(customList)
closeBottomSheet(true)
},
- background = Color.Unspecified,
- titleColor = onBackgroundColor
+ background = Color.Unspecified
)
HorizontalDivider(color = onBackgroundColor)
IconCell(
iconId = R.drawable.icon_delete,
title = stringResource(id = R.string.delete),
+ titleColor = onBackgroundColor,
onClick = {
onDeleteCustomList(customList)
closeBottomSheet(true)
},
- background = Color.Unspecified,
- titleColor = onBackgroundColor
+ background = Color.Unspecified
)
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt
new file mode 100644
index 0000000000..33b8419b9c
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt
@@ -0,0 +1,351 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import android.content.Context
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MenuDefaults
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.result.ResultRecipient
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.InfoIconButton
+import net.mullvad.mullvadvpn.compose.button.PrimaryButton
+import net.mullvad.mullvadvpn.compose.cell.HeaderCell
+import net.mullvad.mullvadvpn.compose.cell.IconCell
+import net.mullvad.mullvadvpn.compose.cell.ServerIpOverridesCell
+import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomSheet
+import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar
+import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
+import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
+import net.mullvad.mullvadvpn.compose.destinations.ImportOverridesByTextDestination
+import net.mullvad.mullvadvpn.compose.destinations.ResetServerIpOverridesConfirmationDestination
+import net.mullvad.mullvadvpn.compose.destinations.ServerIpOverridesInfoDialogDestination
+import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDES_IMPORT_BY_FILE_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDES_IMPORT_BY_TEXT_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDE_IMPORT_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDE_INFO_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDE_MORE_VERT_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDE_RESET_OVERRIDES_TEST_TAG
+import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightLeafTransition
+import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect
+import net.mullvad.mullvadvpn.compose.util.OnNavResultValue
+import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled
+import net.mullvad.mullvadvpn.model.SettingsPatchError
+import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesUiSideEffect
+import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesViewModel
+import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesViewState
+import org.koin.androidx.compose.koinViewModel
+
+@Preview
+@Composable
+private fun PreviewServerIpOverridesScreen() {
+ AppTheme {
+ ServerIpOverridesScreen(
+ ServerIpOverridesViewState.Loaded(false),
+ onBackClick = {},
+ onInfoClick = {},
+ onResetOverridesClick = {},
+ onImportByFile = {},
+ onImportByText = {},
+ SnackbarHostState()
+ )
+ }
+}
+
+@Destination(style = SlideInFromRightLeafTransition::class)
+@Composable
+fun ServerIpOverrides(
+ navigator: DestinationsNavigator,
+ importByTextResult: ResultRecipient<ImportOverridesByTextDestination, String>,
+ clearOverridesResult: ResultRecipient<ResetServerIpOverridesConfirmationDestination, Boolean>,
+) {
+ val vm = koinViewModel<ServerIpOverridesViewModel>()
+ val state by vm.uiState.collectAsStateWithLifecycle()
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ val context = LocalContext.current
+ LaunchedEffectCollect(vm.uiSideEffect) { sideEffect ->
+ when (sideEffect) {
+ is ServerIpOverridesUiSideEffect.ImportResult ->
+ snackbarHostState.showSnackbarImmediately(
+ this,
+ message = sideEffect.error.toString(context),
+ actionLabel = null
+ )
+ }
+ }
+
+ importByTextResult.OnNavResultValue(vm::importText)
+
+ // On successful clear of overrides, show snackbar
+ val scope = rememberCoroutineScope()
+ clearOverridesResult.OnNavResultValue {
+ scope.launch {
+ snackbarHostState.showSnackbarImmediately(
+ this,
+ message = context.getString(R.string.overrides_cleared),
+ actionLabel = null
+ )
+ }
+ }
+
+ val openFileLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
+ if (it != null) {
+ vm.importFile(it)
+ }
+ }
+
+ ServerIpOverridesScreen(
+ state,
+ onBackClick = navigator::navigateUp,
+ onInfoClick = {
+ navigator.navigate(ServerIpOverridesInfoDialogDestination, onlyIfResumed = true)
+ },
+ onResetOverridesClick = {
+ navigator.navigate(ResetServerIpOverridesConfirmationDestination, onlyIfResumed = true)
+ },
+ onImportByFile = { openFileLauncher.launch("application/json") },
+ onImportByText = {
+ navigator.navigate(ImportOverridesByTextDestination, onlyIfResumed = true)
+ },
+ snackbarHostState
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ServerIpOverridesScreen(
+ state: ServerIpOverridesViewState,
+ onBackClick: () -> Unit,
+ onInfoClick: () -> Unit,
+ onResetOverridesClick: () -> Unit,
+ onImportByFile: () -> Unit,
+ onImportByText: () -> Unit,
+ snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
+) {
+
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ var showBottomSheet by remember { mutableStateOf(false) }
+
+ ScaffoldWithMediumTopBar(
+ appBarTitle = stringResource(id = R.string.server_ip_overrides),
+ navigationIcon = { NavigateBackIconButton(onBackClick) },
+ actions = {
+ TopBarActions(
+ overridesActive = state.overridesActive,
+ onInfoClick = onInfoClick,
+ onResetOverridesClick = onResetOverridesClick
+ )
+ }
+ ) { modifier ->
+ if (showBottomSheet && state.overridesActive != null) {
+ ImportOverridesByBottomSheet(
+ sheetState,
+ { showBottomSheet = it },
+ state.overridesActive!!,
+ onImportByFile,
+ onImportByText
+ )
+ }
+
+ Column(
+ modifier = modifier.animateContentSize(),
+ ) {
+ ServerIpOverridesCell(active = state.overridesActive)
+
+ Spacer(modifier = Modifier.weight(1f))
+ PrimaryButton(
+ onClick = { showBottomSheet = true },
+ text = stringResource(R.string.server_ip_overrides_import_button),
+ modifier =
+ Modifier.padding(horizontal = Dimens.sideMargin)
+ .padding(bottom = Dimens.screenVerticalMargin)
+ .testTag(SERVER_IP_OVERRIDE_IMPORT_TEST_TAG),
+ )
+ SnackbarHost(hostState = snackbarHostState, modifier = Modifier.animateContentSize()) {
+ MullvadSnackbar(snackbarData = it)
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ImportOverridesByBottomSheet(
+ sheetState: SheetState,
+ showBottomSheet: (Boolean) -> Unit,
+ overridesActive: Boolean,
+ onImportByFile: () -> Unit,
+ onImportByText: () -> Unit
+) {
+ val scope = rememberCoroutineScope()
+ val onCloseSheet = {
+ scope
+ .launch { sheetState.hide() }
+ .invokeOnCompletion {
+ if (!sheetState.isVisible) {
+ showBottomSheet(false)
+ }
+ }
+ }
+
+ MullvadModalBottomSheet(
+ sheetState = sheetState,
+ onDismissRequest = { showBottomSheet(false) },
+ ) { ->
+ HeaderCell(
+ text = stringResource(id = R.string.server_ip_overrides_import_by),
+ background = Color.Unspecified
+ )
+ HorizontalDivider(color = MaterialTheme.colorScheme.onBackground)
+ IconCell(
+ iconId = R.drawable.icon_upload_file,
+ title = stringResource(id = R.string.server_ip_overrides_import_by_file),
+ onClick = {
+ onImportByFile()
+ onCloseSheet()
+ },
+ background = Color.Unspecified,
+ modifier = Modifier.testTag(SERVER_IP_OVERRIDES_IMPORT_BY_FILE_TEST_TAG)
+ )
+ IconCell(
+ iconId = R.drawable.icon_text_fields,
+ title = stringResource(id = R.string.server_ip_overrides_import_by_text),
+ onClick = {
+ onImportByText()
+ onCloseSheet()
+ },
+ background = Color.Unspecified,
+ modifier = Modifier.testTag(SERVER_IP_OVERRIDES_IMPORT_BY_TEXT_TEST_TAG)
+ )
+ if (overridesActive) {
+ HorizontalDivider(color = MaterialTheme.colorScheme.onBackground)
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ modifier = Modifier.padding(Dimens.mediumPadding),
+ painter = painterResource(id = R.drawable.icon_info),
+ tint = MaterialTheme.colorScheme.errorContainer,
+ contentDescription = null
+ )
+ Text(
+ modifier =
+ Modifier.padding(
+ top = Dimens.smallPadding,
+ end = Dimens.mediumPadding,
+ bottom = Dimens.smallPadding
+ ),
+ text = stringResource(R.string.import_overrides_bottom_sheet_override_warning),
+ maxLines = 2,
+ style = MaterialTheme.typography.bodySmall,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TopBarActions(
+ overridesActive: Boolean?,
+ onInfoClick: () -> Unit,
+ onResetOverridesClick: () -> Unit
+) {
+ var showMenu by remember { mutableStateOf(false) }
+ InfoIconButton(
+ onClick = onInfoClick,
+ modifier = Modifier.testTag(SERVER_IP_OVERRIDE_INFO_TEST_TAG)
+ )
+ IconButton(
+ onClick = { showMenu = !showMenu },
+ modifier = Modifier.testTag(SERVER_IP_OVERRIDE_MORE_VERT_TEST_TAG)
+ ) {
+ Icon(painterResource(id = R.drawable.icon_more_vert), contentDescription = null)
+ }
+ DropdownMenu(
+ modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer),
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.server_ip_overrides_reset)) },
+ onClick = {
+ showMenu = false
+ onResetOverridesClick()
+ },
+ enabled = overridesActive ?: false,
+ colors =
+ MenuDefaults.itemColors(
+ leadingIconColor = MaterialTheme.colorScheme.onPrimary,
+ disabledLeadingIconColor =
+ MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaDisabled)
+ ),
+ leadingIcon = {
+ Icon(
+ Icons.Filled.Delete,
+ contentDescription = null,
+ )
+ },
+ modifier = Modifier.testTag(SERVER_IP_OVERRIDE_RESET_OVERRIDES_TEST_TAG)
+ )
+ }
+}
+
+private fun SettingsPatchError?.toString(context: Context) =
+ when (this) {
+ SettingsPatchError.DeserializePatched ->
+ context.getString(R.string.patch_not_matching_specification)
+ is SettingsPatchError.InvalidOrMissingValue ->
+ context.getString(R.string.settings_patch_error_invalid_or_missing_value, value)
+ SettingsPatchError.ParsePatch ->
+ context.getString(R.string.settings_patch_error_unable_to_parse)
+ is SettingsPatchError.UnknownOrProhibitedKey ->
+ context.getString(R.string.settings_patch_error_unknown_or_prohibited_key, value)
+ SettingsPatchError.ApplyPatch ->
+ context.getString(R.string.settings_patch_error_failed_to_apply_patch)
+ SettingsPatchError.RecursionLimit ->
+ context.getString(R.string.settings_patch_error_recursion_limit)
+ null -> context.getString(R.string.settings_patch_success)
+ }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt
index bd8809b00f..e926e2e97f 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt
@@ -61,6 +61,7 @@ import net.mullvad.mullvadvpn.compose.destinations.MalwareInfoDialogDestination
import net.mullvad.mullvadvpn.compose.destinations.MtuDialogDestination
import net.mullvad.mullvadvpn.compose.destinations.ObfuscationInfoDialogDestination
import net.mullvad.mullvadvpn.compose.destinations.QuantumResistanceInfoDialogDestination
+import net.mullvad.mullvadvpn.compose.destinations.ServerIpOverridesDestination
import net.mullvad.mullvadvpn.compose.destinations.UdpOverTcpPortInfoDialogDestination
import net.mullvad.mullvadvpn.compose.destinations.WireguardCustomPortDialogDestination
import net.mullvad.mullvadvpn.compose.destinations.WireguardPortInfoDialogDestination
@@ -219,6 +220,9 @@ fun VpnSettings(
navigateToLocalNetworkSharingInfo = {
navigator.navigate(LocalNetworkSharingInfoDialogDestination) { launchSingleTop = true }
},
+ navigateToServerIpOverrides = {
+ navigator.navigate(ServerIpOverridesDestination) { launchSingleTop = true }
+ },
onToggleBlockTrackers = vm::onToggleBlockTrackers,
onToggleBlockAds = vm::onToggleBlockAds,
onToggleBlockMalware = vm::onToggleBlockMalware,
@@ -267,6 +271,7 @@ fun VpnSettingsScreen(
navigateToWireguardPortInfo: (availablePortRanges: List<PortRange>) -> Unit = {},
navigateToLocalNetworkSharingInfo: () -> Unit = {},
navigateToWireguardPortDialog: () -> Unit = {},
+ navigateToServerIpOverrides: () -> Unit = {},
onToggleBlockTrackers: (Boolean) -> Unit = {},
onToggleBlockAds: (Boolean) -> Unit = {},
onToggleBlockMalware: (Boolean) -> Unit = {},
@@ -614,6 +619,16 @@ fun VpnSettingsScreen(
MtuSubtitle(modifier = Modifier.testTag(LAZY_LIST_LAST_ITEM_TEST_TAG))
Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
}
+
+ item { ServerIpOverrides(navigateToServerIpOverrides) }
}
}
}
+
+@Composable
+private fun ServerIpOverrides(onServerIpOverridesClick: () -> Unit) {
+ NavigationComposeCell(
+ title = stringResource(id = R.string.server_ip_overrides),
+ onClick = onServerIpOverridesClick
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
index efd8e34250..8ebdaede33 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
@@ -64,3 +64,16 @@ const val SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG =
"select_location_custom_list_bottom_sheet_test_tag"
const val SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG =
"select_location_location_bottom_sheet_test_tag"
+
+// ServerIpOverridesScreen
+const val SERVER_IP_OVERRIDE_IMPORT_TEST_TAG = "server_ip_override_import_button_test_tag"
+const val SERVER_IP_OVERRIDE_INFO_TEST_TAG = "server_ip_override_info_button_test_tag"
+const val SERVER_IP_OVERRIDE_MORE_VERT_TEST_TAG = "server_ip_override_more_vert_button_test_tag"
+const val SERVER_IP_OVERRIDE_RESET_OVERRIDES_TEST_TAG = "server_ip_override_reset_button_test_tag"
+const val SERVER_IP_OVERRIDES_IMPORT_BY_FILE_TEST_TAG = "server_ip_override_import_by_file_test_tag"
+const val SERVER_IP_OVERRIDES_IMPORT_BY_TEXT_TEST_TAG = "server_ip_override_import_by_text_test_tag"
+
+// ResetServerIpOverridesConfirmationDialog
+const val RESET_SERVER_IP_OVERRIDE_RESET_TEST_TAG = "reset_server_ip_override_reset_button_test_tag"
+const val RESET_SERVER_IP_OVERRIDE_CANCEL_TEST_TAG =
+ "reset_server_ip_override_cancel_button_test_tag"
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightLeafTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightLeafTransition.kt
new file mode 100644
index 0000000000..45ea74931a
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightLeafTransition.kt
@@ -0,0 +1,30 @@
+package net.mullvad.mullvadvpn.compose.transitions
+
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.navigation.NavBackStackEntry
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import com.ramcosta.composedestinations.utils.destination
+import net.mullvad.mullvadvpn.compose.destinations.NoDaemonScreenDestination
+import net.mullvad.mullvadvpn.constant.SCREEN_ANIMATION_TIME_MILLIS
+
+object SlideInFromRightLeafTransition : DestinationStyle.Animated {
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition() =
+ slideInHorizontally(initialOffsetX = { it })
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() =
+ when (targetState.destination()) {
+ NoDaemonScreenDestination -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS))
+ else -> fadeOut()
+ }
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() =
+ fadeIn(snap(0))
+
+ override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() =
+ slideOutHorizontally(targetOffsetX = { it })
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Navigation.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Navigation.kt
new file mode 100644
index 0000000000..9566bc0da2
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Navigation.kt
@@ -0,0 +1,17 @@
+package net.mullvad.mullvadvpn.compose.util
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisallowComposableCalls
+import com.ramcosta.composedestinations.result.NavResult
+import com.ramcosta.composedestinations.result.ResultRecipient
+import net.mullvad.mullvadvpn.compose.destinations.DirectionDestination
+
+@Composable
+fun <D : DirectionDestination, V> ResultRecipient<D, V>.OnNavResultValue(
+ onValue: @DisallowComposableCalls (value: V) -> Unit
+) = onNavResult {
+ when (it) {
+ NavResult.Canceled -> Unit
+ is NavResult.Value -> onValue(it.value)
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Snackbar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Snackbar.kt
new file mode 100644
index 0000000000..3e5b7e1618
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Snackbar.kt
@@ -0,0 +1,19 @@
+package net.mullvad.mullvadvpn.compose.util
+
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHostState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+suspend fun SnackbarHostState.showSnackbarImmediately(
+ coroutineScope: CoroutineScope,
+ message: String,
+ actionLabel: String? = null,
+ withDismissAction: Boolean = false,
+ duration: SnackbarDuration =
+ if (actionLabel == null) SnackbarDuration.Short else SnackbarDuration.Indefinite
+) =
+ coroutineScope.launch {
+ currentSnackbarData?.dismiss()
+ showSnackbar(message, actionLabel, withDismissAction, duration)
+ }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt
index 13b5e7a2db..fe02cf5b7a 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt
@@ -20,6 +20,7 @@ import net.mullvad.mullvadvpn.repository.DeviceRepository
import net.mullvad.mullvadvpn.repository.InAppNotificationController
import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository
import net.mullvad.mullvadvpn.repository.ProblemReportRepository
+import net.mullvad.mullvadvpn.repository.RelayOverridesRepository
import net.mullvad.mullvadvpn.repository.SettingsRepository
import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
@@ -60,7 +61,9 @@ import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel
import net.mullvad.mullvadvpn.viewmodel.PaymentViewModel
import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel
import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel
+import net.mullvad.mullvadvpn.viewmodel.ResetServerIpOverridesConfirmationViewModel
import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel
+import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesViewModel
import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel
import net.mullvad.mullvadvpn.viewmodel.SplashViewModel
import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel
@@ -95,6 +98,7 @@ val uiModule = module {
single { InetAddressValidator.getInstance() }
single { androidContext().resources }
single { androidContext().assets }
+ single { androidContext().contentResolver }
single { ChangelogRepository(get(named(APP_PREFERENCES_NAME)), get()) }
@@ -179,6 +183,8 @@ val uiModule = module {
}
viewModel { CustomListsViewModel(get(), get()) }
viewModel { parameters -> DeleteCustomListConfirmationViewModel(parameters.get(), get()) }
+ viewModel { ServerIpOverridesViewModel(get(), get(), get(), get()) }
+ viewModel { ResetServerIpOverridesConfirmationViewModel(get()) }
// This view model must be single so we correctly attach lifecycle and share it with activity
single { NoDaemonViewModel(get()) }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
index a0841c0746..c7a9be2ff9 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
@@ -92,7 +92,13 @@ class MainActivity : ComponentActivity() {
}
override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
- serviceConnectionManager.onVpnPermissionResult(resultCode == Activity.RESULT_OK)
+ // super call is needed for return value when opening file.
+ super.onActivityResult(requestCode, resultCode, resultData)
+
+ // Ensure we are responding to the correct request
+ if (requestCode == REQUEST_VPN_PERMISSION_RESULT_CODE) {
+ serviceConnectionManager.onVpnPermissionResult(resultCode == Activity.RESULT_OK)
+ }
}
override fun onStop() {
@@ -111,6 +117,10 @@ class MainActivity : ComponentActivity() {
private fun requestVpnPermission() {
val intent = VpnService.prepare(this)
- startActivityForResult(intent, 0)
+ startActivityForResult(intent, REQUEST_VPN_PERMISSION_RESULT_CODE)
+ }
+
+ companion object {
+ private const val REQUEST_VPN_PERMISSION_RESULT_CODE = 0
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModel.kt
new file mode 100644
index 0000000000..4afa12219a
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModel.kt
@@ -0,0 +1,25 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.repository.RelayOverridesRepository
+
+class ResetServerIpOverridesConfirmationViewModel(
+ private val relayOverridesRepository: RelayOverridesRepository,
+) : ViewModel() {
+ private val _uiSideEffect = Channel<ResetServerIpOverridesConfirmationUiSideEffect>()
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+
+ fun clearAllOverrides() =
+ viewModelScope.launch {
+ relayOverridesRepository.clearAllOverrides()
+ _uiSideEffect.send(ResetServerIpOverridesConfirmationUiSideEffect.OverridesCleared)
+ }
+}
+
+sealed class ResetServerIpOverridesConfirmationUiSideEffect {
+ data object OverridesCleared : ResetServerIpOverridesConfirmationUiSideEffect()
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt
new file mode 100644
index 0000000000..5a77727b18
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt
@@ -0,0 +1,89 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import android.content.ContentResolver
+import android.net.Uri
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import java.io.InputStreamReader
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+import net.mullvad.mullvadvpn.model.SettingsPatchError
+import net.mullvad.mullvadvpn.repository.RelayOverridesRepository
+import net.mullvad.mullvadvpn.repository.SettingsRepository
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
+
+class ServerIpOverridesViewModel(
+ private val serviceConnectionManager: ServiceConnectionManager,
+ relayOverridesRepository: RelayOverridesRepository,
+ private val settingsRepository: SettingsRepository,
+ private val contentResolver: ContentResolver,
+) : ViewModel() {
+
+ private val _uiSideEffect = Channel<ServerIpOverridesUiSideEffect>()
+ val uiSideEffect = merge(_uiSideEffect.receiveAsFlow())
+
+ val uiState: StateFlow<ServerIpOverridesViewState> =
+ relayOverridesRepository.relayOverrides
+ .filterNotNull()
+ .map { ServerIpOverridesViewState.Loaded(overridesActive = it.isNotEmpty()) }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ ServerIpOverridesViewState.Loading
+ )
+
+ fun importFile(uri: Uri) =
+ viewModelScope.launch {
+ // Read json from file
+ val inputStream = contentResolver.openInputStream(uri)!!
+ val json = InputStreamReader(inputStream, Charsets.UTF_8).readText()
+
+ applySettingsPatch(json)
+ }
+
+ fun importText(json: String) = viewModelScope.launch { applySettingsPatch(json) }
+
+ private suspend fun applySettingsPatch(json: String) {
+ // Wait for daemon to come online since we might be disconnected (due to File picker being
+ // open
+ // and we disconnect from daemon in paused state)
+ val connResult =
+ withTimeoutOrNull(5.seconds) {
+ serviceConnectionManager.connectionState
+ .filterIsInstance(ServiceConnectionState.ConnectedReady::class)
+ .first()
+ }
+ if (connResult != null) {
+ // Apply patch
+ val result = settingsRepository.applySettingsPatch(json)
+ _uiSideEffect.send(ServerIpOverridesUiSideEffect.ImportResult(result.error))
+ } else {
+ // Service never came online, at this point we should already display daemon overlay
+ }
+ }
+}
+
+sealed interface ServerIpOverridesUiSideEffect {
+ data class ImportResult(val error: SettingsPatchError?) : ServerIpOverridesUiSideEffect
+}
+
+sealed interface ServerIpOverridesViewState {
+ val overridesActive: Boolean?
+ get() = (this as? Loaded)?.overridesActive
+
+ data object Loading : ServerIpOverridesViewState
+
+ data class Loaded(override val overridesActive: Boolean) : ServerIpOverridesViewState
+}
diff --git a/android/lib/resource/src/main/res/drawable/icon_text_fields.xml b/android/lib/resource/src/main/res/drawable/icon_text_fields.xml
new file mode 100644
index 0000000000..ecc6072999
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable/icon_text_fields.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M280,800v-520L80,280v-120h520v120L400,280v520L280,800ZM640,800v-320L520,480v-120h360v120L760,480v320L640,800Z"/>
+</vector>
diff --git a/android/lib/resource/src/main/res/drawable/icon_upload_file.xml b/android/lib/resource/src/main/res/drawable/icon_upload_file.xml
new file mode 100644
index 0000000000..4f812f7fc5
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable/icon_upload_file.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M440,760h80v-167l64,64 56,-57 -160,-160 -160,160 57,56 63,-63v167ZM240,880q-33,0 -56.5,-23.5T160,800v-640q0,-33 23.5,-56.5T240,80h320l240,240v480q0,33 -23.5,56.5T720,880L240,880ZM520,360v-200L240,160v640h480v-440L520,360ZM240,160v200,-200 640,-640Z"/>
+</vector>