summaryrefslogtreecommitdiffhomepage
path: root/android/lib/map/src
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/map/src
parentd0b49816b95dbf7b6a5e8048caef61212191d07d (diff)
parentdb4179285e29c0c94b9195f7579ef17daaa8b719 (diff)
downloadmullvadvpn-d42287a49451c6ab42efb0edc5e66a1375e28306.tar.xz
mullvadvpn-d42287a49451c6ab42efb0edc5e66a1375e28306.zip
Merge branch 'android-gl-maps'
Diffstat (limited to 'android/lib/map/src')
-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
15 files changed, 952 insertions, 0 deletions
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