diff options
| author | Kalle Lindström <karl.lindstrom@mullvad.net> | 2025-07-22 14:26:22 +0200 |
|---|---|---|
| committer | Kalle Lindström <karl.lindstrom@mullvad.net> | 2025-07-22 14:26:22 +0200 |
| commit | b2fc803af349205bc40d7cd00e0a480536c3d09e (patch) | |
| tree | d603241a7e9ed6284f89704140f02c1a828518cb | |
| parent | 75501a665b1bb7257cacd79f1eca84c839929725 (diff) | |
| parent | 526ecbf7d85c8abe7af08daf04dc4bc0c6df109c (diff) | |
| download | mullvadvpn-b2fc803af349205bc40d7cd00e0a480536c3d09e.tar.xz mullvadvpn-b2fc803af349205bc40d7cd00e0a480536c3d09e.zip | |
Merge branch 'implement-recents-support-ui'
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 "" |
