summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorKalle Lindström <karl.lindstrom@mullvad.net>2024-09-17 10:32:49 +0200
committerKalle Lindström <karl.lindstrom@mullvad.net>2024-09-17 10:39:50 +0200
commit00154eb3bce1ed925b11d1cb6ef7b562cb78b524 (patch)
tree241cdbb77b6df7fe2e8dd9abbdbe5369ac2abdbf /android
parenta87b10565db5ccc1b49757e8ae6d65876bc18b18 (diff)
downloadmullvadvpn-00154eb3bce1ed925b11d1cb6ef7b562cb78b524.tar.xz
mullvadvpn-00154eb3bce1ed925b11d1cb6ef7b562cb78b524.zip
Refactor port dialog to use a view model
Diffstat (limited to 'android')
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialogTest.kt137
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt69
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WireguardCustomPortDialogViewModel.kt83
4 files changed, 248 insertions, 43 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialogTest.kt
index 930e5f5201..ec30495265 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialogTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialogTest.kt
@@ -2,16 +2,26 @@ package net.mullvad.mullvadvpn.compose.dialog
import android.annotation.SuppressLint
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.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
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.CUSTOM_PORT_DIALOG_INPUT_TEST_TAG
-import net.mullvad.mullvadvpn.lib.model.Port
import net.mullvad.mullvadvpn.lib.model.PortRange
import net.mullvad.mullvadvpn.onNodeWithTagAndText
+import net.mullvad.mullvadvpn.viewmodel.WireguardCustomPortDialogUiState
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
@@ -30,17 +40,29 @@ class CustomPortDialogTest {
@SuppressLint("ComposableNaming")
@Composable
private fun testWireguardCustomPortDialog(
- initialPort: Port? = null,
+ portInput: String = "",
+ isValidInput: Boolean = false,
+ showResetToDefault: Boolean = false,
allowedPortRanges: List<PortRange> = emptyList(),
- onSave: (Port?) -> Unit = { _ -> },
+ onInputChanged: (String) -> Unit = { _ -> },
+ onSavePort: (String) -> Unit = { _ -> },
+ onResetPort: () -> Unit = {},
onDismiss: () -> Unit = {},
) {
+ val state =
+ WireguardCustomPortDialogUiState(
+ portInput = portInput,
+ isValidInput = isValidInput,
+ allowedPortRanges = allowedPortRanges,
+ showResetToDefault = showResetToDefault,
+ )
WireguardCustomPortDialog(
- initialPort = initialPort,
- allowedPortRanges = allowedPortRanges,
- onSave = onSave,
+ state,
+ onInputChanged = onInputChanged,
+ onSavePort = onSavePort,
onDismiss = onDismiss,
+ onResetPort = onResetPort,
)
}
@@ -51,17 +73,112 @@ class CustomPortDialogTest {
// crash the app
// Arrange
- setContentWithTheme { testWireguardCustomPortDialog() }
+ setContentWithTheme {
+ var input by remember { mutableStateOf("") }
+ testWireguardCustomPortDialog(portInput = input, onInputChanged = { input = it })
+ }
// Act
- onNodeWithTag(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG).performTextInput(INVALID_CUSTOM_PORT)
+ onNodeWithTag(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG).performTextInput(INVALID_PORT_INPUT)
// Assert
- onNodeWithTagAndText(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG, INVALID_CUSTOM_PORT)
+ onNodeWithTagAndText(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG, INVALID_PORT_INPUT)
.assertDoesNotExist()
}
+ @Test
+ fun testEmptyInputResultsInSetPortButtonBeingDisabled() =
+ composeExtension.use {
+ // Arrange
+ setContentWithTheme { testWireguardCustomPortDialog(isValidInput = false) }
+
+ // Assert
+ onNodeWithText("Set port").assertIsNotEnabled()
+ }
+
+ @Test
+ fun testValidInputResultsInSetPortButtonBeingEnabled() =
+ composeExtension.use {
+ // Arrange
+ setContentWithTheme {
+ testWireguardCustomPortDialog(portInput = VALID_CUSTOM_PORT, isValidInput = true)
+ }
+
+ // Assert
+ onNodeWithText("Set port").assertIsEnabled()
+ onNodeWithText(VALID_CUSTOM_PORT).assertExists()
+ }
+
+ @Test
+ fun testInvalidInputResultsInSetPortButtonBeingDisabled() =
+ composeExtension.use {
+ // Arrange
+ setContentWithTheme {
+ testWireguardCustomPortDialog(portInput = INVALID_CUSTOM_PORT, isValidInput = false)
+ }
+
+ // Assert
+ onNodeWithText("Set port").assertIsNotEnabled()
+ }
+
+ @Test
+ fun testDialogSubmitOfValidValue() =
+ composeExtension.use {
+ // Arrange
+ val mockedSubmitHandler: (String) -> Unit = mockk(relaxed = true)
+ setContentWithTheme {
+ testWireguardCustomPortDialog(
+ portInput = VALID_CUSTOM_PORT,
+ isValidInput = true,
+ onSavePort = mockedSubmitHandler,
+ )
+ }
+
+ // Act
+ onNodeWithText("Set port").assertIsEnabled().performClick()
+
+ // Assert
+ verify { mockedSubmitHandler.invoke(VALID_CUSTOM_PORT) }
+ }
+
+ @Test
+ fun testDialogResetClick() =
+ composeExtension.use {
+ // Arrange
+ val mockedClickHandler: () -> Unit = mockk(relaxed = true)
+ setContentWithTheme {
+ testWireguardCustomPortDialog(
+ portInput = VALID_CUSTOM_PORT,
+ isValidInput = true,
+ showResetToDefault = true,
+ onResetPort = mockedClickHandler,
+ )
+ }
+
+ // Act
+ onNodeWithText("Remove custom port").performClick()
+
+ // Assert
+ verify { mockedClickHandler.invoke() }
+ }
+
+ @Test
+ fun testMtuDialogCancelClick() =
+ composeExtension.use {
+ // Arrange
+ val mockedClickHandler: () -> Unit = mockk(relaxed = true)
+ setContentWithTheme { testWireguardCustomPortDialog(onDismiss = mockedClickHandler) }
+
+ // Assert
+ onNodeWithText("Cancel").performClick()
+
+ // Assert
+ verify { mockedClickHandler.invoke() }
+ }
+
companion object {
- const val INVALID_CUSTOM_PORT = "21474836471"
+ const val INVALID_PORT_INPUT = "21474836471"
+ const val INVALID_CUSTOM_PORT = "10"
+ const val VALID_CUSTOM_PORT = "4001"
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt
index 63f17c780b..9bf8b8bf3b 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt
@@ -3,12 +3,13 @@ package net.mullvad.mullvadvpn.compose.dialog
import android.os.Parcelable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
+import androidx.compose.runtime.getValue
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.compose.dropUnlessResumed
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.result.EmptyResultBackNavigator
@@ -18,11 +19,15 @@ import kotlinx.parcelize.Parcelize
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.test.CUSTOM_PORT_DIALOG_INPUT_TEST_TAG
import net.mullvad.mullvadvpn.compose.textfield.CustomPortTextField
+import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle
import net.mullvad.mullvadvpn.lib.model.Port
import net.mullvad.mullvadvpn.lib.model.PortRange
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.util.asString
-import net.mullvad.mullvadvpn.util.inAnyOf
+import net.mullvad.mullvadvpn.viewmodel.WireguardCustomPortDialogSideEffect
+import net.mullvad.mullvadvpn.viewmodel.WireguardCustomPortDialogUiState
+import net.mullvad.mullvadvpn.viewmodel.WireguardCustomPortDialogViewModel
+import org.koin.androidx.compose.koinViewModel
@Preview
@Composable
@@ -47,39 +52,44 @@ data class WireguardCustomPortNavArgs(
@Destination<RootGraph>(style = DestinationStyle.Dialog::class)
@Composable
fun WireguardCustomPort(
- navArg: WireguardCustomPortNavArgs,
+ @Suppress("UNUSED_PARAMETER") navArg: WireguardCustomPortNavArgs,
backNavigator: ResultBackNavigator<Port?>,
) {
+ val viewModel = koinViewModel<WireguardCustomPortDialogViewModel>()
+
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ CollectSideEffectWithLifecycle(viewModel.uiSideEffect) {
+ when (it) {
+ is WireguardCustomPortDialogSideEffect.Success -> backNavigator.navigateBack(it.port)
+ }
+ }
+
WireguardCustomPortDialog(
- initialPort = navArg.customPort,
- allowedPortRanges = navArg.allowedPortRanges,
- onSave = { port -> backNavigator.navigateBack(port) },
- onDismiss = backNavigator::navigateBack,
+ uiState,
+ onInputChanged = viewModel::onInputChanged,
+ onSavePort = viewModel::onSaveClick,
+ onResetPort = viewModel::onResetClick,
+ onDismiss = dropUnlessResumed { backNavigator.navigateBack() },
)
}
@Composable
fun WireguardCustomPortDialog(
- initialPort: Port?,
- allowedPortRanges: List<PortRange>,
- onSave: (Port?) -> Unit,
+ state: WireguardCustomPortDialogUiState,
+ onInputChanged: (String) -> Unit,
+ onSavePort: (String) -> Unit,
+ onResetPort: () -> Unit,
onDismiss: () -> Unit,
) {
- val port = remember { mutableStateOf(initialPort?.value?.toString() ?: "") }
- val isValidPort = port.value.toPortOrNull()?.inAnyOf(allowedPortRanges) ?: false
-
InputDialog(
title = stringResource(id = R.string.custom_port_dialog_title),
input = {
CustomPortTextField(
- value = port.value,
- onSubmit = { input ->
- if (isValidPort) {
- onSave(input.toPortOrNull())
- }
- },
- onValueChanged = { input -> port.value = input },
- isValidValue = isValidPort,
+ value = state.portInput,
+ onValueChanged = onInputChanged,
+ onSubmit = onSavePort,
+ isValidValue = state.isValidInput,
maxCharLength = 5,
modifier = Modifier.testTag(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG).fillMaxWidth(),
)
@@ -87,20 +97,13 @@ fun WireguardCustomPortDialog(
message =
stringResource(
id = R.string.custom_port_dialog_valid_ranges,
- allowedPortRanges.asString(),
+ state.allowedPortRanges.asString(),
),
- confirmButtonEnabled = isValidPort,
+ confirmButtonEnabled = state.isValidInput,
confirmButtonText = stringResource(id = R.string.custom_port_dialog_submit),
onResetButtonText = stringResource(R.string.custom_port_dialog_remove),
onBack = onDismiss,
- onReset =
- if (initialPort != null) {
- { onSave(null) }
- } else {
- null
- },
- onConfirm = { onSave(port.value.toPortOrNull()) },
+ onReset = if (state.showResetToDefault) onResetPort else null,
+ onConfirm = { onSavePort(state.portInput) },
)
}
-
-private fun String.toPortOrNull() = toIntOrNull()?.let { Port(it) }
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 6b909d394d..32fad5614b 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
@@ -83,6 +83,7 @@ import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel
import net.mullvad.mullvadvpn.viewmodel.VpnPermissionViewModel
import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel
import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel
+import net.mullvad.mullvadvpn.viewmodel.WireguardCustomPortDialogViewModel
import org.apache.commons.validator.routines.InetAddressValidator
import org.koin.android.ext.koin.androidApplication
import org.koin.android.ext.koin.androidContext
@@ -182,6 +183,7 @@ val uiModule = module {
viewModel { DeviceRevokedViewModel(get(), get()) }
viewModel { MtuDialogViewModel(get(), get()) }
viewModel { DnsDialogViewModel(get(), get(), get()) }
+ viewModel { WireguardCustomPortDialogViewModel(get()) }
viewModel { LoginViewModel(get(), get(), get()) }
viewModel { PrivacyDisclaimerViewModel(get(), IS_PLAY_BUILD) }
viewModel {
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WireguardCustomPortDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WireguardCustomPortDialogViewModel.kt
new file mode 100644
index 0000000000..b98801612e
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WireguardCustomPortDialogViewModel.kt
@@ -0,0 +1,83 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ramcosta.composedestinations.generated.destinations.WireguardCustomPortDestination
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.lib.model.Port
+import net.mullvad.mullvadvpn.lib.model.PortRange
+import net.mullvad.mullvadvpn.util.inAnyOf
+
+class WireguardCustomPortDialogViewModel(
+ savedStateHandle: SavedStateHandle,
+ private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
+) : ViewModel() {
+ private val navArgs = WireguardCustomPortDestination.argsFrom(savedStateHandle).navArg
+
+ private val _portInput = MutableStateFlow(navArgs.customPort?.value?.toString() ?: "")
+ private val _isValidPort = MutableStateFlow(_portInput.value.isValidPort())
+
+ val uiState: StateFlow<WireguardCustomPortDialogUiState> =
+ combine(_portInput, _isValidPort, ::createState)
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ createState(_portInput.value, _isValidPort.value),
+ )
+
+ private val _uiSideEffect = Channel<WireguardCustomPortDialogSideEffect>()
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+
+ private fun createState(portInput: String, isValidPortInput: Boolean) =
+ WireguardCustomPortDialogUiState(
+ portInput = portInput,
+ isValidInput = isValidPortInput,
+ allowedPortRanges = navArgs.allowedPortRanges,
+ showResetToDefault = navArgs.customPort != null,
+ )
+
+ fun onInputChanged(value: String) {
+ _portInput.value = value
+ _isValidPort.value = value.isValidPort()
+ }
+
+ fun onSaveClick(portValue: String) =
+ viewModelScope.launch(dispatcher) {
+ val port = portValue.parseValidPort() ?: return@launch
+ _uiSideEffect.send(WireguardCustomPortDialogSideEffect.Success(port))
+ }
+
+ fun onResetClick() {
+ viewModelScope.launch(dispatcher) {
+ _uiSideEffect.send(WireguardCustomPortDialogSideEffect.Success(null))
+ }
+ }
+
+ private fun String.isValidPort(): Boolean = parseValidPort() != null
+
+ private fun String.parseValidPort(): Port? =
+ Port.fromString(this).getOrNull()?.takeIf { port ->
+ port.inAnyOf(navArgs.allowedPortRanges)
+ }
+}
+
+sealed interface WireguardCustomPortDialogSideEffect {
+ data class Success(val port: Port?) : WireguardCustomPortDialogSideEffect
+}
+
+data class WireguardCustomPortDialogUiState(
+ val portInput: String,
+ val isValidInput: Boolean,
+ val allowedPortRanges: List<PortRange>,
+ val showResetToDefault: Boolean,
+)