summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2024-06-13 16:37:23 +0200
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2024-06-14 13:27:05 +0200
commit7dd2345f65acb9fe9a9b57809603db3a65417a8b (patch)
tree1ed1918cb8742f470b5d7182f6ef92c7ee0ed97e /android
parente80e9ee68559fcd32747c28a829e70d2121e9344 (diff)
downloadmullvadvpn-7dd2345f65acb9fe9a9b57809603db3a65417a8b.tar.xz
mullvadvpn-7dd2345f65acb9fe9a9b57809603db3a65417a8b.zip
Add ui for api access method functionality
Diffstat (limited to 'android')
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt61
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SwitchLocationButton.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/TestMethodButton.kt36
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadExposedDropdownMenuBox.kt73
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NavigateButton.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NavigateBackIconButton.kt)7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ApiAccessMethodInfoDialog.kt36
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteApiAccessMethodConfirmationDialog.kt72
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteConfirmationDialog.kt90
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt66
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialog.kt150
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessListUiStateParameterProvider.kt24
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessMethodDetailsUiStatePreviewParameterProvider.kt36
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessPreviewData.kt56
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/EditApiAccessMethodUiStateParameterProvider.kt77
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SaveApiAccessMethodUiStatePreviewParameterProvider.kt25
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/TestMethodButtonPreviewParameterProvider.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessListScreen.kt202
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessMethodDetailsScreen.kt300
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreen.kt605
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt9
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt13
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiAccessListUiState.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiAccessMethodDetailsUiState.kt29
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeleteApiAccessMethodUiState.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditApiAccessMethodUiState.kt87
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SaveApiAccessMethodUiState.kt16
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt21
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/ApiAccessMethodTextField.kt85
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt21
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/TextFieldColors.kt27
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt27
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiAccessListViewModel.kt23
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiAccessMethodDetailsViewModel.kt128
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteApiAccessMethodConfirmationViewModel.kt51
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditApiAccessMethodViewModel.kt274
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SaveApiAccessMethodViewModel.kt102
-rw-r--r--android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/CommonListExtensions.kt4
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml39
-rw-r--r--android/lib/resource/src/main/res/values/strings_non_translatable.xml2
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt11
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt1
44 files changed, 2828 insertions, 101 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt
index 3dd7068389..9f75a656c4 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt
@@ -22,10 +22,8 @@ import androidx.compose.ui.unit.dp
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.Alpha20
-import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled
import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive
import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible
-import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible
import net.mullvad.mullvadvpn.lib.theme.color.onVariant
import net.mullvad.mullvadvpn.lib.theme.color.variant
@@ -92,7 +90,7 @@ fun NegativeButton(
text = text,
modifier = modifier,
isEnabled = isEnabled,
- icon = icon
+ trailingIcon = icon
)
}
@@ -124,7 +122,7 @@ fun VariantButton(
text = text,
modifier = modifier,
isEnabled = isEnabled,
- icon = icon
+ trailingIcon = icon
)
}
@@ -147,7 +145,8 @@ fun PrimaryButton(
.compositeOver(MaterialTheme.colorScheme.background),
),
isEnabled: Boolean = true,
- icon: @Composable (() -> Unit)? = null
+ leadingIcon: @Composable (() -> Unit)? = null,
+ trailingIcon: @Composable (() -> Unit)? = null
) {
BaseButton(
onClick = onClick,
@@ -155,7 +154,8 @@ fun PrimaryButton(
text = text,
modifier = modifier,
isEnabled = isEnabled,
- icon = icon,
+ leadingIcon = leadingIcon,
+ trailingIcon = trailingIcon
)
}
@@ -166,25 +166,35 @@ private fun BaseButton(
text: String,
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
- icon: @Composable (() -> Unit)? = null
+ leadingIcon: @Composable (() -> Unit)? = null,
+ trailingIcon: @Composable (() -> Unit)? = null
) {
+ val hasIcon = leadingIcon != null || trailingIcon != null
Button(
onClick = onClick,
colors = colors,
enabled = isEnabled,
contentPadding =
- icon?.let { PaddingValues(horizontal = 0.dp, vertical = Dimens.buttonVerticalPadding) }
- ?: ButtonDefaults.ContentPadding,
+ if (hasIcon) {
+ PaddingValues(horizontal = 0.dp, vertical = Dimens.buttonVerticalPadding)
+ } else {
+ ButtonDefaults.ContentPadding
+ },
modifier = modifier.wrapContentHeight().fillMaxWidth(),
shape = MaterialTheme.shapes.small
) {
// Used to center the text
- icon?.let {
- Box(
- modifier = Modifier.padding(horizontal = Dimens.smallPadding).alpha(AlphaInvisible)
- ) {
- icon()
- }
+ when {
+ leadingIcon != null ->
+ Box(modifier = Modifier.padding(horizontal = Dimens.smallPadding)) { leadingIcon() }
+ trailingIcon != null ->
+ // Used to center the text
+ Box(
+ modifier =
+ Modifier.padding(horizontal = Dimens.smallPadding).alpha(AlphaInvisible)
+ ) {
+ trailingIcon()
+ }
}
Text(
text = text,
@@ -194,14 +204,19 @@ private fun BaseButton(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
- icon?.let {
- Box(
- modifier =
- Modifier.padding(horizontal = Dimens.smallPadding)
- .alpha(if (isEnabled) AlphaVisible else AlphaDisabled)
- ) {
- icon()
- }
+ when {
+ trailingIcon != null ->
+ Box(modifier = Modifier.padding(horizontal = Dimens.smallPadding)) {
+ trailingIcon()
+ }
+ leadingIcon != null ->
+ // Used to center the text
+ Box(
+ modifier =
+ Modifier.padding(horizontal = Dimens.smallPadding).alpha(AlphaInvisible)
+ ) {
+ leadingIcon()
+ }
}
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SwitchLocationButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SwitchLocationButton.kt
index 2feabcfaf3..291b5a743b 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SwitchLocationButton.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SwitchLocationButton.kt
@@ -39,7 +39,7 @@ fun SwitchLocationButton(
),
modifier = modifier,
text = text,
- icon =
+ trailingIcon =
if (showChevron) {
{
Icon(
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/TestMethodButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/TestMethodButton.kt
new file mode 100644
index 0000000000..9ded39ea15
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/TestMethodButton.kt
@@ -0,0 +1,36 @@
+package net.mullvad.mullvadvpn.compose.button
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.preview.TestMethodButtonPreviewParameterProvider
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+
+@Preview
+@Composable
+private fun PreviewTestMethodButton(
+ @PreviewParameter(provider = TestMethodButtonPreviewParameterProvider::class) isTesting: Boolean
+) {
+ AppTheme { TestMethodButton(isTesting = isTesting, onTestMethod = {}) }
+}
+
+@Composable
+fun TestMethodButton(modifier: Modifier = Modifier, isTesting: Boolean, onTestMethod: () -> Unit) {
+ PrimaryButton(
+ modifier = modifier,
+ onClick = onTestMethod,
+ isEnabled = !isTesting,
+ text =
+ stringResource(
+ id =
+ if (isTesting) {
+ R.string.testing
+ } else {
+ R.string.test_method
+ }
+ ),
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
index 7fbc4bdda3..fdc01ab62d 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
@@ -67,7 +67,8 @@ fun NavigationComposeCell(
showWarning: Boolean = false,
bodyView: @Composable () -> Unit = { DefaultNavigationView(chevronContentDescription = title) },
isRowEnabled: Boolean = true,
- onClick: () -> Unit
+ onClick: () -> Unit,
+ testTag: String = ""
) {
BaseCell(
onCellClicked = onClick,
@@ -79,7 +80,8 @@ fun NavigationComposeCell(
)
},
bodyView = { bodyView() },
- isRowEnabled = isRowEnabled
+ isRowEnabled = isRowEnabled,
+ testTag = testTag
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt
index 17eb5d315a..0e046cdfd8 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt
@@ -1,12 +1,14 @@
package net.mullvad.mullvadvpn.compose.cell
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import net.mullvad.mullvadvpn.lib.theme.AppTheme
@@ -22,9 +24,12 @@ private fun PreviewTwoRowCell() {
fun TwoRowCell(
titleText: String,
subtitleText: String,
+ bodyView: @Composable ColumnScope.() -> Unit = {},
onCellClicked: () -> Unit = {},
titleColor: Color = MaterialTheme.colorScheme.onPrimary,
subtitleColor: Color = MaterialTheme.colorScheme.onPrimary,
+ titleStyle: TextStyle = MaterialTheme.typography.labelLarge,
+ subtitleStyle: TextStyle = MaterialTheme.typography.labelLarge,
background: Color = MaterialTheme.colorScheme.primary
) {
BaseCell(
@@ -33,7 +38,7 @@ fun TwoRowCell(
Text(
modifier = Modifier.fillMaxWidth(),
text = titleText,
- style = MaterialTheme.typography.labelLarge,
+ style = titleStyle,
color = titleColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -41,13 +46,14 @@ fun TwoRowCell(
Text(
modifier = Modifier.fillMaxWidth(),
text = subtitleText,
- style = MaterialTheme.typography.labelLarge,
+ style = subtitleStyle,
color = subtitleColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
},
+ bodyView = bodyView,
onCellClicked = onCellClicked,
background = background,
minHeight = Dimens.cellHeightTwoRows
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadExposedDropdownMenuBox.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadExposedDropdownMenuBox.kt
new file mode 100644
index 0000000000..58df0815f0
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadExposedDropdownMenuBox.kt
@@ -0,0 +1,73 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldColors
+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 net.mullvad.mullvadvpn.lib.theme.color.menuItemColors
+
+/*
+ This has bug with dropdown menu width that might be fixed in compose material 3 1.3
+ https://issuetracker.google.com/issues/205589613
+*/
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MullvadExposedDropdownMenuBox(
+ modifier: Modifier = Modifier,
+ label: String,
+ title: String,
+ colors: TextFieldColors,
+ content: @Composable ColumnScope.(onClick: () -> Unit) -> Unit
+) {
+ var expanded by remember { mutableStateOf(false) }
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = { expanded = it },
+ modifier = modifier.clickable { expanded = !expanded }
+ ) {
+ TextField(
+ modifier = Modifier.fillMaxWidth().menuAnchor(),
+ readOnly = true,
+ value = title,
+ onValueChange = { /* Do nothing */},
+ label = { Text(text = label) },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
+ colors = colors,
+ )
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ modifier = Modifier.background(MaterialTheme.colorScheme.primary)
+ ) {
+ content { expanded = false }
+ }
+ }
+}
+
+@Composable
+fun MullvadDropdownMenuItem(
+ leadingIcon: @Composable (() -> Unit)? = null,
+ text: String,
+ onClick: () -> Unit
+) {
+ DropdownMenuItem(
+ leadingIcon = leadingIcon,
+ colors = menuItemColors,
+ text = { Text(text = text) },
+ onClick = onClick,
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NavigateBackIconButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NavigateButton.kt
index 798b5e1574..3543ac31cb 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NavigateBackIconButton.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NavigateButton.kt
@@ -25,3 +25,10 @@ fun NavigateBackDownIconButton(onNavigateBack: () -> Unit) {
)
}
}
+
+@Composable
+fun NavigateCloseIconButton(onNavigateClose: () -> Unit) {
+ IconButton(onClick = onNavigateClose) {
+ Icon(painter = painterResource(id = R.drawable.icon_close), contentDescription = null)
+ }
+}
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 4e03ebf4ae..c90703b7c4 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
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.lazy.LazyListState
@@ -245,7 +246,7 @@ fun ScaffoldWithLargeTopBarAndButton(
horizontal = Dimens.sideMargin,
vertical = Dimens.screenVerticalMargin
),
- icon = {
+ trailingIcon = {
Icon(
painter = painterResource(id = R.drawable.icon_extlink),
contentDescription = null
@@ -274,7 +275,7 @@ fun ScaffoldWithSmallTopBar(
content: @Composable (modifier: Modifier) -> Unit
) {
Scaffold(
- modifier = modifier.fillMaxSize(),
+ modifier = modifier.fillMaxSize().imePadding(),
topBar = {
MullvadSmallTopBar(
title = appBarTitle,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ApiAccessMethodInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ApiAccessMethodInfoDialog.kt
new file mode 100644
index 0000000000..141b610d43
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ApiAccessMethodInfoDialog.kt
@@ -0,0 +1,36 @@
+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
+import net.mullvad.mullvadvpn.compose.component.textResource
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+
+@Preview
+@Composable
+private fun PreviewApiAccessMethodInfoDialog() {
+ AppTheme { ApiAccessMethodInfoDialog(EmptyDestinationsNavigator) }
+}
+
+@Destination(style = DestinationStyle.Dialog::class)
+@Composable
+fun ApiAccessMethodInfoDialog(navigator: DestinationsNavigator) {
+ InfoDialog(
+ message =
+ buildString {
+ appendLine(stringResource(id = R.string.api_access_method_info_first_line))
+ appendLine()
+ appendLine(stringResource(id = R.string.api_access_method_info_second_line))
+ appendLine()
+ appendLine(textResource(id = R.string.api_access_method_info_third_line))
+ appendLine()
+ appendLine(textResource(id = R.string.api_access_method_info_fourth_line))
+ },
+ onDismiss = navigator::navigateUp
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteApiAccessMethodConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteApiAccessMethodConfirmationDialog.kt
new file mode 100644
index 0000000000..b4a98bd82c
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteApiAccessMethodConfirmationDialog.kt
@@ -0,0 +1,72 @@
+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 androidx.lifecycle.compose.collectAsStateWithLifecycle
+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.state.DeleteApiAccessMethodUiState
+import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.viewmodel.DeleteApiAccessMethodConfirmationSideEffect
+import net.mullvad.mullvadvpn.viewmodel.DeleteApiAccessMethodConfirmationViewModel
+import org.koin.androidx.compose.koinViewModel
+import org.koin.core.parameter.parametersOf
+
+@Preview
+@Composable
+private fun PreviewDeleteApiAccessMethodConfirmationDialog() {
+ AppTheme { DeleteApiAccessMethodConfirmationDialog(state = DeleteApiAccessMethodUiState(null)) }
+}
+
+@Composable
+@Destination(style = DestinationStyle.Dialog::class)
+fun DeleteApiAccessMethodConfirmation(
+ navigator: ResultBackNavigator<Boolean>,
+ apiAccessMethodId: ApiAccessMethodId
+) {
+ val viewModel =
+ koinViewModel<DeleteApiAccessMethodConfirmationViewModel>(
+ parameters = { parametersOf(apiAccessMethodId) }
+ )
+ val state = viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffectCollect(viewModel.uiSideEffect) {
+ when (it) {
+ is DeleteApiAccessMethodConfirmationSideEffect.Deleted ->
+ navigator.navigateBack(result = true)
+ }
+ }
+
+ DeleteApiAccessMethodConfirmationDialog(
+ state = state.value,
+ onDelete = viewModel::deleteApiAccessMethod,
+ onBack = navigator::navigateBack
+ )
+}
+
+@Composable
+fun DeleteApiAccessMethodConfirmationDialog(
+ state: DeleteApiAccessMethodUiState,
+ onDelete: () -> Unit = {},
+ onBack: () -> Unit = {}
+) {
+ DeleteConfirmationDialog(
+ onDelete = onDelete,
+ onBack = onBack,
+ message =
+ stringResource(
+ id = R.string.delete_method_question,
+ ),
+ errorMessage =
+ if (state.deleteError != null) {
+ stringResource(id = R.string.error_occurred)
+ } else {
+ null
+ }
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteConfirmationDialog.kt
new file mode 100644
index 0000000000..0133e0df3a
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteConfirmationDialog.kt
@@ -0,0 +1,90 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+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.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+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 PreviewDeleteConfirmationDialog() {
+ AppTheme {
+ DeleteConfirmationDialog(
+ message = "Do you want to delete Cookie?",
+ errorMessage = null,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun PreviewDeleteConfirmationDialogError() {
+ AppTheme {
+ DeleteConfirmationDialog(
+ message = "Do you want to delete Cookie?",
+ errorMessage = "Cookie can not be deleted"
+ )
+ }
+}
+
+@Composable
+fun DeleteConfirmationDialog(
+ message: String,
+ errorMessage: String?,
+ onDelete: () -> Unit = {},
+ onBack: () -> Unit = {}
+) {
+ AlertDialog(
+ onDismissRequest = onBack,
+ icon = {
+ Icon(
+ modifier = Modifier.fillMaxWidth().height(Dimens.dialogIconHeight),
+ painter = painterResource(id = R.drawable.icon_alert),
+ contentDescription = stringResource(id = R.string.remove_button),
+ tint = Color.Unspecified
+ )
+ },
+ title = {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(text = message)
+ if (errorMessage != null) {
+ Text(
+ text = errorMessage,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(top = Dimens.smallPadding)
+ )
+ }
+ }
+ },
+ dismissButton = {
+ PrimaryButton(
+ modifier = Modifier.focusRequester(FocusRequester()),
+ onClick = onBack,
+ text = stringResource(id = R.string.cancel)
+ )
+ },
+ confirmButton = {
+ NegativeButton(onClick = onDelete, text = stringResource(id = R.string.delete))
+ },
+ containerColor = MaterialTheme.colorScheme.background
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt
index e9718d7c24..b6e56ec637 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt
@@ -1,20 +1,6 @@
package net.mullvad.mullvadvpn.compose.dialog
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Icon
-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.focus.FocusRequester
-import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -22,15 +8,12 @@ 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.communication.Deleted
import net.mullvad.mullvadvpn.compose.state.DeleteCustomListUiState
import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect
import net.mullvad.mullvadvpn.lib.model.CustomListId
import net.mullvad.mullvadvpn.lib.model.CustomListName
import net.mullvad.mullvadvpn.lib.theme.AppTheme
-import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.viewmodel.DeleteCustomListConfirmationSideEffect
import net.mullvad.mullvadvpn.viewmodel.DeleteCustomListConfirmationViewModel
import org.koin.androidx.compose.koinViewModel
@@ -80,45 +63,16 @@ fun DeleteCustomListConfirmationDialog(
onDelete: () -> Unit = {},
onBack: () -> Unit = {}
) {
- AlertDialog(
- onDismissRequest = onBack,
- icon = {
- Icon(
- modifier = Modifier.fillMaxWidth().height(Dimens.dialogIconHeight),
- painter = painterResource(id = R.drawable.icon_alert),
- contentDescription = stringResource(id = R.string.remove_button),
- tint = Color.Unspecified
- )
- },
- title = {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Text(
- text =
- stringResource(
- id = R.string.delete_custom_list_confirmation_description,
- name.value
- )
- )
- if (state.deleteError != null) {
- Text(
- text = stringResource(id = R.string.error_occurred),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error,
- modifier = Modifier.padding(top = Dimens.smallPadding)
- )
- }
+ DeleteConfirmationDialog(
+ onDelete = onDelete,
+ onBack = onBack,
+ message =
+ stringResource(id = R.string.delete_custom_list_confirmation_description, name.value),
+ errorMessage =
+ if (state.deleteError != null) {
+ stringResource(id = R.string.error_occurred)
+ } else {
+ null
}
- },
- dismissButton = {
- PrimaryButton(
- modifier = Modifier.focusRequester(FocusRequester()),
- onClick = onBack,
- text = stringResource(id = R.string.cancel)
- )
- },
- confirmButton = {
- NegativeButton(onClick = onDelete, text = stringResource(id = R.string.delete))
- },
- containerColor = MaterialTheme.colorScheme.background
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialog.kt
new file mode 100644
index 0000000000..3ade701db4
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialog.kt
@@ -0,0 +1,150 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+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.PrimaryButton
+import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium
+import net.mullvad.mullvadvpn.compose.preview.SaveApiAccessMethodUiStatePreviewParameterProvider
+import net.mullvad.mullvadvpn.compose.state.SaveApiAccessMethodUiState
+import net.mullvad.mullvadvpn.compose.state.TestApiAccessMethodState
+import net.mullvad.mullvadvpn.compose.test.SAVE_API_ACCESS_METHOD_CANCEL_BUTTON_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SAVE_API_ACCESS_METHOD_LOADING_SPINNER_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SAVE_API_ACCESS_METHOD_SAVE_BUTTON_TEST_TAG
+import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.viewmodel.SaveApiAccessMethodSideEffect
+import net.mullvad.mullvadvpn.viewmodel.SaveApiAccessMethodViewModel
+import org.koin.androidx.compose.koinViewModel
+import org.koin.core.parameter.parametersOf
+
+@Preview
+@Composable
+private fun PreviewSaveApiAccessMethodDialog(
+ @PreviewParameter(SaveApiAccessMethodUiStatePreviewParameterProvider::class)
+ state: SaveApiAccessMethodUiState
+) {
+ AppTheme { SaveApiAccessMethodDialog(state = state) }
+}
+
+@Destination(style = DestinationStyle.Dialog::class)
+@Composable
+fun SaveApiAccessMethod(
+ backNavigator: ResultBackNavigator<Boolean>,
+ id: ApiAccessMethodId?,
+ name: ApiAccessMethodName,
+ customProxy: ApiAccessMethod.CustomProxy
+) {
+ val viewModel =
+ koinViewModel<SaveApiAccessMethodViewModel>(
+ parameters = { parametersOf(id, name, customProxy) }
+ )
+
+ LaunchedEffectCollect(sideEffect = viewModel.uiSideEffect) {
+ when (it) {
+ SaveApiAccessMethodSideEffect.CouldNotSaveApiAccessMethod ->
+ backNavigator.navigateBack(result = false)
+ SaveApiAccessMethodSideEffect.SuccessfullyCreatedApiMethod ->
+ backNavigator.navigateBack(result = true)
+ }
+ }
+
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ SaveApiAccessMethodDialog(
+ state = state,
+ onCancel = backNavigator::navigateBack,
+ onSave = viewModel::save
+ )
+}
+
+@Composable
+fun SaveApiAccessMethodDialog(
+ state: SaveApiAccessMethodUiState,
+ onCancel: () -> Unit = {},
+ onSave: () -> Unit = {}
+) {
+ AlertDialog(
+ icon = {
+ when (val testingState = state.testingState) {
+ is TestApiAccessMethodState.Result ->
+ Icon(
+ painter =
+ painterResource(
+ id =
+ if (
+ testingState is TestApiAccessMethodState.Result.Successful
+ ) {
+ R.drawable.icon_success
+ } else {
+ R.drawable.icon_fail
+ }
+ ),
+ contentDescription = null
+ )
+ TestApiAccessMethodState.Testing ->
+ MullvadCircularProgressIndicatorMedium(
+ modifier = Modifier.testTag(SAVE_API_ACCESS_METHOD_LOADING_SPINNER_TEST_TAG)
+ )
+ }
+ },
+ title = { Text(text = state.text(), style = MaterialTheme.typography.headlineSmall) },
+ onDismissRequest = { /*Should not be able to dismiss*/},
+ confirmButton = {
+ PrimaryButton(
+ onClick = onCancel,
+ text = stringResource(id = R.string.cancel),
+ isEnabled =
+ state.testingState is TestApiAccessMethodState.Testing ||
+ state.testingState is TestApiAccessMethodState.Result.Failure,
+ modifier = Modifier.testTag(SAVE_API_ACCESS_METHOD_CANCEL_BUTTON_TEST_TAG)
+ )
+ },
+ dismissButton = {
+ if (state.testingState is TestApiAccessMethodState.Result.Failure) {
+ PrimaryButton(
+ onClick = onSave,
+ text = stringResource(id = R.string.save),
+ modifier = Modifier.testTag(SAVE_API_ACCESS_METHOD_SAVE_BUTTON_TEST_TAG)
+ )
+ }
+ },
+ containerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ iconContentColor = Color.Unspecified,
+ )
+}
+
+@Composable
+private fun SaveApiAccessMethodUiState.text() =
+ stringResource(
+ id =
+ when (testingState) {
+ TestApiAccessMethodState.Testing -> R.string.verifying_api_method
+ TestApiAccessMethodState.Result.Successful -> R.string.api_reachable_adding_method
+ TestApiAccessMethodState.Result.Failure -> {
+ if (isSaving) {
+ R.string.adding_method
+ } else {
+ R.string.api_unreachable_save_anyway
+ }
+ }
+ }
+ )
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessListUiStateParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessListUiStateParameterProvider.kt
new file mode 100644
index 0000000000..980ff36848
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessListUiStateParameterProvider.kt
@@ -0,0 +1,24 @@
+package net.mullvad.mullvadvpn.compose.preview
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import net.mullvad.mullvadvpn.compose.state.ApiAccessListUiState
+
+class ApiAccessListUiStateParameterProvider : PreviewParameterProvider<ApiAccessListUiState> {
+
+ override val values: Sequence<ApiAccessListUiState> =
+ sequenceOf(
+ // Default state
+ ApiAccessListUiState(),
+ // Without custom api access method
+ ApiAccessListUiState(
+ currentApiAccessMethodSetting = defaultAccessMethods.first(),
+ apiAccessMethodSettings = defaultAccessMethods
+ ),
+ // With custom api
+ ApiAccessListUiState(
+ currentApiAccessMethodSetting = defaultAccessMethods.first(),
+ apiAccessMethodSettings =
+ defaultAccessMethods.plus(listOf(shadowsocks, socks5Remote))
+ )
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessMethodDetailsUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessMethodDetailsUiStatePreviewParameterProvider.kt
new file mode 100644
index 0000000000..2f04157967
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessMethodDetailsUiStatePreviewParameterProvider.kt
@@ -0,0 +1,36 @@
+package net.mullvad.mullvadvpn.compose.preview
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import net.mullvad.mullvadvpn.compose.state.ApiAccessMethodDetailsUiState
+
+class ApiAccessMethodDetailsUiStatePreviewParameterProvider :
+ PreviewParameterProvider<ApiAccessMethodDetailsUiState> {
+ override val values: Sequence<ApiAccessMethodDetailsUiState> =
+ sequenceOf(
+ ApiAccessMethodDetailsUiState.Loading(shadowsocks.id),
+ // Non-editable api access type
+ defaultAccessMethods[0].let {
+ ApiAccessMethodDetailsUiState.Content(
+ apiAccessMethodId = it.id,
+ name = it.name,
+ enabled = it.enabled,
+ isEditable = false,
+ isCurrentMethod = false,
+ isDisableable = true,
+ isTestingAccessMethod = false
+ )
+ },
+ // Editable api access type, current method, can not be disabled
+ shadowsocks.let {
+ ApiAccessMethodDetailsUiState.Content(
+ apiAccessMethodId = it.id,
+ name = it.name,
+ enabled = it.enabled,
+ isEditable = true,
+ isCurrentMethod = true,
+ isDisableable = false,
+ isTestingAccessMethod = false
+ )
+ }
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessPreviewData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessPreviewData.kt
new file mode 100644
index 0000000000..73027f55de
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessPreviewData.kt
@@ -0,0 +1,56 @@
+package net.mullvad.mullvadvpn.compose.preview
+
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodSetting
+import net.mullvad.mullvadvpn.lib.model.Cipher
+import net.mullvad.mullvadvpn.lib.model.Port
+import net.mullvad.mullvadvpn.lib.model.SocksAuth
+
+private const val UUID1 = "12345678-1234-5678-1234-567812345678"
+private const val UUID2 = "12345678-1234-5678-1234-567812345679"
+private const val UUID3 = "12345678-1234-5678-1234-567812345671"
+private const val UUID4 = "12345678-1234-5678-1234-567812345672"
+
+internal val defaultAccessMethods =
+ listOf(
+ ApiAccessMethodSetting(
+ id = ApiAccessMethodId.fromString(UUID1),
+ name = ApiAccessMethodName.fromString("Direct"),
+ enabled = true,
+ apiAccessMethod = ApiAccessMethod.Direct
+ ),
+ ApiAccessMethodSetting(
+ id = ApiAccessMethodId.fromString(UUID2),
+ name = ApiAccessMethodName.fromString("Bridges"),
+ enabled = false,
+ apiAccessMethod = ApiAccessMethod.Bridges
+ )
+ )
+
+internal val socks5Remote =
+ ApiAccessMethodSetting(
+ id = ApiAccessMethodId.fromString(UUID3),
+ name = ApiAccessMethodName.fromString("Socks5 Remote"),
+ enabled = true,
+ apiAccessMethod =
+ ApiAccessMethod.CustomProxy.Socks5Remote(
+ ip = "192.167.1.1",
+ port = Port(80),
+ auth = SocksAuth(username = "hej", password = "password")
+ )
+ )
+
+internal val shadowsocks =
+ ApiAccessMethodSetting(
+ ApiAccessMethodId.fromString(UUID4),
+ ApiAccessMethodName.fromString("ShadowSocks"),
+ enabled = true,
+ ApiAccessMethod.CustomProxy.Shadowsocks(
+ ip = "192.168.1.1",
+ port = Port(123),
+ password = "Password",
+ cipher = Cipher.fromString("aes-128-cfb")
+ )
+ )
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/EditApiAccessMethodUiStateParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/EditApiAccessMethodUiStateParameterProvider.kt
new file mode 100644
index 0000000000..d08f45f5dd
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/EditApiAccessMethodUiStateParameterProvider.kt
@@ -0,0 +1,77 @@
+package net.mullvad.mullvadvpn.compose.preview
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import arrow.core.nonEmptyListOf
+import net.mullvad.mullvadvpn.compose.state.EditApiAccessFormData
+import net.mullvad.mullvadvpn.compose.state.EditApiAccessMethodUiState
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod
+import net.mullvad.mullvadvpn.lib.model.InvalidDataError
+
+class EditApiAccessMethodUiStateParameterProvider :
+ PreviewParameterProvider<EditApiAccessMethodUiState> {
+ override val values =
+ sequenceOf(
+ EditApiAccessMethodUiState.Loading(editMode = true),
+ // Empty default state
+ EditApiAccessMethodUiState.Content(
+ editMode = false,
+ formData = EditApiAccessFormData.empty(),
+ hasChanges = false,
+ isTestingApiAccessMethod = false
+ ),
+ // Shadowsocks, no errors
+ EditApiAccessMethodUiState.Content(
+ editMode = true,
+ hasChanges = false,
+ formData =
+ shadowsocks.let {
+ val data = (it.apiAccessMethod as ApiAccessMethod.CustomProxy.Shadowsocks)
+ EditApiAccessFormData(
+ name = it.name.value,
+ serverIp = data.ip,
+ port = data.port.toString(),
+ password = data.password ?: "",
+ cipher = data.cipher,
+ username = ""
+ )
+ },
+ isTestingApiAccessMethod = false
+ ),
+ // Socks5 Remote, no errors, testing method
+ EditApiAccessMethodUiState.Content(
+ editMode = true,
+ hasChanges = false,
+ formData =
+ socks5Remote.let {
+ val data = (it.apiAccessMethod as ApiAccessMethod.CustomProxy.Socks5Remote)
+ EditApiAccessFormData(
+ name = it.name.value,
+ serverIp = data.ip,
+ port = data.port.toString(),
+ enableAuthentication = data.auth != null,
+ username = data.auth?.username ?: "",
+ password = data.auth?.password ?: ""
+ )
+ },
+ isTestingApiAccessMethod = true
+ ),
+ // Socks 5 remote, required errors
+ EditApiAccessMethodUiState.Content(
+ editMode = true,
+ hasChanges = false,
+ formData =
+ EditApiAccessFormData.empty()
+ .copy(enableAuthentication = true)
+ .updateWithErrors(
+ nonEmptyListOf(
+ InvalidDataError.NameError.Required,
+ InvalidDataError.PortError.Required,
+ InvalidDataError.ServerIpError.Required,
+ InvalidDataError.UserNameError.Required,
+ InvalidDataError.PasswordError.Required
+ )
+ ),
+ isTestingApiAccessMethod = false
+ )
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SaveApiAccessMethodUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SaveApiAccessMethodUiStatePreviewParameterProvider.kt
new file mode 100644
index 0000000000..e603d11ea8
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SaveApiAccessMethodUiStatePreviewParameterProvider.kt
@@ -0,0 +1,25 @@
+package net.mullvad.mullvadvpn.compose.preview
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import net.mullvad.mullvadvpn.compose.state.SaveApiAccessMethodUiState
+import net.mullvad.mullvadvpn.compose.state.TestApiAccessMethodState
+
+class SaveApiAccessMethodUiStatePreviewParameterProvider :
+ PreviewParameterProvider<SaveApiAccessMethodUiState> {
+ override val values: Sequence<SaveApiAccessMethodUiState> =
+ sequenceOf(
+ SaveApiAccessMethodUiState(testingState = TestApiAccessMethodState.Testing),
+ SaveApiAccessMethodUiState(
+ testingState = TestApiAccessMethodState.Result.Successful,
+ isSaving = true
+ ),
+ SaveApiAccessMethodUiState(
+ testingState = TestApiAccessMethodState.Result.Failure,
+ isSaving = false
+ ),
+ SaveApiAccessMethodUiState(
+ testingState = TestApiAccessMethodState.Result.Failure,
+ isSaving = true
+ )
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/TestMethodButtonPreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/TestMethodButtonPreviewParameterProvider.kt
new file mode 100644
index 0000000000..1ee6a09c31
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/TestMethodButtonPreviewParameterProvider.kt
@@ -0,0 +1,7 @@
+package net.mullvad.mullvadvpn.compose.preview
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+class TestMethodButtonPreviewParameterProvider : PreviewParameterProvider<Boolean> {
+ override val values: Sequence<Boolean> = sequenceOf(false, true)
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessListScreen.kt
new file mode 100644
index 0000000000..1c26986fac
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessListScreen.kt
@@ -0,0 +1,202 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.PrimaryButton
+import net.mullvad.mullvadvpn.compose.cell.DefaultNavigationView
+import net.mullvad.mullvadvpn.compose.cell.TwoRowCell
+import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
+import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
+import net.mullvad.mullvadvpn.compose.constant.ContentType
+import net.mullvad.mullvadvpn.compose.destinations.ApiAccessMethodDetailsDestination
+import net.mullvad.mullvadvpn.compose.destinations.ApiAccessMethodInfoDialogDestination
+import net.mullvad.mullvadvpn.compose.destinations.EditApiAccessMethodDestination
+import net.mullvad.mullvadvpn.compose.extensions.itemsWithDivider
+import net.mullvad.mullvadvpn.compose.preview.ApiAccessListUiStateParameterProvider
+import net.mullvad.mullvadvpn.compose.state.ApiAccessListUiState
+import net.mullvad.mullvadvpn.compose.test.API_ACCESS_LIST_INFO_TEST_TAG
+import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodSetting
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.viewmodel.ApiAccessListViewModel
+import org.koin.androidx.compose.koinViewModel
+
+@Preview
+@Composable
+private fun PreviewApiAccessList(
+ @PreviewParameter(ApiAccessListUiStateParameterProvider::class) state: ApiAccessListUiState
+) {
+ AppTheme { ApiAccessListScreen(state = state) }
+}
+
+@Destination(style = SlideInFromRightTransition::class)
+@Composable
+fun ApiAccessList(navigator: DestinationsNavigator) {
+ val viewModel = koinViewModel<ApiAccessListViewModel>()
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+
+ ApiAccessListScreen(
+ state = state,
+ onAddMethodClick = {
+ navigator.navigate(EditApiAccessMethodDestination(null)) { launchSingleTop = true }
+ },
+ onApiAccessMethodClick = {
+ navigator.navigate(ApiAccessMethodDetailsDestination(it.id)) { launchSingleTop = true }
+ },
+ onApiAccessInfoClick = {
+ navigator.navigate(ApiAccessMethodInfoDialogDestination) { launchSingleTop = true }
+ },
+ onBackClick = navigator::navigateUp
+ )
+}
+
+@Composable
+fun ApiAccessListScreen(
+ state: ApiAccessListUiState,
+ onAddMethodClick: () -> Unit = {},
+ onApiAccessMethodClick: (apiAccessMethodSetting: ApiAccessMethodSetting) -> Unit = {},
+ onApiAccessInfoClick: () -> Unit = {},
+ onBackClick: () -> Unit = {}
+) {
+ ScaffoldWithMediumTopBar(
+ appBarTitle = stringResource(id = R.string.settings_api_access),
+ navigationIcon = { NavigateBackIconButton(onBackClick) },
+ ) { modifier, lazyListState ->
+ LazyColumn(modifier = modifier, state = lazyListState) {
+ description()
+ currentAccessMethod(
+ currentApiAccessMethodName = state.currentApiAccessMethodSetting?.name,
+ onInfoClicked = onApiAccessInfoClick
+ )
+ apiAccessMethodItems(
+ state.apiAccessMethodSettings,
+ onApiAccessMethodClick = onApiAccessMethodClick
+ )
+ buttonPanel(onAddMethodClick = onAddMethodClick)
+ }
+ }
+}
+
+private fun LazyListScope.description() {
+ item {
+ Text(
+ text = stringResource(id = R.string.api_access_description),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSecondary,
+ modifier =
+ Modifier.padding(start = Dimens.cellStartPadding, end = Dimens.cellEndPadding)
+ .fillMaxWidth()
+ )
+ }
+}
+
+private fun LazyListScope.currentAccessMethod(
+ currentApiAccessMethodName: ApiAccessMethodName?,
+ onInfoClicked: () -> Unit
+) {
+ item {
+ Row(
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ bottom = Dimens.mediumPadding
+ ),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onBackground,
+ text =
+ stringResource(
+ id = R.string.current_method,
+ currentApiAccessMethodName?.value ?: "-",
+ ),
+ )
+ IconButton(
+ onClick = onInfoClicked,
+ modifier =
+ Modifier.align(Alignment.CenterVertically)
+ .testTag(API_ACCESS_LIST_INFO_TEST_TAG),
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.icon_info),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onBackground
+ )
+ }
+ }
+ }
+}
+
+private fun LazyListScope.apiAccessMethodItems(
+ apiAccessMethodSettings: List<ApiAccessMethodSetting>,
+ onApiAccessMethodClick: (apiAccessMethodSetting: ApiAccessMethodSetting) -> Unit
+) {
+ itemsWithDivider(
+ items = apiAccessMethodSettings,
+ key = { item -> item.id },
+ contentType = { ContentType.ITEM },
+ ) {
+ ApiAccessMethodItem(
+ apiAccessMethodSetting = it,
+ onApiAccessMethodClick = onApiAccessMethodClick
+ )
+ }
+}
+
+@Composable
+private fun ApiAccessMethodItem(
+ apiAccessMethodSetting: ApiAccessMethodSetting,
+ onApiAccessMethodClick: (apiAccessMethodSetting: ApiAccessMethodSetting) -> Unit
+) {
+ TwoRowCell(
+ titleText = apiAccessMethodSetting.name.value,
+ subtitleText =
+ stringResource(
+ id =
+ if (apiAccessMethodSetting.enabled) {
+ R.string.on
+ } else {
+ R.string.off
+ }
+ ),
+ titleStyle = MaterialTheme.typography.titleMedium,
+ subtitleColor = MaterialTheme.colorScheme.onSecondary,
+ bodyView = { DefaultNavigationView(apiAccessMethodSetting.name.value) },
+ onCellClicked = { onApiAccessMethodClick(apiAccessMethodSetting) }
+ )
+}
+
+private fun LazyListScope.buttonPanel(onAddMethodClick: () -> Unit) {
+ item {
+ PrimaryButton(
+ modifier =
+ Modifier.padding(horizontal = Dimens.sideMargin, vertical = Dimens.largePadding),
+ onClick = onAddMethodClick,
+ text = stringResource(id = R.string.add)
+ )
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessMethodDetailsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessMethodDetailsScreen.kt
new file mode 100644
index 0000000000..0b3902aa7c
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessMethodDetailsScreen.kt
@@ -0,0 +1,300 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+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.PrimaryButton
+import net.mullvad.mullvadvpn.compose.button.TestMethodButton
+import net.mullvad.mullvadvpn.compose.cell.HeaderSwitchComposeCell
+import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell
+import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell
+import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge
+import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
+import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
+import net.mullvad.mullvadvpn.compose.destinations.DeleteApiAccessMethodConfirmationDestination
+import net.mullvad.mullvadvpn.compose.destinations.EditApiAccessMethodDestination
+import net.mullvad.mullvadvpn.compose.preview.ApiAccessMethodDetailsUiStatePreviewParameterProvider
+import net.mullvad.mullvadvpn.compose.state.ApiAccessMethodDetailsUiState
+import net.mullvad.mullvadvpn.compose.test.API_ACCESS_DETAILS_EDIT_BUTTON
+import net.mullvad.mullvadvpn.compose.test.API_ACCESS_DETAILS_TOP_BAR_DROPDOWN_BUTTON_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.API_ACCESS_TEST_METHOD_BUTTON
+import net.mullvad.mullvadvpn.compose.test.API_ACCESS_USE_METHOD_BUTTON
+import net.mullvad.mullvadvpn.compose.test.DELETE_DROPDOWN_MENU_ITEM_TEST_TAG
+import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition
+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.model.ApiAccessMethodId
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.menuItemColors
+import net.mullvad.mullvadvpn.viewmodel.ApiAccessMethodDetailsSideEffect
+import net.mullvad.mullvadvpn.viewmodel.ApiAccessMethodDetailsViewModel
+import org.koin.androidx.compose.koinViewModel
+import org.koin.core.parameter.parametersOf
+
+@Preview
+@Composable
+private fun PreviewApiAccessMethodDetailsScreen(
+ @PreviewParameter(ApiAccessMethodDetailsUiStatePreviewParameterProvider::class)
+ state: ApiAccessMethodDetailsUiState
+) {
+ AppTheme { ApiAccessMethodDetailsScreen(state = state) }
+}
+
+@Destination(style = SlideInFromRightTransition::class)
+@Composable
+fun ApiAccessMethodDetails(
+ navigator: DestinationsNavigator,
+ accessMethodId: ApiAccessMethodId,
+ confirmDeleteListResultRecipient:
+ ResultRecipient<DeleteApiAccessMethodConfirmationDestination, Boolean>
+) {
+ val viewModel =
+ koinViewModel<ApiAccessMethodDetailsViewModel>(
+ parameters = { parametersOf(accessMethodId) }
+ )
+
+ val snackbarHostState = remember { SnackbarHostState() }
+ val context = LocalContext.current
+ val coroutineScope = rememberCoroutineScope()
+
+ LaunchedEffectCollect(sideEffect = viewModel.uiSideEffect) {
+ when (it) {
+ ApiAccessMethodDetailsSideEffect.GenericError ->
+ launch {
+ snackbarHostState.showSnackbarImmediately(
+ context.getString(R.string.error_occurred)
+ )
+ }
+ is ApiAccessMethodDetailsSideEffect.OpenEditPage ->
+ navigator.navigate(EditApiAccessMethodDestination(it.apiAccessMethodId)) {
+ launchSingleTop = true
+ }
+ is ApiAccessMethodDetailsSideEffect.TestApiAccessMethodResult -> {
+ launch {
+ snackbarHostState.showSnackbarImmediately(
+ context.getString(
+ if (it.successful) {
+ R.string.api_reachable
+ } else {
+ R.string.api_unreachable
+ }
+ )
+ )
+ }
+ }
+ is ApiAccessMethodDetailsSideEffect.UnableToSetCurrentMethod ->
+ launch {
+ snackbarHostState.showSnackbarImmediately(
+ context.getString(
+ if (it.testMethodFailed) {
+ R.string.failed_to_set_current_test_error
+ } else {
+ R.string.failed_to_set_current_unknown_error
+ }
+ )
+ )
+ }
+ }
+ }
+
+ confirmDeleteListResultRecipient.OnNavResultValue { navigator.navigateUp() }
+
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(state.testingAccessMethod()) {
+ if (state.testingAccessMethod()) {
+ launch {
+ snackbarHostState.showSnackbarImmediately(
+ message = context.getString(R.string.testing_name, state.name()),
+ duration = SnackbarDuration.Indefinite,
+ actionLabel = context.getString(R.string.cancel),
+ onAction = viewModel::cancelTestMethod
+ )
+ }
+ }
+ }
+
+ ApiAccessMethodDetailsScreen(
+ state = state,
+ snackbarHostState = snackbarHostState,
+ onEditMethodClicked = viewModel::openEditPage,
+ onEnableClicked = viewModel::setEnableMethod,
+ onTestMethodClicked = viewModel::testMethod,
+ onUseMethodClicked = {
+ if (!state.currentMethod()) {
+ viewModel.setCurrentMethod()
+ } else {
+ coroutineScope.launch {
+ snackbarHostState.showSnackbarImmediately(
+ message = context.getString(R.string.this_is_already_set_as_current)
+ )
+ }
+ }
+ },
+ onDeleteApiAccessMethodClicked = {
+ navigator.navigate(DeleteApiAccessMethodConfirmationDestination(it)) {
+ launchSingleTop = true
+ }
+ },
+ onBackClicked = navigator::navigateUp,
+ )
+}
+
+@Composable
+fun ApiAccessMethodDetailsScreen(
+ state: ApiAccessMethodDetailsUiState,
+ snackbarHostState: SnackbarHostState = SnackbarHostState(),
+ onEditMethodClicked: () -> Unit = {},
+ onEnableClicked: (Boolean) -> Unit = {},
+ onTestMethodClicked: () -> Unit = {},
+ onUseMethodClicked: () -> Unit = {},
+ onDeleteApiAccessMethodClicked: (ApiAccessMethodId) -> Unit = {},
+ onBackClicked: () -> Unit = {}
+) {
+ ScaffoldWithMediumTopBar(
+ appBarTitle = state.name(),
+ navigationIcon = { NavigateBackIconButton(onBackClicked) },
+ snackbarHostState = snackbarHostState,
+ actions = {
+ if (state.canBeEdited()) {
+ Actions(
+ onDeleteAccessMethod = {
+ onDeleteApiAccessMethodClicked(state.apiAccessMethodId)
+ }
+ )
+ }
+ }
+ ) { modifier: Modifier ->
+ Column(modifier = modifier) {
+ when (state) {
+ is ApiAccessMethodDetailsUiState.Loading -> Loading()
+ is ApiAccessMethodDetailsUiState.Content ->
+ Content(
+ state = state,
+ onEditMethodClicked = onEditMethodClicked,
+ onEnableClicked = onEnableClicked,
+ onTestMethodClicked = onTestMethodClicked,
+ onUseMethodClicked = onUseMethodClicked
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ColumnScope.Loading() {
+ MullvadCircularProgressIndicatorLarge(modifier = Modifier.align(Alignment.CenterHorizontally))
+}
+
+@Composable
+private fun Content(
+ state: ApiAccessMethodDetailsUiState.Content,
+ onEditMethodClicked: () -> Unit,
+ onEnableClicked: (Boolean) -> Unit,
+ onTestMethodClicked: () -> Unit,
+ onUseMethodClicked: () -> Unit
+) {
+ if (state.isEditable) {
+ NavigationComposeCell(
+ title = stringResource(id = R.string.edit_method),
+ onClick = onEditMethodClicked,
+ testTag = API_ACCESS_DETAILS_EDIT_BUTTON
+ )
+ HorizontalDivider()
+ }
+ HeaderSwitchComposeCell(
+ isEnabled = state.isDisableable,
+ title = stringResource(id = R.string.enable_method),
+ isToggled = state.enabled,
+ onCellClicked = onEnableClicked
+ )
+ if (!state.isDisableable) {
+ SwitchComposeSubtitleCell(
+ text = stringResource(id = R.string.at_least_on_method_needs_to_enabled),
+ )
+ }
+ Spacer(modifier = Modifier.height(Dimens.verticalSpace))
+ TestMethodButton(
+ modifier =
+ Modifier.padding(horizontal = Dimens.sideMargin).testTag(API_ACCESS_TEST_METHOD_BUTTON),
+ isTesting = state.isTestingAccessMethod,
+ onTestMethod = onTestMethodClicked
+ )
+ Spacer(modifier = Modifier.height(Dimens.verticalSpace))
+ PrimaryButton(
+ isEnabled = !state.isTestingAccessMethod,
+ modifier =
+ Modifier.padding(horizontal = Dimens.sideMargin).testTag(API_ACCESS_USE_METHOD_BUTTON),
+ onClick = onUseMethodClicked,
+ text = stringResource(id = R.string.use_method)
+ )
+}
+
+@Composable
+private fun Actions(onDeleteAccessMethod: () -> Unit) {
+ var showMenu by remember { mutableStateOf(false) }
+ IconButton(
+ onClick = { showMenu = true },
+ modifier = Modifier.testTag(API_ACCESS_DETAILS_TOP_BAR_DROPDOWN_BUTTON_TEST_TAG)
+ ) {
+ Icon(painter = painterResource(id = R.drawable.icon_more_vert), contentDescription = null)
+ if (showMenu) {
+ DropdownMenu(
+ expanded = true,
+ onDismissRequest = { showMenu = false },
+ modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
+ ) {
+ DropdownMenuItem(
+ text = { Text(text = stringResource(id = R.string.delete_method)) },
+ leadingIcon = {
+ Icon(
+ painter = painterResource(id = R.drawable.icon_delete),
+ contentDescription = null,
+ )
+ },
+ colors = menuItemColors,
+ onClick = {
+ onDeleteAccessMethod()
+ showMenu = false
+ },
+ modifier = Modifier.testTag(DELETE_DROPDOWN_MENU_ITEM_TEST_TAG)
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreen.kt
new file mode 100644
index 0000000000..c6576cf21d
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreen.kt
@@ -0,0 +1,605 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+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.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.result.ResultBackNavigator
+import com.ramcosta.composedestinations.result.ResultRecipient
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.PrimaryButton
+import net.mullvad.mullvadvpn.compose.button.TestMethodButton
+import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge
+import net.mullvad.mullvadvpn.compose.component.MullvadDropdownMenuItem
+import net.mullvad.mullvadvpn.compose.component.MullvadExposedDropdownMenuBox
+import net.mullvad.mullvadvpn.compose.component.NavigateCloseIconButton
+import net.mullvad.mullvadvpn.compose.component.ScaffoldWithSmallTopBar
+import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
+import net.mullvad.mullvadvpn.compose.component.textResource
+import net.mullvad.mullvadvpn.compose.destinations.DiscardChangesDialogDestination
+import net.mullvad.mullvadvpn.compose.destinations.SaveApiAccessMethodDestination
+import net.mullvad.mullvadvpn.compose.preview.EditApiAccessMethodUiStateParameterProvider
+import net.mullvad.mullvadvpn.compose.state.ApiAccessMethodTypes
+import net.mullvad.mullvadvpn.compose.state.EditApiAccessFormData
+import net.mullvad.mullvadvpn.compose.state.EditApiAccessMethodUiState
+import net.mullvad.mullvadvpn.compose.test.EDIT_API_ACCESS_NAME_INPUT
+import net.mullvad.mullvadvpn.compose.textfield.ApiAccessMethodTextField
+import net.mullvad.mullvadvpn.compose.textfield.apiAccessTextFieldColors
+import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition
+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.model.ApiAccessMethodId
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName
+import net.mullvad.mullvadvpn.lib.model.Cipher
+import net.mullvad.mullvadvpn.lib.model.InvalidDataError
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible
+import net.mullvad.mullvadvpn.viewmodel.EditApiAccessMethodViewModel
+import net.mullvad.mullvadvpn.viewmodel.EditApiAccessSideEffect
+import org.koin.androidx.compose.koinViewModel
+import org.koin.core.parameter.parametersOf
+
+@Preview
+@Composable
+private fun PreviewEditApiAccessMethodScreen(
+ @PreviewParameter(EditApiAccessMethodUiStateParameterProvider::class)
+ state: EditApiAccessMethodUiState
+) {
+ AppTheme { EditApiAccessMethodScreen(state = state) }
+}
+
+@Destination(style = SlideInFromRightTransition::class)
+@Composable
+fun EditApiAccessMethod(
+ navigator: DestinationsNavigator,
+ backNavigator: ResultBackNavigator<Boolean>,
+ saveApiAccessMethodResultRecipient: ResultRecipient<SaveApiAccessMethodDestination, Boolean>,
+ discardChangesResultRecipient: ResultRecipient<DiscardChangesDialogDestination, Boolean>,
+ accessMethodId: ApiAccessMethodId?
+) {
+ val viewModel =
+ koinViewModel<EditApiAccessMethodViewModel>(parameters = { parametersOf(accessMethodId) })
+
+ val snackbarHostState = remember { SnackbarHostState() }
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ LaunchedEffectCollect(sideEffect = viewModel.uiSideEffect) {
+ when (it) {
+ is EditApiAccessSideEffect.OpenSaveDialog ->
+ navigator.navigate(
+ SaveApiAccessMethodDestination(
+ id = it.id,
+ name = it.name,
+ customProxy = it.customProxy
+ )
+ ) {
+ launchSingleTop = true
+ }
+ is EditApiAccessSideEffect.TestApiAccessMethodResult -> {
+ launch {
+ snackbarHostState.showSnackbarImmediately(
+ message =
+ context.getString(
+ if (it.successful) {
+ R.string.api_reachable
+ } else {
+ R.string.api_unreachable
+ }
+ )
+ )
+ }
+ }
+ }
+ }
+
+ saveApiAccessMethodResultRecipient.OnNavResultValue { saveSuccessful ->
+ if (saveSuccessful) {
+ backNavigator.navigateBack(result = true)
+ } else {
+ // Show error snackbar
+ scope.launch {
+ snackbarHostState.showSnackbarImmediately(
+ message = context.getString(R.string.error_occurred)
+ )
+ }
+ }
+ }
+
+ discardChangesResultRecipient.OnNavResultValue { discardChanges ->
+ if (discardChanges) {
+ navigator.navigateUp()
+ }
+ }
+
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(state.testingApiAccessMethod()) {
+ if (state.testingApiAccessMethod()) {
+ launch {
+ snackbarHostState.showSnackbarImmediately(
+ message = context.getString(R.string.testing),
+ duration = SnackbarDuration.Indefinite,
+ actionLabel = context.getString(R.string.cancel),
+ onAction = viewModel::cancelTestMethod
+ )
+ }
+ }
+ }
+
+ EditApiAccessMethodScreen(
+ state = state,
+ snackbarHostState = snackbarHostState,
+ onNameChanged = viewModel::onNameChanged,
+ onTypeSelected = viewModel::setAccessMethodType,
+ onIpChanged = viewModel::onServerIpChanged,
+ onPortChanged = viewModel::onPortChanged,
+ onPasswordChanged = viewModel::onPasswordChanged,
+ onCipherChange = viewModel::onCipherChanged,
+ onToggleAuthenticationEnabled = viewModel::onAuthenticationEnabledChanged,
+ onUsernameChanged = viewModel::onUsernameChanged,
+ onTestMethod = viewModel::testMethod,
+ onAddMethod = viewModel::trySave,
+ onNavigateBack = {
+ if (state.hasChanges()) {
+ navigator.navigate(DiscardChangesDialogDestination) { launchSingleTop = true }
+ } else {
+ navigator.navigateUp()
+ }
+ }
+ )
+}
+
+@Composable
+fun EditApiAccessMethodScreen(
+ state: EditApiAccessMethodUiState,
+ snackbarHostState: SnackbarHostState = SnackbarHostState(),
+ onNameChanged: (String) -> Unit = {},
+ onTypeSelected: (ApiAccessMethodTypes) -> Unit = {},
+ onIpChanged: (String) -> Unit = {},
+ onPortChanged: (String) -> Unit = {},
+ onPasswordChanged: (String) -> Unit = {},
+ onCipherChange: (Cipher) -> Unit = {},
+ onToggleAuthenticationEnabled: (Boolean) -> Unit = {},
+ onUsernameChanged: (String) -> Unit = {},
+ onTestMethod: () -> Unit = {},
+ onAddMethod: () -> Unit = {},
+ onNavigateBack: () -> Unit = {}
+) {
+ ScaffoldWithSmallTopBar(
+ snackbarHostState = snackbarHostState,
+ navigationIcon = { NavigateCloseIconButton(onNavigateClose = onNavigateBack) },
+ appBarTitle =
+ stringResource(
+ if (state.editMode) {
+ R.string.edit_method
+ } else {
+ R.string.add_method
+ }
+ ),
+ ) { modifier ->
+ val scrollState = rememberScrollState()
+ Column(
+ modifier =
+ modifier
+ .drawVerticalScrollbar(
+ state = scrollState,
+ color = MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar)
+ )
+ .verticalScroll(scrollState)
+ .padding(horizontal = Dimens.sideMargin, vertical = Dimens.screenVerticalMargin)
+ ) {
+ when (state) {
+ is EditApiAccessMethodUiState.Loading -> Loading()
+ is EditApiAccessMethodUiState.Content -> {
+ NameInputField(
+ name = state.formData.name,
+ nameError = state.formData.nameError,
+ onNameChanged = onNameChanged
+ )
+ Spacer(modifier = Modifier.height(Dimens.verticalSpace))
+ ApiAccessMethodTypeSelection(state.formData, onTypeSelected)
+ Spacer(modifier = Modifier.height(Dimens.verticalSpace))
+ when (state.formData.apiAccessMethodTypes) {
+ ApiAccessMethodTypes.SHADOWSOCKS ->
+ ShadowsocksForm(
+ formData = state.formData,
+ onIpChanged = onIpChanged,
+ onPortChanged = onPortChanged,
+ onPasswordChanged = onPasswordChanged,
+ onCipherChange = onCipherChange
+ )
+ ApiAccessMethodTypes.SOCKS5_REMOTE ->
+ Socks5RemoteForm(
+ formData = state.formData,
+ onIpChanged = onIpChanged,
+ onPortChanged = onPortChanged,
+ onToggleAuthenticationEnabled = onToggleAuthenticationEnabled,
+ onUsernameChanged = onUsernameChanged,
+ onPasswordChanged = onPasswordChanged
+ )
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ TestMethodButton(
+ modifier =
+ Modifier.padding(
+ bottom = Dimens.verticalSpace,
+ top = Dimens.largePadding
+ ),
+ isTesting = state.isTestingApiAccessMethod,
+ onTestMethod = onTestMethod
+ )
+ AddMethodButton(isNew = !state.editMode, onAddMethod = onAddMethod)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ColumnScope.Loading() {
+ MullvadCircularProgressIndicatorLarge(modifier = Modifier.align(Alignment.CenterHorizontally))
+}
+
+@Composable
+private fun NameInputField(
+ name: String,
+ nameError: InvalidDataError.NameError?,
+ onNameChanged: (String) -> Unit
+) {
+ ApiAccessMethodTextField(
+ value = name,
+ keyboardType = KeyboardType.Text,
+ onValueChanged = onNameChanged,
+ labelText = stringResource(id = R.string.name),
+ isValidValue = nameError == null,
+ isDigitsOnlyAllowed = false,
+ maxCharLength = ApiAccessMethodName.MAX_LENGTH,
+ errorText = nameError?.let { textResource(id = R.string.this_field_is_required) },
+ capitalization = KeyboardCapitalization.Words,
+ modifier = Modifier.animateContentSize().testTag(EDIT_API_ACCESS_NAME_INPUT)
+ )
+}
+
+@Composable
+private fun ApiAccessMethodTypeSelection(
+ formData: EditApiAccessFormData,
+ onTypeSelected: (ApiAccessMethodTypes) -> Unit
+) {
+ MullvadExposedDropdownMenuBox(
+ modifier = Modifier.padding(vertical = Dimens.miniPadding),
+ label = stringResource(id = R.string.type),
+ title = formData.apiAccessMethodTypes.text(),
+ colors = apiAccessTextFieldColors()
+ ) { close ->
+ ApiAccessMethodTypes.entries.forEach {
+ MullvadDropdownMenuItem(
+ text = it.text(),
+ onClick = {
+ close()
+ onTypeSelected(it)
+ },
+ leadingIcon = {
+ Icon(
+ painter = painterResource(id = R.drawable.icon_tick),
+ contentDescription = null,
+ modifier =
+ Modifier.padding(end = Dimens.selectableCellTextMargin)
+ .alpha(
+ if (it == formData.apiAccessMethodTypes) AlphaVisible
+ else AlphaInvisible
+ )
+ )
+ }
+ )
+ }
+ }
+}
+
+@Composable
+private fun ShadowsocksForm(
+ formData: EditApiAccessFormData,
+ onIpChanged: (String) -> Unit,
+ onPortChanged: (String) -> Unit,
+ onPasswordChanged: (String) -> Unit,
+ onCipherChange: (Cipher) -> Unit
+) {
+ ServerIpInput(
+ serverIp = formData.serverIp,
+ serverIpError = formData.serverIpError,
+ onIpChanged = onIpChanged
+ )
+ PortInput(port = formData.port, formData.portError, onPortChanged = onPortChanged)
+ PasswordInput(
+ password = formData.password,
+ passwordError = formData.passwordError,
+ optional = true,
+ onPasswordChanged = onPasswordChanged
+ )
+ CipherSelection(cipher = formData.cipher, onCipherChange = onCipherChange)
+}
+
+@Composable
+private fun Socks5RemoteForm(
+ formData: EditApiAccessFormData,
+ onIpChanged: (String) -> Unit,
+ onPortChanged: (String) -> Unit,
+ onToggleAuthenticationEnabled: (Boolean) -> Unit,
+ onUsernameChanged: (String) -> Unit,
+ onPasswordChanged: (String) -> Unit
+) {
+ ServerIpInput(
+ serverIp = formData.serverIp,
+ serverIpError = formData.serverIpError,
+ onIpChanged = onIpChanged
+ )
+ PortInput(port = formData.port, portError = formData.portError, onPortChanged = onPortChanged)
+ EnableAuthentication(formData.enableAuthentication, onToggleAuthenticationEnabled)
+ if (formData.enableAuthentication) {
+ UsernameInput(
+ username = formData.username,
+ usernameError = formData.usernameError,
+ onUsernameChanged = onUsernameChanged,
+ )
+ PasswordInput(
+ password = formData.password,
+ passwordError = formData.passwordError,
+ optional = false,
+ onPasswordChanged = onPasswordChanged
+ )
+ }
+}
+
+@Composable
+private fun ServerIpInput(
+ serverIp: String,
+ serverIpError: InvalidDataError.ServerIpError?,
+ onIpChanged: (String) -> Unit
+) {
+ ApiAccessMethodTextField(
+ value = serverIp,
+ keyboardType = KeyboardType.Text,
+ onValueChanged = onIpChanged,
+ labelText = stringResource(id = R.string.server),
+ isValidValue = serverIpError == null,
+ isDigitsOnlyAllowed = false,
+ errorText =
+ serverIpError?.let {
+ textResource(
+ id =
+ when (it) {
+ InvalidDataError.ServerIpError.Invalid ->
+ R.string.please_enter_a_valid_ip_address
+ InvalidDataError.ServerIpError.Required ->
+ R.string.this_field_is_required
+ }
+ )
+ },
+ modifier = Modifier.animateContentSize()
+ )
+}
+
+@Composable
+private fun PortInput(
+ port: String,
+ portError: InvalidDataError.PortError?,
+ onPortChanged: (String) -> Unit
+) {
+ ApiAccessMethodTextField(
+ value = port,
+ keyboardType = KeyboardType.Number,
+ onValueChanged = onPortChanged,
+ labelText = stringResource(id = R.string.port),
+ isValidValue = portError == null,
+ isDigitsOnlyAllowed = false,
+ errorText =
+ portError?.let {
+ textResource(
+ id =
+ when (it) {
+ is InvalidDataError.PortError.Invalid ->
+ R.string.please_enter_a_valid_remote_server_port
+ InvalidDataError.PortError.Required -> R.string.this_field_is_required
+ }
+ )
+ },
+ modifier = Modifier.animateContentSize()
+ )
+}
+
+@Composable
+private fun PasswordInput(
+ password: String,
+ passwordError: InvalidDataError.PasswordError?,
+ optional: Boolean,
+ onPasswordChanged: (String) -> Unit
+) {
+ ApiAccessMethodTextField(
+ value = password,
+ keyboardType = KeyboardType.Password,
+ onValueChanged = onPasswordChanged,
+ labelText =
+ stringResource(
+ id =
+ if (optional) {
+ R.string.password_optional
+ } else {
+ R.string.password
+ }
+ ),
+ isValidValue = passwordError == null,
+ isDigitsOnlyAllowed = false,
+ imeAction =
+ // So that we avoid going back to the name input when pressing done/next
+ if (optional) {
+ ImeAction.Next
+ } else {
+ ImeAction.Done
+ },
+ errorText = passwordError?.let { textResource(id = R.string.this_field_is_required) },
+ modifier = Modifier.animateContentSize()
+ )
+}
+
+@Composable
+private fun CipherSelection(cipher: Cipher, onCipherChange: (Cipher) -> Unit) {
+ MullvadExposedDropdownMenuBox(
+ modifier = Modifier.padding(vertical = Dimens.miniPadding),
+ label = stringResource(id = R.string.cipher),
+ title = cipher.label,
+ colors = apiAccessTextFieldColors()
+ ) { close ->
+ Cipher.listAll().forEach {
+ MullvadDropdownMenuItem(
+ text = it.label,
+ onClick = {
+ close()
+ onCipherChange(it)
+ },
+ leadingIcon = {
+ Icon(
+ painter = painterResource(id = R.drawable.icon_tick),
+ contentDescription = null,
+ modifier =
+ Modifier.padding(end = Dimens.selectableCellTextMargin)
+ .alpha(if (it == cipher) AlphaVisible else AlphaInvisible)
+ )
+ }
+ )
+ }
+ }
+}
+
+@Composable
+private fun EnableAuthentication(
+ authenticationEnabled: Boolean,
+ onToggleAuthenticationEnabled: (Boolean) -> Unit
+) {
+ MullvadExposedDropdownMenuBox(
+ modifier = Modifier.padding(vertical = Dimens.miniPadding),
+ label = stringResource(id = R.string.authentication),
+ title =
+ stringResource(
+ id =
+ if (authenticationEnabled) {
+ R.string.on
+ } else {
+ R.string.off
+ }
+ ),
+ colors = apiAccessTextFieldColors()
+ ) { close ->
+ MullvadDropdownMenuItem(
+ text = stringResource(id = R.string.on),
+ onClick = {
+ close()
+ onToggleAuthenticationEnabled(true)
+ },
+ leadingIcon = {
+ Icon(
+ painter = painterResource(id = R.drawable.icon_tick),
+ contentDescription = null,
+ modifier =
+ Modifier.padding(end = Dimens.selectableCellTextMargin)
+ .alpha(if (authenticationEnabled) AlphaVisible else AlphaInvisible)
+ )
+ }
+ )
+ MullvadDropdownMenuItem(
+ text = stringResource(id = R.string.off),
+ onClick = {
+ close()
+ onToggleAuthenticationEnabled(false)
+ },
+ leadingIcon = {
+ Icon(
+ painter = painterResource(id = R.drawable.icon_tick),
+ contentDescription = null,
+ modifier =
+ Modifier.padding(end = Dimens.selectableCellTextMargin)
+ .alpha(
+ if (authenticationEnabled.not()) AlphaVisible else AlphaInvisible
+ )
+ )
+ }
+ )
+ }
+}
+
+@Composable
+private fun UsernameInput(
+ username: String,
+ usernameError: InvalidDataError.UserNameError?,
+ onUsernameChanged: (String) -> Unit
+) {
+ ApiAccessMethodTextField(
+ value = username,
+ keyboardType = KeyboardType.Text,
+ onValueChanged = onUsernameChanged,
+ labelText = stringResource(id = R.string.username),
+ isValidValue = usernameError == null,
+ isDigitsOnlyAllowed = false,
+ errorText = usernameError?.let { textResource(id = R.string.this_field_is_required) },
+ modifier = Modifier.animateContentSize()
+ )
+}
+
+@Composable
+private fun AddMethodButton(isNew: Boolean, onAddMethod: () -> Unit) {
+ PrimaryButton(
+ onClick = onAddMethod,
+ text =
+ stringResource(
+ id =
+ if (isNew) {
+ R.string.add
+ } else {
+ R.string.save
+ }
+ )
+ )
+}
+
+@Composable
+private fun ApiAccessMethodTypes.text(): String =
+ stringResource(
+ id =
+ when (this) {
+ ApiAccessMethodTypes.SHADOWSOCKS -> R.string.shadowsocks
+ ApiAccessMethodTypes.SOCKS5_REMOTE -> R.string.socks5_remote
+ },
+ )
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt
index 0deadd545c..ed0285eaf1 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt
@@ -7,7 +7,6 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -47,6 +46,7 @@ import net.mullvad.mullvadvpn.lib.model.CustomListName
import net.mullvad.mullvadvpn.lib.model.GeoLocationId
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.menuItemColors
import net.mullvad.mullvadvpn.viewmodel.EditCustomListViewModel
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -201,12 +201,7 @@ private fun Actions(enabled: Boolean, onDeleteList: () -> Unit) {
contentDescription = null,
)
},
- colors =
- MenuDefaults.itemColors()
- .copy(
- leadingIconColor = MaterialTheme.colorScheme.onSurface,
- textColor = MaterialTheme.colorScheme.onSurface,
- ),
+ colors = menuItemColors,
onClick = {
onDeleteList()
showMenu = false
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt
index 611f2e29ae..cb84246e34 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt
@@ -27,6 +27,7 @@ import net.mullvad.mullvadvpn.compose.cell.NavigationCellBody
import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell
import net.mullvad.mullvadvpn.compose.component.NavigateBackDownIconButton
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
+import net.mullvad.mullvadvpn.compose.destinations.ApiAccessListDestination
import net.mullvad.mullvadvpn.compose.destinations.ReportProblemDestination
import net.mullvad.mullvadvpn.compose.destinations.SplitTunnelingDestination
import net.mullvad.mullvadvpn.compose.destinations.VpnSettingsDestination
@@ -75,6 +76,9 @@ fun Settings(navigator: DestinationsNavigator) {
onReportProblemCellClick = {
navigator.navigate(ReportProblemDestination) { launchSingleTop = true }
},
+ onApiAccessClick = {
+ navigator.navigate(ApiAccessListDestination) { launchSingleTop = true }
+ },
onBackClick = navigator::navigateUp
)
}
@@ -86,6 +90,7 @@ fun SettingsScreen(
onVpnSettingCellClick: () -> Unit = {},
onSplitTunnelingCellClick: () -> Unit = {},
onReportProblemCellClick: () -> Unit = {},
+ onApiAccessClick: () -> Unit = {},
onBackClick: () -> Unit = {}
) {
val context = LocalContext.current
@@ -111,6 +116,14 @@ fun SettingsScreen(
item { Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) }
}
+ item {
+ NavigationComposeCell(
+ title = stringResource(id = R.string.settings_api_access),
+ onClick = onApiAccessClick
+ )
+ }
+ item { Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) }
+
item { AppVersion(context, state) }
item { Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiAccessListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiAccessListUiState.kt
new file mode 100644
index 0000000000..91b84d36b7
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiAccessListUiState.kt
@@ -0,0 +1,8 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodSetting
+
+data class ApiAccessListUiState(
+ val currentApiAccessMethodSetting: ApiAccessMethodSetting? = null,
+ val apiAccessMethodSettings: List<ApiAccessMethodSetting> = emptyList()
+)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiAccessMethodDetailsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiAccessMethodDetailsUiState.kt
new file mode 100644
index 0000000000..d91bf850d0
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiAccessMethodDetailsUiState.kt
@@ -0,0 +1,29 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName
+
+sealed interface ApiAccessMethodDetailsUiState {
+ val apiAccessMethodId: ApiAccessMethodId
+
+ data class Loading(override val apiAccessMethodId: ApiAccessMethodId) :
+ ApiAccessMethodDetailsUiState
+
+ data class Content(
+ override val apiAccessMethodId: ApiAccessMethodId,
+ val name: ApiAccessMethodName,
+ val enabled: Boolean,
+ val isEditable: Boolean,
+ val isDisableable: Boolean,
+ val isCurrentMethod: Boolean,
+ val isTestingAccessMethod: Boolean,
+ ) : ApiAccessMethodDetailsUiState
+
+ fun name() = (this as? Content)?.name?.value ?: ""
+
+ fun canBeEdited() = this is Content && isEditable
+
+ fun testingAccessMethod() = this is Content && isTestingAccessMethod
+
+ fun currentMethod() = this is Content && isCurrentMethod
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeleteApiAccessMethodUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeleteApiAccessMethodUiState.kt
new file mode 100644
index 0000000000..8e08818cca
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeleteApiAccessMethodUiState.kt
@@ -0,0 +1,5 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import net.mullvad.mullvadvpn.lib.model.RemoveApiAccessMethodError
+
+data class DeleteApiAccessMethodUiState(val deleteError: RemoveApiAccessMethodError?)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditApiAccessMethodUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditApiAccessMethodUiState.kt
new file mode 100644
index 0000000000..77590611c0
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditApiAccessMethodUiState.kt
@@ -0,0 +1,87 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import arrow.core.NonEmptyList
+import net.mullvad.mullvadvpn.lib.common.util.getFirstInstanceOrNull
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName
+import net.mullvad.mullvadvpn.lib.model.Cipher
+import net.mullvad.mullvadvpn.lib.model.InvalidDataError
+
+sealed interface EditApiAccessMethodUiState {
+ val editMode: Boolean
+
+ data class Loading(override val editMode: Boolean) : EditApiAccessMethodUiState
+
+ data class Content(
+ override val editMode: Boolean,
+ val formData: EditApiAccessFormData,
+ val hasChanges: Boolean,
+ val isTestingApiAccessMethod: Boolean,
+ ) : EditApiAccessMethodUiState
+
+ fun hasChanges() = this is Content && hasChanges
+
+ fun testingApiAccessMethod(): Boolean = this is Content && isTestingApiAccessMethod
+}
+
+data class EditApiAccessFormData(
+ val name: String,
+ val nameError: InvalidDataError.NameError? = null,
+ val apiAccessMethodTypes: ApiAccessMethodTypes = ApiAccessMethodTypes.default(),
+ val serverIp: String,
+ val serverIpError: InvalidDataError.ServerIpError? = null,
+ val port: String,
+ val portError: InvalidDataError.PortError? = null,
+ val enableAuthentication: Boolean = false,
+ val username: String,
+ val usernameError: InvalidDataError.UserNameError? = null,
+ val password: String,
+ val passwordError: InvalidDataError.PasswordError? = null,
+ val cipher: Cipher = Cipher.first()
+) {
+ fun updateWithErrors(errors: NonEmptyList<InvalidDataError>): EditApiAccessFormData =
+ copy(
+ nameError = errors.getFirstInstanceOrNull(),
+ serverIpError = errors.getFirstInstanceOrNull(),
+ portError = errors.getFirstInstanceOrNull(),
+ usernameError = errors.getFirstInstanceOrNull(),
+ passwordError = errors.getFirstInstanceOrNull()
+ )
+
+ companion object {
+ fun empty() =
+ EditApiAccessFormData(name = "", password = "", port = "", serverIp = "", username = "")
+
+ fun fromCustomProxy(name: ApiAccessMethodName, customProxy: ApiAccessMethod.CustomProxy) =
+ when (customProxy) {
+ is ApiAccessMethod.CustomProxy.Shadowsocks -> {
+ EditApiAccessFormData(
+ name = name.value,
+ serverIp = customProxy.ip,
+ port = customProxy.port.toString(),
+ password = customProxy.password ?: "",
+ cipher = customProxy.cipher,
+ username = "",
+ )
+ }
+ is ApiAccessMethod.CustomProxy.Socks5Remote ->
+ EditApiAccessFormData(
+ name = name.value,
+ serverIp = customProxy.ip,
+ port = customProxy.port.toString(),
+ enableAuthentication = customProxy.auth != null,
+ username = customProxy.auth?.username ?: "",
+ password = customProxy.auth?.password ?: ""
+ )
+ }
+ }
+}
+
+enum class ApiAccessMethodTypes {
+ SHADOWSOCKS,
+ SOCKS5_REMOTE;
+
+ companion object {
+ fun default(): ApiAccessMethodTypes = SHADOWSOCKS
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SaveApiAccessMethodUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SaveApiAccessMethodUiState.kt
new file mode 100644
index 0000000000..e38a4de569
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SaveApiAccessMethodUiState.kt
@@ -0,0 +1,16 @@
+package net.mullvad.mullvadvpn.compose.state
+
+data class SaveApiAccessMethodUiState(
+ val testingState: TestApiAccessMethodState = TestApiAccessMethodState.Testing,
+ val isSaving: Boolean = false
+)
+
+sealed interface TestApiAccessMethodState {
+ data object Testing : TestApiAccessMethodState
+
+ sealed interface Result : TestApiAccessMethodState {
+ data object Successful : Result
+
+ data object Failure : Result
+ }
+}
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 0111fc7a46..47c109d353 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
@@ -81,3 +81,24 @@ const val SERVER_IP_OVERRIDES_IMPORT_BY_TEXT_TEST_TAG = "server_ip_override_impo
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"
+
+// SaveApiAccessMethodDialog
+const val SAVE_API_ACCESS_METHOD_LOADING_SPINNER_TEST_TAG =
+ "save_api_access_method_loading_spinner_test_tag"
+const val SAVE_API_ACCESS_METHOD_CANCEL_BUTTON_TEST_TAG =
+ "save_api_access_method_cancel_button_test_tag"
+const val SAVE_API_ACCESS_METHOD_SAVE_BUTTON_TEST_TAG =
+ "save_api_access_method_save_button_test_tag"
+
+// ApiAccessListScreen
+const val API_ACCESS_LIST_INFO_TEST_TAG = "api_access_list_info_test_tag"
+
+// ApiAccessMethodDetailsScreen
+const val API_ACCESS_DETAILS_TOP_BAR_DROPDOWN_BUTTON_TEST_TAG =
+ "api_access_details_top_bar_dropdown_button_test_tag"
+const val API_ACCESS_DETAILS_EDIT_BUTTON = "api_access_details_edit_button_test_tag"
+const val API_ACCESS_USE_METHOD_BUTTON = "api_access_details_use_method_test_tag"
+const val API_ACCESS_TEST_METHOD_BUTTON = "api_access_details_test_method_test_tag"
+
+// EditApiAccessMethodScreen
+const val EDIT_API_ACCESS_NAME_INPUT = "edit_api_access_name_input"
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/ApiAccessMethodTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/ApiAccessMethodTextField.kt
new file mode 100644
index 0000000000..614470da48
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/ApiAccessMethodTextField.kt
@@ -0,0 +1,85 @@
+package net.mullvad.mullvadvpn.compose.textfield
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.KeyboardOptions
+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.platform.LocalFocusManager
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+
+@Composable
+fun ApiAccessMethodTextField(
+ value: String,
+ keyboardType: KeyboardType,
+ modifier: Modifier = Modifier,
+ onValueChanged: (String) -> Unit,
+ labelText: String?,
+ maxCharLength: Int = Int.MAX_VALUE,
+ isValidValue: Boolean,
+ isDigitsOnlyAllowed: Boolean,
+ errorText: String?,
+ capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
+ imeAction: ImeAction = ImeAction.Next,
+) {
+ val focusManager = LocalFocusManager.current
+ CustomTextField(
+ value = value,
+ keyboardType = keyboardType,
+ onValueChanged = onValueChanged,
+ onSubmit = {
+ if (imeAction == ImeAction.Done) {
+ focusManager.clearFocus()
+ }
+ },
+ labelText = labelText,
+ placeholderText = null,
+ isValidValue = isValidValue,
+ isDigitsOnlyAllowed = isDigitsOnlyAllowed,
+ maxCharLength = maxCharLength,
+ supportingText = errorText?.let { { ErrorSupportingText(errorText) } },
+ colors = apiAccessTextFieldColors(),
+ modifier =
+ modifier
+ .defaultMinSize(minHeight = Dimens.formTextFieldMinHeight)
+ .padding(vertical = Dimens.miniPadding),
+ keyboardOptions =
+ KeyboardOptions(
+ capitalization = capitalization,
+ autoCorrect = false,
+ keyboardType = keyboardType,
+ imeAction = imeAction
+ )
+ )
+}
+
+@Composable
+private fun ErrorSupportingText(text: String) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(top = Dimens.miniPadding)
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.icon_alert),
+ contentDescription = null,
+ modifier = Modifier.size(Dimens.smallIconSize)
+ )
+ Text(
+ text = text,
+ color = MaterialTheme.colorScheme.onSecondary,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(horizontal = Dimens.smallPadding)
+ )
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt
index be5750ef5c..ac73e9fa34 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt
@@ -7,6 +7,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
@@ -35,11 +36,19 @@ fun CustomTextField(
onSubmit: (String) -> Unit,
isEnabled: Boolean = true,
placeholderText: String?,
+ labelText: String? = null,
maxCharLength: Int = Int.MAX_VALUE,
isValidValue: Boolean,
isDigitsOnlyAllowed: Boolean,
visualTransformation: VisualTransformation = VisualTransformation.None,
supportingText: @Composable (() -> Unit)? = null,
+ colors: TextFieldColors = mullvadDarkTextFieldColors(),
+ keyboardOptions: KeyboardOptions =
+ KeyboardOptions(
+ keyboardType = keyboardType,
+ imeAction = ImeAction.Done,
+ autoCorrect = false,
+ )
) {
val scope = rememberCoroutineScope()
@@ -84,12 +93,7 @@ fun CustomTextField(
enabled = isEnabled,
singleLine = true,
placeholder = placeholderText?.let { { Text(text = it) } },
- keyboardOptions =
- KeyboardOptions(
- keyboardType = keyboardType,
- imeAction = ImeAction.Done,
- autoCorrect = false,
- ),
+ keyboardOptions = keyboardOptions,
keyboardActions =
KeyboardActions(
onDone = {
@@ -101,9 +105,10 @@ fun CustomTextField(
}
),
visualTransformation = visualTransformation,
- colors = mullvadDarkTextFieldColors(),
+ colors = colors,
isError = !isValidValue,
modifier = modifier.clip(MaterialTheme.shapes.small).fillMaxWidth(),
- supportingText = supportingText
+ supportingText = supportingText,
+ label = labelText?.let { { Text(text = labelText) } },
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/TextFieldColors.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/TextFieldColors.kt
index b0770632bb..69b387ee7a 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/TextFieldColors.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/TextFieldColors.kt
@@ -109,3 +109,30 @@ fun mullvadDarkTextFieldColors(): TextFieldColors =
errorIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
)
+
+@Composable
+fun apiAccessTextFieldColors(): TextFieldColors =
+ TextFieldDefaults.colors(
+ focusedTextColor = MaterialTheme.colorScheme.onSurface,
+ unfocusedTextColor = MaterialTheme.colorScheme.onSurface,
+ disabledTextColor = MaterialTheme.colorScheme.onSurface,
+ disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ errorContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ errorTextColor = MaterialTheme.colorScheme.onSurface,
+ cursorColor = MaterialTheme.colorScheme.onSurface,
+ focusedPlaceholderColor = MaterialTheme.colorScheme.onSecondary,
+ unfocusedPlaceholderColor = MaterialTheme.colorScheme.onSecondary,
+ focusedLabelColor = MaterialTheme.colorScheme.onSecondary,
+ disabledLabelColor = MaterialTheme.colorScheme.onSecondary,
+ unfocusedLabelColor = MaterialTheme.colorScheme.onSecondary,
+ errorLabelColor = MaterialTheme.colorScheme.onSecondary,
+ focusedIndicatorColor = MaterialTheme.colorScheme.onSurface,
+ disabledIndicatorColor = Color.Transparent,
+ errorIndicatorColor = MaterialTheme.colorScheme.error,
+ unfocusedIndicatorColor = Color.Transparent,
+ focusedTrailingIconColor = MaterialTheme.colorScheme.onSurface,
+ disabledTrailingIconColor = MaterialTheme.colorScheme.onSurface,
+ unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurface
+ )
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 cdb0f9e0a3..70153de619 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
@@ -9,9 +9,13 @@ import net.mullvad.mullvadvpn.BuildConfig
import net.mullvad.mullvadvpn.applist.ApplicationsProvider
import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD
import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName
import net.mullvad.mullvadvpn.lib.model.GeoLocationId
import net.mullvad.mullvadvpn.lib.payment.PaymentProvider
import net.mullvad.mullvadvpn.lib.shared.VoucherRepository
+import net.mullvad.mullvadvpn.repository.ApiAccessRepository
import net.mullvad.mullvadvpn.repository.ChangelogRepository
import net.mullvad.mullvadvpn.repository.CustomListsRepository
import net.mullvad.mullvadvpn.repository.InAppNotificationController
@@ -44,15 +48,19 @@ import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase
import net.mullvad.mullvadvpn.util.ChangelogDataProvider
import net.mullvad.mullvadvpn.util.IChangelogDataProvider
import net.mullvad.mullvadvpn.viewmodel.AccountViewModel
+import net.mullvad.mullvadvpn.viewmodel.ApiAccessListViewModel
+import net.mullvad.mullvadvpn.viewmodel.ApiAccessMethodDetailsViewModel
import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel
import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel
import net.mullvad.mullvadvpn.viewmodel.CreateCustomListDialogViewModel
import net.mullvad.mullvadvpn.viewmodel.CustomListLocationsViewModel
import net.mullvad.mullvadvpn.viewmodel.CustomListsViewModel
+import net.mullvad.mullvadvpn.viewmodel.DeleteApiAccessMethodConfirmationViewModel
import net.mullvad.mullvadvpn.viewmodel.DeleteCustomListConfirmationViewModel
import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel
import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel
import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewModel
+import net.mullvad.mullvadvpn.viewmodel.EditApiAccessMethodViewModel
import net.mullvad.mullvadvpn.viewmodel.EditCustomListNameDialogViewModel
import net.mullvad.mullvadvpn.viewmodel.EditCustomListViewModel
import net.mullvad.mullvadvpn.viewmodel.FilterViewModel
@@ -64,6 +72,7 @@ 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.SaveApiAccessMethodViewModel
import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel
import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesViewModel
import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel
@@ -112,6 +121,7 @@ val uiModule = module {
single { RelayListFilterRepository(get()) }
single { VoucherRepository(get(), get()) }
single { SplitTunnelingRepository(get()) }
+ single { ApiAccessRepository(get()) }
single { AccountExpiryNotificationUseCase(get()) }
single { TunnelStateNotificationUseCase(get()) }
@@ -198,6 +208,23 @@ val uiModule = module {
viewModel { ServerIpOverridesViewModel(get(), get()) }
viewModel { ResetServerIpOverridesConfirmationViewModel(get()) }
viewModel { VpnPermissionViewModel(get(), get()) }
+ viewModel { ApiAccessListViewModel(get()) }
+ viewModel { (accessMethodId: ApiAccessMethodId?) ->
+ EditApiAccessMethodViewModel(accessMethodId, get(), get())
+ }
+ viewModel {
+ (
+ id: ApiAccessMethodId?,
+ name: ApiAccessMethodName,
+ customProxy: ApiAccessMethod.CustomProxy) ->
+ SaveApiAccessMethodViewModel(id, name, customProxy, get())
+ }
+ viewModel { (accessMethodId: ApiAccessMethodId) ->
+ ApiAccessMethodDetailsViewModel(accessMethodId, get())
+ }
+ viewModel { (accessMethodId: ApiAccessMethodId) ->
+ DeleteApiAccessMethodConfirmationViewModel(accessMethodId, 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/viewmodel/ApiAccessListViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiAccessListViewModel.kt
new file mode 100644
index 0000000000..cabc452b0a
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiAccessListViewModel.kt
@@ -0,0 +1,23 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import net.mullvad.mullvadvpn.compose.state.ApiAccessListUiState
+import net.mullvad.mullvadvpn.repository.ApiAccessRepository
+
+class ApiAccessListViewModel(apiAccessRepository: ApiAccessRepository) : ViewModel() {
+
+ val uiState =
+ combine(apiAccessRepository.accessMethods, apiAccessRepository.currentAccessMethod) {
+ apiAccessMethods,
+ currentAccessMethod ->
+ ApiAccessListUiState(
+ currentApiAccessMethodSetting = currentAccessMethod,
+ apiAccessMethodSettings = apiAccessMethods ?: emptyList()
+ )
+ }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ApiAccessListUiState())
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiAccessMethodDetailsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiAccessMethodDetailsViewModel.kt
new file mode 100644
index 0000000000..a6ba01e81c
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiAccessMethodDetailsViewModel.kt
@@ -0,0 +1,128 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import arrow.core.Either
+import arrow.core.raise.either
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.compose.state.ApiAccessMethodDetailsUiState
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId
+import net.mullvad.mullvadvpn.lib.model.TestApiAccessMethodError
+import net.mullvad.mullvadvpn.repository.ApiAccessRepository
+
+class ApiAccessMethodDetailsViewModel(
+ private val apiAccessMethodId: ApiAccessMethodId,
+ private val apiAccessRepository: ApiAccessRepository
+) : ViewModel() {
+ private var testingJob: Job? = null
+
+ private val _uiSideEffect = Channel<ApiAccessMethodDetailsSideEffect>(Channel.BUFFERED)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+ private val isTestingApiAccessMethodState = MutableStateFlow(false)
+ val uiState =
+ combine(
+ apiAccessRepository.apiAccessMethodSettingById(apiAccessMethodId),
+ apiAccessRepository.enabledApiAccessMethods(),
+ apiAccessRepository.currentAccessMethod,
+ isTestingApiAccessMethodState
+ ) {
+ apiAccessMethod,
+ enabledApiAccessMethods,
+ currentAccessMethod,
+ isTestingApiAccessMethod ->
+ ApiAccessMethodDetailsUiState.Content(
+ apiAccessMethodId = apiAccessMethodId,
+ name = apiAccessMethod.name,
+ enabled = apiAccessMethod.enabled,
+ isEditable = apiAccessMethod.apiAccessMethod is ApiAccessMethod.CustomProxy,
+ isDisableable = enabledApiAccessMethods.any { it.id != apiAccessMethodId },
+ isCurrentMethod = currentAccessMethod?.id == apiAccessMethodId,
+ isTestingAccessMethod = isTestingApiAccessMethod
+ )
+ }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ ApiAccessMethodDetailsUiState.Loading(apiAccessMethodId = apiAccessMethodId)
+ )
+
+ fun setCurrentMethod() {
+ testingJob =
+ viewModelScope.launch {
+ either {
+ testMethodById().bind()
+ apiAccessRepository
+ .setCurrentApiAccessMethod(apiAccessMethodId = apiAccessMethodId)
+ .bind()
+ }
+ .onLeft {
+ _uiSideEffect.send(
+ ApiAccessMethodDetailsSideEffect.UnableToSetCurrentMethod(
+ testMethodFailed = it is TestApiAccessMethodError
+ )
+ )
+ }
+ }
+ }
+
+ fun testMethod() {
+ testingJob =
+ viewModelScope.launch {
+ val result = testMethodById()
+ _uiSideEffect.send(
+ ApiAccessMethodDetailsSideEffect.TestApiAccessMethodResult(result.isRight())
+ )
+ }
+ }
+
+ fun setEnableMethod(enable: Boolean) {
+ viewModelScope.launch {
+ apiAccessRepository.setEnabledApiAccessMethod(apiAccessMethodId, enable).onLeft {
+ _uiSideEffect.send(ApiAccessMethodDetailsSideEffect.GenericError)
+ }
+ }
+ }
+
+ fun openEditPage() {
+ viewModelScope.launch {
+ _uiSideEffect.send(ApiAccessMethodDetailsSideEffect.OpenEditPage(apiAccessMethodId))
+ }
+ }
+
+ fun cancelTestMethod() {
+ if (testingJob?.isActive == true) {
+ testingJob?.cancel("User cancelled job")
+ isTestingApiAccessMethodState.value = false
+ }
+ }
+
+ private suspend fun testMethodById(): Either<TestApiAccessMethodError, Unit> {
+ isTestingApiAccessMethodState.value = true
+ return apiAccessRepository
+ .testApiAccessMethodById(apiAccessMethodId)
+ .onLeft { isTestingApiAccessMethodState.value = false }
+ .onRight { isTestingApiAccessMethodState.value = false }
+ }
+}
+
+sealed interface ApiAccessMethodDetailsSideEffect {
+ data class OpenEditPage(val apiAccessMethodId: ApiAccessMethodId) :
+ ApiAccessMethodDetailsSideEffect
+
+ data object GenericError : ApiAccessMethodDetailsSideEffect
+
+ data class TestApiAccessMethodResult(val successful: Boolean) :
+ ApiAccessMethodDetailsSideEffect
+
+ data class UnableToSetCurrentMethod(val testMethodFailed: Boolean) :
+ ApiAccessMethodDetailsSideEffect
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteApiAccessMethodConfirmationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteApiAccessMethodConfirmationViewModel.kt
new file mode 100644
index 0000000000..651081244f
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteApiAccessMethodConfirmationViewModel.kt
@@ -0,0 +1,51 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.compose.state.DeleteApiAccessMethodUiState
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId
+import net.mullvad.mullvadvpn.lib.model.RemoveApiAccessMethodError
+import net.mullvad.mullvadvpn.repository.ApiAccessRepository
+
+class DeleteApiAccessMethodConfirmationViewModel(
+ private val apiAccessMethodId: ApiAccessMethodId,
+ private val apiAccessRepository: ApiAccessRepository
+) : ViewModel() {
+ private val _uiSideEffect =
+ Channel<DeleteApiAccessMethodConfirmationSideEffect>(Channel.BUFFERED)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+
+ private val _error = MutableStateFlow<RemoveApiAccessMethodError?>(null)
+
+ val uiState =
+ _error
+ .map { DeleteApiAccessMethodUiState(it) }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ DeleteApiAccessMethodUiState(null)
+ )
+
+ fun deleteApiAccessMethod() {
+ viewModelScope.launch {
+ _error.emit(null)
+ apiAccessRepository
+ .removeApiAccessMethod(apiAccessMethodId)
+ .fold(
+ { _error.tryEmit(it) },
+ { _uiSideEffect.send(DeleteApiAccessMethodConfirmationSideEffect.Deleted) }
+ )
+ }
+ }
+}
+
+sealed interface DeleteApiAccessMethodConfirmationSideEffect {
+ data object Deleted : DeleteApiAccessMethodConfirmationSideEffect
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditApiAccessMethodViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditApiAccessMethodViewModel.kt
new file mode 100644
index 0000000000..87316e90e2
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditApiAccessMethodViewModel.kt
@@ -0,0 +1,274 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import arrow.core.Either
+import arrow.core.Either.Companion.zipOrAccumulate
+import arrow.core.EitherNel
+import arrow.core.getOrElse
+import arrow.core.nel
+import arrow.core.raise.either
+import arrow.core.raise.ensure
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.compose.state.ApiAccessMethodTypes
+import net.mullvad.mullvadvpn.compose.state.EditApiAccessFormData
+import net.mullvad.mullvadvpn.compose.state.EditApiAccessMethodUiState
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName
+import net.mullvad.mullvadvpn.lib.model.Cipher
+import net.mullvad.mullvadvpn.lib.model.InvalidDataError
+import net.mullvad.mullvadvpn.lib.model.ParsePortError
+import net.mullvad.mullvadvpn.lib.model.Port
+import net.mullvad.mullvadvpn.lib.model.SocksAuth
+import net.mullvad.mullvadvpn.repository.ApiAccessRepository
+import org.apache.commons.validator.routines.InetAddressValidator
+
+class EditApiAccessMethodViewModel(
+ private val apiAccessMethodId: ApiAccessMethodId?,
+ private val apiAccessRepository: ApiAccessRepository,
+ private val inetAddressValidator: InetAddressValidator
+) : ViewModel() {
+ private var testingJob: Job? = null
+
+ private val _uiSideEffect = Channel<EditApiAccessSideEffect>(Channel.BUFFERED)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+ private val isTestingApiAccessMethod = MutableStateFlow(false)
+ private val formData = MutableStateFlow(initialData())
+ val uiState =
+ combine(flowOf(initialData()), formData, isTestingApiAccessMethod) {
+ initialData,
+ formData,
+ isTestingApiAccessMethod ->
+ EditApiAccessMethodUiState.Content(
+ editMode = apiAccessMethodId != null,
+ formData = formData,
+ hasChanges = initialData != formData,
+ isTestingApiAccessMethod = isTestingApiAccessMethod
+ )
+ }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ EditApiAccessMethodUiState.Loading(editMode = apiAccessMethodId != null)
+ )
+
+ fun setAccessMethodType(accessMethodType: ApiAccessMethodTypes) {
+ formData.update { it.copy(apiAccessMethodTypes = accessMethodType) }
+ }
+
+ fun onNameChanged(name: String) {
+ formData.update { it.copy(name = name, nameError = null) }
+ }
+
+ fun onServerIpChanged(serverIp: String) {
+ formData.update { it.copy(serverIp = serverIp, serverIpError = null) }
+ }
+
+ fun onPortChanged(port: String) {
+ formData.update { it.copy(port = port, portError = null) }
+ }
+
+ fun onPasswordChanged(password: String) {
+ formData.update { it.copy(password = password, passwordError = null) }
+ }
+
+ fun onCipherChanged(cipher: Cipher) {
+ formData.update { it.copy(cipher = cipher) }
+ }
+
+ fun onAuthenticationEnabledChanged(enabled: Boolean) {
+ formData.update { it.copy(enableAuthentication = enabled) }
+ }
+
+ fun onUsernameChanged(username: String) {
+ formData.update { it.copy(username = username, usernameError = null) }
+ }
+
+ fun testMethod() {
+ testingJob =
+ viewModelScope.launch {
+ formData.value
+ .parseConnectionFormData()
+ .fold(
+ { errors -> formData.update { it.updateWithErrors(errors) } },
+ { customProxy ->
+ isTestingApiAccessMethod.value = true
+ val result = apiAccessRepository.testCustomApiAccessMethod(customProxy)
+ _uiSideEffect.send(
+ EditApiAccessSideEffect.TestApiAccessMethodResult(result.isRight())
+ )
+ isTestingApiAccessMethod.value = false
+ }
+ )
+ }
+ }
+
+ fun trySave() {
+ viewModelScope.launch {
+ formData.value
+ .parseFormData()
+ .fold(
+ { errors -> formData.update { it.updateWithErrors(errors) } },
+ { (name, customProxy) ->
+ _uiSideEffect.send(
+ EditApiAccessSideEffect.OpenSaveDialog(
+ id = apiAccessMethodId,
+ name = name,
+ customProxy = customProxy
+ )
+ )
+ }
+ )
+ }
+ }
+
+ fun cancelTestMethod() {
+ if (testingJob?.isActive == true) {
+ testingJob?.cancel("User cancelled test")
+ isTestingApiAccessMethod.value = false
+ }
+ }
+
+ private fun initialData(): EditApiAccessFormData =
+ if (apiAccessMethodId == null) {
+ EditApiAccessFormData.empty()
+ } else {
+ apiAccessRepository
+ .getApiAccessMethodSettingById(apiAccessMethodId)
+ .map { accessMethod ->
+ EditApiAccessFormData.fromCustomProxy(
+ accessMethod.name,
+ accessMethod.apiAccessMethod as? ApiAccessMethod.CustomProxy
+ ?: error(
+ "${accessMethod.apiAccessMethod} api access type can not be edited"
+ )
+ )
+ }
+ .getOrElse { error("Access method with id $apiAccessMethodId not found") }
+ }
+
+ private fun EditApiAccessFormData.parseFormData():
+ EitherNel<InvalidDataError, Pair<ApiAccessMethodName, ApiAccessMethod.CustomProxy>> =
+ zipOrAccumulate(parseName(name), parseConnectionFormData()) { name, customProxy ->
+ name to customProxy
+ }
+
+ private fun EditApiAccessFormData.parseConnectionFormData() =
+ when (apiAccessMethodTypes) {
+ ApiAccessMethodTypes.SHADOWSOCKS -> {
+ parseShadowSocksFormData(this)
+ }
+ ApiAccessMethodTypes.SOCKS5_REMOTE -> {
+ parseSocks5RemoteFormData(this)
+ }
+ }
+
+ private fun parseShadowSocksFormData(
+ formData: EditApiAccessFormData
+ ): EitherNel<InvalidDataError, ApiAccessMethod.CustomProxy.Shadowsocks> =
+ parseIpAndPort(formData.serverIp, formData.port).map { (ip, port) ->
+ ApiAccessMethod.CustomProxy.Shadowsocks(
+ ip = ip,
+ port = port,
+ password = formData.password.ifBlank { null },
+ cipher = formData.cipher
+ )
+ }
+
+ private fun parseIpAddress(input: String): Either<InvalidDataError.ServerIpError, String> =
+ either {
+ ensure(input.isNotBlank()) { InvalidDataError.ServerIpError.Required }
+ ensure(inetAddressValidator.isValid(input)) { InvalidDataError.ServerIpError.Invalid }
+ input
+ }
+
+ private fun parsePort(input: String): Either<InvalidDataError.PortError, Port> =
+ Port.fromString(input).mapLeft {
+ when (it) {
+ is ParsePortError.NotANumber ->
+ if (it.input.isBlank()) {
+ InvalidDataError.PortError.Required
+ } else {
+ InvalidDataError.PortError.Invalid(it)
+ }
+ is ParsePortError.OutOfRange -> InvalidDataError.PortError.Invalid(it)
+ }
+ }
+
+ private fun parseSocks5RemoteFormData(
+ formData: EditApiAccessFormData
+ ): EitherNel<InvalidDataError, ApiAccessMethod.CustomProxy.Socks5Remote> =
+ zipOrAccumulate(
+ parseIpAndPort(formData.serverIp, formData.port),
+ parseAuth(
+ authEnabled = formData.enableAuthentication,
+ inputUsername = formData.username,
+ inputPassword = formData.password
+ )
+ ) { (ip, port), auth ->
+ ApiAccessMethod.CustomProxy.Socks5Remote(ip = ip, port = port, auth = auth)
+ }
+
+ private fun parseIpAndPort(ipInput: String, portInput: String) =
+ zipOrAccumulate(
+ parseIpAddress(ipInput),
+ parsePort(portInput),
+ ) { ip, port ->
+ ip to port
+ }
+
+ private fun parseAuth(
+ authEnabled: Boolean,
+ inputUsername: String,
+ inputPassword: String
+ ): EitherNel<InvalidDataError, SocksAuth?> =
+ if (!authEnabled) {
+ Either.Right(null)
+ } else {
+ zipOrAccumulate(parseUsername(inputUsername), parsePassword(inputPassword)) {
+ userName,
+ password ->
+ SocksAuth(userName, password)
+ }
+ }
+
+ private fun parseUsername(input: String): Either<InvalidDataError.UserNameError, String> =
+ either {
+ ensure(input.isNotBlank()) { InvalidDataError.UserNameError.Required }
+ input
+ }
+
+ private fun parsePassword(input: String): Either<InvalidDataError.PasswordError, String> =
+ either {
+ ensure(input.isNotBlank()) { InvalidDataError.PasswordError.Required }
+ input
+ }
+
+ private fun parseName(
+ input: String
+ ): EitherNel<InvalidDataError.NameError, ApiAccessMethodName> = either {
+ ensure(input.isNotBlank()) { InvalidDataError.NameError.Required.nel() }
+ ApiAccessMethodName.fromString(input)
+ }
+}
+
+sealed interface EditApiAccessSideEffect {
+ data class OpenSaveDialog(
+ val id: ApiAccessMethodId?,
+ val name: ApiAccessMethodName,
+ val customProxy: ApiAccessMethod.CustomProxy
+ ) : EditApiAccessSideEffect
+
+ data class TestApiAccessMethodResult(val successful: Boolean) : EditApiAccessSideEffect
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SaveApiAccessMethodViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SaveApiAccessMethodViewModel.kt
new file mode 100644
index 0000000000..be937e9416
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SaveApiAccessMethodViewModel.kt
@@ -0,0 +1,102 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.compose.state.SaveApiAccessMethodUiState
+import net.mullvad.mullvadvpn.compose.state.TestApiAccessMethodState
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId
+import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName
+import net.mullvad.mullvadvpn.lib.model.NewAccessMethodSetting
+import net.mullvad.mullvadvpn.repository.ApiAccessRepository
+
+class SaveApiAccessMethodViewModel(
+ private val apiAccessMethodId: ApiAccessMethodId?,
+ private val apiAccessMethodName: ApiAccessMethodName,
+ private val customProxy: ApiAccessMethod.CustomProxy,
+ private val apiAccessRepository: ApiAccessRepository
+) : ViewModel() {
+ private val _uiSideEffect = Channel<SaveApiAccessMethodSideEffect>()
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+ private val _uiState = MutableStateFlow(SaveApiAccessMethodUiState())
+ val uiState: StateFlow<SaveApiAccessMethodUiState> = _uiState
+
+ init {
+ viewModelScope.launch {
+ apiAccessRepository
+ .testCustomApiAccessMethod(customProxy)
+ .fold(
+ {
+ _uiState.update {
+ it.copy(testingState = TestApiAccessMethodState.Result.Failure)
+ }
+ },
+ {
+ _uiState.update {
+ it.copy(testingState = TestApiAccessMethodState.Result.Successful)
+ }
+ save()
+ }
+ )
+ }
+ }
+
+ fun save() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isSaving = true) }
+ if (apiAccessMethodId != null) {
+ updateAccessMethod(
+ id = apiAccessMethodId,
+ name = apiAccessMethodName,
+ apiAccessMethod = customProxy
+ )
+ } else {
+ addNewAccessMethod(
+ NewAccessMethodSetting(
+ name = apiAccessMethodName,
+ enabled = true,
+ apiAccessMethod = customProxy
+ )
+ )
+ }
+ }
+ }
+
+ private suspend fun addNewAccessMethod(newAccessMethodSetting: NewAccessMethodSetting) {
+ apiAccessRepository
+ .addApiAccessMethod(newAccessMethodSetting)
+ .fold(
+ { _uiSideEffect.send(SaveApiAccessMethodSideEffect.CouldNotSaveApiAccessMethod) },
+ { _uiSideEffect.send(SaveApiAccessMethodSideEffect.SuccessfullyCreatedApiMethod) }
+ )
+ }
+
+ private suspend fun updateAccessMethod(
+ id: ApiAccessMethodId,
+ name: ApiAccessMethodName,
+ apiAccessMethod: ApiAccessMethod.CustomProxy
+ ) {
+ apiAccessRepository
+ .updateApiAccessMethod(
+ apiAccessMethodId = id,
+ apiAccessMethodName = name,
+ apiAccessMethod = apiAccessMethod
+ )
+ .fold(
+ { _uiSideEffect.send(SaveApiAccessMethodSideEffect.CouldNotSaveApiAccessMethod) },
+ { _uiSideEffect.send(SaveApiAccessMethodSideEffect.SuccessfullyCreatedApiMethod) }
+ )
+ }
+}
+
+sealed interface SaveApiAccessMethodSideEffect {
+ data object SuccessfullyCreatedApiMethod : SaveApiAccessMethodSideEffect
+
+ data object CouldNotSaveApiAccessMethod : SaveApiAccessMethodSideEffect
+}
diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/CommonListExtensions.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/CommonListExtensions.kt
new file mode 100644
index 0000000000..d613cf7463
--- /dev/null
+++ b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/CommonListExtensions.kt
@@ -0,0 +1,4 @@
+package net.mullvad.mullvadvpn.lib.common.util
+
+inline fun <T, reified E : T> List<T>.getFirstInstanceOrNull(): E? =
+ this.filterIsInstance<E>().firstOrNull()
diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml
index 72b256b1cc..f7fafc72ff 100644
--- a/android/lib/resource/src/main/res/values/strings.xml
+++ b/android/lib/resource/src/main/res/values/strings.xml
@@ -346,4 +346,43 @@
<string name="settings_patch_success">Import successful, overrides active</string>
<string name="overrides_cleared">Overrides cleared</string>
<string name="unsecured_vpn_permission_error">Unsecured (No VPN permission)</string>
+ <string name="settings_api_access">API access</string>
+ <string name="add">Add</string>
+ <string name="api_access_description">Manage and add custom methods to access the Mullvad API.</string>
+ <string name="current_method">Current: %s</string>
+ <string name="api_access_method_info_first_line">The app needs to communicate with a Mullvad API server to log you in, fetch server lists, and other critical operations.</string>
+ <string name="api_access_method_info_second_line">On some networks, where various types of censorship are being used, the API servers might not be directly reachable.</string>
+ <string name="api_access_method_info_third_line">This feature allows you to circumvent that censorship by adding custom ways to access the API via proxies and similar methods.</string>
+ <string name="api_access_method_info_fourth_line">The \"Current\" method represent which method the app is using to reach the API.</string>
+ <string name="edit_method">Edit method</string>
+ <string name="add_method">Add method</string>
+ <string name="name">Name</string>
+ <string name="this_field_is_required">This field is required</string>
+ <string name="type">Type</string>
+ <string name="server">Server</string>
+ <string name="please_enter_a_valid_ip_address">Please enter a valid IPv4 or IPv6 address</string>
+ <string name="please_enter_a_valid_remote_server_port">Please enter a valid remote server port</string>
+ <string name="password_optional">Password (optional)</string>
+ <string name="cipher">Cipher</string>
+ <string name="authentication">Authentication</string>
+ <string name="username">Username</string>
+ <string name="password">Password</string>
+ <string name="transport_protocol">Transport protocol</string>
+ <string name="test_method">Test method</string>
+ <string name="api_reachable">API reachable</string>
+ <string name="api_unreachable">API unreachable</string>
+ <string name="testing_name">Testing %s...</string>
+ <string name="testing">Testing...</string>
+ <string name="verifying_api_method">Verifying API method...</string>
+ <string name="api_reachable_adding_method">API reachable, adding method...</string>
+ <string name="api_unreachable_save_anyway">API unreachable, save method anyway?</string>
+ <string name="adding_method">Adding method...</string>
+ <string name="enable_method">Enable method</string>
+ <string name="use_method">Use method</string>
+ <string name="delete_method">Delete method</string>
+ <string name="at_least_on_method_needs_to_enabled">At least one method needs to be enabled</string>
+ <string name="this_is_already_set_as_current">This is already set as current</string>
+ <string name="delete_method_question">Delete method?</string>
+ <string name="failed_to_set_current_test_error">Failed to set to current - API not reachable</string>
+ <string name="failed_to_set_current_unknown_error">Failed to set to current - Unknown reason</string>
</resources>
diff --git a/android/lib/resource/src/main/res/values/strings_non_translatable.xml b/android/lib/resource/src/main/res/values/strings_non_translatable.xml
index 0b29d112b2..110e112e99 100644
--- a/android/lib/resource/src/main/res/values/strings_non_translatable.xml
+++ b/android/lib/resource/src/main/res/values/strings_non_translatable.xml
@@ -9,6 +9,8 @@
<string name="lockdown_url" translatable="false">https://mullvad.net/l/android-lockdown</string>
<string name="split_tunneling" translatable="false">Split tunneling</string>
<string name="wireguard" translatable="false">WireGuard</string>
+ <string name="socks5_remote">SOCKS5</string>
+ <string name="shadowsocks">Shadowsocks</string>
<string name="local_network_sharing_ip_ranges">
<![CDATA[<ul><li>10.0.0.0/8</li><li>172.16.0.0/12</li><li>192.168.0.0/16</li><li>169.254.0.0/16</li><li>fe80::/10</li><li>fc00::/7</li></ul>]]>
</string>
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt
index 01959b7934..343e41dc1a 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt
@@ -2,6 +2,8 @@ package net.mullvad.mullvadvpn.lib.theme.color
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MenuDefaults
+import androidx.compose.material3.MenuItemColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
@@ -33,3 +35,12 @@ val ColorScheme.onVariant: Color
val ColorScheme.selected: Color
@Composable get() = MaterialTheme.colorScheme.surface
+
+val menuItemColors: MenuItemColors
+ @Composable
+ get() =
+ MenuDefaults.itemColors()
+ .copy(
+ leadingIconColor = MaterialTheme.colorScheme.onSurface,
+ textColor = MaterialTheme.colorScheme.onSurface,
+ )
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
index 2763033a30..ef3564951f 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
@@ -42,6 +42,7 @@ data class Dimensions(
val dropdownMenuBorder: Dp = 1.dp,
val expandableCellChevronSize: Dp = 30.dp,
val filterTittlePadding: Dp = 4.dp,
+ val formTextFieldMinHeight: Dp = 72.dp,
val iconFailSuccessTopMargin: Dp = 30.dp,
val iconHeight: Dp = 44.dp,
val indentedCellStartPadding: Dp = 38.dp,