diff options
| author | Albin <albin@mullvad.net> | 2024-02-15 14:31:19 +0100 |
|---|---|---|
| committer | Albin <albin@mullvad.net> | 2024-02-15 14:31:19 +0100 |
| commit | d42287a49451c6ab42efb0edc5e66a1375e28306 (patch) | |
| tree | c13dbdc286dfc9740a7d19b8c2c03e578817cd46 /android/lib | |
| parent | d0b49816b95dbf7b6a5e8048caef61212191d07d (diff) | |
| parent | db4179285e29c0c94b9195f7579ef17daaa8b719 (diff) | |
| download | mullvadvpn-d42287a49451c6ab42efb0edc5e66a1375e28306.tar.xz mullvadvpn-d42287a49451c6ab42efb0edc5e66a1375e28306.zip | |
Merge branch 'android-gl-maps'
Diffstat (limited to 'android/lib')
25 files changed, 1482 insertions, 5 deletions
diff --git a/android/lib/map/build.gradle.kts b/android/lib/map/build.gradle.kts new file mode 100644 index 0000000000..75d11ae189 --- /dev/null +++ b/android/lib/map/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + id(Dependencies.Plugin.kotlinAndroidId) + id(Dependencies.Plugin.androidLibraryId) +} + +android { + namespace = "net.mullvad.mullvadvpn.lib.map" + compileSdk = Versions.Android.compileSdkVersion + + defaultConfig { + minSdk = Versions.Android.minSdkVersion + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = Versions.jvmTarget + } + + buildFeatures { compose = true } + + composeOptions { kotlinCompilerExtensionVersion = Versions.kotlinCompilerExtensionVersion } + + lint { + lintConfig = file("${rootProject.projectDir}/config/lint.xml") + abortOnError = true + warningsAsErrors = true + } +} + +dependencies { + + //Model + implementation(project(Dependencies.Mullvad.modelLib)) + + implementation(Dependencies.Compose.ui) + implementation(Dependencies.Compose.foundation) + + implementation(Dependencies.AndroidX.lifecycleRuntimeKtx) +} diff --git a/android/lib/map/src/main/AndroidManifest.xml b/android/lib/map/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8072ee00db --- /dev/null +++ b/android/lib/map/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest /> diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt new file mode 100644 index 0000000000..00fcf97ab7 --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt @@ -0,0 +1,101 @@ +package net.mullvad.mullvadvpn.lib.map + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.lib.map.data.CameraPosition +import net.mullvad.mullvadvpn.lib.map.internal.DISTANCE_DURATION_SCALE_FACTOR +import net.mullvad.mullvadvpn.lib.map.internal.FAR_ANIMATION_MAX_ZOOM_MULTIPLIER +import net.mullvad.mullvadvpn.lib.map.internal.MAX_ANIMATION_MILLIS +import net.mullvad.mullvadvpn.lib.map.internal.MAX_MULTIPLIER_PEAK_TIMING +import net.mullvad.mullvadvpn.lib.map.internal.MIN_ANIMATION_MILLIS +import net.mullvad.mullvadvpn.lib.map.internal.SHORT_ANIMATION_CUTOFF_MILLIS +import net.mullvad.mullvadvpn.model.LatLong +import net.mullvad.mullvadvpn.model.Latitude +import net.mullvad.mullvadvpn.model.Longitude + +@Composable +fun animatedCameraPosition( + baseZoom: Float, + targetCameraLocation: LatLong, + cameraVerticalBias: Float, +): CameraPosition { + + var previousLocation by remember { mutableStateOf(targetCameraLocation) } + var currentLocation by remember { mutableStateOf(targetCameraLocation) } + + if (targetCameraLocation != currentLocation) { + previousLocation = currentLocation + currentLocation = targetCameraLocation + } + + val distance = + remember(targetCameraLocation) { targetCameraLocation.distanceTo(previousLocation).toInt() } + val duration = distance.toAnimationDuration() + + val longitudeAnimation = remember { Animatable(targetCameraLocation.longitude.value) } + + val latitudeAnimation = remember { Animatable(targetCameraLocation.latitude.value) } + val zoomOutMultiplier = remember { Animatable(1f) } + + LaunchedEffect(targetCameraLocation) { + launch { latitudeAnimation.animateTo(targetCameraLocation.latitude.value, tween(duration)) } + launch { + // Unwind longitudeAnimation into a Longitude + val currentLongitude = Longitude.fromFloat(longitudeAnimation.value) + + // Resolve a vector showing us the shortest path to the target longitude, e.g going + // from 170 to -170 would result in 20 since we can wrap around the globe + val shortestPathVector = currentLongitude.vectorTo(targetCameraLocation.longitude) + + // Animate to the new camera location using the shortest path vector + longitudeAnimation.animateTo( + longitudeAnimation.value + shortestPathVector.value, + tween(duration), + ) + + // Current value animation value might be outside of range of a Longitude, so when the + // animation is done we unwind the animation to the correct value + longitudeAnimation.snapTo(targetCameraLocation.longitude.value) + } + launch { + zoomOutMultiplier.animateTo( + targetValue = 1f, + animationSpec = + keyframes { + if (duration < SHORT_ANIMATION_CUTOFF_MILLIS) { + durationMillis = duration + 1f at duration using EaseInOut + } else { + durationMillis = duration + FAR_ANIMATION_MAX_ZOOM_MULTIPLIER at + (duration * MAX_MULTIPLIER_PEAK_TIMING).toInt() using + EaseInOut + 1f at duration using EaseInOut + } + } + ) + } + } + + return CameraPosition( + zoom = baseZoom * zoomOutMultiplier.value, + latLong = + LatLong( + Latitude(latitudeAnimation.value), + Longitude.fromFloat(longitudeAnimation.value) + ), + verticalBias = cameraVerticalBias + ) +} + +private fun Int.toAnimationDuration() = + (this * DISTANCE_DURATION_SCALE_FACTOR).coerceIn(MIN_ANIMATION_MILLIS, MAX_ANIMATION_MILLIS) diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt new file mode 100644 index 0000000000..a143a63cb8 --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt @@ -0,0 +1,83 @@ +package net.mullvad.mullvadvpn.lib.map + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import net.mullvad.mullvadvpn.lib.map.data.CameraPosition +import net.mullvad.mullvadvpn.lib.map.data.GlobeColors +import net.mullvad.mullvadvpn.lib.map.data.MapViewState +import net.mullvad.mullvadvpn.lib.map.data.Marker +import net.mullvad.mullvadvpn.lib.map.internal.MapGLSurfaceView +import net.mullvad.mullvadvpn.model.LatLong + +@Composable +fun Map( + modifier: Modifier, + cameraLocation: CameraPosition, + markers: List<Marker>, + globeColors: GlobeColors, +) { + val mapViewState = MapViewState(cameraLocation, markers, globeColors) + Map(modifier = modifier, mapViewState = mapViewState) +} + +@Composable +fun AnimatedMap( + modifier: Modifier, + cameraLocation: LatLong, + cameraBaseZoom: Float, + cameraVerticalBias: Float, + markers: List<Marker>, + globeColors: GlobeColors +) { + Map( + modifier = modifier, + cameraLocation = + animatedCameraPosition( + baseZoom = cameraBaseZoom, + targetCameraLocation = cameraLocation, + cameraVerticalBias = cameraVerticalBias + ), + markers = markers, + globeColors + ) +} + +@Composable +internal fun Map(modifier: Modifier = Modifier, mapViewState: MapViewState) { + + var view: MapGLSurfaceView? = remember { null } + + val lifeCycleState = LocalLifecycleOwner.current.lifecycle + + DisposableEffect(key1 = lifeCycleState) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + view?.onResume() + } + Lifecycle.Event.ON_PAUSE -> { + view?.onPause() + } + else -> {} + } + } + lifeCycleState.addObserver(observer) + + onDispose { + lifeCycleState.removeObserver(observer) + view?.onPause() + view = null + } + } + + AndroidView(modifier = modifier, factory = { MapGLSurfaceView(it) }) { glSurfaceView -> + view = glSurfaceView + glSurfaceView.setData(mapViewState) + } +} diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt new file mode 100644 index 0000000000..d837bcadfc --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.lib.map.data + +import androidx.compose.runtime.Immutable +import net.mullvad.mullvadvpn.model.LatLong + +@Immutable +data class CameraPosition(val latLong: LatLong, val zoom: Float, val verticalBias: Float) diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/GlobeColors.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/GlobeColors.kt new file mode 100644 index 0000000000..251c466a93 --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/GlobeColors.kt @@ -0,0 +1,16 @@ +package net.mullvad.mullvadvpn.lib.map.data + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import net.mullvad.mullvadvpn.lib.map.internal.toFloatArray + +@Immutable +data class GlobeColors( + val landColor: Color, + val oceanColor: Color, + val contourColor: Color = oceanColor, +) { + val landColorArray = landColor.toFloatArray() + val oceanColorArray = oceanColor.toFloatArray() + val contourColorArray = contourColor.toFloatArray() +} diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/LocationMarkerColors.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/LocationMarkerColors.kt new file mode 100644 index 0000000000..7d4edb09cb --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/LocationMarkerColors.kt @@ -0,0 +1,17 @@ +package net.mullvad.mullvadvpn.lib.map.data + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +@Immutable +data class LocationMarkerColors( + val centerColor: Color, + val ringBorderColor: Color = Color.White, + val shadowColor: Color = Color.Black.copy(alpha = DEFAULT_SHADOW_ALPHA), + val perimeterColors: Color = centerColor.copy(alpha = DEFAULT_PERIMETER_ALPHA) +) { + companion object { + private const val DEFAULT_SHADOW_ALPHA = 0.55f + private const val DEFAULT_PERIMETER_ALPHA = 0.4f + } +} diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/MapViewState.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/MapViewState.kt new file mode 100644 index 0000000000..1e1a211115 --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/MapViewState.kt @@ -0,0 +1,10 @@ +package net.mullvad.mullvadvpn.lib.map.data + +import androidx.compose.runtime.Immutable + +@Immutable +class MapViewState( + val cameraPosition: CameraPosition, + val locationMarker: List<Marker>, + val globeColors: GlobeColors +) diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt new file mode 100644 index 0000000000..9f464612f1 --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt @@ -0,0 +1,15 @@ +package net.mullvad.mullvadvpn.lib.map.data + +import androidx.compose.runtime.Immutable +import net.mullvad.mullvadvpn.model.LatLong + +@Immutable +data class Marker( + val latLong: LatLong, + val size: Float = DEFAULT_MARKER_SIZE, + val colors: LocationMarkerColors +) { + companion object { + private const val DEFAULT_MARKER_SIZE = 0.02f + } +} diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt new file mode 100644 index 0000000000..dfd80d547d --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt @@ -0,0 +1,19 @@ +package net.mullvad.mullvadvpn.lib.map.internal + +internal const val VERTEX_COMPONENT_SIZE = 3 +internal const val COLOR_COMPONENT_SIZE = 4 +internal const val MATRIX_SIZE = 16 + +// Constant what will talk the distance in LatLng multiply it to determine the animation duration, +// the result is then confined to the MIN_ANIMATION_MILLIS and MAX_ANIMATION_MILLIS +internal const val DISTANCE_DURATION_SCALE_FACTOR = 20 +internal const val MIN_ANIMATION_MILLIS = 1300 +internal const val MAX_ANIMATION_MILLIS = 2500 +// The cut off where we go from a short animation (camera pans) to a far animation (camera pans + +// zoom out) +internal const val SHORT_ANIMATION_CUTOFF_MILLIS = 1700 + +// Multiplier for the zoom out animation +internal const val FAR_ANIMATION_MAX_ZOOM_MULTIPLIER = 1.30f +// When in the far animation we reach the MAX_ZOOM_MULTIPLIER, value is between 0 and 1 +internal const val MAX_MULTIPLIER_PEAK_TIMING = .35f diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/GLHelper.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/GLHelper.kt new file mode 100644 index 0000000000..e416988d8d --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/GLHelper.kt @@ -0,0 +1,108 @@ +package net.mullvad.mullvadvpn.lib.map.internal + +import android.opengl.GLES20 +import android.opengl.Matrix +import android.util.Log +import androidx.compose.ui.graphics.Color +import java.nio.Buffer +import java.nio.ByteBuffer +import java.nio.FloatBuffer + +internal fun initShaderProgram(vsSource: String, fsSource: String): Int { + val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vsSource) + require(vertexShader != -1) { "Failed to load vertexShader, result: -1" } + + val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fsSource) + require(fragmentShader != -1) { "fragmentShader == -1" } + + val program = GLES20.glCreateProgram() + check(program != 0) { "Could not create program" } + + // Add the vertex shader to program + GLES20.glAttachShader(program, vertexShader) + + // Add the fragment shader to program + GLES20.glAttachShader(program, fragmentShader) + + // Creates OpenGL ES program executables + GLES20.glLinkProgram(program) + + val linked = IntArray(1) + GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linked, 0) + if (linked[0] == GLES20.GL_FALSE) { + val infoLog = GLES20.glGetProgramInfoLog(program) + Log.e("GLHelper", "Could not link program: $infoLog") + GLES20.glDeleteProgram(program) + error("Could not link program with vsSource: $vsSource and fsSource: $fsSource") + } + + return program +} + +private fun loadShader(type: Int, shaderCode: String): Int { + // Create a vertex shader type (GLES20.GL_VERTEX_SHADER) + // or a fragment shader type (GLES20.GL_FRAGMENT_SHADER) + val shader = GLES20.glCreateShader(type) + + require(shader != 0) { "Unable to create shader" } + + // Add the source code to the shader and compile it + GLES20.glShaderSource(shader, shaderCode) + GLES20.glCompileShader(shader) + + val compiled = IntArray(1) + GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0) + if (compiled[0] == GLES20.GL_FALSE) { + val infoLog = GLES20.glGetShaderInfoLog(shader) + Log.e("GLHelper", "Could not compile shader $type:$infoLog") + GLES20.glDeleteShader(shader) + + error("Could not compile shader with shaderCode: $shaderCode") + } + + return shader +} + +internal fun initArrayBuffer(buffer: ByteBuffer) = initArrayBuffer(buffer, Byte.SIZE_BYTES) + +internal fun initArrayBuffer(buffer: FloatBuffer) = initArrayBuffer(buffer, Float.SIZE_BYTES) + +private fun initArrayBuffer(dataBuffer: Buffer, unitSizeInBytes: Int = 1): Int { + val buffer = IntArray(1) + GLES20.glGenBuffers(1, buffer, 0) + + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, buffer[0]) + GLES20.glBufferData( + GLES20.GL_ARRAY_BUFFER, + dataBuffer.capacity() * unitSizeInBytes, + dataBuffer, + GLES20.GL_STATIC_DRAW + ) + return buffer[0] +} + +internal fun initIndexBuffer(dataBuffer: Buffer): IndexBufferWithLength { + val buffer = IntArray(1) + GLES20.glGenBuffers(1, buffer, 0) + + GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, buffer[0]) + GLES20.glBufferData( + GLES20.GL_ELEMENT_ARRAY_BUFFER, + dataBuffer.capacity(), + dataBuffer, + GLES20.GL_STATIC_DRAW + ) + return IndexBufferWithLength( + indexBuffer = buffer[0], + length = dataBuffer.capacity() / Float.SIZE_BYTES + ) +} + +internal class IndexBufferWithLength(val indexBuffer: Int, val length: Int) + +internal fun newIdentityMatrix(): FloatArray = + FloatArray(MATRIX_SIZE).apply { Matrix.setIdentityM(this, 0) } + +internal fun Color.toFloatArray(): FloatArray { + return floatArrayOf(red, green, blue, alpha) +} diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt new file mode 100644 index 0000000000..bf44e5ee14 --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt @@ -0,0 +1,133 @@ +package net.mullvad.mullvadvpn.lib.map.internal + +import android.content.res.Resources +import android.opengl.GLES20 +import android.opengl.GLSurfaceView +import android.opengl.Matrix +import androidx.collection.LruCache +import javax.microedition.khronos.egl.EGLConfig +import javax.microedition.khronos.opengles.GL10 +import kotlin.math.tan +import net.mullvad.mullvadvpn.lib.map.data.CameraPosition +import net.mullvad.mullvadvpn.lib.map.data.LocationMarkerColors +import net.mullvad.mullvadvpn.lib.map.data.MapViewState +import net.mullvad.mullvadvpn.lib.map.internal.shapes.Globe +import net.mullvad.mullvadvpn.lib.map.internal.shapes.LocationMarker +import net.mullvad.mullvadvpn.model.COMPLETE_ANGLE + +internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.Renderer { + + private lateinit var globe: Globe + + // Due to location markers themselves containing colors we cache them to avoid recreating them + // for every draw call. + private val markerCache: LruCache<LocationMarkerColors, LocationMarker> = + object : LruCache<LocationMarkerColors, LocationMarker>(100) { + override fun entryRemoved( + evicted: Boolean, + key: LocationMarkerColors, + oldValue: LocationMarker, + newValue: LocationMarker? + ) { + oldValue.onRemove() + } + } + + private lateinit var viewState: MapViewState + + override fun onSurfaceCreated(unused: GL10, config: EGLConfig) { + globe = Globe(resources) + markerCache.evictAll() + initGLOptions() + } + + private fun initGLOptions() { + // Enable cull face (To not draw the backside of triangles) + GLES20.glEnable(GLES20.GL_CULL_FACE) + GLES20.glCullFace(GLES20.GL_BACK) + + // Enable blend + GLES20.glEnable(GLES20.GL_BLEND) + GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA) + } + + private val projectionMatrix = newIdentityMatrix() + + override fun onDrawFrame(gl10: GL10) { + // Clear canvas + clear() + + val viewMatrix = newIdentityMatrix() + + // Adjust zoom & vertical bias + val yOffset = toOffsetY(viewState.cameraPosition) + Matrix.translateM(viewMatrix, 0, 0f, yOffset, -viewState.cameraPosition.zoom) + + // Rotate to match the camera position + Matrix.rotateM(viewMatrix, 0, viewState.cameraPosition.latLong.latitude.value, 1f, 0f, 0f) + Matrix.rotateM(viewMatrix, 0, viewState.cameraPosition.latLong.longitude.value, 0f, -1f, 0f) + + globe.draw(projectionMatrix, viewMatrix, viewState.globeColors) + + // Draw location markers + viewState.locationMarker.forEach { + val marker = + markerCache[it.colors] + ?: LocationMarker(it.colors).also { markerCache.put(it.colors, it) } + + marker.draw(projectionMatrix, viewMatrix, it.latLong, it.size) + } + } + + private fun Float.toRadians() = this * Math.PI.toFloat() / (COMPLETE_ANGLE / 2) + + private fun toOffsetY(cameraPosition: CameraPosition): Float { + val percent = cameraPosition.verticalBias + val z = cameraPosition.zoom - 1f + // Calculate the size of the plane at the current z position + val planeSizeY = tan(FIELD_OF_VIEW.toRadians() / 2f) * z * 2f + + // Calculate the start of the plane + val planeStartY = planeSizeY / 2f + + // Return offset based on the bias + return planeStartY - planeSizeY * percent + } + + private fun clear() { + // Redraw background color + GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f) + GLES20.glClearDepthf(1.0f) + GLES20.glEnable(GLES20.GL_DEPTH_TEST) + GLES20.glDepthFunc(GLES20.GL_LEQUAL) + + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT) + } + + override fun onSurfaceChanged(unused: GL10, width: Int, height: Int) { + GLES20.glViewport(0, 0, width, height) + + val ratio: Float = width.toFloat() / height.toFloat() + + if (ratio.isFinite()) { + Matrix.perspectiveM( + projectionMatrix, + 0, + FIELD_OF_VIEW, + ratio, + PERSPECTIVE_Z_NEAR, + PERSPECTIVE_Z_FAR + ) + } + } + + fun setViewState(viewState: MapViewState) { + this.viewState = viewState + } + + companion object { + private const val PERSPECTIVE_Z_NEAR = 0.05f + private const val PERSPECTIVE_Z_FAR = 10f + private const val FIELD_OF_VIEW = 70f + } +} diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt new file mode 100644 index 0000000000..19dd085524 --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt @@ -0,0 +1,31 @@ +package net.mullvad.mullvadvpn.lib.map.internal + +import android.content.Context +import android.opengl.GLSurfaceView +import net.mullvad.mullvadvpn.lib.map.BuildConfig +import net.mullvad.mullvadvpn.lib.map.data.MapViewState + +internal class MapGLSurfaceView(context: Context) : GLSurfaceView(context) { + + private val renderer: MapGLRenderer + + init { + // Create an OpenGL ES 2.0 context + setEGLContextClientVersion(2) + + if (BuildConfig.DEBUG) { + debugFlags = DEBUG_CHECK_GL_ERROR or DEBUG_LOG_GL_CALLS + } + + renderer = MapGLRenderer(context.resources) + + // Set the Renderer for drawing on the GLSurfaceView + setRenderer(renderer) + renderMode = RENDERMODE_WHEN_DIRTY + } + + fun setData(viewState: MapViewState) { + renderer.setViewState(viewState) + requestRender() + } +} diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/Globe.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/Globe.kt new file mode 100644 index 0000000000..379ac407cc --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/Globe.kt @@ -0,0 +1,185 @@ +package net.mullvad.mullvadvpn.lib.map.internal.shapes + +import android.content.res.Resources +import android.opengl.GLES20 +import android.opengl.Matrix +import java.nio.ByteBuffer +import net.mullvad.mullvadvpn.lib.map.R +import net.mullvad.mullvadvpn.lib.map.data.GlobeColors +import net.mullvad.mullvadvpn.lib.map.internal.IndexBufferWithLength +import net.mullvad.mullvadvpn.lib.map.internal.VERTEX_COMPONENT_SIZE +import net.mullvad.mullvadvpn.lib.map.internal.initArrayBuffer +import net.mullvad.mullvadvpn.lib.map.internal.initIndexBuffer +import net.mullvad.mullvadvpn.lib.map.internal.initShaderProgram + +internal class Globe(resources: Resources) { + + private val shaderProgram: Int + + private val attribLocations: AttribLocations + private val uniformLocation: UniformLocation + + private val landIndices: IndexBufferWithLength + private val landContour: IndexBufferWithLength + private val landVertexBuffer: Int + + private val oceanIndices: IndexBufferWithLength + private val oceanVertexBuffer: Int + + init { + val landPosStream = resources.openRawResource(R.raw.land_positions) + val landVertByteArray = landPosStream.use { it.readBytes() } + val landVertByteBuffer = ByteBuffer.wrap(landVertByteArray) + landVertexBuffer = initArrayBuffer(landVertByteBuffer) + + val landTriangleIndicesStream = resources.openRawResource(R.raw.land_triangle_indices) + val landTriangleIndicesByteArray = landTriangleIndicesStream.use { it.readBytes() } + val landTriangleIndicesBuffer = ByteBuffer.wrap(landTriangleIndicesByteArray) + landIndices = initIndexBuffer(landTriangleIndicesBuffer) + + val landContourIndicesStream = resources.openRawResource(R.raw.land_contour_indices) + val landContourIndicesByteArray = landContourIndicesStream.use { it.readBytes() } + val landContourIndicesBuffer = ByteBuffer.wrap(landContourIndicesByteArray) + landContour = initIndexBuffer(landContourIndicesBuffer) + + val oceanPosStream = resources.openRawResource(R.raw.ocean_positions) + val oceanVertByteArray = oceanPosStream.use { it.readBytes() } + val oceanVertByteBuffer = ByteBuffer.wrap(oceanVertByteArray) + oceanVertexBuffer = initArrayBuffer(oceanVertByteBuffer) + + val oceanTriangleIndicesStream = resources.openRawResource(R.raw.ocean_indices) + val oceanTriangleIndicesByteArray = oceanTriangleIndicesStream.use { it.readBytes() } + val oceanTriangleIndicesBuffer = ByteBuffer.wrap(oceanTriangleIndicesByteArray) + oceanIndices = initIndexBuffer(oceanTriangleIndicesBuffer) + + // create empty OpenGL ES Program + shaderProgram = initShaderProgram(vertexShaderCode, fragmentShaderCode) + + attribLocations = + AttribLocations(GLES20.glGetAttribLocation(shaderProgram, "aVertexPosition")) + uniformLocation = + UniformLocation( + color = GLES20.glGetUniformLocation(shaderProgram, "uColor"), + projectionMatrix = GLES20.glGetUniformLocation(shaderProgram, "uProjectionMatrix"), + modelViewMatrix = GLES20.glGetUniformLocation(shaderProgram, "uModelViewMatrix") + ) + } + + fun draw( + projectionMatrix: FloatArray, + viewMatrix: FloatArray, + colors: GlobeColors, + contourWidth: Float = 3f + ) { + val globeViewMatrix = viewMatrix.copyOf() + + // Add program to OpenGL ES environment + GLES20.glUseProgram(shaderProgram) + + // Set thickness of contour lines + GLES20.glLineWidth(contourWidth) + drawBufferElements( + projectionMatrix, + globeViewMatrix, + landVertexBuffer, + landContour, + colors.contourColorArray, + GLES20.GL_LINES + ) + + // Scale the globe to avoid z-fighting + Matrix.scaleM( + globeViewMatrix, + 0, + LAND_OCEAN_SCALE_FACTOR, + LAND_OCEAN_SCALE_FACTOR, + LAND_OCEAN_SCALE_FACTOR + ) + + // Draw land + drawBufferElements( + projectionMatrix, + globeViewMatrix, + landVertexBuffer, + landIndices, + colors.landColorArray, + GLES20.GL_TRIANGLES, + ) + + // Draw ocean + drawBufferElements( + projectionMatrix, + globeViewMatrix, + oceanVertexBuffer, + oceanIndices, + colors.oceanColorArray, + GLES20.GL_TRIANGLES + ) + } + + private fun drawBufferElements( + projectionMatrix: FloatArray, + modelViewMatrix: FloatArray, + positionBuffer: Int, + indexBuffer: IndexBufferWithLength, + color: FloatArray, + mode: Int, + ) { + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, positionBuffer) + GLES20.glVertexAttribPointer( + attribLocations.vertexPosition, + VERTEX_COMPONENT_SIZE, + GLES20.GL_FLOAT, + false, + 0, + 0, + ) + GLES20.glEnableVertexAttribArray(attribLocations.vertexPosition) + + GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, indexBuffer.indexBuffer) + GLES20.glUniform4fv(uniformLocation.color, 1, color, 0) + GLES20.glUniformMatrix4fv(uniformLocation.projectionMatrix, 1, false, projectionMatrix, 0) + GLES20.glUniformMatrix4fv(uniformLocation.modelViewMatrix, 1, false, modelViewMatrix, 0) + GLES20.glDrawElements(mode, indexBuffer.length, GLES20.GL_UNSIGNED_INT, 0) + GLES20.glDisableVertexAttribArray(attribLocations.vertexPosition) + } + + private data class AttribLocations(val vertexPosition: Int) + + private data class UniformLocation( + val color: Int, + val projectionMatrix: Int, + val modelViewMatrix: Int + ) + + companion object { + private const val LAND_OCEAN_SCALE_FACTOR = 0.9999f + + // Vertex, and fragment shader code is taken from Mullvad Desktop 3dmap.ts + private val vertexShaderCode = + """ + attribute vec3 aVertexPosition; + + uniform vec4 uColor; + uniform mat4 uModelViewMatrix; + uniform mat4 uProjectionMatrix; + + varying lowp vec4 vColor; + + void main(void) { + gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0); + vColor = uColor; + } + """ + .trimIndent() + private val fragmentShaderCode = + """ + varying lowp vec4 vColor; + + void main(void) { + gl_FragColor = vColor; + } + """ + .trimIndent() + } +} diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt new file mode 100644 index 0000000000..9d03a540c5 --- /dev/null +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt @@ -0,0 +1,224 @@ +package net.mullvad.mullvadvpn.lib.map.internal.shapes + +import android.opengl.GLES20 +import android.opengl.Matrix +import androidx.compose.ui.graphics.Color +import java.nio.FloatBuffer +import kotlin.math.cos +import kotlin.math.sin +import net.mullvad.mullvadvpn.lib.map.data.LocationMarkerColors +import net.mullvad.mullvadvpn.lib.map.internal.COLOR_COMPONENT_SIZE +import net.mullvad.mullvadvpn.lib.map.internal.VERTEX_COMPONENT_SIZE +import net.mullvad.mullvadvpn.lib.map.internal.initArrayBuffer +import net.mullvad.mullvadvpn.lib.map.internal.initShaderProgram +import net.mullvad.mullvadvpn.lib.map.internal.toFloatArray +import net.mullvad.mullvadvpn.model.LatLong + +internal class LocationMarker(val colors: LocationMarkerColors) { + + private val shaderProgram: Int + private val attribLocations: AttribLocations + private val uniformLocation: UniformLocation + private val positionBuffer: Int + private val colorBuffer: Int + private val ringSizes: List<Int> + + init { + val rings = createRings() + ringSizes = rings.map { (positions, _) -> positions.size } + + val positionFloatArray = joinMultipleArrays(rings.map { it.vertices }) + val positionFloatBuffer = FloatBuffer.wrap(positionFloatArray) + + val colorFloatArray = joinMultipleArrays(rings.map { it.verticesColor }) + val colorFloatBuffer = FloatBuffer.wrap(colorFloatArray) + + positionBuffer = initArrayBuffer(positionFloatBuffer) + colorBuffer = initArrayBuffer(colorFloatBuffer) + + shaderProgram = initShaderProgram(vertexShaderCode, fragmentShaderCode) + + attribLocations = + AttribLocations( + vertexPosition = GLES20.glGetAttribLocation(shaderProgram, "aVertexPosition"), + vertexColor = GLES20.glGetAttribLocation(shaderProgram, "aVertexColor") + ) + uniformLocation = + UniformLocation( + projectionMatrix = GLES20.glGetUniformLocation(shaderProgram, "uProjectionMatrix"), + modelViewMatrix = GLES20.glGetUniformLocation(shaderProgram, "uModelViewMatrix") + ) + } + + fun draw(projectionMatrix: FloatArray, viewMatrix: FloatArray, latLong: LatLong, size: Float) { + val modelViewMatrix = viewMatrix.copyOf() + + GLES20.glUseProgram(shaderProgram) + + Matrix.rotateM(modelViewMatrix, 0, latLong.longitude.value, 0f, 1f, 0f) + Matrix.rotateM(modelViewMatrix, 0, latLong.latitude.value, -1f, 0f, 0f) + + Matrix.scaleM(modelViewMatrix, 0, size, size, 1f) + + // Translate marker to put it above the globe + Matrix.translateM(modelViewMatrix, 0, 0f, 0f, MARKER_TRANSLATE_Z_FACTOR) + + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, positionBuffer) + GLES20.glVertexAttribPointer( + attribLocations.vertexPosition, + VERTEX_COMPONENT_SIZE, + GLES20.GL_FLOAT, + false, + 0, + 0, + ) + GLES20.glEnableVertexAttribArray(attribLocations.vertexPosition) + + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, colorBuffer) + GLES20.glVertexAttribPointer( + attribLocations.vertexColor, + COLOR_COMPONENT_SIZE, + GLES20.GL_FLOAT, + false, + 0, + 0, + ) + GLES20.glEnableVertexAttribArray(attribLocations.vertexColor) + + GLES20.glUniformMatrix4fv(uniformLocation.projectionMatrix, 1, false, projectionMatrix, 0) + GLES20.glUniformMatrix4fv(uniformLocation.modelViewMatrix, 1, false, modelViewMatrix, 0) + + var offset = 0 + for (ringSize in ringSizes) { + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_FAN, offset, ringSize) + // Add number off vertices in the ring to the offset + offset += ringSize / VERTEX_COMPONENT_SIZE + } + } + + // Returns vertex positions and color values for a circle. + // `offset` is a vector of x, y and z values determining how much to offset the circle + // position from origo + private fun circleFanVertices( + numEdges: Int, + radius: Float, + offset: FloatArray = floatArrayOf(0.0f, 0.0f, 0.0f), + centerColor: Color, + ringColor: Color, + ): Ring { + require(numEdges > 2) { "Number of edges must be greater than 2" } + + // Edges + center + first point + val points = numEdges + 2 + + val positions = FloatArray(points * VERTEX_COMPONENT_SIZE) + val positionsColor = FloatArray(points * COLOR_COMPONENT_SIZE) + + // Start adding the center the center point + offset.forEachIndexed { index, value -> positions[index] = value } + centerColor.toFloatArray().forEachIndexed { index, value -> positionsColor[index] = value } + + val ringColorArray = ringColor.toFloatArray() + + for (i in 1 until points) { + + val angle = (i.toFloat() / numEdges) * 2f * Math.PI + val posIndex = i * VERTEX_COMPONENT_SIZE + positions[posIndex] = offset[0] + radius * cos(angle).toFloat() + positions[posIndex + 1] = offset[1] + radius * sin(angle).toFloat() + positions[posIndex + 2] = offset[2] + + val colorIndex = i * COLOR_COMPONENT_SIZE + ringColorArray.forEachIndexed { index, value -> + positionsColor[colorIndex + index] = value + } + } + + return Ring(positions, positionsColor) + } + + private fun joinMultipleArrays(arrays: List<FloatArray>): FloatArray { + val result = FloatArray(arrays.sumOf { it.size }) + var offset = 0 + for (array in arrays) { + array.copyInto(result, offset) + offset += array.size + } + return result + } + + @Suppress("MagicNumber") + private fun createRings(): List<Ring> = + listOf( + circleFanVertices( + 32, + 0.5f, + floatArrayOf(0.0f, 0.0f, 0.0f), + colors.perimeterColors, + colors.perimeterColors, + ), // Semi-transparent outer + circleFanVertices( + 16, + 0.28f, + floatArrayOf(0.0f, -0.05f, 0.00001f), + colors.shadowColor, + colors.shadowColor.copy(alpha = 0.0f), + ), // Shadow + circleFanVertices( + 32, + 0.185f, + floatArrayOf(0.0f, 0.0f, 0.00002f), + colors.ringBorderColor, + colors.ringBorderColor, + ), // White ring + circleFanVertices( + 32, + 0.15f, + floatArrayOf(0.0f, 0.0f, 0.00003f), + colors.centerColor, + colors.centerColor, + ) // Center colored circle + ) + + fun onRemove() { + GLES20.glDeleteBuffers(2, intArrayOf(positionBuffer, colorBuffer), 0) + GLES20.glDeleteProgram(shaderProgram) + } + + private data class Ring(val vertices: FloatArray, val verticesColor: FloatArray) + + private data class AttribLocations(val vertexPosition: Int, val vertexColor: Int) + + private data class UniformLocation(val projectionMatrix: Int, val modelViewMatrix: Int) + + companion object { + private const val MARKER_TRANSLATE_Z_FACTOR = 1.0001f + + // Vertex, and fragment shader code is taken from Mullvad Desktop 3dmap.ts + private val vertexShaderCode = + """ + attribute vec3 aVertexPosition; + attribute vec4 aVertexColor; + + uniform mat4 uModelViewMatrix; + uniform mat4 uProjectionMatrix; + + varying lowp vec4 vColor; + + void main(void) { + gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0); + vColor = aVertexColor; + } + """ + .trimIndent() + private val fragmentShaderCode = + """ + varying lowp vec4 vColor; + + void main(void) { + gl_FragColor = vColor; + } + """ + .trimIndent() + } +} diff --git a/android/lib/map/src/main/res/raw b/android/lib/map/src/main/res/raw new file mode 120000 index 0000000000..9f90f80c93 --- /dev/null +++ b/android/lib/map/src/main/res/raw @@ -0,0 +1 @@ +../../../../../../gui/assets/geo
\ No newline at end of file diff --git a/android/lib/model/build.gradle.kts b/android/lib/model/build.gradle.kts index 040c2fca0c..7264c6041a 100644 --- a/android/lib/model/build.gradle.kts +++ b/android/lib/model/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id(Dependencies.Plugin.androidLibraryId) + id(Dependencies.Plugin.junit5) version Versions.Plugin.junit5 id(Dependencies.Plugin.kotlinAndroidId) id(Dependencies.Plugin.kotlinParcelizeId) } @@ -8,7 +9,10 @@ android { namespace = "net.mullvad.mullvadvpn.model" compileSdk = Versions.Android.compileSdkVersion - defaultConfig { minSdk = Versions.Android.minSdkVersion } + defaultConfig { + minSdk = Versions.Android.minSdkVersion + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -30,4 +34,12 @@ dependencies { implementation(Dependencies.jodaTime) implementation(Dependencies.Kotlin.stdlib) implementation(Dependencies.KotlinX.coroutinesAndroid) + + // Test dependencies + testRuntimeOnly(Dependencies.junitEngine) + + testImplementation(Dependencies.Kotlin.test) + testImplementation(Dependencies.junitApi) + + testImplementation(project(Dependencies.Mullvad.commonTestLib)) } diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeoIpLocation.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeoIpLocation.kt index e15ab20376..625de76b29 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeoIpLocation.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeoIpLocation.kt @@ -10,5 +10,7 @@ data class GeoIpLocation( val ipv6: InetAddress?, val country: String, val city: String?, - val hostname: String? + val latitude: Double, + val longitude: Double, + val hostname: String?, ) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeographicLocationConstraint.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeographicLocationConstraint.kt index 04f92a72ac..386257a72a 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeographicLocationConstraint.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeographicLocationConstraint.kt @@ -9,20 +9,20 @@ sealed class GeographicLocationConstraint : Parcelable { @Parcelize data class Country(val countryCode: String) : GeographicLocationConstraint() { override val location: GeoIpLocation - get() = GeoIpLocation(null, null, countryCode, null, null) + get() = GeoIpLocation(null, null, countryCode, null, 0.0, 0.0, null) } @Parcelize data class City(val countryCode: String, val cityCode: String) : GeographicLocationConstraint() { override val location: GeoIpLocation - get() = GeoIpLocation(null, null, countryCode, cityCode, null) + get() = GeoIpLocation(null, null, countryCode, cityCode, 0.0, 0.0, null) } @Parcelize data class Hostname(val countryCode: String, val cityCode: String, val hostname: String) : GeographicLocationConstraint() { override val location: GeoIpLocation - get() = GeoIpLocation(null, null, countryCode, cityCode, hostname) + get() = GeoIpLocation(null, null, countryCode, cityCode, 0.0, 0.0, hostname) } } diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/LatLong.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/LatLong.kt new file mode 100644 index 0000000000..ae047130e8 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/LatLong.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.model + +import kotlin.math.pow +import kotlin.math.sqrt + +data class LatLong(val latitude: Latitude, val longitude: Longitude) { + + fun distanceTo(other: LatLong): Float = + sqrt( + latitude.distanceTo(other.latitude).pow(2f) + + (longitude.distanceTo(other.longitude).pow(2f)) + ) + + operator fun plus(other: LatLong) = + LatLong(latitude + other.latitude, longitude + other.longitude) + + operator fun minus(other: LatLong) = + LatLong(latitude - other.latitude, longitude - other.longitude) +} + +const val COMPLETE_ANGLE = 360f diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Latitude.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Latitude.kt new file mode 100644 index 0000000000..14c5b66983 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Latitude.kt @@ -0,0 +1,55 @@ +package net.mullvad.mullvadvpn.model + +import kotlin.math.absoluteValue + +@JvmInline +value class Latitude(val value: Float) { + init { + require(value in LATITUDE_RANGE) { + "Latitude: '$value' must be between $MIN_LATITUDE_VALUE and $MAX_LATITUDE_VALUE" + } + } + + fun distanceTo(other: Latitude) = (other.value - value).absoluteValue + + operator fun plus(other: Latitude) = fromFloat(value + other.value) + + operator fun minus(other: Latitude) = fromFloat(value - other.value) + + companion object { + private const val MIN_LATITUDE_VALUE: Float = -90f + private const val MAX_LATITUDE_VALUE: Float = 90f + private val LATITUDE_RANGE = MIN_LATITUDE_VALUE..MAX_LATITUDE_VALUE + + /** + * Create a [Latitude] from a float value. + * + * This function will unwind a float to a valid latitude value. E.g 190 will be unwound to + * -10 and 360 will be unwound to 0. + */ + fun fromFloat(value: Float): Latitude { + val unwoundValue = unwind(value) + return Latitude(unwoundValue) + } + + private fun unwind(value: Float): Float { + // Remove all 360 degrees + val withoutRotations = value % COMPLETE_ANGLE + + // If we are above 180 or below -180, we wrapped half a turn and need to flip sign + val partiallyUnwound = + if (withoutRotations.absoluteValue > COMPLETE_ANGLE / 2) { + -withoutRotations % (COMPLETE_ANGLE / 2) + } else withoutRotations + + return when { + partiallyUnwound < MIN_LATITUDE_VALUE -> + MIN_LATITUDE_VALUE - (partiallyUnwound % MIN_LATITUDE_VALUE) + partiallyUnwound > MAX_LATITUDE_VALUE -> + MAX_LATITUDE_VALUE - (partiallyUnwound % MAX_LATITUDE_VALUE) + // partiallyUnwound in range MIN_LATITUDE_VALUE..MAX_LATITUDE_VALUE + else -> partiallyUnwound + } + } + } +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Longitude.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Longitude.kt new file mode 100644 index 0000000000..9f73a6ff17 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Longitude.kt @@ -0,0 +1,55 @@ +package net.mullvad.mullvadvpn.model + +import kotlin.math.absoluteValue + +@JvmInline +value class Longitude(val value: Float) { + init { + require(value in LONGITUDE_RANGE) { + "Longitude: '$value' must be between $MIN_LONGITUDE_VALUE and $MAX_LONGITUDE_VALUE" + } + } + + fun distanceTo(other: Longitude) = vectorTo(other).value.absoluteValue + + fun vectorTo(other: Longitude): Longitude { + val diff = other.value - value + val vectorValue = + when { + diff > MAX_LONGITUDE_VALUE -> diff - COMPLETE_ANGLE + diff < MIN_LONGITUDE_VALUE -> diff + COMPLETE_ANGLE + else -> diff + } + return Longitude(vectorValue) + } + + operator fun plus(other: Longitude) = fromFloat(value + other.value) + + operator fun minus(other: Longitude) = fromFloat(value - other.value) + + companion object { + private const val MIN_LONGITUDE_VALUE: Float = -180f + private const val MAX_LONGITUDE_VALUE: Float = 180f + private val LONGITUDE_RANGE = MIN_LONGITUDE_VALUE..MAX_LONGITUDE_VALUE + + /** + * Create a [Longitude] from a float value. + * + * This function will unwind a float to a valid longitude value. E.g 190 will be unwound to + * -170 and 360 will be unwound to 0. + */ + fun fromFloat(value: Float): Longitude { + val unwoundValue = unwind(value) + return Longitude(unwoundValue) + } + + private fun unwind(value: Float): Float { + val unwound = value % COMPLETE_ANGLE + return when { + unwound > MAX_LONGITUDE_VALUE -> unwound - COMPLETE_ANGLE + unwound < MIN_LONGITUDE_VALUE -> unwound + COMPLETE_ANGLE + else -> unwound + } + } + } +} diff --git a/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatLongTest.kt b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatLongTest.kt new file mode 100644 index 0000000000..6644e25e82 --- /dev/null +++ b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatLongTest.kt @@ -0,0 +1,20 @@ +package net.mullvad.mullvadvpn.model + +import kotlin.math.sqrt +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class LatLongTest { + + @Test + fun `distance between two LatLong should be same as hypotenuse`() { + val latLong1 = LatLong(Latitude(30f), Longitude(40f)) + val latLong2 = LatLong(Latitude(-40f), Longitude(170f)) + + val latDiff = latLong1.latitude.distanceTo(latLong2.latitude) + val longDiff = latLong1.longitude.distanceTo(latLong2.longitude) + val hypotenuse = sqrt(latDiff * latDiff + longDiff * longDiff) + + assertEquals(hypotenuse, latLong1.distanceTo(latLong2)) + } +} diff --git a/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatitudeTest.kt b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatitudeTest.kt new file mode 100644 index 0000000000..8788c2123a --- /dev/null +++ b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatitudeTest.kt @@ -0,0 +1,163 @@ +package net.mullvad.mullvadvpn.model + +import kotlin.math.absoluteValue +import kotlin.test.assertEquals +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class LatitudeTest { + @Test + fun `creating a valid latitude should work`() { + assertDoesNotThrow { Latitude(30f) } + } + + @Test + fun `creating a valid negative latitude should work`() { + assertDoesNotThrow { Latitude(-30f) } + } + + @Test + fun `create with too high latitude should give IllegalArgumentException`() { + assertThrows<IllegalArgumentException> { Latitude(90.1f) } + } + + @Test + fun `create with too low latitude should give IllegalArgumentException`() { + assertThrows<IllegalArgumentException> { Latitude(-90.1f) } + } + + @Test + fun `fromFloat should accept and wrap large value`() { + val longFloat = 400f + val longitude = Latitude.fromFloat(longFloat) + + assertEquals(40f, longitude.value) + } + + @Test + fun `fromFloat should accept and support half-wrap`() { + val longFloat = 100f + val longitude = Latitude.fromFloat(longFloat) + + assertEquals(80f, longitude.value) + } + + @Test + fun `fromFloat should accept and support negative half-wrap`() { + val longFloat = -100f + val longitude = Latitude.fromFloat(longFloat) + + assertEquals(-80f, longitude.value) + } + + @Test + fun `adding two positive latitude should result in the sum`() { + val latFloat1 = 20f + val latitude1 = Latitude(latFloat1) + val latFloat2 = 30f + val latitude2 = Latitude(latFloat2) + + assertEquals(latFloat1 + latFloat2, (latitude1 + latitude2).value) + } + + @Test + fun `adding two large positive latitude should result in the sum wrapped`() { + val latFloat1 = 70f + val latitude1 = Latitude(latFloat1) + val latFloat2 = 50f + val latitude2 = Latitude(latFloat2) + + val expectedResult = 60f + + assertEquals(expectedResult, (latitude1 + latitude2).value) + } + + @Test + fun `adding two negative latitude should result in the sum`() { + val latFloat1 = -20f + val latitude1 = Latitude(latFloat1) + val latFloat2 = -40f + val latitude2 = Latitude(latFloat2) + + assertEquals(latFloat1 + latFloat2, (latitude1 + latitude2).value) + } + + @Test + fun `adding two large negative latitude should result in the sum`() { + val latFloat1 = -70f + val latitude1 = Latitude(latFloat1) + val latFloat2 = -50f + val latitude2 = Latitude(latFloat2) + + val expectedResult = -60f + + assertEquals(expectedResult, (latitude1 + latitude2).value) + } + + @Test + fun `subtracting two positive latitude should result in the sum`() { + val latFloat1 = 80f + val latitude1 = Latitude(latFloat1) + val latFloat2 = 30f + val latitude2 = Latitude(latFloat2) + + assertEquals(latFloat1 - latFloat2, (latitude1 - latitude2).value) + } + + @Test + fun `subtracting a large latitude should result in the sum wrapped`() { + val latFloat1 = -30f + val latitude1 = Latitude(latFloat1) + val latFloat2 = 80f + val latitude2 = Latitude(latFloat2) + + val expectedResult = -70f + + assertEquals(expectedResult, (latitude1 - latitude2).value) + } + + @Test + fun `subtracting a negative latitude should result in same as addition`() { + val latFloat1 = -30f + val latitude1 = Latitude(latFloat1) + val latFloat2 = -40f + val latitude2 = Latitude(latFloat2) + + assertEquals(latFloat1 + latFloat2.absoluteValue, (latitude1 - latitude2).value) + } + + @Test + fun `subtracting a large negative latitude should result in same as addition wrapped`() { + val latFloat1 = 80f + val latitude1 = Latitude(latFloat1) + val latFloat2 = -90f + val latitude2 = Latitude(latFloat2) + + val absoluteLatitude2 = Latitude.fromFloat(latFloat2.absoluteValue) + + assertEquals(latitude1 + absoluteLatitude2, latitude1 - latitude2) + } + + @Test + fun `distanceTo with two positive latitudes`() { + val latFloat1 = 80f + val latitude1 = Latitude(latFloat1) + val latFloat2 = 30f + val latitude2 = Latitude(latFloat2) + + assertEquals(latFloat1 - latFloat2, latitude1.distanceTo(latitude2)) + } + + @Test + fun `distanceTo with two negative latitudes`() { + val latFloat1 = -80f + val latitude1 = Latitude(latFloat1) + val latFloat2 = -30f + val latitude2 = Latitude(latFloat2) + + val expectedValue = 50f + + assertEquals(expectedValue, latitude1.distanceTo(latitude2)) + } +} diff --git a/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LongitudeTest.kt b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LongitudeTest.kt new file mode 100644 index 0000000000..de94661ad0 --- /dev/null +++ b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LongitudeTest.kt @@ -0,0 +1,154 @@ +package net.mullvad.mullvadvpn.model + +import kotlin.math.absoluteValue +import kotlin.test.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows + +class LongitudeTest { + @Test + fun `create longitude with longitude should work`() { + assertDoesNotThrow { Longitude(80f) } + } + + @Test + fun `create longitude with negative longitude should work`() { + assertDoesNotThrow { Longitude(-80f) } + } + + @Test + fun `create too high longitude should give IllegalArgumentException`() { + assertThrows<IllegalArgumentException> { Longitude(180.1f) } + } + + @Test + fun `create too low longitude should give IllegalArgumentException`() { + assertThrows<IllegalArgumentException> { Longitude(-180.1f) } + } + + @Test + fun `fromFloat should accept and wrap large value`() { + val longFloat = 720f + val longitude = Longitude.fromFloat(longFloat) + + assertEquals(0f, longitude.value) + } + + @Test + fun `fromFloat should accept and wrap large negative value`() { + val longFloat = -720f + val longitude = Longitude.fromFloat(longFloat) + + assertEquals(0f, longitude.value, 0f) + } + + @Test + fun `adding two positive longitude should result in the sum`() { + val longFloat1 = 80f + val longitude1 = Longitude(longFloat1) + val longFloat2 = 30f + val longitude2 = Longitude(longFloat2) + + assertEquals(longFloat1 + longFloat2, (longitude1 + longitude2).value) + } + + @Test + fun `adding two large positive longitude should result in the sum wrapped`() { + val longFloat1 = 170f + val longitude1 = Longitude(longFloat1) + val longFloat2 = 150f + val longitude2 = Longitude(longFloat2) + + val expectedResult = -40f + + assertEquals(expectedResult, (longitude1 + longitude2).value) + } + + @Test + fun `adding two negative longitude should result in the sum wrapped`() { + val longFloat1 = -80f + val longitude1 = Longitude(longFloat1) + val longFloat2 = -40f + val longitude2 = Longitude(longFloat2) + + assertEquals(longFloat1 + longFloat2, (longitude1 + longitude2).value) + } + + @Test + fun `subtracting two positive longitude should result in the sum`() { + val longFloat1 = 80f + val longitude1 = Longitude(longFloat1) + val longFloat2 = 30f + val longitude2 = Longitude(longFloat2) + + assertEquals(longFloat1 - longFloat2, (longitude1 - longitude2).value) + } + + @Test + fun `subtracting a large longitude should result in the sum wrapped`() { + val longFloat1 = -30f + val longitude1 = Longitude(longFloat1) + val longFloat2 = 170f + val longitude2 = Longitude(longFloat2) + + val expectedResult = 160f + + assertEquals(expectedResult, (longitude1 - longitude2).value) + } + + @Test + fun `subtracting a negative latitude should result in same as addition`() { + val longFloat1 = -80f + val longitude1 = Longitude(longFloat1) + val longFloat2 = -40f + val longitude2 = Longitude(longFloat2) + + assertEquals(longFloat1 + longFloat2.absoluteValue, (longitude1 - longitude2).value) + } + + @Test + fun `subtracting a large negative latitude should result in same as addition wrapped`() { + val longFloat1 = 80f + val longitude1 = Longitude(longFloat1) + val longFloat2 = -140f + val longitude2 = Longitude(longFloat2) + + val absoluteLongitude2 = Longitude.fromFloat(longFloat2.absoluteValue) + assertEquals(longitude1 + absoluteLongitude2, longitude1 - longitude2) + } + + @Test + fun `distanceTo with two positive longitudes`() { + val longFloat1 = 80f + val longitude1 = Longitude(longFloat1) + val longFloat2 = 30f + val longitude2 = Longitude(longFloat2) + + assertEquals(longFloat1 - longFloat2, longitude1.distanceTo(longitude2)) + } + + @Test + fun `distanceTo with two negative longitudes`() { + val longFloat1 = -80f + val longitude1 = Longitude(longFloat1) + val longFloat2 = -30f + val longitude2 = Longitude(longFloat2) + + val expectedValue = 50f + + assertEquals(expectedValue, longitude1.distanceTo(longitude2)) + } + + @Test + fun `distanceTo with wrapping value as shortest path`() { + val longFloat1 = -170f + val longitude1 = Longitude(longFloat1) + val longFloat2 = 170f + val longitude2 = Longitude(longFloat2) + + val expectedValue = 20f + + assertEquals(expectedValue, longitude1.distanceTo(longitude2)) + } +} |
