summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2024-11-24 23:18:04 +0100
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2024-11-27 08:50:54 +0100
commita91a791eed3d4e041357622c3ff509601677eec2 (patch)
tree6c0e7381edea81f2bba4db88aa25eabd46eb9780 /android
parent56e46c5cf783d41937e4eb2531a4d2e287381ee6 (diff)
downloadmullvadvpn-a91a791eed3d4e041357622c3ff509601677eec2.tar.xz
mullvadvpn-a91a791eed3d4e041357622c3ff509601677eec2.zip
Implement multihop
Diffstat (limited to 'android')
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt107
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterRow.kt47
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt40
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayListItemPreviewData.kt113
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt29
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt76
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SettingsUiStatePreviewParameterProvider.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt16
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MultihopScreen.kt89
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt938
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt57
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/LocationBottomSheet.kt426
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/RelayListContent.kt196
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreen.kt401
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt114
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt355
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterChip.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListItem.kt89
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListType.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt21
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt13
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt105
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SettingsUiState.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt39
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt20
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/WireguardConstraintsRepository.kt20
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilterChipUseCase.kt103
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilteredRelayListUseCase.kt24
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationUseCase.kt27
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt28
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Daita.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt25
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MultihopViewModel.kt26
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt436
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/CustomListEdit.kt75
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/Expand.kt21
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/RelayItemListCreator.kt355
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt211
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModel.kt89
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt145
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectRelay.kt21
-rw-r--r--android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt22
-rw-r--r--android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt2
-rw-r--r--android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt4
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/FeatureIndicator.kt2
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItemSelection.kt12
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardConstraints.kt2
-rw-r--r--android/lib/resource/src/main/res/drawable-hdpi/multihop_illustration.pngbin0 -> 45568 bytes
-rw-r--r--android/lib/resource/src/main/res/drawable-mdpi/multihop_illustration.pngbin0 -> 24763 bytes
-rw-r--r--android/lib/resource/src/main/res/drawable-xhdpi/multihop_illustration.pngbin0 -> 71127 bytes
-rw-r--r--android/lib/resource/src/main/res/drawable-xxhdpi/multihop_illustration.pngbin0 -> 136698 bytes
-rw-r--r--android/lib/resource/src/main/res/drawable-xxxhdpi/multihop_illustration.pngbin0 -> 220713 bytes
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml18
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt3
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt6
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
new file mode 100644
index 0000000000..4b39420e31
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable-hdpi/multihop_illustration.png
Binary files differ
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
new file mode 100644
index 0000000000..50d3064f25
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable-mdpi/multihop_illustration.png
Binary files differ
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
new file mode 100644
index 0000000000..c7cdd85f7e
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable-xhdpi/multihop_illustration.png
Binary files differ
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
new file mode 100644
index 0000000000..bccd71a158
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable-xxhdpi/multihop_illustration.png
Binary files differ
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
new file mode 100644
index 0000000000..9246fad11c
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable-xxxhdpi/multihop_illustration.png
Binary files differ
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)
- }