diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3932e220f567..fbbe55cf0b66 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -320,6 +320,7 @@ dependencies { implementation(project(Dependencies.Mullvad.talpidLib)) implementation(project(Dependencies.Mullvad.themeLib)) implementation(project(Dependencies.Mullvad.paymentLib)) + implementation(project(Dependencies.Mullvad.mapLib)) // Play implementation playImplementation(project(Dependencies.Mullvad.billingLib)) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b4043fdee507..0337d1200d06 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -17,6 +17,8 @@ android:required="false" /> + Unit, @@ -101,7 +102,7 @@ fun NotificationBanner( visible = notification != null, enter = slideInVertically(initialOffsetY = { -it }), exit = slideOutVertically(targetOffsetY = { -it }), - modifier = Modifier.animateContentSize() + modifier = modifier ) { val visibleNotification = notification ?: previous if (visibleNotification != null) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt index 84d2a0418eb6..a3ebaabf99cd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -2,10 +2,13 @@ package net.mullvad.mullvadvpn.compose.screen import android.content.Intent import android.net.Uri -import androidx.compose.foundation.background +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -18,6 +21,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -25,6 +29,10 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -55,15 +63,28 @@ import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SCROLLABLE_COLUMN_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.transitions.HomeTransition +import net.mullvad.mullvadvpn.constant.SECURE_ZOOM +import net.mullvad.mullvadvpn.constant.SECURE_ZOOM_ANIMATION_MILLIS +import net.mullvad.mullvadvpn.constant.UNSECURE_ZOOM +import net.mullvad.mullvadvpn.constant.fallbackLatLong import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser +import net.mullvad.mullvadvpn.lib.map.AnimatedMap +import net.mullvad.mullvadvpn.lib.map.data.GlobeColors +import net.mullvad.mullvadvpn.lib.map.data.LocationMarkerColors +import net.mullvad.mullvadvpn.lib.map.data.Marker import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar +import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible +import net.mullvad.mullvadvpn.model.GeoIpLocation +import net.mullvad.mullvadvpn.model.LatLong +import net.mullvad.mullvadvpn.model.Latitude +import net.mullvad.mullvadvpn.model.Longitude import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel -import net.mullvad.talpid.tunnel.ActionAfterDisconnect import org.koin.androidx.compose.koinViewModel private const val CONNECT_BUTTON_THROTTLE_MILLIS = 1000 @@ -164,66 +185,80 @@ fun ConnectScreen( } ScaffoldWithTopBarAndDeviceName( - topBarColor = - if (uiState.tunnelUiState.isSecured()) { - MaterialTheme.colorScheme.inversePrimary - } else { - MaterialTheme.colorScheme.error - }, - iconTintColor = - if (uiState.tunnelUiState.isSecured()) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onError - } - .copy(alpha = AlphaTopBar), + topBarColor = uiState.tunnelUiState.topBarColor(), + iconTintColor = uiState.tunnelUiState.iconTintColor(), onSettingsClicked = onSettingsClick, onAccountClicked = onAccountClick, deviceName = uiState.deviceName, timeLeft = uiState.daysLeftUntilExpiry ) { + var progressIndicatorBias by remember { mutableFloatStateOf(0f) } + + // Distance to marker when secure/unsecure + val baseZoom = + animateFloatAsState( + targetValue = + if (uiState.tunnelRealState is TunnelState.Connected) SECURE_ZOOM + else UNSECURE_ZOOM, + animationSpec = tween(SECURE_ZOOM_ANIMATION_MILLIS), + label = "baseZoom" + ) + + val markers = + uiState.tunnelRealState.toMarker(uiState.location)?.let { listOf(it) } ?: emptyList() + + AnimatedMap( + modifier = Modifier.padding(top = it.calculateTopPadding()), + cameraLocation = uiState.location?.toLatLong() ?: fallbackLatLong, + cameraBaseZoom = baseZoom.value, + cameraVerticalBias = progressIndicatorBias, + markers = markers, + globeColors = + GlobeColors( + landColor = MaterialTheme.colorScheme.primary, + oceanColor = MaterialTheme.colorScheme.secondary, + ) + ) + Column( verticalArrangement = Arrangement.Bottom, horizontalAlignment = Alignment.Start, modifier = - Modifier.background(color = MaterialTheme.colorScheme.primary) - .padding(it) + Modifier.animateContentSize() + .padding(top = it.calculateTopPadding()) .fillMaxHeight() .drawVerticalScrollbar( scrollState, color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaScrollbar) ) .verticalScroll(scrollState) - .padding(bottom = Dimens.screenVerticalMargin) .testTag(SCROLLABLE_COLUMN_TEST_TAG) ) { - NotificationBanner( - notification = uiState.inAppNotification, - isPlayBuild = uiState.isPlayBuild, - onClickUpdateVersion = onUpdateVersionClick, - onClickShowAccount = onManageAccountClick, - onClickDismissNewDevice = onDismissNewDeviceClick, + Spacer(modifier = Modifier.defaultMinSize(minHeight = Dimens.mediumPadding).weight(1f)) + MullvadCircularProgressIndicatorLarge( + color = MaterialTheme.colorScheme.onPrimary, + modifier = + Modifier.animateContentSize() + .padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + top = Dimens.mediumPadding + ) + .alpha(if (uiState.showLoading) AlphaVisible else AlphaInvisible) + .align(Alignment.CenterHorizontally) + .testTag(CIRCULAR_PROGRESS_INDICATOR) + .onGloballyPositioned { + val offsetY = it.positionInParent().y + it.size.height / 2 + it.parentLayoutCoordinates?.let { + val parentHeight = it.size.height + val verticalBias = offsetY / parentHeight + if (verticalBias.isFinite()) { + progressIndicatorBias = verticalBias + } + } + } ) - Spacer(modifier = Modifier.weight(1f)) - if ( - uiState.tunnelRealState is TunnelState.Connecting || - (uiState.tunnelRealState is TunnelState.Disconnecting && - uiState.tunnelRealState.actionAfterDisconnect == - ActionAfterDisconnect.Reconnect) - ) { - MullvadCircularProgressIndicatorLarge( - color = MaterialTheme.colorScheme.onPrimary, - modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - top = Dimens.mediumPadding - ) - .align(Alignment.CenterHorizontally) - .testTag(CIRCULAR_PROGRESS_INDICATOR) - ) - } - Spacer(modifier = Modifier.height(Dimens.mediumPadding)) + Spacer(modifier = Modifier.defaultMinSize(minHeight = Dimens.mediumPadding).weight(1f)) ConnectionStatusText( state = uiState.tunnelRealState, modifier = Modifier.padding(horizontal = Dimens.sideMargin) @@ -243,9 +278,7 @@ fun ConnectScreen( var expanded by rememberSaveable { mutableStateOf(false) } LocationInfo( onToggleTunnelInfo = { expanded = !expanded }, - isVisible = - uiState.tunnelRealState !is TunnelState.Disconnected && - uiState.location?.hostname != null, + isVisible = uiState.showLocationInfo, isExpanded = expanded, location = uiState.location, inAddress = uiState.inAddress, @@ -282,6 +315,55 @@ fun ConnectScreen( connectClick = { handleThrottledAction(onConnectClick) }, reconnectButtonTestTag = RECONNECT_BUTTON_TEST_TAG ) + // We need to manually add this padding so we align size with the map + // component and marker with the progress indicator. + Spacer(modifier = Modifier.height(it.calculateBottomPadding())) } + + NotificationBanner( + modifier = Modifier.padding(top = it.calculateTopPadding()), + notification = uiState.inAppNotification, + isPlayBuild = uiState.isPlayBuild, + onClickUpdateVersion = onUpdateVersionClick, + onClickShowAccount = onManageAccountClick, + onClickDismissNewDevice = onDismissNewDeviceClick, + ) + } +} + +@Composable +fun TunnelState.toMarker(location: GeoIpLocation?): Marker? { + if (location == null) return null + return when (this) { + is TunnelState.Connected -> + Marker( + location.toLatLong(), + colors = + LocationMarkerColors(centerColor = MaterialTheme.colorScheme.inversePrimary), + ) + is TunnelState.Connecting -> null + is TunnelState.Disconnected -> + Marker( + location.toLatLong(), + colors = LocationMarkerColors(centerColor = MaterialTheme.colorScheme.error) + ) + is TunnelState.Disconnecting -> null + is TunnelState.Error -> null } } + +@Composable +fun TunnelState.topBarColor(): Color = + if (isSecured()) MaterialTheme.colorScheme.inversePrimary else MaterialTheme.colorScheme.error + +@Composable +fun TunnelState.iconTintColor(): Color = + if (isSecured()) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onError + } + .copy(alpha = AlphaTopBar) + +fun GeoIpLocation.toLatLong() = + LatLong(Latitude(latitude.toFloat()), Longitude(longitude.toFloat())) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt index 988ae914e9c3..4a1c41e56266 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt @@ -17,8 +17,14 @@ data class ConnectUiState( val inAppNotification: InAppNotification?, val deviceName: String?, val daysLeftUntilExpiry: Int?, - val isPlayBuild: Boolean + val isPlayBuild: Boolean, ) { + + val showLocationInfo: Boolean = + tunnelRealState !is TunnelState.Disconnected && location?.hostname != null + val showLoading = + tunnelRealState is TunnelState.Connecting || tunnelRealState is TunnelState.Disconnecting + companion object { val INITIAL = ConnectUiState( @@ -32,7 +38,7 @@ data class ConnectUiState( inAppNotification = null, deviceName = null, daysLeftUntilExpiry = null, - isPlayBuild = false + isPlayBuild = false, ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt index 4ccf15bb63f3..d58107c713f2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt @@ -1,6 +1,9 @@ package net.mullvad.mullvadvpn.constant import androidx.compose.animation.core.Spring +import net.mullvad.mullvadvpn.model.LatLong +import net.mullvad.mullvadvpn.model.Latitude +import net.mullvad.mullvadvpn.model.Longitude const val MINIMUM_LOADING_TIME_MILLIS = 500L @@ -9,3 +12,10 @@ const val SCREEN_ANIMATION_TIME_MILLIS = Spring.StiffnessMediumLow.toInt() const val HORIZONTAL_SLIDE_FACTOR = 1 / 3f fun Int.withHorizontalScalingFactor(): Int = (this * HORIZONTAL_SLIDE_FACTOR).toInt() + +const val SECURE_ZOOM = 1.15f +const val UNSECURE_ZOOM = 1.20f +const val SECURE_ZOOM_ANIMATION_MILLIS = 2000 + +// Location of Gothenburg, Sweden +val fallbackLatLong = LatLong(Latitude(57.7065f), Longitude(11.967f)) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt index bee5b1ad0012..12427e228594 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt @@ -129,7 +129,7 @@ class ConnectViewModel( inAppNotification = notifications.firstOrNull(), deviceName = deviceName, daysLeftUntilExpiry = accountExpiry.date()?.daysFromNow(), - isPlayBuild = isPlayBuild + isPlayBuild = isPlayBuild, ) } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt index 57eeef5ca432..05dae1d53dd1 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt @@ -209,7 +209,9 @@ class ConnectViewModelTest { ipv6 = mockk(relaxed = true), country = "Sweden", city = "Gothenburg", - hostname = "Host" + hostname = "Host", + latitude = 57.7065, + longitude = 11.967 ) // Act, Assert diff --git a/android/buildSrc/src/main/kotlin/Dependencies.kt b/android/buildSrc/src/main/kotlin/Dependencies.kt index 055bf72d1252..af4f414b0c08 100644 --- a/android/buildSrc/src/main/kotlin/Dependencies.kt +++ b/android/buildSrc/src/main/kotlin/Dependencies.kt @@ -99,6 +99,7 @@ object Dependencies { const val commonTestLib = ":lib:common-test" const val billingLib = ":lib:billing" const val paymentLib = ":lib:payment" + const val mapLib = ":lib:map" } object Plugin { diff --git a/android/lib/map/build.gradle.kts b/android/lib/map/build.gradle.kts new file mode 100644 index 000000000000..75d11ae189aa --- /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 000000000000..8072ee00dbf1 --- /dev/null +++ b/android/lib/map/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + 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..00fcf97ab755 --- /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 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/data/CameraPosition.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt new file mode 100644 index 000000000000..d837bcadfc66 --- /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 000000000000..251c466a935a --- /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 000000000000..7d4edb09cb8a --- /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 000000000000..1e1a21111569 --- /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, + 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 000000000000..9f464612f1bf --- /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 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/GLHelper.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/GLHelper.kt new file mode 100644 index 000000000000..e416988d8d37 --- /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 000000000000..bf44e5ee140f --- /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 = + 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() { + // 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 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() + } +} 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 000000000000..379ac407cc27 --- /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 000000000000..9d03a540c5d5 --- /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 + + 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 { + 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 = + 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 000000000000..9f90f80c9342 --- /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 040c2fca0c0c..7264c6041a79 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 e15ab20376e9..625de76b291f 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 04f92a72ac8a..386257a72aa3 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 000000000000..ae047130e8ba --- /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 000000000000..14c5b66983eb --- /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 000000000000..9f73a6ff17bb --- /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 000000000000..6644e25e82f5 --- /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 000000000000..8788c2123aa9 --- /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 { Latitude(90.1f) } + } + + @Test + fun `create with too low latitude should give IllegalArgumentException`() { + assertThrows { 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 000000000000..de94661ad0b8 --- /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 { Longitude(180.1f) } + } + + @Test + fun `create too low longitude should give IllegalArgumentException`() { + assertThrows { 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)) + } +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index b598a49029ce..30f77c215935 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -13,7 +13,8 @@ include( ":lib:theme", ":lib:common-test", ":lib:billing", - ":lib:payment" + ":lib:payment", + ":lib:map" ) include( ":test", diff --git a/mullvad-types/src/location.rs b/mullvad-types/src/location.rs index 07e2b1e90ce3..ca2626c7d2db 100644 --- a/mullvad-types/src/location.rs +++ b/mullvad-types/src/location.rs @@ -143,9 +143,7 @@ pub struct GeoIpLocation { pub ipv6: Option, pub country: String, pub city: Option, - #[cfg_attr(target_os = "android", jnix(skip))] pub latitude: f64, - #[cfg_attr(target_os = "android", jnix(skip))] pub longitude: f64, #[cfg_attr(target_os = "android", jnix(skip))] pub mullvad_exit_ip: bool,