diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialogTest.kt index 5a2d1e7e3b12..0882c1dd4263 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialogTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialogTest.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.test.onNodeWithText import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewState +import net.mullvad.mullvadvpn.viewmodel.ValidationError import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -19,8 +20,8 @@ class DnsDialogTest { private val defaultState = DnsDialogViewState( - ipAddress = "", - validationResult = DnsDialogViewState.ValidationResult.Success, + input = "", + validationError = null, isLocal = false, isAllowLanEnabled = false, index = null @@ -93,8 +94,8 @@ class DnsDialogTest { setContentWithTheme { testDnsDialog( defaultState.copy( - ipAddress = invalidIpAddress, - validationResult = DnsDialogViewState.ValidationResult.InvalidAddress, + input = invalidIpAddress, + validationError = ValidationError.InvalidAddress, ) ) } @@ -110,8 +111,8 @@ class DnsDialogTest { setContentWithTheme { testDnsDialog( defaultState.copy( - ipAddress = "192.168.0.1", - validationResult = DnsDialogViewState.ValidationResult.DuplicateAddress, + input = "192.168.0.1", + validationError = ValidationError.DuplicateAddress, ) ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt index 163d19f4b540..30f1c71486c1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt @@ -28,67 +28,26 @@ import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.viewmodel.DnsDialogSideEffect import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewModel import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewState +import net.mullvad.mullvadvpn.viewmodel.ValidationError import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf @Preview @Composable private fun PreviewDnsDialogNew() { - AppTheme { - DnsDialog( - DnsDialogViewState( - "1.1.1.1", - DnsDialogViewState.ValidationResult.Success, - false, - false, - null - ), - {}, - {}, - {}, - {} - ) - } + AppTheme { DnsDialog(DnsDialogViewState("1.1.1.1", null, false, false, null), {}, {}, {}, {}) } } @Preview @Composable private fun PreviewDnsDialogEdit() { - AppTheme { - DnsDialog( - DnsDialogViewState( - "1.1.1.1", - DnsDialogViewState.ValidationResult.Success, - false, - false, - 0 - ), - {}, - {}, - {}, - {} - ) - } + AppTheme { DnsDialog(DnsDialogViewState("1.1.1.1", null, false, false, 0), {}, {}, {}, {}) } } @Preview @Composable private fun PreviewDnsDialogEditAllowLanDisabled() { - AppTheme { - DnsDialog( - DnsDialogViewState( - "192.168.1.1", - DnsDialogViewState.ValidationResult.Success, - true, - false, - 0 - ), - {}, - {}, - {}, - {} - ) - } + AppTheme { DnsDialog(DnsDialogViewState("192.168.1.1", null, true, false, 0), {}, {}, {}, {}) } } @Destination(style = DestinationStyle.Dialog::class) @@ -143,7 +102,7 @@ fun DnsDialog( text = { Column { DnsTextField( - value = state.ipAddress, + value = state.input, isValidValue = state.isValid(), onValueChanged = { newDnsValue -> onDnsInputChange(newDnsValue) }, onSubmit = onSaveDnsClick, @@ -154,8 +113,7 @@ fun DnsDialog( val errorMessage = when { - state.validationResult is - DnsDialogViewState.ValidationResult.DuplicateAddress -> { + state.validationError is ValidationError.DuplicateAddress -> { stringResource(R.string.duplicate_address_warning) } state.isLocal && !state.isAllowLanEnabled -> { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt index 4b3a6d5d6b58..5df10ef0b1ed 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt @@ -2,6 +2,9 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure import java.net.InetAddress import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -11,7 +14,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -25,88 +27,77 @@ sealed interface DnsDialogSideEffect { data object Error : DnsDialogSideEffect } -data class DnsDialogViewModelState( - val customDnsList: List, - val isAllowLanEnabled: Boolean -) { - companion object { - fun default() = DnsDialogViewModelState(emptyList(), false) - } -} - data class DnsDialogViewState( - val ipAddress: String, - val validationResult: ValidationResult = ValidationResult.Success, + val input: String, + val validationError: ValidationError?, val isLocal: Boolean, val isAllowLanEnabled: Boolean, val index: Int?, ) { val isNewEntry = index == null - fun isValid() = (validationResult is ValidationResult.Success) - - sealed class ValidationResult { - data object Success : ValidationResult() + fun isValid() = validationError == null +} - data object InvalidAddress : ValidationResult() +sealed class ValidationError { + data object InvalidAddress : ValidationError() - data object DuplicateAddress : ValidationResult() - } + data object DuplicateAddress : ValidationError() } class DnsDialogViewModel( private val repository: SettingsRepository, private val inetAddressValidator: InetAddressValidator, - private val index: Int? = null, + index: Int? = null, initialValue: String?, private val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) : ViewModel() { + private val currentIndex = MutableStateFlow(index) private val _ipAddressInput = MutableStateFlow(initialValue ?: EMPTY_STRING) - private val vmState = - repository.settingsUpdates - .filterNotNull() - .map { - val customDnsList = it.addresses() - val isAllowLanEnabled = it.allowLan - DnsDialogViewModelState(customDnsList, isAllowLanEnabled = isAllowLanEnabled) - } - .stateIn(viewModelScope, SharingStarted.Lazily, DnsDialogViewModelState.default()) - val uiState: StateFlow = - combine(_ipAddressInput, vmState, ::createViewState) + combine(_ipAddressInput, currentIndex, repository.settingsUpdates.filterNotNull()) { + input, + currentIndex, + settings -> + createViewState(settings.addresses(), currentIndex, settings.allowLan, input) + } .stateIn( viewModelScope, SharingStarted.Lazily, - createViewState(_ipAddressInput.value, vmState.value) + createViewState(emptyList(), null, false, _ipAddressInput.value) ) private val _uiSideEffect = Channel() val uiSideEffect = _uiSideEffect.receiveAsFlow() - private fun createViewState(ipAddress: String, vmState: DnsDialogViewModelState) = + private fun createViewState( + customDnsList: List, + currentIndex: Int?, + isAllowLanEnabled: Boolean, + input: String + ): DnsDialogViewState = DnsDialogViewState( - ipAddress, - ipAddress.validateDnsEntry(index, vmState.customDnsList), - ipAddress.isLocalAddress(), - isAllowLanEnabled = vmState.isAllowLanEnabled, - index + input, + input.validateDnsEntry(currentIndex, customDnsList).leftOrNull(), + input.isLocalAddress(), + isAllowLanEnabled = isAllowLanEnabled, + currentIndex ) private fun String.validateDnsEntry( index: Int?, dnsList: List - ): DnsDialogViewState.ValidationResult = - when { - this.isBlank() || !this.isValidIp() -> { - DnsDialogViewState.ValidationResult.InvalidAddress - } - InetAddress.getByName(this).isDuplicateDnsEntry(index, dnsList) -> { - DnsDialogViewState.ValidationResult.DuplicateAddress - } - else -> DnsDialogViewState.ValidationResult.Success + ): Either = either { + ensure(isNotBlank()) { ValidationError.InvalidAddress } + ensure(isValidIp()) { ValidationError.InvalidAddress } + val inetAddress = InetAddress.getByName(this@validateDnsEntry) + ensure(!inetAddress.isDuplicateDnsEntry(index, dnsList)) { + ValidationError.DuplicateAddress } + inetAddress + } fun onDnsInputChange(ipAddress: String) { _ipAddressInput.value = ipAddress @@ -116,17 +107,20 @@ class DnsDialogViewModel( viewModelScope.launch(dispatcher) { if (!uiState.value.isValid()) return@launch - val address = InetAddress.getByName(uiState.value.ipAddress) + val address = InetAddress.getByName(uiState.value.input) - if (index != null) { + val index = uiState.value.index + val result = + if (index != null) { repository.setCustomDns(index = index, address = address) } else { - repository.addCustomDns(address = address) + repository.addCustomDns(address = address).onRight { currentIndex.value = it } } - .fold( - { _uiSideEffect.send(DnsDialogSideEffect.Error) }, - { _uiSideEffect.send(DnsDialogSideEffect.Complete) } - ) + + result.fold( + { _uiSideEffect.send(DnsDialogSideEffect.Error) }, + { _uiSideEffect.send(DnsDialogSideEffect.Complete) } + ) } fun onRemoveDnsClick(index: Int) = diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt index 53aefe13dbb8..c02958de4e0c 100644 --- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt @@ -333,15 +333,15 @@ class ManagementService( .mapLeft(SetDnsOptionsError::Unknown) .mapEmpty() - suspend fun addCustomDns(address: InetAddress): Either = + suspend fun addCustomDns(address: InetAddress): Either = Either.catch { val currentDnsOptions = getSettings().tunnelOptions.dnsOptions val updatedDnsOptions = DnsOptions.customOptions.addresses.modify(currentDnsOptions) { it + address } grpc.setDnsOptions(updatedDnsOptions.fromDomain()) + updatedDnsOptions.customOptions.addresses.lastIndex } .mapLeft(SetDnsOptionsError::Unknown) - .mapEmpty() suspend fun deleteCustomDns(index: Int): Either = Either.catch {