diff options
| author | David Göransson <david.goransson@mullvad.net> | 2025-08-29 14:51:10 +0200 |
|---|---|---|
| committer | David Göransson <david.goransson@mullvad.net> | 2025-09-01 08:34:16 +0200 |
| commit | e02d460164fe09d5cf085cdcb3f40a9251bae633 (patch) | |
| tree | cbad368a9d30876f24631a6548e7d32e2e2c9524 | |
| parent | 4f03db1de3224d666cf6fc5ab255f3629435a59a (diff) | |
| download | mullvadvpn-interactive-maps.tar.xz mullvadvpn-interactive-maps.zip | |
Add interactive mapsinteractive-maps
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( |
