diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2024-06-13 16:37:23 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2024-06-14 13:27:05 +0200 |
| commit | 7dd2345f65acb9fe9a9b57809603db3a65417a8b (patch) | |
| tree | 1ed1918cb8742f470b5d7182f6ef92c7ee0ed97e /android | |
| parent | e80e9ee68559fcd32747c28a829e70d2121e9344 (diff) | |
| download | mullvadvpn-7dd2345f65acb9fe9a9b57809603db3a65417a8b.tar.xz mullvadvpn-7dd2345f65acb9fe9a9b57809603db3a65417a8b.zip | |
Add ui for api access method functionality
Diffstat (limited to 'android')
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, |
