diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2024-11-24 23:18:04 +0100 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2024-11-27 08:50:54 +0100 |
| commit | a91a791eed3d4e041357622c3ff509601677eec2 (patch) | |
| tree | 6c0e7381edea81f2bba4db88aa25eabd46eb9780 /android | |
| parent | 56e46c5cf783d41937e4eb2531a4d2e287381ee6 (diff) | |
| download | mullvadvpn-a91a791eed3d4e041357622c3ff509601677eec2.tar.xz mullvadvpn-a91a791eed3d4e041357622c3ff509601677eec2.zip | |
Implement multihop
Diffstat (limited to 'android')
58 files changed, 3386 insertions, 1647 deletions
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/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt index ad4fb20a22..bd27574cbe 100644 --- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt @@ -95,6 +95,7 @@ import net.mullvad.mullvadvpn.lib.model.RedeemVoucherSuccess import net.mullvad.mullvadvpn.lib.model.RelayConstraints import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.model.RelayItemId as ModelRelayItemId +import net.mullvad.mullvadvpn.lib.model.RelayItemId import net.mullvad.mullvadvpn.lib.model.RelayList as ModelRelayList import net.mullvad.mullvadvpn.lib.model.RelayList import net.mullvad.mullvadvpn.lib.model.RelaySettings @@ -122,6 +123,8 @@ import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken import net.mullvad.mullvadvpn.lib.model.WireguardEndpointData as ModelWireguardEndpointData import net.mullvad.mullvadvpn.lib.model.addresses import net.mullvad.mullvadvpn.lib.model.customOptions +import net.mullvad.mullvadvpn.lib.model.entryLocation +import net.mullvad.mullvadvpn.lib.model.isMultihopEnabled import net.mullvad.mullvadvpn.lib.model.location import net.mullvad.mullvadvpn.lib.model.ownership import net.mullvad.mullvadvpn.lib.model.port @@ -131,7 +134,6 @@ import net.mullvad.mullvadvpn.lib.model.selectedObfuscationMode import net.mullvad.mullvadvpn.lib.model.shadowsocks import net.mullvad.mullvadvpn.lib.model.state import net.mullvad.mullvadvpn.lib.model.udp2tcp -import net.mullvad.mullvadvpn.lib.model.useMultihop import net.mullvad.mullvadvpn.lib.model.wireguardConstraints @Suppress("TooManyFunctions") @@ -757,7 +759,7 @@ class ManagementService( Either.catch { val relaySettings = getSettings().relaySettings val updated = - RelaySettings.relayConstraints.wireguardConstraints.useMultihop.set( + RelaySettings.relayConstraints.wireguardConstraints.isMultihopEnabled.set( relaySettings, enabled, ) @@ -767,6 +769,22 @@ class ManagementService( .mapLeft(SetWireguardConstraintsError::Unknown) .mapEmpty() + suspend fun setEntryLocation( + entryLocation: RelayItemId + ): Either<SetWireguardConstraintsError, Unit> = + Either.catch { + val relaySettings = getSettings().relaySettings + val updated = + RelaySettings.relayConstraints.wireguardConstraints.entryLocation.set( + relaySettings, + Constraint.Only(entryLocation), + ) + grpc.setRelaySettings(updated.fromDomain()) + } + .onLeft { Logger.e("Set multihop error") } + .mapLeft(SetWireguardConstraintsError::Unknown) + .mapEmpty() + private fun <A> Either<A, Empty>.mapEmpty() = map {} private inline fun <B, C> Either<Throwable, B>.mapLeftStatus( diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt index 622e95d9dd..b3fe88bdc8 100644 --- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt @@ -126,7 +126,7 @@ internal fun CustomList.fromDomain(): ManagementInterface.CustomList = internal fun WireguardConstraints.fromDomain(): ManagementInterface.WireguardConstraints = ManagementInterface.WireguardConstraints.newBuilder() - .setUseMultihop(useMultihop) + .setUseMultihop(isMultihopEnabled) .setEntryLocation(entryLocation.fromDomain()) .apply { when (val port = this@fromDomain.port) { diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt index 236d4aa19c..fe0222596b 100644 --- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt @@ -336,7 +336,7 @@ internal fun ManagementInterface.WireguardConstraints.toDomain(): WireguardConst } else { Constraint.Any }, - useMultihop = useMultihop, + isMultihopEnabled = useMultihop, entryLocation = entryLocation.toDomain(), ) @@ -644,8 +644,8 @@ internal fun ManagementInterface.FeatureIndicator.toDomain() = ManagementInterface.FeatureIndicator.CUSTOM_MTU -> FeatureIndicator.CUSTOM_MTU ManagementInterface.FeatureIndicator.DAITA -> FeatureIndicator.DAITA ManagementInterface.FeatureIndicator.SHADOWSOCKS -> FeatureIndicator.SHADOWSOCKS + ManagementInterface.FeatureIndicator.MULTIHOP -> FeatureIndicator.MULTIHOP ManagementInterface.FeatureIndicator.LOCKDOWN_MODE, - ManagementInterface.FeatureIndicator.MULTIHOP, ManagementInterface.FeatureIndicator.BRIDGE_MODE, ManagementInterface.FeatureIndicator.CUSTOM_MSS_FIX, ManagementInterface.FeatureIndicator.UNRECOGNIZED -> diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/FeatureIndicator.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/FeatureIndicator.kt index 3c8df824f4..0da5704b4b 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/FeatureIndicator.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/FeatureIndicator.kt @@ -4,7 +4,7 @@ package net.mullvad.mullvadvpn.lib.model enum class FeatureIndicator { DAITA, QUANTUM_RESISTANCE, - // MULTIHOP, + MULTIHOP, SPLIT_TUNNELING, UDP_2_TCP, SHADOWSOCKS, diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItemSelection.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItemSelection.kt new file mode 100644 index 0000000000..c4c78ffe4c --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItemSelection.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface RelayItemSelection { + val exitLocation: Constraint<RelayItemId> + + data class Single(override val exitLocation: Constraint<RelayItemId>) : RelayItemSelection + + data class Multiple( + val entryLocation: Constraint<RelayItemId>, + override val exitLocation: Constraint<RelayItemId>, + ) : RelayItemSelection +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardConstraints.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardConstraints.kt index 7af0144cf4..dcc3a957df 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardConstraints.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardConstraints.kt @@ -5,7 +5,7 @@ import arrow.optics.optics @optics data class WireguardConstraints( val port: Constraint<Port>, - val useMultihop: Boolean, + val isMultihopEnabled: Boolean, val entryLocation: Constraint<RelayItemId>, ) { companion object diff --git a/android/lib/resource/src/main/res/drawable-hdpi/multihop_illustration.png b/android/lib/resource/src/main/res/drawable-hdpi/multihop_illustration.png Binary files differnew file mode 100644 index 0000000000..4b39420e31 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-hdpi/multihop_illustration.png diff --git a/android/lib/resource/src/main/res/drawable-mdpi/multihop_illustration.png b/android/lib/resource/src/main/res/drawable-mdpi/multihop_illustration.png Binary files differnew file mode 100644 index 0000000000..50d3064f25 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-mdpi/multihop_illustration.png diff --git a/android/lib/resource/src/main/res/drawable-xhdpi/multihop_illustration.png b/android/lib/resource/src/main/res/drawable-xhdpi/multihop_illustration.png Binary files differnew file mode 100644 index 0000000000..c7cdd85f7e --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-xhdpi/multihop_illustration.png diff --git a/android/lib/resource/src/main/res/drawable-xxhdpi/multihop_illustration.png b/android/lib/resource/src/main/res/drawable-xxhdpi/multihop_illustration.png Binary files differnew file mode 100644 index 0000000000..bccd71a158 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-xxhdpi/multihop_illustration.png diff --git a/android/lib/resource/src/main/res/drawable-xxxhdpi/multihop_illustration.png b/android/lib/resource/src/main/res/drawable-xxxhdpi/multihop_illustration.png Binary files differnew file mode 100644 index 0000000000..9246fad11c --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-xxxhdpi/multihop_illustration.png diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index b89488bc1a..4625fb3b5f 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -56,7 +56,6 @@ <string name="owned">Owned</string> <string name="rented">Rented</string> <string name="number_of_providers">Providers: %d</string> - <string name="filtered">Filtered:</string> <string name="mullvad_owned_only">Mullvad owned only</string> <string name="all_providers">All providers</string> <string name="rented_only">Rented only</string> @@ -223,10 +222,7 @@ <string name="wireguard_port_title">WireGuard port</string> <string name="wireguard_port_info_description">The automatic setting will randomly choose from the valid port ranges shown below.</string> <string name="search_placeholder">Search for...</string> - <string name="select_location_empty_text_first_row"> - <![CDATA[No result for <b>%s</b>.]]> - </string> - <string name="select_location_empty_text_second_row">Try a different search.</string> + <string name="search_location_empty_text">No result for \"%s\", please try a different search</string> <string name="wireguard_port_info_port_range">The custom port can be any value inside the valid ranges: %s.</string> <string name="wireguard_custon_port_title">Custom</string> <string name="port">Port</string> @@ -377,6 +373,7 @@ <string name="feature_server_ip_override">Server IP override</string> <string name="feature_custom_mtu">MTU</string> <string name="feature_daita">DAITA</string> + <string name="feature_multihop">Multihop</string> <string name="feature_dns_content_blockers">DNS content blockers</string> <string name="connection_details_ipv4">IPv4</string> <string name="connection_details_ipv6">IPv6</string> @@ -399,4 +396,15 @@ <string name="encrypted_dns_proxy_info_message_part1">With the “Encrypted DNS proxy” method, the app will communicate with our Mullvad API through a proxy address. It does this by retrieving an address from a DNS over HTTPS (DoH) server and then using that to reach our API servers.</string> <string name="encrypted_dns_proxy_info_message_part2">If you are not connected to our VPN, then the Encrypted DNS proxy will use your own non-VPN IP when connecting. The DoH servers are hosted by one of the following providers: Quad 9, CloudFlare, or Google.</string> <string name="connection_details_out">Out</string> + <string name="multihop">Multihop</string> + <string name="multihop_description">Multihop routes your traffic into one WireGuard server and out another, making it harder to trace. This results in increased latency but increases anonymity online.</string> + <string name="x_via_x">%s via %s</string> + <string name="entry">Entry</string> + <string name="exit">Exit</string> + <string name="clear_input">Clear input</string> + <string name="x_entry">%s (Entry)</string> + <string name="x_exit">%s (Exit)</string> + <string name="search_results">Search results</string> + <string name="filters">Filters:</string> + <string name="search_query_empty">Type at least 2 characters to start searching.</string> </resources> diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt index 2407fda047..6a5da5c18d 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt @@ -56,10 +56,13 @@ data class Dimensions( val relayCircleSize: Dp = 16.dp, val screenVerticalMargin: Dp = 22.dp, val searchFieldHeight: Dp = 42.dp, + // Search view full screen header container height (material design guidelines) + val searchFieldHeightExpanded: Dp = 72.dp, val searchFieldHorizontalPadding: Dp = 22.dp, val searchIconSize: Dp = 24.dp, val selectLocationTitlePadding: Dp = 12.dp, val selectableCellTextMargin: Dp = 12.dp, + val settingsDetailsImageMaxWidth: Dp = 480.dp, val sideMargin: Dp = 22.dp, val smallIconSize: Dp = 16.dp, val smallPadding: Dp = 8.dp, diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt index aa2f40782c..501cb72946 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt @@ -11,9 +11,3 @@ val Shapes.chipShape: Shape get() { return RoundedCornerShape(8.dp) } - -val Shapes.fabShape: Shape - @Composable - get() { - return RoundedCornerShape(16.dp) - } |
