diff options
Diffstat (limited to 'android')
91 files changed, 4356 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) - } |
