summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2024-11-27 08:57:53 +0100
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2024-11-27 08:57:53 +0100
commit0d155385e1cb7075012bd270de0398d83a438bc5 (patch)
tree43ab56fd00a39cb37dce3b60cb594f509d7057f4 /android
parent56e46c5cf783d41937e4eb2531a4d2e287381ee6 (diff)
parentffde55987991aeb7b7aad0e36e2a8402e0ab47d6 (diff)
downloadmullvadvpn-0d155385e1cb7075012bd270de0398d83a438bc5.tar.xz
mullvadvpn-0d155385e1cb7075012bd270de0398d83a438bc5.zip
Merge branch 'implement-multihop-ui-droid-822'
Diffstat (limited to 'android')
-rw-r--r--android/CHANGELOG.md2
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt1
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt7
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt2
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreenTest.kt105
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt (renamed from android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt)183
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt107
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterRow.kt47
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt40
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayListItemPreviewData.kt113
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt29
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt76
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SettingsUiStatePreviewParameterProvider.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt16
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MultihopScreen.kt89
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt938
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt57
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/LocationBottomSheet.kt426
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/RelayListContent.kt196
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreen.kt401
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt114
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt355
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterChip.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListItem.kt89
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListType.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt21
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt13
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt105
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SettingsUiState.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt39
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt20
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/WireguardConstraintsRepository.kt20
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCase.kt103
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilteredRelayListUseCase.kt24
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationUseCase.kt27
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt28
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Daita.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt25
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModel.kt26
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt436
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/CustomListEdit.kt75
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/Expand.kt21
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/RelayItemListCreator.kt355
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt211
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModel.kt89
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt145
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectRelay.kt21
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCaseTest.kt146
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationUseCaseTest.kt71
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModelTest.kt68
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt26
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt2
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt161
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModelTest.kt158
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt (renamed from android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt)240
-rw-r--r--android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt22
-rw-r--r--android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt2
-rw-r--r--android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt4
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/FeatureIndicator.kt2
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItemSelection.kt12
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardConstraints.kt2
-rw-r--r--android/lib/resource/src/main/res/drawable-hdpi/multihop_illustration.pngbin0 -> 45568 bytes
-rw-r--r--android/lib/resource/src/main/res/drawable-mdpi/multihop_illustration.pngbin0 -> 24763 bytes
-rw-r--r--android/lib/resource/src/main/res/drawable-xhdpi/multihop_illustration.pngbin0 -> 71127 bytes
-rw-r--r--android/lib/resource/src/main/res/drawable-xxhdpi/multihop_illustration.pngbin0 -> 136698 bytes
-rw-r--r--android/lib/resource/src/main/res/drawable-xxxhdpi/multihop_illustration.pngbin0 -> 220713 bytes
-rw-r--r--android/lib/resource/src/main/res/values-da/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-de/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-es/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-fi/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-fr/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-it/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-ja/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-ko/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-my/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-nb/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-nl/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-pl/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-pt/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-ru/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-sv/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-th/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-tr/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-zh-rCN/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values-zh-rTW/strings.xml8
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml18
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt3
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt6
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
new file mode 100644
index 0000000000..4b39420e31
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable-hdpi/multihop_illustration.png
Binary files differ
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
new file mode 100644
index 0000000000..50d3064f25
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable-mdpi/multihop_illustration.png
Binary files differ
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
new file mode 100644
index 0000000000..c7cdd85f7e
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable-xhdpi/multihop_illustration.png
Binary files differ
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
new file mode 100644
index 0000000000..bccd71a158
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable-xxhdpi/multihop_illustration.png
Binary files differ
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
new file mode 100644
index 0000000000..9246fad11c
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable-xxxhdpi/multihop_illustration.png
Binary files differ
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 &lt;b&gt;%1$s&lt;/b&gt;. 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 &lt;b&gt;%1$s&lt;/b&gt;.</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 &lt;b&gt;%1$s&lt;/b&gt;. 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 &lt;b&gt;%1$s&lt;/b&gt;.</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 &lt;b&gt;%1$s&lt;/b&gt;. 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 &lt;b&gt;%1$s&lt;/b&gt;.</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 &lt;b&gt;%1$s&lt;/b&gt;. 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 &lt;b&gt;%1$s&lt;/b&gt;.</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 &lt;b&gt;%1$s&lt;/b&gt;. 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 &lt;b&gt;%1$s&lt;/b&gt;.</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 &lt;b&gt;%1$s&lt;/b&gt;. 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 &lt;b&gt;%1$s&lt;/b&gt;.</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">ようこそ。このデバイスの名前は&lt;b&gt;%1$s&lt;/b&gt;です。詳細はアカウントの情報ボタンで確認してください。</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">&lt;b&gt;%1$s&lt;/b&gt;に該当する検索結果はありません。</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">환영합니다! 이제 이 장치의 이름은 &lt;b&gt;%1$s&lt;/b&gt;입니다. 자세한 내용을 보려면 계정의 정보 버튼을 누르세요.</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">&lt;b&gt;%1$s&lt;/b&gt;에 대한 결과가 없습니다.</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">ကြိုဆိုပါသည်၊ ယခုမှစ၍ ဤစက်ကို &lt;b&gt;%1$s&lt;/b&gt; ဟု ခေါ်ဆိုပါမည်။ နောက်ထပ်အသေးစိတ်တို့အတွက် အကောင့်တွင် အချက်အလက် ခလုတ်ကို နှိပ်၍ ကြည့်နိုင်သည်။</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">&lt;b&gt;%1$s&lt;/b&gt; အတွက် ရလဒ် မရှိပါ။</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 &lt;b&gt;%1$s&lt;/b&gt;. 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 &lt;b&gt;%1$s&lt;/b&gt;.</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 &lt;b&gt;%1$s&lt;/b&gt;. 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 &lt;b&gt;%1$s&lt;/b&gt;.</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 &lt;b&gt;%1$s&lt;/b&gt;. 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 &lt;b&gt;%1$s&lt;/b&gt;.</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 &lt;b&gt;%1$s&lt;/b&gt;. 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 &lt;b&gt;%1$s&lt;/b&gt;.</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">Добро пожаловать, теперь это устройство называется &lt;b&gt;%1$s&lt;/b&gt;. Для получения более подробной нажмите на кнопку «Информация» в учетной записи.</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">По запросу &lt;b&gt;%1$s&lt;/b&gt; ничего не найдено.</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 &lt;b&gt;%1$s&lt;/b&gt;. 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 &lt;b&gt;%1$s&lt;/b&gt;.</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">ยินดีต้อนรับ ขณะนี้อุปกรณ์นี้จะมีชื่อว่า &lt;b&gt;%1$s&lt;/b&gt; สำหรับข้อมูลเพิ่มเติม โปรดกดปุ่มข้อมูลในบัญชี</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">ไม่มีผลลัพธ์สำหรับ &lt;b&gt;%1$s&lt;/b&gt;</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 &lt;b&gt;%1$s&lt;/b&gt;. 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">&lt;b&gt;%1$s&lt;/b&gt; 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">欢迎,此设备现在名为 &lt;b&gt;%1$s&lt;/b&gt;。有关详情,请点击“帐户”中的信息按钮。</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">没有关于&lt;b&gt;%1$s&lt;/b&gt;的结果。</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">歡迎,此裝置現在稱為 &lt;b&gt;%1$s&lt;/b&gt;。如需詳細資訊,請點按「帳戶」中的資訊按鈕。</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">&lt;b&gt;%1$s&lt;/b&gt; 沒有任何結果。</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)
- }