diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 3932e220f567..fbbe55cf0b66 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -320,6 +320,7 @@ dependencies {
implementation(project(Dependencies.Mullvad.talpidLib))
implementation(project(Dependencies.Mullvad.themeLib))
implementation(project(Dependencies.Mullvad.paymentLib))
+ implementation(project(Dependencies.Mullvad.mapLib))
// Play implementation
playImplementation(project(Dependencies.Mullvad.billingLib))
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b4043fdee507..0337d1200d06 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -17,6 +17,8 @@
android:required="false" />
+
Unit,
@@ -101,7 +102,7 @@ fun NotificationBanner(
visible = notification != null,
enter = slideInVertically(initialOffsetY = { -it }),
exit = slideOutVertically(targetOffsetY = { -it }),
- modifier = Modifier.animateContentSize()
+ modifier = modifier
) {
val visibleNotification = notification ?: previous
if (visibleNotification != null)
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 84d2a0418eb6..a3ebaabf99cd 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
@@ -2,10 +2,13 @@ package net.mullvad.mullvadvpn.compose.screen
import android.content.Intent
import android.net.Uri
-import androidx.compose.foundation.background
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -18,6 +21,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -25,6 +29,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@@ -55,15 +63,28 @@ import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG
import net.mullvad.mullvadvpn.compose.test.SCROLLABLE_COLUMN_TEST_TAG
import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG
import net.mullvad.mullvadvpn.compose.transitions.HomeTransition
+import net.mullvad.mullvadvpn.constant.SECURE_ZOOM
+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.openAccountPageInBrowser
+import net.mullvad.mullvadvpn.lib.map.AnimatedMap
+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.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible
import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar
import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible
+import net.mullvad.mullvadvpn.model.GeoIpLocation
+import net.mullvad.mullvadvpn.model.LatLong
+import net.mullvad.mullvadvpn.model.Latitude
+import net.mullvad.mullvadvpn.model.Longitude
import net.mullvad.mullvadvpn.model.TunnelState
import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild
import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel
-import net.mullvad.talpid.tunnel.ActionAfterDisconnect
import org.koin.androidx.compose.koinViewModel
private const val CONNECT_BUTTON_THROTTLE_MILLIS = 1000
@@ -164,66 +185,80 @@ fun ConnectScreen(
}
ScaffoldWithTopBarAndDeviceName(
- topBarColor =
- if (uiState.tunnelUiState.isSecured()) {
- MaterialTheme.colorScheme.inversePrimary
- } else {
- MaterialTheme.colorScheme.error
- },
- iconTintColor =
- if (uiState.tunnelUiState.isSecured()) {
- MaterialTheme.colorScheme.onPrimary
- } else {
- MaterialTheme.colorScheme.onError
- }
- .copy(alpha = AlphaTopBar),
+ topBarColor = uiState.tunnelUiState.topBarColor(),
+ iconTintColor = uiState.tunnelUiState.iconTintColor(),
onSettingsClicked = onSettingsClick,
onAccountClicked = onAccountClick,
deviceName = uiState.deviceName,
timeLeft = uiState.daysLeftUntilExpiry
) {
+ var progressIndicatorBias by remember { mutableFloatStateOf(0f) }
+
+ // Distance to marker when secure/unsecure
+ val baseZoom =
+ animateFloatAsState(
+ targetValue =
+ if (uiState.tunnelRealState is TunnelState.Connected) SECURE_ZOOM
+ else UNSECURE_ZOOM,
+ animationSpec = tween(SECURE_ZOOM_ANIMATION_MILLIS),
+ label = "baseZoom"
+ )
+
+ val markers =
+ uiState.tunnelRealState.toMarker(uiState.location)?.let { listOf(it) } ?: emptyList()
+
+ AnimatedMap(
+ modifier = Modifier.padding(top = it.calculateTopPadding()),
+ cameraLocation = uiState.location?.toLatLong() ?: fallbackLatLong,
+ cameraBaseZoom = baseZoom.value,
+ cameraVerticalBias = progressIndicatorBias,
+ markers = markers,
+ globeColors =
+ GlobeColors(
+ landColor = MaterialTheme.colorScheme.primary,
+ oceanColor = MaterialTheme.colorScheme.secondary,
+ )
+ )
+
Column(
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.Start,
modifier =
- Modifier.background(color = MaterialTheme.colorScheme.primary)
- .padding(it)
+ Modifier.animateContentSize()
+ .padding(top = it.calculateTopPadding())
.fillMaxHeight()
.drawVerticalScrollbar(
scrollState,
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaScrollbar)
)
.verticalScroll(scrollState)
- .padding(bottom = Dimens.screenVerticalMargin)
.testTag(SCROLLABLE_COLUMN_TEST_TAG)
) {
- NotificationBanner(
- notification = uiState.inAppNotification,
- isPlayBuild = uiState.isPlayBuild,
- onClickUpdateVersion = onUpdateVersionClick,
- onClickShowAccount = onManageAccountClick,
- onClickDismissNewDevice = onDismissNewDeviceClick,
+ Spacer(modifier = Modifier.defaultMinSize(minHeight = Dimens.mediumPadding).weight(1f))
+ MullvadCircularProgressIndicatorLarge(
+ color = MaterialTheme.colorScheme.onPrimary,
+ modifier =
+ Modifier.animateContentSize()
+ .padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ top = Dimens.mediumPadding
+ )
+ .alpha(if (uiState.showLoading) AlphaVisible else AlphaInvisible)
+ .align(Alignment.CenterHorizontally)
+ .testTag(CIRCULAR_PROGRESS_INDICATOR)
+ .onGloballyPositioned {
+ val offsetY = it.positionInParent().y + it.size.height / 2
+ it.parentLayoutCoordinates?.let {
+ val parentHeight = it.size.height
+ val verticalBias = offsetY / parentHeight
+ if (verticalBias.isFinite()) {
+ progressIndicatorBias = verticalBias
+ }
+ }
+ }
)
- Spacer(modifier = Modifier.weight(1f))
- if (
- uiState.tunnelRealState is TunnelState.Connecting ||
- (uiState.tunnelRealState is TunnelState.Disconnecting &&
- uiState.tunnelRealState.actionAfterDisconnect ==
- ActionAfterDisconnect.Reconnect)
- ) {
- MullvadCircularProgressIndicatorLarge(
- color = MaterialTheme.colorScheme.onPrimary,
- modifier =
- Modifier.padding(
- start = Dimens.sideMargin,
- end = Dimens.sideMargin,
- top = Dimens.mediumPadding
- )
- .align(Alignment.CenterHorizontally)
- .testTag(CIRCULAR_PROGRESS_INDICATOR)
- )
- }
- Spacer(modifier = Modifier.height(Dimens.mediumPadding))
+ Spacer(modifier = Modifier.defaultMinSize(minHeight = Dimens.mediumPadding).weight(1f))
ConnectionStatusText(
state = uiState.tunnelRealState,
modifier = Modifier.padding(horizontal = Dimens.sideMargin)
@@ -243,9 +278,7 @@ fun ConnectScreen(
var expanded by rememberSaveable { mutableStateOf(false) }
LocationInfo(
onToggleTunnelInfo = { expanded = !expanded },
- isVisible =
- uiState.tunnelRealState !is TunnelState.Disconnected &&
- uiState.location?.hostname != null,
+ isVisible = uiState.showLocationInfo,
isExpanded = expanded,
location = uiState.location,
inAddress = uiState.inAddress,
@@ -282,6 +315,55 @@ fun ConnectScreen(
connectClick = { handleThrottledAction(onConnectClick) },
reconnectButtonTestTag = RECONNECT_BUTTON_TEST_TAG
)
+ // We need to manually add this padding so we align size with the map
+ // component and marker with the progress indicator.
+ Spacer(modifier = Modifier.height(it.calculateBottomPadding()))
}
+
+ NotificationBanner(
+ modifier = Modifier.padding(top = it.calculateTopPadding()),
+ notification = uiState.inAppNotification,
+ isPlayBuild = uiState.isPlayBuild,
+ onClickUpdateVersion = onUpdateVersionClick,
+ onClickShowAccount = onManageAccountClick,
+ onClickDismissNewDevice = onDismissNewDeviceClick,
+ )
+ }
+}
+
+@Composable
+fun TunnelState.toMarker(location: GeoIpLocation?): Marker? {
+ if (location == null) return null
+ return when (this) {
+ is TunnelState.Connected ->
+ Marker(
+ location.toLatLong(),
+ colors =
+ LocationMarkerColors(centerColor = MaterialTheme.colorScheme.inversePrimary),
+ )
+ is TunnelState.Connecting -> null
+ is TunnelState.Disconnected ->
+ Marker(
+ location.toLatLong(),
+ colors = LocationMarkerColors(centerColor = MaterialTheme.colorScheme.error)
+ )
+ is TunnelState.Disconnecting -> null
+ is TunnelState.Error -> null
}
}
+
+@Composable
+fun TunnelState.topBarColor(): Color =
+ if (isSecured()) MaterialTheme.colorScheme.inversePrimary else MaterialTheme.colorScheme.error
+
+@Composable
+fun TunnelState.iconTintColor(): Color =
+ if (isSecured()) {
+ MaterialTheme.colorScheme.onPrimary
+ } else {
+ MaterialTheme.colorScheme.onError
+ }
+ .copy(alpha = AlphaTopBar)
+
+fun GeoIpLocation.toLatLong() =
+ LatLong(Latitude(latitude.toFloat()), Longitude(longitude.toFloat()))
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 988ae914e9c3..4a1c41e56266 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
@@ -17,8 +17,14 @@ data class ConnectUiState(
val inAppNotification: InAppNotification?,
val deviceName: String?,
val daysLeftUntilExpiry: Int?,
- val isPlayBuild: Boolean
+ val isPlayBuild: Boolean,
) {
+
+ val showLocationInfo: Boolean =
+ tunnelRealState !is TunnelState.Disconnected && location?.hostname != null
+ val showLoading =
+ tunnelRealState is TunnelState.Connecting || tunnelRealState is TunnelState.Disconnecting
+
companion object {
val INITIAL =
ConnectUiState(
@@ -32,7 +38,7 @@ data class ConnectUiState(
inAppNotification = null,
deviceName = null,
daysLeftUntilExpiry = null,
- isPlayBuild = false
+ isPlayBuild = false,
)
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt
index 4ccf15bb63f3..d58107c713f2 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt
@@ -1,6 +1,9 @@
package net.mullvad.mullvadvpn.constant
import androidx.compose.animation.core.Spring
+import net.mullvad.mullvadvpn.model.LatLong
+import net.mullvad.mullvadvpn.model.Latitude
+import net.mullvad.mullvadvpn.model.Longitude
const val MINIMUM_LOADING_TIME_MILLIS = 500L
@@ -9,3 +12,10 @@ const val SCREEN_ANIMATION_TIME_MILLIS = Spring.StiffnessMediumLow.toInt()
const val HORIZONTAL_SLIDE_FACTOR = 1 / 3f
fun Int.withHorizontalScalingFactor(): Int = (this * HORIZONTAL_SLIDE_FACTOR).toInt()
+
+const val SECURE_ZOOM = 1.15f
+const val UNSECURE_ZOOM = 1.20f
+const val SECURE_ZOOM_ANIMATION_MILLIS = 2000
+
+// Location of Gothenburg, Sweden
+val fallbackLatLong = LatLong(Latitude(57.7065f), Longitude(11.967f))
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 bee5b1ad0012..12427e228594 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
@@ -129,7 +129,7 @@ class ConnectViewModel(
inAppNotification = notifications.firstOrNull(),
deviceName = deviceName,
daysLeftUntilExpiry = accountExpiry.date()?.daysFromNow(),
- isPlayBuild = isPlayBuild
+ isPlayBuild = isPlayBuild,
)
}
}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt
index 57eeef5ca432..05dae1d53dd1 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt
@@ -209,7 +209,9 @@ class ConnectViewModelTest {
ipv6 = mockk(relaxed = true),
country = "Sweden",
city = "Gothenburg",
- hostname = "Host"
+ hostname = "Host",
+ latitude = 57.7065,
+ longitude = 11.967
)
// Act, Assert
diff --git a/android/buildSrc/src/main/kotlin/Dependencies.kt b/android/buildSrc/src/main/kotlin/Dependencies.kt
index 055bf72d1252..af4f414b0c08 100644
--- a/android/buildSrc/src/main/kotlin/Dependencies.kt
+++ b/android/buildSrc/src/main/kotlin/Dependencies.kt
@@ -99,6 +99,7 @@ object Dependencies {
const val commonTestLib = ":lib:common-test"
const val billingLib = ":lib:billing"
const val paymentLib = ":lib:payment"
+ const val mapLib = ":lib:map"
}
object Plugin {
diff --git a/android/lib/map/build.gradle.kts b/android/lib/map/build.gradle.kts
new file mode 100644
index 000000000000..75d11ae189aa
--- /dev/null
+++ b/android/lib/map/build.gradle.kts
@@ -0,0 +1,43 @@
+plugins {
+ id(Dependencies.Plugin.kotlinAndroidId)
+ id(Dependencies.Plugin.androidLibraryId)
+}
+
+android {
+ namespace = "net.mullvad.mullvadvpn.lib.map"
+ compileSdk = Versions.Android.compileSdkVersion
+
+ defaultConfig {
+ minSdk = Versions.Android.minSdkVersion
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = Versions.jvmTarget
+ }
+
+ buildFeatures { compose = true }
+
+ composeOptions { kotlinCompilerExtensionVersion = Versions.kotlinCompilerExtensionVersion }
+
+ lint {
+ lintConfig = file("${rootProject.projectDir}/config/lint.xml")
+ abortOnError = true
+ warningsAsErrors = true
+ }
+}
+
+dependencies {
+
+ //Model
+ implementation(project(Dependencies.Mullvad.modelLib))
+
+ implementation(Dependencies.Compose.ui)
+ implementation(Dependencies.Compose.foundation)
+
+ implementation(Dependencies.AndroidX.lifecycleRuntimeKtx)
+}
diff --git a/android/lib/map/src/main/AndroidManifest.xml b/android/lib/map/src/main/AndroidManifest.xml
new file mode 100644
index 000000000000..8072ee00dbf1
--- /dev/null
+++ b/android/lib/map/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
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
new file mode 100644
index 000000000000..00fcf97ab755
--- /dev/null
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt
@@ -0,0 +1,101 @@
+package net.mullvad.mullvadvpn.lib.map
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.EaseInOut
+import androidx.compose.animation.core.keyframes
+import androidx.compose.animation.core.tween
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.lib.map.data.CameraPosition
+import net.mullvad.mullvadvpn.lib.map.internal.DISTANCE_DURATION_SCALE_FACTOR
+import net.mullvad.mullvadvpn.lib.map.internal.FAR_ANIMATION_MAX_ZOOM_MULTIPLIER
+import net.mullvad.mullvadvpn.lib.map.internal.MAX_ANIMATION_MILLIS
+import net.mullvad.mullvadvpn.lib.map.internal.MAX_MULTIPLIER_PEAK_TIMING
+import net.mullvad.mullvadvpn.lib.map.internal.MIN_ANIMATION_MILLIS
+import net.mullvad.mullvadvpn.lib.map.internal.SHORT_ANIMATION_CUTOFF_MILLIS
+import net.mullvad.mullvadvpn.model.LatLong
+import net.mullvad.mullvadvpn.model.Latitude
+import net.mullvad.mullvadvpn.model.Longitude
+
+@Composable
+fun animatedCameraPosition(
+ baseZoom: Float,
+ targetCameraLocation: LatLong,
+ cameraVerticalBias: Float,
+): CameraPosition {
+
+ var previousLocation by remember { mutableStateOf(targetCameraLocation) }
+ var currentLocation by remember { mutableStateOf(targetCameraLocation) }
+
+ if (targetCameraLocation != currentLocation) {
+ previousLocation = currentLocation
+ currentLocation = targetCameraLocation
+ }
+
+ val distance =
+ remember(targetCameraLocation) { targetCameraLocation.distanceTo(previousLocation).toInt() }
+ val duration = distance.toAnimationDuration()
+
+ val longitudeAnimation = remember { Animatable(targetCameraLocation.longitude.value) }
+
+ val latitudeAnimation = remember { Animatable(targetCameraLocation.latitude.value) }
+ val zoomOutMultiplier = remember { Animatable(1f) }
+
+ LaunchedEffect(targetCameraLocation) {
+ launch { latitudeAnimation.animateTo(targetCameraLocation.latitude.value, tween(duration)) }
+ launch {
+ // Unwind longitudeAnimation into a Longitude
+ val currentLongitude = Longitude.fromFloat(longitudeAnimation.value)
+
+ // Resolve a vector showing us the shortest path to the target longitude, e.g going
+ // from 170 to -170 would result in 20 since we can wrap around the globe
+ val shortestPathVector = currentLongitude.vectorTo(targetCameraLocation.longitude)
+
+ // Animate to the new camera location using the shortest path vector
+ longitudeAnimation.animateTo(
+ longitudeAnimation.value + shortestPathVector.value,
+ tween(duration),
+ )
+
+ // Current value animation value might be outside of range of a Longitude, so when the
+ // animation is done we unwind the animation to the correct value
+ longitudeAnimation.snapTo(targetCameraLocation.longitude.value)
+ }
+ launch {
+ zoomOutMultiplier.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
+ }
+ }
+ )
+ }
+ }
+
+ return CameraPosition(
+ zoom = baseZoom * zoomOutMultiplier.value,
+ latLong =
+ LatLong(
+ Latitude(latitudeAnimation.value),
+ Longitude.fromFloat(longitudeAnimation.value)
+ ),
+ verticalBias = cameraVerticalBias
+ )
+}
+
+private fun Int.toAnimationDuration() =
+ (this * DISTANCE_DURATION_SCALE_FACTOR).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
new file mode 100644
index 000000000000..a143a63cb818
--- /dev/null
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt
@@ -0,0 +1,83 @@
+package net.mullvad.mullvadvpn.lib.map
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+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.model.LatLong
+
+@Composable
+fun Map(
+ modifier: Modifier,
+ cameraLocation: CameraPosition,
+ markers: List,
+ globeColors: GlobeColors,
+) {
+ val mapViewState = MapViewState(cameraLocation, markers, globeColors)
+ Map(modifier = modifier, mapViewState = mapViewState)
+}
+
+@Composable
+fun AnimatedMap(
+ modifier: Modifier,
+ cameraLocation: LatLong,
+ cameraBaseZoom: Float,
+ cameraVerticalBias: Float,
+ markers: List,
+ globeColors: GlobeColors
+) {
+ Map(
+ modifier = modifier,
+ cameraLocation =
+ animatedCameraPosition(
+ baseZoom = cameraBaseZoom,
+ targetCameraLocation = cameraLocation,
+ cameraVerticalBias = cameraVerticalBias
+ ),
+ markers = markers,
+ globeColors
+ )
+}
+
+@Composable
+internal fun Map(modifier: Modifier = Modifier, mapViewState: MapViewState) {
+
+ var view: MapGLSurfaceView? = remember { null }
+
+ val lifeCycleState = LocalLifecycleOwner.current.lifecycle
+
+ DisposableEffect(key1 = lifeCycleState) {
+ val observer = LifecycleEventObserver { _, event ->
+ when (event) {
+ Lifecycle.Event.ON_RESUME -> {
+ view?.onResume()
+ }
+ Lifecycle.Event.ON_PAUSE -> {
+ view?.onPause()
+ }
+ else -> {}
+ }
+ }
+ lifeCycleState.addObserver(observer)
+
+ onDispose {
+ lifeCycleState.removeObserver(observer)
+ view?.onPause()
+ view = null
+ }
+ }
+
+ AndroidView(modifier = 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
new file mode 100644
index 000000000000..d837bcadfc66
--- /dev/null
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt
@@ -0,0 +1,7 @@
+package net.mullvad.mullvadvpn.lib.map.data
+
+import androidx.compose.runtime.Immutable
+import net.mullvad.mullvadvpn.model.LatLong
+
+@Immutable
+data class CameraPosition(val latLong: LatLong, val zoom: Float, val verticalBias: Float)
diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/GlobeColors.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/GlobeColors.kt
new file mode 100644
index 000000000000..251c466a935a
--- /dev/null
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/GlobeColors.kt
@@ -0,0 +1,16 @@
+package net.mullvad.mullvadvpn.lib.map.data
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.graphics.Color
+import net.mullvad.mullvadvpn.lib.map.internal.toFloatArray
+
+@Immutable
+data class GlobeColors(
+ val landColor: Color,
+ val oceanColor: Color,
+ val contourColor: Color = oceanColor,
+) {
+ val landColorArray = landColor.toFloatArray()
+ val oceanColorArray = oceanColor.toFloatArray()
+ val contourColorArray = contourColor.toFloatArray()
+}
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
new file mode 100644
index 000000000000..7d4edb09cb8a
--- /dev/null
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/LocationMarkerColors.kt
@@ -0,0 +1,17 @@
+package net.mullvad.mullvadvpn.lib.map.data
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.graphics.Color
+
+@Immutable
+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)
+) {
+ companion object {
+ private const val DEFAULT_SHADOW_ALPHA = 0.55f
+ private const val DEFAULT_PERIMETER_ALPHA = 0.4f
+ }
+}
diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/MapViewState.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/MapViewState.kt
new file mode 100644
index 000000000000..1e1a21111569
--- /dev/null
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/MapViewState.kt
@@ -0,0 +1,10 @@
+package net.mullvad.mullvadvpn.lib.map.data
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+class MapViewState(
+ val cameraPosition: CameraPosition,
+ val locationMarker: List,
+ val globeColors: GlobeColors
+)
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
new file mode 100644
index 000000000000..9f464612f1bf
--- /dev/null
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt
@@ -0,0 +1,15 @@
+package net.mullvad.mullvadvpn.lib.map.data
+
+import androidx.compose.runtime.Immutable
+import net.mullvad.mullvadvpn.model.LatLong
+
+@Immutable
+data class Marker(
+ val latLong: LatLong,
+ val size: Float = DEFAULT_MARKER_SIZE,
+ val colors: LocationMarkerColors
+) {
+ 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
new file mode 100644
index 000000000000..dfd80d547d18
--- /dev/null
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt
@@ -0,0 +1,19 @@
+package net.mullvad.mullvadvpn.lib.map.internal
+
+internal const val VERTEX_COMPONENT_SIZE = 3
+internal const val COLOR_COMPONENT_SIZE = 4
+internal const val MATRIX_SIZE = 16
+
+// Constant what will talk the distance in LatLng multiply it to determine the animation duration,
+// the result is then confined to the MIN_ANIMATION_MILLIS and MAX_ANIMATION_MILLIS
+internal const val DISTANCE_DURATION_SCALE_FACTOR = 20
+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
+
+// Multiplier for the zoom out animation
+internal const val FAR_ANIMATION_MAX_ZOOM_MULTIPLIER = 1.30f
+// 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
diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/GLHelper.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/GLHelper.kt
new file mode 100644
index 000000000000..e416988d8d37
--- /dev/null
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/GLHelper.kt
@@ -0,0 +1,108 @@
+package net.mullvad.mullvadvpn.lib.map.internal
+
+import android.opengl.GLES20
+import android.opengl.Matrix
+import android.util.Log
+import androidx.compose.ui.graphics.Color
+import java.nio.Buffer
+import java.nio.ByteBuffer
+import java.nio.FloatBuffer
+
+internal fun initShaderProgram(vsSource: String, fsSource: String): Int {
+ val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vsSource)
+ require(vertexShader != -1) { "Failed to load vertexShader, result: -1" }
+
+ val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fsSource)
+ require(fragmentShader != -1) { "fragmentShader == -1" }
+
+ val program = GLES20.glCreateProgram()
+ check(program != 0) { "Could not create program" }
+
+ // Add the vertex shader to program
+ GLES20.glAttachShader(program, vertexShader)
+
+ // Add the fragment shader to program
+ GLES20.glAttachShader(program, fragmentShader)
+
+ // Creates OpenGL ES program executables
+ GLES20.glLinkProgram(program)
+
+ val linked = IntArray(1)
+ GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linked, 0)
+ if (linked[0] == GLES20.GL_FALSE) {
+ val infoLog = GLES20.glGetProgramInfoLog(program)
+ Log.e("GLHelper", "Could not link program: $infoLog")
+ GLES20.glDeleteProgram(program)
+ error("Could not link program with vsSource: $vsSource and fsSource: $fsSource")
+ }
+
+ return program
+}
+
+private fun loadShader(type: Int, shaderCode: String): Int {
+ // Create a vertex shader type (GLES20.GL_VERTEX_SHADER)
+ // or a fragment shader type (GLES20.GL_FRAGMENT_SHADER)
+ val shader = GLES20.glCreateShader(type)
+
+ require(shader != 0) { "Unable to create shader" }
+
+ // Add the source code to the shader and compile it
+ GLES20.glShaderSource(shader, shaderCode)
+ GLES20.glCompileShader(shader)
+
+ val compiled = IntArray(1)
+ GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0)
+ if (compiled[0] == GLES20.GL_FALSE) {
+ val infoLog = GLES20.glGetShaderInfoLog(shader)
+ Log.e("GLHelper", "Could not compile shader $type:$infoLog")
+ GLES20.glDeleteShader(shader)
+
+ error("Could not compile shader with shaderCode: $shaderCode")
+ }
+
+ return shader
+}
+
+internal fun initArrayBuffer(buffer: ByteBuffer) = initArrayBuffer(buffer, Byte.SIZE_BYTES)
+
+internal fun initArrayBuffer(buffer: FloatBuffer) = initArrayBuffer(buffer, Float.SIZE_BYTES)
+
+private fun initArrayBuffer(dataBuffer: Buffer, unitSizeInBytes: Int = 1): Int {
+ val buffer = IntArray(1)
+ GLES20.glGenBuffers(1, buffer, 0)
+
+ GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, buffer[0])
+ GLES20.glBufferData(
+ GLES20.GL_ARRAY_BUFFER,
+ dataBuffer.capacity() * unitSizeInBytes,
+ dataBuffer,
+ GLES20.GL_STATIC_DRAW
+ )
+ return buffer[0]
+}
+
+internal fun initIndexBuffer(dataBuffer: Buffer): IndexBufferWithLength {
+ val buffer = IntArray(1)
+ GLES20.glGenBuffers(1, buffer, 0)
+
+ GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, buffer[0])
+ GLES20.glBufferData(
+ GLES20.GL_ELEMENT_ARRAY_BUFFER,
+ dataBuffer.capacity(),
+ dataBuffer,
+ GLES20.GL_STATIC_DRAW
+ )
+ return IndexBufferWithLength(
+ indexBuffer = buffer[0],
+ length = dataBuffer.capacity() / Float.SIZE_BYTES
+ )
+}
+
+internal class IndexBufferWithLength(val indexBuffer: Int, val length: Int)
+
+internal fun newIdentityMatrix(): FloatArray =
+ FloatArray(MATRIX_SIZE).apply { Matrix.setIdentityM(this, 0) }
+
+internal fun Color.toFloatArray(): FloatArray {
+ return floatArrayOf(red, green, blue, alpha)
+}
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
new file mode 100644
index 000000000000..bf44e5ee140f
--- /dev/null
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt
@@ -0,0 +1,133 @@
+package net.mullvad.mullvadvpn.lib.map.internal
+
+import android.content.res.Resources
+import android.opengl.GLES20
+import android.opengl.GLSurfaceView
+import android.opengl.Matrix
+import androidx.collection.LruCache
+import javax.microedition.khronos.egl.EGLConfig
+import javax.microedition.khronos.opengles.GL10
+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.internal.shapes.Globe
+import net.mullvad.mullvadvpn.lib.map.internal.shapes.LocationMarker
+import net.mullvad.mullvadvpn.model.COMPLETE_ANGLE
+
+internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.Renderer {
+
+ private lateinit var globe: Globe
+
+ // Due to location markers themselves containing colors we cache them to avoid recreating them
+ // for every draw call.
+ private val markerCache: LruCache =
+ object : LruCache(100) {
+ override fun entryRemoved(
+ evicted: Boolean,
+ key: LocationMarkerColors,
+ oldValue: LocationMarker,
+ newValue: LocationMarker?
+ ) {
+ oldValue.onRemove()
+ }
+ }
+
+ private lateinit var viewState: MapViewState
+
+ override fun onSurfaceCreated(unused: GL10, config: EGLConfig) {
+ globe = Globe(resources)
+ markerCache.evictAll()
+ initGLOptions()
+ }
+
+ private fun initGLOptions() {
+ // Enable cull face (To not draw the backside of triangles)
+ GLES20.glEnable(GLES20.GL_CULL_FACE)
+ GLES20.glCullFace(GLES20.GL_BACK)
+
+ // Enable blend
+ GLES20.glEnable(GLES20.GL_BLEND)
+ GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA)
+ }
+
+ private val projectionMatrix = newIdentityMatrix()
+
+ override fun onDrawFrame(gl10: GL10) {
+ // Clear canvas
+ clear()
+
+ val viewMatrix = newIdentityMatrix()
+
+ // Adjust zoom & vertical bias
+ val yOffset = toOffsetY(viewState.cameraPosition)
+ Matrix.translateM(viewMatrix, 0, 0f, yOffset, -viewState.cameraPosition.zoom)
+
+ // Rotate to match the camera position
+ Matrix.rotateM(viewMatrix, 0, viewState.cameraPosition.latLong.latitude.value, 1f, 0f, 0f)
+ Matrix.rotateM(viewMatrix, 0, viewState.cameraPosition.latLong.longitude.value, 0f, -1f, 0f)
+
+ globe.draw(projectionMatrix, viewMatrix, viewState.globeColors)
+
+ // Draw location markers
+ viewState.locationMarker.forEach {
+ val marker =
+ markerCache[it.colors]
+ ?: LocationMarker(it.colors).also { markerCache.put(it.colors, it) }
+
+ marker.draw(projectionMatrix, viewMatrix, it.latLong, it.size)
+ }
+ }
+
+ private fun Float.toRadians() = this * Math.PI.toFloat() / (COMPLETE_ANGLE / 2)
+
+ 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
+
+ // Calculate the start of the plane
+ val planeStartY = planeSizeY / 2f
+
+ // Return offset based on the bias
+ return planeStartY - planeSizeY * percent
+ }
+
+ private fun clear() {
+ // Redraw background color
+ GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f)
+ GLES20.glClearDepthf(1.0f)
+ GLES20.glEnable(GLES20.GL_DEPTH_TEST)
+ GLES20.glDepthFunc(GLES20.GL_LEQUAL)
+
+ GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
+ }
+
+ override fun onSurfaceChanged(unused: GL10, width: Int, height: Int) {
+ GLES20.glViewport(0, 0, width, height)
+
+ val ratio: Float = width.toFloat() / height.toFloat()
+
+ if (ratio.isFinite()) {
+ Matrix.perspectiveM(
+ projectionMatrix,
+ 0,
+ FIELD_OF_VIEW,
+ ratio,
+ PERSPECTIVE_Z_NEAR,
+ PERSPECTIVE_Z_FAR
+ )
+ }
+ }
+
+ fun setViewState(viewState: MapViewState) {
+ this.viewState = viewState
+ }
+
+ companion object {
+ private const val PERSPECTIVE_Z_NEAR = 0.05f
+ private const val PERSPECTIVE_Z_FAR = 10f
+ private const val FIELD_OF_VIEW = 70f
+ }
+}
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
new file mode 100644
index 000000000000..19dd085524db
--- /dev/null
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt
@@ -0,0 +1,31 @@
+package net.mullvad.mullvadvpn.lib.map.internal
+
+import android.content.Context
+import android.opengl.GLSurfaceView
+import net.mullvad.mullvadvpn.lib.map.BuildConfig
+import net.mullvad.mullvadvpn.lib.map.data.MapViewState
+
+internal class MapGLSurfaceView(context: Context) : GLSurfaceView(context) {
+
+ private val renderer: MapGLRenderer
+
+ init {
+ // Create an OpenGL ES 2.0 context
+ setEGLContextClientVersion(2)
+
+ if (BuildConfig.DEBUG) {
+ debugFlags = DEBUG_CHECK_GL_ERROR or DEBUG_LOG_GL_CALLS
+ }
+
+ renderer = MapGLRenderer(context.resources)
+
+ // Set the Renderer for drawing on the GLSurfaceView
+ setRenderer(renderer)
+ renderMode = RENDERMODE_WHEN_DIRTY
+ }
+
+ fun setData(viewState: MapViewState) {
+ renderer.setViewState(viewState)
+ requestRender()
+ }
+}
diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/Globe.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/Globe.kt
new file mode 100644
index 000000000000..379ac407cc27
--- /dev/null
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/Globe.kt
@@ -0,0 +1,185 @@
+package net.mullvad.mullvadvpn.lib.map.internal.shapes
+
+import android.content.res.Resources
+import android.opengl.GLES20
+import android.opengl.Matrix
+import java.nio.ByteBuffer
+import net.mullvad.mullvadvpn.lib.map.R
+import net.mullvad.mullvadvpn.lib.map.data.GlobeColors
+import net.mullvad.mullvadvpn.lib.map.internal.IndexBufferWithLength
+import net.mullvad.mullvadvpn.lib.map.internal.VERTEX_COMPONENT_SIZE
+import net.mullvad.mullvadvpn.lib.map.internal.initArrayBuffer
+import net.mullvad.mullvadvpn.lib.map.internal.initIndexBuffer
+import net.mullvad.mullvadvpn.lib.map.internal.initShaderProgram
+
+internal class Globe(resources: Resources) {
+
+ private val shaderProgram: Int
+
+ private val attribLocations: AttribLocations
+ private val uniformLocation: UniformLocation
+
+ private val landIndices: IndexBufferWithLength
+ private val landContour: IndexBufferWithLength
+ private val landVertexBuffer: Int
+
+ private val oceanIndices: IndexBufferWithLength
+ private val oceanVertexBuffer: Int
+
+ init {
+ val landPosStream = resources.openRawResource(R.raw.land_positions)
+ val landVertByteArray = landPosStream.use { it.readBytes() }
+ val landVertByteBuffer = ByteBuffer.wrap(landVertByteArray)
+ landVertexBuffer = initArrayBuffer(landVertByteBuffer)
+
+ val landTriangleIndicesStream = resources.openRawResource(R.raw.land_triangle_indices)
+ val landTriangleIndicesByteArray = landTriangleIndicesStream.use { it.readBytes() }
+ val landTriangleIndicesBuffer = ByteBuffer.wrap(landTriangleIndicesByteArray)
+ landIndices = initIndexBuffer(landTriangleIndicesBuffer)
+
+ val landContourIndicesStream = resources.openRawResource(R.raw.land_contour_indices)
+ val landContourIndicesByteArray = landContourIndicesStream.use { it.readBytes() }
+ val landContourIndicesBuffer = ByteBuffer.wrap(landContourIndicesByteArray)
+ landContour = initIndexBuffer(landContourIndicesBuffer)
+
+ val oceanPosStream = resources.openRawResource(R.raw.ocean_positions)
+ val oceanVertByteArray = oceanPosStream.use { it.readBytes() }
+ val oceanVertByteBuffer = ByteBuffer.wrap(oceanVertByteArray)
+ oceanVertexBuffer = initArrayBuffer(oceanVertByteBuffer)
+
+ val oceanTriangleIndicesStream = resources.openRawResource(R.raw.ocean_indices)
+ val oceanTriangleIndicesByteArray = oceanTriangleIndicesStream.use { it.readBytes() }
+ val oceanTriangleIndicesBuffer = ByteBuffer.wrap(oceanTriangleIndicesByteArray)
+ oceanIndices = initIndexBuffer(oceanTriangleIndicesBuffer)
+
+ // create empty OpenGL ES Program
+ shaderProgram = initShaderProgram(vertexShaderCode, fragmentShaderCode)
+
+ attribLocations =
+ AttribLocations(GLES20.glGetAttribLocation(shaderProgram, "aVertexPosition"))
+ uniformLocation =
+ UniformLocation(
+ color = GLES20.glGetUniformLocation(shaderProgram, "uColor"),
+ projectionMatrix = GLES20.glGetUniformLocation(shaderProgram, "uProjectionMatrix"),
+ modelViewMatrix = GLES20.glGetUniformLocation(shaderProgram, "uModelViewMatrix")
+ )
+ }
+
+ fun draw(
+ projectionMatrix: FloatArray,
+ viewMatrix: FloatArray,
+ colors: GlobeColors,
+ contourWidth: Float = 3f
+ ) {
+ val globeViewMatrix = viewMatrix.copyOf()
+
+ // Add program to OpenGL ES environment
+ GLES20.glUseProgram(shaderProgram)
+
+ // Set thickness of contour lines
+ GLES20.glLineWidth(contourWidth)
+ drawBufferElements(
+ projectionMatrix,
+ globeViewMatrix,
+ landVertexBuffer,
+ landContour,
+ colors.contourColorArray,
+ GLES20.GL_LINES
+ )
+
+ // Scale the globe to avoid z-fighting
+ Matrix.scaleM(
+ globeViewMatrix,
+ 0,
+ LAND_OCEAN_SCALE_FACTOR,
+ LAND_OCEAN_SCALE_FACTOR,
+ LAND_OCEAN_SCALE_FACTOR
+ )
+
+ // Draw land
+ drawBufferElements(
+ projectionMatrix,
+ globeViewMatrix,
+ landVertexBuffer,
+ landIndices,
+ colors.landColorArray,
+ GLES20.GL_TRIANGLES,
+ )
+
+ // Draw ocean
+ drawBufferElements(
+ projectionMatrix,
+ globeViewMatrix,
+ oceanVertexBuffer,
+ oceanIndices,
+ colors.oceanColorArray,
+ GLES20.GL_TRIANGLES
+ )
+ }
+
+ private fun drawBufferElements(
+ projectionMatrix: FloatArray,
+ modelViewMatrix: FloatArray,
+ positionBuffer: Int,
+ indexBuffer: IndexBufferWithLength,
+ color: FloatArray,
+ mode: Int,
+ ) {
+ GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, positionBuffer)
+ GLES20.glVertexAttribPointer(
+ attribLocations.vertexPosition,
+ VERTEX_COMPONENT_SIZE,
+ GLES20.GL_FLOAT,
+ false,
+ 0,
+ 0,
+ )
+ GLES20.glEnableVertexAttribArray(attribLocations.vertexPosition)
+
+ GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, indexBuffer.indexBuffer)
+ GLES20.glUniform4fv(uniformLocation.color, 1, color, 0)
+ GLES20.glUniformMatrix4fv(uniformLocation.projectionMatrix, 1, false, projectionMatrix, 0)
+ GLES20.glUniformMatrix4fv(uniformLocation.modelViewMatrix, 1, false, modelViewMatrix, 0)
+ GLES20.glDrawElements(mode, indexBuffer.length, GLES20.GL_UNSIGNED_INT, 0)
+ GLES20.glDisableVertexAttribArray(attribLocations.vertexPosition)
+ }
+
+ private data class AttribLocations(val vertexPosition: Int)
+
+ private data class UniformLocation(
+ val color: Int,
+ val projectionMatrix: Int,
+ val modelViewMatrix: Int
+ )
+
+ companion object {
+ private const val LAND_OCEAN_SCALE_FACTOR = 0.9999f
+
+ // Vertex, and fragment shader code is taken from Mullvad Desktop 3dmap.ts
+ private val vertexShaderCode =
+ """
+ attribute vec3 aVertexPosition;
+
+ uniform vec4 uColor;
+ uniform mat4 uModelViewMatrix;
+ uniform mat4 uProjectionMatrix;
+
+ varying lowp vec4 vColor;
+
+ void main(void) {
+ gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0);
+ vColor = uColor;
+ }
+ """
+ .trimIndent()
+ private val fragmentShaderCode =
+ """
+ varying lowp vec4 vColor;
+
+ void main(void) {
+ gl_FragColor = vColor;
+ }
+ """
+ .trimIndent()
+ }
+}
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
new file mode 100644
index 000000000000..9d03a540c5d5
--- /dev/null
+++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt
@@ -0,0 +1,224 @@
+package net.mullvad.mullvadvpn.lib.map.internal.shapes
+
+import android.opengl.GLES20
+import android.opengl.Matrix
+import androidx.compose.ui.graphics.Color
+import java.nio.FloatBuffer
+import kotlin.math.cos
+import kotlin.math.sin
+import net.mullvad.mullvadvpn.lib.map.data.LocationMarkerColors
+import net.mullvad.mullvadvpn.lib.map.internal.COLOR_COMPONENT_SIZE
+import net.mullvad.mullvadvpn.lib.map.internal.VERTEX_COMPONENT_SIZE
+import net.mullvad.mullvadvpn.lib.map.internal.initArrayBuffer
+import net.mullvad.mullvadvpn.lib.map.internal.initShaderProgram
+import net.mullvad.mullvadvpn.lib.map.internal.toFloatArray
+import net.mullvad.mullvadvpn.model.LatLong
+
+internal class LocationMarker(val colors: LocationMarkerColors) {
+
+ private val shaderProgram: Int
+ private val attribLocations: AttribLocations
+ private val uniformLocation: UniformLocation
+ private val positionBuffer: Int
+ private val colorBuffer: Int
+ private val ringSizes: List
+
+ init {
+ val rings = createRings()
+ ringSizes = rings.map { (positions, _) -> positions.size }
+
+ val positionFloatArray = joinMultipleArrays(rings.map { it.vertices })
+ val positionFloatBuffer = FloatBuffer.wrap(positionFloatArray)
+
+ val colorFloatArray = joinMultipleArrays(rings.map { it.verticesColor })
+ val colorFloatBuffer = FloatBuffer.wrap(colorFloatArray)
+
+ positionBuffer = initArrayBuffer(positionFloatBuffer)
+ colorBuffer = initArrayBuffer(colorFloatBuffer)
+
+ shaderProgram = initShaderProgram(vertexShaderCode, fragmentShaderCode)
+
+ attribLocations =
+ AttribLocations(
+ vertexPosition = GLES20.glGetAttribLocation(shaderProgram, "aVertexPosition"),
+ vertexColor = GLES20.glGetAttribLocation(shaderProgram, "aVertexColor")
+ )
+ uniformLocation =
+ UniformLocation(
+ projectionMatrix = GLES20.glGetUniformLocation(shaderProgram, "uProjectionMatrix"),
+ modelViewMatrix = GLES20.glGetUniformLocation(shaderProgram, "uModelViewMatrix")
+ )
+ }
+
+ fun draw(projectionMatrix: FloatArray, viewMatrix: FloatArray, latLong: LatLong, size: Float) {
+ 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)
+
+ GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, positionBuffer)
+ GLES20.glVertexAttribPointer(
+ attribLocations.vertexPosition,
+ VERTEX_COMPONENT_SIZE,
+ GLES20.GL_FLOAT,
+ false,
+ 0,
+ 0,
+ )
+ GLES20.glEnableVertexAttribArray(attribLocations.vertexPosition)
+
+ GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, colorBuffer)
+ GLES20.glVertexAttribPointer(
+ attribLocations.vertexColor,
+ COLOR_COMPONENT_SIZE,
+ GLES20.GL_FLOAT,
+ false,
+ 0,
+ 0,
+ )
+ GLES20.glEnableVertexAttribArray(attribLocations.vertexColor)
+
+ GLES20.glUniformMatrix4fv(uniformLocation.projectionMatrix, 1, false, projectionMatrix, 0)
+ GLES20.glUniformMatrix4fv(uniformLocation.modelViewMatrix, 1, false, modelViewMatrix, 0)
+
+ var offset = 0
+ for (ringSize in ringSizes) {
+ GLES20.glDrawArrays(GLES20.GL_TRIANGLE_FAN, offset, ringSize)
+ // Add number off vertices in the ring to the offset
+ offset += ringSize / VERTEX_COMPONENT_SIZE
+ }
+ }
+
+ // Returns vertex positions and color values for a circle.
+ // `offset` is a vector of x, y and z values determining how much to offset the circle
+ // position from origo
+ private fun circleFanVertices(
+ numEdges: Int,
+ radius: Float,
+ offset: FloatArray = floatArrayOf(0.0f, 0.0f, 0.0f),
+ centerColor: Color,
+ ringColor: Color,
+ ): Ring {
+ require(numEdges > 2) { "Number of edges must be greater than 2" }
+
+ // Edges + center + first point
+ val points = numEdges + 2
+
+ val positions = FloatArray(points * VERTEX_COMPONENT_SIZE)
+ val positionsColor = FloatArray(points * COLOR_COMPONENT_SIZE)
+
+ // Start adding the center the center point
+ offset.forEachIndexed { index, value -> positions[index] = value }
+ centerColor.toFloatArray().forEachIndexed { index, value -> positionsColor[index] = value }
+
+ val ringColorArray = ringColor.toFloatArray()
+
+ for (i in 1 until points) {
+
+ val angle = (i.toFloat() / numEdges) * 2f * Math.PI
+ val posIndex = i * VERTEX_COMPONENT_SIZE
+ positions[posIndex] = offset[0] + radius * cos(angle).toFloat()
+ positions[posIndex + 1] = offset[1] + radius * sin(angle).toFloat()
+ positions[posIndex + 2] = offset[2]
+
+ val colorIndex = i * COLOR_COMPONENT_SIZE
+ ringColorArray.forEachIndexed { index, value ->
+ positionsColor[colorIndex + index] = value
+ }
+ }
+
+ return Ring(positions, positionsColor)
+ }
+
+ private fun joinMultipleArrays(arrays: List): FloatArray {
+ val result = FloatArray(arrays.sumOf { it.size })
+ var offset = 0
+ for (array in arrays) {
+ array.copyInto(result, offset)
+ offset += array.size
+ }
+ return result
+ }
+
+ @Suppress("MagicNumber")
+ private fun createRings(): List =
+ listOf(
+ circleFanVertices(
+ 32,
+ 0.5f,
+ floatArrayOf(0.0f, 0.0f, 0.0f),
+ colors.perimeterColors,
+ colors.perimeterColors,
+ ), // Semi-transparent outer
+ circleFanVertices(
+ 16,
+ 0.28f,
+ floatArrayOf(0.0f, -0.05f, 0.00001f),
+ colors.shadowColor,
+ colors.shadowColor.copy(alpha = 0.0f),
+ ), // Shadow
+ circleFanVertices(
+ 32,
+ 0.185f,
+ floatArrayOf(0.0f, 0.0f, 0.00002f),
+ colors.ringBorderColor,
+ colors.ringBorderColor,
+ ), // White ring
+ 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)
+ GLES20.glDeleteProgram(shaderProgram)
+ }
+
+ private data class Ring(val vertices: FloatArray, val verticesColor: FloatArray)
+
+ private data class AttribLocations(val vertexPosition: Int, val vertexColor: Int)
+
+ private data class UniformLocation(val projectionMatrix: Int, val modelViewMatrix: Int)
+
+ companion object {
+ private const val MARKER_TRANSLATE_Z_FACTOR = 1.0001f
+
+ // Vertex, and fragment shader code is taken from Mullvad Desktop 3dmap.ts
+ private val vertexShaderCode =
+ """
+ attribute vec3 aVertexPosition;
+ attribute vec4 aVertexColor;
+
+ uniform mat4 uModelViewMatrix;
+ uniform mat4 uProjectionMatrix;
+
+ varying lowp vec4 vColor;
+
+ void main(void) {
+ gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0);
+ vColor = aVertexColor;
+ }
+ """
+ .trimIndent()
+ private val fragmentShaderCode =
+ """
+ varying lowp vec4 vColor;
+
+ void main(void) {
+ gl_FragColor = vColor;
+ }
+ """
+ .trimIndent()
+ }
+}
diff --git a/android/lib/map/src/main/res/raw b/android/lib/map/src/main/res/raw
new file mode 120000
index 000000000000..9f90f80c9342
--- /dev/null
+++ b/android/lib/map/src/main/res/raw
@@ -0,0 +1 @@
+../../../../../../gui/assets/geo
\ No newline at end of file
diff --git a/android/lib/model/build.gradle.kts b/android/lib/model/build.gradle.kts
index 040c2fca0c0c..7264c6041a79 100644
--- a/android/lib/model/build.gradle.kts
+++ b/android/lib/model/build.gradle.kts
@@ -1,5 +1,6 @@
plugins {
id(Dependencies.Plugin.androidLibraryId)
+ id(Dependencies.Plugin.junit5) version Versions.Plugin.junit5
id(Dependencies.Plugin.kotlinAndroidId)
id(Dependencies.Plugin.kotlinParcelizeId)
}
@@ -8,7 +9,10 @@ android {
namespace = "net.mullvad.mullvadvpn.model"
compileSdk = Versions.Android.compileSdkVersion
- defaultConfig { minSdk = Versions.Android.minSdkVersion }
+ defaultConfig {
+ minSdk = Versions.Android.minSdkVersion
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
@@ -30,4 +34,12 @@ dependencies {
implementation(Dependencies.jodaTime)
implementation(Dependencies.Kotlin.stdlib)
implementation(Dependencies.KotlinX.coroutinesAndroid)
+
+ // Test dependencies
+ testRuntimeOnly(Dependencies.junitEngine)
+
+ testImplementation(Dependencies.Kotlin.test)
+ testImplementation(Dependencies.junitApi)
+
+ testImplementation(project(Dependencies.Mullvad.commonTestLib))
}
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeoIpLocation.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeoIpLocation.kt
index e15ab20376e9..625de76b291f 100644
--- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeoIpLocation.kt
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeoIpLocation.kt
@@ -10,5 +10,7 @@ data class GeoIpLocation(
val ipv6: InetAddress?,
val country: String,
val city: String?,
- val hostname: String?
+ val latitude: Double,
+ val longitude: Double,
+ val hostname: String?,
) : Parcelable
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeographicLocationConstraint.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeographicLocationConstraint.kt
index 04f92a72ac8a..386257a72aa3 100644
--- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeographicLocationConstraint.kt
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeographicLocationConstraint.kt
@@ -9,20 +9,20 @@ sealed class GeographicLocationConstraint : Parcelable {
@Parcelize
data class Country(val countryCode: String) : GeographicLocationConstraint() {
override val location: GeoIpLocation
- get() = GeoIpLocation(null, null, countryCode, null, null)
+ get() = GeoIpLocation(null, null, countryCode, null, 0.0, 0.0, null)
}
@Parcelize
data class City(val countryCode: String, val cityCode: String) :
GeographicLocationConstraint() {
override val location: GeoIpLocation
- get() = GeoIpLocation(null, null, countryCode, cityCode, null)
+ get() = GeoIpLocation(null, null, countryCode, cityCode, 0.0, 0.0, null)
}
@Parcelize
data class Hostname(val countryCode: String, val cityCode: String, val hostname: String) :
GeographicLocationConstraint() {
override val location: GeoIpLocation
- get() = GeoIpLocation(null, null, countryCode, cityCode, hostname)
+ get() = GeoIpLocation(null, null, countryCode, cityCode, 0.0, 0.0, hostname)
}
}
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/LatLong.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/LatLong.kt
new file mode 100644
index 000000000000..ae047130e8ba
--- /dev/null
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/LatLong.kt
@@ -0,0 +1,21 @@
+package net.mullvad.mullvadvpn.model
+
+import kotlin.math.pow
+import kotlin.math.sqrt
+
+data class LatLong(val latitude: Latitude, val longitude: Longitude) {
+
+ fun distanceTo(other: LatLong): Float =
+ sqrt(
+ latitude.distanceTo(other.latitude).pow(2f) +
+ (longitude.distanceTo(other.longitude).pow(2f))
+ )
+
+ operator fun plus(other: LatLong) =
+ LatLong(latitude + other.latitude, longitude + other.longitude)
+
+ operator fun minus(other: LatLong) =
+ LatLong(latitude - other.latitude, longitude - other.longitude)
+}
+
+const val COMPLETE_ANGLE = 360f
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Latitude.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Latitude.kt
new file mode 100644
index 000000000000..14c5b66983eb
--- /dev/null
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Latitude.kt
@@ -0,0 +1,55 @@
+package net.mullvad.mullvadvpn.model
+
+import kotlin.math.absoluteValue
+
+@JvmInline
+value class Latitude(val value: Float) {
+ init {
+ require(value in LATITUDE_RANGE) {
+ "Latitude: '$value' must be between $MIN_LATITUDE_VALUE and $MAX_LATITUDE_VALUE"
+ }
+ }
+
+ fun distanceTo(other: Latitude) = (other.value - value).absoluteValue
+
+ operator fun plus(other: Latitude) = fromFloat(value + other.value)
+
+ operator fun minus(other: Latitude) = fromFloat(value - other.value)
+
+ companion object {
+ private const val MIN_LATITUDE_VALUE: Float = -90f
+ private const val MAX_LATITUDE_VALUE: Float = 90f
+ private val LATITUDE_RANGE = MIN_LATITUDE_VALUE..MAX_LATITUDE_VALUE
+
+ /**
+ * Create a [Latitude] from a float value.
+ *
+ * This function will unwind a float to a valid latitude value. E.g 190 will be unwound to
+ * -10 and 360 will be unwound to 0.
+ */
+ fun fromFloat(value: Float): Latitude {
+ val unwoundValue = unwind(value)
+ return Latitude(unwoundValue)
+ }
+
+ private fun unwind(value: Float): Float {
+ // Remove all 360 degrees
+ val withoutRotations = value % COMPLETE_ANGLE
+
+ // If we are above 180 or below -180, we wrapped half a turn and need to flip sign
+ val partiallyUnwound =
+ if (withoutRotations.absoluteValue > COMPLETE_ANGLE / 2) {
+ -withoutRotations % (COMPLETE_ANGLE / 2)
+ } else withoutRotations
+
+ return when {
+ partiallyUnwound < MIN_LATITUDE_VALUE ->
+ MIN_LATITUDE_VALUE - (partiallyUnwound % MIN_LATITUDE_VALUE)
+ partiallyUnwound > MAX_LATITUDE_VALUE ->
+ MAX_LATITUDE_VALUE - (partiallyUnwound % MAX_LATITUDE_VALUE)
+ // partiallyUnwound in range MIN_LATITUDE_VALUE..MAX_LATITUDE_VALUE
+ else -> partiallyUnwound
+ }
+ }
+ }
+}
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Longitude.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Longitude.kt
new file mode 100644
index 000000000000..9f73a6ff17bb
--- /dev/null
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Longitude.kt
@@ -0,0 +1,55 @@
+package net.mullvad.mullvadvpn.model
+
+import kotlin.math.absoluteValue
+
+@JvmInline
+value class Longitude(val value: Float) {
+ init {
+ require(value in LONGITUDE_RANGE) {
+ "Longitude: '$value' must be between $MIN_LONGITUDE_VALUE and $MAX_LONGITUDE_VALUE"
+ }
+ }
+
+ fun distanceTo(other: Longitude) = vectorTo(other).value.absoluteValue
+
+ fun vectorTo(other: Longitude): Longitude {
+ val diff = other.value - value
+ val vectorValue =
+ when {
+ diff > MAX_LONGITUDE_VALUE -> diff - COMPLETE_ANGLE
+ diff < MIN_LONGITUDE_VALUE -> diff + COMPLETE_ANGLE
+ else -> diff
+ }
+ return Longitude(vectorValue)
+ }
+
+ operator fun plus(other: Longitude) = fromFloat(value + other.value)
+
+ operator fun minus(other: Longitude) = fromFloat(value - other.value)
+
+ companion object {
+ private const val MIN_LONGITUDE_VALUE: Float = -180f
+ private const val MAX_LONGITUDE_VALUE: Float = 180f
+ private val LONGITUDE_RANGE = MIN_LONGITUDE_VALUE..MAX_LONGITUDE_VALUE
+
+ /**
+ * Create a [Longitude] from a float value.
+ *
+ * This function will unwind a float to a valid longitude value. E.g 190 will be unwound to
+ * -170 and 360 will be unwound to 0.
+ */
+ fun fromFloat(value: Float): Longitude {
+ val unwoundValue = unwind(value)
+ return Longitude(unwoundValue)
+ }
+
+ private fun unwind(value: Float): Float {
+ val unwound = value % COMPLETE_ANGLE
+ return when {
+ unwound > MAX_LONGITUDE_VALUE -> unwound - COMPLETE_ANGLE
+ unwound < MIN_LONGITUDE_VALUE -> unwound + COMPLETE_ANGLE
+ else -> unwound
+ }
+ }
+ }
+}
diff --git a/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatLongTest.kt b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatLongTest.kt
new file mode 100644
index 000000000000..6644e25e82f5
--- /dev/null
+++ b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatLongTest.kt
@@ -0,0 +1,20 @@
+package net.mullvad.mullvadvpn.model
+
+import kotlin.math.sqrt
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class LatLongTest {
+
+ @Test
+ fun `distance between two LatLong should be same as hypotenuse`() {
+ val latLong1 = LatLong(Latitude(30f), Longitude(40f))
+ val latLong2 = LatLong(Latitude(-40f), Longitude(170f))
+
+ val latDiff = latLong1.latitude.distanceTo(latLong2.latitude)
+ val longDiff = latLong1.longitude.distanceTo(latLong2.longitude)
+ val hypotenuse = sqrt(latDiff * latDiff + longDiff * longDiff)
+
+ assertEquals(hypotenuse, latLong1.distanceTo(latLong2))
+ }
+}
diff --git a/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatitudeTest.kt b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatitudeTest.kt
new file mode 100644
index 000000000000..8788c2123aa9
--- /dev/null
+++ b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatitudeTest.kt
@@ -0,0 +1,163 @@
+package net.mullvad.mullvadvpn.model
+
+import kotlin.math.absoluteValue
+import kotlin.test.assertEquals
+import org.junit.jupiter.api.Assertions.assertDoesNotThrow
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+
+class LatitudeTest {
+ @Test
+ fun `creating a valid latitude should work`() {
+ assertDoesNotThrow { Latitude(30f) }
+ }
+
+ @Test
+ fun `creating a valid negative latitude should work`() {
+ assertDoesNotThrow { Latitude(-30f) }
+ }
+
+ @Test
+ fun `create with too high latitude should give IllegalArgumentException`() {
+ assertThrows { Latitude(90.1f) }
+ }
+
+ @Test
+ fun `create with too low latitude should give IllegalArgumentException`() {
+ assertThrows { Latitude(-90.1f) }
+ }
+
+ @Test
+ fun `fromFloat should accept and wrap large value`() {
+ val longFloat = 400f
+ val longitude = Latitude.fromFloat(longFloat)
+
+ assertEquals(40f, longitude.value)
+ }
+
+ @Test
+ fun `fromFloat should accept and support half-wrap`() {
+ val longFloat = 100f
+ val longitude = Latitude.fromFloat(longFloat)
+
+ assertEquals(80f, longitude.value)
+ }
+
+ @Test
+ fun `fromFloat should accept and support negative half-wrap`() {
+ val longFloat = -100f
+ val longitude = Latitude.fromFloat(longFloat)
+
+ assertEquals(-80f, longitude.value)
+ }
+
+ @Test
+ fun `adding two positive latitude should result in the sum`() {
+ val latFloat1 = 20f
+ val latitude1 = Latitude(latFloat1)
+ val latFloat2 = 30f
+ val latitude2 = Latitude(latFloat2)
+
+ assertEquals(latFloat1 + latFloat2, (latitude1 + latitude2).value)
+ }
+
+ @Test
+ fun `adding two large positive latitude should result in the sum wrapped`() {
+ val latFloat1 = 70f
+ val latitude1 = Latitude(latFloat1)
+ val latFloat2 = 50f
+ val latitude2 = Latitude(latFloat2)
+
+ val expectedResult = 60f
+
+ assertEquals(expectedResult, (latitude1 + latitude2).value)
+ }
+
+ @Test
+ fun `adding two negative latitude should result in the sum`() {
+ val latFloat1 = -20f
+ val latitude1 = Latitude(latFloat1)
+ val latFloat2 = -40f
+ val latitude2 = Latitude(latFloat2)
+
+ assertEquals(latFloat1 + latFloat2, (latitude1 + latitude2).value)
+ }
+
+ @Test
+ fun `adding two large negative latitude should result in the sum`() {
+ val latFloat1 = -70f
+ val latitude1 = Latitude(latFloat1)
+ val latFloat2 = -50f
+ val latitude2 = Latitude(latFloat2)
+
+ val expectedResult = -60f
+
+ assertEquals(expectedResult, (latitude1 + latitude2).value)
+ }
+
+ @Test
+ fun `subtracting two positive latitude should result in the sum`() {
+ val latFloat1 = 80f
+ val latitude1 = Latitude(latFloat1)
+ val latFloat2 = 30f
+ val latitude2 = Latitude(latFloat2)
+
+ assertEquals(latFloat1 - latFloat2, (latitude1 - latitude2).value)
+ }
+
+ @Test
+ fun `subtracting a large latitude should result in the sum wrapped`() {
+ val latFloat1 = -30f
+ val latitude1 = Latitude(latFloat1)
+ val latFloat2 = 80f
+ val latitude2 = Latitude(latFloat2)
+
+ val expectedResult = -70f
+
+ assertEquals(expectedResult, (latitude1 - latitude2).value)
+ }
+
+ @Test
+ fun `subtracting a negative latitude should result in same as addition`() {
+ val latFloat1 = -30f
+ val latitude1 = Latitude(latFloat1)
+ val latFloat2 = -40f
+ val latitude2 = Latitude(latFloat2)
+
+ assertEquals(latFloat1 + latFloat2.absoluteValue, (latitude1 - latitude2).value)
+ }
+
+ @Test
+ fun `subtracting a large negative latitude should result in same as addition wrapped`() {
+ val latFloat1 = 80f
+ val latitude1 = Latitude(latFloat1)
+ val latFloat2 = -90f
+ val latitude2 = Latitude(latFloat2)
+
+ val absoluteLatitude2 = Latitude.fromFloat(latFloat2.absoluteValue)
+
+ assertEquals(latitude1 + absoluteLatitude2, latitude1 - latitude2)
+ }
+
+ @Test
+ fun `distanceTo with two positive latitudes`() {
+ val latFloat1 = 80f
+ val latitude1 = Latitude(latFloat1)
+ val latFloat2 = 30f
+ val latitude2 = Latitude(latFloat2)
+
+ assertEquals(latFloat1 - latFloat2, latitude1.distanceTo(latitude2))
+ }
+
+ @Test
+ fun `distanceTo with two negative latitudes`() {
+ val latFloat1 = -80f
+ val latitude1 = Latitude(latFloat1)
+ val latFloat2 = -30f
+ val latitude2 = Latitude(latFloat2)
+
+ val expectedValue = 50f
+
+ assertEquals(expectedValue, latitude1.distanceTo(latitude2))
+ }
+}
diff --git a/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LongitudeTest.kt b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LongitudeTest.kt
new file mode 100644
index 000000000000..de94661ad0b8
--- /dev/null
+++ b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LongitudeTest.kt
@@ -0,0 +1,154 @@
+package net.mullvad.mullvadvpn.model
+
+import kotlin.math.absoluteValue
+import kotlin.test.assertEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertDoesNotThrow
+import org.junit.jupiter.api.assertThrows
+
+class LongitudeTest {
+ @Test
+ fun `create longitude with longitude should work`() {
+ assertDoesNotThrow { Longitude(80f) }
+ }
+
+ @Test
+ fun `create longitude with negative longitude should work`() {
+ assertDoesNotThrow { Longitude(-80f) }
+ }
+
+ @Test
+ fun `create too high longitude should give IllegalArgumentException`() {
+ assertThrows { Longitude(180.1f) }
+ }
+
+ @Test
+ fun `create too low longitude should give IllegalArgumentException`() {
+ assertThrows { Longitude(-180.1f) }
+ }
+
+ @Test
+ fun `fromFloat should accept and wrap large value`() {
+ val longFloat = 720f
+ val longitude = Longitude.fromFloat(longFloat)
+
+ assertEquals(0f, longitude.value)
+ }
+
+ @Test
+ fun `fromFloat should accept and wrap large negative value`() {
+ val longFloat = -720f
+ val longitude = Longitude.fromFloat(longFloat)
+
+ assertEquals(0f, longitude.value, 0f)
+ }
+
+ @Test
+ fun `adding two positive longitude should result in the sum`() {
+ val longFloat1 = 80f
+ val longitude1 = Longitude(longFloat1)
+ val longFloat2 = 30f
+ val longitude2 = Longitude(longFloat2)
+
+ assertEquals(longFloat1 + longFloat2, (longitude1 + longitude2).value)
+ }
+
+ @Test
+ fun `adding two large positive longitude should result in the sum wrapped`() {
+ val longFloat1 = 170f
+ val longitude1 = Longitude(longFloat1)
+ val longFloat2 = 150f
+ val longitude2 = Longitude(longFloat2)
+
+ val expectedResult = -40f
+
+ assertEquals(expectedResult, (longitude1 + longitude2).value)
+ }
+
+ @Test
+ fun `adding two negative longitude should result in the sum wrapped`() {
+ val longFloat1 = -80f
+ val longitude1 = Longitude(longFloat1)
+ val longFloat2 = -40f
+ val longitude2 = Longitude(longFloat2)
+
+ assertEquals(longFloat1 + longFloat2, (longitude1 + longitude2).value)
+ }
+
+ @Test
+ fun `subtracting two positive longitude should result in the sum`() {
+ val longFloat1 = 80f
+ val longitude1 = Longitude(longFloat1)
+ val longFloat2 = 30f
+ val longitude2 = Longitude(longFloat2)
+
+ assertEquals(longFloat1 - longFloat2, (longitude1 - longitude2).value)
+ }
+
+ @Test
+ fun `subtracting a large longitude should result in the sum wrapped`() {
+ val longFloat1 = -30f
+ val longitude1 = Longitude(longFloat1)
+ val longFloat2 = 170f
+ val longitude2 = Longitude(longFloat2)
+
+ val expectedResult = 160f
+
+ assertEquals(expectedResult, (longitude1 - longitude2).value)
+ }
+
+ @Test
+ fun `subtracting a negative latitude should result in same as addition`() {
+ val longFloat1 = -80f
+ val longitude1 = Longitude(longFloat1)
+ val longFloat2 = -40f
+ val longitude2 = Longitude(longFloat2)
+
+ assertEquals(longFloat1 + longFloat2.absoluteValue, (longitude1 - longitude2).value)
+ }
+
+ @Test
+ fun `subtracting a large negative latitude should result in same as addition wrapped`() {
+ val longFloat1 = 80f
+ val longitude1 = Longitude(longFloat1)
+ val longFloat2 = -140f
+ val longitude2 = Longitude(longFloat2)
+
+ val absoluteLongitude2 = Longitude.fromFloat(longFloat2.absoluteValue)
+ assertEquals(longitude1 + absoluteLongitude2, longitude1 - longitude2)
+ }
+
+ @Test
+ fun `distanceTo with two positive longitudes`() {
+ val longFloat1 = 80f
+ val longitude1 = Longitude(longFloat1)
+ val longFloat2 = 30f
+ val longitude2 = Longitude(longFloat2)
+
+ assertEquals(longFloat1 - longFloat2, longitude1.distanceTo(longitude2))
+ }
+
+ @Test
+ fun `distanceTo with two negative longitudes`() {
+ val longFloat1 = -80f
+ val longitude1 = Longitude(longFloat1)
+ val longFloat2 = -30f
+ val longitude2 = Longitude(longFloat2)
+
+ val expectedValue = 50f
+
+ assertEquals(expectedValue, longitude1.distanceTo(longitude2))
+ }
+
+ @Test
+ fun `distanceTo with wrapping value as shortest path`() {
+ val longFloat1 = -170f
+ val longitude1 = Longitude(longFloat1)
+ val longFloat2 = 170f
+ val longitude2 = Longitude(longFloat2)
+
+ val expectedValue = 20f
+
+ assertEquals(expectedValue, longitude1.distanceTo(longitude2))
+ }
+}
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
index b598a49029ce..30f77c215935 100644
--- a/android/settings.gradle.kts
+++ b/android/settings.gradle.kts
@@ -13,7 +13,8 @@ include(
":lib:theme",
":lib:common-test",
":lib:billing",
- ":lib:payment"
+ ":lib:payment",
+ ":lib:map"
)
include(
":test",
diff --git a/mullvad-types/src/location.rs b/mullvad-types/src/location.rs
index 07e2b1e90ce3..ca2626c7d2db 100644
--- a/mullvad-types/src/location.rs
+++ b/mullvad-types/src/location.rs
@@ -143,9 +143,7 @@ pub struct GeoIpLocation {
pub ipv6: Option,
pub country: String,
pub city: Option,
- #[cfg_attr(target_os = "android", jnix(skip))]
pub latitude: f64,
- #[cfg_attr(target_os = "android", jnix(skip))]
pub longitude: f64,
#[cfg_attr(target_os = "android", jnix(skip))]
pub mullvad_exit_ip: bool,