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/app | |
| 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/app')
34 files changed, 689 insertions, 306 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) } } } |
