diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt new file mode 100644 index 000000000000..33fb98a015b9 --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt @@ -0,0 +1,95 @@ +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.remember +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 { + val previousLocation = + rememberPrevious( + current = targetCameraLocation, + shouldUpdate = { prev, curr -> prev != curr } + ) ?: targetCameraLocation + + val distance = + remember(targetCameraLocation) { targetCameraLocation.distanceTo(previousLocation).toInt() } + val duration = distance.toAnimationDuration() + + val longitudeAnimation = remember { Animatable(targetCameraLocation.longitude.value) } + + val latitudeAnimation = remember { Animatable(targetCameraLocation.latitude.value) } + val zoomOutMultiplier = remember { Animatable(1f) } + + LaunchedEffect(targetCameraLocation) { + launch { latitudeAnimation.animateTo(targetCameraLocation.latitude.value, tween(duration)) } + launch { + // Unwind longitudeAnimation into a Longitude + val currentLongitude = Longitude.fromFloat(longitudeAnimation.value) + + // Resolve a vector showing us the shortest path to the target longitude, e.g going + // from 170 to -170 would result in 20 since we can wrap around the globe + val shortestPathVector = currentLongitude.vectorTo(targetCameraLocation.longitude) + + // Animate to the new camera location using the shortest path vector + longitudeAnimation.animateTo( + longitudeAnimation.value + shortestPathVector.value, + tween(duration), + ) + + // Current value animation value might be outside of range of a Longitude, so when the + // animation is done we unwind the animation to the correct value + longitudeAnimation.snapTo(targetCameraLocation.longitude.value) + } + launch { + zoomOutMultiplier.animateTo( + targetValue = 1f, + animationSpec = + keyframes { + if (duration < SHORT_ANIMATION_CUTOFF_MILLIS) { + durationMillis = duration + 1f at duration using EaseInOut + } else { + durationMillis = duration + FAR_ANIMATION_MAX_ZOOM_MULTIPLIER at + (duration * MAX_MULTIPLIER_PEAK_TIMING).toInt() using + EaseInOut + 1f at duration using EaseInOut + } + } + ) + } + } + + return CameraPosition( + zoom = baseZoom * zoomOutMultiplier.value, + latLong = + LatLong( + Latitude(latitudeAnimation.value), + Longitude.fromFloat(longitudeAnimation.value) + ), + verticalBias = cameraVerticalBias + ) +} + +private fun Int.toAnimationDuration() = + (this * DISTANCE_DURATION_SCALE_FACTOR).coerceIn(MIN_ANIMATION_MILLIS, MAX_ANIMATION_MILLIS) diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt new file mode 100644 index 000000000000..a143a63cb818 --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt @@ -0,0 +1,83 @@ +package net.mullvad.mullvadvpn.lib.map + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import net.mullvad.mullvadvpn.lib.map.data.CameraPosition +import net.mullvad.mullvadvpn.lib.map.data.GlobeColors +import net.mullvad.mullvadvpn.lib.map.data.MapViewState +import net.mullvad.mullvadvpn.lib.map.data.Marker +import net.mullvad.mullvadvpn.lib.map.internal.MapGLSurfaceView +import net.mullvad.mullvadvpn.model.LatLong + +@Composable +fun Map( + modifier: Modifier, + cameraLocation: CameraPosition, + markers: List, + globeColors: GlobeColors, +) { + val mapViewState = MapViewState(cameraLocation, markers, globeColors) + Map(modifier = modifier, mapViewState = mapViewState) +} + +@Composable +fun AnimatedMap( + modifier: Modifier, + cameraLocation: LatLong, + cameraBaseZoom: Float, + cameraVerticalBias: Float, + markers: List, + globeColors: GlobeColors +) { + Map( + modifier = modifier, + cameraLocation = + animatedCameraPosition( + baseZoom = cameraBaseZoom, + targetCameraLocation = cameraLocation, + cameraVerticalBias = cameraVerticalBias + ), + markers = markers, + globeColors + ) +} + +@Composable +internal fun Map(modifier: Modifier = Modifier, mapViewState: MapViewState) { + + var view: MapGLSurfaceView? = remember { null } + + val lifeCycleState = LocalLifecycleOwner.current.lifecycle + + DisposableEffect(key1 = lifeCycleState) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + view?.onResume() + } + Lifecycle.Event.ON_PAUSE -> { + view?.onPause() + } + else -> {} + } + } + lifeCycleState.addObserver(observer) + + onDispose { + lifeCycleState.removeObserver(observer) + view?.onPause() + view = null + } + } + + AndroidView(modifier = modifier, factory = { MapGLSurfaceView(it) }) { glSurfaceView -> + view = glSurfaceView + glSurfaceView.setData(mapViewState) + } +} diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/RememberPrevious.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/RememberPrevious.kt new file mode 100644 index 000000000000..0358221aa975 --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/RememberPrevious.kt @@ -0,0 +1,44 @@ +package net.mullvad.mullvadvpn.lib.map + +/* + * Code snippet taken from: + * https://stackoverflow.com/questions/67801939/get-previous-value-of-state-in-composable-jetpack-compose + */ + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember + +// TODO this file was copied for now and should be removed/broken out to a new module +@Composable +fun rememberPrevious( + current: T, + shouldUpdate: (prev: T?, curr: T) -> Boolean = { a: T?, b: T -> a != b }, +): T? { + val ref = rememberRef() + + // launched after render, so the current render will have the old value anyway + SideEffect { + if (shouldUpdate(ref.value, current)) { + ref.value = current + } + } + + return ref.value +} + +@Composable +private fun rememberRef(): MutableState { + // for some reason it always recreated the value with vararg keys, + // leaving out the keys as a parameter for remember for now + return remember { + object : MutableState { + override var value: T? = null + + override fun component1(): T? = value + + override fun component2(): (T?) -> Unit = { value = it } + } + } +} diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt new file mode 100644 index 000000000000..dfd80d547d18 --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt @@ -0,0 +1,19 @@ +package net.mullvad.mullvadvpn.lib.map.internal + +internal const val VERTEX_COMPONENT_SIZE = 3 +internal const val COLOR_COMPONENT_SIZE = 4 +internal const val MATRIX_SIZE = 16 + +// Constant what will talk the distance in LatLng multiply it to determine the animation duration, +// the result is then confined to the MIN_ANIMATION_MILLIS and MAX_ANIMATION_MILLIS +internal const val DISTANCE_DURATION_SCALE_FACTOR = 20 +internal const val MIN_ANIMATION_MILLIS = 1300 +internal const val MAX_ANIMATION_MILLIS = 2500 +// The cut off where we go from a short animation (camera pans) to a far animation (camera pans + +// zoom out) +internal const val SHORT_ANIMATION_CUTOFF_MILLIS = 1700 + +// Multiplier for the zoom out animation +internal const val FAR_ANIMATION_MAX_ZOOM_MULTIPLIER = 1.30f +// When in the far animation we reach the MAX_ZOOM_MULTIPLIER, value is between 0 and 1 +internal const val MAX_MULTIPLIER_PEAK_TIMING = .35f diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt new file mode 100644 index 000000000000..677eac5eff7b --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt @@ -0,0 +1,126 @@ +package net.mullvad.mullvadvpn.lib.map.internal + +import android.content.res.Resources +import android.opengl.GLES20 +import android.opengl.GLSurfaceView +import android.opengl.Matrix +import androidx.collection.LruCache +import javax.microedition.khronos.egl.EGLConfig +import javax.microedition.khronos.opengles.GL10 +import kotlin.math.tan +import net.mullvad.mullvadvpn.lib.map.data.CameraPosition +import net.mullvad.mullvadvpn.lib.map.data.LocationMarkerColors +import net.mullvad.mullvadvpn.lib.map.data.MapViewState +import net.mullvad.mullvadvpn.lib.map.internal.shapes.Globe +import net.mullvad.mullvadvpn.lib.map.internal.shapes.LocationMarker +import net.mullvad.mullvadvpn.model.COMPLETE_ANGLE + +internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.Renderer { + + private lateinit var globe: Globe + + // Due to location markers themselves containing colors we cache them to avoid recreating them + // for every draw call. + private val markerCache: LruCache = + object : LruCache(100) { + override fun entryRemoved( + evicted: Boolean, + key: LocationMarkerColors, + oldValue: LocationMarker, + newValue: LocationMarker? + ) { + oldValue.onRemove() + } + } + + private lateinit var viewState: MapViewState + + override fun onSurfaceCreated(unused: GL10, config: EGLConfig) { + globe = Globe(resources) + markerCache.evictAll() + initGLOptions() + } + + private fun initGLOptions() { + GLES20.glEnable(GLES20.GL_CULL_FACE) + GLES20.glCullFace(GLES20.GL_BACK) + + 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() + + val yOffset = toOffsetY(viewState.cameraPosition) + + Matrix.translateM(viewMatrix, 0, 0f, yOffset, -viewState.cameraPosition.zoom) + + 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) + + 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() + Matrix.perspectiveM( + projectionMatrix, + 0, + FIELD_OF_VIEW, + ratio, + PERSPECTIVE_Z_NEAR, + PERSPECTIVE_Z_FAR + ) + } + + fun setViewState(viewState: MapViewState) { + this.viewState = viewState + } + + companion object { + private const val PERSPECTIVE_Z_NEAR = 0.05f + private const val PERSPECTIVE_Z_FAR = 10f + private const val FIELD_OF_VIEW = 70f + } +} diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt new file mode 100644 index 000000000000..19dd085524db --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt @@ -0,0 +1,31 @@ +package net.mullvad.mullvadvpn.lib.map.internal + +import android.content.Context +import android.opengl.GLSurfaceView +import net.mullvad.mullvadvpn.lib.map.BuildConfig +import net.mullvad.mullvadvpn.lib.map.data.MapViewState + +internal class MapGLSurfaceView(context: Context) : GLSurfaceView(context) { + + private val renderer: MapGLRenderer + + init { + // Create an OpenGL ES 2.0 context + setEGLContextClientVersion(2) + + if (BuildConfig.DEBUG) { + debugFlags = DEBUG_CHECK_GL_ERROR or DEBUG_LOG_GL_CALLS + } + + renderer = MapGLRenderer(context.resources) + + // Set the Renderer for drawing on the GLSurfaceView + setRenderer(renderer) + renderMode = RENDERMODE_WHEN_DIRTY + } + + fun setData(viewState: MapViewState) { + renderer.setViewState(viewState) + requestRender() + } +}