diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-08-04 17:04:50 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-08-08 13:04:00 +0200 |
| commit | b82153a408f035155c5a26fb68fcaa09e428ca2c (patch) | |
| tree | eb8e89e724ea6ca5403c1c38dce4eb846702e823 /android | |
| parent | 6dee63b6cb3e7dbf9e0d6c77d20a3a6e0dba0de1 (diff) | |
| download | mullvadvpn-b82153a408f035155c5a26fb68fcaa09e428ca2c.tar.xz mullvadvpn-b82153a408f035155c5a26fb68fcaa09e428ca2c.zip | |
Replace select hop code with use cases
Also split the select hop code into select hop and modify multihop
Refactor relay list type
Diffstat (limited to 'android')
36 files changed, 693 insertions, 311 deletions
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 fa41fa8ff5..46fa1b0e58 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 @@ -12,9 +12,9 @@ import io.mockk.verify import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_ITEM_CUSTOM_LISTS import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.RelayListType 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 @@ -41,7 +41,7 @@ class SearchLocationScreenTest { private fun ComposeContext.initScreen( state: Lce<Unit, SearchLocationUiState, Unit>, - onSelectHop: (Hop) -> Unit = {}, + onSelectRelayItem: (RelayItem, RelayListType) -> Unit = { _, _ -> }, onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit = { _, _, _ -> }, onSearchInputChanged: (String) -> Unit = {}, onCreateCustomList: (location: RelayItem.Location?) -> Unit = {}, @@ -63,7 +63,7 @@ class SearchLocationScreenTest { setContentWithTheme { SearchLocationScreen( state = state, - onSelectHop = onSelectHop, + onSelectRelayItem = onSelectRelayItem, onToggleExpand = onToggleExpand, onSearchInputChanged = onSearchInputChanged, onCreateCustomList = onCreateCustomList, @@ -89,9 +89,10 @@ class SearchLocationScreenTest { Lce.Content( SearchLocationUiState( searchTerm = "", + relayListType = RelayListType.Single, filterChips = emptyList(), relayListItems = emptyList(), - emptyList(), + customLists = emptyList(), ) ), onSearchInputChanged = mockedSearchTermInput, @@ -115,6 +116,7 @@ class SearchLocationScreenTest { Lce.Content( SearchLocationUiState( searchTerm = mockSearchString, + relayListType = RelayListType.Single, filterChips = emptyList(), relayListItems = listOf(RelayListItem.LocationsEmptyText(mockSearchString)), @@ -138,6 +140,7 @@ class SearchLocationScreenTest { Lce.Content( SearchLocationUiState( searchTerm = mockSearchString, + relayListType = RelayListType.Single, filterChips = emptyList(), relayListItems = emptyList(), customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, 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 35faa62190..5fdf9c047c 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 @@ -15,6 +15,7 @@ import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_COUNTRIES import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_ITEM_CUSTOM_LISTS import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.MultihopRelayListType import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState @@ -57,7 +58,8 @@ class SelectLocationScreenTest { private fun ComposeContext.initScreen( state: Lc<Unit, SelectLocationUiState> = Lc.Loading(Unit), - onSelectHop: (hop: Hop, relayListType: RelayListType) -> Unit = { _, _ -> }, + onSelectHop: (hop: Hop) -> Unit = {}, + onModifyMultihop: (RelayItem, MultihopRelayListType) -> Unit = { _, _ -> }, onSearchClick: (RelayListType) -> Unit = {}, onBackClick: () -> Unit = {}, onFilterClick: () -> Unit = {}, @@ -76,7 +78,7 @@ class SelectLocationScreenTest { onEditCustomListName: (RelayItem.CustomList) -> Unit = {}, onEditLocationsCustomList: (RelayItem.CustomList) -> Unit = {}, onDeleteCustomList: (RelayItem.CustomList) -> Unit = {}, - onSelectRelayList: (RelayListType) -> Unit = {}, + onSelectRelayList: (MultihopRelayListType) -> Unit = {}, openDaitaSettings: () -> Unit = {}, onRecentsToggleEnableClick: () -> Unit = {}, ) { @@ -85,6 +87,7 @@ class SelectLocationScreenTest { SelectLocationScreen( state = state, onSelectHop = onSelectHop, + onModifyMultihop = onModifyMultihop, onSearchClick = onSearchClick, onBackClick = onBackClick, onFilterClick = onFilterClick, @@ -112,6 +115,7 @@ class SelectLocationScreenTest { MutableStateFlow( Lce.Content( SelectLocationListUiState( + relayListType = RelayListType.Single, relayListItems = DUMMY_RELAY_COUNTRIES.map { RelayListItem.GeoLocationItem( @@ -129,7 +133,7 @@ class SelectLocationScreenTest { SelectLocationUiState( filterChips = emptyList(), multihopEnabled = false, - relayListType = RelayListType.EXIT, + relayListType = RelayListType.Single, isSearchButtonEnabled = true, isFilterButtonEnabled = true, isRecentsEnabled = true, @@ -156,6 +160,7 @@ class SelectLocationScreenTest { SelectLocationListUiState( relayListItems = listOf(RelayListItem.CustomListFooter(false)), customLists = emptyList(), + relayListType = RelayListType.Single, ) ) ) @@ -165,7 +170,7 @@ class SelectLocationScreenTest { SelectLocationUiState( filterChips = emptyList(), multihopEnabled = false, - relayListType = RelayListType.EXIT, + relayListType = RelayListType.Single, isSearchButtonEnabled = true, isFilterButtonEnabled = true, isRecentsEnabled = true, @@ -188,17 +193,18 @@ class SelectLocationScreenTest { SelectLocationListUiState( relayListItems = listOf(RelayListItem.CustomListItem(customList)), customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + relayListType = RelayListType.Single, ) ) ) - val mockedOnSelectHop: (Hop, RelayListType) -> Unit = mockk(relaxed = true) + val mockedOnSelectHop: (Hop) -> Unit = mockk(relaxed = true) initScreen( state = Lc.Content( SelectLocationUiState( filterChips = emptyList(), multihopEnabled = false, - relayListType = RelayListType.EXIT, + relayListType = RelayListType.Single, isSearchButtonEnabled = true, isFilterButtonEnabled = true, isRecentsEnabled = true, @@ -211,7 +217,7 @@ class SelectLocationScreenTest { onNodeWithText(customList.relay.name).performClick() // Assert - verify { mockedOnSelectHop(customList, RelayListType.EXIT) } + verify { mockedOnSelectHop(customList) } } @Test @@ -225,17 +231,18 @@ class SelectLocationScreenTest { SelectLocationListUiState( relayListItems = listOf(RelayListItem.RecentListItem(recent)), customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + relayListType = RelayListType.Single, ) ) ) - val mockedOnSelectHop: (Hop, RelayListType) -> Unit = mockk(relaxed = true) + val mockedOnSelectHop: (Hop) -> Unit = mockk(relaxed = true) initScreen( state = Lc.Content( SelectLocationUiState( filterChips = emptyList(), multihopEnabled = false, - relayListType = RelayListType.EXIT, + relayListType = RelayListType.Single, isSearchButtonEnabled = true, isFilterButtonEnabled = true, isRecentsEnabled = true, @@ -248,7 +255,7 @@ class SelectLocationScreenTest { onNodeWithText(recent.relay.name).performClick() // Assert - verify { mockedOnSelectHop(recent, RelayListType.EXIT) } + verify { mockedOnSelectHop(recent) } } @Test @@ -262,17 +269,18 @@ class SelectLocationScreenTest { SelectLocationListUiState( relayListItems = listOf(RelayListItem.CustomListItem(hop = customList)), customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + relayListType = RelayListType.Single, ) ) ) - val mockedOnSelectHop: (Hop, RelayListType) -> Unit = mockk(relaxed = true) + val mockedOnSelectHop: (Hop) -> Unit = mockk(relaxed = true) initScreen( state = Lc.Content( SelectLocationUiState( filterChips = emptyList(), multihopEnabled = false, - relayListType = RelayListType.EXIT, + relayListType = RelayListType.Single, isSearchButtonEnabled = true, isFilterButtonEnabled = true, isRecentsEnabled = true, @@ -305,17 +313,18 @@ class SelectLocationScreenTest { ) ), customLists = emptyList(), + relayListType = RelayListType.Single, ) ) ) - val mockedOnSelectHop: (Hop, RelayListType) -> Unit = mockk(relaxed = true) + val mockedOnSelectHop: (Hop) -> Unit = mockk(relaxed = true) initScreen( state = Lc.Content( SelectLocationUiState( filterChips = emptyList(), multihopEnabled = false, - relayListType = RelayListType.EXIT, + relayListType = RelayListType.Single, isSearchButtonEnabled = true, isFilterButtonEnabled = true, isRecentsEnabled = true, 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 index 38f4adc250..5f09c46b78 100644 --- 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 @@ -1,6 +1,8 @@ package net.mullvad.mullvadvpn.compose.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.MultihopRelayListType +import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItemPreviewData import net.mullvad.mullvadvpn.util.Lce @@ -17,6 +19,7 @@ class SearchLocationsListUiStatePreviewParameterProvider : isSearching = false, ), customLists = emptyList(), + relayListType = RelayListType.Multihop(MultihopRelayListType.EXIT), ) ), Lce.Loading(Unit), diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt index c868360c55..ea560d4ae2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt @@ -1,6 +1,8 @@ package net.mullvad.mullvadvpn.compose.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.MultihopRelayListType +import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SearchLocationUiState import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItemPreviewData import net.mullvad.mullvadvpn.usecase.FilterChip @@ -21,6 +23,7 @@ class SearchLocationsUiStatePreviewParameterProvider : isSearching = true, ), customLists = emptyList(), + relayListType = RelayListType.Multihop(MultihopRelayListType.ENTRY), ) ), Lce.Error(Unit), @@ -31,6 +34,7 @@ class SearchLocationsUiStatePreviewParameterProvider : relayListItems = RelayListItemPreviewData.generateEmptyList("Mullvad", isSearching = true), customLists = emptyList(), + relayListType = RelayListType.Multihop(MultihopRelayListType.ENTRY), ) ), Lce.Content( @@ -43,6 +47,7 @@ class SearchLocationsUiStatePreviewParameterProvider : isSearching = true, ), customLists = emptyList(), + relayListType = RelayListType.Multihop(MultihopRelayListType.ENTRY), ) ), ) 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 34275cf241..a130112307 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt @@ -1,6 +1,7 @@ package net.mullvad.mullvadvpn.compose.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.MultihopRelayListType import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.usecase.FilterChip @@ -16,7 +17,7 @@ class SelectLocationsUiStatePreviewParameterProvider : SelectLocationUiState( filterChips = emptyList(), multihopEnabled = false, - relayListType = RelayListType.EXIT, + relayListType = RelayListType.Single, isSearchButtonEnabled = true, isFilterButtonEnabled = true, isRecentsEnabled = true, @@ -29,7 +30,7 @@ class SelectLocationsUiStatePreviewParameterProvider : FilterChip.Provider(PROVIDER_COUNT), ), multihopEnabled = false, - relayListType = RelayListType.EXIT, + relayListType = RelayListType.Single, isSearchButtonEnabled = true, isFilterButtonEnabled = true, isRecentsEnabled = true, @@ -38,7 +39,7 @@ class SelectLocationsUiStatePreviewParameterProvider : SelectLocationUiState( filterChips = emptyList(), multihopEnabled = true, - relayListType = RelayListType.ENTRY, + relayListType = RelayListType.Multihop(MultihopRelayListType.ENTRY), isSearchButtonEnabled = true, isFilterButtonEnabled = true, isRecentsEnabled = true, @@ -51,7 +52,7 @@ class SelectLocationsUiStatePreviewParameterProvider : FilterChip.Provider(PROVIDER_COUNT), ), multihopEnabled = true, - relayListType = RelayListType.ENTRY, + relayListType = RelayListType.Multihop(MultihopRelayListType.ENTRY), isSearchButtonEnabled = true, isFilterButtonEnabled = true, isRecentsEnabled = true, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt index 95611ccf5e..114bc1a030 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt @@ -3,6 +3,7 @@ package net.mullvad.mullvadvpn.compose.screen import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -172,7 +173,7 @@ fun CustomListLocationsScreen( color = MaterialTheme.colorScheme.onSurface.copy(alpha = AlphaScrollbar), ) .padding(horizontal = Dimens.mediumPadding) - .fillMaxWidth(), + .fillMaxSize(), state = lazyListState, ) { when (state.content) { 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 98b553cac1..3c3402d1c9 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 @@ -42,6 +42,7 @@ fun LazyListScope.relayListContent( relayListItems: List<RelayListItem>, customLists: List<RelayItem.CustomList>, onSelectHop: (Hop) -> Unit, + onSelectRelayItem: (RelayItem) -> Unit, onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit, onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, customListHeader: @Composable (LazyItemScope.() -> Unit) = {}, @@ -58,14 +59,14 @@ fun LazyListScope.relayListContent( is RelayListItem.CustomListItem -> CustomListItem( listItem, - onSelectHop = onSelectHop, + onSelectHop = { onSelectRelayItem(it.exit()) }, onToggleExpand = onToggleExpand, onUpdateBottomSheetState = onUpdateBottomSheetState, ) is RelayListItem.CustomListEntryItem -> CustomListEntryItem( listItem, - onSelectHop = onSelectHop, + onSelectHop = { onSelectRelayItem(it.exit()) }, onToggleExpand = onToggleExpand, onUpdateBottomSheetState = onUpdateBottomSheetState, ) @@ -74,7 +75,7 @@ fun LazyListScope.relayListContent( is RelayListItem.GeoLocationItem -> GeoLocationItem( listItem, - onSelectHop = onSelectHop, + onSelectHop = { onSelectRelayItem(it.exit()) }, onToggleExpand = onToggleExpand, onUpdateBottomSheetState = onUpdateBottomSheetState, customLists = customLists, @@ -110,7 +111,7 @@ fun Modifier.positionalPadding(itemPosition: ItemPosition): Modifier = @Composable private fun GeoLocationItem( listItem: RelayListItem.GeoLocationItem, - onSelectHop: (Hop) -> Unit, + onSelectHop: (Hop.Single<*>) -> Unit, onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit, onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, customLists: List<RelayItem.CustomList>, @@ -150,7 +151,7 @@ private fun RecentListItem( @Composable private fun CustomListItem( listItem: RelayListItem.CustomListItem, - onSelectHop: (Hop) -> Unit, + onSelectHop: (Hop.Single<*>) -> Unit, onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit, onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, ) { @@ -166,7 +167,7 @@ private fun CustomListItem( @Composable private fun CustomListEntryItem( listItem: RelayListItem.CustomListEntryItem, - onSelectHop: (Hop) -> Unit, + onSelectHop: (Hop.Single<*>) -> Unit, onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit, onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, ) { 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 f3893dcd40..a14788584d 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,13 +67,11 @@ 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 @@ -90,7 +88,7 @@ private fun PreviewSearchLocationScreen( SearchLocationScreen( state = state, snackbarHostState = SnackbarHostState(), - onSelectHop = {}, + onSelectRelayItem = { _, _ -> }, onToggleExpand = { _, _, _ -> }, onSearchInputChanged = {}, onCreateCustomList = {}, @@ -150,17 +148,33 @@ fun SearchLocation( message = context.getString(R.string.error_occurred) ) } - - is SearchLocationSideEffect.HopInactive -> { + is SearchLocationSideEffect.EntryAlreadySelected -> + launch { + snackbarHostState.showSnackbarImmediately( + message = + context.getString( + R.string.relay_item_already_selected_as_entry, + it.relayItem.name, + ) + ) + } + is SearchLocationSideEffect.ExitAlreadySelected -> launch { snackbarHostState.showSnackbarImmediately( message = context.getString( - R.string.relayitem_is_inactive, - it.hop.displayName(context), + R.string.relay_item_already_selected_as_exit, + it.relayItem.name, ) ) } + is SearchLocationSideEffect.RelayItemInactive -> { + launch { + snackbarHostState.showSnackbarImmediately( + message = + context.getString(R.string.relayitem_is_inactive, it.relayItem.name) + ) + } } } } @@ -188,7 +202,7 @@ fun SearchLocation( SearchLocationScreen( state = state, snackbarHostState = snackbarHostState, - onSelectHop = viewModel::selectHop, + onSelectRelayItem = viewModel::selectRelayItem, onToggleExpand = viewModel::onToggleExpand, onSearchInputChanged = viewModel::onSearchInputUpdated, onCreateCustomList = @@ -233,7 +247,7 @@ fun SearchLocation( fun SearchLocationScreen( state: Lce<Unit, SearchLocationUiState, Unit>, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, - onSelectHop: (Hop) -> Unit, + onSelectRelayItem: (RelayItem, RelayListType) -> Unit, onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit, onSearchInputChanged: (String) -> Unit, onCreateCustomList: (location: RelayItem.Location?) -> Unit, @@ -313,7 +327,10 @@ fun SearchLocationScreen( relayListContent( relayListItems = state.value.relayListItems, customLists = state.value.customLists, - onSelectHop = onSelectHop, + onSelectHop = { error("Can not select hop in search screen") }, + onSelectRelayItem = { + onSelectRelayItem(it, state.value.relayListType) + }, 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 eba2498ca1..b7172865c2 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 @@ -38,6 +38,7 @@ import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState import net.mullvad.mullvadvpn.compose.util.RunOnKeyChange 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 @@ -61,6 +62,7 @@ private fun PreviewSelectLocationList( lazyListState = rememberLazyListState(), openDaitaSettings = {}, onSelectHop = {}, + onSelectRelayItem = { _, _ -> }, onUpdateBottomSheetState = {}, onAddCustomList = {}, onEditCustomLists = {}, @@ -77,7 +79,8 @@ private typealias Content = Lce.Content<SelectLocationListUiState> @Composable fun SelectLocationList( relayListType: RelayListType, - onSelectHop: (Hop, RelayListType) -> Unit, + onSelectHop: (Hop) -> Unit, + onSelectRelayItem: (RelayItem, RelayListType) -> Unit, openDaitaSettings: () -> Unit, onAddCustomList: () -> Unit, onEditCustomLists: (() -> Unit)?, @@ -85,7 +88,7 @@ fun SelectLocationList( ) { val viewModel = koinViewModel<SelectLocationListViewModel>( - key = relayListType.name, + key = relayListType.toString(), parameters = { parametersOf(relayListType) }, ) val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -103,7 +106,8 @@ fun SelectLocationList( state = state, lazyListState = lazyListState, openDaitaSettings = openDaitaSettings, - onSelectHop = { onSelectHop(it, relayListType) }, + onSelectHop = onSelectHop, + onSelectRelayItem = onSelectRelayItem, onUpdateBottomSheetState = onUpdateBottomSheetState, onAddCustomList = onAddCustomList, onEditCustomLists = onEditCustomLists, @@ -117,6 +121,7 @@ private fun SelectLocationListContent( lazyListState: LazyListState, openDaitaSettings: () -> Unit, onSelectHop: (Hop) -> Unit, + onSelectRelayItem: (relayItem: RelayItem, relayListType: RelayListType) -> Unit, onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, onAddCustomList: () -> Unit, onEditCustomLists: (() -> Unit)?, @@ -159,6 +164,7 @@ private fun SelectLocationListContent( relayListItems = state.value.relayListItems, customLists = state.value.customLists, onSelectHop = onSelectHop, + onSelectRelayItem = { onSelectRelayItem(it, state.value.relayListType) }, onToggleExpand = onToggleExpand, onUpdateBottomSheetState = onUpdateBottomSheetState, customListHeader = { 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 dab28c681e..3abeee2143 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 @@ -73,6 +73,7 @@ import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicator import net.mullvad.mullvadvpn.compose.component.ScaffoldWithSmallTopBar import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed import net.mullvad.mullvadvpn.compose.preview.SelectLocationsUiStatePreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.MultihopRelayListType import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.compose.transitions.TopLevelTransition @@ -102,7 +103,8 @@ private fun PreviewSelectLocationScreen( SelectLocationScreen( state = state, snackbarHostState = SnackbarHostState(), - onSelectHop = { _, _ -> }, + onSelectHop = {}, + onModifyMultihop = { _, _ -> }, onSearchClick = {}, onBackClick = {}, onFilterClick = {}, @@ -147,6 +149,7 @@ fun SelectLocation( val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current + val focusManager = LocalFocusManager.current CollectSideEffectWithLifecycle(vm.uiSideEffect) { when (it) { SelectLocationSideEffect.CloseScreen -> backNavigator.navigateBack(result = true) @@ -164,7 +167,26 @@ fun SelectLocation( message = context.getString(R.string.error_occurred) ) } - + is SelectLocationSideEffect.EntryAlreadySelected -> + launch { + snackbarHostState.showSnackbarImmediately( + message = + context.getString( + R.string.relay_item_already_selected_as_entry, + it.relayItem.name, + ) + ) + } + is SelectLocationSideEffect.ExitAlreadySelected -> + launch { + snackbarHostState.showSnackbarImmediately( + message = + context.getString( + R.string.relay_item_already_selected_as_exit, + it.relayItem.name, + ) + ) + } is SelectLocationSideEffect.RelayItemInactive -> launch { snackbarHostState.showSnackbarImmediately( @@ -175,6 +197,24 @@ fun SelectLocation( ) ) } + SelectLocationSideEffect.EntryAndExitAreSame -> + launch { + snackbarHostState.showSnackbarImmediately( + message = context.getString(R.string.entry_and_exit_are_same) + ) + } + is SelectLocationSideEffect.FocusExitList -> + launch { + // If multihop is enabled and the user selects a location or custom list in the + // entry list + // the app will switch to the exit list. Normally in this case the focus will + // stay in the + // entry list, but in this case we want move the focus to the exit list. + focusManager.moveFocus(FocusDirection.Right) + if (it.relayItem.hasChildren) { + focusManager.moveFocus(FocusDirection.Right) + } + } } } @@ -197,10 +237,12 @@ fun SelectLocation( searchSelectedLocationResultRecipient.onResult { result -> when (result) { - RelayListType.ENTRY -> { - vm.selectRelayList(RelayListType.EXIT) - } - RelayListType.EXIT -> backNavigator.navigateBack(result = true) + RelayListType.Single -> backNavigator.navigateBack(result = true) + is RelayListType.Multihop -> + when (result.multihopRelayListType) { + MultihopRelayListType.ENTRY -> vm.selectRelayList(MultihopRelayListType.EXIT) + MultihopRelayListType.EXIT -> backNavigator.navigateBack(result = true) + } } } @@ -208,6 +250,7 @@ fun SelectLocation( state = state.value, snackbarHostState = snackbarHostState, onSelectHop = vm::selectHop, + onModifyMultihop = vm::modifyMultihop, onSearchClick = { navigator.navigate(SearchLocationDestination(it)) }, onBackClick = dropUnlessResumed { backNavigator.navigateBack() }, onFilterClick = dropUnlessResumed { navigator.navigate(FilterDestination) }, @@ -256,7 +299,8 @@ fun SelectLocation( fun SelectLocationScreen( state: Lc<Unit, SelectLocationUiState>, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, - onSelectHop: (item: Hop, relayListType: RelayListType) -> Unit, + onSelectHop: (item: Hop) -> Unit, + onModifyMultihop: (relayItem: RelayItem, relayListType: MultihopRelayListType) -> Unit, onSearchClick: (RelayListType) -> Unit, onBackClick: () -> Unit, onFilterClick: () -> Unit, @@ -270,7 +314,7 @@ fun SelectLocationScreen( onEditCustomListName: (RelayItem.CustomList) -> Unit, onEditLocationsCustomList: (RelayItem.CustomList) -> Unit, onDeleteCustomList: (RelayItem.CustomList) -> Unit, - onSelectRelayList: (RelayListType) -> Unit, + onSelectRelayList: (MultihopRelayListType) -> Unit, openDaitaSettings: () -> Unit, ) { val backgroundColor = MaterialTheme.colorScheme.surface @@ -349,18 +393,16 @@ fun SelectLocationScreen( is Lc.Content -> { val pagerState = rememberPagerState( - initialPage = state.value.relayListType.ordinal, - pageCount = { - if (state.value.multihopEnabled) { - RelayListType.entries.size - } else { - 1 - } - }, + initialPage = state.value.relayListType.initialPage(), + pageCount = { state.value.relayListType.pageCount() }, ) - if (state.value.multihopEnabled) { - MultihopBar(pagerState, state.value.relayListType, onSelectRelayList) + if (state.value.relayListType is RelayListType.Multihop) { + MultihopBar( + pagerState, + state.value.relayListType.multihopRelayListType, + onSelectRelayList, + ) } AnimatedContent( @@ -385,6 +427,7 @@ fun SelectLocationScreen( pagerState, state = state.value, onSelectHop = onSelectHop, + onModifyMultihop = onModifyMultihop, openDaitaSettings = openDaitaSettings, onAddCustomList = { onCreateCustomList(null) }, onEditCustomLists = onEditCustomLists, @@ -464,8 +507,8 @@ private fun SelectLocationDropdownMenu( @Composable private fun MultihopBar( pagerState: PagerState, - relayListType: RelayListType, - onSelectHopList: (RelayListType) -> Unit, + relayListType: MultihopRelayListType, + onSelectHopList: (MultihopRelayListType) -> Unit, ) { SingleChoiceSegmentedButtonRow( modifier = @@ -477,21 +520,21 @@ private fun MultihopBar( ) ) { MullvadSegmentedStartButton( - selected = relayListType == RelayListType.ENTRY, + selected = relayListType == MultihopRelayListType.ENTRY, selectedProgress = 1f - - abs(pagerState.getOffsetDistanceInPages(RelayListType.ENTRY.ordinal)) + abs(pagerState.getOffsetDistanceInPages(MultihopRelayListType.ENTRY.ordinal)) .coerceIn(0f..1f), - onClick = { onSelectHopList(RelayListType.ENTRY) }, + onClick = { onSelectHopList(MultihopRelayListType.ENTRY) }, text = stringResource(id = R.string.entry), ) MullvadSegmentedEndButton( - selected = relayListType == RelayListType.EXIT, + selected = relayListType == MultihopRelayListType.EXIT, selectedProgress = 1f - - abs(pagerState.getOffsetDistanceInPages(RelayListType.EXIT.ordinal)) + abs(pagerState.getOffsetDistanceInPages(MultihopRelayListType.EXIT.ordinal)) .coerceIn(0f..1f), - onClick = { onSelectHopList(RelayListType.EXIT) }, + onClick = { onSelectHopList(MultihopRelayListType.EXIT) }, text = stringResource(id = R.string.exit), ) } @@ -502,43 +545,35 @@ private fun MultihopBar( private fun RelayLists( pagerState: PagerState, state: SelectLocationUiState, - onSelectHop: (Hop, RelayListType) -> Unit, + onSelectHop: (hop: Hop) -> Unit, + onModifyMultihop: (RelayItem, MultihopRelayListType) -> Unit, openDaitaSettings: () -> Unit, onAddCustomList: () -> Unit, onEditCustomLists: (() -> Unit)?, onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, - onSelectRelayList: (RelayListType) -> Unit, + onSelectRelayList: (MultihopRelayListType) -> Unit, ) { - // This is so that when the pager is scrolled by the user the relay list type is updated - // correctly. - // If multihop is not enabled, the pager will only have one page, so this will not be called. - if (state.multihopEnabled) { + if (state.relayListType is RelayListType.Multihop) { + // This is so that when the pager is scrolled by the user the relay list type is updated + // correctly. + // If multihop is not enabled, the pager will only have one page, so this will not be + // called. LaunchedEffect(pagerState.currentPage) { - onSelectRelayList(RelayListType.entries[pagerState.currentPage]) + onSelectRelayList(MultihopRelayListType.entries[pagerState.currentPage]) + } + // This is so that when the relay list entry or exit button is clicked, the pager will + // scroll to the correct page. + LaunchedEffect(state.relayListType.multihopRelayListType) { + val index = state.relayListType.multihopRelayListType.ordinal + pagerState.animateScrollToPage(index) } } - LaunchedEffect(state.relayListType) { - val index = state.relayListType.ordinal - pagerState.animateScrollToPage(index) - } - - val focusManager = LocalFocusManager.current - val onSelectHopInner: (Hop, RelayListType) -> Unit = { hop, relayListType -> - onSelectHop(hop, relayListType) - // If multihop is enabled and the user selects a location or custom list in the entry list - // the app will switch to the exit list. Normally in this case the focus will stay in the - // entry list, but in this case we want move the focus to the exit list. - if ( - state.multihopEnabled && - relayListType == RelayListType.ENTRY && - hop is Hop.Single<*> && - hop.isActive - ) { - focusManager.moveFocus(FocusDirection.Right) - if (hop.relay.hasChildren) { - focusManager.moveFocus(FocusDirection.Right) - } + val onSelectRelayItem: (RelayItem, RelayListType) -> Unit = { relayItem, relayListType -> + if (relayListType is RelayListType.Multihop) { + onModifyMultihop(relayItem, relayListType.multihopRelayListType) + } else { + onSelectHop(Hop.Single(relayItem)) } } @@ -555,11 +590,12 @@ private fun RelayLists( SelectLocationList( relayListType = if (state.multihopEnabled) { - RelayListType.entries[pageIndex] + RelayListType.Multihop(MultihopRelayListType.entries[pageIndex]) } else { - RelayListType.EXIT + RelayListType.Single }, - onSelectHop = onSelectHopInner, + onSelectHop = onSelectHop, + onSelectRelayItem = onSelectRelayItem, openDaitaSettings = openDaitaSettings, onAddCustomList = onAddCustomList, onEditCustomLists = onEditCustomLists, @@ -572,3 +608,15 @@ private fun RelayLists( private fun ColumnScope.Loading() { MullvadCircularProgressIndicatorLarge(modifier = Modifier.align(Alignment.CenterHorizontally)) } + +private fun RelayListType.initialPage(): Int = + when (this) { + is RelayListType.Multihop -> multihopRelayListType.ordinal + RelayListType.Single -> 0 + } + +private fun RelayListType.pageCount(): Int = + when (this) { + is RelayListType.Multihop -> MultihopRelayListType.entries.size + RelayListType.Single -> 1 + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListType.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListType.kt index 6640ceea4a..6073992261 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListType.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListType.kt @@ -1,6 +1,16 @@ package net.mullvad.mullvadvpn.compose.state -enum class RelayListType { +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +enum class MultihopRelayListType { ENTRY, EXIT, } + +sealed interface RelayListType : Parcelable { + @Parcelize + data class Multihop(val multihopRelayListType: MultihopRelayListType) : RelayListType + + @Parcelize data object Single : RelayListType +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt index 909e4ea8eb..5ea48d1072 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt @@ -6,6 +6,7 @@ import net.mullvad.mullvadvpn.usecase.FilterChip data class SearchLocationUiState( val searchTerm: String, + val relayListType: RelayListType, val filterChips: List<FilterChip>, val relayListItems: List<RelayListItem>, val customLists: List<RelayItem.CustomList>, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt index 39199b9d04..22b0977074 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt @@ -4,6 +4,7 @@ import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItem data class SelectLocationListUiState( + val relayListType: RelayListType, val relayListItems: List<RelayListItem>, val customLists: List<RelayItem.CustomList>, ) 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 95e2b4c7e7..e24ad8e9ef 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 @@ -38,6 +38,7 @@ import net.mullvad.mullvadvpn.usecase.FilterChipUseCase import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase import net.mullvad.mullvadvpn.usecase.InternetAvailableUseCase import net.mullvad.mullvadvpn.usecase.LastKnownLocationUseCase +import net.mullvad.mullvadvpn.usecase.ModifyMultihopUseCase import net.mullvad.mullvadvpn.usecase.NewChangelogNotificationUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase @@ -45,6 +46,7 @@ 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.SelectHopUseCase import net.mullvad.mullvadvpn.usecase.SelectedLocationTitleUseCase import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsAvailableUseCase @@ -160,9 +162,18 @@ val uiModule = module { single { FilteredRelayListUseCase(get(), get(), get(), get()) } single { LastKnownLocationUseCase(get()) } single { SelectedLocationUseCase(get(), get()) } - single { FilterChipUseCase(get(), get(), get(), get()) } + single { FilterChipUseCase(get(), get(), get()) } single { DeleteCustomDnsUseCase(get()) } single { RecentsUseCase(get(), get(), get()) } + single { SelectHopUseCase(relayListRepository = get()) } + single { + ModifyMultihopUseCase( + relayListRepository = get(), + settingsRepository = get(), + customListsRepository = get(), + wireguardConstraintsRepository = get(), + ) + } single { InAppNotificationController(get(), get(), get(), get(), get(), MainScope()) } @@ -223,7 +234,9 @@ val uiModule = module { viewModel { WireguardCustomPortDialogViewModel(get()) } viewModel { LoginViewModel(get(), get(), get()) } viewModel { PrivacyDisclaimerViewModel(get(), IS_PLAY_BUILD) } - viewModel { SelectLocationViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { + SelectLocationViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get()) + } viewModel { SettingsViewModel(get(), get(), get(), get(), IS_PLAY_BUILD) } viewModel { SplashViewModel(get(), get(), get(), get()) } viewModel { VoucherDialogViewModel(get()) } @@ -263,6 +276,7 @@ val uiModule = module { get(), get(), get(), + get(), ) } viewModel { (relayListType: RelayListType) -> diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt index fac0e77321..4e8575f8f9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt @@ -26,3 +26,6 @@ fun RelayItem.CustomList.canAddLocation(location: RelayItem) = fun List<RelayItem.CustomList>.getById(id: CustomListId) = this.find { it.id == id } fun List<CustomList>.getById(id: CustomListId) = this.find { it.id == id } + +fun RelayItem.CustomList.onlyContains(relayItem: RelayItem.Location) = + this.locations.size == 1 && this.locations.first() == relayItem 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 4803b966a9..f493aa97cb 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 @@ -130,3 +130,25 @@ fun List<RelayItem.Location.Country>.findRelay( ?.find { city -> city.id == geoLocationId.city } ?.relays ?.find { relay -> relay.id == geoLocationId } + +/** + * Checks if two RelayItems are the same for the purpose of blocking selection. Only relays are + * considered the same, cities and countries are not. For the purpose of blocking selection, custom + * lists are considered to be a relay if and only if they contain a single relay. + */ +fun RelayItem.isTheSameAs(other: RelayItem): Boolean { + return when (this) { + is RelayItem.Location.Relay -> { + (other is RelayItem.Location.Relay && this.id == other.id) || + (other is RelayItem.CustomList && other.onlyContains(this)) + } + is RelayItem.CustomList -> { + (other is RelayItem.Location.Relay && this.onlyContains(other)) || + (other is RelayItem.CustomList && + this.locations.size == 1 && + this.locations.first() is RelayItem.Location.Relay && + other.onlyContains(this.locations.first())) + } + else -> false + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepository.kt index 6b8fd69ff8..06d74eed3f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepository.kt @@ -2,6 +2,7 @@ package net.mullvad.mullvadvpn.repository import arrow.core.Either import arrow.core.raise.either +import arrow.core.raise.ensureNotNull import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -63,10 +64,15 @@ class CustomListsRepository( * updateCustomList just before this you might get an out of date value. */ fun getCustomListById(id: CustomListId): Either<GetCustomListError, CustomList> = either { - val customLists = - customLists.value - ?: raise(GetCustomListError(id)).also { Logger.e("Custom lists never loaded") } - customLists.firstOrNull { customList -> customList.id == id } - ?: raise(GetCustomListError(id)) + val customLists = customLists.value + ensureNotNull(customLists) { + Logger.e("Custom lists never loaded") + GetCustomListError(id) + } + val foundList = customLists.firstOrNull { customList -> customList.id == id } + ensureNotNull(foundList) { + Logger.e("Custom list with id $id not found in custom lists") + GetCustomListError(id) + } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCase.kt index 8c542202ff..5e7a82d9ed 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCase.kt @@ -10,7 +10,6 @@ import net.mullvad.mullvadvpn.lib.model.Providers import net.mullvad.mullvadvpn.lib.model.Settings import net.mullvad.mullvadvpn.repository.RelayListFilterRepository import net.mullvad.mullvadvpn.repository.SettingsRepository -import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository import net.mullvad.mullvadvpn.util.shouldFilterByDaita typealias ModelOwnership = Ownership @@ -19,7 +18,6 @@ class FilterChipUseCase( private val relayListFilterRepository: RelayListFilterRepository, private val providerToOwnershipsUseCase: ProviderToOwnershipsUseCase, private val settingsRepository: SettingsRepository, - private val wireguardConstraintsRepository: WireguardConstraintsRepository, ) { operator fun invoke(relayListType: RelayListType): Flow<List<FilterChip>> = combine( @@ -27,19 +25,12 @@ class FilterChipUseCase( relayListFilterRepository.selectedProviders, providerToOwnershipsUseCase(), settingsRepository.settingsUpdates, - wireguardConstraintsRepository.wireguardConstraints, - ) { - selectedOwnership, - selectedConstraintProviders, - providerOwnership, - settings, - wireguardConstraints -> + ) { selectedOwnership, selectedConstraintProviders, providerOwnership, settings -> filterChips( selectedOwnership = selectedOwnership, selectedConstraintProviders = selectedConstraintProviders, providerToOwnerships = providerOwnership, daitaDirectOnly = settings?.daitaAndDirectOnly() == true, - isMultihopEnabled = wireguardConstraints?.isMultihopEnabled == true, relayListType = relayListType, ) } @@ -49,7 +40,6 @@ class FilterChipUseCase( selectedConstraintProviders: Constraint<Providers>, providerToOwnerships: Map<ProviderId, Set<Ownership>>, daitaDirectOnly: Boolean, - isMultihopEnabled: Boolean, relayListType: RelayListType, ): List<FilterChip> { val ownershipFilter = selectedOwnership.getOrNull() @@ -66,11 +56,7 @@ class FilterChipUseCase( // If the provider has been removed from the relay list we add it // so it is visible for the user, because we won't know what // ownerships it had. - if (providerOwnerships == null) { - true - } else { - providerOwnerships.contains(ownershipFilter) - } + providerOwnerships?.contains(ownershipFilter) ?: true } } .size @@ -86,7 +72,6 @@ class FilterChipUseCase( shouldFilterByDaita( daitaDirectOnly = daitaDirectOnly, relayListType = relayListType, - isMultihopEnabled = isMultihopEnabled, ) ) { add(FilterChip.Daita) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilteredRelayListUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilteredRelayListUseCase.kt index 15eee7e4ed..875d63c1ce 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilteredRelayListUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilteredRelayListUseCase.kt @@ -34,7 +34,6 @@ class FilteredRelayListUseCase( shouldFilterByDaita = shouldFilterByDaita( daitaDirectOnly = settings?.daitaAndDirectOnly() == true, - isMultihopEnabled = wireguardConstraints?.isMultihopEnabled == true, relayListType = relayListType, ), ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/ModifyMultihopUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/ModifyMultihopUseCase.kt new file mode 100644 index 0000000000..de76583166 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/ModifyMultihopUseCase.kt @@ -0,0 +1,104 @@ +package net.mullvad.mullvadvpn.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.raise.either +import arrow.core.raise.ensure +import arrow.core.right +import co.touchlab.kermit.Logger +import kotlin.collections.first +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.lib.model.Settings +import net.mullvad.mullvadvpn.repository.CustomListsRepository +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.repository.SettingsRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository + +class ModifyMultihopUseCase( + private val relayListRepository: RelayListRepository, + private val settingsRepository: SettingsRepository, + private val customListsRepository: CustomListsRepository, + private val wireguardConstraintsRepository: WireguardConstraintsRepository, +) { + suspend operator fun invoke(change: MultihopChange): Either<ModifyMultihopError, Unit> = + either { + ensure(change.item.active) { ModifyMultihopError.RelayItemInactive(change.item) } + val changeId: RelayItemId = + change.item.id.convertCustomListWithOnlyHostNameToHostName().bind() + val other = + when (change) { + is MultihopChange.Entry -> + settingsRepository.settingsUpdates.value.exit().bind() + is MultihopChange.Exit -> + settingsRepository.settingsUpdates.value.entry().bind() + } + .convertCustomListWithOnlyHostNameToHostName() + .bind() + ensure(!changeId.isSameHost(other)) { ModifyMultihopError.EntrySameAsExit(change.item) } + when (change) { + is MultihopChange.Entry -> + wireguardConstraintsRepository.setEntryLocation(change.item.id) + is MultihopChange.Exit -> + relayListRepository.updateSelectedRelayLocation(change.item.id) + } + .mapLeft { + Logger.e("Failed to update multihop: $it") + ModifyMultihopError.GenericError + } + .bind() + } + + private fun Settings?.exit(): Either<ModifyMultihopError.GenericError, RelayItemId> = + this?.relaySettings?.relayConstraints?.location?.getOrNull()?.right() + ?: ModifyMultihopError.GenericError.left() + + private fun Settings?.entry(): Either<ModifyMultihopError.GenericError, RelayItemId> = + this?.relaySettings + ?.relayConstraints + ?.wireguardConstraints + ?.entryLocation + ?.getOrNull() + ?.right() ?: ModifyMultihopError.GenericError.left() + + private fun RelayItemId.convertCustomListWithOnlyHostNameToHostName(): + Either<ModifyMultihopError.GenericError, RelayItemId> = + when (this) { + is CustomListId -> + customListsRepository + .getCustomListById(this) + .mapLeft { + Logger.e("Failed to get custom list by id: $it") + ModifyMultihopError.GenericError + } + .map { + if (it.locations.size == 1) { + it.locations.first() as? GeoLocationId.Hostname ?: this + } else { + this + } + } + else -> this.right() + } + + private fun RelayItemId.isSameHost(other: RelayItemId): Boolean = + this is GeoLocationId.Hostname && other == this +} + +sealed class MultihopChange { + abstract val item: RelayItem + + data class Entry(override val item: RelayItem) : MultihopChange() + + data class Exit(override val item: RelayItem) : MultihopChange() +} + +sealed interface ModifyMultihopError { + data class RelayItemInactive(val relayItem: RelayItem) : ModifyMultihopError + + data class EntrySameAsExit(val relayItem: RelayItem) : ModifyMultihopError + + data object GenericError : ModifyMultihopError +} 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 index e4cf5c06bc..7357068a56 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RecentsUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RecentsUseCase.kt @@ -3,6 +3,7 @@ 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.MultihopRelayListType import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.GeoLocationId @@ -24,10 +25,10 @@ class RecentsUseCase( operator fun invoke(): Flow<List<Hop>?> = combine( recents(), - filteredRelayListUseCase(RelayListType.ENTRY), - customListsRelayItemUseCase(RelayListType.ENTRY), - filteredRelayListUseCase(RelayListType.EXIT), - customListsRelayItemUseCase(RelayListType.EXIT), + filteredRelayListUseCase(RelayListType.Multihop(MultihopRelayListType.ENTRY)), + customListsRelayItemUseCase(RelayListType.Multihop(MultihopRelayListType.ENTRY)), + filteredRelayListUseCase(RelayListType.Multihop(MultihopRelayListType.EXIT)), + customListsRelayItemUseCase(RelayListType.Multihop(MultihopRelayListType.EXIT)), ) { recents, entryRelayList, entryCustomLists, exitRelayList, exitCustomLists -> recents?.mapNotNull { recent -> when (recent) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectHopUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectHopUseCase.kt new file mode 100644 index 0000000000..487183a3c8 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectHopUseCase.kt @@ -0,0 +1,35 @@ +package net.mullvad.mullvadvpn.usecase + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import net.mullvad.mullvadvpn.lib.model.Hop +import net.mullvad.mullvadvpn.relaylist.isTheSameAs +import net.mullvad.mullvadvpn.repository.RelayListRepository + +class SelectHopUseCase(private val relayListRepository: RelayListRepository) { + suspend operator fun invoke(hop: Hop): Either<SelectHopError, Unit> = either { + ensure(hop.isActive) { SelectHopError.HopInactive(hop = hop) } + when (hop) { + is Hop.Multi -> { + ensure(!hop.entry.isTheSameAs(hop.exit)) { SelectHopError.EntryAndExitSame } + relayListRepository + .updateSelectedRelayLocationMultihop(entry = hop.entry.id, exit = hop.exit.id) + .mapLeft { SelectHopError.GenericError } + } + is Hop.Single<*> -> { + relayListRepository.updateSelectedRelayLocation(hop.relay.id).mapLeft { + SelectHopError.GenericError + } + } + } + } +} + +sealed interface SelectHopError { + data class HopInactive(val hop: Hop) : SelectHopError + + data object EntryAndExitSame : SelectHopError + + data object GenericError : SelectHopError +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt index 42250ec1dc..243c0c643a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt @@ -35,7 +35,6 @@ class FilterCustomListsRelayItemUseCase( daita = shouldFilterByDaita( daitaDirectOnly = settings?.daitaAndDirectOnly() == true, - isMultihopEnabled = wireguardConstraints?.isMultihopEnabled == true, relayListType = relayListType, ), ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Daita.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Daita.kt index 8049bc2d28..e4a0a9957a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Daita.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Daita.kt @@ -1,12 +1,11 @@ package net.mullvad.mullvadvpn.util +import net.mullvad.mullvadvpn.compose.state.MultihopRelayListType import net.mullvad.mullvadvpn.compose.state.RelayListType -fun shouldFilterByDaita( - daitaDirectOnly: Boolean, - isMultihopEnabled: Boolean, - relayListType: RelayListType, -) = - daitaDirectOnly && - (relayListType == RelayListType.ENTRY || - !isMultihopEnabled && relayListType == RelayListType.EXIT) +fun shouldFilterByDaita(daitaDirectOnly: Boolean, relayListType: RelayListType) = + when (relayListType) { + RelayListType.Single -> daitaDirectOnly + is RelayListType.Multihop -> + daitaDirectOnly && relayListType.multihopRelayListType == MultihopRelayListType.ENTRY + } 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 64ccd99452..1d6691b755 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 @@ -1,5 +1,6 @@ package net.mullvad.mullvadvpn.viewmodel.location +import net.mullvad.mullvadvpn.compose.state.MultihopRelayListType import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.GeoLocationId @@ -486,10 +487,11 @@ internal fun RelayItemId.expandKey(parent: CustomListId? = null) = internal fun RelayItemSelection.selectedByThisEntryExitList(relayListType: RelayListType) = when (this) { is RelayItemSelection.Multiple -> - when (relayListType) { - RelayListType.ENTRY -> entryLocation - RelayListType.EXIT -> exitLocation - }.getOrNull() + when ((relayListType as? RelayListType.Multihop)?.multihopRelayListType) { + MultihopRelayListType.ENTRY -> entryLocation.getOrNull() + MultihopRelayListType.EXIT -> exitLocation.getOrNull() + else -> null + } is RelayItemSelection.Single -> exitLocation.getOrNull() } @@ -500,10 +502,11 @@ internal fun RelayItemSelection.selectedByOtherEntryExitList( when (this) { is RelayItemSelection.Multiple -> { val location = - when (relayListType) { - RelayListType.ENTRY -> exitLocation - RelayListType.EXIT -> entryLocation - }.getOrNull() + when ((relayListType as? RelayListType.Multihop)?.multihopRelayListType) { + MultihopRelayListType.ENTRY -> exitLocation + MultihopRelayListType.EXIT -> entryLocation + else -> null + }?.getOrNull() location.singleRelayId(customLists) } is RelayItemSelection.Single -> null @@ -543,9 +546,10 @@ private fun RelayItem.createState( is RelayItem.Location.Relay -> selectedByOtherId == id } return if (isSelectedByOther) { - when (relayListType) { - RelayListType.ENTRY -> RelayListItemState.USED_AS_EXIT - RelayListType.EXIT -> RelayListItemState.USED_AS_ENTRY + when ((relayListType as? RelayListType.Multihop)?.multihopRelayListType) { + MultihopRelayListType.ENTRY -> RelayListItemState.USED_AS_EXIT + MultihopRelayListType.EXIT -> RelayListItemState.USED_AS_ENTRY + else -> null } } else { null diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt index 02cd8a765b..ce67ddba99 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 @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.communication.CustomListAction import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData +import net.mullvad.mullvadvpn.compose.state.MultihopRelayListType import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SearchLocationUiState import net.mullvad.mullvadvpn.lib.model.Constraint @@ -24,11 +25,15 @@ import net.mullvad.mullvadvpn.lib.model.RelayItemId import net.mullvad.mullvadvpn.relaylist.newFilterOnSearch import net.mullvad.mullvadvpn.repository.CustomListsRepository import net.mullvad.mullvadvpn.repository.RelayListFilterRepository -import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository import net.mullvad.mullvadvpn.usecase.FilterChip import net.mullvad.mullvadvpn.usecase.FilterChipUseCase import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase +import net.mullvad.mullvadvpn.usecase.ModifyMultihopError +import net.mullvad.mullvadvpn.usecase.ModifyMultihopUseCase +import net.mullvad.mullvadvpn.usecase.MultihopChange +import net.mullvad.mullvadvpn.usecase.SelectHopError +import net.mullvad.mullvadvpn.usecase.SelectHopUseCase import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase @@ -39,11 +44,12 @@ import net.mullvad.mullvadvpn.util.combine @Suppress("LongParameterList") class SearchLocationViewModel( private val wireguardConstraintsRepository: WireguardConstraintsRepository, - private val relayListRepository: RelayListRepository, private val customListActionUseCase: CustomListActionUseCase, private val customListsRepository: CustomListsRepository, private val relayListFilterRepository: RelayListFilterRepository, private val filterChipUseCase: FilterChipUseCase, + private val selectHopUseCase: SelectHopUseCase, + private val modifyMultihopUseCase: ModifyMultihopUseCase, filteredRelayListUseCase: FilteredRelayListUseCase, filteredCustomListRelayItemsUseCase: FilterCustomListsRelayItemUseCase, selectedLocationUseCase: SelectedLocationUseCase, @@ -75,7 +81,7 @@ class SearchLocationViewModel( filterChips, expandOverrides -> if (relayCountries.isEmpty()) { - return@combine Lce.Error<Unit>(Unit) + return@combine Lce.Error(Unit) } val (expandSet, relayListLocations) = searchRelayListLocations( @@ -86,6 +92,7 @@ class SearchLocationViewModel( Lce.Content( SearchLocationUiState( searchTerm = searchTerm, + relayListType = relayListType, relayListItems = relayListItemsSearching( searchTerm = searchTerm, @@ -118,31 +125,65 @@ class SearchLocationViewModel( } } - fun selectHop(hop: Hop) { + fun selectRelayItem(relayItem: RelayItem, relayListType: RelayListType) { viewModelScope.launch { - if (hop.isActive) { - selectRelayHop( - hop = hop, - relayListType = relayListType, - selectEntryLocation = wireguardConstraintsRepository::setEntryLocation, - selectExitLocation = relayListRepository::updateSelectedRelayLocation, - selectMultihopLocation = - relayListRepository::updateSelectedRelayLocationMultihop, - ) - .fold( - { _uiSideEffect.send(SearchLocationSideEffect.GenericError) }, - { - _uiSideEffect.send( - SearchLocationSideEffect.LocationSelected(relayListType) - ) - }, + when (relayListType) { + is RelayListType.Multihop -> + modifyMultihop( + when (relayListType.multihopRelayListType) { + MultihopRelayListType.ENTRY -> MultihopChange.Entry(relayItem) + MultihopRelayListType.EXIT -> MultihopChange.Exit(relayItem) + } ) - } else { - _uiSideEffect.send(SearchLocationSideEffect.HopInactive(hop)) + RelayListType.Single -> selectHop(hop = Hop.Single(relayItem)) } } } + private suspend fun selectHop(hop: Hop.Single<*>) = + selectHopUseCase(hop) + .fold( + { + _uiSideEffect.send( + when (it) { + SelectHopError.EntryAndExitSame -> + error("Entry and exit should not be the same when using Single hop") + SelectHopError.GenericError -> SearchLocationSideEffect.GenericError + is SelectHopError.HopInactive -> + SearchLocationSideEffect.RelayItemInactive(hop.relay) + } + ) + }, + { _uiSideEffect.send(SearchLocationSideEffect.LocationSelected(relayListType)) }, + ) + + private suspend fun modifyMultihop(change: MultihopChange) = + modifyMultihopUseCase(change = change) + .fold( + { + _uiSideEffect.send( + when (it) { + is ModifyMultihopError.EntrySameAsExit -> + when (change) { + is MultihopChange.Entry -> + SearchLocationSideEffect.ExitAlreadySelected( + relayItem = change.item + ) + is MultihopChange.Exit -> + SearchLocationSideEffect.EntryAlreadySelected( + relayItem = change.item + ) + } + ModifyMultihopError.GenericError -> + SearchLocationSideEffect.GenericError + is ModifyMultihopError.RelayItemInactive -> + SearchLocationSideEffect.RelayItemInactive(relayItem = it.relayItem) + } + ) + }, + { _uiSideEffect.send(SearchLocationSideEffect.LocationSelected(relayListType)) }, + ) + private fun searchRelayListLocations( searchTerm: String, relayCountries: List<RelayItem.Location.Country>, @@ -160,14 +201,12 @@ class SearchLocationViewModel( wireguardConstraintsRepository.wireguardConstraints, ) { filterChips, constraints -> filterChips.toMutableList().apply { - // Do not show entry and exit filter chips if multihop is disabled - if (constraints?.isMultihopEnabled == true) { - add( - when (relayListType) { - RelayListType.ENTRY -> FilterChip.Entry - RelayListType.EXIT -> FilterChip.Exit - } - ) + // Only show entry and exit filter chips if relayListType is Multihop + if (relayListType is RelayListType.Multihop) { + when (relayListType.multihopRelayListType) { + MultihopRelayListType.ENTRY -> add(FilterChip.Entry) + MultihopRelayListType.EXIT -> add(FilterChip.Exit) + } } } } @@ -228,7 +267,11 @@ sealed interface SearchLocationSideEffect { data class CustomListActionToast(val resultData: CustomListActionResultData) : SearchLocationSideEffect - data class HopInactive(val hop: Hop) : SearchLocationSideEffect + data class RelayItemInactive(val relayItem: RelayItem) : SearchLocationSideEffect + + data class EntryAlreadySelected(val relayItem: RelayItem) : SearchLocationSideEffect + + data class ExitAlreadySelected(val relayItem: RelayItem) : 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 fc9cb77143..30e45d05cf 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 @@ -7,11 +7,13 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.compose.state.MultihopRelayListType import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.lib.model.Settings import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.repository.SettingsRepository import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository @@ -42,11 +44,12 @@ class SelectLocationListViewModel( customListsRelayItemUseCase(), settingsRepository.settingsUpdates, ) { relayListItems, customLists, settings -> - if (relayListType == RelayListType.ENTRY && settings?.entryBlocked() == true) { + if (settings.isBlocked()) { Lce.Error(Unit) } else { Lce.Content( SelectLocationListUiState( + relayListType = relayListType, relayListItems = relayListItems, customLists = customLists, ) @@ -59,6 +62,14 @@ class SelectLocationListViewModel( _expandedItems.onToggleExpandSet(item, parent, expand) } + private fun Settings?.isBlocked(): Boolean = + when (relayListType) { + RelayListType.Single -> false + is RelayListType.Multihop -> + relayListType.multihopRelayListType == MultihopRelayListType.ENTRY && + this?.entryBlocked() == true + } + private fun relayListItems() = combine( filteredRelayListUseCase(relayListType = relayListType), @@ -116,8 +127,12 @@ class SelectLocationListViewModel( private fun initialSelection() = when (relayListType) { - RelayListType.ENTRY -> - wireguardConstraintsRepository.wireguardConstraints.value?.entryLocation - RelayListType.EXIT -> relayListRepository.selectedLocation.value + RelayListType.Single -> relayListRepository.selectedLocation.value + is RelayListType.Multihop -> + when (relayListType.multihopRelayListType) { + MultihopRelayListType.ENTRY -> + wireguardConstraintsRepository.wireguardConstraints.value?.entryLocation + MultihopRelayListType.EXIT -> relayListRepository.selectedLocation.value + } }?.getOrNull() } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt index 26f5dd42fa..768cf794eb 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 @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.communication.CustomListAction import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData +import net.mullvad.mullvadvpn.compose.state.MultihopRelayListType import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.lib.model.Constraint @@ -26,6 +27,11 @@ import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.repository.SettingsRepository import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository import net.mullvad.mullvadvpn.usecase.FilterChipUseCase +import net.mullvad.mullvadvpn.usecase.ModifyMultihopError +import net.mullvad.mullvadvpn.usecase.ModifyMultihopUseCase +import net.mullvad.mullvadvpn.usecase.MultihopChange +import net.mullvad.mullvadvpn.usecase.SelectHopError +import net.mullvad.mullvadvpn.usecase.SelectHopUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase import net.mullvad.mullvadvpn.util.Lc @@ -35,13 +41,23 @@ class SelectLocationViewModel( private val relayListFilterRepository: RelayListFilterRepository, private val customListsRepository: CustomListsRepository, private val customListActionUseCase: CustomListActionUseCase, - private val relayListRepository: RelayListRepository, - private val wireguardConstraintsRepository: WireguardConstraintsRepository, + relayListRepository: RelayListRepository, + wireguardConstraintsRepository: WireguardConstraintsRepository, private val filterChipUseCase: FilterChipUseCase, private val settingsRepository: SettingsRepository, + private val selectHopUseCase: SelectHopUseCase, + private val modifyMultihopUseCase: ModifyMultihopUseCase, ) : ViewModel() { private val _relayListType: MutableStateFlow<RelayListType> = - MutableStateFlow(RelayListType.EXIT) + MutableStateFlow( + if ( + wireguardConstraintsRepository.wireguardConstraints.value?.isMultihopEnabled == true + ) { + RelayListType.Multihop(MultihopRelayListType.EXIT) + } else { + RelayListType.Single + } + ) val uiState = combine( @@ -57,9 +73,10 @@ class SelectLocationViewModel( multihopEnabled = wireguardConstraints?.isMultihopEnabled == true, relayListType = relayListSelection, isSearchButtonEnabled = - relayList.isNotEmpty() && - (relayListSelection == RelayListType.EXIT || - settings?.entryBlocked() != true), + searchButtonEnabled( + relayList = relayList, + relayListSelection = relayListSelection, + ), isFilterButtonEnabled = relayList.isNotEmpty(), isRecentsEnabled = settings?.recents is Recents.Enabled, ) @@ -72,37 +89,58 @@ class SelectLocationViewModel( private fun filterChips() = _relayListType.flatMapLatest { filterChipUseCase(it) } - fun selectRelayList(relayListType: RelayListType) { - viewModelScope.launch { _relayListType.emit(relayListType) } + private fun searchButtonEnabled( + relayList: List<RelayItem.Location.Country>, + relayListSelection: RelayListType, + ): Boolean { + val hasRelayListItems = relayList.isNotEmpty() + val isMultihopEntry = + relayListSelection is RelayListType.Multihop && + relayListSelection.multihopRelayListType == MultihopRelayListType.ENTRY + val isEntryBlocked = settingsRepository.settingsUpdates.value?.entryBlocked() == true + return hasRelayListItems && !(isMultihopEntry && isEntryBlocked) + } + + fun selectRelayList(multihopRelayListType: MultihopRelayListType) { + viewModelScope.launch { _relayListType.emit(RelayListType.Multihop(multihopRelayListType)) } } - fun selectHop(hop: Hop, relayListType: RelayListType) { + fun selectHop(hop: Hop) { viewModelScope.launch { - if (hop.isActive) { - selectRelayHop( - hop = hop, - relayListType = relayListType, - selectEntryLocation = wireguardConstraintsRepository::setEntryLocation, - selectExitLocation = relayListRepository::updateSelectedRelayLocation, - selectMultihopLocation = - relayListRepository::updateSelectedRelayLocationMultihop, - ) - .fold( - { _uiSideEffect.send(SelectLocationSideEffect.GenericError) }, - { - when (relayListType) { - 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(hop)) + selectHopUseCase(hop) + .fold( + { _uiSideEffect.send(it.toSideEffect()) }, + { _uiSideEffect.send(SelectLocationSideEffect.CloseScreen) }, + ) + } + } + + fun modifyMultihop(relayItem: RelayItem, multihopRelayListType: MultihopRelayListType) { + val change = + when (multihopRelayListType) { + MultihopRelayListType.ENTRY -> MultihopChange.Entry(relayItem) + MultihopRelayListType.EXIT -> MultihopChange.Exit(relayItem) } + + viewModelScope.launch { + modifyMultihopUseCase(change) + .fold( + { _uiSideEffect.send(it.toSideEffect(multihopRelayListType)) }, + { + when (multihopRelayListType) { + MultihopRelayListType.ENTRY -> { + _relayListType.emit( + RelayListType.Multihop(MultihopRelayListType.EXIT) + ) + _uiSideEffect.send( + SelectLocationSideEffect.FocusExitList(relayItem) + ) + } + MultihopRelayListType.EXIT -> + _uiSideEffect.send(SelectLocationSideEffect.CloseScreen) + } + }, + ) } } @@ -149,6 +187,30 @@ class SelectLocationViewModel( settingsRepository.setRecentsEnabled(!enabled) } } + + private fun ModifyMultihopError.toSideEffect( + multihopRelayListType: MultihopRelayListType + ): SelectLocationSideEffect = + when (this) { + is ModifyMultihopError.EntrySameAsExit -> + when (multihopRelayListType) { + MultihopRelayListType.ENTRY -> + SelectLocationSideEffect.ExitAlreadySelected(relayItem = relayItem) + MultihopRelayListType.EXIT -> + SelectLocationSideEffect.EntryAlreadySelected(relayItem = relayItem) + } + ModifyMultihopError.GenericError -> SelectLocationSideEffect.GenericError + is ModifyMultihopError.RelayItemInactive -> + SelectLocationSideEffect.RelayItemInactive(hop = Hop.Single(this.relayItem)) + } + + private fun SelectHopError.toSideEffect(): SelectLocationSideEffect = + when (this) { + SelectHopError.GenericError -> SelectLocationSideEffect.GenericError + is SelectHopError.HopInactive -> + SelectLocationSideEffect.RelayItemInactive(hop = this.hop) + SelectHopError.EntryAndExitSame -> SelectLocationSideEffect.EntryAndExitAreSame + } } sealed interface SelectLocationSideEffect { @@ -160,4 +222,12 @@ sealed interface SelectLocationSideEffect { data object GenericError : SelectLocationSideEffect data class RelayItemInactive(val hop: Hop) : SelectLocationSideEffect + + data class EntryAlreadySelected(val relayItem: RelayItem) : SelectLocationSideEffect + + data class ExitAlreadySelected(val relayItem: RelayItem) : SelectLocationSideEffect + + data object EntryAndExitAreSame : SelectLocationSideEffect + + data class FocusExitList(val relayItem: RelayItem) : 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 deleted file mode 100644 index f797501914..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectRelay.kt +++ /dev/null @@ -1,32 +0,0 @@ -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.Hop -import net.mullvad.mullvadvpn.lib.model.RelayItemId - -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> { - 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/FilterChipUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCaseTest.kt index ad1bc41a1b..69d40103a7 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCaseTest.kt @@ -5,6 +5,7 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.state.MultihopRelayListType import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.lib.common.test.assertLists import net.mullvad.mullvadvpn.lib.model.Constraint @@ -12,10 +13,8 @@ import net.mullvad.mullvadvpn.lib.model.Ownership import net.mullvad.mullvadvpn.lib.model.ProviderId import net.mullvad.mullvadvpn.lib.model.Providers import net.mullvad.mullvadvpn.lib.model.Settings -import net.mullvad.mullvadvpn.lib.model.WireguardConstraints import net.mullvad.mullvadvpn.repository.RelayListFilterRepository import net.mullvad.mullvadvpn.repository.SettingsRepository -import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -24,13 +23,11 @@ class FilterChipUseCaseTest { private val mockRelayListFilterRepository: RelayListFilterRepository = mockk() private val mockProviderToOwnershipsUseCase: ProviderToOwnershipsUseCase = mockk() private val mockSettingRepository: SettingsRepository = mockk() - private val mockWireguardConstraintsRepository: WireguardConstraintsRepository = mockk() private val selectedOwnership = MutableStateFlow<Constraint<Ownership>>(Constraint.Any) private val selectedProviders = MutableStateFlow<Constraint<Providers>>(Constraint.Any) private val providerToOwnerships = MutableStateFlow<Map<ProviderId, Set<Ownership>>>(emptyMap()) private val settings = MutableStateFlow<Settings>(mockk(relaxed = true)) - private val wireguardConstraints = MutableStateFlow<WireguardConstraints>(mockk(relaxed = true)) private lateinit var filterChipUseCase: FilterChipUseCase @@ -40,21 +37,18 @@ class FilterChipUseCaseTest { every { mockRelayListFilterRepository.selectedProviders } returns selectedProviders every { mockProviderToOwnershipsUseCase() } returns providerToOwnerships every { mockSettingRepository.settingsUpdates } returns settings - every { mockWireguardConstraintsRepository.wireguardConstraints } returns - wireguardConstraints filterChipUseCase = FilterChipUseCase( relayListFilterRepository = mockRelayListFilterRepository, providerToOwnershipsUseCase = mockProviderToOwnershipsUseCase, settingsRepository = mockSettingRepository, - wireguardConstraintsRepository = mockWireguardConstraintsRepository, ) } @Test fun `when no filters are applied should return empty list`() = runTest { - filterChipUseCase(RelayListType.EXIT).test { assertLists(emptyList(), awaitItem()) } + filterChipUseCase(RelayListType.Single).test { assertLists(emptyList(), awaitItem()) } } @Test @@ -63,7 +57,7 @@ class FilterChipUseCaseTest { val expectedOwnership = Ownership.MullvadOwned selectedOwnership.value = Constraint.Only(expectedOwnership) - filterChipUseCase(RelayListType.EXIT).test { + filterChipUseCase(RelayListType.Single).test { assertLists(listOf(FilterChip.Ownership(expectedOwnership)), awaitItem()) } } @@ -79,7 +73,7 @@ class FilterChipUseCaseTest { ProviderId("2") to setOf(Ownership.Rented), ) - filterChipUseCase(RelayListType.EXIT).test { + filterChipUseCase(RelayListType.Single).test { assertLists(listOf(FilterChip.Provider(2)), awaitItem()) } } @@ -98,7 +92,7 @@ class FilterChipUseCaseTest { ProviderId("2") to setOf(Ownership.Rented), ) - filterChipUseCase(RelayListType.EXIT).test { + filterChipUseCase(RelayListType.Single).test { assertLists( listOf(FilterChip.Ownership(expectedOwnership), FilterChip.Provider(1)), awaitItem(), @@ -115,10 +109,8 @@ class FilterChipUseCaseTest { every { this@mockk.tunnelOptions.wireguard.daitaSettings.enabled } returns true every { tunnelOptions.wireguard.daitaSettings.directOnly } returns true } - wireguardConstraints.value = - mockk(relaxed = true) { every { isMultihopEnabled } returns false } - filterChipUseCase(RelayListType.EXIT).test { + filterChipUseCase(RelayListType.Single).test { assertLists(listOf(FilterChip.Daita), awaitItem()) } } @@ -132,14 +124,12 @@ class FilterChipUseCaseTest { every { tunnelOptions.wireguard.daitaSettings.enabled } returns true every { tunnelOptions.wireguard.daitaSettings.directOnly } returns false } - wireguardConstraints.value = - mockk(relaxed = true) { every { isMultihopEnabled } returns false } - filterChipUseCase(RelayListType.EXIT).test { assertLists(emptyList(), awaitItem()) } + filterChipUseCase(RelayListType.Single).test { assertLists(emptyList(), awaitItem()) } } @Test - fun `when Daita with direct only is enabled and multihop is enabled and relay list type is entry should return Daita filter chip`() = + fun `when Daita with direct only is enabled and relay list type is entry should return Daita filter chip`() = runTest { // Arrange settings.value = @@ -147,16 +137,14 @@ class FilterChipUseCaseTest { every { tunnelOptions.wireguard.daitaSettings.enabled } returns true every { tunnelOptions.wireguard.daitaSettings.directOnly } returns true } - wireguardConstraints.value = - mockk(relaxed = true) { every { isMultihopEnabled } returns true } - filterChipUseCase(RelayListType.ENTRY).test { + filterChipUseCase(RelayListType.Multihop(MultihopRelayListType.ENTRY)).test { assertLists(listOf(FilterChip.Daita), awaitItem()) } } @Test - fun `when Daita with direct only is enabled and multihop is enabled and relay list type is exit should return no filter`() = + fun `when Daita with direct only is enabled and relay list type is exit should return no filter`() = runTest { // Arrange settings.value = @@ -164,14 +152,14 @@ class FilterChipUseCaseTest { every { tunnelOptions.wireguard.daitaSettings.enabled } returns true every { tunnelOptions.wireguard.daitaSettings.directOnly } returns true } - wireguardConstraints.value = - mockk(relaxed = true) { every { isMultihopEnabled } returns true } - filterChipUseCase(RelayListType.EXIT).test { assertLists(emptyList(), awaitItem()) } + filterChipUseCase(RelayListType.Multihop(MultihopRelayListType.EXIT)).test { + assertLists(emptyList(), awaitItem()) + } } @Test - fun `when Daita without direct only is enabled and multihop is enabled and relay list type is exit should return no filter`() = + fun `when Daita without direct only is enabled and relay list type is exit should return no filter`() = runTest { // Arrange settings.value = @@ -179,10 +167,10 @@ class FilterChipUseCaseTest { every { tunnelOptions.wireguard.daitaSettings.enabled } returns true every { tunnelOptions.wireguard.daitaSettings.directOnly } returns false } - wireguardConstraints.value = - mockk(relaxed = true) { every { isMultihopEnabled } returns true } - filterChipUseCase(RelayListType.EXIT).test { assertLists(emptyList(), awaitItem()) } + filterChipUseCase(RelayListType.Multihop(MultihopRelayListType.EXIT)).test { + assertLists(emptyList(), awaitItem()) + } } @Test @@ -200,7 +188,7 @@ class FilterChipUseCaseTest { ) // Act, Assert - filterChipUseCase(RelayListType.EXIT).test { + filterChipUseCase(RelayListType.Single).test { assertLists( listOf(FilterChip.Ownership(expectedOwnership), FilterChip.Provider(1)), awaitItem(), 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 index f7699101d5..9623fffc7c 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/RecentsUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/RecentsUseCaseTest.kt @@ -8,6 +8,7 @@ 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.MultihopRelayListType import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.lib.model.CustomList import net.mullvad.mullvadvpn.lib.model.CustomListId @@ -118,13 +119,18 @@ class RecentsUseCaseTest { 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)) + every { + customListsRelayItemUseCase(RelayListType.Multihop(MultihopRelayListType.ENTRY)) + } returns flowOf(listOf(entryCustomList)) + every { + customListsRelayItemUseCase(RelayListType.Multihop(MultihopRelayListType.EXIT)) + } returns flowOf(emptyList()) + every { + filteredRelayListUseCase(RelayListType.Multihop(MultihopRelayListType.ENTRY)) + } returns flowOf(listOf(sweden, norway)) + every { + filteredRelayListUseCase(RelayListType.Multihop(MultihopRelayListType.EXIT)) + } returns flowOf(listOf(sweden, norway)) useCase().test { val hops = awaitItem() diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt index ad0f87638f..8aa1e9b038 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt @@ -20,11 +20,12 @@ import net.mullvad.mullvadvpn.lib.model.WireguardConstraints import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItem import net.mullvad.mullvadvpn.repository.CustomListsRepository import net.mullvad.mullvadvpn.repository.RelayListFilterRepository -import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository import net.mullvad.mullvadvpn.usecase.FilterChip import net.mullvad.mullvadvpn.usecase.FilterChipUseCase import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase +import net.mullvad.mullvadvpn.usecase.ModifyMultihopUseCase +import net.mullvad.mullvadvpn.usecase.SelectHopUseCase import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase @@ -39,7 +40,6 @@ import org.junit.jupiter.api.extension.ExtendWith class SearchLocationViewModelTest { private val mockWireguardConstraintsRepository: WireguardConstraintsRepository = mockk() - private val mockRelayListRepository: RelayListRepository = mockk() private val mockFilteredRelayListUseCase: FilteredRelayListUseCase = mockk() private val mockCustomListActionUseCase: CustomListActionUseCase = mockk() private val mockCustomListsRepository: CustomListsRepository = mockk() @@ -48,6 +48,8 @@ class SearchLocationViewModelTest { private val mockFilteredCustomListRelayItemsUseCase: FilterCustomListsRelayItemUseCase = mockk() private val mockSelectedLocationUseCase: SelectedLocationUseCase = mockk() private val mockCustomListsRelayItemUseCase: CustomListsRelayItemUseCase = mockk() + private val mockSelectHopUseCase: SelectHopUseCase = mockk() + private val mockModifyMultihopUseCase: ModifyMultihopUseCase = mockk() private val filteredRelayList = MutableStateFlow<List<RelayItem.Location.Country>>(emptyList()) private val selectedLocation = @@ -74,7 +76,6 @@ class SearchLocationViewModelTest { viewModel = SearchLocationViewModel( wireguardConstraintsRepository = mockWireguardConstraintsRepository, - relayListRepository = mockRelayListRepository, filteredRelayListUseCase = mockFilteredRelayListUseCase, customListActionUseCase = mockCustomListActionUseCase, customListsRepository = mockCustomListsRepository, @@ -83,8 +84,10 @@ class SearchLocationViewModelTest { filteredCustomListRelayItemsUseCase = mockFilteredCustomListRelayItemsUseCase, selectedLocationUseCase = mockSelectedLocationUseCase, customListsRelayItemUseCase = mockCustomListsRelayItemUseCase, + selectHopUseCase = mockSelectHopUseCase, + modifyMultihopUseCase = mockModifyMultihopUseCase, savedStateHandle = - SearchLocationNavArgs(relayListType = RelayListType.ENTRY).toSavedStateHandle(), + SearchLocationNavArgs(relayListType = RelayListType.Single).toSavedStateHandle(), ) } 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 1a54d15f95..3f9bfe751a 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 @@ -73,7 +73,7 @@ class SelectLocationListViewModelTest { @Test fun `initial state should be loading`() = runTest { // Arrange - viewModel = createSelectLocationListViewModel(relayListType = RelayListType.ENTRY) + viewModel = createSelectLocationListViewModel(relayListType = RelayListType.Single) // Assert assertEquals(Lce.Loading(Unit), viewModel.uiState.value) @@ -82,7 +82,7 @@ class SelectLocationListViewModelTest { @Test fun `given filteredRelayList emits update uiState should contain new update`() = runTest { // Arrange - viewModel = createSelectLocationListViewModel(RelayListType.EXIT) + viewModel = createSelectLocationListViewModel(RelayListType.Single) filteredRelayList.value = testCountries val selectedId = testCountries.first().id selectedLocationFlow.value = RelayItemSelection.Single(Constraint.Only(selectedId)) @@ -107,7 +107,7 @@ class SelectLocationListViewModelTest { @Test fun `given relay is not selected all relay items should not be selected`() = runTest { // Arrange - viewModel = createSelectLocationListViewModel(RelayListType.EXIT) + viewModel = createSelectLocationListViewModel(RelayListType.Single) filteredRelayList.value = testCountries selectedLocationFlow.value = RelayItemSelection.Single(Constraint.Any) 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 e5804ffe61..e50cfb48a2 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 @@ -17,6 +17,7 @@ import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.communication.CustomListAction import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.communication.LocationsChanged +import net.mullvad.mullvadvpn.compose.state.MultihopRelayListType import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule @@ -25,7 +26,6 @@ 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 @@ -40,6 +40,9 @@ import net.mullvad.mullvadvpn.repository.SettingsRepository import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository import net.mullvad.mullvadvpn.usecase.FilterChip import net.mullvad.mullvadvpn.usecase.FilterChipUseCase +import net.mullvad.mullvadvpn.usecase.ModifyMultihopUseCase +import net.mullvad.mullvadvpn.usecase.MultihopChange +import net.mullvad.mullvadvpn.usecase.SelectHopUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase import net.mullvad.mullvadvpn.util.Lc import org.junit.jupiter.api.AfterEach @@ -57,6 +60,8 @@ class SelectLocationViewModelTest { private val mockWireguardConstraintsRepository: WireguardConstraintsRepository = mockk() private val mockFilterChipUseCase: FilterChipUseCase = mockk() private val mockSettingsRepository: SettingsRepository = mockk() + private val mockSelectHopUseCase: SelectHopUseCase = mockk() + private val mockModifyMultihopUseCase: ModifyMultihopUseCase = mockk() private lateinit var viewModel: SelectLocationViewModel @@ -88,6 +93,8 @@ class SelectLocationViewModelTest { filterChipUseCase = mockFilterChipUseCase, wireguardConstraintsRepository = mockWireguardConstraintsRepository, settingsRepository = mockSettingsRepository, + modifyMultihopUseCase = mockModifyMultihopUseCase, + selectHopUseCase = mockSelectHopUseCase, ) } @@ -103,22 +110,22 @@ class SelectLocationViewModelTest { } @Test - fun `on selectRelay when relay list type is exit call uiSideEffect should emit CloseScreen and connect`() = + fun `on modifyMultihop when relay list type is exit call uiSideEffect should emit CloseScreen and connect`() = runTest { // Arrange val mockRelayItem: RelayItem.Location.Country = mockk() val relayItemId: GeoLocationId.Country = mockk(relaxed = true) + val multihopChange: MultihopChange = MultihopChange.Exit(mockRelayItem) every { mockRelayItem.id } returns relayItemId every { mockRelayItem.active } returns true - coEvery { mockRelayListRepository.updateSelectedRelayLocation(relayItemId) } returns - Unit.right() + coEvery { mockModifyMultihopUseCase.invoke(multihopChange) } returns Unit.right() // Act, Assert viewModel.uiSideEffect.test { - viewModel.selectHop(Hop.Single(mockRelayItem), RelayListType.EXIT) + viewModel.modifyMultihop(mockRelayItem, MultihopRelayListType.EXIT) // Await an empty item assertEquals(SelectLocationSideEffect.CloseScreen, awaitItem()) - coVerify { mockRelayListRepository.updateSelectedRelayLocation(relayItemId) } + coVerify { mockModifyMultihopUseCase.invoke(multihopChange) } } } @@ -128,26 +135,32 @@ class SelectLocationViewModelTest { // Arrange val mockRelayItem: RelayItem.Location.Country = mockk() val relayItemId: GeoLocationId.Country = mockk(relaxed = true) + val multihopChange = MultihopChange.Entry(mockRelayItem) every { mockRelayItem.active } returns true every { mockRelayItem.id } returns relayItemId - coEvery { mockWireguardConstraintsRepository.setEntryLocation(relayItemId) } returns - Unit.right() + coEvery { mockModifyMultihopUseCase.invoke(multihopChange) } returns Unit.right() // Act, Assert viewModel.uiState.test { awaitItem() // Default value - viewModel.selectRelayList(RelayListType.ENTRY) + viewModel.selectRelayList(MultihopRelayListType.ENTRY) // Assert relay list type is entry val firstState = awaitItem() assertIs<Lc.Content<SelectLocationUiState>>(firstState) - assertEquals(RelayListType.ENTRY, firstState.value.relayListType) + assertEquals( + RelayListType.Multihop(MultihopRelayListType.ENTRY), + firstState.value.relayListType, + ) // Select entry - viewModel.selectHop(Hop.Single(mockRelayItem), RelayListType.ENTRY) + viewModel.modifyMultihop(mockRelayItem, MultihopRelayListType.ENTRY) // Assert relay list type is exit val secondState = awaitItem() assertIs<Lc.Content<SelectLocationUiState>>(secondState) - assertEquals(RelayListType.EXIT, secondState.value.relayListType) - coVerify { mockWireguardConstraintsRepository.setEntryLocation(relayItemId) } + assertEquals( + RelayListType.Multihop(MultihopRelayListType.EXIT), + secondState.value.relayListType, + ) + coVerify { mockModifyMultihopUseCase.invoke(multihopChange) } } } diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index abff8ca10c..c58d62fc03 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -433,4 +433,7 @@ <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> + <string name="relay_item_already_selected_as_exit">%s is already selected as exit relay</string> + <string name="relay_item_already_selected_as_entry">%s is already selected as entry relay</string> + <string name="entry_and_exit_are_same">Entry relay is the same as exit relay</string> </resources> 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 8c38610f9e..76fb8e2f33 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 @@ -38,7 +38,6 @@ import net.mullvad.mullvadvpn.lib.resource.R import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible -import net.mullvad.mullvadvpn.lib.theme.color.selected import net.mullvad.mullvadvpn.lib.ui.component.ExpandChevron import net.mullvad.mullvadvpn.lib.ui.designsystem.RelayListItem import net.mullvad.mullvadvpn.lib.ui.designsystem.RelayListItemDefaults @@ -118,10 +117,7 @@ fun SelectableRelayListItem( ) } }, - onClick = - if (relayListItem.state == null) onClick - /* Workaround for not allowing to select relay that is currently used as entry or exit */ - else ({}), + onClick = onClick, onLongClick = onLongClick, trailingContent = if (relayListItem.canExpand) { |
