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 /android | |
| parent | 461e29d36e5e2a6841e05897e732b5d068d4dcf0 (diff) | |
| parent | 1ff2611cdfb06bebc3e3047e6930cb034b452c61 (diff) | |
| download | mullvadvpn-3979cda6f64c509dafaf0d73bd3bfac43ad58cc3.tar.xz mullvadvpn-3979cda6f64c509dafaf0d73bd3bfac43ad58cc3.zip | |
Merge branch 'create-ui-for-custom-list-droid-654'
Diffstat (limited to 'android')
120 files changed, 5963 insertions, 513 deletions
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() { |
