diff options
| author | Kalle Lindström <karl.lindstrom@mullvad.net> | 2024-09-17 10:32:49 +0200 |
|---|---|---|
| committer | Kalle Lindström <karl.lindstrom@mullvad.net> | 2024-09-17 10:39:50 +0200 |
| commit | 00154eb3bce1ed925b11d1cb6ef7b562cb78b524 (patch) | |
| tree | 241cdbb77b6df7fe2e8dd9abbdbe5369ac2abdbf /android | |
| parent | a87b10565db5ccc1b49757e8ae6d65876bc18b18 (diff) | |
| download | mullvadvpn-00154eb3bce1ed925b11d1cb6ef7b562cb78b524.tar.xz mullvadvpn-00154eb3bce1ed925b11d1cb6ef7b562cb78b524.zip | |
Refactor port dialog to use a view model
Diffstat (limited to 'android')
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, +) |
