diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 80565e9221d9..7552b67f8bd1 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -22,6 +22,9 @@ Line wrap the file at 100 chars. Th * **Security**: in case of vulnerabilities. ## [Unreleased] +### Added +- Add the ability to customize how the app talks to the api. + ### Changed - Migrate underlaying communication wtih daemon to gRPC. This also implies major changes and improvements throughout the app. diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/data/DummyApiAccessMethods.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/data/DummyApiAccessMethods.kt new file mode 100644 index 000000000000..a644449a911b --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/data/DummyApiAccessMethods.kt @@ -0,0 +1,33 @@ +package net.mullvad.mullvadvpn.compose.data + +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodSetting +import net.mullvad.mullvadvpn.lib.model.Cipher +import net.mullvad.mullvadvpn.lib.model.Port + +private const val UUID1 = "12345678-1234-5678-1234-567812345678" +private const val UUID2 = "12345678-1234-5678-1234-567812345679" + +val DIRECT_ACCESS_METHOD = + ApiAccessMethodSetting( + id = ApiAccessMethodId.fromString(UUID1), + name = ApiAccessMethodName.fromString("Direct"), + enabled = true, + apiAccessMethod = ApiAccessMethod.Direct + ) + +val CUSTOM_ACCESS_METHOD = + ApiAccessMethodSetting( + id = ApiAccessMethodId.fromString(UUID2), + name = ApiAccessMethodName.fromString("ShadowSocks"), + enabled = true, + apiAccessMethod = + ApiAccessMethod.CustomProxy.Shadowsocks( + ip = "1.1.1.1", + port = Port(123), + password = "Password", + cipher = Cipher.RC4 + ) + ) diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialogTest.kt new file mode 100644 index 000000000000..07f8b039eb1b --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialogTest.kt @@ -0,0 +1,125 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +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.state.SaveApiAccessMethodUiState +import net.mullvad.mullvadvpn.compose.state.TestApiAccessMethodState +import net.mullvad.mullvadvpn.compose.test.SAVE_API_ACCESS_METHOD_CANCEL_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SAVE_API_ACCESS_METHOD_LOADING_SPINNER_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SAVE_API_ACCESS_METHOD_SAVE_BUTTON_TEST_TAG +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +@OptIn(ExperimentalTestApi::class) +class SaveApiAccessMethodDialogTest { + @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + + @Test + fun whenTestingInProgressShouldShowSpinnerWithCancelButton() = + composeExtension.use { + // Arrange + setContentWithTheme { + SaveApiAccessMethodDialog( + state = + SaveApiAccessMethodUiState( + testingState = TestApiAccessMethodState.Testing, + isSaving = false + ) + ) + } + + // Assert + onNodeWithTag(SAVE_API_ACCESS_METHOD_LOADING_SPINNER_TEST_TAG).assertExists() + onNodeWithTag(SAVE_API_ACCESS_METHOD_CANCEL_BUTTON_TEST_TAG).assertExists() + } + + @Test + fun whenTestingFailedShouldShowSaveAndCancelButton() = + composeExtension.use { + // Arrange + setContentWithTheme { + SaveApiAccessMethodDialog( + state = + SaveApiAccessMethodUiState( + testingState = TestApiAccessMethodState.Result.Failure, + isSaving = false + ) + ) + } + + // Assert + onNodeWithTag(SAVE_API_ACCESS_METHOD_SAVE_BUTTON_TEST_TAG).assertExists() + onNodeWithTag(SAVE_API_ACCESS_METHOD_CANCEL_BUTTON_TEST_TAG).assertExists() + } + + @Test + fun whenTestingSuccessfulAndSavingShouldShowDisabledCancelButton() = + composeExtension.use { + // Arrange + setContentWithTheme { + SaveApiAccessMethodDialog( + state = + SaveApiAccessMethodUiState( + testingState = TestApiAccessMethodState.Result.Successful, + isSaving = true + ) + ) + } + + // Assert + onNodeWithTag(SAVE_API_ACCESS_METHOD_CANCEL_BUTTON_TEST_TAG).assertExists() + onNodeWithTag(SAVE_API_ACCESS_METHOD_CANCEL_BUTTON_TEST_TAG).assertIsNotEnabled() + } + + @Test + fun whenTestingInProgressAndClickingCancelShouldCallOnCancel() = + composeExtension.use { + // Arrange + val onCancelClick: () -> Unit = mockk(relaxed = true) + setContentWithTheme { + SaveApiAccessMethodDialog( + state = + SaveApiAccessMethodUiState( + testingState = TestApiAccessMethodState.Testing, + isSaving = false + ), + onCancel = onCancelClick + ) + } + + // Act + onNodeWithTag(SAVE_API_ACCESS_METHOD_CANCEL_BUTTON_TEST_TAG).performClick() + + // Assert + verify { onCancelClick() } + } + + @Test + fun whenTestingFailedAndClickingSaveShouldCallOnSave() = + composeExtension.use { + // Arrange + val onSaveClick: () -> Unit = mockk(relaxed = true) + setContentWithTheme { + SaveApiAccessMethodDialog( + state = + SaveApiAccessMethodUiState( + testingState = TestApiAccessMethodState.Result.Failure, + isSaving = false + ), + onSave = onSaveClick + ) + } + + // Act + onNodeWithTag(SAVE_API_ACCESS_METHOD_SAVE_BUTTON_TEST_TAG).performClick() + + // Assert + verify { onSaveClick() } + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessListScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessListScreenTest.kt new file mode 100644 index 000000000000..97eed92d9137 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessListScreenTest.kt @@ -0,0 +1,111 @@ +package net.mullvad.mullvadvpn.compose.screen + +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.mockk +import io.mockk.verify +import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension +import net.mullvad.mullvadvpn.compose.data.DIRECT_ACCESS_METHOD +import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.ApiAccessListUiState +import net.mullvad.mullvadvpn.compose.test.API_ACCESS_LIST_INFO_TEST_TAG +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodSetting +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +@OptIn(ExperimentalTestApi::class) +class ApiAccessListScreenTest { + @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + + @Test + fun shouldShowCurrentApiAccessName() = + composeExtension.use { + // Arrange + val currentApiAccessMethod = DIRECT_ACCESS_METHOD + setContentWithTheme { + ApiAccessListScreen( + state = + ApiAccessListUiState(currentApiAccessMethodSetting = currentApiAccessMethod) + ) + } + + // Assert + onNodeWithText("Current: ${currentApiAccessMethod.name}") + } + + @Test + fun shouldShowApiAccessNameAndStatusInList() = + composeExtension.use { + // Arrange + val apiAccessMethod = DIRECT_ACCESS_METHOD + setContentWithTheme { + ApiAccessListScreen( + state = ApiAccessListUiState(apiAccessMethodSettings = listOf(apiAccessMethod)) + ) + } + + // Assert + onNodeWithText(apiAccessMethod.name.value) + onNodeWithText("On") + } + + @Test + fun whenClickingOnAddMethodShouldCallOnAddMethodClicked() = + composeExtension.use { + // Arrange + val onAddMethodClick: () -> Unit = mockk(relaxed = true) + setContentWithTheme { + ApiAccessListScreen( + state = ApiAccessListUiState(), + onAddMethodClick = onAddMethodClick + ) + } + + // Act + onNodeWithText("Add").performClick() + + // Assert + verify { onAddMethodClick() } + } + + @Test + fun whenClickingOnInfoButtonShouldCallOnApiAccessInfoClick() = + composeExtension.use { + // Arrange + val onApiAccessInfoClick: () -> Unit = mockk(relaxed = true) + setContentWithTheme { + ApiAccessListScreen( + state = ApiAccessListUiState(), + onApiAccessInfoClick = onApiAccessInfoClick + ) + } + + // Act + onNodeWithTag(API_ACCESS_LIST_INFO_TEST_TAG).performClick() + + // Assert + verify { onApiAccessInfoClick() } + } + + @Test + fun whenClickingOnApiAccessMethodShouldCallOnApiAccessMethodClickWithCorrectAccessMethod() = + composeExtension.use { + // Arrange + val apiAccessMethod = DIRECT_ACCESS_METHOD + val onApiAccessMethodClick: (ApiAccessMethodSetting) -> Unit = mockk(relaxed = true) + setContentWithTheme { + ApiAccessListScreen( + state = ApiAccessListUiState(apiAccessMethodSettings = listOf(apiAccessMethod)), + onApiAccessMethodClick = onApiAccessMethodClick + ) + } + + // Act + onNodeWithText(apiAccessMethod.name.value).performClick() + + // Assert + verify { onApiAccessMethodClick(apiAccessMethod) } + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessMethodDetailsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessMethodDetailsScreenTest.kt new file mode 100644 index 000000000000..8dbd8f983264 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessMethodDetailsScreenTest.kt @@ -0,0 +1,226 @@ +package net.mullvad.mullvadvpn.compose.screen + +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.mockk +import io.mockk.verify +import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension +import net.mullvad.mullvadvpn.compose.data.CUSTOM_ACCESS_METHOD +import net.mullvad.mullvadvpn.compose.data.DIRECT_ACCESS_METHOD +import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.ApiAccessMethodDetailsUiState +import net.mullvad.mullvadvpn.compose.test.API_ACCESS_DETAILS_EDIT_BUTTON +import net.mullvad.mullvadvpn.compose.test.API_ACCESS_DETAILS_TOP_BAR_DROPDOWN_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.API_ACCESS_TEST_METHOD_BUTTON +import net.mullvad.mullvadvpn.compose.test.API_ACCESS_USE_METHOD_BUTTON +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +@OptIn(ExperimentalTestApi::class) +class ApiAccessMethodDetailsScreenTest { + @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + + @Test + fun whenApiAccessMethodIsNotEditableShouldNotShowDeleteAndEdit() = + composeExtension.use { + // Arrange + val apiAccessMethod = DIRECT_ACCESS_METHOD + setContentWithTheme { + ApiAccessMethodDetailsScreen( + state = + ApiAccessMethodDetailsUiState.Content( + apiAccessMethodId = apiAccessMethod.id, + name = apiAccessMethod.name, + enabled = apiAccessMethod.enabled, + isEditable = false, + isDisableable = true, + isCurrentMethod = true, + isTestingAccessMethod = false + ) + ) + } + + // Assert + onNodeWithTag(API_ACCESS_DETAILS_TOP_BAR_DROPDOWN_BUTTON_TEST_TAG).assertDoesNotExist() + onNodeWithTag(API_ACCESS_DETAILS_EDIT_BUTTON).assertDoesNotExist() + } + + @Test + fun whenApiAccessMethodIsNotDisableableShouldNotBeAbleDisable() = + composeExtension.use { + // Arrange + val onEnableClicked: (Boolean) -> Unit = mockk(relaxed = true) + val apiAccessMethod = DIRECT_ACCESS_METHOD + setContentWithTheme { + ApiAccessMethodDetailsScreen( + state = + ApiAccessMethodDetailsUiState.Content( + apiAccessMethodId = apiAccessMethod.id, + name = apiAccessMethod.name, + enabled = apiAccessMethod.enabled, + isEditable = false, + isDisableable = false, + isCurrentMethod = true, + isTestingAccessMethod = false + ), + onEnableClicked = onEnableClicked + ) + } + + // Act + onNodeWithText("Enable method").performClick() + + // Assert + onNodeWithText("At least one method needs to be enabled") + verify(exactly = 0) { onEnableClicked(any()) } + } + + @Test + fun whenClickingOnDeleteMethodShouldCallOnDeleteApiAccessMethodClicked() = + composeExtension.use { + // Arrange + val onDeleteApiAccessMethodClicked: (ApiAccessMethodId) -> Unit = mockk(relaxed = true) + val apiAccessMethod = CUSTOM_ACCESS_METHOD + setContentWithTheme { + ApiAccessMethodDetailsScreen( + state = + ApiAccessMethodDetailsUiState.Content( + apiAccessMethodId = apiAccessMethod.id, + name = apiAccessMethod.name, + enabled = apiAccessMethod.enabled, + isEditable = true, + isDisableable = false, + isCurrentMethod = true, + isTestingAccessMethod = false + ), + onDeleteApiAccessMethodClicked = onDeleteApiAccessMethodClicked + ) + } + + // Act + onNodeWithTag(API_ACCESS_DETAILS_TOP_BAR_DROPDOWN_BUTTON_TEST_TAG).performClick() + onNodeWithText("Delete method").performClick() + + // Assert + verify(exactly = 1) { onDeleteApiAccessMethodClicked(apiAccessMethod.id) } + } + + @Test + fun whenClickingOnEditMethodShouldCallOnEditMethodClicked() = + composeExtension.use { + // Arrange + val onEditMethodClicked: () -> Unit = mockk(relaxed = true) + val apiAccessMethod = CUSTOM_ACCESS_METHOD + setContentWithTheme { + ApiAccessMethodDetailsScreen( + state = + ApiAccessMethodDetailsUiState.Content( + apiAccessMethodId = apiAccessMethod.id, + name = apiAccessMethod.name, + enabled = apiAccessMethod.enabled, + isEditable = true, + isDisableable = false, + isCurrentMethod = true, + isTestingAccessMethod = false + ), + onEditMethodClicked = onEditMethodClicked + ) + } + + // Act + onNodeWithTag(API_ACCESS_DETAILS_EDIT_BUTTON).performClick() + + // Assert + verify(exactly = 1) { onEditMethodClicked() } + } + + @Test + fun whenClickingOnEnableMethodShouldCallOnEnableClicked() = + composeExtension.use { + // Arrange + val onEnableClicked: (Boolean) -> Unit = mockk(relaxed = true) + val apiAccessMethod = DIRECT_ACCESS_METHOD + setContentWithTheme { + ApiAccessMethodDetailsScreen( + state = + ApiAccessMethodDetailsUiState.Content( + apiAccessMethodId = apiAccessMethod.id, + name = apiAccessMethod.name, + enabled = apiAccessMethod.enabled, + isEditable = false, + isDisableable = true, + isCurrentMethod = true, + isTestingAccessMethod = false + ), + onEnableClicked = onEnableClicked + ) + } + + // Act + onNodeWithText("Enable method").performClick() + + // Assert + verify(exactly = 1) { onEnableClicked(false) } + } + + @Test + fun whenClickingOnTestMethodShouldCallOnTestMethodClicked() = + composeExtension.use { + // Arrange + val onTestMethodClicked: () -> Unit = mockk(relaxed = true) + val apiAccessMethod = DIRECT_ACCESS_METHOD + setContentWithTheme { + ApiAccessMethodDetailsScreen( + state = + ApiAccessMethodDetailsUiState.Content( + apiAccessMethodId = apiAccessMethod.id, + name = apiAccessMethod.name, + enabled = apiAccessMethod.enabled, + isEditable = false, + isDisableable = true, + isCurrentMethod = true, + isTestingAccessMethod = false + ), + onTestMethodClicked = onTestMethodClicked + ) + } + + // Act + onNodeWithTag(API_ACCESS_TEST_METHOD_BUTTON).performClick() + + // Assert + verify(exactly = 1) { onTestMethodClicked() } + } + + @Test + fun whenClickingOnUseMethodShouldCallOnUseMethodClicked() = + composeExtension.use { + // Arrange + val onUseMethodClicked: () -> Unit = mockk(relaxed = true) + val apiAccessMethod = DIRECT_ACCESS_METHOD + setContentWithTheme { + ApiAccessMethodDetailsScreen( + state = + ApiAccessMethodDetailsUiState.Content( + apiAccessMethodId = apiAccessMethod.id, + name = apiAccessMethod.name, + enabled = apiAccessMethod.enabled, + isEditable = false, + isDisableable = true, + isCurrentMethod = false, + isTestingAccessMethod = false + ), + onUseMethodClicked = onUseMethodClicked + ) + } + + // Act + onNodeWithTag(API_ACCESS_USE_METHOD_BUTTON).performClick() + + // Assert + verify(exactly = 1) { onUseMethodClicked() } + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreenTest.kt new file mode 100644 index 000000000000..584be7e0747b --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreenTest.kt @@ -0,0 +1,257 @@ +package net.mullvad.mullvadvpn.compose.screen + +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 androidx.compose.ui.test.performTextInput +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.state.ApiAccessMethodTypes +import net.mullvad.mullvadvpn.compose.state.EditApiAccessFormData +import net.mullvad.mullvadvpn.compose.state.EditApiAccessMethodUiState +import net.mullvad.mullvadvpn.compose.test.EDIT_API_ACCESS_NAME_INPUT +import net.mullvad.mullvadvpn.lib.model.InvalidDataError +import net.mullvad.mullvadvpn.lib.model.ParsePortError +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +@OptIn(ExperimentalTestApi::class) +class EditApiAccessMethodScreenTest { + @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + + @Test + fun whenInEditModeAddButtonShouldSaySave() = + composeExtension.use { + // Arrange + setContentWithTheme { + EditApiAccessMethodScreen( + state = + EditApiAccessMethodUiState.Content( + editMode = true, + formData = EditApiAccessFormData.empty(), + hasChanges = false, + isTestingApiAccessMethod = false + ) + ) + } + + // Assert + onNodeWithText("Save").assertExists() + } + + @Test + fun whenNotInEditModeAddButtonShouldSayAdd() = + composeExtension.use { + // Arrange + setContentWithTheme { + EditApiAccessMethodScreen( + state = + EditApiAccessMethodUiState.Content( + editMode = false, + formData = EditApiAccessFormData.empty(), + hasChanges = false, + isTestingApiAccessMethod = false + ) + ) + } + + // Assert + onNodeWithText("Add").assertExists() + } + + @Test + fun whenNameInputHasErrorShouldShowError() = + composeExtension.use { + // Arrange + setContentWithTheme { + EditApiAccessMethodScreen( + state = + EditApiAccessMethodUiState.Content( + editMode = false, + formData = + EditApiAccessFormData( + name = "", + nameError = InvalidDataError.NameError.Required, + serverIp = "", + username = "", + password = "", + port = "" + ), + hasChanges = false, + isTestingApiAccessMethod = false + ) + ) + } + + // Assert + onNodeWithText("This field is required").assertExists() + } + + @Test + fun whenServerInputIsNotIpAddressShouldShowError() = + composeExtension.use { + // Arrange + setContentWithTheme { + EditApiAccessMethodScreen( + state = + EditApiAccessMethodUiState.Content( + editMode = false, + formData = + EditApiAccessFormData( + name = "", + serverIp = "123", + serverIpError = InvalidDataError.ServerIpError.Invalid, + username = "", + password = "", + port = "" + ), + hasChanges = false, + isTestingApiAccessMethod = false + ) + ) + } + + // Assert + onNodeWithText("Please enter a valid IPv4 or IPv6 address").assertExists() + } + + @Test + fun whenPortInputIsNotWithinRangeShouldShowError() = + composeExtension.use { + // Arrange + setContentWithTheme { + EditApiAccessMethodScreen( + state = + EditApiAccessMethodUiState.Content( + editMode = false, + formData = + EditApiAccessFormData( + name = "", + serverIp = "", + username = "", + password = "", + port = "1111111111", + portError = + InvalidDataError.PortError.Invalid( + ParsePortError.OutOfRange(1111111111) + ) + ), + hasChanges = false, + isTestingApiAccessMethod = false + ) + ) + } + + // Assert + onNodeWithText("Please enter a valid remote server port").assertExists() + } + + @Test + fun whenNameInputChangesShouldCallOnNameChanged() = + composeExtension.use { + // Arrange + val onNameChanged: (String) -> Unit = mockk(relaxed = true) + val mockInput = "Name" + setContentWithTheme { + EditApiAccessMethodScreen( + state = + EditApiAccessMethodUiState.Content( + editMode = false, + formData = EditApiAccessFormData.empty(), + hasChanges = false, + isTestingApiAccessMethod = false + ), + onNameChanged = onNameChanged + ) + } + + // Act + onNodeWithTag(EDIT_API_ACCESS_NAME_INPUT).performTextInput(mockInput) + + // Assert + verify(exactly = 1) { onNameChanged(mockInput) } + } + + @Test + fun whenSocks5IsSelectedAndAuthenticationIsEnabledShouldShowUsernameAndPassword() = + composeExtension.use { + // Arrange + setContentWithTheme { + EditApiAccessMethodScreen( + state = + EditApiAccessMethodUiState.Content( + editMode = false, + formData = + EditApiAccessFormData( + name = "", + serverIp = "", + username = "", + password = "", + port = "", + enableAuthentication = true, + apiAccessMethodTypes = ApiAccessMethodTypes.SOCKS5_REMOTE + ), + hasChanges = false, + isTestingApiAccessMethod = false + ) + ) + } + + // Assert + onNodeWithText("Username").assertExists() + onNodeWithText("Password").assertExists() + } + + @Test + fun whenClickingOnTestMethodButtonShouldCallOnTestMethod() = + composeExtension.use { + // Arrange + val onTestMethod: () -> Unit = mockk(relaxed = true) + setContentWithTheme { + EditApiAccessMethodScreen( + state = + EditApiAccessMethodUiState.Content( + editMode = false, + formData = EditApiAccessFormData.empty(), + hasChanges = false, + isTestingApiAccessMethod = false + ), + onTestMethod = onTestMethod + ) + } + + // Act + onNodeWithText("Test method").performClick() + + // Assert + verify(exactly = 1) { onTestMethod() } + } + + @Test + fun whenClickingOnAddMethodButtonShouldCallOnAddMethod() = + composeExtension.use { + // Arrange + val onAddMethod: () -> Unit = mockk(relaxed = true) + setContentWithTheme { + EditApiAccessMethodScreen( + state = + EditApiAccessMethodUiState.Content( + editMode = false, + formData = EditApiAccessFormData.empty(), + hasChanges = false, + isTestingApiAccessMethod = false + ), + onAddMethod = onAddMethod + ) + } + + // Act + onNodeWithText("Add").performClick() + + // Assert + verify(exactly = 1) { onAddMethod() } + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt index b8b6a4e4b8f7..afa144f405a3 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt @@ -40,6 +40,7 @@ class SettingsScreenTest { onNodeWithText("VPN settings").assertExists() onNodeWithText("Split tunneling").assertExists() onNodeWithText("App version").assertExists() + onNodeWithText("API access").assertExists() } @Test @@ -62,5 +63,6 @@ class SettingsScreenTest { onNodeWithText("VPN settings").assertDoesNotExist() onNodeWithText("Split tunneling").assertDoesNotExist() onNodeWithText("App version").assertExists() + onNodeWithText("API access").assertExists() } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt index 3dd7068389f3..9f75a656c449 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt @@ -22,10 +22,8 @@ import androidx.compose.ui.unit.dp import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.Alpha20 -import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible -import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible import net.mullvad.mullvadvpn.lib.theme.color.onVariant import net.mullvad.mullvadvpn.lib.theme.color.variant @@ -92,7 +90,7 @@ fun NegativeButton( text = text, modifier = modifier, isEnabled = isEnabled, - icon = icon + trailingIcon = icon ) } @@ -124,7 +122,7 @@ fun VariantButton( text = text, modifier = modifier, isEnabled = isEnabled, - icon = icon + trailingIcon = icon ) } @@ -147,7 +145,8 @@ fun PrimaryButton( .compositeOver(MaterialTheme.colorScheme.background), ), isEnabled: Boolean = true, - icon: @Composable (() -> Unit)? = null + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null ) { BaseButton( onClick = onClick, @@ -155,7 +154,8 @@ fun PrimaryButton( text = text, modifier = modifier, isEnabled = isEnabled, - icon = icon, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon ) } @@ -166,25 +166,35 @@ private fun BaseButton( text: String, modifier: Modifier = Modifier, isEnabled: Boolean = true, - icon: @Composable (() -> Unit)? = null + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null ) { + val hasIcon = leadingIcon != null || trailingIcon != null Button( onClick = onClick, colors = colors, enabled = isEnabled, contentPadding = - icon?.let { PaddingValues(horizontal = 0.dp, vertical = Dimens.buttonVerticalPadding) } - ?: ButtonDefaults.ContentPadding, + if (hasIcon) { + PaddingValues(horizontal = 0.dp, vertical = Dimens.buttonVerticalPadding) + } else { + ButtonDefaults.ContentPadding + }, modifier = modifier.wrapContentHeight().fillMaxWidth(), shape = MaterialTheme.shapes.small ) { // Used to center the text - icon?.let { - Box( - modifier = Modifier.padding(horizontal = Dimens.smallPadding).alpha(AlphaInvisible) - ) { - icon() - } + when { + leadingIcon != null -> + Box(modifier = Modifier.padding(horizontal = Dimens.smallPadding)) { leadingIcon() } + trailingIcon != null -> + // Used to center the text + Box( + modifier = + Modifier.padding(horizontal = Dimens.smallPadding).alpha(AlphaInvisible) + ) { + trailingIcon() + } } Text( text = text, @@ -194,14 +204,19 @@ private fun BaseButton( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) - icon?.let { - Box( - modifier = - Modifier.padding(horizontal = Dimens.smallPadding) - .alpha(if (isEnabled) AlphaVisible else AlphaDisabled) - ) { - icon() - } + when { + trailingIcon != null -> + Box(modifier = Modifier.padding(horizontal = Dimens.smallPadding)) { + trailingIcon() + } + leadingIcon != null -> + // Used to center the text + Box( + modifier = + Modifier.padding(horizontal = Dimens.smallPadding).alpha(AlphaInvisible) + ) { + leadingIcon() + } } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SwitchLocationButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SwitchLocationButton.kt index 2feabcfaf3fe..291b5a743bcd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SwitchLocationButton.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SwitchLocationButton.kt @@ -39,7 +39,7 @@ fun SwitchLocationButton( ), modifier = modifier, text = text, - icon = + trailingIcon = if (showChevron) { { Icon( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/TestMethodButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/TestMethodButton.kt new file mode 100644 index 000000000000..9ded39ea1583 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/TestMethodButton.kt @@ -0,0 +1,36 @@ +package net.mullvad.mullvadvpn.compose.button + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.preview.TestMethodButtonPreviewParameterProvider +import net.mullvad.mullvadvpn.lib.theme.AppTheme + +@Preview +@Composable +private fun PreviewTestMethodButton( + @PreviewParameter(provider = TestMethodButtonPreviewParameterProvider::class) isTesting: Boolean +) { + AppTheme { TestMethodButton(isTesting = isTesting, onTestMethod = {}) } +} + +@Composable +fun TestMethodButton(modifier: Modifier = Modifier, isTesting: Boolean, onTestMethod: () -> Unit) { + PrimaryButton( + modifier = modifier, + onClick = onTestMethod, + isEnabled = !isTesting, + text = + stringResource( + id = + if (isTesting) { + R.string.testing + } else { + R.string.test_method + } + ), + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt index 7fbc4bdda380..fdc01ab62d59 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt @@ -67,7 +67,8 @@ fun NavigationComposeCell( showWarning: Boolean = false, bodyView: @Composable () -> Unit = { DefaultNavigationView(chevronContentDescription = title) }, isRowEnabled: Boolean = true, - onClick: () -> Unit + onClick: () -> Unit, + testTag: String = "" ) { BaseCell( onCellClicked = onClick, @@ -79,7 +80,8 @@ fun NavigationComposeCell( ) }, bodyView = { bodyView() }, - isRowEnabled = isRowEnabled + isRowEnabled = isRowEnabled, + testTag = testTag ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt index 17eb5d315a34..0e046cdfd827 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt @@ -1,12 +1,14 @@ package net.mullvad.mullvadvpn.compose.cell import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import net.mullvad.mullvadvpn.lib.theme.AppTheme @@ -22,9 +24,12 @@ private fun PreviewTwoRowCell() { fun TwoRowCell( titleText: String, subtitleText: String, + bodyView: @Composable ColumnScope.() -> Unit = {}, onCellClicked: () -> Unit = {}, titleColor: Color = MaterialTheme.colorScheme.onPrimary, subtitleColor: Color = MaterialTheme.colorScheme.onPrimary, + titleStyle: TextStyle = MaterialTheme.typography.labelLarge, + subtitleStyle: TextStyle = MaterialTheme.typography.labelLarge, background: Color = MaterialTheme.colorScheme.primary ) { BaseCell( @@ -33,7 +38,7 @@ fun TwoRowCell( Text( modifier = Modifier.fillMaxWidth(), text = titleText, - style = MaterialTheme.typography.labelLarge, + style = titleStyle, color = titleColor, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -41,13 +46,14 @@ fun TwoRowCell( Text( modifier = Modifier.fillMaxWidth(), text = subtitleText, - style = MaterialTheme.typography.labelLarge, + style = subtitleStyle, color = subtitleColor, maxLines = 1, overflow = TextOverflow.Ellipsis ) } }, + bodyView = bodyView, onCellClicked = onCellClicked, background = background, minHeight = Dimens.cellHeightTwoRows diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadExposedDropdownMenuBox.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadExposedDropdownMenuBox.kt new file mode 100644 index 000000000000..58df0815f099 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadExposedDropdownMenuBox.kt @@ -0,0 +1,73 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldColors +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 net.mullvad.mullvadvpn.lib.theme.color.menuItemColors + +/* + This has bug with dropdown menu width that might be fixed in compose material 3 1.3 + https://issuetracker.google.com/issues/205589613 +*/ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MullvadExposedDropdownMenuBox( + modifier: Modifier = Modifier, + label: String, + title: String, + colors: TextFieldColors, + content: @Composable ColumnScope.(onClick: () -> Unit) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier.clickable { expanded = !expanded } + ) { + TextField( + modifier = Modifier.fillMaxWidth().menuAnchor(), + readOnly = true, + value = title, + onValueChange = { /* Do nothing */}, + label = { Text(text = label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + colors = colors, + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.background(MaterialTheme.colorScheme.primary) + ) { + content { expanded = false } + } + } +} + +@Composable +fun MullvadDropdownMenuItem( + leadingIcon: @Composable (() -> Unit)? = null, + text: String, + onClick: () -> Unit +) { + DropdownMenuItem( + leadingIcon = leadingIcon, + colors = menuItemColors, + text = { Text(text = text) }, + onClick = onClick, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NavigateBackIconButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NavigateButton.kt similarity index 79% rename from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NavigateBackIconButton.kt rename to android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NavigateButton.kt index 798b5e15744c..3543ac31cb8f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NavigateBackIconButton.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NavigateButton.kt @@ -25,3 +25,10 @@ fun NavigateBackDownIconButton(onNavigateBack: () -> Unit) { ) } } + +@Composable +fun NavigateCloseIconButton(onNavigateClose: () -> Unit) { + IconButton(onClick = onNavigateClose) { + Icon(painter = painterResource(id = R.drawable.icon_close), contentDescription = null) + } +} 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 4e03ebf4ae2f..c90703b7c4ca 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 @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyListState @@ -245,7 +246,7 @@ fun ScaffoldWithLargeTopBarAndButton( horizontal = Dimens.sideMargin, vertical = Dimens.screenVerticalMargin ), - icon = { + trailingIcon = { Icon( painter = painterResource(id = R.drawable.icon_extlink), contentDescription = null @@ -274,7 +275,7 @@ fun ScaffoldWithSmallTopBar( content: @Composable (modifier: Modifier) -> Unit ) { Scaffold( - modifier = modifier.fillMaxSize(), + modifier = modifier.fillMaxSize().imePadding(), topBar = { MullvadSmallTopBar( title = appBarTitle, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ApiAccessMethodInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ApiAccessMethodInfoDialog.kt new file mode 100644 index 000000000000..141b610d4390 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ApiAccessMethodInfoDialog.kt @@ -0,0 +1,36 @@ +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 +import net.mullvad.mullvadvpn.compose.component.textResource +import net.mullvad.mullvadvpn.lib.theme.AppTheme + +@Preview +@Composable +private fun PreviewApiAccessMethodInfoDialog() { + AppTheme { ApiAccessMethodInfoDialog(EmptyDestinationsNavigator) } +} + +@Destination(style = DestinationStyle.Dialog::class) +@Composable +fun ApiAccessMethodInfoDialog(navigator: DestinationsNavigator) { + InfoDialog( + message = + buildString { + appendLine(stringResource(id = R.string.api_access_method_info_first_line)) + appendLine() + appendLine(stringResource(id = R.string.api_access_method_info_second_line)) + appendLine() + appendLine(textResource(id = R.string.api_access_method_info_third_line)) + appendLine() + appendLine(textResource(id = R.string.api_access_method_info_fourth_line)) + }, + onDismiss = navigator::navigateUp + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteApiAccessMethodConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteApiAccessMethodConfirmationDialog.kt new file mode 100644 index 000000000000..b4a98bd82cd9 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteApiAccessMethodConfirmationDialog.kt @@ -0,0 +1,72 @@ +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 androidx.lifecycle.compose.collectAsStateWithLifecycle +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.state.DeleteApiAccessMethodUiState +import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.viewmodel.DeleteApiAccessMethodConfirmationSideEffect +import net.mullvad.mullvadvpn.viewmodel.DeleteApiAccessMethodConfirmationViewModel +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Preview +@Composable +private fun PreviewDeleteApiAccessMethodConfirmationDialog() { + AppTheme { DeleteApiAccessMethodConfirmationDialog(state = DeleteApiAccessMethodUiState(null)) } +} + +@Composable +@Destination(style = DestinationStyle.Dialog::class) +fun DeleteApiAccessMethodConfirmation( + navigator: ResultBackNavigator, + apiAccessMethodId: ApiAccessMethodId +) { + val viewModel = + koinViewModel( + parameters = { parametersOf(apiAccessMethodId) } + ) + val state = viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffectCollect(viewModel.uiSideEffect) { + when (it) { + is DeleteApiAccessMethodConfirmationSideEffect.Deleted -> + navigator.navigateBack(result = true) + } + } + + DeleteApiAccessMethodConfirmationDialog( + state = state.value, + onDelete = viewModel::deleteApiAccessMethod, + onBack = navigator::navigateBack + ) +} + +@Composable +fun DeleteApiAccessMethodConfirmationDialog( + state: DeleteApiAccessMethodUiState, + onDelete: () -> Unit = {}, + onBack: () -> Unit = {} +) { + DeleteConfirmationDialog( + onDelete = onDelete, + onBack = onBack, + message = + stringResource( + id = R.string.delete_method_question, + ), + errorMessage = + if (state.deleteError != null) { + stringResource(id = R.string.error_occurred) + } else { + null + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteConfirmationDialog.kt new file mode 100644 index 000000000000..0133e0df3a59 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteConfirmationDialog.kt @@ -0,0 +1,90 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.NegativeButton +import net.mullvad.mullvadvpn.compose.button.PrimaryButton +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Preview +@Composable +private fun PreviewDeleteConfirmationDialog() { + AppTheme { + DeleteConfirmationDialog( + message = "Do you want to delete Cookie?", + errorMessage = null, + ) + } +} + +@Preview +@Composable +private fun PreviewDeleteConfirmationDialogError() { + AppTheme { + DeleteConfirmationDialog( + message = "Do you want to delete Cookie?", + errorMessage = "Cookie can not be deleted" + ) + } +} + +@Composable +fun DeleteConfirmationDialog( + message: String, + errorMessage: String?, + onDelete: () -> Unit = {}, + onBack: () -> Unit = {} +) { + AlertDialog( + onDismissRequest = onBack, + icon = { + Icon( + modifier = Modifier.fillMaxWidth().height(Dimens.dialogIconHeight), + painter = painterResource(id = R.drawable.icon_alert), + contentDescription = stringResource(id = R.string.remove_button), + tint = Color.Unspecified + ) + }, + title = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = message) + if (errorMessage != null) { + Text( + text = errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = Dimens.smallPadding) + ) + } + } + }, + dismissButton = { + PrimaryButton( + modifier = Modifier.focusRequester(FocusRequester()), + onClick = onBack, + text = stringResource(id = R.string.cancel) + ) + }, + confirmButton = { + NegativeButton(onClick = onDelete, text = stringResource(id = R.string.delete)) + }, + containerColor = MaterialTheme.colorScheme.background + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt index e9718d7c24ee..b6e56ec6375b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt @@ -1,20 +1,6 @@ package net.mullvad.mullvadvpn.compose.dialog -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -22,15 +8,12 @@ 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.communication.Deleted import net.mullvad.mullvadvpn.compose.state.DeleteCustomListUiState import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.CustomListName import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.viewmodel.DeleteCustomListConfirmationSideEffect import net.mullvad.mullvadvpn.viewmodel.DeleteCustomListConfirmationViewModel import org.koin.androidx.compose.koinViewModel @@ -80,45 +63,16 @@ fun DeleteCustomListConfirmationDialog( onDelete: () -> Unit = {}, onBack: () -> Unit = {} ) { - AlertDialog( - onDismissRequest = onBack, - icon = { - Icon( - modifier = Modifier.fillMaxWidth().height(Dimens.dialogIconHeight), - painter = painterResource(id = R.drawable.icon_alert), - contentDescription = stringResource(id = R.string.remove_button), - tint = Color.Unspecified - ) - }, - title = { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = - stringResource( - id = R.string.delete_custom_list_confirmation_description, - name.value - ) - ) - if (state.deleteError != null) { - Text( - text = stringResource(id = R.string.error_occurred), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = Dimens.smallPadding) - ) - } + DeleteConfirmationDialog( + onDelete = onDelete, + onBack = onBack, + message = + stringResource(id = R.string.delete_custom_list_confirmation_description, name.value), + errorMessage = + if (state.deleteError != null) { + stringResource(id = R.string.error_occurred) + } else { + null } - }, - dismissButton = { - PrimaryButton( - modifier = Modifier.focusRequester(FocusRequester()), - onClick = onBack, - text = stringResource(id = R.string.cancel) - ) - }, - confirmButton = { - NegativeButton(onClick = onDelete, text = stringResource(id = R.string.delete)) - }, - containerColor = MaterialTheme.colorScheme.background ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialog.kt new file mode 100644 index 000000000000..3ade701db4e7 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialog.kt @@ -0,0 +1,150 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.lifecycle.compose.collectAsStateWithLifecycle +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.PrimaryButton +import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium +import net.mullvad.mullvadvpn.compose.preview.SaveApiAccessMethodUiStatePreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.SaveApiAccessMethodUiState +import net.mullvad.mullvadvpn.compose.state.TestApiAccessMethodState +import net.mullvad.mullvadvpn.compose.test.SAVE_API_ACCESS_METHOD_CANCEL_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SAVE_API_ACCESS_METHOD_LOADING_SPINNER_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SAVE_API_ACCESS_METHOD_SAVE_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.viewmodel.SaveApiAccessMethodSideEffect +import net.mullvad.mullvadvpn.viewmodel.SaveApiAccessMethodViewModel +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Preview +@Composable +private fun PreviewSaveApiAccessMethodDialog( + @PreviewParameter(SaveApiAccessMethodUiStatePreviewParameterProvider::class) + state: SaveApiAccessMethodUiState +) { + AppTheme { SaveApiAccessMethodDialog(state = state) } +} + +@Destination(style = DestinationStyle.Dialog::class) +@Composable +fun SaveApiAccessMethod( + backNavigator: ResultBackNavigator, + id: ApiAccessMethodId?, + name: ApiAccessMethodName, + customProxy: ApiAccessMethod.CustomProxy +) { + val viewModel = + koinViewModel( + parameters = { parametersOf(id, name, customProxy) } + ) + + LaunchedEffectCollect(sideEffect = viewModel.uiSideEffect) { + when (it) { + SaveApiAccessMethodSideEffect.CouldNotSaveApiAccessMethod -> + backNavigator.navigateBack(result = false) + SaveApiAccessMethodSideEffect.SuccessfullyCreatedApiMethod -> + backNavigator.navigateBack(result = true) + } + } + + val state by viewModel.uiState.collectAsStateWithLifecycle() + SaveApiAccessMethodDialog( + state = state, + onCancel = backNavigator::navigateBack, + onSave = viewModel::save + ) +} + +@Composable +fun SaveApiAccessMethodDialog( + state: SaveApiAccessMethodUiState, + onCancel: () -> Unit = {}, + onSave: () -> Unit = {} +) { + AlertDialog( + icon = { + when (val testingState = state.testingState) { + is TestApiAccessMethodState.Result -> + Icon( + painter = + painterResource( + id = + if ( + testingState is TestApiAccessMethodState.Result.Successful + ) { + R.drawable.icon_success + } else { + R.drawable.icon_fail + } + ), + contentDescription = null + ) + TestApiAccessMethodState.Testing -> + MullvadCircularProgressIndicatorMedium( + modifier = Modifier.testTag(SAVE_API_ACCESS_METHOD_LOADING_SPINNER_TEST_TAG) + ) + } + }, + title = { Text(text = state.text(), style = MaterialTheme.typography.headlineSmall) }, + onDismissRequest = { /*Should not be able to dismiss*/}, + confirmButton = { + PrimaryButton( + onClick = onCancel, + text = stringResource(id = R.string.cancel), + isEnabled = + state.testingState is TestApiAccessMethodState.Testing || + state.testingState is TestApiAccessMethodState.Result.Failure, + modifier = Modifier.testTag(SAVE_API_ACCESS_METHOD_CANCEL_BUTTON_TEST_TAG) + ) + }, + dismissButton = { + if (state.testingState is TestApiAccessMethodState.Result.Failure) { + PrimaryButton( + onClick = onSave, + text = stringResource(id = R.string.save), + modifier = Modifier.testTag(SAVE_API_ACCESS_METHOD_SAVE_BUTTON_TEST_TAG) + ) + } + }, + containerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.onBackground, + iconContentColor = Color.Unspecified, + ) +} + +@Composable +private fun SaveApiAccessMethodUiState.text() = + stringResource( + id = + when (testingState) { + TestApiAccessMethodState.Testing -> R.string.verifying_api_method + TestApiAccessMethodState.Result.Successful -> R.string.api_reachable_adding_method + TestApiAccessMethodState.Result.Failure -> { + if (isSaving) { + R.string.adding_method + } else { + R.string.api_unreachable_save_anyway + } + } + } + ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessListUiStateParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessListUiStateParameterProvider.kt new file mode 100644 index 000000000000..980ff36848a9 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessListUiStateParameterProvider.kt @@ -0,0 +1,24 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.ApiAccessListUiState + +class ApiAccessListUiStateParameterProvider : PreviewParameterProvider { + + override val values: Sequence = + sequenceOf( + // Default state + ApiAccessListUiState(), + // Without custom api access method + ApiAccessListUiState( + currentApiAccessMethodSetting = defaultAccessMethods.first(), + apiAccessMethodSettings = defaultAccessMethods + ), + // With custom api + ApiAccessListUiState( + currentApiAccessMethodSetting = defaultAccessMethods.first(), + apiAccessMethodSettings = + defaultAccessMethods.plus(listOf(shadowsocks, socks5Remote)) + ) + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessMethodDetailsUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessMethodDetailsUiStatePreviewParameterProvider.kt new file mode 100644 index 000000000000..2f04157967b9 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessMethodDetailsUiStatePreviewParameterProvider.kt @@ -0,0 +1,36 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.ApiAccessMethodDetailsUiState + +class ApiAccessMethodDetailsUiStatePreviewParameterProvider : + PreviewParameterProvider { + override val values: Sequence = + sequenceOf( + ApiAccessMethodDetailsUiState.Loading(shadowsocks.id), + // Non-editable api access type + defaultAccessMethods[0].let { + ApiAccessMethodDetailsUiState.Content( + apiAccessMethodId = it.id, + name = it.name, + enabled = it.enabled, + isEditable = false, + isCurrentMethod = false, + isDisableable = true, + isTestingAccessMethod = false + ) + }, + // Editable api access type, current method, can not be disabled + shadowsocks.let { + ApiAccessMethodDetailsUiState.Content( + apiAccessMethodId = it.id, + name = it.name, + enabled = it.enabled, + isEditable = true, + isCurrentMethod = true, + isDisableable = false, + isTestingAccessMethod = false + ) + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessPreviewData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessPreviewData.kt new file mode 100644 index 000000000000..73027f55dece --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ApiAccessPreviewData.kt @@ -0,0 +1,56 @@ +package net.mullvad.mullvadvpn.compose.preview + +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodSetting +import net.mullvad.mullvadvpn.lib.model.Cipher +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.SocksAuth + +private const val UUID1 = "12345678-1234-5678-1234-567812345678" +private const val UUID2 = "12345678-1234-5678-1234-567812345679" +private const val UUID3 = "12345678-1234-5678-1234-567812345671" +private const val UUID4 = "12345678-1234-5678-1234-567812345672" + +internal val defaultAccessMethods = + listOf( + ApiAccessMethodSetting( + id = ApiAccessMethodId.fromString(UUID1), + name = ApiAccessMethodName.fromString("Direct"), + enabled = true, + apiAccessMethod = ApiAccessMethod.Direct + ), + ApiAccessMethodSetting( + id = ApiAccessMethodId.fromString(UUID2), + name = ApiAccessMethodName.fromString("Bridges"), + enabled = false, + apiAccessMethod = ApiAccessMethod.Bridges + ) + ) + +internal val socks5Remote = + ApiAccessMethodSetting( + id = ApiAccessMethodId.fromString(UUID3), + name = ApiAccessMethodName.fromString("Socks5 Remote"), + enabled = true, + apiAccessMethod = + ApiAccessMethod.CustomProxy.Socks5Remote( + ip = "192.167.1.1", + port = Port(80), + auth = SocksAuth(username = "hej", password = "password") + ) + ) + +internal val shadowsocks = + ApiAccessMethodSetting( + ApiAccessMethodId.fromString(UUID4), + ApiAccessMethodName.fromString("ShadowSocks"), + enabled = true, + ApiAccessMethod.CustomProxy.Shadowsocks( + ip = "192.168.1.1", + port = Port(123), + password = "Password", + cipher = Cipher.fromString("aes-128-cfb") + ) + ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/EditApiAccessMethodUiStateParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/EditApiAccessMethodUiStateParameterProvider.kt new file mode 100644 index 000000000000..d08f45f5dd56 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/EditApiAccessMethodUiStateParameterProvider.kt @@ -0,0 +1,77 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import arrow.core.nonEmptyListOf +import net.mullvad.mullvadvpn.compose.state.EditApiAccessFormData +import net.mullvad.mullvadvpn.compose.state.EditApiAccessMethodUiState +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod +import net.mullvad.mullvadvpn.lib.model.InvalidDataError + +class EditApiAccessMethodUiStateParameterProvider : + PreviewParameterProvider { + override val values = + sequenceOf( + EditApiAccessMethodUiState.Loading(editMode = true), + // Empty default state + EditApiAccessMethodUiState.Content( + editMode = false, + formData = EditApiAccessFormData.empty(), + hasChanges = false, + isTestingApiAccessMethod = false + ), + // Shadowsocks, no errors + EditApiAccessMethodUiState.Content( + editMode = true, + hasChanges = false, + formData = + shadowsocks.let { + val data = (it.apiAccessMethod as ApiAccessMethod.CustomProxy.Shadowsocks) + EditApiAccessFormData( + name = it.name.value, + serverIp = data.ip, + port = data.port.toString(), + password = data.password ?: "", + cipher = data.cipher, + username = "" + ) + }, + isTestingApiAccessMethod = false + ), + // Socks5 Remote, no errors, testing method + EditApiAccessMethodUiState.Content( + editMode = true, + hasChanges = false, + formData = + socks5Remote.let { + val data = (it.apiAccessMethod as ApiAccessMethod.CustomProxy.Socks5Remote) + EditApiAccessFormData( + name = it.name.value, + serverIp = data.ip, + port = data.port.toString(), + enableAuthentication = data.auth != null, + username = data.auth?.username ?: "", + password = data.auth?.password ?: "" + ) + }, + isTestingApiAccessMethod = true + ), + // Socks 5 remote, required errors + EditApiAccessMethodUiState.Content( + editMode = true, + hasChanges = false, + formData = + EditApiAccessFormData.empty() + .copy(enableAuthentication = true) + .updateWithErrors( + nonEmptyListOf( + InvalidDataError.NameError.Required, + InvalidDataError.PortError.Required, + InvalidDataError.ServerIpError.Required, + InvalidDataError.UserNameError.Required, + InvalidDataError.PasswordError.Required + ) + ), + isTestingApiAccessMethod = false + ) + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SaveApiAccessMethodUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SaveApiAccessMethodUiStatePreviewParameterProvider.kt new file mode 100644 index 000000000000..e603d11ea83b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SaveApiAccessMethodUiStatePreviewParameterProvider.kt @@ -0,0 +1,25 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.SaveApiAccessMethodUiState +import net.mullvad.mullvadvpn.compose.state.TestApiAccessMethodState + +class SaveApiAccessMethodUiStatePreviewParameterProvider : + PreviewParameterProvider { + override val values: Sequence = + sequenceOf( + SaveApiAccessMethodUiState(testingState = TestApiAccessMethodState.Testing), + SaveApiAccessMethodUiState( + testingState = TestApiAccessMethodState.Result.Successful, + isSaving = true + ), + SaveApiAccessMethodUiState( + testingState = TestApiAccessMethodState.Result.Failure, + isSaving = false + ), + SaveApiAccessMethodUiState( + testingState = TestApiAccessMethodState.Result.Failure, + isSaving = true + ) + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/TestMethodButtonPreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/TestMethodButtonPreviewParameterProvider.kt new file mode 100644 index 000000000000..1ee6a09c314e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/TestMethodButtonPreviewParameterProvider.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +class TestMethodButtonPreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf(false, true) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessListScreen.kt new file mode 100644 index 000000000000..1c26986fac2a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessListScreen.kt @@ -0,0 +1,202 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.PrimaryButton +import net.mullvad.mullvadvpn.compose.cell.DefaultNavigationView +import net.mullvad.mullvadvpn.compose.cell.TwoRowCell +import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.constant.ContentType +import net.mullvad.mullvadvpn.compose.destinations.ApiAccessMethodDetailsDestination +import net.mullvad.mullvadvpn.compose.destinations.ApiAccessMethodInfoDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.EditApiAccessMethodDestination +import net.mullvad.mullvadvpn.compose.extensions.itemsWithDivider +import net.mullvad.mullvadvpn.compose.preview.ApiAccessListUiStateParameterProvider +import net.mullvad.mullvadvpn.compose.state.ApiAccessListUiState +import net.mullvad.mullvadvpn.compose.test.API_ACCESS_LIST_INFO_TEST_TAG +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodSetting +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.viewmodel.ApiAccessListViewModel +import org.koin.androidx.compose.koinViewModel + +@Preview +@Composable +private fun PreviewApiAccessList( + @PreviewParameter(ApiAccessListUiStateParameterProvider::class) state: ApiAccessListUiState +) { + AppTheme { ApiAccessListScreen(state = state) } +} + +@Destination(style = SlideInFromRightTransition::class) +@Composable +fun ApiAccessList(navigator: DestinationsNavigator) { + val viewModel = koinViewModel() + val state by viewModel.uiState.collectAsStateWithLifecycle() + + ApiAccessListScreen( + state = state, + onAddMethodClick = { + navigator.navigate(EditApiAccessMethodDestination(null)) { launchSingleTop = true } + }, + onApiAccessMethodClick = { + navigator.navigate(ApiAccessMethodDetailsDestination(it.id)) { launchSingleTop = true } + }, + onApiAccessInfoClick = { + navigator.navigate(ApiAccessMethodInfoDialogDestination) { launchSingleTop = true } + }, + onBackClick = navigator::navigateUp + ) +} + +@Composable +fun ApiAccessListScreen( + state: ApiAccessListUiState, + onAddMethodClick: () -> Unit = {}, + onApiAccessMethodClick: (apiAccessMethodSetting: ApiAccessMethodSetting) -> Unit = {}, + onApiAccessInfoClick: () -> Unit = {}, + onBackClick: () -> Unit = {} +) { + ScaffoldWithMediumTopBar( + appBarTitle = stringResource(id = R.string.settings_api_access), + navigationIcon = { NavigateBackIconButton(onBackClick) }, + ) { modifier, lazyListState -> + LazyColumn(modifier = modifier, state = lazyListState) { + description() + currentAccessMethod( + currentApiAccessMethodName = state.currentApiAccessMethodSetting?.name, + onInfoClicked = onApiAccessInfoClick + ) + apiAccessMethodItems( + state.apiAccessMethodSettings, + onApiAccessMethodClick = onApiAccessMethodClick + ) + buttonPanel(onAddMethodClick = onAddMethodClick) + } + } +} + +private fun LazyListScope.description() { + item { + Text( + text = stringResource(id = R.string.api_access_description), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondary, + modifier = + Modifier.padding(start = Dimens.cellStartPadding, end = Dimens.cellEndPadding) + .fillMaxWidth() + ) + } +} + +private fun LazyListScope.currentAccessMethod( + currentApiAccessMethodName: ApiAccessMethodName?, + onInfoClicked: () -> Unit +) { + item { + Row( + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.mediumPadding + ), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + text = + stringResource( + id = R.string.current_method, + currentApiAccessMethodName?.value ?: "-", + ), + ) + IconButton( + onClick = onInfoClicked, + modifier = + Modifier.align(Alignment.CenterVertically) + .testTag(API_ACCESS_LIST_INFO_TEST_TAG), + ) { + Icon( + painter = painterResource(id = R.drawable.icon_info), + contentDescription = null, + tint = MaterialTheme.colorScheme.onBackground + ) + } + } + } +} + +private fun LazyListScope.apiAccessMethodItems( + apiAccessMethodSettings: List, + onApiAccessMethodClick: (apiAccessMethodSetting: ApiAccessMethodSetting) -> Unit +) { + itemsWithDivider( + items = apiAccessMethodSettings, + key = { item -> item.id }, + contentType = { ContentType.ITEM }, + ) { + ApiAccessMethodItem( + apiAccessMethodSetting = it, + onApiAccessMethodClick = onApiAccessMethodClick + ) + } +} + +@Composable +private fun ApiAccessMethodItem( + apiAccessMethodSetting: ApiAccessMethodSetting, + onApiAccessMethodClick: (apiAccessMethodSetting: ApiAccessMethodSetting) -> Unit +) { + TwoRowCell( + titleText = apiAccessMethodSetting.name.value, + subtitleText = + stringResource( + id = + if (apiAccessMethodSetting.enabled) { + R.string.on + } else { + R.string.off + } + ), + titleStyle = MaterialTheme.typography.titleMedium, + subtitleColor = MaterialTheme.colorScheme.onSecondary, + bodyView = { DefaultNavigationView(apiAccessMethodSetting.name.value) }, + onCellClicked = { onApiAccessMethodClick(apiAccessMethodSetting) } + ) +} + +private fun LazyListScope.buttonPanel(onAddMethodClick: () -> Unit) { + item { + PrimaryButton( + modifier = + Modifier.padding(horizontal = Dimens.sideMargin, vertical = Dimens.largePadding), + onClick = onAddMethodClick, + text = stringResource(id = R.string.add) + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessMethodDetailsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessMethodDetailsScreen.kt new file mode 100644 index 000000000000..0b3902aa7c41 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessMethodDetailsScreen.kt @@ -0,0 +1,300 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +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.PrimaryButton +import net.mullvad.mullvadvpn.compose.button.TestMethodButton +import net.mullvad.mullvadvpn.compose.cell.HeaderSwitchComposeCell +import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell +import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell +import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge +import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.destinations.DeleteApiAccessMethodConfirmationDestination +import net.mullvad.mullvadvpn.compose.destinations.EditApiAccessMethodDestination +import net.mullvad.mullvadvpn.compose.preview.ApiAccessMethodDetailsUiStatePreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.ApiAccessMethodDetailsUiState +import net.mullvad.mullvadvpn.compose.test.API_ACCESS_DETAILS_EDIT_BUTTON +import net.mullvad.mullvadvpn.compose.test.API_ACCESS_DETAILS_TOP_BAR_DROPDOWN_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.API_ACCESS_TEST_METHOD_BUTTON +import net.mullvad.mullvadvpn.compose.test.API_ACCESS_USE_METHOD_BUTTON +import net.mullvad.mullvadvpn.compose.test.DELETE_DROPDOWN_MENU_ITEM_TEST_TAG +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition +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.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.menuItemColors +import net.mullvad.mullvadvpn.viewmodel.ApiAccessMethodDetailsSideEffect +import net.mullvad.mullvadvpn.viewmodel.ApiAccessMethodDetailsViewModel +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Preview +@Composable +private fun PreviewApiAccessMethodDetailsScreen( + @PreviewParameter(ApiAccessMethodDetailsUiStatePreviewParameterProvider::class) + state: ApiAccessMethodDetailsUiState +) { + AppTheme { ApiAccessMethodDetailsScreen(state = state) } +} + +@Destination(style = SlideInFromRightTransition::class) +@Composable +fun ApiAccessMethodDetails( + navigator: DestinationsNavigator, + accessMethodId: ApiAccessMethodId, + confirmDeleteListResultRecipient: + ResultRecipient +) { + val viewModel = + koinViewModel( + parameters = { parametersOf(accessMethodId) } + ) + + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + LaunchedEffectCollect(sideEffect = viewModel.uiSideEffect) { + when (it) { + ApiAccessMethodDetailsSideEffect.GenericError -> + launch { + snackbarHostState.showSnackbarImmediately( + context.getString(R.string.error_occurred) + ) + } + is ApiAccessMethodDetailsSideEffect.OpenEditPage -> + navigator.navigate(EditApiAccessMethodDestination(it.apiAccessMethodId)) { + launchSingleTop = true + } + is ApiAccessMethodDetailsSideEffect.TestApiAccessMethodResult -> { + launch { + snackbarHostState.showSnackbarImmediately( + context.getString( + if (it.successful) { + R.string.api_reachable + } else { + R.string.api_unreachable + } + ) + ) + } + } + is ApiAccessMethodDetailsSideEffect.UnableToSetCurrentMethod -> + launch { + snackbarHostState.showSnackbarImmediately( + context.getString( + if (it.testMethodFailed) { + R.string.failed_to_set_current_test_error + } else { + R.string.failed_to_set_current_unknown_error + } + ) + ) + } + } + } + + confirmDeleteListResultRecipient.OnNavResultValue { navigator.navigateUp() } + + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(state.testingAccessMethod()) { + if (state.testingAccessMethod()) { + launch { + snackbarHostState.showSnackbarImmediately( + message = context.getString(R.string.testing_name, state.name()), + duration = SnackbarDuration.Indefinite, + actionLabel = context.getString(R.string.cancel), + onAction = viewModel::cancelTestMethod + ) + } + } + } + + ApiAccessMethodDetailsScreen( + state = state, + snackbarHostState = snackbarHostState, + onEditMethodClicked = viewModel::openEditPage, + onEnableClicked = viewModel::setEnableMethod, + onTestMethodClicked = viewModel::testMethod, + onUseMethodClicked = { + if (!state.currentMethod()) { + viewModel.setCurrentMethod() + } else { + coroutineScope.launch { + snackbarHostState.showSnackbarImmediately( + message = context.getString(R.string.this_is_already_set_as_current) + ) + } + } + }, + onDeleteApiAccessMethodClicked = { + navigator.navigate(DeleteApiAccessMethodConfirmationDestination(it)) { + launchSingleTop = true + } + }, + onBackClicked = navigator::navigateUp, + ) +} + +@Composable +fun ApiAccessMethodDetailsScreen( + state: ApiAccessMethodDetailsUiState, + snackbarHostState: SnackbarHostState = SnackbarHostState(), + onEditMethodClicked: () -> Unit = {}, + onEnableClicked: (Boolean) -> Unit = {}, + onTestMethodClicked: () -> Unit = {}, + onUseMethodClicked: () -> Unit = {}, + onDeleteApiAccessMethodClicked: (ApiAccessMethodId) -> Unit = {}, + onBackClicked: () -> Unit = {} +) { + ScaffoldWithMediumTopBar( + appBarTitle = state.name(), + navigationIcon = { NavigateBackIconButton(onBackClicked) }, + snackbarHostState = snackbarHostState, + actions = { + if (state.canBeEdited()) { + Actions( + onDeleteAccessMethod = { + onDeleteApiAccessMethodClicked(state.apiAccessMethodId) + } + ) + } + } + ) { modifier: Modifier -> + Column(modifier = modifier) { + when (state) { + is ApiAccessMethodDetailsUiState.Loading -> Loading() + is ApiAccessMethodDetailsUiState.Content -> + Content( + state = state, + onEditMethodClicked = onEditMethodClicked, + onEnableClicked = onEnableClicked, + onTestMethodClicked = onTestMethodClicked, + onUseMethodClicked = onUseMethodClicked + ) + } + } + } +} + +@Composable +private fun ColumnScope.Loading() { + MullvadCircularProgressIndicatorLarge(modifier = Modifier.align(Alignment.CenterHorizontally)) +} + +@Composable +private fun Content( + state: ApiAccessMethodDetailsUiState.Content, + onEditMethodClicked: () -> Unit, + onEnableClicked: (Boolean) -> Unit, + onTestMethodClicked: () -> Unit, + onUseMethodClicked: () -> Unit +) { + if (state.isEditable) { + NavigationComposeCell( + title = stringResource(id = R.string.edit_method), + onClick = onEditMethodClicked, + testTag = API_ACCESS_DETAILS_EDIT_BUTTON + ) + HorizontalDivider() + } + HeaderSwitchComposeCell( + isEnabled = state.isDisableable, + title = stringResource(id = R.string.enable_method), + isToggled = state.enabled, + onCellClicked = onEnableClicked + ) + if (!state.isDisableable) { + SwitchComposeSubtitleCell( + text = stringResource(id = R.string.at_least_on_method_needs_to_enabled), + ) + } + Spacer(modifier = Modifier.height(Dimens.verticalSpace)) + TestMethodButton( + modifier = + Modifier.padding(horizontal = Dimens.sideMargin).testTag(API_ACCESS_TEST_METHOD_BUTTON), + isTesting = state.isTestingAccessMethod, + onTestMethod = onTestMethodClicked + ) + Spacer(modifier = Modifier.height(Dimens.verticalSpace)) + PrimaryButton( + isEnabled = !state.isTestingAccessMethod, + modifier = + Modifier.padding(horizontal = Dimens.sideMargin).testTag(API_ACCESS_USE_METHOD_BUTTON), + onClick = onUseMethodClicked, + text = stringResource(id = R.string.use_method) + ) +} + +@Composable +private fun Actions(onDeleteAccessMethod: () -> Unit) { + var showMenu by remember { mutableStateOf(false) } + IconButton( + onClick = { showMenu = true }, + modifier = Modifier.testTag(API_ACCESS_DETAILS_TOP_BAR_DROPDOWN_BUTTON_TEST_TAG) + ) { + Icon(painter = painterResource(id = R.drawable.icon_more_vert), contentDescription = null) + if (showMenu) { + DropdownMenu( + expanded = true, + onDismissRequest = { showMenu = false }, + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer) + ) { + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.delete_method)) }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.icon_delete), + contentDescription = null, + ) + }, + colors = menuItemColors, + onClick = { + onDeleteAccessMethod() + showMenu = false + }, + modifier = Modifier.testTag(DELETE_DROPDOWN_MENU_ITEM_TEST_TAG) + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreen.kt new file mode 100644 index 000000000000..c6576cf21d80 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreen.kt @@ -0,0 +1,605 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +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.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.result.ResultRecipient +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.PrimaryButton +import net.mullvad.mullvadvpn.compose.button.TestMethodButton +import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge +import net.mullvad.mullvadvpn.compose.component.MullvadDropdownMenuItem +import net.mullvad.mullvadvpn.compose.component.MullvadExposedDropdownMenuBox +import net.mullvad.mullvadvpn.compose.component.NavigateCloseIconButton +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithSmallTopBar +import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.component.textResource +import net.mullvad.mullvadvpn.compose.destinations.DiscardChangesDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.SaveApiAccessMethodDestination +import net.mullvad.mullvadvpn.compose.preview.EditApiAccessMethodUiStateParameterProvider +import net.mullvad.mullvadvpn.compose.state.ApiAccessMethodTypes +import net.mullvad.mullvadvpn.compose.state.EditApiAccessFormData +import net.mullvad.mullvadvpn.compose.state.EditApiAccessMethodUiState +import net.mullvad.mullvadvpn.compose.test.EDIT_API_ACCESS_NAME_INPUT +import net.mullvad.mullvadvpn.compose.textfield.ApiAccessMethodTextField +import net.mullvad.mullvadvpn.compose.textfield.apiAccessTextFieldColors +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition +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.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName +import net.mullvad.mullvadvpn.lib.model.Cipher +import net.mullvad.mullvadvpn.lib.model.InvalidDataError +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible +import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar +import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible +import net.mullvad.mullvadvpn.viewmodel.EditApiAccessMethodViewModel +import net.mullvad.mullvadvpn.viewmodel.EditApiAccessSideEffect +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Preview +@Composable +private fun PreviewEditApiAccessMethodScreen( + @PreviewParameter(EditApiAccessMethodUiStateParameterProvider::class) + state: EditApiAccessMethodUiState +) { + AppTheme { EditApiAccessMethodScreen(state = state) } +} + +@Destination(style = SlideInFromRightTransition::class) +@Composable +fun EditApiAccessMethod( + navigator: DestinationsNavigator, + backNavigator: ResultBackNavigator, + saveApiAccessMethodResultRecipient: ResultRecipient, + discardChangesResultRecipient: ResultRecipient, + accessMethodId: ApiAccessMethodId? +) { + val viewModel = + koinViewModel(parameters = { parametersOf(accessMethodId) }) + + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + val scope = rememberCoroutineScope() + + LaunchedEffectCollect(sideEffect = viewModel.uiSideEffect) { + when (it) { + is EditApiAccessSideEffect.OpenSaveDialog -> + navigator.navigate( + SaveApiAccessMethodDestination( + id = it.id, + name = it.name, + customProxy = it.customProxy + ) + ) { + launchSingleTop = true + } + is EditApiAccessSideEffect.TestApiAccessMethodResult -> { + launch { + snackbarHostState.showSnackbarImmediately( + message = + context.getString( + if (it.successful) { + R.string.api_reachable + } else { + R.string.api_unreachable + } + ) + ) + } + } + } + } + + saveApiAccessMethodResultRecipient.OnNavResultValue { saveSuccessful -> + if (saveSuccessful) { + backNavigator.navigateBack(result = true) + } else { + // Show error snackbar + scope.launch { + snackbarHostState.showSnackbarImmediately( + message = context.getString(R.string.error_occurred) + ) + } + } + } + + discardChangesResultRecipient.OnNavResultValue { discardChanges -> + if (discardChanges) { + navigator.navigateUp() + } + } + + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(state.testingApiAccessMethod()) { + if (state.testingApiAccessMethod()) { + launch { + snackbarHostState.showSnackbarImmediately( + message = context.getString(R.string.testing), + duration = SnackbarDuration.Indefinite, + actionLabel = context.getString(R.string.cancel), + onAction = viewModel::cancelTestMethod + ) + } + } + } + + EditApiAccessMethodScreen( + state = state, + snackbarHostState = snackbarHostState, + onNameChanged = viewModel::onNameChanged, + onTypeSelected = viewModel::setAccessMethodType, + onIpChanged = viewModel::onServerIpChanged, + onPortChanged = viewModel::onPortChanged, + onPasswordChanged = viewModel::onPasswordChanged, + onCipherChange = viewModel::onCipherChanged, + onToggleAuthenticationEnabled = viewModel::onAuthenticationEnabledChanged, + onUsernameChanged = viewModel::onUsernameChanged, + onTestMethod = viewModel::testMethod, + onAddMethod = viewModel::trySave, + onNavigateBack = { + if (state.hasChanges()) { + navigator.navigate(DiscardChangesDialogDestination) { launchSingleTop = true } + } else { + navigator.navigateUp() + } + } + ) +} + +@Composable +fun EditApiAccessMethodScreen( + state: EditApiAccessMethodUiState, + snackbarHostState: SnackbarHostState = SnackbarHostState(), + onNameChanged: (String) -> Unit = {}, + onTypeSelected: (ApiAccessMethodTypes) -> Unit = {}, + onIpChanged: (String) -> Unit = {}, + onPortChanged: (String) -> Unit = {}, + onPasswordChanged: (String) -> Unit = {}, + onCipherChange: (Cipher) -> Unit = {}, + onToggleAuthenticationEnabled: (Boolean) -> Unit = {}, + onUsernameChanged: (String) -> Unit = {}, + onTestMethod: () -> Unit = {}, + onAddMethod: () -> Unit = {}, + onNavigateBack: () -> Unit = {} +) { + ScaffoldWithSmallTopBar( + snackbarHostState = snackbarHostState, + navigationIcon = { NavigateCloseIconButton(onNavigateClose = onNavigateBack) }, + appBarTitle = + stringResource( + if (state.editMode) { + R.string.edit_method + } else { + R.string.add_method + } + ), + ) { modifier -> + val scrollState = rememberScrollState() + Column( + modifier = + modifier + .drawVerticalScrollbar( + state = scrollState, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar) + ) + .verticalScroll(scrollState) + .padding(horizontal = Dimens.sideMargin, vertical = Dimens.screenVerticalMargin) + ) { + when (state) { + is EditApiAccessMethodUiState.Loading -> Loading() + is EditApiAccessMethodUiState.Content -> { + NameInputField( + name = state.formData.name, + nameError = state.formData.nameError, + onNameChanged = onNameChanged + ) + Spacer(modifier = Modifier.height(Dimens.verticalSpace)) + ApiAccessMethodTypeSelection(state.formData, onTypeSelected) + Spacer(modifier = Modifier.height(Dimens.verticalSpace)) + when (state.formData.apiAccessMethodTypes) { + ApiAccessMethodTypes.SHADOWSOCKS -> + ShadowsocksForm( + formData = state.formData, + onIpChanged = onIpChanged, + onPortChanged = onPortChanged, + onPasswordChanged = onPasswordChanged, + onCipherChange = onCipherChange + ) + ApiAccessMethodTypes.SOCKS5_REMOTE -> + Socks5RemoteForm( + formData = state.formData, + onIpChanged = onIpChanged, + onPortChanged = onPortChanged, + onToggleAuthenticationEnabled = onToggleAuthenticationEnabled, + onUsernameChanged = onUsernameChanged, + onPasswordChanged = onPasswordChanged + ) + } + Spacer(modifier = Modifier.weight(1f)) + TestMethodButton( + modifier = + Modifier.padding( + bottom = Dimens.verticalSpace, + top = Dimens.largePadding + ), + isTesting = state.isTestingApiAccessMethod, + onTestMethod = onTestMethod + ) + AddMethodButton(isNew = !state.editMode, onAddMethod = onAddMethod) + } + } + } + } +} + +@Composable +private fun ColumnScope.Loading() { + MullvadCircularProgressIndicatorLarge(modifier = Modifier.align(Alignment.CenterHorizontally)) +} + +@Composable +private fun NameInputField( + name: String, + nameError: InvalidDataError.NameError?, + onNameChanged: (String) -> Unit +) { + ApiAccessMethodTextField( + value = name, + keyboardType = KeyboardType.Text, + onValueChanged = onNameChanged, + labelText = stringResource(id = R.string.name), + isValidValue = nameError == null, + isDigitsOnlyAllowed = false, + maxCharLength = ApiAccessMethodName.MAX_LENGTH, + errorText = nameError?.let { textResource(id = R.string.this_field_is_required) }, + capitalization = KeyboardCapitalization.Words, + modifier = Modifier.animateContentSize().testTag(EDIT_API_ACCESS_NAME_INPUT) + ) +} + +@Composable +private fun ApiAccessMethodTypeSelection( + formData: EditApiAccessFormData, + onTypeSelected: (ApiAccessMethodTypes) -> Unit +) { + MullvadExposedDropdownMenuBox( + modifier = Modifier.padding(vertical = Dimens.miniPadding), + label = stringResource(id = R.string.type), + title = formData.apiAccessMethodTypes.text(), + colors = apiAccessTextFieldColors() + ) { close -> + ApiAccessMethodTypes.entries.forEach { + MullvadDropdownMenuItem( + text = it.text(), + onClick = { + close() + onTypeSelected(it) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.icon_tick), + contentDescription = null, + modifier = + Modifier.padding(end = Dimens.selectableCellTextMargin) + .alpha( + if (it == formData.apiAccessMethodTypes) AlphaVisible + else AlphaInvisible + ) + ) + } + ) + } + } +} + +@Composable +private fun ShadowsocksForm( + formData: EditApiAccessFormData, + onIpChanged: (String) -> Unit, + onPortChanged: (String) -> Unit, + onPasswordChanged: (String) -> Unit, + onCipherChange: (Cipher) -> Unit +) { + ServerIpInput( + serverIp = formData.serverIp, + serverIpError = formData.serverIpError, + onIpChanged = onIpChanged + ) + PortInput(port = formData.port, formData.portError, onPortChanged = onPortChanged) + PasswordInput( + password = formData.password, + passwordError = formData.passwordError, + optional = true, + onPasswordChanged = onPasswordChanged + ) + CipherSelection(cipher = formData.cipher, onCipherChange = onCipherChange) +} + +@Composable +private fun Socks5RemoteForm( + formData: EditApiAccessFormData, + onIpChanged: (String) -> Unit, + onPortChanged: (String) -> Unit, + onToggleAuthenticationEnabled: (Boolean) -> Unit, + onUsernameChanged: (String) -> Unit, + onPasswordChanged: (String) -> Unit +) { + ServerIpInput( + serverIp = formData.serverIp, + serverIpError = formData.serverIpError, + onIpChanged = onIpChanged + ) + PortInput(port = formData.port, portError = formData.portError, onPortChanged = onPortChanged) + EnableAuthentication(formData.enableAuthentication, onToggleAuthenticationEnabled) + if (formData.enableAuthentication) { + UsernameInput( + username = formData.username, + usernameError = formData.usernameError, + onUsernameChanged = onUsernameChanged, + ) + PasswordInput( + password = formData.password, + passwordError = formData.passwordError, + optional = false, + onPasswordChanged = onPasswordChanged + ) + } +} + +@Composable +private fun ServerIpInput( + serverIp: String, + serverIpError: InvalidDataError.ServerIpError?, + onIpChanged: (String) -> Unit +) { + ApiAccessMethodTextField( + value = serverIp, + keyboardType = KeyboardType.Text, + onValueChanged = onIpChanged, + labelText = stringResource(id = R.string.server), + isValidValue = serverIpError == null, + isDigitsOnlyAllowed = false, + errorText = + serverIpError?.let { + textResource( + id = + when (it) { + InvalidDataError.ServerIpError.Invalid -> + R.string.please_enter_a_valid_ip_address + InvalidDataError.ServerIpError.Required -> + R.string.this_field_is_required + } + ) + }, + modifier = Modifier.animateContentSize() + ) +} + +@Composable +private fun PortInput( + port: String, + portError: InvalidDataError.PortError?, + onPortChanged: (String) -> Unit +) { + ApiAccessMethodTextField( + value = port, + keyboardType = KeyboardType.Number, + onValueChanged = onPortChanged, + labelText = stringResource(id = R.string.port), + isValidValue = portError == null, + isDigitsOnlyAllowed = false, + errorText = + portError?.let { + textResource( + id = + when (it) { + is InvalidDataError.PortError.Invalid -> + R.string.please_enter_a_valid_remote_server_port + InvalidDataError.PortError.Required -> R.string.this_field_is_required + } + ) + }, + modifier = Modifier.animateContentSize() + ) +} + +@Composable +private fun PasswordInput( + password: String, + passwordError: InvalidDataError.PasswordError?, + optional: Boolean, + onPasswordChanged: (String) -> Unit +) { + ApiAccessMethodTextField( + value = password, + keyboardType = KeyboardType.Password, + onValueChanged = onPasswordChanged, + labelText = + stringResource( + id = + if (optional) { + R.string.password_optional + } else { + R.string.password + } + ), + isValidValue = passwordError == null, + isDigitsOnlyAllowed = false, + imeAction = + // So that we avoid going back to the name input when pressing done/next + if (optional) { + ImeAction.Next + } else { + ImeAction.Done + }, + errorText = passwordError?.let { textResource(id = R.string.this_field_is_required) }, + modifier = Modifier.animateContentSize() + ) +} + +@Composable +private fun CipherSelection(cipher: Cipher, onCipherChange: (Cipher) -> Unit) { + MullvadExposedDropdownMenuBox( + modifier = Modifier.padding(vertical = Dimens.miniPadding), + label = stringResource(id = R.string.cipher), + title = cipher.label, + colors = apiAccessTextFieldColors() + ) { close -> + Cipher.listAll().forEach { + MullvadDropdownMenuItem( + text = it.label, + onClick = { + close() + onCipherChange(it) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.icon_tick), + contentDescription = null, + modifier = + Modifier.padding(end = Dimens.selectableCellTextMargin) + .alpha(if (it == cipher) AlphaVisible else AlphaInvisible) + ) + } + ) + } + } +} + +@Composable +private fun EnableAuthentication( + authenticationEnabled: Boolean, + onToggleAuthenticationEnabled: (Boolean) -> Unit +) { + MullvadExposedDropdownMenuBox( + modifier = Modifier.padding(vertical = Dimens.miniPadding), + label = stringResource(id = R.string.authentication), + title = + stringResource( + id = + if (authenticationEnabled) { + R.string.on + } else { + R.string.off + } + ), + colors = apiAccessTextFieldColors() + ) { close -> + MullvadDropdownMenuItem( + text = stringResource(id = R.string.on), + onClick = { + close() + onToggleAuthenticationEnabled(true) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.icon_tick), + contentDescription = null, + modifier = + Modifier.padding(end = Dimens.selectableCellTextMargin) + .alpha(if (authenticationEnabled) AlphaVisible else AlphaInvisible) + ) + } + ) + MullvadDropdownMenuItem( + text = stringResource(id = R.string.off), + onClick = { + close() + onToggleAuthenticationEnabled(false) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.icon_tick), + contentDescription = null, + modifier = + Modifier.padding(end = Dimens.selectableCellTextMargin) + .alpha( + if (authenticationEnabled.not()) AlphaVisible else AlphaInvisible + ) + ) + } + ) + } +} + +@Composable +private fun UsernameInput( + username: String, + usernameError: InvalidDataError.UserNameError?, + onUsernameChanged: (String) -> Unit +) { + ApiAccessMethodTextField( + value = username, + keyboardType = KeyboardType.Text, + onValueChanged = onUsernameChanged, + labelText = stringResource(id = R.string.username), + isValidValue = usernameError == null, + isDigitsOnlyAllowed = false, + errorText = usernameError?.let { textResource(id = R.string.this_field_is_required) }, + modifier = Modifier.animateContentSize() + ) +} + +@Composable +private fun AddMethodButton(isNew: Boolean, onAddMethod: () -> Unit) { + PrimaryButton( + onClick = onAddMethod, + text = + stringResource( + id = + if (isNew) { + R.string.add + } else { + R.string.save + } + ) + ) +} + +@Composable +private fun ApiAccessMethodTypes.text(): String = + stringResource( + id = + when (this) { + ApiAccessMethodTypes.SHADOWSOCKS -> R.string.shadowsocks + ApiAccessMethodTypes.SOCKS5_REMOTE -> R.string.socks5_remote + }, + ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt index 0deadd545c88..ed0285eaf1c8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -47,6 +46,7 @@ import net.mullvad.mullvadvpn.lib.model.CustomListName import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.menuItemColors import net.mullvad.mullvadvpn.viewmodel.EditCustomListViewModel import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf @@ -201,12 +201,7 @@ private fun Actions(enabled: Boolean, onDeleteList: () -> Unit) { contentDescription = null, ) }, - colors = - MenuDefaults.itemColors() - .copy( - leadingIconColor = MaterialTheme.colorScheme.onSurface, - textColor = MaterialTheme.colorScheme.onSurface, - ), + colors = menuItemColors, onClick = { onDeleteList() showMenu = false diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt index 611f2e29ae9a..cb84246e34a2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt @@ -27,6 +27,7 @@ import net.mullvad.mullvadvpn.compose.cell.NavigationCellBody import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell import net.mullvad.mullvadvpn.compose.component.NavigateBackDownIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.destinations.ApiAccessListDestination import net.mullvad.mullvadvpn.compose.destinations.ReportProblemDestination import net.mullvad.mullvadvpn.compose.destinations.SplitTunnelingDestination import net.mullvad.mullvadvpn.compose.destinations.VpnSettingsDestination @@ -75,6 +76,9 @@ fun Settings(navigator: DestinationsNavigator) { onReportProblemCellClick = { navigator.navigate(ReportProblemDestination) { launchSingleTop = true } }, + onApiAccessClick = { + navigator.navigate(ApiAccessListDestination) { launchSingleTop = true } + }, onBackClick = navigator::navigateUp ) } @@ -86,6 +90,7 @@ fun SettingsScreen( onVpnSettingCellClick: () -> Unit = {}, onSplitTunnelingCellClick: () -> Unit = {}, onReportProblemCellClick: () -> Unit = {}, + onApiAccessClick: () -> Unit = {}, onBackClick: () -> Unit = {} ) { val context = LocalContext.current @@ -111,6 +116,14 @@ fun SettingsScreen( item { Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) } } + item { + NavigationComposeCell( + title = stringResource(id = R.string.settings_api_access), + onClick = onApiAccessClick + ) + } + item { Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) } + item { AppVersion(context, state) } item { Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiAccessListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiAccessListUiState.kt new file mode 100644 index 000000000000..91b84d36b73c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiAccessListUiState.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodSetting + +data class ApiAccessListUiState( + val currentApiAccessMethodSetting: ApiAccessMethodSetting? = null, + val apiAccessMethodSettings: List = emptyList() +) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiAccessMethodDetailsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiAccessMethodDetailsUiState.kt new file mode 100644 index 000000000000..d91bf850d054 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiAccessMethodDetailsUiState.kt @@ -0,0 +1,29 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName + +sealed interface ApiAccessMethodDetailsUiState { + val apiAccessMethodId: ApiAccessMethodId + + data class Loading(override val apiAccessMethodId: ApiAccessMethodId) : + ApiAccessMethodDetailsUiState + + data class Content( + override val apiAccessMethodId: ApiAccessMethodId, + val name: ApiAccessMethodName, + val enabled: Boolean, + val isEditable: Boolean, + val isDisableable: Boolean, + val isCurrentMethod: Boolean, + val isTestingAccessMethod: Boolean, + ) : ApiAccessMethodDetailsUiState + + fun name() = (this as? Content)?.name?.value ?: "" + + fun canBeEdited() = this is Content && isEditable + + fun testingAccessMethod() = this is Content && isTestingAccessMethod + + fun currentMethod() = this is Content && isCurrentMethod +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeleteApiAccessMethodUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeleteApiAccessMethodUiState.kt new file mode 100644 index 000000000000..8e08818ccaa9 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeleteApiAccessMethodUiState.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.lib.model.RemoveApiAccessMethodError + +data class DeleteApiAccessMethodUiState(val deleteError: RemoveApiAccessMethodError?) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditApiAccessMethodUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditApiAccessMethodUiState.kt new file mode 100644 index 000000000000..77590611c08e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditApiAccessMethodUiState.kt @@ -0,0 +1,87 @@ +package net.mullvad.mullvadvpn.compose.state + +import arrow.core.NonEmptyList +import net.mullvad.mullvadvpn.lib.common.util.getFirstInstanceOrNull +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName +import net.mullvad.mullvadvpn.lib.model.Cipher +import net.mullvad.mullvadvpn.lib.model.InvalidDataError + +sealed interface EditApiAccessMethodUiState { + val editMode: Boolean + + data class Loading(override val editMode: Boolean) : EditApiAccessMethodUiState + + data class Content( + override val editMode: Boolean, + val formData: EditApiAccessFormData, + val hasChanges: Boolean, + val isTestingApiAccessMethod: Boolean, + ) : EditApiAccessMethodUiState + + fun hasChanges() = this is Content && hasChanges + + fun testingApiAccessMethod(): Boolean = this is Content && isTestingApiAccessMethod +} + +data class EditApiAccessFormData( + val name: String, + val nameError: InvalidDataError.NameError? = null, + val apiAccessMethodTypes: ApiAccessMethodTypes = ApiAccessMethodTypes.default(), + val serverIp: String, + val serverIpError: InvalidDataError.ServerIpError? = null, + val port: String, + val portError: InvalidDataError.PortError? = null, + val enableAuthentication: Boolean = false, + val username: String, + val usernameError: InvalidDataError.UserNameError? = null, + val password: String, + val passwordError: InvalidDataError.PasswordError? = null, + val cipher: Cipher = Cipher.first() +) { + fun updateWithErrors(errors: NonEmptyList): EditApiAccessFormData = + copy( + nameError = errors.getFirstInstanceOrNull(), + serverIpError = errors.getFirstInstanceOrNull(), + portError = errors.getFirstInstanceOrNull(), + usernameError = errors.getFirstInstanceOrNull(), + passwordError = errors.getFirstInstanceOrNull() + ) + + companion object { + fun empty() = + EditApiAccessFormData(name = "", password = "", port = "", serverIp = "", username = "") + + fun fromCustomProxy(name: ApiAccessMethodName, customProxy: ApiAccessMethod.CustomProxy) = + when (customProxy) { + is ApiAccessMethod.CustomProxy.Shadowsocks -> { + EditApiAccessFormData( + name = name.value, + serverIp = customProxy.ip, + port = customProxy.port.toString(), + password = customProxy.password ?: "", + cipher = customProxy.cipher, + username = "", + ) + } + is ApiAccessMethod.CustomProxy.Socks5Remote -> + EditApiAccessFormData( + name = name.value, + serverIp = customProxy.ip, + port = customProxy.port.toString(), + enableAuthentication = customProxy.auth != null, + username = customProxy.auth?.username ?: "", + password = customProxy.auth?.password ?: "" + ) + } + } +} + +enum class ApiAccessMethodTypes { + SHADOWSOCKS, + SOCKS5_REMOTE; + + companion object { + fun default(): ApiAccessMethodTypes = SHADOWSOCKS + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SaveApiAccessMethodUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SaveApiAccessMethodUiState.kt new file mode 100644 index 000000000000..e38a4de569b6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SaveApiAccessMethodUiState.kt @@ -0,0 +1,16 @@ +package net.mullvad.mullvadvpn.compose.state + +data class SaveApiAccessMethodUiState( + val testingState: TestApiAccessMethodState = TestApiAccessMethodState.Testing, + val isSaving: Boolean = false +) + +sealed interface TestApiAccessMethodState { + data object Testing : TestApiAccessMethodState + + sealed interface Result : TestApiAccessMethodState { + data object Successful : Result + + data object Failure : Result + } +} 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 0111fc7a4656..47c109d35364 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 @@ -81,3 +81,24 @@ const val SERVER_IP_OVERRIDES_IMPORT_BY_TEXT_TEST_TAG = "server_ip_override_impo 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" + +// SaveApiAccessMethodDialog +const val SAVE_API_ACCESS_METHOD_LOADING_SPINNER_TEST_TAG = + "save_api_access_method_loading_spinner_test_tag" +const val SAVE_API_ACCESS_METHOD_CANCEL_BUTTON_TEST_TAG = + "save_api_access_method_cancel_button_test_tag" +const val SAVE_API_ACCESS_METHOD_SAVE_BUTTON_TEST_TAG = + "save_api_access_method_save_button_test_tag" + +// ApiAccessListScreen +const val API_ACCESS_LIST_INFO_TEST_TAG = "api_access_list_info_test_tag" + +// ApiAccessMethodDetailsScreen +const val API_ACCESS_DETAILS_TOP_BAR_DROPDOWN_BUTTON_TEST_TAG = + "api_access_details_top_bar_dropdown_button_test_tag" +const val API_ACCESS_DETAILS_EDIT_BUTTON = "api_access_details_edit_button_test_tag" +const val API_ACCESS_USE_METHOD_BUTTON = "api_access_details_use_method_test_tag" +const val API_ACCESS_TEST_METHOD_BUTTON = "api_access_details_test_method_test_tag" + +// EditApiAccessMethodScreen +const val EDIT_API_ACCESS_NAME_INPUT = "edit_api_access_name_input" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/ApiAccessMethodTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/ApiAccessMethodTextField.kt new file mode 100644 index 000000000000..614470da4875 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/ApiAccessMethodTextField.kt @@ -0,0 +1,85 @@ +package net.mullvad.mullvadvpn.compose.textfield + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Composable +fun ApiAccessMethodTextField( + value: String, + keyboardType: KeyboardType, + modifier: Modifier = Modifier, + onValueChanged: (String) -> Unit, + labelText: String?, + maxCharLength: Int = Int.MAX_VALUE, + isValidValue: Boolean, + isDigitsOnlyAllowed: Boolean, + errorText: String?, + capitalization: KeyboardCapitalization = KeyboardCapitalization.None, + imeAction: ImeAction = ImeAction.Next, +) { + val focusManager = LocalFocusManager.current + CustomTextField( + value = value, + keyboardType = keyboardType, + onValueChanged = onValueChanged, + onSubmit = { + if (imeAction == ImeAction.Done) { + focusManager.clearFocus() + } + }, + labelText = labelText, + placeholderText = null, + isValidValue = isValidValue, + isDigitsOnlyAllowed = isDigitsOnlyAllowed, + maxCharLength = maxCharLength, + supportingText = errorText?.let { { ErrorSupportingText(errorText) } }, + colors = apiAccessTextFieldColors(), + modifier = + modifier + .defaultMinSize(minHeight = Dimens.formTextFieldMinHeight) + .padding(vertical = Dimens.miniPadding), + keyboardOptions = + KeyboardOptions( + capitalization = capitalization, + autoCorrect = false, + keyboardType = keyboardType, + imeAction = imeAction + ) + ) +} + +@Composable +private fun ErrorSupportingText(text: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = Dimens.miniPadding) + ) { + Image( + painter = painterResource(id = R.drawable.icon_alert), + contentDescription = null, + modifier = Modifier.size(Dimens.smallIconSize) + ) + Text( + text = text, + color = MaterialTheme.colorScheme.onSecondary, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = Dimens.smallPadding) + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt index be5750ef5c64..ac73e9fa34e3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldColors import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue @@ -35,11 +36,19 @@ fun CustomTextField( onSubmit: (String) -> Unit, isEnabled: Boolean = true, placeholderText: String?, + labelText: String? = null, maxCharLength: Int = Int.MAX_VALUE, isValidValue: Boolean, isDigitsOnlyAllowed: Boolean, visualTransformation: VisualTransformation = VisualTransformation.None, supportingText: @Composable (() -> Unit)? = null, + colors: TextFieldColors = mullvadDarkTextFieldColors(), + keyboardOptions: KeyboardOptions = + KeyboardOptions( + keyboardType = keyboardType, + imeAction = ImeAction.Done, + autoCorrect = false, + ) ) { val scope = rememberCoroutineScope() @@ -84,12 +93,7 @@ fun CustomTextField( enabled = isEnabled, singleLine = true, placeholder = placeholderText?.let { { Text(text = it) } }, - keyboardOptions = - KeyboardOptions( - keyboardType = keyboardType, - imeAction = ImeAction.Done, - autoCorrect = false, - ), + keyboardOptions = keyboardOptions, keyboardActions = KeyboardActions( onDone = { @@ -101,9 +105,10 @@ fun CustomTextField( } ), visualTransformation = visualTransformation, - colors = mullvadDarkTextFieldColors(), + colors = colors, isError = !isValidValue, modifier = modifier.clip(MaterialTheme.shapes.small).fillMaxWidth(), - supportingText = supportingText + supportingText = supportingText, + label = labelText?.let { { Text(text = labelText) } }, ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/TextFieldColors.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/TextFieldColors.kt index b0770632bbec..69b387ee7ade 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/TextFieldColors.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/TextFieldColors.kt @@ -109,3 +109,30 @@ fun mullvadDarkTextFieldColors(): TextFieldColors = errorIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ) + +@Composable +fun apiAccessTextFieldColors(): TextFieldColors = + TextFieldDefaults.colors( + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + errorContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + errorTextColor = MaterialTheme.colorScheme.onSurface, + cursorColor = MaterialTheme.colorScheme.onSurface, + focusedPlaceholderColor = MaterialTheme.colorScheme.onSecondary, + unfocusedPlaceholderColor = MaterialTheme.colorScheme.onSecondary, + focusedLabelColor = MaterialTheme.colorScheme.onSecondary, + disabledLabelColor = MaterialTheme.colorScheme.onSecondary, + unfocusedLabelColor = MaterialTheme.colorScheme.onSecondary, + errorLabelColor = MaterialTheme.colorScheme.onSecondary, + focusedIndicatorColor = MaterialTheme.colorScheme.onSurface, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = MaterialTheme.colorScheme.error, + unfocusedIndicatorColor = Color.Transparent, + focusedTrailingIconColor = MaterialTheme.colorScheme.onSurface, + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurface, + unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurface + ) 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 cdb0f9e0a38b..70153de61969 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 @@ -9,9 +9,13 @@ import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.applist.ApplicationsProvider import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.payment.PaymentProvider import net.mullvad.mullvadvpn.lib.shared.VoucherRepository +import net.mullvad.mullvadvpn.repository.ApiAccessRepository import net.mullvad.mullvadvpn.repository.ChangelogRepository import net.mullvad.mullvadvpn.repository.CustomListsRepository import net.mullvad.mullvadvpn.repository.InAppNotificationController @@ -44,15 +48,19 @@ import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase import net.mullvad.mullvadvpn.util.ChangelogDataProvider import net.mullvad.mullvadvpn.util.IChangelogDataProvider import net.mullvad.mullvadvpn.viewmodel.AccountViewModel +import net.mullvad.mullvadvpn.viewmodel.ApiAccessListViewModel +import net.mullvad.mullvadvpn.viewmodel.ApiAccessMethodDetailsViewModel import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import net.mullvad.mullvadvpn.viewmodel.CreateCustomListDialogViewModel import net.mullvad.mullvadvpn.viewmodel.CustomListLocationsViewModel import net.mullvad.mullvadvpn.viewmodel.CustomListsViewModel +import net.mullvad.mullvadvpn.viewmodel.DeleteApiAccessMethodConfirmationViewModel import net.mullvad.mullvadvpn.viewmodel.DeleteCustomListConfirmationViewModel import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewModel +import net.mullvad.mullvadvpn.viewmodel.EditApiAccessMethodViewModel import net.mullvad.mullvadvpn.viewmodel.EditCustomListNameDialogViewModel import net.mullvad.mullvadvpn.viewmodel.EditCustomListViewModel import net.mullvad.mullvadvpn.viewmodel.FilterViewModel @@ -64,6 +72,7 @@ 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.SaveApiAccessMethodViewModel import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesViewModel import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel @@ -112,6 +121,7 @@ val uiModule = module { single { RelayListFilterRepository(get()) } single { VoucherRepository(get(), get()) } single { SplitTunnelingRepository(get()) } + single { ApiAccessRepository(get()) } single { AccountExpiryNotificationUseCase(get()) } single { TunnelStateNotificationUseCase(get()) } @@ -198,6 +208,23 @@ val uiModule = module { viewModel { ServerIpOverridesViewModel(get(), get()) } viewModel { ResetServerIpOverridesConfirmationViewModel(get()) } viewModel { VpnPermissionViewModel(get(), get()) } + viewModel { ApiAccessListViewModel(get()) } + viewModel { (accessMethodId: ApiAccessMethodId?) -> + EditApiAccessMethodViewModel(accessMethodId, get(), get()) + } + viewModel { + ( + id: ApiAccessMethodId?, + name: ApiAccessMethodName, + customProxy: ApiAccessMethod.CustomProxy) -> + SaveApiAccessMethodViewModel(id, name, customProxy, get()) + } + viewModel { (accessMethodId: ApiAccessMethodId) -> + ApiAccessMethodDetailsViewModel(accessMethodId, get()) + } + viewModel { (accessMethodId: ApiAccessMethodId) -> + DeleteApiAccessMethodConfirmationViewModel(accessMethodId, 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/ApiAccessRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ApiAccessRepository.kt new file mode 100644 index 000000000000..bba17d84a0ea --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ApiAccessRepository.kt @@ -0,0 +1,85 @@ +package net.mullvad.mullvadvpn.repository + +import arrow.core.raise.either +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodSetting +import net.mullvad.mullvadvpn.lib.model.GetApiAccessMethodError +import net.mullvad.mullvadvpn.lib.model.NewAccessMethodSetting + +class ApiAccessRepository( + private val managementService: ManagementService, + dispatcher: CoroutineDispatcher = Dispatchers.IO +) { + val accessMethods = + managementService.settings + .mapNotNull { it.apiAccessMethodSettings } + .stateIn(CoroutineScope(dispatcher), SharingStarted.Eagerly, null) + + val currentAccessMethod = + managementService.currentAccessMethod.stateIn( + CoroutineScope(dispatcher), + SharingStarted.Eagerly, + null + ) + + suspend fun addApiAccessMethod(newAccessMethodSetting: NewAccessMethodSetting) = + managementService.addApiAccessMethod(newAccessMethodSetting) + + suspend fun removeApiAccessMethod(apiAccessMethodId: ApiAccessMethodId) = + managementService.removeApiAccessMethod(apiAccessMethodId) + + suspend fun setCurrentApiAccessMethod(apiAccessMethodId: ApiAccessMethodId) = + managementService.setApiAccessMethod(apiAccessMethodId) + + private suspend fun updateApiAccessMethod(apiAccessMethodSetting: ApiAccessMethodSetting) = + managementService.updateApiAccessMethod(apiAccessMethodSetting) + + suspend fun updateApiAccessMethod( + apiAccessMethodId: ApiAccessMethodId, + apiAccessMethodName: ApiAccessMethodName, + apiAccessMethod: ApiAccessMethod + ) = either { + val apiAccessMethodSetting = getApiAccessMethodSettingById(apiAccessMethodId).bind() + updateApiAccessMethod( + apiAccessMethodSetting.copy( + id = apiAccessMethodId, + name = apiAccessMethodName, + apiAccessMethod = apiAccessMethod + ) + ) + .bind() + } + + suspend fun testCustomApiAccessMethod(customProxy: ApiAccessMethod.CustomProxy) = + managementService.testCustomApiAccessMethod(customProxy) + + suspend fun testApiAccessMethodById(apiAccessMethodId: ApiAccessMethodId) = + managementService.testApiAccessMethodById(apiAccessMethodId) + + fun getApiAccessMethodSettingById(id: ApiAccessMethodId) = + either { + accessMethods.value?.firstOrNull { it.id == id } + ?: raise(GetApiAccessMethodError.NotFound) + } + + fun apiAccessMethodSettingById(id: ApiAccessMethodId): Flow = + accessMethods.mapNotNull { it?.firstOrNull { accessMethod -> accessMethod.id == id } } + + fun enabledApiAccessMethods(): Flow> = + accessMethods.mapNotNull { it?.filter { accessMethod -> accessMethod.enabled } } + + suspend fun setEnabledApiAccessMethod(id: ApiAccessMethodId, enabled: Boolean) = either { + val accessMethod = getApiAccessMethodSettingById(id).bind() + updateApiAccessMethod(accessMethod.copy(enabled = enabled)).bind() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiAccessListViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiAccessListViewModel.kt new file mode 100644 index 000000000000..cabc452b0ab1 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiAccessListViewModel.kt @@ -0,0 +1,23 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.compose.state.ApiAccessListUiState +import net.mullvad.mullvadvpn.repository.ApiAccessRepository + +class ApiAccessListViewModel(apiAccessRepository: ApiAccessRepository) : ViewModel() { + + val uiState = + combine(apiAccessRepository.accessMethods, apiAccessRepository.currentAccessMethod) { + apiAccessMethods, + currentAccessMethod -> + ApiAccessListUiState( + currentApiAccessMethodSetting = currentAccessMethod, + apiAccessMethodSettings = apiAccessMethods ?: emptyList() + ) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ApiAccessListUiState()) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiAccessMethodDetailsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiAccessMethodDetailsViewModel.kt new file mode 100644 index 000000000000..a6ba01e81c9f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiAccessMethodDetailsViewModel.kt @@ -0,0 +1,128 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import arrow.core.Either +import arrow.core.raise.either +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.state.ApiAccessMethodDetailsUiState +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.TestApiAccessMethodError +import net.mullvad.mullvadvpn.repository.ApiAccessRepository + +class ApiAccessMethodDetailsViewModel( + private val apiAccessMethodId: ApiAccessMethodId, + private val apiAccessRepository: ApiAccessRepository +) : ViewModel() { + private var testingJob: Job? = null + + private val _uiSideEffect = Channel(Channel.BUFFERED) + val uiSideEffect = _uiSideEffect.receiveAsFlow() + private val isTestingApiAccessMethodState = MutableStateFlow(false) + val uiState = + combine( + apiAccessRepository.apiAccessMethodSettingById(apiAccessMethodId), + apiAccessRepository.enabledApiAccessMethods(), + apiAccessRepository.currentAccessMethod, + isTestingApiAccessMethodState + ) { + apiAccessMethod, + enabledApiAccessMethods, + currentAccessMethod, + isTestingApiAccessMethod -> + ApiAccessMethodDetailsUiState.Content( + apiAccessMethodId = apiAccessMethodId, + name = apiAccessMethod.name, + enabled = apiAccessMethod.enabled, + isEditable = apiAccessMethod.apiAccessMethod is ApiAccessMethod.CustomProxy, + isDisableable = enabledApiAccessMethods.any { it.id != apiAccessMethodId }, + isCurrentMethod = currentAccessMethod?.id == apiAccessMethodId, + isTestingAccessMethod = isTestingApiAccessMethod + ) + } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + ApiAccessMethodDetailsUiState.Loading(apiAccessMethodId = apiAccessMethodId) + ) + + fun setCurrentMethod() { + testingJob = + viewModelScope.launch { + either { + testMethodById().bind() + apiAccessRepository + .setCurrentApiAccessMethod(apiAccessMethodId = apiAccessMethodId) + .bind() + } + .onLeft { + _uiSideEffect.send( + ApiAccessMethodDetailsSideEffect.UnableToSetCurrentMethod( + testMethodFailed = it is TestApiAccessMethodError + ) + ) + } + } + } + + fun testMethod() { + testingJob = + viewModelScope.launch { + val result = testMethodById() + _uiSideEffect.send( + ApiAccessMethodDetailsSideEffect.TestApiAccessMethodResult(result.isRight()) + ) + } + } + + fun setEnableMethod(enable: Boolean) { + viewModelScope.launch { + apiAccessRepository.setEnabledApiAccessMethod(apiAccessMethodId, enable).onLeft { + _uiSideEffect.send(ApiAccessMethodDetailsSideEffect.GenericError) + } + } + } + + fun openEditPage() { + viewModelScope.launch { + _uiSideEffect.send(ApiAccessMethodDetailsSideEffect.OpenEditPage(apiAccessMethodId)) + } + } + + fun cancelTestMethod() { + if (testingJob?.isActive == true) { + testingJob?.cancel("User cancelled job") + isTestingApiAccessMethodState.value = false + } + } + + private suspend fun testMethodById(): Either { + isTestingApiAccessMethodState.value = true + return apiAccessRepository + .testApiAccessMethodById(apiAccessMethodId) + .onLeft { isTestingApiAccessMethodState.value = false } + .onRight { isTestingApiAccessMethodState.value = false } + } +} + +sealed interface ApiAccessMethodDetailsSideEffect { + data class OpenEditPage(val apiAccessMethodId: ApiAccessMethodId) : + ApiAccessMethodDetailsSideEffect + + data object GenericError : ApiAccessMethodDetailsSideEffect + + data class TestApiAccessMethodResult(val successful: Boolean) : + ApiAccessMethodDetailsSideEffect + + data class UnableToSetCurrentMethod(val testMethodFailed: Boolean) : + ApiAccessMethodDetailsSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteApiAccessMethodConfirmationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteApiAccessMethodConfirmationViewModel.kt new file mode 100644 index 000000000000..651081244f98 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteApiAccessMethodConfirmationViewModel.kt @@ -0,0 +1,51 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.state.DeleteApiAccessMethodUiState +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.RemoveApiAccessMethodError +import net.mullvad.mullvadvpn.repository.ApiAccessRepository + +class DeleteApiAccessMethodConfirmationViewModel( + private val apiAccessMethodId: ApiAccessMethodId, + private val apiAccessRepository: ApiAccessRepository +) : ViewModel() { + private val _uiSideEffect = + Channel(Channel.BUFFERED) + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + private val _error = MutableStateFlow(null) + + val uiState = + _error + .map { DeleteApiAccessMethodUiState(it) } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + DeleteApiAccessMethodUiState(null) + ) + + fun deleteApiAccessMethod() { + viewModelScope.launch { + _error.emit(null) + apiAccessRepository + .removeApiAccessMethod(apiAccessMethodId) + .fold( + { _error.tryEmit(it) }, + { _uiSideEffect.send(DeleteApiAccessMethodConfirmationSideEffect.Deleted) } + ) + } + } +} + +sealed interface DeleteApiAccessMethodConfirmationSideEffect { + data object Deleted : DeleteApiAccessMethodConfirmationSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditApiAccessMethodViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditApiAccessMethodViewModel.kt new file mode 100644 index 000000000000..87316e90e21d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditApiAccessMethodViewModel.kt @@ -0,0 +1,274 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import arrow.core.Either +import arrow.core.Either.Companion.zipOrAccumulate +import arrow.core.EitherNel +import arrow.core.getOrElse +import arrow.core.nel +import arrow.core.raise.either +import arrow.core.raise.ensure +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.state.ApiAccessMethodTypes +import net.mullvad.mullvadvpn.compose.state.EditApiAccessFormData +import net.mullvad.mullvadvpn.compose.state.EditApiAccessMethodUiState +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName +import net.mullvad.mullvadvpn.lib.model.Cipher +import net.mullvad.mullvadvpn.lib.model.InvalidDataError +import net.mullvad.mullvadvpn.lib.model.ParsePortError +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.SocksAuth +import net.mullvad.mullvadvpn.repository.ApiAccessRepository +import org.apache.commons.validator.routines.InetAddressValidator + +class EditApiAccessMethodViewModel( + private val apiAccessMethodId: ApiAccessMethodId?, + private val apiAccessRepository: ApiAccessRepository, + private val inetAddressValidator: InetAddressValidator +) : ViewModel() { + private var testingJob: Job? = null + + private val _uiSideEffect = Channel(Channel.BUFFERED) + val uiSideEffect = _uiSideEffect.receiveAsFlow() + private val isTestingApiAccessMethod = MutableStateFlow(false) + private val formData = MutableStateFlow(initialData()) + val uiState = + combine(flowOf(initialData()), formData, isTestingApiAccessMethod) { + initialData, + formData, + isTestingApiAccessMethod -> + EditApiAccessMethodUiState.Content( + editMode = apiAccessMethodId != null, + formData = formData, + hasChanges = initialData != formData, + isTestingApiAccessMethod = isTestingApiAccessMethod + ) + } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + EditApiAccessMethodUiState.Loading(editMode = apiAccessMethodId != null) + ) + + fun setAccessMethodType(accessMethodType: ApiAccessMethodTypes) { + formData.update { it.copy(apiAccessMethodTypes = accessMethodType) } + } + + fun onNameChanged(name: String) { + formData.update { it.copy(name = name, nameError = null) } + } + + fun onServerIpChanged(serverIp: String) { + formData.update { it.copy(serverIp = serverIp, serverIpError = null) } + } + + fun onPortChanged(port: String) { + formData.update { it.copy(port = port, portError = null) } + } + + fun onPasswordChanged(password: String) { + formData.update { it.copy(password = password, passwordError = null) } + } + + fun onCipherChanged(cipher: Cipher) { + formData.update { it.copy(cipher = cipher) } + } + + fun onAuthenticationEnabledChanged(enabled: Boolean) { + formData.update { it.copy(enableAuthentication = enabled) } + } + + fun onUsernameChanged(username: String) { + formData.update { it.copy(username = username, usernameError = null) } + } + + fun testMethod() { + testingJob = + viewModelScope.launch { + formData.value + .parseConnectionFormData() + .fold( + { errors -> formData.update { it.updateWithErrors(errors) } }, + { customProxy -> + isTestingApiAccessMethod.value = true + val result = apiAccessRepository.testCustomApiAccessMethod(customProxy) + _uiSideEffect.send( + EditApiAccessSideEffect.TestApiAccessMethodResult(result.isRight()) + ) + isTestingApiAccessMethod.value = false + } + ) + } + } + + fun trySave() { + viewModelScope.launch { + formData.value + .parseFormData() + .fold( + { errors -> formData.update { it.updateWithErrors(errors) } }, + { (name, customProxy) -> + _uiSideEffect.send( + EditApiAccessSideEffect.OpenSaveDialog( + id = apiAccessMethodId, + name = name, + customProxy = customProxy + ) + ) + } + ) + } + } + + fun cancelTestMethod() { + if (testingJob?.isActive == true) { + testingJob?.cancel("User cancelled test") + isTestingApiAccessMethod.value = false + } + } + + private fun initialData(): EditApiAccessFormData = + if (apiAccessMethodId == null) { + EditApiAccessFormData.empty() + } else { + apiAccessRepository + .getApiAccessMethodSettingById(apiAccessMethodId) + .map { accessMethod -> + EditApiAccessFormData.fromCustomProxy( + accessMethod.name, + accessMethod.apiAccessMethod as? ApiAccessMethod.CustomProxy + ?: error( + "${accessMethod.apiAccessMethod} api access type can not be edited" + ) + ) + } + .getOrElse { error("Access method with id $apiAccessMethodId not found") } + } + + private fun EditApiAccessFormData.parseFormData(): + EitherNel> = + zipOrAccumulate(parseName(name), parseConnectionFormData()) { name, customProxy -> + name to customProxy + } + + private fun EditApiAccessFormData.parseConnectionFormData() = + when (apiAccessMethodTypes) { + ApiAccessMethodTypes.SHADOWSOCKS -> { + parseShadowSocksFormData(this) + } + ApiAccessMethodTypes.SOCKS5_REMOTE -> { + parseSocks5RemoteFormData(this) + } + } + + private fun parseShadowSocksFormData( + formData: EditApiAccessFormData + ): EitherNel = + parseIpAndPort(formData.serverIp, formData.port).map { (ip, port) -> + ApiAccessMethod.CustomProxy.Shadowsocks( + ip = ip, + port = port, + password = formData.password.ifBlank { null }, + cipher = formData.cipher + ) + } + + private fun parseIpAddress(input: String): Either = + either { + ensure(input.isNotBlank()) { InvalidDataError.ServerIpError.Required } + ensure(inetAddressValidator.isValid(input)) { InvalidDataError.ServerIpError.Invalid } + input + } + + private fun parsePort(input: String): Either = + Port.fromString(input).mapLeft { + when (it) { + is ParsePortError.NotANumber -> + if (it.input.isBlank()) { + InvalidDataError.PortError.Required + } else { + InvalidDataError.PortError.Invalid(it) + } + is ParsePortError.OutOfRange -> InvalidDataError.PortError.Invalid(it) + } + } + + private fun parseSocks5RemoteFormData( + formData: EditApiAccessFormData + ): EitherNel = + zipOrAccumulate( + parseIpAndPort(formData.serverIp, formData.port), + parseAuth( + authEnabled = formData.enableAuthentication, + inputUsername = formData.username, + inputPassword = formData.password + ) + ) { (ip, port), auth -> + ApiAccessMethod.CustomProxy.Socks5Remote(ip = ip, port = port, auth = auth) + } + + private fun parseIpAndPort(ipInput: String, portInput: String) = + zipOrAccumulate( + parseIpAddress(ipInput), + parsePort(portInput), + ) { ip, port -> + ip to port + } + + private fun parseAuth( + authEnabled: Boolean, + inputUsername: String, + inputPassword: String + ): EitherNel = + if (!authEnabled) { + Either.Right(null) + } else { + zipOrAccumulate(parseUsername(inputUsername), parsePassword(inputPassword)) { + userName, + password -> + SocksAuth(userName, password) + } + } + + private fun parseUsername(input: String): Either = + either { + ensure(input.isNotBlank()) { InvalidDataError.UserNameError.Required } + input + } + + private fun parsePassword(input: String): Either = + either { + ensure(input.isNotBlank()) { InvalidDataError.PasswordError.Required } + input + } + + private fun parseName( + input: String + ): EitherNel = either { + ensure(input.isNotBlank()) { InvalidDataError.NameError.Required.nel() } + ApiAccessMethodName.fromString(input) + } +} + +sealed interface EditApiAccessSideEffect { + data class OpenSaveDialog( + val id: ApiAccessMethodId?, + val name: ApiAccessMethodName, + val customProxy: ApiAccessMethod.CustomProxy + ) : EditApiAccessSideEffect + + data class TestApiAccessMethodResult(val successful: Boolean) : EditApiAccessSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SaveApiAccessMethodViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SaveApiAccessMethodViewModel.kt new file mode 100644 index 000000000000..be937e941641 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SaveApiAccessMethodViewModel.kt @@ -0,0 +1,102 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.state.SaveApiAccessMethodUiState +import net.mullvad.mullvadvpn.compose.state.TestApiAccessMethodState +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName +import net.mullvad.mullvadvpn.lib.model.NewAccessMethodSetting +import net.mullvad.mullvadvpn.repository.ApiAccessRepository + +class SaveApiAccessMethodViewModel( + private val apiAccessMethodId: ApiAccessMethodId?, + private val apiAccessMethodName: ApiAccessMethodName, + private val customProxy: ApiAccessMethod.CustomProxy, + private val apiAccessRepository: ApiAccessRepository +) : ViewModel() { + private val _uiSideEffect = Channel() + val uiSideEffect = _uiSideEffect.receiveAsFlow() + private val _uiState = MutableStateFlow(SaveApiAccessMethodUiState()) + val uiState: StateFlow = _uiState + + init { + viewModelScope.launch { + apiAccessRepository + .testCustomApiAccessMethod(customProxy) + .fold( + { + _uiState.update { + it.copy(testingState = TestApiAccessMethodState.Result.Failure) + } + }, + { + _uiState.update { + it.copy(testingState = TestApiAccessMethodState.Result.Successful) + } + save() + } + ) + } + } + + fun save() { + viewModelScope.launch { + _uiState.update { it.copy(isSaving = true) } + if (apiAccessMethodId != null) { + updateAccessMethod( + id = apiAccessMethodId, + name = apiAccessMethodName, + apiAccessMethod = customProxy + ) + } else { + addNewAccessMethod( + NewAccessMethodSetting( + name = apiAccessMethodName, + enabled = true, + apiAccessMethod = customProxy + ) + ) + } + } + } + + private suspend fun addNewAccessMethod(newAccessMethodSetting: NewAccessMethodSetting) { + apiAccessRepository + .addApiAccessMethod(newAccessMethodSetting) + .fold( + { _uiSideEffect.send(SaveApiAccessMethodSideEffect.CouldNotSaveApiAccessMethod) }, + { _uiSideEffect.send(SaveApiAccessMethodSideEffect.SuccessfullyCreatedApiMethod) } + ) + } + + private suspend fun updateAccessMethod( + id: ApiAccessMethodId, + name: ApiAccessMethodName, + apiAccessMethod: ApiAccessMethod.CustomProxy + ) { + apiAccessRepository + .updateApiAccessMethod( + apiAccessMethodId = id, + apiAccessMethodName = name, + apiAccessMethod = apiAccessMethod + ) + .fold( + { _uiSideEffect.send(SaveApiAccessMethodSideEffect.CouldNotSaveApiAccessMethod) }, + { _uiSideEffect.send(SaveApiAccessMethodSideEffect.SuccessfullyCreatedApiMethod) } + ) + } +} + +sealed interface SaveApiAccessMethodSideEffect { + data object SuccessfullyCreatedApiMethod : SaveApiAccessMethodSideEffect + + data object CouldNotSaveApiAccessMethod : SaveApiAccessMethodSideEffect +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/data/DummyUUID.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/data/DummyUUID.kt new file mode 100644 index 000000000000..3454af685cbe --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/data/DummyUUID.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.data + +const val UUID = "12345678-1234-5678-1234-567812345678" diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/ApiAccessRepositoryTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/ApiAccessRepositoryTest.kt new file mode 100644 index 000000000000..cb1042e6f8e4 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/ApiAccessRepositoryTest.kt @@ -0,0 +1,280 @@ +package net.mullvad.mullvadvpn.repository + +import arrow.core.left +import arrow.core.right +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.data.UUID +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.model.AddApiAccessMethodError +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodSetting +import net.mullvad.mullvadvpn.lib.model.GetApiAccessMethodError +import net.mullvad.mullvadvpn.lib.model.NewAccessMethodSetting +import net.mullvad.mullvadvpn.lib.model.SetApiAccessMethodError +import net.mullvad.mullvadvpn.lib.model.Settings +import net.mullvad.mullvadvpn.lib.model.TestApiAccessMethodError +import net.mullvad.mullvadvpn.lib.model.UnknownApiAccessMethodError +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ApiAccessRepositoryTest { + private val mockManagementService: ManagementService = mockk() + + private lateinit var apiAccessRepository: ApiAccessRepository + + private val settingsFlow: MutableStateFlow = MutableStateFlow(mockk(relaxed = true)) + + @BeforeEach + fun setUp() { + every { mockManagementService.settings } returns settingsFlow + every { mockManagementService.currentAccessMethod } returns emptyFlow() + + apiAccessRepository = + ApiAccessRepository( + managementService = mockManagementService, + dispatcher = UnconfinedTestDispatcher() + ) + } + + @Test + fun `adding api access method should return id when successful`() = runTest { + // Arrange + val newAccessMethodSetting: NewAccessMethodSetting = mockk() + val accessMethodId: ApiAccessMethodId = ApiAccessMethodId.fromString(UUID) + coEvery { mockManagementService.addApiAccessMethod(newAccessMethodSetting) } returns + accessMethodId.right() + + // Act + val result = apiAccessRepository.addApiAccessMethod(newAccessMethodSetting) + + // Assert + coVerify { mockManagementService.addApiAccessMethod(newAccessMethodSetting) } + assertEquals(accessMethodId.right(), result) + } + + @Test + fun `adding api access method should return error when not successful`() = runTest { + // Arrange + val newAccessMethodSetting: NewAccessMethodSetting = mockk() + val addApiAccessMethodError: AddApiAccessMethodError.Unknown = mockk() + coEvery { mockManagementService.addApiAccessMethod(newAccessMethodSetting) } returns + addApiAccessMethodError.left() + + // Act + val result = apiAccessRepository.addApiAccessMethod(newAccessMethodSetting) + + // Assert + coVerify { mockManagementService.addApiAccessMethod(newAccessMethodSetting) } + assertEquals(addApiAccessMethodError.left(), result) + } + + @Test + fun `setting api access method should return successful when successful`() = runTest { + // Arrange + val apiAccessMethodId: ApiAccessMethodId = ApiAccessMethodId.fromString(UUID) + coEvery { mockManagementService.setApiAccessMethod(apiAccessMethodId) } returns Unit.right() + + // Act + val result = apiAccessRepository.setCurrentApiAccessMethod(apiAccessMethodId) + + // Assert + coVerify { mockManagementService.setApiAccessMethod(apiAccessMethodId) } + assertEquals(Unit.right(), result) + } + + @Test + fun `setting api access method should return error when not successful`() = runTest { + // Arrange + val apiAccessMethodId: ApiAccessMethodId = ApiAccessMethodId.fromString(UUID) + val setApiAccessMethodError: SetApiAccessMethodError = mockk() + coEvery { mockManagementService.setApiAccessMethod(apiAccessMethodId) } returns + setApiAccessMethodError.left() + + // Act + val result = apiAccessRepository.setCurrentApiAccessMethod(apiAccessMethodId) + + // Assert + coVerify { mockManagementService.setApiAccessMethod(apiAccessMethodId) } + assertEquals(setApiAccessMethodError.left(), result) + } + + @Test + fun `test api access method by id should return successful when successful`() = runTest { + // Arrange + val apiAccessMethodId: ApiAccessMethodId = ApiAccessMethodId.fromString(UUID) + coEvery { mockManagementService.testApiAccessMethodById(apiAccessMethodId) } returns + Unit.right() + + // Act + val result = apiAccessRepository.testApiAccessMethodById(apiAccessMethodId) + + // Assert + coVerify { mockManagementService.testApiAccessMethodById(apiAccessMethodId) } + assertEquals(Unit.right(), result) + } + + @Test + fun `test api access method by id should return error when not successful`() = runTest { + // Arrange + val apiAccessMethodId: ApiAccessMethodId = ApiAccessMethodId.fromString(UUID) + val testApiAccessMethodError: TestApiAccessMethodError = mockk() + coEvery { mockManagementService.testApiAccessMethodById(apiAccessMethodId) } returns + testApiAccessMethodError.left() + + // Act + val result = apiAccessRepository.testApiAccessMethodById(apiAccessMethodId) + + // Assert + coVerify { mockManagementService.testApiAccessMethodById(apiAccessMethodId) } + assertEquals(testApiAccessMethodError.left(), result) + } + + @Test + fun `test custom api access method should return successful when successful`() = runTest { + // Arrange + val customProxy: ApiAccessMethod.CustomProxy = mockk() + coEvery { mockManagementService.testCustomApiAccessMethod(customProxy) } returns + Unit.right() + + // Act + val result = apiAccessRepository.testCustomApiAccessMethod(customProxy) + + // Assert + coVerify { mockManagementService.testCustomApiAccessMethod(customProxy) } + assertEquals(Unit.right(), result) + } + + @Test + fun `test custom api access method should return error when not successful`() = runTest { + // Arrange + val customProxy: ApiAccessMethod.CustomProxy = mockk() + val testApiAccessMethodError: TestApiAccessMethodError = mockk() + coEvery { mockManagementService.testCustomApiAccessMethod(customProxy) } returns + testApiAccessMethodError.left() + + // Act + val result = apiAccessRepository.testCustomApiAccessMethod(customProxy) + + // Assert + coVerify { mockManagementService.testCustomApiAccessMethod(customProxy) } + assertEquals(testApiAccessMethodError.left(), result) + } + + @Test + fun `get access method by id should return access method when id matches in settings`() = + runTest { + // Arrange + val apiAccessMethodId: ApiAccessMethodId = ApiAccessMethodId.fromString(UUID) + val expectedResult = + ApiAccessMethodSetting( + name = ApiAccessMethodName.fromString("Name"), + apiAccessMethod = ApiAccessMethod.Direct, + enabled = true, + id = apiAccessMethodId + ) + val mockSettings: Settings = mockk() + every { mockSettings.apiAccessMethodSettings } returns listOf(expectedResult) + settingsFlow.value = mockSettings + + // Act + val result = apiAccessRepository.getApiAccessMethodSettingById(apiAccessMethodId) + + // Assert + assertEquals(expectedResult.right(), result) + } + + @Test + fun `get access method by id should return not found error when id does not matches in settings`() = + runTest { + // Arrange + val apiAccessMethodId: ApiAccessMethodId = ApiAccessMethodId.fromString(UUID) + val expectedError = GetApiAccessMethodError.NotFound + val mockSettings: Settings = mockk() + every { mockSettings.apiAccessMethodSettings } returns emptyList() + settingsFlow.value = mockSettings + + // Act + val result = apiAccessRepository.getApiAccessMethodSettingById(apiAccessMethodId) + + // Assert + assertEquals(expectedError.left(), result) + } + + @Test + fun `when setting enable for api access method should return successful when successful`() = + runTest { + // Arrange + val apiAccessMethodId: ApiAccessMethodId = ApiAccessMethodId.fromString(UUID) + val apiAccessMethodSetting = + ApiAccessMethodSetting( + name = ApiAccessMethodName.fromString("Name"), + apiAccessMethod = ApiAccessMethod.Direct, + enabled = true, + id = apiAccessMethodId + ) + val mockSettings: Settings = mockk() + every { mockSettings.apiAccessMethodSettings } returns listOf(apiAccessMethodSetting) + coEvery { mockManagementService.updateApiAccessMethod(apiAccessMethodSetting) } returns + Unit.right() + settingsFlow.value = mockSettings + + // Act + val result = apiAccessRepository.setEnabledApiAccessMethod(apiAccessMethodId, true) + + // Assert + assertEquals(Unit.right(), result) + } + + @Test + fun `when setting enable for api access method should return error when not method not found`() = + runTest { + // Arrange + val apiAccessMethodId: ApiAccessMethodId = ApiAccessMethodId.fromString(UUID) + val expectedError = GetApiAccessMethodError.NotFound + val mockSettings: Settings = mockk() + every { mockSettings.apiAccessMethodSettings } returns emptyList() + settingsFlow.value = mockSettings + + // Act + val result = apiAccessRepository.setEnabledApiAccessMethod(apiAccessMethodId, true) + + // Assert + assertEquals(expectedError.left(), result) + } + + @Test + fun `when setting enable for api access method should return error when not successful`() = + runTest { + // Arrange + val expectedError: UnknownApiAccessMethodError = mockk() + val apiAccessMethodId: ApiAccessMethodId = ApiAccessMethodId.fromString(UUID) + val apiAccessMethodSetting = + ApiAccessMethodSetting( + name = ApiAccessMethodName.fromString("Name"), + apiAccessMethod = ApiAccessMethod.Direct, + enabled = true, + id = apiAccessMethodId + ) + val mockSettings: Settings = mockk() + every { mockSettings.apiAccessMethodSettings } returns listOf(apiAccessMethodSetting) + coEvery { mockManagementService.updateApiAccessMethod(apiAccessMethodSetting) } returns + expectedError.left() + settingsFlow.value = mockSettings + + // Act + val result = apiAccessRepository.setEnabledApiAccessMethod(apiAccessMethodId, true) + + // Assert + assertEquals(expectedError.left(), result) + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceUseNotificationCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceUseNotificationCaseTest.kt index b13985347108..95df8dc359bd 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceUseNotificationCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceUseNotificationCaseTest.kt @@ -9,6 +9,7 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.data.UUID import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.model.AccountNumber import net.mullvad.mullvadvpn.lib.model.Device @@ -86,8 +87,4 @@ class NewDeviceUseNotificationCaseTest { assertEquals(awaitItem(), emptyList()) } } - - companion object { - private const val UUID = "12345678-1234-5678-1234-567812345678" - } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt index 76c176c519f8..706d8031e7db 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt @@ -13,6 +13,7 @@ import kotlin.test.assertIs import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.data.UUID import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.common.test.assertLists import net.mullvad.mullvadvpn.lib.model.AccountNumber @@ -214,6 +215,5 @@ class AccountViewModelTest { private const val PURCHASE_RESULT_EXTENSIONS_CLASS = "net.mullvad.mullvadvpn.util.PurchaseResultExtensionsKt" private const val DUMMY_DEVICE_NAME = "fake_name" - private const val UUID = "12345678-1234-5678-1234-567812345678" } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiAccessMethodDetailsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiAccessMethodDetailsViewModelTest.kt new file mode 100644 index 000000000000..631deb12e46e --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiAccessMethodDetailsViewModelTest.kt @@ -0,0 +1,180 @@ +package net.mullvad.mullvadvpn.viewmodel + +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import java.time.Duration +import kotlin.test.assertIs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.time.delay +import net.mullvad.mullvadvpn.compose.state.ApiAccessMethodDetailsUiState +import net.mullvad.mullvadvpn.data.UUID +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodSetting +import net.mullvad.mullvadvpn.lib.model.TestApiAccessMethodError +import net.mullvad.mullvadvpn.lib.model.UnknownApiAccessMethodError +import net.mullvad.mullvadvpn.repository.ApiAccessRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(TestCoroutineRule::class) +class ApiAccessMethodDetailsViewModelTest { + private val mockApiAccessRepository: ApiAccessRepository = mockk() + private val apiAccessMethodId = ApiAccessMethodId.fromString(UUID) + + private lateinit var apiAccessMethodDetailsViewModel: ApiAccessMethodDetailsViewModel + + private val accessMethodFlow = MutableStateFlow(mockk(relaxed = true)) + private val enabledMethodsFlow = MutableStateFlow>(emptyList()) + private val currentAccessMethodFlow = MutableStateFlow(null) + + @BeforeEach + fun setUp() { + every { mockApiAccessRepository.apiAccessMethodSettingById(apiAccessMethodId) } returns + accessMethodFlow + every { mockApiAccessRepository.enabledApiAccessMethods() } returns enabledMethodsFlow + every { mockApiAccessRepository.currentAccessMethod } returns currentAccessMethodFlow + + apiAccessMethodDetailsViewModel = + ApiAccessMethodDetailsViewModel( + apiAccessMethodId = apiAccessMethodId, + apiAccessRepository = mockApiAccessRepository + ) + } + + @Test + fun `when calling set current method and testing is successful should call set method`() = + runTest { + // Arrange + coEvery { mockApiAccessRepository.testApiAccessMethodById(apiAccessMethodId) } returns + Unit.right() + coEvery { mockApiAccessRepository.setCurrentApiAccessMethod(any()) } returns + Unit.right() + + // Act + apiAccessMethodDetailsViewModel.setCurrentMethod() + + // Assert + coVerify(exactly = 1) { + mockApiAccessRepository.setCurrentApiAccessMethod(apiAccessMethodId) + } + } + + @Test + fun `when calling set current method and testing is not successful should not call set method`() = + runTest { + // Arrange + coEvery { mockApiAccessRepository.testApiAccessMethodById(apiAccessMethodId) } returns + TestApiAccessMethodError.CouldNotAccess.left() + coEvery { mockApiAccessRepository.setCurrentApiAccessMethod(any()) } returns + Unit.right() + + // Act + apiAccessMethodDetailsViewModel.setCurrentMethod() + + // Assert + coVerify(exactly = 0) { + mockApiAccessRepository.setCurrentApiAccessMethod(apiAccessMethodId) + } + } + + @Test + fun `when testing method should update is testing access method to true`() = runTest { + // Arrange + coEvery { mockApiAccessRepository.testApiAccessMethodById(apiAccessMethodId) } coAnswers + { + // Added so that the state gets updated + delay(Duration.ofMillis(1)) + Unit.right() + } + + // Act, Assert + apiAccessMethodDetailsViewModel.uiState.test { + // Default item + awaitItem() + apiAccessMethodDetailsViewModel.testMethod() + val result = awaitItem() + assertIs(result) + assertEquals(true, result.isTestingAccessMethod) + } + } + + @Test + fun `when testing method is successful should send side effect api reached`() = runTest { + // Arrange + coEvery { mockApiAccessRepository.testApiAccessMethodById(apiAccessMethodId) } returns + Unit.right() + + // Act, Assert + apiAccessMethodDetailsViewModel.uiSideEffect.test { + apiAccessMethodDetailsViewModel.testMethod() + val result = awaitItem() + assertIs(result) + assertEquals(true, result.successful) + } + } + + @Test + fun `when testing method is not successful should send side effect api not reached`() = + runTest { + // Arrange + coEvery { mockApiAccessRepository.testApiAccessMethodById(apiAccessMethodId) } returns + TestApiAccessMethodError.CouldNotAccess.left() + + // Act, Assert + apiAccessMethodDetailsViewModel.uiSideEffect.test { + apiAccessMethodDetailsViewModel.testMethod() + val result = awaitItem() + assertIs(result) + assertEquals(false, result.successful) + } + } + + @Test + fun `when enable access method is successful nothing should happen`() = runTest { + // Arrange + coEvery { + mockApiAccessRepository.setEnabledApiAccessMethod(apiAccessMethodId, true) + } returns Unit.right() + + // Act, Assert + apiAccessMethodDetailsViewModel.uiSideEffect.test { + apiAccessMethodDetailsViewModel.setEnableMethod(true) + expectNoEvents() + } + } + + @Test + fun `when enable access method is not successful should show error`() = runTest { + // Arrange + coEvery { + mockApiAccessRepository.setEnabledApiAccessMethod(apiAccessMethodId, true) + } returns UnknownApiAccessMethodError(Throwable()).left() + + // Act, Assert + apiAccessMethodDetailsViewModel.uiSideEffect.test { + apiAccessMethodDetailsViewModel.setEnableMethod(true) + assertEquals(ApiAccessMethodDetailsSideEffect.GenericError, awaitItem()) + } + } + + @Test + fun `calling open edit page should return side effect with id`() = runTest { + // Act, Assert + apiAccessMethodDetailsViewModel.uiSideEffect.test { + apiAccessMethodDetailsViewModel.openEditPage() + assertEquals( + ApiAccessMethodDetailsSideEffect.OpenEditPage(apiAccessMethodId), + awaitItem() + ) + } + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteApiAccessMethodConfirmationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteApiAccessMethodConfirmationViewModelTest.kt new file mode 100644 index 000000000000..18f6a64647a5 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteApiAccessMethodConfirmationViewModelTest.kt @@ -0,0 +1,65 @@ +package net.mullvad.mullvadvpn.viewmodel + +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.data.UUID +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.RemoveApiAccessMethodError +import net.mullvad.mullvadvpn.repository.ApiAccessRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(TestCoroutineRule::class) +class DeleteApiAccessMethodConfirmationViewModelTest { + + private val mockApiAccessRepository: ApiAccessRepository = mockk() + private lateinit var deleteApiAccessMethodConfirmationViewModel: + DeleteApiAccessMethodConfirmationViewModel + + @BeforeEach + fun setUp() { + val apiAccessMethodId = ApiAccessMethodId.fromString(UUID) + + deleteApiAccessMethodConfirmationViewModel = + DeleteApiAccessMethodConfirmationViewModel( + apiAccessMethodId = apiAccessMethodId, + apiAccessRepository = mockApiAccessRepository + ) + } + + @Test + fun `when deleting api access method is successful should update uiSideEffect`() = runTest { + // Arrange + coEvery { mockApiAccessRepository.removeApiAccessMethod(any()) } returns Unit.right() + + // Act, Assert + deleteApiAccessMethodConfirmationViewModel.uiSideEffect.test { + deleteApiAccessMethodConfirmationViewModel.deleteApiAccessMethod() + val result = awaitItem() + assertEquals(DeleteApiAccessMethodConfirmationSideEffect.Deleted, result) + } + } + + @Test + fun `when deleting api access method is not successful should update ui state`() = runTest { + // Arrange + val error = RemoveApiAccessMethodError.Unknown(Throwable()) + coEvery { mockApiAccessRepository.removeApiAccessMethod(any()) } returns error.left() + + // Act, Assert + deleteApiAccessMethodConfirmationViewModel.uiState.test { + // Default item + awaitItem() + deleteApiAccessMethodConfirmationViewModel.deleteApiAccessMethod() + val result = awaitItem().deleteError + assertEquals(error, result) + } + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SaveApiAccessMethodViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SaveApiAccessMethodViewModelTest.kt new file mode 100644 index 000000000000..0828b3ed0841 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SaveApiAccessMethodViewModelTest.kt @@ -0,0 +1,221 @@ +package net.mullvad.mullvadvpn.viewmodel + +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.state.SaveApiAccessMethodUiState +import net.mullvad.mullvadvpn.compose.state.TestApiAccessMethodState +import net.mullvad.mullvadvpn.data.UUID +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName +import net.mullvad.mullvadvpn.lib.model.NewAccessMethodSetting +import net.mullvad.mullvadvpn.lib.model.TestApiAccessMethodError +import net.mullvad.mullvadvpn.lib.model.UnknownApiAccessMethodError +import net.mullvad.mullvadvpn.repository.ApiAccessRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(TestCoroutineRule::class) +class SaveApiAccessMethodViewModelTest { + private val mockApiAccessRepository: ApiAccessRepository = mockk() + + private lateinit var saveApiAccessMethodViewModel: SaveApiAccessMethodViewModel + + @Test + fun `when testing and updating an existing method successfully should do the correct steps`() = + runTest { + // Arrange + val apiAccessMethodId = ApiAccessMethodId.fromString(UUID) + val apiAccessMethodName = ApiAccessMethodName.fromString("Name") + val customProxy = mockk() + coEvery { mockApiAccessRepository.testCustomApiAccessMethod(customProxy) } returns + Unit.right() + coEvery { + mockApiAccessRepository.updateApiAccessMethod( + apiAccessMethodId, + apiAccessMethodName, + customProxy + ) + } returns Unit.right() + createSaveApiAccessMethodViewModel( + apiAccessMethodId = apiAccessMethodId, + apiAccessMethodName = apiAccessMethodName, + customProxy = customProxy + ) + + // Act, Assert + saveApiAccessMethodViewModel.uiState.test { + // After successful test + assertEquals( + SaveApiAccessMethodUiState( + testingState = TestApiAccessMethodState.Result.Successful, + isSaving = true + ), + awaitItem() + ) + } + saveApiAccessMethodViewModel.uiSideEffect.test { + // Check for successful creation + assertEquals( + SaveApiAccessMethodSideEffect.SuccessfullyCreatedApiMethod, + awaitItem() + ) + } + } + + @Test + fun `when testing api access method fail should update ui state`() = runTest { + // Arrange + val apiAccessMethodId = ApiAccessMethodId.fromString(UUID) + val apiAccessMethodName = ApiAccessMethodName.fromString("Name") + val customProxy = mockk() + coEvery { mockApiAccessRepository.testCustomApiAccessMethod(customProxy) } returns + TestApiAccessMethodError.CouldNotAccess.left() + createSaveApiAccessMethodViewModel( + apiAccessMethodId = apiAccessMethodId, + apiAccessMethodName = apiAccessMethodName, + customProxy = customProxy + ) + + // Act, Assert + saveApiAccessMethodViewModel.uiState.test { + assertEquals( + SaveApiAccessMethodUiState( + testingState = TestApiAccessMethodState.Result.Failure, + isSaving = false + ), + awaitItem() + ) + } + } + + @Test + fun `when saving existing api access method after failure should update ui state`() = runTest { + // Arrange + val apiAccessMethodId = ApiAccessMethodId.fromString(UUID) + val apiAccessMethodName = ApiAccessMethodName.fromString("Name") + val customProxy = mockk() + coEvery { mockApiAccessRepository.testCustomApiAccessMethod(customProxy) } returns + TestApiAccessMethodError.CouldNotAccess.left() + coEvery { + mockApiAccessRepository.updateApiAccessMethod( + apiAccessMethodId, + apiAccessMethodName, + customProxy + ) + } returns Unit.right() + createSaveApiAccessMethodViewModel( + apiAccessMethodId = apiAccessMethodId, + apiAccessMethodName = apiAccessMethodName, + customProxy = customProxy + ) + + // Act, Assert + saveApiAccessMethodViewModel.uiState.test { + // After successful test + assertEquals( + SaveApiAccessMethodUiState( + testingState = TestApiAccessMethodState.Result.Failure, + isSaving = false + ), + awaitItem() + ) + saveApiAccessMethodViewModel.save() + // Saving + assertEquals( + SaveApiAccessMethodUiState( + testingState = TestApiAccessMethodState.Result.Failure, + isSaving = true + ), + awaitItem() + ) + } + saveApiAccessMethodViewModel.uiSideEffect.test { + // Check for successful creation + assertEquals(SaveApiAccessMethodSideEffect.SuccessfullyCreatedApiMethod, awaitItem()) + } + } + + @Test + fun `when saving is not successful should return side effect failure`() = runTest { + // Arrange + val apiAccessMethodId = ApiAccessMethodId.fromString(UUID) + val apiAccessMethodName = ApiAccessMethodName.fromString("Name") + val customProxy = mockk() + coEvery { mockApiAccessRepository.testCustomApiAccessMethod(customProxy) } returns + Unit.right() + coEvery { + mockApiAccessRepository.updateApiAccessMethod( + apiAccessMethodId, + apiAccessMethodName, + customProxy + ) + } returns UnknownApiAccessMethodError(Throwable()).left() + createSaveApiAccessMethodViewModel( + apiAccessMethodId = apiAccessMethodId, + apiAccessMethodName = apiAccessMethodName, + customProxy = customProxy + ) + + // Act, Assert + saveApiAccessMethodViewModel.uiSideEffect.test { + assertEquals(SaveApiAccessMethodSideEffect.CouldNotSaveApiAccessMethod, awaitItem()) + } + } + + @Test + fun `when saving a new api access method should call addApiAccessMethod`() = runTest { + // Arrange + val apiAccessMethodId = null + val apiAccessMethodName = ApiAccessMethodName.fromString("Name") + val customProxy = mockk() + coEvery { mockApiAccessRepository.testCustomApiAccessMethod(customProxy) } returns + Unit.right() + coEvery { + mockApiAccessRepository.addApiAccessMethod( + NewAccessMethodSetting( + name = apiAccessMethodName, + enabled = true, + apiAccessMethod = customProxy + ) + ) + } returns ApiAccessMethodId.fromString(UUID).right() + createSaveApiAccessMethodViewModel( + apiAccessMethodId = apiAccessMethodId, + apiAccessMethodName = apiAccessMethodName, + customProxy = customProxy + ) + + // Assert + coVerify(exactly = 1) { + mockApiAccessRepository.addApiAccessMethod( + NewAccessMethodSetting( + name = apiAccessMethodName, + enabled = true, + apiAccessMethod = customProxy + ) + ) + } + } + + private fun createSaveApiAccessMethodViewModel( + apiAccessMethodId: ApiAccessMethodId?, + apiAccessMethodName: ApiAccessMethodName, + customProxy: ApiAccessMethod.CustomProxy + ) { + saveApiAccessMethodViewModel = + SaveApiAccessMethodViewModel( + apiAccessMethodId = apiAccessMethodId, + apiAccessMethodName = apiAccessMethodName, + customProxy = customProxy, + apiAccessRepository = mockApiAccessRepository + ) + } +} diff --git a/android/config/baseline.xml b/android/config/baseline.xml index cbb943837b63..06de01e5ecf0 100644 --- a/android/config/baseline.xml +++ b/android/config/baseline.xml @@ -6,14 +6,16 @@ EmptyFunctionBlock:AccountTestRule.kt$AccountTestRule${} EmptyKtFile:build.gradle.kts$.build.gradle.kts LargeClass:ConnectScreenTest.kt$ConnectScreenTest + LongMethod:ApiAccessMethodDetailsScreen.kt$@Destination(style = SlideInFromRightTransition::class) @Composable fun ApiAccessMethodDetails( navigator: DestinationsNavigator, accessMethodId: ApiAccessMethodId, confirmDeleteListResultRecipient: ResultRecipient<DeleteApiAccessMethodConfirmationDestination, Boolean> ) LongMethod:ConnectionButton.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ConnectionButton( text: String, mainClick: () -> Unit, reconnectClick: () -> Unit, isReconnectButtonEnabled: Boolean, containerColor: Color, contentColor: Color, modifier: Modifier = Modifier, reconnectButtonTestTag: String = "" ) + LongMethod:EditApiAccessMethodScreen.kt$@Destination(style = SlideInFromRightTransition::class) @Composable fun EditApiAccessMethod( navigator: DestinationsNavigator, backNavigator: ResultBackNavigator<Boolean>, saveApiAccessMethodResultRecipient: ResultRecipient<SaveApiAccessMethodDestination, Boolean>, discardChangesResultRecipient: ResultRecipient<DiscardChangesDialogDestination, Boolean>, accessMethodId: ApiAccessMethodId? ) LongMethod:NotificationBanner.kt$@Composable private fun Notification(notificationBannerData: NotificationData) MagicNumber:Chevron.kt$100 MagicNumber:Chevron.kt$270f MagicNumber:Chevron.kt$90f MagicNumber:CustomTextField.kt$100 MagicNumber:LoginScreen.kt$3f - MagicNumber:NavigateBackIconButton.kt$90f + MagicNumber:NavigateButton.kt$90f MagicNumber:RedeemVoucherDialog.kt$30 MagicNumber:RedeemVoucherDialog.kt$59 MagicNumber:ResourcesExtensions.kt$3 @@ -24,6 +26,7 @@ PrintStackTrace:Extensions.kt$ex ReturnCount:RelayNameComparator.kt$RelayNameComparator$private infix fun List<String>.compareWith(other: List<String>): Int ReturnCount:TalpidVpnService.kt$TalpidVpnService$private fun createTun(config: TunConfig): CreateTunResult + TooManyFunctions:EditApiAccessMethodViewModel.kt$EditApiAccessMethodViewModel : ViewModel TooManyFunctions:VpnSettingsViewModel.kt$VpnSettingsViewModel : ViewModel UnusedParameter:SimpleMullvadHttpClient.kt$SimpleMullvadHttpClient$body: JSONArray? = null UnusedPrivateMember:ConnectivityListener.kt$ConnectivityListener$private fun finalize() diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/CommonListExtensions.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/CommonListExtensions.kt new file mode 100644 index 000000000000..d613cf746309 --- /dev/null +++ b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/CommonListExtensions.kt @@ -0,0 +1,4 @@ +package net.mullvad.mullvadvpn.lib.common.util + +inline fun List.getFirstInstanceOrNull(): E? = + this.filterIsInstance().firstOrNull() 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 f8073323c804..987b7e56ea4d 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 @@ -3,6 +3,8 @@ package net.mullvad.mullvadvpn.lib.daemon.grpc import android.net.LocalSocketAddress import android.util.Log import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure import arrow.optics.copy import arrow.optics.dsl.index import arrow.optics.typeclasses.Index @@ -42,7 +44,11 @@ import net.mullvad.mullvadvpn.lib.daemon.grpc.util.LogInterceptor import net.mullvad.mullvadvpn.lib.daemon.grpc.util.connectivityFlow import net.mullvad.mullvadvpn.lib.model.AccountData import net.mullvad.mullvadvpn.lib.model.AccountNumber +import net.mullvad.mullvadvpn.lib.model.AddApiAccessMethodError import net.mullvad.mullvadvpn.lib.model.AddSplitTunnelingAppError +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodSetting import net.mullvad.mullvadvpn.lib.model.AppId import net.mullvad.mullvadvpn.lib.model.AppVersionInfo as ModelAppVersionInfo import net.mullvad.mullvadvpn.lib.model.ClearAllOverridesError @@ -67,6 +73,7 @@ import net.mullvad.mullvadvpn.lib.model.GetAccountHistoryError import net.mullvad.mullvadvpn.lib.model.GetDeviceListError import net.mullvad.mullvadvpn.lib.model.GetDeviceStateError import net.mullvad.mullvadvpn.lib.model.LoginAccountError +import net.mullvad.mullvadvpn.lib.model.NewAccessMethodSetting import net.mullvad.mullvadvpn.lib.model.ObfuscationSettings import net.mullvad.mullvadvpn.lib.model.Ownership as ModelOwnership import net.mullvad.mullvadvpn.lib.model.PlayPurchase @@ -84,9 +91,11 @@ import net.mullvad.mullvadvpn.lib.model.RelayItemId as ModelRelayItemId import net.mullvad.mullvadvpn.lib.model.RelayList as ModelRelayList import net.mullvad.mullvadvpn.lib.model.RelayList import net.mullvad.mullvadvpn.lib.model.RelaySettings +import net.mullvad.mullvadvpn.lib.model.RemoveApiAccessMethodError import net.mullvad.mullvadvpn.lib.model.RemoveSplitTunnelingAppError import net.mullvad.mullvadvpn.lib.model.SelectedObfuscation import net.mullvad.mullvadvpn.lib.model.SetAllowLanError +import net.mullvad.mullvadvpn.lib.model.SetApiAccessMethodError import net.mullvad.mullvadvpn.lib.model.SetAutoConnectError import net.mullvad.mullvadvpn.lib.model.SetDnsOptionsError import net.mullvad.mullvadvpn.lib.model.SetObfuscationOptionsError @@ -96,8 +105,11 @@ import net.mullvad.mullvadvpn.lib.model.SetWireguardMtuError import net.mullvad.mullvadvpn.lib.model.SetWireguardQuantumResistantError import net.mullvad.mullvadvpn.lib.model.Settings as ModelSettings import net.mullvad.mullvadvpn.lib.model.SettingsPatchError +import net.mullvad.mullvadvpn.lib.model.TestApiAccessMethodError import net.mullvad.mullvadvpn.lib.model.TunnelState as ModelTunnelState +import net.mullvad.mullvadvpn.lib.model.UnknownApiAccessMethodError import net.mullvad.mullvadvpn.lib.model.UnknownCustomListError +import net.mullvad.mullvadvpn.lib.model.UpdateApiAccessMethodError import net.mullvad.mullvadvpn.lib.model.UpdateCustomListError import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken import net.mullvad.mullvadvpn.lib.model.WireguardConstraints as ModelWireguardConstraints @@ -161,6 +173,10 @@ class ManagementService( val wireguardEndpointData: Flow = relayList.mapNotNull { it.wireguardEndpointData } + private val _mutableCurrentAccessMethod = MutableStateFlow(null) + val currentAccessMethod: Flow = + _mutableCurrentAccessMethod.filterNotNull() + fun start() { // Just to ensure that connection is set up since the connection won't be setup without a // call to the daemon @@ -196,9 +212,11 @@ class ManagementService( _mutableVersionInfo.update { event.versionInfo.toDomain() } ManagementInterface.DaemonEvent.EventCase.DEVICE -> _mutableDeviceState.update { event.device.newState.toDomain() } + ManagementInterface.DaemonEvent.EventCase.NEW_ACCESS_METHOD -> { + _mutableCurrentAccessMethod.update { event.newAccessMethod.toDomain() } + } ManagementInterface.DaemonEvent.EventCase.REMOVE_DEVICE -> {} ManagementInterface.DaemonEvent.EventCase.EVENT_NOT_SET -> {} - ManagementInterface.DaemonEvent.EventCase.NEW_ACCESS_METHOD -> {} } } } @@ -297,6 +315,7 @@ class ManagementService( async { _mutableSettings.update { getSettings() } }, async { _mutableVersionInfo.update { getVersionInfo() } }, async { _mutableRelayList.update { getRelayList() } }, + async { _mutableCurrentAccessMethod.update { getCurrentApiAccessMethod() } } ) } } @@ -572,6 +591,55 @@ class ManagementService( Either.catch { grpc.getWwwAuthToken(Empty.getDefaultInstance()) } .map { WebsiteAuthToken.fromString(it.value) } + suspend fun addApiAccessMethod( + newAccessMethodSetting: NewAccessMethodSetting + ): Either = + Either.catch { grpc.addApiAccessMethod(newAccessMethodSetting.fromDomain()) } + .mapLeft(AddApiAccessMethodError::Unknown) + .map { ApiAccessMethodId.fromString(it.value) } + + suspend fun removeApiAccessMethod( + apiAccessMethodId: ApiAccessMethodId + ): Either = + Either.catch { grpc.removeApiAccessMethod(apiAccessMethodId.fromDomain()) } + .mapLeft(RemoveApiAccessMethodError::Unknown) + .mapEmpty() + + suspend fun setApiAccessMethod( + apiAccessMethodId: ApiAccessMethodId + ): Either = + Either.catch { grpc.setApiAccessMethod(apiAccessMethodId.fromDomain()) } + .mapLeft(SetApiAccessMethodError::Unknown) + .mapEmpty() + + suspend fun updateApiAccessMethod( + apiAccessMethodSetting: ApiAccessMethodSetting + ): Either = + Either.catch { grpc.updateApiAccessMethod(apiAccessMethodSetting.fromDomain()) } + .mapLeft(::UnknownApiAccessMethodError) + .mapEmpty() + + private suspend fun getCurrentApiAccessMethod(): ApiAccessMethodSetting = + grpc.getCurrentApiAccessMethod(Empty.getDefaultInstance()).toDomain() + + suspend fun testCustomApiAccessMethod( + customProxy: ApiAccessMethod.CustomProxy + ): Either = + Either.catch { grpc.testCustomApiAccessMethod(customProxy.fromDomain()) } + .mapLeftStatus { TestApiAccessMethodError.Grpc } + .map { result -> + either { ensure(result.value) { TestApiAccessMethodError.CouldNotAccess } } + } + + suspend fun testApiAccessMethodById( + apiAccessMethodId: ApiAccessMethodId + ): Either = + Either.catch { grpc.testApiAccessMethodById(apiAccessMethodId.fromDomain()) } + .mapLeftStatus { TestApiAccessMethodError.Grpc } + .map { result -> + either { ensure(result.value) { TestApiAccessMethodError.CouldNotAccess } } + } + private fun Either.mapEmpty() = map {} private inline fun Either.mapLeftStatus( diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt index 4efd8c452efe..014bafb85b10 100644 --- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt @@ -1,6 +1,9 @@ package net.mullvad.mullvadvpn.lib.daemon.grpc.mapper import mullvad_daemon.management_interface.ManagementInterface +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodSetting import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.CustomDnsOptions import net.mullvad.mullvadvpn.lib.model.CustomList @@ -9,6 +12,7 @@ import net.mullvad.mullvadvpn.lib.model.DefaultDnsOptions import net.mullvad.mullvadvpn.lib.model.DnsOptions import net.mullvad.mullvadvpn.lib.model.DnsState import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.NewAccessMethodSetting import net.mullvad.mullvadvpn.lib.model.ObfuscationSettings import net.mullvad.mullvadvpn.lib.model.Ownership import net.mullvad.mullvadvpn.lib.model.PlayPurchase @@ -18,6 +22,8 @@ import net.mullvad.mullvadvpn.lib.model.Providers import net.mullvad.mullvadvpn.lib.model.RelayItemId import net.mullvad.mullvadvpn.lib.model.RelaySettings import net.mullvad.mullvadvpn.lib.model.SelectedObfuscation +import net.mullvad.mullvadvpn.lib.model.SocksAuth +import net.mullvad.mullvadvpn.lib.model.TransportProtocol import net.mullvad.mullvadvpn.lib.model.Udp2TcpObfuscationSettings import net.mullvad.mullvadvpn.lib.model.WireguardConstraints @@ -160,3 +166,74 @@ internal fun PlayPurchase.fromDomain(): ManagementInterface.PlayPurchase = .setPurchaseToken(purchaseToken.fromDomain()) .setProductId(productId) .build() + +internal fun NewAccessMethodSetting.fromDomain(): ManagementInterface.NewAccessMethodSetting = + ManagementInterface.NewAccessMethodSetting.newBuilder() + .setName(name.value) + .setEnabled(enabled) + .setAccessMethod( + ManagementInterface.AccessMethod.newBuilder().setCustom(apiAccessMethod.fromDomain()) + ) + .build() + +internal fun ApiAccessMethod.fromDomain(): ManagementInterface.AccessMethod = + ManagementInterface.AccessMethod.newBuilder() + .let { + when (this) { + ApiAccessMethod.Direct -> + it.setDirect(ManagementInterface.AccessMethod.Direct.getDefaultInstance()) + ApiAccessMethod.Bridges -> + it.setBridges(ManagementInterface.AccessMethod.Bridges.getDefaultInstance()) + is ApiAccessMethod.CustomProxy -> it.setCustom(this.fromDomain()) + } + } + .build() + +internal fun ApiAccessMethod.CustomProxy.fromDomain(): ManagementInterface.CustomProxy = + ManagementInterface.CustomProxy.newBuilder() + .let { + when (this) { + is ApiAccessMethod.CustomProxy.Shadowsocks -> it.setShadowsocks(this.fromDomain()) + is ApiAccessMethod.CustomProxy.Socks5Remote -> it.setSocks5Remote(this.fromDomain()) + } + } + .build() + +internal fun ApiAccessMethod.CustomProxy.Socks5Remote.fromDomain(): + ManagementInterface.Socks5Remote = + ManagementInterface.Socks5Remote.newBuilder().setIp(ip).setPort(port.value).let { + auth?.let { auth -> it.setAuth(auth.fromDomain()) } + it.build() + } + +internal fun SocksAuth.fromDomain(): ManagementInterface.SocksAuth = + ManagementInterface.SocksAuth.newBuilder().setUsername(username).setPassword(password).build() + +internal fun ApiAccessMethod.CustomProxy.Shadowsocks.fromDomain(): ManagementInterface.Shadowsocks = + ManagementInterface.Shadowsocks.newBuilder() + .setIp(ip) + .setCipher(cipher.label) + .setPort(port.value) + .let { + if (password != null) { + it.setPassword(password) + } + it.build() + } + +internal fun TransportProtocol.fromDomain(): ManagementInterface.TransportProtocol = + when (this) { + TransportProtocol.Tcp -> ManagementInterface.TransportProtocol.TCP + TransportProtocol.Udp -> ManagementInterface.TransportProtocol.UDP + } + +internal fun ApiAccessMethodId.fromDomain(): ManagementInterface.UUID = + ManagementInterface.UUID.newBuilder().setValue(value.toString()).build() + +internal fun ApiAccessMethodSetting.fromDomain(): ManagementInterface.AccessMethodSetting = + ManagementInterface.AccessMethodSetting.newBuilder() + .setName(name.value) + .setId(id.fromDomain()) + .setEnabled(enabled) + .setAccessMethod(apiAccessMethod.fromDomain()) + .build() diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt index 59a94f62dc1b..13ebe74350ce 100644 --- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt @@ -13,8 +13,13 @@ import net.mullvad.mullvadvpn.lib.model.AccountData import net.mullvad.mullvadvpn.lib.model.AccountId import net.mullvad.mullvadvpn.lib.model.AccountNumber import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethod +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodId +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName +import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodSetting import net.mullvad.mullvadvpn.lib.model.AppId import net.mullvad.mullvadvpn.lib.model.AppVersionInfo +import net.mullvad.mullvadvpn.lib.model.Cipher import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.CustomDnsOptions import net.mullvad.mullvadvpn.lib.model.CustomList @@ -53,6 +58,7 @@ import net.mullvad.mullvadvpn.lib.model.RelayOverride import net.mullvad.mullvadvpn.lib.model.RelaySettings import net.mullvad.mullvadvpn.lib.model.SelectedObfuscation import net.mullvad.mullvadvpn.lib.model.Settings +import net.mullvad.mullvadvpn.lib.model.SocksAuth import net.mullvad.mullvadvpn.lib.model.SplitTunnelSettings import net.mullvad.mullvadvpn.lib.model.TransportProtocol import net.mullvad.mullvadvpn.lib.model.TunnelEndpoint @@ -246,7 +252,8 @@ internal fun ManagementInterface.Settings.toDomain(): Settings = tunnelOptions = tunnelOptions.toDomain(), relayOverrides = relayOverridesList.map { it.toDomain() }, showBetaReleases = showBetaReleases, - splitTunnelSettings = splitTunnel.toDomain() + splitTunnelSettings = splitTunnel.toDomain(), + apiAccessMethodSettings = apiAccessMethods.toDomain() ) internal fun ManagementInterface.RelayOverride.toDomain(): RelayOverride = @@ -519,3 +526,53 @@ internal fun ManagementInterface.SplitTunnelSettings.toDomain(): SplitTunnelSett internal fun ManagementInterface.PlayPurchasePaymentToken.toDomain(): PlayPurchasePaymentToken = PlayPurchasePaymentToken(value = token) + +internal fun ManagementInterface.ApiAccessMethodSettings.toDomain(): List = + listOf(direct.toDomain(), mullvadBridges.toDomain()).plus(customList.map { it.toDomain() }) + +internal fun ManagementInterface.AccessMethodSetting.toDomain(): ApiAccessMethodSetting = + ApiAccessMethodSetting( + id = ApiAccessMethodId.fromString(id.value), + name = ApiAccessMethodName.fromString(name), + enabled = enabled, + apiAccessMethod = accessMethod.toDomain() + ) + +internal fun ManagementInterface.AccessMethod.toDomain(): ApiAccessMethod = + when { + hasDirect() -> ApiAccessMethod.Direct + hasBridges() -> ApiAccessMethod.Bridges + hasCustom() -> custom.toDomain() + else -> error("Type not found") + } + +internal fun ManagementInterface.CustomProxy.toDomain(): ApiAccessMethod.CustomProxy = + when { + hasShadowsocks() -> shadowsocks.toDomain() + hasSocks5Remote() -> socks5Remote.toDomain() + hasSocks5Local() -> error("Socks5 local not supported") + else -> error("Custom proxy not found") + } + +internal fun ManagementInterface.Shadowsocks.toDomain(): ApiAccessMethod.CustomProxy.Shadowsocks = + ApiAccessMethod.CustomProxy.Shadowsocks( + ip = ip, + port = Port(port), + password = password, + cipher = Cipher.fromString(cipher) + ) + +internal fun ManagementInterface.Socks5Remote.toDomain(): ApiAccessMethod.CustomProxy.Socks5Remote = + ApiAccessMethod.CustomProxy.Socks5Remote( + ip = ip, + port = Port(port), + auth = + if (hasAuth()) { + auth.toDomain() + } else { + null + } + ) + +internal fun ManagementInterface.SocksAuth.toDomain(): SocksAuth = + SocksAuth(username = username, password = password) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AddApiAccessMethodError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AddApiAccessMethodError.kt new file mode 100644 index 000000000000..d0c741e53c16 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AddApiAccessMethodError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface AddApiAccessMethodError { + data class Unknown(val t: Throwable) : AddApiAccessMethodError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ApiAccessMethod.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ApiAccessMethod.kt new file mode 100644 index 000000000000..d8762af39169 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ApiAccessMethod.kt @@ -0,0 +1,23 @@ +package net.mullvad.mullvadvpn.lib.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed interface ApiAccessMethod : Parcelable { + @Parcelize data object Direct : ApiAccessMethod + + @Parcelize data object Bridges : ApiAccessMethod + + sealed interface CustomProxy : ApiAccessMethod { + @Parcelize + data class Socks5Remote(val ip: String, val port: Port, val auth: SocksAuth?) : CustomProxy + + @Parcelize + data class Shadowsocks( + val ip: String, + val port: Port, + val password: String?, + val cipher: Cipher + ) : CustomProxy + } +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ApiAccessMethodId.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ApiAccessMethodId.kt new file mode 100644 index 000000000000..a6dc0628dfce --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ApiAccessMethodId.kt @@ -0,0 +1,14 @@ +package net.mullvad.mullvadvpn.lib.model + +import android.os.Parcelable +import java.util.UUID +import kotlinx.parcelize.Parcelize + +@JvmInline +@Parcelize +value class ApiAccessMethodId private constructor(val value: UUID) : Parcelable { + + companion object { + fun fromString(id: String) = ApiAccessMethodId(value = UUID.fromString(id)) + } +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ApiAccessMethodName.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ApiAccessMethodName.kt new file mode 100644 index 000000000000..b1eada2982d5 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ApiAccessMethodName.kt @@ -0,0 +1,19 @@ +package net.mullvad.mullvadvpn.lib.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +@JvmInline +value class ApiAccessMethodName private constructor(val value: String) : Parcelable { + override fun toString() = value + + companion object { + const val MAX_LENGTH = 30 + + fun fromString(name: String): ApiAccessMethodName { + val trimmedName = name.trim().take(MAX_LENGTH) + return ApiAccessMethodName(trimmedName) + } + } +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ApiAccessMethodSetting.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ApiAccessMethodSetting.kt new file mode 100644 index 000000000000..07e1c185dfeb --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ApiAccessMethodSetting.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.lib.model + +data class ApiAccessMethodSetting( + val id: ApiAccessMethodId, + val name: ApiAccessMethodName, + val enabled: Boolean, + val apiAccessMethod: ApiAccessMethod +) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Cipher.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Cipher.kt new file mode 100644 index 000000000000..4571c824ddf3 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Cipher.kt @@ -0,0 +1,34 @@ +package net.mullvad.mullvadvpn.lib.model + +// All suppported shadowsocks ciphers +enum class Cipher(val label: String) { + AES_128_CFB("aes-128-cfb"), + AES_128_CFB1("aes-128-cfb1"), + AES_128_CFB8("aes-128-cfb8"), + AES_128_CFB128("aes-128-cfb128"), + AES_256_CFB("aes-256-cfb"), + AES_256_CFB1("aes-256-cfb1"), + AES_256_CFB8("aes-256-cfb8"), + AES_256_CFB128("aes-256-cfb128"), + RC4("rc4"), + RC4_MD5("rc4-md5"), + CHACHA20("chacha20"), + SALSA20("salsa20"), + CHACHA20_IETF("chacha20-ietf"), + AES_128_GCM("aes-128-gcm"), + AES_256_GCM("aes-256-gcm"), + CHACHA20_IETF_POLY1305("chacha20-ietf-poly1305"), + XCHACHA20_IETF_POLY1305("xchacha20-ietf-poly1305"), + AES_128_PMAC_SIV("aes-128-pmac-siv"), + AES_256_PMAC_SIV("aes-256-pmac-siv"); + + override fun toString(): String = label + + companion object { + fun fromString(input: String) = Cipher.entries.first { it.label == input } + + fun listAll() = Cipher.entries.sortedBy { it.label } + + fun first() = listAll().first() + } +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetApiAccessMethodError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetApiAccessMethodError.kt new file mode 100644 index 000000000000..47f2ad29ccda --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetApiAccessMethodError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface GetApiAccessMethodError : UpdateApiAccessMethodError { + data object NotFound : GetApiAccessMethodError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetCurrentApiAccessMethodError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetCurrentApiAccessMethodError.kt new file mode 100644 index 000000000000..54c9791d0b91 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetCurrentApiAccessMethodError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface GetCurrentApiAccessMethodError { + data class Unknown(val t: Throwable) : GetCurrentApiAccessMethodError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/InvalidDataError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/InvalidDataError.kt new file mode 100644 index 000000000000..450d94e691fa --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/InvalidDataError.kt @@ -0,0 +1,27 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface InvalidDataError { + sealed interface NameError : InvalidDataError { + data object Required : NameError + } + + sealed interface ServerIpError : InvalidDataError { + data object Required : ServerIpError + + data object Invalid : ServerIpError + } + + sealed interface PortError : InvalidDataError { + data object Required : PortError + + data class Invalid(val portError: ParsePortError) : PortError + } + + sealed interface UserNameError : InvalidDataError { + data object Required : UserNameError + } + + sealed interface PasswordError : InvalidDataError { + data object Required : PasswordError + } +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NewAccessMethodSetting.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NewAccessMethodSetting.kt new file mode 100644 index 000000000000..990dc300bcc4 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NewAccessMethodSetting.kt @@ -0,0 +1,11 @@ +package net.mullvad.mullvadvpn.lib.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class NewAccessMethodSetting( + val name: ApiAccessMethodName, + val enabled: Boolean, + val apiAccessMethod: ApiAccessMethod.CustomProxy +) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Port.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Port.kt index 5ce44d0565ba..e6ca1e01b990 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Port.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Port.kt @@ -9,6 +9,9 @@ import kotlinx.parcelize.Parcelize @JvmInline @Parcelize value class Port(val value: Int) : Parcelable { + + override fun toString(): String = value.toString() + companion object { fun fromString(value: String): Either = either { val number = value.toIntOrNull() ?: raise(ParsePortError.NotANumber(value)) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RemoveApiAccessMethodError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RemoveApiAccessMethodError.kt new file mode 100644 index 000000000000..88516761c488 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RemoveApiAccessMethodError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface RemoveApiAccessMethodError { + data class Unknown(val t: Throwable) : RemoveApiAccessMethodError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetApiAccessMethodError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetApiAccessMethodError.kt new file mode 100644 index 000000000000..1fa0544a82b4 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetApiAccessMethodError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface SetApiAccessMethodError { + data class Unknown(val t: Throwable) : SetApiAccessMethodError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Settings.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Settings.kt index c5191531bead..e801397b2773 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Settings.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Settings.kt @@ -12,7 +12,8 @@ data class Settings( val tunnelOptions: TunnelOptions, val relayOverrides: List, val showBetaReleases: Boolean, - val splitTunnelSettings: SplitTunnelSettings + val splitTunnelSettings: SplitTunnelSettings, + val apiAccessMethodSettings: List ) { companion object } diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SocksAuth.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SocksAuth.kt new file mode 100644 index 000000000000..ff17641d6379 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SocksAuth.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.lib.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize data class SocksAuth(val username: String, val password: String) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TestApiAccessMethodError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TestApiAccessMethodError.kt new file mode 100644 index 000000000000..ce69919110c9 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TestApiAccessMethodError.kt @@ -0,0 +1,9 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface TestApiAccessMethodError { + data object CouldNotAccess : TestApiAccessMethodError + + data object Grpc : TestApiAccessMethodError + + data class Unknown(val t: Throwable) : TestApiAccessMethodError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/UnknownApiAccessMethodError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/UnknownApiAccessMethodError.kt new file mode 100644 index 000000000000..06cb81fc5e25 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/UnknownApiAccessMethodError.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +data class UnknownApiAccessMethodError(val throwable: Throwable) : UpdateApiAccessMethodError diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/UpdateApiAccessMethodError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/UpdateApiAccessMethodError.kt new file mode 100644 index 000000000000..059796737537 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/UpdateApiAccessMethodError.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface UpdateApiAccessMethodError diff --git a/android/lib/resource/src/main/res/values-da/strings.xml b/android/lib/resource/src/main/res/values-da/strings.xml index 157fdc0476bb..488bcfa7cb76 100644 --- a/android/lib/resource/src/main/res/values-da/strings.xml +++ b/android/lib/resource/src/main/res/values-da/strings.xml @@ -7,12 +7,14 @@ Kontonummer Viser påmindelser, når kontotiden er ved at udløbe Påmindelser om kontotid + Tilføj Tilføj 30 dages tid Tilføj 30 dages tid (%1$s) Tilføj en server Tilføj DNS-server Føj %1$s til listen Tilføj placeringer + Tilføj metode Køb enten kredit på vores hjemmeside, eller indløs en kupon. %1$s blev føjet til din konto. Accepter og fortsæt @@ -23,9 +25,16 @@ Kunne ikke starte tunnelforbindelse. Deaktiver Altid-til VPN for <b>%1$s</b>. Altid-til VPN tildelt en anden app Enhver + Administrer og tilføj brugerdefinerede metoder for at få adgang til Mullvad API. + Appen skal kommunikere med en Mullvad API-server for at kunne logge dig på, hente serverlister og andre kritiske operationer. + På nogle netværk, hvor der bruges forskellige typer censur, er API-serverne muligvis ikke direkte tilgængelige. + Denne funktion giver dig mulighed for at omgå denne censur ved at tilføje brugerdefinerede måder at få adgang til API\'en via proxyer og lignende metoder. + API tilgængelig + API utilgængelig App-version Anvend Kan ikke godkende konto. Indsend en problemrapport. + Godkendelse Auto-tilslutning Auto-tilslutning og Lockdown-tilstand Sørger for, at enheden altid er på VPN-tunnelen. @@ -50,6 +59,7 @@ Køb mere kredit Annuller Ændringer i denne version: + Chiffer Den lokale DNS-server fungerer ikke, medmindre du aktiverer \"Lokal netværksdeling\" under Indstillinger. Du er ved at sende rapporten om problemet, men har ikke angivet hvordan vi kan kontakte dig. Hvis du ønsker et svar på din rapport, skal du indtaste en e-mail-adresse. Ja, log enhed af @@ -108,6 +118,7 @@ Rediger lister Rediger placeringer Rediger meddelelse + Rediger metode Rediger navn Aktiver Brug brugerdefineret DNS-server @@ -173,6 +184,7 @@ For mange enheder Mullvad-kontonummer Kun ejet af Mullvad + Navn Navnet blev ændret til %1$s Velkommen! Denne enhed hedder nu <b>%1$s</b>. Se info-knappen i Konto for at flere oplysninger. NY ENHED OPRETTET @@ -196,6 +208,7 @@ Ejet Ejerskab Betalt indtil + Adgangskode Patch matcher ikke specifikationen For at begynde at bruge appen skal du først føje tid til din konto. Vi kunne ikke starte betalingsprocessen. Sørg for, at du har den nyeste version af Google Play. @@ -242,6 +255,7 @@ Sendt Hvis det er nødvendigt, kontakter vi dig på %1$s Tak! + Server Server IP tilsidesættelse Tilsidesættelser aktive Importer nye tilsidesættelser med @@ -259,6 +273,7 @@ Kan ikke anvende firewallregler. Fejlfind eller send en problemrapport. Indstillinger Konto + API-adgang Ændringer af DNS-relaterede indstillinger træder muligvis ikke i kraft med det samme på grund af cachelagrede resultater. DNS-indstillinger træder muligvis ikke i kraft med det samme Det lykkedes ikke at anvende patch @@ -277,12 +292,15 @@ Indsend Skift placering TCP + Tester... Tryk på \" ︙ \" eller tryk langvarigt på et land, en by eller en server for at tilføje placeringer til en liste. Tryk på \" ︙ \" for at oprette en brugerdefineret liste Slå VPN til/fra Enhedsnavn: %1$s Resterende tid: %1$s + Transportprotokol Prøv igen + Type UDP Hvilken TCP-port UDP-over-TCP tilsløringsprotokollen skal forbinde til på VPN-serveren. UDP-over-TCP-port @@ -299,6 +317,7 @@ Opdater listenavn Din e-mail (valgfrit) For at vi bedre kan hjælpe dig, bedes du skrive på engelsk eller svensk og nævne hvilket land, du befinder dig i. + Brugernavn Bekræfter kupon… Se app-logfiler Fejl ved virtuel adapter diff --git a/android/lib/resource/src/main/res/values-de/strings.xml b/android/lib/resource/src/main/res/values-de/strings.xml index 2a0d04d02cc9..90b979ad819a 100644 --- a/android/lib/resource/src/main/res/values-de/strings.xml +++ b/android/lib/resource/src/main/res/values-de/strings.xml @@ -7,12 +7,14 @@ Kontonummer Erinnerungen anzeigen, wenn die Kontozeit bald abläuft Erinnerungen an die Kontozeit + Hinzufügen 30 Tage Zeit hinzufügen 30 Tage Zeit hinzufügen (%1$s) Server hinzufügen DNS-Server hinzufügen %1$s zur Liste hinzufügen Standorte hinzufügen + Methode hinzufügen Kaufen Sie entweder Guthaben über unsere Seite oder lösen Sie einen Gutschein ein. %1$s wurde zu Ihrem Konto hinzugefügt. Akzeptieren und weiter @@ -23,9 +25,16 @@ Tunnelverbindung kann nicht gestartet werden. Bitte deaktivieren Sie Always-on VPN für <b>%1$s</b>, bevor Sie Mullvad VPN verwenden. Always-on VPN ist einer anderen App zugeordnet Beliebige + Verwaltung und Hinzufügen benutzerdefinierter Methoden für den Zugriff auf die Mullvad-API. + Die App muss mit einem Mullvad API-Server kommunizieren, um Sie anzumelden, Serverlisten abzurufen und andere wichtige Vorgänge durchzuführen. + In einigen Netzwerken, in denen verschiedene Arten der Zensur eingesetzt werden, sind die API-Server möglicherweise nicht direkt erreichbar. + Mit dieser Funktion können Sie diese Zensur umgehen, indem Sie benutzerdefinierte Wege zum Zugriff auf die API über Proxys und ähnliche Methoden hinzufügen. + API erreichbar + API nicht erreichbar App-Version Anwenden Konto konnte nicht authentifiziert werden. Bitte senden Sie einen Problembericht. + Authentifizierung Automatische Verbindung Automatische Verbindung & Sperrmodus Gewährleistet, dass sich das Gerät immer im VPN-Tunnel befindet. @@ -50,6 +59,7 @@ Mehr Guthaben erwerben Abbrechen Änderungen in dieser Version: + Chiffre Der lokale DNS-Server wird nicht funktionieren, solange „Teilen im lokalen Netzwerk“ nicht in den Einstellungen aktiviert ist. Sie wollen einen Problembericht senden, ohne uns die Möglichkeit zu geben, Sie zu erreichen. Wenn Sie sich eine Antwort zu Ihrem Problem wünschen, müssen Sie eine E-Mail-Adresse eingeben. Ja, von Gerät abmelden @@ -108,6 +118,7 @@ Listen bearbeiten Standorte bearbeiten Nachricht bearbeiten + Methode bearbeiten Name bearbeiten Aktivieren Benutzerdefinierten DNS-Server verwenden @@ -173,6 +184,7 @@ Zu viele Geräte Mullvad-Kontonummer Nur im Besitz von Mullvad + Name Name wurde geändert in %1$s Dieses Gerät heißt jetzt <b>%1$s</b>. Weitere Details finden Sie über die Info-Schaltfläche in Ihrem Konto. NEUES GERÄT ERSTELLT @@ -196,6 +208,7 @@ In Besitz Eigentümerschaft Bezahlt bis + Passwort Patch entspricht nicht der Spezifikation Um mit der Nutzung dieser App zu beginnen, müssen Sie erst einmal Zeit zu Ihrem Konto hinzufügen. Wir konnten den Zahlungsvorgang nicht starten. Bitte vergewissern Sie sich, dass Sie die neueste Version von Google Play haben. @@ -242,6 +255,7 @@ Gesendet Bei Bedarf werden wir Sie über %1$s kontaktieren Danke! + Server Server-IP überschreiben Überschreibungen aktiv Neue Überschreibungen importieren via @@ -259,6 +273,7 @@ Firewall-Regeln können nicht angewendet werden. Bitte beheben Sie das Problem oder senden Sie einen Problembericht. Einstellungen Konto + API-Zugriff Änderungen an DNS-Einstellungen werden aufgrund von zwischengespeicherten Daten möglicherweise nicht sofort wirksam. Die DNS-Einstellungen werden möglicherweise nicht sofort wirksam Patch konnte nicht angewendet werden @@ -277,12 +292,15 @@ Absenden Ort wechseln TCP + Testen … Um Standorte zu einer Liste hinzuzufügen, drücken Sie auf „︙“ oder drücken Sie lange auf ein Land, eine Stadt oder einen Server. Um eine eigene Liste zu erstellen, drücken Sie auf „︙“ VPN umschalten Gerätename: %1$s Verbleibende Zeit: %1$s + Transport-Protokoll Erneut versuchen + Typ UDP Mit welchem TCP-Port sich das UDP-über-TCP-Verschleierungsprotokoll auf dem VPN-Server verbinden soll. Port für UDP über TCP @@ -299,6 +317,7 @@ Name der Liste ändern Ihre E-Mail-Adresse (optional) Um Ihnen besser weiterhelfen zu können, schreiben Sie uns bitte auf Englisch oder Schwedisch und geben Sie an, aus welchem Land Sie die Verbindung herstellen. + Benutzername Gutschein verifizieren … App-Protokolle anzeigen Virtueller Adapterfehler diff --git a/android/lib/resource/src/main/res/values-es/strings.xml b/android/lib/resource/src/main/res/values-es/strings.xml index 4834cea2abee..80972a89ddf2 100644 --- a/android/lib/resource/src/main/res/values-es/strings.xml +++ b/android/lib/resource/src/main/res/values-es/strings.xml @@ -7,12 +7,14 @@ Número de cuenta Muestra avisos cuando el tiempo de la cuenta está a punto de caducar Recordatorios de tiempo de la cuenta + Añadir Añadir 30 días Añadir 30 días (%1$s) Añadir un servidor Añadir servidor DNS Añadir %1$s a la lista Añadir ubicaciones + Añadir método Compre crédito en nuestro sitio web o canjee un cupón. Se ha añadido %1$s a su cuenta. Aceptar y continuar @@ -23,9 +25,16 @@ No se puede iniciar la conexión de túnel. Deshabilite la VPN siempre activa en <b>%1$s</b> antes de utilizar la VPN de Mullvad. La VPN siempre activa se ha asignado a otra aplicación Cualquiera + Gestione y añada métodos personalizados para acceder a la API de Mullvad. + La aplicación necesita comunicarse con un servidor API de Mullvad para iniciar su sesión, obtener las listas de servidores y otras operaciones críticas. + En algunas redes, donde se aplican diversos tipos de censura, los servidores de API podrían no estar directamente accesibles. + Esta característica le permite eludir la censura al añadir métodos personalizados de acceder a la API a través de proxies y métodos similares. + API accesible + API inaccesible Versión de la aplicación Aplicar No se puede autenticar la cuenta. Envíe un informe de problemas. + Autenticación Conexión automática Conexión automática y modo de bloqueo Asegura que el dispositivo esté siempre activado en el túnel de la VPN. @@ -50,6 +59,7 @@ Comprar más créditos Cancelar Cambios en esta versión: + Cifrado El servidor DNS local no funcionará a no ser que habilite la opción «Uso compartido de red local» en Preferencias. Va a enviar el informe de problemas sin indicar una forma de contacto. Para obtener una respuesta sobre el informe, necesita especificar su dirección de correo electrónico. Sí, cerrar sesión @@ -108,6 +118,7 @@ Editar listas Editar ubicaciones Editar mensaje + Editar método Editar nombre Habilitar Usar servidor DNS personalizado @@ -173,6 +184,7 @@ Demasiados dispositivos Número de cuenta de Mullvad Solo propiedad de Mullvad + Nombre Se ha cambiado el nombre a %1$s Hola, este dispositivo se llama ahora <b>%1$s</b>. Para más información, consulte el botón de información en la Cuenta. NUEVO DISPOSITIVO CREADO @@ -196,6 +208,7 @@ Propios Propiedad Pagado hasta + Contraseña El parche no coincide con la especificación Para empezar a usar la aplicación, primero necesita agregar tiempo a su cuenta. No hemos podido iniciar el proceso de pago. Asegúrese de tener la última versión de Google Play. @@ -242,6 +255,7 @@ Enviado Si es necesario, le enviaremos un correo electrónico a %1$s ¡Gracias! + Servidor Anulación de IP de servidor Anulaciones activas Importar nuevas anulaciones por @@ -259,6 +273,7 @@ No se pueden aplicar las reglas del firewall. Intente solucionar el problema o envíe un informe de problemas. Configuración Cuenta + Acceso a API Los cambios en la configuración relacionada con el DNS no surtirán efecto inmediatamente debido a los resultados en caché. La configuración de DNS podría no surtir efecto inmediatamente Error al aplicar el parche @@ -277,12 +292,15 @@ Enviar Cambiar ubicación TCP + Probando... Para añadir ubicaciones a una lista, pulse «︙» o mantenga pulsado unos segundos un país, ciudad o servidor. Para crear una lista personalizada, pulse «︙» Alternar VPN Nombre del dispositivo: %1$s Tiempo restante: %1$s + Protocolo de transporte Volver a intentarlo + Tipo UDP El puerto TCP al que se conectará el protocolo de ofuscación de UDP sobre TCP en el servidor VPN. Puerto de UDP sobre TCP @@ -299,6 +317,7 @@ Actualizar nombre de la lista Su correo electrónico (opcional) Para ayudarle mejor, escriba en inglés o sueco e indique desde qué país se está conectando. + Nombre de usuario Verificando el cupón… Ver registros de la aplicación Error del adaptador virtual diff --git a/android/lib/resource/src/main/res/values-fi/strings.xml b/android/lib/resource/src/main/res/values-fi/strings.xml index e4b038d07ece..40e8cb052345 100644 --- a/android/lib/resource/src/main/res/values-fi/strings.xml +++ b/android/lib/resource/src/main/res/values-fi/strings.xml @@ -7,12 +7,14 @@ Tilin numero Näyttää muistutuksia, kun tilin käyttöaika on umpeutumassa Muistutukset tilin käyttöajasta + Lisää Lisää 30 päivää käyttöaikaa Lisää 30 päivää käyttöaikaa (%1$s) Lisää palvelin Lisää DNS-palvelin Lisää %1$s luetteloon Lisää sijainteja + Lisää menetelmä Osta käyttöaikaa verkkosivustoltamme tai lunasta kuponki. Tilillesi lisättiin %1$s käyttöaikaa. Hyväksy ja jatka @@ -23,9 +25,16 @@ Tunneliyhteyden käynnistäminen ei onnistu. Poista aina päällä oleva VPN käytöstä sovellukselle <b>%1$s</b> ennen Mullvad VPN:n käyttämistä. Aina päällä oleva VPN on määritetty toiselle sovellukselle Mikä tahansa + Hallitse ja lisää mukautettuja menetelmiä Mullvadin ohjelmointirajapinnan käyttämiseksi. + Sovelluksen on kommunikoitava Mullvadin ohjelmointirajapinnan palvelimen kanssa, jotta sinut voidaan kirjata sisään, palvelinluetteloiden hakemiseksi sekä muiden tärkeiden toimintojen suorittamiseksi. + Ohjelmointirajapinnan palvelimet eivät välttämättä ole suoraan tavoitettavissa joissakin useita erityyppisiä sensurointimenetelmiä käyttävissä verkoissa. + Tämän ominaisuuden avulla voit kiertää sensuurin lisäämällä välityspalvelinten ja vastaavien menetelmien kautta toimivia, mukautettuja ohjelmointirajapinnan käyttötapoja. + Ohjelmointirajapinta saavutettavissa + Ohjelmointirajapinta ei ole saavutettavissa Sovelluksen versio Ota käyttöön Tilin todentaminen ei onnistu. Lähetä ongelmaraportti. + Todennus Automaattinen yhteys Automaattinen yhdistäminen ja lukitustila Varmistaa, että laite on aina yhteydessä VPN-tunnelin kautta. @@ -50,6 +59,7 @@ Uudista tilaus Peruuta Muutokset tässä versiossa: + Salaus Paikallinen DNS-palvelin ei toimi, ellet ota paikallisen verkon jakamisasetusta käyttöön asetuksissa. Olet aikeissa lähettää ongelmaraportin ilman yhteystietojasi. Mikäli haluat vastauksen raporttiisi, anna sähköpostosoite. Kyllä, kirjaa laite ulos @@ -108,6 +118,7 @@ Muokkaa luetteloita Muokkaa sijainteja Muokkaa viestiä + Muokkaa menetelmää Muokkaa nimeä Ota käyttöön Käytä mukautettua DNS-palvelinta @@ -173,6 +184,7 @@ Liikaa laitteita Mullvad-tilin numero Vain Mullvadin omistamat + Nimi Nimeksi vaihdettiin \"%1$s\" Tervetuloa! Tämän laitteen nimi on nyt <b>%1$s</b>. Katso lisätietoja tilin infopainikkeesta. UUSI LAITE LUOTIIN @@ -196,6 +208,7 @@ Omistettu Omistajuus Maksu ennen + Salasana Muutostiedosto ei vastaa määritelmää Voit aloittaa sovelluksen käyttämisen lisäämällä ensin aikaa tilillesi. Emme pystyneet aloittamaan maksun käsittelyä. Varmista, että käytät Google Playn uusinta versiota. @@ -242,6 +255,7 @@ Lähetetty Tarvittaessa otamme sinuun yhteyttä osoitteeseen %1$s Kiitos! + Palvelin Palvelimen IP-osoitteen ohitus Ohituksia on käytössä Uusien ohitusten tuontitapa @@ -259,6 +273,7 @@ Palomuurisääntöjä ei voida käyttää. Suorita vianetsintä tai lähetä ongelmaraportti. Asetukset Tili + Ohjelmointirajapinnan käyttö DNS-asetuksiin tehdyt muutokset eivät välttämättä astu voimaan välittömästi välimuistissa olevien tulosten vuoksi. Uudet DNS-asetukset eivät välttämättä astu voimaan välittömästi Muutostiedoston käyttöönotto ei onnistunut @@ -277,12 +292,15 @@ Lähetä Vaihda sijaintia TCP + Testataan... Jos haluat lisätä luetteloon sijainteja, paina \"︙\" tai paina pitkään maata, kaupunkia tai palvelinta. Voit luoda mukautetun luettelon painamalla \"︙\" Vaihda VPN:ää Laitteen nimi: %1$s Aikaa jäljellä: %1$s + Siirtoprotokolla Yritä uudelleen + Tyyppi UDP Määrittää, mihin VPN-palvelimen TCP-porttiin \"UDP TCP:n kautta\" -hämäysteknologia-protokollan tulee muodostaa yhteys. Portti: UDP TPC:n kautta @@ -299,6 +317,7 @@ Päivitä luettelon nimi Sähköpostisi (valinnainen) Jotta voisimme avustaa sinua paremmin, kirjoita englanniksi tai ruotsiksi ja mainitse, mistä maasta muodostat yhteyden. + Käyttäjätunnus Kuponkia vahvistetaan… Tarkastele sovelluslokeja Virtuaalisovittimen virhe diff --git a/android/lib/resource/src/main/res/values-fr/strings.xml b/android/lib/resource/src/main/res/values-fr/strings.xml index 9969f5fb36b4..f1b6663b4d2b 100644 --- a/android/lib/resource/src/main/res/values-fr/strings.xml +++ b/android/lib/resource/src/main/res/values-fr/strings.xml @@ -7,12 +7,14 @@ Numéro de compte Affiche des rappels lorsque le temps du compte va expirer Rappels de temps pour le compte + Ajouter Ajouter 30 jours de temps Ajouter 30 jours de temps (%1$s) Ajouter un serveur Ajouter un serveur DNS Ajouter %1$s à la liste Ajouter des localisations + Ajouter un mode Achetez du crédit sur notre site web ou échangez un bon. %1$s ajouté(s) à votre compte. Accepter et continuer @@ -23,9 +25,16 @@ Impossible de démarrer la connexion au tunnel. Veuillez désactiver « Toujours exiger un VPN « pour <b>%1$s</b> avant d\'utiliser Mullvad VPN. « Toujours exiger un VPN » est assigné à une autre application N\'importe lequel + Gérez et ajoutez des modes d\'accès personnalisés à l\'API Mullvad. + L\'application doit communiquer avec un serveur d\'API Mullvad pour vous connecter, récupérer des listes de serveurs et effectuer d\'autres opérations critiques. + Sur certains réseaux, où divers types de censure sont utilisés, les serveurs API peuvent ne pas être directement accessibles. + Cette fonctionnalité vous permet de contourner cette censure en ajoutant des moyens personnalisés d\'accéder à l\'API via des proxys et des méthodes similaires. + API joignable + API injoignable Version de l\'application Appliquer Impossible d\'authentifier le compte. Veuillez envoyer un rapport de problème. + Authentification Connexion automatique Connexion automatique et mode Verrouillage Permet de s\'assurer que l\'appareil est toujours sur le tunnel VPN. @@ -50,6 +59,7 @@ Acheter plus de crédits Annuler Modifications dans cette version : + Chiffre Le serveur DNS local ne fonctionnera pas si vous n\'activez pas le « Partage du réseau local » dans les préférences. Vous êtes sur le point d\'envoyer un signalement de problème sans nous fournir un moyen de vous contacter. Si vous désirez une réponse à votre signalement, vous devez saisir une adresse e-mail. Oui, déconnecter l\'appareil @@ -108,6 +118,7 @@ Modifier les listes Modifier les localisations Modifier le message + Modifier le mode Modifier le nom Activer Utiliser un serveur DNS personnalisé @@ -173,6 +184,7 @@ Trop d\'appareils Numéro de compte Mullvad Propriété de Mullvad uniquement + Nom Le nom a été changé en %1$s Bienvenue, cet appareil s\'appelle désormais <b>%1$s</b>. Pour plus d\'informations, consultez le bouton d\'information sous Compte. NOUVEL APPAREIL CRÉÉ @@ -196,6 +208,7 @@ Possédé Propriété Payé jusqu\'au + Mot de passe Le correctif ne correspond pas à la spécification Pour commencer à utiliser l\'application, vous devez d\'abord ajouter du temps à votre compte. Nous n\'avons pas pu lancer le processus de paiement, merci de vérifier que vous disposez de la dernière version de Google Play. @@ -242,6 +255,7 @@ Envoyé Si nécessaire, nous vous contacterons à l\'adresse %1$s Merci ! + Serveur Substitution d\'IP de serveur Substitutions actives Importer les nouvelles substitutions depuis @@ -259,6 +273,7 @@ Impossible d\'appliquer les règles du pare-feu. Merci de résoudre le problème ou d\'envoyer un rapport de problème. Paramètres Compte + Accès à l\'API Les modifications apportées aux paramètres liés au DNS peuvent ne pas prendre effet immédiatement en raison de la mise en cache des résultats. Les paramètres DNS peuvent ne pas être immédiatement pris en compte Impossible d\'appliquer le correctif @@ -277,12 +292,15 @@ Envoyer Changer de localisation TCP + Test... Pour ajouter des localisations à une liste, appuyez sur la touche « ︙ » ou appuyez longuement sur un pays, une ville ou un serveur. Appuyez sur « ︙ » pour créer une liste personnalisée Activer/désactiver le VPN Nom de l\'appareil : %1$s Temps restant : %1$s + Protocole de transport Réessayer + Type UDP Le port TCP auquel le protocole de dissimulation UDP sur TCP doit se connecter sur le serveur VPN. Port UDP sur TCP @@ -299,6 +317,7 @@ Mettre à jour le nom de la liste Votre e-mail (facultatif) Pour nous permettre de mieux vous assister, merci d\'écrire en anglais ou en suédois et d\'indiquer le pays à partir duquel vous vous connectez. + Nom d\'utilisateur Vérification du bon… Afficher les journaux de l\'application Erreur d\'adaptateur virtuel diff --git a/android/lib/resource/src/main/res/values-it/strings.xml b/android/lib/resource/src/main/res/values-it/strings.xml index a8224f5835d0..22d3563b7211 100644 --- a/android/lib/resource/src/main/res/values-it/strings.xml +++ b/android/lib/resource/src/main/res/values-it/strings.xml @@ -7,12 +7,14 @@ Numero di account Mostra promemoria quando il tempo dell\'account sta per scadere Promemoria temporali per l\'account + Aggiungi Aggiungi 30 giorni di tempo Aggiungi 30 giorni di tempo (%1$s) Aggiungi un server Aggiungi server DNS Aggiungi %1$s all\'elenco Aggiungi posizioni + Aggiungi metodo Acquista credito sul nostro sito web o riscatta un voucher. %1$s aggiunto al tuo account. Accetta e continua @@ -23,9 +25,16 @@ Impossibile avviare la connessione tunnel. Disabilita VPN sempre attiva per <b>%1$s</b> prima di utilizzare Mullvad VPN. VPN sempre attiva assegnata a un\'altra app Qualsiasi + Gestisci e aggiungi metodi personalizzati per accedere all\'API Mullvad. + L\'app deve comunicare con un server API Mullvad per accedere, recuperare elenchi di server e altre operazioni critiche. + Su alcune reti, dove vengono utilizzati vari tipi di censura, i server API potrebbero non essere direttamente raggiungibili. + Questa funzionalità ti consente di aggirare tale censura aggiungendo modi personalizzati per accedere all\'API tramite proxy e metodi simili. + API raggiungibile + API non raggiungibile Versione app Applica Impossibile autenticare l\'account. Invia una segnalazione del problema. + Autenticazione Connessione automatica Modalità di connessione automatica e blocco Assicurati che il dispositivo sia sempre collegato al tunnel VPN. @@ -50,6 +59,7 @@ Acquista altro credito Annulla Modifiche in questa versione: + Codice Il server DNS locale non funzionerà a meno che non si abiliti \"Condivisione rete locale\" in Preferenze. Stai inviando la segnalazione di un problema senza averci indicato un modo per ricontattarti. Se desideri ricevere risposta, inserisci un indirizzo e-mail. Sì, disconnetti dal dispositivo @@ -108,6 +118,7 @@ Modifica elenchi Modifica posizioni Modifica messaggio + Modifica metodo Modifica nome Abilita Usa un server DNS personalizzato @@ -173,6 +184,7 @@ Troppi dispositivi Numero di account Mullvad Solo di proprietà di Mullvad + Nome Il nome è stato modificato in %1$s Benvenuto, questo dispositivo ora si chiama <b>%1$s</b>. Per maggiori dettagli, premi il pulsante delle informazioni in Account. NUOVO DISPOSITIVO CREATO @@ -196,6 +208,7 @@ Di proprietà Proprietà Pagato fino al + Password La patch non corrisponde alle specifiche Per iniziare a utilizzare l\'app, devi prima aggiungere tempo al tuo account. Non siamo riusciti ad avviare il processo di pagamento, assicurati di avere la versione più recente di Google Play. @@ -242,6 +255,7 @@ Inviato Se necessario, ti contatteremo all\'indirizzo %1$s Grazie! + Server Sovrascritture IP server Sovrascritture attive Importa nuove sovrascritture tramite @@ -259,6 +273,7 @@ Impossibile applicare le regole del firewall. Consulta la risoluzione dei problemi o invia una segnalazione del problema. Impostazioni Account + Accesso API Le modifiche alle impostazioni relative al DNS potrebbero non avere effetto immediato a causa dei risultati memorizzati nella cache. Le impostazioni DNS potrebbero non avere effetto immediato Impossibile applicare la patch @@ -277,12 +292,15 @@ Invia Cambia posizione TCP + Test... Per aggiungere posizioni a un elenco, premi \"︙\" o tieni premuto su un Paese, una città o un server. Per creare un elenco personalizzato, premi \"︙\" Attiva/disattiva VPN Nome del dispositivo: %1$s Tempo rimasto: %1$s + Protocollo di trasporto Riprova + Tipo UDP A quale porta TCP deve connettersi il protocollo di offuscamento UDP-over-TCP sul server VPN. Porta UDP-over-TCP @@ -299,6 +317,7 @@ Aggiorna nome elenco La tua e-mail (opzionale) Per consentirci di assisterti meglio, scrivi in ​​inglese o in svedese e indica da quale Paese ti stai connettendo. + Nome utente Verifica del voucher… Visualizza registri app Errore scheda virtuale diff --git a/android/lib/resource/src/main/res/values-ja/strings.xml b/android/lib/resource/src/main/res/values-ja/strings.xml index f62d10c1b620..b3c2d744278c 100644 --- a/android/lib/resource/src/main/res/values-ja/strings.xml +++ b/android/lib/resource/src/main/res/values-ja/strings.xml @@ -7,12 +7,14 @@ アカウント番号 アカウントの期限切れが迫っているときにリマインダーを表示します アカウント時間のリマインダー + 追加 30日分を追加する 30日分を追加する (%1$s) サーバーを追加 DNS サーバーを追加 %1$s をリストに追加する 場所の追加 + 方法の追加 当社ウェブサイトでクレジットを購入するか、バウチャーを使用してください。 %1$s がご利用のアカウントに追加されました。 同意して続行 @@ -23,9 +25,16 @@ トンネル接続を開始できません。Mullvad VPNを使用する前に<b>%1$s</b>のAlways-on VPNを無効にしてください。 Always-on VPNは他のアプリに割り当てられています すべて + Mullvad APIへのカスタムのアクセス方法を管理・追加します。 + アプリはユーザーのログイン、サーバーリストの取得、およびその他の重要な操作を行うためにMullvad APIサーバーと通信する必要があります。 + 各種の検閲が使用されている一部のネットワークでは、APIサーバーに直接アクセスできない場合があります。 + この機能では、プロキシなどの方法を経由してAPIにアクセスするためのカスタムの方法を追加することで検閲を回避できます。 + API到達可能 + API到達不能 アプリのバージョン 適用 アカウントを認証できません。問題の報告を送信してください。 + 認証 自動接続 自動接続とロックダウンモード デバイスが必ずVPNトンネルを使用するようにします。 @@ -50,6 +59,7 @@ 追加クレジットを購入 キャンセル このバージョンでの変更内容: + 暗号化 環境設定で \"ローカルネットワーク共有\" を有効にしない限り、ローカルDNSサーバーは機能しません。 お客様への返信先を入力せずに問題の報告を送信しようとしています。ご報告に対する返信が必要な場合は、返信先のメールアドレスを入力する必要があります。 はい。デバイスをログアウトさせます @@ -108,6 +118,7 @@ リストを編集 場所の編集 メッセージを編集する + 方法の編集 名前を編集 有効にする カスタムDNSサーバーを使う @@ -173,6 +184,7 @@ デバイスが多すぎます Mullvadアカウント番号 Mullvad 所有サーバーのみ + 名前 名前が %1$s に変更されました ようこそ。このデバイスの名前は<b>%1$s</b>です。詳細はアカウントの情報ボタンで確認してください。 新しいデバイスが作成されました @@ -196,6 +208,7 @@ 自社サーバー 所有権 次の日時まで支払い済み + パスワード パッチが仕様に一致していません アプリを使い始めるには、まずはアカウントに時間を追加する必要があります。 決済処理を開始できませんでした。最新バージョンのGoogle Playを使用していることを確認してください。 @@ -242,6 +255,7 @@ 送信済み 必要に応じて %1$s 宛にご連絡します  ありがとうございます! + サーバー サーバーIPのオーバーライド オーバーライドは有効です 新しいオーバーライドのインポート @@ -259,6 +273,7 @@ ファイアウォールのルールを適用できません。問題に対処するか、問題の報告を送信してください。 設定 アカウント + APIアクセス 結果がキャッシュされているため、DNS関連の設定の変更はすぐには適用されない可能性があります。 DNS設定はすぐに適用されない可能性があります パッチを適用できませんでした @@ -277,12 +292,15 @@ 送信 場所を切り替える TCP + テスト中... リストに場所を追加するには、\"︙\" を押すか、または、国、都市、サーバーを長押ししてください。 カスタムリストを作成するには、\"︙\" を押してください VPNの切り替え デバイス名: %1$s 残り時間: %1$s + 転送プロトコル 再試行 + 種類 UDP UDP-over-TCP難読化プロトコルで接続する必要のあるVPNサーバーのTCPポートです。 UDP-over-TCPポート @@ -299,6 +317,7 @@ リスト名の更新 あなたのメールアドレス (任意) 最適なサポートを提供するため、英語またはスウェーデン語でご入力ください。また、接続元の国をお知らせください。 + ユーザー名 バウチャーを確認中… アプリのログを表示 仮想アダプタのエラー diff --git a/android/lib/resource/src/main/res/values-ko/strings.xml b/android/lib/resource/src/main/res/values-ko/strings.xml index 209cb5318b33..c62618fcc8a7 100644 --- a/android/lib/resource/src/main/res/values-ko/strings.xml +++ b/android/lib/resource/src/main/res/values-ko/strings.xml @@ -7,12 +7,14 @@ 계정 번호 계정 시간이 만료되려고 할 때 알림 표시 계정 시간 알림 + 추가 30일 시간 추가 30일 시간 추가(%1$s) 서버 추가 DNS 서버 추가 목록에 %1$s 추가 위치 추가 + 방법 추가 웹 사이트에서 크레딧을 구매하거나 바우처를 사용하세요. %1$s이(가) 계정에 추가되었습니다. 동의하고 계속하기 @@ -23,9 +25,16 @@ 터널 연결을 시작할 수 없습니다. Mullvad VPN을 사용하기 전에 <b>%1$s</b>에 대한 상시 접속 VPN을 비활성화하세요. 상시 접속 VPN이 다른 앱에 할당됨 모두 + Mullvad API에 액세스하기 위한 사용자 지정 방법을 관리하고 추가합니다. + 이 앱은 로그인, 서버 목록 가져오기 및 기타 중요한 작업을 위해 Mullvad API 서버와 통신해야 합니다. + 다양한 유형의 검열이 사용되고 있는 일부 네트워크에서는 API 서버에 직접 연결하지 못할 수도 있습니다. + 이 기능을 사용하면 프록시 및 유사한 방법을 통해 API에 액세스하는 사용자 지정 방법을 추가하여 검열을 우회할 수 있습니다. + API 연결 가능 + API 연결 불가 앱 버전 적용 계정을 인증할 수 없습니다. 문제 보고서를 보내주세요. + 인증 자동 연결 자동 연결 및 잠금 모드 장치가 항상 VPN 터널에 있어야 합니다. @@ -50,6 +59,7 @@ 추가 크레딧 구매 취소 이 버전의 변경 사항: + 암호 환경 설정에서 ”로컬 네트워크 공유”를 활성화하지 않으면 로컬 DNS 서버가 작동하지 않습니다. 연락처 없이 문제 보고서를 보내려고 합니다. 보고서에 대한 답변을 원하면 이메일 주소를 입력해야 합니다. 예, 장치에서 로그아웃 @@ -108,6 +118,7 @@ 목록 편집 위치 편집 메시지 편집 + 방법 편집 이름 편집 사용 사용자 지정 DNS 서버 사용 @@ -173,6 +184,7 @@ 장치가 너무 많음 Mullvad 계정 번호 Mullvad 소유만 + 이름 이름이 %1$s(으)로 변경되었습니다 환영합니다! 이제 이 장치의 이름은 <b>%1$s</b>입니다. 자세한 내용을 보려면 계정의 정보 버튼을 누르세요. 새 장치가 생성됨 @@ -196,6 +208,7 @@ 소유 소유권 유효 기간 + 암호 사양과 일치하지 않는 패치 앱 사용을 시작하려면, 먼저 계정에 시간을 추가해야 합니다. 결제 프로세스를 시작할 수 없습니다. Google Play가 최신 버전인지 확인하세요. @@ -242,6 +255,7 @@ 전송 완료 필요한 경우 %1$s(으)로 연락드리겠습니다. 감사합니다! + 서버 서버 IP 재정의 재정의 활성화 다음으로 새로운 재정의 가져오기: @@ -259,6 +273,7 @@ 방화벽 규칙을 적용할 수 없습니다. 문제를 해결하거나 문제 보고서를 보내주세요. 설정 계정 + API 액세스 DNS 관련 설정에 대한 변경 사항은 캐시된 결과로 인해 즉시 적용되지 않을 수도 있습니다. DNS 설정이 즉시 적용되지 않을 수도 있습니다 패치 적용 실패 @@ -277,12 +292,15 @@ 제출 위치 전환 TCP + 테스트 중... 목록에 위치를 추가하려면 \"︙\"를 누르거나 국가, 도시 또는 서버를 길게 누릅니다. 사용자 지정 목록을 생성하려면 \"︙\"를 누릅니다 VPN 전환 장치 이름: %1$s 남은 시간: %1$s + 전송 프로토콜 다시 시도 + 유형 UDP UDP-over-TCP 난독 처리 프로토콜이 VPN 서버에서 연결해야 하는 TCP 포트입니다. UDP-over-TCP 포트 @@ -299,6 +317,7 @@ 목록 이름 업데이트 이메일(선택 사항) 더 나은 지원을 위해 영어 또는 스웨덴어로 메시지를 작성하고 연결 국가를 포함하세요. + 사용자 이름 바우처 확인 중… 앱 로그 보기 가상 어댑터 오류 diff --git a/android/lib/resource/src/main/res/values-my/strings.xml b/android/lib/resource/src/main/res/values-my/strings.xml index 92bda18cc2af..a31d6eb5ac98 100644 --- a/android/lib/resource/src/main/res/values-my/strings.xml +++ b/android/lib/resource/src/main/res/values-my/strings.xml @@ -7,12 +7,14 @@ အကောင့် နံပါတ် အကောင့်အချိန် သက်တမ်းကုန်ခါနီးချိန်၌ သတိပေးချက်များ ပြသပေးပါသည် အကောင့်အချိန် သတိပေးချက်များ + ပေါင်းထည့်ရန် အချိန် ရက် 30 ကို‌ ပေါင်းထည့်ရန် အချိန် ရက် 30 ကို‌ ပေါင်းထည့်ရန် (%1$s) ဆာဗာ ပေါင်းထည့်ရန် DNS ဆာဗာကို ပေါင်းထည့်ရန် စာရင်းထဲသို့ %1$s ပေါင်းထည့်ရန် တည်နေရာများ ပေါင်းထည့်ရန် + နည်းလမ်း ပေါင်းထည့်ရန် ကျွန်ုပ်တို့၏ ဝက်ဘ်ဆိုက်တွင် ခရက်ဒစ် ဝယ်ယူပါ သို့မဟုတ် ဘောက်ချာဖြင့် လဲယူပါ။ သင့်အကောင့်သို့ %1$s ကို ထည့်ပြီးပါပြီ။ သဘောတူပြီး ဆက်လုပ်ရန် @@ -23,9 +25,16 @@ Tunnel ချိတ်ဆက်မှုကို စတင်၍ မရနိုင်ပါ။ Mullvad VPN ကို မသုံးမီ <b>%1$s</b> အတွက် VPN အမြဲဖွင့်ထားမှုကို ပိတ်ပေးပါ။ အမြဲဖွင့် VPN ကို အခြားအက်ပ်တစ်ခုသို့ သတ်မှတ်ထားပါသည် တစ်ခုခု + Mullvad API ကို ရယူသုံးစွဲရန် စိတ်ကြိုက် နည်းလမ်းများကို ပေါင်းထည့်၍ စီမံပါ။ + Mullvad API ဆာဗာသို့ သင်ဝင်ရောက်ရန်၊ ဆာဗာစာရင်းများ ရယူရန်နှင့် အလွန်အရေးပါသည့် အခြားလုပ်ဆောင်မှုများအတွက် ဤအက်ပ်သည် ၎င်းနှင့်ဆက်သွယ်ရန် လိုအပ်ပါသည်။ + စိစစ်ဖြတ်တောက်မှု အမျိုးအစားများစွာကို သုံးထားသော အချို့ကွန်ရက်များတွင် API ဆာဗာများသည် တိုက်ရိုက် ဝင်ရောက် သုံးစွဲနိုင်စွမ်း မရှိပါ။ + ဤလုပ်ဆောင်ချက်သည် ပရောက်စီများနှင့် အလားတူနည်းလမ်းများမှတစ်ဆင့် API ကို ဝင်ရောက်သုံးစွဲရန် မိမိစိတ်ကြိုက်နည်းလမ်းများကို ပေါင်းထည့်ခြင်းအားဖြင့် ထိုစိစစ်ဖြတ်တောက်မှုကို ရှောင်လွှဲနိုင်စေပါသည်။ + API ဝင်ရောက် သုံးစွဲနိုင်ပါသည် + API ဝင်ရောက် သုံးစွဲ၍ မရနိုင်ပါ အက်ပ်ဗားရှင်း သုံးရန် အကောင့်ကို စစ်မှန်ကြောင်း အတည်ပြု၍ မရနိုင်ပါ။ ပြဿနာ ရီပို့တ်တစ်ခု ပေးပို့ပေးပါ။ + စစ်မှန်ကြောင်း အတည်ပြုခြင်း အော်တို ချိတ်ဆက်မှု အော်တို ချိတ်ဆက်မှု နှင့် လော့ခ်ဒေါင်းစနစ် စက်သည် VPN Tunnel ကို အမြဲဖွင့်ထားကြောင်း သေချာပါစေ။ @@ -50,6 +59,7 @@ ခရက်ဒစ်များ ဝယ်ရန် မလုပ်တော့ပါ ဤဗားရှင်းတွင် ပြောင်းလဲမှုများ- + ဝှက်စာ လိုကယ် DNS ဆာဗာသည် လိုလားမှုများအောက်ရှိ \"လိုကယ် ကွန်ရက် ဝေမျှမှု\"ကို မဖွင့်မချင်း အလုပ်လုပ်မည် မဟုတ်ပါ။ သင်သည် သင့်ထံ ကျွန်ုပ်တို့ ပြန်ဆက်သွယ်နိုင်မည့် နည်းလမ်း မပါဘဲ ပြဿနာ ရီပို့တ်ကို ပေးပို့တော့မည် ဖြစ်ပါသည်။ သင့်ရီပို့တ်အတွက် အဖြေ ရရှိလိုပါက အီမေးလိပ်စာ ဖြည့်သွင်းပေးရပါမည်။ စက်မှ ထွက်မည် @@ -108,6 +118,7 @@ စာရင်းများကို တည်းဖြတ်ရန် တည်နေရာများကို တည်းဖြတ်ရန် မက်ဆေ့ချ် တည်းဖြတ်ရန် + နည်းလမ်းကို တည်းဖြတ်ပါ အမည် တည်းဖြတ်ရန် ဖွင့်ရန် စိတ်ကြိုက် DNS ဆာဗာကို သုံးရန် @@ -173,6 +184,7 @@ စက်များလွန်းနေသည် Mullvad အကောင့်နံပါတ် Mullvad ပိုင်ဆိုင်သည်များသာ + အမည် အမည်ကို %1$s သို့ ပြောင်းလိုက်ပါသည် ကြိုဆိုပါသည်၊ ယခုမှစ၍ ဤစက်ကို <b>%1$s</b> ဟု ခေါ်ဆိုပါမည်။ နောက်ထပ်အသေးစိတ်တို့အတွက် အကောင့်တွင် အချက်အလက် ခလုတ်ကို နှိပ်၍ ကြည့်နိုင်သည်။ စက်အသစ် ဖန်တီးထားသည် @@ -196,6 +208,7 @@ အပိုင် ပိုင်ဆိုင်မှု ဖော်ပြပါအထိ ပေးချေထားပြီး + စကားဝှက် ပက်ချ်သည် သတ်မှတ်ချက်နှင့် မကိုက်ညီပါ အက်ပ်ကို စသုံးရန်အတွက် ဦးစွာ သင့်အကောင့်တွင် အချိန်ပေါင်းထည့်ပေးရန် လိုအပ်ပါသည်။ လက်ရှိတွင် ပေးချေမှု လုပ်ငန်းစဉ်ကို စတင်၍ မရနိုင်ပါ၊ Google Play နောက်ဆုံး ဗားရှင်း သင့်တွင်ရှိနေကြောင်း သေချာပါစေ။ @@ -242,6 +255,7 @@ ပို့ပြီး လိုအပ်ပါက %1$s မှတစ်ဆင့် ကျွန်ုပ်တို့ထံ ဆက်သွယ်ပါ ကျေးဇူးတင်ပါသည်။ + ဆာဗာ ဆာဗာ IP ကျော်လွန် ပယ်ဖျက်မှု ကျော်လွန် ပယ်ဖျက်မှုများ သက်ဝင်နေသည် ဖော်ပြပါဖြင့် ကျော်လွန် ပယ်ဖျက်မှုအသစ်များကို ထည့်သွင်းရန် @@ -259,6 +273,7 @@ Firewall စည်းမျဉ်းများကို အသုံးချ၍ မရနိုင်ပါ။ ပြစ်ချက် ရှာဖွေဖယ်ရှာပေးပါ သို့မဟုတ် ပြဿနာ ရီပို့တ် ပေးပို့ပေးပါ။ ဆက်တင် အကောင့် + API ရယူသုံးစွဲခွင့် DNS နှင့်ဆက်စပ်သော ဆက်တင်များ၌ ပြုလုပ်သည့် ပြောင်းလဲမှုများသည် ယာယီသိမ်းထားသော ရလဒ်များကြောင့် ချက်ချင်း အကျိုးမသက်ရောက်နိုင်ပါ။ DNS ဆက်တင်ကို ချက်ချင်း အကျိုးမရောက်နိုင်ပါ။ ပတ်(ချ်) သုံးခြင်း မအောင်မြင်ပါ @@ -277,12 +292,15 @@ ပေးပို့ရန် တည်နေရာ ပြောင်းရန် TCP + စမ်းသပ်နေဆဲ... စာရင်းထဲသို့ တည်နေရာများကို ပေါင်းထည့်ရန် \"︙\" ကို နှိပ်ပါ သို့မဟုတ် နိုင်ငံ၊ မြို့၊ ဆာဗာကို နှိပ်ပါ။ စိတ်ကြိုက် စာရင်းများကို ဖန်တီးရန် \"︙\" ကို နှိပ်ပါ VPN ရွေးသုံးရန် စက်အမည်- %1$s ကျန်သည့် အချိန်- %1$s + ပို့ဆောင်ရေး ပရိုတိုကောလ် ထပ်ကြိုးစားရန် + အမျိုးအစား UDP VPN ဆာဗာကို ဖွင့်ရန် ၎င်း TCP ပေါ့တ် UDP-over-TCP Obfuscation ပရိုတိုကောလ်နှင့် ချိတ်ဆက်ထားသင့်ပါသည်။ UDP-over-TCP ပေါ့တ် @@ -299,6 +317,7 @@ စာရင်း အမည်ကို အပ်ဒိတ်လုပ်ရန် သင့်အီးမေးလ် (မဖြည့်လည်း ရပါသည်) သင့်အား ပို၍ အကူအညီပေးနိုင်ရန် အင်္ဂလိပ်ဘာသာ သို့မဟုတ် ဆွီဒင်ဘာသာဖြင့် ရေးပြီး မည်သည့်နိုင်ငံမှ သင်ချိတ်ဆက်နေသည်ကို ထည့်သွင်းဖော်ပြပါ။ + သုံးစွဲသူအမည် ဘောက်ချာကို စစ်ဆေးနေဆဲ… အက်ပ်မှတ်တမ်းများ ကြည့်ရန် စက်တွင်း အဒက်တာ ချို့ယွင်းချက် diff --git a/android/lib/resource/src/main/res/values-nb/strings.xml b/android/lib/resource/src/main/res/values-nb/strings.xml index 3c3ebfbaa2e1..c037c545979f 100644 --- a/android/lib/resource/src/main/res/values-nb/strings.xml +++ b/android/lib/resource/src/main/res/values-nb/strings.xml @@ -7,12 +7,14 @@ Kontonummer Viser påminnelser når tidsavbrudd for kontoen er i ferd med å inntreffe Påminnelser om tidsavbrudd for konto + Legg til Legg til 30 dager Legg til 30 dager (%1$s) Legg til en server Legg til DNS-server Legg til %1$s i listen Legg til plasseringer + Legg til metode Du kan enten kjøpe kreditt på nettsiden vår eller løse inn en kupong. %1$s ble lagt til kontoen din. Godta og fortsett @@ -23,9 +25,16 @@ Kunne ikke starte tunneltilkobling. Deaktiver VPN som alltid er på, for <b>%1$s</b> før du bruker Mullvad VPN. VPN som alltid er på, er tilordnet en annen app Hvilken som helst + Administrer og legg til tilpassede metoder for tilgang til Mullvad API. + Appen må kunne kommunisere med en Mullvad API-server for å logge deg inn, hente serverlister og utføre andre kritiske operasjoner. + På enkelte nettverk der det brukes ulike typer sensur, kan det hende API-servere ikke er direkte tilgjengelig. + Denne funksjonen lar deg omgå sensuren ved å legge til tilpassede måter å få tilgang til API på via proxyer og lignende metoder. + API tilgjengelig + API ikke tilgjengelig Appversjon Angi Kunne ikke autentisere konto. Send inn en problemrapport. + Autentisering Automatisk tilkobling Automatisk tilkobling og låsemodus Sørger for at enheten alltid er på VPN-tunnelen. @@ -50,6 +59,7 @@ Kjøp mer kreditt Avbryt Endringer i denne versjonen: + Chiffer Den lokale DNS-serveren fungerer ikke med mindre du aktiverer «Deling av lokalt nettverk» under Innstillinger. Problemrapporten blir nå sendt uten en måte for oss å kontakte deg på. Hvis du ønsker svar på rapporten, må du oppgi en e-postadresse. Ja, logg av enhet @@ -108,6 +118,7 @@ Endre lister Endre plasseringer Rediger melding + Endre metode Endre navn Aktiver Bruk egendefinert DNS-server @@ -173,6 +184,7 @@ For mange enheter Mullvad-kontonummer Kun eid av Mullvad + Navn Navn ble endret til %1$s Velkommen. Denne enheten har fått navnet <b>%1$s</b>. For å finne ut mer kan du bruke informasjonsknappen under Konto. NY ENHET OPPRETTET @@ -196,6 +208,7 @@ Eid Eierskap Betalt fram til + Passord Oppdateringen samsvarer ikke med spesifikasjon For å starte bruken av appen, må du først legge til tid til kontoen. Vi kunne ikke starte betalingsprosessen. Kontroller om du har siste versjon av Google Play. @@ -242,6 +255,7 @@ Sendt Vi vil kontakte deg på %1$s ved behov Takk! + Server Overstyring av server-IP Overstyringer aktive Importer nye overstyringer via @@ -259,6 +273,7 @@ Kunne ikke bruke brannmur-regler. Feilsøk eller send inn en problemrapport. Innstillinger Konto + API-tilgang Endringer til DNS-relaterte innstillinger vil kanskje ikke tre i kraft umiddelbart på grunn av bufrede resultater. DNS-innstillinger vil kanskje ikke tre i kraft umiddelbart Kunne ikke bruke oppdatering @@ -277,12 +292,15 @@ Send inn Bytt plassering TCP + Tester ... Hvis du vil legge til plasseringer i en liste, trykker du på ︙ eller trykker på og holder inne et land, en by eller en server. For å opprette en egendefinert liste trykker du på ︙ Velg VPN Enhetsnavn: %1$s Tid igjen: %1$s + Transportprotokoll Prøv på nytt + Type UDP TCP-porten som UDP-over-TCP-tilsløringen skal koble til på VPN-serveren. UDP-over-TCP-port @@ -299,6 +317,7 @@ Oppdater listenavn E-post (valgfritt) For at vi skal kunne hjelpe deg bedre, ber vi deg om å skrive på engelsk eller svensk og fortelle hvilket land du befinner deg i. + Brukernavn Bekrefter kupong … Se applogger Virtuell adapterfeil diff --git a/android/lib/resource/src/main/res/values-nl/strings.xml b/android/lib/resource/src/main/res/values-nl/strings.xml index 31e278a4c882..0cd4196e7a04 100644 --- a/android/lib/resource/src/main/res/values-nl/strings.xml +++ b/android/lib/resource/src/main/res/values-nl/strings.xml @@ -7,12 +7,14 @@ Accountnummer Toont herinneringen wanneer de accounttijd op het punt staat te verlopen Accounttijdherinneringen + Toevoegen 30 dagen tijd toevoegen 30 dagen tijd toevoegen (%1$s) Server toevoegen DNS-server toevoegen %1$s toevoegen aan lijst Voeg locaties toe + Methode toevoegen Koop krediet op onze website of wissel een voucher in. %1$s is toegevoegd aan uw account. Akkoord en doorgaan @@ -23,9 +25,16 @@ Kan de tunnelverbinding niet starten. Schakel Altijd-aan VPN uit voor <b>%1$s</b> voordat u Mullvad VPN gebruikt. Altijd-aan VPN toegewezen aan andere app Elke + Beheer en voeg aangepaste methoden toe om toegang te krijgen tot de Mullvad-API. + De app moet communiceren met een Mullvad-API-server om u aan te melden, serverlijsten op te halen en andere belangrijke handelingen uit te voeren. + Op sommige netwerken, waar verschillende soorten censuur worden gebruikt, zijn de API-servers mogelijk niet direct bereikbaar. + Met deze functie kunt u die censuur omzeilen door aangepaste manieren toe te voegen om toegang te krijgen tot de API via proxy\'s en dergelijke methoden. + API bereikbaar + API onbereikbaar Appversie Toevoegen Kan account niet verifiëren. Stuur een probleemrapport. + Authenticatie Automatisch verbinden Automatisch verbinden en lockdownmodus Zorg dat het apparaat altijd de VPN-tunnel gebruikt. @@ -50,6 +59,7 @@ Meer krediet kopen Annuleren Wijzigingen in deze versie: + Versleuteling De lokale DNS-server werkt niet tenzij u \"Lokale netwerken delen\" inschakelt onder Voorkeuren. U staat op het punt om het probleemrapport te verzenden zonder een contactmethode op te geven. Voer een e-mailadres in als u een antwoord wenst op het rapport. Ja, apparaat afmelden @@ -108,6 +118,7 @@ Lijsten bewerken Locaties bewerken Bericht bewerken + Methode bewerken Naam bewerken Inschakelen Aangepaste DNS-server gebruiken @@ -173,6 +184,7 @@ Te veel apparaten Mullvad-accountnummer Alleen in eigendom van Multivad + Naam Naam is gewijzigd in %1$s Welkom, dit apparaat heet nu <b>%1$s</b>. Zie voor meer informatie de infoknop in Account. NIEUW APPARAAT GEMAAKT @@ -196,6 +208,7 @@ In eigendom Eigendom Betaald tot + Wachtwoord Patch komt niet overeen met specificatie Om de app te gebruiken, moet u eerst tijd toevoegen aan uw account. We kunnen het betalingsproces niet starten. Controleer of u de nieuwste versie van Google Play hebt. @@ -242,6 +255,7 @@ Verzonden Indien nodig nemen we u contact op via %1$s Bedankt! + Server Overschrijving van server-IP-adressen Overschrijvingen actief Nieuwe overschrijvingen importeren vanuit @@ -259,6 +273,7 @@ Kan firewallregels niet toepassen. Los problemen op of stuur een probleemmelding. Instellingen Account + API-toegang Wijzigingen in DNS-gerelateerde instellingen worden mogelijk niet onmiddellijk van kracht vanwege gecachete resultaten. DNS-instellingen worden mogelijk niet onmiddellijk van kracht Patch toepassen mislukt @@ -277,12 +292,15 @@ Verzenden Locatie wijzigen TCP + Testen... Druk op de \"︙\" of druk lang op een land, plaats of server om locaties toe te voegen. Druk op de \"︙\" om een aangepaste lijst te maken VPN in-/uitschakelen Apparaatnaam: %1$s Resterende tijd: %1$s + Transportprotocol Probeer het opnieuw + Type UDP Met welke TCP-poort moet het UDP-over-TCP-obfuscatieprotocol verbinding maken op de VPN-server. UDP-over-TCP-poort @@ -299,6 +317,7 @@ Lijstnaam bijwerken Uw e-mailadres (optioneel) Om u beter te kunnen helpen, kunt u in het Engels of Zweeds schrijven. Vermeld uit welk land u komt. + Gebruikersnaam Voucher verifiëren … Applogboeken weergeven Fout virtuele adapter diff --git a/android/lib/resource/src/main/res/values-pl/strings.xml b/android/lib/resource/src/main/res/values-pl/strings.xml index 0487ba66fc67..ea5245885a4c 100644 --- a/android/lib/resource/src/main/res/values-pl/strings.xml +++ b/android/lib/resource/src/main/res/values-pl/strings.xml @@ -7,12 +7,14 @@ Numer konta Pokazuje przypomnienia, gdy kończy się czas na koncie Przypomnienia o czasie na koncie + Dodaj Dodaj 30 dni Dodaj 30 dni (%1$s) Dodaj serwer Dodaj serwer DNS Dodaj lokalizację %1$s do listy Dodaj lokalizacje + Dodaj metodę Doładuj w naszej witrynie internetowej lub zrealizuj kupon. Do konta dodano %1$s. Zaakceptuj i kontynuuj @@ -23,9 +25,16 @@ Nie można uruchomić połączenia tunelowego. Przed rozpoczęciem użytkowania usługi Mullvad VPN wyłącz opcję „Zawsze włączony VPN” w <b>%1$s</b>. Opcja „Zawsze włączony VPN” przypisana jest do innej aplikacji Dowolny + Zarządzaj i dodawaj niestandardowe metody dostępu do interfejsu API Mullvad. + Aplikacja musi komunikować się z serwerem API Mullvad, aby można było się zalogować, pobrać listy serwerów i wykonywać inne krytyczne operacje. + W niektórych sieciach, w których stosowane są różne rodzaje cenzury, serwery API mogą nie być bezpośrednio dostępne. + Ta funkcja pozwala ominąć tę cenzurę, dodając niestandardowe sposoby dostępu do API za pośrednictwem serwerów proxy i podobnych metod. + API osiągalny + API nieosiągalny Wersja aplikacji Zastosuj Nie można uwierzytelnić konta. Wyślij zgłoszenie problemu. + Uwierzytelnianie Automatyczne łączenie Automatyczne łączenie i tryb Lockdown Zapewnia, że urządzenie zawsze działa za pośrednictwem tunelu VPN. @@ -50,6 +59,7 @@ Doładuj konto Anuluj Zmiany w tej wersji: + Szyfrowanie Lokalny serwer DNS nie będzie działał, dopóki nie włączysz opcji „Udostępnianie sieci lokalnej” w Preferencjach. Za chwilę wyślesz zgłoszenie problemu, nie umożliwiając nam skontaktowania się z Tobą. Aby uzyskać odpowiedź na zgłoszenie, musisz podać adres e-mail. Tak, wyloguj urządzenie @@ -108,6 +118,7 @@ Edytuj listy Edytuj lokalizacje Edytuj wiadomość + Edytuj metodę Edytuj nazwę Włącz Użyj niestandardowego serwera DNS @@ -173,6 +184,7 @@ Zbyt wiele urządzeń Numer konta Mullvad Wyłącznie firmy Mullvad + Nazwa Nazwę zmieniono na %1$s Witaj, to urządzenie nazywa się teraz <b>%1$s</b>. Więcej szczegółów znajdziesz, korzystając z przycisku Informacje na koncie. UTWORZONO NOWE URZĄDZENIE @@ -196,6 +208,7 @@ Posiadane Własność Płatne do + Hasło Poprawka niezgodna ze specyfikacją Aby rozpocząć korzystanie z aplikacji, musisz najpierw dodać czas do swojego konta. Nie mogliśmy rozpocząć procesu płatności. Upewnij się, że masz najnowszą wersję aplikacji Google Play. @@ -242,6 +255,7 @@ Wysłano W razie potrzeby skontaktujemy się z Tobą pod adresem %1$s Dziękujemy! + Serwer Zastąpienie adresu IP serwera Zastąpienia aktywne Importuj nowe zastąpienia z @@ -259,6 +273,7 @@ Nie można zastosować reguł zapory. Rozwiąż problem lub wyślij zgłoszenie problemu. Ustawienia Konto + Dostęp do API Zmiany w ustawieniach związanych z usługą DNS mogą nie zostać wprowadzone natychmiast ze względu na zbuforowane wyniki. Ustawienia usługi DNS mogą nie zostać zastosowane natychmiast Nie udało się zastosować poprawki @@ -277,12 +292,15 @@ Prześlij Zmień lokalizację TCP + Testowanie... Aby dodać lokalizacje do listy, naciśnij przycisk „︙” lub naciśnij i przytrzymaj kraj, miasto albo serwer. Aby utworzyć listę niestandardową, naciśnij przycisk „︙” Przełącz VPN Nazwa urządzenia: %1$s Pozostało: %1$s + Protokół transportowy Spróbuj ponownie + Typ UDP Port TCP, z którym powinien łączyć się protokół zaciemniania UDP-przez-TCP na serwerze VPN. Port UDP-przez-TCP @@ -299,6 +317,7 @@ Zaktualizuj nazwę listy Twój adres e-mail (opcjonalnie) Abyśmy mogli lepiej Ci pomóc, napisz w języku angielskim lub szwedzkim i podaj kraj, z którego się łączysz. + Nazwa użytkownika Weryfikowanie kuponu… Wyświetl dzienniki aplikacji Błąd wirtualnej karty sieciowej diff --git a/android/lib/resource/src/main/res/values-pt/strings.xml b/android/lib/resource/src/main/res/values-pt/strings.xml index 0f7dcc088dc5..6f3b875aa3f1 100644 --- a/android/lib/resource/src/main/res/values-pt/strings.xml +++ b/android/lib/resource/src/main/res/values-pt/strings.xml @@ -7,12 +7,14 @@ Número de conta Mostra lembretes quando o tempo da conta está prestes a expirar Lembretes de tempo da conta + Adicionar Adicionar 30 dias Adicionar 30 dias (%1$s) Adicionar um servidor Adicionar servidor DNS Adicionar %1$s à lista Adicionar localizações + Adicionar método Compre crédito no nosso sítio da web ou reclame um voucher. %1$s foi adicionado à sua conta. Concordar e continuar @@ -23,9 +25,16 @@ Não foi possível iniciar a ligação de túnel. Desative a VPN sempre ligada para <b>%1$s</b> antes de utilizar a Mullvad VPN. VPN sempre ligada atribuída a outra app Qualquer + Gerir e adicionar métodos personalizados para aceder à Mullvad API. + A app precisa de comunicar com um servidor da Mullvad API para iniciar a sua sessão, obter listas de servidores e outras operações críticas. + Em algumas redes, onde são utilizados vários tipos de censura, os servidores de API podem não ser diretamente alcançáveis. + Esta funcionalidade permite-lhe contornar essa censura, adicionando formas personalizadas de aceder à API através de proxies e métodos semelhantes. + API alcançável + API inalcançável Versão da app Aplicar Não foi possível autenticar a conta. Envie um relatório do problema. + Autenticação Ligação automática Ligação automática e modo de bloqueio Assegura que o dispositivo está sempre no túnel VPN. @@ -50,6 +59,7 @@ Comprar mais crédito Cancelar Alterações nesta versão: + Cifra O servidor DNS local não funcionará exceto se ativar \"Partilha de rede local\" em Preferências. Está prestes a enviar o relatório de problema sem que tenhamos uma forma de lhe responder. Se pretender uma resposta ao seu relatório, tem de introduzir um endereço de email. Sim, desligar o dispositivo @@ -108,6 +118,7 @@ Editar listas Editar localizações Editar mensagem + Editar método Editar nome Ativar Usar servidor DNS personalizado @@ -173,6 +184,7 @@ Demasiados dispositivos Número de conta Mullvad Apenas propriedade de Mullvad + Nome O nome foi alterado para %1$s Bem-vindo, este dispositivo é agora chamado <b>%1$s</b>. Para mais detalhes consulte o botão de informação na Conta. NOVO DISPOSITIVO CRIADO @@ -196,6 +208,7 @@ Propriedade de Propriedade Pago até + Palavra-passe A correção não corresponde à especificação Para começar a utilizar a aplicação, primeiro tem de adicionar tempo à sua conta. Não foi possível iniciar o processo de pagamento. Verifique se tem a versão mais recente do Google Play. @@ -242,6 +255,7 @@ Enviado Se necessário, iremos contactá-lo através de %1$s Obrigado! + Servidor Substituição de IP de servidor Substituições ativas Importar novas substituições através de @@ -259,6 +273,7 @@ Não foi possível aplicar as regras de firewall. Experimente a resolução de problemas ou envie um relatório do problema. Definições Conta + Acesso à API As alterações às definições relacionadas com o DNS podem não fazer efeito imediatamente devido aos resultados em cache. As definições de DNS podem não fazer efeito imediatamente Erro ao aplicar patch @@ -277,12 +292,15 @@ Enviar Alterar local TCP + A testar... Para adicionar localizações a uma lista, prima o botão \"︙\" ou mantenha premido um país, uma cidade ou um servidor. Para criar uma lista personalizada prima o botão \"︙\" Alternar VPN Nome do dispositivo: %1$s Tempo restante: %1$s + Protocolo de transporte Tentar novamente + Tipo UDP A que porta TCP o protocolo de ofuscação UDP sobre TCP deve ligar-se no servidor VPN. Porta UDP sobre TCP @@ -299,6 +317,7 @@ Atualizar nome da lista O seu email (opcional) Para o ajudar melhor, escreva em inglês ou sueco e indique o país de onde está a efetuar a ligação. + Nome de utilizador A verificar voucher… Ver os registos da app Erro de adaptador virtual diff --git a/android/lib/resource/src/main/res/values-ru/strings.xml b/android/lib/resource/src/main/res/values-ru/strings.xml index 024787c97b21..80ec4188aea6 100644 --- a/android/lib/resource/src/main/res/values-ru/strings.xml +++ b/android/lib/resource/src/main/res/values-ru/strings.xml @@ -7,12 +7,14 @@ Номер учетной записи Показывает уведомления, когда время на учетной записи скоро закончится Напоминания о времени на учетной записи + Добавить Добавить 30 дней Добавить 30 дней (%1$s) Добавить сервер Добавить DNS-сервер Добавление местоположения %1$s к списку Добавление местоположений + Добавление метода Пополните баланс у нас на сайте или погасите ваучер. На учетную запись добавлено время: %1$s. Согласиться и продолжить @@ -23,9 +25,16 @@ Не удалось запустить туннельное подключение. Перед использованием Mullvad VPN отключите опцию «Постоянная VPN» для приложения <b>%1$s</b>. Опция «Постоянная VPN» назначена другому приложению Все + Добавление пользовательских методов для доступа к API Mullvad и управление ими. + Приложение должно взаимодействовать с сервером API Mullvad для входа в учетную запись, получения списков серверов и других важных операций. + В некоторых сетях, где используются различные виды цензуры, серверы API могут быть недоступны напрямую. + Эта функция позволяет обойти такую цензуру, добавив пользовательские способы доступа к API через прокси-серверы и другие подобные методы. + Есть доступ к API + Нет доступа к API Версия приложения Применить Не удается произвести аутентификацию учетной записи. Отправьте сообщение о проблеме. + Аутентификация Автоподключение Автоподключение и режим блокировки Обеспечивает постоянное подключение устройства к туннелю VPN. @@ -50,6 +59,7 @@ Пополнить баланс Отмена Изменения в этой версии: + Шифр Локальный DNS-сервер не будет работать, пока вы не включите «Обмен данными в локальной сети» в разделе «Параметры». Вы собираетесь отправить отчет о проблеме, не оставив контакты. Если вы хотите получить ответ, введите свой адрес электронной почты. Выйти из профиля на устройстве @@ -108,6 +118,7 @@ Изменить списки Изменить местоположения Изменить сообщение + Изменение метода Изменить имя Включить Пользовательский DNS-сервер @@ -173,6 +184,7 @@ Слишком много устройств Номер учетной записи Mullvad Только принадлежащие Mullvad + Имя Имя изменено на «%1$s» Добро пожаловать, теперь это устройство называется <b>%1$s</b>. Для получения более подробной нажмите на кнопку «Информация» в учетной записи. СОЗДАНО НОВОЕ УСТРОЙСТВО @@ -196,6 +208,7 @@ Во владении Собственность Оплачено до + Пароль Патч не соответствует спецификации Чтобы пользоваться приложением, нужно добавить время на учетную запись. Не удалось начать процесс оплаты — убедитесь, что у вас установлена последняя версия Google Play. @@ -242,6 +255,7 @@ Отправлено При необходимости мы свяжемся с вами по адресу %1$s Спасибо! + Сервер Переопределение IP-адреса сервера Переопределения активны Импорт новых переопределений: @@ -259,6 +273,7 @@ Невозможно применить правила брандмауэра. Устраните неполадки или отправьте сообщение о проблеме. Настройки Учетная запись + Доступ к API Изменения в настройках, связанные с DNS, могут не сразу вступить в силу из-за кешированных результатов. Настройки DNS могут не сразу вступить в силу Не удалось применить патч @@ -277,12 +292,15 @@ Отправить Сменить местоположение TCP + Проверка... Чтобы добавить местоположения в список, нажмите «︙» или нажмите и удерживайте страну, город или сервер. Чтобы создать свой список, нажмите «︙» Включение VPN Имя устройства: %1$s Осталось времени: %1$s + Транспортный протокол Повторить попытку + Тип UDP TCP-порт, к которому должен подключаться протокол обфускации UDP через TCP на VPN-сервере. Порт для UDP через TCP @@ -299,6 +317,7 @@ Обновление названия списка Ваша электронная почта (необязательно) Чтобы мы могли быстрее решить проблему, напишите нам на английском или шведском и укажите, из какой страны вы подключаетесь. + Имя пользователя Идет проверка ваучера… Открыть журналы Ошибка виртуального адаптера diff --git a/android/lib/resource/src/main/res/values-sv/strings.xml b/android/lib/resource/src/main/res/values-sv/strings.xml index 79c04dfca798..a6bb06479f99 100644 --- a/android/lib/resource/src/main/res/values-sv/strings.xml +++ b/android/lib/resource/src/main/res/values-sv/strings.xml @@ -7,12 +7,14 @@ Kontonummer Visar påminnelser när kontots tidsgräns uppnås Påminnelser om kontotid + Lägg till Lägg till 30 dagar Lägg till 30 dagar (%1$s) Lägg till en server Lägg till DNS-server Lägg till %1$s i listan Lägg till platser + Lägg till metod Du kan antingen köpa kredit på vår webbplats eller lösa in en kupong. %1$s har lagts till på ditt konto. Godkänn och fortsätt @@ -23,9 +25,16 @@ Det går inte att starta tunnelanslutning. Aktivera VPN som alltid är på för <b>%1$s</b> innan du använder Mullvad VPN. VPN som alltid är på har tilldelats till annan app Valfri + Hantera och lägg till anpassade metoder för att komma åt Mullvad API. + Appen måste kommunicera med en Mullvad API-server för att logga in dig, hämta serverlistor och andra viktiga åtgärder. + Det kanske inte går att nå API-servrarna direkt på nätverk som använder olika censureringstyper. + Men den här funktionen kan du kringgå censureringen genom att lägga till anpassade sätt att komma åt API:n via proxyservrar och liknande metoder. + API kan nås + API kan inte nås Appversion Använd Det går inte att autentisera kontot. Skicka en problemrapport. + Autentisering Anslut automatiskt Anslut automatiskt och Låsningsläge Ser till att enheten alltid är i VPN-tunneln. @@ -50,6 +59,7 @@ Köp mer kredit Avbryt Ändringar i den här versionen: + Chiffrering Den lokala DNS-servern fungerar inte om du inte aktiverar \"Lokal nätverksdelning\" under Inställningar. Du är på väg att skicka problemrapporten utan att vi har möjlighet att besvara dig. Om du vill ha svar på din rapport måste du ange en e-postadress. Ja, logga ut enheten @@ -108,6 +118,7 @@ Redigera listor Redigera platser Redigera meddelande + Redigera metod Redigera namn Aktivera Använd anpassad DNS-server @@ -173,6 +184,7 @@ För många enheter Mullvad-kontonummer Endast Mullvad-ägd + Namn Namnet har ändrats till %1$s Välkommen! Den här enheten heter nu <b>%1$s</b>. Använd informationsknappen i Konto för mer information. NY ENHET HAR SKAPATS @@ -196,6 +208,7 @@ Ägd Ägarskap Betalat till + Lösenord Konfigurationsfilen överensstämmer inte med specifikationen Om du vill börja använda appen måste du först lägga till tid i ditt konto. Vi kunde inte starta betalningsprocessen. Se till att du har den senaste versionen av Google Play. @@ -242,6 +255,7 @@ Skickat Om det behövs kontaktar vi dig på %1$s Tack! + Server Åsidosättning av server-IP Åsidosättningar aktiva Importera nya åsidosättningar med @@ -259,6 +273,7 @@ Det går inte att tillämpa brandväggsregler. Felsök eller skicka en problemrapport. Inställningar Konto + API-åtkomst Ändringar i DNS-relaterade inställningar kanske inte börjar gälla direkt på grund av cachade resultat. DNS-inställningarna kanske inte börjar gälla direkt Konfigurationsfilen kunde inte tillämpas @@ -277,12 +292,15 @@ Skicka Växla plats TCP + Testar ... Om du vill lägga till platser i en lista kan du trycka på \"︙\" eller trycka länge på ett land, en stad eller en server. Om du vill skapa en anpassad lista trycker du på \"︙\" Växla VPN Enhetsnamn: %1$s Tid kvar: %1$s + Transportprotokoll Försök igen + Typ UDP Vilken TCP-port som UDP-över-TCP-obfuskeringsprotokoll bör ansluta till på VPN-servern. UDP-över-TCP-port @@ -299,6 +317,7 @@ Uppdatera listnamn Din e-postadress (valfritt) Skriv på engelska eller svenska och ange från vilket land du är ansluten så att vi kan hjälpa dig bättre. + Användarnamn Verifierar kupong … Visa appens loggar Fel med virtuell adapter diff --git a/android/lib/resource/src/main/res/values-th/strings.xml b/android/lib/resource/src/main/res/values-th/strings.xml index ec22c1cfbf82..4354c4741bfd 100644 --- a/android/lib/resource/src/main/res/values-th/strings.xml +++ b/android/lib/resource/src/main/res/values-th/strings.xml @@ -7,12 +7,14 @@ หมายเลขบัญชี แสดงการแจ้งเตือน ในขณะที่เวลาบัญชีใกล้หมดอายุ การแจ้งเตือนเวลาบัญชี + เพิ่ม เพิ่มเวลา 30 วัน เพิ่มเวลา 30 วัน (%1$s) เพิ่มเซิร์ฟเวอร์ เพิ่มเซิร์ฟเวอร์ DNS เพิ่ม %1$s ลงในรายการ เพิ่มตำแหน่งที่ตั้ง + เพิ่มวิธีการ ซื้อเครดิตบนเว็บไซต์ของเรา หรือแลกรับบัตรกำนัล %1$s ถูกเพิ่มลงในบัญชีของคุณแล้ว ยอมรับและดำเนินการต่อ @@ -23,9 +25,16 @@ ไม่สามารถเริ่มการเชื่อมต่ออุโมงค์ได้ โปรดปิดใช้งาน Always-on VPN เป็นเวลา <b>%1$s</b> ก่อนที่จะใช้งาน Mullvad VPN Always-on VPN ได้รับการมอบหมายไปยังแอปอื่นแล้ว อะไรก็ได้ + จัดการและเพิ่มวิธีแบบกำหนดเอง เพื่อเข้าถึง Mullvad API + แอปจำเป็นต้องสื่อสารกับเซิร์ฟเวอร์ Mullvad API เพื่อนำคุณเข้าสู่ระบบ ดึงข้อมูลรายการเซิร์ฟเวอร์ และการดำเนินการที่สำคัญอื่นๆ + คุณอาจไม่สามารถเข้าถึงเซิร์ฟเวอร์ API ได้โดยตรง ในบางเครือข่ายที่มีการใช้งานเซ็นเซอร์หลายประเภท + คุณลักษณะนี้ช่วยให้คุณหลีกเลี่ยงการเซ็นเซอร์ โดยการเพิ่มวิธีที่กำหนดเองในการเข้าถึง API ผ่านพร็อกซี่ และวิธีการที่คล้ายคลึงกัน + เข้าถึง API ได้ + ไม่สามารถเข้าถึง API ได้ เวอร์ชันแอป ใช้ ไม่สามารถตรวจสอบความถูกต้องของบัญชีได้ โปรดส่งรายงานปัญหา + การรับรองความถูกต้อง เชื่อมต่ออัตโนมัติ เชื่อมต่ออัตโนมัติและโหมดล็อกดาวน์ ตรวจสอบให้แน่ใจว่า อุปกรณ์อยู่ในช่องทาง VPN เสมอ @@ -50,6 +59,7 @@ ซื้อเครดิตเพิ่ม ยกเลิก การเปลี่ยนแปลงในเวอร์ชันนี้: + เข้ารหัส เซิร์ฟเวอร์ DNS ท้องถิ่นจะไม่ทำงาน เว้นแต่คุณจะเปิดใช้ \"การแชร์ในเครือข่ายท้องถิ่น\" ซึ่งอยู่ในส่วนการกำหนดค่า คุณกำลังจะส่งรายงานปัญหา โดยไม่มีการระบุวิธีการติดต่อกลับให้กับเรา และคุณจำเป็นต้องป้อนที่อยู่อีเมลของคุณ หากคุณอยากให้เราตอบกลับการรายงานของคุณ ใช่ นำอุปกรณ์ออกจากระบบ @@ -108,6 +118,7 @@ แก้ไขรายการ แก้ไขตำแหน่งที่ตั้ง แก้ไขข้อความ + แก้ไขวิธี แก้ไขชื่อ เปิดใช้งาน ใช้เซิร์ฟเวอร์ DNS แบบกำหนดเอง @@ -173,6 +184,7 @@ มีอุปกรณ์มากเกินไป หมายเลขบัญชี Mullvad ของ Mullvad เท่านั้น + ชื่อ ชื่อถูกเปลี่ยนเป็น %1$s ยินดีต้อนรับ ขณะนี้อุปกรณ์นี้จะมีชื่อว่า <b>%1$s</b> สำหรับข้อมูลเพิ่มเติม โปรดกดปุ่มข้อมูลในบัญชี สร้างอุปกรณ์ใหม่แล้ว @@ -196,6 +208,7 @@ เป็นเจ้าของ ความเป็นเจ้าของ ชำระเงินแล้วจนถึง + รหัสผ่าน แพตช์ไม่ตรงกับข้อมูลจำเพาะ คุณจำเป็นต้องเพิ่มเวลาไปยังบัญชีของคุณก่อน เพื่อที่จะเริ่มใช้งานแอป เราไม่สามารถเริ่มกระบวนการชำระเงินได้ โปรดตรวจสอบให้แน่ใจว่า คุณมี Google Play เวอร์ชันล่าสุด @@ -242,6 +255,7 @@ ส่ง เราจะติดต่อคุณไปทาง %1$s ในกรณีจำเป็น ขอบคุณ! + เซิร์ฟเวอร์ โอเวอร์ไรด์ IP เซิร์ฟเวอร์ เปิดใช้โอเวอร์ไรด์อยู่ นำเข้าโอเวอร์ไรด์ใหม่โดยใช้ @@ -259,6 +273,7 @@ ไม่สามารถใช้กฎไฟร์วอลล์ได้ โปรดแก้ไขปัญหา หรือส่งรายงานปัญหา การตั้งค่า บัญชี + การเข้าถึง API การเปลี่ยนแปลงการตั้งค่าที่เกี่ยวข้องกับ DNS อาจไม่มีผลทันที เนื่องจากผลลัพธ์ที่แคชไว้ การตั้งค่า DNS อาจไม่มีผลทันที ล้มเหลวในการปรับใช้แพตช์ @@ -277,12 +292,15 @@ ส่ง สลับตำแหน่ง TCP + กำลังทดสอบ... กด \"︙\" หรือกดประเทศ เมือง หรือเซิร์ฟเวอร์ค้างไว้ เพื่อเพิ่มตำแหน่งที่ตั้งลงในรายการ กด \"︙\" เพื่อสร้างรายการแบบกำหนดเอง เปิด/ปิด VPN ชื่ออุปกรณ์: %1$s เหลือเวลา: %1$s + โพรโทคอลการส่งข้อมูล ลองอีกครั้ง + ประเภท UDP พอร์ต TCP ใดที่โพรโทคอลการทำให้ข้อมูลยุ่งเหยิง UDP-ผ่าน-TCP ควรเชื่อมต่อบนเซิร์ฟเวอร์ VPN พอร์ต UDP-ผ่าน-TCP @@ -299,6 +317,7 @@ อัปเดตชื่อรายการ อีเมลของคุณ (ไม่บังคับ) โปรดเขียนเป็นภาษาอังกฤษหรือสวีเดน พร้อมระบุประเทศต้นทางที่คุณเชื่อมต่อ เพื่อให้รับความช่วยเหลือได้ดียิ่งขึ้น + ชื่อผู้ใช้ กำลังตรวจสอบยืนยันบัตรกำนัล… ดูบันทึกของแอป ข้อผิดพลาดของอะแดปเตอร์เสมือน diff --git a/android/lib/resource/src/main/res/values-tr/strings.xml b/android/lib/resource/src/main/res/values-tr/strings.xml index 17e710c06559..9b13f88fe2d9 100644 --- a/android/lib/resource/src/main/res/values-tr/strings.xml +++ b/android/lib/resource/src/main/res/values-tr/strings.xml @@ -7,12 +7,14 @@ Hesap Kimliği Hesap süresinin dolmak üzere olduğunu bildiren hatırlatıcıları gösterir Hesap süresi hatırlatıcıları + Ekle 30 gün süre ekleyin 30 gün süre ekleyin (%1$s) Sunucu ekle DNS sunucusu ekle %1$s konumunu listeye ekle Konum ekle + Yöntem ekle Web sitemizden kredi satın alın veya kupon kullanın. %1$s, hesabınıza eklendi. Kabul et ve devam et @@ -23,9 +25,16 @@ Tünel bağlantısı başlatılamıyor. Mullvad VPN\'i kullanmadan önce lütfen Her zaman açık VPN\'i <b>%1$s</b> için devre dışı bırakın. Her zaman açık VPN başka bir uygulamaya atandı Tümü + Mullvad API\'sine erişim için özel yöntemler ekleyip yönetin. + Uygulamanın oturumunuzu açmak, sunucu listelerini almak ve diğer kritik işlemleri yapmak için bir Mullvad API sunucusuyla iletişim kurması gerekir. + Çeşitli sansür türlerinin kullanıldığı bazı ağlarda API sunucularına doğrudan erişilemeyebilir. + Bu özellik, proxy\'ler ve benzer yöntemler aracılığıyla API\'ye erişmenin özel yollarını ekleyerek bu sansürü aşmanıza imkan tanır. + Erişilebilir API + Erişilemez API Uygulama sürümü Uygula Hesap doğrulanamıyor. Lütfen bir hata raporu gönderin. + Yetkilendirme Otomatik Bağlan Otomatik bağlantı ve Kilitleme modu Cihazın her zaman VPN tünelinde olduğundan emin olun. @@ -50,6 +59,7 @@ Daha fazla kredi satın alın İptal et Bu sürümdeki değişiklikler: + Şifre Tercihler sekmesinin altındaki \"Yerel Ağ Paylaşımı\" seçeneğini etkinleştirmediğiniz sürece yerel DNS sunucusu çalışmaz. Sorun raporunu, size geri dönüş yapmamıza imkan vermeyen bir şekilde göndermek üzeresiniz. Sorununuz için yanıt almak istiyorsanız bir e-posta adresi girmelisiniz. Evet, cihazdan çıkış yap @@ -108,6 +118,7 @@ Listeleri düzenle Konumları düzenle Mesajı düzenle + Yöntemi düzenle Adı düzenle Etkinleştir Özel DNS sunucusu kullanın @@ -173,6 +184,7 @@ Cihaz sayısı çok fazla Mullvad hesap numarası Sadece Mullvad\'a ait olanlar + Ad Ad, %1$s olarak değiştirildi Hoş geldiniz, bu cihazın adı artık <b>%1$s</b>. Daha fazla ayrıntı için Hesap içinden bilgi düğmesine bakın. YENİ CİHAZ OLUŞTURULDU @@ -196,6 +208,7 @@ Tarafımıza ait olanlar Sahiplik durumu Şu tarihe kadar ödendi: + Parola Yama spesifikasyonla eşleşmiyor Uygulamayı kullanmaya başlamak için önce hesabınıza süre eklemeniz gerekir. Ödeme işlemini başlatamadık. Lütfen Google Play\'in en son sürümüne sahip olduğunuzdan emin olun. @@ -242,6 +255,7 @@ Gönderildi Gerektiğinde sizinle %1$s adresinden iletişime geçeceğiz Teşekkürler! + Sunucu Sunucu IP\'sini geçersiz kılma Geçersiz kılmalar etkin Şuradaki yeni geçersiz kılmaları içe aktar @@ -259,6 +273,7 @@ Güvenlik duvarı kuralları uygulanamıyor. Lütfen sorunu çözmeye çalışın veya bir hata raporu gönderin. Ayarlar Hesap + API erişimi DNS ile ilgili ayarlarda yapılan değişiklikler, önbelleğe alınan sonuçlar nedeniyle hemen etkili olmayabilir. DNS ayarları hemen etkili olmayabilir Yama uygulanamadı @@ -277,12 +292,15 @@ Gönder Konum değiştir TCP + Test ediliyor... Listeye konum eklemek için \"︙\" düğmesine basın veya bir ülke, şehir veya sunucunun üzerine uzun basın. Özel bir liste oluşturmak için \"︙\" düğmesine basın VPN\'i aç/kapat Cihaz adı: %1$s Kalan süre: %1$s + Taşıma protokolü Tekrar dene + Tür UDP TCP üzerinden UDP gizleme protokolünün VPN sunucusunda hangi TCP portuna bağlanması gerekiyor. TCP üzerinden UDP portu @@ -299,6 +317,7 @@ Liste adını güncelle E-posta adresiniz (isteğe bağlı) Size daha iyi yardımcı olabilmemiz için lütfen mesajınızı İngilizce veya İsveççe olarak yazın ve hangi ülkeden bağlandığınızı belirtin. + Kullanıcı adı Kupon doğrulanıyor… Uygulama kayıtlarını görüntüle Sanal adaptör hatası diff --git a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml index 90511640ac69..2dc01778301f 100644 --- a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml +++ b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml @@ -7,12 +7,14 @@ 帐号 在帐户时间即将到期时显示提醒 帐户时间提醒 + 添加 增加 30 天 增加 30 天 (%1$s) 添加服务器 添加 DNS 服务器 将%1$s添加到列表中 添加位置 + 添加方法 在我们的网站上购买额度或兑换优惠券。 %1$s已添加到您的帐户中。 同意并继续 @@ -23,9 +25,16 @@ 无法启动隧道连接。在使用 Mullvad VPN 之前,请为 <b>%1$s</b> 禁用“始终开启的 VPN”。 “始终开启的 VPN”已分配给其他应用 任何 + 管理和添加访问 Mulvad API 的自定义方法。 + 该应用需要与 Mulvad API 服务器通信,以便您登录、获取服务器列表和执行其他关键操作。 + 在某些使用各类审查的网络上,可能无法直接访问 API 服务器。 + 此功能允许您通过代理和类似方法添加访问 API 的自定义方式,从而规避审查。 + 可通过 API 访问 + 无法通过 API 访问 应用版本 应用 无法验证帐户。请发送问题报告。 + 身份验证 自动连接 自动连接和锁定模式 确保设备始终位于 VPN 隧道上。 @@ -50,6 +59,7 @@ 购买更多额度 取消 此版本中的变更: + 加密方式 除非您在“偏好设置”下启用“本地网络共享”,否则本地 DNS 服务器将不会运行。 您即将发送问题报告,但没有提供让我们可以联系到您的方式。如果您希望获得回复,必须输入您的电子邮件地址。 是,退出设备 @@ -108,6 +118,7 @@ 编辑列表 编辑位置 编辑消息 + 编辑方法 编辑名称 启用 使用自定义 DNS 服务器 @@ -173,6 +184,7 @@ 设备过多 Mullvad 帐号 仅 Mullvad 自有 + 名称 名称已更改为“%1$s” 欢迎,此设备现在名为 <b>%1$s</b>。有关详情,请点击“帐户”中的信息按钮。 已创建新设备 @@ -196,6 +208,7 @@ 自有 所有权 到期时间 + 密码 补丁与规范不匹配 要开始使用本应用,您首先需要向帐户中充入时间。 我们无法启动付款流程,请确保拥有最新版本的 Google Play。 @@ -242,6 +255,7 @@ 已发送 如果需要,我们将通过 %1$s 与您联系 谢谢! + 服务器 服务器 IP 覆盖 覆盖设置已激活 导入新覆盖设置的方式 @@ -259,6 +273,7 @@ 无法应用防火墙规则。请排查问题或发送问题报告。 设置 帐户 + API 访问 由于缓存结果,对 DNS 相关设置的更改可能不会立即生效。 DNS 设置可能不会立即生效 无法应用补丁 @@ -277,12 +292,15 @@ 提交 切换位置 TCP + 正在测试… 要将位置添加到列表中,请按“︙”或长按国家/地区、城市或服务器。 要创建自定义列表,请按“︙” 切换 VPN 设备名称:%1$s 剩余时间:%1$s + 传输协议 重试 + 类型 UDP UDP-over-TCP 混淆协议应连接到 VPN 服务器上的哪个 TCP 端口。 UDP-over-TCP 端口 @@ -299,6 +317,7 @@ 更新列表名称 您的电子邮件(可选) 为了更好地帮助您,请用英语或瑞典语书写,并包含您连接时所在的国家/地区。 + 用户名 正在验证优惠券… 查看应用日志 虚拟适配器错误 diff --git a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml index 0c06f471145b..fed5f612d849 100644 --- a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml +++ b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml @@ -7,12 +7,14 @@ 帳戶編號 在帳戶時間即將到期時顯示提醒 帳戶時間提醒 + 新增 增加 30 天時間 增加 30 天時間 (%1$s) 新增伺服器 新增 DNS 伺服器 將 %1$s 新增至清單 新增位置 + 新增方式 在我們網站上購買點數或兌換憑證。 %1$s 已新增至您的帳戶。 同意並繼續 @@ -23,9 +25,16 @@ 無法啟動通道連線。在使用 Mullvad VPN 之前,請先為 <b>%1$s</b> 停用「始終啟用 VPN」。 「始終啟用 VPN」已指派給其他應用程式 任何 + 管理並新增自訂方式以存取 Mullvad API。 + 該應用程式需要與 Mulvad API 伺服器通訊,以便您登入、取得伺服器清單並執行其他重要作業。 + 在某些採用了各類審查制度的網路上,可能無法直接存取 API 伺服器。 + 此功能允許您透過代理伺服器和類似方式來新增存取 API 的自訂方式,從而規避審查。 + 可透過 API 存取 + 無法透過 API 存取 應用程式版本 套用 無法驗證帳戶。請傳送問題回報。 + 驗證 自動連線 自動連線和鎖定模式 請確認裝置始終位於 VPN 通道上。 @@ -50,6 +59,7 @@ 購買更多點數 取消 此版本中的變更: + 加密方式 若要使本機 DNS 伺服器運作,需先在「偏好設定」下啟用「本機網路共用」。 您即將傳送的問題報告未包含回覆方式資訊。如果想收到您這份報告的回覆,請輸入您的電子郵件位址。 是,將裝置登出 @@ -108,6 +118,7 @@ 編輯清單 編輯位置 編輯訊息 + 編輯方式 編輯名稱 啟用 使用自訂 DNS 伺服器 @@ -173,6 +184,7 @@ 裝置過多 Mullvad 帳號 僅 Mullvad 自有 + 名稱 名稱已變更為「%1$s」 歡迎,此裝置現在稱為 <b>%1$s</b>。如需詳細資訊,請點按「帳戶」中的資訊按鈕。 已建立新裝置 @@ -196,6 +208,7 @@ 自有 所有權狀態 支付至 + 密碼 修補檔與規範不相符 需先在帳戶中加時,才能開始使用本應用程式。 我們無法啟動付款流程,請確認您是否擁有最新版本的 Google Play。 @@ -242,6 +255,7 @@ 已傳送 如有需要,我們將透過 %1$s 與您聯絡 謝謝! + 伺服器 伺服器 IP 覆寫 覆寫設定已啟用 匯入新覆寫設定的方式 @@ -259,6 +273,7 @@ 無法套用防火牆規則。請排除故障或傳送問題回報。 設定 帳戶 + API 存取 由於快取的結果,對 DNS 相關設定所做的變更可能不會立即生效。 DNS 設定可能不會立即生效 無法套用修補檔 @@ -277,12 +292,15 @@ 提交 切換位置 TCP + 正在測試… 若要在清單中新增位置,請按下「︙」,或是長按下國家/地區、城市或伺服器。 若要建立自訂清單,請按下「︙」 切換 VPN 裝置名稱:%1$s 剩餘時間:%1$s + 傳輸通訊協定 再試一次 + 類型 UDP UDP-over-TCP 混淆通訊協定應連線到 VPN 伺服器上的哪個 TCP 連接埠。 UDP-over-TCP 連接埠 @@ -299,6 +317,7 @@ 更新清單名稱 您的電子郵件 (選填) 為了給您提供更完善的協助,請您使用英語或瑞典語書寫,並註明您是從哪個國家/地區進行連線。 + 使用者名稱 正在驗證優惠券… 檢視應用程式日誌 虛擬配接器錯誤 diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index 72b256b1ccfe..f7fafc72ffcb 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -346,4 +346,43 @@ Import successful, overrides active Overrides cleared Unsecured (No VPN permission) + API access + Add + Manage and add custom methods to access the Mullvad API. + Current: %s + The app needs to communicate with a Mullvad API server to log you in, fetch server lists, and other critical operations. + On some networks, where various types of censorship are being used, the API servers might not be directly reachable. + This feature allows you to circumvent that censorship by adding custom ways to access the API via proxies and similar methods. + The \"Current\" method represent which method the app is using to reach the API. + Edit method + Add method + Name + This field is required + Type + Server + Please enter a valid IPv4 or IPv6 address + Please enter a valid remote server port + Password (optional) + Cipher + Authentication + Username + Password + Transport protocol + Test method + API reachable + API unreachable + Testing %s... + Testing... + Verifying API method... + API reachable, adding method... + API unreachable, save method anyway? + Adding method... + Enable method + Use method + Delete method + At least one method needs to be enabled + This is already set as current + Delete method? + Failed to set to current - API not reachable + Failed to set to current - Unknown reason diff --git a/android/lib/resource/src/main/res/values/strings_non_translatable.xml b/android/lib/resource/src/main/res/values/strings_non_translatable.xml index 0b29d112b260..110e112e99f3 100644 --- a/android/lib/resource/src/main/res/values/strings_non_translatable.xml +++ b/android/lib/resource/src/main/res/values/strings_non_translatable.xml @@ -9,6 +9,8 @@ https://mullvad.net/l/android-lockdown Split tunneling WireGuard + SOCKS5 + Shadowsocks
  • 10.0.0.0/8
  • 172.16.0.0/12
  • 192.168.0.0/16
  • 169.254.0.0/16
  • fe80::/10
  • fc00::/7
  • ]]>
    diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt index 01959b7934bb..343e41dc1a0f 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt @@ -2,6 +2,8 @@ package net.mullvad.mullvadvpn.lib.theme.color import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.MenuItemColors import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color @@ -33,3 +35,12 @@ val ColorScheme.onVariant: Color val ColorScheme.selected: Color @Composable get() = MaterialTheme.colorScheme.surface + +val menuItemColors: MenuItemColors + @Composable + get() = + MenuDefaults.itemColors() + .copy( + leadingIconColor = MaterialTheme.colorScheme.onSurface, + textColor = MaterialTheme.colorScheme.onSurface, + ) diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt index 2763033a306d..ef3564951f8f 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt @@ -42,6 +42,7 @@ data class Dimensions( val dropdownMenuBorder: Dp = 1.dp, val expandableCellChevronSize: Dp = 30.dp, val filterTittlePadding: Dp = 4.dp, + val formTextFieldMinHeight: Dp = 72.dp, val iconFailSuccessTopMargin: Dp = 30.dp, val iconHeight: Dp = 44.dp, val indentedCellStartPadding: Dp = 38.dp, diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index c6c8a15f218e..664a1951e0a9 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -2126,6 +2126,12 @@ msgstr "" msgid "30 days was added to your account." msgstr "" +msgid "API reachable, adding method..." +msgstr "" + +msgid "API unreachable, save method anyway?" +msgstr "" + msgid "Account credit expires in a few minutes" msgstr "" @@ -2150,6 +2156,9 @@ msgstr "" msgid "Add locations" msgstr "" +msgid "Adding method..." +msgstr "" + msgid "Agree and continue" msgstr "" @@ -2168,6 +2177,9 @@ msgstr "" msgid "Always-on VPN might be enabled for another app" msgstr "" +msgid "At least one method needs to be enabled" +msgstr "" + msgid "Attention: Split tunneling is a privacy risk." msgstr "" @@ -2216,6 +2228,9 @@ msgstr "" msgid "Critical error (your attention is required)" msgstr "" +msgid "Current: %s" +msgstr "" + msgid "Custom DNS server addresses %s are invalid" msgstr "" @@ -2225,6 +2240,12 @@ msgstr "" msgid "Delete \"%s\"?" msgstr "" +msgid "Delete method" +msgstr "" + +msgid "Delete method?" +msgstr "" + msgid "Disable all %s above to activate this setting." msgstr "" @@ -2249,6 +2270,9 @@ msgstr "" msgid "Edit name" msgstr "" +msgid "Enable method" +msgstr "" + msgid "Enter MTU" msgstr "" @@ -2258,6 +2282,12 @@ msgstr "" msgid "Failed to apply patch" msgstr "" +msgid "Failed to set to current - API not reachable" +msgstr "" + +msgid "Failed to set to current - Unknown reason" +msgstr "" + msgid "File" msgstr "" @@ -2342,12 +2372,21 @@ msgstr "" msgid "Overrides inactive" msgstr "" +msgid "Password (optional)" +msgstr "" + msgid "Paste or write overrides to be imported" msgstr "" msgid "Patch not matching specification" msgstr "" +msgid "Please enter a valid IPv4 or IPv6 address" +msgstr "" + +msgid "Please enter a valid remote server port" +msgstr "" + msgid "Please use the Always-on system setting instead by following the guide in %s above." msgstr "" @@ -2405,13 +2444,22 @@ msgstr "" msgid "Submit" msgstr "" +msgid "Test method" +msgstr "" + +msgid "Testing %s..." +msgstr "" + msgid "Text" msgstr "" msgid "The Auto-connect and Lockdown mode settings can be found in the Android system settings, follow this guide to enable one or both." msgstr "" -msgid "The Lockdown mode is called Block connections without VPN in the Android system settings. It helps minimize leaks, however it has some known limitations which you can read more about it" +msgid "The Lockdown mode is called Block connections without VPN in the Android system settings. It helps minimize leaks, however it has some known limitations which you can read more about" +msgstr "" + +msgid "The \"Current\" method represent which method the app is using to reach the API." msgstr "" msgid "The local DNS server will not work unless you enable \"Local Network Sharing\" under Preferences." @@ -2423,6 +2471,12 @@ msgstr "" msgid "This address has already been entered." msgstr "" +msgid "This field is required" +msgstr "" + +msgid "This is already set as current" +msgstr "" + msgid "To add locations to a list, press the \"︙\" or long press on a country, city, or server." msgstr "" @@ -2465,6 +2519,9 @@ msgstr "" msgid "Update list name" msgstr "" +msgid "Use method" +msgstr "" + msgid "VPN permission error" msgstr "" @@ -2480,6 +2537,9 @@ msgstr "" msgid "Valid ranges: %s" msgstr "" +msgid "Verifying API method..." +msgstr "" + msgid "Verifying purchase" msgstr ""