summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson@mullvad.net>2025-08-29 14:51:10 +0200
committerDavid Göransson <david.goransson@mullvad.net>2025-09-01 08:34:16 +0200
commite02d460164fe09d5cf085cdcb3f40a9251bae633 (patch)
treecbad368a9d30876f24631a6548e7d32e2e2c9524
parent4f03db1de3224d666cf6fc5ab255f3629435a59a (diff)
downloadmullvadvpn-interactive-maps.tar.xz
mullvadvpn-interactive-maps.zip
Add interactive mapsinteractive-maps
-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
-rw-r--r--android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt8
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt5
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt42
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt11
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/LocationMarkerColors.kt2
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt3
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt6
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt101
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt10
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt47
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LatLong.kt2
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt1
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/map/Ray.kt3
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/map/Sphere.kt7
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/map/Vector3.kt89
-rw-r--r--android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/lib/model/map/Vector3Test.kt69
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemPreviewData.kt4
24 files changed, 738 insertions, 45 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 =
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 140cf5aafb..4c5613c2c4 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
@@ -46,6 +46,9 @@ import net.mullvad.mullvadvpn.lib.model.GenericOptions
import net.mullvad.mullvadvpn.lib.model.GeoIpLocation
import net.mullvad.mullvadvpn.lib.model.GeoLocationId
import net.mullvad.mullvadvpn.lib.model.IpVersion
+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.Mtu
import net.mullvad.mullvadvpn.lib.model.ObfuscationEndpoint
import net.mullvad.mullvadvpn.lib.model.ObfuscationMode
@@ -580,6 +583,11 @@ internal fun ManagementInterface.RelayListCity.toDomain(
.filter { it.endpointData.hasWireguard() }
.map { it.toDomain(cityCode) }
.sortedWith(RelayNameComparator),
+ latLong =
+ LatLong(
+ Latitude.fromFloat(latitude.toFloat()),
+ Longitude.fromFloat(longitude.toFloat()),
+ ),
)
}
diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt
index 2e0b2fcf05..0963ec1aa1 100644
--- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt
@@ -88,16 +88,17 @@ fun animatedCameraPosition(
return CameraPosition(
zoom = baseZoom * zoomOutMultiplier.value,
+ verticalBias = cameraVerticalBias,
latLong =
LatLong(
Latitude(latitudeAnimation.value),
Longitude.fromFloat(longitudeAnimation.value),
),
- verticalBias = cameraVerticalBias,
+ // verticalBias = cameraVerticalBias,
)
}
-private fun Float.toAnimationDurationMillis(): Int =
+fun Float.toAnimationDurationMillis(): Int =
(this * DISTANCE_DURATION_SCALE_FACTOR)
.toInt()
.coerceIn(MIN_ANIMATION_MILLIS, MAX_ANIMATION_MILLIS)
diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt
index 05b8d59701..76e84dca98 100644
--- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt
@@ -1,19 +1,25 @@
package net.mullvad.mullvadvpn.lib.map
+import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
+import co.touchlab.kermit.Logger
import net.mullvad.mullvadvpn.lib.map.data.CameraPosition
import net.mullvad.mullvadvpn.lib.map.data.GlobeColors
import net.mullvad.mullvadvpn.lib.map.data.MapViewState
import net.mullvad.mullvadvpn.lib.map.data.Marker
import net.mullvad.mullvadvpn.lib.map.internal.MapGLSurfaceView
+import net.mullvad.mullvadvpn.lib.model.GeoLocationId
import net.mullvad.mullvadvpn.lib.model.LatLong
+import net.mullvad.mullvadvpn.lib.model.RelayItemId
@Composable
fun Map(
@@ -21,9 +27,11 @@ fun Map(
cameraLocation: CameraPosition,
markers: List<Marker>,
globeColors: GlobeColors,
+ onClickRelayItemId: (GeoLocationId) -> Unit,
+ onLongClickRelayItemId: (Offset, GeoLocationId) -> Unit,
) {
val mapViewState = MapViewState(cameraLocation, markers, globeColors)
- Map(modifier = modifier, mapViewState = mapViewState)
+ Map(modifier = modifier, mapViewState = mapViewState, onClickRelayItemId, onLongClickRelayItemId)
}
@Composable
@@ -34,6 +42,8 @@ fun AnimatedMap(
cameraVerticalBias: Float,
markers: List<Marker>,
globeColors: GlobeColors,
+ onClickRelayItemId: (RelayItemId) -> Unit,
+ onLongClickRelayItemId: (Offset, RelayItemId) -> Unit
) {
Map(
modifier = modifier,
@@ -45,12 +55,18 @@ fun AnimatedMap(
),
markers = markers,
globeColors,
+ onClickRelayItemId = onClickRelayItemId,
+ onLongClickRelayItemId = onLongClickRelayItemId,
)
}
@Composable
-internal fun Map(modifier: Modifier = Modifier, mapViewState: MapViewState) {
-
+internal fun Map(
+ modifier: Modifier = Modifier,
+ mapViewState: MapViewState,
+ onClickRelayItemId: (GeoLocationId) -> Unit,
+ onLongClickRelayItemId: (Offset, GeoLocationId) -> Unit,
+) {
var view: MapGLSurfaceView? = remember { null }
val lifeCycleState = LocalLifecycleOwner.current.lifecycle
@@ -76,7 +92,25 @@ internal fun Map(modifier: Modifier = Modifier, mapViewState: MapViewState) {
}
}
- AndroidView(modifier = modifier, factory = { MapGLSurfaceView(it) }) { glSurfaceView ->
+ AndroidView(
+ modifier =
+ Modifier.pointerInput(lifeCycleState) {
+ detectTapGestures(
+ onTap = {
+ Logger.i("Registered marker click: $it")
+ val result = view?.onMapClick(it) ?: return@detectTapGestures
+ onClickRelayItemId(result.first)
+ },
+ onLongPress = {
+ Logger.i("Registered marker long click")
+ val result = view?.onMapClick(it) ?: return@detectTapGestures
+
+ onLongClickRelayItemId(result.second, result.first)
+ },
+ )
+ }.then(modifier),
+ factory = { MapGLSurfaceView(it) },
+ ) { glSurfaceView ->
view = glSurfaceView
glSurfaceView.setData(mapViewState)
}
diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt
index b66b0ea657..e8ac13f793 100644
--- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt
@@ -4,4 +4,13 @@ import androidx.compose.runtime.Immutable
import net.mullvad.mullvadvpn.lib.model.LatLong
@Immutable
-data class CameraPosition(val latLong: LatLong, val zoom: Float, val verticalBias: Float)
+data class CameraPosition(
+ val latLong: LatLong,
+ val zoom: Float,
+ val verticalBias: Float,
+ val fov: Float = DEFAULT_FIELD_OF_VIEW,
+) {
+ companion object {
+ const val DEFAULT_FIELD_OF_VIEW = 70f
+ }
+}
diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/LocationMarkerColors.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/LocationMarkerColors.kt
index 4e0959912b..b5fc439e6e 100644
--- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/LocationMarkerColors.kt
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/LocationMarkerColors.kt
@@ -8,7 +8,7 @@ data class LocationMarkerColors(
val centerColor: Color,
val ringBorderColor: Color = Color.White,
val shadowColor: Color = Color.Black.copy(alpha = DEFAULT_SHADOW_ALPHA),
- val perimeterColors: Color = centerColor.copy(alpha = DEFAULT_PERIMETER_ALPHA),
+ val perimeterColors: Color? = centerColor.copy(alpha = DEFAULT_PERIMETER_ALPHA),
) {
companion object {
private const val DEFAULT_SHADOW_ALPHA = 0.55f
diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt
index a7f25ec545..aae3bb991e 100644
--- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt
@@ -1,6 +1,8 @@
package net.mullvad.mullvadvpn.lib.map.data
+import android.os.Parcelable
import androidx.compose.runtime.Immutable
+import net.mullvad.mullvadvpn.lib.model.GeoLocationId
import net.mullvad.mullvadvpn.lib.model.LatLong
@Immutable
@@ -8,6 +10,7 @@ data class Marker(
val latLong: LatLong,
val size: Float = DEFAULT_MARKER_SIZE,
val colors: LocationMarkerColors,
+ val id: GeoLocationId? = null,
) {
companion object {
private const val DEFAULT_MARKER_SIZE = 0.02f
diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt
index a0aacc34c6..292abcf06a 100644
--- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt
@@ -12,9 +12,9 @@ internal const val MIN_ANIMATION_MILLIS = 1300
internal const val MAX_ANIMATION_MILLIS = 2500
// The cut off where we go from a short animation (camera pans) to a far animation (camera pans +
// zoom out)
-internal const val SHORT_ANIMATION_CUTOFF_MILLIS = 1700
+const val SHORT_ANIMATION_CUTOFF_MILLIS = 1700
// Multiplier for the zoom out animation
-internal const val FAR_ANIMATION_MAX_ZOOM_MULTIPLIER = 1.30f
+const val FAR_ANIMATION_MAX_ZOOM_MULTIPLIER = 1.80f
// When in the far animation we reach the MAX_ZOOM_MULTIPLIER, value is between 0 and 1
-internal const val MAX_MULTIPLIER_PEAK_TIMING = .35f
+const val MAX_MULTIPLIER_PEAK_TIMING = .35f
diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt
index b767d894b7..40ffc7c61e 100644
--- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt
@@ -5,19 +5,34 @@ import android.opengl.GLES20
import android.opengl.GLSurfaceView
import android.opengl.Matrix
import androidx.collection.LruCache
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import co.touchlab.kermit.Logger
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10
+import kotlin.math.pow
+import kotlin.math.sqrt
import kotlin.math.tan
import net.mullvad.mullvadvpn.lib.map.data.CameraPosition
import net.mullvad.mullvadvpn.lib.map.data.LocationMarkerColors
import net.mullvad.mullvadvpn.lib.map.data.MapViewState
+import net.mullvad.mullvadvpn.lib.map.data.Marker
import net.mullvad.mullvadvpn.lib.map.internal.shapes.Globe
import net.mullvad.mullvadvpn.lib.map.internal.shapes.LocationMarker
+import net.mullvad.mullvadvpn.lib.model.map.Ray
+import net.mullvad.mullvadvpn.lib.model.map.Sphere
+import net.mullvad.mullvadvpn.lib.model.map.Vector3
+import net.mullvad.mullvadvpn.lib.model.map.rotateAroundX
+import net.mullvad.mullvadvpn.lib.model.map.rotateAroundY
+import net.mullvad.mullvadvpn.lib.model.map.toLatLng
+import net.mullvad.mullvadvpn.lib.model.map.toVector3
import net.mullvad.mullvadvpn.lib.model.toRadians
internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.Renderer {
private lateinit var globe: Globe
+ private var viewPortSize: Size = Size(0f, 0f)
+ private val radius: Float = 1f
// Due to location markers themselves containing colors we cache them to avoid recreating them
// for every draw call.
@@ -34,6 +49,8 @@ internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.R
}
private lateinit var viewState: MapViewState
+ private val projectionMatrix = newIdentityMatrix()
+ private var globalViewMatrix = newIdentityMatrix()
override fun onSurfaceCreated(unused: GL10, config: EGLConfig) {
globe = Globe(resources)
@@ -51,8 +68,6 @@ internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.R
GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA)
}
- private val projectionMatrix = newIdentityMatrix()
-
override fun onDrawFrame(gl10: GL10) {
// Clear canvas
clear()
@@ -67,6 +82,7 @@ internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.R
Matrix.rotateM(viewMatrix, 0, viewState.cameraPosition.latLong.latitude.value, 1f, 0f, 0f)
Matrix.rotateM(viewMatrix, 0, viewState.cameraPosition.latLong.longitude.value, 0f, -1f, 0f)
+ globalViewMatrix = viewMatrix.copyOf()
globe.draw(projectionMatrix, viewMatrix, viewState.globeColors)
// Draw location markers
@@ -82,8 +98,9 @@ internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.R
private fun toOffsetY(cameraPosition: CameraPosition): Float {
val percent = cameraPosition.verticalBias
val z = cameraPosition.zoom - 1f
+
// Calculate the size of the plane at the current z position
- val planeSizeY = tan(FIELD_OF_VIEW.toRadians() / 2f) * z * 2f
+ val planeSizeY = tan(cameraPosition.fov.toRadians() / 2f) * z * 2f
// Calculate the start of the plane
val planeStartY = planeSizeY / 2f
@@ -105,6 +122,8 @@ internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.R
override fun onSurfaceChanged(unused: GL10, width: Int, height: Int) {
GLES20.glViewport(0, 0, width, height)
+ viewPortSize = Size(width.toFloat(), height.toFloat())
+
val ratio: Float = width.toFloat() / height.toFloat()
if (ratio.isFinite()) {
@@ -121,6 +140,82 @@ internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.R
fun setViewState(viewState: MapViewState) {
this.viewState = viewState
+ markerVector = viewState.locationMarker.map { it.latLong.toVector3() to it }.toMap()
+ }
+
+ var markerVector = mapOf<Vector3, Marker>()
+
+ fun closestMarker(offset: Offset): Pair<Marker?, Float>? {
+ val cameraz = -viewState.cameraPosition.zoom
+ val camerax = 0f
+ val cameray = toOffsetY(viewState.cameraPosition)
+
+ val camera = Vector3(camerax, cameray, cameraz)
+
+ val sphere = Sphere(Vector3(0f, 0f, 0f), 1f)
+ val ratio: Float = viewPortSize.width.toFloat() / viewPortSize.height.toFloat()
+
+ val directionVector =
+ calculateDirectionVector(
+ viewState.cameraPosition.fov,
+ ratio,
+ viewPortSize.width,
+ viewPortSize.height,
+ offset.x,
+ offset.y,
+ nearPlaneDistance = PERSPECTIVE_Z_NEAR,
+ )
+
+ val ray = Ray(camera, directionVector)
+
+ val oc = ray.origin - sphere.center // Vector from ray origin to sphere center
+ val a = ray.direction.dot(ray.direction)
+ val b = 2f * oc.dot(ray.direction)
+ val c = oc.dot(oc) - sphere.radius.pow(2f)
+ val discriminant = b.pow(2f) - 4f * a * c
+
+ if (discriminant < 0f) {
+ return null // No intersection
+ } else {
+ val t = (-b - sqrt(discriminant)) / (2f * a) // Closest intersection point
+ val t2 = (-b + sqrt(discriminant)) / (2f * a) // Closest intersection point
+ Logger.d("Intersection t1: $t, t2: $t2")
+ val point2 = ray.origin + ray.direction * t2
+
+ val newPosition =
+ point2
+ .rotateAroundX(-viewState.cameraPosition.latLong.latitude.value)
+ .rotateAroundY(viewState.cameraPosition.latLong.longitude.value)
+
+ Logger.d("Intersection point2: $point2")
+ Logger.d("Intersection real vector: $newPosition")
+ Logger.d("Clicked lat lng: ${newPosition.toLatLng()}")
+
+ val closestMarker = markerVector.minByOrNull { it.key.distanceTo(newPosition) }
+
+ if (closestMarker != null) {
+ return closestMarker.value to closestMarker.key.distanceTo(newPosition)
+ }
+
+ return null
+ }
+ }
+
+ fun calculateDirectionVector(
+ fovy: Float,
+ aspectRatio: Float,
+ viewportWidth: Float,
+ viewportHeight: Float,
+ tapScreenX: Float,
+ tapScreenY: Float,
+ nearPlaneDistance: Float = 1.0f,
+ ): Vector3 {
+ val halfHeight = tan(fovy.toRadians() / 2.0f) * nearPlaneDistance
+ val halfWidth = halfHeight * aspectRatio
+ val x = (2.0f * tapScreenX / viewportWidth - 1.0f) * halfWidth
+ val y = (1.0f - 2.0f * tapScreenY / viewportHeight) * halfHeight
+ val z = -nearPlaneDistance
+ return Vector3(x, y, z).normalize()
}
companion object {
diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt
index 19dd085524..3607cca820 100644
--- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt
@@ -2,8 +2,11 @@ package net.mullvad.mullvadvpn.lib.map.internal
import android.content.Context
import android.opengl.GLSurfaceView
+import androidx.compose.ui.geometry.Offset
import net.mullvad.mullvadvpn.lib.map.BuildConfig
import net.mullvad.mullvadvpn.lib.map.data.MapViewState
+import net.mullvad.mullvadvpn.lib.model.GeoLocationId
+import net.mullvad.mullvadvpn.lib.model.RelayItemId
internal class MapGLSurfaceView(context: Context) : GLSurfaceView(context) {
@@ -28,4 +31,11 @@ internal class MapGLSurfaceView(context: Context) : GLSurfaceView(context) {
renderer.setViewState(viewState)
requestRender()
}
+
+ fun onMapClick(offset: Offset): Pair<GeoLocationId, Offset>? {
+ val (marker, distance) = renderer.closestMarker(offset) ?: return null
+ if (distance < 0.02f) {
+ return marker?.id?.let { it to offset }
+ } else return null
+ }
}
diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt
index 26e69416b9..c12209273c 100644
--- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt
@@ -54,14 +54,17 @@ internal class LocationMarker(val colors: LocationMarkerColors) {
val modelViewMatrix = viewMatrix.copyOf()
GLES20.glUseProgram(shaderProgram)
-
Matrix.rotateM(modelViewMatrix, 0, latLong.longitude.value, 0f, 1f, 0f)
Matrix.rotateM(modelViewMatrix, 0, latLong.latitude.value, -1f, 0f, 0f)
Matrix.scaleM(modelViewMatrix, 0, size, size, 1f)
// Translate marker to put it above the globe
- Matrix.translateM(modelViewMatrix, 0, 0f, 0f, MARKER_TRANSLATE_Z_FACTOR)
+ if (colors.perimeterColors != null) {
+ Matrix.translateM(modelViewMatrix, 0, 0f, 0f, MARKER_TRANSLATE_Z_FACTOR + 0.0003f)
+ } else {
+ Matrix.translateM(modelViewMatrix, 0, 0f, 0f, MARKER_TRANSLATE_Z_FACTOR)
+ }
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, positionBuffer)
GLES20.glVertexAttribPointer(
@@ -148,37 +151,53 @@ internal class LocationMarker(val colors: LocationMarkerColors) {
}
@Suppress("MagicNumber")
- private fun createRings(): List<Ring> =
- listOf(
- circleFanVertices(
- 32,
- 0.5f,
- floatArrayOf(0.0f, 0.0f, 0.0f),
- colors.perimeterColors,
- colors.perimeterColors,
- ), // Semi-transparent outer
+ private fun createRings(): List<Ring> = buildList {
+ colors.perimeterColors?.let {
+ // Semi-transparent outer
+ add(
+ circleFanVertices(
+ 32,
+ 0.5f,
+ floatArrayOf(0.0f, 0.0f, 0.0f),
+ colors.perimeterColors,
+ colors.perimeterColors,
+ )
+ )
+ }
+
+ // Shadow
+ add(
circleFanVertices(
16,
0.28f,
floatArrayOf(0.0f, -0.05f, 0.00001f),
colors.shadowColor,
colors.shadowColor.copy(alpha = 0.0f),
- ), // Shadow
+ )
+ )
+
+ // White ring
+ add(
circleFanVertices(
32,
0.185f,
floatArrayOf(0.0f, 0.0f, 0.00002f),
colors.ringBorderColor,
colors.ringBorderColor,
- ), // White ring
+ )
+ )
+
+ // Center colored circle
+ add(
circleFanVertices(
32,
0.15f,
floatArrayOf(0.0f, 0.0f, 0.00003f),
colors.centerColor,
colors.centerColor,
- ), // Center colored circle
+ )
)
+ }
fun onRemove() {
GLES20.glDeleteBuffers(2, intArrayOf(positionBuffer, colorBuffer), 0)
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LatLong.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LatLong.kt
index 19f757ffc3..a3cbf13761 100644
--- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LatLong.kt
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LatLong.kt
@@ -46,3 +46,5 @@ data class LatLong(val latitude: Latitude, val longitude: Longitude) {
const val COMPLETE_ANGLE = 360f
fun Float.toRadians() = this * Math.PI.toFloat() / (COMPLETE_ANGLE / 2)
+
+fun Float.toDegrees() = this * ((COMPLETE_ANGLE / 2) / Math.PI.toFloat())
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt
index b1df67fea6..3ea0b48cbb 100644
--- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt
@@ -71,6 +71,7 @@ sealed interface RelayItem {
override val id: GeoLocationId.City,
override val name: String,
val relays: List<Relay>,
+ val latLong: LatLong,
) : Location {
override val active = relays.any { it.active }
override val hasChildren: Boolean = relays.isNotEmpty()
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/map/Ray.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/map/Ray.kt
new file mode 100644
index 0000000000..2e8eb236c8
--- /dev/null
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/map/Ray.kt
@@ -0,0 +1,3 @@
+package net.mullvad.mullvadvpn.lib.model.map
+
+data class Ray(val origin: Vector3, val direction: Vector3)
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/map/Sphere.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/map/Sphere.kt
new file mode 100644
index 0000000000..73a759e938
--- /dev/null
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/map/Sphere.kt
@@ -0,0 +1,7 @@
+package net.mullvad.mullvadvpn.lib.model.map
+
+data class Sphere(val center: Vector3, val radius: Float) {
+ companion object {
+ const val RADIUS = 1f
+ }
+}
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/map/Vector3.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/map/Vector3.kt
new file mode 100644
index 0000000000..e1c3901a11
--- /dev/null
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/map/Vector3.kt
@@ -0,0 +1,89 @@
+package net.mullvad.mullvadvpn.lib.model.map
+
+import kotlin.math.acos
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+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.toDegrees
+import net.mullvad.mullvadvpn.lib.model.toRadians
+
+data class Vector3(val x: Float, val y: Float, val z: Float) {
+ fun dot(other: Vector3): Float {
+ return x * other.x + y * other.y + z * other.z
+ }
+
+ operator fun minus(other: Vector3): Vector3 {
+ return Vector3(x - other.x, y - other.y, z - other.z)
+ }
+
+ operator fun times(scalar: Float): Vector3 {
+ return Vector3(x * scalar, y * scalar, z * scalar)
+ }
+
+ operator fun plus(other: Vector3): Vector3 {
+ return Vector3(x + other.x, y + other.y, z + other.z)
+ }
+
+ fun normalize(): Vector3 {
+ val length = sqrt(x * x + y * y + z * z)
+ return Vector3(x / length, y / length, z / length)
+ }
+
+ fun distanceTo(other: Vector3): Float {
+ val dx = x - other.x
+ val dy = y - other.y
+ val dz = z - other.z
+ return sqrt(dx * dx + dy * dy + dz * dz)
+ }
+}
+
+fun Vector3.rotateAroundX(degrees: Float): Vector3 {
+ val radians = degrees.toRadians()
+ val cosTheta = cos(radians)
+ val sinTheta = sin(radians)
+
+ val newY = cosTheta * y - sinTheta * z
+ val newZ = sinTheta * y + cosTheta * z
+
+ return Vector3(x, -newY, newZ)
+}
+
+fun Vector3.rotateAroundY(degrees: Float): Vector3 {
+ val radians = degrees.toRadians()
+ val cosTheta = cos(radians)
+ val sinTheta = sin(radians)
+
+ val newX = cosTheta * x + sinTheta * z
+ val newZ = -sinTheta * x + cosTheta * z
+
+ return Vector3(newX, y, -newZ)
+}
+
+fun Vector3.toLatLng(): LatLong {
+ // phi
+ val lat = acos(y / Sphere.RADIUS)
+
+ // theta
+ val lon = atan2(x, z)
+
+ return LatLong(
+ // This worked for some reason (camera starts at lat 90!)
+ Latitude.fromFloat(90f - lat.toDegrees()),
+ Longitude.fromFloat(-lon.toDegrees()),
+ )
+}
+
+fun LatLong.toVector3(): Vector3 {
+ val phi = this.latitude.value.toRadians()
+ val theta = this.longitude.value.toRadians()
+
+ val x = -Sphere.RADIUS * cos(phi) * sin(theta)
+ val y = Sphere.RADIUS * sin(phi)
+ val z = Sphere.RADIUS * cos(phi) * cos(theta)
+
+ return Vector3(x, y, z)
+}
diff --git a/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/lib/model/map/Vector3Test.kt b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/lib/model/map/Vector3Test.kt
new file mode 100644
index 0000000000..c9f35692bf
--- /dev/null
+++ b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/lib/model/map/Vector3Test.kt
@@ -0,0 +1,69 @@
+package net.mullvad.mullvadvpn.lib.model.map
+
+import kotlin.test.assertEquals
+import net.mullvad.mullvadvpn.lib.model.LatLong
+import net.mullvad.mullvadvpn.lib.model.Latitude
+import net.mullvad.mullvadvpn.lib.model.Longitude
+import org.junit.jupiter.api.Test
+
+class Vector3Test {
+ @Test
+ fun `Y-axis center test`() {
+ assertVector3Equals(Y_POSITIVE_CENTER.toVector3(), Vector3(0f, 1f, 0f))
+ assertVector3Equals(Y_NEGATIVE_CENTER.toVector3(), Vector3(0f, -1f, 0f))
+ }
+
+ @Test
+ fun `Z-axis center test`() {
+ assertVector3Equals(Z_POSITIVE_CENTER.toVector3(), Vector3(0f, 0f, 1f))
+ assertVector3Equals(Z_NEGATIVE_CENTER.toVector3(), Vector3(0f, 0f, -1f))
+ }
+
+ @Test
+ fun `X-axis center test`() {
+ assertVector3Equals(X_POSITIVE_CENTER.toVector3(), Vector3(-1f, 0f, 0f))
+ assertVector3Equals(X_NEGATIVE_CENTER.toVector3(), Vector3(1f, 0f, 0f))
+ }
+
+ @Test
+ fun `Y-axis center to LatLng test`() {
+ assertLatLngEquals(Vector3(0f, 1f, 0f).toLatLng(), Y_POSITIVE_CENTER)
+ assertLatLngEquals(Vector3(0f, -1f, 0f).toLatLng(), Y_NEGATIVE_CENTER)
+ }
+
+ @Test
+ fun `Z-axis center to LatLng test`() {
+ assertLatLngEquals(Vector3(0f, 0f, 1f).toLatLng(), Z_POSITIVE_CENTER)
+ assertLatLngEquals(Vector3(0f, 0f, -1f).toLatLng(), Z_NEGATIVE_CENTER)
+ }
+
+ @Test
+ fun `X-axis center to LatLng test`() {
+ assertLatLngEquals(Vector3(-1f, 0f, 0f).toLatLng(), X_POSITIVE_CENTER)
+ assertLatLngEquals(Vector3(1f, 0f, 0f).toLatLng(), X_NEGATIVE_CENTER)
+ }
+
+ companion object {
+ // NORTH POLE
+ val Y_POSITIVE_CENTER = LatLong(Latitude(90f), Longitude(0f))
+ // SOUTH POLE
+ val Y_NEGATIVE_CENTER = LatLong(Latitude(-90f), Longitude(0f))
+
+ val Z_POSITIVE_CENTER = LatLong(Latitude(0f), Longitude(0f))
+ val Z_NEGATIVE_CENTER = LatLong(Latitude(0f), Longitude.fromFloat(180f))
+
+ val X_NEGATIVE_CENTER = LatLong(Latitude(0f), Longitude.fromFloat(-90f))
+ val X_POSITIVE_CENTER = LatLong(Latitude(0f), Longitude.fromFloat(90f))
+ }
+
+ fun assertVector3Equals(expected: Vector3, actual: Vector3) {
+ assertEquals(expected.x, actual.x, 0.0001f)
+ assertEquals(expected.y, actual.y, 0.0001f)
+ assertEquals(expected.z, actual.z, 0.0001f)
+ }
+
+ fun assertLatLngEquals(expected: LatLong, actual: LatLong) {
+ assertEquals(expected.latitude.distanceTo(actual.latitude), 0f, 0.0001f)
+ assertEquals(expected.longitude.distanceTo(actual.longitude), 0f, 0.0001f)
+ }
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemPreviewData.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemPreviewData.kt
index 724c567595..e28f45478d 100644
--- a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemPreviewData.kt
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemPreviewData.kt
@@ -1,6 +1,9 @@
package net.mullvad.mullvadvpn.lib.ui.component.relaylist
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.Ownership
import net.mullvad.mullvadvpn.lib.model.ProviderId
import net.mullvad.mullvadvpn.lib.model.RelayItem
@@ -37,6 +40,7 @@ private fun generateRelayItemCity(
active,
)
},
+ latLong = LatLong(Latitude.fromFloat(0f), Longitude.fromFloat(0f)),
)
private fun generateRelayItemRelay(