summaryrefslogtreecommitdiffhomepage
path: root/android/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'android/app/src')
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ConnectUiStatePreviewParameterProvider.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt236
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MapThingy.kt77
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt18
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt33
7 files changed, 356 insertions, 17 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ConnectUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ConnectUiStatePreviewParameterProvider.kt
index 0a332ba385..d116fddcd6 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ConnectUiStatePreviewParameterProvider.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ConnectUiStatePreviewParameterProvider.kt
@@ -49,6 +49,7 @@ private val otherStates =
if (index == 0) InAppNotification.NewDevice("Test Device") else null,
deviceName = "Cool Beans",
daysLeftUntilExpiry = 42,
+ selectedGeoLocationId = emptyList(),
isPlayBuild = true,
)
}
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 8f7775c613..acfb93dde9 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
@@ -5,7 +5,12 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.AnimationEndReason
+import androidx.compose.animation.core.EaseInOut
import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.exponentialDecay
+import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -25,6 +30,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -38,6 +45,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -46,7 +54,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -58,10 +69,12 @@ 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.DpOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.dropUnlessResumed
+import co.touchlab.kermit.Logger
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.NavGraphs
@@ -107,16 +120,23 @@ import net.mullvad.mullvadvpn.constant.SECURE_ZOOM_ANIMATION_MILLIS
import net.mullvad.mullvadvpn.constant.UNSECURE_ZOOM
import net.mullvad.mullvadvpn.constant.fallbackLatLong
import net.mullvad.mullvadvpn.lib.common.util.openVpnSettings
-import net.mullvad.mullvadvpn.lib.map.AnimatedMap
+import net.mullvad.mullvadvpn.lib.map.Map
+import net.mullvad.mullvadvpn.lib.map.data.CameraPosition
import net.mullvad.mullvadvpn.lib.map.data.GlobeColors
import net.mullvad.mullvadvpn.lib.map.data.LocationMarkerColors
import net.mullvad.mullvadvpn.lib.map.data.Marker
+import net.mullvad.mullvadvpn.lib.map.internal.FAR_ANIMATION_MAX_ZOOM_MULTIPLIER
+import net.mullvad.mullvadvpn.lib.map.internal.MAX_MULTIPLIER_PEAK_TIMING
+import net.mullvad.mullvadvpn.lib.map.internal.SHORT_ANIMATION_CUTOFF_MILLIS
+import net.mullvad.mullvadvpn.lib.map.toAnimationDurationMillis
import net.mullvad.mullvadvpn.lib.model.FeatureIndicator
import net.mullvad.mullvadvpn.lib.model.GeoIpLocation
+import net.mullvad.mullvadvpn.lib.model.GeoLocationId
import net.mullvad.mullvadvpn.lib.model.LatLong
import net.mullvad.mullvadvpn.lib.model.Latitude
import net.mullvad.mullvadvpn.lib.model.Longitude
import net.mullvad.mullvadvpn.lib.model.PrepareError
+import net.mullvad.mullvadvpn.lib.model.RelayItemId
import net.mullvad.mullvadvpn.lib.model.TunnelState
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
@@ -126,6 +146,7 @@ import net.mullvad.mullvadvpn.lib.theme.color.Alpha80
import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible
import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar
import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible
+import net.mullvad.mullvadvpn.lib.theme.color.warning
import net.mullvad.mullvadvpn.lib.tv.NavigationDrawerTv
import net.mullvad.mullvadvpn.lib.ui.component.ExpandChevron
import net.mullvad.mullvadvpn.lib.ui.tag.CONNECT_BUTTON_TEST_TAG
@@ -164,6 +185,7 @@ private fun PreviewAccountScreen(
onDismissNewDeviceClick = {},
onNavigateToFeature = {},
onClickShowWireguardPortSettings = {},
+ onSelectRelay = {},
)
}
}
@@ -306,6 +328,7 @@ fun Connect(
},
onClickShowWireguardPortSettings =
dropUnlessResumed { navigator.navigate(VpnSettingsDestination()) },
+ onSelectRelay = connectViewModel::onSelectRelay,
)
}
}
@@ -330,6 +353,7 @@ fun ConnectScreen(
onDismissNewDeviceClick: () -> Unit,
onNavigateToFeature: (FeatureIndicator) -> Unit,
onClickShowWireguardPortSettings: () -> Unit,
+ onSelectRelay: (RelayItemId) -> Unit,
) {
val contentFocusRequester = remember { FocusRequester() }
@@ -351,6 +375,7 @@ fun ConnectScreen(
onDismissNewDeviceClick,
onNavigateToFeature,
onClickShowWireguardPortSettings,
+ onSelectRelay,
)
}
@@ -406,6 +431,7 @@ private fun Content(
onDismissNewDeviceClick: () -> Unit,
onNavigateToFeature: (FeatureIndicator) -> Unit,
onClickShowWireguardPortSettings: () -> Unit,
+ onSelectRelay: (RelayItemId) -> Unit,
) {
val screenHeight =
with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
@@ -422,7 +448,7 @@ private fun Content(
)
.fillMaxSize()
) {
- MullvadMap(state, indicatorPercentOffset)
+ MullvadMap(state, indicatorPercentOffset, onSelectRelay = onSelectRelay)
MullvadCircularProgressIndicatorLarge(
color = MaterialTheme.colorScheme.onSurface,
@@ -474,7 +500,11 @@ private fun Content(
}
@Composable
-private fun MullvadMap(state: ConnectUiState, progressIndicatorBias: Float) {
+private fun MullvadMap(
+ state: ConnectUiState,
+ progressIndicatorBias: Float,
+ onSelectRelay: (RelayItemId) -> Unit,
+) {
// Distance to marker when secure/unsecure
val baseZoom =
@@ -485,19 +515,207 @@ private fun MullvadMap(state: ConnectUiState, progressIndicatorBias: Float) {
label = "baseZoom",
)
+ val locationLatLng = (state.location?.toLatLong() ?: fallbackLatLong)
+
+ val longitudeAnimation = remember { Animatable(locationLatLng.longitude.value) }
+ val latitudeAnimation = remember { Animatable(locationLatLng.latitude.value) }
+ latitudeAnimation.updateBounds(-40f, upperBound = 80f)
+ val userZoom = remember { Animatable(1f) }
+
+ LaunchedEffect(locationLatLng) {
+ val currentPosition =
+ LatLong(
+ Latitude.fromFloat(latitudeAnimation.value),
+ Longitude.fromFloat(longitudeAnimation.value),
+ )
+ val distance = locationLatLng.seppDistanceTo(currentPosition)
+ val duration = distance.toAnimationDurationMillis()
+ launch {
+ longitudeAnimation.animateTo(
+ state.location?.longitude?.toFloat() ?: fallbackLatLong.longitude.value,
+ animationSpec = tween(duration),
+ )
+ }
+ launch {
+ latitudeAnimation.animateTo(
+ state.location?.latitude?.toFloat() ?: fallbackLatLong.latitude.value,
+ animationSpec = tween(duration),
+ )
+ }
+ launch {
+ userZoom.animateTo(
+ targetValue = 1f,
+ animationSpec =
+ keyframes {
+ if (duration < SHORT_ANIMATION_CUTOFF_MILLIS) {
+ durationMillis = duration
+ 1f at duration using EaseInOut
+ } else {
+ durationMillis = duration
+ FAR_ANIMATION_MAX_ZOOM_MULTIPLIER at
+ (duration * MAX_MULTIPLIER_PEAK_TIMING).toInt() using
+ EaseInOut
+ 1f at duration using EaseInOut
+ }
+ },
+ )
+ }
+ }
+
+ val locationMarkers =
+ state.relayLocations.map { location ->
+ val isSelected =
+ state.selectedGeoLocationId.any {
+ when (it) {
+ is GeoLocationId.City -> it == location.id
+ is GeoLocationId.Country -> it == location.id.country
+ is GeoLocationId.Hostname -> it.city == location.id
+ }
+ }
+ val colors =
+ if (isSelected) {
+ LocationMarkerColors(
+ perimeterColors = null,
+ centerColor = MaterialTheme.colorScheme.warning,
+ ringBorderColor = MaterialTheme.colorScheme.onPrimary,
+ )
+ } else {
+ LocationMarkerColors(
+ perimeterColors = null,
+ centerColor = MaterialTheme.colorScheme.primary,
+ ringBorderColor = MaterialTheme.colorScheme.onPrimary,
+ )
+ }
+ Marker(location.latLong, colors = colors, id = location.id)
+ }
+
+ val tracker = remember { VelocityTracker() }
+ val scope = rememberCoroutineScope()
+
val markers = state.tunnelState.toMarker(state.location)?.let { listOf(it) } ?: emptyList()
- AnimatedMap(
- modifier = Modifier,
- cameraLocation = state.location?.toLatLong() ?: fallbackLatLong,
- cameraBaseZoom = baseZoom.value,
- cameraVerticalBias = progressIndicatorBias,
- markers = markers,
+ val density = LocalDensity.current
+ val dropdownPosition = remember { mutableStateOf(DpOffset(0.dp, 0.dp)) }
+ val showDropdown = remember { mutableStateOf(false) }
+ val locationName = remember { mutableStateOf<GeoLocationId?>(null) }
+
+ if (showDropdown.value) {
+ DropdownMenu(
+ expanded = true,
+ onDismissRequest = { showDropdown.value = false },
+ offset = dropdownPosition.value,
+ ) {
+ val text =
+ when (val geolocation = locationName.value) {
+ is GeoLocationId.City -> "${geolocation.country.code}-${geolocation.code}"
+ is GeoLocationId.Country -> geolocation.toString()
+ is GeoLocationId.Hostname -> geolocation.toString()
+ else -> ""
+ }
+
+ Text(
+ style = MaterialTheme.typography.bodyLarge,
+ color = Color.White.copy(Alpha80),
+ text = text,
+ modifier =
+ Modifier.padding(
+ horizontal = Dimens.mediumPadding,
+ vertical = Dimens.smallPadding,
+ ),
+ )
+ DropdownMenuItem(
+ text = { Text(text = "Set as Entry") },
+ onClick = {
+ locationName.value?.let { onSelectRelay(it) }
+ showDropdown.value = false
+ },
+ )
+ DropdownMenuItem(
+ text = { Text(text = "Set as Exit") },
+ onClick = {
+ locationName.value?.let { onSelectRelay(it) }
+ showDropdown.value = false
+ },
+ )
+ }
+ }
+
+
+ Map(
+ modifier =
+ Modifier.pointerInput(Unit) {
+ detectTransformGesturesWithEnd(
+ true,
+ onGestureStart = {
+ Logger.d { "Animation onGestureStart" }
+ tracker.resetTracking()
+ scope.launch { longitudeAnimation.stop() }
+ scope.launch { latitudeAnimation.stop() }
+ },
+ onGesture = { centroid: Offset, pan: Offset, newZoom: Float, rotation: Float ->
+ Logger.d { "Animation onGesture" }
+
+ val longitude = longitudeAnimation.value - pan.x * userZoom.value / 50f
+ val tempLatitude = Latitude.fromFloat(latitudeAnimation.value)
+ val latitude = (tempLatitude.value + pan.y * userZoom.value / 40f)
+ val newZoom = (userZoom.value + (1 - newZoom) * 0.5f).coerceIn(1f, 2f)
+
+ scope.launch {
+ userZoom.snapTo(newZoom)
+ longitudeAnimation.snapTo(longitude)
+ latitudeAnimation.snapTo(latitude)
+
+ tracker.addPosition(
+ System.currentTimeMillis(),
+ Offset(longitude, latitude),
+ )
+ }
+ },
+ onGestureEnd = {
+ Logger.d { "Animation onGestureEnd" }
+ // Longitude
+ val velocity = tracker.calculateVelocity()
+ scope.launch {
+ longitudeAnimation.animateDecay(velocity.x, exponentialDecay(0.4f))
+ }
+ scope.launch {
+ var velY = velocity.y
+ do {
+ val res =
+ latitudeAnimation.animateDecay(velY, exponentialDecay(0.4f))
+
+ velY = -res.endState.velocityVector.value
+ } while (res.endReason != AnimationEndReason.Finished)
+ }
+ },
+ )
+ },
+ cameraLocation =
+ CameraPosition(
+ latLong =
+ LatLong(
+ Latitude.fromFloat(latitudeAnimation.value),
+ Longitude.fromFloat(longitudeAnimation.value),
+ ),
+ verticalBias = progressIndicatorBias,
+ zoom = (baseZoom.value * userZoom.value).also { Logger.d("Zoom: $it") },
+ ),
+ markers = markers + locationMarkers,
globeColors =
GlobeColors(
landColor = MaterialTheme.colorScheme.primary,
oceanColor = MaterialTheme.colorScheme.surface,
),
+ onClickRelayItemId = onSelectRelay,
+ onLongClickRelayItemId = { offset, geolocationId ->
+ with(density) {
+ DpOffset(x = offset.x.toDp(), y = offset.y.toDp()).also {
+ dropdownPosition.value = it
+ locationName.value = geolocationId
+ showDropdown.value = true
+ }
+ }
+ },
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MapThingy.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MapThingy.kt
new file mode 100644
index 0000000000..b5c37e4ea4
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MapThingy.kt
@@ -0,0 +1,77 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.calculateCentroid
+import androidx.compose.foundation.gestures.calculateCentroidSize
+import androidx.compose.foundation.gestures.calculatePan
+import androidx.compose.foundation.gestures.calculateRotation
+import androidx.compose.foundation.gestures.calculateZoom
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.PointerInputScope
+import androidx.compose.ui.input.pointer.positionChanged
+import kotlin.math.abs
+
+suspend fun PointerInputScope.detectTransformGesturesWithEnd(
+ panZoomLock: Boolean = false,
+ onGestureStart: () -> Unit,
+ onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit,
+ onGestureEnd: () -> Unit,
+) {
+ awaitEachGesture {
+ var rotation = 0f
+ var zoom = 1f
+ var pan = Offset.Zero
+ var pastTouchSlop = false
+ val touchSlop = viewConfiguration.touchSlop
+ var lockedToPanZoom = false
+
+ awaitFirstDown(requireUnconsumed = false)
+ onGestureStart()
+ do {
+ val event = awaitPointerEvent()
+ val canceled = event.changes.any { it.isConsumed }
+ if (!canceled) {
+ val zoomChange = event.calculateZoom()
+ val rotationChange = event.calculateRotation()
+ val panChange = event.calculatePan()
+
+ if (!pastTouchSlop) {
+ zoom *= zoomChange
+ rotation += rotationChange
+ pan += panChange
+
+ val centroidSize = event.calculateCentroidSize(useCurrent = false)
+ val zoomMotion = abs(1 - zoom) * centroidSize
+ val rotationMotion =
+ abs(rotation * kotlin.math.PI.toFloat() * centroidSize / 180f)
+ val panMotion = pan.getDistance()
+
+ if (
+ zoomMotion > touchSlop ||
+ rotationMotion > touchSlop ||
+ panMotion > touchSlop
+ ) {
+ pastTouchSlop = true
+ lockedToPanZoom = panZoomLock && rotationMotion < touchSlop
+ }
+ }
+
+ if (pastTouchSlop) {
+ val centroid = event.calculateCentroid(useCurrent = false)
+ val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
+ if (effectiveRotation != 0f || zoomChange != 1f || panChange != Offset.Zero) {
+ onGesture(centroid, panChange, zoomChange, effectiveRotation)
+ }
+ event.changes.forEach {
+ if (it.positionChanged()) {
+ it.consume()
+ }
+ }
+ }
+ }
+ } while (!canceled && event.changes.any { it.pressed })
+
+ onGestureEnd()
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt
index 956b1506a7..65d3ac09e9 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt
@@ -2,11 +2,15 @@ package net.mullvad.mullvadvpn.compose.state
import net.mullvad.mullvadvpn.lib.model.GeoIpLocation
import net.mullvad.mullvadvpn.lib.model.InAppNotification
+import net.mullvad.mullvadvpn.lib.model.GeoLocationId
+import net.mullvad.mullvadvpn.lib.model.RelayItem
import net.mullvad.mullvadvpn.lib.model.TunnelState
data class ConnectUiState(
val location: GeoIpLocation?,
+ val relayLocations: List<RelayItem.Location.City> = emptyList(),
val selectedRelayItemTitle: String?,
+ val selectedGeoLocationId: List<GeoLocationId>,
val tunnelState: TunnelState,
val inAppNotification: InAppNotification?,
val deviceName: String?,
@@ -22,6 +26,7 @@ data class ConnectUiState(
ConnectUiState(
location = null,
selectedRelayItemTitle = null,
+ selectedGeoLocationId = emptyList(),
tunnelState = TunnelState.Disconnected(),
inAppNotification = null,
deviceName = null,
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 e24ad8e9ef..87b921f8ca 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
@@ -224,6 +224,9 @@ val uiModule = module {
isPlayBuild = IS_PLAY_BUILD,
isFdroidBuild = IS_FDROID_BUILD,
packageName = get(named(SELF_PACKAGE_NAME)),
+ customListRepository = get(),
+ relayListRepository = get(),
+ filteredRelayListUseCase = get(),
)
}
viewModel { DeviceListViewModel(get(), get()) }
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 de79d159e1..4655638198 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
@@ -20,17 +20,18 @@ inline fun <T> Flow<T>.onFirst(crossinline action: suspend (T) -> Unit): Flow<T>
}
}
-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>,
- crossinline transform: suspend (T1, T2, T3, T4, T5, T6) -> R,
+ 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) { args: Array<*>
- ->
+ return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) {
+ args: Array<*> ->
@Suppress("UNCHECKED_CAST")
transform(
args[0] as T1,
@@ -39,11 +40,12 @@ inline fun <T1, T2, T3, T4, T5, T6, R> combine(
args[3] as T4,
args[4] as T5,
args[5] as T6,
+ args[6] as T7,
)
}
}
-inline fun <T1, T2, T3, T4, T5, T6, T7, R> combine(
+inline fun <T1, T2, T3, T4, T5, T6, T7, T8, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
@@ -51,9 +53,10 @@ inline fun <T1, T2, T3, T4, T5, T6, T7, R> combine(
flow5: Flow<T5>,
flow6: Flow<T6>,
flow7: Flow<T7>,
- crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R,
+ flow8: Flow<T8>,
+ crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R,
): Flow<R> {
- return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) {
+ return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8) {
args: Array<*> ->
@Suppress("UNCHECKED_CAST")
transform(
@@ -64,6 +67,7 @@ inline fun <T1, T2, T3, T4, T5, T6, T7, R> combine(
args[4] as T5,
args[5] as T6,
args[6] as T7,
+ args[7] as T8,
)
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt
index e836acb844..a8f123d030 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt
@@ -17,20 +17,28 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.screen.toLatLong
import net.mullvad.mullvadvpn.compose.state.ConnectUiState
import net.mullvad.mullvadvpn.lib.common.util.daysFromNow
+import net.mullvad.mullvadvpn.compose.state.RelayListType
import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect
import net.mullvad.mullvadvpn.lib.model.ConnectError
+import net.mullvad.mullvadvpn.lib.model.CustomListId
import net.mullvad.mullvadvpn.lib.model.DeviceState
+import net.mullvad.mullvadvpn.lib.model.GeoLocationId
import net.mullvad.mullvadvpn.lib.model.PrepareError
+import net.mullvad.mullvadvpn.lib.model.RelayItemId
import net.mullvad.mullvadvpn.lib.model.TunnelState
import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken
import net.mullvad.mullvadvpn.lib.shared.AccountRepository
import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy
import net.mullvad.mullvadvpn.lib.shared.DeviceRepository
import net.mullvad.mullvadvpn.repository.ChangelogRepository
+import net.mullvad.mullvadvpn.repository.CustomListsRepository
import net.mullvad.mullvadvpn.repository.InAppNotificationController
import net.mullvad.mullvadvpn.repository.NewDeviceRepository
+import net.mullvad.mullvadvpn.repository.RelayListRepository
+import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase
import net.mullvad.mullvadvpn.usecase.LastKnownLocationUseCase
import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase
import net.mullvad.mullvadvpn.usecase.PaymentUseCase
@@ -50,13 +58,16 @@ class ConnectViewModel(
selectedLocationTitleUseCase: SelectedLocationTitleUseCase,
private val outOfTimeUseCase: OutOfTimeUseCase,
private val paymentUseCase: PaymentUseCase,
+ private val customListRepository: CustomListsRepository,
private val connectionProxy: ConnectionProxy,
lastKnownLocationUseCase: LastKnownLocationUseCase,
private val systemVpnSettingsUseCase: SystemVpnSettingsAvailableUseCase,
private val resources: Resources,
+ private val filteredRelayListUseCase: FilteredRelayListUseCase,
private val isPlayBuild: Boolean,
private val isFdroidBuild: Boolean,
private val packageName: String,
+ private val relayListRepository: RelayListRepository,
) : ViewModel() {
private val _uiSideEffect = Channel<UiSideEffect>()
@@ -67,18 +78,24 @@ class ConnectViewModel(
val uiState: StateFlow<ConnectUiState> =
combine(
selectedLocationTitleUseCase(),
+ relayListRepository.selectedLocation,
inAppNotificationController.notifications,
connectionProxy.tunnelState.withPrev(),
lastKnownLocationUseCase.lastKnownDisconnectedLocation,
accountRepository.accountData,
deviceRepository.deviceState.map { it?.displayName() },
+ filteredRelayListUseCase(RelayListType.Single).map { countries ->
+ countries.flatMap { it.cities }
+ },
) {
selectedRelayItemTitle,
+ selectedLocation,
notifications,
(tunnelState, prevTunnelState),
lastKnownDisconnectedLocation,
accountData,
- deviceName ->
+ deviceName,
+ relayCities ->
ConnectUiState(
location =
when (tunnelState) {
@@ -103,10 +120,20 @@ class ConnectViewModel(
} else {
null
},
+ selectedGeoLocationId =
+ when (val id = selectedLocation.getOrNull()) {
+ is CustomListId ->
+ customListRepository.getCustomListById(id).getOrNull()?.locations
+ ?: emptyList()
+ is GeoLocationId -> listOf(id)
+ null -> emptyList()
+ },
tunnelState = tunnelState,
inAppNotification = notifications.firstOrNull(),
deviceName = deviceName,
daysLeftUntilExpiry = accountData?.expiryDate?.daysFromNow(),
+ relayLocations =
+ relayCities.filter { it.latLong != tunnelState.location()?.toLatLong() },
isPlayBuild = isPlayBuild,
)
}
@@ -180,6 +207,10 @@ class ConnectViewModel(
}
}
+ fun onSelectRelay(id: RelayItemId) {
+ viewModelScope.launch { relayListRepository.updateSelectedRelayLocation(id) }
+ }
+
fun openAppListing() =
viewModelScope.launch {
val sideEffect =