diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2024-11-27 08:57:53 +0100 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2024-11-27 08:57:53 +0100 |
| commit | 0d155385e1cb7075012bd270de0398d83a438bc5 (patch) | |
| tree | 43ab56fd00a39cb37dce3b60cb594f509d7057f4 /android/app/src | |
| parent | 56e46c5cf783d41937e4eb2531a4d2e287381ee6 (diff) | |
| parent | ffde55987991aeb7b7aad0e36e2a8402e0ab47d6 (diff) | |
| download | mullvadvpn-0d155385e1cb7075012bd270de0398d83a438bc5.tar.xz mullvadvpn-0d155385e1cb7075012bd270de0398d83a438bc5.zip | |
Merge branch 'implement-multihop-ui-droid-822'
Diffstat (limited to 'android/app/src')
57 files changed, 4206 insertions, 1926 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt index 1c33420863..487e739025 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt @@ -474,6 +474,7 @@ class ConnectScreenTest { val inPort = 99 val inProtocol = TransportProtocol.Udp every { mockLocation.hostname } returns mockHostName + every { mockLocation.entryHostname } returns null // In every { mockTunnelEndpoint.obfuscation } returns null diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt index 444bbd2c5b..484bb132d6 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt @@ -163,9 +163,7 @@ class CustomListLocationsScreenTest { } // Assert - onNodeWithText(EMPTY_SEARCH_FIRST_ROW.format(mockSearchString), substring = true) - .assertExists() - onNodeWithText(EMPTY_SEARCH_SECOND_ROW, substring = true).assertExists() + onNodeWithText(EMPTY_SEARCH.format(mockSearchString)).assertExists() } @Test @@ -239,8 +237,7 @@ class CustomListLocationsScreenTest { const val ADD_LOCATIONS_TEXT = "Add locations" const val EDIT_LOCATIONS_TEXT = "Edit locations" const val SEARCH_PLACEHOLDER = "Search for..." - const val EMPTY_SEARCH_FIRST_ROW = "No result for %s." - const val EMPTY_SEARCH_SECOND_ROW = "Try a different search" + const val EMPTY_SEARCH = "No result for \"%s\", please try a different search" const val NO_LOCATIONS_FOUND_TEXT = "No locations found" } } diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt index 2509c7be8d..e691909a40 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt @@ -33,6 +33,7 @@ class SettingsScreenTest { isLoggedIn = true, isSupportedVersion = true, isPlayBuild = false, + multihopEnabled = false, ) ) } @@ -56,6 +57,7 @@ class SettingsScreenTest { isLoggedIn = false, isSupportedVersion = true, isPlayBuild = false, + multihopEnabled = false, ) ) } diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreenTest.kt new file mode 100644 index 0000000000..5901599df9 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreenTest.kt @@ -0,0 +1,105 @@ +package net.mullvad.mullvadvpn.compose.screen.location + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import io.mockk.MockKAnnotations +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension +import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_ITEM_CUSTOM_LISTS +import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.SearchLocationUiState +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +@OptIn(ExperimentalTestApi::class) +class SearchLocationScreenTest { + @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @AfterEach + fun teardown() { + unmockkAll() + } + + @Test + fun testSearchInput() = + composeExtension.use { + // Arrange + val mockedSearchTermInput: (String) -> Unit = mockk(relaxed = true) + setContentWithTheme { + SearchLocationScreen( + state = + SearchLocationUiState.NoQuery(searchTerm = "", filterChips = emptyList()), + onSearchInputChanged = mockedSearchTermInput, + ) + } + val mockSearchString = "SEARCH" + + // Act + onNodeWithText("Search for...").performTextInput(mockSearchString) + + // Assert + verify { mockedSearchTermInput.invoke(mockSearchString) } + } + + @Test + fun testSearchTermNotFound() = + composeExtension.use { + // Arrange + val mockSearchString = "SEARCH" + setContentWithTheme { + SearchLocationScreen( + state = + SearchLocationUiState.Content( + searchTerm = mockSearchString, + filterChips = emptyList(), + relayListItems = + listOf(RelayListItem.LocationsEmptyText(mockSearchString)), + customLists = emptyList(), + ) + ) + } + + // Assert + onNodeWithText("No result for \"$mockSearchString\", please try a different search") + .assertExists() + } + + @Test + fun givenNoCustomListsAndSearchIsActiveShouldNotShowCustomListHeader() = + composeExtension.use { + // Arrange + val mockSearchString = "SEARCH" + setContentWithTheme { + SearchLocationScreen( + state = + SearchLocationUiState.Content( + searchTerm = mockSearchString, + filterChips = emptyList(), + relayListItems = emptyList(), + customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + ) + ) + } + + // Assert + onNodeWithText(CUSTOM_LISTS_EMPTY_TEXT).assertDoesNotExist() + onNodeWithTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG).assertDoesNotExist() + } + + companion object { + private const val CUSTOM_LISTS_EMPTY_TEXT = "To create a custom list press the \"︙\"" + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt index 31097725db..a154344f26 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt @@ -1,63 +1,74 @@ -package net.mullvad.mullvadvpn.compose.screen +package net.mullvad.mullvadvpn.compose.screen.location import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput import io.mockk.MockKAnnotations +import io.mockk.every import io.mockk.mockk +import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_COUNTRIES import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_ITEM_CUSTOM_LISTS import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState -import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG -import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.performLongClick +import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationListViewModel +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.core.context.loadKoinModules +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module @OptIn(ExperimentalTestApi::class) class SelectLocationScreenTest { @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + private val listViewModel: SelectLocationListViewModel = mockk(relaxed = true) + @BeforeEach fun setup() { MockKAnnotations.init(this) + loadKoinModules(module { viewModel { listViewModel } }) + every { listViewModel.uiState } returns MutableStateFlow(SelectLocationListUiState.Loading) } - @Test - fun testDefaultState() = - composeExtension.use { - // Arrange - setContentWithTheme { SelectLocationScreen(state = SelectLocationUiState.Loading) } - - // Assert - onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertExists() - } + @AfterEach + fun teardown() { + unmockkAll() + } @Test fun testShowRelayListState() = composeExtension.use { // Arrange + every { listViewModel.uiState } returns + MutableStateFlow( + SelectLocationListUiState.Content( + relayListItems = + DUMMY_RELAY_COUNTRIES.map { RelayListItem.GeoLocationItem(item = it) }, + customLists = emptyList(), + ) + ) setContentWithTheme { SelectLocationScreen( state = - SelectLocationUiState.Content( - searchTerm = "", + SelectLocationUiState( + // searchTerm = "", filterChips = emptyList(), - relayListItems = - DUMMY_RELAY_COUNTRIES.map { - RelayListItem.GeoLocationItem(item = it) - }, - customLists = emptyList(), + multihopEnabled = false, + relayListType = RelayListType.EXIT, ) ) } @@ -72,97 +83,29 @@ class SelectLocationScreenTest { } @Test - fun testSearchInput() = - composeExtension.use { - // Arrange - val mockedSearchTermInput: (String) -> Unit = mockk(relaxed = true) - setContentWithTheme { - SelectLocationScreen( - state = - SelectLocationUiState.Content( - searchTerm = "", - filterChips = emptyList(), - relayListItems = emptyList(), - customLists = emptyList(), - ), - onSearchTermInput = mockedSearchTermInput, - ) - } - val mockSearchString = "SEARCH" - - // Act - onNodeWithText("Search for...").performTextInput(mockSearchString) - - // Assert - verify { mockedSearchTermInput.invoke(mockSearchString) } - } - - @Test - fun testSearchTermNotFound() = - composeExtension.use { - // Arrange - val mockedSearchTermInput: (String) -> Unit = mockk(relaxed = true) - val mockSearchString = "SEARCH" - setContentWithTheme { - SelectLocationScreen( - state = - SelectLocationUiState.Content( - searchTerm = mockSearchString, - filterChips = emptyList(), - relayListItems = - listOf(RelayListItem.LocationsEmptyText(mockSearchString)), - customLists = emptyList(), - ), - onSearchTermInput = mockedSearchTermInput, - ) - } - - // Assert - onNodeWithText("No result for $mockSearchString.", substring = true).assertExists() - onNodeWithText("Try a different search", substring = true).assertExists() - } - - @Test fun customListFooterShouldShowEmptyTextWhenNoCustomList() = composeExtension.use { // Arrange - val mockSearchString = "" - setContentWithTheme { - SelectLocationScreen( - state = - SelectLocationUiState.Content( - searchTerm = mockSearchString, - filterChips = emptyList(), - relayListItems = listOf(RelayListItem.CustomListFooter(false)), - customLists = emptyList(), - ) + every { listViewModel.uiState } returns + MutableStateFlow( + SelectLocationListUiState.Content( + relayListItems = listOf(RelayListItem.CustomListFooter(false)), + customLists = emptyList(), + ) ) - } - - // Assert - onNodeWithText(CUSTOM_LISTS_EMPTY_TEXT).assertExists() - } - - @Test - fun givenNoCustomListsAndSearchIsActiveShouldNotShowCustomListHeader() = - composeExtension.use { - // Arrange - val mockSearchString = "SEARCH" setContentWithTheme { SelectLocationScreen( state = - SelectLocationUiState.Content( - searchTerm = mockSearchString, + SelectLocationUiState( filterChips = emptyList(), - relayListItems = emptyList(), - customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + multihopEnabled = false, + relayListType = RelayListType.EXIT, ) ) } // Assert - onNodeWithText(CUSTOM_LISTS_EMPTY_TEXT).assertDoesNotExist() - onNodeWithTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG).assertDoesNotExist() + onNodeWithText(CUSTOM_LISTS_EMPTY_TEXT).assertExists() } @Test @@ -170,15 +113,21 @@ class SelectLocationScreenTest { composeExtension.use { // Arrange val customList = DUMMY_RELAY_ITEM_CUSTOM_LISTS[0] + every { listViewModel.uiState } returns + MutableStateFlow( + SelectLocationListUiState.Content( + relayListItems = listOf(RelayListItem.CustomListItem(customList)), + customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + ) + ) val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true) setContentWithTheme { SelectLocationScreen( state = - SelectLocationUiState.Content( - searchTerm = "", + SelectLocationUiState( filterChips = emptyList(), - relayListItems = listOf(RelayListItem.CustomListItem(customList)), - customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + multihopEnabled = false, + relayListType = RelayListType.EXIT, ), onSelectRelay = mockedOnSelectRelay, ) @@ -196,16 +145,22 @@ class SelectLocationScreenTest { composeExtension.use { // Arrange val customList = DUMMY_RELAY_ITEM_CUSTOM_LISTS[0] + every { listViewModel.uiState } returns + MutableStateFlow( + SelectLocationListUiState.Content( + relayListItems = listOf(RelayListItem.CustomListItem(item = customList)), + customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + ) + ) val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true) setContentWithTheme { SelectLocationScreen( state = - SelectLocationUiState.Content( - searchTerm = "", + SelectLocationUiState( + // searchTerm = "", filterChips = emptyList(), - relayListItems = - listOf(RelayListItem.CustomListItem(item = customList)), - customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + multihopEnabled = false, + relayListType = RelayListType.EXIT, ), onSelectRelay = mockedOnSelectRelay, ) @@ -223,15 +178,21 @@ class SelectLocationScreenTest { composeExtension.use { // Arrange val relayItem = DUMMY_RELAY_COUNTRIES[0] + every { listViewModel.uiState } returns + MutableStateFlow( + SelectLocationListUiState.Content( + relayListItems = listOf(RelayListItem.GeoLocationItem(relayItem)), + customLists = emptyList(), + ) + ) val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true) setContentWithTheme { SelectLocationScreen( state = - SelectLocationUiState.Content( - searchTerm = "", + SelectLocationUiState( filterChips = emptyList(), - relayListItems = listOf(RelayListItem.GeoLocationItem(relayItem)), - customLists = emptyList(), + multihopEnabled = false, + relayListType = RelayListType.EXIT, ), onSelectRelay = mockedOnSelectRelay, ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt new file mode 100644 index 0000000000..f67e7228af --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt @@ -0,0 +1,107 @@ +package net.mullvad.mullvadvpn.compose.button + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SingleChoiceSegmentedButtonRowScope +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.color.onSelected +import net.mullvad.mullvadvpn.lib.theme.color.selected + +@Preview +@Composable +private fun PreviewMullvadSegmentedButton() { + AppTheme { + SingleChoiceSegmentedButtonRow { + MullvadSegmentedStartButton(selected = true, text = "Start", onClick = {}) + MullvadSegmentedMiddleButton(selected = false, text = "Middle", onClick = {}) + MullvadSegmentedEndButton(selected = false, text = "End", onClick = {}) + } + } +} + +@Composable +private fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedButton( + selected: Boolean, + text: String, + onClick: () -> Unit, + shape: Shape, +) { + SegmentedButton( + onClick = onClick, + selected = selected, + colors = + SegmentedButtonDefaults.colors() + .copy( + activeContainerColor = MaterialTheme.colorScheme.selected, + activeContentColor = MaterialTheme.colorScheme.onSelected, + inactiveContainerColor = MaterialTheme.colorScheme.primary, + inactiveContentColor = MaterialTheme.colorScheme.onPrimary, + ), + border = BorderStroke(0.dp, Color.Unspecified), + label = { + Text( + text = text, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + icon = {}, + shape = shape, + ) +} + +@Composable +fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedStartButton( + selected: Boolean, + text: String, + onClick: () -> Unit, +) { + MullvadSegmentedButton( + selected = selected, + text = text, + onClick = onClick, + shape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp), + ) +} + +@Composable +fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedMiddleButton( + selected: Boolean, + text: String, + onClick: () -> Unit, +) { + MullvadSegmentedButton( + selected = selected, + text = text, + onClick = onClick, + shape = RoundedCornerShape(0.dp), // Square + ) +} + +@Composable +fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedEndButton( + selected: Boolean, + text: String, + onClick: () -> Unit, +) { + MullvadSegmentedButton( + selected = selected, + text = text, + onClick = onClick, + shape = RoundedCornerShape(topEnd = 8.dp, bottomEnd = 8.dp), + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterRow.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterRow.kt index d3e233c67b..ab708e77d1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterRow.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterRow.kt @@ -15,19 +15,19 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.MullvadFilterChip -import net.mullvad.mullvadvpn.compose.state.FilterChip import net.mullvad.mullvadvpn.lib.model.Ownership import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.usecase.FilterChip @Preview @Composable private fun PreviewFilterCell() { AppTheme { FilterRow( - listOf(FilterChip.Ownership(Ownership.MullvadOwned), FilterChip.Provider(2)), - {}, - {}, + filters = listOf(FilterChip.Ownership(Ownership.MullvadOwned), FilterChip.Provider(2)), + onRemoveOwnershipFilter = {}, + onRemoveProviderFilter = {}, ) } } @@ -35,6 +35,7 @@ private fun PreviewFilterCell() { @Composable fun FilterRow( filters: List<FilterChip>, + showTitle: Boolean = true, onRemoveOwnershipFilter: () -> Unit, onRemoveProviderFilter: () -> Unit, ) { @@ -42,22 +43,26 @@ fun FilterRow( Row( verticalAlignment = Alignment.CenterVertically, modifier = - Modifier.horizontalScroll(scrollState) - .padding(horizontal = Dimens.searchFieldHorizontalPadding) - .fillMaxWidth(), + Modifier.padding(horizontal = Dimens.searchFieldHorizontalPadding) + .fillMaxWidth() + .horizontalScroll(scrollState), horizontalArrangement = Arrangement.spacedBy(Dimens.chipSpace), ) { - Text( - text = stringResource(id = R.string.filtered), - color = MaterialTheme.colorScheme.onPrimary, - style = MaterialTheme.typography.labelMedium, - ) + if (showTitle) { + Text( + text = stringResource(id = R.string.filters), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelMedium, + ) + } filters.forEach { when (it) { is FilterChip.Ownership -> OwnershipFilterChip(it.ownership, onRemoveOwnershipFilter) is FilterChip.Provider -> ProviderFilterChip(it.count, onRemoveProviderFilter) is FilterChip.Daita -> DaitaFilterChip() + is FilterChip.Entry -> EntryFilterChip() + is FilterChip.Exit -> ExitFilterChip() } } } @@ -90,6 +95,24 @@ fun DaitaFilterChip() { ) } +@Composable +fun EntryFilterChip() { + MullvadFilterChip( + text = stringResource(id = R.string.entry), + onRemoveClick = {}, + enabled = false, + ) +} + +@Composable +fun ExitFilterChip() { + MullvadFilterChip( + text = stringResource(id = R.string.exit), + onRemoveClick = {}, + enabled = false, + ) +} + private fun Ownership.stringResources(): Int = when (this) { Ownership.MullvadOwned -> R.string.owned diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt index e1157eb3bc..eb729701bc 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt @@ -27,13 +27,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.ExpandChevron import net.mullvad.mullvadvpn.compose.component.MullvadCheckbox import net.mullvad.mullvadvpn.compose.preview.RelayItemCheckableCellPreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.RelayListItemState import net.mullvad.mullvadvpn.compose.test.EXPAND_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LOCATION_CELL_TEST_TAG import net.mullvad.mullvadvpn.lib.model.RelayItem @@ -70,6 +73,7 @@ private fun PreviewCheckableRelayLocationCell( fun StatusRelayItemCell( item: RelayItem, isSelected: Boolean, + state: RelayListItemState?, modifier: Modifier = Modifier, onClick: () -> Unit = {}, onLongClick: (() -> Unit)? = null, @@ -80,11 +84,11 @@ fun StatusRelayItemCell( inactiveColor: Color = MaterialTheme.colorScheme.error, disabledColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, ) { - RelayItemCell( modifier = modifier, - item, - isSelected, + item = item, + isSelected = isSelected, + state = state, leadingContent = { if (isSelected) { Icon(imageVector = Icons.Default.Check, contentDescription = null) @@ -98,6 +102,7 @@ fun StatusRelayItemCell( when { item is RelayItem.CustomList && item.locations.isEmpty() -> disabledColor + state != null -> disabledColor item.active -> activeColor else -> inactiveColor }, @@ -120,6 +125,7 @@ fun RelayItemCell( modifier: Modifier = Modifier, item: RelayItem, isSelected: Boolean, + state: RelayListItemState?, leadingContent: (@Composable RowScope.() -> Unit)? = null, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, @@ -148,7 +154,7 @@ fun RelayItemCell( Row( modifier = Modifier.combinedClickable( - enabled = item.active, + enabled = state == null && item.active, onClick = onClick, onLongClick = onLongClick, ) @@ -159,7 +165,7 @@ fun RelayItemCell( if (leadingContent != null) { leadingContent() } - Name(relay = item) + Name(name = item.name, state = state) } if (item.hasChildren) { @@ -187,6 +193,7 @@ fun CheckableRelayLocationCell( modifier = modifier, item = item, isSelected = false, + state = null, leadingContent = { MullvadCheckbox( checked = checked, @@ -201,14 +208,14 @@ fun CheckableRelayLocationCell( } @Composable -private fun Name(modifier: Modifier = Modifier, relay: RelayItem) { +private fun Name(modifier: Modifier = Modifier, name: String, state: RelayListItemState?) { Text( - text = relay.name, + text = state?.let { name.withSuffix(state) } ?: name, color = MaterialTheme.colorScheme.onSurface, modifier = modifier .alpha( - if (relay.active) { + if (state == null) { AlphaVisible } else { AlphaInactive @@ -252,3 +259,10 @@ private fun Int.toBackgroundColor(): Color = 2 -> MaterialTheme.colorScheme.surfaceContainerLow else -> MaterialTheme.colorScheme.surfaceContainerLowest } + +@Composable +private fun String.withSuffix(state: RelayListItemState) = + when (state) { + RelayListItemState.USED_AS_EXIT -> stringResource(R.string.x_exit, this) + RelayListItemState.USED_AS_ENTRY -> stringResource(R.string.x_entry, this) + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt index 347de1654e..579be88bb6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt @@ -1,51 +1,29 @@ package net.mullvad.mullvadvpn.compose.component -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.core.text.HtmlCompat import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH @Composable fun LocationsEmptyText(searchTerm: String) { if (searchTerm.length >= MIN_SEARCH_LENGTH) { - val firstRow = - HtmlCompat.fromHtml( - textResource(id = R.string.select_location_empty_text_first_row, searchTerm), - HtmlCompat.FROM_HTML_MODE_COMPACT, - ) - .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) - val secondRow = textResource(id = R.string.select_location_empty_text_second_row) - Column( - modifier = Modifier.padding(horizontal = Dimens.selectLocationTitlePadding), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = firstRow, - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = secondRow, - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Text( + text = textResource(R.string.search_location_empty_text, searchTerm), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(Dimens.screenVerticalMargin), + ) } else { Text( text = stringResource(R.string.no_locations_found), diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt index 8b04017f0a..c31608949d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt @@ -92,6 +92,7 @@ private fun FeatureIndicator.text(): String { FeatureIndicator.SERVER_IP_OVERRIDE -> R.string.feature_server_ip_override FeatureIndicator.CUSTOM_MTU -> R.string.feature_custom_mtu FeatureIndicator.DAITA -> R.string.feature_daita + FeatureIndicator.MULTIHOP -> R.string.feature_multihop } return textResource(resource) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayListItemPreviewData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayListItemPreviewData.kt new file mode 100644 index 0000000000..2c695764d7 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayListItemPreviewData.kt @@ -0,0 +1,113 @@ +package net.mullvad.mullvadvpn.compose.preview + +import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.RelayListItemState +import net.mullvad.mullvadvpn.lib.model.CustomList +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.RelayItem + +object RelayListItemPreviewData { + @Suppress("LongMethod") + fun generateRelayListItems( + includeCustomLists: Boolean, + isSearching: Boolean, + ): List<RelayListItem> = buildList { + if (!isSearching || includeCustomLists) { + add(RelayListItem.CustomListHeader) + // Add custom list items + if (includeCustomLists) { + RelayListItem.CustomListItem( + item = + RelayItem.CustomList( + customList = + CustomList( + id = CustomListId("custom_list_id"), + name = CustomListName.fromString("Custom List"), + locations = emptyList(), + ), + locations = + listOf( + RelayItemPreviewData.generateRelayItemCountry( + name = "Country", + cityNames = listOf("City"), + relaysPerCity = 2, + active = true, + ) + ), + ), + isSelected = false, + state = null, + expanded = false, + ) + } + if (!isSearching) { + add(RelayListItem.CustomListFooter(hasCustomList = includeCustomLists)) + } + } + add(RelayListItem.LocationHeader) + val locations = + listOf( + RelayItemPreviewData.generateRelayItemCountry( + name = "A relay", + cityNames = listOf("City 1", "City 2"), + relaysPerCity = 2, + active = true, + ), + RelayItemPreviewData.generateRelayItemCountry( + name = "Another relay", + cityNames = listOf("City X", "City Y", "City Z"), + relaysPerCity = 1, + active = false, + ), + ) + addAll( + listOf( + RelayListItem.GeoLocationItem( + item = locations[0], + isSelected = false, + depth = 0, + expanded = true, + state = null, + ), + RelayListItem.GeoLocationItem( + item = locations[0].cities[0], + isSelected = true, + depth = 1, + expanded = false, + state = null, + ), + RelayListItem.GeoLocationItem( + item = locations[0].cities[1], + isSelected = false, + depth = 1, + expanded = true, + state = null, + ), + RelayListItem.GeoLocationItem( + item = locations[0].cities[1].relays[0], + isSelected = false, + depth = 2, + expanded = false, + state = RelayListItemState.USED_AS_EXIT, + ), + RelayListItem.GeoLocationItem( + item = locations[0].cities[1].relays[0], + isSelected = false, + depth = 2, + expanded = false, + state = null, + ), + RelayListItem.GeoLocationItem( + item = locations[1], + isSelected = false, + depth = 0, + expanded = false, + state = null, + ), + ) + ) + } + + fun generateEmptyList(searchTerm: String) = listOf(RelayListItem.LocationsEmptyText(searchTerm)) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt new file mode 100644 index 0000000000..ebed8d229f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt @@ -0,0 +1,29 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.SearchLocationUiState +import net.mullvad.mullvadvpn.usecase.FilterChip + +class SearchLocationsUiStatePreviewParameterProvider : + PreviewParameterProvider<SearchLocationUiState> { + override val values = + sequenceOf( + SearchLocationUiState.NoQuery(searchTerm = "", filterChips = listOf(FilterChip.Entry)), + SearchLocationUiState.Content( + searchTerm = "Mullvad", + filterChips = listOf(FilterChip.Entry), + relayListItems = RelayListItemPreviewData.generateEmptyList("Mullvad"), + customLists = emptyList(), + ), + SearchLocationUiState.Content( + searchTerm = "Germany", + filterChips = listOf(FilterChip.Entry), + relayListItems = + RelayListItemPreviewData.generateRelayListItems( + includeCustomLists = true, + isSearching = true, + ), + customLists = emptyList(), + ), + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt index a3b4e1bcdc..b0415b1c7e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt @@ -1,66 +1,46 @@ package net.mullvad.mullvadvpn.compose.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import net.mullvad.mullvadvpn.compose.state.FilterChip -import net.mullvad.mullvadvpn.compose.state.ModelOwnership -import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState -import net.mullvad.mullvadvpn.lib.model.CustomListId -import net.mullvad.mullvadvpn.lib.model.CustomListName -import net.mullvad.mullvadvpn.lib.model.DomainCustomList -import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.model.Ownership import net.mullvad.mullvadvpn.lib.model.Provider -import net.mullvad.mullvadvpn.lib.model.ProviderId -import net.mullvad.mullvadvpn.lib.model.RelayItem - -private val RELAY = - RelayItem.Location.Relay( - id = - GeoLocationId.Hostname( - city = GeoLocationId.City(country = GeoLocationId.Country("se"), code = "code"), - code = "code", - ), - provider = Provider(providerId = ProviderId("providerId"), ownership = Ownership.Rented), - active = true, - daita = true, - ) +import net.mullvad.mullvadvpn.usecase.FilterChip +import net.mullvad.mullvadvpn.usecase.ModelOwnership class SelectLocationsUiStatePreviewParameterProvider : PreviewParameterProvider<SelectLocationUiState> { override val values = sequenceOf( - SelectLocationUiState.Content( - searchTerm = "search term", - listOf(FilterChip.Ownership(ownership = ModelOwnership.MullvadOwned)), - relayListItems = + SelectLocationUiState( + filterChips = emptyList(), + multihopEnabled = false, + relayListType = RelayListType.EXIT, + ), + SelectLocationUiState( + filterChips = listOf( - RelayListItem.GeoLocationItem( - item = RELAY, - isSelected = true, - depth = 1, - expanded = true, - ) + FilterChip.Ownership(ownership = ModelOwnership.Rented), + FilterChip.Provider(PROVIDER_COUNT), ), - customLists = + multihopEnabled = false, + relayListType = RelayListType.EXIT, + ), + SelectLocationUiState( + filterChips = emptyList(), + multihopEnabled = true, + relayListType = RelayListType.ENTRY, + ), + SelectLocationUiState( + filterChips = listOf( - RelayItem.CustomList( - customList = - DomainCustomList( - id = CustomListId("custom_list_id"), - locations = - listOf( - GeoLocationId.City( - country = GeoLocationId.Country("dk"), - code = "code2", - ) - ), - name = CustomListName.fromString("Custom List"), - ), - locations = listOf(RELAY), - ) + FilterChip.Ownership(ownership = ModelOwnership.MullvadOwned), + FilterChip.Provider(PROVIDER_COUNT), ), + multihopEnabled = true, + relayListType = RelayListType.ENTRY, ), - SelectLocationUiState.Loading, ) } + +private const val PROVIDER_COUNT = 3 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SettingsUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SettingsUiStatePreviewParameterProvider.kt index 6230911766..18f422a988 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SettingsUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SettingsUiStatePreviewParameterProvider.kt @@ -11,12 +11,14 @@ class SettingsUiStatePreviewParameterProvider : PreviewParameterProvider<Setting isLoggedIn = true, isSupportedVersion = true, isPlayBuild = true, + multihopEnabled = false, ), SettingsUiState( appVersion = "9000.1", isLoggedIn = false, isSupportedVersion = false, isPlayBuild = false, + multihopEnabled = false, ), ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt index 71e7f66d0f..c3640979d3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -409,9 +409,8 @@ private fun ConnectionCardHeader( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - - val hostname = location?.hostname - AnimatedContent(hostname, label = "hostname") { + val hostnameText = location.hostnameText() + AnimatedContent(hostnameText, label = "hostname") { if (it != null) { Text( modifier = Modifier.fillMaxWidth(), @@ -440,6 +439,17 @@ private fun GeoIpLocation?.asString(): String { } @Composable +private fun GeoIpLocation?.hostnameText(): String? { + val entryHostName = this?.entryHostname + val exitHostName = this?.hostname + return when { + entryHostName != null && exitHostName != null -> + stringResource(R.string.x_via_x, exitHostName, entryHostName) + else -> exitHostName + } +} + +@Composable private fun ConnectionInfo( featureIndicators: List<FeatureIndicator>, connectionDetails: ConnectionDetails?, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MultihopScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MultihopScreen.kt new file mode 100644 index 0000000000..5491fc624c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MultihopScreen.kt @@ -0,0 +1,89 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.HeaderSwitchComposeCell +import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell +import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.viewmodel.MultihopUiState +import net.mullvad.mullvadvpn.viewmodel.MultihopViewModel +import org.koin.androidx.compose.koinViewModel + +@Preview +@Composable +private fun PreviewMultihopScreen() { + AppTheme { MultihopScreen(state = MultihopUiState(false)) } +} + +@Destination<RootGraph>(style = SlideInFromRightTransition::class) +@Composable +fun Multihop(navigator: DestinationsNavigator) { + val viewModel = koinViewModel<MultihopViewModel>() + val state by viewModel.uiState.collectAsStateWithLifecycle() + MultihopScreen( + state = state, + onMultihopClick = viewModel::setMultihop, + onBackClick = dropUnlessResumed { navigator.navigateUp() }, + ) +} + +@Composable +fun MultihopScreen( + state: MultihopUiState, + onMultihopClick: (enable: Boolean) -> Unit = {}, + onBackClick: () -> Unit = {}, +) { + ScaffoldWithMediumTopBar( + appBarTitle = stringResource(id = R.string.multihop), + navigationIcon = { NavigateBackIconButton { onBackClick() } }, + ) { modifier -> + Column(modifier = modifier) { + // Scale image to fit width up to certain width + Image( + contentScale = ContentScale.FillWidth, + modifier = + Modifier.widthIn(max = Dimens.settingsDetailsImageMaxWidth) + .fillMaxWidth() + .padding(horizontal = Dimens.mediumPadding) + .align(Alignment.CenterHorizontally), + painter = painterResource(id = R.drawable.multihop_illustration), + contentDescription = stringResource(R.string.multihop), + ) + Description() + HeaderSwitchComposeCell( + title = stringResource(R.string.enable), + isToggled = state.enable, + onCellClicked = onMultihopClick, + ) + } + } +} + +@Composable +private fun Description() { + SwitchComposeSubtitleCell( + modifier = Modifier.padding(vertical = Dimens.mediumPadding), + text = stringResource(R.string.multihop_description), + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt deleted file mode 100644 index c36f10212e..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt +++ /dev/null @@ -1,938 +0,0 @@ -package net.mullvad.mullvadvpn.compose.screen - -import android.content.Context -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.FilterList -import androidx.compose.material.icons.filled.Remove -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SheetState -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.compose.dropUnlessResumed -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootGraph -import com.ramcosta.composedestinations.generated.destinations.CreateCustomListDestination -import com.ramcosta.composedestinations.generated.destinations.CustomListLocationsDestination -import com.ramcosta.composedestinations.generated.destinations.CustomListsDestination -import com.ramcosta.composedestinations.generated.destinations.DeleteCustomListDestination -import com.ramcosta.composedestinations.generated.destinations.EditCustomListNameDestination -import com.ramcosta.composedestinations.generated.destinations.FilterDestination -import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import com.ramcosta.composedestinations.result.NavResult -import com.ramcosta.composedestinations.result.ResultBackNavigator -import com.ramcosta.composedestinations.result.ResultRecipient -import com.ramcosta.composedestinations.spec.DestinationSpec -import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.cell.FilterRow -import net.mullvad.mullvadvpn.compose.cell.HeaderCell -import net.mullvad.mullvadvpn.compose.cell.IconCell -import net.mullvad.mullvadvpn.compose.cell.StatusRelayItemCell -import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell -import net.mullvad.mullvadvpn.compose.cell.ThreeDotCell -import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData -import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText -import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge -import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomSheet -import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar -import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar -import net.mullvad.mullvadvpn.compose.constant.ContentType -import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed -import net.mullvad.mullvadvpn.compose.preview.SelectLocationsUiStatePreviewParameterProvider -import net.mullvad.mullvadvpn.compose.screen.BottomSheetState.ShowCustomListsBottomSheet -import net.mullvad.mullvadvpn.compose.screen.BottomSheetState.ShowCustomListsEntryBottomSheet -import net.mullvad.mullvadvpn.compose.screen.BottomSheetState.ShowEditCustomListBottomSheet -import net.mullvad.mullvadvpn.compose.screen.BottomSheetState.ShowLocationBottomSheet -import net.mullvad.mullvadvpn.compose.state.RelayListItem -import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState -import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR -import net.mullvad.mullvadvpn.compose.test.LOCATION_CELL_TEST_TAG -import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG -import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG -import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG -import net.mullvad.mullvadvpn.compose.textfield.SearchTextField -import net.mullvad.mullvadvpn.compose.transitions.TopLevelTransition -import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle -import net.mullvad.mullvadvpn.compose.util.RunOnKeyChange -import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately -import net.mullvad.mullvadvpn.lib.model.CustomListId -import net.mullvad.mullvadvpn.lib.model.CustomListName -import net.mullvad.mullvadvpn.lib.model.RelayItem -import net.mullvad.mullvadvpn.lib.model.RelayItemId -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive -import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar -import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible -import net.mullvad.mullvadvpn.relaylist.canAddLocation -import net.mullvad.mullvadvpn.viewmodel.SelectLocationSideEffect -import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel -import org.koin.androidx.compose.koinViewModel - -@Preview("Content|Loading") -@Composable -private fun PreviewSelectLocationScreen( - @PreviewParameter(SelectLocationsUiStatePreviewParameterProvider::class) - state: SelectLocationUiState -) { - AppTheme { SelectLocationScreen(state = state) } -} - -@Destination<RootGraph>(style = TopLevelTransition::class) -@Suppress("LongMethod") -@Composable -fun SelectLocation( - navigator: DestinationsNavigator, - backNavigator: ResultBackNavigator<Boolean>, - createCustomListDialogResultRecipient: - ResultRecipient< - CreateCustomListDestination, - CustomListActionResultData.Success.CreatedWithLocations, - >, - editCustomListNameDialogResultRecipient: - ResultRecipient<EditCustomListNameDestination, CustomListActionResultData.Success.Renamed>, - deleteCustomListDialogResultRecipient: - ResultRecipient<DeleteCustomListDestination, CustomListActionResultData.Success.Deleted>, - updateCustomListResultRecipient: - ResultRecipient<CustomListLocationsDestination, CustomListActionResultData>, -) { - val vm = koinViewModel<SelectLocationViewModel>() - val state = vm.uiState.collectAsStateWithLifecycle() - - val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current - val lazyListState = rememberLazyListState() - CollectSideEffectWithLifecycle(vm.uiSideEffect) { - when (it) { - SelectLocationSideEffect.CloseScreen -> backNavigator.navigateBack(result = true) - is SelectLocationSideEffect.CustomListActionToast -> - launch { - snackbarHostState.showResultSnackbar( - context = context, - result = it.resultData, - onUndo = vm::performAction, - ) - } - SelectLocationSideEffect.GenericError -> - launch { - snackbarHostState.showSnackbarImmediately( - message = context.getString(R.string.error_occurred), - duration = SnackbarDuration.Short, - ) - } - } - } - - val stateActual = state.value - RunOnKeyChange(stateActual is SelectLocationUiState.Content) { - val index = stateActual.indexOfSelectedRelayItem() - if (index != -1) { - lazyListState.scrollToItem(index) - lazyListState.animateScrollAndCentralizeItem(index) - } - } - - createCustomListDialogResultRecipient.OnCustomListNavResult( - snackbarHostState, - vm::performAction, - ) - - editCustomListNameDialogResultRecipient.OnCustomListNavResult( - snackbarHostState, - vm::performAction, - ) - - deleteCustomListDialogResultRecipient.OnCustomListNavResult( - snackbarHostState, - vm::performAction, - ) - - updateCustomListResultRecipient.OnCustomListNavResult(snackbarHostState, vm::performAction) - - SelectLocationScreen( - state = state.value, - lazyListState = lazyListState, - snackbarHostState = snackbarHostState, - onSelectRelay = vm::selectRelay, - onSearchTermInput = vm::onSearchTermInput, - onBackClick = dropUnlessResumed { backNavigator.navigateBack() }, - onFilterClick = dropUnlessResumed { navigator.navigate(FilterDestination) }, - onCreateCustomList = - dropUnlessResumed { relayItem -> - navigator.navigate(CreateCustomListDestination(locationCode = relayItem?.id)) - }, - onToggleExpand = vm::onToggleExpand, - onEditCustomLists = dropUnlessResumed { navigator.navigate(CustomListsDestination()) }, - removeOwnershipFilter = vm::removeOwnerFilter, - removeProviderFilter = vm::removeProviderFilter, - onAddLocationToList = vm::addLocationToList, - onRemoveLocationFromList = vm::removeLocationFromList, - onEditCustomListName = - dropUnlessResumed { customList: RelayItem.CustomList -> - navigator.navigate( - EditCustomListNameDestination( - customListId = customList.id, - initialName = customList.customList.name, - ) - ) - }, - onEditLocationsCustomList = - dropUnlessResumed { customList: RelayItem.CustomList -> - navigator.navigate( - CustomListLocationsDestination(customListId = customList.id, newList = false) - ) - }, - onDeleteCustomList = - dropUnlessResumed { customList: RelayItem.CustomList -> - navigator.navigate( - DeleteCustomListDestination( - customListId = customList.id, - name = customList.customList.name, - ) - ) - }, - ) -} - -@OptIn(ExperimentalFoundationApi::class) -@Suppress("LongMethod") -@Composable -fun SelectLocationScreen( - state: SelectLocationUiState, - lazyListState: LazyListState = rememberLazyListState(), - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, - onSelectRelay: (item: RelayItem) -> Unit = {}, - onSearchTermInput: (searchTerm: String) -> Unit = {}, - onBackClick: () -> Unit = {}, - onFilterClick: () -> Unit = {}, - onCreateCustomList: (location: RelayItem.Location?) -> Unit = {}, - onEditCustomLists: () -> Unit = {}, - removeOwnershipFilter: () -> Unit = {}, - removeProviderFilter: () -> Unit = {}, - onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit = - { _, _ -> - }, - onRemoveLocationFromList: (location: RelayItem.Location, customListId: CustomListId) -> Unit = - { _, _ -> - }, - onEditCustomListName: (RelayItem.CustomList) -> Unit = {}, - onEditLocationsCustomList: (RelayItem.CustomList) -> Unit = {}, - onDeleteCustomList: (RelayItem.CustomList) -> Unit = {}, - onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit = { _, _, _ -> }, -) { - val backgroundColor = MaterialTheme.colorScheme.surface - - Scaffold( - snackbarHost = { - SnackbarHost( - snackbarHostState, - snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) }, - ) - } - ) { - var bottomSheetState by remember { mutableStateOf<BottomSheetState?>(null) } - BottomSheets( - bottomSheetState = bottomSheetState, - onCreateCustomList = onCreateCustomList, - onEditCustomLists = onEditCustomLists, - onAddLocationToList = onAddLocationToList, - onRemoveLocationFromList = onRemoveLocationFromList, - onEditCustomListName = onEditCustomListName, - onEditLocationsCustomList = onEditLocationsCustomList, - onDeleteCustomList = onDeleteCustomList, - onHideBottomSheet = { bottomSheetState = null }, - ) - - Column(modifier = Modifier.padding(it).background(backgroundColor).fillMaxSize()) { - SelectLocationTopBar(onBackClick = onBackClick, onFilterClick = onFilterClick) - - if (state is SelectLocationUiState.Content && state.filterChips.isNotEmpty()) { - FilterRow(filters = state.filterChips, removeOwnershipFilter, removeProviderFilter) - } - - SearchTextField( - modifier = - Modifier.fillMaxWidth() - .height(Dimens.searchFieldHeight) - .padding(horizontal = Dimens.searchFieldHorizontalPadding), - textColor = MaterialTheme.colorScheme.onTertiaryContainer, - backgroundColor = MaterialTheme.colorScheme.tertiaryContainer, - ) { searchString -> - onSearchTermInput.invoke(searchString) - } - Spacer(modifier = Modifier.height(height = Dimens.verticalSpace)) - - LazyColumn( - modifier = - Modifier.fillMaxSize() - .drawVerticalScrollbar( - lazyListState, - MaterialTheme.colorScheme.onSurface.copy(alpha = AlphaScrollbar), - ), - state = lazyListState, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - when (state) { - SelectLocationUiState.Loading -> { - loading() - } - is SelectLocationUiState.Content -> { - - itemsIndexed( - items = state.relayListItems, - key = { _: Int, item: RelayListItem -> item.key }, - contentType = { _, item -> item.contentType }, - itemContent = { index: Int, listItem: RelayListItem -> - Column(modifier = Modifier.animateItem()) { - if (index != 0) { - HorizontalDivider(color = backgroundColor) - } - when (listItem) { - RelayListItem.CustomListHeader -> - CustomListHeader( - onShowCustomListBottomSheet = { - bottomSheetState = - ShowCustomListsBottomSheet( - editListEnabled = - state.customLists.isNotEmpty() - ) - } - ) - is RelayListItem.CustomListItem -> - CustomListItem( - listItem, - onSelectRelay, - { - bottomSheetState = - ShowEditCustomListBottomSheet(it) - }, - { customListId, expand -> - onToggleExpand(customListId, null, expand) - }, - ) - is RelayListItem.CustomListEntryItem -> - CustomListEntryItem( - listItem, - { onSelectRelay(listItem.item) }, - if (listItem.depth == 1) { - { - bottomSheetState = - ShowCustomListsEntryBottomSheet( - listItem.parentId, - listItem.parentName, - listItem.item, - ) - } - } else { - null - }, - { expand: Boolean -> - onToggleExpand( - listItem.item.id, - listItem.parentId, - expand, - ) - }, - ) - is RelayListItem.CustomListFooter -> - CustomListFooter(listItem) - RelayListItem.LocationHeader -> RelayLocationHeader() - is RelayListItem.GeoLocationItem -> - RelayLocationItem( - listItem, - { onSelectRelay(listItem.item) }, - { - // Only direct children can be removed - bottomSheetState = - ShowLocationBottomSheet( - state.customLists, - listItem.item, - ) - }, - { expand -> - onToggleExpand(listItem.item.id, null, expand) - }, - ) - is RelayListItem.LocationsEmptyText -> - LocationsEmptyText(listItem.searchTerm) - } - } - }, - ) - } - } - } - } - } -} - -@Composable -fun LazyItemScope.RelayLocationHeader() { - HeaderCell(text = stringResource(R.string.all_locations)) -} - -@Composable -fun LazyItemScope.RelayLocationItem( - relayItem: RelayListItem.GeoLocationItem, - onSelectRelay: () -> Unit, - onLongClick: () -> Unit, - onExpand: (Boolean) -> Unit, -) { - val location = relayItem.item - StatusRelayItemCell( - location, - relayItem.isSelected, - onClick = { onSelectRelay() }, - onLongClick = { onLongClick() }, - onToggleExpand = { onExpand(it) }, - isExpanded = relayItem.expanded, - depth = relayItem.depth, - modifier = Modifier.testTag(LOCATION_CELL_TEST_TAG), - ) -} - -@Composable -fun LazyItemScope.CustomListItem( - itemState: RelayListItem.CustomListItem, - onSelectRelay: (item: RelayItem) -> Unit, - onShowEditBottomSheet: (RelayItem.CustomList) -> Unit, - onExpand: ((CustomListId, Boolean) -> Unit), -) { - val customListItem = itemState.item - StatusRelayItemCell( - customListItem, - itemState.isSelected, - onClick = { onSelectRelay(customListItem) }, - onLongClick = { onShowEditBottomSheet(customListItem) }, - onToggleExpand = { onExpand(customListItem.id, it) }, - isExpanded = itemState.expanded, - ) -} - -@Composable -fun LazyItemScope.CustomListEntryItem( - itemState: RelayListItem.CustomListEntryItem, - onSelectRelay: () -> Unit, - onShowEditCustomListEntryBottomSheet: (() -> Unit)?, - onToggleExpand: (Boolean) -> Unit, -) { - val customListEntryItem = itemState.item - StatusRelayItemCell( - customListEntryItem, - false, - onClick = onSelectRelay, - onLongClick = onShowEditCustomListEntryBottomSheet, - onToggleExpand = onToggleExpand, - isExpanded = itemState.expanded, - depth = itemState.depth, - ) -} - -@Composable -fun LazyItemScope.CustomListFooter(item: RelayListItem.CustomListFooter) { - SwitchComposeSubtitleCell( - text = - if (item.hasCustomList) { - stringResource(R.string.to_add_locations_to_a_list) - } else { - stringResource(R.string.to_create_a_custom_list) - }, - modifier = Modifier.background(MaterialTheme.colorScheme.surface), - ) -} - -@Composable -private fun SelectLocationTopBar(onBackClick: () -> Unit, onFilterClick: () -> Unit) { - Row(modifier = Modifier.fillMaxWidth()) { - IconButton(onClick = onBackClick) { - Icon( - imageVector = Icons.Default.Close, - tint = MaterialTheme.colorScheme.onSurface, - contentDescription = stringResource(id = R.string.back), - ) - } - Text( - text = stringResource(id = R.string.select_location), - modifier = Modifier.align(Alignment.CenterVertically).weight(weight = 1f), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - IconButton(onClick = onFilterClick) { - Icon( - imageVector = Icons.Default.FilterList, - contentDescription = stringResource(id = R.string.filter), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } -} - -private fun LazyListScope.loading() { - item(contentType = ContentType.PROGRESS) { - MullvadCircularProgressIndicatorLarge(Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR)) - } -} - -@Composable -private fun LazyItemScope.CustomListHeader(onShowCustomListBottomSheet: () -> Unit) { - ThreeDotCell( - text = stringResource(R.string.custom_lists), - onClickDots = onShowCustomListBottomSheet, - modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG), - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun BottomSheets( - bottomSheetState: BottomSheetState?, - onCreateCustomList: (RelayItem.Location?) -> Unit, - onEditCustomLists: () -> Unit, - onAddLocationToList: (RelayItem.Location, RelayItem.CustomList) -> Unit, - onRemoveLocationFromList: (location: RelayItem.Location, parent: CustomListId) -> Unit, - onEditCustomListName: (RelayItem.CustomList) -> Unit, - onEditLocationsCustomList: (RelayItem.CustomList) -> Unit, - onDeleteCustomList: (RelayItem.CustomList) -> Unit, - onHideBottomSheet: () -> Unit, -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - val onCloseBottomSheet: (animate: Boolean) -> Unit = { animate -> - if (animate) { - scope.launch { sheetState.hide() }.invokeOnCompletion { onHideBottomSheet() } - } else { - onHideBottomSheet() - } - } - val backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer - val onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface - - when (bottomSheetState) { - is ShowCustomListsBottomSheet -> { - CustomListsBottomSheet( - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - sheetState = sheetState, - bottomSheetState = bottomSheetState, - onCreateCustomList = { onCreateCustomList(null) }, - onEditCustomLists = onEditCustomLists, - closeBottomSheet = onCloseBottomSheet, - ) - } - is ShowLocationBottomSheet -> { - LocationBottomSheet( - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - sheetState = sheetState, - customLists = bottomSheetState.customLists, - item = bottomSheetState.item, - onCreateCustomList = onCreateCustomList, - onAddLocationToList = onAddLocationToList, - closeBottomSheet = onCloseBottomSheet, - ) - } - is ShowEditCustomListBottomSheet -> { - EditCustomListBottomSheet( - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - sheetState = sheetState, - customList = bottomSheetState.customList, - onEditName = onEditCustomListName, - onEditLocations = onEditLocationsCustomList, - onDeleteCustomList = onDeleteCustomList, - closeBottomSheet = onCloseBottomSheet, - ) - } - is ShowCustomListsEntryBottomSheet -> { - CustomListEntryBottomSheet( - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - sheetState = sheetState, - customListId = bottomSheetState.customListId, - customListName = bottomSheetState.customListName, - item = bottomSheetState.item, - onRemoveLocationFromList = onRemoveLocationFromList, - closeBottomSheet = onCloseBottomSheet, - ) - } - null -> { - /* Do nothing */ - } - } -} - -private fun SelectLocationUiState.indexOfSelectedRelayItem(): Int = - if (this is SelectLocationUiState.Content) { - relayListItems.indexOfFirst { - when (it) { - is RelayListItem.CustomListItem -> it.isSelected - is RelayListItem.GeoLocationItem -> it.isSelected - is RelayListItem.CustomListEntryItem -> false - is RelayListItem.CustomListFooter -> false - RelayListItem.CustomListHeader -> false - RelayListItem.LocationHeader -> false - is RelayListItem.LocationsEmptyText -> false - } - } - } else { - -1 - } - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CustomListsBottomSheet( - backgroundColor: Color, - onBackgroundColor: Color, - sheetState: SheetState, - bottomSheetState: ShowCustomListsBottomSheet, - onCreateCustomList: () -> Unit, - onEditCustomLists: () -> Unit, - closeBottomSheet: (animate: Boolean) -> Unit, -) { - - MullvadModalBottomSheet( - sheetState = sheetState, - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - onDismissRequest = { closeBottomSheet(false) }, - modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG), - ) { - HeaderCell( - text = stringResource(id = R.string.edit_custom_lists), - background = backgroundColor, - ) - HorizontalDivider(color = onBackgroundColor) - IconCell( - imageVector = Icons.Default.Add, - title = stringResource(id = R.string.new_list), - titleColor = onBackgroundColor, - onClick = { - onCreateCustomList() - closeBottomSheet(true) - }, - background = backgroundColor, - ) - IconCell( - imageVector = Icons.Default.Edit, - title = stringResource(id = R.string.edit_lists), - titleColor = - onBackgroundColor.copy( - alpha = - if (bottomSheetState.editListEnabled) { - AlphaVisible - } else { - AlphaInactive - } - ), - onClick = { - onEditCustomLists() - closeBottomSheet(true) - }, - background = backgroundColor, - enabled = bottomSheetState.editListEnabled, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun LocationBottomSheet( - backgroundColor: Color, - onBackgroundColor: Color, - sheetState: SheetState, - customLists: List<RelayItem.CustomList>, - item: RelayItem.Location, - onCreateCustomList: (relayItem: RelayItem.Location) -> Unit, - onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit, - closeBottomSheet: (animate: Boolean) -> Unit, -) { - MullvadModalBottomSheet( - sheetState = sheetState, - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - onDismissRequest = { closeBottomSheet(false) }, - modifier = Modifier.testTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG), - ) { -> - HeaderCell( - text = stringResource(id = R.string.add_location_to_list, item.name), - background = backgroundColor, - ) - HorizontalDivider(color = onBackgroundColor) - customLists.forEach { - val enabled = it.canAddLocation(item) - IconCell( - imageVector = null, - title = - if (enabled) { - it.name - } else { - stringResource(id = R.string.location_added, it.name) - }, - titleColor = - if (enabled) { - onBackgroundColor - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - onClick = { - onAddLocationToList(item, it) - closeBottomSheet(true) - }, - background = backgroundColor, - enabled = enabled, - ) - } - IconCell( - imageVector = Icons.Default.Add, - title = stringResource(id = R.string.new_list), - titleColor = onBackgroundColor, - onClick = { - onCreateCustomList(item) - closeBottomSheet(true) - }, - background = backgroundColor, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun EditCustomListBottomSheet( - backgroundColor: Color, - onBackgroundColor: Color, - sheetState: SheetState, - customList: RelayItem.CustomList, - onEditName: (item: RelayItem.CustomList) -> Unit, - onEditLocations: (item: RelayItem.CustomList) -> Unit, - onDeleteCustomList: (item: RelayItem.CustomList) -> Unit, - closeBottomSheet: (animate: Boolean) -> Unit, -) { - MullvadModalBottomSheet( - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - sheetState = sheetState, - onDismissRequest = { closeBottomSheet(false) }, - ) { - HeaderCell(text = customList.name, background = backgroundColor) - HorizontalDivider(color = onBackgroundColor) - IconCell( - imageVector = Icons.Default.Edit, - title = stringResource(id = R.string.edit_name), - titleColor = onBackgroundColor, - onClick = { - onEditName(customList) - closeBottomSheet(true) - }, - background = backgroundColor, - ) - IconCell( - imageVector = Icons.Default.Add, - title = stringResource(id = R.string.edit_locations), - titleColor = onBackgroundColor, - onClick = { - onEditLocations(customList) - closeBottomSheet(true) - }, - background = backgroundColor, - ) - IconCell( - imageVector = Icons.Default.Delete, - title = stringResource(id = R.string.delete), - titleColor = onBackgroundColor, - onClick = { - onDeleteCustomList(customList) - closeBottomSheet(true) - }, - background = backgroundColor, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CustomListEntryBottomSheet( - backgroundColor: Color, - onBackgroundColor: Color, - sheetState: SheetState, - customListId: CustomListId, - customListName: CustomListName, - item: RelayItem.Location, - onRemoveLocationFromList: (location: RelayItem.Location, customListId: CustomListId) -> Unit, - closeBottomSheet: (animate: Boolean) -> Unit, -) { - MullvadModalBottomSheet( - sheetState = sheetState, - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - onDismissRequest = { closeBottomSheet(false) }, - modifier = Modifier.testTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG), - ) { - HeaderCell( - text = - stringResource(id = R.string.remove_location_from_list, item.name, customListName), - background = backgroundColor, - ) - HorizontalDivider(color = onBackgroundColor) - - IconCell( - imageVector = Icons.Default.Remove, - title = stringResource(id = R.string.remove_button), - titleColor = onBackgroundColor, - onClick = { - onRemoveLocationFromList(item, customListId) - closeBottomSheet(true) - }, - background = backgroundColor, - ) - } -} - -private suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) { - val itemInfo = this.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } - if (itemInfo != null) { - val center = layoutInfo.viewportEndOffset / 2 - val childCenter = itemInfo.offset + itemInfo.size / 2 - animateScrollBy((childCenter - center).toFloat()) - } else { - animateScrollToItem(index) - } -} - -private suspend fun SnackbarHostState.showResultSnackbar( - context: Context, - result: CustomListActionResultData, - onUndo: (CustomListAction) -> Unit, -) { - - showSnackbarImmediately( - message = result.message(context), - actionLabel = - if (result is CustomListActionResultData.Success) context.getString(R.string.undo) - else { - null - }, - duration = SnackbarDuration.Long, - onAction = { - if (result is CustomListActionResultData.Success) { - onUndo(result.undo) - } - }, - ) -} - -private fun CustomListActionResultData.message(context: Context): String = - when (this) { - is CustomListActionResultData.Success.CreatedWithLocations -> - if (locationNames.size == 1) { - context.getString( - R.string.location_was_added_to_list, - locationNames.first(), - customListName, - ) - } else { - context.getString(R.string.create_custom_list_message, customListName) - } - is CustomListActionResultData.Success.Deleted -> - context.getString(R.string.delete_custom_list_message, customListName) - is CustomListActionResultData.Success.LocationAdded -> - context.getString(R.string.location_was_added_to_list, locationName, customListName) - is CustomListActionResultData.Success.LocationRemoved -> - context.getString(R.string.location_was_removed_from_list, locationName, customListName) - is CustomListActionResultData.Success.LocationChanged -> - context.getString(R.string.locations_were_changed_for, customListName) - is CustomListActionResultData.Success.Renamed -> - context.getString(R.string.name_was_changed_to, newName) - CustomListActionResultData.GenericError -> context.getString(R.string.error_occurred) - } - -@Composable -private fun <D : DestinationSpec, R : CustomListActionResultData> ResultRecipient<D, R> - .OnCustomListNavResult( - snackbarHostState: SnackbarHostState, - performAction: (action: CustomListAction) -> Unit, -) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - this.onNavResult { result -> - when (result) { - NavResult.Canceled -> { - /* Do nothing */ - } - is NavResult.Value -> { - // Handle result - scope.launch { - snackbarHostState.showResultSnackbar( - context = context, - result = result.value, - onUndo = performAction, - ) - } - } - } - } -} - -sealed interface BottomSheetState { - - data class ShowCustomListsBottomSheet(val editListEnabled: Boolean) : BottomSheetState - - data class ShowCustomListsEntryBottomSheet( - val customListId: CustomListId, - val customListName: CustomListName, - val item: RelayItem.Location, - ) : BottomSheetState - - data class ShowLocationBottomSheet( - val customLists: List<RelayItem.CustomList>, - val item: RelayItem.Location, - ) : BottomSheetState - - data class ShowEditCustomListBottomSheet(val customList: RelayItem.CustomList) : - BottomSheetState -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt index 27beeeca4e..b8c418cd06 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt @@ -26,6 +26,7 @@ import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.destinations.ApiAccessListDestination import com.ramcosta.composedestinations.generated.destinations.AppInfoDestination +import com.ramcosta.composedestinations.generated.destinations.MultihopDestination import com.ramcosta.composedestinations.generated.destinations.ReportProblemDestination import com.ramcosta.composedestinations.generated.destinations.SplitTunnelingDestination import com.ramcosta.composedestinations.generated.destinations.VpnSettingsDestination @@ -49,7 +50,7 @@ import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) -@Preview("Supported|Unsupported") +@Preview("Supported|+") @Composable private fun PreviewSettingsScreen( @PreviewParameter(SettingsUiStatePreviewParameterProvider::class) state: SettingsUiState @@ -72,6 +73,7 @@ fun Settings(navigator: DestinationsNavigator) { onApiAccessClick = dropUnlessResumed { navigator.navigate(ApiAccessListDestination) }, onReportProblemCellClick = dropUnlessResumed { navigator.navigate(ReportProblemDestination) }, + onMultihopClick = dropUnlessResumed { navigator.navigate(MultihopDestination) }, onBackClick = dropUnlessResumed { navigator.navigateUp() }, ) } @@ -85,6 +87,7 @@ fun SettingsScreen( onAppInfoClick: () -> Unit = {}, onReportProblemCellClick: () -> Unit = {}, onApiAccessClick: () -> Unit = {}, + onMultihopClick: () -> Unit = {}, onBackClick: () -> Unit = {}, ) { ScaffoldWithMediumTopBar( @@ -96,8 +99,13 @@ fun SettingsScreen( state = lazyListState, ) { if (state.isLoggedIn) { - item { Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) } - item { + itemWithDivider { + MultihopCell( + isMultihopEnabled = state.multihopEnabled, + onMultihopClick = onMultihopClick, + ) + } + itemWithDivider { NavigationComposeCell( title = stringResource(id = R.string.settings_vpn), onClick = onVpnSettingCellClick, @@ -181,13 +189,12 @@ private fun FaqAndGuides() { NavigationComposeCell( title = faqGuideLabel, - bodyView = - @Composable { - DefaultExternalLinkView( - chevronContentDescription = faqGuideLabel, - tint = MaterialTheme.colorScheme.onPrimary, - ) - }, + bodyView = { + DefaultExternalLinkView( + chevronContentDescription = faqGuideLabel, + tint = MaterialTheme.colorScheme.onPrimary, + ) + }, onClick = openFaqAndGuides, ) } @@ -203,13 +210,29 @@ private fun PrivacyPolicy(state: SettingsUiState) { NavigationComposeCell( title = privacyPolicyLabel, - bodyView = - @Composable { - DefaultExternalLinkView( - chevronContentDescription = privacyPolicyLabel, - tint = MaterialTheme.colorScheme.onPrimary, - ) - }, + bodyView = { + DefaultExternalLinkView( + chevronContentDescription = privacyPolicyLabel, + tint = MaterialTheme.colorScheme.onPrimary, + ) + }, onClick = openPrivacyPolicy, ) } + +@Composable +private fun MultihopCell(isMultihopEnabled: Boolean, onMultihopClick: () -> Unit) { + val title = stringResource(id = R.string.multihop) + TwoRowCell( + titleText = title, + subtitleText = + stringResource( + if (isMultihopEnabled) { + R.string.on + } else { + R.string.off + } + ), + onCellClicked = onMultihopClick, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/LocationBottomSheet.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/LocationBottomSheet.kt new file mode 100644 index 0000000000..7df4987d03 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/LocationBottomSheet.kt @@ -0,0 +1,426 @@ +package net.mullvad.mullvadvpn.compose.screen.location + +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultRecipient +import com.ramcosta.composedestinations.spec.DestinationSpec +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.HeaderCell +import net.mullvad.mullvadvpn.compose.cell.IconCell +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData +import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomSheet +import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowCustomListsBottomSheet +import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowCustomListsEntryBottomSheet +import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowEditCustomListBottomSheet +import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowLocationBottomSheet +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG +import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive +import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible +import net.mullvad.mullvadvpn.relaylist.canAddLocation + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun LocationBottomSheets( + locationBottomSheetState: LocationBottomSheetState?, + onCreateCustomList: (RelayItem.Location?) -> Unit, + onEditCustomLists: () -> Unit, + onAddLocationToList: (RelayItem.Location, RelayItem.CustomList) -> Unit, + onRemoveLocationFromList: (location: RelayItem.Location, parent: CustomListId) -> Unit, + onEditCustomListName: (RelayItem.CustomList) -> Unit, + onEditLocationsCustomList: (RelayItem.CustomList) -> Unit, + onDeleteCustomList: (RelayItem.CustomList) -> Unit, + onHideBottomSheet: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + val onCloseBottomSheet: (animate: Boolean) -> Unit = { animate -> + if (animate) { + scope.launch { sheetState.hide() }.invokeOnCompletion { onHideBottomSheet() } + } else { + onHideBottomSheet() + } + } + val backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer + val onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface + + when (locationBottomSheetState) { + is ShowCustomListsBottomSheet -> { + CustomListsBottomSheet( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + sheetState = sheetState, + bottomSheetState = locationBottomSheetState, + onCreateCustomList = { onCreateCustomList(null) }, + onEditCustomLists = onEditCustomLists, + closeBottomSheet = onCloseBottomSheet, + ) + } + is ShowLocationBottomSheet -> { + LocationBottomSheet( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + sheetState = sheetState, + customLists = locationBottomSheetState.customLists, + item = locationBottomSheetState.item, + onCreateCustomList = onCreateCustomList, + onAddLocationToList = onAddLocationToList, + closeBottomSheet = onCloseBottomSheet, + ) + } + is ShowEditCustomListBottomSheet -> { + EditCustomListBottomSheet( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + sheetState = sheetState, + customList = locationBottomSheetState.customList, + onEditName = onEditCustomListName, + onEditLocations = onEditLocationsCustomList, + onDeleteCustomList = onDeleteCustomList, + closeBottomSheet = onCloseBottomSheet, + ) + } + is ShowCustomListsEntryBottomSheet -> { + CustomListEntryBottomSheet( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + sheetState = sheetState, + customListId = locationBottomSheetState.customListId, + customListName = locationBottomSheetState.customListName, + item = locationBottomSheetState.item, + onRemoveLocationFromList = onRemoveLocationFromList, + closeBottomSheet = onCloseBottomSheet, + ) + } + null -> { + /* Do nothing */ + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CustomListsBottomSheet( + backgroundColor: Color, + onBackgroundColor: Color, + sheetState: SheetState, + bottomSheetState: ShowCustomListsBottomSheet, + onCreateCustomList: () -> Unit, + onEditCustomLists: () -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit, +) { + + MullvadModalBottomSheet( + sheetState = sheetState, + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + onDismissRequest = { closeBottomSheet(false) }, + modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG), + ) { + HeaderCell( + text = stringResource(id = R.string.edit_custom_lists), + background = backgroundColor, + ) + HorizontalDivider(color = onBackgroundColor) + IconCell( + imageVector = Icons.Default.Add, + title = stringResource(id = R.string.new_list), + titleColor = onBackgroundColor, + onClick = { + onCreateCustomList() + closeBottomSheet(true) + }, + background = backgroundColor, + ) + IconCell( + imageVector = Icons.Default.Edit, + title = stringResource(id = R.string.edit_lists), + titleColor = + onBackgroundColor.copy( + alpha = + if (bottomSheetState.editListEnabled) { + AlphaVisible + } else { + AlphaInactive + } + ), + onClick = { + onEditCustomLists() + closeBottomSheet(true) + }, + background = backgroundColor, + enabled = bottomSheetState.editListEnabled, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LocationBottomSheet( + backgroundColor: Color, + onBackgroundColor: Color, + sheetState: SheetState, + customLists: List<RelayItem.CustomList>, + item: RelayItem.Location, + onCreateCustomList: (relayItem: RelayItem.Location) -> Unit, + onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit, +) { + MullvadModalBottomSheet( + sheetState = sheetState, + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + onDismissRequest = { closeBottomSheet(false) }, + modifier = Modifier.testTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG), + ) { -> + HeaderCell( + text = stringResource(id = R.string.add_location_to_list, item.name), + background = backgroundColor, + ) + HorizontalDivider(color = onBackgroundColor) + customLists.forEach { + val enabled = it.canAddLocation(item) + IconCell( + imageVector = null, + title = + if (enabled) { + it.name + } else { + stringResource(id = R.string.location_added, it.name) + }, + titleColor = + if (enabled) { + onBackgroundColor + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + onClick = { + onAddLocationToList(item, it) + closeBottomSheet(true) + }, + background = backgroundColor, + enabled = enabled, + ) + } + IconCell( + imageVector = Icons.Default.Add, + title = stringResource(id = R.string.new_list), + titleColor = onBackgroundColor, + onClick = { + onCreateCustomList(item) + closeBottomSheet(true) + }, + background = backgroundColor, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EditCustomListBottomSheet( + backgroundColor: Color, + onBackgroundColor: Color, + sheetState: SheetState, + customList: RelayItem.CustomList, + onEditName: (item: RelayItem.CustomList) -> Unit, + onEditLocations: (item: RelayItem.CustomList) -> Unit, + onDeleteCustomList: (item: RelayItem.CustomList) -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit, +) { + MullvadModalBottomSheet( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + sheetState = sheetState, + onDismissRequest = { closeBottomSheet(false) }, + ) { + HeaderCell(text = customList.name, background = backgroundColor) + HorizontalDivider(color = onBackgroundColor) + IconCell( + imageVector = Icons.Default.Edit, + title = stringResource(id = R.string.edit_name), + titleColor = onBackgroundColor, + onClick = { + onEditName(customList) + closeBottomSheet(true) + }, + background = backgroundColor, + ) + IconCell( + imageVector = Icons.Default.Add, + title = stringResource(id = R.string.edit_locations), + titleColor = onBackgroundColor, + onClick = { + onEditLocations(customList) + closeBottomSheet(true) + }, + background = backgroundColor, + ) + IconCell( + imageVector = Icons.Default.Delete, + title = stringResource(id = R.string.delete), + titleColor = onBackgroundColor, + onClick = { + onDeleteCustomList(customList) + closeBottomSheet(true) + }, + background = backgroundColor, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CustomListEntryBottomSheet( + backgroundColor: Color, + onBackgroundColor: Color, + sheetState: SheetState, + customListId: CustomListId, + customListName: CustomListName, + item: RelayItem.Location, + onRemoveLocationFromList: (location: RelayItem.Location, customListId: CustomListId) -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit, +) { + MullvadModalBottomSheet( + sheetState = sheetState, + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + onDismissRequest = { closeBottomSheet(false) }, + modifier = Modifier.testTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG), + ) { + HeaderCell( + text = + stringResource(id = R.string.remove_location_from_list, item.name, customListName), + background = backgroundColor, + ) + HorizontalDivider(color = onBackgroundColor) + + IconCell( + imageVector = Icons.Default.Remove, + title = stringResource(id = R.string.remove_button), + titleColor = onBackgroundColor, + onClick = { + onRemoveLocationFromList(item, customListId) + closeBottomSheet(true) + }, + background = backgroundColor, + ) + } +} + +internal suspend fun SnackbarHostState.showResultSnackbar( + context: Context, + result: CustomListActionResultData, + onUndo: (CustomListAction) -> Unit, +) { + + showSnackbarImmediately( + message = result.message(context), + actionLabel = + if (result is CustomListActionResultData.Success) context.getString(R.string.undo) + else { + null + }, + duration = SnackbarDuration.Long, + onAction = { + if (result is CustomListActionResultData.Success) { + onUndo(result.undo) + } + }, + ) +} + +private fun CustomListActionResultData.message(context: Context): String = + when (this) { + is CustomListActionResultData.Success.CreatedWithLocations -> + if (locationNames.size == 1) { + context.getString( + R.string.location_was_added_to_list, + locationNames.first(), + customListName, + ) + } else { + context.getString(R.string.create_custom_list_message, customListName) + } + is CustomListActionResultData.Success.Deleted -> + context.getString(R.string.delete_custom_list_message, customListName) + is CustomListActionResultData.Success.LocationAdded -> + context.getString(R.string.location_was_added_to_list, locationName, customListName) + is CustomListActionResultData.Success.LocationRemoved -> + context.getString(R.string.location_was_removed_from_list, locationName, customListName) + is CustomListActionResultData.Success.LocationChanged -> + context.getString(R.string.locations_were_changed_for, customListName) + is CustomListActionResultData.Success.Renamed -> + context.getString(R.string.name_was_changed_to, newName) + CustomListActionResultData.GenericError -> context.getString(R.string.error_occurred) + } + +@Composable +internal fun <D : DestinationSpec, R : CustomListActionResultData> ResultRecipient<D, R> + .OnCustomListNavResult( + snackbarHostState: SnackbarHostState, + performAction: (action: CustomListAction) -> Unit, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + this.onNavResult { result -> + when (result) { + NavResult.Canceled -> { + /* Do nothing */ + } + is NavResult.Value -> { + // Handle result + scope.launch { + snackbarHostState.showResultSnackbar( + context = context, + result = result.value, + onUndo = performAction, + ) + } + } + } + } +} + +sealed interface LocationBottomSheetState { + + data class ShowCustomListsBottomSheet(val editListEnabled: Boolean) : LocationBottomSheetState + + data class ShowCustomListsEntryBottomSheet( + val customListId: CustomListId, + val customListName: CustomListName, + val item: RelayItem.Location, + ) : LocationBottomSheetState + + data class ShowLocationBottomSheet( + val customLists: List<RelayItem.CustomList>, + val item: RelayItem.Location, + ) : LocationBottomSheetState + + data class ShowEditCustomListBottomSheet(val customList: RelayItem.CustomList) : + LocationBottomSheetState +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/RelayListContent.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/RelayListContent.kt new file mode 100644 index 0000000000..62eeb38892 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/RelayListContent.kt @@ -0,0 +1,196 @@ +package net.mullvad.mullvadvpn.compose.screen.location + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.HeaderCell +import net.mullvad.mullvadvpn.compose.cell.StatusRelayItemCell +import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell +import net.mullvad.mullvadvpn.compose.cell.ThreeDotCell +import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText +import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowCustomListsBottomSheet +import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowCustomListsEntryBottomSheet +import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowEditCustomListBottomSheet +import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowLocationBottomSheet +import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.test.LOCATION_CELL_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId + +/** Used by both the select location screen and search select location screen */ +fun LazyListScope.relayListContent( + backgroundColor: Color, + relayListItems: List<RelayListItem>, + customLists: List<RelayItem.CustomList>, + onSelectRelay: (RelayItem) -> Unit, + onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit, + onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, + customListHeader: @Composable LazyItemScope.() -> Unit = { + CustomListHeader( + onShowCustomListBottomSheet = { + onUpdateBottomSheetState( + ShowCustomListsBottomSheet(editListEnabled = customLists.isNotEmpty()) + ) + } + ) + }, + locationHeader: @Composable LazyItemScope.() -> Unit = { RelayLocationHeader() }, +) { + itemsIndexed( + items = relayListItems, + key = { _: Int, item: RelayListItem -> item.key }, + contentType = { _, item -> item.contentType }, + itemContent = { index: Int, listItem: RelayListItem -> + Column(modifier = Modifier.animateItem()) { + if (index != 0) { + HorizontalDivider(color = backgroundColor) + } + when (listItem) { + RelayListItem.CustomListHeader -> customListHeader() + is RelayListItem.CustomListItem -> + CustomListItem( + listItem, + onSelectRelay, + { onUpdateBottomSheetState(ShowEditCustomListBottomSheet(it)) }, + { customListId, expand -> onToggleExpand(customListId, null, expand) }, + ) + is RelayListItem.CustomListEntryItem -> + CustomListEntryItem( + listItem, + { onSelectRelay(listItem.item) }, + // Only direct children can be removed + if (listItem.depth == 1) { + { + onUpdateBottomSheetState( + ShowCustomListsEntryBottomSheet( + listItem.parentId, + listItem.parentName, + listItem.item, + ) + ) + } + } else { + null + }, + { expand: Boolean -> + onToggleExpand(listItem.item.id, listItem.parentId, expand) + }, + ) + is RelayListItem.CustomListFooter -> CustomListFooter(listItem) + RelayListItem.LocationHeader -> locationHeader() + is RelayListItem.GeoLocationItem -> + RelayLocationItem( + listItem, + { onSelectRelay(listItem.item) }, + { + onUpdateBottomSheetState( + ShowLocationBottomSheet(customLists, listItem.item) + ) + }, + { expand -> onToggleExpand(listItem.item.id, null, expand) }, + ) + is RelayListItem.LocationsEmptyText -> LocationsEmptyText(listItem.searchTerm) + } + } + }, + ) +} + +@Composable +private fun LazyItemScope.RelayLocationItem( + relayItem: RelayListItem.GeoLocationItem, + onSelectRelay: () -> Unit, + onLongClick: () -> Unit, + onExpand: (Boolean) -> Unit, +) { + val location = relayItem.item + StatusRelayItemCell( + item = location, + state = relayItem.state, + isSelected = relayItem.isSelected, + onClick = { onSelectRelay() }, + onLongClick = { onLongClick() }, + onToggleExpand = { onExpand(it) }, + isExpanded = relayItem.expanded, + depth = relayItem.depth, + modifier = Modifier.testTag(LOCATION_CELL_TEST_TAG), + ) +} + +@Composable +private fun LazyItemScope.CustomListEntryItem( + itemState: RelayListItem.CustomListEntryItem, + onSelectRelay: () -> Unit, + onShowEditCustomListEntryBottomSheet: (() -> Unit)?, + onToggleExpand: (Boolean) -> Unit, +) { + val customListEntryItem = itemState.item + StatusRelayItemCell( + item = customListEntryItem, + state = itemState.state, + isSelected = false, + onClick = onSelectRelay, + onLongClick = onShowEditCustomListEntryBottomSheet, + onToggleExpand = onToggleExpand, + isExpanded = itemState.expanded, + depth = itemState.depth, + ) +} + +@Composable +private fun LazyItemScope.CustomListItem( + itemState: RelayListItem.CustomListItem, + onSelectRelay: (item: RelayItem) -> Unit, + onShowEditBottomSheet: (RelayItem.CustomList) -> Unit, + onExpand: ((CustomListId, Boolean) -> Unit), +) { + val customListItem = itemState.item + StatusRelayItemCell( + item = customListItem, + state = itemState.state, + isSelected = itemState.isSelected, + onClick = { onSelectRelay(customListItem) }, + onLongClick = { onShowEditBottomSheet(customListItem) }, + onToggleExpand = { onExpand(customListItem.id, it) }, + isExpanded = itemState.expanded, + ) +} + +@Composable +private fun LazyItemScope.CustomListHeader(onShowCustomListBottomSheet: () -> Unit) { + ThreeDotCell( + text = stringResource(R.string.custom_lists), + onClickDots = onShowCustomListBottomSheet, + modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG), + ) +} + +@Composable +private fun LazyItemScope.CustomListFooter(item: RelayListItem.CustomListFooter) { + SwitchComposeSubtitleCell( + text = + if (item.hasCustomList) { + stringResource(R.string.to_add_locations_to_a_list) + } else { + stringResource(R.string.to_create_a_custom_list) + }, + modifier = Modifier.background(MaterialTheme.colorScheme.surface), + ) +} + +@Composable +private fun LazyItemScope.RelayLocationHeader() { + HeaderCell(text = stringResource(R.string.all_locations)) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreen.kt new file mode 100644 index 0000000000..fc810e6882 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreen.kt @@ -0,0 +1,401 @@ +package net.mullvad.mullvadvpn.compose.screen.location + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.CreateCustomListDestination +import com.ramcosta.composedestinations.generated.destinations.CustomListLocationsDestination +import com.ramcosta.composedestinations.generated.destinations.CustomListsDestination +import com.ramcosta.composedestinations.generated.destinations.DeleteCustomListDestination +import com.ramcosta.composedestinations.generated.destinations.EditCustomListNameDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.result.ResultRecipient +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.FilterRow +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData +import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar +import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.constant.ContentType +import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed +import net.mullvad.mullvadvpn.compose.preview.SearchLocationsUiStatePreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SearchLocationUiState +import net.mullvad.mullvadvpn.compose.transitions.TopLevelTransition +import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle +import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar +import net.mullvad.mullvadvpn.usecase.FilterChip +import net.mullvad.mullvadvpn.viewmodel.location.SearchLocationSideEffect +import net.mullvad.mullvadvpn.viewmodel.location.SearchLocationViewModel +import org.koin.androidx.compose.koinViewModel + +@Preview("Default|Not found|Results") +@Composable +private fun PreviewSearchLocationScreen( + @PreviewParameter(SearchLocationsUiStatePreviewParameterProvider::class) + state: SearchLocationUiState +) { + AppTheme { SearchLocationScreen(state = state) } +} + +data class SearchLocationNavArgs(val relayListType: RelayListType) + +@Suppress("LongMethod") +@Composable +@Destination<RootGraph>(style = TopLevelTransition::class, navArgs = SearchLocationNavArgs::class) +fun SearchLocation( + navigator: DestinationsNavigator, + backNavigator: ResultBackNavigator<RelayListType>, + createCustomListDialogResultRecipient: + ResultRecipient< + CreateCustomListDestination, + CustomListActionResultData.Success.CreatedWithLocations, + >, + editCustomListNameDialogResultRecipient: + ResultRecipient<EditCustomListNameDestination, CustomListActionResultData.Success.Renamed>, + deleteCustomListDialogResultRecipient: + ResultRecipient<DeleteCustomListDestination, CustomListActionResultData.Success.Deleted>, + updateCustomListResultRecipient: + ResultRecipient<CustomListLocationsDestination, CustomListActionResultData>, +) { + val viewModel = koinViewModel<SearchLocationViewModel>() + val state by viewModel.uiState.collectAsStateWithLifecycle() + + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + CollectSideEffectWithLifecycle(viewModel.uiSideEffect) { + when (it) { + is SearchLocationSideEffect.LocationSelected -> + backNavigator.navigateBack(result = it.relayListType) + is SearchLocationSideEffect.CustomListActionToast -> + launch { + snackbarHostState.showResultSnackbar( + context = context, + result = it.resultData, + onUndo = viewModel::performAction, + ) + } + SearchLocationSideEffect.GenericError -> + launch { + snackbarHostState.showSnackbarImmediately( + message = context.getString(R.string.error_occurred) + ) + } + } + } + + createCustomListDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + viewModel::performAction, + ) + + editCustomListNameDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + viewModel::performAction, + ) + + deleteCustomListDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + viewModel::performAction, + ) + + updateCustomListResultRecipient.OnCustomListNavResult( + snackbarHostState, + viewModel::performAction, + ) + + SearchLocationScreen( + state = state, + snackbarHostState = snackbarHostState, + onSelectRelay = viewModel::selectRelay, + onToggleExpand = viewModel::onToggleExpand, + onSearchInputChanged = viewModel::onSearchInputUpdated, + onCreateCustomList = + dropUnlessResumed { relayItem -> + navigator.navigate(CreateCustomListDestination(locationCode = relayItem?.id)) + }, + onEditCustomLists = dropUnlessResumed { navigator.navigate(CustomListsDestination()) }, + onAddLocationToList = viewModel::addLocationToList, + onRemoveLocationFromList = viewModel::removeLocationFromList, + onEditCustomListName = + dropUnlessResumed { customList: RelayItem.CustomList -> + navigator.navigate( + EditCustomListNameDestination( + customListId = customList.id, + initialName = customList.customList.name, + ) + ) + }, + onEditLocationsCustomList = + dropUnlessResumed { customList: RelayItem.CustomList -> + navigator.navigate( + CustomListLocationsDestination(customListId = customList.id, newList = false) + ) + }, + onDeleteCustomList = + dropUnlessResumed { customList: RelayItem.CustomList -> + navigator.navigate( + DeleteCustomListDestination( + customListId = customList.id, + name = customList.customList.name, + ) + ) + }, + onRemoveOwnershipFilter = viewModel::removeOwnerFilter, + onRemoveProviderFilter = viewModel::removeProviderFilter, + onGoBack = dropUnlessResumed { navigator.navigateUp() }, + ) +} + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchLocationScreen( + state: SearchLocationUiState, + snackbarHostState: SnackbarHostState = SnackbarHostState(), + onSelectRelay: (RelayItem) -> Unit = {}, + onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit = { _, _, _ -> }, + onSearchInputChanged: (String) -> Unit = {}, + onCreateCustomList: (location: RelayItem.Location?) -> Unit = {}, + onEditCustomLists: () -> Unit = {}, + onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit = + { _, _ -> + }, + onRemoveLocationFromList: (location: RelayItem.Location, customListId: CustomListId) -> Unit = + { _, _ -> + }, + onEditCustomListName: (RelayItem.CustomList) -> Unit = {}, + onEditLocationsCustomList: (RelayItem.CustomList) -> Unit = {}, + onDeleteCustomList: (RelayItem.CustomList) -> Unit = {}, + onRemoveOwnershipFilter: () -> Unit = {}, + onRemoveProviderFilter: () -> Unit = {}, + onGoBack: () -> Unit = {}, +) { + val backgroundColor = MaterialTheme.colorScheme.surface + val onBackgroundColor = MaterialTheme.colorScheme.onSurface + val keyboardController = LocalSoftwareKeyboardController.current + Scaffold( + snackbarHost = { + SnackbarHost( + snackbarHostState, + snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) }, + ) + } + ) { + var locationBottomSheetState by remember { mutableStateOf<LocationBottomSheetState?>(null) } + LocationBottomSheets( + locationBottomSheetState = locationBottomSheetState, + onCreateCustomList = onCreateCustomList, + onEditCustomLists = onEditCustomLists, + onAddLocationToList = onAddLocationToList, + onRemoveLocationFromList = onRemoveLocationFromList, + onEditCustomListName = onEditCustomListName, + onEditLocationsCustomList = onEditLocationsCustomList, + onDeleteCustomList = onDeleteCustomList, + onHideBottomSheet = { locationBottomSheetState = null }, + ) + Column(modifier = Modifier.padding(it)) { + SearchBar( + searchTerm = state.searchTerm, + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + onSearchInputChanged = onSearchInputChanged, + hideKeyboard = { keyboardController?.hide() }, + onGoBack = onGoBack, + ) + HorizontalDivider(color = onBackgroundColor) + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = + Modifier.fillMaxSize() + .background(color = backgroundColor) + .drawVerticalScrollbar( + lazyListState, + MaterialTheme.colorScheme.onSurface.copy(alpha = AlphaScrollbar), + ), + state = lazyListState, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + filterRow( + filters = state.filterChips, + onBackgroundColor = onBackgroundColor, + onRemoveOwnershipFilter = onRemoveOwnershipFilter, + onRemoveProviderFilter = onRemoveProviderFilter, + ) + when (state) { + is SearchLocationUiState.NoQuery -> { + noQuery() + } + is SearchLocationUiState.Content -> { + relayListContent( + backgroundColor = backgroundColor, + customLists = state.customLists, + relayListItems = state.relayListItems, + onSelectRelay = onSelectRelay, + onToggleExpand = onToggleExpand, + onUpdateBottomSheetState = { newSheetState -> + locationBottomSheetState = newSheetState + }, + customListHeader = { + Title( + text = stringResource(R.string.custom_lists), + onBackgroundColor = onBackgroundColor, + ) + }, + locationHeader = { + Title( + text = stringResource(R.string.locations), + onBackgroundColor = onBackgroundColor, + ) + }, + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchBar( + searchTerm: String, + backgroundColor: Color, + onBackgroundColor: Color, + onSearchInputChanged: (String) -> Unit, + hideKeyboard: () -> Unit, + onGoBack: () -> Unit, +) { + SearchBarDefaults.InputField( + modifier = Modifier.height(Dimens.searchFieldHeightExpanded).fillMaxWidth(), + query = searchTerm, + onQueryChange = onSearchInputChanged, + onSearch = { hideKeyboard() }, + expanded = true, + onExpandedChange = {}, + leadingIcon = { + IconButton(onClick = onGoBack) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + trailingIcon = { + if (searchTerm.isNotEmpty()) { + IconButton(onClick = { onSearchInputChanged("") }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.clear_input), + ) + } + } + }, + placeholder = { Text(text = stringResource(id = R.string.search_placeholder)) }, + colors = + TextFieldDefaults.colors( + focusedContainerColor = backgroundColor, + unfocusedContainerColor = backgroundColor, + focusedPlaceholderColor = onBackgroundColor, + unfocusedPlaceholderColor = onBackgroundColor, + focusedTextColor = onBackgroundColor, + unfocusedTextColor = onBackgroundColor, + cursorColor = onBackgroundColor, + focusedLeadingIconColor = onBackgroundColor, + unfocusedLeadingIconColor = onBackgroundColor, + focusedTrailingIconColor = onBackgroundColor, + unfocusedTrailingIconColor = onBackgroundColor, + ), + ) +} + +private fun LazyListScope.noQuery() { + item(contentType = ContentType.DESCRIPTION) { + Text( + text = stringResource(R.string.search_query_empty), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(Dimens.mediumPadding), + ) + } +} + +private fun LazyListScope.filterRow( + filters: List<FilterChip>, + onBackgroundColor: Color, + onRemoveOwnershipFilter: () -> Unit, + onRemoveProviderFilter: () -> Unit, +) { + if (filters.isNotEmpty()) { + item { + Title(text = stringResource(R.string.filters), onBackgroundColor = onBackgroundColor) + } + item { + FilterRow( + filters = filters, + showTitle = false, + onRemoveOwnershipFilter = onRemoveOwnershipFilter, + onRemoveProviderFilter = onRemoveProviderFilter, + ) + } + } +} + +@Composable +private fun Title(text: String, onBackgroundColor: Color) { + Text( + text = text, + color = onBackgroundColor, + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = Dimens.sideMargin, vertical = Dimens.smallPadding), + style = MaterialTheme.typography.labelMedium, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt new file mode 100644 index 0000000000..8f07ab180e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt @@ -0,0 +1,114 @@ +package net.mullvad.mullvadvpn.compose.screen.location + +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge +import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.constant.ContentType +import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState +import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR +import net.mullvad.mullvadvpn.compose.util.RunOnKeyChange +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar +import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationListViewModel +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun SelectLocationList( + backgroundColor: Color, + relayListType: RelayListType, + onSelectRelay: (RelayItem) -> Unit, + onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, +) { + val viewModel = + koinViewModel<SelectLocationListViewModel>( + key = relayListType.name, + parameters = { parametersOf(relayListType) }, + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + val lazyListState = rememberLazyListState() + val stateActual = state + RunOnKeyChange(stateActual is SelectLocationListUiState.Content) { + stateActual.indexOfSelectedRelayItem()?.let { index -> + lazyListState.scrollToItem(index) + lazyListState.animateScrollAndCentralizeItem(index) + } + } + LazyColumn( + modifier = + Modifier.fillMaxSize() + .drawVerticalScrollbar( + lazyListState, + MaterialTheme.colorScheme.onSurface.copy(alpha = AlphaScrollbar), + ), + state = lazyListState, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (stateActual) { + SelectLocationListUiState.Loading -> { + loading() + } + is SelectLocationListUiState.Content -> { + relayListContent( + backgroundColor = backgroundColor, + relayListItems = stateActual.relayListItems, + customLists = stateActual.customLists, + onSelectRelay = onSelectRelay, + onToggleExpand = viewModel::onToggleExpand, + onUpdateBottomSheetState = onUpdateBottomSheetState, + ) + } + } + } +} + +private fun LazyListScope.loading() { + item(contentType = ContentType.PROGRESS) { + MullvadCircularProgressIndicatorLarge(Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR)) + } +} + +private fun SelectLocationListUiState.indexOfSelectedRelayItem(): Int? = + if (this is SelectLocationListUiState.Content) { + val index = + relayListItems.indexOfFirst { + when (it) { + is RelayListItem.CustomListItem -> it.isSelected + is RelayListItem.GeoLocationItem -> it.isSelected + is RelayListItem.CustomListEntryItem, + is RelayListItem.CustomListFooter, + RelayListItem.CustomListHeader, + RelayListItem.LocationHeader, + is RelayListItem.LocationsEmptyText -> false + } + } + if (index >= 0) index else null + } else { + null + } + +private suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) { + val itemInfo = this.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } + if (itemInfo != null) { + val center = layoutInfo.viewportEndOffset / 2 + val childCenter = itemInfo.offset + itemInfo.size / 2 + animateScrollBy((childCenter - center).toFloat()) + } else { + animateScrollToItem(index) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt new file mode 100644 index 0000000000..3e40d57090 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt @@ -0,0 +1,355 @@ +package net.mullvad.mullvadvpn.compose.screen.location + +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.CreateCustomListDestination +import com.ramcosta.composedestinations.generated.destinations.CustomListLocationsDestination +import com.ramcosta.composedestinations.generated.destinations.CustomListsDestination +import com.ramcosta.composedestinations.generated.destinations.DeleteCustomListDestination +import com.ramcosta.composedestinations.generated.destinations.EditCustomListNameDestination +import com.ramcosta.composedestinations.generated.destinations.FilterDestination +import com.ramcosta.composedestinations.generated.destinations.SearchLocationDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.result.ResultRecipient +import com.ramcosta.composedestinations.result.onResult +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.MullvadSegmentedEndButton +import net.mullvad.mullvadvpn.compose.button.MullvadSegmentedStartButton +import net.mullvad.mullvadvpn.compose.cell.FilterRow +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithSmallTopBar +import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed +import net.mullvad.mullvadvpn.compose.preview.SelectLocationsUiStatePreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState +import net.mullvad.mullvadvpn.compose.transitions.TopLevelTransition +import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle +import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationSideEffect +import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationViewModel +import org.koin.androidx.compose.koinViewModel + +@Preview("Default|Filters|Multihop|Multihop and Filters") +@Composable +private fun PreviewSelectLocationScreen( + @PreviewParameter(SelectLocationsUiStatePreviewParameterProvider::class) + state: SelectLocationUiState +) { + AppTheme { SelectLocationScreen(state = state) } +} + +@SuppressLint("CheckResult") +@Destination<RootGraph>(style = TopLevelTransition::class) +@Suppress("LongMethod") +@Composable +fun SelectLocation( + navigator: DestinationsNavigator, + backNavigator: ResultBackNavigator<Boolean>, + createCustomListDialogResultRecipient: + ResultRecipient< + CreateCustomListDestination, + CustomListActionResultData.Success.CreatedWithLocations, + >, + editCustomListNameDialogResultRecipient: + ResultRecipient<EditCustomListNameDestination, CustomListActionResultData.Success.Renamed>, + deleteCustomListDialogResultRecipient: + ResultRecipient<DeleteCustomListDestination, CustomListActionResultData.Success.Deleted>, + updateCustomListResultRecipient: + ResultRecipient<CustomListLocationsDestination, CustomListActionResultData>, + searchSelectedLocationResultRecipient: ResultRecipient<SearchLocationDestination, RelayListType>, +) { + val vm = koinViewModel<SelectLocationViewModel>() + val state = vm.uiState.collectAsStateWithLifecycle() + + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + CollectSideEffectWithLifecycle(vm.uiSideEffect) { + when (it) { + SelectLocationSideEffect.CloseScreen -> backNavigator.navigateBack(result = true) + is SelectLocationSideEffect.CustomListActionToast -> + launch { + snackbarHostState.showResultSnackbar( + context = context, + result = it.resultData, + onUndo = vm::performAction, + ) + } + SelectLocationSideEffect.GenericError -> + launch { + snackbarHostState.showSnackbarImmediately( + message = context.getString(R.string.error_occurred) + ) + } + } + } + + createCustomListDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + vm::performAction, + ) + + editCustomListNameDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + vm::performAction, + ) + + deleteCustomListDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + vm::performAction, + ) + + updateCustomListResultRecipient.OnCustomListNavResult(snackbarHostState, vm::performAction) + + searchSelectedLocationResultRecipient.onResult { result -> + when (result) { + RelayListType.ENTRY -> { + vm.selectRelayList(RelayListType.EXIT) + } + RelayListType.EXIT -> backNavigator.navigateBack(result = true) + } + } + + SelectLocationScreen( + state = state.value, + snackbarHostState = snackbarHostState, + onSelectRelay = vm::selectRelay, + onSearchClick = { navigator.navigate(SearchLocationDestination(it)) }, + onBackClick = dropUnlessResumed { backNavigator.navigateBack() }, + onFilterClick = dropUnlessResumed { navigator.navigate(FilterDestination) }, + onCreateCustomList = + dropUnlessResumed { relayItem -> + navigator.navigate(CreateCustomListDestination(locationCode = relayItem?.id)) + }, + onEditCustomLists = dropUnlessResumed { navigator.navigate(CustomListsDestination()) }, + removeOwnershipFilter = vm::removeOwnerFilter, + removeProviderFilter = vm::removeProviderFilter, + onAddLocationToList = vm::addLocationToList, + onRemoveLocationFromList = vm::removeLocationFromList, + onEditCustomListName = + dropUnlessResumed { customList: RelayItem.CustomList -> + navigator.navigate( + EditCustomListNameDestination( + customListId = customList.id, + initialName = customList.customList.name, + ) + ) + }, + onEditLocationsCustomList = + dropUnlessResumed { customList: RelayItem.CustomList -> + navigator.navigate( + CustomListLocationsDestination(customListId = customList.id, newList = false) + ) + }, + onDeleteCustomList = + dropUnlessResumed { customList: RelayItem.CustomList -> + navigator.navigate( + DeleteCustomListDestination( + customListId = customList.id, + name = customList.customList.name, + ) + ) + }, + onSelectRelayList = vm::selectRelayList, + ) +} + +@Suppress("LongMethod") +@Composable +fun SelectLocationScreen( + state: SelectLocationUiState, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + onSelectRelay: (item: RelayItem) -> Unit = {}, + onSearchClick: (RelayListType) -> Unit = {}, + onBackClick: () -> Unit = {}, + onFilterClick: () -> Unit = {}, + onCreateCustomList: (location: RelayItem.Location?) -> Unit = {}, + onEditCustomLists: () -> Unit = {}, + removeOwnershipFilter: () -> Unit = {}, + removeProviderFilter: () -> Unit = {}, + onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit = + { _, _ -> + }, + onRemoveLocationFromList: (location: RelayItem.Location, customListId: CustomListId) -> Unit = + { _, _ -> + }, + onEditCustomListName: (RelayItem.CustomList) -> Unit = {}, + onEditLocationsCustomList: (RelayItem.CustomList) -> Unit = {}, + onDeleteCustomList: (RelayItem.CustomList) -> Unit = {}, + onSelectRelayList: (RelayListType) -> Unit = {}, +) { + val backgroundColor = MaterialTheme.colorScheme.surface + + ScaffoldWithSmallTopBar( + appBarTitle = stringResource(id = R.string.select_location), + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.Default.Close, + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = stringResource(id = R.string.back), + ) + } + }, + snackbarHostState = snackbarHostState, + actions = { + IconButton(onClick = { onSearchClick(state.relayListType) }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(id = R.string.filter), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + IconButton(onClick = onFilterClick) { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = stringResource(id = R.string.filter), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + ) { modifier -> + var locationBottomSheetState by remember { mutableStateOf<LocationBottomSheetState?>(null) } + LocationBottomSheets( + locationBottomSheetState = locationBottomSheetState, + onCreateCustomList = onCreateCustomList, + onEditCustomLists = onEditCustomLists, + onAddLocationToList = onAddLocationToList, + onRemoveLocationFromList = onRemoveLocationFromList, + onEditCustomListName = onEditCustomListName, + onEditLocationsCustomList = onEditLocationsCustomList, + onDeleteCustomList = onDeleteCustomList, + onHideBottomSheet = { locationBottomSheetState = null }, + ) + + Column(modifier = modifier.background(backgroundColor).fillMaxSize()) { + AnimatedContent(targetState = state.filterChips, label = "Select location top bar") { + filterChips -> + if (filterChips.isNotEmpty()) { + FilterRow( + filters = filterChips, + onRemoveOwnershipFilter = removeOwnershipFilter, + onRemoveProviderFilter = removeProviderFilter, + ) + } + } + + if (state.multihopEnabled) { + MultihopBar(state.relayListType, onSelectRelayList) + } + + if (state.filterChips.isNotEmpty() || state.multihopEnabled) { + Spacer(modifier = Modifier.height(height = Dimens.verticalSpace)) + } + + RelayLists( + state = state, + backgroundColor = backgroundColor, + onSelectRelay = onSelectRelay, + onUpdateBottomSheetState = { newState -> locationBottomSheetState = newState }, + ) + } + } +} + +@Composable +private fun MultihopBar(relayListType: RelayListType, onSelectRelayList: (RelayListType) -> Unit) { + SingleChoiceSegmentedButtonRow( + modifier = + Modifier.fillMaxWidth().padding(start = Dimens.sideMargin, end = Dimens.sideMargin) + ) { + MullvadSegmentedStartButton( + selected = relayListType == RelayListType.ENTRY, + onClick = { onSelectRelayList(RelayListType.ENTRY) }, + text = stringResource(id = R.string.entry), + ) + MullvadSegmentedEndButton( + selected = relayListType == RelayListType.EXIT, + onClick = { onSelectRelayList(RelayListType.EXIT) }, + text = stringResource(id = R.string.exit), + ) + } +} + +@Composable +private fun RelayLists( + state: SelectLocationUiState, + backgroundColor: Color, + onSelectRelay: (RelayItem) -> Unit, + onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, +) { + // For multihop we want to start on the entry list. + // If multihop is not enabled we want to start on the exit list. + // The exit endpoint is what is selected when multihop is disabled. + val pagerState = + rememberPagerState( + initialPage = + if (state.multihopEnabled) { + RelayListType.ENTRY.ordinal + } else { + RelayListType.EXIT.ordinal + }, + pageCount = { RelayListType.entries.size }, + ) + LaunchedEffect(state.relayListType) { + val index = state.relayListType.ordinal + pagerState.animateScrollToPage(index) + } + + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + beyondViewportPageCount = + if (state.multihopEnabled) { + 1 + } else { + 0 + }, + ) { pageIndex -> + SelectLocationList( + backgroundColor = backgroundColor, + relayListType = RelayListType.entries[pageIndex], + onSelectRelay = onSelectRelay, + onUpdateBottomSheetState = onUpdateBottomSheetState, + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterChip.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterChip.kt new file mode 100644 index 0000000000..8439680500 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterChip.kt @@ -0,0 +1 @@ +package net.mullvad.mullvadvpn.compose.state diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListItem.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListItem.kt new file mode 100644 index 0000000000..34fd369526 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListItem.kt @@ -0,0 +1,89 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.RelayItem + +enum class RelayListItemContentType { + CUSTOM_LIST_HEADER, + CUSTOM_LIST_ITEM, + CUSTOM_LIST_ENTRY_ITEM, + CUSTOM_LIST_FOOTER, + LOCATION_HEADER, + LOCATION_ITEM, + LOCATIONS_EMPTY_TEXT, +} + +enum class RelayListItemState { + USED_AS_ENTRY, + USED_AS_EXIT, +} + +sealed interface RelayListItem { + val key: Any + val contentType: RelayListItemContentType + + data object CustomListHeader : RelayListItem { + override val key = "custom_list_header" + override val contentType = RelayListItemContentType.CUSTOM_LIST_HEADER + } + + sealed interface SelectableItem : RelayListItem { + val depth: Int + val isSelected: Boolean + val expanded: Boolean + val state: RelayListItemState? + } + + data class CustomListItem( + val item: RelayItem.CustomList, + override val isSelected: Boolean = false, + override val expanded: Boolean = false, + override val state: RelayListItemState? = null, + ) : SelectableItem { + override val key = item.id + override val depth: Int = 0 + override val contentType = RelayListItemContentType.CUSTOM_LIST_ITEM + } + + data class CustomListEntryItem( + val parentId: CustomListId, + val parentName: CustomListName, + val item: RelayItem.Location, + override val expanded: Boolean, + override val depth: Int = 0, + override val state: RelayListItemState? = null, + ) : SelectableItem { + override val key = parentId to item.id + + // Can't be displayed as selected + override val isSelected: Boolean = false + override val contentType = RelayListItemContentType.CUSTOM_LIST_ENTRY_ITEM + } + + data class CustomListFooter(val hasCustomList: Boolean) : RelayListItem { + override val key = "custom_list_footer" + override val contentType = RelayListItemContentType.CUSTOM_LIST_FOOTER + } + + data object LocationHeader : RelayListItem { + override val key = "location_header" + override val contentType = RelayListItemContentType.LOCATION_HEADER + } + + data class GeoLocationItem( + val item: RelayItem.Location, + override val isSelected: Boolean = false, + override val depth: Int = 0, + override val expanded: Boolean = false, + override val state: RelayListItemState? = null, + ) : SelectableItem { + override val key = item.id + override val contentType = RelayListItemContentType.LOCATION_ITEM + } + + data class LocationsEmptyText(val searchTerm: String) : RelayListItem { + override val key = "locations_empty_text" + override val contentType = RelayListItemContentType.LOCATIONS_EMPTY_TEXT + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListType.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListType.kt new file mode 100644 index 0000000000..6640ceea4a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListType.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.compose.state + +enum class RelayListType { + ENTRY, + EXIT, +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt new file mode 100644 index 0000000000..fd35213dac --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.usecase.FilterChip + +sealed interface SearchLocationUiState { + val searchTerm: String + val filterChips: List<FilterChip> + + data class NoQuery( + override val searchTerm: String, + override val filterChips: List<FilterChip>, + ) : SearchLocationUiState + + data class Content( + override val searchTerm: String, + override val filterChips: List<FilterChip>, + val relayListItems: List<RelayListItem>, + val customLists: List<RelayItem.CustomList>, + ) : SearchLocationUiState +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt new file mode 100644 index 0000000000..bb320de81d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt @@ -0,0 +1,13 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.lib.model.RelayItem + +sealed interface SelectLocationListUiState { + + data object Loading : SelectLocationListUiState + + data class Content( + val relayListItems: List<RelayListItem>, + val customLists: List<RelayItem.CustomList>, + ) : SelectLocationListUiState +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt index d8245792a3..bb61bd4e7d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt @@ -1,102 +1,9 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.lib.model.CustomListId -import net.mullvad.mullvadvpn.lib.model.CustomListName -import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.usecase.FilterChip -typealias ModelOwnership = net.mullvad.mullvadvpn.lib.model.Ownership - -sealed interface SelectLocationUiState { - - data object Loading : SelectLocationUiState - - data class Content( - val searchTerm: String, - val filterChips: List<FilterChip>, - val relayListItems: List<RelayListItem>, - val customLists: List<RelayItem.CustomList>, - ) : SelectLocationUiState -} - -sealed interface FilterChip { - data class Ownership(val ownership: ModelOwnership) : FilterChip - - data class Provider(val count: Int) : FilterChip - - data object Daita : FilterChip -} - -enum class RelayListItemContentType { - CUSTOM_LIST_HEADER, - CUSTOM_LIST_ITEM, - CUSTOM_LIST_ENTRY_ITEM, - CUSTOM_LIST_FOOTER, - LOCATION_HEADER, - LOCATION_ITEM, - LOCATIONS_EMPTY_TEXT, -} - -sealed interface RelayListItem { - val key: Any - val contentType: RelayListItemContentType - - data object CustomListHeader : RelayListItem { - override val key = "custom_list_header" - override val contentType = RelayListItemContentType.CUSTOM_LIST_HEADER - } - - sealed interface SelectableItem : RelayListItem { - val depth: Int - val isSelected: Boolean - val expanded: Boolean - } - - data class CustomListItem( - val item: RelayItem.CustomList, - override val isSelected: Boolean = false, - override val expanded: Boolean = false, - ) : SelectableItem { - override val key = item.id - override val depth: Int = 0 - override val contentType = RelayListItemContentType.CUSTOM_LIST_ITEM - } - - data class CustomListEntryItem( - val parentId: CustomListId, - val parentName: CustomListName, - val item: RelayItem.Location, - override val expanded: Boolean, - override val depth: Int = 0, - ) : SelectableItem { - override val key = parentId to item.id - - // Can't be displayed as selected - override val isSelected: Boolean = false - override val contentType = RelayListItemContentType.CUSTOM_LIST_ENTRY_ITEM - } - - data class CustomListFooter(val hasCustomList: Boolean) : RelayListItem { - override val key = "custom_list_footer" - override val contentType = RelayListItemContentType.CUSTOM_LIST_FOOTER - } - - data object LocationHeader : RelayListItem { - override val key: Any = "location_header" - override val contentType = RelayListItemContentType.LOCATION_HEADER - } - - data class GeoLocationItem( - val item: RelayItem.Location, - override val isSelected: Boolean = false, - override val depth: Int = 0, - override val expanded: Boolean = false, - ) : SelectableItem { - override val key = item.id - override val contentType = RelayListItemContentType.LOCATION_ITEM - } - - data class LocationsEmptyText(val searchTerm: String) : RelayListItem { - override val key: Any = "locations_empty_text" - override val contentType = RelayListItemContentType.LOCATIONS_EMPTY_TEXT - } -} +data class SelectLocationUiState( + val filterChips: List<FilterChip>, + val multihopEnabled: Boolean, + val relayListType: RelayListType, +) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SettingsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SettingsUiState.kt index d804dd6678..4ebbf9ad23 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SettingsUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SettingsUiState.kt @@ -5,4 +5,5 @@ data class SettingsUiState( val isLoggedIn: Boolean, val isSupportedVersion: Boolean, val isPlayBuild: Boolean, + val multihopEnabled: Boolean, ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 2605075ef8..1d62de5bb2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.applist.ApplicationsProvider +import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport import net.mullvad.mullvadvpn.lib.payment.PaymentProvider @@ -34,6 +35,7 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.usecase.AccountExpiryInAppNotificationUseCase import net.mullvad.mullvadvpn.usecase.AvailableProvidersUseCase import net.mullvad.mullvadvpn.usecase.EmptyPaymentUseCase +import net.mullvad.mullvadvpn.usecase.FilterChipUseCase import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase import net.mullvad.mullvadvpn.usecase.InternetAvailableUseCase import net.mullvad.mullvadvpn.usecase.LastKnownLocationUseCase @@ -42,6 +44,7 @@ import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.usecase.PlayPaymentUseCase import net.mullvad.mullvadvpn.usecase.SelectedLocationTitleUseCase +import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsAvailableUseCase import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase @@ -71,6 +74,7 @@ import net.mullvad.mullvadvpn.viewmodel.EditCustomListViewModel import net.mullvad.mullvadvpn.viewmodel.FilterViewModel import net.mullvad.mullvadvpn.viewmodel.LoginViewModel import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel +import net.mullvad.mullvadvpn.viewmodel.MultihopViewModel import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel import net.mullvad.mullvadvpn.viewmodel.PaymentViewModel @@ -78,7 +82,6 @@ import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel import net.mullvad.mullvadvpn.viewmodel.ResetServerIpOverridesConfirmationViewModel import net.mullvad.mullvadvpn.viewmodel.SaveApiAccessMethodViewModel -import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesViewModel import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel import net.mullvad.mullvadvpn.viewmodel.ShadowsocksCustomPortDialogViewModel @@ -92,6 +95,9 @@ import net.mullvad.mullvadvpn.viewmodel.VpnPermissionViewModel import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel import net.mullvad.mullvadvpn.viewmodel.WireguardCustomPortDialogViewModel +import net.mullvad.mullvadvpn.viewmodel.location.SearchLocationViewModel +import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationListViewModel +import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationViewModel import org.apache.commons.validator.routines.InetAddressValidator import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext @@ -154,11 +160,13 @@ val uiModule = module { single { CustomListActionUseCase(get(), get()) } single { SelectedLocationTitleUseCase(get(), get()) } single { AvailableProvidersUseCase(get()) } - single { FilterCustomListsRelayItemUseCase(get(), get(), get()) } + single { FilterCustomListsRelayItemUseCase(get(), get(), get(), get()) } single { CustomListsRelayItemUseCase(get(), get()) } single { CustomListRelayItemsUseCase(get(), get()) } - single { FilteredRelayListUseCase(get(), get(), get()) } + single { FilteredRelayListUseCase(get(), get(), get(), get()) } single { LastKnownLocationUseCase(get()) } + single { SelectedLocationUseCase(get(), get()) } + single { FilterChipUseCase(get(), get(), get(), get()) } single { InAppNotificationController(get(), get(), get(), get(), MainScope()) } @@ -210,10 +218,8 @@ val uiModule = module { viewModel { WireguardCustomPortDialogViewModel(get()) } viewModel { LoginViewModel(get(), get(), get()) } viewModel { PrivacyDisclaimerViewModel(get(), IS_PLAY_BUILD) } - viewModel { - SelectLocationViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get()) - } - viewModel { SettingsViewModel(get(), get(), IS_PLAY_BUILD) } + viewModel { SelectLocationViewModel(get(), get(), get(), get(), get(), get()) } + viewModel { SettingsViewModel(get(), get(), get(), IS_PLAY_BUILD) } viewModel { SplashViewModel(get(), get(), get(), get()) } viewModel { VoucherDialogViewModel(get()) } viewModel { VpnSettingsViewModel(get(), get(), get(), get(), get()) } @@ -240,6 +246,25 @@ val uiModule = module { viewModel { Udp2TcpSettingsViewModel(get()) } viewModel { ShadowsocksSettingsViewModel(get(), get()) } viewModel { ShadowsocksCustomPortDialogViewModel(get()) } + viewModel { MultihopViewModel(get()) } + viewModel { + SearchLocationViewModel( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + ) + } + viewModel { (relayListType: RelayListType) -> + SelectLocationListViewModel(relayListType, get(), get(), get(), get(), get(), get()) + } // This view model must be single so we correctly attach lifecycle and share it with activity single { NoDaemonViewModel(get()) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt index 5d6e48a3f7..f21adee735 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt @@ -56,14 +56,14 @@ private fun RelayItem.Location.hasProvider(providersConstraint: Constraint<Provi fun RelayItem.CustomList.filter( ownership: Constraint<Ownership>, providers: Constraint<Providers>, - isDaitaEnabled: Boolean, + daita: Boolean, ): RelayItem.CustomList { val newLocations = locations.mapNotNull { when (it) { - is RelayItem.Location.Country -> it.filter(ownership, providers, isDaitaEnabled) - is RelayItem.Location.City -> it.filter(ownership, providers, isDaitaEnabled) - is RelayItem.Location.Relay -> it.filter(ownership, providers, isDaitaEnabled) + is RelayItem.Location.Country -> it.filter(ownership, providers, daita) + is RelayItem.Location.City -> it.filter(ownership, providers, daita) + is RelayItem.Location.Relay -> it.filter(ownership, providers, daita) } } return copy(locations = newLocations) @@ -72,9 +72,9 @@ fun RelayItem.CustomList.filter( fun RelayItem.Location.Country.filter( ownership: Constraint<Ownership>, providers: Constraint<Providers>, - isDaitaEnabled: Boolean, + daita: Boolean, ): RelayItem.Location.Country? { - val cities = cities.mapNotNull { it.filter(ownership, providers, isDaitaEnabled) } + val cities = cities.mapNotNull { it.filter(ownership, providers, daita) } return if (cities.isNotEmpty()) { this.copy(cities = cities) } else { @@ -85,9 +85,9 @@ fun RelayItem.Location.Country.filter( private fun RelayItem.Location.City.filter( ownership: Constraint<Ownership>, providers: Constraint<Providers>, - isDaitaEnabled: Boolean, + daita: Boolean, ): RelayItem.Location.City? { - val relays = relays.mapNotNull { it.filter(ownership, providers, isDaitaEnabled) } + val relays = relays.mapNotNull { it.filter(ownership, providers, daita) } return if (relays.isNotEmpty()) { this.copy(relays = relays) } else { @@ -102,10 +102,10 @@ private fun RelayItem.Location.Relay.hasMatchingDaitaSetting(isDaitaEnabled: Boo private fun RelayItem.Location.Relay.filter( ownership: Constraint<Ownership>, providers: Constraint<Providers>, - isDaitaEnabled: Boolean, + daita: Boolean, ): RelayItem.Location.Relay? { return if ( - hasMatchingDaitaSetting(isDaitaEnabled) && hasOwnership(ownership) && hasProvider(providers) + hasMatchingDaitaSetting(daita) && hasOwnership(ownership) && hasProvider(providers) ) { this } else { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/WireguardConstraintsRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/WireguardConstraintsRepository.kt index 816b172ea5..093b87cafc 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/WireguardConstraintsRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/WireguardConstraintsRepository.kt @@ -1,11 +1,29 @@ package net.mullvad.mullvadvpn.repository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.RelayItemId + +class WireguardConstraintsRepository( + private val managementService: ManagementService, + dispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + val wireguardConstraints = + managementService.settings + .mapNotNull { it.relaySettings.relayConstraints.wireguardConstraints } + .stateIn(CoroutineScope(dispatcher), SharingStarted.Eagerly, null) -class WireguardConstraintsRepository(private val managementService: ManagementService) { suspend fun setWireguardPort(port: Constraint<Port>) = managementService.setWireguardPort(port) suspend fun setMultihop(enabled: Boolean) = managementService.setMultihop(enabled) + + suspend fun setEntryLocation(relayItemId: RelayItemId) = + managementService.setEntryLocation(relayItemId) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCase.kt new file mode 100644 index 0000000000..366a7321f6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCase.kt @@ -0,0 +1,103 @@ +package net.mullvad.mullvadvpn.usecase + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.toSelectedProviders +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Provider +import net.mullvad.mullvadvpn.lib.model.Providers +import net.mullvad.mullvadvpn.repository.RelayListFilterRepository +import net.mullvad.mullvadvpn.repository.SettingsRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.util.shouldFilterByDaita + +typealias ModelOwnership = Ownership + +class FilterChipUseCase( + private val relayListFilterRepository: RelayListFilterRepository, + private val availableProvidersUseCase: AvailableProvidersUseCase, + private val settingsRepository: SettingsRepository, + private val wireguardConstraintsRepository: WireguardConstraintsRepository, +) { + operator fun invoke(relayListType: RelayListType): Flow<List<FilterChip>> = + combine( + relayListFilterRepository.selectedOwnership, + relayListFilterRepository.selectedProviders, + availableProvidersUseCase(), + settingsRepository.settingsUpdates, + wireguardConstraintsRepository.wireguardConstraints, + ) { + selectedOwnership, + selectedConstraintProviders, + allProviders, + settings, + wireguardConstraints -> + filterChips( + selectedOwnership = selectedOwnership, + selectedConstraintProviders = selectedConstraintProviders, + allProviders = allProviders, + isDaitaEnabled = settings?.isDaitaEnabled() == true, + isMultihopEnabled = wireguardConstraints?.isMultihopEnabled == true, + relayListType = relayListType, + ) + } + + private fun filterChips( + selectedOwnership: Constraint<Ownership>, + selectedConstraintProviders: Constraint<Providers>, + allProviders: List<Provider>, + isDaitaEnabled: Boolean, + isMultihopEnabled: Boolean, + relayListType: RelayListType, + ): List<FilterChip> { + val ownershipFilter = selectedOwnership.getOrNull() + val providerCountFilter = + when (selectedConstraintProviders) { + is Constraint.Any -> null + is Constraint.Only -> + filterSelectedProvidersByOwnership( + selectedConstraintProviders.toSelectedProviders(allProviders), + ownershipFilter, + ) + .size + } + return buildList { + if (ownershipFilter != null) { + add(FilterChip.Ownership(ownershipFilter)) + } + if (providerCountFilter != null) { + add(FilterChip.Provider(providerCountFilter)) + } + if ( + shouldFilterByDaita( + isDaitaEnabled = isDaitaEnabled, + relayListType = relayListType, + isMultihopEnabled = isMultihopEnabled, + ) + ) { + add(FilterChip.Daita) + } + } + } + + private fun filterSelectedProvidersByOwnership( + selectedProviders: List<Provider>, + selectedOwnership: Ownership?, + ): List<Provider> = + if (selectedOwnership == null) selectedProviders + else selectedProviders.filter { it.ownership == selectedOwnership } +} + +sealed interface FilterChip { + data class Ownership(val ownership: ModelOwnership) : FilterChip + + data class Provider(val count: Int) : FilterChip + + data object Daita : FilterChip + + data object Entry : FilterChip + + data object Exit : FilterChip +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilteredRelayListUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilteredRelayListUseCase.kt index 60de94946f..6712d9275f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilteredRelayListUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilteredRelayListUseCase.kt @@ -1,6 +1,7 @@ package net.mullvad.mullvadvpn.usecase import kotlinx.coroutines.flow.combine +import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.Ownership import net.mullvad.mullvadvpn.lib.model.Providers @@ -9,29 +10,38 @@ import net.mullvad.mullvadvpn.relaylist.filter import net.mullvad.mullvadvpn.repository.RelayListFilterRepository import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.repository.SettingsRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.util.shouldFilterByDaita class FilteredRelayListUseCase( private val relayListRepository: RelayListRepository, private val relayListFilterRepository: RelayListFilterRepository, private val settingsRepository: SettingsRepository, + private val wireguardConstraintsRepository: WireguardConstraintsRepository, ) { - operator fun invoke() = + operator fun invoke(relayListType: RelayListType) = combine( relayListRepository.relayList, relayListFilterRepository.selectedOwnership, relayListFilterRepository.selectedProviders, settingsRepository.settingsUpdates, - ) { relayList, selectedOwnership, selectedProviders, settings -> + wireguardConstraintsRepository.wireguardConstraints, + ) { relayList, selectedOwnership, selectedProviders, settings, wireguardConstraints -> relayList.filter( - selectedOwnership, - selectedProviders, - isDaitaEnabled = settings?.isDaitaEnabled() ?: false, + ownership = selectedOwnership, + providers = selectedProviders, + shouldFilterByDaita = + shouldFilterByDaita( + isDaitaEnabled = settings?.isDaitaEnabled() == true, + isMultihopEnabled = wireguardConstraints?.isMultihopEnabled == true, + relayListType = relayListType, + ), ) } private fun List<RelayItem.Location.Country>.filter( ownership: Constraint<Ownership>, providers: Constraint<Providers>, - isDaitaEnabled: Boolean, - ) = mapNotNull { it.filter(ownership, providers, isDaitaEnabled) } + shouldFilterByDaita: Boolean, + ) = mapNotNull { it.filter(ownership, providers, shouldFilterByDaita) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationUseCase.kt new file mode 100644 index 0000000000..b103e45c63 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationUseCase.kt @@ -0,0 +1,27 @@ +package net.mullvad.mullvadvpn.usecase + +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import net.mullvad.mullvadvpn.lib.model.RelayItemSelection +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository + +class SelectedLocationUseCase( + private val relayListRepository: RelayListRepository, + private val wireguardConstraintsRepository: WireguardConstraintsRepository, +) { + operator fun invoke() = + combine( + relayListRepository.selectedLocation.filterNotNull(), + wireguardConstraintsRepository.wireguardConstraints.filterNotNull(), + ) { selectedLocation, wireguardConstraints -> + if (wireguardConstraints.isMultihopEnabled) { + RelayItemSelection.Multiple( + entryLocation = wireguardConstraints.entryLocation, + exitLocation = selectedLocation, + ) + } else { + RelayItemSelection.Single(selectedLocation) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt index 17ead75d2a..c326b176a5 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt @@ -2,6 +2,7 @@ package net.mullvad.mullvadvpn.usecase.customlists import kotlin.collections.mapNotNull import kotlinx.coroutines.flow.combine +import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.Ownership import net.mullvad.mullvadvpn.lib.model.Providers @@ -9,30 +10,39 @@ import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.relaylist.filter import net.mullvad.mullvadvpn.repository.RelayListFilterRepository import net.mullvad.mullvadvpn.repository.SettingsRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.util.shouldFilterByDaita class FilterCustomListsRelayItemUseCase( private val customListsRelayItemUseCase: CustomListsRelayItemUseCase, private val relayListFilterRepository: RelayListFilterRepository, private val settingsRepository: SettingsRepository, + private val wireguardConstraintsRepository: WireguardConstraintsRepository, ) { - operator fun invoke() = + operator fun invoke(relayListType: RelayListType) = combine( customListsRelayItemUseCase(), relayListFilterRepository.selectedOwnership, relayListFilterRepository.selectedProviders, settingsRepository.settingsUpdates, - ) { customLists, selectedOwnership, selectedProviders, settings -> - customLists.filterOnOwnershipAndProvider( - selectedOwnership, - selectedProviders, - isDaitaEnabled = settings?.isDaitaEnabled() ?: false, + wireguardConstraintsRepository.wireguardConstraints, + ) { customLists, selectedOwnership, selectedProviders, settings, wireguardConstraints -> + customLists.filter( + ownership = selectedOwnership, + providers = selectedProviders, + daita = + shouldFilterByDaita( + isDaitaEnabled = settings?.isDaitaEnabled() == true, + isMultihopEnabled = wireguardConstraints?.isMultihopEnabled == true, + relayListType = relayListType, + ), ) } - private fun List<RelayItem.CustomList>.filterOnOwnershipAndProvider( + private fun List<RelayItem.CustomList>.filter( ownership: Constraint<Ownership>, providers: Constraint<Providers>, - isDaitaEnabled: Boolean, - ) = mapNotNull { it.filter(ownership, providers, isDaitaEnabled = isDaitaEnabled) } + daita: Boolean, + ) = mapNotNull { it.filter(ownership, providers, daita = daita) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Daita.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Daita.kt new file mode 100644 index 0000000000..717d007f92 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Daita.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.util + +import net.mullvad.mullvadvpn.compose.state.RelayListType + +fun shouldFilterByDaita( + isDaitaEnabled: Boolean, + isMultihopEnabled: Boolean, + relayListType: RelayListType, +) = + isDaitaEnabled && + (relayListType == RelayListType.ENTRY || + !isMultihopEnabled && relayListType == RelayListType.EXIT) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt index 200502dee4..0c88598923 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt @@ -29,6 +29,31 @@ inline fun <T1, T2, T3, T4, T5, T6, R> combine( } } +inline fun <T1, T2, T3, T4, T5, T6, T7, R> combine( + flow: Flow<T1>, + flow2: Flow<T2>, + flow3: Flow<T3>, + flow4: Flow<T4>, + flow5: Flow<T5>, + flow6: Flow<T6>, + flow7: Flow<T7>, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R, +): Flow<R> { + return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) { + args: Array<*> -> + @Suppress("UNCHECKED_CAST") + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + ) + } +} + @OptIn(ExperimentalCoroutinesApi::class) fun <T> Deferred<T>.getOrDefault(default: T) = try { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModel.kt new file mode 100644 index 0000000000..4ff63b8fe7 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModel.kt @@ -0,0 +1,26 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository + +class MultihopViewModel( + private val wireguardConstraintsRepository: WireguardConstraintsRepository +) : ViewModel() { + + val uiState: StateFlow<MultihopUiState> = + wireguardConstraintsRepository.wireguardConstraints + .map { MultihopUiState(it?.isMultihopEnabled ?: false) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), MultihopUiState(false)) + + fun setMultihop(enable: Boolean) { + viewModelScope.launch { wireguardConstraintsRepository.setMultihop(enable) } + } +} + +data class MultihopUiState(val enable: Boolean) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt deleted file mode 100644 index 4ddad8477b..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt +++ /dev/null @@ -1,436 +0,0 @@ -package net.mullvad.mullvadvpn.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import arrow.core.getOrElse -import arrow.core.raise.either -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData -import net.mullvad.mullvadvpn.compose.state.FilterChip -import net.mullvad.mullvadvpn.compose.state.RelayListItem -import net.mullvad.mullvadvpn.compose.state.RelayListItem.CustomListHeader -import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState -import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState.Content -import net.mullvad.mullvadvpn.compose.state.toSelectedProviders -import net.mullvad.mullvadvpn.lib.model.Constraint -import net.mullvad.mullvadvpn.lib.model.CustomListId -import net.mullvad.mullvadvpn.lib.model.GeoLocationId -import net.mullvad.mullvadvpn.lib.model.Ownership -import net.mullvad.mullvadvpn.lib.model.Provider -import net.mullvad.mullvadvpn.lib.model.RelayItem -import net.mullvad.mullvadvpn.lib.model.RelayItemId -import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH -import net.mullvad.mullvadvpn.relaylist.descendants -import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm -import net.mullvad.mullvadvpn.relaylist.newFilterOnSearch -import net.mullvad.mullvadvpn.repository.CustomListsRepository -import net.mullvad.mullvadvpn.repository.RelayListFilterRepository -import net.mullvad.mullvadvpn.repository.RelayListRepository -import net.mullvad.mullvadvpn.repository.SettingsRepository -import net.mullvad.mullvadvpn.usecase.AvailableProvidersUseCase -import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase -import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase -import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase -import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase - -@Suppress("TooManyFunctions") -class SelectLocationViewModel( - private val relayListFilterRepository: RelayListFilterRepository, - private val availableProvidersUseCase: AvailableProvidersUseCase, - customListsRelayItemUseCase: CustomListsRelayItemUseCase, - private val filteredCustomListRelayItemsUseCase: FilterCustomListsRelayItemUseCase, - private val customListsRepository: CustomListsRepository, - private val customListActionUseCase: CustomListActionUseCase, - private val filteredRelayListUseCase: FilteredRelayListUseCase, - private val relayListRepository: RelayListRepository, - private val settingsRepository: SettingsRepository, -) : ViewModel() { - private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) - - private val _expandedItems = MutableStateFlow(initialExpand()) - - @Suppress("DestructuringDeclarationWithTooManyEntries") - val uiState = - combine(_searchTerm, relayListItems(), filterChips(), customListsRelayItemUseCase()) { - searchTerm, - relayListItems, - filterChips, - customLists -> - Content( - searchTerm = searchTerm, - filterChips = filterChips, - relayListItems = relayListItems, - customLists = customLists, - ) - } - .stateIn(viewModelScope, SharingStarted.Lazily, SelectLocationUiState.Loading) - - private val _uiSideEffect = Channel<SelectLocationSideEffect>() - val uiSideEffect = _uiSideEffect.receiveAsFlow() - - private fun initialExpand(): Set<String> = buildSet { - when (val item = relayListRepository.selectedLocation.value.getOrNull()) { - is GeoLocationId.City -> { - add(item.country.code) - } - is GeoLocationId.Hostname -> { - add(item.country.code) - add(item.city.code) - } - is CustomListId, - is GeoLocationId.Country, - null -> { - /* No expands */ - } - } - } - - private fun searchRelayListLocations() = - combine(_searchTerm, filteredRelayListUseCase()) { searchTerm, relayCountries -> - val isSearching = searchTerm.length >= MIN_SEARCH_LENGTH - if (isSearching) { - val (exp, filteredRelayCountries) = relayCountries.newFilterOnSearch(searchTerm) - exp.map { it.expandKey() }.toSet() to filteredRelayCountries - } else { - initialExpand() to relayCountries - } - } - .onEach { _expandedItems.value = it.first } - .map { it.second } - - private fun filterChips() = - combine( - relayListFilterRepository.selectedOwnership, - relayListFilterRepository.selectedProviders, - availableProvidersUseCase(), - settingsRepository.settingsUpdates, - ) { selectedOwnership, selectedConstraintProviders, allProviders, settings -> - val ownershipFilter = selectedOwnership.getOrNull() - val providerCountFilter = - when (selectedConstraintProviders) { - is Constraint.Any -> null - is Constraint.Only -> - filterSelectedProvidersByOwnership( - selectedConstraintProviders.toSelectedProviders(allProviders), - ownershipFilter, - ) - .size - } - buildList { - if (ownershipFilter != null) { - add(FilterChip.Ownership(ownershipFilter)) - } - if (providerCountFilter != null) { - add(FilterChip.Provider(providerCountFilter)) - } - if (settings?.isDaitaEnabled() == true) { - add(FilterChip.Daita) - } - } - } - - private fun relayListItems() = - combine( - _searchTerm, - searchRelayListLocations(), - filteredCustomListRelayItemsUseCase(), - relayListRepository.selectedLocation, - _expandedItems, - ) { searchTerm, relayCountries, customLists, selectedItem, expandedItems -> - val filteredCustomLists = customLists.filterOnSearchTerm(searchTerm) - - buildList { - val relayItems = - createRelayListItems( - searchTerm.length >= MIN_SEARCH_LENGTH, - selectedItem.getOrNull(), - filteredCustomLists, - relayCountries, - ) { - it in expandedItems - } - if (relayItems.isEmpty()) { - add(RelayListItem.LocationsEmptyText(searchTerm)) - } else { - addAll(relayItems) - } - } - } - - private fun createRelayListItems( - isSearching: Boolean, - selectedItem: RelayItemId?, - customLists: List<RelayItem.CustomList>, - countries: List<RelayItem.Location.Country>, - isExpanded: (String) -> Boolean, - ): List<RelayListItem> = - createCustomListSection(isSearching, selectedItem, customLists, isExpanded) + - createLocationSection(isSearching, selectedItem, countries, isExpanded) - - private fun createCustomListSection( - isSearching: Boolean, - selectedItem: RelayItemId?, - customLists: List<RelayItem.CustomList>, - isExpanded: (String) -> Boolean, - ): List<RelayListItem> = buildList { - if (isSearching && customLists.isEmpty()) { - // If we are searching and no results are found don't show header or footer - } else { - add(CustomListHeader) - val customListItems = createCustomListRelayItems(customLists, selectedItem, isExpanded) - addAll(customListItems) - add(RelayListItem.CustomListFooter(customListItems.isNotEmpty())) - } - } - - private fun createCustomListRelayItems( - customLists: List<RelayItem.CustomList>, - selectedItem: RelayItemId?, - isExpanded: (String) -> Boolean, - ): List<RelayListItem> = - customLists.flatMap { customList -> - val expanded = isExpanded(customList.id.expandKey()) - buildList { - add( - RelayListItem.CustomListItem( - customList, - isSelected = selectedItem == customList.id, - expanded, - ) - ) - - if (expanded) { - addAll( - customList.locations.flatMap { - createCustomListEntry(parent = customList, item = it, 1, isExpanded) - } - ) - } - } - } - - private fun createLocationSection( - isSearching: Boolean, - selectedItem: RelayItemId?, - countries: List<RelayItem.Location.Country>, - isExpanded: (String) -> Boolean, - ): List<RelayListItem> = buildList { - if (isSearching && countries.isEmpty()) { - // If we are searching and no results are found don't show header or footer - } else { - add(RelayListItem.LocationHeader) - addAll( - countries.flatMap { country -> - createGeoLocationEntry(country, selectedItem, isExpanded = isExpanded) - } - ) - } - } - - private fun createCustomListEntry( - parent: RelayItem.CustomList, - item: RelayItem.Location, - depth: Int = 1, - isExpanded: (String) -> Boolean, - ): List<RelayListItem.CustomListEntryItem> = buildList { - val expanded = isExpanded(item.id.expandKey(parent.id)) - add( - RelayListItem.CustomListEntryItem( - parentId = parent.id, - parentName = parent.customList.name, - item = item, - expanded = expanded, - depth, - ) - ) - - if (expanded) { - when (item) { - is RelayItem.Location.City -> - addAll( - item.relays.flatMap { - createCustomListEntry(parent, it, depth + 1, isExpanded) - } - ) - is RelayItem.Location.Country -> - addAll( - item.cities.flatMap { - createCustomListEntry(parent, it, depth + 1, isExpanded) - } - ) - is RelayItem.Location.Relay -> {} // No children to add - } - } - } - - private fun createGeoLocationEntry( - item: RelayItem.Location, - selectedItem: RelayItemId?, - depth: Int = 0, - isExpanded: (String) -> Boolean, - ): List<RelayListItem.GeoLocationItem> = buildList { - val expanded = isExpanded(item.id.expandKey()) - - add( - RelayListItem.GeoLocationItem( - item = item, - isSelected = selectedItem == item.id, - depth = depth, - expanded = expanded, - ) - ) - - if (expanded) { - when (item) { - is RelayItem.Location.City -> - addAll( - item.relays.flatMap { - createGeoLocationEntry(it, selectedItem, depth + 1, isExpanded) - } - ) - is RelayItem.Location.Country -> - addAll( - item.cities.flatMap { - createGeoLocationEntry(it, selectedItem, depth + 1, isExpanded) - } - ) - is RelayItem.Location.Relay -> {} // Do nothing - } - } - } - - private fun RelayItemId.expandKey(parent: CustomListId? = null) = - (parent?.value ?: "") + - when (this) { - is CustomListId -> value - is GeoLocationId -> code - } - - fun selectRelay(relayItem: RelayItem) { - viewModelScope.launch { - val locationConstraint = relayItem.id - relayListRepository - .updateSelectedRelayLocation(locationConstraint) - .fold( - { _uiSideEffect.send(SelectLocationSideEffect.GenericError) }, - { _uiSideEffect.send(SelectLocationSideEffect.CloseScreen) }, - ) - } - } - - fun onToggleExpand(item: RelayItemId, parent: CustomListId? = null, expand: Boolean) { - _expandedItems.update { - val key = item.expandKey(parent) - if (expand) { - it + key - } else { - it - key - } - } - } - - fun onSearchTermInput(searchTerm: String) { - viewModelScope.launch { _searchTerm.emit(searchTerm) } - } - - private fun filterSelectedProvidersByOwnership( - selectedProviders: List<Provider>, - selectedOwnership: Ownership?, - ): List<Provider> = - if (selectedOwnership == null) selectedProviders - else selectedProviders.filter { it.ownership == selectedOwnership } - - fun removeOwnerFilter() { - viewModelScope.launch { relayListFilterRepository.updateSelectedOwnership(Constraint.Any) } - } - - fun removeProviderFilter() { - viewModelScope.launch { relayListFilterRepository.updateSelectedProviders(Constraint.Any) } - } - - fun addLocationToList(item: RelayItem.Location, customList: RelayItem.CustomList) { - viewModelScope.launch { - val newLocations = - (customList.locations + item).filter { it !in item.descendants() }.map { it.id } - val result = - customListActionUseCase( - CustomListAction.UpdateLocations(customList.id, newLocations) - ) - .fold( - { CustomListActionResultData.GenericError }, - { - if (it.removedLocations.isEmpty()) { - CustomListActionResultData.Success.LocationAdded( - customListName = it.name, - locationName = item.name, - undo = it.undo, - ) - } else { - CustomListActionResultData.Success.LocationChanged( - customListName = it.name, - undo = it.undo, - ) - } - }, - ) - _uiSideEffect.send(SelectLocationSideEffect.CustomListActionToast(result)) - } - } - - fun performAction(action: CustomListAction) { - viewModelScope.launch { customListActionUseCase(action) } - } - - fun removeLocationFromList(item: RelayItem.Location, customListId: CustomListId) { - viewModelScope.launch { - val result = - either { - val customList = - customListsRepository.getCustomListById(customListId).bind() - val newLocations = (customList.locations - item.id) - val success = - customListActionUseCase( - CustomListAction.UpdateLocations(customList.id, newLocations) - ) - .bind() - if (success.addedLocations.isEmpty()) { - CustomListActionResultData.Success.LocationRemoved( - customListName = success.name, - locationName = item.name, - undo = success.undo, - ) - } else { - CustomListActionResultData.Success.LocationChanged( - customListName = success.name, - undo = success.undo, - ) - } - } - .getOrElse { CustomListActionResultData.GenericError } - _uiSideEffect.send(SelectLocationSideEffect.CustomListActionToast(result)) - } - } - - companion object { - private const val EMPTY_SEARCH_TERM = "" - } -} - -sealed interface SelectLocationSideEffect { - data object CloseScreen : SelectLocationSideEffect - - data class CustomListActionToast(val resultData: CustomListActionResultData) : - SelectLocationSideEffect - - data object GenericError : SelectLocationSideEffect -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt index fc6b4af3ee..22309fecfd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt @@ -9,23 +9,28 @@ import kotlinx.coroutines.flow.stateIn import net.mullvad.mullvadvpn.compose.state.SettingsUiState import net.mullvad.mullvadvpn.lib.model.DeviceState import net.mullvad.mullvadvpn.lib.shared.DeviceRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository class SettingsViewModel( deviceRepository: DeviceRepository, appVersionInfoRepository: AppVersionInfoRepository, + wireguardConstraintsRepository: WireguardConstraintsRepository, isPlayBuild: Boolean, ) : ViewModel() { val uiState: StateFlow<SettingsUiState> = - combine(deviceRepository.deviceState, appVersionInfoRepository.versionInfo) { - deviceState, - versionInfo -> + combine( + deviceRepository.deviceState, + appVersionInfoRepository.versionInfo, + wireguardConstraintsRepository.wireguardConstraints, + ) { deviceState, versionInfo, wireguardConstraints -> SettingsUiState( isLoggedIn = deviceState is DeviceState.LoggedIn, appVersion = versionInfo.currentVersion, isSupportedVersion = versionInfo.isSupported, isPlayBuild = isPlayBuild, + multihopEnabled = wireguardConstraints?.isMultihopEnabled ?: false, ) } .stateIn( @@ -36,6 +41,7 @@ class SettingsViewModel( isLoggedIn = false, isSupportedVersion = true, isPlayBuild = isPlayBuild, + multihopEnabled = false, ), ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/CustomListEdit.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/CustomListEdit.kt new file mode 100644 index 0000000000..26454fc028 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/CustomListEdit.kt @@ -0,0 +1,75 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.raise.either +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData +import net.mullvad.mullvadvpn.compose.communication.LocationsChanged +import net.mullvad.mullvadvpn.lib.model.CustomList +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.GetCustomListError +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.relaylist.descendants +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionError + +internal suspend fun addLocationToCustomList( + customList: RelayItem.CustomList, + item: RelayItem.Location, + update: + suspend (CustomListAction.UpdateLocations) -> Either< + CustomListActionError, + LocationsChanged, + >, +): CustomListActionResultData { + val newLocations = + (customList.locations + item).filter { it !in item.descendants() }.map { it.id } + return update(CustomListAction.UpdateLocations(customList.id, newLocations)) + .fold( + { CustomListActionResultData.GenericError }, + { + if (it.removedLocations.isEmpty()) { + CustomListActionResultData.Success.LocationAdded( + customListName = it.name, + locationName = item.name, + undo = it.undo, + ) + } else { + CustomListActionResultData.Success.LocationChanged( + customListName = it.name, + undo = it.undo, + ) + } + }, + ) +} + +internal suspend fun removeLocationFromCustomList( + item: RelayItem.Location, + customListId: CustomListId, + getCustomListById: suspend (CustomListId) -> Either<GetCustomListError, CustomList>, + update: + suspend (CustomListAction.UpdateLocations) -> Either< + CustomListActionError, + LocationsChanged, + >, +) = + either { + val customList = getCustomListById(customListId).bind() + val newLocations = (customList.locations - item.id) + val success = + update(CustomListAction.UpdateLocations(customList.id, newLocations)).bind() + if (success.addedLocations.isEmpty()) { + CustomListActionResultData.Success.LocationRemoved( + customListName = success.name, + locationName = item.name, + undo = success.undo, + ) + } else { + CustomListActionResultData.Success.LocationChanged( + customListName = success.name, + undo = success.undo, + ) + } + } + .getOrElse { CustomListActionResultData.GenericError } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/Expand.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/Expand.kt new file mode 100644 index 0000000000..b517619e6b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/Expand.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.RelayItemId + +internal fun MutableStateFlow<Set<String>>.onToggleExpand( + item: RelayItemId, + parent: CustomListId? = null, + expand: Boolean, +) { + update { + val key = item.expandKey(parent) + if (expand) { + it + key + } else { + it - key + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/RelayItemListCreator.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/RelayItemListCreator.kt new file mode 100644 index 0000000000..c4b9e44f4d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/RelayItemListCreator.kt @@ -0,0 +1,355 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.RelayListItemState +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.lib.model.RelayItemSelection +import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH +import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm + +// Creates a relay list to be displayed by RelayListContent +internal fun relayListItems( + searchTerm: String = "", + relayListType: RelayListType, + relayCountries: List<RelayItem.Location.Country>, + customLists: List<RelayItem.CustomList>, + selectedByThisEntryExitList: RelayItemId?, + selectedByOtherEntryExitList: RelayItemId?, + expandedItems: Set<String>, +): List<RelayListItem> { + val filteredCustomLists = customLists.filterOnSearchTerm(searchTerm) + + return buildList { + val relayItems = + createRelayListItems( + isSearching = searchTerm.isSearching(), + relayListType = relayListType, + selectedByThisEntryExitList = selectedByThisEntryExitList, + selectedByOtherEntryExitList = selectedByOtherEntryExitList, + customLists = filteredCustomLists, + countries = relayCountries, + ) { + it in expandedItems + } + if (relayItems.isEmpty()) { + add(RelayListItem.LocationsEmptyText(searchTerm)) + } else { + addAll(relayItems) + } + } +} + +private fun createRelayListItems( + isSearching: Boolean, + relayListType: RelayListType, + selectedByThisEntryExitList: RelayItemId?, + selectedByOtherEntryExitList: RelayItemId?, + customLists: List<RelayItem.CustomList>, + countries: List<RelayItem.Location.Country>, + isExpanded: (String) -> Boolean, +): List<RelayListItem> = + createCustomListSection( + isSearching, + relayListType, + selectedByThisEntryExitList, + selectedByOtherEntryExitList, + customLists, + isExpanded, + ) + + createLocationSection( + isSearching, + selectedByThisEntryExitList, + relayListType, + selectedByOtherEntryExitList, + countries, + isExpanded, + ) + +private fun createCustomListSection( + isSearching: Boolean, + relayListType: RelayListType, + selectedByThisEntryExitList: RelayItemId?, + selectedByOtherEntryExitList: RelayItemId?, + customLists: List<RelayItem.CustomList>, + isExpanded: (String) -> Boolean, +): List<RelayListItem> = buildList { + if (isSearching && customLists.isEmpty()) { + // If we are searching and no results are found don't show header or footer + } else { + add(RelayListItem.CustomListHeader) + val customListItems = + createCustomListRelayItems( + customLists, + relayListType, + selectedByThisEntryExitList, + selectedByOtherEntryExitList, + isExpanded, + ) + addAll(customListItems) + // Do not show the footer in the search view + if (!isSearching) { + add(RelayListItem.CustomListFooter(customListItems.isNotEmpty())) + } + } +} + +private fun createCustomListRelayItems( + customLists: List<RelayItem.CustomList>, + relayListType: RelayListType, + selectedByThisEntryExitList: RelayItemId?, + selectedByOtherEntryExitList: RelayItemId?, + isExpanded: (String) -> Boolean, +): List<RelayListItem> = + customLists.flatMap { customList -> + val expanded = isExpanded(customList.id.expandKey()) + buildList { + add( + RelayListItem.CustomListItem( + item = customList, + isSelected = selectedByThisEntryExitList == customList.id, + state = + customList.createState( + relayListType = relayListType, + selectedByOther = selectedByOtherEntryExitList, + ), + expanded = expanded, + ) + ) + + if (expanded) { + addAll( + customList.locations.flatMap { + createCustomListEntry( + parent = customList, + item = it, + relayListType = relayListType, + selectedByOtherEntryExitList = selectedByOtherEntryExitList, + depth = 1, + isExpanded = isExpanded, + ) + } + ) + } + } + } + +private fun createLocationSection( + isSearching: Boolean, + selectedByThisEntryExitList: RelayItemId?, + relayListType: RelayListType, + selectedByOtherEntryExitList: RelayItemId?, + countries: List<RelayItem.Location.Country>, + isExpanded: (String) -> Boolean, +): List<RelayListItem> = buildList { + if (isSearching && countries.isEmpty()) { + // If we are searching and no results are found don't show header or footer + } else { + add(RelayListItem.LocationHeader) + addAll( + countries.flatMap { country -> + createGeoLocationEntry( + item = country, + selectedByThisEntryExitList = selectedByThisEntryExitList, + relayListType = relayListType, + selectedByOtherEntryExitList = selectedByOtherEntryExitList, + isExpanded = isExpanded, + ) + } + ) + } +} + +private fun createCustomListEntry( + parent: RelayItem.CustomList, + item: RelayItem.Location, + relayListType: RelayListType, + selectedByOtherEntryExitList: RelayItemId?, + depth: Int = 1, + isExpanded: (String) -> Boolean, +): List<RelayListItem.CustomListEntryItem> = buildList { + val expanded = isExpanded(item.id.expandKey(parent.id)) + add( + RelayListItem.CustomListEntryItem( + parentId = parent.id, + parentName = parent.customList.name, + item = item, + state = + item.createState( + relayListType = relayListType, + selectedByOther = selectedByOtherEntryExitList, + ), + expanded = expanded, + depth = depth, + ) + ) + + if (expanded) { + when (item) { + is RelayItem.Location.City -> + addAll( + item.relays.flatMap { + createCustomListEntry( + parent = parent, + item = it, + relayListType = relayListType, + selectedByOtherEntryExitList = selectedByOtherEntryExitList, + depth = depth + 1, + isExpanded = isExpanded, + ) + } + ) + is RelayItem.Location.Country -> + addAll( + item.cities.flatMap { + createCustomListEntry( + parent = parent, + item = it, + relayListType = relayListType, + selectedByOtherEntryExitList = selectedByOtherEntryExitList, + depth = depth + 1, + isExpanded = isExpanded, + ) + } + ) + is RelayItem.Location.Relay -> {} // No children to add + } + } +} + +private fun createGeoLocationEntry( + item: RelayItem.Location, + relayListType: RelayListType, + selectedByThisEntryExitList: RelayItemId?, + selectedByOtherEntryExitList: RelayItemId?, + depth: Int = 0, + isExpanded: (String) -> Boolean, +): List<RelayListItem.GeoLocationItem> = buildList { + val expanded = isExpanded(item.id.expandKey()) + + add( + RelayListItem.GeoLocationItem( + item = item, + isSelected = selectedByThisEntryExitList == item.id, + state = + item.createState( + relayListType = relayListType, + selectedByOther = selectedByOtherEntryExitList, + ), + depth = depth, + expanded = expanded, + ) + ) + + if (expanded) { + when (item) { + is RelayItem.Location.City -> + addAll( + item.relays.flatMap { + createGeoLocationEntry( + item = it, + relayListType = relayListType, + selectedByThisEntryExitList = selectedByThisEntryExitList, + selectedByOtherEntryExitList = selectedByOtherEntryExitList, + depth = depth + 1, + isExpanded = isExpanded, + ) + } + ) + is RelayItem.Location.Country -> + addAll( + item.cities.flatMap { + createGeoLocationEntry( + item = it, + relayListType = relayListType, + selectedByThisEntryExitList = selectedByThisEntryExitList, + selectedByOtherEntryExitList = selectedByOtherEntryExitList, + depth = depth + 1, + isExpanded = isExpanded, + ) + } + ) + is RelayItem.Location.Relay -> {} // Do nothing + } + } +} + +internal fun RelayItemId.expandKey(parent: CustomListId? = null) = + (parent?.value ?: "") + + when (this) { + is CustomListId -> value + is GeoLocationId -> code + } + +internal fun RelayItemSelection.selectedByThisEntryExitList(relayListType: RelayListType) = + when (this) { + is RelayItemSelection.Multiple -> + when (relayListType) { + RelayListType.ENTRY -> entryLocation + RelayListType.EXIT -> exitLocation + }.getOrNull() + is RelayItemSelection.Single -> exitLocation.getOrNull() + } + +internal fun RelayItemSelection.selectedByOtherEntryExitList( + relayListType: RelayListType, + customLists: List<RelayItem.CustomList>, +) = + when (this) { + is RelayItemSelection.Multiple -> { + val location = + when (relayListType) { + RelayListType.ENTRY -> exitLocation + RelayListType.EXIT -> entryLocation + }.getOrNull() + location.singleRelayId(customLists) + } + is RelayItemSelection.Single -> null + } + +// We only want to block selecting the same entry as exit if it is a relay. For country and +// city it is fine to have same entry and exit +// For custom lists we will block if the custom lists only contains one relay and +// nothing else +private fun RelayItemId?.singleRelayId(customLists: List<RelayItem.CustomList>): RelayItemId? = + when (this) { + is GeoLocationId.City, + is GeoLocationId.Country -> null + is GeoLocationId.Hostname -> this + is CustomListId -> + customLists + .firstOrNull { customList -> customList.id == this } + ?.locations + ?.singleOrNull() + ?.id as? GeoLocationId.Hostname + else -> null + } + +private fun String.isSearching() = length >= MIN_SEARCH_LENGTH + +private fun RelayItem.createState( + relayListType: RelayListType, + selectedByOther: RelayItemId?, +): RelayListItemState? { + val selectedByOther = + when (this) { + is RelayItem.CustomList -> { + selectedByOther == customList.id || + customList.locations.all { it == selectedByOther } + } + is RelayItem.Location.City -> selectedByOther == id + is RelayItem.Location.Country -> selectedByOther == id + is RelayItem.Location.Relay -> selectedByOther == id + } + return if (selectedByOther) { + when (relayListType) { + RelayListType.ENTRY -> RelayListItemState.USED_AS_EXIT + RelayListType.EXIT -> RelayListItemState.USED_AS_ENTRY + } + } else { + null + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt new file mode 100644 index 0000000000..74cecbfdda --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt @@ -0,0 +1,211 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ramcosta.composedestinations.generated.destinations.SearchLocationDestination +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SearchLocationUiState +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH +import net.mullvad.mullvadvpn.relaylist.newFilterOnSearch +import net.mullvad.mullvadvpn.repository.CustomListsRepository +import net.mullvad.mullvadvpn.repository.RelayListFilterRepository +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.usecase.FilterChip +import net.mullvad.mullvadvpn.usecase.FilterChipUseCase +import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase +import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase +import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase +import net.mullvad.mullvadvpn.util.combine + +@Suppress("LongParameterList") +class SearchLocationViewModel( + private val wireguardConstraintsRepository: WireguardConstraintsRepository, + private val relayListRepository: RelayListRepository, + private val filteredRelayListUseCase: FilteredRelayListUseCase, + private val customListActionUseCase: CustomListActionUseCase, + private val customListsRepository: CustomListsRepository, + private val relayListFilterRepository: RelayListFilterRepository, + private val filterChipUseCase: FilterChipUseCase, + filteredCustomListRelayItemsUseCase: FilterCustomListsRelayItemUseCase, + selectedLocationUseCase: SelectedLocationUseCase, + customListsRelayItemUseCase: CustomListsRelayItemUseCase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val relayListType: RelayListType = + SearchLocationDestination.argsFrom(savedStateHandle).relayListType + + private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) + private val _expandedItems = MutableStateFlow<Set<String>>(emptySet()) + + val uiState: StateFlow<SearchLocationUiState> = + combine( + _searchTerm, + searchRelayListLocations(), + filteredCustomListRelayItemsUseCase(relayListType = relayListType), + customListsRelayItemUseCase(), + selectedLocationUseCase(), + filterChips(), + _expandedItems, + ) { + searchTerm, + relayCountries, + filteredCustomLists, + customLists, + selectedItem, + filterChips, + expandedItems -> + if (searchTerm.length >= MIN_SEARCH_LENGTH) { + SearchLocationUiState.Content( + searchTerm = searchTerm, + relayListItems = + relayListItems( + searchTerm = searchTerm, + relayCountries = relayCountries, + relayListType = relayListType, + customLists = filteredCustomLists, + selectedByThisEntryExitList = + selectedItem.selectedByThisEntryExitList(relayListType), + selectedByOtherEntryExitList = + selectedItem.selectedByOtherEntryExitList( + relayListType, + customLists, + ), + expandedItems = expandedItems, + ), + customLists = customLists, + filterChips = filterChips, + ) + } else { + SearchLocationUiState.NoQuery(searchTerm, filterChips) + } + } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + SearchLocationUiState.NoQuery("", emptyList()), + ) + + private val _uiSideEffect = Channel<SearchLocationSideEffect>() + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + fun onSearchInputUpdated(searchTerm: String) { + viewModelScope.launch { _searchTerm.emit(searchTerm) } + } + + fun selectRelay(relayItem: RelayItem) { + viewModelScope.launch { + selectRelayItem( + relayItem = relayItem, + relayListType = relayListType, + selectEntryLocation = wireguardConstraintsRepository::setEntryLocation, + selectExitLocation = relayListRepository::updateSelectedRelayLocation, + ) + .fold( + { _uiSideEffect.send(SearchLocationSideEffect.GenericError) }, + { _uiSideEffect.send(SearchLocationSideEffect.LocationSelected(relayListType)) }, + ) + } + } + + private fun searchRelayListLocations() = + combine(_searchTerm, filteredRelayListUseCase(relayListType)) { searchTerm, relayCountries + -> + val (exp, filteredRelayCountries) = relayCountries.newFilterOnSearch(searchTerm) + exp.map { it.expandKey() }.toSet() to filteredRelayCountries + } + .onEach { _expandedItems.value = it.first } + .map { it.second } + + private fun filterChips() = + combine( + filterChipUseCase(relayListType), + wireguardConstraintsRepository.wireguardConstraints, + ) { filterChips, constraints -> + filterChips.toMutableList().apply { + // Do not show entry and exit filter chips if multihop is disabled + if (constraints?.isMultihopEnabled == true) { + add( + when (relayListType) { + RelayListType.ENTRY -> FilterChip.Entry + RelayListType.EXIT -> FilterChip.Exit + } + ) + } + } + } + + fun addLocationToList(item: RelayItem.Location, customList: RelayItem.CustomList) { + viewModelScope.launch { + val result = + addLocationToCustomList( + item = item, + customList = customList, + update = customListActionUseCase::invoke, + ) + _uiSideEffect.send(SearchLocationSideEffect.CustomListActionToast(result)) + } + } + + fun removeLocationFromList(item: RelayItem.Location, customListId: CustomListId) { + viewModelScope.launch { + val result = + removeLocationFromCustomList( + item = item, + customListId = customListId, + getCustomListById = customListsRepository::getCustomListById, + update = customListActionUseCase::invoke, + ) + _uiSideEffect.trySend(SearchLocationSideEffect.CustomListActionToast(result)) + } + } + + fun performAction(action: CustomListAction) { + viewModelScope.launch { customListActionUseCase(action) } + } + + fun removeOwnerFilter() { + viewModelScope.launch { relayListFilterRepository.updateSelectedOwnership(Constraint.Any) } + } + + fun removeProviderFilter() { + viewModelScope.launch { relayListFilterRepository.updateSelectedProviders(Constraint.Any) } + } + + fun onToggleExpand(item: RelayItemId, parent: CustomListId? = null, expand: Boolean) { + _expandedItems.onToggleExpand(item = item, parent = parent, expand = expand) + } + + companion object { + private const val EMPTY_SEARCH_TERM = "" + } +} + +sealed interface SearchLocationSideEffect { + data class LocationSelected(val relayListType: RelayListType) : SearchLocationSideEffect + + data class CustomListActionToast(val resultData: CustomListActionResultData) : + SearchLocationSideEffect + + data object GenericError : SearchLocationSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModel.kt new file mode 100644 index 0000000000..d5063f0f44 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModel.kt @@ -0,0 +1,89 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase +import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase +import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase + +class SelectLocationListViewModel( + private val relayListType: RelayListType, + private val filteredRelayListUseCase: FilteredRelayListUseCase, + private val filteredCustomListRelayItemsUseCase: FilterCustomListsRelayItemUseCase, + private val selectedLocationUseCase: SelectedLocationUseCase, + private val wireguardConstraintsRepository: WireguardConstraintsRepository, + private val relayListRepository: RelayListRepository, + customListsRelayItemUseCase: CustomListsRelayItemUseCase, +) : ViewModel() { + private val _expandedItems: MutableStateFlow<Set<String>> = + MutableStateFlow(initialExpand(initialSelection())) + + val uiState: StateFlow<SelectLocationListUiState> = + combine(relayListItems(), customListsRelayItemUseCase()) { relayListItems, customLists -> + SelectLocationListUiState.Content( + relayListItems = relayListItems, + customLists = customLists, + ) + } + .stateIn(viewModelScope, SharingStarted.Lazily, SelectLocationListUiState.Loading) + + fun onToggleExpand(item: RelayItemId, parent: CustomListId? = null, expand: Boolean) { + _expandedItems.onToggleExpand(item, parent, expand) + } + + private fun relayListItems() = + combine( + filteredRelayListUseCase(relayListType = relayListType), + filteredCustomListRelayItemsUseCase(relayListType = relayListType), + selectedLocationUseCase(), + _expandedItems, + ) { relayCountries, customLists, selectedItem, expandedItems -> + relayListItems( + relayCountries = relayCountries, + relayListType = relayListType, + customLists = customLists, + selectedByThisEntryExitList = + selectedItem.selectedByThisEntryExitList(relayListType), + selectedByOtherEntryExitList = + selectedItem.selectedByOtherEntryExitList(relayListType, customLists), + expandedItems = expandedItems, + ) + } + + private fun initialExpand(item: RelayItemId?): Set<String> = buildSet { + when (item) { + is GeoLocationId.City -> { + add(item.country.code) + } + is GeoLocationId.Hostname -> { + add(item.country.code) + add(item.city.code) + } + is CustomListId, + is GeoLocationId.Country, + null -> { + /* No expands */ + } + } + } + + private fun initialSelection() = + when (relayListType) { + RelayListType.ENTRY -> + wireguardConstraintsRepository.wireguardConstraints.value?.entryLocation + RelayListType.EXIT -> relayListRepository.selectedLocation.value + }?.getOrNull() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt new file mode 100644 index 0000000000..dd6736a45d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt @@ -0,0 +1,145 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.repository.CustomListsRepository +import net.mullvad.mullvadvpn.repository.RelayListFilterRepository +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.usecase.FilterChipUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase + +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("TooManyFunctions") +class SelectLocationViewModel( + private val relayListFilterRepository: RelayListFilterRepository, + private val customListsRepository: CustomListsRepository, + private val customListActionUseCase: CustomListActionUseCase, + private val relayListRepository: RelayListRepository, + private val wireguardConstraintsRepository: WireguardConstraintsRepository, + private val filterChipUseCase: FilterChipUseCase, +) : ViewModel() { + private val _relayListType: MutableStateFlow<RelayListType> = + MutableStateFlow(initialRelayListSelection()) + + val uiState = + combine( + filterChips(), + wireguardConstraintsRepository.wireguardConstraints, + _relayListType, + ) { filterChips, wireguardConstraints, relayListSelection -> + SelectLocationUiState( + filterChips = filterChips, + multihopEnabled = wireguardConstraints?.isMultihopEnabled == true, + relayListType = relayListSelection, + ) + } + .stateIn( + viewModelScope, + SharingStarted.Lazily, + SelectLocationUiState( + filterChips = emptyList(), + multihopEnabled = false, + relayListType = RelayListType.EXIT, + ), + ) + + private val _uiSideEffect = Channel<SelectLocationSideEffect>() + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + private fun initialRelayListSelection() = + if (wireguardConstraintsRepository.wireguardConstraints.value?.isMultihopEnabled == true) { + RelayListType.ENTRY + } else { + RelayListType.EXIT + } + + private fun filterChips() = _relayListType.flatMapLatest { filterChipUseCase(it) } + + fun selectRelayList(relayListType: RelayListType) { + viewModelScope.launch { _relayListType.emit(relayListType) } + } + + fun selectRelay(relayItem: RelayItem) { + viewModelScope.launch { + selectRelayItem( + relayItem = relayItem, + relayListType = _relayListType.value, + selectEntryLocation = wireguardConstraintsRepository::setEntryLocation, + selectExitLocation = relayListRepository::updateSelectedRelayLocation, + ) + .fold( + { _uiSideEffect.send(SelectLocationSideEffect.GenericError) }, + { + when (_relayListType.value) { + RelayListType.ENTRY -> _relayListType.emit(RelayListType.EXIT) + RelayListType.EXIT -> + _uiSideEffect.send(SelectLocationSideEffect.CloseScreen) + } + }, + ) + } + } + + fun addLocationToList(item: RelayItem.Location, customList: RelayItem.CustomList) { + viewModelScope.launch { + val result = + addLocationToCustomList( + item = item, + customList = customList, + update = customListActionUseCase::invoke, + ) + _uiSideEffect.send(SelectLocationSideEffect.CustomListActionToast(result)) + } + } + + fun removeLocationFromList(item: RelayItem.Location, customListId: CustomListId) { + viewModelScope.launch { + val result = + removeLocationFromCustomList( + item = item, + customListId = customListId, + getCustomListById = customListsRepository::getCustomListById, + update = customListActionUseCase::invoke, + ) + _uiSideEffect.trySend(SelectLocationSideEffect.CustomListActionToast(result)) + } + } + + fun performAction(action: CustomListAction) { + viewModelScope.launch { customListActionUseCase(action) } + } + + fun removeOwnerFilter() { + viewModelScope.launch { relayListFilterRepository.updateSelectedOwnership(Constraint.Any) } + } + + fun removeProviderFilter() { + viewModelScope.launch { relayListFilterRepository.updateSelectedProviders(Constraint.Any) } + } +} + +sealed interface SelectLocationSideEffect { + data object CloseScreen : SelectLocationSideEffect + + data class CustomListActionToast(val resultData: CustomListActionResultData) : + SelectLocationSideEffect + + data object GenericError : SelectLocationSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectRelay.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectRelay.kt new file mode 100644 index 0000000000..8d6c90961b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectRelay.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import arrow.core.Either +import arrow.core.raise.either +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId + +internal suspend fun selectRelayItem( + relayItem: RelayItem, + relayListType: RelayListType, + selectEntryLocation: suspend (RelayItemId) -> Either<Any, Unit>, + selectExitLocation: suspend (RelayItemId) -> Either<Any, Unit>, +) = + either<Any, Unit> { + val locationConstraint = relayItem.id + when (relayListType) { + RelayListType.ENTRY -> selectEntryLocation(locationConstraint) + RelayListType.EXIT -> selectExitLocation(locationConstraint) + } + } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCaseTest.kt new file mode 100644 index 0000000000..8b3d6d68a2 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCaseTest.kt @@ -0,0 +1,146 @@ +package net.mullvad.mullvadvpn.usecase + +import app.cash.turbine.test +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.lib.common.test.assertLists +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Provider +import net.mullvad.mullvadvpn.lib.model.ProviderId +import net.mullvad.mullvadvpn.lib.model.Providers +import net.mullvad.mullvadvpn.lib.model.Settings +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints +import net.mullvad.mullvadvpn.repository.RelayListFilterRepository +import net.mullvad.mullvadvpn.repository.SettingsRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class FilterChipUseCaseTest { + + private val mockRelayListFilterRepository: RelayListFilterRepository = mockk() + private val mockAvailableProvidersUseCase: AvailableProvidersUseCase = mockk() + private val mockSettingRepository: SettingsRepository = mockk() + private val mockWireguardConstraintsRepository: WireguardConstraintsRepository = mockk() + + private val selectedOwnership = MutableStateFlow<Constraint<Ownership>>(Constraint.Any) + private val selectedProviders = MutableStateFlow<Constraint<Providers>>(Constraint.Any) + private val availableProviders = MutableStateFlow<List<Provider>>(emptyList()) + private val settings = MutableStateFlow<Settings>(mockk(relaxed = true)) + private val wireguardConstraints = MutableStateFlow<WireguardConstraints>(mockk(relaxed = true)) + + private lateinit var filterChipUseCase: FilterChipUseCase + + @BeforeEach + fun setUp() { + every { mockRelayListFilterRepository.selectedOwnership } returns selectedOwnership + every { mockRelayListFilterRepository.selectedProviders } returns selectedProviders + every { mockAvailableProvidersUseCase() } returns availableProviders + every { mockSettingRepository.settingsUpdates } returns settings + every { mockWireguardConstraintsRepository.wireguardConstraints } returns + wireguardConstraints + + filterChipUseCase = + FilterChipUseCase( + relayListFilterRepository = mockRelayListFilterRepository, + availableProvidersUseCase = mockAvailableProvidersUseCase, + settingsRepository = mockSettingRepository, + wireguardConstraintsRepository = mockWireguardConstraintsRepository, + ) + } + + @Test + fun `when no filters are applied should return empty list`() = runTest { + filterChipUseCase(RelayListType.EXIT).test { assertLists(emptyList(), awaitItem()) } + } + + @Test + fun `when ownership filter is applied should return correct ownership`() = runTest { + // Arrange + val expectedOwnership = Ownership.MullvadOwned + selectedOwnership.value = Constraint.Only(expectedOwnership) + + filterChipUseCase(RelayListType.EXIT).test { + assertLists(listOf(FilterChip.Ownership(expectedOwnership)), awaitItem()) + } + } + + @Test + fun `when provider filter is applied should return correct number of providers`() = runTest { + // Arrange + val expectedProviders = Providers(providers = setOf(ProviderId("1"), ProviderId("2"))) + selectedProviders.value = Constraint.Only(expectedProviders) + availableProviders.value = + listOf( + Provider(ProviderId("1"), Ownership.MullvadOwned), + Provider(ProviderId("2"), Ownership.Rented), + ) + + filterChipUseCase(RelayListType.EXIT).test { + assertLists(listOf(FilterChip.Provider(2)), awaitItem()) + } + } + + @Test + fun `when provider and ownership filter is applied should return correct filter chips`() = + runTest { + // Arrange + val expectedProviders = Providers(providers = setOf(ProviderId("1"))) + val expectedOwnership = Ownership.MullvadOwned + selectedProviders.value = Constraint.Only(expectedProviders) + selectedOwnership.value = Constraint.Only(expectedOwnership) + availableProviders.value = + listOf( + Provider(ProviderId("1"), Ownership.MullvadOwned), + Provider(ProviderId("2"), Ownership.Rented), + ) + + filterChipUseCase(RelayListType.EXIT).test { + assertLists( + listOf(FilterChip.Ownership(expectedOwnership), FilterChip.Provider(1)), + awaitItem(), + ) + } + } + + @Test + fun `when Daita is enabled and multihop is disabled should return Daita filter chip`() = + runTest { + // Arrange + settings.value = mockk(relaxed = true) { every { isDaitaEnabled() } returns true } + wireguardConstraints.value = + mockk(relaxed = true) { every { isMultihopEnabled } returns false } + + filterChipUseCase(RelayListType.EXIT).test { + assertLists(listOf(FilterChip.Daita), awaitItem()) + } + } + + @Test + fun `when Daita is enabled and multihop is enabled and relay list type is entry should return Daita filter chip`() = + runTest { + // Arrange + settings.value = mockk(relaxed = true) { every { isDaitaEnabled() } returns true } + wireguardConstraints.value = + mockk(relaxed = true) { every { isMultihopEnabled } returns true } + + filterChipUseCase(RelayListType.ENTRY).test { + assertLists(listOf(FilterChip.Daita), awaitItem()) + } + } + + @Test + fun `when Daita is enabled and multihop is enabled and relay list type is exit should return no filter`() = + runTest { + // Arrange + settings.value = mockk(relaxed = true) { every { isDaitaEnabled() } returns true } + wireguardConstraints.value = + mockk(relaxed = true) { every { isMultihopEnabled } returns true } + + filterChipUseCase(RelayListType.EXIT).test { assertLists(emptyList(), awaitItem()) } + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationUseCaseTest.kt new file mode 100644 index 0000000000..deef7b7ab9 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationUseCaseTest.kt @@ -0,0 +1,71 @@ +package net.mullvad.mullvadvpn.usecase + +import app.cash.turbine.test +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.lib.model.RelayItemSelection +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class SelectedLocationUseCaseTest { + private val mockRelayListRepository: RelayListRepository = mockk() + private val mockWireguardConstraintsRepository: WireguardConstraintsRepository = mockk() + + private val selectedLocation = MutableStateFlow<Constraint<RelayItemId>>(Constraint.Any) + private val wireguardConstraints = MutableStateFlow<WireguardConstraints>(mockk(relaxed = true)) + + private lateinit var selectLocationUseCase: SelectedLocationUseCase + + @BeforeEach + fun setup() { + every { mockRelayListRepository.selectedLocation } returns selectedLocation + every { mockWireguardConstraintsRepository.wireguardConstraints } returns + wireguardConstraints + + selectLocationUseCase = + SelectedLocationUseCase( + relayListRepository = mockRelayListRepository, + wireguardConstraintsRepository = mockWireguardConstraintsRepository, + ) + } + + @Test + fun `when wireguard constraints is multihop enabled should return Multiple`() = runTest { + // Arrange + val entryLocation: Constraint<RelayItemId> = Constraint.Only(GeoLocationId.Country("se")) + val exitLocation = Constraint.Only(GeoLocationId.Country("us")) + wireguardConstraints.value = + WireguardConstraints( + isMultihopEnabled = true, + entryLocation = entryLocation, + port = Constraint.Any, + ) + selectedLocation.value = exitLocation + + // Act, Assert + selectLocationUseCase().test { + assertEquals(RelayItemSelection.Multiple(entryLocation, exitLocation), awaitItem()) + } + } + + @Test + fun `when wireguard constraints is multihop disabled should return Single`() = runTest { + // Arrange + val exitLocation = Constraint.Only(GeoLocationId.Country("us")) + selectedLocation.value = exitLocation + + // Act, Assert + selectLocationUseCase().test { + assertEquals(RelayItemSelection.Single(exitLocation), awaitItem()) + } + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModelTest.kt new file mode 100644 index 0000000000..34cb1353bb --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModelTest.kt @@ -0,0 +1,68 @@ +package net.mullvad.mullvadvpn.viewmodel + +import app.cash.turbine.test +import arrow.core.Either +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(TestCoroutineRule::class) +class MultihopViewModelTest { + + private val mockWireguardConstraintsRepository: WireguardConstraintsRepository = mockk() + + private val wireguardConstraints = MutableStateFlow<WireguardConstraints>(mockk(relaxed = true)) + + private lateinit var multihopViewModel: MultihopViewModel + + @BeforeEach + fun setUp() { + every { mockWireguardConstraintsRepository.wireguardConstraints } returns + wireguardConstraints + + multihopViewModel = + MultihopViewModel(wireguardConstraintsRepository = mockWireguardConstraintsRepository) + } + + @Test + fun `default state should be multihop disabled`() { + assertEquals(false, multihopViewModel.uiState.value.enable) + } + + @Test + fun `when multihop enabled is true state should return multihop enabled true`() = runTest { + // Arrange + wireguardConstraints.value = + WireguardConstraints( + isMultihopEnabled = true, + entryLocation = Constraint.Any, + port = Constraint.Any, + ) + + // Act, Assert + multihopViewModel.uiState.test { assertEquals(MultihopUiState(true), awaitItem()) } + } + + @Test + fun `when set multihop is called should call repository set multihop`() = runTest { + // Arrange + coEvery { mockWireguardConstraintsRepository.setMultihop(any()) } returns Either.Right(Unit) + + // Act + multihopViewModel.setMultihop(true) + + // Assert + coVerify { mockWireguardConstraintsRepository.setMultihop(true) } + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt index 8857eb364a..f2468cbb11 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt @@ -10,8 +10,11 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.DeviceState +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints import net.mullvad.mullvadvpn.lib.shared.DeviceRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository import net.mullvad.mullvadvpn.ui.VersionInfo import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository import org.junit.jupiter.api.AfterEach @@ -24,9 +27,11 @@ class SettingsViewModelTest { private val mockDeviceRepository: DeviceRepository = mockk() private val mockAppVersionInfoRepository: AppVersionInfoRepository = mockk() + private val mockWireguardConstraintsRepository: WireguardConstraintsRepository = mockk() private val versionInfo = MutableStateFlow(VersionInfo(currentVersion = "", isSupported = false)) + private val wireguardConstraints = MutableStateFlow<WireguardConstraints>(mockk(relaxed = true)) private lateinit var viewModel: SettingsViewModel @@ -36,11 +41,14 @@ class SettingsViewModelTest { every { mockDeviceRepository.deviceState } returns deviceState every { mockAppVersionInfoRepository.versionInfo } returns versionInfo + every { mockWireguardConstraintsRepository.wireguardConstraints } returns + wireguardConstraints viewModel = SettingsViewModel( deviceRepository = mockDeviceRepository, appVersionInfoRepository = mockAppVersionInfoRepository, + wireguardConstraintsRepository = mockWireguardConstraintsRepository, isPlayBuild = false, ) } @@ -84,4 +92,22 @@ class SettingsViewModelTest { assertEquals(false, result.isSupportedVersion) } } + + @Test + fun `when WireguardConstraintsRepository return multihop enabled uiState should return multihop enabled true`() = + runTest { + // Arrange + wireguardConstraints.value = + WireguardConstraints( + isMultihopEnabled = true, + entryLocation = Constraint.Any, + port = Constraint.Any, + ) + + // Act, Assert + viewModel.uiState.test { + val result = awaitItem() + assertEquals(true, result.multihopEnabled) + } + } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt index 340809fbb3..427b003d33 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt @@ -189,7 +189,7 @@ class VpnSettingsViewModelTest { val wireguardConstraints = WireguardConstraints( port = wireguardPort, - useMultihop = false, + isMultihopEnabled = false, entryLocation = Constraint.Any, ) coEvery { mockWireguardConstraintsRepository.setWireguardPort(any()) } returns diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt new file mode 100644 index 0000000000..be60f9d723 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt @@ -0,0 +1,161 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import app.cash.turbine.test +import com.ramcosta.composedestinations.generated.navargs.toSavedStateHandle +import io.mockk.every +import io.mockk.mockk +import kotlin.test.assertIs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.screen.location.SearchLocationNavArgs +import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SearchLocationUiState +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.common.test.assertLists +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemSelection +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints +import net.mullvad.mullvadvpn.repository.CustomListsRepository +import net.mullvad.mullvadvpn.repository.RelayListFilterRepository +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.usecase.FilterChip +import net.mullvad.mullvadvpn.usecase.FilterChipUseCase +import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase +import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase +import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(TestCoroutineRule::class) +class SearchLocationViewModelTest { + + private val mockWireguardConstraintsRepository: WireguardConstraintsRepository = mockk() + private val mockRelayListRepository: RelayListRepository = mockk() + private val mockFilteredRelayListUseCase: FilteredRelayListUseCase = mockk() + private val mockCustomListActionUseCase: CustomListActionUseCase = mockk() + private val mockCustomListsRepository: CustomListsRepository = mockk() + private val mockRelayListFilterRepository: RelayListFilterRepository = mockk() + private val mockFilterChipUseCase: FilterChipUseCase = mockk() + private val mockFilteredCustomListRelayItemsUseCase: FilterCustomListsRelayItemUseCase = mockk() + private val mockSelectedLocationUseCase: SelectedLocationUseCase = mockk() + private val mockCustomListsRelayItemUseCase: CustomListsRelayItemUseCase = mockk() + + private val filteredRelayList = MutableStateFlow<List<RelayItem.Location.Country>>(emptyList()) + private val selectedLocation = + MutableStateFlow<RelayItemSelection>(RelayItemSelection.Single(Constraint.Any)) + private val filteredCustomListRelayItems = + MutableStateFlow<List<RelayItem.CustomList>>(emptyList()) + private val customListRelayItems = MutableStateFlow<List<RelayItem.CustomList>>(emptyList()) + private val filterChips = MutableStateFlow<List<FilterChip>>(emptyList()) + private val wireguardConstraints = MutableStateFlow<WireguardConstraints>(mockk(relaxed = true)) + + private lateinit var viewModel: SearchLocationViewModel + + @BeforeEach + fun setup() { + every { mockFilteredRelayListUseCase(any()) } returns filteredRelayList + every { mockSelectedLocationUseCase() } returns selectedLocation + every { mockFilteredCustomListRelayItemsUseCase(any()) } returns + filteredCustomListRelayItems + every { mockCustomListsRelayItemUseCase() } returns customListRelayItems + every { mockFilterChipUseCase(any()) } returns filterChips + every { mockWireguardConstraintsRepository.wireguardConstraints } returns + wireguardConstraints + + viewModel = + SearchLocationViewModel( + wireguardConstraintsRepository = mockWireguardConstraintsRepository, + relayListRepository = mockRelayListRepository, + filteredRelayListUseCase = mockFilteredRelayListUseCase, + customListActionUseCase = mockCustomListActionUseCase, + customListsRepository = mockCustomListsRepository, + relayListFilterRepository = mockRelayListFilterRepository, + filterChipUseCase = mockFilterChipUseCase, + filteredCustomListRelayItemsUseCase = mockFilteredCustomListRelayItemsUseCase, + selectedLocationUseCase = mockSelectedLocationUseCase, + customListsRelayItemUseCase = mockCustomListsRelayItemUseCase, + savedStateHandle = + SearchLocationNavArgs(relayListType = RelayListType.ENTRY).toSavedStateHandle(), + ) + } + + @Test + fun `on onSearchTermInput call uiState should emit with filtered countries`() = runTest { + // Arrange + val mockSearchString = "got" + filteredRelayList.value = testCountries + + // Act, Assert + viewModel.uiState.test() { + // Wait for first data + assertIs<SearchLocationUiState.NoQuery>(awaitItem()) + + // Update search string + viewModel.onSearchInputUpdated(mockSearchString) + + // We get some unnecessary emissions for now + awaitItem() + + val actualState = awaitItem() + assertIs<SearchLocationUiState.Content>(actualState) + assertTrue( + actualState.relayListItems.filterIsInstance<RelayListItem.GeoLocationItem>().any { + it.item is RelayItem.Location.City && it.item.name == "Gothenburg" + } + ) + } + } + + @Test + fun `when onSearchTermInput returns empty result uiState should return empty list`() = runTest { + // Arrange + filteredRelayList.value = testCountries + val mockSearchString = "SEARCH" + + // Act, Assert + viewModel.uiState.test { + // Wait for first data + assertIs<SearchLocationUiState.NoQuery>(awaitItem()) + + // Update search string + viewModel.onSearchInputUpdated(mockSearchString) + + // We get some unnecessary emissions for now + awaitItem() + + // Assert + val actualState = awaitItem() + assertIs<SearchLocationUiState.Content>(actualState) + assertLists( + listOf(RelayListItem.LocationsEmptyText(mockSearchString)), + actualState.relayListItems, + ) + } + } + + companion object { + private val testCountries = + listOf( + RelayItem.Location.Country( + id = GeoLocationId.Country("se"), + "Sweden", + listOf( + RelayItem.Location.City( + id = GeoLocationId.City(GeoLocationId.Country("se"), "got"), + "Gothenburg", + emptyList(), + ) + ), + ), + RelayItem.Location.Country(id = GeoLocationId.Country("no"), "Norway", emptyList()), + ) + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModelTest.kt new file mode 100644 index 0000000000..3584877170 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModelTest.kt @@ -0,0 +1,158 @@ +package net.mullvad.mullvadvpn.viewmodel.location + +import app.cash.turbine.test +import io.mockk.every +import io.mockk.mockk +import kotlin.test.assertIs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.RelayListType +import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.common.test.assertLists +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemSelection +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase +import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase +import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(TestCoroutineRule::class) +class SelectLocationListViewModelTest { + + private val mockFilteredRelayListUseCase: FilteredRelayListUseCase = mockk() + private val mockFilteredCustomListRelayItemsUseCase: FilterCustomListsRelayItemUseCase = mockk() + private val mockSelectedLocationUseCase: SelectedLocationUseCase = mockk() + private val mockWireguardConstraintsRepository: WireguardConstraintsRepository = mockk() + private val mockRelayListRepository: RelayListRepository = mockk() + private val mockCustomListRelayItemsUseCase: CustomListsRelayItemUseCase = mockk() + + private val filteredRelayList = MutableStateFlow<List<RelayItem.Location.Country>>(emptyList()) + private val selectedLocationFlow = MutableStateFlow<RelayItemSelection>(mockk(relaxed = true)) + private val filteredCustomListRelayItems = + MutableStateFlow<List<RelayItem.CustomList>>(emptyList()) + private val customListRelayItems = MutableStateFlow<List<RelayItem.CustomList>>(emptyList()) + + private lateinit var viewModel: SelectLocationListViewModel + + @BeforeEach + fun setUp() { + // Used for initial selection + every { mockRelayListRepository.selectedLocation } returns MutableStateFlow(Constraint.Any) + every { mockWireguardConstraintsRepository.wireguardConstraints } returns + MutableStateFlow(null) + + every { mockSelectedLocationUseCase() } returns selectedLocationFlow + every { mockFilteredRelayListUseCase(any()) } returns filteredRelayList + every { mockFilteredCustomListRelayItemsUseCase(any()) } returns + filteredCustomListRelayItems + every { mockCustomListRelayItemsUseCase() } returns customListRelayItems + } + + @Test + fun `initial state should be loading`() = runTest { + // Arrange + viewModel = createSelectLocationListViewModel(relayListType = RelayListType.ENTRY) + + // Assert + assertEquals(SelectLocationListUiState.Loading, viewModel.uiState.value) + } + + @Test + fun `given filteredRelayList emits update uiState should contain new update`() = runTest { + // Arrange + viewModel = createSelectLocationListViewModel(RelayListType.EXIT) + filteredRelayList.value = testCountries + val selectedId = testCountries.first().id + selectedLocationFlow.value = RelayItemSelection.Single(Constraint.Only(selectedId)) + + // Act, Assert + viewModel.uiState.test { + val actualState = awaitItem() + assertIs<SelectLocationListUiState.Content>(actualState) + assertLists( + testCountries.map { it.id }, + actualState.relayListItems.mapNotNull { it.relayItemId() }, + ) + assertTrue( + actualState.relayListItems + .filterIsInstance<RelayListItem.SelectableItem>() + .first { it.relayItemId() == selectedId } + .isSelected + ) + } + } + + @Test + fun `given relay is not selected all relay items should not be selected`() = runTest { + // Arrange + viewModel = createSelectLocationListViewModel(RelayListType.EXIT) + filteredRelayList.value = testCountries + selectedLocationFlow.value = RelayItemSelection.Single(Constraint.Any) + + // Act, Assert + viewModel.uiState.test { + val actualState = awaitItem() + assertIs<SelectLocationListUiState.Content>(actualState) + assertLists( + testCountries.map { it.id }, + actualState.relayListItems.mapNotNull { it.relayItemId() }, + ) + assertTrue( + actualState.relayListItems.filterIsInstance<RelayListItem.SelectableItem>().all { + !it.isSelected + } + ) + } + } + + private fun createSelectLocationListViewModel(relayListType: RelayListType) = + SelectLocationListViewModel( + relayListType = relayListType, + filteredRelayListUseCase = mockFilteredRelayListUseCase, + filteredCustomListRelayItemsUseCase = mockFilteredCustomListRelayItemsUseCase, + selectedLocationUseCase = mockSelectedLocationUseCase, + wireguardConstraintsRepository = mockWireguardConstraintsRepository, + relayListRepository = mockRelayListRepository, + customListsRelayItemUseCase = mockCustomListRelayItemsUseCase, + ) + + private fun RelayListItem.relayItemId() = + when (this) { + is RelayListItem.CustomListFooter -> null + RelayListItem.CustomListHeader -> null + RelayListItem.LocationHeader -> null + is RelayListItem.LocationsEmptyText -> null + is RelayListItem.CustomListEntryItem -> item.id + is RelayListItem.CustomListItem -> item.id + is RelayListItem.GeoLocationItem -> item.id + } + + companion object { + private val testCountries = + listOf( + RelayItem.Location.Country( + id = GeoLocationId.Country("se"), + "Sweden", + listOf( + RelayItem.Location.City( + id = GeoLocationId.City(GeoLocationId.Country("se"), "got"), + "Gothenburg", + emptyList(), + ) + ), + ), + RelayItem.Location.Country(id = GeoLocationId.Country("no"), "Norway", emptyList()), + ) + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt index bee888d279..ef21eac139 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.viewmodel +package net.mullvad.mullvadvpn.viewmodel.location import androidx.lifecycle.viewModelScope import app.cash.turbine.test @@ -11,39 +11,35 @@ import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlin.test.assertEquals import kotlin.test.assertIs -import kotlin.test.assertTrue import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.communication.CustomListAction import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.communication.LocationsChanged -import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.lib.common.test.assertLists import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.CustomList import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.CustomListName import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.model.Ownership -import net.mullvad.mullvadvpn.lib.model.Provider import net.mullvad.mullvadvpn.lib.model.Providers import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.model.RelayItemId -import net.mullvad.mullvadvpn.lib.model.Settings +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints import net.mullvad.mullvadvpn.relaylist.descendants import net.mullvad.mullvadvpn.repository.CustomListsRepository import net.mullvad.mullvadvpn.repository.RelayListFilterRepository import net.mullvad.mullvadvpn.repository.RelayListRepository -import net.mullvad.mullvadvpn.repository.SettingsRepository -import net.mullvad.mullvadvpn.usecase.AvailableProvidersUseCase -import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase +import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository +import net.mullvad.mullvadvpn.usecase.FilterChip +import net.mullvad.mullvadvpn.usecase.FilterChipUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase -import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase -import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -52,39 +48,25 @@ import org.junit.jupiter.api.extension.ExtendWith class SelectLocationViewModelTest { private val mockRelayListFilterRepository: RelayListFilterRepository = mockk() - private val mockAvailableProvidersUseCase: AvailableProvidersUseCase = mockk(relaxed = true) private val mockCustomListActionUseCase: CustomListActionUseCase = mockk(relaxed = true) - private val mockFilteredCustomListRelayItemsUseCase: FilterCustomListsRelayItemUseCase = mockk() - private val mockFilteredRelayListUseCase: FilteredRelayListUseCase = mockk() private val mockRelayListRepository: RelayListRepository = mockk() private val mockCustomListsRepository: CustomListsRepository = mockk() - private val mockCustomListsRelayItemUseCase: CustomListsRelayItemUseCase = mockk() - - private val mockSettingsRepository: SettingsRepository = mockk() - private val settingsFlow = MutableStateFlow(mockk<Settings>(relaxed = true)) + private val mockWireguardConstraintsRepository: WireguardConstraintsRepository = mockk() + private val mockFilterChipUseCase: FilterChipUseCase = mockk() private lateinit var viewModel: SelectLocationViewModel - private val allProviders = MutableStateFlow<List<Provider>>(emptyList()) - private val selectedOwnership = MutableStateFlow<Constraint<Ownership>>(Constraint.Any) - private val selectedProviders = MutableStateFlow<Constraint<Providers>>(Constraint.Any) private val selectedRelayItemFlow = MutableStateFlow<Constraint<RelayItemId>>(Constraint.Any) - private val filteredRelayList = MutableStateFlow<List<RelayItem.Location.Country>>(emptyList()) - private val filteredCustomRelayListItems = - MutableStateFlow<List<RelayItem.CustomList>>(emptyList()) - private val customListsRelayItem = MutableStateFlow<List<RelayItem.CustomList>>(emptyList()) + private val wireguardConstraints = MutableStateFlow<WireguardConstraints>(mockk(relaxed = true)) + private val filterChips = MutableStateFlow<List<FilterChip>>(emptyList()) @BeforeEach fun setup() { - every { mockRelayListFilterRepository.selectedOwnership } returns selectedOwnership - every { mockRelayListFilterRepository.selectedProviders } returns selectedProviders - every { mockAvailableProvidersUseCase() } returns allProviders every { mockRelayListRepository.selectedLocation } returns selectedRelayItemFlow - every { mockFilteredRelayListUseCase() } returns filteredRelayList - every { mockFilteredCustomListRelayItemsUseCase() } returns filteredCustomRelayListItems - every { mockCustomListsRelayItemUseCase() } returns customListsRelayItem - every { mockSettingsRepository.settingsUpdates } returns settingsFlow + every { mockWireguardConstraintsRepository.wireguardConstraints } returns + wireguardConstraints + every { mockFilterChipUseCase(any()) } returns filterChips mockkStatic(RELAY_LIST_EXTENSIONS) mockkStatic(RELAY_ITEM_EXTENSIONS) @@ -92,14 +74,11 @@ class SelectLocationViewModelTest { viewModel = SelectLocationViewModel( relayListFilterRepository = mockRelayListFilterRepository, - availableProvidersUseCase = mockAvailableProvidersUseCase, - filteredCustomListRelayItemsUseCase = mockFilteredCustomListRelayItemsUseCase, customListActionUseCase = mockCustomListActionUseCase, - filteredRelayListUseCase = mockFilteredRelayListUseCase, relayListRepository = mockRelayListRepository, customListsRepository = mockCustomListsRepository, - customListsRelayItemUseCase = mockCustomListsRelayItemUseCase, - settingsRepository = mockSettingsRepository, + filterChipUseCase = mockFilterChipUseCase, + wireguardConstraintsRepository = mockWireguardConstraintsRepository, ) } @@ -110,131 +89,59 @@ class SelectLocationViewModelTest { } @Test - fun `initial state should be loading`() = runTest { - assertEquals(SelectLocationUiState.Loading, viewModel.uiState.value) - } - - @Test - fun `given filteredRelayList emits update uiState should contain new update`() = runTest { - // Arrange - filteredRelayList.value = testCountries - val selectedId = testCountries.first().id - selectedRelayItemFlow.value = Constraint.Only(selectedId) - - // Act, Assert - viewModel.uiState.test { - val actualState = awaitItem() - assertIs<SelectLocationUiState.Content>(actualState) - assertLists( - testCountries.map { it.id }, - actualState.relayListItems.mapNotNull { it.relayItemId() }, - ) - assertTrue( - actualState.relayListItems - .filterIsInstance<RelayListItem.SelectableItem>() - .first { it.relayItemId() == selectedId } - .isSelected - ) - } - } - - @Test - fun `given relay is selected all relay items should not be selected`() = runTest { - // Arrange - filteredRelayList.value = testCountries - selectedRelayItemFlow.value = Constraint.Any - - // Act, Assert - viewModel.uiState.test { - val actualState = awaitItem() - assertIs<SelectLocationUiState.Content>(actualState) - assertLists( - testCountries.map { it.id }, - actualState.relayListItems.mapNotNull { it.relayItemId() }, - ) - assertTrue( - actualState.relayListItems.filterIsInstance<RelayListItem.SelectableItem>().all { - !it.isSelected - } - ) - } - } - - @Test - fun `on selectRelay call uiSideEffect should emit CloseScreen and connect`() = runTest { - // Arrange - val mockRelayItem: RelayItem.Location.Country = mockk() - val relayItemId: GeoLocationId.Country = mockk(relaxed = true) - every { mockRelayItem.id } returns relayItemId - coEvery { mockRelayListRepository.updateSelectedRelayLocation(relayItemId) } returns - Unit.right() - - // Act, Assert - viewModel.uiSideEffect.test { - viewModel.selectRelay(mockRelayItem) - // Await an empty item - assertEquals(SelectLocationSideEffect.CloseScreen, awaitItem()) - coVerify { mockRelayListRepository.updateSelectedRelayLocation(relayItemId) } - } + fun `initial state should be correct`() = runTest { + Assertions.assertEquals( + SelectLocationUiState( + filterChips = emptyList(), + multihopEnabled = false, + relayListType = RelayListType.EXIT, + ), + viewModel.uiState.value, + ) } @Test - fun `on onSearchTermInput call uiState should emit with filtered countries`() = runTest { - // Arrange - val mockSearchString = "got" - filteredRelayList.value = testCountries - selectedRelayItemFlow.value = Constraint.Any - - // Act, Assert - viewModel.uiState.test { - // Wait for first data - assertIs<SelectLocationUiState.Content>(awaitItem()) - - // Update search string - viewModel.onSearchTermInput(mockSearchString) - - // We get some unnecessary emissions for now - awaitItem() - awaitItem() - awaitItem() + fun `on selectRelay when relay list type is exit call uiSideEffect should emit CloseScreen and connect`() = + runTest { + // Arrange + val mockRelayItem: RelayItem.Location.Country = mockk() + val relayItemId: GeoLocationId.Country = mockk(relaxed = true) + every { mockRelayItem.id } returns relayItemId + coEvery { mockRelayListRepository.updateSelectedRelayLocation(relayItemId) } returns + Unit.right() - val actualState = awaitItem() - assertIs<SelectLocationUiState.Content>(actualState) - assertTrue( - actualState.relayListItems.filterIsInstance<RelayListItem.GeoLocationItem>().any { - it.item is RelayItem.Location.City && it.item.name == "Gothenburg" - } - ) + // Act, Assert + viewModel.uiSideEffect.test { + viewModel.selectRelay(mockRelayItem) + // Await an empty item + assertEquals(SelectLocationSideEffect.CloseScreen, awaitItem()) + coVerify { mockRelayListRepository.updateSelectedRelayLocation(relayItemId) } + } } - } @Test - fun `when onSearchTermInput returns empty result uiState should return empty list`() = runTest { - // Arrange - filteredRelayList.value = testCountries - val mockSearchString = "SEARCH" - - // Act, Assert - viewModel.uiState.test { - // Wait for first data - assertIs<SelectLocationUiState.Content>(awaitItem()) - - // Update search string - viewModel.onSearchTermInput(mockSearchString) - - // We get some unnecessary emissions for now - awaitItem() - awaitItem() + fun `on selectRelay when relay list type is entry call uiSideEffect should switch relay list type to exit`() = + runTest { + // Arrange + val mockRelayItem: RelayItem.Location.Country = mockk() + val relayItemId: GeoLocationId.Country = mockk(relaxed = true) + every { mockRelayItem.id } returns relayItemId + coEvery { mockWireguardConstraintsRepository.setEntryLocation(relayItemId) } returns + Unit.right() - // Assert - val actualState = awaitItem() - assertIs<SelectLocationUiState.Content>(actualState) - assertEquals( - listOf(RelayListItem.LocationsEmptyText(mockSearchString)), - actualState.relayListItems, - ) + // Act, Assert + viewModel.uiState.test { + awaitItem() // Default value + viewModel.selectRelayList(RelayListType.ENTRY) + // Assert relay list type is entry + assertEquals(RelayListType.ENTRY, awaitItem().relayListType) + // Select entry + viewModel.selectRelay(mockRelayItem) + // Await an empty item + assertEquals(RelayListType.EXIT, awaitItem().relayListType) + coVerify { mockWireguardConstraintsRepository.setEntryLocation(relayItemId) } + } } - } @Test fun `removeOwnerFilter should invoke use case with Constraint Any Ownership`() = runTest { @@ -372,17 +279,6 @@ class SelectLocationViewModelTest { } } - private fun RelayListItem.relayItemId() = - when (this) { - is RelayListItem.CustomListFooter -> null - RelayListItem.CustomListHeader -> null - RelayListItem.LocationHeader -> null - is RelayListItem.LocationsEmptyText -> null - is RelayListItem.CustomListEntryItem -> item.id - is RelayListItem.CustomListItem -> item.id - is RelayListItem.GeoLocationItem -> item.id - } - companion object { private const val RELAY_LIST_EXTENSIONS = "net.mullvad.mullvadvpn.relaylist.RelayListExtensionsKt" @@ -390,21 +286,5 @@ class SelectLocationViewModelTest { "net.mullvad.mullvadvpn.relaylist.RelayItemExtensionsKt" private const val CUSTOM_LIST_EXTENSIONS = "net.mullvad.mullvadvpn.relaylist.CustomListExtensionsKt" - - private val testCountries = - listOf( - RelayItem.Location.Country( - id = GeoLocationId.Country("se"), - "Sweden", - listOf( - RelayItem.Location.City( - id = GeoLocationId.City(GeoLocationId.Country("se"), "got"), - "Gothenburg", - emptyList(), - ) - ), - ), - RelayItem.Location.Country(id = GeoLocationId.Country("no"), "Norway", emptyList()), - ) } } |
