summaryrefslogtreecommitdiffhomepage
path: root/android/lib/map
diff options
context:
space:
mode:
Diffstat (limited to 'android/lib/map')
-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/internal/Constants.kt19
-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
5 files changed, 367 insertions, 0 deletions
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/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/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()
+ }
+}