diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2024-03-14 14:59:43 +0100 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2024-03-14 14:59:43 +0100 |
| commit | 3979cda6f64c509dafaf0d73bd3bfac43ad58cc3 (patch) | |
| tree | a9b46514a6c3091cc890deb03ca66f9037c4f8b1 | |
| parent | 461e29d36e5e2a6841e05897e732b5d068d4dcf0 (diff) | |
| parent | 1ff2611cdfb06bebc3e3047e6930cb034b452c61 (diff) | |
| download | mullvadvpn-3979cda6f64c509dafaf0d73bd3bfac43ad58cc3.tar.xz mullvadvpn-3979cda6f64c509dafaf0d73bd3bfac43ad58cc3.zip | |
Merge branch 'create-ui-for-custom-list-droid-654'
124 files changed, 6126 insertions, 528 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index c882837eff..82aafb2d59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Line wrap the file at 100 chars. Th - Add toggle for enabling or disabling split tunneling. - Add auto connect and lockdown mode guide on platforms that has system vpn settings. - Add 3D map to Connect screen. +- Add the ability to create and manage custom lists of relays. ### Changed - Change default obfuscation setting to `auto`. diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/Actions.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/Actions.kt new file mode 100644 index 0000000000..ebd570252a --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/Actions.kt @@ -0,0 +1,15 @@ +package net.mullvad.mullvadvpn + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.invokeGlobalAssertions +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.performTouchInput + +fun SemanticsNodeInteraction.performLongClick(): SemanticsNodeInteraction { + @OptIn(ExperimentalTestApi::class) return this.invokeGlobalAssertions().performLongClickImpl() +} + +private fun SemanticsNodeInteraction.performLongClickImpl(): SemanticsNodeInteraction { + return performTouchInput { longClick() } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/data/DummyRelayItems.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/data/DummyRelayItems.kt new file mode 100644 index 0000000000..5a20438c23 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/data/DummyRelayItems.kt @@ -0,0 +1,51 @@ +package net.mullvad.mullvadvpn.compose.data + +import net.mullvad.mullvadvpn.model.Constraint +import net.mullvad.mullvadvpn.model.PortRange +import net.mullvad.mullvadvpn.model.RelayEndpointData +import net.mullvad.mullvadvpn.model.RelayList +import net.mullvad.mullvadvpn.model.RelayListCity +import net.mullvad.mullvadvpn.model.RelayListCountry +import net.mullvad.mullvadvpn.model.WireguardEndpointData +import net.mullvad.mullvadvpn.model.WireguardRelayEndpointData +import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.relaylist.toRelayCountries + +private val DUMMY_RELAY_1 = + net.mullvad.mullvadvpn.model.Relay( + hostname = "Relay host 1", + active = true, + endpointData = RelayEndpointData.Wireguard(WireguardRelayEndpointData), + owned = true, + provider = "PROVIDER" + ) +private val DUMMY_RELAY_2 = + net.mullvad.mullvadvpn.model.Relay( + hostname = "Relay host 2", + active = true, + endpointData = RelayEndpointData.Wireguard(WireguardRelayEndpointData), + owned = true, + provider = "PROVIDER" + ) +private val DUMMY_RELAY_CITY_1 = RelayListCity("Relay City 1", "RCi1", arrayListOf(DUMMY_RELAY_1)) +private val DUMMY_RELAY_CITY_2 = RelayListCity("Relay City 2", "RCi2", arrayListOf(DUMMY_RELAY_2)) +private val DUMMY_RELAY_COUNTRY_1 = + RelayListCountry("Relay Country 1", "RCo1", arrayListOf(DUMMY_RELAY_CITY_1)) +private val DUMMY_RELAY_COUNTRY_2 = + RelayListCountry("Relay Country 2", "RCo2", arrayListOf(DUMMY_RELAY_CITY_2)) + +private val DUMMY_WIREGUARD_PORT_RANGES = ArrayList<PortRange>() +private val DUMMY_WIREGUARD_ENDPOINT_DATA = WireguardEndpointData(DUMMY_WIREGUARD_PORT_RANGES) + +val DUMMY_RELAY_COUNTRIES = + RelayList( + arrayListOf(DUMMY_RELAY_COUNTRY_1, DUMMY_RELAY_COUNTRY_2), + DUMMY_WIREGUARD_ENDPOINT_DATA, + ) + .toRelayCountries(ownership = Constraint.Any(), providers = Constraint.Any()) + +val DUMMY_CUSTOM_LISTS = + listOf( + RelayItem.CustomList("First list", false, "1", locations = DUMMY_RELAY_COUNTRIES), + RelayItem.CustomList("Empty list", expanded = false, "2", locations = emptyList()) + ) diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialogTest.kt new file mode 100644 index 0000000000..baeb5902d7 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialogTest.kt @@ -0,0 +1,144 @@ +package net.mullvad.mullvadvpn.compose.dialog + +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.MockKAnnotations +import io.mockk.mockk +import io.mockk.verify +import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension +import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.CreateCustomListUiState +import net.mullvad.mullvadvpn.compose.test.CREATE_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG +import net.mullvad.mullvadvpn.model.CustomListsError +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +class CreateCustomListDialogTest { + @OptIn(ExperimentalTestApi::class) + @JvmField + @RegisterExtension + val composeExtension = createEdgeToEdgeComposeExtension() + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @Test + fun givenNoErrorShouldShowNoErrorMessage() = + composeExtension.use { + // Arrange + val state = CreateCustomListUiState(error = null) + setContentWithTheme { CreateCustomListDialog(state = state) } + + // Assert + onNodeWithText(NAME_EXIST_ERROR_TEXT).assertDoesNotExist() + onNodeWithText(OTHER_ERROR_TEXT).assertDoesNotExist() + } + + @Test + fun givenCustomListExistsShouldShowCustomListExitsErrorText() = + composeExtension.use { + // Arrange + val state = CreateCustomListUiState(error = CustomListsError.CustomListExists) + setContentWithTheme { CreateCustomListDialog(state = state) } + + // Assert + onNodeWithText(NAME_EXIST_ERROR_TEXT).assertExists() + onNodeWithText(OTHER_ERROR_TEXT).assertDoesNotExist() + } + + @Test + fun givenOtherCustomListErrorShouldShowAnErrorOccurredErrorText() = + composeExtension.use { + // Arrange + val state = CreateCustomListUiState(error = CustomListsError.OtherError) + setContentWithTheme { CreateCustomListDialog(state = state) } + + // Assert + onNodeWithText(NAME_EXIST_ERROR_TEXT).assertDoesNotExist() + onNodeWithText(OTHER_ERROR_TEXT).assertExists() + } + + @Test + fun whenCancelIsClickedShouldDismissDialog() = + composeExtension.use { + // Arrange + val mockedOnDismiss: () -> Unit = mockk(relaxed = true) + val state = CreateCustomListUiState() + setContentWithTheme { + CreateCustomListDialog(state = state, onDismiss = mockedOnDismiss) + } + + // Act + onNodeWithText(CANCEL_BUTTON_TEXT).performClick() + + // Assert + verify { mockedOnDismiss.invoke() } + } + + @Test + fun givenEmptyTextInputWhenSubmitIsClickedThenShouldNotCallOnCreate() = + composeExtension.use { + // Arrange + val mockedCreateCustomList: (String) -> Unit = mockk(relaxed = true) + val state = CreateCustomListUiState() + setContentWithTheme { + CreateCustomListDialog(state = state, createCustomList = mockedCreateCustomList) + } + + // Act + onNodeWithText(CREATE_BUTTON_TEXT).performClick() + + // Assert + verify(exactly = 0) { mockedCreateCustomList.invoke(any()) } + } + + @Test + fun givenValidTextInputWhenSubmitIsClickedThenShouldCallOnCreate() = + composeExtension.use { + // Arrange + val mockedCreateCustomList: (String) -> Unit = mockk(relaxed = true) + val inputText = "NEW LIST" + val state = CreateCustomListUiState() + setContentWithTheme { + CreateCustomListDialog(state = state, createCustomList = mockedCreateCustomList) + } + + // Act + onNodeWithTag(CREATE_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG).performTextInput(inputText) + onNodeWithText(CREATE_BUTTON_TEXT).performClick() + + // Assert + verify { mockedCreateCustomList.invoke(inputText) } + } + + @Test + fun whenInputIsChangedShouldCallOnInputChanged() = + composeExtension.use { + // Arrange + val mockedOnInputChanged: () -> Unit = mockk(relaxed = true) + val inputText = "NEW LIST" + val state = CreateCustomListUiState() + setContentWithTheme { + CreateCustomListDialog(state = state, onInputChanged = mockedOnInputChanged) + } + + // Act + onNodeWithTag(CREATE_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG).performTextInput(inputText) + + // Assert + verify { mockedOnInputChanged.invoke() } + } + + companion object { + private const val NAME_EXIST_ERROR_TEXT = "Name is already taken." + private const val OTHER_ERROR_TEXT = "An error occurred." + private const val CANCEL_BUTTON_TEXT = "Cancel" + private const val CREATE_BUTTON_TEXT = "Create" + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialogTest.kt new file mode 100644 index 0000000000..e79c5a2fe7 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialogTest.kt @@ -0,0 +1,76 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import io.mockk.MockKAnnotations +import io.mockk.mockk +import io.mockk.verify +import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension +import net.mullvad.mullvadvpn.compose.setContentWithTheme +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +class DeleteCustomListConfirmationDialogTest { + @OptIn(ExperimentalTestApi::class) + @JvmField + @RegisterExtension + val composeExtension = createEdgeToEdgeComposeExtension() + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @Test + fun givenNameShouldShowDeleteNameTitle() = + composeExtension.use { + // Arrange + val name = "List should be deleted" + setContentWithTheme { DeleteCustomListConfirmationDialog(name = name) } + + // Assert + onNodeWithText(DELETE_TITLE.format(name)).assertExists() + } + + @Test + fun whenDeleteIsClickedShouldCallOnDelete() = + composeExtension.use { + // Arrange + val name = "List should be deleted" + val mockedOnDelete: () -> Unit = mockk(relaxed = true) + setContentWithTheme { + DeleteCustomListConfirmationDialog(name = name, onDelete = mockedOnDelete) + } + + // Act + onNodeWithText(DELETE_BUTTON_TEXT).performClick() + + // Assert + verify { mockedOnDelete.invoke() } + } + + @Test + fun whenCancelIsClickedShouldCallOnBack() = + composeExtension.use { + // Arrange + val name = "List should be deleted" + val mockedOnBack: () -> Unit = mockk(relaxed = true) + setContentWithTheme { + DeleteCustomListConfirmationDialog(name = name, onBack = mockedOnBack) + } + + // Act + onNodeWithText(CANCEL_BUTTON_TEXT).performClick() + + // Assert + verify { mockedOnBack.invoke() } + } + + companion object { + private const val DELETE_TITLE = "Delete \"%s\"?" + private const val CANCEL_BUTTON_TEXT = "Cancel" + private const val DELETE_BUTTON_TEXT = "Delete" + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialogTest.kt new file mode 100644 index 0000000000..cbd6ae09d7 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialogTest.kt @@ -0,0 +1,144 @@ +package net.mullvad.mullvadvpn.compose.dialog + +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.MockKAnnotations +import io.mockk.mockk +import io.mockk.verify +import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension +import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.UpdateCustomListUiState +import net.mullvad.mullvadvpn.compose.test.EDIT_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG +import net.mullvad.mullvadvpn.model.CustomListsError +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +class EditCustomListNameDialogTest { + @OptIn(ExperimentalTestApi::class) + @JvmField + @RegisterExtension + val composeExtension = createEdgeToEdgeComposeExtension() + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @Test + fun givenNoErrorShouldShowNoErrorMessage() = + composeExtension.use { + // Arrange + val state = UpdateCustomListUiState(error = null) + setContentWithTheme { EditCustomListNameDialog(state = state) } + + // Assert + onNodeWithText(NAME_EXIST_ERROR_TEXT).assertDoesNotExist() + onNodeWithText(OTHER_ERROR_TEXT).assertDoesNotExist() + } + + @Test + fun givenCustomListExistsShouldShowCustomListExitsErrorText() = + composeExtension.use { + // Arrange + val state = UpdateCustomListUiState(error = CustomListsError.CustomListExists) + setContentWithTheme { EditCustomListNameDialog(state = state) } + + // Assert + onNodeWithText(NAME_EXIST_ERROR_TEXT).assertExists() + onNodeWithText(OTHER_ERROR_TEXT).assertDoesNotExist() + } + + @Test + fun givenOtherCustomListErrorShouldShowAnErrorOccurredErrorText() = + composeExtension.use { + // Arrange + val state = UpdateCustomListUiState(error = CustomListsError.OtherError) + setContentWithTheme { EditCustomListNameDialog(state = state) } + + // Assert + onNodeWithText(NAME_EXIST_ERROR_TEXT).assertDoesNotExist() + onNodeWithText(OTHER_ERROR_TEXT).assertExists() + } + + @Test + fun whenCancelIsClickedShouldDismissDialog() = + composeExtension.use { + // Arrange + val mockedOnDismiss: () -> Unit = mockk(relaxed = true) + val state = UpdateCustomListUiState() + setContentWithTheme { + EditCustomListNameDialog(state = state, onDismiss = mockedOnDismiss) + } + + // Act + onNodeWithText(CANCEL_BUTTON_TEXT).performClick() + + // Assert + verify { mockedOnDismiss.invoke() } + } + + @Test + fun givenEmptyTextInputWhenSaveIsClickedThenShouldNotCallUpdateName() = + composeExtension.use { + // Arrange + val mockedUpdateName: (String) -> Unit = mockk(relaxed = true) + val state = UpdateCustomListUiState() + setContentWithTheme { + EditCustomListNameDialog(state = state, updateName = mockedUpdateName) + } + + // Act + onNodeWithText(SAVE_BUTTON_TEXT).performClick() + + // Assert + verify(exactly = 0) { mockedUpdateName.invoke(any()) } + } + + @Test + fun givenValidTextInputWhenSaveIsClickedThenShouldCallUpdateName() = + composeExtension.use { + // Arrange + val mockedUpdateName: (String) -> Unit = mockk(relaxed = true) + val inputText = "NEW NAME" + val state = UpdateCustomListUiState() + setContentWithTheme { + EditCustomListNameDialog(state = state, updateName = mockedUpdateName) + } + + // Act + onNodeWithTag(EDIT_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG).performTextInput(inputText) + onNodeWithText(SAVE_BUTTON_TEXT).performClick() + + // Assert + verify { mockedUpdateName.invoke(inputText) } + } + + @Test + fun whenInputIsChangedShouldCallOnInputChanged() = + composeExtension.use { + // Arrange + val mockedOnInputChanged: () -> Unit = mockk(relaxed = true) + val inputText = "NEW NAME" + val state = UpdateCustomListUiState() + setContentWithTheme { + EditCustomListNameDialog(state = state, onInputChanged = mockedOnInputChanged) + } + + // Act + onNodeWithTag(EDIT_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG).performTextInput(inputText) + + // Assert + verify { mockedOnInputChanged.invoke() } + } + + companion object { + private const val NAME_EXIST_ERROR_TEXT = "Name is already taken." + private const val OTHER_ERROR_TEXT = "An error occurred." + private const val CANCEL_BUTTON_TEXT = "Cancel" + private const val SAVE_BUTTON_TEXT = "Save" + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt new file mode 100644 index 0000000000..5951550550 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt @@ -0,0 +1,246 @@ +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.MockKAnnotations +import io.mockk.mockk +import io.mockk.verify +import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension +import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_COUNTRIES +import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState +import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR +import net.mullvad.mullvadvpn.compose.test.SAVE_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.relaylist.RelayItem +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +@OptIn(ExperimentalTestApi::class) +class CustomListLocationsScreenTest { + @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @Test + fun givenLoadingStateShouldShowLoadingSpinner() = + composeExtension.use { + // Arrange + setContentWithTheme { + CustomListLocationsScreen( + state = CustomListLocationsUiState.Loading(newList = false) + ) + } + + // Assert + onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertExists() + } + + @Test + fun givenNewListTrueShouldShowAddLocations() = + composeExtension.use { + // Arrange + val newList = true + setContentWithTheme { + CustomListLocationsScreen( + state = CustomListLocationsUiState.Loading(newList = newList) + ) + } + + // Assert + onNodeWithText(ADD_LOCATIONS_TEXT).assertExists() + } + + @Test + fun givenNewListFalseShouldShowEditLocations() = + composeExtension.use { + // Arrange + val newList = false + setContentWithTheme { + CustomListLocationsScreen( + state = CustomListLocationsUiState.Loading(newList = newList) + ) + } + + // Assert + onNodeWithText(EDIT_LOCATIONS_TEXT).assertExists() + } + + @Test + fun givenListOfAvailableLocationsShouldShowThem() = + composeExtension.use { + // Arrange + setContentWithTheme { + CustomListLocationsScreen( + state = + CustomListLocationsUiState.Content.Data( + availableLocations = DUMMY_RELAY_COUNTRIES, + selectedLocations = emptySet(), + searchTerm = "" + ), + ) + } + + // Assert + onNodeWithText("Relay Country 1").assertExists() + onNodeWithText("Relay City 1").assertDoesNotExist() + onNodeWithText("Relay host 1").assertDoesNotExist() + onNodeWithText("Relay Country 2").assertExists() + onNodeWithText("Relay City 2").assertDoesNotExist() + onNodeWithText("Relay host 2").assertDoesNotExist() + } + + @Test + fun whenClickingOnRelayShouldCallOnSelectForThatRelay() = + composeExtension.use { + // Arrange + val selectedCountry = DUMMY_RELAY_COUNTRIES[0] + val mockedOnRelaySelectionClicked: (RelayItem, Boolean) -> Unit = mockk(relaxed = true) + setContentWithTheme { + CustomListLocationsScreen( + state = + CustomListLocationsUiState.Content.Data( + newList = false, + availableLocations = DUMMY_RELAY_COUNTRIES, + selectedLocations = setOf(selectedCountry) + ), + onRelaySelectionClick = mockedOnRelaySelectionClicked + ) + } + + // Act + onNodeWithText(selectedCountry.name).performClick() + + // Assert + verify { mockedOnRelaySelectionClicked(selectedCountry, false) } + } + + @Test + fun whenSearchInputIsUpdatedShouldCallOnSearchTermInput() = + composeExtension.use { + // Arrange + val mockedSearchTermInput: (String) -> Unit = mockk(relaxed = true) + setContentWithTheme { + CustomListLocationsScreen( + state = + CustomListLocationsUiState.Content.Data( + newList = false, + availableLocations = DUMMY_RELAY_COUNTRIES, + ), + onSearchTermInput = mockedSearchTermInput + ) + } + val mockSearchString = "SEARCH" + + // Act + onNodeWithText(SEARCH_PLACEHOLDER).performTextInput(mockSearchString) + + // Assert + verify { mockedSearchTermInput.invoke(mockSearchString) } + } + + @Test + fun whenSearchResultNotFoundShouldShowSearchNotFoundText() = + composeExtension.use { + // Arrange + val mockedSearchTermInput: (String) -> Unit = mockk(relaxed = true) + val mockSearchString = "SEARCH" + setContentWithTheme { + CustomListLocationsScreen( + state = + CustomListLocationsUiState.Content.Empty( + newList = false, + searchTerm = mockSearchString + ), + onSearchTermInput = mockedSearchTermInput + ) + } + + // Assert + onNodeWithText(EMPTY_SEARCH_FIRST_ROW.format(mockSearchString), substring = true) + .assertExists() + onNodeWithText(EMPTY_SEARCH_SECOND_ROW, substring = true).assertExists() + } + + @Test + fun whenRelayListIsEmptyShouldShowNoRelaysText() = + composeExtension.use { + // Arrange + val emptySearchString = "" + setContentWithTheme { + CustomListLocationsScreen( + state = + CustomListLocationsUiState.Content.Empty( + newList = false, + searchTerm = emptySearchString + ) + ) + } + + // Assert + onNodeWithText(NO_LOCATIONS_FOUND_TEXT).assertExists() + } + + @Test + fun givenSaveIsEnabledWhenSaveClickedShouldCallOnSaveClick() = + composeExtension.use { + // Arrange + val mockOnSaveClick: () -> Unit = mockk(relaxed = true) + setContentWithTheme { + CustomListLocationsScreen( + state = + CustomListLocationsUiState.Content.Data( + newList = false, + availableLocations = DUMMY_RELAY_COUNTRIES, + saveEnabled = true, + ), + onSaveClick = mockOnSaveClick + ) + } + + // Act + onNodeWithTag(SAVE_BUTTON_TEST_TAG).performClick() + + // Assert + verify { mockOnSaveClick() } + } + + @Test + fun givenSaveIsDisabledWhenSaveClickedShouldNotCallOnSaveClick() = + composeExtension.use { + // Arrange + val mockOnSaveClick: () -> Unit = mockk(relaxed = true) + setContentWithTheme { + CustomListLocationsScreen( + state = + CustomListLocationsUiState.Content.Data( + newList = false, + availableLocations = DUMMY_RELAY_COUNTRIES, + saveEnabled = false, + ), + onSaveClick = mockOnSaveClick + ) + } + + // Act + onNodeWithTag(SAVE_BUTTON_TEST_TAG).performClick() + + // Assert + verify(exactly = 0) { mockOnSaveClick() } + } + + companion object { + const val ADD_LOCATIONS_TEXT = "Add locations" + const val EDIT_LOCATIONS_TEXT = "Edit locations" + const val SEARCH_PLACEHOLDER = "Search for..." + const val EMPTY_SEARCH_FIRST_ROW = "No result for %s." + const val EMPTY_SEARCH_SECOND_ROW = "Try a different search" + const val NO_LOCATIONS_FOUND_TEXT = "No locations found" + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreenTest.kt new file mode 100644 index 0000000000..da9ed60997 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreenTest.kt @@ -0,0 +1,105 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import io.mockk.MockKAnnotations +import io.mockk.mockk +import io.mockk.verify +import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension +import net.mullvad.mullvadvpn.compose.data.DUMMY_CUSTOM_LISTS +import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.CustomListsUiState +import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR +import net.mullvad.mullvadvpn.compose.test.NEW_LIST_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.relaylist.RelayItem +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +@OptIn(ExperimentalTestApi::class) +class CustomListsScreenTest { + @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @Test + fun givenLoadingStateShouldShowLoadingSpinner() = + composeExtension.use { + // Arrange + setContentWithTheme { + CustomListsScreen( + state = CustomListsUiState.Loading, + snackbarHostState = SnackbarHostState() + ) + } + + // Assert + onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertExists() + } + + @Test + fun givenCustomListsShouldShowTheirNames() = + composeExtension.use { + // Arrange + val customLists = DUMMY_CUSTOM_LISTS + setContentWithTheme { + CustomListsScreen( + state = CustomListsUiState.Content(customLists = customLists), + snackbarHostState = SnackbarHostState() + ) + } + + // Assert + onNodeWithText(customLists[0].name).assertExists() + onNodeWithText(customLists[1].name).assertExists() + } + + @Test + fun whenNewListButtonIsClickedShouldCallAddCustomList() = + composeExtension.use { + // Arrange + val customLists = DUMMY_CUSTOM_LISTS + val mockedAddCustomList: () -> Unit = mockk(relaxed = true) + setContentWithTheme { + CustomListsScreen( + state = CustomListsUiState.Content(customLists = customLists), + snackbarHostState = SnackbarHostState(), + addCustomList = mockedAddCustomList + ) + } + + // Act + onNodeWithTag(NEW_LIST_BUTTON_TEST_TAG).performClick() + + // Assert + verify { mockedAddCustomList() } + } + + @Test + fun whenACustomListIsClickedShouldCallOpenCustomList() = + composeExtension.use { + // Arrange + val customLists = DUMMY_CUSTOM_LISTS + val clickedList = DUMMY_CUSTOM_LISTS[0] + val mockedOpenCustomList: (RelayItem.CustomList) -> Unit = mockk(relaxed = true) + setContentWithTheme { + CustomListsScreen( + state = CustomListsUiState.Content(customLists = customLists), + snackbarHostState = SnackbarHostState(), + openCustomList = mockedOpenCustomList + ) + } + + // Act + onNodeWithText(clickedList.name).performClick() + + // Assert + verify { mockedOpenCustomList(clickedList) } + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreenTest.kt new file mode 100644 index 0000000000..f44441b536 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreenTest.kt @@ -0,0 +1,170 @@ +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.MockKAnnotations +import io.mockk.mockk +import io.mockk.verify +import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension +import net.mullvad.mullvadvpn.compose.data.DUMMY_CUSTOM_LISTS +import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.EditCustomListState +import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR +import net.mullvad.mullvadvpn.compose.test.DELETE_DROPDOWN_MENU_ITEM_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.TOP_BAR_DROPDOWN_BUTTON_TEST_TAG +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +@OptIn(ExperimentalTestApi::class) +class EditCustomListScreenTest { + @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @Test + fun givenLoadingStateShouldShowLoadingSpinner() = + composeExtension.use { + // Arrange + setContentWithTheme { EditCustomListScreen(state = EditCustomListState.Loading) } + + // Assert + onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertExists() + } + + @Test + fun givenNotFoundStateShouldShowNotFound() = + composeExtension.use { + // Arrange + setContentWithTheme { EditCustomListScreen(state = EditCustomListState.NotFound) } + + // Assert + onNodeWithText(NOT_FOUND_TEXT).assertExists() + } + + @Test + fun givenContentStateShouldShowNameFromState() = + composeExtension.use { + // Arrange + val customList = DUMMY_CUSTOM_LISTS[0] + setContentWithTheme { + EditCustomListScreen( + state = + EditCustomListState.Content( + id = customList.id, + name = customList.name, + locations = customList.locations + ) + ) + } + + // Assert + onNodeWithText(customList.name) + } + + @Test + fun givenContentStateShouldShowNumberOfLocationsFromState() = + composeExtension.use { + // Arrange + val customList = DUMMY_CUSTOM_LISTS[0] + setContentWithTheme { + EditCustomListScreen( + state = + EditCustomListState.Content( + id = customList.id, + name = customList.name, + locations = customList.locations + ) + ) + } + + // Assert + onNodeWithText(LOCATIONS_TEXT.format(customList.locations.size)) + } + + @Test + fun whenClickingOnDeleteDropdownShouldCallOnDeleteList() = + composeExtension.use { + // Arrange + val mockedOnDelete: (String) -> Unit = mockk(relaxed = true) + val customList = DUMMY_CUSTOM_LISTS[0] + setContentWithTheme { + EditCustomListScreen( + state = + EditCustomListState.Content( + id = customList.id, + name = customList.name, + locations = customList.locations + ), + onDeleteList = mockedOnDelete + ) + } + + // Act + onNodeWithTag(TOP_BAR_DROPDOWN_BUTTON_TEST_TAG).performClick() + onNodeWithTag(DELETE_DROPDOWN_MENU_ITEM_TEST_TAG).performClick() + + // Assert + verify { mockedOnDelete(customList.name) } + } + + @Test + fun whenClickingOnNameCellShouldCallOnNameClicked() = + composeExtension.use { + // Arrange + val mockedOnNameClicked: (String, String) -> Unit = mockk(relaxed = true) + val customList = DUMMY_CUSTOM_LISTS[0] + setContentWithTheme { + EditCustomListScreen( + state = + EditCustomListState.Content( + id = customList.id, + name = customList.name, + locations = customList.locations + ), + onNameClicked = mockedOnNameClicked + ) + } + + // Act + onNodeWithText(customList.name).performClick() + + // Assert + verify { mockedOnNameClicked(customList.id, customList.name) } + } + + @Test + fun whenClickingOnLocationCellShouldCallOnLocationsClicked() = + composeExtension.use { + // Arrange + val mockedOnLocationsClicked: (String) -> Unit = mockk(relaxed = true) + val customList = DUMMY_CUSTOM_LISTS[0] + setContentWithTheme { + EditCustomListScreen( + state = + EditCustomListState.Content( + id = customList.id, + name = customList.name, + locations = customList.locations + ), + onLocationsClicked = mockedOnLocationsClicked + ) + } + + // Act + onNodeWithText(LOCATIONS_TEXT.format(customList.locations.size)).performClick() + + // Assert + verify { mockedOnLocationsClicked(customList.id) } + } + + companion object { + const val NOT_FOUND_TEXT = "Not found" + const val LOCATIONS_TEXT = "%d locations" + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt index fe28357048..28651c3852 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt @@ -3,24 +3,22 @@ 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.MockKAnnotations import io.mockk.mockk import io.mockk.verify import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension +import net.mullvad.mullvadvpn.compose.data.DUMMY_CUSTOM_LISTS +import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_COUNTRIES import net.mullvad.mullvadvpn.compose.setContentWithTheme -import net.mullvad.mullvadvpn.compose.state.RelayListState import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR -import net.mullvad.mullvadvpn.model.Constraint -import net.mullvad.mullvadvpn.model.PortRange -import net.mullvad.mullvadvpn.model.RelayEndpointData -import net.mullvad.mullvadvpn.model.RelayList -import net.mullvad.mullvadvpn.model.RelayListCity -import net.mullvad.mullvadvpn.model.RelayListCountry -import net.mullvad.mullvadvpn.model.WireguardEndpointData -import net.mullvad.mullvadvpn.model.WireguardRelayEndpointData -import net.mullvad.mullvadvpn.relaylist.toRelayCountries +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG +import net.mullvad.mullvadvpn.performLongClick +import net.mullvad.mullvadvpn.relaylist.RelayItem import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -55,12 +53,11 @@ class SelectLocationScreenTest { setContentWithTheme { SelectLocationScreen( state = - SelectLocationUiState.Data( - relayListState = - RelayListState.RelayList( - countries = DUMMY_RELAY_COUNTRIES, - selectedItem = null - ), + SelectLocationUiState.Content( + customLists = emptyList(), + filteredCustomLists = emptyList(), + countries = DUMMY_RELAY_COUNTRIES, + selectedItem = null, selectedOwnership = null, selectedProvidersCount = 0, searchTerm = "" @@ -95,12 +92,11 @@ class SelectLocationScreenTest { setContentWithTheme { SelectLocationScreen( state = - SelectLocationUiState.Data( - relayListState = - RelayListState.RelayList( - countries = updatedDummyList, - selectedItem = updatedDummyList[0].cities[0].relays[0] - ), + SelectLocationUiState.Content( + customLists = emptyList(), + filteredCustomLists = emptyList(), + countries = updatedDummyList, + selectedItem = updatedDummyList[0].cities[0].relays[0], selectedOwnership = null, selectedProvidersCount = 0, searchTerm = "" @@ -125,12 +121,11 @@ class SelectLocationScreenTest { setContentWithTheme { SelectLocationScreen( state = - SelectLocationUiState.Data( - relayListState = - RelayListState.RelayList( - countries = emptyList(), - selectedItem = null - ), + SelectLocationUiState.Content( + customLists = emptyList(), + filteredCustomLists = emptyList(), + countries = emptyList(), + selectedItem = null, selectedOwnership = null, selectedProvidersCount = 0, searchTerm = "" @@ -156,8 +151,11 @@ class SelectLocationScreenTest { setContentWithTheme { SelectLocationScreen( state = - SelectLocationUiState.Data( - relayListState = RelayListState.Empty, + SelectLocationUiState.Content( + customLists = emptyList(), + filteredCustomLists = emptyList(), + countries = emptyList(), + selectedItem = null, selectedOwnership = null, selectedProvidersCount = 0, searchTerm = mockSearchString @@ -171,41 +169,143 @@ class SelectLocationScreenTest { onNodeWithText("Try a different search", substring = true).assertExists() } - companion object { - private val DUMMY_RELAY_1 = - net.mullvad.mullvadvpn.model.Relay( - hostname = "Relay host 1", - active = true, - endpointData = RelayEndpointData.Wireguard(WireguardRelayEndpointData), - owned = true, - provider = "PROVIDER" - ) - private val DUMMY_RELAY_2 = - net.mullvad.mullvadvpn.model.Relay( - hostname = "Relay host 2", - active = true, - endpointData = RelayEndpointData.Wireguard(WireguardRelayEndpointData), - owned = true, - provider = "PROVIDER" - ) - private val DUMMY_RELAY_CITY_1 = - RelayListCity("Relay City 1", "RCi1", arrayListOf(DUMMY_RELAY_1)) - private val DUMMY_RELAY_CITY_2 = - RelayListCity("Relay City 2", "RCi2", arrayListOf(DUMMY_RELAY_2)) - private val DUMMY_RELAY_COUNTRY_1 = - RelayListCountry("Relay Country 1", "RCo1", arrayListOf(DUMMY_RELAY_CITY_1)) - private val DUMMY_RELAY_COUNTRY_2 = - RelayListCountry("Relay Country 2", "RCo2", arrayListOf(DUMMY_RELAY_CITY_2)) + @Test + fun givenNoCustomListsAndSearchIsTermIsEmptyShouldShowCustomListsEmptyText() = + composeExtension.use { + // Arrange + val mockSearchString = "" + setContentWithTheme { + SelectLocationScreen( + state = + SelectLocationUiState.Content( + customLists = emptyList(), + filteredCustomLists = emptyList(), + countries = emptyList(), + selectedItem = null, + selectedOwnership = null, + selectedProvidersCount = 0, + searchTerm = mockSearchString + ), + ) + } + + // Assert + onNodeWithText(CUSTOM_LISTS_EMPTY_TEXT).assertExists() + } + + @Test + fun givenNoCustomListsAndSearchIsActiveShouldNotShowCustomListHeader() = + composeExtension.use { + // Arrange + val mockSearchString = "SEARCH" + setContentWithTheme { + SelectLocationScreen( + state = + SelectLocationUiState.Content( + customLists = DUMMY_CUSTOM_LISTS, + filteredCustomLists = emptyList(), + countries = emptyList(), + selectedItem = null, + selectedOwnership = null, + selectedProvidersCount = 0, + searchTerm = mockSearchString + ), + ) + } + + // Assert + onNodeWithText(CUSTOM_LISTS_EMPTY_TEXT).assertDoesNotExist() + onNodeWithTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG).assertDoesNotExist() + } + + @Test + fun whenCustomListIsClickedShouldCallOnSelectRelay() = + composeExtension.use { + // Arrange + val customList = DUMMY_CUSTOM_LISTS[0] + val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true) + setContentWithTheme { + SelectLocationScreen( + state = + SelectLocationUiState.Content( + customLists = DUMMY_CUSTOM_LISTS, + filteredCustomLists = DUMMY_CUSTOM_LISTS, + countries = emptyList(), + selectedItem = null, + selectedOwnership = null, + selectedProvidersCount = 0, + searchTerm = "" + ), + onSelectRelay = mockedOnSelectRelay + ) + } - private val DUMMY_WIREGUARD_PORT_RANGES = ArrayList<PortRange>() - private val DUMMY_WIREGUARD_ENDPOINT_DATA = - WireguardEndpointData(DUMMY_WIREGUARD_PORT_RANGES) + // Act + onNodeWithText(customList.name).performClick() - private val DUMMY_RELAY_COUNTRIES = - RelayList( - arrayListOf(DUMMY_RELAY_COUNTRY_1, DUMMY_RELAY_COUNTRY_2), - DUMMY_WIREGUARD_ENDPOINT_DATA, + // Assert + verify { mockedOnSelectRelay(customList) } + } + + @Test + fun whenCustomListIsLongClickedShouldShowBottomSheet() = + composeExtension.use { + // Arrange + val customList = DUMMY_CUSTOM_LISTS[0] + val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true) + setContentWithTheme { + SelectLocationScreen( + state = + SelectLocationUiState.Content( + customLists = DUMMY_CUSTOM_LISTS, + filteredCustomLists = DUMMY_CUSTOM_LISTS, + countries = emptyList(), + selectedItem = null, + selectedOwnership = null, + selectedProvidersCount = 0, + searchTerm = "" + ), + onSelectRelay = mockedOnSelectRelay ) - .toRelayCountries(ownership = Constraint.Any(), providers = Constraint.Any()) + } + + // Act + onNodeWithText(customList.name).performLongClick() + + // Assert + onNodeWithTag(SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG) + } + + @Test + fun whenLocationIsLongClickedShouldShowBottomSheet() = + composeExtension.use { + // Arrange + val relayItem = DUMMY_RELAY_COUNTRIES[0] + val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true) + setContentWithTheme { + SelectLocationScreen( + state = + SelectLocationUiState.Content( + customLists = emptyList(), + filteredCustomLists = emptyList(), + countries = DUMMY_RELAY_COUNTRIES, + selectedItem = null, + selectedOwnership = null, + selectedProvidersCount = 0, + searchTerm = "" + ), + onSelectRelay = mockedOnSelectRelay + ) + } + + // Act + onNodeWithText(relayItem.name).performLongClick() + + // Assert + onNodeWithTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG) + } + + companion object { + private const val CUSTOM_LISTS_EMPTY_TEXT = "To create a custom list press the \"︙\"" } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt index 15f6e4d111..19049613bd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt @@ -35,7 +35,7 @@ private fun PreviewBaseCell() { AppTheme { SpacedColumn { BaseCell( - title = { + headlineContent = { BaseCellTitle( title = "Header title", style = MaterialTheme.typography.titleMedium @@ -43,7 +43,7 @@ private fun PreviewBaseCell() { } ) BaseCell( - title = { + headlineContent = { BaseCellTitle( title = "Normal title", style = MaterialTheme.typography.labelLarge @@ -58,7 +58,7 @@ private fun PreviewBaseCell() { internal fun BaseCell( modifier: Modifier = Modifier, iconView: @Composable RowScope.() -> Unit = {}, - title: @Composable RowScope.() -> Unit, + headlineContent: @Composable RowScope.() -> Unit, bodyView: @Composable ColumnScope.() -> Unit = {}, isRowEnabled: Boolean = true, onCellClicked: () -> Unit = {}, @@ -83,7 +83,7 @@ internal fun BaseCell( ) { iconView() - title() + headlineContent() Column(modifier = Modifier.wrapContentWidth().wrapContentHeight()) { bodyView() } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt index 5190a3a959..fe270ba445 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt @@ -2,16 +2,12 @@ package net.mullvad.mullvadvpn.compose.cell import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -20,9 +16,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp +import net.mullvad.mullvadvpn.compose.component.MullvadCheckbox import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.lib.theme.color.selected @Preview @Composable @@ -37,7 +33,7 @@ internal fun CheckboxCell( checked: Boolean, onCheckedChange: (Boolean) -> Unit, background: Color = MaterialTheme.colorScheme.secondaryContainer, - startPadding: Dp = Dimens.cellStartPadding, + startPadding: Dp = Dimens.mediumPadding, endPadding: Dp = Dimens.cellEndPadding, minHeight: Dp = Dimens.cellHeight ) { @@ -51,23 +47,7 @@ internal fun CheckboxCell( .background(background) .padding(start = startPadding, end = endPadding) ) { - Box( - modifier = - Modifier.size(Dimens.checkBoxSize) - .background(Color.White, MaterialTheme.shapes.small) - ) { - Checkbox( - modifier = Modifier.fillMaxSize(), - checked = checked, - onCheckedChange = onCheckedChange, - colors = - CheckboxDefaults.colors( - checkedColor = Color.Transparent, - uncheckedColor = Color.Transparent, - checkmarkColor = MaterialTheme.colorScheme.selected - ), - ) - } + MullvadCheckbox(checked = checked, onCheckedChange = onCheckedChange) Spacer(modifier = Modifier.size(Dimens.mediumPadding)) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomListCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomListCell.kt new file mode 100644 index 0000000000..1029cfada0 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomListCell.kt @@ -0,0 +1,31 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import net.mullvad.mullvadvpn.relaylist.RelayItem + +@Composable +fun CustomListCell( + customList: RelayItem.CustomList, + modifier: Modifier = Modifier, + onCellClicked: (RelayItem.CustomList) -> Unit = {}, + textStyle: TextStyle = MaterialTheme.typography.titleMedium, + textColor: Color = MaterialTheme.colorScheme.onPrimary, + background: Color = MaterialTheme.colorScheme.primary, +) { + BaseCell( + headlineContent = { + BaseCellTitle( + title = customList.name, + style = textStyle, + color = textColor, + ) + }, + modifier = modifier, + background = background, + onCellClicked = { onCellClicked(customList) } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt index 2a0043842a..21d5558f9e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt @@ -34,7 +34,7 @@ fun DnsCell( val startPadding = 54.dp BaseCell( - title = { DnsTitle(address = address, modifier = titleModifier) }, + headlineContent = { DnsTitle(address = address, modifier = titleModifier) }, bodyView = { if (isUnreachableLocalDnsWarningVisible) { Icon( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DropdownMenuCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DropdownMenuCell.kt new file mode 100644 index 0000000000..3d52aca80c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DropdownMenuCell.kt @@ -0,0 +1,61 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.foundation.background +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Preview +@Composable +private fun PreviewThreeDotCell() { + AppTheme { + ThreeDotCell( + text = "Three dots", + ) + } +} + +@Composable +fun ThreeDotCell( + text: String, + modifier: Modifier = Modifier, + onClickDots: () -> Unit = {}, + textStyle: TextStyle = MaterialTheme.typography.titleMedium, + textColor: Color = MaterialTheme.colorScheme.onPrimary, + background: Color = MaterialTheme.colorScheme.primary +) { + BaseCell( + headlineContent = { + BaseCellTitle( + title = text, + style = textStyle, + color = textColor, + modifier = Modifier.weight(1f, true) + ) + }, + modifier = modifier, + background = background, + bodyView = { + IconButton(onClick = onClickDots) { + Icon( + painter = painterResource(id = R.drawable.icon_more_vert), + contentDescription = null, + tint = textColor + ) + } + }, + isRowEnabled = false, + endPadding = Dimens.smallPadding + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt index 9ea26d6e01..73a6a5283d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt @@ -54,7 +54,7 @@ fun ExpandableComposeCell( BaseCell( modifier = Modifier.focusProperties { canFocus = false }, - title = { + headlineContent = { BaseCellTitle( title = title, style = MaterialTheme.typography.titleMedium, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt index 8807430989..3260e9099a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt @@ -15,7 +15,7 @@ fun HeaderCell( background: Color = MaterialTheme.colorScheme.primary, ) { BaseCell( - title = { + headlineContent = { BaseCellTitle( title = text, style = textStyle, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt new file mode 100644 index 0000000000..faf537fb7f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt @@ -0,0 +1,54 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Preview +@Composable +private fun PreviewIconCell() { + AppTheme { IconCell(iconId = R.drawable.icon_add, title = "Add") } +} + +@Composable +fun IconCell( + iconId: Int?, + contentDescription: String? = null, + title: String, + titleStyle: TextStyle = MaterialTheme.typography.labelLarge, + titleColor: Color = MaterialTheme.colorScheme.onPrimary, + onClick: () -> Unit = {}, + background: Color = MaterialTheme.colorScheme.primary, + enabled: Boolean = true, +) { + BaseCell( + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + iconId?.let { + Icon( + painter = painterResource(id = iconId), + contentDescription = contentDescription, + tint = titleColor + ) + Spacer(modifier = Modifier.width(Dimens.mediumPadding)) + } + BaseCellTitle(title = title, style = titleStyle, color = titleColor) + } + }, + onCellClicked = onClick, + background = background, + isRowEnabled = enabled + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt index ef898aac6d..f490294491 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt @@ -47,7 +47,7 @@ fun InformationComposeCell( BaseCell( modifier = Modifier.focusProperties { canFocus = false }, - title = { + headlineContent = { BaseCellTitle( title = title, style = MaterialTheme.typography.titleMedium, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt index 7cd45ddb2d..d949f2a708 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt @@ -28,7 +28,7 @@ fun MtuComposeCell( val titleModifier = Modifier BaseCell( - title = { MtuTitle(modifier = titleModifier.weight(1f, true)) }, + headlineContent = { MtuTitle(modifier = titleModifier.weight(1f, true)) }, bodyView = { MtuBodyView(mtuValue = mtuValue, modifier = titleModifier) }, onCellClicked = { onEditMtu.invoke() } ) 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 272e599d8d..27b74227ca 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 @@ -70,7 +70,7 @@ fun NavigationComposeCell( ) { BaseCell( onCellClicked = onClick, - title = { + headlineContent = { NavigationTitleView( title = title, modifier = modifier.weight(1f, true), diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt index 68899f7f77..032695be88 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt @@ -1,13 +1,17 @@ package net.mullvad.mullvadvpn.compose.cell import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -25,163 +29,210 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.Chevron +import net.mullvad.mullvadvpn.compose.component.MullvadCheckbox +import net.mullvad.mullvadvpn.compose.util.generateRelayItemCountry import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.lib.theme.color.Alpha40 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.selected -import net.mullvad.mullvadvpn.model.GeographicLocationConstraint import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.relaylist.children @Composable @Preview -private fun PreviewRelayLocationCell() { +private fun PreviewStatusRelayLocationCell() { AppTheme { Column(Modifier.background(color = MaterialTheme.colorScheme.background)) { val countryActive = - RelayItem.Country( + generateRelayItemCountry( name = "Relay country Active", - code = "RC1", - expanded = false, - cities = - listOf( - RelayItem.City( - name = "Relay city 1", - code = "RI1", - expanded = false, - location = GeographicLocationConstraint.City("RC1", "RI1"), - relays = - listOf( - RelayItem.Relay( - name = "Relay 1", - active = true, - locationName = "", - location = - GeographicLocationConstraint.Hostname( - "RC1", - "RI1", - "NER" - ) - ) - ) - ), - RelayItem.City( - name = "Relay city 2", - code = "RI2", - expanded = true, - location = GeographicLocationConstraint.City("RC1", "RI2"), - relays = - listOf( - RelayItem.Relay( - name = "Relay 2", - active = true, - locationName = "", - location = - GeographicLocationConstraint.Hostname( - "RC1", - "RI2", - "NER" - ) - ), - RelayItem.Relay( - name = "Relay 3", - active = true, - locationName = "", - location = - GeographicLocationConstraint.Hostname( - "RC1", - "RI1", - "NER" - ) - ) - ) - ) - ) + cityNames = listOf("Relay city 1", "Relay city 2"), + relaysPerCity = 2 ) val countryNotActive = - RelayItem.Country( + generateRelayItemCountry( name = "Not Enabled Relay country", - code = "RC3", + cityNames = listOf("Not Enabled city"), + relaysPerCity = 1, + active = false + ) + val countryExpanded = + generateRelayItemCountry( + name = "Relay country Expanded", + cityNames = listOf("Normal city"), + relaysPerCity = 2, + expanded = true + ) + val countryAndCityExpanded = + generateRelayItemCountry( + name = "Country and city Expanded", + cityNames = listOf("Expanded city A", "Expanded city B"), + relaysPerCity = 2, expanded = true, - cities = - listOf( - RelayItem.City( - name = "Not Enabled city", - code = "RI3", - expanded = true, - location = GeographicLocationConstraint.City("RC3", "RI3"), - relays = - listOf( - RelayItem.Relay( - name = "Not Enabled Relay", - active = false, - locationName = "", - location = - GeographicLocationConstraint.Hostname( - "RC3", - "RI3", - "NER" - ) - ) - ) - ) - ) + expandChildren = true ) // Active relay list not expanded - RelayLocationCell(countryActive) + StatusRelayLocationCell(countryActive) // Not Active Relay - RelayLocationCell(countryNotActive) - // Relay expanded country and city - RelayLocationCell(countryActive.copy(expanded = true)) + StatusRelayLocationCell(countryNotActive) + // Relay expanded country + StatusRelayLocationCell(countryExpanded) + // Relay expanded country and cities + StatusRelayLocationCell(countryAndCityExpanded) + } + } +} + +@Composable +@Preview +private fun PreviewCheckableRelayLocationCell() { + AppTheme { + Column(Modifier.background(color = MaterialTheme.colorScheme.background)) { + val countryActive = + generateRelayItemCountry( + name = "Relay country Active", + cityNames = listOf("Relay city 1", "Relay city 2"), + relaysPerCity = 2 + ) + val countryExpanded = + generateRelayItemCountry( + name = "Relay country Expanded", + cityNames = listOf("Normal city"), + relaysPerCity = 2, + expanded = true + ) + val countryAndCityExpanded = + generateRelayItemCountry( + name = "Country and city Expanded", + cityNames = listOf("Expanded city A", "Expanded city B"), + relaysPerCity = 2, + expanded = true, + expandChildren = true + ) + // Active relay list not expanded + CheckableRelayLocationCell(countryActive) + // Relay expanded country + CheckableRelayLocationCell(countryExpanded) + // Relay expanded country and cities + CheckableRelayLocationCell(countryAndCityExpanded) } } } @Composable -fun RelayLocationCell( +fun StatusRelayLocationCell( relay: RelayItem, modifier: Modifier = Modifier, activeColor: Color = MaterialTheme.colorScheme.selected, inactiveColor: Color = MaterialTheme.colorScheme.error, + disabledColor: Color = MaterialTheme.colorScheme.onSecondary, selectedItem: RelayItem? = null, - onSelectRelay: (item: RelayItem) -> Unit = {} + onSelectRelay: (item: RelayItem) -> Unit = {}, + onLongClick: (item: RelayItem) -> Unit = {} ) { - val startPadding = - when (relay) { - is RelayItem.Country, - is RelayItem.CustomList -> Dimens.countryRowPadding - is RelayItem.City -> Dimens.cityRowPadding - is RelayItem.Relay -> Dimens.relayRowPadding - } - val selected = selectedItem?.code == relay.code + RelayLocationCell( + relay = relay, + leadingContent = { relayItem -> + val selected = selectedItem?.code == relayItem.code + Box( + modifier = + Modifier.align(Alignment.CenterStart) + .size(Dimens.relayCircleSize) + .background( + color = + when { + selected -> Color.Unspecified + relayItem is RelayItem.CustomList && !relayItem.hasChildren -> + disabledColor + relayItem.active -> activeColor + else -> inactiveColor + }, + shape = CircleShape + ) + ) + Image( + painter = painterResource(id = R.drawable.icon_tick), + modifier = + Modifier.align(Alignment.CenterStart) + .alpha( + if (selected) { + AlphaVisible + } else { + AlphaInvisible + } + ), + contentDescription = null + ) + }, + modifier = modifier, + specialBackgroundColor = { relayItem -> + when { + selectedItem?.code == relayItem.code -> MaterialTheme.colorScheme.selected + relayItem is RelayItem.CustomList && !relayItem.active -> + MaterialTheme.colorScheme.surfaceTint + else -> null + } + }, + onClick = onSelectRelay, + onLongClick = onLongClick, + depth = 0 + ) +} + +@Composable +fun CheckableRelayLocationCell( + relay: RelayItem, + modifier: Modifier = Modifier, + onRelayCheckedChange: (item: RelayItem, isChecked: Boolean) -> Unit = { _, _ -> }, + selectedRelays: Set<RelayItem> = emptySet(), +) { + RelayLocationCell( + relay = relay, + leadingContent = { relayItem -> + val checked = selectedRelays.contains(relayItem) + MullvadCheckbox( + checked = checked, + onCheckedChange = { isChecked -> onRelayCheckedChange(relayItem, isChecked) } + ) + }, + leadingContentStartPadding = Dimens.cellStartPaddingInteractive, + modifier = modifier, + onClick = { onRelayCheckedChange(it, !selectedRelays.contains(it)) }, + onLongClick = null, + depth = 0 + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun RelayLocationCell( + relay: RelayItem, + leadingContent: @Composable BoxScope.(relay: RelayItem) -> Unit, + modifier: Modifier = Modifier, + leadingContentStartPadding: Dp = Dimens.cellStartPadding, + leadingContentStarPaddingModifier: Dp = Dimens.mediumPadding, + specialBackgroundColor: @Composable (relayItem: RelayItem) -> Color? = { null }, + onClick: (item: RelayItem) -> Unit, + onLongClick: ((item: RelayItem) -> Unit)?, + depth: Int +) { + val startPadding = leadingContentStartPadding + leadingContentStarPaddingModifier * depth val expanded = rememberSaveable(key = relay.expanded.toString()) { mutableStateOf(relay.expanded) } - val backgroundColor = - when { - selected -> MaterialTheme.colorScheme.inversePrimary - relay is RelayItem.Country -> MaterialTheme.colorScheme.primary - relay is RelayItem.City -> - MaterialTheme.colorScheme.primary - .copy(alpha = Alpha40) - .compositeOver(MaterialTheme.colorScheme.background) - relay is RelayItem.Relay -> MaterialTheme.colorScheme.secondaryContainer - else -> MaterialTheme.colorScheme.primary - } Column( modifier = - modifier.then( - Modifier.fillMaxWidth() - .padding(top = Dimens.listItemDivider) - .wrapContentHeight() - .fillMaxWidth() - ) + modifier + .fillMaxWidth() + .padding(top = Dimens.listItemDivider) + .wrapContentHeight() + .fillMaxWidth() ) { Row( modifier = @@ -189,110 +240,89 @@ fun RelayLocationCell( .wrapContentHeight() .height(IntrinsicSize.Min) .fillMaxWidth() - .background(backgroundColor) + .background(specialBackgroundColor.invoke(relay) ?: depth.toBackgroundColor()) ) { Row( modifier = Modifier.weight(1f) - .then( - if (relay.active) { - Modifier.clickable { onSelectRelay(relay) } - } else { - Modifier - } + .combinedClickable( + enabled = relay.active, + onClick = { onClick(relay) }, + onLongClick = { onLongClick?.invoke(relay) }, ) ) { Box( modifier = Modifier.align(Alignment.CenterVertically).padding(start = startPadding) ) { - Box( - modifier = - Modifier.align(Alignment.CenterStart) - .size(Dimens.relayCircleSize) - .background( - color = - when { - selected -> Color.Transparent - relay.active -> activeColor - else -> inactiveColor - }, - shape = CircleShape - ) - ) - Image( - painter = painterResource(id = R.drawable.icon_tick), - modifier = - Modifier.align(Alignment.CenterStart) - .alpha( - if (selected) { - AlphaVisible - } else { - AlphaInvisible - } - ), - contentDescription = null - ) + leadingContent(relay) } - Text( - text = relay.name, - color = MaterialTheme.colorScheme.onPrimary, - modifier = - Modifier.weight(1f) - .align(Alignment.CenterVertically) - .alpha( - if (relay.active) { - AlphaVisible - } else { - AlphaInactive - } - ) - .padding( - horizontal = Dimens.smallPadding, - vertical = Dimens.mediumPadding - ) + Name( + modifier = Modifier.weight(1f).align(Alignment.CenterVertically), + relay = relay ) } if (relay.hasChildren) { - VerticalDivider( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(vertical = Dimens.verticalDividerPadding) - ) - Chevron( - isExpanded = expanded.value, - modifier = - Modifier.fillMaxHeight() - .clickable { expanded.value = !expanded.value } - .padding(horizontal = Dimens.largePadding) - .align(Alignment.CenterVertically) - ) + ExpandButton(isExpanded = expanded.value) { expand -> expanded.value = expand } } } if (expanded.value) { - when (relay) { - is RelayItem.Country -> { - relay.cities.forEach { relayCity -> - RelayLocationCell( - relay = relayCity, - selectedItem = selectedItem, - onSelectRelay = onSelectRelay, - modifier = Modifier.animateContentSize() - ) - } - } - is RelayItem.City -> { - relay.relays.forEach { relay -> - RelayLocationCell( - relay = relay, - selectedItem = selectedItem, - onSelectRelay = onSelectRelay, - modifier = Modifier.animateContentSize() - ) - } - } - is RelayItem.Relay, - is RelayItem.CustomList -> {} + relay.children().forEach { + RelayLocationCell( + relay = it, + onClick = onClick, + modifier = Modifier.animateContentSize(), + leadingContent = leadingContent, + specialBackgroundColor = specialBackgroundColor, + onLongClick = onLongClick, + depth = depth + 1, + ) } } } } + +@Composable +private fun Name(modifier: Modifier = Modifier, relay: RelayItem) { + Text( + text = relay.name, + color = MaterialTheme.colorScheme.onPrimary, + modifier = + modifier + .alpha( + if (relay.active) { + AlphaVisible + } else { + AlphaInactive + } + ) + .padding(horizontal = Dimens.smallPadding, vertical = Dimens.mediumPadding) + ) +} + +@Composable +private fun RowScope.ExpandButton(isExpanded: Boolean, onClick: (expand: Boolean) -> Unit) { + VerticalDivider( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.padding(vertical = Dimens.verticalDividerPadding) + ) + Chevron( + isExpanded = isExpanded, + modifier = + Modifier.fillMaxHeight() + .clickable { onClick(!isExpanded) } + .padding(horizontal = Dimens.largePadding) + .align(Alignment.CenterVertically) + ) +} + +@Suppress("MagicNumber") +@Composable +private fun Int.toBackgroundColor(): Color = + when (this) { + 0 -> MaterialTheme.colorScheme.surfaceContainerHighest + 1 -> MaterialTheme.colorScheme.surfaceContainerHigh + 2 -> MaterialTheme.colorScheme.surfaceContainerLow + 3 -> MaterialTheme.colorScheme.surfaceContainerLowest + else -> MaterialTheme.colorScheme.surfaceContainerLowest + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt index 686ba8846d..d69194490e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt @@ -56,7 +56,7 @@ fun SelectableCell( ) { BaseCell( onCellClicked = onCellClicked, - title = { BaseCellTitle(title = title, style = titleStyle) }, + headlineContent = { BaseCellTitle(title = title, style = titleStyle) }, background = if (isSelected) { selectedColor diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt index 25b6f71445..98c3767393 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt @@ -89,7 +89,7 @@ fun SplitTunnelingCell( Modifier.align(Alignment.CenterVertically).size(size = Dimens.listIconSize) ) }, - title = { + headlineContent = { Text( text = title, style = MaterialTheme.typography.listItemText, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt index 6aeea8897d..f74349113c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt @@ -121,7 +121,7 @@ private fun SwitchComposeCell( ) { BaseCell( modifier = modifier.focusProperties { canFocus = false }, - title = titleView, + headlineContent = titleView, isRowEnabled = isEnabled, bodyView = { SwitchCellView( 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 new file mode 100644 index 0000000000..0b1f36d21d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt @@ -0,0 +1,50 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.foundation.layout.Column +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.tooling.preview.Preview +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Preview +@Composable +private fun PreviewTwoRowCell() { + AppTheme { TwoRowCell(titleText = "Title", subtitleText = "Subtitle") } +} + +@Composable +fun TwoRowCell( + titleText: String, + subtitleText: String, + onCellClicked: () -> Unit = {}, + titleColor: Color = MaterialTheme.colorScheme.onPrimary, + subtitleColor: Color = MaterialTheme.colorScheme.onPrimary, + background: Color = MaterialTheme.colorScheme.primary +) { + BaseCell( + headlineContent = { + Column(modifier = Modifier.weight(1f)) { + Text( + modifier = Modifier.fillMaxWidth(), + text = titleText, + style = MaterialTheme.typography.labelLarge, + color = titleColor + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = subtitleText, + style = MaterialTheme.typography.labelLarge, + color = subtitleColor + ) + } + }, + onCellClicked = onCellClicked, + background = background, + minHeight = Dimens.cellHeightTwoRows + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListAction.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListAction.kt new file mode 100644 index 0000000000..0b478f5272 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListAction.kt @@ -0,0 +1,33 @@ +package net.mullvad.mullvadvpn.compose.communication + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed interface CustomListAction : Parcelable { + + @Parcelize + data class Rename(val customListId: String, val name: String, val newName: String) : + CustomListAction { + fun not() = this.copy(name = newName, newName = name) + } + + @Parcelize + data class Delete(val customListId: String) : CustomListAction { + fun not(name: String, locations: List<String>) = Create(name, locations) + } + + @Parcelize + data class Create(val name: String = "", val locations: List<String> = emptyList()) : + CustomListAction, Parcelable { + fun not(customListId: String) = Delete(customListId) + } + + @Parcelize + data class UpdateLocations( + val customListId: String, + val locations: List<String> = emptyList() + ) : CustomListAction { + fun not(locations: List<String>): UpdateLocations = + UpdateLocations(customListId = customListId, locations = locations) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListResult.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListResult.kt new file mode 100644 index 0000000000..32fa077a7f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListResult.kt @@ -0,0 +1,34 @@ +package net.mullvad.mullvadvpn.compose.communication + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed interface CustomListResult : Parcelable { + val undo: CustomListAction + + @Parcelize + data class Created( + val id: String, + val name: String, + val locationName: String?, + override val undo: CustomListAction.Delete + ) : CustomListResult + + @Parcelize + data class Deleted(override val undo: CustomListAction.Create) : CustomListResult { + val name + get() = undo.name + } + + @Parcelize + data class Renamed(override val undo: CustomListAction.Rename) : CustomListResult { + val name: String + get() = undo.name + } + + @Parcelize + data class LocationsChanged( + val name: String, + override val undo: CustomListAction.UpdateLocations + ) : CustomListResult +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CustomListNameTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CustomListNameTextField.kt new file mode 100644 index 0000000000..675f6f8f14 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CustomListNameTextField.kt @@ -0,0 +1,72 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.textfield.CustomTextField +import net.mullvad.mullvadvpn.model.CustomListsError + +@Composable +fun CustomListNameTextField( + modifier: Modifier = Modifier, + name: String, + isValidName: Boolean, + error: CustomListsError?, + onValueChanged: (String) -> Unit, + onSubmit: (String) -> Unit +) { + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + CustomTextField( + value = name, + onValueChanged = onValueChanged, + onSubmit = { + if (isValidName) { + onSubmit(it) + } + }, + // This can not be set to KeyboardType.Text because it will show the + // suggestions, this will cause an infinite loop on Android TV with Gboard + keyboardType = KeyboardType.Password, + placeholderText = null, + isValidValue = error == null, + isDigitsOnlyAllowed = false, + supportingText = + error?.let { + { + Text( + text = it.errorString(), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + }, + modifier = + modifier.focusRequester(focusRequester).onFocusChanged { focusState -> + if (focusState.hasFocus) { + keyboardController?.show() + } + } + ) + + LaunchedEffect(Unit) { focusRequester.requestFocus() } +} + +@Composable +private fun CustomListsError.errorString() = + stringResource( + when (this) { + CustomListsError.CustomListExists -> R.string.custom_list_error_list_exists + CustomListsError.OtherError -> R.string.error_occurred + } + ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt new file mode 100644 index 0000000000..7c12443647 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt @@ -0,0 +1,60 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.core.text.HtmlCompat +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH + +@Composable +fun LocationsEmptyText(searchTerm: String) { + if (searchTerm.length >= MIN_SEARCH_LENGTH) { + val firstRow = + HtmlCompat.fromHtml( + textResource( + id = R.string.select_location_empty_text_first_row, + searchTerm, + ), + HtmlCompat.FROM_HTML_MODE_COMPACT, + ) + .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) + val secondRow = textResource(id = R.string.select_location_empty_text_second_row) + Column( + modifier = Modifier.padding(horizontal = Dimens.selectLocationTitlePadding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = firstRow, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSecondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = secondRow, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSecondary, + ) + } + } else { + Text( + text = stringResource(R.string.no_locations_found), + modifier = Modifier.padding(Dimens.screenVerticalMargin), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondary + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadCheckbox.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadCheckbox.kt new file mode 100644 index 0000000000..741fcf960f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadCheckbox.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import net.mullvad.mullvadvpn.lib.theme.color.selected + +@Composable +fun MullvadCheckbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + colors = + CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.onPrimary, + uncheckedColor = MaterialTheme.colorScheme.onPrimary, + checkmarkColor = MaterialTheme.colorScheme.selected + ) + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt new file mode 100644 index 0000000000..1f8fb46cd7 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt @@ -0,0 +1,69 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.compose.cell.HeaderCell +import net.mullvad.mullvadvpn.compose.cell.IconCell +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewMullvadModalBottomSheet() { + AppTheme { + MullvadModalBottomSheet( + sheetContent = { + HeaderCell( + text = "Title", + ) + HorizontalDivider() + IconCell( + iconId = null, + title = "Select", + ) + }, + closeBottomSheet = {} + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MullvadModalBottomSheet( + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer, + onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface, + closeBottomSheet: () -> Unit, + sheetContent: @Composable ColumnScope.() -> Unit +) { + // This is to avoid weird colors in the status bar and the navigation bar + val paddingValues = BottomSheetDefaults.windowInsets.asPaddingValues() + ModalBottomSheet( + onDismissRequest = closeBottomSheet, + sheetState = sheetState, + containerColor = backgroundColor, + modifier = modifier, + windowInsets = WindowInsets(0, 0, 0, 0), // No insets + dragHandle = { BottomSheetDefaults.DragHandle(color = onBackgroundColor) } + ) { + sheetContent() + Spacer(modifier = Modifier.height(Dimens.smallPadding)) + Spacer(modifier = Modifier.height(paddingValues.calculateBottomPadding())) + } +} 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 7ca9af3b17..b9a6306413 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 @@ -108,7 +108,12 @@ fun ScaffoldWithTopBarAndDeviceName( @Composable fun MullvadSnackbar(snackbarData: SnackbarData) { - Snackbar(snackbarData = snackbarData, contentColor = MaterialTheme.colorScheme.secondary) + Snackbar( + snackbarData = snackbarData, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + actionColor = MaterialTheme.colorScheme.onSurface + ) } @Composable @@ -257,3 +262,24 @@ fun ScaffoldWithLargeTopBarAndButton( } ) } + +@Composable +fun ScaffoldWithSmallTopBar( + appBarTitle: String, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + content: @Composable (modifier: Modifier) -> Unit +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + MullvadSmallTopBar( + title = appBarTitle, + navigationIcon = navigationIcon, + actions = actions + ) + }, + content = { content(Modifier.fillMaxSize().padding(it)) } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt index 52e6e03efb..a9db7e0e62 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt @@ -189,6 +189,25 @@ fun MullvadTopBar( ) } +@Composable +fun MullvadSmallTopBar( + title: String, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {} +) { + TopAppBar( + title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = navigationIcon, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + scrolledContainerColor = MaterialTheme.colorScheme.background, + actionIconContentColor = MaterialTheme.colorScheme.onPrimary.copy(AlphaTopBar), + ), + actions = actions + ) +} + @Preview @Composable private fun PreviewMediumTopBar() { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/CommonContentKey.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/CommonContentKey.kt index 41dbcadaa1..899a5b8fee 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/CommonContentKey.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/CommonContentKey.kt @@ -2,6 +2,6 @@ package net.mullvad.mullvadvpn.compose.constant object CommonContentKey { const val DESCRIPTION = "description" - const val SPACER = "spacer" const val PROGRESS = "progress" + const val EMPTY = "empty" } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialog.kt new file mode 100644 index 0000000000..98f2007bc0 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialog.kt @@ -0,0 +1,132 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +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.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.component.CustomListNameTextField +import net.mullvad.mullvadvpn.compose.destinations.CustomListLocationsDestination +import net.mullvad.mullvadvpn.compose.state.CreateCustomListUiState +import net.mullvad.mullvadvpn.compose.test.CREATE_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.viewmodel.CreateCustomListDialogSideEffect +import net.mullvad.mullvadvpn.viewmodel.CreateCustomListDialogViewModel +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Preview +@Composable +private fun PreviewCreateCustomListDialog() { + AppTheme { CreateCustomListDialog(state = CreateCustomListUiState()) } +} + +@Preview +@Composable +private fun PreviewCreateCustomListDialogError() { + AppTheme { + CreateCustomListDialog( + state = CreateCustomListUiState(error = CustomListsError.CustomListExists) + ) + } +} + +@Composable +@Destination(style = DestinationStyle.Dialog::class) +fun CreateCustomList( + navigator: DestinationsNavigator, + backNavigator: ResultBackNavigator<CustomListResult.Created>, + locationCode: String = "" +) { + val vm: CreateCustomListDialogViewModel = + koinViewModel(parameters = { parametersOf(locationCode) }) + LaunchedEffect(key1 = Unit) { + vm.uiSideEffect.collect { sideEffect -> + when (sideEffect) { + is CreateCustomListDialogSideEffect.NavigateToCustomListLocationsScreen -> { + navigator.navigate( + CustomListLocationsDestination( + customListId = sideEffect.customListId, + newList = true + ) + ) { + launchSingleTop = true + } + } + is CreateCustomListDialogSideEffect.ReturnWithResult -> { + backNavigator.navigateBack(result = sideEffect.result) + } + } + } + } + val state by vm.uiState.collectAsStateWithLifecycle() + CreateCustomListDialog( + state = state, + createCustomList = vm::createCustomList, + onInputChanged = vm::clearError, + onDismiss = backNavigator::navigateBack + ) +} + +@Composable +fun CreateCustomListDialog( + state: CreateCustomListUiState, + createCustomList: (String) -> Unit = {}, + onInputChanged: () -> Unit = {}, + onDismiss: () -> Unit = {} +) { + + val name = remember { mutableStateOf("") } + val isValidName by remember { derivedStateOf { name.value.isNotBlank() } } + + AlertDialog( + title = { + Text( + text = stringResource(id = R.string.create_new_list), + ) + }, + text = { + CustomListNameTextField( + name = name.value, + isValidName = isValidName, + error = state.error, + onSubmit = createCustomList, + onValueChanged = { + name.value = it + onInputChanged() + }, + modifier = Modifier.testTag(CREATE_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG) + ) + }, + containerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.onBackground, + onDismissRequest = onDismiss, + confirmButton = { + PrimaryButton( + text = stringResource(id = R.string.create), + onClick = { createCustomList(name.value) }, + isEnabled = isValidName + ) + }, + dismissButton = { + PrimaryButton(text = stringResource(id = R.string.cancel), onClick = onDismiss) + } + ) +} 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 new file mode 100644 index 0000000000..236dedec6a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt @@ -0,0 +1,96 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.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 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.CustomListResult +import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect +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 +import org.koin.core.parameter.parametersOf + +@Preview +@Composable +private fun PreviewRemoveDeviceConfirmationDialog() { + AppTheme { DeleteCustomListConfirmationDialog("My Custom List") } +} + +@Composable +@Destination(style = DestinationStyle.Dialog::class) +fun DeleteCustomList( + navigator: ResultBackNavigator<CustomListResult.Deleted>, + customListId: String, + name: String +) { + val viewModel: DeleteCustomListConfirmationViewModel = + koinViewModel(parameters = { parametersOf(customListId) }) + + LaunchedEffectCollect(viewModel.uiSideEffect) { + when (it) { + is DeleteCustomListConfirmationSideEffect.ReturnWithResult -> + navigator.navigateBack(result = it.result) + } + } + + DeleteCustomListConfirmationDialog( + name = name, + onDelete = viewModel::deleteCustomList, + onBack = navigator::navigateBack + ) +} + +@Composable +fun DeleteCustomListConfirmationDialog( + name: 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 = { + Text( + text = + stringResource(id = R.string.delete_custom_list_confirmation_description, name) + ) + }, + 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/DiscardChangesDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DiscardChangesDialog.kt new file mode 100644 index 0000000000..06f2df0003 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DiscardChangesDialog.kt @@ -0,0 +1,38 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +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 + +@Destination(style = DestinationStyle.Dialog::class) +@Composable +fun DiscardChangesDialog(resultBackNavigator: ResultBackNavigator<Boolean>) { + AlertDialog( + onDismissRequest = resultBackNavigator::navigateBack, + title = { Text(text = stringResource(id = R.string.discard_changes)) }, + dismissButton = { + PrimaryButton( + modifier = Modifier.focusRequester(FocusRequester()), + onClick = resultBackNavigator::navigateBack, + text = stringResource(id = R.string.cancel) + ) + }, + confirmButton = { + PrimaryButton( + onClick = { resultBackNavigator.navigateBack(result = true) }, + text = stringResource(id = R.string.discard) + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialog.kt new file mode 100644 index 0000000000..9f46ee1d5a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialog.kt @@ -0,0 +1,107 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import 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.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.component.CustomListNameTextField +import net.mullvad.mullvadvpn.compose.state.UpdateCustomListUiState +import net.mullvad.mullvadvpn.compose.test.EDIT_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG +import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.viewmodel.EditCustomListNameDialogSideEffect +import net.mullvad.mullvadvpn.viewmodel.EditCustomListNameDialogViewModel +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Preview +@Composable +private fun PreviewEditCustomListNameDialog() { + AppTheme { EditCustomListNameDialog(UpdateCustomListUiState()) } +} + +@Composable +@Destination(style = DestinationStyle.Dialog::class) +fun EditCustomListName( + backNavigator: ResultBackNavigator<CustomListResult.Renamed>, + customListId: String, + initialName: String +) { + val vm: EditCustomListNameDialogViewModel = + koinViewModel(parameters = { parametersOf(customListId, initialName) }) + LaunchedEffectCollect(vm.uiSideEffect) { sideEffect -> + when (sideEffect) { + is EditCustomListNameDialogSideEffect.ReturnWithResult -> { + backNavigator.navigateBack(result = sideEffect.result) + } + } + } + + val state by vm.uiState.collectAsStateWithLifecycle() + EditCustomListNameDialog( + state = state, + updateName = vm::updateCustomListName, + onInputChanged = vm::clearError, + onDismiss = backNavigator::navigateBack + ) +} + +@Composable +fun EditCustomListNameDialog( + state: UpdateCustomListUiState, + updateName: (String) -> Unit = {}, + onInputChanged: () -> Unit = {}, + onDismiss: () -> Unit = {} +) { + val name = remember { mutableStateOf(state.name) } + val isValidName by remember { derivedStateOf { name.value.isNotBlank() } } + + AlertDialog( + title = { + Text( + text = stringResource(id = R.string.update_list_name), + ) + }, + text = { + CustomListNameTextField( + name = name.value, + isValidName = isValidName, + error = state.error, + onSubmit = updateName, + onValueChanged = { + name.value = it + onInputChanged() + }, + modifier = Modifier.testTag(EDIT_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG) + ) + }, + containerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.onBackground, + onDismissRequest = onDismiss, + confirmButton = { + PrimaryButton( + text = stringResource(id = R.string.save), + onClick = { updateName(name.value) }, + isEnabled = isValidName + ) + }, + dismissButton = { + PrimaryButton(text = stringResource(id = R.string.cancel), onClick = onDismiss) + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt index 9c48c0a1e8..a0270989cf 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt @@ -1,7 +1,7 @@ package net.mullvad.mullvadvpn.compose.dialog +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -13,7 +13,6 @@ 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.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.result.EmptyResultBackNavigator @@ -25,6 +24,7 @@ import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.component.HtmlText import net.mullvad.mullvadvpn.compose.component.textResource import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.model.Device @Preview @@ -42,12 +42,12 @@ private fun PreviewRemoveDeviceConfirmationDialog() { @Composable fun RemoveDeviceConfirmationDialog(navigator: ResultBackNavigator<String>, device: Device) { AlertDialog( - onDismissRequest = { navigator.navigateBack() }, + onDismissRequest = navigator::navigateBack, icon = { Icon( + modifier = Modifier.fillMaxWidth().height(Dimens.dialogIconHeight), painter = painterResource(id = R.drawable.icon_alert), - contentDescription = "Remove", - modifier = Modifier.width(50.dp).height(50.dp), + contentDescription = stringResource(id = R.string.remove_button), tint = Color.Unspecified ) }, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SnackbarHostExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SnackbarHostExtensions.kt new file mode 100644 index 0000000000..8a418c17aa --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SnackbarHostExtensions.kt @@ -0,0 +1,18 @@ +package net.mullvad.mullvadvpn.compose.extensions + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult + +suspend fun SnackbarHostState.showSnackbar( + message: String, + actionLabel: String, + duration: SnackbarDuration = SnackbarDuration.Indefinite, + onAction: (() -> Unit), + onDismiss: (() -> Unit) = {} +) { + when (showSnackbar(message = message, actionLabel = actionLabel, duration = duration)) { + SnackbarResult.ActionPerformed -> onAction() + SnackbarResult.Dismissed -> onDismiss() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt new file mode 100644 index 0000000000..3bd924a189 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt @@ -0,0 +1,219 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.result.ResultRecipient +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.CheckableRelayLocationCell +import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText +import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge +import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithSmallTopBar +import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.constant.CommonContentKey +import net.mullvad.mullvadvpn.compose.constant.ContentType +import net.mullvad.mullvadvpn.compose.destinations.DiscardChangesDialogDestination +import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState +import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR +import net.mullvad.mullvadvpn.compose.test.SAVE_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.compose.textfield.SearchTextField +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition +import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar +import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.viewmodel.CustomListLocationsSideEffect +import net.mullvad.mullvadvpn.viewmodel.CustomListLocationsViewModel +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +@Preview +private fun PreviewCustomListLocationScreen() { + AppTheme { CustomListLocationsScreen(state = CustomListLocationsUiState.Content.Data()) } +} + +@Composable +@Destination(style = SlideInFromRightTransition::class) +fun CustomListLocations( + navigator: DestinationsNavigator, + backNavigator: ResultBackNavigator<CustomListResult.LocationsChanged>, + discardChangesResultRecipient: ResultRecipient<DiscardChangesDialogDestination, Boolean>, + customListId: String, + newList: Boolean, +) { + val customListsViewModel = + koinViewModel<CustomListLocationsViewModel>( + parameters = { parametersOf(customListId, newList) } + ) + + discardChangesResultRecipient.onNavResult { + when (it) { + NavResult.Canceled -> {} + is NavResult.Value -> { + if (it.value) { + backNavigator.navigateBack() + } + } + } + } + + LaunchedEffectCollect(customListsViewModel.uiSideEffect) { sideEffect -> + when (sideEffect) { + is CustomListLocationsSideEffect.ReturnWithResult -> + backNavigator.navigateBack(result = sideEffect.result) + CustomListLocationsSideEffect.CloseScreen -> backNavigator.navigateBack() + } + } + + val state by customListsViewModel.uiState.collectAsStateWithLifecycle() + CustomListLocationsScreen( + state = state, + onSearchTermInput = customListsViewModel::onSearchTermInput, + onSaveClick = customListsViewModel::save, + onRelaySelectionClick = customListsViewModel::onRelaySelectionClick, + onBackClick = { + if (state.hasUnsavedChanges) { + navigator.navigate(DiscardChangesDialogDestination) { launchSingleTop = true } + } else { + backNavigator.navigateBack() + } + } + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CustomListLocationsScreen( + state: CustomListLocationsUiState, + onSearchTermInput: (String) -> Unit = {}, + onSaveClick: () -> Unit = {}, + onRelaySelectionClick: (RelayItem, selected: Boolean) -> Unit = { _, _ -> }, + onBackClick: () -> Unit = {} +) { + ScaffoldWithSmallTopBar( + appBarTitle = + stringResource( + if (state.newList) { + R.string.add_locations + } else { + R.string.edit_locations + } + ), + navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) }, + actions = { Actions(isSaveEnabled = state.saveEnabled, onSaveClick = onSaveClick) } + ) { modifier -> + Column(modifier = modifier) { + SearchTextField( + modifier = + Modifier.fillMaxWidth() + .height(Dimens.searchFieldHeight) + .padding(horizontal = Dimens.searchFieldHorizontalPadding), + backgroundColor = MaterialTheme.colorScheme.tertiaryContainer, + textColor = MaterialTheme.colorScheme.onTertiaryContainer, + ) { searchString -> + onSearchTermInput.invoke(searchString) + } + Spacer(modifier = Modifier.height(Dimens.verticalSpace)) + val lazyListState = rememberLazyListState() + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier.drawVerticalScrollbar( + state = lazyListState, + color = + MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar) + ) + .fillMaxWidth(), + state = lazyListState, + ) { + when (state) { + is CustomListLocationsUiState.Loading -> { + loading() + } + is CustomListLocationsUiState.Content.Empty -> { + empty(searchTerm = state.searchTerm) + } + is CustomListLocationsUiState.Content.Data -> { + content(uiState = state, onRelaySelectedChanged = onRelaySelectionClick) + } + } + } + } + } +} + +@Composable +private fun Actions(isSaveEnabled: Boolean, onSaveClick: () -> Unit) { + TextButton( + onClick = onSaveClick, + enabled = isSaveEnabled, + colors = + ButtonDefaults.textButtonColors() + .copy(contentColor = MaterialTheme.colorScheme.onPrimary), + modifier = Modifier.testTag(SAVE_BUTTON_TEST_TAG) + ) { + Text( + text = stringResource(R.string.save), + ) + } +} + +private fun LazyListScope.loading() { + item(key = CommonContentKey.PROGRESS, contentType = ContentType.PROGRESS) { + MullvadCircularProgressIndicatorLarge( + modifier = Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR) + ) + } +} + +private fun LazyListScope.empty(searchTerm: String) { + item(key = CommonContentKey.EMPTY, contentType = ContentType.EMPTY_TEXT) { + LocationsEmptyText(searchTerm = searchTerm) + } +} + +private fun LazyListScope.content( + uiState: CustomListLocationsUiState.Content.Data, + onRelaySelectedChanged: (RelayItem, selected: Boolean) -> Unit, +) { + items( + count = uiState.availableLocations.size, + key = { index -> uiState.availableLocations[index].hashCode() }, + contentType = { ContentType.ITEM }, + ) { index -> + val country = uiState.availableLocations[index] + CheckableRelayLocationCell( + relay = country, + modifier = Modifier.animateContentSize(), + onRelayCheckedChange = onRelaySelectedChanged, + selectedRelays = uiState.selectedLocations, + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt new file mode 100644 index 0000000000..20a92132f1 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt @@ -0,0 +1,193 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +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.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.graphics.compositeOver +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.lifecycle.compose.collectAsStateWithLifecycle +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultRecipient +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell +import net.mullvad.mullvadvpn.compose.communication.CustomListResult +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.constant.ContentType +import net.mullvad.mullvadvpn.compose.destinations.CreateCustomListDestination +import net.mullvad.mullvadvpn.compose.destinations.EditCustomListDestination +import net.mullvad.mullvadvpn.compose.extensions.itemsWithDivider +import net.mullvad.mullvadvpn.compose.extensions.showSnackbar +import net.mullvad.mullvadvpn.compose.state.CustomListsUiState +import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR +import net.mullvad.mullvadvpn.compose.test.NEW_LIST_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.Alpha60 +import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.viewmodel.CustomListsViewModel +import org.koin.androidx.compose.koinViewModel + +@Preview +@Composable +private fun PreviewCustomListsScreen() { + AppTheme { CustomListsScreen(CustomListsUiState.Content(), SnackbarHostState()) } +} + +@Composable +@Destination(style = SlideInFromRightTransition::class) +fun CustomLists( + navigator: DestinationsNavigator, + editCustomListResultRecipient: + ResultRecipient<EditCustomListDestination, CustomListResult.Deleted> +) { + val viewModel = koinViewModel<CustomListsViewModel>() + val state by viewModel.uiState.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + + editCustomListResultRecipient.onNavResult { result -> + when (result) { + NavResult.Canceled -> { + /* Do nothing */ + } + is NavResult.Value -> { + scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar( + message = + context.getString( + R.string.delete_custom_list_message, + result.value.name + ), + actionLabel = context.getString(R.string.undo), + duration = SnackbarDuration.Long, + onAction = { viewModel.undoDeleteCustomList(result.value.undo) } + ) + } + } + } + } + + CustomListsScreen( + state = state, + snackbarHostState = snackbarHostState, + addCustomList = { + navigator.navigate( + CreateCustomListDestination(), + ) { + launchSingleTop = true + } + }, + openCustomList = { customList -> + navigator.navigate(EditCustomListDestination(customListId = customList.id)) { + launchSingleTop = true + } + }, + onBackClick = navigator::navigateUp + ) +} + +@Composable +fun CustomListsScreen( + state: CustomListsUiState, + snackbarHostState: SnackbarHostState, + addCustomList: () -> Unit = {}, + openCustomList: (RelayItem.CustomList) -> Unit = {}, + onBackClick: () -> Unit = {} +) { + ScaffoldWithMediumTopBar( + appBarTitle = stringResource(id = R.string.edit_custom_lists), + navigationIcon = { NavigateBackIconButton(onBackClick) }, + actions = { + IconButton( + onClick = addCustomList, + modifier = Modifier.testTag(NEW_LIST_BUTTON_TEST_TAG) + ) { + Icon( + painterResource(id = R.drawable.ic_icons_add), + tint = + MaterialTheme.colorScheme.onBackground + .copy(alpha = Alpha60) + .compositeOver(MaterialTheme.colorScheme.background), + contentDescription = stringResource(id = R.string.new_list) + ) + } + }, + snackbarHostState = snackbarHostState + ) { modifier: Modifier, lazyListState: LazyListState -> + LazyColumn( + modifier = modifier, + state = lazyListState, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (state) { + is CustomListsUiState.Content -> { + if (state.customLists.isNotEmpty()) { + content(customLists = state.customLists, openCustomList = openCustomList) + } else { + empty() + } + } + is CustomListsUiState.Loading -> { + loading() + } + } + } + } +} + +private fun LazyListScope.loading() { + item(contentType = ContentType.PROGRESS) { + MullvadCircularProgressIndicatorLarge( + modifier = Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR) + ) + } +} + +private fun LazyListScope.content( + customLists: List<RelayItem.CustomList>, + openCustomList: (RelayItem.CustomList) -> Unit +) { + itemsWithDivider( + items = customLists, + key = { item: RelayItem.CustomList -> item.id }, + contentType = { ContentType.ITEM } + ) { customList -> + NavigationComposeCell(title = customList.name, onClick = { openCustomList(customList) }) + } +} + +private fun LazyListScope.empty() { + item(contentType = ContentType.EMPTY_TEXT) { + Text( + text = stringResource(R.string.no_custom_lists_available), + modifier = Modifier.padding(Dimens.screenVerticalMargin), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondary + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt index 6e762bbf43..e6402fc8bd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt @@ -350,7 +350,7 @@ private fun DeviceListItem( ) { BaseCell( isRowEnabled = false, - title = { + headlineContent = { Column(modifier = Modifier.weight(1f)) { Text( modifier = Modifier.fillMaxWidth(), 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 new file mode 100644 index 0000000000..87ed88d263 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt @@ -0,0 +1,222 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DropdownMenu +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 +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.pluralStringResource +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.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.result.ResultRecipient +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.TwoRowCell +import net.mullvad.mullvadvpn.compose.communication.CustomListResult +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.component.SpacedColumn +import net.mullvad.mullvadvpn.compose.destinations.CustomListLocationsDestination +import net.mullvad.mullvadvpn.compose.destinations.DeleteCustomListDestination +import net.mullvad.mullvadvpn.compose.destinations.EditCustomListNameDestination +import net.mullvad.mullvadvpn.compose.state.EditCustomListState +import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR +import net.mullvad.mullvadvpn.compose.test.DELETE_DROPDOWN_MENU_ITEM_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.TOP_BAR_DROPDOWN_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.model.GeographicLocationConstraint +import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.viewmodel.EditCustomListViewModel +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Preview +@Composable +private fun PreviewEditCustomListScreen() { + AppTheme { + EditCustomListScreen( + state = + EditCustomListState.Content( + id = "id", + name = "Custom list", + locations = + listOf( + RelayItem.Relay( + "Relay", + "Relay", + true, + GeographicLocationConstraint.Hostname( + "hostname", + "hostname", + "hostname" + ) + ) + ) + ) + ) + } +} + +@Composable +@Destination(style = SlideInFromRightTransition::class) +fun EditCustomList( + navigator: DestinationsNavigator, + backNavigator: ResultBackNavigator<CustomListResult.Deleted>, + customListId: String, + confirmDeleteListResultRecipient: + ResultRecipient<DeleteCustomListDestination, CustomListResult.Deleted> +) { + val viewModel = + koinViewModel<EditCustomListViewModel>(parameters = { parametersOf(customListId) }) + + confirmDeleteListResultRecipient.onNavResult { + when (it) { + NavResult.Canceled -> { + // Do nothing + } + is NavResult.Value -> backNavigator.navigateBack(result = it.value) + } + } + + val state by viewModel.uiState.collectAsStateWithLifecycle() + EditCustomListScreen( + state = state, + onDeleteList = { name -> + navigator.navigate( + DeleteCustomListDestination(customListId = customListId, name = name) + ) { + launchSingleTop = true + } + }, + onNameClicked = { id, name -> + navigator.navigate( + EditCustomListNameDestination(customListId = id, initialName = name) + ) { + launchSingleTop = true + } + }, + onLocationsClicked = { + navigator.navigate(CustomListLocationsDestination(customListId = it, newList = false)) { + launchSingleTop = true + } + }, + onBackClick = backNavigator::navigateBack + ) +} + +@Composable +fun EditCustomListScreen( + state: EditCustomListState, + onDeleteList: (name: String) -> Unit = {}, + onNameClicked: (id: String, name: String) -> Unit = { _, _ -> }, + onLocationsClicked: (String) -> Unit = {}, + onBackClick: () -> Unit = {} +) { + val title = + when (state) { + EditCustomListState.Loading, + EditCustomListState.NotFound -> "" + is EditCustomListState.Content -> state.name + } + ScaffoldWithMediumTopBar( + appBarTitle = stringResource(id = R.string.edit_list), + navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) }, + actions = { Actions(onDeleteList = { onDeleteList(title) }) }, + ) { modifier: Modifier -> + SpacedColumn(modifier = modifier, alignment = Alignment.Top) { + when (state) { + EditCustomListState.Loading -> { + MullvadCircularProgressIndicatorLarge( + modifier = Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR) + ) + } + EditCustomListState.NotFound -> { + Text( + text = stringResource(id = R.string.not_found), + modifier = Modifier.padding(Dimens.screenVerticalMargin), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondary + ) + } + is EditCustomListState.Content -> { + // Name cell + TwoRowCell( + titleText = stringResource(id = R.string.list_name), + subtitleText = state.name, + onCellClicked = { onNameClicked(state.id, state.name) } + ) + // Locations cell + TwoRowCell( + titleText = stringResource(id = R.string.locations), + subtitleText = + pluralStringResource( + id = R.plurals.number_of_locations, + state.locations.size, + state.locations.size + ), + onCellClicked = { onLocationsClicked(state.id) } + ) + } + } + } + } +} + +@Composable +private fun Actions(onDeleteList: () -> Unit) { + var showMenu by remember { mutableStateOf(false) } + IconButton( + onClick = { showMenu = true }, + modifier = Modifier.testTag(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_list)) }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.icon_delete), + contentDescription = null, + ) + }, + colors = + MenuDefaults.itemColors() + .copy( + leadingIconColor = MaterialTheme.colorScheme.onSurface, + textColor = MaterialTheme.colorScheme.onSurface, + ), + onClick = { + onDeleteList() + showMenu = false + }, + modifier = Modifier.testTag(DELETE_DROPDOWN_MENU_ITEM_TEST_TAG) + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt index 1f17da8bc5..594c657cdb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt @@ -1,6 +1,8 @@ package net.mullvad.mullvadvpn.compose.screen +import android.content.Context import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.layout.Column @@ -13,49 +15,81 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SheetState +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.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.draw.rotate import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.core.text.HtmlCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultRecipient +import com.ramcosta.composedestinations.spec.DestinationSpec +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.cell.FilterCell -import net.mullvad.mullvadvpn.compose.cell.RelayLocationCell +import net.mullvad.mullvadvpn.compose.cell.HeaderCell +import net.mullvad.mullvadvpn.compose.cell.IconCell +import net.mullvad.mullvadvpn.compose.cell.StatusRelayLocationCell +import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell +import net.mullvad.mullvadvpn.compose.cell.ThreeDotCell +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge +import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomSheet +import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar -import net.mullvad.mullvadvpn.compose.component.textResource import net.mullvad.mullvadvpn.compose.constant.ContentType +import net.mullvad.mullvadvpn.compose.destinations.CreateCustomListDestination +import net.mullvad.mullvadvpn.compose.destinations.CustomListLocationsDestination +import net.mullvad.mullvadvpn.compose.destinations.CustomListsDestination +import net.mullvad.mullvadvpn.compose.destinations.DeleteCustomListDestination +import net.mullvad.mullvadvpn.compose.destinations.EditCustomListNameDestination import net.mullvad.mullvadvpn.compose.destinations.FilterScreenDestination -import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString -import net.mullvad.mullvadvpn.compose.state.RelayListState +import net.mullvad.mullvadvpn.compose.extensions.showSnackbar import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG import net.mullvad.mullvadvpn.compose.textfield.SearchTextField import net.mullvad.mullvadvpn.compose.transitions.SelectLocationTransition import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect +import net.mullvad.mullvadvpn.compose.util.RunOnKeyChange import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar +import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.relaylist.canAddLocation import net.mullvad.mullvadvpn.viewmodel.SelectLocationSideEffect import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import org.koin.androidx.compose.koinViewModel @@ -64,16 +98,14 @@ import org.koin.androidx.compose.koinViewModel @Composable private fun PreviewSelectLocationScreen() { val state = - SelectLocationUiState.Data( + SelectLocationUiState.Content( searchTerm = "", selectedOwnership = null, selectedProvidersCount = 0, - relayListState = - RelayListState.RelayList( - countries = - listOf(RelayItem.Country("Country 1", "Code 1", false, emptyList())), - selectedItem = null, - ) + countries = listOf(RelayItem.Country("Country 1", "Code 1", false, emptyList())), + selectedItem = null, + customLists = emptyList(), + filteredCustomLists = emptyList() ) AppTheme { SelectLocationScreen( @@ -84,23 +116,84 @@ private fun PreviewSelectLocationScreen() { @Destination(style = SelectLocationTransition::class) @Composable -fun SelectLocation(navigator: DestinationsNavigator) { +fun SelectLocation( + navigator: DestinationsNavigator, + createCustomListDialogResultRecipient: + ResultRecipient<CreateCustomListDestination, CustomListResult.Created>, + editCustomListNameDialogResultRecipient: + ResultRecipient<EditCustomListNameDestination, CustomListResult.Renamed>, + deleteCustomListDialogResultRecipient: + ResultRecipient<DeleteCustomListDestination, CustomListResult.Deleted>, + updateCustomListResultRecipient: + ResultRecipient<CustomListLocationsDestination, CustomListResult.LocationsChanged> +) { val vm = koinViewModel<SelectLocationViewModel>() - val state by vm.uiState.collectAsStateWithLifecycle() + val state = vm.uiState.collectAsStateWithLifecycle().value + + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + LaunchedEffectCollect(vm.uiSideEffect) { when (it) { SelectLocationSideEffect.CloseScreen -> navigator.navigateUp() + is SelectLocationSideEffect.LocationAddedToCustomList -> { + launch { + snackbarHostState.showResultSnackbar( + context = context, + result = it.result, + onUndo = vm::performAction + ) + } + } } } + createCustomListDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + vm::performAction + ) + + editCustomListNameDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + vm::performAction + ) + + deleteCustomListDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + vm::performAction + ) + + updateCustomListResultRecipient.OnCustomListNavResult(snackbarHostState, vm::performAction) + SelectLocationScreen( state = state, + snackbarHostState = snackbarHostState, onSelectRelay = vm::selectRelay, onSearchTermInput = vm::onSearchTermInput, onBackClick = navigator::navigateUp, onFilterClick = { navigator.navigate(FilterScreenDestination, true) }, + onCreateCustomList = { relayItem -> + navigator.navigate(CreateCustomListDestination(locationCode = relayItem?.code ?: "")) { + launchSingleTop = true + } + }, + onEditCustomLists = { navigator.navigate(CustomListsDestination()) }, removeOwnershipFilter = vm::removeOwnerFilter, - removeProviderFilter = vm::removeProviderFilter + removeProviderFilter = vm::removeProviderFilter, + onAddLocationToList = vm::addLocationToList, + onEditCustomListName = { + navigator.navigate( + EditCustomListNameDestination(customListId = it.id, initialName = it.name) + ) + }, + onEditLocationsCustomList = { + navigator.navigate( + CustomListLocationsDestination(customListId = it.id, newList = false) + ) + }, + onDeleteCustomList = { + navigator.navigate(DeleteCustomListDestination(customListId = it.id, name = it.name)) + } ) } @@ -108,16 +201,43 @@ fun SelectLocation(navigator: DestinationsNavigator) { @Composable fun SelectLocationScreen( state: SelectLocationUiState, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, onSelectRelay: (item: RelayItem) -> Unit = {}, onSearchTermInput: (searchTerm: String) -> Unit = {}, onBackClick: () -> Unit = {}, onFilterClick: () -> Unit = {}, + onCreateCustomList: (location: RelayItem?) -> Unit = {}, + onEditCustomLists: () -> Unit = {}, removeOwnershipFilter: () -> Unit = {}, - removeProviderFilter: () -> Unit = {} + removeProviderFilter: () -> Unit = {}, + onAddLocationToList: (location: RelayItem, customList: RelayItem.CustomList) -> Unit = { _, _ -> + }, + onEditCustomListName: (RelayItem.CustomList) -> Unit = {}, + onEditLocationsCustomList: (RelayItem.CustomList) -> Unit = {}, + onDeleteCustomList: (RelayItem.CustomList) -> Unit = {} ) { val backgroundColor = MaterialTheme.colorScheme.background - Scaffold { + Scaffold( + snackbarHost = { + SnackbarHost( + snackbarHostState, + snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) } + ) + } + ) { + var bottomSheetState by remember { mutableStateOf<BottomSheetState?>(null) } + BottomSheets( + bottomSheetState = bottomSheetState, + onCreateCustomList = onCreateCustomList, + onEditCustomLists = onEditCustomLists, + onAddLocationToList = onAddLocationToList, + onEditCustomListName = onEditCustomListName, + onEditLocationsCustomList = onEditLocationsCustomList, + onDeleteCustomList = onDeleteCustomList, + onHideBottomSheet = { bottomSheetState = null } + ) + Column(modifier = Modifier.padding(it).background(backgroundColor).fillMaxSize()) { Row(modifier = Modifier.fillMaxWidth()) { IconButton(onClick = onBackClick) { @@ -133,7 +253,7 @@ fun SelectLocationScreen( modifier = Modifier.align(Alignment.CenterVertically).weight(weight = 1f), textAlign = TextAlign.Center, style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onPrimary + color = MaterialTheme.colorScheme.onPrimary, ) IconButton(onClick = onFilterClick) { Icon( @@ -146,13 +266,13 @@ fun SelectLocationScreen( when (state) { SelectLocationUiState.Loading -> {} - is SelectLocationUiState.Data -> { + is SelectLocationUiState.Content -> { if (state.hasFilter) { FilterCell( ownershipFilter = state.selectedOwnership, selectedProviderFilter = state.selectedProvidersCount, removeOwnershipFilter = removeOwnershipFilter, - removeProviderFilter = removeProviderFilter + removeProviderFilter = removeProviderFilter, ) } } @@ -164,24 +284,20 @@ fun SelectLocationScreen( .height(Dimens.searchFieldHeight) .padding(horizontal = Dimens.searchFieldHorizontalPadding), backgroundColor = MaterialTheme.colorScheme.tertiaryContainer, - textColor = MaterialTheme.colorScheme.onTertiaryContainer + textColor = MaterialTheme.colorScheme.onTertiaryContainer, ) { searchString -> onSearchTermInput.invoke(searchString) } Spacer(modifier = Modifier.height(height = Dimens.verticalSpace)) val lazyListState = rememberLazyListState() - if ( - state is SelectLocationUiState.Data && - state.relayListState is RelayListState.RelayList && - state.relayListState.selectedItem != null - ) { - LaunchedEffect(state.relayListState.selectedItem) { - val index = state.relayListState.indexOfSelectedRelayItem() + val selectedItemCode = + (state as? SelectLocationUiState.Content)?.selectedItem?.code ?: "" + RunOnKeyChange(key = selectedItemCode) { + val index = state.indexOfSelectedRelayItem() - if (index >= 0) { - lazyListState.scrollToItem(index) - lazyListState.animateScrollAndCentralizeItem(index) - } + if (index >= 0) { + lazyListState.scrollToItem(index) + lazyListState.animateScrollAndCentralizeItem(index) } } LazyColumn( @@ -189,7 +305,7 @@ fun SelectLocationScreen( Modifier.fillMaxSize() .drawVerticalScrollbar( lazyListState, - MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar) + MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar), ), state = lazyListState, horizontalAlignment = Alignment.CenterHorizontally, @@ -198,12 +314,42 @@ fun SelectLocationScreen( SelectLocationUiState.Loading -> { loading() } - is SelectLocationUiState.Data -> { - relayList( - relayListState = state.relayListState, - searchTerm = state.searchTerm, - onSelectRelay = onSelectRelay - ) + is SelectLocationUiState.Content -> { + if (state.showCustomLists) { + customLists( + customLists = state.filteredCustomLists, + selectedItem = state.selectedItem, + onSelectRelay = onSelectRelay, + onShowCustomListBottomSheet = { + bottomSheetState = + BottomSheetState.ShowCustomListsBottomSheet( + state.customLists.isNotEmpty() + ) + }, + onShowEditBottomSheet = { customList -> + bottomSheetState = + BottomSheetState.ShowEditCustomListBottomSheet(customList) + } + ) + item { Spacer(modifier = Modifier.height(Dimens.mediumPadding)) } + } + if (state.countries.isNotEmpty()) { + relayList( + countries = state.countries, + selectedItem = state.selectedItem, + onSelectRelay = onSelectRelay, + onShowLocationBottomSheet = { location -> + bottomSheetState = + BottomSheetState.ShowLocationBottomSheet( + customLists = state.customLists, + item = location + ) + } + ) + } + if (state.showEmpty) { + item { LocationsEmptyText(searchTerm = state.searchTerm) } + } } } } @@ -217,78 +363,337 @@ private fun LazyListScope.loading() { } } -private fun LazyListScope.relayList( - relayListState: RelayListState, - searchTerm: String, - onSelectRelay: (item: RelayItem) -> Unit +@OptIn(ExperimentalFoundationApi::class) +private fun LazyListScope.customLists( + customLists: List<RelayItem.CustomList>, + selectedItem: RelayItem?, + onSelectRelay: (item: RelayItem) -> Unit, + onShowCustomListBottomSheet: () -> Unit, + onShowEditBottomSheet: (RelayItem.CustomList) -> Unit ) { - when (relayListState) { - is RelayListState.RelayList -> { - items( - count = relayListState.countries.size, - key = { index -> relayListState.countries[index].hashCode() }, - contentType = { ContentType.ITEM } - ) { index -> - val country = relayListState.countries[index] - RelayLocationCell( - relay = country, - selectedItem = relayListState.selectedItem, - onSelectRelay = onSelectRelay, - modifier = Modifier.animateContentSize() - ) - } + item( + contentType = { ContentType.HEADER }, + ) { + ThreeDotCell( + text = stringResource(R.string.custom_lists), + onClickDots = onShowCustomListBottomSheet, + modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG) + ) + } + if (customLists.isNotEmpty()) { + items( + items = customLists, + key = { item -> item.code }, + contentType = { ContentType.ITEM }, + ) { customList -> + StatusRelayLocationCell( + relay = customList, + // Do not show selection for locations in custom lists + selectedItem = selectedItem as? RelayItem.CustomList, + onSelectRelay = onSelectRelay, + onLongClick = { + if (it is RelayItem.CustomList) { + onShowEditBottomSheet(it) + } + }, + modifier = Modifier.animateContentSize().animateItemPlacement(), + ) } - RelayListState.Empty -> { - if (searchTerm.isNotEmpty()) - item(contentType = ContentType.EMPTY_TEXT) { - val firstRow = - HtmlCompat.fromHtml( - textResource( - id = R.string.select_location_empty_text_first_row, - searchTerm - ), - HtmlCompat.FROM_HTML_MODE_COMPACT - ) - .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) - val secondRow = - textResource(id = R.string.select_location_empty_text_second_row) - Column( - modifier = Modifier.padding(horizontal = Dimens.selectLocationTitlePadding), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = firstRow, - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSecondary, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Text( - text = secondRow, - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSecondary - ) + item { + SwitchComposeSubtitleCell(text = stringResource(R.string.to_add_locations_to_a_list)) + } + } else { + item(contentType = ContentType.EMPTY_TEXT) { + SwitchComposeSubtitleCell(text = stringResource(R.string.to_create_a_custom_list)) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +private fun LazyListScope.relayList( + countries: List<RelayItem.Country>, + selectedItem: RelayItem?, + onSelectRelay: (item: RelayItem) -> Unit, + onShowLocationBottomSheet: (item: RelayItem) -> Unit, +) { + item( + contentType = ContentType.HEADER, + ) { + HeaderCell( + text = stringResource(R.string.all_locations), + ) + } + items( + items = countries, + key = { item -> item.code }, + contentType = { ContentType.ITEM }, + ) { country -> + StatusRelayLocationCell( + relay = country, + selectedItem = selectedItem, + onSelectRelay = onSelectRelay, + onLongClick = onShowLocationBottomSheet, + modifier = Modifier.animateContentSize().animateItemPlacement(), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BottomSheets( + bottomSheetState: BottomSheetState?, + onCreateCustomList: (RelayItem?) -> Unit, + onEditCustomLists: () -> Unit, + onAddLocationToList: (RelayItem, RelayItem.CustomList) -> Unit, + onEditCustomListName: (RelayItem.CustomList) -> Unit, + onEditLocationsCustomList: (RelayItem.CustomList) -> Unit, + onDeleteCustomList: (RelayItem.CustomList) -> Unit, + onHideBottomSheet: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + val onCloseBottomSheet: (animate: Boolean) -> Unit = { animate -> + if (animate) { + scope + .launch { sheetState.hide() } + .invokeOnCompletion { + if (!sheetState.isVisible) { + onHideBottomSheet() } } + } else { + onHideBottomSheet() + } + } + val onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface + + when (bottomSheetState) { + is BottomSheetState.ShowCustomListsBottomSheet -> { + CustomListsBottomSheet( + sheetState = sheetState, + onBackgroundColor = onBackgroundColor, + bottomSheetState = bottomSheetState, + onCreateCustomList = { onCreateCustomList(null) }, + onEditCustomLists = onEditCustomLists, + closeBottomSheet = onCloseBottomSheet + ) + } + is BottomSheetState.ShowLocationBottomSheet -> { + LocationBottomSheet( + sheetState = sheetState, + onBackgroundColor = onBackgroundColor, + customLists = bottomSheetState.customLists, + item = bottomSheetState.item, + onCreateCustomList = onCreateCustomList, + onAddLocationToList = onAddLocationToList, + closeBottomSheet = onCloseBottomSheet + ) + } + is BottomSheetState.ShowEditCustomListBottomSheet -> { + EditCustomListBottomSheet( + sheetState = sheetState, + onBackgroundColor = onBackgroundColor, + customList = bottomSheetState.customList, + onEditName = onEditCustomListName, + onEditLocations = onEditLocationsCustomList, + onDeleteCustomList = onDeleteCustomList, + closeBottomSheet = onCloseBottomSheet + ) + } + null -> { + /* Do nothing */ } } } -private fun RelayListState.RelayList.indexOfSelectedRelayItem(): Int = - countries.indexOfFirst { relayCountry -> - relayCountry.location.location.country == - when (selectedItem) { - is RelayItem.Country -> selectedItem.code - is RelayItem.City -> selectedItem.location.countryCode - is RelayItem.Relay -> selectedItem.location.countryCode - is RelayItem.CustomList, - null -> null - } +private fun SelectLocationUiState.indexOfSelectedRelayItem(): Int = + if (this is SelectLocationUiState.Content) { + when (selectedItem) { + is RelayItem.Country, + is RelayItem.City, + is RelayItem.Relay -> + countries.indexOfFirst { it.code == selectedItem.countryCode() } + + customLists.size + + EXTRA_ITEMS_LOCATION + is RelayItem.CustomList -> + filteredCustomLists.indexOfFirst { it.id == selectedItem.id } + + EXTRA_ITEM_CUSTOM_LIST + else -> -1 + } + } else { + -1 + } + +private fun RelayItem.countryCode(): String = + when (this) { + is RelayItem.Country -> this.code + is RelayItem.City -> this.location.countryCode + is RelayItem.Relay -> this.location.countryCode + is RelayItem.CustomList -> + throw IllegalArgumentException("Custom list does not have a country code") } -suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) { +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CustomListsBottomSheet( + onBackgroundColor: Color, + sheetState: SheetState, + bottomSheetState: BottomSheetState.ShowCustomListsBottomSheet, + onCreateCustomList: () -> Unit, + onEditCustomLists: () -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit +) { + MullvadModalBottomSheet( + sheetState = sheetState, + closeBottomSheet = { closeBottomSheet(false) }, + modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG) + ) { -> + HeaderCell( + text = stringResource(id = R.string.edit_custom_lists), + background = Color.Unspecified + ) + HorizontalDivider(color = onBackgroundColor) + IconCell( + iconId = R.drawable.icon_add, + title = stringResource(id = R.string.new_list), + onClick = { + onCreateCustomList() + closeBottomSheet(true) + }, + background = Color.Unspecified, + titleColor = onBackgroundColor + ) + IconCell( + iconId = R.drawable.icon_edit, + title = stringResource(id = R.string.edit_lists), + onClick = { + onEditCustomLists() + closeBottomSheet(true) + }, + background = Color.Unspecified, + titleColor = + onBackgroundColor.copy( + alpha = + if (bottomSheetState.editListEnabled) { + AlphaVisible + } else { + AlphaInactive + } + ), + enabled = bottomSheetState.editListEnabled + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LocationBottomSheet( + onBackgroundColor: Color, + sheetState: SheetState, + customLists: List<RelayItem.CustomList>, + item: RelayItem, + onCreateCustomList: (relayItem: RelayItem) -> Unit, + onAddLocationToList: (location: RelayItem, customList: RelayItem.CustomList) -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit +) { + MullvadModalBottomSheet( + sheetState = sheetState, + closeBottomSheet = { closeBottomSheet(false) }, + modifier = Modifier.testTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG) + ) { -> + HeaderCell( + text = stringResource(id = R.string.add_location_to_list, item.name), + background = Color.Unspecified + ) + HorizontalDivider(color = onBackgroundColor) + customLists.forEach { + val enabled = it.canAddLocation(item) + IconCell( + background = Color.Unspecified, + titleColor = + if (enabled) { + onBackgroundColor + } else { + MaterialTheme.colorScheme.onSecondary + }, + iconId = null, + title = + if (enabled) { + it.name + } else { + stringResource(id = R.string.location_added, it.name) + }, + onClick = { + onAddLocationToList(item, it) + closeBottomSheet(true) + }, + enabled = enabled + ) + } + IconCell( + iconId = R.drawable.icon_add, + title = stringResource(id = R.string.new_list), + onClick = { + onCreateCustomList(item) + closeBottomSheet(true) + }, + background = Color.Unspecified, + titleColor = onBackgroundColor + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EditCustomListBottomSheet( + onBackgroundColor: Color, + sheetState: SheetState, + customList: RelayItem.CustomList, + onEditName: (item: RelayItem.CustomList) -> Unit, + onEditLocations: (item: RelayItem.CustomList) -> Unit, + onDeleteCustomList: (item: RelayItem.CustomList) -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit +) { + MullvadModalBottomSheet( + sheetState = sheetState, + closeBottomSheet = { closeBottomSheet(false) } + ) { + HeaderCell(text = customList.name, background = Color.Unspecified) + IconCell( + iconId = R.drawable.icon_edit, + title = stringResource(id = R.string.edit_name), + onClick = { + onEditName(customList) + closeBottomSheet(true) + }, + background = Color.Unspecified, + titleColor = onBackgroundColor + ) + IconCell( + iconId = R.drawable.icon_add, + title = stringResource(id = R.string.edit_locations), + onClick = { + onEditLocations(customList) + closeBottomSheet(true) + }, + background = Color.Unspecified, + titleColor = onBackgroundColor + ) + HorizontalDivider(color = onBackgroundColor) + IconCell( + iconId = R.drawable.icon_delete, + title = stringResource(id = R.string.delete), + onClick = { + onDeleteCustomList(customList) + closeBottomSheet(true) + }, + background = Color.Unspecified, + titleColor = onBackgroundColor + ) + } +} + +private suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) { val itemInfo = this.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } if (itemInfo != null) { val center = layoutInfo.viewportEndOffset / 2 @@ -298,3 +703,71 @@ suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) { animateScrollToItem(index) } } + +private suspend fun SnackbarHostState.showResultSnackbar( + context: Context, + result: CustomListResult, + onUndo: (CustomListAction) -> Unit +) { + currentSnackbarData?.dismiss() + showSnackbar( + message = result.message(context), + actionLabel = context.getString(R.string.undo), + duration = SnackbarDuration.Long, + onAction = { onUndo(result.undo) } + ) +} + +private fun CustomListResult.message(context: Context): String = + when (this) { + is CustomListResult.Created -> + context.getString(R.string.location_was_added_to_list, locationName, name) + is CustomListResult.Deleted -> context.getString(R.string.delete_custom_list_message, name) + is CustomListResult.Renamed -> context.getString(R.string.name_was_changed_to, name) + is CustomListResult.LocationsChanged -> + context.getString(R.string.locations_were_changed_for, name) + } + +@Composable +private fun <D : DestinationSpec<*>, R : CustomListResult> ResultRecipient<D, R> + .OnCustomListNavResult( + snackbarHostState: SnackbarHostState, + performAction: (action: CustomListAction) -> Unit +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + this.onNavResult { result -> + when (result) { + NavResult.Canceled -> { + /* Do nothing */ + } + is NavResult.Value -> { + // Handle result + scope.launch { + snackbarHostState.showResultSnackbar( + context = context, + result = result.value, + onUndo = performAction + ) + } + } + } + } +} + +private const val EXTRA_ITEMS_LOCATION = + 4 // Custom lists header, custom lists description, spacer, all locations header +private const val EXTRA_ITEM_CUSTOM_LIST = 1 // Custom lists header + +sealed interface BottomSheetState { + + data class ShowCustomListsBottomSheet(val editListEnabled: Boolean) : BottomSheetState + + data class ShowLocationBottomSheet( + val customLists: List<RelayItem.CustomList>, + val item: RelayItem + ) : BottomSheetState + + data class ShowEditCustomListBottomSheet(val customList: RelayItem.CustomList) : + BottomSheetState +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt index 403b1bb57e..bd8809b00f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt @@ -468,7 +468,7 @@ fun VpnSettingsScreen( itemWithDivider { BaseCell( onCellClicked = { navigateToDns(null, null) }, - title = { + headlineContent = { Text( text = stringResource(id = R.string.add_a_server), color = Color.White, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CreateCustomListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CreateCustomListUiState.kt new file mode 100644 index 0000000000..43052702bd --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CreateCustomListUiState.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.model.CustomListsError + +data class CreateCustomListUiState(val error: CustomListsError? = null) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt new file mode 100644 index 0000000000..7c9c5aedec --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt @@ -0,0 +1,32 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.relaylist.RelayItem + +sealed interface CustomListLocationsUiState { + val newList: Boolean + val saveEnabled: Boolean + val hasUnsavedChanges: Boolean + + data class Loading(override val newList: Boolean = false) : CustomListLocationsUiState { + override val saveEnabled: Boolean = false + override val hasUnsavedChanges: Boolean = false + } + + sealed interface Content : CustomListLocationsUiState { + val searchTerm: String + + data class Empty(override val newList: Boolean, override val searchTerm: String) : Content { + override val saveEnabled: Boolean = false + override val hasUnsavedChanges: Boolean = false + } + + data class Data( + override val newList: Boolean = false, + val availableLocations: List<RelayItem.Country> = emptyList(), + val selectedLocations: Set<RelayItem> = emptySet(), + override val searchTerm: String = "", + override val saveEnabled: Boolean = false, + override val hasUnsavedChanges: Boolean = false + ) : Content + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListsUiState.kt new file mode 100644 index 0000000000..f055bf95d2 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListsUiState.kt @@ -0,0 +1,10 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.relaylist.RelayItem + +interface CustomListsUiState { + object Loading : CustomListsUiState + + data class Content(val customLists: List<RelayItem.CustomList> = emptyList()) : + CustomListsUiState +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditCustomListState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditCustomListState.kt new file mode 100644 index 0000000000..9b564bb407 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditCustomListState.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.relaylist.RelayItem + +sealed interface EditCustomListState { + data object Loading : EditCustomListState + + data object NotFound : EditCustomListState + + data class Content(val id: String, val name: String, val locations: List<RelayItem>) : + EditCustomListState +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt index fd775fa1bb..747e21d91c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt @@ -1,25 +1,27 @@ package net.mullvad.mullvadvpn.compose.state import net.mullvad.mullvadvpn.model.Ownership +import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH import net.mullvad.mullvadvpn.relaylist.RelayItem sealed interface SelectLocationUiState { data object Loading : SelectLocationUiState - data class Data( + data class Content( val searchTerm: String, val selectedOwnership: Ownership?, val selectedProvidersCount: Int?, - val relayListState: RelayListState + val filteredCustomLists: List<RelayItem.CustomList>, + val customLists: List<RelayItem.CustomList>, + val countries: List<RelayItem.Country>, + val selectedItem: RelayItem? ) : SelectLocationUiState { val hasFilter: Boolean = (selectedProvidersCount != null || selectedOwnership != null) + val inSearch = searchTerm.length >= MIN_SEARCH_LENGTH + val showCustomLists = inSearch.not() || filteredCustomLists.isNotEmpty() + // Show empty state if we don't have any relays or if we are searching and no custom list or + // relay is found + val showEmpty = countries.isEmpty() && (inSearch.not() || filteredCustomLists.isEmpty()) } } - -sealed interface RelayListState { - data object Empty : RelayListState - - data class RelayList(val countries: List<RelayItem.Country>, val selectedItem: RelayItem?) : - RelayListState -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/UpdateCustomListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/UpdateCustomListUiState.kt new file mode 100644 index 0000000000..7eac74a40a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/UpdateCustomListUiState.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.model.CustomListsError + +data class UpdateCustomListUiState(val name: String = "", val error: CustomListsError? = null) 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 996f610404..efd8e34250 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 @@ -16,7 +16,7 @@ const val LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG = "lazy_list_wireguard_custom_port_number_test_tag" const val CUSTOM_PORT_DIALOG_INPUT_TEST_TAG = "custom_port_dialog_input_test_tag" -// SelectLocationScreen, ConnectScreen +// SelectLocationScreen, ConnectScreen, CustomListLocationsScreen const val CIRCULAR_PROGRESS_INDICATOR = "circular_progress_indicator" // ConnectScreen @@ -42,3 +42,25 @@ const val VOUCHER_INPUT_TEST_TAG = "voucher_input_test_tag" // OutOfTimeScreen const val OUT_OF_TIME_SCREEN_TITLE_TEST_TAG = "out_of_time_screen_title_test_tag" + +// CreateCustomListDialog +const val CREATE_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG = "create_custom_list_dialog_input_test_tag" + +// UpdateCustomListDialog +const val EDIT_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG = "edit_custom_list_dialog_input_test_tag" + +// CustomListLocationsScreen +const val SAVE_BUTTON_TEST_TAG = "save_button_test_tag" + +// CustomListsScreen +const val NEW_LIST_BUTTON_TEST_TAG = "new_list_button_test_tag" +const val TOP_BAR_DROPDOWN_BUTTON_TEST_TAG = "top_bar_dropdown_button_test_tag" +const val DELETE_DROPDOWN_MENU_ITEM_TEST_TAG = "delete_dropdown_menu_item_test_tag" + +// SelectLocationScreen +const val SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG = + "select_location_custom_list_header_test_tag" +const val SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG = + "select_location_custom_list_bottom_sheet_test_tag" +const val SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG = + "select_location_location_bottom_sheet_test_tag" 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 4b3817b441..be5750ef5c 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 @@ -38,7 +38,8 @@ fun CustomTextField( maxCharLength: Int = Int.MAX_VALUE, isValidValue: Boolean, isDigitsOnlyAllowed: Boolean, - visualTransformation: VisualTransformation = VisualTransformation.None + visualTransformation: VisualTransformation = VisualTransformation.None, + supportingText: @Composable (() -> Unit)? = null, ) { val scope = rememberCoroutineScope() @@ -102,6 +103,7 @@ fun CustomTextField( visualTransformation = visualTransformation, colors = mullvadDarkTextFieldColors(), isError = !isValidValue, - modifier = modifier.clip(MaterialTheme.shapes.small).fillMaxWidth() + modifier = modifier.clip(MaterialTheme.shapes.small).fillMaxWidth(), + supportingText = supportingText ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt index 42d5f6caa0..c8a0847e89 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt @@ -2,11 +2,14 @@ package net.mullvad.mullvadvpn.compose.util import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch @Composable inline fun <T> LaunchedEffectCollect( @@ -34,3 +37,12 @@ inline fun <T> CollectSideEffectWithLifecycle( } } } + +@Composable +fun RunOnKeyChange(key: Any, block: suspend CoroutineScope.() -> Unit) { + val scope = rememberCoroutineScope() + rememberSaveable(key) { + scope.launch { block() } + key + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/PreviewData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/PreviewData.kt new file mode 100644 index 0000000000..61b563564c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/PreviewData.kt @@ -0,0 +1,78 @@ +package net.mullvad.mullvadvpn.compose.util + +import net.mullvad.mullvadvpn.model.GeographicLocationConstraint +import net.mullvad.mullvadvpn.relaylist.RelayItem + +fun generateRelayItemCountry( + name: String, + cityNames: List<String>, + relaysPerCity: Int, + active: Boolean = true, + expanded: Boolean = false, + expandChildren: Boolean = false, +) = + RelayItem.Country( + name = name, + code = name.generateCountryCode(), + cities = + cityNames.map { cityName -> + generateRelayItemCity( + cityName, + name.generateCountryCode(), + relaysPerCity, + active, + expandChildren + ) + }, + expanded = expanded, + ) + +fun generateRelayItemCity( + name: String, + countryCode: String, + numberOfRelays: Int, + active: Boolean = true, + expanded: Boolean = false, +) = + RelayItem.City( + name = name, + code = name.generateCityCode(), + relays = + List(numberOfRelays) { index -> + generateRelayItemRelay( + countryCode, + name.generateCityCode(), + generateHostname(countryCode, name.generateCityCode(), index), + active + ) + }, + expanded = expanded, + location = GeographicLocationConstraint.City(countryCode, name.generateCityCode()), + ) + +fun generateRelayItemRelay( + countryCode: String, + cityCode: String, + hostName: String, + active: Boolean = true, +) = + RelayItem.Relay( + name = hostName, + location = + GeographicLocationConstraint.Hostname( + countryCode = countryCode, + cityCode = cityCode, + hostname = hostName, + ), + locationName = "$cityCode $hostName", + active = active + ) + +private fun String.generateCountryCode() = (take(1) + takeLast(1)).lowercase() + +private fun String.generateCityCode() = take(CITY_CODE_LENGTH).lowercase() + +private fun generateHostname(countryCode: String, cityCode: String, index: Int) = + "$countryCode-$cityCode-wg-${index+1}" + +private const val CITY_CODE_LENGTH = 3 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 afd3f72211..62fd84854d 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 @@ -37,14 +37,21 @@ import net.mullvad.mullvadvpn.usecase.RelayListUseCase import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsUseCase import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase import net.mullvad.mullvadvpn.util.ChangelogDataProvider import net.mullvad.mullvadvpn.util.IChangelogDataProvider import net.mullvad.mullvadvpn.viewmodel.AccountViewModel 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.DeleteCustomListConfirmationViewModel import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewModel +import net.mullvad.mullvadvpn.viewmodel.EditCustomListNameDialogViewModel +import net.mullvad.mullvadvpn.viewmodel.EditCustomListViewModel import net.mullvad.mullvadvpn.viewmodel.FilterViewModel import net.mullvad.mullvadvpn.viewmodel.LoginViewModel import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel @@ -100,7 +107,7 @@ val uiModule = module { } single { SettingsRepository(get()) } single { MullvadProblemReport(get()) } - single { CustomListsRepository(get()) } + single { CustomListsRepository(get(), get(), get()) } single { AccountExpiryNotificationUseCase(get()) } single { TunnelStateNotificationUseCase(get()) } @@ -111,6 +118,7 @@ val uiModule = module { single { OutOfTimeUseCase(get(), get(), MainScope()) } single { ConnectivityUseCase(get()) } single { SystemVpnSettingsUseCase(androidContext()) } + single { CustomListActionUseCase(get(), get()) } single { InAppNotificationController(get(), get(), get(), get(), MainScope()) } @@ -150,6 +158,7 @@ val uiModule = module { viewModel { LoginViewModel(get(), get(), get(), get()) } viewModel { PrivacyDisclaimerViewModel(get(), IS_PLAY_BUILD) } viewModel { SelectLocationViewModel(get(), get(), get()) } + viewModel { SelectLocationViewModel(get(), get(), get(), get()) } viewModel { SettingsViewModel(get(), get(), IS_PLAY_BUILD) } viewModel { SplashViewModel(get(), get(), get()) } viewModel { VoucherDialogViewModel(get(), get()) } @@ -160,6 +169,16 @@ val uiModule = module { viewModel { OutOfTimeViewModel(get(), get(), get(), get(), get(), isPlayBuild = IS_PLAY_BUILD) } viewModel { PaymentViewModel(get()) } viewModel { FilterViewModel(get()) } + viewModel { parameters -> CreateCustomListDialogViewModel(parameters.get(), get()) } + viewModel { parameters -> + CustomListLocationsViewModel(parameters.get(), parameters.get(), get(), get()) + } + viewModel { parameters -> EditCustomListViewModel(parameters.get(), get()) } + viewModel { parameters -> + EditCustomListNameDialogViewModel(parameters.get(), parameters.get(), get()) + } + viewModel { CustomListsViewModel(get(), get()) } + viewModel { parameters -> DeleteCustomListConfirmationViewModel(parameters.get(), 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/relaylist/CustomListExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt index 9ad0c220e9..6fb87a6af5 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt @@ -2,18 +2,32 @@ package net.mullvad.mullvadvpn.relaylist import net.mullvad.mullvadvpn.model.CustomList -fun CustomList.toRelayItemCustomList( +private fun CustomList.toRelayItemCustomList( relayCountries: List<RelayItem.Country> ): RelayItem.CustomList = RelayItem.CustomList( - code = this.id, id = this.id, name = this.name, expanded = false, locations = - this.locations.mapNotNull { relayCountries.findItemForGeographicLocationConstraint(it) } + this.locations.mapNotNull { + relayCountries.findItemForGeographicLocationConstraint(it) + }, ) fun List<CustomList>.toRelayItemLists( relayCountries: List<RelayItem.Country> ): List<RelayItem.CustomList> = this.map { it.toRelayItemCustomList(relayCountries) } + +fun List<RelayItem.CustomList>.filterOnSearchTerm(searchTerm: String) = + if (searchTerm.length >= MIN_SEARCH_LENGTH) { + this.filter { it.name.contains(searchTerm, ignoreCase = true) } + } else { + this + } + +fun RelayItem.CustomList.canAddLocation(location: RelayItem) = + this.locations.none { it.code == location.code } && + this.locations.flatMap { it.descendants() }.none { it.code == location.code } + +fun List<RelayItem.CustomList>.getById(id: String) = this.find { it.id == id } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt index 03ba7a02f3..54c4a9bef4 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt @@ -16,7 +16,6 @@ sealed interface RelayItem { data class CustomList( override val name: String, - override val code: String, override val expanded: Boolean, val id: String, val locations: List<RelayItem>, @@ -26,6 +25,8 @@ sealed interface RelayItem { override val hasChildren get() = locations.isNotEmpty() + + override val code = id } data class Country( @@ -35,6 +36,7 @@ sealed interface RelayItem { val cities: List<City> ) : RelayItem { val location = GeographicLocationConstraint.Country(code) + val relays = cities.flatMap { city -> city.relays } override val active get() = cities.any { city -> city.active } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt index 6808d707b2..a71005a78a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt @@ -10,3 +10,17 @@ fun RelayItem.toLocationConstraint(): LocationConstraint { is RelayItem.CustomList -> LocationConstraint.CustomList(id) } } + +fun RelayItem.children(): List<RelayItem> { + return when (this) { + is RelayItem.Country -> cities + is RelayItem.City -> relays + is RelayItem.CustomList -> locations + else -> emptyList() + } +} + +fun RelayItem.descendants(): List<RelayItem> { + val children = children() + return children + children.flatMap { it.descendants() } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemType.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemType.kt deleted file mode 100644 index cdbd58b291..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemType.kt +++ /dev/null @@ -1,7 +0,0 @@ -package net.mullvad.mullvadvpn.relaylist - -enum class RelayItemType { - Country, - City, - Relay, -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListConstant.kt new file mode 100644 index 0000000000..4fcc5c7902 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListConstant.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.relaylist + +const val MIN_SEARCH_LENGTH = 2 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt index 06e00a022a..882a3e42a4 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt @@ -236,4 +236,32 @@ private fun List<RelayItem.Country>.expandItemForSelection( } ?: this } -private const val MIN_SEARCH_LENGTH = 2 +@Suppress("NestedBlockDepth", "ReturnCount") +fun RelayList.getGeographicLocationConstraintByCode(code: String): GeographicLocationConstraint? { + countries.forEach { country -> + val countryCode = country.code + if (country.code == code) { + return GeographicLocationConstraint.Country(countryCode) + } + country.cities.forEach { city -> + val cityCode = city.code + if (city.code == code) { + return GeographicLocationConstraint.City(countryCode, city.code) + } + city.relays.forEach { relay -> + if (relay.hostname == code) { + return GeographicLocationConstraint.Hostname( + countryCode, + cityCode, + relay.hostname + ) + } + } + } + } + return null +} + +fun List<RelayItem.Country>.getRelayItemsByCodes(codes: List<String>): List<RelayItem> = + this.filter { codes.contains(it.code) } + + this.flatMap { it.descendants() }.filter { codes.contains(it.code) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepository.kt index 9660981688..f1a38871bd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepository.kt @@ -1,28 +1,80 @@ package net.mullvad.mullvadvpn.repository import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapNotNull import net.mullvad.mullvadvpn.lib.ipc.Event import net.mullvad.mullvadvpn.lib.ipc.MessageHandler import net.mullvad.mullvadvpn.lib.ipc.Request import net.mullvad.mullvadvpn.lib.ipc.events +import net.mullvad.mullvadvpn.model.CreateCustomListResult import net.mullvad.mullvadvpn.model.CustomList +import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.model.GeographicLocationConstraint +import net.mullvad.mullvadvpn.model.UpdateCustomListResult +import net.mullvad.mullvadvpn.relaylist.getGeographicLocationConstraintByCode +import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener +import net.mullvad.mullvadvpn.util.firstOrNullWithTimeout -class CustomListsRepository(private val messageHandler: MessageHandler) { - suspend fun createCustomList(name: String): String? { +class CustomListsRepository( + private val messageHandler: MessageHandler, + private val settingsRepository: SettingsRepository, + private val relayListListener: RelayListListener +) { + suspend fun createCustomList(name: String): CreateCustomListResult { val result = messageHandler.trySendRequest(Request.CreateCustomList(name)) return if (result) { - messageHandler.events<Event.CreateCustomListResult>().first().listId + messageHandler.events<Event.CreateCustomListResultEvent>().first().result } else { - null + CreateCustomListResult.Error(CustomListsError.OtherError) } } - fun deleteCustomList(id: String) { - messageHandler.trySendRequest(Request.DeleteCustomList(id)) + fun deleteCustomList(id: String) = messageHandler.trySendRequest(Request.DeleteCustomList(id)) + + private suspend fun updateCustomList(customList: CustomList): UpdateCustomListResult { + val result = messageHandler.trySendRequest(Request.UpdateCustomList(customList)) + + return if (result) { + messageHandler.events<Event.UpdateCustomListResultEvent>().first().result + } else { + UpdateCustomListResult.Error(CustomListsError.OtherError) + } } - fun updateCustomList(customList: CustomList) { - messageHandler.trySendRequest(Request.UpdateCustomList(customList)) + suspend fun updateCustomListLocationsFromCodes( + id: String, + locationCodes: List<String> + ): UpdateCustomListResult = + updateCustomListLocations( + id = id, + locations = + ArrayList(locationCodes.mapNotNull { getGeographicLocationConstraintByCode(it) }) + ) + + suspend fun updateCustomListName(id: String, name: String): UpdateCustomListResult = + getCustomListById(id)?.let { updateCustomList(it.copy(name = name)) } + ?: UpdateCustomListResult.Error(CustomListsError.OtherError) + + private suspend fun updateCustomListLocations( + id: String, + locations: ArrayList<GeographicLocationConstraint> + ): UpdateCustomListResult = + awaitCustomListById(id)?.let { updateCustomList(it.copy(locations = locations)) } + ?: UpdateCustomListResult.Error(CustomListsError.OtherError) + + private suspend fun awaitCustomListById(id: String): CustomList? = + settingsRepository.settingsUpdates + .mapNotNull { settings -> settings?.customLists?.customLists?.find { it.id == id } } + .firstOrNullWithTimeout(GET_CUSTOM_LIST_TIMEOUT_MS) + + fun getCustomListById(id: String): CustomList? = + settingsRepository.settingsUpdates.value?.customLists?.customLists?.find { it.id == id } + + private fun getGeographicLocationConstraintByCode(code: String): GeographicLocationConstraint? = + relayListListener.relayListEvents.value.getGeographicLocationConstraintByCode(code) + + companion object { + private const val GET_CUSTOM_LIST_TIMEOUT_MS = 5000L } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RelayListUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RelayListUseCase.kt index ab3d93e06e..4957818283 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RelayListUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RelayListUseCase.kt @@ -44,13 +44,18 @@ class RelayListUseCase( findSelectedRelayItem( relaySettings = settings?.relaySettings, relayCountries = relayCountries, - customLists = customLists + customLists = customLists, ) RelayList(customLists, relayCountries, selectedItem) } fun selectedRelayItem(): Flow<RelayItem?> = relayListWithSelection().map { it.selectedItem } + fun relayList(): Flow<List<RelayItem.Country>> = relayListWithSelection().map { it.country } + + fun customLists(): Flow<List<RelayItem.CustomList>> = + relayListWithSelection().map { it.customLists } + fun fetchRelayList() { relayListListener.fetchRelayList() } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListActionUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListActionUseCase.kt new file mode 100644 index 0000000000..7b2e5a43aa --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListActionUseCase.kt @@ -0,0 +1,117 @@ +package net.mullvad.mullvadvpn.usecase.customlists + +import kotlinx.coroutines.flow.firstOrNull +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.model.CreateCustomListResult +import net.mullvad.mullvadvpn.model.CustomList +import net.mullvad.mullvadvpn.model.GeographicLocationConstraint +import net.mullvad.mullvadvpn.model.UpdateCustomListResult +import net.mullvad.mullvadvpn.relaylist.getRelayItemsByCodes +import net.mullvad.mullvadvpn.repository.CustomListsRepository +import net.mullvad.mullvadvpn.usecase.RelayListUseCase + +class CustomListActionUseCase( + private val customListsRepository: CustomListsRepository, + private val relayListUseCase: RelayListUseCase +) { + suspend fun performAction(action: CustomListAction): Result<CustomListResult> { + return when (action) { + is CustomListAction.Create -> { + performAction(action) + } + is CustomListAction.Rename -> { + performAction(action) + } + is CustomListAction.Delete -> { + performAction(action) + } + is CustomListAction.UpdateLocations -> { + performAction(action) + } + } + } + + suspend fun performAction(action: CustomListAction.Rename): Result<CustomListResult.Renamed> = + when ( + val result = + customListsRepository.updateCustomListName(action.customListId, action.newName) + ) { + is UpdateCustomListResult.Ok -> + Result.success(CustomListResult.Renamed(undo = action.not())) + is UpdateCustomListResult.Error -> Result.failure(CustomListsException(result.error)) + } + + suspend fun performAction(action: CustomListAction.Create): Result<CustomListResult.Created> = + when (val result = customListsRepository.createCustomList(action.name)) { + is CreateCustomListResult.Ok -> { + if (action.locations.isNotEmpty()) { + customListsRepository.updateCustomListLocationsFromCodes( + result.id, + action.locations + ) + val locationNames = + relayListUseCase + .relayList() + .firstOrNull() + ?.getRelayItemsByCodes(action.locations) + ?.map { it.name } + Result.success( + CustomListResult.Created( + id = result.id, + name = action.name, + locationName = locationNames?.first(), + undo = action.not(result.id) + ) + ) + } else { + Result.success( + CustomListResult.Created( + id = result.id, + name = action.name, + locationName = null, + undo = action.not(result.id) + ) + ) + } + } + is CreateCustomListResult.Error -> Result.failure(CustomListsException(result.error)) + } + + fun performAction(action: CustomListAction.Delete): Result<CustomListResult.Deleted> { + val customList: CustomList? = customListsRepository.getCustomListById(action.customListId) + val oldLocations = customList.locations() + val name = customList?.name ?: "" + customListsRepository.deleteCustomList(action.customListId) + return Result.success( + CustomListResult.Deleted(undo = action.not(locations = oldLocations, name = name)) + ) + } + + suspend fun performAction( + action: CustomListAction.UpdateLocations + ): Result<CustomListResult.LocationsChanged> { + val customList: CustomList? = customListsRepository.getCustomListById(action.customListId) + val oldLocations = customList.locations() + val name = customList?.name ?: "" + customListsRepository.updateCustomListLocationsFromCodes( + action.customListId, + action.locations + ) + return Result.success( + CustomListResult.LocationsChanged( + name = name, + undo = action.not(locations = oldLocations) + ) + ) + } + + private fun CustomList?.locations(): List<String> = + this?.locations?.map { + when (it) { + is GeographicLocationConstraint.City -> it.cityCode + is GeographicLocationConstraint.Country -> it.countryCode + is GeographicLocationConstraint.Hostname -> it.hostname + } + } ?: emptyList() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListsException.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListsException.kt new file mode 100644 index 0000000000..07c37f7333 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListsException.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.usecase.customlists + +import net.mullvad.mullvadvpn.model.CustomListsError + +class CustomListsException(val error: CustomListsError) : Throwable() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt index 843c7c2930..b3a8727df9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.retryWhen @@ -149,3 +150,7 @@ suspend inline fun <T> Flow<T>.retryWithExponentialBackOff( } class ExceptionWrapper(val item: Any) : Throwable() + +suspend fun <T> Flow<T>.firstOrNullWithTimeout(timeMillis: Long): T? { + return withTimeoutOrNull(timeMillis) { firstOrNull() } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt new file mode 100644 index 0000000000..9ae5bb7a64 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt @@ -0,0 +1,85 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.BufferOverflow +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.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.state.CreateCustomListUiState +import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsException + +class CreateCustomListDialogViewModel( + private val locationCode: String, + private val customListActionUseCase: CustomListActionUseCase, +) : ViewModel() { + + private val _uiSideEffect = + Channel<CreateCustomListDialogSideEffect>(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + private val _error = MutableStateFlow<CustomListsError?>(null) + + val uiState = + _error + .map { CreateCustomListUiState(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CreateCustomListUiState()) + + fun createCustomList(name: String) { + viewModelScope.launch { + customListActionUseCase + .performAction( + CustomListAction.Create( + name, + if (locationCode.isNotEmpty()) { + listOf(locationCode) + } else { + emptyList() + } + ) + ) + .fold( + onSuccess = { result -> + if (result.locationName != null) { + _uiSideEffect.send( + CreateCustomListDialogSideEffect.ReturnWithResult(result) + ) + } else { + _uiSideEffect.send( + CreateCustomListDialogSideEffect + .NavigateToCustomListLocationsScreen(result.id) + ) + } + }, + onFailure = { error -> + if (error is CustomListsException) { + _error.emit(error.error) + } else { + _error.emit(CustomListsError.OtherError) + } + } + ) + } + } + + fun clearError() { + viewModelScope.launch { _error.emit(null) } + } +} + +sealed interface CreateCustomListDialogSideEffect { + + data class NavigateToCustomListLocationsScreen(val customListId: String) : + CreateCustomListDialogSideEffect + + data class ReturnWithResult(val result: CustomListResult.Created) : + CreateCustomListDialogSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt new file mode 100644 index 0000000000..5fa99306a3 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt @@ -0,0 +1,216 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState +import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.relaylist.descendants +import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm +import net.mullvad.mullvadvpn.relaylist.getById +import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.util.firstOrNullWithTimeout + +class CustomListLocationsViewModel( + private val customListId: String, + private val newList: Boolean, + private val relayListUseCase: RelayListUseCase, + private val customListActionUseCase: CustomListActionUseCase +) : ViewModel() { + private var customListName: String = "" + + private val _uiSideEffect = + MutableSharedFlow<CustomListLocationsSideEffect>(replay = 1, extraBufferCapacity = 1) + val uiSideEffect: SharedFlow<CustomListLocationsSideEffect> = _uiSideEffect + + private val _initialLocations = MutableStateFlow<Set<RelayItem>>(emptySet()) + private val _selectedLocations = MutableStateFlow<Set<RelayItem>?>(null) + private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) + + val uiState = + combine(relayListUseCase.relayList(), _searchTerm, _selectedLocations) { + relayCountries, + searchTerm, + selectedLocations -> + val filteredRelayCountries = relayCountries.filterOnSearchTerm(searchTerm, null) + + when { + selectedLocations == null -> + CustomListLocationsUiState.Loading(newList = newList) + filteredRelayCountries.isEmpty() -> + CustomListLocationsUiState.Content.Empty( + newList = newList, + searchTerm = searchTerm + ) + else -> + CustomListLocationsUiState.Content.Data( + newList = newList, + searchTerm = searchTerm, + availableLocations = filteredRelayCountries, + selectedLocations = selectedLocations, + saveEnabled = + selectedLocations.isNotEmpty() && + selectedLocations != _initialLocations.value, + hasUnsavedChanges = selectedLocations != _initialLocations.value + ) + } + } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + CustomListLocationsUiState.Loading(newList = newList) + ) + + init { + viewModelScope.launch { fetchInitialSelectedLocations() } + } + + fun save() { + viewModelScope.launch { + _selectedLocations.value?.let { selectedLocations -> + val result = + customListActionUseCase.performAction( + CustomListAction.UpdateLocations( + customListId, + selectedLocations.calculateLocationsToSave().map { it.code } + ) + ) + _uiSideEffect.tryEmit( + // This is so that we don't show a snackbar after returning to the select + // location screen + if (newList) { + CustomListLocationsSideEffect.CloseScreen + } else { + CustomListLocationsSideEffect.ReturnWithResult(result.getOrThrow()) + } + ) + } + } + } + + fun onRelaySelectionClick(relayItem: RelayItem, selected: Boolean) { + if (selected) { + selectLocation(relayItem) + } else { + deselectLocation(relayItem) + } + } + + fun onSearchTermInput(searchTerm: String) { + viewModelScope.launch { _searchTerm.emit(searchTerm) } + } + + private suspend fun awaitCustomListById(id: String): RelayItem.CustomList? = + relayListUseCase + .customLists() + .mapNotNull { customList -> customList.getById(id) } + .firstOrNullWithTimeout(GET_CUSTOM_LIST_TIMEOUT_MS) + + private fun selectLocation(relayItem: RelayItem) { + viewModelScope.launch { + _selectedLocations.update { + it?.plus(relayItem)?.plus(relayItem.descendants()) ?: setOf(relayItem) + } + } + } + + private fun deselectLocation(relayItem: RelayItem) { + viewModelScope.launch { + _selectedLocations.update { + val newSelectedLocations = it?.toMutableSet() ?: mutableSetOf() + newSelectedLocations.remove(relayItem) + newSelectedLocations.removeAll(relayItem.descendants().toSet()) + // If a parent is selected, deselect it, since we only want to select a parent if + // all children are selected + newSelectedLocations.deselectParents(relayItem) + } + } + } + + private fun availableLocations(): List<RelayItem.Country> = + (uiState.value as? CustomListLocationsUiState.Content.Data)?.availableLocations + ?: emptyList() + + private fun Set<RelayItem>.deselectParents(relayItem: RelayItem): Set<RelayItem> { + val availableLocations = availableLocations() + val updateSelectionList = this.toMutableSet() + when (relayItem) { + is RelayItem.City -> { + availableLocations + .find { it.code == relayItem.location.countryCode } + ?.let { updateSelectionList.remove(it) } + } + is RelayItem.Relay -> { + availableLocations + .flatMap { country -> country.cities } + .find { it.code == relayItem.location.cityCode } + ?.let { updateSelectionList.remove(it) } + availableLocations + .find { it.code == relayItem.location.countryCode } + ?.let { updateSelectionList.remove(it) } + } + is RelayItem.Country, + is RelayItem.CustomList -> { + /* Do nothing */ + } + } + + return updateSelectionList + } + + private fun Set<RelayItem>.calculateLocationsToSave(): List<RelayItem> { + // We don't want to save children for a selected parent + val saveSelectionList = this.toMutableList() + this.forEach { relayItem -> + when (relayItem) { + is RelayItem.Country -> { + saveSelectionList.removeAll(relayItem.cities) + saveSelectionList.removeAll(relayItem.relays) + } + is RelayItem.City -> { + saveSelectionList.removeAll(relayItem.relays) + } + is RelayItem.Relay, + is RelayItem.CustomList -> { + /* Do nothing */ + } + } + } + return saveSelectionList + } + + private fun List<RelayItem>.selectChildren(): Set<RelayItem> = + (this + flatMap { it.descendants() }).toSet() + + private suspend fun fetchInitialSelectedLocations() { + _selectedLocations.value = + awaitCustomListById(customListId) + ?.apply { customListName = name } + ?.locations + ?.selectChildren() + .apply { _initialLocations.value = this ?: emptySet() } + } + + companion object { + private const val EMPTY_SEARCH_TERM = "" + private const val GET_CUSTOM_LIST_TIMEOUT_MS = 5000L + } +} + +sealed interface CustomListLocationsSideEffect { + data object CloseScreen : CustomListLocationsSideEffect + + data class ReturnWithResult(val result: CustomListResult.LocationsChanged) : + CustomListLocationsSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModel.kt new file mode 100644 index 0000000000..79a2ba61c6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModel.kt @@ -0,0 +1,32 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.state.CustomListsUiState +import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase + +class CustomListsViewModel( + relayListUseCase: RelayListUseCase, + private val customListActionUseCase: CustomListActionUseCase +) : ViewModel() { + + val uiState = + relayListUseCase + .customLists() + .map { CustomListsUiState.Content(it) } + .stateIn( + viewModelScope, + started = SharingStarted.WhileSubscribed(), + CustomListsUiState.Loading + ) + + fun undoDeleteCustomList(action: CustomListAction.Create) { + viewModelScope.launch { customListActionUseCase.performAction(action) } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModel.kt new file mode 100644 index 0000000000..e3c7f45664 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModel.kt @@ -0,0 +1,33 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase + +class DeleteCustomListConfirmationViewModel( + private val customListId: String, + private val customListActionUseCase: CustomListActionUseCase +) : ViewModel() { + private val _uiSideEffect = Channel<DeleteCustomListConfirmationSideEffect>(Channel.BUFFERED) + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + fun deleteCustomList() { + viewModelScope.launch { + val result = + customListActionUseCase + .performAction(CustomListAction.Delete(customListId)) + .getOrThrow() + _uiSideEffect.send(DeleteCustomListConfirmationSideEffect.ReturnWithResult(result)) + } + } +} + +sealed class DeleteCustomListConfirmationSideEffect { + data class ReturnWithResult(val result: CustomListResult.Deleted) : + DeleteCustomListConfirmationSideEffect() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModel.kt new file mode 100644 index 0000000000..c2625e6d56 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModel.kt @@ -0,0 +1,76 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.BufferOverflow +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.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.state.UpdateCustomListUiState +import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsException + +class EditCustomListNameDialogViewModel( + private val customListId: String, + private val initialName: String, + private val customListActionUseCase: CustomListActionUseCase +) : ViewModel() { + + private val _uiSideEffect = + Channel<EditCustomListNameDialogSideEffect>(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + private val _error = MutableStateFlow<CustomListsError?>(null) + + val uiState = + _error + .map { UpdateCustomListUiState(name = initialName, error = it) } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + UpdateCustomListUiState(name = initialName) + ) + + fun updateCustomListName(name: String) { + viewModelScope.launch { + customListActionUseCase + .performAction( + CustomListAction.Rename( + customListId = customListId, + name = initialName, + newName = name + ) + ) + .fold( + onSuccess = { result -> + _uiSideEffect.send( + EditCustomListNameDialogSideEffect.ReturnWithResult(result) + ) + }, + onFailure = { exception -> + if (exception is CustomListsException) { + _error.emit(exception.error) + } else { + _error.emit(CustomListsError.OtherError) + } + } + ) + } + } + + fun clearError() { + viewModelScope.launch { _error.emit(null) } + } +} + +sealed interface EditCustomListNameDialogSideEffect { + data class ReturnWithResult(val result: CustomListResult.Renamed) : + EditCustomListNameDialogSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModel.kt new file mode 100644 index 0000000000..81232e63d5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModel.kt @@ -0,0 +1,30 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.compose.state.EditCustomListState +import net.mullvad.mullvadvpn.usecase.RelayListUseCase + +class EditCustomListViewModel( + private val customListId: String, + relayListUseCase: RelayListUseCase +) : ViewModel() { + val uiState = + relayListUseCase + .customLists() + .map { customLists -> + customLists + .find { it.id == customListId } + ?.let { + EditCustomListState.Content( + id = it.id, + name = it.name, + locations = it.locations + ) + } ?: EditCustomListState.NotFound + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), EditCustomListState.Loading) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt index 4ca27a105a..15df90be9e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt @@ -10,7 +10,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.compose.state.RelayListState +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListResult import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.compose.state.toNullableOwnership import net.mullvad.mullvadvpn.compose.state.toSelectedProviders @@ -24,11 +25,13 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase class SelectLocationViewModel( private val serviceConnectionManager: ServiceConnectionManager, private val relayListUseCase: RelayListUseCase, - private val relayListFilterUseCase: RelayListFilterUseCase + private val relayListFilterUseCase: RelayListFilterUseCase, + private val customListActionUseCase: CustomListActionUseCase ) : ViewModel() { private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) @@ -38,9 +41,9 @@ class SelectLocationViewModel( _searchTerm, relayListFilterUseCase.selectedOwnership(), relayListFilterUseCase.availableProviders(), - relayListFilterUseCase.selectedProviders() + relayListFilterUseCase.selectedProviders(), ) { - (customList, relayCountries, selectedItem), + (customLists, relayCountries, selectedItem), searchTerm, selectedOwnership, allProviders, @@ -60,25 +63,22 @@ class SelectLocationViewModel( val filteredRelayCountries = relayCountries.filterOnSearchTerm(searchTerm, selectedItem) - SelectLocationUiState.Data( + val filteredCustomLists = customLists.filterOnSearchTerm(searchTerm) + + SelectLocationUiState.Content( searchTerm = searchTerm, selectedOwnership = selectedOwnershipItem, selectedProvidersCount = selectedProvidersCount, - relayListState = - if (filteredRelayCountries.isNotEmpty()) { - RelayListState.RelayList( - countries = filteredRelayCountries, - selectedItem = selectedItem - ) - } else { - RelayListState.Empty - }, + filteredCustomLists = filteredCustomLists, + customLists = customLists, + countries = filteredRelayCountries, + selectedItem = selectedItem, ) } .stateIn( viewModelScope, SharingStarted.WhileSubscribed(), - SelectLocationUiState.Loading + SelectLocationUiState.Loading, ) private val _uiSideEffect = Channel<SelectLocationSideEffect>() @@ -113,7 +113,7 @@ class SelectLocationViewModel( viewModelScope.launch { relayListFilterUseCase.updateOwnershipAndProviderFilter( Constraint.Any(), - relayListFilterUseCase.selectedProviders().first() + relayListFilterUseCase.selectedProviders().first(), ) } } @@ -122,11 +122,28 @@ class SelectLocationViewModel( viewModelScope.launch { relayListFilterUseCase.updateOwnershipAndProviderFilter( relayListFilterUseCase.selectedOwnership().first(), - Constraint.Any() + Constraint.Any(), ) } } + fun addLocationToList(item: RelayItem, customList: RelayItem.CustomList) { + viewModelScope.launch { + val newLocations = (customList.locations + item).map { it.code } + val result = + customListActionUseCase.performAction( + CustomListAction.UpdateLocations(customList.id, newLocations) + ) + _uiSideEffect.send( + SelectLocationSideEffect.LocationAddedToCustomList(result.getOrThrow()) + ) + } + } + + fun performAction(action: CustomListAction) { + viewModelScope.launch { customListActionUseCase.performAction(action) } + } + companion object { private const val EMPTY_SEARCH_TERM = "" } @@ -134,4 +151,7 @@ class SelectLocationViewModel( sealed interface SelectLocationSideEffect { data object CloseScreen : SelectLocationSideEffect + + data class LocationAddedToCustomList(val result: CustomListResult.LocationsChanged) : + SelectLocationSideEffect } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepositoryTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepositoryTest.kt new file mode 100644 index 0000000000..129d921c36 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepositoryTest.kt @@ -0,0 +1,268 @@ +package net.mullvad.mullvadvpn.repository + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.ipc.Event +import net.mullvad.mullvadvpn.lib.ipc.MessageHandler +import net.mullvad.mullvadvpn.lib.ipc.Request +import net.mullvad.mullvadvpn.lib.ipc.events +import net.mullvad.mullvadvpn.model.CreateCustomListResult +import net.mullvad.mullvadvpn.model.CustomList +import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.model.GeographicLocationConstraint +import net.mullvad.mullvadvpn.model.RelayList +import net.mullvad.mullvadvpn.model.Settings +import net.mullvad.mullvadvpn.model.UpdateCustomListResult +import net.mullvad.mullvadvpn.relaylist.getGeographicLocationConstraintByCode +import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class CustomListsRepositoryTest { + private val mockMessageHandler: MessageHandler = mockk() + private val mockSettingsRepository: SettingsRepository = mockk() + private val mockRelayListListener: RelayListListener = mockk() + private val customListsRepository = + CustomListsRepository( + messageHandler = mockMessageHandler, + settingsRepository = mockSettingsRepository, + relayListListener = mockRelayListListener + ) + + private val settingsFlow: MutableStateFlow<Settings?> = MutableStateFlow(null) + private val relayListFlow: MutableStateFlow<RelayList> = MutableStateFlow(mockk()) + + @BeforeEach + fun setup() { + mockkStatic(RELAY_LIST_EXTENSIONS) + every { mockSettingsRepository.settingsUpdates } returns settingsFlow + every { mockRelayListListener.relayListEvents } returns relayListFlow + } + + @Test + fun `get custom list by id should return custom list when id matches custom list in settings`() { + // Arrange + val mockCustomList: CustomList = mockk() + val mockSettings: Settings = mockk() + val customListId = "1" + settingsFlow.value = mockSettings + every { mockSettings.customLists.customLists } returns arrayListOf(mockCustomList) + every { mockCustomList.id } returns customListId + + // Act + val result = customListsRepository.getCustomListById(customListId) + + // Assert + assertEquals(mockCustomList, result) + } + + @Test + fun `get custom list by id should return null when id does not matches custom list in settings`() { + // Arrange + val mockCustomList: CustomList = mockk() + val mockSettings: Settings = mockk() + val customListId = "1" + val otherCustomListId = "2" + settingsFlow.value = mockSettings + every { mockSettings.customLists.customLists } returns arrayListOf(mockCustomList) + every { mockCustomList.id } returns customListId + + // Act + val result = customListsRepository.getCustomListById(otherCustomListId) + + // Assert + assertNull(result) + } + + @Test + fun `create custom list should return Ok when creation is successful`() = runTest { + // Arrange + val customListId = "1" + val expectedResult = CreateCustomListResult.Ok(customListId) + val customListName = "CUSTOM" + every { + mockMessageHandler.trySendRequest(Request.CreateCustomList(customListName)) + } returns true + every { mockMessageHandler.events<Event.CreateCustomListResultEvent>() } returns + flowOf(Event.CreateCustomListResultEvent(expectedResult)) + + // Act + val result = customListsRepository.createCustomList(customListName) + + // Assert + assertEquals(expectedResult, result) + } + + @Test + fun `create custom list should return lists exists when lists exists error event is received`() = + runTest { + // Arrange + val expectedResult = CreateCustomListResult.Error(CustomListsError.CustomListExists) + val customListName = "CUSTOM" + every { + mockMessageHandler.trySendRequest(Request.CreateCustomList(customListName)) + } returns true + every { mockMessageHandler.events<Event.CreateCustomListResultEvent>() } returns + flowOf(Event.CreateCustomListResultEvent(expectedResult)) + + // Act + val result = customListsRepository.createCustomList(customListName) + + // Assert + assertEquals(expectedResult, result) + } + + @Test + fun `update custom list name should return ok when list updated event is received`() = runTest { + // Arrange + val customListId = "1" + val expectedResult = UpdateCustomListResult.Ok + val customListName = "CUSTOM" + val mockSettings: Settings = mockk() + val mockCustomList: CustomList = mockk() + val updatedCustomList: CustomList = mockk() + settingsFlow.value = mockSettings + every { mockCustomList.id } returns customListId + every { mockCustomList.copy(customListId, customListName, any()) } returns updatedCustomList + every { + mockMessageHandler.trySendRequest(Request.UpdateCustomList(updatedCustomList)) + } returns true + every { mockMessageHandler.events<Event.UpdateCustomListResultEvent>() } returns + flowOf(Event.UpdateCustomListResultEvent(expectedResult)) + every { mockSettings.customLists.customLists } returns arrayListOf(mockCustomList) + + // Act + val result = customListsRepository.updateCustomListName(customListId, customListName) + + // Assert + assertEquals(expectedResult, result) + } + + @Test + fun `update custom list name should return list exists error when list exists error is received`() = + runTest { + // Arrange + val customListId = "1" + val expectedResult = UpdateCustomListResult.Error(CustomListsError.CustomListExists) + val customListName = "CUSTOM" + val mockSettings: Settings = mockk() + val mockCustomList: CustomList = mockk() + val updatedCustomList: CustomList = mockk() + settingsFlow.value = mockSettings + every { mockCustomList.id } returns customListId + every { mockCustomList.copy(customListId, customListName, any()) } returns + updatedCustomList + every { + mockMessageHandler.trySendRequest(Request.UpdateCustomList(updatedCustomList)) + } returns true + every { mockMessageHandler.events<Event.UpdateCustomListResultEvent>() } returns + flowOf(Event.UpdateCustomListResultEvent(expectedResult)) + every { mockSettings.customLists.customLists } returns arrayListOf(mockCustomList) + + // Act + val result = customListsRepository.updateCustomListName(customListId, customListName) + + // Assert + assertEquals(expectedResult, result) + } + + @Test + fun `when delete custom lists is called a delete custom event should be sent`() = runTest { + // Arrange + val customListId = "1" + every { mockMessageHandler.trySendRequest(Request.DeleteCustomList(customListId)) } returns + true + + // Act + customListsRepository.deleteCustomList(customListId) + + // Assert + verify { mockMessageHandler.trySendRequest(Request.DeleteCustomList(customListId)) } + } + + @Test + fun `update custom list locations should return ok when list exists and ok updated list event is received`() = + runTest { + // Arrange + val expectedResult = UpdateCustomListResult.Ok + val customListId = "1" + val customListName = "CUSTOM" + val locationCode = "AB" + val mockSettings: Settings = mockk() + val mockRelayList: RelayList = mockk() + val mockCustomList: CustomList = mockk() + val updatedCustomList: CustomList = mockk() + val mockLocationConstraint: GeographicLocationConstraint = mockk() + settingsFlow.value = mockSettings + relayListFlow.value = mockRelayList + every { mockCustomList.id } returns customListId + every { mockCustomList.name } returns customListName + every { + mockCustomList.copy( + customListId, + customListName, + arrayListOf(mockLocationConstraint) + ) + } returns updatedCustomList + every { + mockMessageHandler.trySendRequest(Request.UpdateCustomList(updatedCustomList)) + } returns true + every { mockMessageHandler.events<Event.UpdateCustomListResultEvent>() } returns + flowOf(Event.UpdateCustomListResultEvent(expectedResult)) + every { mockSettings.customLists.customLists } returns arrayListOf(mockCustomList) + every { mockRelayList.getGeographicLocationConstraintByCode(locationCode) } returns + mockLocationConstraint + + // Act + val result = + customListsRepository.updateCustomListLocationsFromCodes( + customListId, + listOf(locationCode) + ) + + // Assert + assertEquals(expectedResult, result) + } + + @Test + fun `update custom list locations should return other error when list does not exist`() = + runTest { + // Arrange + val expectedResult = UpdateCustomListResult.Error(CustomListsError.OtherError) + val mockCustomList: CustomList = mockk() + val mockSettings: Settings = mockk() + val customListId = "1" + val otherCustomListId = "2" + val locationCode = "AB" + val mockRelayList: RelayList = mockk() + val mockLocationConstraint: GeographicLocationConstraint = mockk() + settingsFlow.value = mockSettings + relayListFlow.value = mockRelayList + every { mockSettings.customLists.customLists } returns arrayListOf(mockCustomList) + every { mockCustomList.id } returns customListId + every { mockRelayList.getGeographicLocationConstraintByCode(locationCode) } returns + mockLocationConstraint + + // Act + val result = + customListsRepository.updateCustomListLocationsFromCodes( + otherCustomListId, + listOf(locationCode) + ) + + // Assert + assertEquals(expectedResult, result) + } + + companion object { + private const val RELAY_LIST_EXTENSIONS = + "net.mullvad.mullvadvpn.relaylist.RelayListExtensionsKt" + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/CustomListActionUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/CustomListActionUseCaseTest.kt new file mode 100644 index 0000000000..0370f23ffb --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/CustomListActionUseCaseTest.kt @@ -0,0 +1,220 @@ +package net.mullvad.mullvadvpn.usecase + +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlin.test.assertIs +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.model.CreateCustomListResult +import net.mullvad.mullvadvpn.model.CustomList +import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.model.GeographicLocationConstraint +import net.mullvad.mullvadvpn.model.UpdateCustomListResult +import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.relaylist.getRelayItemsByCodes +import net.mullvad.mullvadvpn.repository.CustomListsRepository +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class CustomListActionUseCaseTest { + private val mockCustomListsRepository: CustomListsRepository = mockk() + private val mockRelayListUseCase: RelayListUseCase = mockk() + private val customListActionUseCase = + CustomListActionUseCase( + customListsRepository = mockCustomListsRepository, + relayListUseCase = mockRelayListUseCase + ) + + @BeforeEach + fun setup() { + mockkStatic(RELAY_LIST_EXTENSIONS) + } + + @Test + fun `create action should return success when ok`() = runTest { + // Arrange + val name = "test" + val locationCode = "AB" + val locationName = "Acklaba" + val createdId = "1" + val action = CustomListAction.Create(name = name, locations = listOf(locationCode)) + val expectedResult = + Result.success( + CustomListResult.Created( + id = createdId, + name = name, + locationName = locationName, + undo = action.not(createdId) + ) + ) + val relayItem = + RelayItem.Country( + name = locationName, + code = locationCode, + expanded = false, + cities = emptyList() + ) + val mockLocations: List<RelayItem.Country> = listOf(relayItem) + coEvery { mockCustomListsRepository.createCustomList(name) } returns + CreateCustomListResult.Ok(createdId) + coEvery { + mockCustomListsRepository.updateCustomListLocationsFromCodes( + createdId, + listOf(locationCode) + ) + } returns UpdateCustomListResult.Ok + coEvery { mockRelayListUseCase.relayList() } returns flowOf(mockLocations) + every { mockLocations.getRelayItemsByCodes(listOf(locationCode)) } returns mockLocations + + // Act + val result = customListActionUseCase.performAction(action) + + // Assert + assertEquals(expectedResult, result) + } + + @Test + fun `create action should return error when name already exists`() = runTest { + // Arrange + val name = "test" + val locationCode = "AB" + val action = CustomListAction.Create(name = name, locations = listOf(locationCode)) + val expectedError = CustomListsError.CustomListExists + coEvery { mockCustomListsRepository.createCustomList(name) } returns + CreateCustomListResult.Error(CustomListsError.CustomListExists) + + // Act + val result = customListActionUseCase.performAction(action) + + // Assert + assertIs<Result<CustomListsException>>(result) + val exception = result.exceptionOrNull() + assertIs<CustomListsException>(exception) + assertEquals(expectedError, exception.error) + } + + @Test + fun `rename action should return success when ok`() = runTest { + // Arrange + val name = "test" + val newName = "test2" + val customListId = "1" + val action = + CustomListAction.Rename(customListId = customListId, name = name, newName = newName) + val expectedResult = Result.success(CustomListResult.Renamed(undo = action.not())) + coEvery { + mockCustomListsRepository.updateCustomListName(id = customListId, name = newName) + } returns UpdateCustomListResult.Ok + + // Act + val result = customListActionUseCase.performAction(action) + + // Assert + assertEquals(expectedResult, result) + } + + @Test + fun `rename action should return error when name already exists`() = runTest { + // Arrange + val name = "test" + val newName = "test2" + val customListId = "1" + val action = + CustomListAction.Rename(customListId = customListId, name = name, newName = newName) + val expectedError = CustomListsError.CustomListExists + coEvery { + mockCustomListsRepository.updateCustomListName(id = customListId, name = newName) + } returns UpdateCustomListResult.Error(expectedError) + + // Act + val result = customListActionUseCase.performAction(action) + + // Assert + assertIs<Result<CustomListsException>>(result) + val exception = result.exceptionOrNull() + assertIs<CustomListsException>(exception) + assertEquals(expectedError, exception.error) + } + + @Test + fun `delete action should return successful with deleted list`() = runTest { + // Arrange + val mockCustomList: CustomList = mockk() + val mockLocation: GeographicLocationConstraint.Country = mockk() + val mockLocations: ArrayList<GeographicLocationConstraint> = arrayListOf(mockLocation) + val name = "test" + val customListId = "1" + val locationCode = "AB" + val action = CustomListAction.Delete(customListId = customListId) + val expectedResult = + Result.success( + CustomListResult.Deleted( + undo = action.not(name = name, locations = listOf(locationCode)) + ) + ) + every { mockCustomList.locations } returns mockLocations + every { mockCustomList.name } returns name + every { mockLocation.countryCode } returns locationCode + coEvery { mockCustomListsRepository.deleteCustomList(id = customListId) } returns true + every { mockCustomListsRepository.getCustomListById(customListId) } returns mockCustomList + + // Act + val result = customListActionUseCase.performAction(action) + + // Assert + assertEquals(expectedResult, result) + } + + @Test + fun `update locations action should return success with changed locations`() = runTest { + // Arrange + val name = "test" + val oldLocationCodes = listOf("AB", "CD") + val newLocationCodes = listOf("EF", "GH") + val oldLocations: ArrayList<GeographicLocationConstraint> = + arrayListOf( + GeographicLocationConstraint.Country("AB"), + GeographicLocationConstraint.Country("CD") + ) + val customListId = "1" + val customList = CustomList(id = customListId, name = name, locations = oldLocations) + val action = + CustomListAction.UpdateLocations( + customListId = customListId, + locations = newLocationCodes + ) + val expectedResult = + Result.success( + CustomListResult.LocationsChanged( + name = name, + undo = action.not(locations = oldLocationCodes) + ) + ) + coEvery { mockCustomListsRepository.getCustomListById(customListId) } returns customList + + coEvery { + mockCustomListsRepository.updateCustomListLocationsFromCodes( + customListId, + newLocationCodes + ) + } returns UpdateCustomListResult.Ok + + // Act + val result = customListActionUseCase.performAction(action) + + // Assert + assertEquals(expectedResult, result) + } + + companion object { + private const val RELAY_LIST_EXTENSIONS = + "net.mullvad.mullvadvpn.relaylist.RelayListExtensionsKt" + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModelTest.kt new file mode 100644 index 0000000000..7b14db3ffb --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModelTest.kt @@ -0,0 +1,114 @@ +package net.mullvad.mullvadvpn.viewmodel + +import app.cash.turbine.test +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlin.test.assertIs +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(TestCoroutineRule::class) +class CreateCustomListDialogViewModelTest { + private val mockCustomListActionUseCase: CustomListActionUseCase = mockk() + + @Test + fun `when successfully creating a list with locations should emit return with result side effect`() = + runTest { + // Arrange + val expectedResult: CustomListResult.Created = mockk() + val customListName = "list" + val viewModel = createViewModelWithLocationCode("AB") + coEvery { + mockCustomListActionUseCase.performAction(any<CustomListAction.Create>()) + } returns Result.success(expectedResult) + every { expectedResult.locationName } returns "locationName" + + // Act, Assert + viewModel.uiSideEffect.test { + viewModel.createCustomList(customListName) + val sideEffect = awaitItem() + assertIs<CreateCustomListDialogSideEffect.ReturnWithResult>(sideEffect) + assertEquals(expectedResult, sideEffect.result) + } + } + + @Test + fun `when successfully creating a list without locations should emit with navigate to location screen`() = + runTest { + // Arrange + val expectedResult: CustomListResult.Created = mockk() + val customListName = "list" + val createdId = "1" + val viewModel = createViewModelWithLocationCode("") + coEvery { + mockCustomListActionUseCase.performAction(any<CustomListAction.Create>()) + } returns Result.success(expectedResult) + every { expectedResult.locationName } returns null + every { expectedResult.id } returns createdId + + // Act, Assert + viewModel.uiSideEffect.test { + viewModel.createCustomList(customListName) + val sideEffect = awaitItem() + assertIs<CreateCustomListDialogSideEffect.NavigateToCustomListLocationsScreen>( + sideEffect + ) + assertEquals(createdId, sideEffect.customListId) + } + } + + @Test + fun `when failing to creating a list should update ui state with error`() = runTest { + // Arrange + val expectedError = CustomListsError.CustomListExists + val customListName = "list" + val viewModel = createViewModelWithLocationCode("") + coEvery { + mockCustomListActionUseCase.performAction(any<CustomListAction.Create>()) + } returns Result.failure(CustomListsException(expectedError)) + + // Act, Assert + viewModel.uiState.test { + awaitItem() // Default state + viewModel.createCustomList(customListName) + assertEquals(expectedError, awaitItem().error) + } + } + + @Test + fun `given error state when calling clear error then should update to state without error`() = + runTest { + // Arrange + val expectedError = CustomListsError.CustomListExists + val customListName = "list" + val viewModel = createViewModelWithLocationCode("") + coEvery { + mockCustomListActionUseCase.performAction(any<CustomListAction.Create>()) + } returns Result.failure(CustomListsException(expectedError)) + + // Act, Assert + viewModel.uiState.test { + awaitItem() // Default state + viewModel.createCustomList(customListName) + assertEquals(expectedError, awaitItem().error) // Showing error + viewModel.clearError() + assertNull(awaitItem().error) + } + } + + private fun createViewModelWithLocationCode(locationCode: String) = + CreateCustomListDialogViewModel( + locationCode = locationCode, + customListActionUseCase = mockCustomListActionUseCase + ) +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModelTest.kt new file mode 100644 index 0000000000..df10ba96c4 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModelTest.kt @@ -0,0 +1,294 @@ +package net.mullvad.mullvadvpn.viewmodel + +import app.cash.turbine.test +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlin.test.assertIs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.model.GeographicLocationConstraint +import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.relaylist.descendants +import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +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 CustomListLocationsViewModelTest { + private val mockRelayListUseCase: RelayListUseCase = mockk() + private val mockCustomListUseCase: CustomListActionUseCase = mockk() + + private val relayListFlow = MutableStateFlow<List<RelayItem.Country>>(emptyList()) + private val customListFlow = MutableStateFlow<List<RelayItem.CustomList>>(emptyList()) + + @BeforeEach + fun setup() { + every { mockRelayListUseCase.relayList() } returns relayListFlow + every { mockRelayListUseCase.customLists() } returns customListFlow + } + + @Test + fun `given new list false state should return new list false`() = runTest { + // Arrange + val newList = false + val viewModel = createViewModel("id", newList) + + // Act, Assert + viewModel.uiState.test { assertEquals(newList, awaitItem().newList) } + } + + @Test + fun `when selected locations is not null and relay countries is not empty should return ui state content`() = + runTest { + // Arrange + val expectedList = DUMMY_COUNTRIES + val customListId = "id" + val customListName = "name" + val customList: RelayItem.CustomList = mockk { + every { id } returns customListId + every { name } returns customListName + every { locations } returns emptyList() + } + customListFlow.value = listOf(customList) + val expectedState = + CustomListLocationsUiState.Content.Data( + newList = true, + availableLocations = expectedList + ) + val viewModel = createViewModel(customListId, true) + relayListFlow.value = expectedList + + // Act, Assert + viewModel.uiState.test { assertEquals(expectedState, awaitItem()) } + } + + @Test + fun `when selecting parent should select children`() = runTest { + // Arrange + val expectedList = DUMMY_COUNTRIES + val customListId = "id" + val customListName = "name" + val customList: RelayItem.CustomList = mockk { + every { id } returns customListId + every { name } returns customListName + every { locations } returns emptyList() + } + customListFlow.value = listOf(customList) + val expectedSelection = + (DUMMY_COUNTRIES + DUMMY_COUNTRIES.flatMap { it.descendants() }).toSet() + val viewModel = createViewModel(customListId, true) + relayListFlow.value = expectedList + + // Act, Assert + viewModel.uiState.test { + // Check no selected + val firstState = awaitItem() + assertIs<CustomListLocationsUiState.Content.Data>(firstState) + assertEquals(emptySet<RelayItem>(), firstState.selectedLocations) + viewModel.onRelaySelectionClick(DUMMY_COUNTRIES[0], true) + // Check all items selected + val secondState = awaitItem() + assertIs<CustomListLocationsUiState.Content.Data>(secondState) + assertEquals(expectedSelection, secondState.selectedLocations) + } + } + + @Test + fun `when deselecting child should deselect parent`() = runTest { + // Arrange + val expectedList = DUMMY_COUNTRIES + val initialSelection = + (DUMMY_COUNTRIES + DUMMY_COUNTRIES.flatMap { it.descendants() }).toSet() + val customListId = "id" + val customListName = "name" + val customList: RelayItem.CustomList = mockk { + every { id } returns customListId + every { name } returns customListName + every { locations } returns initialSelection.toList() + } + customListFlow.value = listOf(customList) + val expectedSelection = emptySet<RelayItem>() + val viewModel = createViewModel(customListId, true) + relayListFlow.value = expectedList + + // Act, Assert + viewModel.uiState.test { + // Check initial selected + val firstState = awaitItem() + assertIs<CustomListLocationsUiState.Content.Data>(firstState) + assertEquals(initialSelection, firstState.selectedLocations) + viewModel.onRelaySelectionClick(DUMMY_COUNTRIES[0].cities[0].relays[0], false) + // Check all items selected + val secondState = awaitItem() + assertIs<CustomListLocationsUiState.Content.Data>(secondState) + assertEquals(expectedSelection, secondState.selectedLocations) + } + } + + @Test + fun `when deselecting parent should deselect child`() = runTest { + // Arrange + val expectedList = DUMMY_COUNTRIES + val initialSelection = + (DUMMY_COUNTRIES + DUMMY_COUNTRIES.flatMap { it.descendants() }).toSet() + val customListId = "id" + val customListName = "name" + val customList: RelayItem.CustomList = mockk { + every { id } returns customListId + every { name } returns customListName + every { locations } returns initialSelection.toList() + } + customListFlow.value = listOf(customList) + val expectedSelection = emptySet<RelayItem>() + val viewModel = createViewModel(customListId, true) + relayListFlow.value = expectedList + + // Act, Assert + viewModel.uiState.test { + // Check initial selected + val firstState = awaitItem() + assertIs<CustomListLocationsUiState.Content.Data>(firstState) + assertEquals(initialSelection, firstState.selectedLocations) + viewModel.onRelaySelectionClick(DUMMY_COUNTRIES[0], false) + // Check all items selected + val secondState = awaitItem() + assertIs<CustomListLocationsUiState.Content.Data>(secondState) + assertEquals(expectedSelection, secondState.selectedLocations) + } + } + + @Test + fun `when selecting child should not select parent`() = runTest { + // Arrange + val expectedList = DUMMY_COUNTRIES + val customListId = "id" + val customListName = "name" + val customList: RelayItem.CustomList = mockk { + every { id } returns customListId + every { name } returns customListName + every { locations } returns emptyList() + } + customListFlow.value = listOf(customList) + val expectedSelection = DUMMY_COUNTRIES[0].cities[0].relays.toSet() + val viewModel = createViewModel(customListId, true) + relayListFlow.value = expectedList + + // Act, Assert + viewModel.uiState.test { + // Check no selected + val firstState = awaitItem() + assertIs<CustomListLocationsUiState.Content.Data>(firstState) + assertEquals(emptySet<RelayItem>(), firstState.selectedLocations) + viewModel.onRelaySelectionClick(DUMMY_COUNTRIES[0].cities[0].relays[0], true) + // Check all items selected + val secondState = awaitItem() + assertIs<CustomListLocationsUiState.Content.Data>(secondState) + assertEquals(expectedSelection, secondState.selectedLocations) + } + } + + @Test + fun `given new list true when saving successfully should emit close screen side effect`() = + runTest { + // Arrange + val customListId = "1" + val customListName = "name" + val newList = true + val expectedResult: CustomListResult.LocationsChanged = mockk() + val customList: RelayItem.CustomList = mockk { + every { id } returns customListId + every { name } returns customListName + every { locations } returns DUMMY_COUNTRIES + } + customListFlow.value = listOf(customList) + coEvery { + mockCustomListUseCase.performAction(any<CustomListAction.UpdateLocations>()) + } returns Result.success(expectedResult) + val viewModel = createViewModel(customListId, newList) + + // Act, Assert + viewModel.uiSideEffect.test { + viewModel.save() + val sideEffect = awaitItem() + assertIs<CustomListLocationsSideEffect.CloseScreen>(sideEffect) + } + } + + @Test + fun `given new list false when saving successfully should emit return with result side effect`() = + runTest { + // Arrange + val customListId = "1" + val customListName = "name" + val newList = false + val expectedResult: CustomListResult.LocationsChanged = mockk() + val customList: RelayItem.CustomList = mockk { + every { id } returns customListId + every { name } returns customListName + every { locations } returns DUMMY_COUNTRIES + } + customListFlow.value = listOf(customList) + coEvery { + mockCustomListUseCase.performAction(any<CustomListAction.UpdateLocations>()) + } returns Result.success(expectedResult) + val viewModel = createViewModel(customListId, newList) + + // Act, Assert + viewModel.uiSideEffect.test { + viewModel.save() + val sideEffect = awaitItem() + assertIs<CustomListLocationsSideEffect.ReturnWithResult>(sideEffect) + assertEquals(expectedResult, sideEffect.result) + } + } + + private fun createViewModel(customListId: String, newList: Boolean) = + CustomListLocationsViewModel( + customListId = customListId, + newList = newList, + relayListUseCase = mockRelayListUseCase, + customListActionUseCase = mockCustomListUseCase + ) + + companion object { + private val DUMMY_COUNTRIES = + listOf( + RelayItem.Country( + name = "Sweden", + code = "SE", + expanded = false, + cities = + listOf( + RelayItem.City( + name = "Gothenburg", + code = "GBG", + expanded = false, + location = GeographicLocationConstraint.City("SE", "GBG"), + relays = + listOf( + RelayItem.Relay( + name = "gbg-1", + locationName = "GBG gbg-1", + active = true, + location = + GeographicLocationConstraint.Hostname( + "SE", + "GBG", + "gbg-1" + ) + ) + ) + ) + ) + ) + ) + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModelTest.kt new file mode 100644 index 0000000000..612ae38a3a --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModelTest.kt @@ -0,0 +1,54 @@ +package net.mullvad.mullvadvpn.viewmodel + +import app.cash.turbine.test +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.state.CustomListsUiState +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(TestCoroutineRule::class) +class CustomListsViewModelTest { + private val mockRelayListUseCase: RelayListUseCase = mockk(relaxed = true) + private val mockCustomListsActionUseCase: CustomListActionUseCase = mockk(relaxed = true) + + @Test + fun `given custom list from relay list use case should be in state`() = runTest { + // Arrange + val customLists: List<RelayItem.CustomList> = mockk() + val expectedState = CustomListsUiState.Content(customLists) + every { mockRelayListUseCase.customLists() } returns flowOf(customLists) + val viewModel = createViewModel() + + // Act, Assert + viewModel.uiState.test { assertEquals(expectedState, awaitItem()) } + } + + @Test + fun `undo delete action should call custom list use case`() = runTest { + // Arrange + val viewModel = createViewModel() + val action: CustomListAction.Create = mockk() + + // Act + viewModel.undoDeleteCustomList(action) + + // Assert + coVerify { mockCustomListsActionUseCase.performAction(action) } + } + + private fun createViewModel() = + CustomListsViewModel( + relayListUseCase = mockRelayListUseCase, + customListActionUseCase = mockCustomListsActionUseCase + ) +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModelTest.kt new file mode 100644 index 0000000000..9f7f3f1f0b --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModelTest.kt @@ -0,0 +1,43 @@ +package net.mullvad.mullvadvpn.viewmodel + +import app.cash.turbine.test +import io.mockk.coEvery +import io.mockk.mockk +import kotlin.test.assertIs +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(TestCoroutineRule::class) +class DeleteCustomListConfirmationViewModelTest { + private val mockCustomListActionUseCase: CustomListActionUseCase = mockk() + + @Test + fun `when successfully deleting a list should emit return with result side effect`() = runTest { + // Arrange + val expectedResult: CustomListResult.Deleted = mockk() + val viewModel = createViewModel() + coEvery { + mockCustomListActionUseCase.performAction(any<CustomListAction.Delete>()) + } returns Result.success(expectedResult) + + // Act, Assert + viewModel.uiSideEffect.test { + viewModel.deleteCustomList() + val sideEffect = awaitItem() + assertIs<DeleteCustomListConfirmationSideEffect.ReturnWithResult>(sideEffect) + assertEquals(expectedResult, sideEffect.result) + } + } + + private fun createViewModel() = + DeleteCustomListConfirmationViewModel( + customListId = "1", + customListActionUseCase = mockCustomListActionUseCase + ) +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModelTest.kt new file mode 100644 index 0000000000..e9592d0336 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModelTest.kt @@ -0,0 +1,90 @@ +package net.mullvad.mullvadvpn.viewmodel + +import app.cash.turbine.test +import io.mockk.coEvery +import io.mockk.mockk +import kotlin.test.assertIs +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(TestCoroutineRule::class) +class EditCustomListNameDialogViewModelTest { + private val mockCustomListActionUseCase: CustomListActionUseCase = mockk() + + @Test + fun `when successfully renamed list should emit return with result side effect`() = runTest { + // Arrange + val expectedResult: CustomListResult.Renamed = mockk() + val customListId = "id" + val customListName = "list" + val viewModel = createViewModel(customListId, customListName) + coEvery { + mockCustomListActionUseCase.performAction(any<CustomListAction.Rename>()) + } returns Result.success(expectedResult) + + // Act, Assert + viewModel.uiSideEffect.test { + viewModel.updateCustomListName(customListName) + val sideEffect = awaitItem() + assertIs<EditCustomListNameDialogSideEffect.ReturnWithResult>(sideEffect) + assertEquals(expectedResult, sideEffect.result) + } + } + + @Test + fun `when failing to creating a list should update ui state with error`() = runTest { + // Arrange + val expectedError = CustomListsError.CustomListExists + val customListId = "id2" + val customListName = "list2" + val viewModel = createViewModel(customListId, customListName) + coEvery { + mockCustomListActionUseCase.performAction(any<CustomListAction.Rename>()) + } returns Result.failure(CustomListsException(expectedError)) + + // Act, Assert + viewModel.uiState.test { + awaitItem() // Default state + viewModel.updateCustomListName(customListName) + assertEquals(expectedError, awaitItem().error) + } + } + + @Test + fun `given error state when calling clear error then should update to state without error`() = + runTest { + // Arrange + val expectedError = CustomListsError.CustomListExists + val customListId = "id" + val customListName = "list" + val viewModel = createViewModel(customListId, customListName) + coEvery { + mockCustomListActionUseCase.performAction(any<CustomListAction.Rename>()) + } returns Result.failure(CustomListsException(expectedError)) + + // Act, Assert + viewModel.uiState.test { + awaitItem() // Default state + viewModel.updateCustomListName(customListName) + assertEquals(expectedError, awaitItem().error) // Showing error + viewModel.clearError() + assertNull(awaitItem().error) + } + } + + private fun createViewModel(customListId: String, initialName: String) = + EditCustomListNameDialogViewModel( + customListId = customListId, + initialName = initialName, + customListActionUseCase = mockCustomListActionUseCase + ) +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModelTest.kt new file mode 100644 index 0000000000..33986961b3 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModelTest.kt @@ -0,0 +1,66 @@ +package net.mullvad.mullvadvpn.viewmodel + +import app.cash.turbine.test +import io.mockk.every +import io.mockk.mockk +import kotlin.test.assertIs +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.state.EditCustomListState +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(TestCoroutineRule::class) +class EditCustomListViewModelTest { + private val mockRelayListUseCase: RelayListUseCase = mockk(relaxed = true) + + @Test + fun `given a custom list id that does not exists should return not found ui state`() = runTest { + // Arrange + val customListId = "2" + val customList = + RelayItem.CustomList(id = "1", name = "test", expanded = false, locations = emptyList()) + every { mockRelayListUseCase.customLists() } returns flowOf(listOf(customList)) + val viewModel = createViewModel(customListId) + + // Act, Assert + viewModel.uiState.test { + val item = awaitItem() + assertIs<EditCustomListState.NotFound>(item) + } + } + + @Test + fun `given a custom list id that exists should return content ui state`() = runTest { + // Arrange + val customListId = "1" + val customList = + RelayItem.CustomList( + id = customListId, + name = "test", + expanded = false, + locations = emptyList() + ) + every { mockRelayListUseCase.customLists() } returns flowOf(listOf(customList)) + val viewModel = createViewModel(customListId) + + // Act, Assert + viewModel.uiState.test { + val item = awaitItem() + assertIs<EditCustomListState.Content>(item) + assertEquals(item.id, customList.id) + assertEquals(item.name, customList.name) + assertEquals(item.locations, customList.locations) + } + } + + private fun createViewModel(customListId: String) = + EditCustomListViewModel( + customListId = customListId, + relayListUseCase = mockRelayListUseCase + ) +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt index d8bcc7080f..41bff94ccd 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt @@ -2,6 +2,8 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.viewModelScope import app.cash.turbine.test +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -14,7 +16,8 @@ import kotlin.test.assertIs import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import net.mullvad.mullvadvpn.compose.state.RelayListState +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListResult import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.common.test.assertLists @@ -33,6 +36,7 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -47,6 +51,7 @@ class SelectLocationViewModelTest { private val relayListWithSelectionFlow = MutableStateFlow(RelayList(emptyList(), emptyList(), null)) private val mockRelayListUseCase: RelayListUseCase = mockk() + private val mockCustomListActionUseCase: CustomListActionUseCase = mockk(relaxed = true) private val selectedOwnership = MutableStateFlow<Constraint<Ownership>>(Constraint.Any()) private val selectedProvider = MutableStateFlow<Constraint<Providers>>(Constraint.Any()) private val allProvider = MutableStateFlow<List<Provider>>(emptyList()) @@ -63,11 +68,13 @@ class SelectLocationViewModelTest { mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS) mockkStatic(RELAY_LIST_EXTENSIONS) mockkStatic(RELAY_ITEM_EXTENSIONS) + mockkStatic(CUSTOM_LIST_EXTENSIONS) viewModel = SelectLocationViewModel( mockServiceConnectionManager, mockRelayListUseCase, - mockRelayListFilterUseCase + mockRelayListFilterUseCase, + mockCustomListActionUseCase ) } @@ -94,16 +101,9 @@ class SelectLocationViewModelTest { // Act, Assert viewModel.uiState.test { val actualState = awaitItem() - assertIs<SelectLocationUiState.Data>(actualState) - assertIs<RelayListState.RelayList>(actualState.relayListState) - assertLists( - mockCountries, - (actualState.relayListState as RelayListState.RelayList).countries - ) - assertEquals( - selectedItem, - (actualState.relayListState as RelayListState.RelayList).selectedItem - ) + assertIs<SelectLocationUiState.Content>(actualState) + assertLists(mockCountries, actualState.countries) + assertEquals(selectedItem, actualState.selectedItem) } } @@ -121,16 +121,9 @@ class SelectLocationViewModelTest { // Act, Assert viewModel.uiState.test { val actualState = awaitItem() - assertIs<SelectLocationUiState.Data>(actualState) - assertIs<RelayListState.RelayList>(actualState.relayListState) - assertLists( - mockCountries, - (actualState.relayListState as RelayListState.RelayList).countries - ) - assertEquals( - selectedItem, - (actualState.relayListState as RelayListState.RelayList).selectedItem - ) + assertIs<SelectLocationUiState.Content>(actualState) + assertLists(mockCountries, actualState.countries) + assertEquals(selectedItem, actualState.selectedItem) } } @@ -169,28 +162,22 @@ class SelectLocationViewModelTest { val mockSearchString = "SEARCH" every { mockRelayList.filterOnSearchTerm(mockSearchString, selectedItem) } returns mockCountries + every { mockCustomList.filterOnSearchTerm(mockSearchString) } returns mockCustomList relayListWithSelectionFlow.value = RelayList(mockCustomList, mockRelayList, selectedItem) // Act, Assert viewModel.uiState.test { // Wait for first data - assertIs<SelectLocationUiState.Data>(awaitItem()) + assertIs<SelectLocationUiState.Content>(awaitItem()) // Update search string viewModel.onSearchTermInput(mockSearchString) // Assert val actualState = awaitItem() - assertIs<SelectLocationUiState.Data>(actualState) - assertIs<RelayListState.RelayList>(actualState.relayListState) - assertLists( - mockCountries, - (actualState.relayListState as RelayListState.RelayList).countries - ) - assertEquals( - selectedItem, - (actualState.relayListState as RelayListState.RelayList).selectedItem - ) + assertIs<SelectLocationUiState.Content>(actualState) + assertLists(mockCountries, actualState.countries) + assertEquals(selectedItem, actualState.selectedItem) } } @@ -204,19 +191,20 @@ class SelectLocationViewModelTest { val mockSearchString = "SEARCH" every { mockRelayList.filterOnSearchTerm(mockSearchString, selectedItem) } returns mockCountries + every { mockCustomList.filterOnSearchTerm(mockSearchString) } returns mockCustomList relayListWithSelectionFlow.value = RelayList(mockCustomList, mockRelayList, selectedItem) // Act, Assert viewModel.uiState.test { // Wait for first data - assertIs<SelectLocationUiState.Data>(awaitItem()) + assertIs<SelectLocationUiState.Content>(awaitItem()) // Update search string viewModel.onSearchTermInput(mockSearchString) // Assert val actualState = awaitItem() - assertIs<SelectLocationUiState.Data>(actualState) + assertIs<SelectLocationUiState.Content>(actualState) assertEquals(mockSearchString, actualState.searchTerm) } } @@ -257,6 +245,40 @@ class SelectLocationViewModelTest { } } + @Test + fun `when perform action is called should call custom list use case`() { + // Arrange + val action: CustomListAction = mockk() + + // Act + viewModel.performAction(action) + + // Assert + coVerify { mockCustomListActionUseCase.performAction(action) } + } + + @Test + fun `after adding a location to a list should emit location added side effect`() = runTest { + // Arrange + val expectedResult: CustomListResult.LocationsChanged = mockk() + val location: RelayItem = mockk { every { code } returns "code" } + val customList: RelayItem.CustomList = mockk { + every { id } returns "1" + every { locations } returns emptyList() + } + coEvery { + mockCustomListActionUseCase.performAction(any<CustomListAction.UpdateLocations>()) + } returns Result.success(expectedResult) + + // Act, Assert + viewModel.uiSideEffect.test { + viewModel.addLocationToList(item = location, customList = customList) + val sideEffect = awaitItem() + assertIs<SelectLocationSideEffect.LocationAddedToCustomList>(sideEffect) + assertEquals(expectedResult, sideEffect.result) + } + } + companion object { private const val SERVICE_CONNECTION_MANAGER_EXTENSIONS = "net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManagerExtensionsKt" @@ -264,5 +286,7 @@ class SelectLocationViewModelTest { "net.mullvad.mullvadvpn.relaylist.RelayListExtensionsKt" private const val RELAY_ITEM_EXTENSIONS = "net.mullvad.mullvadvpn.relaylist.RelayItemExtensionsKt" + private const val CUSTOM_LIST_EXTENSIONS = + "net.mullvad.mullvadvpn.relaylist.CustomListExtensionsKt" } } diff --git a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Event.kt b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Event.kt index 1136ae8c55..cce2ab1f87 100644 --- a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Event.kt +++ b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Event.kt @@ -5,6 +5,7 @@ import kotlinx.parcelize.Parcelize import net.mullvad.mullvadvpn.model.AccountCreationResult import net.mullvad.mullvadvpn.model.AccountExpiry import net.mullvad.mullvadvpn.model.AccountHistory +import net.mullvad.mullvadvpn.model.CreateCustomListResult import net.mullvad.mullvadvpn.model.DeviceListEvent import net.mullvad.mullvadvpn.model.DeviceState import net.mullvad.mullvadvpn.model.LoginResult @@ -14,6 +15,7 @@ import net.mullvad.mullvadvpn.model.RelayList import net.mullvad.mullvadvpn.model.RemoveDeviceResult import net.mullvad.mullvadvpn.model.Settings import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.model.UpdateCustomListResult // Events that can be sent from the service sealed class Event : Message.EventMessage() { @@ -65,7 +67,9 @@ sealed class Event : Message.EventMessage() { @Parcelize object VpnPermissionRequest : Event() - @Parcelize data class CreateCustomListResult(val listId: String) : Event() + @Parcelize data class CreateCustomListResultEvent(val result: CreateCustomListResult) : Event() + + @Parcelize data class UpdateCustomListResultEvent(val result: UpdateCustomListResult) : Event() companion object { private const val MESSAGE_KEY = "event" diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CreateCustomListResult.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CreateCustomListResult.kt new file mode 100644 index 0000000000..73eaa209c8 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CreateCustomListResult.kt @@ -0,0 +1,10 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class CreateCustomListResult : Parcelable { + @Parcelize data class Ok(val id: String) : CreateCustomListResult() + + @Parcelize data class Error(val error: CustomListsError) : CreateCustomListResult() +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomListsError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomListsError.kt new file mode 100644 index 0000000000..83806af4f7 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomListsError.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.model + +enum class CustomListsError { + CustomListExists, + OtherError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/UpdateCustomListResult.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/UpdateCustomListResult.kt new file mode 100644 index 0000000000..ebfe9e8cd6 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/UpdateCustomListResult.kt @@ -0,0 +1,10 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class UpdateCustomListResult : Parcelable { + @Parcelize data object Ok : UpdateCustomListResult() + + @Parcelize data class Error(val error: CustomListsError) : UpdateCustomListResult() +} diff --git a/android/lib/resource/src/main/res/drawable/icon_add.xml b/android/lib/resource/src/main/res/drawable/icon_add.xml new file mode 100644 index 0000000000..1b016dcfb2 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable/icon_add.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960"> + <path + android:fillColor="@android:color/white" + android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" /> +</vector> diff --git a/android/lib/resource/src/main/res/drawable/icon_delete.xml b/android/lib/resource/src/main/res/drawable/icon_delete.xml new file mode 100644 index 0000000000..0e8b2004cb --- /dev/null +++ b/android/lib/resource/src/main/res/drawable/icon_delete.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960"> + <path + android:fillColor="@android:color/white" + android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z" /> +</vector> diff --git a/android/lib/resource/src/main/res/drawable/icon_edit.xml b/android/lib/resource/src/main/res/drawable/icon_edit.xml new file mode 100644 index 0000000000..3df2eb93a6 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable/icon_edit.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960"> + <path + android:fillColor="@android:color/white" + android:pathData="M200,760L257,760L648,369L591,312L200,703L200,760ZM120,840L120,670L648,143Q660,132 674.5,126Q689,120 705,120Q721,120 736,126Q751,132 762,144L817,200Q829,211 834.5,226Q840,241 840,256Q840,272 834.5,286.5Q829,301 817,313L290,840L120,840ZM760,256L760,256L704,200L704,200L760,256ZM619,341L591,312L591,312L648,369L648,369L619,341Z" /> +</vector> diff --git a/android/lib/resource/src/main/res/drawable/icon_more_vert.xml b/android/lib/resource/src/main/res/drawable/icon_more_vert.xml new file mode 100644 index 0000000000..59400ec977 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable/icon_more_vert.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960"> + <path + android:fillColor="@android:color/white" + android:pathData="M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z"/> +</vector> 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 66c4fbb885..1b6088b643 100644 --- a/android/lib/resource/src/main/res/values-da/strings.xml +++ b/android/lib/resource/src/main/res/values-da/strings.xml @@ -15,6 +15,7 @@ <string name="added_to_your_account">%1$s blev føjet til din konto.</string> <string name="agree_and_continue">Accepter og fortsæt</string> <string name="all_applications">Alle applikationer</string> + <string name="all_locations">Alle placeringer</string> <string name="all_providers">Alle udbydere</string> <string name="allow_lan_footer">Giver adgang til andre enheder på det samme netværk til deling, udskrivning osv.</string> <string name="always_on_vpn_error_notification_content">Kunne ikke starte tunnelforbindelse. Deaktiver Altid-til VPN for <b>%1$s</b>.</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">Deaktiver alle <b>%1$s</b> ovenfor for at aktivere denne indstilling.</string> <string name="custom_dns_footer">Aktiver for at tilføje mindst én DNS-server.</string> <string name="custom_dns_hint">Indtast IP</string> + <string name="custom_list_error_list_exists">Navnet er allerede taget.</string> + <string name="custom_lists">Brugerdefinerede lister</string> <string name="custom_port_dialog_placeholder">Indtast port</string> <string name="custom_port_dialog_remove">Fjern brugerdefineret port</string> <string name="custom_port_dialog_submit">Indstil port</string> <string name="custom_port_dialog_title">Brugerdefineret WireGuard-port</string> <string name="custom_port_dialog_valid_ranges">Gyldige intervaller: %1$s</string> <string name="custom_tunnel_host_resolution_error">Kunne ikke fortolke værten for den tilpassede tunnel. Prøv at ændre dine indstillinger.</string> + <string name="delete">Slet</string> + <string name="delete_list">Slet liste</string> <string name="device_inactive_description">Du har fjernet denne enhed. For at oprette forbindelse igen skal du logge ind igen.</string> <string name="device_inactive_title">Enheden er inaktiv</string> <string name="device_inactive_unblock_warning">Hvis du logger på, ophæves blokeringen af internettet på denne enhed.</string> @@ -194,6 +199,7 @@ <string name="rented_only">Kun lejet</string> <string name="report_a_problem">Rapporter et problem</string> <string name="reset_to_default_button">Nulstil til standard</string> + <string name="save">Gem</string> <string name="search_placeholder">Søg efter...</string> <string name="secure_connection">SIKKER TILSLUTNING</string> <string name="secured">Sikret</string> 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 59a04fcb7e..0b72c89620 100644 --- a/android/lib/resource/src/main/res/values-de/strings.xml +++ b/android/lib/resource/src/main/res/values-de/strings.xml @@ -15,6 +15,7 @@ <string name="added_to_your_account">%1$s wurde zu Ihrem Konto hinzugefügt.</string> <string name="agree_and_continue">Akzeptieren und weiter</string> <string name="all_applications">Alle Anwendungen</string> + <string name="all_locations">Alle Standorte</string> <string name="all_providers">Alle Anbieter</string> <string name="allow_lan_footer">Ermöglicht den Zugriff auf andere Geräte im selben Netzwerk zum Teilen von Dateien, Drucken etc.</string> <string name="always_on_vpn_error_notification_content">Tunnelverbindung kann nicht gestartet werden. Bitte deaktivieren Sie Always-on VPN für <b>%1$s</b>, bevor Sie Mullvad VPN verwenden.</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">Deaktivieren Sie oben alle <b>%1$s</b>, um diese Einstellung zu aktivieren.</string> <string name="custom_dns_footer">Aktivieren, um mindestens einen DNS-Server hinzuzufügen.</string> <string name="custom_dns_hint">IP eingeben</string> + <string name="custom_list_error_list_exists">Der Name ist bereits vergeben.</string> + <string name="custom_lists">Eigene Listen</string> <string name="custom_port_dialog_placeholder">Port eingeben</string> <string name="custom_port_dialog_remove">Eigenen Port entfernen</string> <string name="custom_port_dialog_submit">Port festlegen</string> <string name="custom_port_dialog_title">Eigener WireGuard-Port</string> <string name="custom_port_dialog_valid_ranges">Gültige Bereiche: %1$s</string> <string name="custom_tunnel_host_resolution_error">Der Host des benutzerdefinierten Tunnels konnte nicht aufgelöst werden. Versuchen Sie, Ihre Einstellungen zu ändern.</string> + <string name="delete">Löschen</string> + <string name="delete_list">Liste löschen</string> <string name="device_inactive_description">Sie haben dieses Gerät entfernt. Um sich erneut zu verbinden, müssen Sie sich erneut anmelden.</string> <string name="device_inactive_title">Gerät ist inaktiv</string> <string name="device_inactive_unblock_warning">Wenn Sie mit der Anmeldung fortfahren, wird die Internetsperre auf diesem Gerät aufgehoben.</string> @@ -194,6 +199,7 @@ <string name="rented_only">Nur gemietet</string> <string name="report_a_problem">Problem melden</string> <string name="reset_to_default_button">Auf Standard zurücksetzen</string> + <string name="save">Speichern</string> <string name="search_placeholder">Suchen nach …</string> <string name="secure_connection">SICHERE VERBINDUNG</string> <string name="secured">Gesichert</string> 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 b43f206ba9..0a2369af88 100644 --- a/android/lib/resource/src/main/res/values-es/strings.xml +++ b/android/lib/resource/src/main/res/values-es/strings.xml @@ -15,6 +15,7 @@ <string name="added_to_your_account">Se ha(n) añadido %1$s a su cuenta.</string> <string name="agree_and_continue">Aceptar y continuar</string> <string name="all_applications">Todas las aplicaciones</string> + <string name="all_locations">Todas las ubicaciones</string> <string name="all_providers">Todos los proveedores</string> <string name="allow_lan_footer">Permite el acceso a otros dispositivos de la misma red para compartir, imprimir, etc.</string> <string name="always_on_vpn_error_notification_content">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.</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">Deshabilite todos los <b>%1$s</b> de arriba para activar este ajuste.</string> <string name="custom_dns_footer">Active esta opción para agregar como mínimo un servidor DNS.</string> <string name="custom_dns_hint">Introducir IP</string> + <string name="custom_list_error_list_exists">Este nombre ya se está utilizando.</string> + <string name="custom_lists">Listas personalizadas</string> <string name="custom_port_dialog_placeholder">Introducir puerto</string> <string name="custom_port_dialog_remove">Quitar puerto personalizado</string> <string name="custom_port_dialog_submit">Establecer puerto</string> <string name="custom_port_dialog_title">Puerto personalizado de WireGuard</string> <string name="custom_port_dialog_valid_ranges">Intervalos válidos: %1$s</string> <string name="custom_tunnel_host_resolution_error">No se puede resolver el host del túnel personalizado. Pruebe a cambiar la configuración.</string> + <string name="delete">Eliminar</string> + <string name="delete_list">Eliminar lista</string> <string name="device_inactive_description">Ha quitado este dispositivo. Vuelva a iniciar la sesión para conectarse.</string> <string name="device_inactive_title">El dispositivo está inactivo</string> <string name="device_inactive_unblock_warning">Al iniciar la sesión, se desbloqueará el acceso a Internet en este dispositivo.</string> @@ -194,6 +199,7 @@ <string name="rented_only">Solo alquilados</string> <string name="report_a_problem">Informar de un problema</string> <string name="reset_to_default_button">Restablecer a valores predeterminados</string> + <string name="save">Guardar</string> <string name="search_placeholder">Buscar...</string> <string name="secure_connection">CONEXIÓN SEGURA</string> <string name="secured">Protegido</string> 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 a88eaa3ea4..2df0abd210 100644 --- a/android/lib/resource/src/main/res/values-fi/strings.xml +++ b/android/lib/resource/src/main/res/values-fi/strings.xml @@ -15,6 +15,7 @@ <string name="added_to_your_account">Tilillesi lisättiin %1$s käyttöaikaa.</string> <string name="agree_and_continue">Hyväksy ja jatka</string> <string name="all_applications">Kaikki sovellukset</string> + <string name="all_locations">Kaikki sijainnit</string> <string name="all_providers">Kaikki palveluntarjoajat</string> <string name="allow_lan_footer">Sallii jakamisen, tulostuksen ym. saman verkon muille laitteille.</string> <string name="always_on_vpn_error_notification_content">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ä.</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">Ota tämä asetus käyttöön poistamalla kaikki <b>%1$s</b> käytöstä yllä.</string> <string name="custom_dns_footer">Ota käyttöön lisätäksesi vähintään yhden DNS-palvelimen.</string> <string name="custom_dns_hint">Anna IP-osoite</string> + <string name="custom_list_error_list_exists">Nimi on jo olemassa.</string> + <string name="custom_lists">Mukautetut luettelot</string> <string name="custom_port_dialog_placeholder">Anna portti</string> <string name="custom_port_dialog_remove">Poista mukautettu portti</string> <string name="custom_port_dialog_submit">Määritä portti</string> <string name="custom_port_dialog_title">Mukautettu WireGuard-portti</string> <string name="custom_port_dialog_valid_ranges">Kelvolliset portit: %1$s</string> <string name="custom_tunnel_host_resolution_error">Muokatun tunnelin isännän selvittäminen ei onnistu. Kokeile muuttaa asetuksiasi.</string> + <string name="delete">Poista</string> + <string name="delete_list">Poista luettelo</string> <string name="device_inactive_description">Olet poistanut tämän laitteen. Jos haluat muodostaa yhteyden uudelleen, sinun täytyy kirjautua takaisin sisään.</string> <string name="device_inactive_title">Laite ei ole aktiivinen</string> <string name="device_inactive_unblock_warning">Kirjautumiseen siirtyminen purkaa internetin käytön eston tältä laitteelta.</string> @@ -194,6 +199,7 @@ <string name="rented_only">Vain vuokratut</string> <string name="report_a_problem">Raportoi ongelma</string> <string name="reset_to_default_button">Palauta oletusarvo</string> + <string name="save">Tallenna</string> <string name="search_placeholder">Hae...</string> <string name="secure_connection">SUOJATTU YHTEYS</string> <string name="secured">Suojattu</string> 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 633dbc9ffc..e834a0209e 100644 --- a/android/lib/resource/src/main/res/values-fr/strings.xml +++ b/android/lib/resource/src/main/res/values-fr/strings.xml @@ -15,6 +15,7 @@ <string name="added_to_your_account">%1$s ajouté(s) à votre compte.</string> <string name="agree_and_continue">Accepter et continuer</string> <string name="all_applications">Toutes les applications</string> + <string name="all_locations">Toutes les localisations</string> <string name="all_providers">Tous les fournisseurs</string> <string name="allow_lan_footer">Autorise l\'accès aux autres appareils sur le même réseau pour partager, imprimer, etc.</string> <string name="always_on_vpn_error_notification_content">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.</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">Désactivez tous les <b>%1$s</b> ci-dessus pour activer ce paramètre.</string> <string name="custom_dns_footer">Activez pour ajouter au moins un serveur DNS.</string> <string name="custom_dns_hint">Saisir l\'IP</string> + <string name="custom_list_error_list_exists">Le nom est déjà pris.</string> + <string name="custom_lists">Listes personnalisées</string> <string name="custom_port_dialog_placeholder">Saisir le port</string> <string name="custom_port_dialog_remove">Supprimer le port personnalisé</string> <string name="custom_port_dialog_submit">Définir le port</string> <string name="custom_port_dialog_title">Port WireGuard personnalisé</string> <string name="custom_port_dialog_valid_ranges">Plages valides : %1$s</string> <string name="custom_tunnel_host_resolution_error">Échec de la résolution de l\'hôte du tunnel personnalisé. Essayez de modifier vos paramètres.</string> + <string name="delete">Supprimer</string> + <string name="delete_list">Supprimer la liste</string> <string name="device_inactive_description">Vous avez supprimé cet appareil. Vous devrez vous reconnecter pour connecter cet appareil à nouveau.</string> <string name="device_inactive_title">L\'appareil est inactif</string> <string name="device_inactive_unblock_warning">Aller à la connexion débloquera la connexion Internet sur cet appareil.</string> @@ -194,6 +199,7 @@ <string name="rented_only">Loués uniquement</string> <string name="report_a_problem">Signaler un problème</string> <string name="reset_to_default_button">Réinitialiser à la valeur par défaut</string> + <string name="save">Enregistrer</string> <string name="search_placeholder">Rechercher...</string> <string name="secure_connection">CONNEXION SÉCURISÉE</string> <string name="secured">Sécurisé</string> 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 b2343abd86..bc707e2b96 100644 --- a/android/lib/resource/src/main/res/values-it/strings.xml +++ b/android/lib/resource/src/main/res/values-it/strings.xml @@ -15,6 +15,7 @@ <string name="added_to_your_account">%1$s aggiunti al tuo account.</string> <string name="agree_and_continue">Accetta e continua</string> <string name="all_applications">Tutte le applicazioni</string> + <string name="all_locations">Tutti i luoghi</string> <string name="all_providers">Tutti i fornitori</string> <string name="allow_lan_footer">Consenti l\'accesso ad altri dispositivi sulla stessa rete per condividere, stampare e altro.</string> <string name="always_on_vpn_error_notification_content">Impossibile avviare la connessione tunnel. Disabilita VPN sempre attiva per <b>%1$s</b> prima di utilizzare Mullvad VPN.</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">Disabilita tutti i <b>%1$s</b> sopra per attivare questa impostazione.</string> <string name="custom_dns_footer">Abilita per aggiungere almeno un server DNS.</string> <string name="custom_dns_hint">Inserisci IP</string> + <string name="custom_list_error_list_exists">Il nome è già preso.</string> + <string name="custom_lists">Elenchi personalizzati</string> <string name="custom_port_dialog_placeholder">Inserisci porta</string> <string name="custom_port_dialog_remove">Rimuovi porta personalizzata</string> <string name="custom_port_dialog_submit">Imposta porta</string> <string name="custom_port_dialog_title">Porta personalizzata WireGuard</string> <string name="custom_port_dialog_valid_ranges">Intervalli validi: %1$s</string> <string name="custom_tunnel_host_resolution_error">Impossibile risolvere l\'host del tunnel personalizzato. Prova a modificare le impostazioni.</string> + <string name="delete">Elimina</string> + <string name="delete_list">Elimina elenco</string> <string name="device_inactive_description">Hai rimosso questo dispositivo. Per riconnetterti, dovrai effettuare nuovamente il login.</string> <string name="device_inactive_title">Il dispositivo è inattivo</string> <string name="device_inactive_unblock_warning">Andare al login sbloccherà Internet su questo dispositivo.</string> @@ -194,6 +199,7 @@ <string name="rented_only">Solo noleggiati</string> <string name="report_a_problem">Segnala un problema</string> <string name="reset_to_default_button">Ripristina predefiniti</string> + <string name="save">Salva</string> <string name="search_placeholder">Cerca...</string> <string name="secure_connection">CONNESSIONE PROTETTA</string> <string name="secured">Protetto</string> 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 4a92cd78af..71e1907d9e 100644 --- a/android/lib/resource/src/main/res/values-ja/strings.xml +++ b/android/lib/resource/src/main/res/values-ja/strings.xml @@ -15,6 +15,7 @@ <string name="added_to_your_account">%1$sがアカウントに追加されました。</string> <string name="agree_and_continue">同意して続行</string> <string name="all_applications">すべてのアプリケーション</string> + <string name="all_locations">すべての場所</string> <string name="all_providers">すべてのプロバイダ</string> <string name="allow_lan_footer">共有や印刷などのため、同一ネットワーク上の他のデバイスへのアクセスを許可します。</string> <string name="always_on_vpn_error_notification_content">トンネル接続を開始できません。Mullvad VPNを使用する前に<b>%1$s</b>のAlways-on VPNを無効にしてください。</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">この設定を有効にするには、上記のすべての<b>%1$s</b>を無効にしてください。</string> <string name="custom_dns_footer">1つ以上のDNSサーバーを追加するには有効にしてください。</string> <string name="custom_dns_hint">IPを入力</string> + <string name="custom_list_error_list_exists">名前はすでに使用されています。</string> + <string name="custom_lists">カスタムリスト</string> <string name="custom_port_dialog_placeholder">ポートを入力</string> <string name="custom_port_dialog_remove">カスタムポートを削除</string> <string name="custom_port_dialog_submit">ポートを設定</string> <string name="custom_port_dialog_title">WireGuardカスタムポート</string> <string name="custom_port_dialog_valid_ranges">有効な範囲: %1$s</string> <string name="custom_tunnel_host_resolution_error">カスタムトンネルのホストを解決できません。設定を変更してみてください。</string> + <string name="delete">削除</string> + <string name="delete_list">リストを削除</string> <string name="device_inactive_description">このデバイスを削除しました。再度接続するには、ログインし直す必要があります。</string> <string name="device_inactive_title">デバイスが無効です</string> <string name="device_inactive_unblock_warning">ログインに進むと、このデバイスのインターネットのブロックが解除されます。</string> @@ -194,6 +199,7 @@ <string name="rented_only">レンタルサーバーのみ</string> <string name="report_a_problem">問題を報告する</string> <string name="reset_to_default_button">デフォルトにリセット</string> + <string name="save">保存</string> <string name="search_placeholder">検索...</string> <string name="secure_connection">セキュリティ保護された接続</string> <string name="secured">セキュリティ保護されています</string> 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 063628f691..7700ad87dc 100644 --- a/android/lib/resource/src/main/res/values-ko/strings.xml +++ b/android/lib/resource/src/main/res/values-ko/strings.xml @@ -15,6 +15,7 @@ <string name="added_to_your_account">%1$s이(가) 계정에 추가되었습니다.</string> <string name="agree_and_continue">동의하고 계속하기</string> <string name="all_applications">모든 애플리케이션</string> + <string name="all_locations">모든 위치</string> <string name="all_providers">모든 제공업체</string> <string name="allow_lan_footer">공유, 인쇄 등을 위해 동일한 네트워크의 다른 장치에 액세스할 수 있습니다.</string> <string name="always_on_vpn_error_notification_content">터널 연결을 시작할 수 없습니다. Mullvad VPN을 사용하기 전에 <b>%1$s</b>에 대한 상시 접속 VPN을 비활성화하세요.</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">이 설정을 활성화하려면 위의 모든 <b>%1$s</b>을(를) 비활성화하세요.</string> <string name="custom_dns_footer">하나 이상의 DNS 서버를 추가하려면 활성화합니다.</string> <string name="custom_dns_hint">IP 입력</string> + <string name="custom_list_error_list_exists">이미 사용 중인 이름입니다.</string> + <string name="custom_lists">사용자 지정 목록</string> <string name="custom_port_dialog_placeholder">포트 입력</string> <string name="custom_port_dialog_remove">사용자 지정 포트 제거</string> <string name="custom_port_dialog_submit">포트 설정</string> <string name="custom_port_dialog_title">WireGuard 사용자 지정 포트</string> <string name="custom_port_dialog_valid_ranges">유효한 범위: %1$s</string> <string name="custom_tunnel_host_resolution_error">사용자 지정 터널의 호스트를 확인할 수 없습니다. 설정을 변경해 보세요.</string> + <string name="delete">삭제</string> + <string name="delete_list">목록 삭제</string> <string name="device_inactive_description">이 장치를 제거했습니다. 다시 연결하려면 다시 로그인해야 합니다.</string> <string name="device_inactive_title">장치가 비활성 상태입니다.</string> <string name="device_inactive_unblock_warning">로그인하면 이 장치에서 인터넷 차단이 해제됩니다.</string> @@ -194,6 +199,7 @@ <string name="rented_only">대여만</string> <string name="report_a_problem">문제 신고</string> <string name="reset_to_default_button">기본값으로 재설정</string> + <string name="save">저장</string> <string name="search_placeholder">검색...</string> <string name="secure_connection">보안 연결</string> <string name="secured">안전함</string> 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 9a3cf2ba40..34b27d3cf8 100644 --- a/android/lib/resource/src/main/res/values-my/strings.xml +++ b/android/lib/resource/src/main/res/values-my/strings.xml @@ -15,6 +15,7 @@ <string name="added_to_your_account">သင့် အကောင့်ထဲသို့ %1$s ကို ပေါင်းထည့်ထားပါသည်။</string> <string name="agree_and_continue">သဘောတူပြီး ဆက်လုပ်ရန်</string> <string name="all_applications">အပလီကေးရှင်း အားလုံး</string> + <string name="all_locations">တည်နေရာအာလုံး</string> <string name="all_providers">ပံ့ပိုးသူအားလုံး</string> <string name="allow_lan_footer">ဝေမျှရန်၊ ပရင့်ထုတ်ရန်စသည်တို့အတွက် တူညီသည့် ကွန်ရက်ရှိ အခြားစက်များကို ရယူသုံးစွဲခွင့်ပြုပေးပါသည်။</string> <string name="always_on_vpn_error_notification_content">Tunnel ချိတ်ဆက်မှုကို စတင်၍ မရနိုင်ပါ။ Mullvad VPN ကို မသုံးမီ <b>%1$s</b> အတွက် VPN အမြဲဖွင့်ထားမှုကို ပိတ်ပေးပါ။</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">ဤဆက်တင်ကို သက်ဝင်လုပ်ဆောင်ရန် အထက်ရှိ <b>%1$s</b> အားလုံးကို ပိတ်ပါ။</string> <string name="custom_dns_footer">အနည်းဆုံး DNS ဆာဗာတစ်ခုကို ပေါင်းထည့်ပါ။</string> <string name="custom_dns_hint">IP ရိုက်ထည့်ရန်</string> + <string name="custom_list_error_list_exists">အမည်သည် ရှိနှင့်ပြီး ဖြစ်သည်။</string> + <string name="custom_lists">စိတ်ကြိုက်စာရင်းများ</string> <string name="custom_port_dialog_placeholder">ပေါ့တ် ရိုက်ထည့်ရန်</string> <string name="custom_port_dialog_remove">စိတ်ကြိုက် ပေါ့တ်ကို ဖယ်ရှားရန်</string> <string name="custom_port_dialog_submit">ပေါ့တ် သတ်မှတ်ရန်</string> <string name="custom_port_dialog_title">စိတ်ကြိုက် WireGuard ပေါ့တ်</string> <string name="custom_port_dialog_valid_ranges">အကျုံးဝင်သည့် အပိုင်းအခြား- %1$s</string> <string name="custom_tunnel_host_resolution_error">စိတ်ကြိုက်ပြုလုပ်ထားသည့် Tunnel ၏ Host ကို ဖြေရှင်း၍ မရနိုင်ပါ။ သင့်ဆက်တင်ကို ပြောင်းကြည့်ပါ။</string> + <string name="delete">ဖျက်ရန်</string> + <string name="delete_list">စာရင်းကို ဖျက်ရန်</string> <string name="device_inactive_description">ဤစက်ကို ဖယ်ရှားပြီး ဖြစ်သည်။ ထပ်မံချိတ်ဆက်ရန်အတွက် ပြန်လည် ဝင်ရောက်ရန် လိုပါသည်။</string> <string name="device_inactive_title">စက်သည် သက်ဝင်လုပ်ဆောင်မှု မရှိပါ</string> <string name="device_inactive_unblock_warning">ဝင်ရောက်ရန်သွားခြင်းဖြင့် ဤစက်တွင် အင်တာနက်ကို ပိတ်ဆို့ထားမှုမှ ဖယ်ရှားပါလိမ့်မည်။</string> @@ -194,6 +199,7 @@ <string name="rented_only">အငှားသီးသန့်</string> <string name="report_a_problem">ပြဿနာ ရီပို့တ်လုပ်ရန်</string> <string name="reset_to_default_button">ပုံသေသို့ ပြန်လည်သတ်မှတ်ရန်</string> + <string name="save">သိမ်းမည်</string> <string name="search_placeholder">ရှာရန်...</string> <string name="secure_connection">လုံခြုံသည့် ချိတ်ဆက်မှု</string> <string name="secured">လုံခြုံပါသည်</string> 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 5d9a283305..e227790588 100644 --- a/android/lib/resource/src/main/res/values-nb/strings.xml +++ b/android/lib/resource/src/main/res/values-nb/strings.xml @@ -15,6 +15,7 @@ <string name="added_to_your_account">%1$s ble lagt til kontoen din.</string> <string name="agree_and_continue">Godta og fortsett</string> <string name="all_applications">Alle applikasjoner</string> + <string name="all_locations">Alle steder</string> <string name="all_providers">Alle leverandører</string> <string name="allow_lan_footer">Gir tilgang til andre enheter på samme nettverk for deling, utskrift osv.</string> <string name="always_on_vpn_error_notification_content">Kunne ikke starte tunneltilkobling. Deaktiver VPN som alltid er på, for <b>%1$s</b> før du bruker Mullvad VPN.</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">Deaktiver alle <b>%1$s</b> ovenfor for å aktivere denne innstillingen.</string> <string name="custom_dns_footer">Aktiver for å legge til minst én DNS-server.</string> <string name="custom_dns_hint">Skriv inn IP</string> + <string name="custom_list_error_list_exists">Navn allerede i bruk.</string> + <string name="custom_lists">Tilpassede lister</string> <string name="custom_port_dialog_placeholder">Skriv inn port</string> <string name="custom_port_dialog_remove">Fjern tilpasset port</string> <string name="custom_port_dialog_submit">Konfigurer port</string> <string name="custom_port_dialog_title">Tilpasset WireGuard-port</string> <string name="custom_port_dialog_valid_ranges">Gyldige verdiområder: %1$s</string> <string name="custom_tunnel_host_resolution_error">Kunne ikke løse vert for egendefinert tunnel. Forsøk å endre innstillingene dine.</string> + <string name="delete">Slett</string> + <string name="delete_list">Slett liste</string> <string name="device_inactive_description">Du har fjernet denne enheten. For å koble til igjen, må du logge inn på nytt.</string> <string name="device_inactive_title">Enheten er inaktiv</string> <string name="device_inactive_unblock_warning">Å gå til pålogging vil oppheve blokkeringen av internettet på denne enheten.</string> @@ -194,6 +199,7 @@ <string name="rented_only">Kun leid</string> <string name="report_a_problem">Rapporter et problem</string> <string name="reset_to_default_button">Tilbakestill til standard</string> + <string name="save">Lagre</string> <string name="search_placeholder">Søk etter ...</string> <string name="secure_connection">SIKKER TILKOBLING</string> <string name="secured">Sikret</string> 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 63d878951f..eeae7d2718 100644 --- a/android/lib/resource/src/main/res/values-nl/strings.xml +++ b/android/lib/resource/src/main/res/values-nl/strings.xml @@ -15,6 +15,7 @@ <string name="added_to_your_account">%1$s is toegevoegd aan uw account.</string> <string name="agree_and_continue">Akkoord en doorgaan</string> <string name="all_applications">Alle toepassingen</string> + <string name="all_locations">Alle locaties</string> <string name="all_providers">Alle aanbieders</string> <string name="allow_lan_footer">Biedt toegang tot andere apparaten op hetzelfde netwerk voor delen, afdrukken en dergelijke</string> <string name="always_on_vpn_error_notification_content">Kan de tunnelverbinding niet starten. Schakel Altijd-aan VPN uit voor <b>%1$s</b> voordat u Mullvad VPN gebruikt.</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">Schakel alle <b>%1$s</b> hierboven uit om deze instelling te activeren.</string> <string name="custom_dns_footer">Schakel in om minimaal één DNS-server toe te voegen.</string> <string name="custom_dns_hint">Voer IP-adres in</string> + <string name="custom_list_error_list_exists">Naam wordt al gebruikt.</string> + <string name="custom_lists">Aangepaste lijsten</string> <string name="custom_port_dialog_placeholder">Voer poort in</string> <string name="custom_port_dialog_remove">Aangepaste poort verwijderen</string> <string name="custom_port_dialog_submit">Poort instellen</string> <string name="custom_port_dialog_title">Aangepaste WireGuard-poort</string> <string name="custom_port_dialog_valid_ranges">Geldige bereiken: %1$s</string> <string name="custom_tunnel_host_resolution_error">Kan host van aangepaste tunnel niet omzetten. Probeer uw instellingen te wijzigen.</string> + <string name="delete">Verwijderen</string> + <string name="delete_list">Lijst verwijderen</string> <string name="device_inactive_description">U hebt dit apparaat verwijderd. U moet zich opnieuw aanmelden om het opnieuw te verbinden.</string> <string name="device_inactive_title">Apparaat is niet actief</string> <string name="device_inactive_unblock_warning">Als u naar aanmelden gaat, wordt het blokkeren van internet op dit apparaat opgeheven.</string> @@ -194,6 +199,7 @@ <string name="rented_only">Alleen gehuurde</string> <string name="report_a_problem">Een probleem rapporteren</string> <string name="reset_to_default_button">Standaardwaarde herstellen</string> + <string name="save">Opslaan</string> <string name="search_placeholder">Zoeken naar...</string> <string name="secure_connection">BEVEILIGDE VERBINDING</string> <string name="secured">Beveiligd</string> 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 aa1f500c88..c03157ab0b 100644 --- a/android/lib/resource/src/main/res/values-pl/strings.xml +++ b/android/lib/resource/src/main/res/values-pl/strings.xml @@ -15,6 +15,7 @@ <string name="added_to_your_account">Do Twojego konta dodano %1$s.</string> <string name="agree_and_continue">Zaakceptuj i kontynuuj</string> <string name="all_applications">Wszystkie aplikacje</string> + <string name="all_locations">Wszystkie lokalizacje</string> <string name="all_providers">Wszyscy dostawcy</string> <string name="allow_lan_footer">Umożliwia dostęp do innych urządzeń w tej samej sieci w celu udostępniania, drukowania itd.</string> <string name="always_on_vpn_error_notification_content">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>.</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">Aby aktywować to ustawienie, wyłącz powyżej wszystkie <b>%1$s</b>.</string> <string name="custom_dns_footer">Włącz, aby dodać co najmniej jeden serwer DNS.</string> <string name="custom_dns_hint">Wprowadź adres IP</string> + <string name="custom_list_error_list_exists">Nazwa jest już zajęta.</string> + <string name="custom_lists">Listy niestandardowe</string> <string name="custom_port_dialog_placeholder">Wprowadź port</string> <string name="custom_port_dialog_remove">Usuń port niestandardowy</string> <string name="custom_port_dialog_submit">Ustaw port</string> <string name="custom_port_dialog_title">Niestandardowy port WireGuard</string> <string name="custom_port_dialog_valid_ranges">Prawidłowe zakresy: %1$s</string> <string name="custom_tunnel_host_resolution_error">Nie można rozpoznać hosta tunelu niestandardowego. Spróbuj zmienić ustawienia.</string> + <string name="delete">Usuń</string> + <string name="delete_list">Usuń listę</string> <string name="device_inactive_description">Urządzenie usunięto. Aby połączyć się ponownie, musisz się ponownie zalogować.</string> <string name="device_inactive_title">Urządzenie nieaktywne</string> <string name="device_inactive_unblock_warning">Przejście do logowania odblokuje Internet na tym urządzeniu.</string> @@ -194,6 +199,7 @@ <string name="rented_only">Wyłącznie wynajmowane</string> <string name="report_a_problem">Zgłoś problem</string> <string name="reset_to_default_button">Przywróć domyślne</string> + <string name="save">Zapisz</string> <string name="search_placeholder">Wyszukaj...</string> <string name="secure_connection">BEZPIECZNE POŁĄCZENIE</string> <string name="secured">Zabezpieczone</string> 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 fe7a1fcfe3..ddecdfde52 100644 --- a/android/lib/resource/src/main/res/values-pt/strings.xml +++ b/android/lib/resource/src/main/res/values-pt/strings.xml @@ -15,6 +15,7 @@ <string name="added_to_your_account">%1$s adicionado(s) à sua conta.</string> <string name="agree_and_continue">Concordar e continuar</string> <string name="all_applications">Todas as aplicações</string> + <string name="all_locations">Todas as localizações</string> <string name="all_providers">Todos os fornecedores</string> <string name="allow_lan_footer">Permite o acesso a outros dispositivos na mesma rede para partilha, impressão, etc.</string> <string name="always_on_vpn_error_notification_content">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.</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">Desative todos os <b>%1$s</b> abaixo para ativar esta definição.</string> <string name="custom_dns_footer">Ativar para adicionar pelo menos um servidor DNS.</string> <string name="custom_dns_hint">Introduzir IP</string> + <string name="custom_list_error_list_exists">O nome já está a ser utilizado.</string> + <string name="custom_lists">Listas personalizadas</string> <string name="custom_port_dialog_placeholder">Introduzir porta</string> <string name="custom_port_dialog_remove">Remover porta personalizada</string> <string name="custom_port_dialog_submit">Definir porta</string> <string name="custom_port_dialog_title">Porta personalizada WireGuard</string> <string name="custom_port_dialog_valid_ranges">Intervalos válidos: %1$s</string> <string name="custom_tunnel_host_resolution_error">Não foi possível resolver o anfitrião do túnel personalizado. Experimente alterar as suas definições.</string> + <string name="delete">Eliminar</string> + <string name="delete_list">Eliminar lista</string> <string name="device_inactive_description">Removeu este dispositivo. Para voltar a ligar o dispositivo, terá de voltar a iniciar a sessão.</string> <string name="device_inactive_title">O dispositivo está desativado</string> <string name="device_inactive_unblock_warning">Ir para a ligação irá desbloquear a Internet neste dispositivo.</string> @@ -194,6 +199,7 @@ <string name="rented_only">Apenas alugado</string> <string name="report_a_problem">Reportar um problema</string> <string name="reset_to_default_button">Repor para as predefinições</string> + <string name="save">Guardar</string> <string name="search_placeholder">Pesquisar por...</string> <string name="secure_connection">LIGAÇÃO SEGURA</string> <string name="secured">Seguro</string> 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 5f2ad8489c..088ad7625f 100644 --- a/android/lib/resource/src/main/res/values-ru/strings.xml +++ b/android/lib/resource/src/main/res/values-ru/strings.xml @@ -15,6 +15,7 @@ <string name="added_to_your_account">На учетную запись добавлено время: %1$s.</string> <string name="agree_and_continue">Согласиться и продолжить</string> <string name="all_applications">Все приложения</string> + <string name="all_locations">Все местоположения</string> <string name="all_providers">Все провайдеры</string> <string name="allow_lan_footer">Разрешить доступ к другим устройствам в той же сети для организации общего доступа, печати и т. д.</string> <string name="always_on_vpn_error_notification_content">Не удалось запустить туннельное подключение. Перед использованием Mullvad VPN отключите опцию «Постоянная VPN» для приложения <b>%1$s</b>.</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">Чтобы включить эту настройку, отключите все <b>%1$s</b> выше.</string> <string name="custom_dns_footer">Чтобы добавить как минимум один DNS-сервер, включите этот параметр.</string> <string name="custom_dns_hint">Введите IP-адрес</string> + <string name="custom_list_error_list_exists">Такое название уже используется.</string> + <string name="custom_lists">Свои списки</string> <string name="custom_port_dialog_placeholder">Введите порт</string> <string name="custom_port_dialog_remove">Удалить пользовательский порт</string> <string name="custom_port_dialog_submit">Установить порт</string> <string name="custom_port_dialog_title">Пользовательский порт WireGuard</string> <string name="custom_port_dialog_valid_ranges">Допустимые диапазоны: %1$s</string> <string name="custom_tunnel_host_resolution_error">Не удалось преобразовать имя узла пользовательского туннеля. Попробуйте изменить настройки.</string> + <string name="delete">Удалить</string> + <string name="delete_list">Удалить список</string> <string name="device_inactive_description">Вы удалили это устройство. Чтобы снова подключиться, нужно будет выполнить вход.</string> <string name="device_inactive_title">Устройство неактивно</string> <string name="device_inactive_unblock_warning">Вход в профиль разблокирует Интернет на этом устройстве.</string> @@ -194,6 +199,7 @@ <string name="rented_only">Только арендованные</string> <string name="report_a_problem">Сообщение о проблеме</string> <string name="reset_to_default_button">Восстановить значение по умолчанию</string> + <string name="save">Сохранить</string> <string name="search_placeholder">Поиск...</string> <string name="secure_connection">ЗАЩИЩЕННОЕ ПОДКЛЮЧЕНИЕ</string> <string name="secured">Подключение защищено</string> 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 0addc6598b..f5a5012025 100644 --- a/android/lib/resource/src/main/res/values-sv/strings.xml +++ b/android/lib/resource/src/main/res/values-sv/strings.xml @@ -15,6 +15,7 @@ <string name="added_to_your_account">%1$s har lagts till på ditt konto.</string> <string name="agree_and_continue">Godkänn och fortsätt</string> <string name="all_applications">Alla applikationer</string> + <string name="all_locations">Alla platser</string> <string name="all_providers">Alla leverantörer</string> <string name="allow_lan_footer">Tillåter åtkomst till andra enheter på samma nätverk för delning, utskrift osv.</string> <string name="always_on_vpn_error_notification_content">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.</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">Inaktivera alla <b>%1$s</b> ovan för att aktivera inställningen.</string> <string name="custom_dns_footer">Aktivera för att lägga till minst en DNS-server.</string> <string name="custom_dns_hint">Ange IP</string> + <string name="custom_list_error_list_exists">Namnet används redan.</string> + <string name="custom_lists">Anpassade listor</string> <string name="custom_port_dialog_placeholder">Ange port</string> <string name="custom_port_dialog_remove">Ta bort anpassad port</string> <string name="custom_port_dialog_submit">Ställ in port</string> <string name="custom_port_dialog_title">Anpassad WireGuard-port</string> <string name="custom_port_dialog_valid_ranges">Giltiga intervall: %1$s</string> <string name="custom_tunnel_host_resolution_error">Det går inte att lösa värd för anpassad tunnel. Försök att ändra inställningarna.</string> + <string name="delete">Ta bort</string> + <string name="delete_list">Ta bort lista</string> <string name="device_inactive_description">Du har tagit bort den här enheten. Du måste logga in igen för att återansluta.</string> <string name="device_inactive_title">Enheten är inaktiv</string> <string name="device_inactive_unblock_warning">Om du loggar in tas blockering av internet bort på den här enheten.</string> @@ -194,6 +199,7 @@ <string name="rented_only">Endast hyrd</string> <string name="report_a_problem">Rapportera ett problem</string> <string name="reset_to_default_button">Återställ till standard</string> + <string name="save">Spara</string> <string name="search_placeholder">Sök efter …</string> <string name="secure_connection">SÄKER ANSLUTNING</string> <string name="secured">Skyddad</string> 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 fd0e73ffe1..935ca673e4 100644 --- a/android/lib/resource/src/main/res/values-th/strings.xml +++ b/android/lib/resource/src/main/res/values-th/strings.xml @@ -15,6 +15,7 @@ <string name="added_to_your_account">%1$s ถูกเพิ่มลงในบัญชีของคุณแล้ว</string> <string name="agree_and_continue">ยอมรับและดำเนินการต่อ</string> <string name="all_applications">แอปพลิเคชันทั้งหมด</string> + <string name="all_locations">ตำแหน่งที่ตั้งทั้งหมด</string> <string name="all_providers">ผู้ให้บริการทั้งหมด</string> <string name="allow_lan_footer">อนุญาตให้เข้าถึงอุปกรณ์อื่นๆ บนเครือข่ายเดียวกัน เพื่อแชร์ พิมพ์ ฯลฯ</string> <string name="always_on_vpn_error_notification_content">ไม่สามารถเริ่มการเชื่อมต่ออุโมงค์ได้ โปรดปิดใช้งาน Always-on VPN เป็นเวลา <b>%1$s</b> ก่อนที่จะใช้งาน Mullvad VPN</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">ปิดใช้งาน <b>%1$s</b> ด้านบนทั้งหมด เพื่อเปิดใช้การตั้งค่านี้</string> <string name="custom_dns_footer">เปิดเพื่อเพิ่มเซิร์ฟเวอร์ DNS อย่างน้อยหนึ่งรายการ</string> <string name="custom_dns_hint">ป้อน IP</string> + <string name="custom_list_error_list_exists">ชื่อนี้ถูกใช้ไปแล้ว</string> + <string name="custom_lists">รายการกำหนดเอง</string> <string name="custom_port_dialog_placeholder">ป้อนพอร์ต</string> <string name="custom_port_dialog_remove">นำพอร์ตแบบกำหนดเองออก</string> <string name="custom_port_dialog_submit">ตั้งค่าพอร์ต</string> <string name="custom_port_dialog_title">พอร์ต WireGuard แบบกำหนดเอง</string> <string name="custom_port_dialog_valid_ranges">ช่วงที่ใช้ได้: %1$s</string> <string name="custom_tunnel_host_resolution_error">ไม่พบโฮสต์ของช่องทางแบบกำหนดเอง กรุณาลองเปลี่ยนการตั้งค่าของคุณ</string> + <string name="delete">ลบ</string> + <string name="delete_list">ลบรายการ</string> <string name="device_inactive_description">คุณได้ลบอุปกรณ์เครื่องนี้แล้ว หากต้องการเชื่อมต่ออีกครั้ง คุณจะต้องเข้าสู่ระบบใหม่อีกครั้ง</string> <string name="device_inactive_title">อุปกรณ์ไม่ได้ใช้งาน</string> <string name="device_inactive_unblock_warning">การไปที่ส่วนเข้าสู่ระบบจะปลดบล็อกอินเทอร์เน็ตบนอุปกรณ์เครื่องนี้</string> @@ -194,6 +199,7 @@ <string name="rented_only">เช่าเท่านั้น</string> <string name="report_a_problem">รายงานปัญหา</string> <string name="reset_to_default_button">รีเซ็ตเป็นค่าเริ่มต้น</string> + <string name="save">บันทึก</string> <string name="search_placeholder">ค้นหา…</string> <string name="secure_connection">การเชื่อมต่อที่ปลอดภัย</string> <string name="secured">ปลอดภัย</string> 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 120faf329c..4d3a0e4b45 100644 --- a/android/lib/resource/src/main/res/values-tr/strings.xml +++ b/android/lib/resource/src/main/res/values-tr/strings.xml @@ -15,6 +15,7 @@ <string name="added_to_your_account">Hesabınıza %1$s eklendi.</string> <string name="agree_and_continue">Kabul et ve devam et</string> <string name="all_applications">Tüm uygulamalar</string> + <string name="all_locations">Tüm konumlar</string> <string name="all_providers">Tüm hizmet sağlayıcılar</string> <string name="allow_lan_footer">Paylaşım, yazdırma gibi özellikler için aynı ağdaki diğer cihazlara erişim izni verir.</string> <string name="always_on_vpn_error_notification_content">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.</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">Bu ayarı etkinleştirmek için yukarıdaki <b>%1$s</b> öğelerinin tümünü devre dışı bırakın.</string> <string name="custom_dns_footer">En az bir DNS sunucusu eklemek için etkinleştirin.</string> <string name="custom_dns_hint">IP\'yi girin</string> + <string name="custom_list_error_list_exists">İsim zaten kullanılıyor.</string> + <string name="custom_lists">Özel listeler</string> <string name="custom_port_dialog_placeholder">Portu girin</string> <string name="custom_port_dialog_remove">Özel portu kaldır</string> <string name="custom_port_dialog_submit">Portu ayarla</string> <string name="custom_port_dialog_title">WireGuard özel portu</string> <string name="custom_port_dialog_valid_ranges">Geçerli aralıklar: %1$s</string> <string name="custom_tunnel_host_resolution_error">Özel tünel ana bilgisayarı çözülemedi. Ayarlarınızı değiştirmeyi deneyin.</string> + <string name="delete">Sil</string> + <string name="delete_list">Listeyi sil</string> <string name="device_inactive_description">Bu cihazı kaldırdın. Tekrar bağlanmak için yeniden giriş yapmanız gerekecek.</string> <string name="device_inactive_title">Cihaz etkin değil</string> <string name="device_inactive_unblock_warning">Giriş yapmak bu cihazdaki internet engelini kaldıracaktır.</string> @@ -194,6 +199,7 @@ <string name="rented_only">Sadece kiralananlar</string> <string name="report_a_problem">Bir sorun bildir</string> <string name="reset_to_default_button">Varsayılana sıfırla</string> + <string name="save">Kaydet</string> <string name="search_placeholder">Ara...</string> <string name="secure_connection">GÜVENLİ BAĞLANTI</string> <string name="secured">Güvenli</string> 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 6fcf9acc4d..f9e3e30ea6 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 @@ -15,6 +15,7 @@ <string name="added_to_your_account">%1$s已添加到您的帐户中。</string> <string name="agree_and_continue">同意并继续</string> <string name="all_applications">所有应用程序</string> + <string name="all_locations">所有位置</string> <string name="all_providers">所有提供商</string> <string name="allow_lan_footer">允许访问同一网络上的其他设备,以进行共享、打印等</string> <string name="always_on_vpn_error_notification_content">无法启动隧道连接。在使用 Mullvad VPN 之前,请为 <b>%1$s</b> 禁用“始终开启的 VPN”。</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">禁用上方的所有 <b>%1$s</b>以激活此设置。</string> <string name="custom_dns_footer">启用以添加至少一个 DNS 服务器。</string> <string name="custom_dns_hint">输入 IP</string> + <string name="custom_list_error_list_exists">名称已被占用。</string> + <string name="custom_lists">自定义列表</string> <string name="custom_port_dialog_placeholder">输入端口</string> <string name="custom_port_dialog_remove">移除自定义端口</string> <string name="custom_port_dialog_submit">设置端口</string> <string name="custom_port_dialog_title">WireGuard 自定义端口</string> <string name="custom_port_dialog_valid_ranges">有效范围:%1$s</string> <string name="custom_tunnel_host_resolution_error">无法解析自定义隧道的主机。请尝试更改您的设置。</string> + <string name="delete">删除</string> + <string name="delete_list">删除列表</string> <string name="device_inactive_description">您已移除此设备。要重新连接,您需要重新登录。</string> <string name="device_inactive_title">设备处于非活动状态</string> <string name="device_inactive_unblock_warning">前往登录将在此设备上解除阻止互联网。</string> @@ -194,6 +199,7 @@ <string name="rented_only">仅租用</string> <string name="report_a_problem">报告问题</string> <string name="reset_to_default_button">重置为默认值</string> + <string name="save">保存</string> <string name="search_placeholder">搜索…</string> <string name="secure_connection">安全连接</string> <string name="secured">已受保护</string> 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 c7e5f7f137..a250e018e4 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 @@ -15,6 +15,7 @@ <string name="added_to_your_account">%1$s已新增至您的帳戶。</string> <string name="agree_and_continue">同意並繼續</string> <string name="all_applications">所有應用程式</string> + <string name="all_locations">所有位置</string> <string name="all_providers">所有供應商</string> <string name="allow_lan_footer">允許存取同一網路上的其他裝置,以進行分享、列印等。</string> <string name="always_on_vpn_error_notification_content">無法啟動通道連線。在使用 Mullvad VPN 之前,請先為 <b>%1$s</b> 停用「始終啟用 VPN」。</string> @@ -68,12 +69,16 @@ <string name="custom_dns_disable_mode_subtitle">停用上方所有 <b>%1$s</b>以啟動此設定。</string> <string name="custom_dns_footer">啟用以新增至少一個 DNS 伺服器。</string> <string name="custom_dns_hint">輸入 IP</string> + <string name="custom_list_error_list_exists">名稱已在別處使用。</string> + <string name="custom_lists">自訂清單</string> <string name="custom_port_dialog_placeholder">輸入連接埠</string> <string name="custom_port_dialog_remove">移除自訂連接埠</string> <string name="custom_port_dialog_submit">設定連接埠</string> <string name="custom_port_dialog_title">WireGuard 自訂連接埠</string> <string name="custom_port_dialog_valid_ranges">有效範圍:%1$s</string> <string name="custom_tunnel_host_resolution_error">無法解析自訂通道的主機。請嘗試變更您的設定。</string> + <string name="delete">刪除</string> + <string name="delete_list">刪除清單</string> <string name="device_inactive_description">您已移除此裝置。若要重新連線,您需要重新登入。</string> <string name="device_inactive_title">裝置處於非活動狀態</string> <string name="device_inactive_unblock_warning">若前往登入,則會在此裝置上解除對網際網路的封鎖。</string> @@ -194,6 +199,7 @@ <string name="rented_only">僅租用</string> <string name="report_a_problem">回報問題</string> <string name="reset_to_default_button">重設為預設值</string> + <string name="save">儲存</string> <string name="search_placeholder">搜尋…</string> <string name="secure_connection">安全連線</string> <string name="secured">安全</string> diff --git a/android/lib/resource/src/main/res/values/plurals.xml b/android/lib/resource/src/main/res/values/plurals.xml index 455d42c1f2..cd913ffa05 100644 --- a/android/lib/resource/src/main/res/values/plurals.xml +++ b/android/lib/resource/src/main/res/values/plurals.xml @@ -48,4 +48,8 @@ <item quantity="one">%d month</item> <item quantity="other">%d months</item> </plurals> + <plurals name="number_of_locations"> + <item quantity="one">%d location</item> + <item quantity="other">%d locations</item> + </plurals> </resources> diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index 4400250dbc..88c38adc7f 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -282,4 +282,39 @@ <string name="split_tunneling_disabled_description">Split tunneling is disabled.</string> <string name="auto_connect_legacy">Auto-connect (legacy)</string> <string name="auto_connect_footer_legacy"><![CDATA[Please use the <b>Always-on</b> system setting instead by following the guide in <b>%s</b> above.]]></string> + <string name="custom_lists">Custom lists</string> + <string name="all_locations">All locations</string> + <string name="edit_lists">Edit lists</string> + <string name="create_new_list">Create new list</string> + <string name="create">Create</string> + <string name="no_locations_found">No locations found</string> + <string name="add_locations">Add locations</string> + <string name="save">Save</string> + <string name="edit_custom_lists">Edit custom lists</string> + <string name="delete_list">Delete list</string> + <string name="list_name">List name</string> + <string name="locations">Locations</string> + <string name="edit_locations">Edit locations</string> + <string name="delete_custom_list_confirmation_description"> + Delete \"%s\"? + </string> + <string name="custom_list_error_list_exists">Name is already taken.</string> + <string name="update_list_name">Update list name</string> + <string name="no_custom_lists_available">No custom lists available</string> + <string name="to_create_a_custom_list">To create a custom list press the \"︙\"</string> + <string name="new_list">New list</string> + <string name="to_add_locations_to_a_list">To add locations to a list, press the \"︙\" or long press on a country, city, or server.</string> + <string name="edit_list">Edit list</string> + <string name="delete">Delete</string> + <string name="delete_custom_list_message">\"%s\" was deleted</string> + <string name="undo">Undo</string> + <string name="discard_changes">Discard changes?</string> + <string name="discard">Discard</string> + <string name="add_location_to_list">Add %s to list</string> + <string name="location_was_added_to_list">%s was added to \"%s\"</string> + <string name="location_added">%s (added)</string> + <string name="edit_name">Edit name</string> + <string name="name_was_changed_to">Name was changed to %s</string> + <string name="locations_were_changed_for">Locations were changed for \"%s\"</string> + <string name="not_found">Not found</string> </resources> diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt index 85d45c4d2b..69096ceccb 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt @@ -33,6 +33,12 @@ import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_onSurfaceVariant import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_onTertiaryContainer import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_primary import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_secondaryContainer +import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainer +import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainerHigh +import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainerHighest +import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainerLow +import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainerLowest +import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceTint import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceVariant import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_tertiaryContainer import net.mullvad.mullvadvpn.lib.theme.dimensions.Dimensions @@ -89,6 +95,12 @@ private val darkColorScheme = // surfaceTint = md_theme_dark_surfaceTint, outlineVariant = Color.Transparent, // Used by divider, // scrim = md_theme_dark_scrim, + surfaceContainerHighest = md_theme_dark_surfaceContainerHighest, + surfaceContainerHigh = md_theme_dark_surfaceContainerHigh, + surfaceContainerLow = md_theme_dark_surfaceContainerLow, + surfaceContainerLowest = md_theme_dark_surfaceContainerLowest, + surfaceContainer = md_theme_dark_surfaceContainer, + surfaceTint = md_theme_dark_surfaceTint ) val Shapes = 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 82f924ebe0..01959b7934 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 @@ -21,6 +21,7 @@ const val AlphaDescription = 0.6f const val AlphaDisconnectButton = 0.6f const val AlphaChevron = 0.6f const val AlphaScrollbar = 0.6f +const val Alpha60 = 0.6f const val AlphaTopBar = 0.8f const val AlphaInvisible = 0f diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt index 413a37f93e..1915cc911a 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt @@ -61,6 +61,11 @@ internal val md_theme_dark_outline = Color(0xFF8D9199) // Generated internal val md_theme_dark_inverseOnSurface = Color(0xFFFFFFFF) // MullvadWhite internal val md_theme_dark_inverseSurface = Color(0xFFFFFFFF) // MullvadWhite internal val md_theme_dark_inversePrimary = Color(0xFF0561A3) // Generated -internal val md_theme_dark_surfaceTint = Color(0xFF9FCAFF) // Generated +internal val md_theme_dark_surfaceTint = Color(0xFF233953) // Custom list disabled internal val md_theme_dark_outlineVariant = Color(0xFF43474E) // Generated internal val md_theme_dark_scrim = Color(0xFF000000) // Generated +internal val md_theme_dark_surfaceContainerHighest = Color(0xFF234161) // Relay list depth 0 +internal val md_theme_dark_surfaceContainerHigh = Color(0xFF1F3A57) // Relay list depth 1 +internal val md_theme_dark_surfaceContainerLow = Color(0xFF1C344E) // Relay list depth 2 +internal val md_theme_dark_surfaceContainerLowest = Color(0xFF1B314A) // Relay list depth 3 +internal val md_theme_dark_surfaceContainer = Color(0xFF192638) // Alert Blue 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 ef6b04146e..2763033a30 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 @@ -16,8 +16,10 @@ data class Dimensions( val cellEndPadding: Dp = 16.dp, val cellFooterTopPadding: Dp = 6.dp, val cellHeight: Dp = 56.dp, + val cellHeightTwoRows: Dp = 72.dp, val cellLabelVerticalPadding: Dp = 14.dp, val cellStartPadding: Dp = 22.dp, + val cellStartPaddingInteractive: Dp = 14.dp, val cellTopPadding: Dp = 6.dp, val cellVerticalSpacing: Dp = 14.dp, val checkBoxSize: Dp = 24.dp, @@ -36,7 +38,8 @@ data class Dimensions( val customPortBoxMinWidth: Dp = 80.dp, val deleteIconSize: Dp = 24.dp, val dialogIconHeight: Dp = 44.dp, - val dialogIconSize: Dp = 48.dp, + val dropdownMenuVerticalPadding: Dp = 8.dp, // Used to remove padding from dropdown menu + val dropdownMenuBorder: Dp = 1.dp, val expandableCellChevronSize: Dp = 30.dp, val filterTittlePadding: Dp = 4.dp, val iconFailSuccessTopMargin: Dp = 30.dp, @@ -61,6 +64,7 @@ data class Dimensions( val progressIndicatorSize: Dp = 48.dp, val relayCircleSize: Dp = 16.dp, val relayRowPadding: Dp = 50.dp, + val relayRowPaddingExtra: Dp = 66.dp, val screenVerticalMargin: Dp = 22.dp, val searchFieldHeight: Dp = 42.dp, val searchFieldHorizontalPadding: Dp = 22.dp, diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt index 501cb72946..aa2f40782c 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt @@ -11,3 +11,9 @@ val Shapes.chipShape: Shape get() { return RoundedCornerShape(8.dp) } + +val Shapes.fabShape: Shape + @Composable + get() { + return RoundedCornerShape(16.dp) + } diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt index d09287fbf2..f99d36c679 100644 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.asSharedFlow import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpoint import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration import net.mullvad.mullvadvpn.model.AppVersionInfo +import net.mullvad.mullvadvpn.model.CreateCustomListResult import net.mullvad.mullvadvpn.model.CustomList import net.mullvad.mullvadvpn.model.Device import net.mullvad.mullvadvpn.model.DeviceEvent @@ -24,6 +25,7 @@ import net.mullvad.mullvadvpn.model.RemoveDeviceEvent import net.mullvad.mullvadvpn.model.RemoveDeviceResult import net.mullvad.mullvadvpn.model.Settings import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.model.UpdateCustomListResult import net.mullvad.mullvadvpn.model.VoucherSubmissionResult import net.mullvad.talpid.util.EventNotifier @@ -190,17 +192,15 @@ class MullvadDaemon( setQuantumResistantTunnel(daemonInterfaceAddress, quantumResistant) } - fun createCustomList(name: String): String { - return createCustomList(daemonInterfaceAddress, name) - } + fun createCustomList(name: String): CreateCustomListResult = + createCustomList(daemonInterfaceAddress, name) fun deleteCustomList(id: String) { deleteCustomList(daemonInterfaceAddress, id) } - fun updateCustomList(customList: CustomList) { + fun updateCustomList(customList: CustomList): UpdateCustomListResult = updateCustomList(daemonInterfaceAddress, customList) - } fun onDestroy() { onSettingsChange.unsubscribeAll() @@ -311,11 +311,17 @@ class MullvadDaemon( // Used by JNI - private external fun createCustomList(daemonInterfaceAddress: Long, name: String): String + private external fun createCustomList( + daemonInterfaceAddress: Long, + name: String + ): CreateCustomListResult private external fun deleteCustomList(daemonInterfaceAddress: Long, id: String) - private external fun updateCustomList(daemonInterfaceAddress: Long, customList: CustomList) + private external fun updateCustomList( + daemonInterfaceAddress: Long, + customList: CustomList + ): UpdateCustomListResult @Suppress("unused") private fun notifyAppVersionInfoEvent(appVersionInfo: AppVersionInfo) { diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomLists.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomLists.kt index d80bcf04ff..39702398c7 100644 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomLists.kt +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomLists.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.lib.ipc.Event import net.mullvad.mullvadvpn.lib.ipc.Request +import net.mullvad.mullvadvpn.model.CustomList class CustomLists( private val endpoint: ServiceEndpoint, @@ -34,13 +35,18 @@ class CustomLists( scope.launch { endpoint.dispatcher.parsedMessages .filterIsInstance<Request.UpdateCustomList>() - .collect { daemon.await().updateCustomList(it.customList) } + .collect { updateCustomList(it.customList) } } } private suspend fun createCustomList(name: String) { val result = daemon.await().createCustomList(name) - endpoint.sendEvent(Event.CreateCustomListResult(result)) + endpoint.sendEvent(Event.CreateCustomListResultEvent(result)) + } + + private suspend fun updateCustomList(customList: CustomList) { + val result = daemon.await().updateCustomList(customList) + endpoint.sendEvent(Event.UpdateCustomListResultEvent(result)) } fun onDestroy() { diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index 4679499198..58acfd3b51 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -2004,6 +2004,12 @@ msgctxt "wireguard-settings-view" msgid "Which TCP port the UDP-over-TCP obfuscation protocol should connect to on the VPN server." msgstr "" +msgid "%s (added)" +msgstr "" + +msgid "%s was added to \"%s\"" +msgstr "" + msgid "%s was added to your account." msgstr "" @@ -2028,6 +2034,9 @@ msgstr "" msgid "Account time reminders" msgstr "" +msgid "Add %s to list" +msgstr "" + msgid "Add 30 days time" msgstr "" @@ -2037,6 +2046,9 @@ msgstr "" msgid "Add DNS server" msgstr "" +msgid "Add locations" +msgstr "" + msgid "Agree and continue" msgstr "" @@ -2085,6 +2097,12 @@ msgstr "" msgid "Copied to clipboard" msgstr "" +msgid "Create" +msgstr "" + +msgid "Create new list" +msgstr "" + msgid "Critical error (your attention is required)" msgstr "" @@ -2094,9 +2112,33 @@ msgstr "" msgid "DNS settings might not go into effect immediately" msgstr "" +msgid "Delete \"%s\"?" +msgstr "" + msgid "Disable all <b>%s</b> above to activate this setting." msgstr "" +msgid "Discard" +msgstr "" + +msgid "Discard changes?" +msgstr "" + +msgid "Edit custom lists" +msgstr "" + +msgid "Edit list" +msgstr "" + +msgid "Edit lists" +msgstr "" + +msgid "Edit locations" +msgstr "" + +msgid "Edit name" +msgstr "" + msgid "Enable" msgstr "" @@ -2124,6 +2166,15 @@ msgstr "" msgid "Install Mullvad VPN (%s) to stay up to date" msgstr "" +msgid "List name" +msgstr "" + +msgid "Locations" +msgstr "" + +msgid "Locations were changed for \"%s\"" +msgstr "" + msgid "Makes sure the device is always on the VPN tunnel." msgstr "" @@ -2136,9 +2187,24 @@ msgstr "" msgid "Mullvad services unavailable" msgstr "" +msgid "Name was changed to %s" +msgstr "" + +msgid "New list" +msgstr "" + +msgid "No custom lists available" +msgstr "" + msgid "No internet connection" msgstr "" +msgid "No locations found" +msgstr "" + +msgid "Not found" +msgstr "" + msgid "Please use the <b>Always-on</b> system setting instead by following the guide in <b>%s</b> above." msgstr "" @@ -2199,6 +2265,12 @@ msgstr "" msgid "This address has already been entered." msgstr "" +msgid "To add locations to a list, press the \"︙\" or long press on a country, city, or server." +msgstr "" + +msgid "To create a custom list press the \"︙\"" +msgstr "" + msgid "To make sure that you have the most secure version and to inform you of any issues with the current version that is running, the app performs version checks automatically. This sends the app version and Android system version to Mullvad servers. Mullvad keeps counters on number of used app versions and Android versions. The data is never stored or used in any way that can identify you." msgstr "" @@ -2211,6 +2283,9 @@ msgstr "" msgid "Unable to start tunnel connection. Please disable Always-on VPN for <b>%s</b> before using Mullvad VPN." msgstr "" +msgid "Undo" +msgstr "" + msgid "Unsecured" msgstr "" @@ -2220,6 +2295,9 @@ msgstr "" msgid "Update available, download to remain safe." msgstr "" +msgid "Update list name" +msgstr "" + msgid "VPN permission error" msgstr "" @@ -2277,6 +2355,9 @@ msgstr "" msgid "You are running an unsupported app version." msgstr "" +msgid "\"%s\" was deleted" +msgstr "" + msgid "less than a minute ago" msgstr "" @@ -2288,6 +2369,11 @@ msgid_plural "%d days" msgstr[0] "" msgstr[1] "" +msgid "%d location" +msgid_plural "%d locations" +msgstr[0] "" +msgstr[1] "" + msgid "%d month" msgid_plural "%d months" msgstr[0] "" diff --git a/mullvad-jni/src/classes.rs b/mullvad-jni/src/classes.rs index 562fe786f0..969c7a5057 100644 --- a/mullvad-jni/src/classes.rs +++ b/mullvad-jni/src/classes.rs @@ -13,7 +13,9 @@ pub const CLASSES: &[&str] = &[ "net/mullvad/mullvadvpn/model/CustomListsSettings", "net/mullvad/mullvadvpn/model/DnsState", "net/mullvad/mullvadvpn/model/DnsOptions", + "net/mullvad/mullvadvpn/model/CreateCustomListResult", "net/mullvad/mullvadvpn/model/CustomDnsOptions", + "net/mullvad/mullvadvpn/model/CustomListsError", "net/mullvad/mullvadvpn/model/DefaultDnsOptions", "net/mullvad/mullvadvpn/model/Device", "net/mullvad/mullvadvpn/model/DeviceEvent", @@ -65,6 +67,7 @@ pub const CLASSES: &[&str] = &[ "net/mullvad/mullvadvpn/model/TunnelState$Disconnected", "net/mullvad/mullvadvpn/model/TunnelState$Disconnecting", "net/mullvad/mullvadvpn/model/Udp2TcpObfuscationSettings", + "net/mullvad/mullvadvpn/model/UpdateCustomListResult", "net/mullvad/mullvadvpn/model/VoucherSubmission", "net/mullvad/mullvadvpn/model/VoucherSubmissionResult", "net/mullvad/mullvadvpn/model/LoginResult", diff --git a/mullvad-jni/src/lib.rs b/mullvad-jni/src/lib.rs index e70027b68b..9139f1f435 100644 --- a/mullvad-jni/src/lib.rs +++ b/mullvad-jni/src/lib.rs @@ -246,6 +246,53 @@ impl From<daemon_interface::Error> for PlayPurchaseVerifyError { } } +#[derive(IntoJava)] +#[jnix(package = "net.mullvad.mullvadvpn.model")] +pub enum CreateCustomListResult { + Ok(mullvad_types::custom_list::Id), + Error(CustomListsError), +} + +#[derive(IntoJava)] +#[jnix(package = "net.mullvad.mullvadvpn.model")] +pub enum UpdateCustomListResult { + Ok, + Error(CustomListsError), +} + +#[derive(IntoJava)] +#[jnix(package = "net.mullvad.mullvadvpn.model")] +pub enum CustomListsError { + CustomListExists, + OtherError, +} + +impl From<Result<mullvad_types::custom_list::Id, daemon_interface::Error>> + for CreateCustomListResult +{ + fn from(result: Result<mullvad_types::custom_list::Id, daemon_interface::Error>) -> Self { + match result { + Ok(id) => CreateCustomListResult::Ok(id), + Err(error) => CreateCustomListResult::Error(error.into()), + } + } +} + +impl From<Result<(), daemon_interface::Error>> for UpdateCustomListResult { + fn from(result: Result<(), daemon_interface::Error>) -> Self { + match result { + Ok(()) => UpdateCustomListResult::Ok, + Err(error) => UpdateCustomListResult::Error(error.into()), + } + } +} + +impl From<daemon_interface::Error> for CustomListsError { + fn from(_error: daemon_interface::Error) -> Self { + CustomListsError::CustomListExists + } +} + #[no_mangle] #[allow(non_snake_case)] pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_initialize( @@ -1351,15 +1398,19 @@ pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_createC // SAFETY: The address points to an instance valid for the duration of this function call if let Some(daemon_interface) = unsafe { get_daemon_interface(daemon_interface_address) } { let name = String::from_java(&env, name); - match daemon_interface.create_custom_list(name) { - Ok(id) => id.into_java(&env).forget(), - Err(error) => { - log_request_error("create custom list", &error); - JObject::null() - } + let raw_result = daemon_interface.create_custom_list(name); + + if let Err(ref error) = &raw_result { + log_request_error("Failed to create custom list", error); } + + CreateCustomListResult::from(raw_result) + .into_java(&env) + .forget() } else { - JObject::null() + CreateCustomListResult::Error(CustomListsError::OtherError) + .into_java(&env) + .forget() } } @@ -1387,23 +1438,30 @@ pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_deleteC #[no_mangle] #[allow(non_snake_case)] -pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_updateCustomList( - env: JNIEnv<'_>, +pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_updateCustomList<'env>( + env: JNIEnv<'env>, _: JObject<'_>, daemon_interface_address: jlong, customList: JObject<'_>, -) { +) -> JObject<'env> { let env = JnixEnv::from(env); // SAFETY: The address points to an instance valid for the duration of this function call if let Some(daemon_interface) = unsafe { get_daemon_interface(daemon_interface_address) } { let list = CustomList::from_java(&env, customList); - if let Err(error) = daemon_interface.update_custom_list(list) { - log::error!( - "{}", - error.display_chain_with_msg("Failed to update custom list") - ); + let raw_result = daemon_interface.update_custom_list(list); + + if let Err(ref error) = &raw_result { + log_request_error("Failed to update custom list", error); } + + UpdateCustomListResult::from(raw_result) + .into_java(&env) + .forget() + } else { + UpdateCustomListResult::Error(CustomListsError::OtherError) + .into_java(&env) + .forget() } } |
