diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2024-11-27 08:57:53 +0100 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2024-11-27 08:57:53 +0100 |
| commit | 0d155385e1cb7075012bd270de0398d83a438bc5 (patch) | |
| tree | 43ab56fd00a39cb37dce3b60cb594f509d7057f4 | |
| parent | 56e46c5cf783d41937e4eb2531a4d2e287381ee6 (diff) | |
| parent | ffde55987991aeb7b7aad0e36e2a8402e0ab47d6 (diff) | |
| download | mullvadvpn-0d155385e1cb7075012bd270de0398d83a438bc5.tar.xz mullvadvpn-0d155385e1cb7075012bd270de0398d83a438bc5.zip | |
Merge branch 'implement-multihop-ui-droid-822'
92 files changed, 4377 insertions, 2001 deletions
diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index d97bf45ed7..37562f4119 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -25,6 +25,8 @@ Line wrap the file at 100 chars. Th ### Added - Add a new access method: Encrypted DNS Proxy. Encrypted DNS proxy is a way to reach the API via proxies. The access method is enabled by default. +- Add multihop which allows the routing of traffic through an entry and exit server, making it + harder to trace. ### Changed - Animation has been changed to look better with predictive back. diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt index 1c33420863..487e739025 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt @@ -474,6 +474,7 @@ class ConnectScreenTest { val inPort = 99 val inProtocol = TransportProtocol.Udp every { mockLocation.hostname } returns mockHostName + every { mockLocation.entryHostname } returns null // In every { mockTunnelEndpoint.obfuscation } returns null 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 index 444bbd2c5b..484bb132d6 100644 --- 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 @@ -163,9 +163,7 @@ class CustomListLocationsScreenTest { } // Assert - onNodeWithText(EMPTY_SEARCH_FIRST_ROW.format(mockSearchString), substring = true) - .assertExists() - onNodeWithText(EMPTY_SEARCH_SECOND_ROW, substring = true).assertExists() + onNodeWithText(EMPTY_SEARCH.format(mockSearchString)).assertExists() } @Test @@ -239,8 +237,7 @@ class CustomListLocationsScreenTest { 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 EMPTY_SEARCH = "No result for \"%s\", please 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/SettingsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt index 2509c7be8d..e691909a40 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt @@ -33,6 +33,7 @@ class SettingsScreenTest { isLoggedIn = true, isSupportedVersion = true, isPlayBuild = false, + multihopEnabled = false, ) ) } @@ -56,6 +57,7 @@ class SettingsScreenTest { isLoggedIn = false, isSupportedVersion = true, isPlayBuild = false, + multihopEnabled = false, ) ) } diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreenTest.kt new file mode 100644 index 0000000000..5901599df9 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreenTest.kt @@ -0,0 +1,105 @@ +package net.mullvad.mullvadvpn.compose.screen.location + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import io.mockk.MockKAnnotations +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension +import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_ITEM_CUSTOM_LISTS +import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.SearchLocationUiState +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +@OptIn(ExperimentalTestApi::class) +class SearchLocationScreenTest { + @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @AfterEach + fun teardown() { + unmockkAll() + } + + @Test + fun testSearchInput() = + composeExtension.use { + // Arrange + val mockedSearchTermInput: (String) -> Unit = mockk(relaxed = true) + setContentWithTheme { + SearchLocationScreen( + state = + SearchLocationUiState.NoQuery(searchTerm = "", filterChips = emptyList()), + onSearchInputChanged = mockedSearchTermInput, + ) + } + val mockSearchString = "SEARCH" + + // Act + onNodeWithText("Search for...").performTextInput(mockSearchString) + + // Assert + verify { mockedSearchTermInput.invoke(mockSearchString) } + } + + @Test + fun testSearchTermNotFound() = + composeExtension.use { + // Arrange + val mockSearchString = "SEARCH" + setContentWithTheme { + SearchLocationScreen( + state = + SearchLocationUiState.Content( + searchTerm = mockSearchString, + filterChips = emptyList(), + relayListItems = + listOf(RelayListItem.LocationsEmptyText(mockSearchString)), + customLists = emptyList(), + ) + ) + } + + // Assert + onNodeWithText("No result for \"$mockSearchString\", please try a different search") + .assertExists() + } + + @Test + fun givenNoCustomListsAndSearchIsActiveShouldNotShowCustomListHeader() = + composeExtension.use { + // Arrange + val mockSearchString = "SEARCH" + setContentWithTheme { + SearchLocationScreen( + state = + SearchLocationUiState.Content( + searchTerm = mockSearchString, + filterChips = emptyList(), + relayListItems = emptyList(), + customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + ) + ) + } + + // Assert + onNodeWithText(CUSTOM_LISTS_EMPTY_TEXT).assertDoesNotExist() + onNodeWithTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG).assertDoesNotExist() + } + + companion object { + private const val CUSTOM_LISTS_EMPTY_TEXT = "To create a custom list press the \"︙\"" + } +} 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/location/SelectLocationScreenTest.kt index 31097725db..a154344f26 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/location/SelectLocationScreenTest.kt @@ -1,63 +1,74 @@ -package net.mullvad.mullvadvpn.compose.screen +package net.mullvad.mullvadvpn.compose.screen.location 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.every import io.mockk.mockk +import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_COUNTRIES import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_ITEM_CUSTOM_LISTS import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState 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.lib.model.RelayItem import net.mullvad.mullvadvpn.performLongClick +import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationListViewModel +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.core.context.loadKoinModules +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module @OptIn(ExperimentalTestApi::class) class SelectLocationScreenTest { @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + private val listViewModel: SelectLocationListViewModel = mockk(relaxed = true) + @BeforeEach fun setup() { MockKAnnotations.init(this) + loadKoinModules(module { viewModel { listViewModel } }) + every { listViewModel.uiState } returns MutableStateFlow(SelectLocationListUiState.Loading) } - @Test - fun testDefaultState() = - composeExtension.use { - // Arrange - setContentWithTheme { SelectLocationScreen(state = SelectLocationUiState.Loading) } - - // Assert - onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertExists() - } + @AfterEach + fun teardown() { + unmockkAll() + } @Test fun testShowRelayListState() = composeExtension.use { // Arrange + every { listViewModel.uiState } returns + MutableStateFlow( + SelectLocationListUiState.Content( + relayListItems = + DUMMY_RELAY_COUNTRIES.map { RelayListItem.GeoLocationItem(item = it) }, + customLists = emptyList(), + ) + ) setContentWithTheme { SelectLocationScreen( state = - SelectLocationUiState.Content( - searchTerm = "", + SelectLocationUiState( + // searchTerm = "", filterChips = emptyList(), - relayListItems = - DUMMY_RELAY_COUNTRIES.map { - RelayListItem.GeoLocationItem(item = it) - }, - customLists = emptyList(), + multihopEnabled = false, + relayListType = RelayListType.EXIT, ) ) } @@ -72,97 +83,29 @@ class SelectLocationScreenTest { } @Test - fun testSearchInput() = - composeExtension.use { - // Arrange - val mockedSearchTermInput: (String) -> Unit = mockk(relaxed = true) - setContentWithTheme { - SelectLocationScreen( - state = - SelectLocationUiState.Content( - searchTerm = "", - filterChips = emptyList(), - relayListItems = emptyList(), - customLists = emptyList(), - ), - onSearchTermInput = mockedSearchTermInput, - ) - } - val mockSearchString = "SEARCH" - - // Act - onNodeWithText("Search for...").performTextInput(mockSearchString) - - // Assert - verify { mockedSearchTermInput.invoke(mockSearchString) } - } - - @Test - fun testSearchTermNotFound() = - composeExtension.use { - // Arrange - val mockedSearchTermInput: (String) -> Unit = mockk(relaxed = true) - val mockSearchString = "SEARCH" - setContentWithTheme { - SelectLocationScreen( - state = - SelectLocationUiState.Content( - searchTerm = mockSearchString, - filterChips = emptyList(), - relayListItems = - listOf(RelayListItem.LocationsEmptyText(mockSearchString)), - customLists = emptyList(), - ), - onSearchTermInput = mockedSearchTermInput, - ) - } - - // Assert - onNodeWithText("No result for $mockSearchString.", substring = true).assertExists() - onNodeWithText("Try a different search", substring = true).assertExists() - } - - @Test fun customListFooterShouldShowEmptyTextWhenNoCustomList() = composeExtension.use { // Arrange - val mockSearchString = "" - setContentWithTheme { - SelectLocationScreen( - state = - SelectLocationUiState.Content( - searchTerm = mockSearchString, - filterChips = emptyList(), - relayListItems = listOf(RelayListItem.CustomListFooter(false)), - customLists = emptyList(), - ) + every { listViewModel.uiState } returns + MutableStateFlow( + SelectLocationListUiState.Content( + relayListItems = listOf(RelayListItem.CustomListFooter(false)), + customLists = emptyList(), + ) ) - } - - // Assert - onNodeWithText(CUSTOM_LISTS_EMPTY_TEXT).assertExists() - } - - @Test - fun givenNoCustomListsAndSearchIsActiveShouldNotShowCustomListHeader() = - composeExtension.use { - // Arrange - val mockSearchString = "SEARCH" setContentWithTheme { SelectLocationScreen( state = - SelectLocationUiState.Content( - searchTerm = mockSearchString, + SelectLocationUiState( filterChips = emptyList(), - relayListItems = emptyList(), - customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + multihopEnabled = false, + relayListType = RelayListType.EXIT, ) ) } // Assert - onNodeWithText(CUSTOM_LISTS_EMPTY_TEXT).assertDoesNotExist() - onNodeWithTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG).assertDoesNotExist() + onNodeWithText(CUSTOM_LISTS_EMPTY_TEXT).assertExists() } @Test @@ -170,15 +113,21 @@ class SelectLocationScreenTest { composeExtension.use { // Arrange val customList = DUMMY_RELAY_ITEM_CUSTOM_LISTS[0] + every { listViewModel.uiState } returns + MutableStateFlow( + SelectLocationListUiState.Content( + relayListItems = listOf(RelayListItem.CustomListItem(customList)), + customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + ) + ) val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true) setContentWithTheme { SelectLocationScreen( state = - SelectLocationUiState.Content( - searchTerm = "", + SelectLocationUiState( filterChips = emptyList(), - relayListItems = listOf(RelayListItem.CustomListItem(customList)), - customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + multihopEnabled = false, + relayListType = RelayListType.EXIT, ), onSelectRelay = mockedOnSelectRelay, ) @@ -196,16 +145,22 @@ class SelectLocationScreenTest { composeExtension.use { // Arrange val customList = DUMMY_RELAY_ITEM_CUSTOM_LISTS[0] + every { listViewModel.uiState } returns + MutableStateFlow( + SelectLocationListUiState.Content( + relayListItems = listOf(RelayListItem.CustomListItem(item = customList)), + customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + ) + ) val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true) setContentWithTheme { SelectLocationScreen( state = - SelectLocationUiState.Content( - searchTerm = "", + SelectLocationUiState( + // searchTerm = "", filterChips = emptyList(), - relayListItems = - listOf(RelayListItem.CustomListItem(item = customList)), - customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + multihopEnabled = false, + relayListType = RelayListType.EXIT, ), onSelectRelay = mockedOnSelectRelay, ) @@ -223,15 +178,21 @@ class SelectLocationScreenTest { composeExtension.use { // Arrange val relayItem = DUMMY_RELAY_COUNTRIES[0] + every { listViewModel.uiState } returns + MutableStateFlow( + SelectLocationListUiState.Content( + relayListItems = listOf(RelayListItem.GeoLocationItem(relayItem)), + customLists = emptyList(), + ) + ) val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true) setContentWithTheme { SelectLocationScreen( state = - SelectLocationUiState.Content( - searchTerm = "", + SelectLocationUiState( filterChips = emptyList(), - relayListItems = listOf(RelayListItem.GeoLocationItem(relayItem)), - customLists = emptyList(), + multihopEnabled = false, + relayListType = RelayListType.EXIT, ), onSelectRelay = mockedOnSelectRelay, ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt new file mode 100644 index 0000000000..f67e7228af --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt @@ -0,0 +1,107 @@ +package net.mullvad.mullvadvpn.compose.button + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SingleChoiceSegmentedButtonRowScope +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.color.onSelected +import net.mullvad.mullvadvpn.lib.theme.color.selected + +@Preview +@Composable +private fun PreviewMullvadSegmentedButton() { + AppTheme { + SingleChoiceSegmentedButtonRow { + MullvadSegmentedStartButton(selected = true, text = "Start", onClick = {}) + MullvadSegmentedMiddleButton(selected = false, text = "Middle", onClick = {}) + MullvadSegmentedEndButton(selected = false, text = "End", onClick = {}) + } + } +} + +@Composable +private fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedButton( + selected: Boolean, + text: String, + onClick: () -> Unit, + shape: Shape, +) { + SegmentedButton( + onClick = onClick, + selected = selected, + colors = + SegmentedButtonDefaults.colors() + .copy( + activeContainerColor = MaterialTheme.colorScheme.selected, + activeContentColor = MaterialTheme.colorScheme.onSelected, + inactiveContainerColor = MaterialTheme.colorScheme.primary, + inactiveContentColor = MaterialTheme.colorScheme.onPrimary, + ), + border = BorderStroke(0.dp, Color.Unspecified), + label = { + Text( + text = text, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + icon = {}, + shape = shape, + ) +} + +@Composable +fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedStartButton( + selected: Boolean, + text: String, + onClick: () -> Unit, +) { + MullvadSegmentedButton( + selected = selected, + text = text, + onClick = onClick, + shape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp), + ) +} + +@Composable +fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedMiddleButton( + selected: Boolean, + text: String, + onClick: () -> Unit, +) { + MullvadSegmentedButton( + selected = selected, + text = text, + onClick = onClick, + shape = RoundedCornerShape(0.dp), // Square + ) +} + +@Composable +fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedEndButton( + selected: Boolean, + text: String, + onClick: () -> Unit, +) { + MullvadSegmentedButton( + selected = selected, + text = text, + onClick = onClick, + shape = RoundedCornerShape(topEnd = 8.dp, bottomEnd = 8.dp), + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterRow.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterRow.kt index d3e233c67b..ab708e77d1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterRow.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterRow.kt @@ -15,19 +15,19 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.MullvadFilterChip -import net.mullvad.mullvadvpn.compose.state.FilterChip import net.mullvad.mullvadvpn.lib.model.Ownership import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.usecase.FilterChip @Preview @Composable private fun PreviewFilterCell() { AppTheme { FilterRow( - listOf(FilterChip.Ownership(Ownership.MullvadOwned), FilterChip.Provider(2)), - {}, - {}, + filters = listOf(FilterChip.Ownership(Ownership.MullvadOwned), FilterChip.Provider(2)), + onRemoveOwnershipFilter = {}, + onRemoveProviderFilter = {}, ) } } @@ -35,6 +35,7 @@ private fun PreviewFilterCell() { @Composable fun FilterRow( filters: List<FilterChip>, + showTitle: Boolean = true, onRemoveOwnershipFilter: () -> Unit, onRemoveProviderFilter: () -> Unit, ) { @@ -42,22 +43,26 @@ fun FilterRow( Row( verticalAlignment = Alignment.CenterVertically, modifier = - Modifier.horizontalScroll(scrollState) - .padding(horizontal = Dimens.searchFieldHorizontalPadding) - .fillMaxWidth(), + Modifier.padding(horizontal = Dimens.searchFieldHorizontalPadding) + .fillMaxWidth() + .horizontalScroll(scrollState), horizontalArrangement = Arrangement.spacedBy(Dimens.chipSpace), ) { - Text( - text = stringResource(id = R.string.filtered), - color = MaterialTheme.colorScheme.onPrimary, - style = MaterialTheme.typography.labelMedium, - ) + if (showTitle) { + Text( + text = stringResource(id = R.string.filters), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelMedium, + ) + } filters.forEach { when (it) { is FilterChip.Ownership -> OwnershipFilterChip(it.ownership, onRemoveOwnershipFilter) is FilterChip.Provider -> ProviderFilterChip(it.count, onRemoveProviderFilter) is FilterChip.Daita -> DaitaFilterChip() + is FilterChip.Entry -> EntryFilterChip() + is FilterChip.Exit -> ExitFilterChip() } } } @@ -90,6 +95,24 @@ fun DaitaFilterChip() { ) } +@Composable +fun EntryFilterChip() { + MullvadFilterChip( + text = stringResource(id = R.string.entry), + onRemoveClick = {}, + enabled = false, + ) +} + +@Composable +fun ExitFilterChip() { + MullvadFilterChip( + text = stringResource(id = R.string.exit), + onRemoveClick = {}, + enabled = false, + ) +} + private fun Ownership.stringResources(): Int = when (this) { Ownership.MullvadOwned -> R.string.owned 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 e1157eb3bc..eb729701bc 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 @@ -27,13 +27,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.ExpandChevron import net.mullvad.mullvadvpn.compose.component.MullvadCheckbox import net.mullvad.mullvadvpn.compose.preview.RelayItemCheckableCellPreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.RelayListItemState import net.mullvad.mullvadvpn.compose.test.EXPAND_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LOCATION_CELL_TEST_TAG import net.mullvad.mullvadvpn.lib.model.RelayItem @@ -70,6 +73,7 @@ private fun PreviewCheckableRelayLocationCell( fun StatusRelayItemCell( item: RelayItem, isSelected: Boolean, + state: RelayListItemState?, modifier: Modifier = Modifier, onClick: () -> Unit = {}, onLongClick: (() -> Unit)? = null, @@ -80,11 +84,11 @@ fun StatusRelayItemCell( inactiveColor: Color = MaterialTheme.colorScheme.error, disabledColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, ) { - RelayItemCell( modifier = modifier, - item, - isSelected, + item = item, + isSelected = isSelected, + state = state, leadingContent = { if (isSelected) { Icon(imageVector = Icons.Default.Check, contentDescription = null) @@ -98,6 +102,7 @@ fun StatusRelayItemCell( when { item is RelayItem.CustomList && item.locations.isEmpty() -> disabledColor + state != null -> disabledColor item.active -> activeColor else -> inactiveColor }, @@ -120,6 +125,7 @@ fun RelayItemCell( modifier: Modifier = Modifier, item: RelayItem, isSelected: Boolean, + state: RelayListItemState?, leadingContent: (@Composable RowScope.() -> Unit)? = null, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, @@ -148,7 +154,7 @@ fun RelayItemCell( Row( modifier = Modifier.combinedClickable( - enabled = item.active, + enabled = state == null && item.active, onClick = onClick, onLongClick = onLongClick, ) @@ -159,7 +165,7 @@ fun RelayItemCell( if (leadingContent != null) { leadingContent() } - Name(relay = item) + Name(name = item.name, state = state) } if (item.hasChildren) { @@ -187,6 +193,7 @@ fun CheckableRelayLocationCell( modifier = modifier, item = item, isSelected = false, + state = null, leadingContent = { MullvadCheckbox( checked = checked, @@ -201,14 +208,14 @@ fun CheckableRelayLocationCell( } @Composable -private fun Name(modifier: Modifier = Modifier, relay: RelayItem) { +private fun Name(modifier: Modifier = Modifier, name: String, state: RelayListItemState?) { Text( - text = relay.name, + text = state?.let { name.withSuffix(state) } ?: name, color = MaterialTheme.colorScheme.onSurface, modifier = modifier .alpha( - if (relay.active) { + if (state == null) { AlphaVisible } else { AlphaInactive @@ -252,3 +259,10 @@ private fun Int.toBackgroundColor(): Color = 2 -> MaterialTheme.colorScheme.surfaceContainerLow else -> MaterialTheme.colorScheme.surfaceContainerLowest } + +@Composable +private fun String.withSuffix(state: RelayListItemState) = + when (state) { + RelayListItemState.USED_AS_EXIT -> stringResource(R.string.x_exit, this) + RelayListItemState.USED_AS_ENTRY -> stringResource(R.string.x_entry, this) + } 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 index 347de1654e..579be88bb6 100644 --- 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 @@ -1,51 +1,29 @@ 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.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = secondRow, - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Text( + text = textResource(R.string.search_location_empty_text, searchTerm), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(Dimens.screenVerticalMargin), + ) } else { Text( text = stringResource(R.string.no_locations_found), diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt index 8b04017f0a..c31608949d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt @@ -92,6 +92,7 @@ private fun FeatureIndicator.text(): String { FeatureIndicator.SERVER_IP_OVERRIDE -> R.string.feature_server_ip_override FeatureIndicator.CUSTOM_MTU -> R.string.feature_custom_mtu FeatureIndicator.DAITA -> R.string.feature_daita + FeatureIndicator.MULTIHOP -> R.string.feature_multihop } return textResource(resource) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayListItemPreviewData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayListItemPreviewData.kt new file mode 100644 index 0000000000..2c695764d7 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayListItemPreviewData.kt @@ -0,0 +1,113 @@ +package net.mullvad.mullvadvpn.compose.preview + +import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.RelayListItemState +import net.mullvad.mullvadvpn.lib.model.CustomList +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.RelayItem + +object RelayListItemPreviewData { + @Suppress("LongMethod") + fun generateRelayListItems( + includeCustomLists: Boolean, + isSearching: Boolean, + ): List<RelayListItem> = buildList { + if (!isSearching || includeCustomLists) { + add(RelayListItem.CustomListHeader) + // Add custom list items + if (includeCustomLists) { + RelayListItem.CustomListItem( + item = + RelayItem.CustomList( + customList = + CustomList( + id = CustomListId("custom_list_id"), + name = CustomListName.fromString("Custom List"), + locations = emptyList(), + ), + locations = + listOf( + RelayItemPreviewData.generateRelayItemCountry( + name = "Country", + cityNames = listOf("City"), + relaysPerCity = 2, + active = true, + ) + ), + ), + isSelected = false, + state = null, + expanded = false, + ) + } + if (!isSearching) { + add(RelayListItem.CustomListFooter(hasCustomList = includeCustomLists)) + } + } + add(RelayListItem.LocationHeader) + val locations = + listOf( + RelayItemPreviewData.generateRelayItemCountry( + name = "A relay", + cityNames = listOf("City 1", "City 2"), + relaysPerCity = 2, + active = true, + ), + RelayItemPreviewData.generateRelayItemCountry( + name = "Another relay", + cityNames = listOf("City X", "City Y", "City Z"), + relaysPerCity = 1, + active = false, + ), + ) + addAll( + listOf( + RelayListItem.GeoLocationItem( + item = locations[0], + isSelected = false, + depth = 0, + expanded = true, + state = null, + ), + RelayListItem.GeoLocationItem( + item = locations[0].cities[0], + isSelected = true, + depth = 1, + expanded = false, + state = null, + ), + RelayListItem.GeoLocationItem( + item = locations[0].cities[1], + isSelected = false, + depth = 1, + expanded = true, + state = null, + ), + RelayListItem.GeoLocationItem( + item = locations[0].cities[1].relays[0], + isSelected = false, + depth = 2, + expanded = false, + state = RelayListItemState.USED_AS_EXIT, + ), + RelayListItem.GeoLocationItem( + item = locations[0].cities[1].relays[0], + isSelected = false, + depth = 2, + expanded = false, + state = null, + ), + RelayListItem.GeoLocationItem( + item = locations[1], + isSelected = false, + depth = 0, + expanded = false, + state = null, + ), + ) + ) + } + + fun generateEmptyList(searchTerm: String) = listOf(RelayListItem.LocationsEmptyText(searchTerm)) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt new file mode 100644 index 0000000000..ebed8d229f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt @@ -0,0 +1,29 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.SearchLocationUiState +import net.mullvad.mullvadvpn.usecase.FilterChip + +class SearchLocationsUiStatePreviewParameterProvider : + PreviewParameterProvider<SearchLocationUiState> { + override val values = + sequenceOf( + SearchLocationUiState.NoQuery(searchTerm = "", filterChips = listOf(FilterChip.Entry)), + SearchLocationUiState.Content( + searchTerm = "Mullvad", + filterChips = listOf(FilterChip.Entry), + relayListItems = RelayListItemPreviewData.generateEmptyList("Mullvad"), + customLists = emptyList(), + ), + SearchLocationUiState.Content( + searchTerm = "Germany", + filterChips = listOf(FilterChip.Entry), + relayListItems = + RelayListItemPreviewData.generateRelayListItems( + includeCustomLists = true, + isSearching = true, + ), + customLists = emptyList(), + ), + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt index a3b4e1bcdc..b0415b1c7e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt @@ -1,66 +1,46 @@ package net.mullvad.mullvadvpn.compose.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import net.mullvad.mullvadvpn.compose.state.FilterChip -import net.mullvad.mullvadvpn.compose.state.ModelOwnership -import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState -import net.mullvad.mullvadvpn.lib.model.CustomListId -import net.mullvad.mullvadvpn.lib.model.CustomListName -import net.mullvad.mullvadvpn.lib.model.DomainCustomList -import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.model.Ownership import net.mullvad.mullvadvpn.lib.model.Provider -import net.mullvad.mullvadvpn.lib.model.ProviderId -import net.mullvad.mullvadvpn.lib.model.RelayItem - -private val RELAY = - RelayItem.Location.Relay( - id = - GeoLocationId.Hostname( - city = GeoLocationId.City(country = GeoLocationId.Country("se"), code = "code"), - code = "code", - ), - provider = Provider(providerId = ProviderId("providerId"), ownership = Ownership.Rented), - active = true, - daita = true, - ) +import net.mullvad.mullvadvpn.usecase.FilterChip +import net.mullvad.mullvadvpn.usecase.ModelOwnership class SelectLocationsUiStatePreviewParameterProvider : PreviewParameterProvider<SelectLocationUiState> { override val values = sequenceOf( - SelectLocationUiState.Content( - searchTerm = "search term", - listOf(FilterChip.Ownership(ownership = ModelOwnership.MullvadOwned)), - relayListItems = + SelectLocationUiState( + filterChips = emptyList(), + multihopEnabled = false, + relayListType = RelayListType.EXIT, + ), + SelectLocationUiState( + filterChips = listOf( - RelayListItem.GeoLocationItem( - item = RELAY, - isSelected = true, - depth = 1, - expanded = true, - ) + FilterChip.Ownership(ownership = ModelOwnership.Rented), + FilterChip.Provider(PROVIDER_COUNT), ), - customLists = + multihopEnabled = false, + relayListType = RelayListType.EXIT, + ), + SelectLocationUiState( + filterChips = emptyList(), + multihopEnabled = true, + relayListType = RelayListType.ENTRY, + ), + SelectLocationUiState( + filterChips = listOf( - RelayItem.CustomList( - customList = - DomainCustomList( - id = CustomListId("custom_list_id"), - locations = - listOf( - GeoLocationId.City( - country = GeoLocationId.Country("dk"), - code = "code2", - ) - ), - name = CustomListName.fromString("Custom List"), - ), - locations = listOf(RELAY), - ) + FilterChip.Ownership(ownership = ModelOwnership.MullvadOwned), + FilterChip.Provider(PROVIDER_COUNT), ), + multihopEnabled = true, + relayListType = RelayListType.ENTRY, ), - SelectLocationUiState.Loading, ) } + +private const val PROVIDER_COUNT = 3 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SettingsUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SettingsUiStatePreviewParameterProvider.kt index 6230911766..18f422a988 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SettingsUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SettingsUiStatePreviewParameterProvider.kt @@ -11,12 +11,14 @@ class SettingsUiStatePreviewParameterProvider : PreviewParameterProvider<Setting isLoggedIn = true, isSupportedVersion = true, isPlayBuild = true, + multihopEnabled = false, ), SettingsUiState( appVersion = "9000.1", isLoggedIn = false, isSupportedVersion = false, isPlayBuild = false, + multihopEnabled = false, ), ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt index 71e7f66d0f..c3640979d3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -409,9 +409,8 @@ private fun ConnectionCardHeader( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - - val hostname = location?.hostname - AnimatedContent(hostname, label = "hostname") { + val hostnameText = location.hostnameText() + AnimatedContent(hostnameText, label = "hostname") { if (it != null) { Text( modifier = Modifier.fillMaxWidth(), @@ -440,6 +439,17 @@ private fun GeoIpLocation?.asString(): String { } @Composable +private fun GeoIpLocation?.hostnameText(): String? { + val entryHostName = this?.entryHostname + val exitHostName = this?.hostname + return when { + entryHostName != null && exitHostName != null -> + stringResource(R.string.x_via_x, exitHostName, entryHostName) + else -> exitHostName + } +} + +@Composable private fun ConnectionInfo( featureIndicators: List<FeatureIndicator>, connectionDetails: ConnectionDetails?, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MultihopScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MultihopScreen.kt new file mode 100644 index 0000000000..5491fc624c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MultihopScreen.kt @@ -0,0 +1,89 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +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 androidx.lifecycle.compose.dropUnlessResumed +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.HeaderSwitchComposeCell +import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell +import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +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.viewmodel.MultihopUiState +import net.mullvad.mullvadvpn.viewmodel.MultihopViewModel +import org.koin.androidx.compose.koinViewModel + +@Preview +@Composable +private fun PreviewMultihopScreen() { + AppTheme { MultihopScreen(state = MultihopUiState(false)) } +} + +@Destination<RootGraph>(style = SlideInFromRightTransition::class) +@Composable +fun Multihop(navigator: DestinationsNavigator) { + val viewModel = koinViewModel<MultihopViewModel>() + val state by viewModel.uiState.collectAsStateWithLifecycle() + MultihopScreen( + state = state, + onMultihopClick = viewModel::setMultihop, + onBackClick = dropUnlessResumed { navigator.navigateUp() }, + ) +} + +@Composable +fun MultihopScreen( + state: MultihopUiState, + onMultihopClick: (enable: Boolean) -> Unit = {}, + onBackClick: () -> Unit = {}, +) { + ScaffoldWithMediumTopBar( + appBarTitle = stringResource(id = R.string.multihop), + navigationIcon = { NavigateBackIconButton { onBackClick() } }, + ) { modifier -> + Column(modifier = modifier) { + // Scale image to fit width up to certain width + Image( + contentScale = ContentScale.FillWidth, + modifier = + Modifier.widthIn(max = Dimens.settingsDetailsImageMaxWidth) + .fillMaxWidth() + .padding(horizontal = Dimens.mediumPadding) + .align(Alignment.CenterHorizontally), + painter = painterResource(id = R.drawable.multihop_illustration), + contentDescription = stringResource(R.string.multihop), + ) + Description() + HeaderSwitchComposeCell( + title = stringResource(R.string.enable), + isToggled = state.enable, + onCellClicked = onMultihopClick, + ) + } + } +} + +@Composable +private fun Description() { + SwitchComposeSubtitleCell( + modifier = Modifier.padding(vertical = Dimens.mediumPadding), + text = stringResource(R.string.multihop_description), + ) +} 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 deleted file mode 100644 index c36f10212e..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt +++ /dev/null @@ -1,938 +0,0 @@ -package net.mullvad.mullvadvpn.compose.screen - -import android.content.Context -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -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.LazyItemScope -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.FilterList -import androidx.compose.material.icons.filled.Remove -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.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.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.compose.dropUnlessResumed -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootGraph -import com.ramcosta.composedestinations.generated.destinations.CreateCustomListDestination -import com.ramcosta.composedestinations.generated.destinations.CustomListLocationsDestination -import com.ramcosta.composedestinations.generated.destinations.CustomListsDestination -import com.ramcosta.composedestinations.generated.destinations.DeleteCustomListDestination -import com.ramcosta.composedestinations.generated.destinations.EditCustomListNameDestination -import com.ramcosta.composedestinations.generated.destinations.FilterDestination -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 com.ramcosta.composedestinations.spec.DestinationSpec -import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.cell.FilterRow -import net.mullvad.mullvadvpn.compose.cell.HeaderCell -import net.mullvad.mullvadvpn.compose.cell.IconCell -import net.mullvad.mullvadvpn.compose.cell.StatusRelayItemCell -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.CustomListActionResultData -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.constant.ContentType -import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed -import net.mullvad.mullvadvpn.compose.preview.SelectLocationsUiStatePreviewParameterProvider -import net.mullvad.mullvadvpn.compose.screen.BottomSheetState.ShowCustomListsBottomSheet -import net.mullvad.mullvadvpn.compose.screen.BottomSheetState.ShowCustomListsEntryBottomSheet -import net.mullvad.mullvadvpn.compose.screen.BottomSheetState.ShowEditCustomListBottomSheet -import net.mullvad.mullvadvpn.compose.screen.BottomSheetState.ShowLocationBottomSheet -import net.mullvad.mullvadvpn.compose.state.RelayListItem -import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState -import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR -import net.mullvad.mullvadvpn.compose.test.LOCATION_CELL_TEST_TAG -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.TopLevelTransition -import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle -import net.mullvad.mullvadvpn.compose.util.RunOnKeyChange -import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately -import net.mullvad.mullvadvpn.lib.model.CustomListId -import net.mullvad.mullvadvpn.lib.model.CustomListName -import net.mullvad.mullvadvpn.lib.model.RelayItem -import net.mullvad.mullvadvpn.lib.model.RelayItemId -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.canAddLocation -import net.mullvad.mullvadvpn.viewmodel.SelectLocationSideEffect -import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel -import org.koin.androidx.compose.koinViewModel - -@Preview("Content|Loading") -@Composable -private fun PreviewSelectLocationScreen( - @PreviewParameter(SelectLocationsUiStatePreviewParameterProvider::class) - state: SelectLocationUiState -) { - AppTheme { SelectLocationScreen(state = state) } -} - -@Destination<RootGraph>(style = TopLevelTransition::class) -@Suppress("LongMethod") -@Composable -fun SelectLocation( - navigator: DestinationsNavigator, - backNavigator: ResultBackNavigator<Boolean>, - createCustomListDialogResultRecipient: - ResultRecipient< - CreateCustomListDestination, - CustomListActionResultData.Success.CreatedWithLocations, - >, - editCustomListNameDialogResultRecipient: - ResultRecipient<EditCustomListNameDestination, CustomListActionResultData.Success.Renamed>, - deleteCustomListDialogResultRecipient: - ResultRecipient<DeleteCustomListDestination, CustomListActionResultData.Success.Deleted>, - updateCustomListResultRecipient: - ResultRecipient<CustomListLocationsDestination, CustomListActionResultData>, -) { - val vm = koinViewModel<SelectLocationViewModel>() - val state = vm.uiState.collectAsStateWithLifecycle() - - val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current - val lazyListState = rememberLazyListState() - CollectSideEffectWithLifecycle(vm.uiSideEffect) { - when (it) { - SelectLocationSideEffect.CloseScreen -> backNavigator.navigateBack(result = true) - is SelectLocationSideEffect.CustomListActionToast -> - launch { - snackbarHostState.showResultSnackbar( - context = context, - result = it.resultData, - onUndo = vm::performAction, - ) - } - SelectLocationSideEffect.GenericError -> - launch { - snackbarHostState.showSnackbarImmediately( - message = context.getString(R.string.error_occurred), - duration = SnackbarDuration.Short, - ) - } - } - } - - val stateActual = state.value - RunOnKeyChange(stateActual is SelectLocationUiState.Content) { - val index = stateActual.indexOfSelectedRelayItem() - if (index != -1) { - lazyListState.scrollToItem(index) - lazyListState.animateScrollAndCentralizeItem(index) - } - } - - createCustomListDialogResultRecipient.OnCustomListNavResult( - snackbarHostState, - vm::performAction, - ) - - editCustomListNameDialogResultRecipient.OnCustomListNavResult( - snackbarHostState, - vm::performAction, - ) - - deleteCustomListDialogResultRecipient.OnCustomListNavResult( - snackbarHostState, - vm::performAction, - ) - - updateCustomListResultRecipient.OnCustomListNavResult(snackbarHostState, vm::performAction) - - SelectLocationScreen( - state = state.value, - lazyListState = lazyListState, - snackbarHostState = snackbarHostState, - onSelectRelay = vm::selectRelay, - onSearchTermInput = vm::onSearchTermInput, - onBackClick = dropUnlessResumed { backNavigator.navigateBack() }, - onFilterClick = dropUnlessResumed { navigator.navigate(FilterDestination) }, - onCreateCustomList = - dropUnlessResumed { relayItem -> - navigator.navigate(CreateCustomListDestination(locationCode = relayItem?.id)) - }, - onToggleExpand = vm::onToggleExpand, - onEditCustomLists = dropUnlessResumed { navigator.navigate(CustomListsDestination()) }, - removeOwnershipFilter = vm::removeOwnerFilter, - removeProviderFilter = vm::removeProviderFilter, - onAddLocationToList = vm::addLocationToList, - onRemoveLocationFromList = vm::removeLocationFromList, - onEditCustomListName = - dropUnlessResumed { customList: RelayItem.CustomList -> - navigator.navigate( - EditCustomListNameDestination( - customListId = customList.id, - initialName = customList.customList.name, - ) - ) - }, - onEditLocationsCustomList = - dropUnlessResumed { customList: RelayItem.CustomList -> - navigator.navigate( - CustomListLocationsDestination(customListId = customList.id, newList = false) - ) - }, - onDeleteCustomList = - dropUnlessResumed { customList: RelayItem.CustomList -> - navigator.navigate( - DeleteCustomListDestination( - customListId = customList.id, - name = customList.customList.name, - ) - ) - }, - ) -} - -@OptIn(ExperimentalFoundationApi::class) -@Suppress("LongMethod") -@Composable -fun SelectLocationScreen( - state: SelectLocationUiState, - lazyListState: LazyListState = rememberLazyListState(), - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, - onSelectRelay: (item: RelayItem) -> Unit = {}, - onSearchTermInput: (searchTerm: String) -> Unit = {}, - onBackClick: () -> Unit = {}, - onFilterClick: () -> Unit = {}, - onCreateCustomList: (location: RelayItem.Location?) -> Unit = {}, - onEditCustomLists: () -> Unit = {}, - removeOwnershipFilter: () -> Unit = {}, - removeProviderFilter: () -> Unit = {}, - onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit = - { _, _ -> - }, - onRemoveLocationFromList: (location: RelayItem.Location, customListId: CustomListId) -> Unit = - { _, _ -> - }, - onEditCustomListName: (RelayItem.CustomList) -> Unit = {}, - onEditLocationsCustomList: (RelayItem.CustomList) -> Unit = {}, - onDeleteCustomList: (RelayItem.CustomList) -> Unit = {}, - onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit = { _, _, _ -> }, -) { - val backgroundColor = MaterialTheme.colorScheme.surface - - 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, - onRemoveLocationFromList = onRemoveLocationFromList, - onEditCustomListName = onEditCustomListName, - onEditLocationsCustomList = onEditLocationsCustomList, - onDeleteCustomList = onDeleteCustomList, - onHideBottomSheet = { bottomSheetState = null }, - ) - - Column(modifier = Modifier.padding(it).background(backgroundColor).fillMaxSize()) { - SelectLocationTopBar(onBackClick = onBackClick, onFilterClick = onFilterClick) - - if (state is SelectLocationUiState.Content && state.filterChips.isNotEmpty()) { - FilterRow(filters = state.filterChips, removeOwnershipFilter, removeProviderFilter) - } - - SearchTextField( - modifier = - Modifier.fillMaxWidth() - .height(Dimens.searchFieldHeight) - .padding(horizontal = Dimens.searchFieldHorizontalPadding), - textColor = MaterialTheme.colorScheme.onTertiaryContainer, - backgroundColor = MaterialTheme.colorScheme.tertiaryContainer, - ) { searchString -> - onSearchTermInput.invoke(searchString) - } - Spacer(modifier = Modifier.height(height = Dimens.verticalSpace)) - - LazyColumn( - modifier = - Modifier.fillMaxSize() - .drawVerticalScrollbar( - lazyListState, - MaterialTheme.colorScheme.onSurface.copy(alpha = AlphaScrollbar), - ), - state = lazyListState, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - when (state) { - SelectLocationUiState.Loading -> { - loading() - } - is SelectLocationUiState.Content -> { - - itemsIndexed( - items = state.relayListItems, - key = { _: Int, item: RelayListItem -> item.key }, - contentType = { _, item -> item.contentType }, - itemContent = { index: Int, listItem: RelayListItem -> - Column(modifier = Modifier.animateItem()) { - if (index != 0) { - HorizontalDivider(color = backgroundColor) - } - when (listItem) { - RelayListItem.CustomListHeader -> - CustomListHeader( - onShowCustomListBottomSheet = { - bottomSheetState = - ShowCustomListsBottomSheet( - editListEnabled = - state.customLists.isNotEmpty() - ) - } - ) - is RelayListItem.CustomListItem -> - CustomListItem( - listItem, - onSelectRelay, - { - bottomSheetState = - ShowEditCustomListBottomSheet(it) - }, - { customListId, expand -> - onToggleExpand(customListId, null, expand) - }, - ) - is RelayListItem.CustomListEntryItem -> - CustomListEntryItem( - listItem, - { onSelectRelay(listItem.item) }, - if (listItem.depth == 1) { - { - bottomSheetState = - ShowCustomListsEntryBottomSheet( - listItem.parentId, - listItem.parentName, - listItem.item, - ) - } - } else { - null - }, - { expand: Boolean -> - onToggleExpand( - listItem.item.id, - listItem.parentId, - expand, - ) - }, - ) - is RelayListItem.CustomListFooter -> - CustomListFooter(listItem) - RelayListItem.LocationHeader -> RelayLocationHeader() - is RelayListItem.GeoLocationItem -> - RelayLocationItem( - listItem, - { onSelectRelay(listItem.item) }, - { - // Only direct children can be removed - bottomSheetState = - ShowLocationBottomSheet( - state.customLists, - listItem.item, - ) - }, - { expand -> - onToggleExpand(listItem.item.id, null, expand) - }, - ) - is RelayListItem.LocationsEmptyText -> - LocationsEmptyText(listItem.searchTerm) - } - } - }, - ) - } - } - } - } - } -} - -@Composable -fun LazyItemScope.RelayLocationHeader() { - HeaderCell(text = stringResource(R.string.all_locations)) -} - -@Composable -fun LazyItemScope.RelayLocationItem( - relayItem: RelayListItem.GeoLocationItem, - onSelectRelay: () -> Unit, - onLongClick: () -> Unit, - onExpand: (Boolean) -> Unit, -) { - val location = relayItem.item - StatusRelayItemCell( - location, - relayItem.isSelected, - onClick = { onSelectRelay() }, - onLongClick = { onLongClick() }, - onToggleExpand = { onExpand(it) }, - isExpanded = relayItem.expanded, - depth = relayItem.depth, - modifier = Modifier.testTag(LOCATION_CELL_TEST_TAG), - ) -} - -@Composable -fun LazyItemScope.CustomListItem( - itemState: RelayListItem.CustomListItem, - onSelectRelay: (item: RelayItem) -> Unit, - onShowEditBottomSheet: (RelayItem.CustomList) -> Unit, - onExpand: ((CustomListId, Boolean) -> Unit), -) { - val customListItem = itemState.item - StatusRelayItemCell( - customListItem, - itemState.isSelected, - onClick = { onSelectRelay(customListItem) }, - onLongClick = { onShowEditBottomSheet(customListItem) }, - onToggleExpand = { onExpand(customListItem.id, it) }, - isExpanded = itemState.expanded, - ) -} - -@Composable -fun LazyItemScope.CustomListEntryItem( - itemState: RelayListItem.CustomListEntryItem, - onSelectRelay: () -> Unit, - onShowEditCustomListEntryBottomSheet: (() -> Unit)?, - onToggleExpand: (Boolean) -> Unit, -) { - val customListEntryItem = itemState.item - StatusRelayItemCell( - customListEntryItem, - false, - onClick = onSelectRelay, - onLongClick = onShowEditCustomListEntryBottomSheet, - onToggleExpand = onToggleExpand, - isExpanded = itemState.expanded, - depth = itemState.depth, - ) -} - -@Composable -fun LazyItemScope.CustomListFooter(item: RelayListItem.CustomListFooter) { - SwitchComposeSubtitleCell( - text = - if (item.hasCustomList) { - stringResource(R.string.to_add_locations_to_a_list) - } else { - stringResource(R.string.to_create_a_custom_list) - }, - modifier = Modifier.background(MaterialTheme.colorScheme.surface), - ) -} - -@Composable -private fun SelectLocationTopBar(onBackClick: () -> Unit, onFilterClick: () -> Unit) { - Row(modifier = Modifier.fillMaxWidth()) { - IconButton(onClick = onBackClick) { - Icon( - imageVector = Icons.Default.Close, - tint = MaterialTheme.colorScheme.onSurface, - contentDescription = stringResource(id = R.string.back), - ) - } - Text( - text = stringResource(id = R.string.select_location), - modifier = Modifier.align(Alignment.CenterVertically).weight(weight = 1f), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - IconButton(onClick = onFilterClick) { - Icon( - imageVector = Icons.Default.FilterList, - contentDescription = stringResource(id = R.string.filter), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } -} - -private fun LazyListScope.loading() { - item(contentType = ContentType.PROGRESS) { - MullvadCircularProgressIndicatorLarge(Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR)) - } -} - -@Composable -private fun LazyItemScope.CustomListHeader(onShowCustomListBottomSheet: () -> Unit) { - ThreeDotCell( - text = stringResource(R.string.custom_lists), - onClickDots = onShowCustomListBottomSheet, - modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG), - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun BottomSheets( - bottomSheetState: BottomSheetState?, - onCreateCustomList: (RelayItem.Location?) -> Unit, - onEditCustomLists: () -> Unit, - onAddLocationToList: (RelayItem.Location, RelayItem.CustomList) -> Unit, - onRemoveLocationFromList: (location: RelayItem.Location, parent: CustomListId) -> 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 { onHideBottomSheet() } - } else { - onHideBottomSheet() - } - } - val backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer - val onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface - - when (bottomSheetState) { - is ShowCustomListsBottomSheet -> { - CustomListsBottomSheet( - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - sheetState = sheetState, - bottomSheetState = bottomSheetState, - onCreateCustomList = { onCreateCustomList(null) }, - onEditCustomLists = onEditCustomLists, - closeBottomSheet = onCloseBottomSheet, - ) - } - is ShowLocationBottomSheet -> { - LocationBottomSheet( - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - sheetState = sheetState, - customLists = bottomSheetState.customLists, - item = bottomSheetState.item, - onCreateCustomList = onCreateCustomList, - onAddLocationToList = onAddLocationToList, - closeBottomSheet = onCloseBottomSheet, - ) - } - is ShowEditCustomListBottomSheet -> { - EditCustomListBottomSheet( - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - sheetState = sheetState, - customList = bottomSheetState.customList, - onEditName = onEditCustomListName, - onEditLocations = onEditLocationsCustomList, - onDeleteCustomList = onDeleteCustomList, - closeBottomSheet = onCloseBottomSheet, - ) - } - is ShowCustomListsEntryBottomSheet -> { - CustomListEntryBottomSheet( - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - sheetState = sheetState, - customListId = bottomSheetState.customListId, - customListName = bottomSheetState.customListName, - item = bottomSheetState.item, - onRemoveLocationFromList = onRemoveLocationFromList, - closeBottomSheet = onCloseBottomSheet, - ) - } - null -> { - /* Do nothing */ - } - } -} - -private fun SelectLocationUiState.indexOfSelectedRelayItem(): Int = - if (this is SelectLocationUiState.Content) { - relayListItems.indexOfFirst { - when (it) { - is RelayListItem.CustomListItem -> it.isSelected - is RelayListItem.GeoLocationItem -> it.isSelected - is RelayListItem.CustomListEntryItem -> false - is RelayListItem.CustomListFooter -> false - RelayListItem.CustomListHeader -> false - RelayListItem.LocationHeader -> false - is RelayListItem.LocationsEmptyText -> false - } - } - } else { - -1 - } - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CustomListsBottomSheet( - backgroundColor: Color, - onBackgroundColor: Color, - sheetState: SheetState, - bottomSheetState: ShowCustomListsBottomSheet, - onCreateCustomList: () -> Unit, - onEditCustomLists: () -> Unit, - closeBottomSheet: (animate: Boolean) -> Unit, -) { - - MullvadModalBottomSheet( - sheetState = sheetState, - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - onDismissRequest = { closeBottomSheet(false) }, - modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG), - ) { - HeaderCell( - text = stringResource(id = R.string.edit_custom_lists), - background = backgroundColor, - ) - HorizontalDivider(color = onBackgroundColor) - IconCell( - imageVector = Icons.Default.Add, - title = stringResource(id = R.string.new_list), - titleColor = onBackgroundColor, - onClick = { - onCreateCustomList() - closeBottomSheet(true) - }, - background = backgroundColor, - ) - IconCell( - imageVector = Icons.Default.Edit, - title = stringResource(id = R.string.edit_lists), - titleColor = - onBackgroundColor.copy( - alpha = - if (bottomSheetState.editListEnabled) { - AlphaVisible - } else { - AlphaInactive - } - ), - onClick = { - onEditCustomLists() - closeBottomSheet(true) - }, - background = backgroundColor, - enabled = bottomSheetState.editListEnabled, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun LocationBottomSheet( - backgroundColor: Color, - onBackgroundColor: Color, - sheetState: SheetState, - customLists: List<RelayItem.CustomList>, - item: RelayItem.Location, - onCreateCustomList: (relayItem: RelayItem.Location) -> Unit, - onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit, - closeBottomSheet: (animate: Boolean) -> Unit, -) { - MullvadModalBottomSheet( - sheetState = sheetState, - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - onDismissRequest = { 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 = backgroundColor, - ) - HorizontalDivider(color = onBackgroundColor) - customLists.forEach { - val enabled = it.canAddLocation(item) - IconCell( - imageVector = null, - title = - if (enabled) { - it.name - } else { - stringResource(id = R.string.location_added, it.name) - }, - titleColor = - if (enabled) { - onBackgroundColor - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - onClick = { - onAddLocationToList(item, it) - closeBottomSheet(true) - }, - background = backgroundColor, - enabled = enabled, - ) - } - IconCell( - imageVector = Icons.Default.Add, - title = stringResource(id = R.string.new_list), - titleColor = onBackgroundColor, - onClick = { - onCreateCustomList(item) - closeBottomSheet(true) - }, - background = backgroundColor, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun EditCustomListBottomSheet( - backgroundColor: Color, - 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( - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - sheetState = sheetState, - onDismissRequest = { closeBottomSheet(false) }, - ) { - HeaderCell(text = customList.name, background = backgroundColor) - HorizontalDivider(color = onBackgroundColor) - IconCell( - imageVector = Icons.Default.Edit, - title = stringResource(id = R.string.edit_name), - titleColor = onBackgroundColor, - onClick = { - onEditName(customList) - closeBottomSheet(true) - }, - background = backgroundColor, - ) - IconCell( - imageVector = Icons.Default.Add, - title = stringResource(id = R.string.edit_locations), - titleColor = onBackgroundColor, - onClick = { - onEditLocations(customList) - closeBottomSheet(true) - }, - background = backgroundColor, - ) - IconCell( - imageVector = Icons.Default.Delete, - title = stringResource(id = R.string.delete), - titleColor = onBackgroundColor, - onClick = { - onDeleteCustomList(customList) - closeBottomSheet(true) - }, - background = backgroundColor, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CustomListEntryBottomSheet( - backgroundColor: Color, - onBackgroundColor: Color, - sheetState: SheetState, - customListId: CustomListId, - customListName: CustomListName, - item: RelayItem.Location, - onRemoveLocationFromList: (location: RelayItem.Location, customListId: CustomListId) -> Unit, - closeBottomSheet: (animate: Boolean) -> Unit, -) { - MullvadModalBottomSheet( - sheetState = sheetState, - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - onDismissRequest = { closeBottomSheet(false) }, - modifier = Modifier.testTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG), - ) { - HeaderCell( - text = - stringResource(id = R.string.remove_location_from_list, item.name, customListName), - background = backgroundColor, - ) - HorizontalDivider(color = onBackgroundColor) - - IconCell( - imageVector = Icons.Default.Remove, - title = stringResource(id = R.string.remove_button), - titleColor = onBackgroundColor, - onClick = { - onRemoveLocationFromList(item, customListId) - closeBottomSheet(true) - }, - background = backgroundColor, - ) - } -} - -private suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) { - val itemInfo = this.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } - if (itemInfo != null) { - val center = layoutInfo.viewportEndOffset / 2 - val childCenter = itemInfo.offset + itemInfo.size / 2 - animateScrollBy((childCenter - center).toFloat()) - } else { - animateScrollToItem(index) - } -} - -private suspend fun SnackbarHostState.showResultSnackbar( - context: Context, - result: CustomListActionResultData, - onUndo: (CustomListAction) -> Unit, -) { - - showSnackbarImmediately( - message = result.message(context), - actionLabel = - if (result is CustomListActionResultData.Success) context.getString(R.string.undo) - else { - null - }, - duration = SnackbarDuration.Long, - onAction = { - if (result is CustomListActionResultData.Success) { - onUndo(result.undo) - } - }, - ) -} - -private fun CustomListActionResultData.message(context: Context): String = - when (this) { - is CustomListActionResultData.Success.CreatedWithLocations -> - if (locationNames.size == 1) { - context.getString( - R.string.location_was_added_to_list, - locationNames.first(), - customListName, - ) - } else { - context.getString(R.string.create_custom_list_message, customListName) - } - is CustomListActionResultData.Success.Deleted -> - context.getString(R.string.delete_custom_list_message, customListName) - is CustomListActionResultData.Success.LocationAdded -> - context.getString(R.string.location_was_added_to_list, locationName, customListName) - is CustomListActionResultData.Success.LocationRemoved -> - context.getString(R.string.location_was_removed_from_list, locationName, customListName) - is CustomListActionResultData.Success.LocationChanged -> - context.getString(R.string.locations_were_changed_for, customListName) - is CustomListActionResultData.Success.Renamed -> - context.getString(R.string.name_was_changed_to, newName) - CustomListActionResultData.GenericError -> context.getString(R.string.error_occurred) - } - -@Composable -private fun <D : DestinationSpec, R : CustomListActionResultData> 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, - ) - } - } - } - } -} - -sealed interface BottomSheetState { - - data class ShowCustomListsBottomSheet(val editListEnabled: Boolean) : BottomSheetState - - data class ShowCustomListsEntryBottomSheet( - val customListId: CustomListId, - val customListName: CustomListName, - val item: RelayItem.Location, - ) : BottomSheetState - - data class ShowLocationBottomSheet( - val customLists: List<RelayItem.CustomList>, - val item: RelayItem.Location, - ) : BottomSheetState - - data class ShowEditCustomListBottomSheet(val customList: RelayItem.CustomList) : - BottomSheetState -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt index 27beeeca4e..b8c418cd06 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt @@ -26,6 +26,7 @@ import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.destinations.ApiAccessListDestination import com.ramcosta.composedestinations.generated.destinations.AppInfoDestination +import com.ramcosta.composedestinations.generated.destinations.MultihopDestination import com.ramcosta.composedestinations.generated.destinations.ReportProblemDestination import com.ramcosta.composedestinations.generated.destinations.SplitTunnelingDestination import com.ramcosta.composedestinations.generated.destinations.VpnSettingsDestination @@ -49,7 +50,7 @@ import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) -@Preview("Supported|Unsupported") +@Preview("Supported|+") @Composable private fun PreviewSettingsScreen( @PreviewParameter(SettingsUiStatePreviewParameterProvider::class) state: SettingsUiState @@ -72,6 +73,7 @@ fun Settings(navigator: DestinationsNavigator) { onApiAccessClick = dropUnlessResumed { navigator.navigate(ApiAccessListDestination) }, onReportProblemCellClick = dropUnlessResumed { navigator.navigate(ReportProblemDestination) }, + onMultihopClick = dropUnlessResumed { navigator.navigate(MultihopDestination) }, onBackClick = dropUnlessResumed { navigator.navigateUp() }, ) } @@ -85,6 +87,7 @@ fun SettingsScreen( onAppInfoClick: () -> Unit = {}, onReportProblemCellClick: () -> Unit = {}, onApiAccessClick: () -> Unit = {}, + onMultihopClick: () -> Unit = {}, onBackClick: () -> Unit = {}, ) { ScaffoldWithMediumTopBar( @@ -96,8 +99,13 @@ fun SettingsScreen( state = lazyListState, ) { if (state.isLoggedIn) { - item { Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) } - item { + itemWithDivider { + MultihopCell( + isMultihopEnabled = state.multihopEnabled, + onMultihopClick = onMultihopClick, + ) + } + itemWithDivider { NavigationComposeCell( title = stringResource(id = R.string.settings_vpn), onClick = onVpnSettingCellClick, @@ -181,13 +189,12 @@ private fun FaqAndGuides() { NavigationComposeCell( title = faqGuideLabel, - bodyView = - @Composable { - DefaultExternalLinkView( - chevronContentDescription = faqGuideLabel, - tint = MaterialTheme.colorScheme.onPrimary, - ) - }, + bodyView = { + DefaultExternalLinkView( + chevronContentDescription = faqGuideLabel, + tint = MaterialTheme.colorScheme.onPrimary, + ) + }, onClick = openFaqAndGuides, ) } @@ -203,13 +210,29 @@ private fun PrivacyPolicy(state: SettingsUiState) { NavigationComposeCell( title = privacyPolicyLabel, - bodyView = - @Composable { - DefaultExternalLinkView( - chevronContentDescription = privacyPolicyLabel, - tint = MaterialTheme.colorScheme.onPrimary, - ) - }, + bodyView = { + DefaultExternalLinkView( + chevronContentDescription = privacyPolicyLabel, + tint = MaterialTheme.colorScheme.onPrimary, + ) + }, onClick = openPrivacyPolicy, ) } + +@Composable +private fun MultihopCell(isMultihopEnabled: Boolean, onMultihopClick: () -> Unit) { + val title = stringResource(id = R.string.multihop) + TwoRowCell( + titleText = title, + subtitleText = + stringResource( + if (isMultihopEnabled) { + R.string.on + } else { + R.string.off + } + ), + onCellClicked = onMultihopClick, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/LocationBottomSheet.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/LocationBottomSheet.kt new file mode 100644 index 0000000000..7df4987d03 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/LocationBottomSheet.kt @@ -0,0 +1,426 @@ +package net.mullvad.mullvadvpn.compose.screen.location + +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +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.HeaderCell +import net.mullvad.mullvadvpn.compose.cell.IconCell +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData +import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomSheet +import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowCustomListsBottomSheet +import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowCustomListsEntryBottomSheet +import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowEditCustomListBottomSheet +import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowLocationBottomSheet +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG +import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive +import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible +import net.mullvad.mullvadvpn.relaylist.canAddLocation + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun LocationBottomSheets( + locationBottomSheetState: LocationBottomSheetState?, + onCreateCustomList: (RelayItem.Location?) -> Unit, + onEditCustomLists: () -> Unit, + onAddLocationToList: (RelayItem.Location, RelayItem.CustomList) -> Unit, + onRemoveLocationFromList: (location: RelayItem.Location, parent: CustomListId) -> 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 { onHideBottomSheet() } + } else { + onHideBottomSheet() + } + } + val backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer + val onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface + + when (locationBottomSheetState) { + is ShowCustomListsBottomSheet -> { + CustomListsBottomSheet( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + sheetState = sheetState, + bottomSheetState = locationBottomSheetState, + onCreateCustomList = { onCreateCustomList(null) }, + onEditCustomLists = onEditCustomLists, + closeBottomSheet = onCloseBottomSheet, + ) + } + is ShowLocationBottomSheet -> { + LocationBottomSheet( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + sheetState = sheetState, + customLists = locationBottomSheetState.customLists, + item = locationBottomSheetState.item, + onCreateCustomList = onCreateCustomList, + onAddLocationToList = onAddLocationToList, + closeBottomSheet = onCloseBottomSheet, + ) + } + is ShowEditCustomListBottomSheet -> { + EditCustomListBottomSheet( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + sheetState = sheetState, + customList = locationBottomSheetState.customList, + onEditName = onEditCustomListName, + onEditLocations = onEditLocationsCustomList, + onDeleteCustomList = onDeleteCustomList, + closeBottomSheet = onCloseBottomSheet, + ) + } + is ShowCustomListsEntryBottomSheet -> { + CustomListEntryBottomSheet( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + sheetState = sheetState, + customListId = locationBottomSheetState.customListId, + customListName = locationBottomSheetState.customListName, + item = locationBottomSheetState.item, + onRemoveLocationFromList = onRemoveLocationFromList, + closeBottomSheet = onCloseBottomSheet, + ) + } + null -> { + /* Do nothing */ + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CustomListsBottomSheet( + backgroundColor: Color, + onBackgroundColor: Color, + sheetState: SheetState, + bottomSheetState: ShowCustomListsBottomSheet, + onCreateCustomList: () -> Unit, + onEditCustomLists: () -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit, +) { + + MullvadModalBottomSheet( + sheetState = sheetState, + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + onDismissRequest = { closeBottomSheet(false) }, + modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG), + ) { + HeaderCell( + text = stringResource(id = R.string.edit_custom_lists), + background = backgroundColor, + ) + HorizontalDivider(color = onBackgroundColor) + IconCell( + imageVector = Icons.Default.Add, + title = stringResource(id = R.string.new_list), + titleColor = onBackgroundColor, + onClick = { + onCreateCustomList() + closeBottomSheet(true) + }, + background = backgroundColor, + ) + IconCell( + imageVector = Icons.Default.Edit, + title = stringResource(id = R.string.edit_lists), + titleColor = + onBackgroundColor.copy( + alpha = + if (bottomSheetState.editListEnabled) { + AlphaVisible + } else { + AlphaInactive + } + ), + onClick = { + onEditCustomLists() + closeBottomSheet(true) + }, + background = backgroundColor, + enabled = bottomSheetState.editListEnabled, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LocationBottomSheet( + backgroundColor: Color, + onBackgroundColor: Color, + sheetState: SheetState, + customLists: List<RelayItem.CustomList>, + item: RelayItem.Location, + onCreateCustomList: (relayItem: RelayItem.Location) -> Unit, + onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit, +) { + MullvadModalBottomSheet( + sheetState = sheetState, + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + onDismissRequest = { 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 = backgroundColor, + ) + HorizontalDivider(color = onBackgroundColor) + customLists.forEach { + val enabled = it.canAddLocation(item) + IconCell( + imageVector = null, + title = + if (enabled) { + it.name + } else { + stringResource(id = R.string.location_added, it.name) + }, + titleColor = + if (enabled) { + onBackgroundColor + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + onClick = { + onAddLocationToList(item, it) + closeBottomSheet(true) + }, + background = backgroundColor, + enabled = enabled, + ) + } + IconCell( + imageVector = Icons.Default.Add, + title = stringResource(id = R.string.new_list), + titleColor = onBackgroundColor, + onClick = { + onCreateCustomList(item) + closeBottomSheet(true) + }, + background = backgroundColor, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EditCustomListBottomSheet( + backgroundColor: Color, + 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( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + sheetState = sheetState, + onDismissRequest = { closeBottomSheet(false) }, + ) { + HeaderCell(text = customList.name, background = backgroundColor) + HorizontalDivider(color = onBackgroundColor) + IconCell( + imageVector = Icons.Default.Edit, + title = stringResource(id = R.string.edit_name), + titleColor = onBackgroundColor, + onClick = { + onEditName(customList) + closeBottomSheet(true) + }, + background = backgroundColor, + ) + IconCell( + imageVector = Icons.Default.Add, + title = stringResource(id = R.string.edit_locations), + titleColor = onBackgroundColor, + onClick = { + onEditLocations(customList) + closeBottomSheet(true) + }, + background = backgroundColor, + ) + IconCell( + imageVector = Icons.Default.Delete, + title = stringResource(id = R.string.delete), + titleColor = onBackgroundColor, + onClick = { + onDeleteCustomList(customList) + closeBottomSheet(true) + }, + background = backgroundColor, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CustomListEntryBottomSheet( + backgroundColor: Color, + onBackgroundColor: Color, + sheetState: SheetState, + customListId: CustomListId, + customListName: CustomListName, + item: RelayItem.Location, + onRemoveLocationFromList: (location: RelayItem.Location, customListId: CustomListId) -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit, +) { + MullvadModalBottomSheet( + sheetState = sheetState, + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + onDismissRequest = { closeBottomSheet(false) }, + modifier = Modifier.testTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG), + ) { + HeaderCell( + text = + stringResource(id = R.string.remove_location_from_list, item.name, customListName), + background = backgroundColor, + ) + HorizontalDivider(color = onBackgroundColor) + + IconCell( + imageVector = Icons.Default.Remove, + title = stringResource(id = R.string.remove_button), + titleColor = onBackgroundColor, + onClick = { + onRemoveLocationFromList(item, customListId) + closeBottomSheet(true) + }, + background = backgroundColor, + ) + } +} + +internal suspend fun SnackbarHostState.showResultSnackbar( + context: Context, + result: CustomListActionResultData, + onUndo: (CustomListAction) -> Unit, +) { + + showSnackbarImmediately( + message = result.message(context), + actionLabel = + if (result is CustomListActionResultData.Success) context.getString(R.string.undo) + else { + null + }, + duration = SnackbarDuration.Long, + onAction = { + if (result is CustomListActionResultData.Success) { + onUndo(result.undo) + } + }, + ) +} + +private fun CustomListActionResultData.message(context: Context): String = + when (this) { + is CustomListActionResultData.Success.CreatedWithLocations -> + if (locationNames.size == 1) { + context.getString( + R.string.location_was_added_to_list, + locationNames.first(), + customListName, + ) + } else { + context.getString(R.string.create_custom_list_message, customListName) + } + is CustomListActionResultData.Success.Deleted -> + context.getString(R.string.delete_custom_list_message, customListName) + is CustomListActionResultData.Success.LocationAdded -> + context.getString(R.string.location_was_added_to_list, locationName, customListName) + is CustomListActionResultData.Success.LocationRemoved -> + context.getString(R.string.location_was_removed_from_list, locationName, customListName) + is CustomListActionResultData.Success.LocationChanged -> + context.getString(R.string.locations_were_changed_for, customListName) + is CustomListActionResultData.Success.Renamed -> + context.getString(R.string.name_was_changed_to, newName) + CustomListActionResultData.GenericError -> context.getString(R.string.error_occurred) + } + +@Composable +internal fun <D : DestinationSpec, R : CustomListActionResultData> 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, + ) + } + } + } + } +} + +sealed interface LocationBottomSheetState { + + data class ShowCustomListsBottomSheet(val editListEnabled: Boolean) : LocationBottomSheetState + + data class ShowCustomListsEntryBottomSheet( + val customListId: CustomListId, + val customListName: CustomListName, + val item: RelayItem.Location, + ) : LocationBottomSheetState + + data class ShowLocationBottomSheet( + val customLists: List<RelayItem.CustomList>, + val item: RelayItem.Location, + ) : LocationBottomSheetState + + data class ShowEditCustomListBottomSheet(val customList: RelayItem.CustomList) : + LocationBottomSheetState +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/RelayListContent.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/RelayListContent.kt new file mode 100644 index 0000000000..62eeb38892 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/RelayListContent.kt @@ -0,0 +1,196 @@ +package net.mullvad.mullvadvpn.compose.screen.location + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +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.platform.testTag +import androidx.compose.ui.res.stringResource +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.HeaderCell +import net.mullvad.mullvadvpn.compose.cell.StatusRelayItemCell +import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell +import net.mullvad.mullvadvpn.compose.cell.ThreeDotCell +import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText +import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowCustomListsBottomSheet +import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowCustomListsEntryBottomSheet +import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowEditCustomListBottomSheet +import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowLocationBottomSheet +import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.test.LOCATION_CELL_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId + +/** Used by both the select location screen and search select location screen */ +fun LazyListScope.relayListContent( + backgroundColor: Color, + relayListItems: List<RelayListItem>, + customLists: List<RelayItem.CustomList>, + onSelectRelay: (RelayItem) -> Unit, + onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit, + onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, + customListHeader: @Composable LazyItemScope.() -> Unit = { + CustomListHeader( + onShowCustomListBottomSheet = { + onUpdateBottomSheetState( + ShowCustomListsBottomSheet(editListEnabled = customLists.isNotEmpty()) + ) + } + ) + }, + locationHeader: @Composable LazyItemScope.() -> Unit = { RelayLocationHeader() }, +) { + itemsIndexed( + items = relayListItems, + key = { _: Int, item: RelayListItem -> item.key }, + contentType = { _, item -> item.contentType }, + itemContent = { index: Int, listItem: RelayListItem -> + Column(modifier = Modifier.animateItem()) { + if (index != 0) { + HorizontalDivider(color = backgroundColor) + } + when (listItem) { + RelayListItem.CustomListHeader -> customListHeader() + is RelayListItem.CustomListItem -> + CustomListItem( + listItem, + onSelectRelay, + { onUpdateBottomSheetState(ShowEditCustomListBottomSheet(it)) }, + { customListId, expand -> onToggleExpand(customListId, null, expand) }, + ) + is RelayListItem.CustomListEntryItem -> + CustomListEntryItem( + listItem, + { onSelectRelay(listItem.item) }, + // Only direct children can be removed + if (listItem.depth == 1) { + { + onUpdateBottomSheetState( + ShowCustomListsEntryBottomSheet( + listItem.parentId, + listItem.parentName, + listItem.item, + ) + ) + } + } else { + null + }, + { expand: Boolean -> + onToggleExpand(listItem.item.id, listItem.parentId, expand) + }, + ) + is RelayListItem.CustomListFooter -> CustomListFooter(listItem) + RelayListItem.LocationHeader -> locationHeader() + is RelayListItem.GeoLocationItem -> + RelayLocationItem( + listItem, + { onSelectRelay(listItem.item) }, + { + onUpdateBottomSheetState( + ShowLocationBottomSheet(customLists, listItem.item) + ) + }, + { expand -> onToggleExpand(listItem.item.id, null, expand) }, + ) + is RelayListItem.LocationsEmptyText -> LocationsEmptyText(listItem.searchTerm) + } + } + }, + ) +} + +@Composable +private fun LazyItemScope.RelayLocationItem( + relayItem: RelayListItem.GeoLocationItem, + onSelectRelay: () -> Unit, + onLongClick: () -> Unit, + onExpand: (Boolean) -> Unit, +) { + val location = relayItem.item + StatusRelayItemCell( + item = location, + state = relayItem.state, + isSelected = relayItem.isSelected, + onClick = { onSelectRelay() }, + onLongClick = { onLongClick() }, + onToggleExpand = { onExpand(it) }, + isExpanded = relayItem.expanded, + depth = relayItem.depth, + modifier = Modifier.testTag(LOCATION_CELL_TEST_TAG), + ) +} + +@Composable +private fun LazyItemScope.CustomListEntryItem( + itemState: RelayListItem.CustomListEntryItem, + onSelectRelay: () -> Unit, + onShowEditCustomListEntryBottomSheet: (() -> Unit)?, + onToggleExpand: (Boolean) -> Unit, +) { + val customListEntryItem = itemState.item + StatusRelayItemCell( + item = customListEntryItem, + state = itemState.state, + isSelected = false, + onClick = onSelectRelay, + onLongClick = onShowEditCustomListEntryBottomSheet, + onToggleExpand = onToggleExpand, + isExpanded = itemState.expanded, + depth = itemState.depth, + ) +} + +@Composable +private fun LazyItemScope.CustomListItem( + itemState: RelayListItem.CustomListItem, + onSelectRelay: (item: RelayItem) -> Unit, + onShowEditBottomSheet: (RelayItem.CustomList) -> Unit, + onExpand: ((CustomListId, Boolean) -> Unit), +) { + val customListItem = itemState.item + StatusRelayItemCell( + item = customListItem, + state = itemState.state, + isSelected = itemState.isSelected, + onClick = { onSelectRelay(customListItem) }, + onLongClick = { onShowEditBottomSheet(customListItem) }, + onToggleExpand = { onExpand(customListItem.id, it) }, + isExpanded = itemState.expanded, + ) +} + +@Composable +private fun LazyItemScope.CustomListHeader(onShowCustomListBottomSheet: () -> Unit) { + ThreeDotCell( + text = stringResource(R.string.custom_lists), + onClickDots = onShowCustomListBottomSheet, + modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG), + ) +} + +@Composable +private fun LazyItemScope.CustomListFooter(item: RelayListItem.CustomListFooter) { + SwitchComposeSubtitleCell( + text = + if (item.hasCustomList) { + stringResource(R.string.to_add_locations_to_a_list) + } else { + stringResource(R.string.to_create_a_custom_list) + }, + modifier = Modifier.background(MaterialTheme.colorScheme.surface), + ) +} + +@Composable +private fun LazyItemScope.RelayLocationHeader() { + HeaderCell(text = stringResource(R.string.all_locations)) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreen.kt new file mode 100644 index 0000000000..fc810e6882 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreen.kt @@ -0,0 +1,401 @@ +package net.mullvad.mullvadvpn.compose.screen.location + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +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.SearchBarDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.CreateCustomListDestination +import com.ramcosta.composedestinations.generated.destinations.CustomListLocationsDestination +import com.ramcosta.composedestinations.generated.destinations.CustomListsDestination +import com.ramcosta.composedestinations.generated.destinations.DeleteCustomListDestination +import com.ramcosta.composedestinations.generated.destinations.EditCustomListNameDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.result.ResultRecipient +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.FilterRow +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData +import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar +import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.constant.ContentType +import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed +import net.mullvad.mullvadvpn.compose.preview.SearchLocationsUiStatePreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SearchLocationUiState +import net.mullvad.mullvadvpn.compose.transitions.TopLevelTransition +import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle +import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId +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.usecase.FilterChip +import net.mullvad.mullvadvpn.viewmodel.location.SearchLocationSideEffect +import net.mullvad.mullvadvpn.viewmodel.location.SearchLocationViewModel +import org.koin.androidx.compose.koinViewModel + +@Preview("Default|Not found|Results") +@Composable +private fun PreviewSearchLocationScreen( + @PreviewParameter(SearchLocationsUiStatePreviewParameterProvider::class) + state: SearchLocationUiState +) { + AppTheme { SearchLocationScreen(state = state) } +} + +data class SearchLocationNavArgs(val relayListType: RelayListType) + +@Suppress("LongMethod") +@Composable +@Destination<RootGraph>(style = TopLevelTransition::class, navArgs = SearchLocationNavArgs::class) +fun SearchLocation( + navigator: DestinationsNavigator, + backNavigator: ResultBackNavigator<RelayListType>, + createCustomListDialogResultRecipient: + ResultRecipient< + CreateCustomListDestination, + CustomListActionResultData.Success.CreatedWithLocations, + >, + editCustomListNameDialogResultRecipient: + ResultRecipient<EditCustomListNameDestination, CustomListActionResultData.Success.Renamed>, + deleteCustomListDialogResultRecipient: + ResultRecipient<DeleteCustomListDestination, CustomListActionResultData.Success.Deleted>, + updateCustomListResultRecipient: + ResultRecipient<CustomListLocationsDestination, CustomListActionResultData>, +) { + val viewModel = koinViewModel<SearchLocationViewModel>() + val state by viewModel.uiState.collectAsStateWithLifecycle() + + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + CollectSideEffectWithLifecycle(viewModel.uiSideEffect) { + when (it) { + is SearchLocationSideEffect.LocationSelected -> + backNavigator.navigateBack(result = it.relayListType) + is SearchLocationSideEffect.CustomListActionToast -> + launch { + snackbarHostState.showResultSnackbar( + context = context, + result = it.resultData, + onUndo = viewModel::performAction, + ) + } + SearchLocationSideEffect.GenericError -> + launch { + snackbarHostState.showSnackbarImmediately( + message = context.getString(R.string.error_occurred) + ) + } + } + } + + createCustomListDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + viewModel::performAction, + ) + + editCustomListNameDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + viewModel::performAction, + ) + + deleteCustomListDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + viewModel::performAction, + ) + + updateCustomListResultRecipient.OnCustomListNavResult( + snackbarHostState, + viewModel::performAction, + ) + + SearchLocationScreen( + state = state, + snackbarHostState = snackbarHostState, + onSelectRelay = viewModel::selectRelay, + onToggleExpand = viewModel::onToggleExpand, + onSearchInputChanged = viewModel::onSearchInputUpdated, + onCreateCustomList = + dropUnlessResumed { relayItem -> + navigator.navigate(CreateCustomListDestination(locationCode = relayItem?.id)) + }, + onEditCustomLists = dropUnlessResumed { navigator.navigate(CustomListsDestination()) }, + onAddLocationToList = viewModel::addLocationToList, + onRemoveLocationFromList = viewModel::removeLocationFromList, + onEditCustomListName = + dropUnlessResumed { customList: RelayItem.CustomList -> + navigator.navigate( + EditCustomListNameDestination( + customListId = customList.id, + initialName = customList.customList.name, + ) + ) + }, + onEditLocationsCustomList = + dropUnlessResumed { customList: RelayItem.CustomList -> + navigator.navigate( + CustomListLocationsDestination(customListId = customList.id, newList = false) + ) + }, + onDeleteCustomList = + dropUnlessResumed { customList: RelayItem.CustomList -> + navigator.navigate( + DeleteCustomListDestination( + customListId = customList.id, + name = customList.customList.name, + ) + ) + }, + onRemoveOwnershipFilter = viewModel::removeOwnerFilter, + onRemoveProviderFilter = viewModel::removeProviderFilter, + onGoBack = dropUnlessResumed { navigator.navigateUp() }, + ) +} + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchLocationScreen( + state: SearchLocationUiState, + snackbarHostState: SnackbarHostState = SnackbarHostState(), + onSelectRelay: (RelayItem) -> Unit = {}, + onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit = { _, _, _ -> }, + onSearchInputChanged: (String) -> Unit = {}, + onCreateCustomList: (location: RelayItem.Location?) -> Unit = {}, + onEditCustomLists: () -> Unit = {}, + onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit = + { _, _ -> + }, + onRemoveLocationFromList: (location: RelayItem.Location, customListId: CustomListId) -> Unit = + { _, _ -> + }, + onEditCustomListName: (RelayItem.CustomList) -> Unit = {}, + onEditLocationsCustomList: (RelayItem.CustomList) -> Unit = {}, + onDeleteCustomList: (RelayItem.CustomList) -> Unit = {}, + onRemoveOwnershipFilter: () -> Unit = {}, + onRemoveProviderFilter: () -> Unit = {}, + onGoBack: () -> Unit = {}, +) { + val backgroundColor = MaterialTheme.colorScheme.surface + val onBackgroundColor = MaterialTheme.colorScheme.onSurface + val keyboardController = LocalSoftwareKeyboardController.current + Scaffold( + snackbarHost = { + SnackbarHost( + snackbarHostState, + snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) }, + ) + } + ) { + var locationBottomSheetState by remember { mutableStateOf<LocationBottomSheetState?>(null) } + LocationBottomSheets( + locationBottomSheetState = locationBottomSheetState, + onCreateCustomList = onCreateCustomList, + onEditCustomLists = onEditCustomLists, + onAddLocationToList = onAddLocationToList, + onRemoveLocationFromList = onRemoveLocationFromList, + onEditCustomListName = onEditCustomListName, + onEditLocationsCustomList = onEditLocationsCustomList, + onDeleteCustomList = onDeleteCustomList, + onHideBottomSheet = { locationBottomSheetState = null }, + ) + Column(modifier = Modifier.padding(it)) { + SearchBar( + searchTerm = state.searchTerm, + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + onSearchInputChanged = onSearchInputChanged, + hideKeyboard = { keyboardController?.hide() }, + onGoBack = onGoBack, + ) + HorizontalDivider(color = onBackgroundColor) + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = + Modifier.fillMaxSize() + .background(color = backgroundColor) + .drawVerticalScrollbar( + lazyListState, + MaterialTheme.colorScheme.onSurface.copy(alpha = AlphaScrollbar), + ), + state = lazyListState, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + filterRow( + filters = state.filterChips, + onBackgroundColor = onBackgroundColor, + onRemoveOwnershipFilter = onRemoveOwnershipFilter, + onRemoveProviderFilter = onRemoveProviderFilter, + ) + when (state) { + is SearchLocationUiState.NoQuery -> { + noQuery() + } + is SearchLocationUiState.Content -> { + relayListContent( + backgroundColor = backgroundColor, + customLists = state.customLists, + relayListItems = state.relayListItems, + onSelectRelay = onSelectRelay, + onToggleExpand = onToggleExpand, + onUpdateBottomSheetState = { newSheetState -> + locationBottomSheetState = newSheetState + }, + customListHeader = { + Title( + text = stringResource(R.string.custom_lists), + onBackgroundColor = onBackgroundColor, + ) + }, + locationHeader = { + Title( + text = stringResource(R.string.locations), + onBackgroundColor = onBackgroundColor, + ) + }, + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchBar( + searchTerm: String, + backgroundColor: Color, + onBackgroundColor: Color, + onSearchInputChanged: (String) -> Unit, + hideKeyboard: () -> Unit, + onGoBack: () -> Unit, +) { + SearchBarDefaults.InputField( + modifier = Modifier.height(Dimens.searchFieldHeightExpanded).fillMaxWidth(), + query = searchTerm, + onQueryChange = onSearchInputChanged, + onSearch = { hideKeyboard() }, + expanded = true, + onExpandedChange = {}, + leadingIcon = { + IconButton(onClick = onGoBack) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + trailingIcon = { + if (searchTerm.isNotEmpty()) { + IconButton(onClick = { onSearchInputChanged("") }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.clear_input), + ) + } + } + }, + placeholder = { Text(text = stringResource(id = R.string.search_placeholder)) }, + colors = + TextFieldDefaults.colors( + focusedContainerColor = backgroundColor, + unfocusedContainerColor = backgroundColor, + focusedPlaceholderColor = onBackgroundColor, + unfocusedPlaceholderColor = onBackgroundColor, + focusedTextColor = onBackgroundColor, + unfocusedTextColor = onBackgroundColor, + cursorColor = onBackgroundColor, + focusedLeadingIconColor = onBackgroundColor, + unfocusedLeadingIconColor = onBackgroundColor, + focusedTrailingIconColor = onBackgroundColor, + unfocusedTrailingIconColor = onBackgroundColor, + ), + ) +} + +private fun LazyListScope.noQuery() { + item(contentType = ContentType.DESCRIPTION) { + Text( + text = stringResource(R.string.search_query_empty), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(Dimens.mediumPadding), + ) + } +} + +private fun LazyListScope.filterRow( + filters: List<FilterChip>, + onBackgroundColor: Color, + onRemoveOwnershipFilter: () -> Unit, + onRemoveProviderFilter: () -> Unit, +) { + if (filters.isNotEmpty()) { + item { + Title(text = stringResource(R.string.filters), onBackgroundColor = onBackgroundColor) + } + item { + FilterRow( + filters = filters, + showTitle = false, + onRemoveOwnershipFilter = onRemoveOwnershipFilter, + onRemoveProviderFilter = onRemoveProviderFilter, + ) + } + } +} + +@Composable +private fun Title(text: String, onBackgroundColor: Color) { + Text( + text = text, + color = onBackgroundColor, + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = Dimens.sideMargin, vertical = Dimens.smallPadding), + style = MaterialTheme.typography.labelMedium, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt new file mode 100644 index 0000000000..8f07ab180e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt @@ -0,0 +1,114 @@ +package net.mullvad.mullvadvpn.compose.screen.location + +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge +import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.constant.ContentType +import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState +import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR +import net.mullvad.mullvadvpn.compose.util.RunOnKeyChange +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar +import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationListViewModel +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun SelectLocationList( + backgroundColor: Color, + relayListType: RelayListType, + onSelectRelay: (RelayItem) -> Unit, + onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, +) { + val viewModel = + koinViewModel<SelectLocationListViewModel>( + key = relayListType.name, + parameters = { parametersOf(relayListType) }, + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + val lazyListState = rememberLazyListState() + val stateActual = state + RunOnKeyChange(stateActual is SelectLocationListUiState.Content) { + stateActual.indexOfSelectedRelayItem()?.let { index -> + lazyListState.scrollToItem(index) + lazyListState.animateScrollAndCentralizeItem(index) + } + } + LazyColumn( + modifier = + Modifier.fillMaxSize() + .drawVerticalScrollbar( + lazyListState, + MaterialTheme.colorScheme.onSurface.copy(alpha = AlphaScrollbar), + ), + state = lazyListState, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (stateActual) { + SelectLocationListUiState.Loading -> { + loading() + } + is SelectLocationListUiState.Content -> { + relayListContent( + backgroundColor = backgroundColor, + relayListItems = stateActual.relayListItems, + customLists = stateActual.customLists, + onSelectRelay = onSelectRelay, + onToggleExpand = viewModel::onToggleExpand, + onUpdateBottomSheetState = onUpdateBottomSheetState, + ) + } + } + } +} + +private fun LazyListScope.loading() { + item(contentType = ContentType.PROGRESS) { + MullvadCircularProgressIndicatorLarge(Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR)) + } +} + +private fun SelectLocationListUiState.indexOfSelectedRelayItem(): Int? = + if (this is SelectLocationListUiState.Content) { + val index = + relayListItems.indexOfFirst { + when (it) { + is RelayListItem.CustomListItem -> it.isSelected + is RelayListItem.GeoLocationItem -> it.isSelected + is RelayListItem.CustomListEntryItem, + is RelayListItem.CustomListFooter, + RelayListItem.CustomListHeader, + RelayListItem.LocationHeader, + is RelayListItem.LocationsEmptyText -> false + } + } + if (index >= 0) index else null + } else { + null + } + +private suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) { + val itemInfo = this.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } + if (itemInfo != null) { + val center = layoutInfo.viewportEndOffset / 2 + val childCenter = itemInfo.offset + itemInfo.size / 2 + animateScrollBy((childCenter - center).toFloat()) + } else { + animateScrollToItem(index) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt new file mode 100644 index 0000000000..3e40d57090 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt @@ -0,0 +1,355 @@ +package net.mullvad.mullvadvpn.compose.screen.location + +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SnackbarHostState +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.CreateCustomListDestination +import com.ramcosta.composedestinations.generated.destinations.CustomListLocationsDestination +import com.ramcosta.composedestinations.generated.destinations.CustomListsDestination +import com.ramcosta.composedestinations.generated.destinations.DeleteCustomListDestination +import com.ramcosta.composedestinations.generated.destinations.EditCustomListNameDestination +import com.ramcosta.composedestinations.generated.destinations.FilterDestination +import com.ramcosta.composedestinations.generated.destinations.SearchLocationDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.result.ResultRecipient +import com.ramcosta.composedestinations.result.onResult +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.MullvadSegmentedEndButton +import net.mullvad.mullvadvpn.compose.button.MullvadSegmentedStartButton +import net.mullvad.mullvadvpn.compose.cell.FilterRow +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithSmallTopBar +import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed +import net.mullvad.mullvadvpn.compose.preview.SelectLocationsUiStatePreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState +import net.mullvad.mullvadvpn.compose.transitions.TopLevelTransition +import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle +import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationSideEffect +import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationViewModel +import org.koin.androidx.compose.koinViewModel + +@Preview("Default|Filters|Multihop|Multihop and Filters") +@Composable +private fun PreviewSelectLocationScreen( + @PreviewParameter(SelectLocationsUiStatePreviewParameterProvider::class) + state: SelectLocationUiState +) { + AppTheme { SelectLocationScreen(state = state) } +} + +@SuppressLint("CheckResult") +@Destination<RootGraph>(style = TopLevelTransition::class) +@Suppress("LongMethod") +@Composable +fun SelectLocation( + navigator: DestinationsNavigator, + backNavigator: ResultBackNavigator<Boolean>, + createCustomListDialogResultRecipient: + ResultRecipient< + CreateCustomListDestination, + CustomListActionResultData.Success.CreatedWithLocations, + >, + editCustomListNameDialogResultRecipient: + ResultRecipient<EditCustomListNameDestination, CustomListActionResultData.Success.Renamed>, + deleteCustomListDialogResultRecipient: + ResultRecipient<DeleteCustomListDestination, CustomListActionResultData.Success.Deleted>, + updateCustomListResultRecipient: + ResultRecipient<CustomListLocationsDestination, CustomListActionResultData>, + searchSelectedLocationResultRecipient: ResultRecipient<SearchLocationDestination, RelayListType>, +) { + val vm = koinViewModel<SelectLocationViewModel>() + val state = vm.uiState.collectAsStateWithLifecycle() + + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + CollectSideEffectWithLifecycle(vm.uiSideEffect) { + when (it) { + SelectLocationSideEffect.CloseScreen -> backNavigator.navigateBack(result = true) + is SelectLocationSideEffect.CustomListActionToast -> + launch { + snackbarHostState.showResultSnackbar( + context = context, + result = it.resultData, + onUndo = vm::performAction, + ) + } + SelectLocationSideEffect.GenericError -> + launch { + snackbarHostState.showSnackbarImmediately( + message = context.getString(R.string.error_occurred) + ) + } + } + } + + createCustomListDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + vm::performAction, + ) + + editCustomListNameDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + vm::performAction, + ) + + deleteCustomListDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + vm::performAction, + ) + + updateCustomListResultRecipient.OnCustomListNavResult(snackbarHostState, vm::performAction) + + searchSelectedLocationResultRecipient.onResult { result -> + when (result) { + RelayListType.ENTRY -> { + vm.selectRelayList(RelayListType.EXIT) + } + RelayListType.EXIT -> backNavigator.navigateBack(result = true) + } + } + + SelectLocationScreen( + state = state.value, + snackbarHostState = snackbarHostState, + onSelectRelay = vm::selectRelay, + onSearchClick = { navigator.navigate(SearchLocationDestination(it)) }, + onBackClick = dropUnlessResumed { backNavigator.navigateBack() }, + onFilterClick = dropUnlessResumed { navigator.navigate(FilterDestination) }, + onCreateCustomList = + dropUnlessResumed { relayItem -> + navigator.navigate(CreateCustomListDestination(locationCode = relayItem?.id)) + }, + onEditCustomLists = dropUnlessResumed { navigator.navigate(CustomListsDestination()) }, + removeOwnershipFilter = vm::removeOwnerFilter, + removeProviderFilter = vm::removeProviderFilter, + onAddLocationToList = vm::addLocationToList, + onRemoveLocationFromList = vm::removeLocationFromList, + onEditCustomListName = + dropUnlessResumed { customList: RelayItem.CustomList -> + navigator.navigate( + EditCustomListNameDestination( + customListId = customList.id, + initialName = customList.customList.name, + ) + ) + }, + onEditLocationsCustomList = + dropUnlessResumed { customList: RelayItem.CustomList -> + navigator.navigate( + CustomListLocationsDestination(customListId = customList.id, newList = false) + ) + }, + onDeleteCustomList = + dropUnlessResumed { customList: RelayItem.CustomList -> + navigator.navigate( + DeleteCustomListDestination( + customListId = customList.id, + name = customList.customList.name, + ) + ) + }, + onSelectRelayList = vm::selectRelayList, + ) +} + +@Suppress("LongMethod") +@Composable +fun SelectLocationScreen( + state: SelectLocationUiState, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + onSelectRelay: (item: RelayItem) -> Unit = {}, + onSearchClick: (RelayListType) -> Unit = {}, + onBackClick: () -> Unit = {}, + onFilterClick: () -> Unit = {}, + onCreateCustomList: (location: RelayItem.Location?) -> Unit = {}, + onEditCustomLists: () -> Unit = {}, + removeOwnershipFilter: () -> Unit = {}, + removeProviderFilter: () -> Unit = {}, + onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit = + { _, _ -> + }, + onRemoveLocationFromList: (location: RelayItem.Location, customListId: CustomListId) -> Unit = + { _, _ -> + }, + onEditCustomListName: (RelayItem.CustomList) -> Unit = {}, + onEditLocationsCustomList: (RelayItem.CustomList) -> Unit = {}, + onDeleteCustomList: (RelayItem.CustomList) -> Unit = {}, + onSelectRelayList: (RelayListType) -> Unit = {}, +) { + val backgroundColor = MaterialTheme.colorScheme.surface + + ScaffoldWithSmallTopBar( + appBarTitle = stringResource(id = R.string.select_location), + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.Default.Close, + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = stringResource(id = R.string.back), + ) + } + }, + snackbarHostState = snackbarHostState, + actions = { + IconButton(onClick = { onSearchClick(state.relayListType) }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(id = R.string.filter), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + IconButton(onClick = onFilterClick) { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = stringResource(id = R.string.filter), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + ) { modifier -> + var locationBottomSheetState by remember { mutableStateOf<LocationBottomSheetState?>(null) } + LocationBottomSheets( + locationBottomSheetState = locationBottomSheetState, + onCreateCustomList = onCreateCustomList, + onEditCustomLists = onEditCustomLists, + onAddLocationToList = onAddLocationToList, + onRemoveLocationFromList = onRemoveLocationFromList, + onEditCustomListName = onEditCustomListName, + onEditLocationsCustomList = onEditLocationsCustomList, + onDeleteCustomList = onDeleteCustomList, + onHideBottomSheet = { locationBottomSheetState = null }, + ) + + Column(modifier = modifier.background(backgroundColor).fillMaxSize()) { + AnimatedContent(targetState = state.filterChips, label = "Select location top bar") { + filterChips -> + if (filterChips.isNotEmpty()) { + FilterRow( + filters = filterChips, + onRemoveOwnershipFilter = removeOwnershipFilter, + onRemoveProviderFilter = removeProviderFilter, + ) + } + } + + if (state.multihopEnabled) { + MultihopBar(state.relayListType, onSelectRelayList) + } + + if (state.filterChips.isNotEmpty() || state.multihopEnabled) { + Spacer(modifier = Modifier.height(height = Dimens.verticalSpace)) + } + + RelayLists( + state = state, + backgroundColor = backgroundColor, + onSelectRelay = onSelectRelay, + onUpdateBottomSheetState = { newState -> locationBottomSheetState = newState }, + ) + } + } +} + +@Composable +private fun MultihopBar(relayListType: RelayListType, onSelectRelayList: (RelayListType) -> Unit) { + SingleChoiceSegmentedButtonRow( + modifier = + Modifier.fillMaxWidth().padding(start = Dimens.sideMargin, end = Dimens.sideMargin) + ) { + MullvadSegmentedStartButton( + selected = relayListType == RelayListType.ENTRY, + onClick = { onSelectRelayList(RelayListType.ENTRY) }, + text = stringResource(id = R.string.entry), + ) + MullvadSegmentedEndButton( + selected = relayListType == RelayListType.EXIT, + onClick = { onSelectRelayList(RelayListType.EXIT) }, + text = stringResource(id = R.string.exit), + ) + } +} + +@Composable +private fun RelayLists( + state: SelectLocationUiState, + backgroundColor: Color, + onSelectRelay: (RelayItem) -> Unit, + onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, +) { + // For multihop we want to start on the entry list. + // If multihop is not enabled we want to start on the exit list. + // The exit endpoint is what is selected when multihop is disabled. + val pagerState = + rememberPagerState( + initialPage = + if (state.multihopEnabled) { + RelayListType.ENTRY.ordinal + } else { + RelayListType.EXIT.ordinal + }, + pageCount = { RelayListType.entries.size }, + ) + LaunchedEffect(state.relayListType) { + val index = state.relayListType.ordinal + pagerState.animateScrollToPage(index) + } + + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + beyondViewportPageCount = + if (state.multihopEnabled) { + 1 + } else { + 0 + }, + ) { pageIndex -> + SelectLocationList( + backgroundColor = backgroundColor, + relayListType = RelayListType.entries[pageIndex], + onSelectRelay = onSelectRelay, + onUpdateBottomSheetState = onUpdateBottomSheetState, + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterChip.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterChip.kt new file mode 100644 index 0000000000..8439680500 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterChip.kt @@ -0,0 +1 @@ +package net.mullvad.mullvadvpn.compose.state diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListItem.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListItem.kt new file mode 100644 index 0000000000..34fd369526 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListItem.kt @@ -0,0 +1,89 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.RelayItem + +enum class RelayListItemContentType { + CUSTOM_LIST_HEADER, + CUSTOM_LIST_ITEM, + CUSTOM_LIST_ENTRY_ITEM, + CUSTOM_LIST_FOOTER, + LOCATION_HEADER, + LOCATION_ITEM, + LOCATIONS_EMPTY_TEXT, +} + +enum class RelayListItemState { + USED_AS_ENTRY, + USED_AS_EXIT, +} + +sealed interface RelayListItem { + val key: Any + val contentType: RelayListItemContentType + + data object CustomListHeader : RelayListItem { + override val key = "custom_list_header" + override val contentType = RelayListItemContentType.CUSTOM_LIST_HEADER + } + + sealed interface SelectableItem : RelayListItem { + val depth: Int + val isSelected: Boolean + val expanded: Boolean + val state: RelayListItemState? + } + + data class CustomListItem( + val item: RelayItem.CustomList, + override val isSelected: Boolean = false, + override val expanded: Boolean = false, + override val state: RelayListItemState? = null, + ) : SelectableItem { + override val key = item.id + override val depth: Int = 0 + override val contentType = RelayListItemContentType.CUSTOM_LIST_ITEM + } + + data class CustomListEntryItem( + val parentId: CustomListId, + val parentName: CustomListName, + val item: RelayItem.Location, + override val expanded: Boolean, + override val depth: Int = 0, + override val state: RelayListItemState? = null, + ) : SelectableItem { + override val key = parentId to item.id + + // Can't be displayed as selected + override val isSelected: Boolean = false + override val contentType = RelayListItemContentType.CUSTOM_LIST_ENTRY_ITEM + } + + data class CustomListFooter(val hasCustomList: Boolean) : RelayListItem { + override val key = "custom_list_footer" + override val contentType = RelayListItemContentType.CUSTOM_LIST_FOOTER + } + + data object LocationHeader : RelayListItem { + override val key = "location_header" + override val contentType = RelayListItemContentType.LOCATION_HEADER + } + + data class GeoLocationItem( + val item: RelayItem.Location, + override val isSelected: Boolean = false, + override val depth: Int = 0, + override val expanded: Boolean = false, + override val state: RelayListItemState? = null, + ) : SelectableItem { + override val key = item.id + override val contentType = RelayListItemContentType.LOCATION_ITEM + } + + data class LocationsEmptyText(val searchTerm: String) : RelayListItem { + override val key = "locations_empty_text" + override val contentType = RelayListItemContentType.LOCATIONS_EMPTY_TEXT + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListType.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListType.kt new file mode 100644 index 0000000000..6640ceea4a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListType.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.compose.state + +enum class RelayListType { + ENTRY, + EXIT, +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt new file mode 100644 index 0000000000..fd35213dac --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.usecase.FilterChip + +sealed interface SearchLocationUiState { + val searchTerm: String + val filterChips: List<FilterChip> + + data class NoQuery( + override val searchTerm: String, + override val filterChips: List<FilterChip>, + ) : SearchLocationUiState + + data class Content( + override val searchTerm: String, + override val filterChips: List<FilterChip>, + val relayListItems: List<RelayListItem>, + val customLists: List<RelayItem.CustomList>, + ) : SearchLocationUiState +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt new file mode 100644 index 0000000000..bb320de81d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt @@ -0,0 +1,13 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.lib.model.RelayItem + +sealed interface SelectLocationListUiState { + + data object Loading : SelectLocationListUiState + + data class Content( + val relayListItems: List<RelayListItem>, + val customLists: List<RelayItem.CustomList>, + ) : SelectLocationListUiState +} 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 d8245792a3..bb61bd4e7d 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,102 +1,9 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.lib.model.CustomListId -import net.mullvad.mullvadvpn.lib.model.CustomListName -import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.usecase.FilterChip -typealias ModelOwnership = net.mullvad.mullvadvpn.lib.model.Ownership - -sealed interface SelectLocationUiState { - - data object Loading : SelectLocationUiState - - data class Content( - val searchTerm: String, - val filterChips: List<FilterChip>, - val relayListItems: List<RelayListItem>, - val customLists: List<RelayItem.CustomList>, - ) : SelectLocationUiState -} - -sealed interface FilterChip { - data class Ownership(val ownership: ModelOwnership) : FilterChip - - data class Provider(val count: Int) : FilterChip - - data object Daita : FilterChip -} - -enum class RelayListItemContentType { - CUSTOM_LIST_HEADER, - CUSTOM_LIST_ITEM, - CUSTOM_LIST_ENTRY_ITEM, - CUSTOM_LIST_FOOTER, - LOCATION_HEADER, - LOCATION_ITEM, - LOCATIONS_EMPTY_TEXT, -} - -sealed interface RelayListItem { - val key: Any - val contentType: RelayListItemContentType - - data object CustomListHeader : RelayListItem { - override val key = "custom_list_header" - override val contentType = RelayListItemContentType.CUSTOM_LIST_HEADER - } - - sealed interface SelectableItem : RelayListItem { - val depth: Int - val isSelected: Boolean - val expanded: Boolean - } - - data class CustomListItem( - val item: RelayItem.CustomList, - override val isSelected: Boolean = false, - override val expanded: Boolean = false, - ) : SelectableItem { - override val key = item.id - override val depth: Int = 0 - override val contentType = RelayListItemContentType.CUSTOM_LIST_ITEM - } - - data class CustomListEntryItem( - val parentId: CustomListId, - val parentName: CustomListName, - val item: RelayItem.Location, - override val expanded: Boolean, - override val depth: Int = 0, - ) : SelectableItem { - override val key = parentId to item.id - - // Can't be displayed as selected - override val isSelected: Boolean = false - override val contentType = RelayListItemContentType.CUSTOM_LIST_ENTRY_ITEM - } - - data class CustomListFooter(val hasCustomList: Boolean) : RelayListItem { - override val key = "custom_list_footer" - override val contentType = RelayListItemContentType.CUSTOM_LIST_FOOTER - } - - data object LocationHeader : RelayListItem { - override val key: Any = "location_header" - override val contentType = RelayListItemContentType.LOCATION_HEADER - } - - data class GeoLocationItem( - val item: RelayItem.Location, - override val isSelected: Boolean = false, - override val depth: Int = 0, - override val expanded: Boolean = false, - ) : SelectableItem { - override val key = item.id - override val contentType = RelayListItemContentType.LOCATION_ITEM - } - - data class LocationsEmptyText(val searchTerm: String) : RelayListItem { - override val key: Any = "locations_empty_text" - override val contentType = RelayListItemContentType.LOCATIONS_EMPTY_TEXT - } -} +data class SelectLocationUiState( + val filterChips: List<FilterChip>, + val multihopEnabled: Boolean, + val relayListType: RelayListType, +) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SettingsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SettingsUiState.kt index d804dd6678..4ebbf9ad23 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SettingsUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SettingsUiState.kt @@ -5,4 +5,5 @@ data class SettingsUiState( val isLoggedIn: Boolean, val isSupportedVersion: Boolean, val isPlayBuild: Boolean, + val multihopEnabled: Boolean, ) 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 2605075ef8..1d62de5bb2 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 @@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.applist.ApplicationsProvider +import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport import net.mullvad.mullvadvpn.lib.payment.PaymentProvider @@ -34,6 +35,7 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.usecase.AccountExpiryInAppNotificationUseCase import net.mullvad.mullvadvpn.usecase.AvailableProvidersUseCase import net.mullvad.mullvadvpn.usecase.EmptyPaymentUseCase +import net.mullvad.mullvadvpn.usecase.FilterChipUseCase import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase import net.mullvad.mullvadvpn.usecase.InternetAvailableUseCase import net.mullvad.mullvadvpn.usecase.LastKnownLocationUseCase @@ -42,6 +44,7 @@ import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.usecase.PlayPaymentUseCase import net.mullvad.mullvadvpn.usecase.SelectedLocationTitleUseCase +import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsAvailableUseCase import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase @@ -71,6 +74,7 @@ import net.mullvad.mullvadvpn.viewmodel.EditCustomListViewModel import net.mullvad.mullvadvpn.viewmodel.FilterViewModel import net.mullvad.mullvadvpn.viewmodel.LoginViewModel import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel +import net.mullvad.mullvadvpn.viewmodel.MultihopViewModel import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel import net.mullvad.mullvadvpn.viewmodel.PaymentViewModel @@ -78,7 +82,6 @@ import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel import net.mullvad.mullvadvpn.viewmodel.ResetServerIpOverridesConfirmationViewModel import net.mullvad.mullvadvpn.viewmodel.SaveApiAccessMethodViewModel -import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesViewModel import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel import net.mullvad.mullvadvpn.viewmodel.ShadowsocksCustomPortDialogViewModel @@ -92,6 +95,9 @@ import net.mullvad.mullvadvpn.viewmodel.VpnPermissionViewModel import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel import net.mullvad.mullvadvpn.viewmodel.WireguardCustomPortDialogViewModel +import net.mullvad.mullvadvpn.viewmodel.location.SearchLocationViewModel +import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationListViewModel +import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationViewModel import org.apache.commons.validator.routines.InetAddressValidator import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext @@ -154,11 +160,13 @@ val uiModule = module { single { CustomListActionUseCase(get(), get()) } single { SelectedLocationTitleUseCase(get(), get()) } single { AvailableProvidersUseCase(get()) } - single { FilterCustomListsRelayItemUseCase(get(), get(), get()) } + single { FilterCustomListsRelayItemUseCase(get(), get(), get(), get()) } single { CustomListsRelayItemUseCase(get(), get()) } single { CustomListRelayItemsUseCase(get(), get()) } - single { FilteredRelayListUseCase(get(), get(), get()) } + single { FilteredRelayListUseCase(get(), get(), get(), get()) } single { LastKnownLocationUseCase(get()) } + single { SelectedLocationUseCase(get(), get()) } + single { FilterChipUseCase(get(), get(), get(), get()) } single { InAppNotificationController(get(), get(), get(), get(), MainScope()) } @@ -210,10 +218,8 @@ val uiModule = module { viewModel { WireguardCustomPortDialogViewModel(get()) } viewModel { LoginViewModel(get(), get(), get()) } viewModel { PrivacyDisclaimerViewModel(get(), IS_PLAY_BUILD) } - viewModel { - SelectLocationViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get()) - } - viewModel { SettingsViewModel(get(), get(), IS_PLAY_BUILD) } + viewModel { SelectLocationViewModel(get(), get(), get(), get(), get(), get()) } + viewModel { SettingsViewModel(get(), get(), get(), IS_PLAY_BUILD) } viewModel { SplashViewModel(get(), get(), get(), get()) } viewModel { VoucherDialogViewModel(get()) } viewModel { VpnSettingsViewModel(get(), get(), get(), get(), get()) } @@ -240,6 +246,25 @@ val uiModule = module { viewModel { Udp2TcpSettingsViewModel(get()) } viewModel { ShadowsocksSettingsViewModel(get(), get()) } viewModel { ShadowsocksCustomPortDialogViewModel(get()) } + viewModel { MultihopViewModel(get()) } + viewModel { + SearchLocationViewModel( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + ) + } + viewModel { (relayListType: RelayListType) -> + SelectLocationListViewModel(relayListType, get(), get(), get(), get(), 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/RelayItemExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt index 5d6e48a3f7..f21adee735 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 @@ -56,14 +56,14 @@ private fun RelayItem.Location.hasProvider(providersConstraint: Constraint<Provi fun RelayItem.CustomList.filter( ownership: Constraint<Ownership>, providers: Constraint<Providers>, - isDaitaEnabled: Boolean, + daita: Boolean, ): RelayItem.CustomList { val newLocations = locations.mapNotNull { when (it) { - is RelayItem.Location.Country -> it.filter(ownership, providers, isDaitaEnabled) - is RelayItem.Location.City -> it.filter(ownership, providers, isDaitaEnabled) - is RelayItem.Location.Relay -> it.filter(ownership, providers, isDaitaEnabled) + is RelayItem.Location.Country -> it.filter(ownership, providers, daita) + is RelayItem.Location.City -> it.filter(ownership, providers, daita) + is RelayItem.Location.Relay -> it.filter(ownership, providers, daita) } } return copy(locations = newLocations) @@ -72,9 +72,9 @@ fun RelayItem.CustomList.filter( fun RelayItem.Location.Country.filter( ownership: Constraint<Ownership>, providers: Constraint<Providers>, - isDaitaEnabled: Boolean, + daita: Boolean, ): RelayItem.Location.Country? { - val cities = cities.mapNotNull { it.filter(ownership, providers, isDaitaEnabled) } + val cities = cities.mapNotNull { it.filter(ownership, providers, daita) } return if (cities.isNotEmpty()) { this.copy(cities = cities) } else { @@ -85,9 +85,9 @@ fun RelayItem.Location.Country.filter( private fun RelayItem.Location.City.filter( ownership: Constraint<Ownership>, providers: Constraint<Providers>, - isDaitaEnabled: Boolean, + daita: Boolean, ): RelayItem.Location.City? { - val relays = relays.mapNotNull { it.filter(ownership, providers, isDaitaEnabled) } + val relays = relays.mapNotNull { it.filter(ownership, providers, daita) } return if (relays.isNotEmpty()) { this.copy(relays = relays) } else { @@ -102,10 +102,10 @@ private fun RelayItem.Location.Relay.hasMatchingDaitaSetting(isDaitaEnabled: Boo private fun RelayItem.Location.Relay.filter( ownership: Constraint<Ownership>, providers: Constraint<Providers>, - isDaitaEnabled: Boolean, + daita: Boolean, ): RelayItem.Location.Relay? { return if ( - hasMatchingDaitaSetting(isDaitaEnabled) && hasOwnership(ownership) && hasProvider(providers) + hasMatchingDaitaSetting(daita) && hasOwnership(ownership) && hasProvider(providers) ) { this } else { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/WireguardConstraintsRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/WireguardConstraintsRepository.kt index 816b172ea5..093b87cafc 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/WireguardConstraintsRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/WireguardConstraintsRepository.kt @@ -1,11 +1,29 @@ package net.mullvad.mullvadvpn.repository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.RelayItemId + +class WireguardConstraintsRepository( + private val managementService: ManagementService, + dispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + val wireguardConstraints = + managementService.settings + .mapNotNull { it.relaySettings.relayConstraints.wireguardConstraints } + .stateIn(CoroutineScope(dispatcher), SharingStarted.Eagerly, null) -class WireguardConstraintsRepository(private val managementService: ManagementService) { suspend fun setWireguardPort(port: Constraint<Port>) = managementService.setWireguardPort(port) suspend fun setMultihop(enabled: Boolean) = managementService.setMultihop(enabled) + + suspend fun setEntryLocation(relayItemId: RelayItemId) = + managementService.setEntryLocation(relayItemId) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCase.kt new file mode 100644 index 0000000000..366a7321f6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCase.kt @@ -0,0 +1,103 @@ +package net.mullvad.mullvadvpn.usecase + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.toSelectedProviders +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Provider +import net.mullvad.mullvadvpn.lib.model.Providers +import net.mullvad.mullvadvpn.repository.RelayListFilterRepository +import net.mullvad.mullvadvpn.repository.SettingsRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.util.shouldFilterByDaita + +typealias ModelOwnership = Ownership + +class FilterChipUseCase( + private val relayListFilterRepository: RelayListFilterRepository, + private val availableProvidersUseCase: AvailableProvidersUseCase, + private val settingsRepository: SettingsRepository, + private val wireguardConstraintsRepository: WireguardConstraintsRepository, +) { + operator fun invoke(relayListType: RelayListType): Flow<List<FilterChip>> = + combine( + relayListFilterRepository.selectedOwnership, + relayListFilterRepository.selectedProviders, + availableProvidersUseCase(), + settingsRepository.settingsUpdates, + wireguardConstraintsRepository.wireguardConstraints, + ) { + selectedOwnership, + selectedConstraintProviders, + allProviders, + settings, + wireguardConstraints -> + filterChips( + selectedOwnership = selectedOwnership, + selectedConstraintProviders = selectedConstraintProviders, + allProviders = allProviders, + isDaitaEnabled = settings?.isDaitaEnabled() == true, + isMultihopEnabled = wireguardConstraints?.isMultihopEnabled == true, + relayListType = relayListType, + ) + } + + private fun filterChips( + selectedOwnership: Constraint<Ownership>, + selectedConstraintProviders: Constraint<Providers>, + allProviders: List<Provider>, + isDaitaEnabled: Boolean, + isMultihopEnabled: Boolean, + relayListType: RelayListType, + ): List<FilterChip> { + val ownershipFilter = selectedOwnership.getOrNull() + val providerCountFilter = + when (selectedConstraintProviders) { + is Constraint.Any -> null + is Constraint.Only -> + filterSelectedProvidersByOwnership( + selectedConstraintProviders.toSelectedProviders(allProviders), + ownershipFilter, + ) + .size + } + return buildList { + if (ownershipFilter != null) { + add(FilterChip.Ownership(ownershipFilter)) + } + if (providerCountFilter != null) { + add(FilterChip.Provider(providerCountFilter)) + } + if ( + shouldFilterByDaita( + isDaitaEnabled = isDaitaEnabled, + relayListType = relayListType, + isMultihopEnabled = isMultihopEnabled, + ) + ) { + add(FilterChip.Daita) + } + } + } + + private fun filterSelectedProvidersByOwnership( + selectedProviders: List<Provider>, + selectedOwnership: Ownership?, + ): List<Provider> = + if (selectedOwnership == null) selectedProviders + else selectedProviders.filter { it.ownership == selectedOwnership } +} + +sealed interface FilterChip { + data class Ownership(val ownership: ModelOwnership) : FilterChip + + data class Provider(val count: Int) : FilterChip + + data object Daita : FilterChip + + data object Entry : FilterChip + + data object Exit : FilterChip +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilteredRelayListUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilteredRelayListUseCase.kt index 60de94946f..6712d9275f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilteredRelayListUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilteredRelayListUseCase.kt @@ -1,6 +1,7 @@ package net.mullvad.mullvadvpn.usecase import kotlinx.coroutines.flow.combine +import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.Ownership import net.mullvad.mullvadvpn.lib.model.Providers @@ -9,29 +10,38 @@ import net.mullvad.mullvadvpn.relaylist.filter import net.mullvad.mullvadvpn.repository.RelayListFilterRepository import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.repository.SettingsRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.util.shouldFilterByDaita class FilteredRelayListUseCase( private val relayListRepository: RelayListRepository, private val relayListFilterRepository: RelayListFilterRepository, private val settingsRepository: SettingsRepository, + private val wireguardConstraintsRepository: WireguardConstraintsRepository, ) { - operator fun invoke() = + operator fun invoke(relayListType: RelayListType) = combine( relayListRepository.relayList, relayListFilterRepository.selectedOwnership, relayListFilterRepository.selectedProviders, settingsRepository.settingsUpdates, - ) { relayList, selectedOwnership, selectedProviders, settings -> + wireguardConstraintsRepository.wireguardConstraints, + ) { relayList, selectedOwnership, selectedProviders, settings, wireguardConstraints -> relayList.filter( - selectedOwnership, - selectedProviders, - isDaitaEnabled = settings?.isDaitaEnabled() ?: false, + ownership = selectedOwnership, + providers = selectedProviders, + shouldFilterByDaita = + shouldFilterByDaita( + isDaitaEnabled = settings?.isDaitaEnabled() == true, + isMultihopEnabled = wireguardConstraints?.isMultihopEnabled == true, + relayListType = relayListType, + ), ) } private fun List<RelayItem.Location.Country>.filter( ownership: Constraint<Ownership>, providers: Constraint<Providers>, - isDaitaEnabled: Boolean, - ) = mapNotNull { it.filter(ownership, providers, isDaitaEnabled) } + shouldFilterByDaita: Boolean, + ) = mapNotNull { it.filter(ownership, providers, shouldFilterByDaita) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationUseCase.kt new file mode 100644 index 0000000000..b103e45c63 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationUseCase.kt @@ -0,0 +1,27 @@ +package net.mullvad.mullvadvpn.usecase + +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import net.mullvad.mullvadvpn.lib.model.RelayItemSelection +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository + +class SelectedLocationUseCase( + private val relayListRepository: RelayListRepository, + private val wireguardConstraintsRepository: WireguardConstraintsRepository, +) { + operator fun invoke() = + combine( + relayListRepository.selectedLocation.filterNotNull(), + wireguardConstraintsRepository.wireguardConstraints.filterNotNull(), + ) { selectedLocation, wireguardConstraints -> + if (wireguardConstraints.isMultihopEnabled) { + RelayItemSelection.Multiple( + entryLocation = wireguardConstraints.entryLocation, + exitLocation = selectedLocation, + ) + } else { + RelayItemSelection.Single(selectedLocation) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt index 17ead75d2a..c326b176a5 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt @@ -2,6 +2,7 @@ package net.mullvad.mullvadvpn.usecase.customlists import kotlin.collections.mapNotNull import kotlinx.coroutines.flow.combine +import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.Ownership import net.mullvad.mullvadvpn.lib.model.Providers @@ -9,30 +10,39 @@ import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.relaylist.filter import net.mullvad.mullvadvpn.repository.RelayListFilterRepository import net.mullvad.mullvadvpn.repository.SettingsRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.util.shouldFilterByDaita class FilterCustomListsRelayItemUseCase( private val customListsRelayItemUseCase: CustomListsRelayItemUseCase, private val relayListFilterRepository: RelayListFilterRepository, private val settingsRepository: SettingsRepository, + private val wireguardConstraintsRepository: WireguardConstraintsRepository, ) { - operator fun invoke() = + operator fun invoke(relayListType: RelayListType) = combine( customListsRelayItemUseCase(), relayListFilterRepository.selectedOwnership, relayListFilterRepository.selectedProviders, settingsRepository.settingsUpdates, - ) { customLists, selectedOwnership, selectedProviders, settings -> - customLists.filterOnOwnershipAndProvider( - selectedOwnership, - selectedProviders, - isDaitaEnabled = settings?.isDaitaEnabled() ?: false, + wireguardConstraintsRepository.wireguardConstraints, + ) { customLists, selectedOwnership, selectedProviders, settings, wireguardConstraints -> + customLists.filter( + ownership = selectedOwnership, + providers = selectedProviders, + daita = + shouldFilterByDaita( + isDaitaEnabled = settings?.isDaitaEnabled() == true, + isMultihopEnabled = wireguardConstraints?.isMultihopEnabled == true, + relayListType = relayListType, + ), ) } - private fun List<RelayItem.CustomList>.filterOnOwnershipAndProvider( + private fun List<RelayItem.CustomList>.filter( ownership: Constraint<Ownership>, providers: Constraint<Providers>, - isDaitaEnabled: Boolean, - ) = mapNotNull { it.filter(ownership, providers, isDaitaEnabled = isDaitaEnabled) } + daita: Boolean, + ) = mapNotNull { it.filter(ownership, providers, daita = daita) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Daita.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Daita.kt new file mode 100644 index 0000000000..717d007f92 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Daita.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.util + +import net.mullvad.mullvadvpn.compose.state.RelayListType + +fun shouldFilterByDaita( + isDaitaEnabled: Boolean, + isMultihopEnabled: Boolean, + relayListType: RelayListType, +) = + isDaitaEnabled && + (relayListType == RelayListType.ENTRY || + !isMultihopEnabled && relayListType == RelayListType.EXIT) 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 200502dee4..0c88598923 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 @@ -29,6 +29,31 @@ inline fun <T1, T2, T3, T4, T5, T6, R> combine( } } +inline fun <T1, T2, T3, T4, T5, T6, T7, R> combine( + flow: Flow<T1>, + flow2: Flow<T2>, + flow3: Flow<T3>, + flow4: Flow<T4>, + flow5: Flow<T5>, + flow6: Flow<T6>, + flow7: Flow<T7>, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R, +): Flow<R> { + return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) { + args: Array<*> -> + @Suppress("UNCHECKED_CAST") + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + ) + } +} + @OptIn(ExperimentalCoroutinesApi::class) fun <T> Deferred<T>.getOrDefault(default: T) = try { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModel.kt new file mode 100644 index 0000000000..4ff63b8fe7 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModel.kt @@ -0,0 +1,26 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository + +class MultihopViewModel( + private val wireguardConstraintsRepository: WireguardConstraintsRepository +) : ViewModel() { + + val uiState: StateFlow<MultihopUiState> = + wireguardConstraintsRepository.wireguardConstraints + .map { MultihopUiState(it?.isMultihopEnabled ?: false) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), MultihopUiState(false)) + + fun setMultihop(enable: Boolean) { + viewModelScope.launch { wireguardConstraintsRepository.setMultihop(enable) } + } +} + +data class MultihopUiState(val enable: Boolean) 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 deleted file mode 100644 index 4ddad8477b..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt +++ /dev/null @@ -1,436 +0,0 @@ -package net.mullvad.mullvadvpn.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import arrow.core.getOrElse -import arrow.core.raise.either -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.receiveAsFlow -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.CustomListActionResultData -import net.mullvad.mullvadvpn.compose.state.FilterChip -import net.mullvad.mullvadvpn.compose.state.RelayListItem -import net.mullvad.mullvadvpn.compose.state.RelayListItem.CustomListHeader -import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState -import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState.Content -import net.mullvad.mullvadvpn.compose.state.toSelectedProviders -import net.mullvad.mullvadvpn.lib.model.Constraint -import net.mullvad.mullvadvpn.lib.model.CustomListId -import net.mullvad.mullvadvpn.lib.model.GeoLocationId -import net.mullvad.mullvadvpn.lib.model.Ownership -import net.mullvad.mullvadvpn.lib.model.Provider -import net.mullvad.mullvadvpn.lib.model.RelayItem -import net.mullvad.mullvadvpn.lib.model.RelayItemId -import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH -import net.mullvad.mullvadvpn.relaylist.descendants -import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm -import net.mullvad.mullvadvpn.relaylist.newFilterOnSearch -import net.mullvad.mullvadvpn.repository.CustomListsRepository -import net.mullvad.mullvadvpn.repository.RelayListFilterRepository -import net.mullvad.mullvadvpn.repository.RelayListRepository -import net.mullvad.mullvadvpn.repository.SettingsRepository -import net.mullvad.mullvadvpn.usecase.AvailableProvidersUseCase -import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase -import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase -import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase -import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase - -@Suppress("TooManyFunctions") -class SelectLocationViewModel( - private val relayListFilterRepository: RelayListFilterRepository, - private val availableProvidersUseCase: AvailableProvidersUseCase, - customListsRelayItemUseCase: CustomListsRelayItemUseCase, - private val filteredCustomListRelayItemsUseCase: FilterCustomListsRelayItemUseCase, - private val customListsRepository: CustomListsRepository, - private val customListActionUseCase: CustomListActionUseCase, - private val filteredRelayListUseCase: FilteredRelayListUseCase, - private val relayListRepository: RelayListRepository, - private val settingsRepository: SettingsRepository, -) : ViewModel() { - private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) - - private val _expandedItems = MutableStateFlow(initialExpand()) - - @Suppress("DestructuringDeclarationWithTooManyEntries") - val uiState = - combine(_searchTerm, relayListItems(), filterChips(), customListsRelayItemUseCase()) { - searchTerm, - relayListItems, - filterChips, - customLists -> - Content( - searchTerm = searchTerm, - filterChips = filterChips, - relayListItems = relayListItems, - customLists = customLists, - ) - } - .stateIn(viewModelScope, SharingStarted.Lazily, SelectLocationUiState.Loading) - - private val _uiSideEffect = Channel<SelectLocationSideEffect>() - val uiSideEffect = _uiSideEffect.receiveAsFlow() - - private fun initialExpand(): Set<String> = buildSet { - when (val item = relayListRepository.selectedLocation.value.getOrNull()) { - is GeoLocationId.City -> { - add(item.country.code) - } - is GeoLocationId.Hostname -> { - add(item.country.code) - add(item.city.code) - } - is CustomListId, - is GeoLocationId.Country, - null -> { - /* No expands */ - } - } - } - - private fun searchRelayListLocations() = - combine(_searchTerm, filteredRelayListUseCase()) { searchTerm, relayCountries -> - val isSearching = searchTerm.length >= MIN_SEARCH_LENGTH - if (isSearching) { - val (exp, filteredRelayCountries) = relayCountries.newFilterOnSearch(searchTerm) - exp.map { it.expandKey() }.toSet() to filteredRelayCountries - } else { - initialExpand() to relayCountries - } - } - .onEach { _expandedItems.value = it.first } - .map { it.second } - - private fun filterChips() = - combine( - relayListFilterRepository.selectedOwnership, - relayListFilterRepository.selectedProviders, - availableProvidersUseCase(), - settingsRepository.settingsUpdates, - ) { selectedOwnership, selectedConstraintProviders, allProviders, settings -> - val ownershipFilter = selectedOwnership.getOrNull() - val providerCountFilter = - when (selectedConstraintProviders) { - is Constraint.Any -> null - is Constraint.Only -> - filterSelectedProvidersByOwnership( - selectedConstraintProviders.toSelectedProviders(allProviders), - ownershipFilter, - ) - .size - } - buildList { - if (ownershipFilter != null) { - add(FilterChip.Ownership(ownershipFilter)) - } - if (providerCountFilter != null) { - add(FilterChip.Provider(providerCountFilter)) - } - if (settings?.isDaitaEnabled() == true) { - add(FilterChip.Daita) - } - } - } - - private fun relayListItems() = - combine( - _searchTerm, - searchRelayListLocations(), - filteredCustomListRelayItemsUseCase(), - relayListRepository.selectedLocation, - _expandedItems, - ) { searchTerm, relayCountries, customLists, selectedItem, expandedItems -> - val filteredCustomLists = customLists.filterOnSearchTerm(searchTerm) - - buildList { - val relayItems = - createRelayListItems( - searchTerm.length >= MIN_SEARCH_LENGTH, - selectedItem.getOrNull(), - filteredCustomLists, - relayCountries, - ) { - it in expandedItems - } - if (relayItems.isEmpty()) { - add(RelayListItem.LocationsEmptyText(searchTerm)) - } else { - addAll(relayItems) - } - } - } - - private fun createRelayListItems( - isSearching: Boolean, - selectedItem: RelayItemId?, - customLists: List<RelayItem.CustomList>, - countries: List<RelayItem.Location.Country>, - isExpanded: (String) -> Boolean, - ): List<RelayListItem> = - createCustomListSection(isSearching, selectedItem, customLists, isExpanded) + - createLocationSection(isSearching, selectedItem, countries, isExpanded) - - private fun createCustomListSection( - isSearching: Boolean, - selectedItem: RelayItemId?, - customLists: List<RelayItem.CustomList>, - isExpanded: (String) -> Boolean, - ): List<RelayListItem> = buildList { - if (isSearching && customLists.isEmpty()) { - // If we are searching and no results are found don't show header or footer - } else { - add(CustomListHeader) - val customListItems = createCustomListRelayItems(customLists, selectedItem, isExpanded) - addAll(customListItems) - add(RelayListItem.CustomListFooter(customListItems.isNotEmpty())) - } - } - - private fun createCustomListRelayItems( - customLists: List<RelayItem.CustomList>, - selectedItem: RelayItemId?, - isExpanded: (String) -> Boolean, - ): List<RelayListItem> = - customLists.flatMap { customList -> - val expanded = isExpanded(customList.id.expandKey()) - buildList { - add( - RelayListItem.CustomListItem( - customList, - isSelected = selectedItem == customList.id, - expanded, - ) - ) - - if (expanded) { - addAll( - customList.locations.flatMap { - createCustomListEntry(parent = customList, item = it, 1, isExpanded) - } - ) - } - } - } - - private fun createLocationSection( - isSearching: Boolean, - selectedItem: RelayItemId?, - countries: List<RelayItem.Location.Country>, - isExpanded: (String) -> Boolean, - ): List<RelayListItem> = buildList { - if (isSearching && countries.isEmpty()) { - // If we are searching and no results are found don't show header or footer - } else { - add(RelayListItem.LocationHeader) - addAll( - countries.flatMap { country -> - createGeoLocationEntry(country, selectedItem, isExpanded = isExpanded) - } - ) - } - } - - private fun createCustomListEntry( - parent: RelayItem.CustomList, - item: RelayItem.Location, - depth: Int = 1, - isExpanded: (String) -> Boolean, - ): List<RelayListItem.CustomListEntryItem> = buildList { - val expanded = isExpanded(item.id.expandKey(parent.id)) - add( - RelayListItem.CustomListEntryItem( - parentId = parent.id, - parentName = parent.customList.name, - item = item, - expanded = expanded, - depth, - ) - ) - - if (expanded) { - when (item) { - is RelayItem.Location.City -> - addAll( - item.relays.flatMap { - createCustomListEntry(parent, it, depth + 1, isExpanded) - } - ) - is RelayItem.Location.Country -> - addAll( - item.cities.flatMap { - createCustomListEntry(parent, it, depth + 1, isExpanded) - } - ) - is RelayItem.Location.Relay -> {} // No children to add - } - } - } - - private fun createGeoLocationEntry( - item: RelayItem.Location, - selectedItem: RelayItemId?, - depth: Int = 0, - isExpanded: (String) -> Boolean, - ): List<RelayListItem.GeoLocationItem> = buildList { - val expanded = isExpanded(item.id.expandKey()) - - add( - RelayListItem.GeoLocationItem( - item = item, - isSelected = selectedItem == item.id, - depth = depth, - expanded = expanded, - ) - ) - - if (expanded) { - when (item) { - is RelayItem.Location.City -> - addAll( - item.relays.flatMap { - createGeoLocationEntry(it, selectedItem, depth + 1, isExpanded) - } - ) - is RelayItem.Location.Country -> - addAll( - item.cities.flatMap { - createGeoLocationEntry(it, selectedItem, depth + 1, isExpanded) - } - ) - is RelayItem.Location.Relay -> {} // Do nothing - } - } - } - - private fun RelayItemId.expandKey(parent: CustomListId? = null) = - (parent?.value ?: "") + - when (this) { - is CustomListId -> value - is GeoLocationId -> code - } - - fun selectRelay(relayItem: RelayItem) { - viewModelScope.launch { - val locationConstraint = relayItem.id - relayListRepository - .updateSelectedRelayLocation(locationConstraint) - .fold( - { _uiSideEffect.send(SelectLocationSideEffect.GenericError) }, - { _uiSideEffect.send(SelectLocationSideEffect.CloseScreen) }, - ) - } - } - - fun onToggleExpand(item: RelayItemId, parent: CustomListId? = null, expand: Boolean) { - _expandedItems.update { - val key = item.expandKey(parent) - if (expand) { - it + key - } else { - it - key - } - } - } - - fun onSearchTermInput(searchTerm: String) { - viewModelScope.launch { _searchTerm.emit(searchTerm) } - } - - private fun filterSelectedProvidersByOwnership( - selectedProviders: List<Provider>, - selectedOwnership: Ownership?, - ): List<Provider> = - if (selectedOwnership == null) selectedProviders - else selectedProviders.filter { it.ownership == selectedOwnership } - - fun removeOwnerFilter() { - viewModelScope.launch { relayListFilterRepository.updateSelectedOwnership(Constraint.Any) } - } - - fun removeProviderFilter() { - viewModelScope.launch { relayListFilterRepository.updateSelectedProviders(Constraint.Any) } - } - - fun addLocationToList(item: RelayItem.Location, customList: RelayItem.CustomList) { - viewModelScope.launch { - val newLocations = - (customList.locations + item).filter { it !in item.descendants() }.map { it.id } - val result = - customListActionUseCase( - CustomListAction.UpdateLocations(customList.id, newLocations) - ) - .fold( - { CustomListActionResultData.GenericError }, - { - if (it.removedLocations.isEmpty()) { - CustomListActionResultData.Success.LocationAdded( - customListName = it.name, - locationName = item.name, - undo = it.undo, - ) - } else { - CustomListActionResultData.Success.LocationChanged( - customListName = it.name, - undo = it.undo, - ) - } - }, - ) - _uiSideEffect.send(SelectLocationSideEffect.CustomListActionToast(result)) - } - } - - fun performAction(action: CustomListAction) { - viewModelScope.launch { customListActionUseCase(action) } - } - - fun removeLocationFromList(item: RelayItem.Location, customListId: CustomListId) { - viewModelScope.launch { - val result = - either { - val customList = - customListsRepository.getCustomListById(customListId).bind() - val newLocations = (customList.locations - item.id) - val success = - customListActionUseCase( - CustomListAction.UpdateLocations(customList.id, newLocations) - ) - .bind() - if (success.addedLocations.isEmpty()) { - CustomListActionResultData.Success.LocationRemoved( - customListName = success.name, - locationName = item.name, - undo = success.undo, - ) - } else { - CustomListActionResultData.Success.LocationChanged( - customListName = success.name, - undo = success.undo, - ) - } - } - .getOrElse { CustomListActionResultData.GenericError } - _uiSideEffect.send(SelectLocationSideEffect.CustomListActionToast(result)) - } - } - - companion object { - private const val EMPTY_SEARCH_TERM = "" - } -} - -sealed interface SelectLocationSideEffect { - data object CloseScreen : SelectLocationSideEffect - - data class CustomListActionToast(val resultData: CustomListActionResultData) : - SelectLocationSideEffect - - data object GenericError : SelectLocationSideEffect -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt index fc6b4af3ee..22309fecfd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt @@ -9,23 +9,28 @@ import kotlinx.coroutines.flow.stateIn import net.mullvad.mullvadvpn.compose.state.SettingsUiState import net.mullvad.mullvadvpn.lib.model.DeviceState import net.mullvad.mullvadvpn.lib.shared.DeviceRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository class SettingsViewModel( deviceRepository: DeviceRepository, appVersionInfoRepository: AppVersionInfoRepository, + wireguardConstraintsRepository: WireguardConstraintsRepository, isPlayBuild: Boolean, ) : ViewModel() { val uiState: StateFlow<SettingsUiState> = - combine(deviceRepository.deviceState, appVersionInfoRepository.versionInfo) { - deviceState, - versionInfo -> + combine( + deviceRepository.deviceState, + appVersionInfoRepository.versionInfo, + wireguardConstraintsRepository.wireguardConstraints, + ) { deviceState, versionInfo, wireguardConstraints -> SettingsUiState( isLoggedIn = deviceState is DeviceState.LoggedIn, appVersion = versionInfo.currentVersion, isSupportedVersion = versionInfo.isSupported, isPlayBuild = isPlayBuild, + multihopEnabled = wireguardConstraints?.isMultihopEnabled ?: false, ) } .stateIn( @@ -36,6 +41,7 @@ class SettingsViewModel( isLoggedIn = false, isSupportedVersion = true, isPlayBuild = isPlayBuild, + multihopEnabled = false, ), ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/CustomListEdit.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/CustomListEdit.kt new file mode 100644 index 0000000000..26454fc028 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/CustomListEdit.kt @@ -0,0 +1,75 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.raise.either +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData +import net.mullvad.mullvadvpn.compose.communication.LocationsChanged +import net.mullvad.mullvadvpn.lib.model.CustomList +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.GetCustomListError +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.relaylist.descendants +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionError + +internal suspend fun addLocationToCustomList( + customList: RelayItem.CustomList, + item: RelayItem.Location, + update: + suspend (CustomListAction.UpdateLocations) -> Either< + CustomListActionError, + LocationsChanged, + >, +): CustomListActionResultData { + val newLocations = + (customList.locations + item).filter { it !in item.descendants() }.map { it.id } + return update(CustomListAction.UpdateLocations(customList.id, newLocations)) + .fold( + { CustomListActionResultData.GenericError }, + { + if (it.removedLocations.isEmpty()) { + CustomListActionResultData.Success.LocationAdded( + customListName = it.name, + locationName = item.name, + undo = it.undo, + ) + } else { + CustomListActionResultData.Success.LocationChanged( + customListName = it.name, + undo = it.undo, + ) + } + }, + ) +} + +internal suspend fun removeLocationFromCustomList( + item: RelayItem.Location, + customListId: CustomListId, + getCustomListById: suspend (CustomListId) -> Either<GetCustomListError, CustomList>, + update: + suspend (CustomListAction.UpdateLocations) -> Either< + CustomListActionError, + LocationsChanged, + >, +) = + either { + val customList = getCustomListById(customListId).bind() + val newLocations = (customList.locations - item.id) + val success = + update(CustomListAction.UpdateLocations(customList.id, newLocations)).bind() + if (success.addedLocations.isEmpty()) { + CustomListActionResultData.Success.LocationRemoved( + customListName = success.name, + locationName = item.name, + undo = success.undo, + ) + } else { + CustomListActionResultData.Success.LocationChanged( + customListName = success.name, + undo = success.undo, + ) + } + } + .getOrElse { CustomListActionResultData.GenericError } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/Expand.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/Expand.kt new file mode 100644 index 0000000000..b517619e6b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/Expand.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.RelayItemId + +internal fun MutableStateFlow<Set<String>>.onToggleExpand( + item: RelayItemId, + parent: CustomListId? = null, + expand: Boolean, +) { + update { + val key = item.expandKey(parent) + if (expand) { + it + key + } else { + it - key + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/RelayItemListCreator.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/RelayItemListCreator.kt new file mode 100644 index 0000000000..c4b9e44f4d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/RelayItemListCreator.kt @@ -0,0 +1,355 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.RelayListItemState +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.lib.model.RelayItemSelection +import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH +import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm + +// Creates a relay list to be displayed by RelayListContent +internal fun relayListItems( + searchTerm: String = "", + relayListType: RelayListType, + relayCountries: List<RelayItem.Location.Country>, + customLists: List<RelayItem.CustomList>, + selectedByThisEntryExitList: RelayItemId?, + selectedByOtherEntryExitList: RelayItemId?, + expandedItems: Set<String>, +): List<RelayListItem> { + val filteredCustomLists = customLists.filterOnSearchTerm(searchTerm) + + return buildList { + val relayItems = + createRelayListItems( + isSearching = searchTerm.isSearching(), + relayListType = relayListType, + selectedByThisEntryExitList = selectedByThisEntryExitList, + selectedByOtherEntryExitList = selectedByOtherEntryExitList, + customLists = filteredCustomLists, + countries = relayCountries, + ) { + it in expandedItems + } + if (relayItems.isEmpty()) { + add(RelayListItem.LocationsEmptyText(searchTerm)) + } else { + addAll(relayItems) + } + } +} + +private fun createRelayListItems( + isSearching: Boolean, + relayListType: RelayListType, + selectedByThisEntryExitList: RelayItemId?, + selectedByOtherEntryExitList: RelayItemId?, + customLists: List<RelayItem.CustomList>, + countries: List<RelayItem.Location.Country>, + isExpanded: (String) -> Boolean, +): List<RelayListItem> = + createCustomListSection( + isSearching, + relayListType, + selectedByThisEntryExitList, + selectedByOtherEntryExitList, + customLists, + isExpanded, + ) + + createLocationSection( + isSearching, + selectedByThisEntryExitList, + relayListType, + selectedByOtherEntryExitList, + countries, + isExpanded, + ) + +private fun createCustomListSection( + isSearching: Boolean, + relayListType: RelayListType, + selectedByThisEntryExitList: RelayItemId?, + selectedByOtherEntryExitList: RelayItemId?, + customLists: List<RelayItem.CustomList>, + isExpanded: (String) -> Boolean, +): List<RelayListItem> = buildList { + if (isSearching && customLists.isEmpty()) { + // If we are searching and no results are found don't show header or footer + } else { + add(RelayListItem.CustomListHeader) + val customListItems = + createCustomListRelayItems( + customLists, + relayListType, + selectedByThisEntryExitList, + selectedByOtherEntryExitList, + isExpanded, + ) + addAll(customListItems) + // Do not show the footer in the search view + if (!isSearching) { + add(RelayListItem.CustomListFooter(customListItems.isNotEmpty())) + } + } +} + +private fun createCustomListRelayItems( + customLists: List<RelayItem.CustomList>, + relayListType: RelayListType, + selectedByThisEntryExitList: RelayItemId?, + selectedByOtherEntryExitList: RelayItemId?, + isExpanded: (String) -> Boolean, +): List<RelayListItem> = + customLists.flatMap { customList -> + val expanded = isExpanded(customList.id.expandKey()) + buildList { + add( + RelayListItem.CustomListItem( + item = customList, + isSelected = selectedByThisEntryExitList == customList.id, + state = + customList.createState( + relayListType = relayListType, + selectedByOther = selectedByOtherEntryExitList, + ), + expanded = expanded, + ) + ) + + if (expanded) { + addAll( + customList.locations.flatMap { + createCustomListEntry( + parent = customList, + item = it, + relayListType = relayListType, + selectedByOtherEntryExitList = selectedByOtherEntryExitList, + depth = 1, + isExpanded = isExpanded, + ) + } + ) + } + } + } + +private fun createLocationSection( + isSearching: Boolean, + selectedByThisEntryExitList: RelayItemId?, + relayListType: RelayListType, + selectedByOtherEntryExitList: RelayItemId?, + countries: List<RelayItem.Location.Country>, + isExpanded: (String) -> Boolean, +): List<RelayListItem> = buildList { + if (isSearching && countries.isEmpty()) { + // If we are searching and no results are found don't show header or footer + } else { + add(RelayListItem.LocationHeader) + addAll( + countries.flatMap { country -> + createGeoLocationEntry( + item = country, + selectedByThisEntryExitList = selectedByThisEntryExitList, + relayListType = relayListType, + selectedByOtherEntryExitList = selectedByOtherEntryExitList, + isExpanded = isExpanded, + ) + } + ) + } +} + +private fun createCustomListEntry( + parent: RelayItem.CustomList, + item: RelayItem.Location, + relayListType: RelayListType, + selectedByOtherEntryExitList: RelayItemId?, + depth: Int = 1, + isExpanded: (String) -> Boolean, +): List<RelayListItem.CustomListEntryItem> = buildList { + val expanded = isExpanded(item.id.expandKey(parent.id)) + add( + RelayListItem.CustomListEntryItem( + parentId = parent.id, + parentName = parent.customList.name, + item = item, + state = + item.createState( + relayListType = relayListType, + selectedByOther = selectedByOtherEntryExitList, + ), + expanded = expanded, + depth = depth, + ) + ) + + if (expanded) { + when (item) { + is RelayItem.Location.City -> + addAll( + item.relays.flatMap { + createCustomListEntry( + parent = parent, + item = it, + relayListType = relayListType, + selectedByOtherEntryExitList = selectedByOtherEntryExitList, + depth = depth + 1, + isExpanded = isExpanded, + ) + } + ) + is RelayItem.Location.Country -> + addAll( + item.cities.flatMap { + createCustomListEntry( + parent = parent, + item = it, + relayListType = relayListType, + selectedByOtherEntryExitList = selectedByOtherEntryExitList, + depth = depth + 1, + isExpanded = isExpanded, + ) + } + ) + is RelayItem.Location.Relay -> {} // No children to add + } + } +} + +private fun createGeoLocationEntry( + item: RelayItem.Location, + relayListType: RelayListType, + selectedByThisEntryExitList: RelayItemId?, + selectedByOtherEntryExitList: RelayItemId?, + depth: Int = 0, + isExpanded: (String) -> Boolean, +): List<RelayListItem.GeoLocationItem> = buildList { + val expanded = isExpanded(item.id.expandKey()) + + add( + RelayListItem.GeoLocationItem( + item = item, + isSelected = selectedByThisEntryExitList == item.id, + state = + item.createState( + relayListType = relayListType, + selectedByOther = selectedByOtherEntryExitList, + ), + depth = depth, + expanded = expanded, + ) + ) + + if (expanded) { + when (item) { + is RelayItem.Location.City -> + addAll( + item.relays.flatMap { + createGeoLocationEntry( + item = it, + relayListType = relayListType, + selectedByThisEntryExitList = selectedByThisEntryExitList, + selectedByOtherEntryExitList = selectedByOtherEntryExitList, + depth = depth + 1, + isExpanded = isExpanded, + ) + } + ) + is RelayItem.Location.Country -> + addAll( + item.cities.flatMap { + createGeoLocationEntry( + item = it, + relayListType = relayListType, + selectedByThisEntryExitList = selectedByThisEntryExitList, + selectedByOtherEntryExitList = selectedByOtherEntryExitList, + depth = depth + 1, + isExpanded = isExpanded, + ) + } + ) + is RelayItem.Location.Relay -> {} // Do nothing + } + } +} + +internal fun RelayItemId.expandKey(parent: CustomListId? = null) = + (parent?.value ?: "") + + when (this) { + is CustomListId -> value + is GeoLocationId -> code + } + +internal fun RelayItemSelection.selectedByThisEntryExitList(relayListType: RelayListType) = + when (this) { + is RelayItemSelection.Multiple -> + when (relayListType) { + RelayListType.ENTRY -> entryLocation + RelayListType.EXIT -> exitLocation + }.getOrNull() + is RelayItemSelection.Single -> exitLocation.getOrNull() + } + +internal fun RelayItemSelection.selectedByOtherEntryExitList( + relayListType: RelayListType, + customLists: List<RelayItem.CustomList>, +) = + when (this) { + is RelayItemSelection.Multiple -> { + val location = + when (relayListType) { + RelayListType.ENTRY -> exitLocation + RelayListType.EXIT -> entryLocation + }.getOrNull() + location.singleRelayId(customLists) + } + is RelayItemSelection.Single -> null + } + +// We only want to block selecting the same entry as exit if it is a relay. For country and +// city it is fine to have same entry and exit +// For custom lists we will block if the custom lists only contains one relay and +// nothing else +private fun RelayItemId?.singleRelayId(customLists: List<RelayItem.CustomList>): RelayItemId? = + when (this) { + is GeoLocationId.City, + is GeoLocationId.Country -> null + is GeoLocationId.Hostname -> this + is CustomListId -> + customLists + .firstOrNull { customList -> customList.id == this } + ?.locations + ?.singleOrNull() + ?.id as? GeoLocationId.Hostname + else -> null + } + +private fun String.isSearching() = length >= MIN_SEARCH_LENGTH + +private fun RelayItem.createState( + relayListType: RelayListType, + selectedByOther: RelayItemId?, +): RelayListItemState? { + val selectedByOther = + when (this) { + is RelayItem.CustomList -> { + selectedByOther == customList.id || + customList.locations.all { it == selectedByOther } + } + is RelayItem.Location.City -> selectedByOther == id + is RelayItem.Location.Country -> selectedByOther == id + is RelayItem.Location.Relay -> selectedByOther == id + } + return if (selectedByOther) { + when (relayListType) { + RelayListType.ENTRY -> RelayListItemState.USED_AS_EXIT + RelayListType.EXIT -> RelayListItemState.USED_AS_ENTRY + } + } else { + null + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt new file mode 100644 index 0000000000..74cecbfdda --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt @@ -0,0 +1,211 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ramcosta.composedestinations.generated.destinations.SearchLocationDestination +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +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.CustomListActionResultData +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SearchLocationUiState +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH +import net.mullvad.mullvadvpn.relaylist.newFilterOnSearch +import net.mullvad.mullvadvpn.repository.CustomListsRepository +import net.mullvad.mullvadvpn.repository.RelayListFilterRepository +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.usecase.FilterChip +import net.mullvad.mullvadvpn.usecase.FilterChipUseCase +import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase +import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase +import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase +import net.mullvad.mullvadvpn.util.combine + +@Suppress("LongParameterList") +class SearchLocationViewModel( + private val wireguardConstraintsRepository: WireguardConstraintsRepository, + private val relayListRepository: RelayListRepository, + private val filteredRelayListUseCase: FilteredRelayListUseCase, + private val customListActionUseCase: CustomListActionUseCase, + private val customListsRepository: CustomListsRepository, + private val relayListFilterRepository: RelayListFilterRepository, + private val filterChipUseCase: FilterChipUseCase, + filteredCustomListRelayItemsUseCase: FilterCustomListsRelayItemUseCase, + selectedLocationUseCase: SelectedLocationUseCase, + customListsRelayItemUseCase: CustomListsRelayItemUseCase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val relayListType: RelayListType = + SearchLocationDestination.argsFrom(savedStateHandle).relayListType + + private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) + private val _expandedItems = MutableStateFlow<Set<String>>(emptySet()) + + val uiState: StateFlow<SearchLocationUiState> = + combine( + _searchTerm, + searchRelayListLocations(), + filteredCustomListRelayItemsUseCase(relayListType = relayListType), + customListsRelayItemUseCase(), + selectedLocationUseCase(), + filterChips(), + _expandedItems, + ) { + searchTerm, + relayCountries, + filteredCustomLists, + customLists, + selectedItem, + filterChips, + expandedItems -> + if (searchTerm.length >= MIN_SEARCH_LENGTH) { + SearchLocationUiState.Content( + searchTerm = searchTerm, + relayListItems = + relayListItems( + searchTerm = searchTerm, + relayCountries = relayCountries, + relayListType = relayListType, + customLists = filteredCustomLists, + selectedByThisEntryExitList = + selectedItem.selectedByThisEntryExitList(relayListType), + selectedByOtherEntryExitList = + selectedItem.selectedByOtherEntryExitList( + relayListType, + customLists, + ), + expandedItems = expandedItems, + ), + customLists = customLists, + filterChips = filterChips, + ) + } else { + SearchLocationUiState.NoQuery(searchTerm, filterChips) + } + } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + SearchLocationUiState.NoQuery("", emptyList()), + ) + + private val _uiSideEffect = Channel<SearchLocationSideEffect>() + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + fun onSearchInputUpdated(searchTerm: String) { + viewModelScope.launch { _searchTerm.emit(searchTerm) } + } + + fun selectRelay(relayItem: RelayItem) { + viewModelScope.launch { + selectRelayItem( + relayItem = relayItem, + relayListType = relayListType, + selectEntryLocation = wireguardConstraintsRepository::setEntryLocation, + selectExitLocation = relayListRepository::updateSelectedRelayLocation, + ) + .fold( + { _uiSideEffect.send(SearchLocationSideEffect.GenericError) }, + { _uiSideEffect.send(SearchLocationSideEffect.LocationSelected(relayListType)) }, + ) + } + } + + private fun searchRelayListLocations() = + combine(_searchTerm, filteredRelayListUseCase(relayListType)) { searchTerm, relayCountries + -> + val (exp, filteredRelayCountries) = relayCountries.newFilterOnSearch(searchTerm) + exp.map { it.expandKey() }.toSet() to filteredRelayCountries + } + .onEach { _expandedItems.value = it.first } + .map { it.second } + + private fun filterChips() = + combine( + filterChipUseCase(relayListType), + wireguardConstraintsRepository.wireguardConstraints, + ) { filterChips, constraints -> + filterChips.toMutableList().apply { + // Do not show entry and exit filter chips if multihop is disabled + if (constraints?.isMultihopEnabled == true) { + add( + when (relayListType) { + RelayListType.ENTRY -> FilterChip.Entry + RelayListType.EXIT -> FilterChip.Exit + } + ) + } + } + } + + fun addLocationToList(item: RelayItem.Location, customList: RelayItem.CustomList) { + viewModelScope.launch { + val result = + addLocationToCustomList( + item = item, + customList = customList, + update = customListActionUseCase::invoke, + ) + _uiSideEffect.send(SearchLocationSideEffect.CustomListActionToast(result)) + } + } + + fun removeLocationFromList(item: RelayItem.Location, customListId: CustomListId) { + viewModelScope.launch { + val result = + removeLocationFromCustomList( + item = item, + customListId = customListId, + getCustomListById = customListsRepository::getCustomListById, + update = customListActionUseCase::invoke, + ) + _uiSideEffect.trySend(SearchLocationSideEffect.CustomListActionToast(result)) + } + } + + fun performAction(action: CustomListAction) { + viewModelScope.launch { customListActionUseCase(action) } + } + + fun removeOwnerFilter() { + viewModelScope.launch { relayListFilterRepository.updateSelectedOwnership(Constraint.Any) } + } + + fun removeProviderFilter() { + viewModelScope.launch { relayListFilterRepository.updateSelectedProviders(Constraint.Any) } + } + + fun onToggleExpand(item: RelayItemId, parent: CustomListId? = null, expand: Boolean) { + _expandedItems.onToggleExpand(item = item, parent = parent, expand = expand) + } + + companion object { + private const val EMPTY_SEARCH_TERM = "" + } +} + +sealed interface SearchLocationSideEffect { + data class LocationSelected(val relayListType: RelayListType) : SearchLocationSideEffect + + data class CustomListActionToast(val resultData: CustomListActionResultData) : + SearchLocationSideEffect + + data object GenericError : SearchLocationSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModel.kt new file mode 100644 index 0000000000..d5063f0f44 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModel.kt @@ -0,0 +1,89 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase +import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase +import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase + +class SelectLocationListViewModel( + private val relayListType: RelayListType, + private val filteredRelayListUseCase: FilteredRelayListUseCase, + private val filteredCustomListRelayItemsUseCase: FilterCustomListsRelayItemUseCase, + private val selectedLocationUseCase: SelectedLocationUseCase, + private val wireguardConstraintsRepository: WireguardConstraintsRepository, + private val relayListRepository: RelayListRepository, + customListsRelayItemUseCase: CustomListsRelayItemUseCase, +) : ViewModel() { + private val _expandedItems: MutableStateFlow<Set<String>> = + MutableStateFlow(initialExpand(initialSelection())) + + val uiState: StateFlow<SelectLocationListUiState> = + combine(relayListItems(), customListsRelayItemUseCase()) { relayListItems, customLists -> + SelectLocationListUiState.Content( + relayListItems = relayListItems, + customLists = customLists, + ) + } + .stateIn(viewModelScope, SharingStarted.Lazily, SelectLocationListUiState.Loading) + + fun onToggleExpand(item: RelayItemId, parent: CustomListId? = null, expand: Boolean) { + _expandedItems.onToggleExpand(item, parent, expand) + } + + private fun relayListItems() = + combine( + filteredRelayListUseCase(relayListType = relayListType), + filteredCustomListRelayItemsUseCase(relayListType = relayListType), + selectedLocationUseCase(), + _expandedItems, + ) { relayCountries, customLists, selectedItem, expandedItems -> + relayListItems( + relayCountries = relayCountries, + relayListType = relayListType, + customLists = customLists, + selectedByThisEntryExitList = + selectedItem.selectedByThisEntryExitList(relayListType), + selectedByOtherEntryExitList = + selectedItem.selectedByOtherEntryExitList(relayListType, customLists), + expandedItems = expandedItems, + ) + } + + private fun initialExpand(item: RelayItemId?): Set<String> = buildSet { + when (item) { + is GeoLocationId.City -> { + add(item.country.code) + } + is GeoLocationId.Hostname -> { + add(item.country.code) + add(item.city.code) + } + is CustomListId, + is GeoLocationId.Country, + null -> { + /* No expands */ + } + } + } + + private fun initialSelection() = + when (relayListType) { + RelayListType.ENTRY -> + wireguardConstraintsRepository.wireguardConstraints.value?.entryLocation + RelayListType.EXIT -> relayListRepository.selectedLocation.value + }?.getOrNull() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt new file mode 100644 index 0000000000..dd6736a45d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt @@ -0,0 +1,145 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +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.CustomListActionResultData +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.repository.CustomListsRepository +import net.mullvad.mullvadvpn.repository.RelayListFilterRepository +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.usecase.FilterChipUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase + +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("TooManyFunctions") +class SelectLocationViewModel( + private val relayListFilterRepository: RelayListFilterRepository, + private val customListsRepository: CustomListsRepository, + private val customListActionUseCase: CustomListActionUseCase, + private val relayListRepository: RelayListRepository, + private val wireguardConstraintsRepository: WireguardConstraintsRepository, + private val filterChipUseCase: FilterChipUseCase, +) : ViewModel() { + private val _relayListType: MutableStateFlow<RelayListType> = + MutableStateFlow(initialRelayListSelection()) + + val uiState = + combine( + filterChips(), + wireguardConstraintsRepository.wireguardConstraints, + _relayListType, + ) { filterChips, wireguardConstraints, relayListSelection -> + SelectLocationUiState( + filterChips = filterChips, + multihopEnabled = wireguardConstraints?.isMultihopEnabled == true, + relayListType = relayListSelection, + ) + } + .stateIn( + viewModelScope, + SharingStarted.Lazily, + SelectLocationUiState( + filterChips = emptyList(), + multihopEnabled = false, + relayListType = RelayListType.EXIT, + ), + ) + + private val _uiSideEffect = Channel<SelectLocationSideEffect>() + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + private fun initialRelayListSelection() = + if (wireguardConstraintsRepository.wireguardConstraints.value?.isMultihopEnabled == true) { + RelayListType.ENTRY + } else { + RelayListType.EXIT + } + + private fun filterChips() = _relayListType.flatMapLatest { filterChipUseCase(it) } + + fun selectRelayList(relayListType: RelayListType) { + viewModelScope.launch { _relayListType.emit(relayListType) } + } + + fun selectRelay(relayItem: RelayItem) { + viewModelScope.launch { + selectRelayItem( + relayItem = relayItem, + relayListType = _relayListType.value, + selectEntryLocation = wireguardConstraintsRepository::setEntryLocation, + selectExitLocation = relayListRepository::updateSelectedRelayLocation, + ) + .fold( + { _uiSideEffect.send(SelectLocationSideEffect.GenericError) }, + { + when (_relayListType.value) { + RelayListType.ENTRY -> _relayListType.emit(RelayListType.EXIT) + RelayListType.EXIT -> + _uiSideEffect.send(SelectLocationSideEffect.CloseScreen) + } + }, + ) + } + } + + fun addLocationToList(item: RelayItem.Location, customList: RelayItem.CustomList) { + viewModelScope.launch { + val result = + addLocationToCustomList( + item = item, + customList = customList, + update = customListActionUseCase::invoke, + ) + _uiSideEffect.send(SelectLocationSideEffect.CustomListActionToast(result)) + } + } + + fun removeLocationFromList(item: RelayItem.Location, customListId: CustomListId) { + viewModelScope.launch { + val result = + removeLocationFromCustomList( + item = item, + customListId = customListId, + getCustomListById = customListsRepository::getCustomListById, + update = customListActionUseCase::invoke, + ) + _uiSideEffect.trySend(SelectLocationSideEffect.CustomListActionToast(result)) + } + } + + fun performAction(action: CustomListAction) { + viewModelScope.launch { customListActionUseCase(action) } + } + + fun removeOwnerFilter() { + viewModelScope.launch { relayListFilterRepository.updateSelectedOwnership(Constraint.Any) } + } + + fun removeProviderFilter() { + viewModelScope.launch { relayListFilterRepository.updateSelectedProviders(Constraint.Any) } + } +} + +sealed interface SelectLocationSideEffect { + data object CloseScreen : SelectLocationSideEffect + + data class CustomListActionToast(val resultData: CustomListActionResultData) : + SelectLocationSideEffect + + data object GenericError : SelectLocationSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectRelay.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectRelay.kt new file mode 100644 index 0000000000..8d6c90961b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectRelay.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import arrow.core.Either +import arrow.core.raise.either +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId + +internal suspend fun selectRelayItem( + relayItem: RelayItem, + relayListType: RelayListType, + selectEntryLocation: suspend (RelayItemId) -> Either<Any, Unit>, + selectExitLocation: suspend (RelayItemId) -> Either<Any, Unit>, +) = + either<Any, Unit> { + val locationConstraint = relayItem.id + when (relayListType) { + RelayListType.ENTRY -> selectEntryLocation(locationConstraint) + RelayListType.EXIT -> selectExitLocation(locationConstraint) + } + } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCaseTest.kt new file mode 100644 index 0000000000..8b3d6d68a2 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCaseTest.kt @@ -0,0 +1,146 @@ +package net.mullvad.mullvadvpn.usecase + +import app.cash.turbine.test +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.lib.common.test.assertLists +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Provider +import net.mullvad.mullvadvpn.lib.model.ProviderId +import net.mullvad.mullvadvpn.lib.model.Providers +import net.mullvad.mullvadvpn.lib.model.Settings +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints +import net.mullvad.mullvadvpn.repository.RelayListFilterRepository +import net.mullvad.mullvadvpn.repository.SettingsRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class FilterChipUseCaseTest { + + private val mockRelayListFilterRepository: RelayListFilterRepository = mockk() + private val mockAvailableProvidersUseCase: AvailableProvidersUseCase = mockk() + private val mockSettingRepository: SettingsRepository = mockk() + private val mockWireguardConstraintsRepository: WireguardConstraintsRepository = mockk() + + private val selectedOwnership = MutableStateFlow<Constraint<Ownership>>(Constraint.Any) + private val selectedProviders = MutableStateFlow<Constraint<Providers>>(Constraint.Any) + private val availableProviders = MutableStateFlow<List<Provider>>(emptyList()) + private val settings = MutableStateFlow<Settings>(mockk(relaxed = true)) + private val wireguardConstraints = MutableStateFlow<WireguardConstraints>(mockk(relaxed = true)) + + private lateinit var filterChipUseCase: FilterChipUseCase + + @BeforeEach + fun setUp() { + every { mockRelayListFilterRepository.selectedOwnership } returns selectedOwnership + every { mockRelayListFilterRepository.selectedProviders } returns selectedProviders + every { mockAvailableProvidersUseCase() } returns availableProviders + every { mockSettingRepository.settingsUpdates } returns settings + every { mockWireguardConstraintsRepository.wireguardConstraints } returns + wireguardConstraints + + filterChipUseCase = + FilterChipUseCase( + relayListFilterRepository = mockRelayListFilterRepository, + availableProvidersUseCase = mockAvailableProvidersUseCase, + settingsRepository = mockSettingRepository, + wireguardConstraintsRepository = mockWireguardConstraintsRepository, + ) + } + + @Test + fun `when no filters are applied should return empty list`() = runTest { + filterChipUseCase(RelayListType.EXIT).test { assertLists(emptyList(), awaitItem()) } + } + + @Test + fun `when ownership filter is applied should return correct ownership`() = runTest { + // Arrange + val expectedOwnership = Ownership.MullvadOwned + selectedOwnership.value = Constraint.Only(expectedOwnership) + + filterChipUseCase(RelayListType.EXIT).test { + assertLists(listOf(FilterChip.Ownership(expectedOwnership)), awaitItem()) + } + } + + @Test + fun `when provider filter is applied should return correct number of providers`() = runTest { + // Arrange + val expectedProviders = Providers(providers = setOf(ProviderId("1"), ProviderId("2"))) + selectedProviders.value = Constraint.Only(expectedProviders) + availableProviders.value = + listOf( + Provider(ProviderId("1"), Ownership.MullvadOwned), + Provider(ProviderId("2"), Ownership.Rented), + ) + + filterChipUseCase(RelayListType.EXIT).test { + assertLists(listOf(FilterChip.Provider(2)), awaitItem()) + } + } + + @Test + fun `when provider and ownership filter is applied should return correct filter chips`() = + runTest { + // Arrange + val expectedProviders = Providers(providers = setOf(ProviderId("1"))) + val expectedOwnership = Ownership.MullvadOwned + selectedProviders.value = Constraint.Only(expectedProviders) + selectedOwnership.value = Constraint.Only(expectedOwnership) + availableProviders.value = + listOf( + Provider(ProviderId("1"), Ownership.MullvadOwned), + Provider(ProviderId("2"), Ownership.Rented), + ) + + filterChipUseCase(RelayListType.EXIT).test { + assertLists( + listOf(FilterChip.Ownership(expectedOwnership), FilterChip.Provider(1)), + awaitItem(), + ) + } + } + + @Test + fun `when Daita is enabled and multihop is disabled should return Daita filter chip`() = + runTest { + // Arrange + settings.value = mockk(relaxed = true) { every { isDaitaEnabled() } returns true } + wireguardConstraints.value = + mockk(relaxed = true) { every { isMultihopEnabled } returns false } + + filterChipUseCase(RelayListType.EXIT).test { + assertLists(listOf(FilterChip.Daita), awaitItem()) + } + } + + @Test + fun `when Daita is enabled and multihop is enabled and relay list type is entry should return Daita filter chip`() = + runTest { + // Arrange + settings.value = mockk(relaxed = true) { every { isDaitaEnabled() } returns true } + wireguardConstraints.value = + mockk(relaxed = true) { every { isMultihopEnabled } returns true } + + filterChipUseCase(RelayListType.ENTRY).test { + assertLists(listOf(FilterChip.Daita), awaitItem()) + } + } + + @Test + fun `when Daita is enabled and multihop is enabled and relay list type is exit should return no filter`() = + runTest { + // Arrange + settings.value = mockk(relaxed = true) { every { isDaitaEnabled() } returns true } + wireguardConstraints.value = + mockk(relaxed = true) { every { isMultihopEnabled } returns true } + + filterChipUseCase(RelayListType.EXIT).test { assertLists(emptyList(), awaitItem()) } + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationUseCaseTest.kt new file mode 100644 index 0000000000..deef7b7ab9 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationUseCaseTest.kt @@ -0,0 +1,71 @@ +package net.mullvad.mullvadvpn.usecase + +import app.cash.turbine.test +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.lib.model.RelayItemSelection +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class SelectedLocationUseCaseTest { + private val mockRelayListRepository: RelayListRepository = mockk() + private val mockWireguardConstraintsRepository: WireguardConstraintsRepository = mockk() + + private val selectedLocation = MutableStateFlow<Constraint<RelayItemId>>(Constraint.Any) + private val wireguardConstraints = MutableStateFlow<WireguardConstraints>(mockk(relaxed = true)) + + private lateinit var selectLocationUseCase: SelectedLocationUseCase + + @BeforeEach + fun setup() { + every { mockRelayListRepository.selectedLocation } returns selectedLocation + every { mockWireguardConstraintsRepository.wireguardConstraints } returns + wireguardConstraints + + selectLocationUseCase = + SelectedLocationUseCase( + relayListRepository = mockRelayListRepository, + wireguardConstraintsRepository = mockWireguardConstraintsRepository, + ) + } + + @Test + fun `when wireguard constraints is multihop enabled should return Multiple`() = runTest { + // Arrange + val entryLocation: Constraint<RelayItemId> = Constraint.Only(GeoLocationId.Country("se")) + val exitLocation = Constraint.Only(GeoLocationId.Country("us")) + wireguardConstraints.value = + WireguardConstraints( + isMultihopEnabled = true, + entryLocation = entryLocation, + port = Constraint.Any, + ) + selectedLocation.value = exitLocation + + // Act, Assert + selectLocationUseCase().test { + assertEquals(RelayItemSelection.Multiple(entryLocation, exitLocation), awaitItem()) + } + } + + @Test + fun `when wireguard constraints is multihop disabled should return Single`() = runTest { + // Arrange + val exitLocation = Constraint.Only(GeoLocationId.Country("us")) + selectedLocation.value = exitLocation + + // Act, Assert + selectLocationUseCase().test { + assertEquals(RelayItemSelection.Single(exitLocation), awaitItem()) + } + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModelTest.kt new file mode 100644 index 0000000000..34cb1353bb --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModelTest.kt @@ -0,0 +1,68 @@ +package net.mullvad.mullvadvpn.viewmodel + +import app.cash.turbine.test +import arrow.core.Either +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +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 MultihopViewModelTest { + + private val mockWireguardConstraintsRepository: WireguardConstraintsRepository = mockk() + + private val wireguardConstraints = MutableStateFlow<WireguardConstraints>(mockk(relaxed = true)) + + private lateinit var multihopViewModel: MultihopViewModel + + @BeforeEach + fun setUp() { + every { mockWireguardConstraintsRepository.wireguardConstraints } returns + wireguardConstraints + + multihopViewModel = + MultihopViewModel(wireguardConstraintsRepository = mockWireguardConstraintsRepository) + } + + @Test + fun `default state should be multihop disabled`() { + assertEquals(false, multihopViewModel.uiState.value.enable) + } + + @Test + fun `when multihop enabled is true state should return multihop enabled true`() = runTest { + // Arrange + wireguardConstraints.value = + WireguardConstraints( + isMultihopEnabled = true, + entryLocation = Constraint.Any, + port = Constraint.Any, + ) + + // Act, Assert + multihopViewModel.uiState.test { assertEquals(MultihopUiState(true), awaitItem()) } + } + + @Test + fun `when set multihop is called should call repository set multihop`() = runTest { + // Arrange + coEvery { mockWireguardConstraintsRepository.setMultihop(any()) } returns Either.Right(Unit) + + // Act + multihopViewModel.setMultihop(true) + + // Assert + coVerify { mockWireguardConstraintsRepository.setMultihop(true) } + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt index 8857eb364a..f2468cbb11 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt @@ -10,8 +10,11 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.DeviceState +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints import net.mullvad.mullvadvpn.lib.shared.DeviceRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository import net.mullvad.mullvadvpn.ui.VersionInfo import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository import org.junit.jupiter.api.AfterEach @@ -24,9 +27,11 @@ class SettingsViewModelTest { private val mockDeviceRepository: DeviceRepository = mockk() private val mockAppVersionInfoRepository: AppVersionInfoRepository = mockk() + private val mockWireguardConstraintsRepository: WireguardConstraintsRepository = mockk() private val versionInfo = MutableStateFlow(VersionInfo(currentVersion = "", isSupported = false)) + private val wireguardConstraints = MutableStateFlow<WireguardConstraints>(mockk(relaxed = true)) private lateinit var viewModel: SettingsViewModel @@ -36,11 +41,14 @@ class SettingsViewModelTest { every { mockDeviceRepository.deviceState } returns deviceState every { mockAppVersionInfoRepository.versionInfo } returns versionInfo + every { mockWireguardConstraintsRepository.wireguardConstraints } returns + wireguardConstraints viewModel = SettingsViewModel( deviceRepository = mockDeviceRepository, appVersionInfoRepository = mockAppVersionInfoRepository, + wireguardConstraintsRepository = mockWireguardConstraintsRepository, isPlayBuild = false, ) } @@ -84,4 +92,22 @@ class SettingsViewModelTest { assertEquals(false, result.isSupportedVersion) } } + + @Test + fun `when WireguardConstraintsRepository return multihop enabled uiState should return multihop enabled true`() = + runTest { + // Arrange + wireguardConstraints.value = + WireguardConstraints( + isMultihopEnabled = true, + entryLocation = Constraint.Any, + port = Constraint.Any, + ) + + // Act, Assert + viewModel.uiState.test { + val result = awaitItem() + assertEquals(true, result.multihopEnabled) + } + } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt index 340809fbb3..427b003d33 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt @@ -189,7 +189,7 @@ class VpnSettingsViewModelTest { val wireguardConstraints = WireguardConstraints( port = wireguardPort, - useMultihop = false, + isMultihopEnabled = false, entryLocation = Constraint.Any, ) coEvery { mockWireguardConstraintsRepository.setWireguardPort(any()) } returns diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt new file mode 100644 index 0000000000..be60f9d723 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt @@ -0,0 +1,161 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import app.cash.turbine.test +import com.ramcosta.composedestinations.generated.navargs.toSavedStateHandle +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.screen.location.SearchLocationNavArgs +import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SearchLocationUiState +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.common.test.assertLists +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemSelection +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints +import net.mullvad.mullvadvpn.repository.CustomListsRepository +import net.mullvad.mullvadvpn.repository.RelayListFilterRepository +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.usecase.FilterChip +import net.mullvad.mullvadvpn.usecase.FilterChipUseCase +import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase +import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase +import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(TestCoroutineRule::class) +class SearchLocationViewModelTest { + + private val mockWireguardConstraintsRepository: WireguardConstraintsRepository = mockk() + private val mockRelayListRepository: RelayListRepository = mockk() + private val mockFilteredRelayListUseCase: FilteredRelayListUseCase = mockk() + private val mockCustomListActionUseCase: CustomListActionUseCase = mockk() + private val mockCustomListsRepository: CustomListsRepository = mockk() + private val mockRelayListFilterRepository: RelayListFilterRepository = mockk() + private val mockFilterChipUseCase: FilterChipUseCase = mockk() + private val mockFilteredCustomListRelayItemsUseCase: FilterCustomListsRelayItemUseCase = mockk() + private val mockSelectedLocationUseCase: SelectedLocationUseCase = mockk() + private val mockCustomListsRelayItemUseCase: CustomListsRelayItemUseCase = mockk() + + private val filteredRelayList = MutableStateFlow<List<RelayItem.Location.Country>>(emptyList()) + private val selectedLocation = + MutableStateFlow<RelayItemSelection>(RelayItemSelection.Single(Constraint.Any)) + private val filteredCustomListRelayItems = + MutableStateFlow<List<RelayItem.CustomList>>(emptyList()) + private val customListRelayItems = MutableStateFlow<List<RelayItem.CustomList>>(emptyList()) + private val filterChips = MutableStateFlow<List<FilterChip>>(emptyList()) + private val wireguardConstraints = MutableStateFlow<WireguardConstraints>(mockk(relaxed = true)) + + private lateinit var viewModel: SearchLocationViewModel + + @BeforeEach + fun setup() { + every { mockFilteredRelayListUseCase(any()) } returns filteredRelayList + every { mockSelectedLocationUseCase() } returns selectedLocation + every { mockFilteredCustomListRelayItemsUseCase(any()) } returns + filteredCustomListRelayItems + every { mockCustomListsRelayItemUseCase() } returns customListRelayItems + every { mockFilterChipUseCase(any()) } returns filterChips + every { mockWireguardConstraintsRepository.wireguardConstraints } returns + wireguardConstraints + + viewModel = + SearchLocationViewModel( + wireguardConstraintsRepository = mockWireguardConstraintsRepository, + relayListRepository = mockRelayListRepository, + filteredRelayListUseCase = mockFilteredRelayListUseCase, + customListActionUseCase = mockCustomListActionUseCase, + customListsRepository = mockCustomListsRepository, + relayListFilterRepository = mockRelayListFilterRepository, + filterChipUseCase = mockFilterChipUseCase, + filteredCustomListRelayItemsUseCase = mockFilteredCustomListRelayItemsUseCase, + selectedLocationUseCase = mockSelectedLocationUseCase, + customListsRelayItemUseCase = mockCustomListsRelayItemUseCase, + savedStateHandle = + SearchLocationNavArgs(relayListType = RelayListType.ENTRY).toSavedStateHandle(), + ) + } + + @Test + fun `on onSearchTermInput call uiState should emit with filtered countries`() = runTest { + // Arrange + val mockSearchString = "got" + filteredRelayList.value = testCountries + + // Act, Assert + viewModel.uiState.test() { + // Wait for first data + assertIs<SearchLocationUiState.NoQuery>(awaitItem()) + + // Update search string + viewModel.onSearchInputUpdated(mockSearchString) + + // We get some unnecessary emissions for now + awaitItem() + + val actualState = awaitItem() + assertIs<SearchLocationUiState.Content>(actualState) + assertTrue( + actualState.relayListItems.filterIsInstance<RelayListItem.GeoLocationItem>().any { + it.item is RelayItem.Location.City && it.item.name == "Gothenburg" + } + ) + } + } + + @Test + fun `when onSearchTermInput returns empty result uiState should return empty list`() = runTest { + // Arrange + filteredRelayList.value = testCountries + val mockSearchString = "SEARCH" + + // Act, Assert + viewModel.uiState.test { + // Wait for first data + assertIs<SearchLocationUiState.NoQuery>(awaitItem()) + + // Update search string + viewModel.onSearchInputUpdated(mockSearchString) + + // We get some unnecessary emissions for now + awaitItem() + + // Assert + val actualState = awaitItem() + assertIs<SearchLocationUiState.Content>(actualState) + assertLists( + listOf(RelayListItem.LocationsEmptyText(mockSearchString)), + actualState.relayListItems, + ) + } + } + + companion object { + private val testCountries = + listOf( + RelayItem.Location.Country( + id = GeoLocationId.Country("se"), + "Sweden", + listOf( + RelayItem.Location.City( + id = GeoLocationId.City(GeoLocationId.Country("se"), "got"), + "Gothenburg", + emptyList(), + ) + ), + ), + RelayItem.Location.Country(id = GeoLocationId.Country("no"), "Norway", emptyList()), + ) + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModelTest.kt new file mode 100644 index 0000000000..3584877170 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModelTest.kt @@ -0,0 +1,158 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import app.cash.turbine.test +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.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.common.test.assertLists +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemSelection +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase +import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase +import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(TestCoroutineRule::class) +class SelectLocationListViewModelTest { + + private val mockFilteredRelayListUseCase: FilteredRelayListUseCase = mockk() + private val mockFilteredCustomListRelayItemsUseCase: FilterCustomListsRelayItemUseCase = mockk() + private val mockSelectedLocationUseCase: SelectedLocationUseCase = mockk() + private val mockWireguardConstraintsRepository: WireguardConstraintsRepository = mockk() + private val mockRelayListRepository: RelayListRepository = mockk() + private val mockCustomListRelayItemsUseCase: CustomListsRelayItemUseCase = mockk() + + private val filteredRelayList = MutableStateFlow<List<RelayItem.Location.Country>>(emptyList()) + private val selectedLocationFlow = MutableStateFlow<RelayItemSelection>(mockk(relaxed = true)) + private val filteredCustomListRelayItems = + MutableStateFlow<List<RelayItem.CustomList>>(emptyList()) + private val customListRelayItems = MutableStateFlow<List<RelayItem.CustomList>>(emptyList()) + + private lateinit var viewModel: SelectLocationListViewModel + + @BeforeEach + fun setUp() { + // Used for initial selection + every { mockRelayListRepository.selectedLocation } returns MutableStateFlow(Constraint.Any) + every { mockWireguardConstraintsRepository.wireguardConstraints } returns + MutableStateFlow(null) + + every { mockSelectedLocationUseCase() } returns selectedLocationFlow + every { mockFilteredRelayListUseCase(any()) } returns filteredRelayList + every { mockFilteredCustomListRelayItemsUseCase(any()) } returns + filteredCustomListRelayItems + every { mockCustomListRelayItemsUseCase() } returns customListRelayItems + } + + @Test + fun `initial state should be loading`() = runTest { + // Arrange + viewModel = createSelectLocationListViewModel(relayListType = RelayListType.ENTRY) + + // Assert + assertEquals(SelectLocationListUiState.Loading, viewModel.uiState.value) + } + + @Test + fun `given filteredRelayList emits update uiState should contain new update`() = runTest { + // Arrange + viewModel = createSelectLocationListViewModel(RelayListType.EXIT) + filteredRelayList.value = testCountries + val selectedId = testCountries.first().id + selectedLocationFlow.value = RelayItemSelection.Single(Constraint.Only(selectedId)) + + // Act, Assert + viewModel.uiState.test { + val actualState = awaitItem() + assertIs<SelectLocationListUiState.Content>(actualState) + assertLists( + testCountries.map { it.id }, + actualState.relayListItems.mapNotNull { it.relayItemId() }, + ) + assertTrue( + actualState.relayListItems + .filterIsInstance<RelayListItem.SelectableItem>() + .first { it.relayItemId() == selectedId } + .isSelected + ) + } + } + + @Test + fun `given relay is not selected all relay items should not be selected`() = runTest { + // Arrange + viewModel = createSelectLocationListViewModel(RelayListType.EXIT) + filteredRelayList.value = testCountries + selectedLocationFlow.value = RelayItemSelection.Single(Constraint.Any) + + // Act, Assert + viewModel.uiState.test { + val actualState = awaitItem() + assertIs<SelectLocationListUiState.Content>(actualState) + assertLists( + testCountries.map { it.id }, + actualState.relayListItems.mapNotNull { it.relayItemId() }, + ) + assertTrue( + actualState.relayListItems.filterIsInstance<RelayListItem.SelectableItem>().all { + !it.isSelected + } + ) + } + } + + private fun createSelectLocationListViewModel(relayListType: RelayListType) = + SelectLocationListViewModel( + relayListType = relayListType, + filteredRelayListUseCase = mockFilteredRelayListUseCase, + filteredCustomListRelayItemsUseCase = mockFilteredCustomListRelayItemsUseCase, + selectedLocationUseCase = mockSelectedLocationUseCase, + wireguardConstraintsRepository = mockWireguardConstraintsRepository, + relayListRepository = mockRelayListRepository, + customListsRelayItemUseCase = mockCustomListRelayItemsUseCase, + ) + + private fun RelayListItem.relayItemId() = + when (this) { + is RelayListItem.CustomListFooter -> null + RelayListItem.CustomListHeader -> null + RelayListItem.LocationHeader -> null + is RelayListItem.LocationsEmptyText -> null + is RelayListItem.CustomListEntryItem -> item.id + is RelayListItem.CustomListItem -> item.id + is RelayListItem.GeoLocationItem -> item.id + } + + companion object { + private val testCountries = + listOf( + RelayItem.Location.Country( + id = GeoLocationId.Country("se"), + "Sweden", + listOf( + RelayItem.Location.City( + id = GeoLocationId.City(GeoLocationId.Country("se"), "got"), + "Gothenburg", + emptyList(), + ) + ), + ), + RelayItem.Location.Country(id = GeoLocationId.Country("no"), "Norway", emptyList()), + ) + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt index bee888d279..ef21eac139 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.viewmodel +package net.mullvad.mullvadvpn.viewmodel.location import androidx.lifecycle.viewModelScope import app.cash.turbine.test @@ -11,39 +11,35 @@ import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlin.test.assertEquals import kotlin.test.assertIs -import kotlin.test.assertTrue import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.communication.CustomListAction import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.communication.LocationsChanged -import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.lib.common.test.assertLists import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.CustomList import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.CustomListName import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.model.Ownership -import net.mullvad.mullvadvpn.lib.model.Provider import net.mullvad.mullvadvpn.lib.model.Providers import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.model.RelayItemId -import net.mullvad.mullvadvpn.lib.model.Settings +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints import net.mullvad.mullvadvpn.relaylist.descendants import net.mullvad.mullvadvpn.repository.CustomListsRepository import net.mullvad.mullvadvpn.repository.RelayListFilterRepository import net.mullvad.mullvadvpn.repository.RelayListRepository -import net.mullvad.mullvadvpn.repository.SettingsRepository -import net.mullvad.mullvadvpn.usecase.AvailableProvidersUseCase -import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.usecase.FilterChip +import net.mullvad.mullvadvpn.usecase.FilterChipUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase -import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase -import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -52,39 +48,25 @@ import org.junit.jupiter.api.extension.ExtendWith class SelectLocationViewModelTest { private val mockRelayListFilterRepository: RelayListFilterRepository = mockk() - private val mockAvailableProvidersUseCase: AvailableProvidersUseCase = mockk(relaxed = true) private val mockCustomListActionUseCase: CustomListActionUseCase = mockk(relaxed = true) - private val mockFilteredCustomListRelayItemsUseCase: FilterCustomListsRelayItemUseCase = mockk() - private val mockFilteredRelayListUseCase: FilteredRelayListUseCase = mockk() private val mockRelayListRepository: RelayListRepository = mockk() private val mockCustomListsRepository: CustomListsRepository = mockk() - private val mockCustomListsRelayItemUseCase: CustomListsRelayItemUseCase = mockk() - - private val mockSettingsRepository: SettingsRepository = mockk() - private val settingsFlow = MutableStateFlow(mockk<Settings>(relaxed = true)) + private val mockWireguardConstraintsRepository: WireguardConstraintsRepository = mockk() + private val mockFilterChipUseCase: FilterChipUseCase = mockk() private lateinit var viewModel: SelectLocationViewModel - private val allProviders = MutableStateFlow<List<Provider>>(emptyList()) - private val selectedOwnership = MutableStateFlow<Constraint<Ownership>>(Constraint.Any) - private val selectedProviders = MutableStateFlow<Constraint<Providers>>(Constraint.Any) private val selectedRelayItemFlow = MutableStateFlow<Constraint<RelayItemId>>(Constraint.Any) - private val filteredRelayList = MutableStateFlow<List<RelayItem.Location.Country>>(emptyList()) - private val filteredCustomRelayListItems = - MutableStateFlow<List<RelayItem.CustomList>>(emptyList()) - private val customListsRelayItem = MutableStateFlow<List<RelayItem.CustomList>>(emptyList()) + private val wireguardConstraints = MutableStateFlow<WireguardConstraints>(mockk(relaxed = true)) + private val filterChips = MutableStateFlow<List<FilterChip>>(emptyList()) @BeforeEach fun setup() { - every { mockRelayListFilterRepository.selectedOwnership } returns selectedOwnership - every { mockRelayListFilterRepository.selectedProviders } returns selectedProviders - every { mockAvailableProvidersUseCase() } returns allProviders every { mockRelayListRepository.selectedLocation } returns selectedRelayItemFlow - every { mockFilteredRelayListUseCase() } returns filteredRelayList - every { mockFilteredCustomListRelayItemsUseCase() } returns filteredCustomRelayListItems - every { mockCustomListsRelayItemUseCase() } returns customListsRelayItem - every { mockSettingsRepository.settingsUpdates } returns settingsFlow + every { mockWireguardConstraintsRepository.wireguardConstraints } returns + wireguardConstraints + every { mockFilterChipUseCase(any()) } returns filterChips mockkStatic(RELAY_LIST_EXTENSIONS) mockkStatic(RELAY_ITEM_EXTENSIONS) @@ -92,14 +74,11 @@ class SelectLocationViewModelTest { viewModel = SelectLocationViewModel( relayListFilterRepository = mockRelayListFilterRepository, - availableProvidersUseCase = mockAvailableProvidersUseCase, - filteredCustomListRelayItemsUseCase = mockFilteredCustomListRelayItemsUseCase, customListActionUseCase = mockCustomListActionUseCase, - filteredRelayListUseCase = mockFilteredRelayListUseCase, relayListRepository = mockRelayListRepository, customListsRepository = mockCustomListsRepository, - customListsRelayItemUseCase = mockCustomListsRelayItemUseCase, - settingsRepository = mockSettingsRepository, + filterChipUseCase = mockFilterChipUseCase, + wireguardConstraintsRepository = mockWireguardConstraintsRepository, ) } @@ -110,131 +89,59 @@ class SelectLocationViewModelTest { } @Test - fun `initial state should be loading`() = runTest { - assertEquals(SelectLocationUiState.Loading, viewModel.uiState.value) - } - - @Test - fun `given filteredRelayList emits update uiState should contain new update`() = runTest { - // Arrange - filteredRelayList.value = testCountries - val selectedId = testCountries.first().id - selectedRelayItemFlow.value = Constraint.Only(selectedId) - - // Act, Assert - viewModel.uiState.test { - val actualState = awaitItem() - assertIs<SelectLocationUiState.Content>(actualState) - assertLists( - testCountries.map { it.id }, - actualState.relayListItems.mapNotNull { it.relayItemId() }, - ) - assertTrue( - actualState.relayListItems - .filterIsInstance<RelayListItem.SelectableItem>() - .first { it.relayItemId() == selectedId } - .isSelected - ) - } - } - - @Test - fun `given relay is selected all relay items should not be selected`() = runTest { - // Arrange - filteredRelayList.value = testCountries - selectedRelayItemFlow.value = Constraint.Any - - // Act, Assert - viewModel.uiState.test { - val actualState = awaitItem() - assertIs<SelectLocationUiState.Content>(actualState) - assertLists( - testCountries.map { it.id }, - actualState.relayListItems.mapNotNull { it.relayItemId() }, - ) - assertTrue( - actualState.relayListItems.filterIsInstance<RelayListItem.SelectableItem>().all { - !it.isSelected - } - ) - } - } - - @Test - fun `on selectRelay call uiSideEffect should emit CloseScreen and connect`() = runTest { - // Arrange - val mockRelayItem: RelayItem.Location.Country = mockk() - val relayItemId: GeoLocationId.Country = mockk(relaxed = true) - every { mockRelayItem.id } returns relayItemId - coEvery { mockRelayListRepository.updateSelectedRelayLocation(relayItemId) } returns - Unit.right() - - // Act, Assert - viewModel.uiSideEffect.test { - viewModel.selectRelay(mockRelayItem) - // Await an empty item - assertEquals(SelectLocationSideEffect.CloseScreen, awaitItem()) - coVerify { mockRelayListRepository.updateSelectedRelayLocation(relayItemId) } - } + fun `initial state should be correct`() = runTest { + Assertions.assertEquals( + SelectLocationUiState( + filterChips = emptyList(), + multihopEnabled = false, + relayListType = RelayListType.EXIT, + ), + viewModel.uiState.value, + ) } @Test - fun `on onSearchTermInput call uiState should emit with filtered countries`() = runTest { - // Arrange - val mockSearchString = "got" - filteredRelayList.value = testCountries - selectedRelayItemFlow.value = Constraint.Any - - // Act, Assert - viewModel.uiState.test { - // Wait for first data - assertIs<SelectLocationUiState.Content>(awaitItem()) - - // Update search string - viewModel.onSearchTermInput(mockSearchString) - - // We get some unnecessary emissions for now - awaitItem() - awaitItem() - awaitItem() + fun `on selectRelay when relay list type is exit call uiSideEffect should emit CloseScreen and connect`() = + runTest { + // Arrange + val mockRelayItem: RelayItem.Location.Country = mockk() + val relayItemId: GeoLocationId.Country = mockk(relaxed = true) + every { mockRelayItem.id } returns relayItemId + coEvery { mockRelayListRepository.updateSelectedRelayLocation(relayItemId) } returns + Unit.right() - val actualState = awaitItem() - assertIs<SelectLocationUiState.Content>(actualState) - assertTrue( - actualState.relayListItems.filterIsInstance<RelayListItem.GeoLocationItem>().any { - it.item is RelayItem.Location.City && it.item.name == "Gothenburg" - } - ) + // Act, Assert + viewModel.uiSideEffect.test { + viewModel.selectRelay(mockRelayItem) + // Await an empty item + assertEquals(SelectLocationSideEffect.CloseScreen, awaitItem()) + coVerify { mockRelayListRepository.updateSelectedRelayLocation(relayItemId) } + } } - } @Test - fun `when onSearchTermInput returns empty result uiState should return empty list`() = runTest { - // Arrange - filteredRelayList.value = testCountries - val mockSearchString = "SEARCH" - - // Act, Assert - viewModel.uiState.test { - // Wait for first data - assertIs<SelectLocationUiState.Content>(awaitItem()) - - // Update search string - viewModel.onSearchTermInput(mockSearchString) - - // We get some unnecessary emissions for now - awaitItem() - awaitItem() + fun `on selectRelay when relay list type is entry call uiSideEffect should switch relay list type to exit`() = + runTest { + // Arrange + val mockRelayItem: RelayItem.Location.Country = mockk() + val relayItemId: GeoLocationId.Country = mockk(relaxed = true) + every { mockRelayItem.id } returns relayItemId + coEvery { mockWireguardConstraintsRepository.setEntryLocation(relayItemId) } returns + Unit.right() - // Assert - val actualState = awaitItem() - assertIs<SelectLocationUiState.Content>(actualState) - assertEquals( - listOf(RelayListItem.LocationsEmptyText(mockSearchString)), - actualState.relayListItems, - ) + // Act, Assert + viewModel.uiState.test { + awaitItem() // Default value + viewModel.selectRelayList(RelayListType.ENTRY) + // Assert relay list type is entry + assertEquals(RelayListType.ENTRY, awaitItem().relayListType) + // Select entry + viewModel.selectRelay(mockRelayItem) + // Await an empty item + assertEquals(RelayListType.EXIT, awaitItem().relayListType) + coVerify { mockWireguardConstraintsRepository.setEntryLocation(relayItemId) } + } } - } @Test fun `removeOwnerFilter should invoke use case with Constraint Any Ownership`() = runTest { @@ -372,17 +279,6 @@ class SelectLocationViewModelTest { } } - private fun RelayListItem.relayItemId() = - when (this) { - is RelayListItem.CustomListFooter -> null - RelayListItem.CustomListHeader -> null - RelayListItem.LocationHeader -> null - is RelayListItem.LocationsEmptyText -> null - is RelayListItem.CustomListEntryItem -> item.id - is RelayListItem.CustomListItem -> item.id - is RelayListItem.GeoLocationItem -> item.id - } - companion object { private const val RELAY_LIST_EXTENSIONS = "net.mullvad.mullvadvpn.relaylist.RelayListExtensionsKt" @@ -390,21 +286,5 @@ class SelectLocationViewModelTest { "net.mullvad.mullvadvpn.relaylist.RelayItemExtensionsKt" private const val CUSTOM_LIST_EXTENSIONS = "net.mullvad.mullvadvpn.relaylist.CustomListExtensionsKt" - - private val testCountries = - listOf( - RelayItem.Location.Country( - id = GeoLocationId.Country("se"), - "Sweden", - listOf( - RelayItem.Location.City( - id = GeoLocationId.City(GeoLocationId.Country("se"), "got"), - "Gothenburg", - emptyList(), - ) - ), - ), - RelayItem.Location.Country(id = GeoLocationId.Country("no"), "Norway", emptyList()), - ) } } diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt index ad4fb20a22..bd27574cbe 100644 --- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt @@ -95,6 +95,7 @@ import net.mullvad.mullvadvpn.lib.model.RedeemVoucherSuccess import net.mullvad.mullvadvpn.lib.model.RelayConstraints import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.model.RelayItemId as ModelRelayItemId +import net.mullvad.mullvadvpn.lib.model.RelayItemId import net.mullvad.mullvadvpn.lib.model.RelayList as ModelRelayList import net.mullvad.mullvadvpn.lib.model.RelayList import net.mullvad.mullvadvpn.lib.model.RelaySettings @@ -122,6 +123,8 @@ import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken import net.mullvad.mullvadvpn.lib.model.WireguardEndpointData as ModelWireguardEndpointData import net.mullvad.mullvadvpn.lib.model.addresses import net.mullvad.mullvadvpn.lib.model.customOptions +import net.mullvad.mullvadvpn.lib.model.entryLocation +import net.mullvad.mullvadvpn.lib.model.isMultihopEnabled import net.mullvad.mullvadvpn.lib.model.location import net.mullvad.mullvadvpn.lib.model.ownership import net.mullvad.mullvadvpn.lib.model.port @@ -131,7 +134,6 @@ import net.mullvad.mullvadvpn.lib.model.selectedObfuscationMode import net.mullvad.mullvadvpn.lib.model.shadowsocks import net.mullvad.mullvadvpn.lib.model.state import net.mullvad.mullvadvpn.lib.model.udp2tcp -import net.mullvad.mullvadvpn.lib.model.useMultihop import net.mullvad.mullvadvpn.lib.model.wireguardConstraints @Suppress("TooManyFunctions") @@ -757,7 +759,7 @@ class ManagementService( Either.catch { val relaySettings = getSettings().relaySettings val updated = - RelaySettings.relayConstraints.wireguardConstraints.useMultihop.set( + RelaySettings.relayConstraints.wireguardConstraints.isMultihopEnabled.set( relaySettings, enabled, ) @@ -767,6 +769,22 @@ class ManagementService( .mapLeft(SetWireguardConstraintsError::Unknown) .mapEmpty() + suspend fun setEntryLocation( + entryLocation: RelayItemId + ): Either<SetWireguardConstraintsError, Unit> = + Either.catch { + val relaySettings = getSettings().relaySettings + val updated = + RelaySettings.relayConstraints.wireguardConstraints.entryLocation.set( + relaySettings, + Constraint.Only(entryLocation), + ) + grpc.setRelaySettings(updated.fromDomain()) + } + .onLeft { Logger.e("Set multihop error") } + .mapLeft(SetWireguardConstraintsError::Unknown) + .mapEmpty() + private fun <A> Either<A, Empty>.mapEmpty() = map {} private inline fun <B, C> Either<Throwable, B>.mapLeftStatus( diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt index 622e95d9dd..b3fe88bdc8 100644 --- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt @@ -126,7 +126,7 @@ internal fun CustomList.fromDomain(): ManagementInterface.CustomList = internal fun WireguardConstraints.fromDomain(): ManagementInterface.WireguardConstraints = ManagementInterface.WireguardConstraints.newBuilder() - .setUseMultihop(useMultihop) + .setUseMultihop(isMultihopEnabled) .setEntryLocation(entryLocation.fromDomain()) .apply { when (val port = this@fromDomain.port) { diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt index 236d4aa19c..fe0222596b 100644 --- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt @@ -336,7 +336,7 @@ internal fun ManagementInterface.WireguardConstraints.toDomain(): WireguardConst } else { Constraint.Any }, - useMultihop = useMultihop, + isMultihopEnabled = useMultihop, entryLocation = entryLocation.toDomain(), ) @@ -644,8 +644,8 @@ internal fun ManagementInterface.FeatureIndicator.toDomain() = ManagementInterface.FeatureIndicator.CUSTOM_MTU -> FeatureIndicator.CUSTOM_MTU ManagementInterface.FeatureIndicator.DAITA -> FeatureIndicator.DAITA ManagementInterface.FeatureIndicator.SHADOWSOCKS -> FeatureIndicator.SHADOWSOCKS + ManagementInterface.FeatureIndicator.MULTIHOP -> FeatureIndicator.MULTIHOP ManagementInterface.FeatureIndicator.LOCKDOWN_MODE, - ManagementInterface.FeatureIndicator.MULTIHOP, ManagementInterface.FeatureIndicator.BRIDGE_MODE, ManagementInterface.FeatureIndicator.CUSTOM_MSS_FIX, ManagementInterface.FeatureIndicator.UNRECOGNIZED -> diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/FeatureIndicator.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/FeatureIndicator.kt index 3c8df824f4..0da5704b4b 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/FeatureIndicator.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/FeatureIndicator.kt @@ -4,7 +4,7 @@ package net.mullvad.mullvadvpn.lib.model enum class FeatureIndicator { DAITA, QUANTUM_RESISTANCE, - // MULTIHOP, + MULTIHOP, SPLIT_TUNNELING, UDP_2_TCP, SHADOWSOCKS, diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItemSelection.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItemSelection.kt new file mode 100644 index 0000000000..c4c78ffe4c --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItemSelection.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface RelayItemSelection { + val exitLocation: Constraint<RelayItemId> + + data class Single(override val exitLocation: Constraint<RelayItemId>) : RelayItemSelection + + data class Multiple( + val entryLocation: Constraint<RelayItemId>, + override val exitLocation: Constraint<RelayItemId>, + ) : RelayItemSelection +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardConstraints.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardConstraints.kt index 7af0144cf4..dcc3a957df 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardConstraints.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardConstraints.kt @@ -5,7 +5,7 @@ import arrow.optics.optics @optics data class WireguardConstraints( val port: Constraint<Port>, - val useMultihop: Boolean, + val isMultihopEnabled: Boolean, val entryLocation: Constraint<RelayItemId>, ) { companion object diff --git a/android/lib/resource/src/main/res/drawable-hdpi/multihop_illustration.png b/android/lib/resource/src/main/res/drawable-hdpi/multihop_illustration.png Binary files differnew file mode 100644 index 0000000000..4b39420e31 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-hdpi/multihop_illustration.png diff --git a/android/lib/resource/src/main/res/drawable-mdpi/multihop_illustration.png b/android/lib/resource/src/main/res/drawable-mdpi/multihop_illustration.png Binary files differnew file mode 100644 index 0000000000..50d3064f25 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-mdpi/multihop_illustration.png diff --git a/android/lib/resource/src/main/res/drawable-xhdpi/multihop_illustration.png b/android/lib/resource/src/main/res/drawable-xhdpi/multihop_illustration.png Binary files differnew file mode 100644 index 0000000000..c7cdd85f7e --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-xhdpi/multihop_illustration.png diff --git a/android/lib/resource/src/main/res/drawable-xxhdpi/multihop_illustration.png b/android/lib/resource/src/main/res/drawable-xxhdpi/multihop_illustration.png Binary files differnew file mode 100644 index 0000000000..bccd71a158 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-xxhdpi/multihop_illustration.png diff --git a/android/lib/resource/src/main/res/drawable-xxxhdpi/multihop_illustration.png b/android/lib/resource/src/main/res/drawable-xxxhdpi/multihop_illustration.png Binary files differnew file mode 100644 index 0000000000..9246fad11c --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-xxxhdpi/multihop_illustration.png 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 890ae1b0bc..88c135c304 100644 --- a/android/lib/resource/src/main/res/values-da/strings.xml +++ b/android/lib/resource/src/main/res/values-da/strings.xml @@ -132,9 +132,11 @@ <string name="enable_method">Aktiver metode</string> <string name="enter_value_placeholder">Indtast MTU</string> <string name="enter_voucher_code">Indtast kuponkode</string> + <string name="entry">Indgang</string> <string name="error_occurred">Der opstod en fejl.</string> <string name="error_state">KUNNE IKKE SIKRE FORBINDELSEN</string> <string name="exclude_applications">Ekskluderede applikationer</string> + <string name="exit">Udgang</string> <string name="failed_to_block_internet">Kan ikke blokere al netværkstrafik. Udfør fejlfinding, eller indsend en problemrapport.</string> <string name="failed_to_create_account">Kunne ikke oprette konto</string> <string name="failed_to_fetch_devices">Kunne ikke hente listen over enheder</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">Server IP tilsidesættelse</string> <string name="feature_udp_2_tcp">Tilsløring</string> <string name="filter">Filter</string> - <string name="filtered">Filtreret:</string> <string name="foreground_notification_channel_description">Viser den aktuelle VPN-tunnelstatus</string> <string name="foreground_notification_channel_name">VPN-tunnelstatus</string> <string name="go_to_login">Gå til login</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">For mange enheder</string> <string name="more_information">Mere information</string> <string name="mullvad_owned_only">Kun ejet af Mullvad</string> + <string name="multihop">Multihop</string> + <string name="multihop_description">Multihop dirigerer din trafik ind på en WireGuard-server og ud på en anden, hvilket gør det sværere at spore den. Dette resulterer i øget ventetid, men øger anonymiteten online.</string> <string name="name">Navn</string> <string name="name_was_changed_to">Navnet blev ændret til %1$s</string> <string name="new_device_notification_message">Velkommen! Denne enhed hedder nu <b>%1$s</b>. Se info-knappen i Konto for at flere oplysninger.</string> @@ -259,8 +262,6 @@ <string name="save">Gem</string> <string name="search_placeholder">Søg efter...</string> <string name="select_location">Vælg placering</string> - <string name="select_location_empty_text_first_row">Intet resultat for <b>%1$s</b>.</string> - <string name="select_location_empty_text_second_row">Prøv en anden søgning.</string> <string name="send">Send</string> <string name="send_anyway">Send alligevel</string> <string name="sending">Sender...</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">Den automatiske indstilling vælger tilfældigt fra de gyldige rækker af porte nedenfor.</string> <string name="wireguard_port_info_port_range">Den brugerdefinerede port kan være en hvilken som helst værdi inden for de gyldige intervaller: %1$s.</string> <string name="wireguard_port_title">WireGuard-port</string> + <string name="x_via_x">%1$s via %2$s</string> </resources> 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 c992536bb2..fdaceb2899 100644 --- a/android/lib/resource/src/main/res/values-de/strings.xml +++ b/android/lib/resource/src/main/res/values-de/strings.xml @@ -132,9 +132,11 @@ <string name="enable_method">Methode aktivieren</string> <string name="enter_value_placeholder">MTU eingeben</string> <string name="enter_voucher_code">Gutscheincode eingeben</string> + <string name="entry">Eingang</string> <string name="error_occurred">Ein Fehler ist aufgetreten.</string> <string name="error_state">SICHERE VERBINDUNG KONNTE NICHT HERGESTELLT WERDEN</string> <string name="exclude_applications">Ausgeschlossene Anwendungen</string> + <string name="exit">Ausgang</string> <string name="failed_to_block_internet">Der Netzwerk-Traffic konnte nicht gänzlich blockiert werden. Bitte beheben Sie den Fehler oder senden Sie einen Problembericht.</string> <string name="failed_to_create_account">Konto konnte nicht erstellt werden</string> <string name="failed_to_fetch_devices">Fehler beim Abrufen der Geräteliste</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">Server-IP überschreiben</string> <string name="feature_udp_2_tcp">Verschleierung</string> <string name="filter">Filter</string> - <string name="filtered">Gefiltert:</string> <string name="foreground_notification_channel_description">Zeigt den aktuellen Status des VPN-Tunnels an</string> <string name="foreground_notification_channel_name">Status des VPN-Tunnels</string> <string name="go_to_login">Zur Anmeldung</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">Zu viele Geräte</string> <string name="more_information">Weitere Informationen</string> <string name="mullvad_owned_only">Nur im Besitz von Mullvad</string> + <string name="multihop">Multihop</string> + <string name="multihop_description">Multihop leitet Ihren Traffic in einen WireGuard-Server hinein und aus einem anderen heraus, so dass er schwerer zu verfolgen ist. Dies führt zu einer erhöhten Latenzzeit, erhöht aber die Anonymität im Internet.</string> <string name="name">Name</string> <string name="name_was_changed_to">Name wurde geändert in %1$s</string> <string name="new_device_notification_message">Dieses Gerät heißt jetzt <b>%1$s</b>. Weitere Details finden Sie über die Info-Schaltfläche in Ihrem Konto.</string> @@ -259,8 +262,6 @@ <string name="save">Speichern</string> <string name="search_placeholder">Suchen nach …</string> <string name="select_location">Ort auswählen</string> - <string name="select_location_empty_text_first_row">Keine Ergebnisse für <b>%1$s</b>.</string> - <string name="select_location_empty_text_second_row">Versuchen Sie es mit einer anderen Suchanfrage.</string> <string name="send">Senden</string> <string name="send_anyway">Trotzdem senden</string> <string name="sending">Wird gesendet...</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">Die automatische Einstellung wählt zufällig aus den unten gezeigten gültigen Portbereichen.</string> <string name="wireguard_port_info_port_range">Der benutzerdefinierte Port kann ein beliebiger Wert innerhalb dieser gültigen Bereiche sein: %1$s.</string> <string name="wireguard_port_title">WireGuard-Port</string> + <string name="x_via_x">%1$s über %2$s</string> </resources> 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 984c3d21cd..fb5981905b 100644 --- a/android/lib/resource/src/main/res/values-es/strings.xml +++ b/android/lib/resource/src/main/res/values-es/strings.xml @@ -132,9 +132,11 @@ <string name="enable_method">Habilitar método</string> <string name="enter_value_placeholder">Introducir MTU</string> <string name="enter_voucher_code">Escriba el código del cupón</string> + <string name="entry">Entrada</string> <string name="error_occurred">Se produjo un error.</string> <string name="error_state">NO SE PUDO PROTEGER LA CONEXIÓN</string> <string name="exclude_applications">Aplicaciones excluidas</string> + <string name="exit">Salida</string> <string name="failed_to_block_internet">No se puede bloquear todo el tráfico de red. Intente solucionar el problema o envíe un informe de problemas.</string> <string name="failed_to_create_account">No se puede crear la cuenta</string> <string name="failed_to_fetch_devices">No se pudo obtener la lista de dispositivos</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">Anulación de IP de servidor</string> <string name="feature_udp_2_tcp">Ofuscación</string> <string name="filter">Filtrar</string> - <string name="filtered">Filtros aplicados:</string> <string name="foreground_notification_channel_description">Muestra el estado actual del túnel VPN</string> <string name="foreground_notification_channel_name">Estado del túnel VPN</string> <string name="go_to_login">Iniciar sesión</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">Demasiados dispositivos</string> <string name="more_information">Más información</string> <string name="mullvad_owned_only">Solo propiedad de Mullvad</string> + <string name="multihop">Salto múltiple</string> + <string name="multihop_description">El salto múltiple dirige su tráfico a través de un servidor WireGuard y lo envía a otro, lo que dificulta su rastreo. Esto genera una mayor latencia, pero aumenta el anonimato en Internet.</string> <string name="name">Nombre</string> <string name="name_was_changed_to">Se ha cambiado el nombre a %1$s</string> <string name="new_device_notification_message">Hola, este dispositivo se llama ahora <b>%1$s</b>. Para más información, consulte el botón de información en la Cuenta.</string> @@ -259,8 +262,6 @@ <string name="save">Guardar</string> <string name="search_placeholder">Buscar...</string> <string name="select_location">Seleccionar ubicación</string> - <string name="select_location_empty_text_first_row">No hay resultados para <b>%1$s</b>.</string> - <string name="select_location_empty_text_second_row">Pruebe con otra búsqueda.</string> <string name="send">Enviar</string> <string name="send_anyway">Enviar de todos modos</string> <string name="sending">Enviando…</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">El ajuste automático se elegirá al azar entre los rangos de puertos válidos que se muestran a continuación.</string> <string name="wireguard_port_info_port_range">El puerto personalizado pueder ser cualquier valor dentro de los rangos válidos: %1$s.</string> <string name="wireguard_port_title">Puerto de WireGuard</string> + <string name="x_via_x">%1$s a través de %2$s</string> </resources> 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 09069d2b8b..38e2a76a8d 100644 --- a/android/lib/resource/src/main/res/values-fi/strings.xml +++ b/android/lib/resource/src/main/res/values-fi/strings.xml @@ -132,9 +132,11 @@ <string name="enable_method">Ota menetelmä käyttöön</string> <string name="enter_value_placeholder">Syötä MTU</string> <string name="enter_voucher_code">Syötä kuponkikoodi</string> + <string name="entry">Tulo</string> <string name="error_occurred">Ilmeni virhe.</string> <string name="error_state">YHTEYDEN SUOJAAMINEN EPÄONNISTUI</string> <string name="exclude_applications">Poissuljetut sovellukset</string> + <string name="exit">Lähtö</string> <string name="failed_to_block_internet">Kaiken verkkoliikenteen estäminen ei onnistu. Käytä vianetsintää tai lähetä ongelmaraportti.</string> <string name="failed_to_create_account">Tilin luonti epäonnistui</string> <string name="failed_to_fetch_devices">Laiteluettelon nouto epäonnistui</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">Palvelimen IP-osoitteen ohitus</string> <string name="feature_udp_2_tcp">Hämäysteknologia</string> <string name="filter">Suodatin</string> - <string name="filtered">Suodatettu:</string> <string name="foreground_notification_channel_description">Näyttää VPN-tunnelin nykyisen tilan</string> <string name="foreground_notification_channel_name">VPN-tunnelin tila</string> <string name="go_to_login">Siirry kirjautumiseen</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">Liikaa laitteita</string> <string name="more_information">Lisätietoja</string> <string name="mullvad_owned_only">Vain Mullvadin omistamat</string> + <string name="multihop">Multihop</string> + <string name="multihop_description">Multihop reitittää liikenteesi yhteen WireGuard-palvelimeen ja ulos toisesta palvelimesta, mikä tekee siitä hankalampaa jäljittää. Tuloksena on suurempi viive, mutta se parantaa nimettömyyttä verkossa.</string> <string name="name">Nimi</string> <string name="name_was_changed_to">Nimeksi vaihdettiin \"%1$s\"</string> <string name="new_device_notification_message">Tervetuloa! Tämän laitteen nimi on nyt <b>%1$s</b>. Katso lisätietoja tilin infopainikkeesta.</string> @@ -259,8 +262,6 @@ <string name="save">Tallenna</string> <string name="search_placeholder">Hae...</string> <string name="select_location">Valitse sijainti</string> - <string name="select_location_empty_text_first_row">Ei tuloksia haulle <b>%1$s</b>.</string> - <string name="select_location_empty_text_second_row">Kokeile toista hakua.</string> <string name="send">Lähetä</string> <string name="send_anyway">Lähetä silti</string> <string name="sending">Lähetetään...</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">Automaattinen asetus valitsee satunnaisesti käytettävissä olevista, alla luetelluista porteista.</string> <string name="wireguard_port_info_port_range">Mukautettu portti voi olla mikä tahansa sallittu arvo: %1$s.</string> <string name="wireguard_port_title">WireGuard-portti</string> + <string name="x_via_x">%1$s, yhteys: %2$s</string> </resources> 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 d5245f8d51..cd70ac2701 100644 --- a/android/lib/resource/src/main/res/values-fr/strings.xml +++ b/android/lib/resource/src/main/res/values-fr/strings.xml @@ -132,9 +132,11 @@ <string name="enable_method">Activer la méthode</string> <string name="enter_value_placeholder">Saisir le MTU</string> <string name="enter_voucher_code">Saisir un code de bon</string> + <string name="entry">Entrée</string> <string name="error_occurred">Une erreur est survenue.</string> <string name="error_state">ÉCHEC DE LA SÉCURISATION DE LA CONNEXION</string> <string name="exclude_applications">Applications exclues</string> + <string name="exit">Sortie</string> <string name="failed_to_block_internet">Impossible de bloquer tout le trafic réseau. Veuillez dépanner ou envoyer un rapport de problème.</string> <string name="failed_to_create_account">Échec de la création du compte</string> <string name="failed_to_fetch_devices">Impossible de récupérer la liste des appareils</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">Substitution d\'IP de serveur</string> <string name="feature_udp_2_tcp">Dissimulation</string> <string name="filter">Filtrer</string> - <string name="filtered">Filtré :</string> <string name="foreground_notification_channel_description">Affiche l\'état actuel du tunnel VPN</string> <string name="foreground_notification_channel_name">État du tunnel VPN</string> <string name="go_to_login">Aller à la connexion</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">Trop d\'appareils</string> <string name="more_information">Plus d\'informations</string> <string name="mullvad_owned_only">Propriété de Mullvad uniquement</string> + <string name="multihop">Multihop</string> + <string name="multihop_description">Le multihop fait passer votre trafic par un serveur WireGuard et le fait sortir par un autre, ce qui le rend plus difficile à tracer. Cela se traduit par une latence accrue, mais plus d\'anonymat en ligne.</string> <string name="name">Nom</string> <string name="name_was_changed_to">Le nom a été changé en %1$s</string> <string name="new_device_notification_message">Bienvenue, cet appareil s\'appelle désormais <b>%1$s</b>. Pour plus d\'informations, consultez le bouton d\'information sous Compte.</string> @@ -259,8 +262,6 @@ <string name="save">Enregistrer</string> <string name="search_placeholder">Rechercher...</string> <string name="select_location">Sélectionner une localisation</string> - <string name="select_location_empty_text_first_row">Aucun résultat pour <b>%1$s</b>.</string> - <string name="select_location_empty_text_second_row">Essayez une autre recherche.</string> <string name="send">Envoyer</string> <string name="send_anyway">Envoyer quand même</string> <string name="sending">Envoi...</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">Le réglage automatique choisira au hasard parmi la plage de ports valide affichée ci-dessous.</string> <string name="wireguard_port_info_port_range">Le port personnalisé peut prendre n\'importe quelle valeur dans les plages valides : %1$s.</string> <string name="wireguard_port_title">Port WireGuard</string> + <string name="x_via_x">%1$s via %2$s</string> </resources> 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 55ac2960dc..70a08dbf7c 100644 --- a/android/lib/resource/src/main/res/values-it/strings.xml +++ b/android/lib/resource/src/main/res/values-it/strings.xml @@ -132,9 +132,11 @@ <string name="enable_method">Abilita metodo</string> <string name="enter_value_placeholder">Inserisci MTU</string> <string name="enter_voucher_code">Inserisci codice voucher</string> + <string name="entry">Ingresso</string> <string name="error_occurred">Si è verificato un errore.</string> <string name="error_state">IMPOSSIBILE STABILIRE UNA CONNESSIONE PROTETTA</string> <string name="exclude_applications">Applicazioni escluse</string> + <string name="exit">Uscita</string> <string name="failed_to_block_internet">Impossibile bloccare tutto il traffico di rete. Consulta la risoluzione dei problemi o invia una segnalazione del problema.</string> <string name="failed_to_create_account">Impossibile creare l\'account</string> <string name="failed_to_fetch_devices">Impossibile recuperare l\'elenco dei dispositivi</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">Sovrascritture IP server</string> <string name="feature_udp_2_tcp">Offuscamento</string> <string name="filter">Filtra</string> - <string name="filtered">Filtrato:</string> <string name="foreground_notification_channel_description">Mostra lo stato attuale del tunnel VPN</string> <string name="foreground_notification_channel_name">Stato del tunnel VPN</string> <string name="go_to_login">Vai al login</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">Troppi dispositivi</string> <string name="more_information">Maggiori informazioni</string> <string name="mullvad_owned_only">Solo di proprietà di Mullvad</string> + <string name="multihop">Multihop</string> + <string name="multihop_description">Il multihop instrada il tuo traffico in un server WireGuard in entrata e in un altro in uscita, rendendo più difficile il tracciamento. Questo aumenta la latenza ma aumenta anche l\'anonimato online.</string> <string name="name">Nome</string> <string name="name_was_changed_to">Il nome è stato modificato in %1$s</string> <string name="new_device_notification_message">Benvenuto, questo dispositivo ora si chiama <b>%1$s</b>. Per maggiori dettagli, premi il pulsante delle informazioni in Account.</string> @@ -259,8 +262,6 @@ <string name="save">Salva</string> <string name="search_placeholder">Cerca...</string> <string name="select_location">Seleziona posizione</string> - <string name="select_location_empty_text_first_row">Nessun risultato per <b>%1$s</b>.</string> - <string name="select_location_empty_text_second_row">Prova un\'altra ricerca.</string> <string name="send">Invia</string> <string name="send_anyway">Invia comunque</string> <string name="sending">Invio...</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">L\'impostazione automatica sceglierà in modo casuale una porta valida negli intervalli mostrati di seguito.</string> <string name="wireguard_port_info_port_range">La porta personalizzata può essere qualsiasi valore all\'interno degli intervalli validi: %1$s.</string> <string name="wireguard_port_title">Porta WireGuard</string> + <string name="x_via_x">%1$s tramite %2$s</string> </resources> 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 dd11e1b272..2fb24ebe8e 100644 --- a/android/lib/resource/src/main/res/values-ja/strings.xml +++ b/android/lib/resource/src/main/res/values-ja/strings.xml @@ -132,9 +132,11 @@ <string name="enable_method">方法を有効化する</string> <string name="enter_value_placeholder">MTU を入力</string> <string name="enter_voucher_code">バウチャーコードを入力</string> + <string name="entry">入口</string> <string name="error_occurred">エラー発生。</string> <string name="error_state">セキュリティ保護接続を確立できませんでした</string> <string name="exclude_applications">除外対象アプリケーション</string> + <string name="exit">出口</string> <string name="failed_to_block_internet">すべてのネットワークトラフィックをブロックできません。問題に対処するか、問題の報告を送信してください。</string> <string name="failed_to_create_account">アカウントを作成できませんでした</string> <string name="failed_to_fetch_devices">デバイスのリストを取得できませんでした</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">サーバーIPのオーバーライド</string> <string name="feature_udp_2_tcp">難読化</string> <string name="filter">絞り込み</string> - <string name="filtered">絞り込み結果:</string> <string name="foreground_notification_channel_description">現在のVPNトンネルのステータスを表示します</string> <string name="foreground_notification_channel_name">VPNトンネルのステータス</string> <string name="go_to_login">ログインに進む</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">デバイスが多すぎます</string> <string name="more_information">詳細情報</string> <string name="mullvad_owned_only">Mullvad 所有サーバーのみ</string> + <string name="multihop">マルチホップ</string> + <string name="multihop_description">マルチホップはトラフィックをあるWireGuardサーバーにルーティングし、別サーバーに送出することで追跡を困難にします。これによって遅延が増加しますが、オンラインの匿名性は高まります。</string> <string name="name">名前</string> <string name="name_was_changed_to">名前が %1$s に変更されました</string> <string name="new_device_notification_message">ようこそ。このデバイスの名前は<b>%1$s</b>です。詳細はアカウントの情報ボタンで確認してください。</string> @@ -259,8 +262,6 @@ <string name="save">保存</string> <string name="search_placeholder">検索...</string> <string name="select_location">場所を選択する</string> - <string name="select_location_empty_text_first_row"><b>%1$s</b>に該当する検索結果はありません。</string> - <string name="select_location_empty_text_second_row">別の検索をお試しください。</string> <string name="send">送信</string> <string name="send_anyway">とにかく送信する</string> <string name="sending">送信中...</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">自動設定では、以下の有効なポート範囲からランダムに選択されます。</string> <string name="wireguard_port_info_port_range">カスタムポートは次の有効範囲内の任意の値に設定できます: %1$s。</string> <string name="wireguard_port_title">WireGuardポート</string> + <string name="x_via_x">%1$s (%2$s経由)</string> </resources> 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 7b1d4f9f9f..f8727aabbc 100644 --- a/android/lib/resource/src/main/res/values-ko/strings.xml +++ b/android/lib/resource/src/main/res/values-ko/strings.xml @@ -132,9 +132,11 @@ <string name="enable_method">방법 활성화</string> <string name="enter_value_placeholder">MTU 입력</string> <string name="enter_voucher_code">바우처 코드 입력</string> + <string name="entry">시작</string> <string name="error_occurred">오류가 발생했습니다.</string> <string name="error_state">보안 연결 실패</string> <string name="exclude_applications">제외된 애플리케이션</string> + <string name="exit">종료</string> <string name="failed_to_block_internet">모든 네트워크 트래픽을 차단할 수는 없습니다. 문제를 해결하거나 문제 보고서를 보내주세요.</string> <string name="failed_to_create_account">계정을 만들지 못함</string> <string name="failed_to_fetch_devices">장치 목록을 가져오지 못함</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">서버 IP 재정의</string> <string name="feature_udp_2_tcp">난독 처리</string> <string name="filter">필터</string> - <string name="filtered">필터링됨:</string> <string name="foreground_notification_channel_description">현재 VPN 터널 상태 표시</string> <string name="foreground_notification_channel_name">VPN 터널 상태</string> <string name="go_to_login">로그인하기</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">장치가 너무 많음</string> <string name="more_information">추가 정보</string> <string name="mullvad_owned_only">Mullvad 소유만</string> + <string name="multihop">멀티홉</string> + <string name="multihop_description">멀티홉은 사용자의 트래픽을 하나의 WireGuard 서버로 라우팅하고 다른 서버로 전달하여 추적을 더 어렵게 만듭니다. 그로 인해 대기 시간은 증가하지만 온라인 익명성은 증대됩니다.</string> <string name="name">이름</string> <string name="name_was_changed_to">이름이 %1$s(으)로 변경되었습니다</string> <string name="new_device_notification_message">환영합니다! 이제 이 장치의 이름은 <b>%1$s</b>입니다. 자세한 내용을 보려면 계정의 정보 버튼을 누르세요.</string> @@ -259,8 +262,6 @@ <string name="save">저장</string> <string name="search_placeholder">검색...</string> <string name="select_location">위치 선택</string> - <string name="select_location_empty_text_first_row"><b>%1$s</b>에 대한 결과가 없습니다.</string> - <string name="select_location_empty_text_second_row">다른 검색어를 시도하세요.</string> <string name="send">전송</string> <string name="send_anyway">그래도 전송</string> <string name="sending">전송 중...</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">자동 설정은 아래 표시된 유효한 포트 범위에서 임의로 선택합니다.</string> <string name="wireguard_port_info_port_range">사용자 지정 포트는 유효한 범위 내의 모든 값이 될 수 있습니다: %1$s</string> <string name="wireguard_port_title">WireGuard 포트</string> + <string name="x_via_x">%1$s을(를) 통한 %2$s</string> </resources> 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 7b3ff221eb..5c9cd497f2 100644 --- a/android/lib/resource/src/main/res/values-my/strings.xml +++ b/android/lib/resource/src/main/res/values-my/strings.xml @@ -132,9 +132,11 @@ <string name="enable_method">နည်းလမ်းကို ဖွင့်ရန်</string> <string name="enter_value_placeholder">MTU ကို ရိုက်ထည့်ရန်</string> <string name="enter_voucher_code">ဘောက်ချာကုဒ် ဖြည့်သွင်းရန်</string> + <string name="entry">အဝင်</string> <string name="error_occurred">ချို့ယွင်းချက် ဖြစ်ပေါ်ခဲ့ပါသည်။</string> <string name="error_state">ချိတ်ဆက်မှုကို ကာကွယ်ရန် မအောင်မြင်ပါ</string> <string name="exclude_applications">အပလီကေးရှင်းများ ဖယ်ထားပြီး</string> + <string name="exit">အထွက်</string> <string name="failed_to_block_internet">ကွန်ရက် ကူးလူးမှု အားလုံးကို ပိတ်ဆို့၍ မရနိုင်ပါ။ ပြစ်ချက် ရှာဖွေဖယ်ရှားပေးပါ သို့မဟုတ် ပြဿနာ ရီပို့တ်တစ်ခု ပေးပို့ပါ။</string> <string name="failed_to_create_account">အကောင့် ဖန်တီးရန် မအောင်မြင်ခဲ့ပါ</string> <string name="failed_to_fetch_devices">စက်စာရင်းကို ယူရန် မအောင်မြင်ခဲ့ပါ</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">ဆာဗာ IP ကျော်လွန် ပယ်ဖျက်မှု</string> <string name="feature_udp_2_tcp">Obfuscation</string> <string name="filter">စစ်ထုတ်မှု</string> - <string name="filtered">စစ်ထုတ်ထားသော-</string> <string name="foreground_notification_channel_description">လက်ရှိ VPN Tunnel အခြေအနေကို ပြသပေးပါသည်</string> <string name="foreground_notification_channel_name">VPN Tunnel အခြေအနေ</string> <string name="go_to_login">ဝင်ရောက်ရန် သွားပါ</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">စက်များလွန်းနေသည်</string> <string name="more_information">နောက်ထပ်အချက်အလက်</string> <string name="mullvad_owned_only">Mullvad ပိုင်ဆိုင်သည်များသာ</string> + <string name="multihop">မာလ်တီဟော့ပ်</string> + <string name="multihop_description">မာလ်တီဟော့ပ်သည် သင်၏အသွားအလာကို WireGuard ဆာဗာတစ်ခုသို့ လမ်းကြောင်းပေးပြီး အခြားတစ်နေရာမှ ထွက်စေသောကြောင့် ခြေရာခံရန် ပိုမိုခက်ခဲစေသည်။ ၎င်းသည် အချိန်ကို ပိုမိုကြန့်ကြာစေသော်လည်း အွန်လိုင်းတွင် ပို၍ သိုသိုသိပ်သိပ်ဖြစ်စေသည်။</string> <string name="name">အမည်</string> <string name="name_was_changed_to">အမည်ကို %1$s သို့ ပြောင်းလိုက်ပါသည်</string> <string name="new_device_notification_message">ကြိုဆိုပါသည်၊ ယခုမှစ၍ ဤစက်ကို <b>%1$s</b> ဟု ခေါ်ဆိုပါမည်။ နောက်ထပ်အသေးစိတ်တို့အတွက် အကောင့်တွင် အချက်အလက် ခလုတ်ကို နှိပ်၍ ကြည့်နိုင်သည်။</string> @@ -259,8 +262,6 @@ <string name="save">သိမ်းမည်</string> <string name="search_placeholder">ရှာရန်...</string> <string name="select_location">တည်နေရာ ရွေးရန်</string> - <string name="select_location_empty_text_first_row"><b>%1$s</b> အတွက် ရလဒ် မရှိပါ။</string> - <string name="select_location_empty_text_second_row">မတူညီသော ရှာဖွေမှုဖြင့် ကြိုးစားကြည့်ပါ။</string> <string name="send">ပို့ရန်</string> <string name="send_anyway">မည်သို့ပင်ဖြစ်စေ ပို့ရန်</string> <string name="sending">ပို့နေဆဲ...</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">အော်တိုဆက်တင်သည် အောက်တွင် ဖော်ပြထားသည့် အကျုံးဝင် ပေါ့တ် အပိုင်းအခြားများထဲမှ ကျပန်းရွေးချယ်ပါမည်။</string> <string name="wireguard_port_info_port_range">စိတ်ကြိုက်ပေါ့တ်သည် အကျုံးဝင် အပိုင်းအခြားများထဲမှ မည်သည့်တန်ဖိုးမဆို ဖြစ်နိုင်ပါသည်- %1$s ။</string> <string name="wireguard_port_title">WireGuard ပေါ့တ်</string> + <string name="x_via_x">%1$s မှတစ်ဆင့် %2$s</string> </resources> 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 600930f1a2..bef18f4629 100644 --- a/android/lib/resource/src/main/res/values-nb/strings.xml +++ b/android/lib/resource/src/main/res/values-nb/strings.xml @@ -132,9 +132,11 @@ <string name="enable_method">Aktiver metoden</string> <string name="enter_value_placeholder">Angi MTU</string> <string name="enter_voucher_code">Skriv inn kupongkode</string> + <string name="entry">Inngang</string> <string name="error_occurred">Det oppstod en feil.</string> <string name="error_state">KUNNE IKKE OPPRETTE SIKKER TILKOBLING</string> <string name="exclude_applications">Ekskluder applikasjoner</string> + <string name="exit">Utgang</string> <string name="failed_to_block_internet">Kunne ikke blokkere all nettverkstrafikk. Feilsøk eller send inn en problemrapport.</string> <string name="failed_to_create_account">Kunne ikke opprette konto</string> <string name="failed_to_fetch_devices">Kunne ikke hente liste over enheter</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">Overstyring av server-IP</string> <string name="feature_udp_2_tcp">Tilsløring</string> <string name="filter">Filter</string> - <string name="filtered">Filtrert:</string> <string name="foreground_notification_channel_description">Viser gjeldende VPN-tunnelstatus</string> <string name="foreground_notification_channel_name">VPN-tunnelstatus</string> <string name="go_to_login">Gå til pålogging</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">For mange enheter</string> <string name="more_information">Mer informasjon</string> <string name="mullvad_owned_only">Kun eid av Mullvad</string> + <string name="multihop">Multihopp</string> + <string name="multihop_description">Multihopp dirigerer trafikken din inn på én WireGuard-server og ut på en annen, noe som gjør det vanskeligere å spore den. Dette resulterer i økt ventetid, men øker anonymiteten på nettet.</string> <string name="name">Navn</string> <string name="name_was_changed_to">Navn ble endret til %1$s</string> <string name="new_device_notification_message">Velkommen. Denne enheten har fått navnet <b>%1$s</b>. For å finne ut mer kan du bruke informasjonsknappen under Konto.</string> @@ -259,8 +262,6 @@ <string name="save">Lagre</string> <string name="search_placeholder">Søk etter ...</string> <string name="select_location">Velg plassering</string> - <string name="select_location_empty_text_first_row">Ingen resultater for <b>%1$s</b>.</string> - <string name="select_location_empty_text_second_row">Prøv et annet søk.</string> <string name="send">Send</string> <string name="send_anyway">Send allikevel</string> <string name="sending">Sender ...</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">Den automatiske innstillingen vil tilfeldig velge fra utvalget av gyldige porter vist under.</string> <string name="wireguard_port_info_port_range">Den egendefinerte porten kan ha en hvilken som helst verdi innen det gyldige utvalget: %1$s.</string> <string name="wireguard_port_title">WireGuard-port</string> + <string name="x_via_x">%1$s via %2$s</string> </resources> 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 4db93cec36..e965388f40 100644 --- a/android/lib/resource/src/main/res/values-nl/strings.xml +++ b/android/lib/resource/src/main/res/values-nl/strings.xml @@ -132,9 +132,11 @@ <string name="enable_method">Methode inschakelen</string> <string name="enter_value_placeholder">Voer MTU in</string> <string name="enter_voucher_code">Vouchercode invoeren</string> + <string name="entry">Ingang</string> <string name="error_occurred">Er is een fout opgetreden.</string> <string name="error_state">VERBINDING BEVEILIGEN MISLUKT</string> <string name="exclude_applications">Uitgesloten toepassingen</string> + <string name="exit">Uitgang</string> <string name="failed_to_block_internet">Kan niet alle netwerkverkeer blokkeren. Los problemen op of stuur een probleemmelding.</string> <string name="failed_to_create_account">Account aanmaken mislukt</string> <string name="failed_to_fetch_devices">Ophalen van lijst van apparaten mislukt</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">Overschrijving van server-IP-adressen</string> <string name="feature_udp_2_tcp">Obfuscatie</string> <string name="filter">Filter</string> - <string name="filtered">Gefilterd:</string> <string name="foreground_notification_channel_description">Toont de huidige status van de VPN-tunnel</string> <string name="foreground_notification_channel_name">Status VPN-tunnel</string> <string name="go_to_login">Ga naar aanmelden</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">Te veel apparaten</string> <string name="more_information">Meer informatie</string> <string name="mullvad_owned_only">Alleen in eigendom van Multivad</string> + <string name="multihop">Multihop</string> + <string name="multihop_description">Multihop leidt uw verkeer de ene WireGuard-server in en de andere uit, waardoor het moeilijker te traceren is. Dit leidt tot een hogere latentie, maar verhoogt de online anonimiteit.</string> <string name="name">Naam</string> <string name="name_was_changed_to">Naam is gewijzigd in %1$s</string> <string name="new_device_notification_message">Welkom, dit apparaat heet nu <b>%1$s</b>. Zie voor meer informatie de infoknop in Account.</string> @@ -259,8 +262,6 @@ <string name="save">Opslaan</string> <string name="search_placeholder">Zoeken naar...</string> <string name="select_location">Locatie selecteren</string> - <string name="select_location_empty_text_first_row">Geen resultaten voor <b>%1$s</b>.</string> - <string name="select_location_empty_text_second_row">Probeer een andere zoekopdracht.</string> <string name="send">Verzenden</string> <string name="send_anyway">Toch verzenden</string> <string name="sending">Verzenden...</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">Bij de automatische instelling wordt willekeurig gekozen uit de hieronder weergegeven geldige poortbereiken.</string> <string name="wireguard_port_info_port_range">De aangepaste poort kan elke waarde zijn binnen de geldige bereiken: %1$s.</string> <string name="wireguard_port_title">WireGuard-poort</string> + <string name="x_via_x">%1$s via %2$s</string> </resources> 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 080564c51e..9fd1e9de55 100644 --- a/android/lib/resource/src/main/res/values-pl/strings.xml +++ b/android/lib/resource/src/main/res/values-pl/strings.xml @@ -132,9 +132,11 @@ <string name="enable_method">Włącz metodę</string> <string name="enter_value_placeholder">Wprowadź MTU</string> <string name="enter_voucher_code">Wprowadź kod kuponu</string> + <string name="entry">Wejście</string> <string name="error_occurred">Wystąpił błąd.</string> <string name="error_state">BŁĄD ZABEZPIECZANIA POŁĄCZENIA</string> <string name="exclude_applications">Wykluczone aplikacje</string> + <string name="exit">Wyjście</string> <string name="failed_to_block_internet">Nie można zablokować całego ruchu sieciowego. Rozwiąż problem lub wyślij zgłoszenie problemu.</string> <string name="failed_to_create_account">Nie można utworzyć konta</string> <string name="failed_to_fetch_devices">Nie udało się pobrać listy urządzeń</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">Zastąpienie adresu IP serwera</string> <string name="feature_udp_2_tcp">Zaciemnianie</string> <string name="filter">Filtruj</string> - <string name="filtered">Odfiltrowane:</string> <string name="foreground_notification_channel_description">Pokazuje bieżący status tunelu VPN</string> <string name="foreground_notification_channel_name">Status tunelu VPN</string> <string name="go_to_login">Przejdź do logowania</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">Zbyt wiele urządzeń</string> <string name="more_information">Więcej informacji</string> <string name="mullvad_owned_only">Wyłącznie firmy Mullvad</string> + <string name="multihop">Wielokrotny przeskok</string> + <string name="multihop_description">Funkcja wielokrotnego przeskoku kieruje Twój ruch przychodzący do jednego serwera WireGuard, a wychodzący wysyła z innego, co utrudnia jego śledzenie. Skutkuje to zwiększoną latencją, ale zwiększa anonimowość online.</string> <string name="name">Nazwa</string> <string name="name_was_changed_to">Nazwę zmieniono na %1$s</string> <string name="new_device_notification_message">Witaj, to urządzenie nazywa się teraz <b>%1$s</b>. Więcej szczegółów znajdziesz, korzystając z przycisku Informacje na koncie.</string> @@ -259,8 +262,6 @@ <string name="save">Zapisz</string> <string name="search_placeholder">Wyszukaj...</string> <string name="select_location">Wybierz lokalizację</string> - <string name="select_location_empty_text_first_row">Brak wyników dla <b>%1$s</b>.</string> - <string name="select_location_empty_text_second_row">Wypróbuj inne wyszukiwanie.</string> <string name="send">Wyślij</string> <string name="send_anyway">Mimo to wyślij</string> <string name="sending">Wysyłanie...</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">Ustawienie automatyczne skutkuje wyborem losowym prawidłowego zakresu portów spośród zakresów przedstawionych poniżej.</string> <string name="wireguard_port_info_port_range">Port niestandardowy może mieć dowolną wartość z następujących prawidłowych zakresów: %1$s.</string> <string name="wireguard_port_title">Port WireGuard</string> + <string name="x_via_x">%1$s przez %2$s</string> </resources> 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 d3d230a5ba..34175af582 100644 --- a/android/lib/resource/src/main/res/values-pt/strings.xml +++ b/android/lib/resource/src/main/res/values-pt/strings.xml @@ -132,9 +132,11 @@ <string name="enable_method">Ativar método</string> <string name="enter_value_placeholder">Introduzir MTU</string> <string name="enter_voucher_code">Introduza o código do voucher</string> + <string name="entry">Entrada</string> <string name="error_occurred">Ocorreu um erro.</string> <string name="error_state">ERRO AO ESTABELECER LIGAÇÃO SEGURA</string> <string name="exclude_applications">Aplicações excluídas</string> + <string name="exit">Saída</string> <string name="failed_to_block_internet">Não foi possível bloquear todo o tráfego de rede. Experimente a resolução de problemas ou envie um relatório do problema.</string> <string name="failed_to_create_account">Não foi possível criar a conta</string> <string name="failed_to_fetch_devices">Erro ao obter a lista de dispositivos</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">Substituição de IP de servidor</string> <string name="feature_udp_2_tcp">Ofuscação</string> <string name="filter">Filtrar</string> - <string name="filtered">Filtrado:</string> <string name="foreground_notification_channel_description">Indica o estado atual do túnel VPN</string> <string name="foreground_notification_channel_name">Estado do túnel VPN</string> <string name="go_to_login">Ir para a ligação</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">Demasiados dispositivos</string> <string name="more_information">Mais informações</string> <string name="mullvad_owned_only">Apenas propriedade de Mullvad</string> + <string name="multihop">Multihop</string> + <string name="multihop_description">Multihop encaminha o tráfego para entrar num servidor WireGuard e sair por outro, dificultando o seguimento. Isto resulta em maior latência, mas aumenta o anonimato online.</string> <string name="name">Nome</string> <string name="name_was_changed_to">O nome foi alterado para %1$s</string> <string name="new_device_notification_message">Bem-vindo, este dispositivo é agora chamado <b>%1$s</b>. Para mais detalhes consulte o botão de informação na Conta.</string> @@ -259,8 +262,6 @@ <string name="save">Guardar</string> <string name="search_placeholder">Pesquisar por...</string> <string name="select_location">Selecionar localização</string> - <string name="select_location_empty_text_first_row">Sem resultados para <b>%1$s</b>.</string> - <string name="select_location_empty_text_second_row">Experimente uma pesquisa diferente.</string> <string name="send">Enviar</string> <string name="send_anyway">Enviar mesmo assim</string> <string name="sending">A enviar...</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">A definição automática escolherá aleatoriamente a partir do intervalo de portas válido apresentado abaixo.</string> <string name="wireguard_port_info_port_range">A porta personalizada pode ser qualquer valor dentro dos intervalos válidos: %1$s.</string> <string name="wireguard_port_title">Porta WireGuard</string> + <string name="x_via_x">%1$s via %2$s</string> </resources> 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 6f59122aa7..4d7e03a968 100644 --- a/android/lib/resource/src/main/res/values-ru/strings.xml +++ b/android/lib/resource/src/main/res/values-ru/strings.xml @@ -132,9 +132,11 @@ <string name="enable_method">Включить метод</string> <string name="enter_value_placeholder">Введите MTU</string> <string name="enter_voucher_code">Введите код ваучера</string> + <string name="entry">Вход</string> <string name="error_occurred">Произошла ошибка.</string> <string name="error_state">НЕ УДАЛОСЬ УСТАНОВИТЬ БЕЗОПАСНОЕ ПОДКЛЮЧЕНИЕ</string> <string name="exclude_applications">Исключенные приложения</string> + <string name="exit">Выход</string> <string name="failed_to_block_internet">Не удалось заблокировать весь сетевой трафик. Устраните неполадки или отправьте сообщение о проблеме.</string> <string name="failed_to_create_account">Не удалось создать учетную запись</string> <string name="failed_to_fetch_devices">Не удалось получить список устройств</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">Переопределение IP-адреса сервера</string> <string name="feature_udp_2_tcp">Обфускация</string> <string name="filter">Фильтр</string> - <string name="filtered">Фильтр:</string> <string name="foreground_notification_channel_description">Показывает текущее состояние VPN-туннеля</string> <string name="foreground_notification_channel_name">Состояние туннеля VPN</string> <string name="go_to_login">Войти</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">Слишком много устройств</string> <string name="more_information">Подробнее</string> <string name="mullvad_owned_only">Только принадлежащие Mullvad</string> + <string name="multihop">Многократный переход</string> + <string name="multihop_description">Функция «Многократный переход» перенаправляет трафик с одного сервера WireGuard на другой, что затрудняет отслеживание. Это увеличивает задержку, но зато повышает анонимность в сети.</string> <string name="name">Имя</string> <string name="name_was_changed_to">Имя изменено на «%1$s»</string> <string name="new_device_notification_message">Добро пожаловать, теперь это устройство называется <b>%1$s</b>. Для получения более подробной нажмите на кнопку «Информация» в учетной записи.</string> @@ -259,8 +262,6 @@ <string name="save">Сохранить</string> <string name="search_placeholder">Поиск...</string> <string name="select_location">Выбор местоположения</string> - <string name="select_location_empty_text_first_row">По запросу <b>%1$s</b> ничего не найдено.</string> - <string name="select_location_empty_text_second_row">Измените условие поиска.</string> <string name="send">Отправить</string> <string name="send_anyway">Все равно отправить</string> <string name="sending">Идет отправка...</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">При автоматической настройке порт будет выбираться случайным образом из допустимого диапазона, показанного ниже.</string> <string name="wireguard_port_info_port_range">Пользовательский порт может принимать любое значение внутри допустимых диапазонов: %1$s.</string> <string name="wireguard_port_title">Порт WireGuard</string> + <string name="x_via_x">%1$s через %2$s</string> </resources> 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 f3d185c5f9..a1f0557de7 100644 --- a/android/lib/resource/src/main/res/values-sv/strings.xml +++ b/android/lib/resource/src/main/res/values-sv/strings.xml @@ -132,9 +132,11 @@ <string name="enable_method">Aktivera metod</string> <string name="enter_value_placeholder">Ange MTU</string> <string name="enter_voucher_code">Ange kupongkod</string> + <string name="entry">Ingång</string> <string name="error_occurred">Ett fel har inträffat.</string> <string name="error_state">DET GICK INTE ATT SÄKRA ANSLUTNINGEN</string> <string name="exclude_applications">Exkluderade applikationer</string> + <string name="exit">Utgång</string> <string name="failed_to_block_internet">Det går inte att blockera all nätverkstrafik. Felsök eller skicka en problemrapport.</string> <string name="failed_to_create_account">Det gick inte att skapa konto</string> <string name="failed_to_fetch_devices">Det gick inte att hämta lista med enheter</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">Åsidosättning av server-IP</string> <string name="feature_udp_2_tcp">Obfuskering</string> <string name="filter">Filtrera</string> - <string name="filtered">Filtrerat:</string> <string name="foreground_notification_channel_description">Visar nuvarande status för VPN-tunnel</string> <string name="foreground_notification_channel_name">VPN-tunnelstatus</string> <string name="go_to_login">Gå till inloggning</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">För många enheter</string> <string name="more_information">Mer information</string> <string name="mullvad_owned_only">Endast Mullvad-ägd</string> + <string name="multihop">Multihopp</string> + <string name="multihop_description">Multihopp dirigerar din trafik till en WireGuard-server och ut genom en annan, vilket gör det svårare att spåra. Detta leder till ökad fördröjning men bättre anonymitet online.</string> <string name="name">Namn</string> <string name="name_was_changed_to">Namnet har ändrats till %1$s</string> <string name="new_device_notification_message">Välkommen! Den här enheten heter nu <b>%1$s</b>. Använd informationsknappen i Konto för mer information.</string> @@ -259,8 +262,6 @@ <string name="save">Spara</string> <string name="search_placeholder">Sök efter …</string> <string name="select_location">Välj plats</string> - <string name="select_location_empty_text_first_row">Inga resultat för <b>%1$s</b>.</string> - <string name="select_location_empty_text_second_row">Testa en annan sökning.</string> <string name="send">Skicka</string> <string name="send_anyway">Skicka ändå</string> <string name="sending">Skicka...</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">Den automatiska inställningen väljer slumpmässigt från giltiga portintervall som visas nedan.</string> <string name="wireguard_port_info_port_range">Den anpassade porten kan vara ett värde inom de giltiga intervallen: %1$s.</string> <string name="wireguard_port_title">WireGuard-port</string> + <string name="x_via_x">%1$s via %2$s</string> </resources> 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 3ceee70558..9664d0a917 100644 --- a/android/lib/resource/src/main/res/values-th/strings.xml +++ b/android/lib/resource/src/main/res/values-th/strings.xml @@ -132,9 +132,11 @@ <string name="enable_method">เปิดใช้งานวิธีการ</string> <string name="enter_value_placeholder">ป้อน MTU</string> <string name="enter_voucher_code">ป้อนรหัสบัตรกำนัล</string> + <string name="entry">เข้า</string> <string name="error_occurred">เกิดข้อผิดพลาดขึ้น</string> <string name="error_state">ไม่สามารถเชื่อมต่ออย่างปลอดภัยได้</string> <string name="exclude_applications">แอปพลิเคชันที่แยกออก</string> + <string name="exit">ออก</string> <string name="failed_to_block_internet">ไม่สามารถบล็อกการรับส่งข้อมูลทางเครือข่ายทั้งหมดได้ โปรดแก้ไขปัญหาหรือส่งรายงานปัญหา</string> <string name="failed_to_create_account">ไม่สามารถสร้างบัญชีได้</string> <string name="failed_to_fetch_devices">ไม่สามารถดึงรายการอุปกรณ์มาได้</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">โอเวอร์ไรด์ IP เซิร์ฟเวอร์</string> <string name="feature_udp_2_tcp">การทำให้ข้อมูลยุ่งเหยิง</string> <string name="filter">ตัวกรอง</string> - <string name="filtered">กรอง:</string> <string name="foreground_notification_channel_description">แสดงสถานะอุโมงค์ VPN ในปัจจุบัน</string> <string name="foreground_notification_channel_name">สถานะอุโมงค์ VPN</string> <string name="go_to_login">ไปเข้าสู่ระบบ</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">มีอุปกรณ์มากเกินไป</string> <string name="more_information">ข้อมูลเพิ่มเติม</string> <string name="mullvad_owned_only">ของ Mullvad เท่านั้น</string> + <string name="multihop">มัลติฮอป</string> + <string name="multihop_description">มัลติฮอปจะกำหนดเส้นทางการรับส่งข้อมูลของคุณ ไปยังหนึ่งในเซิร์ฟเวอร์ WireGuard และออกไปยังอีกเซิร์ฟเวอร์หนึ่ง ซึ่งทำให้ติดตามได้ยากขึ้น นี่จะส่งผลให้มีเวลาแฝงเพิ่มขึ้น แต่ก็จะช่วยปกปิดตัวตนออนไลน์ได้มากขึ้น</string> <string name="name">ชื่อ</string> <string name="name_was_changed_to">ชื่อถูกเปลี่ยนเป็น %1$s</string> <string name="new_device_notification_message">ยินดีต้อนรับ ขณะนี้อุปกรณ์นี้จะมีชื่อว่า <b>%1$s</b> สำหรับข้อมูลเพิ่มเติม โปรดกดปุ่มข้อมูลในบัญชี</string> @@ -259,8 +262,6 @@ <string name="save">บันทึก</string> <string name="search_placeholder">ค้นหา…</string> <string name="select_location">เลือกตำแหน่งที่ตั้ง</string> - <string name="select_location_empty_text_first_row">ไม่มีผลลัพธ์สำหรับ <b>%1$s</b></string> - <string name="select_location_empty_text_second_row">ลองใช้การค้นหาอื่น</string> <string name="send">ส่ง</string> <string name="send_anyway">ส่งต่อไป</string> <string name="sending">กำลังส่ง...</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">การตั้งค่าอัตโนมัติจะเป็นการสุ่มเลือกจากช่วงพอร์ตที่ใช้งานได้ต่างๆ ซึ่งแสดงอยู่ด้านล่าง</string> <string name="wireguard_port_info_port_range">พอร์ตแบบกำหนดเองอาจมีค่าใดๆ ก็ได้ ภายในช่วงที่ใช้งานได้: %1$s</string> <string name="wireguard_port_title">พอร์ต WireGuard</string> + <string name="x_via_x">%1$s ผ่าน %2$s</string> </resources> 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 296346242f..cdb01b9d2f 100644 --- a/android/lib/resource/src/main/res/values-tr/strings.xml +++ b/android/lib/resource/src/main/res/values-tr/strings.xml @@ -132,9 +132,11 @@ <string name="enable_method">Yöntemi etkinleştir</string> <string name="enter_value_placeholder">MTU\'yu girin</string> <string name="enter_voucher_code">Kupon kodunu girin</string> + <string name="entry">Giriş</string> <string name="error_occurred">Bir hata oluştu.</string> <string name="error_state">GÜVENLİ BAĞLANTI OLUŞTURULAMADI</string> <string name="exclude_applications">Hariç tutulan uygulamalar</string> + <string name="exit">Çıkış</string> <string name="failed_to_block_internet">Tüm ağ trafiği engellenemiyor. Lütfen sorunu çözmeyi deneyin veya bir hata raporu gönderin.</string> <string name="failed_to_create_account">Hesap oluşturulamadı</string> <string name="failed_to_fetch_devices">Cihaz listesi alınamadı</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">Sunucu IP\'sini geçersiz kılma</string> <string name="feature_udp_2_tcp">Gizleme</string> <string name="filter">Filtrele</string> - <string name="filtered">Filtrelendi:</string> <string name="foreground_notification_channel_description">Mevcut VPN tünelinin durumunu gösterir</string> <string name="foreground_notification_channel_name">VPN tüneli durumu</string> <string name="go_to_login">Giriş sayfasına git</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">Cihaz sayısı çok fazla</string> <string name="more_information">Daha fazla bilgi</string> <string name="mullvad_owned_only">Sadece Mullvad\'a ait olanlar</string> + <string name="multihop">Çoklu geçiş</string> + <string name="multihop_description">Çoklu geçiş, trafiğinizi bir WireGuard sunucusundan diğerine yönlendirerek izlemeyi zorlaştırır. Bu, gecikmenin artmasına neden olur ancak çevrimiçi gizliliği artırır.</string> <string name="name">Ad</string> <string name="name_was_changed_to">Ad, %1$s olarak değiştirildi</string> <string name="new_device_notification_message">Hoş geldiniz, bu cihazın adı artık <b>%1$s</b>. Daha fazla ayrıntı için Hesap içinden bilgi düğmesine bakın.</string> @@ -259,8 +262,6 @@ <string name="save">Kaydet</string> <string name="search_placeholder">Ara...</string> <string name="select_location">Konum seçin</string> - <string name="select_location_empty_text_first_row"><b>%1$s</b> için sonuç bulunamadı.</string> - <string name="select_location_empty_text_second_row">Farklı bir arama deneyin.</string> <string name="send">Gönder</string> <string name="send_anyway">Yine de gönder</string> <string name="sending">Gönderiliyor...</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">Otomatik ayar, aşağıda gösterilen geçerli port aralıklarından rastgele seçim yapar.</string> <string name="wireguard_port_info_port_range">Özel port, geçerli aralıklar içindeki herhangi bir değer olabilir: %1$s.</string> <string name="wireguard_port_title">WireGuard portu</string> + <string name="x_via_x">%1$s aracılığıyla %2$s</string> </resources> 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 82921a5001..39306a64ae 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 @@ -132,9 +132,11 @@ <string name="enable_method">启用方法</string> <string name="enter_value_placeholder">输入 MTU</string> <string name="enter_voucher_code">输入优惠码</string> + <string name="entry">入口</string> <string name="error_occurred">出错了。</string> <string name="error_state">无法保护连接</string> <string name="exclude_applications">排除的应用程序</string> + <string name="exit">出口</string> <string name="failed_to_block_internet">无法阻止所有网络流量。请排查问题或发送问题报告。</string> <string name="failed_to_create_account">无法创建帐户</string> <string name="failed_to_fetch_devices">无法获取设备列表</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">服务器 IP 覆盖</string> <string name="feature_udp_2_tcp">混淆</string> <string name="filter">筛选</string> - <string name="filtered">已筛选:</string> <string name="foreground_notification_channel_description">显示当前的 VPN 隧道状态</string> <string name="foreground_notification_channel_name">VPN 隧道状态</string> <string name="go_to_login">前往登录</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">设备过多</string> <string name="more_information">更多信息</string> <string name="mullvad_owned_only">仅 Mullvad 自有</string> + <string name="multihop">多跳</string> + <string name="multihop_description">多跳技术会将您的流量传输到一个 WireGuard 服务器并从另一个服务器传出,从而提高追踪的难度。这会导致延迟增加,但会提高在线匿名性。</string> <string name="name">名称</string> <string name="name_was_changed_to">名称已更改为“%1$s”</string> <string name="new_device_notification_message">欢迎,此设备现在名为 <b>%1$s</b>。有关详情,请点击“帐户”中的信息按钮。</string> @@ -259,8 +262,6 @@ <string name="save">保存</string> <string name="search_placeholder">搜索…</string> <string name="select_location">选择位置</string> - <string name="select_location_empty_text_first_row">没有关于<b>%1$s</b>的结果。</string> - <string name="select_location_empty_text_second_row">尝试其他搜索词。</string> <string name="send">发送</string> <string name="send_anyway">仍然发送</string> <string name="sending">正在发送…</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">自动设置将从下方显示的有效端口范围中随机选择。</string> <string name="wireguard_port_info_port_range">自定义端口可以是有效范围内的任何值:%1$s。</string> <string name="wireguard_port_title">WireGuard 端口</string> + <string name="x_via_x">%1$s,经由 %2$s</string> </resources> 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 cb0085a015..8be3c88b59 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 @@ -132,9 +132,11 @@ <string name="enable_method">啟用方式</string> <string name="enter_value_placeholder">輸入 MTU</string> <string name="enter_voucher_code">輸入優惠券兌換碼</string> + <string name="entry">入口</string> <string name="error_occurred">發生錯誤了。</string> <string name="error_state">保護連線失敗</string> <string name="exclude_applications">已排除的應用程式</string> + <string name="exit">出口</string> <string name="failed_to_block_internet">無法封鎖所有網路流量。請排除故障或傳送問題回報。</string> <string name="failed_to_create_account">無法建立帳戶</string> <string name="failed_to_fetch_devices">無法取得裝置清單</string> @@ -152,7 +154,6 @@ <string name="feature_server_ip_override">伺服器 IP 覆寫</string> <string name="feature_udp_2_tcp">混淆</string> <string name="filter">篩選</string> - <string name="filtered">已篩選:</string> <string name="foreground_notification_channel_description">顯示目前的 VPN 通道狀態</string> <string name="foreground_notification_channel_name">VPN 通道狀態</string> <string name="go_to_login">前往登入</string> @@ -199,6 +200,8 @@ <string name="max_devices_warning_title">裝置過多</string> <string name="more_information">更多資訊</string> <string name="mullvad_owned_only">僅 Mullvad 自有</string> + <string name="multihop">多點跳躍</string> + <string name="multihop_description">多點跳躍可將您的流量傳入一個 WireGuard 伺服器,再傳出至另一個伺服器,使其更難以追蹤。雖然這會導致延遲時間增加,卻能提高線上的匿名程度。</string> <string name="name">名稱</string> <string name="name_was_changed_to">名稱已變更為「%1$s」</string> <string name="new_device_notification_message">歡迎,此裝置現在稱為 <b>%1$s</b>。如需詳細資訊,請點按「帳戶」中的資訊按鈕。</string> @@ -259,8 +262,6 @@ <string name="save">儲存</string> <string name="search_placeholder">搜尋…</string> <string name="select_location">選擇位置</string> - <string name="select_location_empty_text_first_row"><b>%1$s</b> 沒有任何結果。</string> - <string name="select_location_empty_text_second_row">請嘗試使用其他關鍵字。</string> <string name="send">傳送</string> <string name="send_anyway">仍要傳送</string> <string name="sending">傳送中...</string> @@ -342,4 +343,5 @@ <string name="wireguard_port_info_description">自動設定將會隨機從下方顯示的有效連接埠範圍中進行選擇。</string> <string name="wireguard_port_info_port_range">自訂連接埠可以是有效範圍內的任何值:%1$s。</string> <string name="wireguard_port_title">WireGuard 連接埠</string> + <string name="x_via_x">%1$s,經由 %2$s</string> </resources> diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index b89488bc1a..4625fb3b5f 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -56,7 +56,6 @@ <string name="owned">Owned</string> <string name="rented">Rented</string> <string name="number_of_providers">Providers: %d</string> - <string name="filtered">Filtered:</string> <string name="mullvad_owned_only">Mullvad owned only</string> <string name="all_providers">All providers</string> <string name="rented_only">Rented only</string> @@ -223,10 +222,7 @@ <string name="wireguard_port_title">WireGuard port</string> <string name="wireguard_port_info_description">The automatic setting will randomly choose from the valid port ranges shown below.</string> <string name="search_placeholder">Search for...</string> - <string name="select_location_empty_text_first_row"> - <![CDATA[No result for <b>%s</b>.]]> - </string> - <string name="select_location_empty_text_second_row">Try a different search.</string> + <string name="search_location_empty_text">No result for \"%s\", please try a different search</string> <string name="wireguard_port_info_port_range">The custom port can be any value inside the valid ranges: %s.</string> <string name="wireguard_custon_port_title">Custom</string> <string name="port">Port</string> @@ -377,6 +373,7 @@ <string name="feature_server_ip_override">Server IP override</string> <string name="feature_custom_mtu">MTU</string> <string name="feature_daita">DAITA</string> + <string name="feature_multihop">Multihop</string> <string name="feature_dns_content_blockers">DNS content blockers</string> <string name="connection_details_ipv4">IPv4</string> <string name="connection_details_ipv6">IPv6</string> @@ -399,4 +396,15 @@ <string name="encrypted_dns_proxy_info_message_part1">With the “Encrypted DNS proxy” method, the app will communicate with our Mullvad API through a proxy address. It does this by retrieving an address from a DNS over HTTPS (DoH) server and then using that to reach our API servers.</string> <string name="encrypted_dns_proxy_info_message_part2">If you are not connected to our VPN, then the Encrypted DNS proxy will use your own non-VPN IP when connecting. The DoH servers are hosted by one of the following providers: Quad 9, CloudFlare, or Google.</string> <string name="connection_details_out">Out</string> + <string name="multihop">Multihop</string> + <string name="multihop_description">Multihop routes your traffic into one WireGuard server and out another, making it harder to trace. This results in increased latency but increases anonymity online.</string> + <string name="x_via_x">%s via %s</string> + <string name="entry">Entry</string> + <string name="exit">Exit</string> + <string name="clear_input">Clear input</string> + <string name="x_entry">%s (Entry)</string> + <string name="x_exit">%s (Exit)</string> + <string name="search_results">Search results</string> + <string name="filters">Filters:</string> + <string name="search_query_empty">Type at least 2 characters to start searching.</string> </resources> 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 2407fda047..6a5da5c18d 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 @@ -56,10 +56,13 @@ data class Dimensions( val relayCircleSize: Dp = 16.dp, val screenVerticalMargin: Dp = 22.dp, val searchFieldHeight: Dp = 42.dp, + // Search view full screen header container height (material design guidelines) + val searchFieldHeightExpanded: Dp = 72.dp, val searchFieldHorizontalPadding: Dp = 22.dp, val searchIconSize: Dp = 24.dp, val selectLocationTitlePadding: Dp = 12.dp, val selectableCellTextMargin: Dp = 12.dp, + val settingsDetailsImageMaxWidth: Dp = 480.dp, val sideMargin: Dp = 22.dp, val smallIconSize: Dp = 16.dp, val smallPadding: Dp = 8.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 aa2f40782c..501cb72946 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,9 +11,3 @@ val Shapes.chipShape: Shape get() { return RoundedCornerShape(8.dp) } - -val Shapes.fabShape: Shape - @Composable - get() { - return RoundedCornerShape(16.dp) - } diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot index 6b469ec9c5..ca8a81df02 100644 --- a/desktop/packages/mullvad-vpn/locales/messages.pot +++ b/desktop/packages/mullvad-vpn/locales/messages.pot @@ -2212,6 +2212,12 @@ msgstr "" msgid "%s (%s) hides patterns in your encrypted VPN traffic. If anyone is monitoring your connection, this makes it significantly harder for them to identify what websites you are visiting. It does this by carefully adding network noise and making all network packets the same size." msgstr "" +msgid "%s (Entry)" +msgstr "" + +msgid "%s (Exit)" +msgstr "" + msgid "%s (added)" msgstr "" @@ -2329,6 +2335,9 @@ msgstr "" msgid "Changes to DNS related settings might not go into effect immediately due to cached results." msgstr "" +msgid "Clear input" +msgstr "" + msgid "Collapse" msgstr "" @@ -2443,6 +2452,9 @@ msgstr "" msgid "File" msgstr "" +msgid "Filters:" +msgstr "" + msgid "Go to VPN settings" msgstr "" @@ -2509,6 +2521,9 @@ msgstr "" msgid "No locations found" msgstr "" +msgid "No result for \"%s\", please try a different search" +msgstr "" + msgid "Not found" msgstr "" @@ -2569,6 +2584,9 @@ msgstr "" msgid "Reset to default" msgstr "" +msgid "Search results" +msgstr "" + msgid "Set WireGuard MTU value. Valid range: %d - %d." msgstr "" @@ -2638,6 +2656,9 @@ msgstr "" msgid "Toggle VPN" msgstr "" +msgid "Type at least 2 characters to start searching." +msgstr "" + msgid "Unable to apply firewall rules. Please troubleshoot or send a problem report." msgstr "" |
