summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKalle Lindström <karl.lindstrom@mullvad.net>2025-07-22 14:26:22 +0200
committerKalle Lindström <karl.lindstrom@mullvad.net>2025-07-22 14:26:22 +0200
commitb2fc803af349205bc40d7cd00e0a480536c3d09e (patch)
treed603241a7e9ed6284f89704140f02c1a828518cb
parent75501a665b1bb7257cacd79f1eca84c839929725 (diff)
parent526ecbf7d85c8abe7af08daf04dc4bc0c6df109c (diff)
downloadmullvadvpn-b2fc803af349205bc40d7cd00e0a480536c3d09e.tar.xz
mullvadvpn-b2fc803af349205bc40d7cd00e0a480536c3d09e.zip
Merge branch 'implement-recents-support-ui'
-rw-r--r--android/CHANGELOG.md2
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreenTest.kt5
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt82
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsListUiStatePreviewParameterProvider.kt25
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/RelayListContent.kt60
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreen.kt15
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt107
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt158
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt26
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RecentsUseCase.kt71
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationTitleUseCase.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/RelayItemListCreator.kt100
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt15
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModel.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt29
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectRelay.kt25
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/RecentsUseCaseTest.kt139
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModelTest.kt22
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt5
-rw-r--r--android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt27
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt25
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml6
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItem.kt66
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItemPreviewData.kt49
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItem.kt13
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItemPreviewParameterProvider.kt65
-rw-r--r--desktop/packages/mullvad-vpn/locales/messages.pot18
34 files changed, 997 insertions, 209 deletions
diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md
index 9aa2feee91..e226ac3551 100644
--- a/android/CHANGELOG.md
+++ b/android/CHANGELOG.md
@@ -22,6 +22,8 @@ Line wrap the file at 100 chars. Th
* **Security**: in case of vulnerabilities.
## [Unreleased]
+### Added
+- Add list of recent server selections in the select location view.
## [android/2025.6-beta1] - 2025-07-17
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
index 7e03afb18c..fa41fa8ff5 100644
--- 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
@@ -14,6 +14,7 @@ import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_ITEM_CUSTOM_LISTS
import net.mullvad.mullvadvpn.compose.setContentWithTheme
import net.mullvad.mullvadvpn.compose.state.SearchLocationUiState
import net.mullvad.mullvadvpn.lib.model.CustomListId
+import net.mullvad.mullvadvpn.lib.model.Hop
import net.mullvad.mullvadvpn.lib.model.RelayItem
import net.mullvad.mullvadvpn.lib.model.RelayItemId
import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItem
@@ -40,7 +41,7 @@ class SearchLocationScreenTest {
private fun ComposeContext.initScreen(
state: Lce<Unit, SearchLocationUiState, Unit>,
- onSelectRelay: (RelayItem) -> Unit = {},
+ onSelectHop: (Hop) -> Unit = {},
onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit = { _, _, _ -> },
onSearchInputChanged: (String) -> Unit = {},
onCreateCustomList: (location: RelayItem.Location?) -> Unit = {},
@@ -62,7 +63,7 @@ class SearchLocationScreenTest {
setContentWithTheme {
SearchLocationScreen(
state = state,
- onSelectRelay = onSelectRelay,
+ onSelectHop = onSelectHop,
onToggleExpand = onToggleExpand,
onSearchInputChanged = onSearchInputChanged,
onCreateCustomList = onCreateCustomList,
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt
index 0767fc35ad..2b7b6ab977 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt
@@ -19,6 +19,7 @@ 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.lib.model.CustomListId
+import net.mullvad.mullvadvpn.lib.model.Hop
import net.mullvad.mullvadvpn.lib.model.RelayItem
import net.mullvad.mullvadvpn.lib.ui.component.relaylist.ItemPosition
import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItem
@@ -56,7 +57,7 @@ class SelectLocationScreenTest {
private fun ComposeContext.initScreen(
state: Lc<Unit, SelectLocationUiState> = Lc.Loading(Unit),
- onSelectRelay: (item: RelayItem) -> Unit = {},
+ onSelectHop: (hop: Hop) -> Unit = {},
onSearchClick: (RelayListType) -> Unit = {},
onBackClick: () -> Unit = {},
onFilterClick: () -> Unit = {},
@@ -77,12 +78,13 @@ class SelectLocationScreenTest {
onDeleteCustomList: (RelayItem.CustomList) -> Unit = {},
onSelectRelayList: (RelayListType) -> Unit = {},
openDaitaSettings: () -> Unit = {},
+ onRecentsToggleEnableClick: () -> Unit = {},
) {
setContentWithTheme {
SelectLocationScreen(
state = state,
- onSelectRelay = onSelectRelay,
+ onSelectHop = onSelectHop,
onSearchClick = onSearchClick,
onBackClick = onBackClick,
onFilterClick = onFilterClick,
@@ -97,6 +99,7 @@ class SelectLocationScreenTest {
onDeleteCustomList = onDeleteCustomList,
onSelectRelayList = onSelectRelayList,
openDaitaSettings = openDaitaSettings,
+ onRecentsToggleEnableClick = onRecentsToggleEnableClick,
)
}
}
@@ -112,7 +115,7 @@ class SelectLocationScreenTest {
relayListItems =
DUMMY_RELAY_COUNTRIES.map {
RelayListItem.GeoLocationItem(
- item = it,
+ hop = Hop.Single(it),
itemPosition = ItemPosition.Single,
)
},
@@ -129,6 +132,7 @@ class SelectLocationScreenTest {
relayListType = RelayListType.EXIT,
isSearchButtonEnabled = true,
isFilterButtonEnabled = true,
+ isRecentsEnabled = true,
)
)
)
@@ -164,6 +168,7 @@ class SelectLocationScreenTest {
relayListType = RelayListType.EXIT,
isSearchButtonEnabled = true,
isFilterButtonEnabled = true,
+ isRecentsEnabled = true,
)
)
)
@@ -173,10 +178,10 @@ class SelectLocationScreenTest {
}
@Test
- fun whenCustomListIsClickedShouldCallOnSelectRelay() =
+ fun whenCustomListIsClickedShouldCallOnSelectHop() =
composeExtension.use {
// Arrange
- val customList = DUMMY_RELAY_ITEM_CUSTOM_LISTS[0]
+ val customList = Hop.Single(DUMMY_RELAY_ITEM_CUSTOM_LISTS[0])
every { listViewModel.uiState } returns
MutableStateFlow(
Lce.Content(
@@ -186,7 +191,7 @@ class SelectLocationScreenTest {
)
)
)
- val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true)
+ val mockedOnSelectHop: (Hop) -> Unit = mockk(relaxed = true)
initScreen(
state =
Lc.Content(
@@ -196,34 +201,71 @@ class SelectLocationScreenTest {
relayListType = RelayListType.EXIT,
isSearchButtonEnabled = true,
isFilterButtonEnabled = true,
+ isRecentsEnabled = true,
)
),
- onSelectRelay = mockedOnSelectRelay,
+ onSelectHop = mockedOnSelectHop,
)
// Act
- onNodeWithText(customList.name).performClick()
+ onNodeWithText(customList.relay.name).performClick()
// Assert
- verify { mockedOnSelectRelay(customList) }
+ verify { mockedOnSelectHop(customList) }
+ }
+
+ @Test
+ fun whenRecentIsClickedShouldCallOnSelectHop() =
+ composeExtension.use {
+ // Arrange
+ val recent = Hop.Single(DUMMY_RELAY_COUNTRIES[0])
+ every { listViewModel.uiState } returns
+ MutableStateFlow(
+ Lce.Content(
+ SelectLocationListUiState(
+ relayListItems = listOf(RelayListItem.RecentListItem(recent)),
+ customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS,
+ )
+ )
+ )
+ val mockedOnSelectHop: (Hop) -> Unit = mockk(relaxed = true)
+ initScreen(
+ state =
+ Lc.Content(
+ SelectLocationUiState(
+ filterChips = emptyList(),
+ multihopEnabled = false,
+ relayListType = RelayListType.EXIT,
+ isSearchButtonEnabled = true,
+ isFilterButtonEnabled = true,
+ isRecentsEnabled = true,
+ )
+ ),
+ onSelectHop = mockedOnSelectHop,
+ )
+
+ // Act
+ onNodeWithText(recent.relay.name).performClick()
+
+ // Assert
+ verify { mockedOnSelectHop(recent) }
}
@Test
fun whenCustomListIsLongClickedShouldShowBottomSheet() =
composeExtension.use {
// Arrange
- val customList = DUMMY_RELAY_ITEM_CUSTOM_LISTS[0]
+ val customList = Hop.Single(DUMMY_RELAY_ITEM_CUSTOM_LISTS[0])
every { listViewModel.uiState } returns
MutableStateFlow(
Lce.Content(
SelectLocationListUiState(
- relayListItems =
- listOf(RelayListItem.CustomListItem(item = customList)),
+ relayListItems = listOf(RelayListItem.CustomListItem(hop = customList)),
customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS,
)
)
)
- val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true)
+ val mockedOnSelectHop: (Hop) -> Unit = mockk(relaxed = true)
initScreen(
state =
Lc.Content(
@@ -233,13 +275,14 @@ class SelectLocationScreenTest {
relayListType = RelayListType.EXIT,
isSearchButtonEnabled = true,
isFilterButtonEnabled = true,
+ isRecentsEnabled = true,
)
),
- onSelectRelay = mockedOnSelectRelay,
+ onSelectHop = mockedOnSelectHop,
)
// Act
- onNodeWithText(customList.name).performLongClick()
+ onNodeWithText(customList.relay.name).performLongClick()
// Assert
onNodeWithTag(SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG)
@@ -249,7 +292,7 @@ class SelectLocationScreenTest {
fun whenLocationIsLongClickedShouldShowBottomSheet() =
composeExtension.use {
// Arrange
- val relayItem = DUMMY_RELAY_COUNTRIES[0]
+ val relayItem = Hop.Single(DUMMY_RELAY_COUNTRIES[0] as RelayItem.Location)
every { listViewModel.uiState } returns
MutableStateFlow(
Lce.Content(
@@ -265,7 +308,7 @@ class SelectLocationScreenTest {
)
)
)
- val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true)
+ val mockedOnSelectHop: (Hop) -> Unit = mockk(relaxed = true)
initScreen(
state =
Lc.Content(
@@ -275,13 +318,14 @@ class SelectLocationScreenTest {
relayListType = RelayListType.EXIT,
isSearchButtonEnabled = true,
isFilterButtonEnabled = true,
+ isRecentsEnabled = true,
)
),
- onSelectRelay = mockedOnSelectRelay,
+ onSelectHop = mockedOnSelectHop,
)
// Act
- onNodeWithText(relayItem.name).performLongClick()
+ onNodeWithText(relayItem.relay.name).performLongClick()
// Assert
onNodeWithTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsListUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsListUiStatePreviewParameterProvider.kt
new file mode 100644
index 0000000000..38f4adc250
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsListUiStatePreviewParameterProvider.kt
@@ -0,0 +1,25 @@
+package net.mullvad.mullvadvpn.compose.preview
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState
+import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItemPreviewData
+import net.mullvad.mullvadvpn.util.Lce
+
+class SearchLocationsListUiStatePreviewParameterProvider :
+ PreviewParameterProvider<Lce<Unit, SelectLocationListUiState, Unit>> {
+ override val values =
+ sequenceOf(
+ Lce.Content(
+ SelectLocationListUiState(
+ relayListItems =
+ RelayListItemPreviewData.generateRelayListItems(
+ includeCustomLists = true,
+ isSearching = false,
+ ),
+ customLists = emptyList(),
+ )
+ ),
+ Lce.Loading(Unit),
+ Lce.Error(Unit),
+ )
+}
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 5a26fd4b33..34275cf241 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
@@ -19,6 +19,7 @@ class SelectLocationsUiStatePreviewParameterProvider :
relayListType = RelayListType.EXIT,
isSearchButtonEnabled = true,
isFilterButtonEnabled = true,
+ isRecentsEnabled = true,
)
.toLc(),
SelectLocationUiState(
@@ -31,6 +32,7 @@ class SelectLocationsUiStatePreviewParameterProvider :
relayListType = RelayListType.EXIT,
isSearchButtonEnabled = true,
isFilterButtonEnabled = true,
+ isRecentsEnabled = true,
)
.toLc(),
SelectLocationUiState(
@@ -39,6 +41,7 @@ class SelectLocationsUiStatePreviewParameterProvider :
relayListType = RelayListType.ENTRY,
isSearchButtonEnabled = true,
isFilterButtonEnabled = true,
+ isRecentsEnabled = true,
)
.toLc(),
SelectLocationUiState(
@@ -51,6 +54,7 @@ class SelectLocationsUiStatePreviewParameterProvider :
relayListType = RelayListType.ENTRY,
isSearchButtonEnabled = true,
isFilterButtonEnabled = true,
+ isRecentsEnabled = true,
)
.toLc(),
)
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
index fbb0eb4efd..4d7ca43f95 100644
--- 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
@@ -1,6 +1,8 @@
package net.mullvad.mullvadvpn.compose.screen.location
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
@@ -24,6 +26,7 @@ import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.S
import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowEditCustomListBottomSheet
import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowLocationBottomSheet
import net.mullvad.mullvadvpn.lib.model.CustomListId
+import net.mullvad.mullvadvpn.lib.model.Hop
import net.mullvad.mullvadvpn.lib.model.RelayItem
import net.mullvad.mullvadvpn.lib.model.RelayItemId
import net.mullvad.mullvadvpn.lib.theme.Dimens
@@ -38,7 +41,7 @@ import net.mullvad.mullvadvpn.lib.ui.tag.SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST
fun LazyListScope.relayListContent(
relayListItems: List<RelayListItem>,
customLists: List<RelayItem.CustomList>,
- onSelectRelay: (RelayItem) -> Unit,
+ onSelectHop: (Hop) -> Unit,
onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit,
onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit,
customListHeader: @Composable (LazyItemScope.() -> Unit) = {},
@@ -55,14 +58,14 @@ fun LazyListScope.relayListContent(
is RelayListItem.CustomListItem ->
CustomListItem(
listItem,
- onSelectRelay = onSelectRelay,
+ onSelectHop = onSelectHop,
onToggleExpand = onToggleExpand,
onUpdateBottomSheetState = onUpdateBottomSheetState,
)
is RelayListItem.CustomListEntryItem ->
CustomListEntryItem(
listItem,
- onSelectRelay = onSelectRelay,
+ onSelectHop = onSelectHop,
onToggleExpand = onToggleExpand,
onUpdateBottomSheetState = onUpdateBottomSheetState,
)
@@ -71,13 +74,18 @@ fun LazyListScope.relayListContent(
is RelayListItem.GeoLocationItem ->
GeoLocationItem(
listItem,
- onSelectRelay = onSelectRelay,
+ onSelectHop = onSelectHop,
onToggleExpand = onToggleExpand,
onUpdateBottomSheetState = onUpdateBottomSheetState,
customLists = customLists,
)
- is RelayListItem.LocationsEmptyText -> LocationsEmptyText(listItem.searchTerm)
+
+ RelayListItem.RecentsListHeader -> RecentsListHeader()
+ is RelayListItem.RecentListItem -> RecentListItem(listItem, onSelectHop)
+ RelayListItem.RecentsListFooter -> RecentsListFooter()
is RelayListItem.EmptyRelayList -> EmptyRelayListText()
+ is RelayListItem.LocationsEmptyText -> LocationsEmptyText(listItem.searchTerm)
+ is RelayListItem.SectionDivider -> SectionDivider()
}
}
},
@@ -96,14 +104,14 @@ fun Modifier.positionalPadding(itemPosition: ItemPosition): Modifier =
@Composable
private fun GeoLocationItem(
listItem: RelayListItem.GeoLocationItem,
- onSelectRelay: (RelayItem) -> Unit,
+ onSelectHop: (Hop) -> Unit,
onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit,
onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit,
customLists: List<RelayItem.CustomList>,
) {
SelectableRelayListItem(
relayListItem = listItem,
- onClick = { onSelectRelay(listItem.item) },
+ onClick = { onSelectHop(listItem.hop) },
onLongClick = {
onUpdateBottomSheetState(ShowLocationBottomSheet(customLists, listItem.item))
},
@@ -113,15 +121,26 @@ private fun GeoLocationItem(
}
@Composable
+private fun RecentListItem(listItem: RelayListItem.RecentListItem, onSelectHop: (Hop) -> Unit) {
+ SelectableRelayListItem(
+ relayListItem = listItem,
+ onClick = { onSelectHop(listItem.hop) },
+ onLongClick = {},
+ onToggleExpand = { _ -> },
+ modifier = Modifier.positionalPadding(listItem.itemPosition),
+ )
+}
+
+@Composable
private fun CustomListItem(
listItem: RelayListItem.CustomListItem,
- onSelectRelay: (RelayItem) -> Unit,
+ onSelectHop: (Hop) -> Unit,
onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit,
onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit,
) {
SelectableRelayListItem(
relayListItem = listItem,
- onClick = { onSelectRelay(listItem.item) },
+ onClick = { onSelectHop(listItem.hop) },
onLongClick = { onUpdateBottomSheetState(ShowEditCustomListBottomSheet(listItem.item)) },
onToggleExpand = { onToggleExpand(listItem.item.id, null, it) },
modifier = Modifier.positionalPadding(listItem.itemPosition),
@@ -131,13 +150,13 @@ private fun CustomListItem(
@Composable
private fun CustomListEntryItem(
listItem: RelayListItem.CustomListEntryItem,
- onSelectRelay: (RelayItem) -> Unit,
+ onSelectHop: (Hop) -> Unit,
onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit,
onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit,
) {
SelectableRelayListItem(
relayListItem = listItem,
- onClick = { onSelectRelay(listItem.item) },
+ onClick = { onSelectHop(listItem.hop) },
// Only direct children can be removed
onLongClick =
if (listItem.depth == 1) {
@@ -204,3 +223,22 @@ private fun RelayLocationHeader() {
}
)
}
+
+@Composable
+private fun RecentsListHeader() {
+ RelayListHeader(
+ content = {
+ Text(text = stringResource(id = R.string.recents), overflow = TextOverflow.Ellipsis)
+ }
+ )
+}
+
+@Composable
+private fun RecentsListFooter() {
+ SwitchComposeSubtitleCell(text = stringResource(R.string.no_recent_selection))
+}
+
+@Composable
+private fun SectionDivider() {
+ Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing))
+}
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
index 6d6fdff142..1b512b20b5 100644
--- 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
@@ -67,11 +67,13 @@ 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.Hop
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.lib.ui.component.relaylist.displayName
import net.mullvad.mullvadvpn.usecase.FilterChip
import net.mullvad.mullvadvpn.util.Lce
import net.mullvad.mullvadvpn.viewmodel.location.SearchLocationSideEffect
@@ -149,11 +151,14 @@ fun SearchLocation(
)
}
- is SearchLocationSideEffect.RelayItemInactive -> {
+ is SearchLocationSideEffect.HopInactive -> {
launch {
snackbarHostState.showSnackbarImmediately(
message =
- context.getString(R.string.relayitem_is_inactive, it.relayItem.name)
+ context.getString(
+ R.string.relayitem_is_inactive,
+ it.hop.displayName(context),
+ )
)
}
}
@@ -183,7 +188,7 @@ fun SearchLocation(
SearchLocationScreen(
state = state,
snackbarHostState = snackbarHostState,
- onSelectRelay = viewModel::selectRelay,
+ onSelectHop = viewModel::selectHop,
onToggleExpand = viewModel::onToggleExpand,
onSearchInputChanged = viewModel::onSearchInputUpdated,
onCreateCustomList =
@@ -228,7 +233,7 @@ fun SearchLocation(
fun SearchLocationScreen(
state: Lce<Unit, SearchLocationUiState, Unit>,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
- onSelectRelay: (RelayItem) -> Unit,
+ onSelectHop: (Hop) -> Unit,
onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit,
onSearchInputChanged: (String) -> Unit,
onCreateCustomList: (location: RelayItem.Location?) -> Unit,
@@ -308,7 +313,7 @@ fun SearchLocationScreen(
relayListContent(
relayListItems = state.value.relayListItems,
customLists = state.value.customLists,
- onSelectRelay = onSelectRelay,
+ onSelectHop = onSelectHop,
onToggleExpand = onToggleExpand,
onUpdateBottomSheetState = { newSheetState ->
locationBottomSheetState = newSheetState
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
index 4ef79723c0..1e918219cd 100644
--- 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
@@ -7,17 +7,24 @@ 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.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.toLowerCase
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.PrimaryButton
@@ -25,10 +32,14 @@ import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicator
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
import net.mullvad.mullvadvpn.compose.constant.ContentType
import net.mullvad.mullvadvpn.compose.extensions.animateScrollAndCentralizeItem
+import net.mullvad.mullvadvpn.compose.preview.SearchLocationsListUiStatePreviewParameterProvider
import net.mullvad.mullvadvpn.compose.state.RelayListType
import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState
import net.mullvad.mullvadvpn.compose.util.RunOnKeyChange
-import net.mullvad.mullvadvpn.lib.model.RelayItem
+import net.mullvad.mullvadvpn.lib.model.CustomListId
+import net.mullvad.mullvadvpn.lib.model.Hop
+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.lib.ui.component.relaylist.RelayListItem
@@ -37,6 +48,28 @@ import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationListViewModel
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
+@Preview("Content|Loading|Error")
+@Composable
+private fun PreviewSelectLocationList(
+ @PreviewParameter(SearchLocationsListUiStatePreviewParameterProvider::class)
+ state: Lce<Unit, SelectLocationListUiState, Unit>
+) {
+ AppTheme {
+ Surface {
+ SelectLocationListContent(
+ state = state,
+ lazyListState = rememberLazyListState(),
+ openDaitaSettings = {},
+ onSelectHop = {},
+ onUpdateBottomSheetState = {},
+ onAddCustomList = {},
+ onEditCustomLists = {},
+ onToggleExpand = { id: RelayItemId, id1: CustomListId?, bool: Boolean -> },
+ )
+ }
+ }
+}
+
private typealias EntryBlocked = Lce.Error<Unit>
private typealias Content = Lce.Content<SelectLocationListUiState>
@@ -44,7 +77,7 @@ private typealias Content = Lce.Content<SelectLocationListUiState>
@Composable
fun SelectLocationList(
relayListType: RelayListType,
- onSelectRelay: (RelayItem) -> Unit,
+ onSelectHop: (Hop) -> Unit,
openDaitaSettings: () -> Unit,
onAddCustomList: () -> Unit,
onEditCustomLists: (() -> Unit)?,
@@ -56,14 +89,41 @@ fun SelectLocationList(
parameters = { parametersOf(relayListType) },
)
val state by viewModel.uiState.collectAsStateWithLifecycle()
- val lazyListState = rememberLazyListState()
val stateActual = state
+
+ val lazyListState = rememberLazyListState()
RunOnKeyChange(stateActual is Content) {
stateActual.indexOfSelectedRelayItem()?.let { index ->
lazyListState.scrollToItem(index)
lazyListState.animateScrollAndCentralizeItem(index)
}
}
+
+ SelectLocationListContent(
+ state = state,
+ lazyListState = lazyListState,
+ openDaitaSettings = openDaitaSettings,
+ onSelectHop = onSelectHop,
+ onUpdateBottomSheetState = onUpdateBottomSheetState,
+ onAddCustomList = onAddCustomList,
+ onEditCustomLists = onEditCustomLists,
+ onToggleExpand = viewModel::onToggleExpand,
+ )
+}
+
+@Composable
+private fun SelectLocationListContent(
+ state: Lce<Unit, SelectLocationListUiState, Unit>,
+ lazyListState: LazyListState,
+ openDaitaSettings: () -> Unit,
+ onSelectHop: (Hop) -> Unit,
+ onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit,
+ onAddCustomList: () -> Unit,
+ onEditCustomLists: (() -> Unit)?,
+ onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit,
+) {
+ var prevTopItem by remember { mutableStateOf<RelayListItem?>(null) }
+
LazyColumn(
modifier =
Modifier.fillMaxSize()
@@ -81,28 +141,37 @@ fun SelectLocationList(
Arrangement.Top
},
) {
- when (stateActual) {
- is Lce.Loading -> {
- loading()
- }
- is EntryBlocked -> {
- entryBlocked(openDaitaSettings = openDaitaSettings)
- }
+ when (state) {
+ is Lce.Loading -> loading()
+ is EntryBlocked -> entryBlocked(openDaitaSettings = openDaitaSettings)
is Content -> {
+ // When recents have been disabled and are enabled again and we are at the
+ // top of the list we scroll up so that recents are visible again.
+ val shouldScrollToTop =
+ state.value.relayListItems[0] is RelayListItem.RecentsListHeader &&
+ prevTopItem !is RelayListItem.RecentsListHeader &&
+ lazyListState.firstVisibleItemIndex == 0 &&
+ lazyListState.firstVisibleItemScrollOffset == 0
+
+ prevTopItem = state.value.relayListItems[0]
+
relayListContent(
- relayListItems = stateActual.value.relayListItems,
- customLists = stateActual.value.customLists,
- onSelectRelay = onSelectRelay,
- onToggleExpand = viewModel::onToggleExpand,
+ relayListItems = state.value.relayListItems,
+ customLists = state.value.customLists,
+ onSelectHop = onSelectHop,
+ onToggleExpand = onToggleExpand,
onUpdateBottomSheetState = onUpdateBottomSheetState,
customListHeader = {
CustomListHeader(
onAddCustomList,
- if (stateActual.value.customLists.isNotEmpty()) onEditCustomLists
- else null,
+ if (state.value.customLists.isNotEmpty()) onEditCustomLists else null,
)
},
)
+
+ if (shouldScrollToTop) {
+ lazyListState.requestScrollToItem(0)
+ }
}
}
}
@@ -148,12 +217,16 @@ private fun Lce<Unit, SelectLocationListUiState, Unit>.indexOfSelectedRelayItem(
when (it) {
is RelayListItem.CustomListItem -> it.isSelected
is RelayListItem.GeoLocationItem -> it.isSelected
+ is RelayListItem.RecentListItem -> it.isSelected
is RelayListItem.CustomListEntryItem,
is RelayListItem.CustomListFooter,
RelayListItem.CustomListHeader,
RelayListItem.LocationHeader,
is RelayListItem.LocationsEmptyText,
- is RelayListItem.EmptyRelayList -> false
+ is RelayListItem.EmptyRelayList,
+ RelayListItem.RecentsListFooter,
+ RelayListItem.RecentsListHeader,
+ is RelayListItem.SectionDivider -> false
}
}
if (index >= 0) index else null
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
index 7429b39324..f1d9a51f64 100644
--- 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
@@ -17,17 +17,25 @@ 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.History
+import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
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
@@ -69,11 +77,13 @@ 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.Hop
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.lib.theme.color.AlphaDisabled
import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible
+import net.mullvad.mullvadvpn.lib.ui.component.relaylist.displayName
import net.mullvad.mullvadvpn.lib.ui.tag.SELECT_LOCATION_SCREEN_TEST_TAG
import net.mullvad.mullvadvpn.util.Lc
import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationSideEffect
@@ -89,22 +99,23 @@ private fun PreviewSelectLocationScreen(
AppTheme {
SelectLocationScreen(
state = state,
- SnackbarHostState(),
- {},
- {},
- {},
- {},
- {},
- {},
- {},
- {},
- { _, _ -> },
- { _, _ -> },
- {},
- {},
- {},
- {},
- {},
+ snackbarHostState = SnackbarHostState(),
+ onSelectHop = {},
+ onSearchClick = {},
+ onBackClick = {},
+ onFilterClick = {},
+ onCreateCustomList = { _ -> },
+ onEditCustomLists = {},
+ onRecentsToggleEnableClick = {},
+ removeOwnershipFilter = {},
+ removeProviderFilter = {},
+ onAddLocationToList = { _, _ -> },
+ onRemoveLocationFromList = { _, _ -> },
+ onEditCustomListName = {},
+ onEditLocationsCustomList = {},
+ onDeleteCustomList = {},
+ onSelectRelayList = {},
+ openDaitaSettings = {},
)
}
}
@@ -156,7 +167,10 @@ fun SelectLocation(
launch {
snackbarHostState.showSnackbarImmediately(
message =
- context.getString(R.string.relayitem_is_inactive, it.relayItem.name)
+ context.getString(
+ R.string.relayitem_is_inactive,
+ it.hop.displayName(context),
+ )
)
}
}
@@ -191,7 +205,7 @@ fun SelectLocation(
SelectLocationScreen(
state = state.value,
snackbarHostState = snackbarHostState,
- onSelectRelay = vm::selectRelay,
+ onSelectHop = vm::selectHop,
onSearchClick = { navigator.navigate(SearchLocationDestination(it)) },
onBackClick = dropUnlessResumed { backNavigator.navigateBack() },
onFilterClick = dropUnlessResumed { navigator.navigate(FilterDestination) },
@@ -229,6 +243,7 @@ fun SelectLocation(
)
},
onSelectRelayList = vm::selectRelayList,
+ onRecentsToggleEnableClick = vm::toggleRecentsEnabled,
openDaitaSettings =
dropUnlessResumed { navigator.navigate(DaitaDestination(isModal = true)) },
)
@@ -239,12 +254,13 @@ fun SelectLocation(
fun SelectLocationScreen(
state: Lc<Unit, SelectLocationUiState>,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
- onSelectRelay: (item: RelayItem) -> Unit,
+ onSelectHop: (item: Hop) -> Unit,
onSearchClick: (RelayListType) -> Unit,
onBackClick: () -> Unit,
onFilterClick: () -> Unit,
onCreateCustomList: (location: RelayItem.Location?) -> Unit,
onEditCustomLists: () -> Unit,
+ onRecentsToggleEnableClick: () -> Unit,
removeOwnershipFilter: () -> Unit,
removeProviderFilter: () -> Unit,
onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit,
@@ -285,17 +301,23 @@ fun SelectLocationScreen(
),
)
}
- val isFilterButtonEnabled = state.contentOrNull()?.isFilterButtonEnabled == true
- IconButton(enabled = isFilterButtonEnabled, onClick = onFilterClick) {
- Icon(
- imageVector = Icons.Default.FilterList,
- contentDescription = stringResource(id = R.string.filter),
- tint =
- MaterialTheme.colorScheme.onSurface.copy(
- alpha = if (isFilterButtonEnabled) AlphaVisible else AlphaDisabled
- ),
- )
- }
+
+ val filterButtonEnabled = state.contentOrNull()?.isFilterButtonEnabled == true
+ val recentsCurrentlyEnabled = state.contentOrNull()?.isRecentsEnabled == true
+ val disabledText = stringResource(id = R.string.recents_disabled)
+ val scope = rememberCoroutineScope()
+
+ SelectLocationDropdownMenu(
+ filterButtonEnabled = filterButtonEnabled,
+ onFilterClick = onFilterClick,
+ recentsEnabled = recentsCurrentlyEnabled,
+ onRecentsToggleEnableClick = {
+ if (recentsCurrentlyEnabled) {
+ scope.launch { snackbarHostState.showSnackbarImmediately(disabledText) }
+ }
+ onRecentsToggleEnableClick()
+ },
+ )
},
) { modifier ->
var locationBottomSheetState by remember { mutableStateOf<LocationBottomSheetState?>(null) }
@@ -347,7 +369,7 @@ fun SelectLocationScreen(
RelayLists(
state = state.value,
- onSelectRelay = onSelectRelay,
+ onSelectHop = onSelectHop,
openDaitaSettings = openDaitaSettings,
onAddCustomList = { onCreateCustomList(null) },
onEditCustomLists = onEditCustomLists,
@@ -362,7 +384,69 @@ fun SelectLocationScreen(
}
@Composable
-private fun MultihopBar(relayListType: RelayListType, onSelectRelayList: (RelayListType) -> Unit) {
+private fun SelectLocationDropdownMenu(
+ filterButtonEnabled: Boolean,
+ onFilterClick: () -> Unit,
+ recentsEnabled: Boolean,
+ onRecentsToggleEnableClick: () -> Unit,
+) {
+ var showMenu by remember { mutableStateOf(false) }
+
+ var recentsItemTextId by remember { mutableIntStateOf(R.string.disable_recents) }
+
+ IconButton(
+ onClick = {
+ showMenu = !showMenu
+ // Only update the recents menu item text when the menu is being opened to prevent
+ // the text from being updated when the menu is being closed.
+ if (showMenu) {
+ recentsItemTextId =
+ if (recentsEnabled) R.string.disable_recents else R.string.enable_recents
+ }
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.MoreVert,
+ contentDescription = stringResource(R.string.more_actions),
+ )
+ }
+ DropdownMenu(
+ modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer),
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false },
+ ) {
+ val colors =
+ MenuDefaults.itemColors(
+ leadingIconColor = MaterialTheme.colorScheme.onPrimary,
+ disabledLeadingIconColor =
+ MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaDisabled),
+ )
+
+ DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.filter)) },
+ onClick = {
+ showMenu = false
+ onFilterClick()
+ },
+ enabled = filterButtonEnabled,
+ colors = colors,
+ leadingIcon = { Icon(Icons.Filled.FilterList, contentDescription = null) },
+ )
+
+ DropdownMenuItem(
+ text = { Text(text = stringResource(recentsItemTextId)) },
+ onClick = {
+ showMenu = false
+ onRecentsToggleEnableClick()
+ },
+ colors = colors,
+ leadingIcon = { Icon(Icons.Filled.History, contentDescription = null) },
+ )
+ }
+}
+
+@Composable
+private fun MultihopBar(relayListType: RelayListType, onSelectHopList: (RelayListType) -> Unit) {
SingleChoiceSegmentedButtonRow(
modifier =
Modifier.fillMaxWidth()
@@ -374,12 +458,12 @@ private fun MultihopBar(relayListType: RelayListType, onSelectRelayList: (RelayL
) {
MullvadSegmentedStartButton(
selected = relayListType == RelayListType.ENTRY,
- onClick = { onSelectRelayList(RelayListType.ENTRY) },
+ onClick = { onSelectHopList(RelayListType.ENTRY) },
text = stringResource(id = R.string.entry),
)
MullvadSegmentedEndButton(
selected = relayListType == RelayListType.EXIT,
- onClick = { onSelectRelayList(RelayListType.EXIT) },
+ onClick = { onSelectHopList(RelayListType.EXIT) },
text = stringResource(id = R.string.exit),
)
}
@@ -388,7 +472,7 @@ private fun MultihopBar(relayListType: RelayListType, onSelectRelayList: (RelayL
@Composable
private fun RelayLists(
state: SelectLocationUiState,
- onSelectRelay: (RelayItem) -> Unit,
+ onSelectHop: (Hop) -> Unit,
openDaitaSettings: () -> Unit,
onAddCustomList: () -> Unit,
onEditCustomLists: (() -> Unit)?,
@@ -401,7 +485,7 @@ private fun RelayLists(
if (configuration.navigation == Configuration.NAVIGATION_DPAD) {
SelectLocationList(
relayListType = state.relayListType,
- onSelectRelay = onSelectRelay,
+ onSelectHop = onSelectHop,
openDaitaSettings = openDaitaSettings,
onAddCustomList = onAddCustomList,
onEditCustomLists = onEditCustomLists,
@@ -430,7 +514,7 @@ private fun RelayLists(
) { pageIndex ->
SelectLocationList(
relayListType = RelayListType.entries[pageIndex],
- onSelectRelay = onSelectRelay,
+ onSelectHop = onSelectHop,
openDaitaSettings = openDaitaSettings,
onAddCustomList = onAddCustomList,
onEditCustomLists = onEditCustomLists,
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 d6014647ea..0a7f835542 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
@@ -8,4 +8,5 @@ data class SelectLocationUiState(
val relayListType: RelayListType,
val isSearchButtonEnabled: Boolean,
val isFilterButtonEnabled: Boolean,
+ val isRecentsEnabled: 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 a75aaef553..f7b41b9378 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
@@ -44,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.ProviderToOwnershipsUseCase
+import net.mullvad.mullvadvpn.usecase.RecentsUseCase
import net.mullvad.mullvadvpn.usecase.SelectedLocationTitleUseCase
import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase
import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsAvailableUseCase
@@ -161,6 +162,7 @@ val uiModule = module {
single { SelectedLocationUseCase(get(), get()) }
single { FilterChipUseCase(get(), get(), get(), get()) }
single { DeleteCustomDnsUseCase(get()) }
+ single { RecentsUseCase(get(), get(), get()) }
single { InAppNotificationController(get(), get(), get(), get(), get(), MainScope()) }
@@ -265,7 +267,17 @@ val uiModule = module {
)
}
viewModel { (relayListType: RelayListType) ->
- SelectLocationListViewModel(relayListType, get(), get(), get(), get(), get(), get(), get())
+ SelectLocationListViewModel(
+ relayListType,
+ get(),
+ get(),
+ get(),
+ get(),
+ get(),
+ get(),
+ get(),
+ get(),
+ )
}
viewModel { DaitaViewModel(get(), get()) }
viewModel {
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 d58da5bc9a..6fe027249e 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
@@ -1,6 +1,7 @@
package net.mullvad.mullvadvpn.relaylist
import net.mullvad.mullvadvpn.lib.model.Constraint
+import net.mullvad.mullvadvpn.lib.model.GeoLocationId
import net.mullvad.mullvadvpn.lib.model.Ownership
import net.mullvad.mullvadvpn.lib.model.Providers
import net.mullvad.mullvadvpn.lib.model.RelayItem
@@ -111,3 +112,28 @@ private fun RelayItem.Location.Relay.filter(
null
}
}
+
+fun List<RelayItem.Location.Country>.findByGeoLocationId(
+ geoLocationId: GeoLocationId
+): RelayItem.Location? =
+ when (geoLocationId) {
+ is GeoLocationId.Country -> find { country -> country.id == geoLocationId }
+ is GeoLocationId.City -> findCity(geoLocationId)
+ is GeoLocationId.Hostname -> findRelay(geoLocationId)
+ }
+
+fun List<RelayItem.Location.Country>.findCity(
+ geoLocationId: GeoLocationId.City
+): RelayItem.Location.City? =
+ find { country -> country.id == geoLocationId.country }
+ ?.cities
+ ?.find { city -> city.id == geoLocationId }
+
+fun List<RelayItem.Location.Country>.findRelay(
+ geoLocationId: GeoLocationId.Hostname
+): RelayItem.Location.Relay? =
+ find { country -> country.id == geoLocationId.country }
+ ?.cities
+ ?.find { city -> city.id == geoLocationId.city }
+ ?.relays
+ ?.find { relay -> relay.id == geoLocationId }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt
index 6d308e3b26..d0cb46b95e 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt
@@ -3,12 +3,6 @@ package net.mullvad.mullvadvpn.relaylist
import net.mullvad.mullvadvpn.lib.model.GeoLocationId
import net.mullvad.mullvadvpn.lib.model.RelayItem
-fun List<RelayItem.Location.Country>.findByGeoLocationId(geoLocationId: GeoLocationId) =
- withDescendants().firstOrNull { it.id == geoLocationId }
-
-fun List<RelayItem.Location.Country>.findByGeoLocationId(geoLocationId: GeoLocationId.City) =
- flatMap { it.cities }.firstOrNull { it.id == geoLocationId }
-
fun List<RelayItem.Location.Country>.search(searchTerm: String): List<GeoLocationId> =
withDescendants().filter { it.name.contains(searchTerm, ignoreCase = true) }.map { it.id }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt
index ed78ecb537..5347a4666e 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt
@@ -81,6 +81,9 @@ class RelayListRepository(
suspend fun updateSelectedRelayLocation(value: RelayItemId) =
managementService.setRelayLocation(value)
+ suspend fun updateSelectedRelayLocationMultihop(entry: RelayItemId, exit: RelayItemId) =
+ managementService.setRelayLocationMultihop(entry, exit)
+
fun find(geoLocationId: GeoLocationId) = relayList.value.findByGeoLocationId(geoLocationId)
private fun defaultWireguardEndpointData() = WireguardEndpointData(emptyList(), emptyList())
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt
index 17ed523b82..a076af648a 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt
@@ -79,4 +79,6 @@ class SettingsRepository(
suspend fun setDaitaDirectOnly(enabled: Boolean) = managementService.setDaitaDirectOnly(enabled)
suspend fun setIpv6Enabled(enabled: Boolean) = managementService.setIpv6Enabled(enabled)
+
+ suspend fun setRecentsEnabled(enabled: Boolean) = managementService.setRecentsEnabled(enabled)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RecentsUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RecentsUseCase.kt
new file mode 100644
index 0000000000..e4cf5c06bc
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RecentsUseCase.kt
@@ -0,0 +1,71 @@
+package net.mullvad.mullvadvpn.usecase
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+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.Hop
+import net.mullvad.mullvadvpn.lib.model.Recent
+import net.mullvad.mullvadvpn.lib.model.Recents
+import net.mullvad.mullvadvpn.lib.model.RelayItem
+import net.mullvad.mullvadvpn.lib.model.RelayItemId
+import net.mullvad.mullvadvpn.relaylist.findByGeoLocationId
+import net.mullvad.mullvadvpn.repository.SettingsRepository
+import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase
+
+class RecentsUseCase(
+ private val customListsRelayItemUseCase: FilterCustomListsRelayItemUseCase,
+ private val filteredRelayListUseCase: FilteredRelayListUseCase,
+ private val settingsRepository: SettingsRepository,
+) {
+
+ operator fun invoke(): Flow<List<Hop>?> =
+ combine(
+ recents(),
+ filteredRelayListUseCase(RelayListType.ENTRY),
+ customListsRelayItemUseCase(RelayListType.ENTRY),
+ filteredRelayListUseCase(RelayListType.EXIT),
+ customListsRelayItemUseCase(RelayListType.EXIT),
+ ) { recents, entryRelayList, entryCustomLists, exitRelayList, exitCustomLists ->
+ recents?.mapNotNull { recent ->
+ when (recent) {
+ is Recent.Multihop -> {
+ val entry = recent.entry.findItem(entryCustomLists, entryRelayList)
+ val exit = recent.exit.findItem(exitCustomLists, exitRelayList)
+
+ if (entry != null && exit != null) {
+ Hop.Multi(entry, exit)
+ } else {
+ null
+ }
+ }
+ is Recent.Singlehop -> {
+ val relayListItem = recent.location.findItem(exitCustomLists, exitRelayList)
+
+ relayListItem?.let { Hop.Single(it) }
+ }
+ }
+ }
+ }
+
+ private fun recents(): Flow<List<Recent>?> =
+ settingsRepository.settingsUpdates.map { settings ->
+ val recents = settings?.recents
+ when (recents) {
+ is Recents.Enabled -> recents.recents
+ Recents.Disabled,
+ null -> null
+ }
+ }
+
+ private fun RelayItemId.findItem(
+ customLists: List<RelayItem.CustomList>,
+ relayList: List<RelayItem.Location.Country>,
+ ): RelayItem? =
+ when (this) {
+ is CustomListId -> customLists.firstOrNull { this == it.id }
+ is GeoLocationId -> relayList.findByGeoLocationId(this)
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationTitleUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationTitleUseCase.kt
index 98bc77d05b..df1cb855b9 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationTitleUseCase.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationTitleUseCase.kt
@@ -8,7 +8,7 @@ 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.relaylist.findByGeoLocationId
+import net.mullvad.mullvadvpn.relaylist.findCity
import net.mullvad.mullvadvpn.repository.CustomListsRepository
import net.mullvad.mullvadvpn.repository.RelayListRepository
@@ -37,7 +37,7 @@ class SelectedLocationTitleUseCase(
when (relayItemId) {
is CustomListId -> customLists.firstOrNull { it.id == relayItemId }?.name?.value
is GeoLocationId.Hostname -> createRelayTitle(relayCountries, relayItemId)
- is GeoLocationId.City -> relayCountries.findByGeoLocationId(relayItemId)?.name
+ is GeoLocationId.City -> relayCountries.findCity(relayItemId)?.name
is GeoLocationId.Country -> relayCountries.firstOrNull { it.id == relayItemId }?.name
}
@@ -45,8 +45,8 @@ class SelectedLocationTitleUseCase(
relayCountries: List<RelayItem.Location.Country>,
relayItemId: GeoLocationId.Hostname,
): String? = nullable {
- val city = relayCountries.findByGeoLocationId(relayItemId.city).bind()
- val relay = city.relays.firstOrNull { it.id == relayItemId }.bind()
+ val city = relayCountries.findCity(relayItemId.city).bind()
+ val relay = city.relays.find { it.id == relayItemId }.bind()
relay.formatTitle(city)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt
index 8cee6c7423..9627dd4c90 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt
@@ -244,7 +244,7 @@ class CustomListLocationsViewModel(
_initialLocations.value = selectedLocations
_selectedLocations.value = selectedLocations
// Initial expand
- _expandOverrides.value = initialExpands(locations).associate { it to true }
+ _expandOverrides.value = initialExpands(locations).associateWith { true }
}
private fun initialExpands(locations: List<RelayItem.Location>): Set<RelayItemId> =
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
index 85bd7b282f..f1ccd360b3 100644
--- 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
@@ -3,6 +3,7 @@ package net.mullvad.mullvadvpn.viewmodel.location
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.Hop
import net.mullvad.mullvadvpn.lib.model.RelayItem
import net.mullvad.mullvadvpn.lib.model.RelayItemId
import net.mullvad.mullvadvpn.lib.model.RelayItemSelection
@@ -11,21 +12,29 @@ import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItem
import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItemState
import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm
+const val RECENTS_MAX_VISIBLE: Int = 3
+
// Creates a relay list to be displayed by RelayListContent
internal fun relayListItems(
relayListType: RelayListType,
relayCountries: List<RelayItem.Location.Country>,
customLists: List<RelayItem.CustomList>,
+ recents: List<Hop>?,
+ selectedItem: RelayItemSelection,
selectedByThisEntryExitList: RelayItemId?,
selectedByOtherEntryExitList: RelayItemId?,
expandedItems: Set<String>,
+ isEntryBlocked: Boolean,
): List<RelayListItem> {
return createRelayListItems(
relayListType = relayListType,
+ selectedItem = selectedItem,
selectedByThisEntryExitList = selectedByThisEntryExitList,
selectedByOtherEntryExitList = selectedByOtherEntryExitList,
customLists = customLists,
+ recents = recents,
countries = relayCountries,
+ isEntryBlocked = isEntryBlocked,
) {
it in expandedItems
}
@@ -72,19 +81,28 @@ internal fun emptyLocationsRelayListItems(
private fun createRelayListItems(
relayListType: RelayListType,
+ selectedItem: RelayItemSelection,
selectedByThisEntryExitList: RelayItemId?,
selectedByOtherEntryExitList: RelayItemId?,
customLists: List<RelayItem.CustomList>,
+ recents: List<Hop>?,
countries: List<RelayItem.Location.Country>,
+ isEntryBlocked: Boolean,
isExpanded: (String) -> Boolean,
-): List<RelayListItem> =
- createCustomListSection(
- relayListType,
- selectedByThisEntryExitList,
- selectedByOtherEntryExitList,
- customLists,
- isExpanded,
- ) +
+): List<RelayListItem> = buildList {
+ if (recents != null) {
+ addAll(createRecentsSection(recents, selectedItem, isEntryBlocked))
+ }
+ addAll(
+ createCustomListSection(
+ relayListType,
+ selectedByThisEntryExitList,
+ selectedByOtherEntryExitList,
+ customLists,
+ isExpanded,
+ )
+ )
+ addAll(
createLocationSection(
selectedByThisEntryExitList,
relayListType,
@@ -92,6 +110,66 @@ private fun createRelayListItems(
countries,
isExpanded,
)
+ )
+}
+
+private fun createRecentsSection(
+ recents: List<Hop>,
+ itemSelection: RelayItemSelection,
+ isEntryBlocked: Boolean,
+): List<RelayListItem> = buildList {
+ add(RelayListItem.RecentsListHeader)
+
+ val selectionIsSingle = itemSelection is RelayItemSelection.Single
+ val selectionIsMulti = itemSelection is RelayItemSelection.Multiple
+
+ val shown =
+ recents
+ .filter { recent ->
+ when (recent) {
+ is Hop.Multi -> selectionIsMulti
+ is Hop.Single<*> -> selectionIsSingle
+ }
+ }
+ .take(RECENTS_MAX_VISIBLE)
+ .map { recent ->
+ val isSelected = recent.matches(itemSelection, isEntryBlocked)
+ if (isEntryBlocked) {
+ // When the entry is blocked we want to show a multihop's exit location
+ // as a singlehop in the recents list.
+ RelayListItem.RecentListItem(
+ hop = Hop.Single(recent.exit()),
+ isSelected = isSelected,
+ )
+ } else {
+ RelayListItem.RecentListItem(hop = recent, isSelected = isSelected)
+ }
+ }
+
+ addAll(shown)
+ if (shown.isEmpty()) {
+ add(RelayListItem.RecentsListFooter)
+ } else {
+ add(RelayListItem.SectionDivider())
+ }
+}
+
+private fun Hop.matches(itemSelection: RelayItemSelection, isEntryBlocked: Boolean): Boolean {
+ return when (itemSelection) {
+ is RelayItemSelection.Single -> {
+ entry().id == itemSelection.exitLocation.getOrNull()
+ }
+
+ is RelayItemSelection.Multiple -> {
+ if (isEntryBlocked) {
+ exit().id == itemSelection.exitLocation.getOrNull()
+ } else {
+ entry().id == itemSelection.entryLocation.getOrNull() &&
+ exit().id == itemSelection.exitLocation.getOrNull()
+ }
+ }
+ }
+}
private fun createRelayListItemsSearching(
relayListType: RelayListType,
@@ -169,7 +247,7 @@ private fun createCustomListRelayItems(
buildList {
add(
RelayListItem.CustomListItem(
- item = customList,
+ hop = Hop.Single(customList),
isSelected = selectedByThisEntryExitList == customList.id,
state =
customList.createState(
@@ -264,7 +342,7 @@ private fun createCustomListEntry(
RelayListItem.CustomListEntryItem(
parentId = parent.id,
parentName = parent.customList.name,
- item = item,
+ hop = Hop.Single(item),
state =
item.createState(
relayListType = relayListType,
@@ -329,7 +407,7 @@ private fun createGeoLocationEntry(
add(
RelayListItem.GeoLocationItem(
- item = item,
+ hop = Hop.Single(item),
isSelected = selectedByThisEntryExitList == item.id,
state =
item.createState(
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
index 9ac657423f..02cd8a765b 100644
--- 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
@@ -18,6 +18,7 @@ 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.Hop
import net.mullvad.mullvadvpn.lib.model.RelayItem
import net.mullvad.mullvadvpn.lib.model.RelayItemId
import net.mullvad.mullvadvpn.relaylist.newFilterOnSearch
@@ -117,14 +118,16 @@ class SearchLocationViewModel(
}
}
- fun selectRelay(relayItem: RelayItem) {
+ fun selectHop(hop: Hop) {
viewModelScope.launch {
- if (relayItem.active) {
- selectRelayItem(
- relayItem = relayItem,
+ if (hop.isActive) {
+ selectRelayHop(
+ hop = hop,
relayListType = relayListType,
selectEntryLocation = wireguardConstraintsRepository::setEntryLocation,
selectExitLocation = relayListRepository::updateSelectedRelayLocation,
+ selectMultihopLocation =
+ relayListRepository::updateSelectedRelayLocationMultihop,
)
.fold(
{ _uiSideEffect.send(SearchLocationSideEffect.GenericError) },
@@ -135,7 +138,7 @@ class SearchLocationViewModel(
},
)
} else {
- _uiSideEffect.send(SearchLocationSideEffect.RelayItemInactive(relayItem))
+ _uiSideEffect.send(SearchLocationSideEffect.HopInactive(hop))
}
}
}
@@ -225,7 +228,7 @@ sealed interface SearchLocationSideEffect {
data class CustomListActionToast(val resultData: CustomListActionResultData) :
SearchLocationSideEffect
- data class RelayItemInactive(val relayItem: RelayItem) : SearchLocationSideEffect
+ data class HopInactive(val hop: Hop) : 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
index fc01b69ea6..fc9cb77143 100644
--- 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
@@ -16,6 +16,7 @@ import net.mullvad.mullvadvpn.repository.RelayListRepository
import net.mullvad.mullvadvpn.repository.SettingsRepository
import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository
import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase
+import net.mullvad.mullvadvpn.usecase.RecentsUseCase
import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase
import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase
import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase
@@ -28,8 +29,9 @@ class SelectLocationListViewModel(
private val selectedLocationUseCase: SelectedLocationUseCase,
private val wireguardConstraintsRepository: WireguardConstraintsRepository,
private val relayListRepository: RelayListRepository,
+ private val recentsUseCase: RecentsUseCase,
+ private val settingsRepository: SettingsRepository,
customListsRelayItemUseCase: CustomListsRelayItemUseCase,
- settingsRepository: SettingsRepository,
) : ViewModel() {
private val _expandedItems: MutableStateFlow<Set<String>> =
MutableStateFlow(initialExpand(initialSelection()))
@@ -61,9 +63,10 @@ class SelectLocationListViewModel(
combine(
filteredRelayListUseCase(relayListType = relayListType),
filteredCustomListRelayItemsUseCase(relayListType = relayListType),
+ recentsUseCase(),
selectedLocationUseCase(),
_expandedItems,
- ) { relayCountries, customLists, selectedItem, expandedItems ->
+ ) { relayCountries, customLists, recents, selectedItem, expandedItems ->
// If we have no locations we have an empty relay list
// and we should show an error
if (relayCountries.isEmpty()) {
@@ -81,11 +84,15 @@ class SelectLocationListViewModel(
relayCountries = relayCountries,
relayListType = relayListType,
customLists = customLists,
+ recents = recents,
+ selectedItem = selectedItem,
selectedByThisEntryExitList =
selectedItem.selectedByThisEntryExitList(relayListType),
selectedByOtherEntryExitList =
selectedItem.selectedByOtherEntryExitList(relayListType, customLists),
expandedItems = expandedItems,
+ isEntryBlocked =
+ settingsRepository.settingsUpdates.value?.entryBlocked() == true,
)
}
}
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
index 0420559245..4964f24e8e 100644
--- 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
@@ -17,6 +17,8 @@ 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.Hop
+import net.mullvad.mullvadvpn.lib.model.Recents
import net.mullvad.mullvadvpn.lib.model.RelayItem
import net.mullvad.mullvadvpn.repository.CustomListsRepository
import net.mullvad.mullvadvpn.repository.RelayListFilterRepository
@@ -59,6 +61,7 @@ class SelectLocationViewModel(
(relayListSelection == RelayListType.EXIT ||
settings?.entryBlocked() != true),
isFilterButtonEnabled = relayList.isNotEmpty(),
+ isRecentsEnabled = settings?.recents is Recents.Enabled,
)
)
}
@@ -73,28 +76,33 @@ class SelectLocationViewModel(
viewModelScope.launch { _relayListType.emit(relayListType) }
}
- fun selectRelay(relayItem: RelayItem) {
+ fun selectHop(hop: Hop) {
viewModelScope.launch {
- if (relayItem.active) {
+ if (hop.isActive) {
- selectRelayItem(
- relayItem = relayItem,
+ selectRelayHop(
+ hop = hop,
relayListType = _relayListType.value,
selectEntryLocation = wireguardConstraintsRepository::setEntryLocation,
selectExitLocation = relayListRepository::updateSelectedRelayLocation,
+ selectMultihopLocation =
+ relayListRepository::updateSelectedRelayLocationMultihop,
)
.fold(
{ _uiSideEffect.send(SelectLocationSideEffect.GenericError) },
{
when (_relayListType.value) {
- RelayListType.ENTRY -> _relayListType.emit(RelayListType.EXIT)
+ RelayListType.ENTRY ->
+ if (hop is Hop.Multi)
+ _uiSideEffect.send(SelectLocationSideEffect.CloseScreen)
+ else _relayListType.emit(RelayListType.EXIT)
RelayListType.EXIT ->
_uiSideEffect.send(SelectLocationSideEffect.CloseScreen)
}
},
)
} else {
- _uiSideEffect.send(SelectLocationSideEffect.RelayItemInactive(relayItem))
+ _uiSideEffect.send(SelectLocationSideEffect.RelayItemInactive(hop))
}
}
}
@@ -135,6 +143,13 @@ class SelectLocationViewModel(
fun removeProviderFilter() {
viewModelScope.launch { relayListFilterRepository.updateSelectedProviders(Constraint.Any) }
}
+
+ fun toggleRecentsEnabled() {
+ viewModelScope.launch {
+ val enabled = settingsRepository.settingsUpdates.value?.recents is Recents.Enabled
+ settingsRepository.setRecentsEnabled(!enabled)
+ }
+ }
}
sealed interface SelectLocationSideEffect {
@@ -145,5 +160,5 @@ sealed interface SelectLocationSideEffect {
data object GenericError : SelectLocationSideEffect
- data class RelayItemInactive(val relayItem: RelayItem) : SelectLocationSideEffect
+ data class RelayItemInactive(val hop: Hop) : 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
index 8d6c90961b..f797501914 100644
--- 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
@@ -3,19 +3,30 @@ 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.Hop
import net.mullvad.mullvadvpn.lib.model.RelayItemId
-internal suspend fun selectRelayItem(
- relayItem: RelayItem,
+internal suspend fun selectRelayHop(
+ hop: Hop,
relayListType: RelayListType,
selectEntryLocation: suspend (RelayItemId) -> Either<Any, Unit>,
selectExitLocation: suspend (RelayItemId) -> Either<Any, Unit>,
+ selectMultihopLocation: suspend (RelayItemId, RelayItemId) -> Either<Any, Unit>,
) =
either<Any, Unit> {
- val locationConstraint = relayItem.id
- when (relayListType) {
- RelayListType.ENTRY -> selectEntryLocation(locationConstraint)
- RelayListType.EXIT -> selectExitLocation(locationConstraint)
+ when (hop) {
+ is Hop.Multi -> {
+ val entryConstraint = hop.entry.id
+ val exitConstraint = hop.exit.id
+ selectMultihopLocation(entryConstraint, exitConstraint)
+ }
+
+ is Hop.Single<*> -> {
+ val locationConstraint = hop.relay.id
+ when (relayListType) {
+ RelayListType.ENTRY -> selectEntryLocation(locationConstraint)
+ RelayListType.EXIT -> selectExitLocation(locationConstraint)
+ }
+ }
}
}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/RecentsUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/RecentsUseCaseTest.kt
new file mode 100644
index 0000000000..f7699101d5
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/RecentsUseCaseTest.kt
@@ -0,0 +1,139 @@
+package net.mullvad.mullvadvpn.usecase
+
+import app.cash.turbine.test
+import io.mockk.every
+import io.mockk.mockk
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.compose.state.RelayListType
+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.Hop
+import net.mullvad.mullvadvpn.lib.model.Recent
+import net.mullvad.mullvadvpn.lib.model.Recents
+import net.mullvad.mullvadvpn.lib.model.RelayItem
+import net.mullvad.mullvadvpn.lib.model.Settings
+import net.mullvad.mullvadvpn.repository.SettingsRepository
+import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+class RecentsUseCaseTest {
+
+ private val customListsRelayItemUseCase: FilterCustomListsRelayItemUseCase = mockk()
+ private val filteredRelayListUseCase: FilteredRelayListUseCase = mockk()
+ private val settingsRepository: SettingsRepository = mockk()
+
+ private val settingsFlow = MutableStateFlow<Settings?>(null)
+
+ private lateinit var useCase: RecentsUseCase
+
+ @BeforeEach
+ fun setUp() {
+ every { settingsRepository.settingsUpdates } returns settingsFlow
+ useCase =
+ RecentsUseCase(
+ customListsRelayItemUseCase,
+ filteredRelayListUseCase,
+ settingsRepository,
+ )
+ }
+
+ @Test
+ fun `given null settings when invoke then emit null`() = runTest {
+ settingsFlow.value = null
+ every { customListsRelayItemUseCase(any()) } returns flowOf(emptyList())
+ every { filteredRelayListUseCase(any()) } returns flowOf(emptyList())
+
+ useCase().test { assertNull(awaitItem()) }
+ }
+
+ @Test
+ fun `given recents disabled when invoke then emit null`() = runTest {
+ settingsFlow.value = mockk<Settings> { every { recents } returns Recents.Disabled }
+ every { customListsRelayItemUseCase(any()) } returns flowOf(emptyList())
+ every { filteredRelayListUseCase(any()) } returns flowOf(emptyList())
+
+ useCase().test { assertNull(awaitItem()) }
+ }
+
+ @Test
+ fun `given recents enabled but empty when invoke then emit empty list`() = runTest {
+ settingsFlow.value =
+ mockk<Settings> { every { recents } returns Recents.Enabled(emptyList()) }
+ every { customListsRelayItemUseCase(any()) } returns flowOf(emptyList())
+ every { filteredRelayListUseCase(any()) } returns flowOf(emptyList())
+
+ useCase().test { assertEquals(emptyList(), awaitItem()) }
+ }
+
+ @Test
+ fun `given recents enabled when invoke then emit hops based on the relay item filters`() =
+ runTest {
+ val swedenId = GeoLocationId.Country("se")
+ val stockholmId = GeoLocationId.City(swedenId, "sto")
+ val sweden =
+ RelayItem.Location.Country(
+ id = swedenId,
+ name = "Sweden",
+ cities =
+ listOf(
+ RelayItem.Location.City(
+ id = stockholmId,
+ name = "Stockholm",
+ relays = emptyList(),
+ )
+ ),
+ )
+
+ val norwayId = GeoLocationId.Country("no")
+ val norway =
+ RelayItem.Location.Country(id = norwayId, name = "Norway", cities = emptyList())
+
+ val entryCustomListId = CustomListId("custom")
+ val customList =
+ CustomList(
+ id = entryCustomListId,
+ name = CustomListName.fromString("Custom"),
+ locations = listOf(swedenId, norwayId),
+ )
+ val entryCustomList =
+ RelayItem.CustomList(customList = customList, locations = emptyList())
+
+ val singleHopRecent = Recent.Singlehop(stockholmId)
+ val multiHopRecent = Recent.Multihop(entry = entryCustomListId, exit = norwayId)
+ val filteredOutRecent =
+ Recent.Singlehop(
+ GeoLocationId.City(country = GeoLocationId.Country("xx"), code = "xx-xxx-xx")
+ )
+
+ settingsFlow.value =
+ mockk<Settings> {
+ every { recents } returns
+ Recents.Enabled(listOf(singleHopRecent, multiHopRecent, filteredOutRecent))
+ }
+
+ every { customListsRelayItemUseCase(RelayListType.ENTRY) } returns
+ flowOf(listOf(entryCustomList))
+ every { customListsRelayItemUseCase(RelayListType.EXIT) } returns flowOf(emptyList())
+ every { filteredRelayListUseCase(RelayListType.ENTRY) } returns
+ flowOf(listOf(sweden, norway))
+ every { filteredRelayListUseCase(RelayListType.EXIT) } returns
+ flowOf(listOf(sweden, norway))
+
+ useCase().test {
+ val hops = awaitItem()
+
+ val stockholmCity = sweden.cities.first()
+
+ val expectedHops =
+ listOf(Hop.Single(stockholmCity), Hop.Multi(entryCustomList, norway))
+ assertEquals(expectedHops, hops)
+ }
+ }
+}
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
index fb974e52fb..1a54d15f95 100644
--- 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
@@ -12,6 +12,7 @@ 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.Hop
import net.mullvad.mullvadvpn.lib.model.RelayItem
import net.mullvad.mullvadvpn.lib.model.RelayItemSelection
import net.mullvad.mullvadvpn.lib.model.Settings
@@ -20,6 +21,7 @@ import net.mullvad.mullvadvpn.repository.RelayListRepository
import net.mullvad.mullvadvpn.repository.SettingsRepository
import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository
import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase
+import net.mullvad.mullvadvpn.usecase.RecentsUseCase
import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase
import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase
import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase
@@ -40,12 +42,14 @@ class SelectLocationListViewModelTest {
private val mockRelayListRepository: RelayListRepository = mockk()
private val mockCustomListRelayItemsUseCase: CustomListsRelayItemUseCase = mockk()
private val mockSettingsRepository: SettingsRepository = mockk()
+ private val recentsUseCase: RecentsUseCase = 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 val recentsRelayItems = MutableStateFlow<List<Hop>?>(emptyList())
private val settings = MutableStateFlow(mockk<Settings>(relaxed = true))
private lateinit var viewModel: SelectLocationListViewModel
@@ -63,6 +67,7 @@ class SelectLocationListViewModelTest {
filteredCustomListRelayItems
every { mockCustomListRelayItemsUseCase() } returns customListRelayItems
every { mockSettingsRepository.settingsUpdates } returns settings
+ every { recentsUseCase() } returns recentsRelayItems
}
@Test
@@ -132,18 +137,23 @@ class SelectLocationListViewModelTest {
relayListRepository = mockRelayListRepository,
customListsRelayItemUseCase = mockCustomListRelayItemsUseCase,
settingsRepository = mockSettingsRepository,
+ recentsUseCase = recentsUseCase,
)
private fun RelayListItem.relayItemId() =
when (this) {
- is RelayListItem.CustomListFooter -> null
- RelayListItem.CustomListHeader -> null
- RelayListItem.LocationHeader -> null
- is RelayListItem.LocationsEmptyText -> null
- is RelayListItem.EmptyRelayList -> null
is RelayListItem.CustomListEntryItem -> item.id
- is RelayListItem.CustomListItem -> item.id
+ is RelayListItem.CustomListItem -> hop.exit().id
is RelayListItem.GeoLocationItem -> item.id
+ is RelayListItem.RecentListItem -> hop.exit().id
+ is RelayListItem.CustomListFooter,
+ is RelayListItem.LocationsEmptyText,
+ is RelayListItem.EmptyRelayList,
+ is RelayListItem.SectionDivider,
+ RelayListItem.CustomListHeader,
+ RelayListItem.LocationHeader,
+ RelayListItem.RecentsListHeader,
+ RelayListItem.RecentsListFooter -> null
}
companion object {
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt
index 7115cd58c0..3f80572e06 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt
@@ -25,6 +25,7 @@ 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.Hop
import net.mullvad.mullvadvpn.lib.model.Ownership
import net.mullvad.mullvadvpn.lib.model.Providers
import net.mullvad.mullvadvpn.lib.model.RelayItem
@@ -114,7 +115,7 @@ class SelectLocationViewModelTest {
// Act, Assert
viewModel.uiSideEffect.test {
- viewModel.selectRelay(mockRelayItem)
+ viewModel.selectHop(Hop.Single(mockRelayItem))
// Await an empty item
assertEquals(SelectLocationSideEffect.CloseScreen, awaitItem())
coVerify { mockRelayListRepository.updateSelectedRelayLocation(relayItemId) }
@@ -141,7 +142,7 @@ class SelectLocationViewModelTest {
assertIs<Lc.Content<SelectLocationUiState>>(firstState)
assertEquals(RelayListType.ENTRY, firstState.value.relayListType)
// Select entry
- viewModel.selectRelay(mockRelayItem)
+ viewModel.selectHop(Hop.Single(mockRelayItem))
// Assert relay list type is exit
val secondState = awaitItem()
assertIs<Lc.Content<SelectLocationUiState>>(secondState)
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 cff4e2bd3e..3716e4d9c0 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
@@ -576,6 +576,28 @@ class ManagementService(
.mapLeft(SetRelayLocationError::Unknown)
.mapEmpty()
+ suspend fun setRelayLocationMultihop(
+ entry: RelayItemId,
+ exit: RelayItemId,
+ ): Either<SetRelayLocationError, Unit> =
+ Either.catch {
+ val currentRelaySettings = getSettings().relaySettings
+
+ val updatedRelaySettings =
+ currentRelaySettings.copy {
+ inside(RelaySettings.relayConstraints) {
+ RelayConstraints.location set Constraint.Only(exit)
+ RelayConstraints.wireguardConstraints.entryLocation set
+ Constraint.Only(entry)
+ RelayConstraints.wireguardConstraints.isMultihopEnabled set true
+ }
+ }
+ grpc.setRelaySettings(updatedRelaySettings.fromDomain())
+ }
+ .onLeft { Logger.e("Set relay multihop error") }
+ .mapLeft(SetRelayLocationError::Unknown)
+ .mapEmpty()
+
suspend fun createCustomList(
name: CustomListName,
locations: List<GeoLocationId> = emptyList(),
@@ -855,6 +877,11 @@ class ManagementService(
.mapLeft(SetDaitaSettingsError::Unknown)
.mapEmpty()
+ suspend fun setRecentsEnabled(enabled: Boolean): Either<SetWireguardConstraintsError, Unit> =
+ Either.catch { grpc.setEnableRecents(BoolValue.of(enabled)) }
+ .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/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt
index 3ff0788776..27ce80c016 100644
--- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt
@@ -4,6 +4,31 @@ import arrow.optics.optics
typealias DomainCustomList = CustomList
+sealed interface Hop {
+ data class Single<R : RelayItem>(val relay: R) : Hop
+
+ data class Multi(val entry: RelayItem, val exit: RelayItem) : Hop
+
+ val isActive: Boolean
+ get() =
+ when (this) {
+ is Multi -> entry.active && exit.active
+ is Single<*> -> relay.active
+ }
+
+ fun entry(): RelayItem =
+ when (this) {
+ is Multi -> entry
+ is Single<*> -> relay
+ }
+
+ fun exit(): RelayItem =
+ when (this) {
+ is Multi -> exit
+ is Single<*> -> relay
+ }
+}
+
@optics
sealed interface RelayItem {
val id: RelayItemId
diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml
index fd01cfbcd6..abff8ca10c 100644
--- a/android/lib/resource/src/main/res/values/strings.xml
+++ b/android/lib/resource/src/main/res/values/strings.xml
@@ -427,4 +427,10 @@
<string name="app_is_blocking_internet">The app is blocking internet, please disconnect first</string>
<string name="in_app_products_unavailable">In-app products unavailable, please make sure you have the latest version of Google Play.</string>
<string name="relayitem_is_inactive">%s is unavailable</string>
+ <string name="recents">Recents</string>
+ <string name="enable_recents">Enable recents</string>
+ <string name="disable_recents">Disable recents</string>
+ <string name="no_recent_selection">No recent selection history</string>
+ <string name="recents_disabled">Recents disabled and history cleared</string>
+ <string name="more_actions">More actions</string>
</resources>
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItem.kt
index 8132a9ece7..f88b7b92b4 100644
--- a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItem.kt
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItem.kt
@@ -1,8 +1,11 @@
package net.mullvad.mullvadvpn.lib.ui.component.relaylist
+import android.content.Context
import net.mullvad.mullvadvpn.lib.model.CustomListId
import net.mullvad.mullvadvpn.lib.model.CustomListName
+import net.mullvad.mullvadvpn.lib.model.Hop
import net.mullvad.mullvadvpn.lib.model.RelayItem
+import net.mullvad.mullvadvpn.lib.resource.R
enum class RelayListItemContentType {
CUSTOM_LIST_HEADER,
@@ -13,6 +16,10 @@ enum class RelayListItemContentType {
LOCATION_ITEM,
LOCATIONS_EMPTY_TEXT,
EMPTY_RELAY_LIST,
+ RECENT_LIST_ITEM,
+ RECENT_LIST_HEADER,
+ RECENT_LIST_FOOTER,
+ SECTION_DIVIDER,
}
enum class RelayListItemState {
@@ -24,46 +31,51 @@ 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 item: RelayItem
+ val hop: Hop
val depth: Int
val isSelected: Boolean
val expanded: Boolean
+ val canExpand: Boolean
val state: RelayListItemState?
val itemPosition: ItemPosition
}
+ data object CustomListHeader : RelayListItem {
+ override val key = "custom_list_header"
+ override val contentType = RelayListItemContentType.CUSTOM_LIST_HEADER
+ }
+
data class CustomListItem(
- override val item: RelayItem.CustomList,
+ override val hop: Hop.Single<RelayItem.CustomList>,
override val isSelected: Boolean = false,
override val expanded: Boolean = false,
override val state: RelayListItemState? = null,
override val itemPosition: ItemPosition = ItemPosition.Single,
) : SelectableItem {
+ val item = hop.relay
override val key = item.id
override val depth: Int = 0
override val contentType = RelayListItemContentType.CUSTOM_LIST_ITEM
+ override val canExpand: Boolean = item.hasChildren
}
data class CustomListEntryItem(
val parentId: CustomListId,
val parentName: CustomListName,
- override val item: RelayItem.Location,
+ override val hop: Hop.Single<RelayItem.Location>,
override val expanded: Boolean,
override val depth: Int = 0,
override val state: RelayListItemState? = null,
override val itemPosition: ItemPosition,
) : SelectableItem {
+ val item = hop.relay
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
+ override val canExpand: Boolean = item.hasChildren
}
data class CustomListFooter(val hasCustomList: Boolean) : RelayListItem {
@@ -77,15 +89,40 @@ sealed interface RelayListItem {
}
data class GeoLocationItem(
- override val item: RelayItem.Location,
+ override val hop: Hop.Single<RelayItem.Location>,
override val isSelected: Boolean = false,
override val depth: Int = 0,
override val expanded: Boolean = false,
override val state: RelayListItemState? = null,
override val itemPosition: ItemPosition,
) : SelectableItem {
+ val item = hop.relay
override val key = item.id
override val contentType = RelayListItemContentType.LOCATION_ITEM
+ override val canExpand: Boolean = item.hasChildren
+ }
+
+ data object RecentsListHeader : RelayListItem {
+ override val key = "recents_list_header"
+ override val contentType = RelayListItemContentType.RECENT_LIST_HEADER
+ }
+
+ data class RecentListItem(
+ override val hop: Hop,
+ override val isSelected: Boolean = false,
+ override val expanded: Boolean = false,
+ override val state: RelayListItemState? = null,
+ override val itemPosition: ItemPosition = ItemPosition.Single,
+ ) : SelectableItem {
+ override val key = "recents$hop"
+ override val depth: Int = 0
+ override val contentType = RelayListItemContentType.RECENT_LIST_ITEM
+ override val canExpand: Boolean = false
+ }
+
+ data object RecentsListFooter : RelayListItem {
+ override val key = "recents_list_footer"
+ override val contentType = RelayListItemContentType.RECENT_LIST_FOOTER
}
data class LocationsEmptyText(val searchTerm: String) : RelayListItem {
@@ -97,6 +134,11 @@ sealed interface RelayListItem {
override val key = "empty_relay_list"
override val contentType = RelayListItemContentType.EMPTY_RELAY_LIST
}
+
+ class SectionDivider : RelayListItem {
+ override val key: String = "section_divider_${this.hashCode()}"
+ override val contentType = RelayListItemContentType.SECTION_DIVIDER
+ }
}
data class CheckableRelayListItem(
@@ -130,3 +172,9 @@ sealed interface ItemPosition {
else -> false
}
}
+
+fun Hop.displayName(context: Context): String =
+ when (this) {
+ is Hop.Multi -> context.getString(R.string.x_via_x, exit.name, entry.name)
+ is Hop.Single<*> -> relay.name
+ }
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItemPreviewData.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItemPreviewData.kt
index 5776601168..58ae2f2e82 100644
--- a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItemPreviewData.kt
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItemPreviewData.kt
@@ -3,6 +3,7 @@ package net.mullvad.mullvadvpn.lib.ui.component.relaylist
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.Hop
import net.mullvad.mullvadvpn.lib.model.RelayItem
object RelayListItemPreviewData {
@@ -16,23 +17,25 @@ object RelayListItemPreviewData {
// 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(
- generateRelayItemCountry(
- name = "Country",
- cityNames = listOf("City"),
- relaysPerCity = 2,
- active = true,
- )
- ),
+ hop =
+ Hop.Single(
+ RelayItem.CustomList(
+ customList =
+ CustomList(
+ id = CustomListId("custom_list_id"),
+ name = CustomListName.fromString("Custom List"),
+ locations = emptyList(),
+ ),
+ locations =
+ listOf(
+ generateRelayItemCountry(
+ name = "Country",
+ cityNames = listOf("City"),
+ relaysPerCity = 2,
+ active = true,
+ )
+ ),
+ )
),
isSelected = false,
state = null,
@@ -63,7 +66,7 @@ object RelayListItemPreviewData {
addAll(
listOf(
RelayListItem.GeoLocationItem(
- item = locations[0],
+ hop = Hop.Single(locations[0]),
isSelected = false,
depth = 0,
expanded = true,
@@ -71,7 +74,7 @@ object RelayListItemPreviewData {
itemPosition = ItemPosition.Middle,
),
RelayListItem.GeoLocationItem(
- item = locations[0].cities[0],
+ hop = Hop.Single(locations[0].cities[0]),
isSelected = true,
depth = 1,
expanded = false,
@@ -79,7 +82,7 @@ object RelayListItemPreviewData {
itemPosition = ItemPosition.Middle,
),
RelayListItem.GeoLocationItem(
- item = locations[0].cities[1],
+ hop = Hop.Single(locations[0].cities[1]),
isSelected = false,
depth = 1,
expanded = true,
@@ -87,7 +90,7 @@ object RelayListItemPreviewData {
itemPosition = ItemPosition.Middle,
),
RelayListItem.GeoLocationItem(
- item = locations[0].cities[1].relays[0],
+ hop = Hop.Single(locations[0].cities[1].relays[0]),
isSelected = false,
depth = 2,
expanded = false,
@@ -95,7 +98,7 @@ object RelayListItemPreviewData {
itemPosition = ItemPosition.Middle,
),
RelayListItem.GeoLocationItem(
- item = locations[0].cities[1].relays[1],
+ hop = Hop.Single(locations[0].cities[1].relays[1]),
isSelected = false,
depth = 2,
expanded = false,
@@ -103,7 +106,7 @@ object RelayListItemPreviewData {
itemPosition = ItemPosition.Middle,
),
RelayListItem.GeoLocationItem(
- item = locations[1],
+ hop = Hop.Single(locations[1]),
isSelected = false,
depth = 0,
expanded = false,
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItem.kt
index 83b24ff137..289eb5aa9f 100644
--- a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItem.kt
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItem.kt
@@ -26,6 +26,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -72,7 +73,7 @@ fun SelectableRelayListItem(
modifier = modifier,
shape = relayListItem.itemPosition.toShape(),
selected = relayListItem.isSelected,
- enabled = relayListItem.item.active,
+ enabled = relayListItem.hop.isActive,
content = {
Row(
modifier =
@@ -84,7 +85,7 @@ fun SelectableRelayListItem(
) {
val iconTint =
when {
- !relayListItem.item.active -> MaterialTheme.colorScheme.error
+ !relayListItem.hop.isActive -> MaterialTheme.colorScheme.error
relayListItem.isSelected -> MaterialTheme.colorScheme.tertiary
else -> Color.Transparent
}
@@ -94,14 +95,14 @@ fun SelectableRelayListItem(
contentDescription = null,
tint = iconTint,
)
- } else if (!relayListItem.item.active) {
+ } else if (!relayListItem.hop.isActive) {
InactiveRelayIndicator(iconTint)
}
Name(
- name = relayListItem.item.name,
+ name = relayListItem.hop.displayName(LocalContext.current),
state = relayListItem.state,
- active = relayListItem.item.active,
+ active = relayListItem.hop.isActive,
)
}
},
@@ -111,7 +112,7 @@ fun SelectableRelayListItem(
else ({}),
onLongClick = onLongClick,
trailingContent =
- if (relayListItem.item.hasChildren) {
+ if (relayListItem.canExpand) {
{
ExpandChevron(
isExpanded = relayListItem.expanded,
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItemPreviewParameterProvider.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItemPreviewParameterProvider.kt
index 732c03bbc4..812f4de60e 100644
--- a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItemPreviewParameterProvider.kt
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItemPreviewParameterProvider.kt
@@ -1,6 +1,7 @@
package net.mullvad.mullvadvpn.lib.ui.component.relaylist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import net.mullvad.mullvadvpn.lib.model.Hop
class SelectableRelayListItemPreviewParameterProvider :
PreviewParameterProvider<List<RelayListItem.SelectableItem>> {
@@ -8,55 +9,65 @@ class SelectableRelayListItemPreviewParameterProvider :
sequenceOf(
listOf(
RelayListItem.GeoLocationItem(
- item =
- generateRelayItemCountry(
- name = "Relay country Active",
- cityNames = listOf("Relay city 1", "Relay city 2"),
- relaysPerCity = 2,
+ hop =
+ Hop.Single(
+ generateRelayItemCountry(
+ name = "Relay country Active",
+ cityNames = listOf("Relay city 1", "Relay city 2"),
+ relaysPerCity = 2,
+ )
),
isSelected = true,
expanded = false,
itemPosition = ItemPosition.Single,
),
RelayListItem.GeoLocationItem(
- item =
- generateRelayItemCountry(
- name = "Not Enabled Relay country",
- cityNames = listOf("Not Enabled city"),
- relaysPerCity = 1,
- active = false,
+ hop =
+ Hop.Single(
+ generateRelayItemCountry(
+ name = "Not Enabled Relay country",
+ cityNames = listOf("Not Enabled city"),
+ relaysPerCity = 1,
+ active = false,
+ )
),
isSelected = false,
itemPosition = ItemPosition.Single,
),
RelayListItem.GeoLocationItem(
- item =
- generateRelayItemCountry(
- name = "Relay country Expanded",
- cityNames = listOf("Normal city"),
- relaysPerCity = 2,
+ hop =
+ Hop.Single(
+ generateRelayItemCountry(
+ name = "Relay country Expanded",
+ cityNames = listOf("Normal city"),
+ relaysPerCity = 2,
+ )
),
isSelected = true,
expanded = true,
itemPosition = ItemPosition.Single,
),
RelayListItem.GeoLocationItem(
- item =
- generateRelayItemCountry(
- name = "Country and city Expanded",
- cityNames = listOf("Expanded city A", "Expanded city B"),
- relaysPerCity = 2,
+ hop =
+ Hop.Single(
+ generateRelayItemCountry(
+ name = "Country and city Expanded",
+ cityNames = listOf("Expanded city A", "Expanded city B"),
+ relaysPerCity = 2,
+ )
),
isSelected = false,
itemPosition = ItemPosition.Single,
),
RelayListItem.GeoLocationItem(
- item =
- generateRelayItemCountry(
- name = "Country selected but inactive",
- cityNames = listOf("Expanded city A", "Expanded city B"),
- relaysPerCity = 2,
- active = false,
+ hop =
+ Hop.Single(
+ generateRelayItemCountry(
+ name = "Country selected but inactive",
+ cityNames = listOf("Expanded city A", "Expanded city B"),
+ relaysPerCity = 2,
+ active = false,
+ )
),
isSelected = true,
itemPosition = ItemPosition.Single,
diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot
index 7b0ac3c8f2..0f1f997ba8 100644
--- a/desktop/packages/mullvad-vpn/locales/messages.pot
+++ b/desktop/packages/mullvad-vpn/locales/messages.pot
@@ -2958,6 +2958,9 @@ msgstr ""
msgid "Disable all \"%s\" above to activate this setting."
msgstr ""
+msgid "Disable recents"
+msgstr ""
+
msgid "Discard"
msgstr ""
@@ -2997,6 +3000,9 @@ msgstr ""
msgid "Enable method"
msgstr ""
+msgid "Enable recents"
+msgstr ""
+
msgid "Enter MTU"
msgstr ""
@@ -3096,6 +3102,9 @@ msgstr ""
msgid "Manage devices"
msgstr ""
+msgid "More actions"
+msgstr ""
+
msgid "Mullvad services unavailable"
msgstr ""
@@ -3123,6 +3132,9 @@ msgstr ""
msgid "No locations found"
msgstr ""
+msgid "No recent selection history"
+msgstr ""
+
msgid "No result for \"%s\", please try a different search"
msgstr ""
@@ -3162,6 +3174,12 @@ msgstr ""
msgid "Privacy policy"
msgstr ""
+msgid "Recents"
+msgstr ""
+
+msgid "Recents disabled and history cleared"
+msgstr ""
+
msgid "Recursion limit"
msgstr ""