summaryrefslogtreecommitdiffhomepage
path: root/android/lib
diff options
context:
space:
mode:
authorAlbin <albin@mullvad.net>2024-02-15 14:31:19 +0100
committerAlbin <albin@mullvad.net>2024-02-15 14:31:19 +0100
commitd42287a49451c6ab42efb0edc5e66a1375e28306 (patch)
treec13dbdc286dfc9740a7d19b8c2c03e578817cd46 /android/lib
parentd0b49816b95dbf7b6a5e8048caef61212191d07d (diff)
parentdb4179285e29c0c94b9195f7579ef17daaa8b719 (diff)
downloadmullvadvpn-d42287a49451c6ab42efb0edc5e66a1375e28306.tar.xz
mullvadvpn-d42287a49451c6ab42efb0edc5e66a1375e28306.zip
Merge branch 'android-gl-maps'
Diffstat (limited to 'android/lib')
-rw-r--r--android/lib/map/build.gradle.kts43
-rw-r--r--android/lib/map/src/main/AndroidManifest.xml2
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt101
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt83
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt7
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/GlobeColors.kt16
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/LocationMarkerColors.kt17
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/MapViewState.kt10
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt15
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt19
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/GLHelper.kt108
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt133
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt31
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/Globe.kt185
-rw-r--r--android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt224
l---------android/lib/map/src/main/res/raw1
-rw-r--r--android/lib/model/build.gradle.kts14
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeoIpLocation.kt4
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeographicLocationConstraint.kt6
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/LatLong.kt21
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Latitude.kt55
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Longitude.kt55
-rw-r--r--android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatLongTest.kt20
-rw-r--r--android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatitudeTest.kt163
-rw-r--r--android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LongitudeTest.kt154
25 files changed, 1482 insertions, 5 deletions
diff --git a/android/lib/map/build.gradle.kts b/android/lib/map/build.gradle.kts
new file mode 100644
index 0000000000..75d11ae189
--- /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 0000000000..8072ee00db
--- /dev/null
+++ b/android/lib/map/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest />
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 0000000000..00fcf97ab7
--- /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 0000000000..a143a63cb8
--- /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<Marker>,
+ 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<Marker>,
+ 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 0000000000..d837bcadfc
--- /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 0000000000..251c466a93
--- /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 0000000000..7d4edb09cb
--- /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 0000000000..1e1a211115
--- /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<Marker>,
+ 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 0000000000..9f464612f1
--- /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 0000000000..dfd80d547d
--- /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 0000000000..e416988d8d
--- /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 0000000000..bf44e5ee14
--- /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<LocationMarkerColors, LocationMarker> =
+ object : LruCache<LocationMarkerColors, LocationMarker>(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 0000000000..19dd085524
--- /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 0000000000..379ac407cc
--- /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 0000000000..9d03a540c5
--- /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<Int>
+
+ 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>): 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<Ring> =
+ 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 0000000000..9f90f80c93
--- /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 040c2fca0c..7264c6041a 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 e15ab20376..625de76b29 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 04f92a72ac..386257a72a 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 0000000000..ae047130e8
--- /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 0000000000..14c5b66983
--- /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 0000000000..9f73a6ff17
--- /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 0000000000..6644e25e82
--- /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 0000000000..8788c2123a
--- /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<IllegalArgumentException> { Latitude(90.1f) }
+ }
+
+ @Test
+ fun `create with too low latitude should give IllegalArgumentException`() {
+ assertThrows<IllegalArgumentException> { 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 0000000000..de94661ad0
--- /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<IllegalArgumentException> { Longitude(180.1f) }
+ }
+
+ @Test
+ fun `create too low longitude should give IllegalArgumentException`() {
+ assertThrows<IllegalArgumentException> { 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))
+ }
+}