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