diff options
Diffstat (limited to 'android/lib/map/src')
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 |
