diff options
| author | David Göransson <david.goransson@mullvad.net> | 2024-03-20 09:10:23 +0100 |
|---|---|---|
| committer | David Göransson <david.goransson@mullvad.net> | 2024-03-20 09:10:23 +0100 |
| commit | 2b40cfa0cd087490ef0061fd6cfa24ed246d8556 (patch) | |
| tree | a0cd9d0bc982d2794e4ae33306247c5d6fc63e20 /android/app | |
| parent | fc7a0c22152c411a0bf00f20ac6ed6fb993d961b (diff) | |
| parent | cb19d35111887b07dbafb97edfcd180835f2bc5e (diff) | |
| download | mullvadvpn-2b40cfa0cd087490ef0061fd6cfa24ed246d8556.tar.xz mullvadvpn-2b40cfa0cd087490ef0061fd6cfa24ed246d8556.zip | |
Merge branch 'create-server-ip-overrides-composable-droid-709'
Diffstat (limited to 'android/app')
26 files changed, 1416 insertions, 36 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIPOverridesConfirmationDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIPOverridesConfirmationDialogTest.kt new file mode 100644 index 0000000000..df06f00fc7 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIPOverridesConfirmationDialogTest.kt @@ -0,0 +1,67 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import io.mockk.MockKAnnotations +import io.mockk.mockk +import io.mockk.verify +import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension +import net.mullvad.mullvadvpn.compose.setContentWithTheme +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 org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +class ResetServerIPOverridesConfirmationDialogTest { + @OptIn(ExperimentalTestApi::class) + @JvmField + @RegisterExtension + val composeExtension = createEdgeToEdgeComposeExtension() + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @Test + fun ensure_cancel_click_works() = + composeExtension.use { + val clickHandler: () -> Unit = mockk(relaxed = true) + + // Arrange + setContentWithTheme { + ResetServerIpOverridesConfirmationDialog( + onNavigateBack = clickHandler, + onClearAllOverrides = {} + ) + } + + // Act + onNodeWithTag(RESET_SERVER_IP_OVERRIDE_CANCEL_TEST_TAG).performClick() + + // Assert + verify { clickHandler() } + } + + @Test + fun ensure_reset_click_works() = + composeExtension.use { + val clickHandler: () -> Unit = mockk(relaxed = true) + + // Arrange + setContentWithTheme { + ResetServerIpOverridesConfirmationDialog( + onNavigateBack = {}, + onClearAllOverrides = clickHandler + ) + } + + // Act + onNodeWithTag(RESET_SERVER_IP_OVERRIDE_RESET_TEST_TAG).performClick() + + // Assert + verify { clickHandler() } + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreenTest.kt new file mode 100644 index 0000000000..32bab72de2 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreenTest.kt @@ -0,0 +1,173 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import io.mockk.MockKAnnotations +import io.mockk.mockk +import io.mockk.verify +import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension +import net.mullvad.mullvadvpn.compose.setContentWithTheme +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.viewmodel.ServerIpOverridesViewState +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +@ExperimentalTestApi +class ServerIpOverridesScreenTest { + @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @Suppress("TestFunctionName") + @Composable + private fun ScreenWithDefault( + state: ServerIpOverridesViewState, + onBackClick: () -> Unit = {}, + onInfoClick: () -> Unit = {}, + onResetOverridesClick: () -> Unit = {}, + onImportByFile: () -> Unit = {}, + onImportByText: () -> Unit = {}, + ) { + ServerIpOverridesScreen( + state = state, + onBackClick = onBackClick, + onInfoClick = onInfoClick, + onResetOverridesClick = onResetOverridesClick, + onImportByFile = onImportByFile, + onImportByText = onImportByText + ) + } + + @Test + fun ensure_overrides_inactive_is_displayed() = + composeExtension.use { + // Arrange + setContentWithTheme { + ScreenWithDefault(state = ServerIpOverridesViewState.Loaded(false)) + } + + // Assert + onNodeWithText("Overrides inactive").assertExists() + } + + @Test + fun ensure_overrides_active_is_displayed() = + composeExtension.use { + // Arrange + setContentWithTheme { + ScreenWithDefault(state = ServerIpOverridesViewState.Loaded(true)) + } + + // Assert + onNodeWithText("Overrides active").assertExists() + } + + @Test + fun ensure_overrides_active_shows_warning_on_import() = + composeExtension.use { + // Arrange + setContentWithTheme { + ScreenWithDefault(state = ServerIpOverridesViewState.Loaded(true)) + } + + // Act + onNodeWithTag(testTag = SERVER_IP_OVERRIDE_IMPORT_TEST_TAG).performClick() + + // Assert + onNodeWithText( + "Importing new overrides might replace some previously imported overrides." + ) + .assertExists() + } + + @Test + fun ensure_info_click_works() = + composeExtension.use { + // Arrange + val clickHandler: () -> Unit = mockk(relaxed = true) + setContentWithTheme { + ScreenWithDefault( + state = ServerIpOverridesViewState.Loaded(false), + onInfoClick = clickHandler + ) + } + + // Act + onNodeWithTag(SERVER_IP_OVERRIDE_INFO_TEST_TAG).performClick() + + // Assert + verify { clickHandler() } + } + + @Test + fun ensure_reset_click_works() = + composeExtension.use { + // Arrange + val clickHandler: () -> Unit = mockk(relaxed = true) + setContentWithTheme { + ScreenWithDefault( + state = ServerIpOverridesViewState.Loaded(true), + onResetOverridesClick = clickHandler + ) + } + + // Act + onNodeWithTag(SERVER_IP_OVERRIDE_MORE_VERT_TEST_TAG).performClick() + onNodeWithTag(SERVER_IP_OVERRIDE_RESET_OVERRIDES_TEST_TAG).performClick() + + // Assert + verify { clickHandler() } + } + + @Test + fun ensure_import_by_file_works() = + composeExtension.use { + // Arrange + val clickHandler: () -> Unit = mockk(relaxed = true) + setContentWithTheme { + ScreenWithDefault( + state = ServerIpOverridesViewState.Loaded(false), + onImportByFile = clickHandler + ) + } + + // Act + onNodeWithTag(SERVER_IP_OVERRIDE_IMPORT_TEST_TAG).performClick() + onNodeWithTag(SERVER_IP_OVERRIDES_IMPORT_BY_FILE_TEST_TAG).performClick() + + // Assert + verify { clickHandler() } + } + + @Test + fun ensure_import_by_text() = + composeExtension.use { + // Arrange + val clickHandler: () -> Unit = mockk(relaxed = true) + setContentWithTheme { + ScreenWithDefault( + state = ServerIpOverridesViewState.Loaded(false), + onImportByText = clickHandler + ) + } + + // Act + onNodeWithTag(SERVER_IP_OVERRIDE_IMPORT_TEST_TAG).performClick() + onNodeWithTag(SERVER_IP_OVERRIDES_IMPORT_BY_TEXT_TEST_TAG).performClick() + + // Assert + verify { clickHandler() } + } +} 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 c3eb19b270..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()) } @@ -105,8 +109,9 @@ val uiModule = module { androidContext().getSharedPreferences(APP_PREFERENCES_NAME, Context.MODE_PRIVATE) ) } - single { SettingsRepository(get()) } + single { SettingsRepository(get(), get()) } single { MullvadProblemReport(get()) } + single { RelayOverridesRepository(get(), get()) } single { CustomListsRepository(get(), get(), get()) } single { AccountExpiryNotificationUseCase(get()) } @@ -178,6 +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/repository/RelayOverridesRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayOverridesRepository.kt new file mode 100644 index 0000000000..835cab4710 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayOverridesRepository.kt @@ -0,0 +1,44 @@ +package net.mullvad.mullvadvpn.repository + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.lib.ipc.MessageHandler +import net.mullvad.mullvadvpn.lib.ipc.Request +import net.mullvad.mullvadvpn.model.RelayOverride +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.settingsListener +import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier +import net.mullvad.mullvadvpn.util.flatMapReadyConnectionOrDefault + +class RelayOverridesRepository( + private val serviceConnectionManager: ServiceConnectionManager, + private val messageHandler: MessageHandler, + dispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + fun clearAllOverrides() { + messageHandler.trySendRequest(Request.ClearAllRelayOverrides) + } + + val relayOverrides: StateFlow<List<RelayOverride>?> = + serviceConnectionManager.connectionState + .flatMapReadyConnectionOrDefault(flowOf()) { state -> + callbackFlowFromNotifier(state.container.settingsListener.settingsNotifier) + } + .mapNotNull { it?.relayOverrides?.toList() } + .onStart { + serviceConnectionManager + .settingsListener() + ?.settingsNotifier + ?.latestEvent + ?.relayOverrides + ?.toList() + } + .stateIn(CoroutineScope(dispatcher), SharingStarted.Eagerly, null) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt index 81c4b85b88..7d61feaf0c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt @@ -4,11 +4,18 @@ import java.net.InetAddress import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import net.mullvad.mullvadvpn.lib.ipc.Event.ApplyJsonSettingsResult +import net.mullvad.mullvadvpn.lib.ipc.MessageHandler +import net.mullvad.mullvadvpn.lib.ipc.Request +import net.mullvad.mullvadvpn.lib.ipc.events import net.mullvad.mullvadvpn.model.CustomDnsOptions import net.mullvad.mullvadvpn.model.DefaultDnsOptions import net.mullvad.mullvadvpn.model.DnsOptions @@ -24,7 +31,8 @@ import net.mullvad.mullvadvpn.util.flatMapReadyConnectionOrDefault class SettingsRepository( private val serviceConnectionManager: ServiceConnectionManager, - dispatcher: CoroutineDispatcher = Dispatchers.IO + private val messageHandler: MessageHandler, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) { val settingsUpdates: StateFlow<Settings?> = serviceConnectionManager.connectionState @@ -92,4 +100,11 @@ class SettingsRepository( fun setLocalNetworkSharing(isEnabled: Boolean) { serviceConnectionManager.settingsListener()?.allowLan = isEnabled } + + suspend fun applySettingsPatch(json: String) = + withContext(dispatcher) { + val deferred = async { messageHandler.events<ApplyJsonSettingsResult>().first() } + messageHandler.trySendRequest(Request.ApplyJsonSettings(json)) + deferred.await() + } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt index 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/ui/serviceconnection/SettingsListener.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt index d996c432ad..e2ccc2e470 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt @@ -68,4 +68,8 @@ class SettingsListener(private val connection: Messenger, eventDispatcher: Event settings = newSettings } + + fun applySettingsPatch(json: String) { + connection.send(Request.ApplyJsonSettings(json).message) + } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/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/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModelTest.kt new file mode 100644 index 0000000000..9be365e7ae --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModelTest.kt @@ -0,0 +1,63 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.viewModelScope +import app.cash.turbine.test +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlin.test.assertEquals +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.model.RelayOverride +import net.mullvad.mullvadvpn.repository.RelayOverridesRepository +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(TestCoroutineRule::class) +class ResetServerIpOverridesConfirmationViewModelTest { + private lateinit var viewModel: ResetServerIpOverridesConfirmationViewModel + + private val mockRelayOverridesRepository: RelayOverridesRepository = mockk() + private val relayOverrides = MutableStateFlow<List<RelayOverride>?>(null) + + @BeforeEach + fun setup() { + coEvery { mockRelayOverridesRepository.relayOverrides } returns relayOverrides + + viewModel = + ResetServerIpOverridesConfirmationViewModel( + relayOverridesRepository = mockRelayOverridesRepository, + ) + } + + @AfterEach + fun teardown() { + viewModel.viewModelScope.coroutineContext.cancel() + unmockkAll() + } + + @Test + fun `successful clear of override should result in side effect`() = runTest { + every { mockRelayOverridesRepository.clearAllOverrides() } returns Unit + viewModel.uiSideEffect.test { + viewModel.clearAllOverrides() + assertEquals( + ResetServerIpOverridesConfirmationUiSideEffect.OverridesCleared, + awaitItem() + ) + } + } + + @Test + fun `clear overrides should invoke repository`() = runTest { + every { mockRelayOverridesRepository.clearAllOverrides() } returns Unit + viewModel.clearAllOverrides() + verify { mockRelayOverridesRepository.clearAllOverrides() } + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModelTest.kt new file mode 100644 index 0000000000..16e89ac20b --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModelTest.kt @@ -0,0 +1,118 @@ +package net.mullvad.mullvadvpn.viewmodel + +import android.content.ContentResolver +import android.net.Uri +import androidx.lifecycle.viewModelScope +import app.cash.turbine.test +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import java.io.InputStream +import java.io.InputStreamReader +import kotlin.test.assertEquals +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.ipc.Event +import net.mullvad.mullvadvpn.model.RelayOverride +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 +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(TestCoroutineRule::class) +class ServerIpOverridesViewModelTest { + private lateinit var viewModel: ServerIpOverridesViewModel + + private val mockServiceConnectionManager: ServiceConnectionManager = mockk() + private val mockRelayOverridesRepository: RelayOverridesRepository = mockk() + private val mockSettingsRepository: SettingsRepository = mockk(relaxed = true) + private val mockContentResolver: ContentResolver = mockk() + + private val relayOverrides = MutableStateFlow<List<RelayOverride>?>(null) + private val serviceConnectionState = + MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.ConnectedReady(mockk())) + + @BeforeEach + fun setup() { + coEvery { mockRelayOverridesRepository.relayOverrides } returns relayOverrides + coEvery { mockServiceConnectionManager.connectionState } returns serviceConnectionState + + mockkStatic(READ_TEXT) + + viewModel = + ServerIpOverridesViewModel( + serviceConnectionManager = mockServiceConnectionManager, + relayOverridesRepository = mockRelayOverridesRepository, + settingsRepository = mockSettingsRepository, + contentResolver = mockContentResolver + ) + } + + @AfterEach + fun teardown() { + viewModel.viewModelScope.coroutineContext.cancel() + unmockkAll() + } + + @Test + fun `ensure state is loading by default`() = runTest { + viewModel.uiState.test { assertEquals(ServerIpOverridesViewState.Loading, awaitItem()) } + } + + @Test + fun `when server ip overrides are empty ui state overrides should be inactive`() = runTest { + viewModel.uiState.test { + assertEquals(ServerIpOverridesViewState.Loading, awaitItem()) + relayOverrides.emit(emptyList()) + assertEquals(ServerIpOverridesViewState.Loaded(false), awaitItem()) + } + } + + @Test + fun `when import is finished we should get side effect`() = runTest { + val mockkResult: SettingsPatchError = mockk() + coEvery { mockSettingsRepository.applySettingsPatch(TEXT_INPUT) } returns + Event.ApplyJsonSettingsResult(mockkResult) + + viewModel.uiSideEffect.test { + viewModel.importText(TEXT_INPUT) + assertEquals(ServerIpOverridesUiSideEffect.ImportResult(mockkResult), awaitItem()) + } + } + + @Test + fun `ensure import text invokes repository`() = runTest { + viewModel.importText(TEXT_INPUT) + + coVerify { mockSettingsRepository.applySettingsPatch(TEXT_INPUT) } + } + + @Test + fun `ensure import file invokes repository`() = runTest { + val uri: Uri = mockk() + + val mockInputStream: InputStream = mockk() + every { mockContentResolver.openInputStream(uri) } returns mockInputStream + every { any<InputStreamReader>().readText() } returns TEXT_INPUT + + viewModel.importFile(uri) + + coVerify { mockSettingsRepository.applySettingsPatch(TEXT_INPUT) } + } + + companion object { + private const val TEXT_INPUT = "My cool json patch" + + private const val READ_TEXT = "kotlin.io.TextStreamsKt" + } +} |
