diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 4015a3c5fff..6367b8ba307 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1096,6 +1096,51 @@ mesh_beacon_target_channel_index mesh_beacon_target_default mesh_beacon_target_remove mesh_beacon_targets +mesh_hud_camera_rationale +mesh_hud_camera_unavailable +mesh_hud_cardinal_east +mesh_hud_cardinal_north +mesh_hud_cardinal_south +mesh_hud_cardinal_west +mesh_hud_chip_description +mesh_hud_chirpy_says +mesh_hud_coach_dismiss +mesh_hud_coach_find +mesh_hud_coach_point +mesh_hud_coach_tap +mesh_hud_distance_no_altitude +mesh_hud_elevation_approximate +mesh_hud_find +mesh_hud_finding +mesh_hud_heading +mesh_hud_heading_accuracy +mesh_hud_heading_low_accuracy +mesh_hud_inside_geofence +mesh_hud_inside_unnamed_geofence +mesh_hud_joined +mesh_hud_location_rationale +mesh_hud_more +mesh_hud_nearby +mesh_hud_no_nodes +mesh_hud_on_target +mesh_hud_open +mesh_hud_open_node +mesh_hud_open_waypoint +mesh_hud_own_accuracy +mesh_hud_radar_description +mesh_hud_rise_above +mesh_hud_rise_above_spoken +mesh_hud_rise_below +mesh_hud_rise_below_spoken +mesh_hud_snr +mesh_hud_thermal_critical +mesh_hud_thermal_hot +mesh_hud_tilt_down +mesh_hud_tilt_up +mesh_hud_turn_left +mesh_hud_turn_right +mesh_hud_waiting_sensor +mesh_hud_watch_step mesh_map_location mesh_map_location_description ### MESHTASTIC ### diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt index 99661f0bf8a..8398de840c4 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt @@ -21,6 +21,7 @@ import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.di.GOOGLE_SERVICES_AVAILABLE import org.meshtastic.core.ui.theme.EventFontResolver +import org.meshtastic.feature.map.hud.FusedOrientationSource @Module(includes = [FdroidAiModule::class]) class FlavorModule { @@ -32,6 +33,9 @@ class FlavorModule { /** No Play Services font provider on F-Droid — event fonts stay off; UI falls back to the app typeface. */ @Single fun eventFontResolver(): EventFontResolver = EventFontResolver { null } + /** F-Droid has no Play services orientation fusion, so the camera mesh view uses the platform rotation vector. */ + @Single fun fusedOrientationSource(): FusedOrientationSource = FusedOrientationSource.NONE + /** No analytics on F-Droid — the shared Ktor `HttpClient` gets no Datadog interceptor or event listener. */ @Single fun okHttpNetworkInstrumentation(): OkHttpNetworkInstrumentation = OkHttpNetworkInstrumentation.NONE } diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt index e515c1d1b58..43169c540be 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt @@ -18,19 +18,27 @@ package org.meshtastic.app.di +import android.content.Context import com.datadog.android.okhttp.DatadogEventListener import com.datadog.android.okhttp.DatadogInterceptor import org.koin.core.annotation.Module import org.koin.core.annotation.Single +import org.meshtastic.app.map.hud.GoogleFusedOrientationSource import org.meshtastic.app.map.prefs.di.GoogleMapsKoinModule import org.meshtastic.app.theme.GoogleFontsEventFontResolver import org.meshtastic.core.ui.theme.EventFontResolver +import org.meshtastic.feature.map.hud.FusedOrientationSource @Module(includes = [GoogleMapsKoinModule::class, GoogleAiModule::class, AppFunctionsModule::class]) class FlavorModule { /** Downloadable Google Fonts for event branding — Google flavor only. */ @Single fun eventFontResolver(): EventFontResolver = GoogleFontsEventFontResolver() + /** + * Play services' fused orientation for the camera mesh view, which falls back to the rotation vector without it. + */ + @Single fun fusedOrientationSource(context: Context): FusedOrientationSource = GoogleFusedOrientationSource(context) + /** * Datadog network instrumentation for the shared Ktor `HttpClient`. The interceptor emits RUM Resource spans with * full DNS/connect/SSL/download timing for first-party (`meshtastic.org`) requests, and the event listener supplies diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/hud/GoogleFusedOrientationSource.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/hud/GoogleFusedOrientationSource.kt new file mode 100644 index 00000000000..244f8cebab1 --- /dev/null +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/hud/GoogleFusedOrientationSource.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.app.map.hud + +import android.content.Context +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailabilityLight +import com.google.android.gms.location.DeviceOrientationListener +import com.google.android.gms.location.DeviceOrientationRequest +import com.google.android.gms.location.LocationServices +import org.meshtastic.feature.map.hud.FusedOrientationSource +import java.util.concurrent.Executor + +/** The platform's SENSOR_DELAY_UI period, which the rotation-vector path drops to when the phone runs hot. */ +private const val REDUCED_PERIOD_MICROS = 66_667L + +/** Play services' Fused Orientation Provider, the same fusion Google Maps draws its heading from. */ +class GoogleFusedOrientationSource(private val context: Context) : FusedOrientationSource { + + override fun start( + reduced: Boolean, + executor: Executor, + listener: FusedOrientationSource.Listener, + onFailure: () -> Unit, + ): AutoCloseable? { + val availability = GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(context) + if (availability != ConnectionResult.SUCCESS) return null + val client = LocationServices.getFusedOrientationProviderClient(context) + val callback = DeviceOrientationListener { orientation -> + val headingError = + if (orientation.hasConservativeHeadingErrorDegrees()) { + orientation.conservativeHeadingErrorDegrees + } else { + orientation.headingErrorDegrees + } + // FOP has no per-sample flag: it applies the declination whenever Play services knows the location, + // which it does whenever the view has the fix it needs to place anything. + listener.onSample( + attitude = orientation.attitude, + headingErrorDeg = headingError, + trueNorth = true, + timestampNs = orientation.elapsedRealtimeNs, + ) + } + val period = if (reduced) REDUCED_PERIOD_MICROS else DeviceOrientationRequest.OUTPUT_PERIOD_DEFAULT + client + .requestOrientationUpdates(DeviceOrientationRequest.Builder(period).build(), executor, callback) + .addOnFailureListener(executor) { onFailure() } + return AutoCloseable { client.removeOrientationUpdates(callback) } + } +} diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 827e1125935..e2e1fdbb9dc 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -103,6 +103,7 @@ import org.meshtastic.core.ui.util.LocalTracerouteMapProvider import org.meshtastic.core.ui.util.accentColorOrNull import org.meshtastic.core.ui.util.brandHighlightOrNull import org.meshtastic.core.ui.util.brandPalette +import org.meshtastic.core.ui.util.rememberMeshHudSupported import org.meshtastic.core.ui.util.showToast import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.feature.intro.AppIntroductionScreen @@ -296,7 +297,7 @@ class MainActivity : AppCompatActivity() { org.meshtastic.app.map.discovery.DiscoveryMap(userLat, userLon, nodes, modifier) }, LocalMapMainScreenProvider provides - { onClickNodeChip, navigateToNodeDetails, waypointId, sitePlannerNodeNum -> + { onClickNodeChip, navigateToNodeDetails, waypointId, sitePlannerNodeNum, onOpenMeshHud -> val viewModel = koinViewModel() MapScreen( viewModel = viewModel, @@ -304,6 +305,7 @@ class MainActivity : AppCompatActivity() { navigateToNodeDetails = navigateToNodeDetails, waypointId = waypointId, sitePlannerNodeNum = sitePlannerNodeNum, + onOpenMeshHud = onOpenMeshHud.takeIf { rememberMeshHudSupported() }, ) }, content = content, diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt index 5f402a445ba..fbcadccf2b9 100644 --- a/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt +++ b/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt @@ -81,7 +81,7 @@ class NavigationAssemblyTest { val backStack = rememberNavBackStack(route) CompositionLocalProvider( LocalMapMainScreenProvider provides - { _, _, waypointId, sitePlannerNodeNum -> + { _, _, waypointId, sitePlannerNodeNum, _ -> SideEffect { receivedWaypointId = waypointId receivedSitePlannerNodeNum = sitePlannerNodeNum @@ -113,7 +113,7 @@ class NavigationAssemblyTest { backStack = rememberNavBackStack(route) CompositionLocalProvider( LocalMapMainScreenProvider provides - { _, _, _, sitePlannerNodeNum -> + { _, _, _, sitePlannerNodeNum, _ -> LaunchedEffect(sitePlannerNodeNum) { effectStarts += 1 } DisposableEffect(Unit) { onDispose { disposals += 1 } } }, diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass/AndroidMagneticFieldProvider.kt b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidMagneticFieldProvider.kt similarity index 91% rename from feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass/AndroidMagneticFieldProvider.kt rename to core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidMagneticFieldProvider.kt index e6657082e3b..ad0d473ebff 100644 --- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass/AndroidMagneticFieldProvider.kt +++ b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidMagneticFieldProvider.kt @@ -14,10 +14,11 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.node.compass +package org.meshtastic.core.data.location import android.hardware.GeomagneticField import org.koin.core.annotation.Single +import org.meshtastic.core.repository.MagneticFieldProvider @Single class AndroidMagneticFieldProvider : MagneticFieldProvider { diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass/AndroidPhoneLocationProvider.kt b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt similarity index 76% rename from feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass/AndroidPhoneLocationProvider.kt rename to core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt index f869249960c..3d2307080aa 100644 --- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass/AndroidPhoneLocationProvider.kt +++ b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt @@ -14,23 +14,30 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.node.compass +package org.meshtastic.core.data.location import android.annotation.SuppressLint import android.content.Context import android.location.Location import android.location.LocationManager import android.os.Looper +import androidx.core.location.LocationCompat import androidx.core.location.LocationListenerCompat import androidx.core.location.LocationManagerCompat import androidx.core.location.LocationRequestCompat +import androidx.core.location.altitude.AltitudeConverterCompat +import co.touchlab.kermit.Logger import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single import org.meshtastic.core.common.hasLocationPermission import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.repository.PhoneLocation +import org.meshtastic.core.repository.PhoneLocationProvider +import org.meshtastic.core.repository.PhoneLocationState @Single class AndroidPhoneLocationProvider(private val context: Context, private val dispatchers: CoroutineDispatchers) : @@ -121,10 +128,46 @@ class AndroidPhoneLocationProvider(private val context: Context, private val dis awaitClose { LocationManagerCompat.removeUpdates(locationManager, listener) } } + .map { it.withMslAltitude() } .flowOn(dispatchers.io) - private fun Location.toPhoneLocation() = - PhoneLocation(latitude = latitude, longitude = longitude, altitude = altitude, timeMillis = time) + /** + * Fills the sea-level height a provider left out, from the geoid model, on every API level through the compat + * converter (as `LocationRepositoryImpl` does). It loads model data on first use and may block, which is why it + * runs here, upstream of the IO dispatcher, rather than in the callback. + */ + @Suppress("ReturnCount") + private fun PhoneLocationState.withMslAltitude(): PhoneLocationState { + val fix = location ?: return this + val ellipsoid = fix.altitude + if (fix.mslAltitude != null || ellipsoid == null) return this + val probe = + Location("mesh-view").apply { + latitude = fix.latitude + longitude = fix.longitude + altitude = ellipsoid + } + @Suppress("TooGenericExceptionCaught") + return try { + AltitudeConverterCompat.addMslAltitudeToLocation(context, probe) + val msl = if (LocationCompat.hasMslAltitude(probe)) LocationCompat.getMslAltitudeMeters(probe) else null + copy(location = fix.copy(mslAltitude = msl)) + } catch (e: Exception) { + Logger.w(e) { "MSL altitude conversion failed" } + this + } + } + + private fun Location.toPhoneLocation() = PhoneLocation( + latitude = latitude, + longitude = longitude, + altitude = if (hasAltitude()) altitude else null, + timeMillis = time, + mslAltitude = if (LocationCompat.hasMslAltitude(this)) LocationCompat.getMslAltitudeMeters(this) else null, + accuracyM = if (hasAccuracy()) accuracy else null, + verticalAccuracyM = if (hasVerticalAccuracy()) verticalAccuracyMeters else null, + speedMps = if (hasSpeed()) speed else null, + ) companion object { private const val MIN_UPDATE_INTERVAL_MS = 1_000L diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeMapFilter.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeMapFilter.kt new file mode 100644 index 00000000000..448ffc72bda --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeMapFilter.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import org.meshtastic.core.model.util.ONLINE_WINDOW_SECONDS +import org.meshtastic.proto.Config + +/** + * The node-level map filters, shared by every view that draws nodes over a place: both map renderers and the camera + * mesh view. [lastHeardSeconds] of 0 means no limit. + */ +data class NodeMapFilter( + val onlyFavorites: Boolean = false, + val showIgnored: Boolean = false, + /** Roles the user has switched off. Excluded rather than included so a role added by future firmware shows. */ + val excludedRoles: Set = emptySet(), + val includeUnknown: Boolean = true, + val onlyOnline: Boolean = false, + val onlyDirect: Boolean = false, + val excludeMqtt: Boolean = false, + val lastHeardSeconds: Long = 0, + val onlySigned: Boolean = false, + val onlyEncrypted: Boolean = false, +) + +/** + * One node against every filter. + * + * The node-level rules match the node list's query exactly, because they carry its labels: direct means `hopsAway == 0` + * (a node whose distance was never measured, -1, is not direct) and unknown means a node that has not sent a short + * name. + * + * [NodeMapFilter.showIgnored] is the one place the map deliberately differs. The node list segregates (`isIgnored == + * showIgnored`, so switching it on shows *only* ignored nodes); here it reads literally and adds them, because a map of + * nothing but ignored nodes is not a view anyone asked for. + */ +fun Node.passesMapFilter(filter: NodeMapFilter, nowSeconds: Long): Boolean { + val secondsSinceHeard = nowSeconds - lastHeard + return passesIdentityFilters(filter) && + passesSecurityFilters(filter) && + passesReachabilityFilters(filter, secondsSinceHeard) +} + +/** + * Resolves persisted role names back to roles, dropping any the current protobufs no longer define. + * + * Names rather than ordinals, and forgiving of names it does not know: a set written by a newer build (or a role + * removed from the protos) must not throw on the way back in. + */ +fun decodeExcludedRoles(names: Set): Set = + names.mapNotNullTo(mutableSetOf()) { name -> Config.DeviceConfig.Role.entries.firstOrNull { it.name == name } } + +/** Who the node is: favourited, ignored, its role, and whether its name has arrived yet. */ +private fun Node.passesIdentityFilters(filter: NodeMapFilter): Boolean = (!filter.onlyFavorites || isFavorite) && + (!isIgnored || filter.showIgnored) && + user.role !in filter.excludedRoles && + (filter.includeUnknown || user.short_name.isNotEmpty()) + +/** How the node is reachable: online, hop count, bearer, and how recently it was heard. */ +private fun Node.passesReachabilityFilters(filter: NodeMapFilter, secondsSinceHeard: Long): Boolean = + (!filter.onlyOnline || secondsSinceHeard <= ONLINE_WINDOW_SECONDS) && + (!filter.onlyDirect || (hopsAway == 0 && !viaMqtt)) && + (!filter.excludeMqtt || !viaMqtt) && + (filter.lastHeardSeconds == 0L || secondsSinceHeard <= filter.lastHeardSeconds) + +/** + * The two security filters (design#149). + * + * Signing reads the raw flag rather than the version-gated indicator: the filter answers "has the radio verified this + * node's signature", which is what the badge beside it claims. Encrypted excludes a stored key that stopped matching, + * since a mismatch is not a key you can safely encrypt to. + */ +private fun Node.passesSecurityFilters(filter: NodeMapFilter): Boolean = + (!filter.onlySigned || signsPackets) && (!filter.onlyEncrypted || (hasPKC && !mismatchKey)) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/PositionPrecision.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/PositionPrecision.kt index ea2307e886a..e9d432c2bd3 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/PositionPrecision.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/PositionPrecision.kt @@ -16,6 +16,10 @@ */ package org.meshtastic.core.model.util +import org.meshtastic.proto.Position +import kotlin.math.pow +import kotlin.math.sqrt + /** * The bit depths firmware degrades a shared position to. * @@ -53,3 +57,29 @@ private val PRECISION_RADIUS_METERS = 18 to 91.182212, 19 to 45.58554, ) + +private const val DOP_SCALE = 100.0 +private const val MILLIMETERS_PER_METER = 1000.0 + +/** + * Radius, in meters, within which a reported position lies: the receiver's accuracy scaled by its dilution of precision + * when both are reported, else the precision-bits circle, else null. + */ +fun Position.uncertaintyRadiusMetersOrNull(): Double? { + val dop = + when { + PDOP > 0 -> PDOP / DOP_SCALE + HDOP > 0 && VDOP > 0 -> sqrt((HDOP / DOP_SCALE).pow(2) + (VDOP / DOP_SCALE).pow(2)) + HDOP > 0 -> HDOP / DOP_SCALE + else -> null + } + if (gps_accuracy > 0 && dop != null) return gps_accuracy / MILLIMETERS_PER_METER * dop + return precisionRadiusMetersOrNull(precision_bits) +} + +/** Radius, in meters, of a reported altitude's uncertainty: the receiver's accuracy scaled by its vertical DOP. */ +fun Position.verticalUncertaintyMetersOrNull(): Double? = + if (gps_accuracy > 0 && VDOP > 0) gps_accuracy / MILLIMETERS_PER_METER * (VDOP / DOP_SCALE) else null + +/** When the fix was taken, in epoch seconds: the GPS timestamp when set, else the time the position was sent. */ +fun Position.fixTimeSecondsOrNull(): Long? = timestamp.takeIf { it > 0 }?.toLong() ?: time.takeIf { it > 0 }?.toLong() diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt index 0af0502add2..2a53af43943 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -55,13 +55,28 @@ sealed interface ContactsRoute : Route { @Serializable sealed interface MapRoute : Route { @Serializable data class Map(val waypointId: Int? = null, val sitePlannerNodeNum: Int? = null) : MapRoute + + /** + * The camera mesh view. [focusNodeNum] points the finder at one node. A traceroute ([tracerouteDestNum] with + * [tracerouteRequestId], and [tracerouteLogUuid] when it was logged) draws that route in the air, and + * [trackNodeNum] draws that node's position track. + */ + @Serializable + data class MeshHud( + val focusNodeNum: Int? = null, + val tracerouteDestNum: Int? = null, + val tracerouteRequestId: Int? = null, + val tracerouteLogUuid: String? = null, + val trackNodeNum: Int? = null, + ) : MapRoute } @Serializable sealed interface NodesRoute : Route { @Serializable data object Nodes : NodesRoute, Graph - @Serializable data class NodeDetail(val destNum: Int? = null) : NodesRoute + /** [openCompass] opens the compass sheet on arrival, for entry points that offer the compass directly. */ + @Serializable data class NodeDetail(val destNum: Int? = null, val openCompass: Boolean = false) : NodesRoute } @Serializable diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt index 5772e4a4ee5..fa6da61d9e1 100644 --- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt +++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt @@ -50,6 +50,7 @@ class NavigationConfigTest { ContactsRoute.QuickChat, // MapRoute MapRoute.Map(), + MapRoute.MeshHud(focusNodeNum = 100, tracerouteDestNum = 100, tracerouteRequestId = 7, trackNodeNum = 100), MapRoute.Map(waypointId = 42), // NodesRoute NodesRoute.Nodes, diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt index 8973b4b45f0..d01e593c1d1 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt @@ -159,7 +159,16 @@ class MapPrefsImpl(private val dataStore: MapDataStore, dispatchers: CoroutineDi } .first() + // Starts as seen, so a returning user does not glimpse the coaching before the stored value loads. + override val meshHudCoachSeen: StateFlow = + dataStore.data.map { it[KEY_MESH_HUD_COACH_SEEN_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, true) + + override fun setMeshHudCoachSeen(seen: Boolean) { + scope.launch { dataStore.edit { it[KEY_MESH_HUD_COACH_SEEN_PREF] = seen } } + } + companion object { + val KEY_MESH_HUD_COACH_SEEN_PREF = booleanPreferencesKey("mesh_hud_coach_seen") val KEY_MAP_STYLE_PREF = intPreferencesKey("map_style_id") val KEY_SHOW_ONLY_FAVORITES_PREF = booleanPreferencesKey("show_only_favorites") val KEY_SHOW_WAYPOINTS_PREF = booleanPreferencesKey("show_waypoints") diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index 45a3866a68f..2f71548f6f8 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -340,6 +340,11 @@ interface MapPrefs { /** Load the complete persisted camera, or null before the map has ever been positioned. */ suspend fun awaitCameraPosition(): MapCameraPosition? + + /** True once the user has dismissed the mesh view's first-run coaching. */ + val meshHudCoachSeen: StateFlow + + fun setMeshHudCoachSeen(seen: Boolean) } data class MapCameraPosition(val latitude: Double, val longitude: Double, val zoom: Double) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/MagneticFieldProvider.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MagneticFieldProvider.kt similarity index 94% rename from feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/MagneticFieldProvider.kt rename to core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MagneticFieldProvider.kt index fce026b4237..aabe17dfc0c 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/MagneticFieldProvider.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MagneticFieldProvider.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.node.compass +package org.meshtastic.core.repository interface MagneticFieldProvider { fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeFilterPrefs.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeFilterPrefs.kt index 026ba7aa623..a1ff64e52bd 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeFilterPrefs.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeFilterPrefs.kt @@ -16,6 +16,9 @@ */ package org.meshtastic.core.repository +import org.meshtastic.core.model.NodeMapFilter +import org.meshtastic.core.model.decodeExcludedRoles + /** * The stored node-list filters, as one value. * @@ -68,3 +71,17 @@ data class MapFilterPrefs( */ val excludedRoles: Set = emptySet(), ) + +/** The node-level part of these filters, for views that draw nodes without the map's filter sheet. */ +fun MapFilterPrefs.toNodeMapFilter() = NodeMapFilter( + onlyFavorites = onlyFavorites, + showIgnored = showIgnored, + excludedRoles = decodeExcludedRoles(excludedRoles), + includeUnknown = includeUnknown, + onlyOnline = onlyOnline, + onlyDirect = onlyDirect, + excludeMqtt = excludeMqtt, + lastHeardSeconds = lastHeardSeconds, + onlySigned = onlySigned, + onlyEncrypted = onlyEncrypted, +) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/PhoneLocationProvider.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PhoneLocationProvider.kt similarity index 59% rename from feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/PhoneLocationProvider.kt rename to core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PhoneLocationProvider.kt index 76eee099695..e587417a9f0 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/PhoneLocationProvider.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PhoneLocationProvider.kt @@ -14,11 +14,26 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.node.compass +package org.meshtastic.core.repository import kotlinx.coroutines.flow.Flow -data class PhoneLocation(val latitude: Double, val longitude: Double, val altitude: Double, val timeMillis: Long) +/** + * [altitude] is height above the WGS84 ellipsoid, as Android reports it, and null when the fix has none (a network + * fix). [mslAltitude] is height above mean sea level, null when the platform cannot convert. [accuracyM] and + * [verticalAccuracyM] are the fix's accuracy radii, null when the platform reports none. [speedMps] is ground speed, + * null when unknown. + */ +data class PhoneLocation( + val latitude: Double, + val longitude: Double, + val altitude: Double?, + val timeMillis: Long, + val mslAltitude: Double? = null, + val accuracyM: Float? = null, + val verticalAccuracyM: Float? = null, + val speedMps: Float? = null, +) data class PhoneLocationState( val permissionGranted: Boolean, diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_center_focus_strong.xml b/core/resources/src/commonMain/composeResources/drawable/ic_center_focus_strong.xml new file mode 100644 index 00000000000..b16af13b13b --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_center_focus_strong.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index f13aa213829..b90dc9caa46 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1138,6 +1138,51 @@ Default Remove target Broadcast targets + Allow camera access to see nodes over the camera view. + Camera unavailable. The view still works as a compass. + E + N + S + W + %1$s, %2$s, at %3$d o'clock + Chirpy says, "This'll use your battery and make your phone hot!" + Got it + Long-press a node to find it. + Hold the phone up and point it toward the horizon. + Tap a node to open it. + %1$s · no altitude + Elevation is approximate + Find + Finding %1$s · %2$s + Heading %1$s + Heading %1$s ± %2$s + Low heading accuracy. Move the phone in a figure eight to calibrate. + Inside %1$s + Inside a geofence + %1$s · %2$s + The mesh view uses your location to place nodes around you. + +%1$d + Nearby + No nodes with a position + On target + Open mesh view + Open node + Open waypoint + You ± %1$s + %1$s, %2$s + ▲ %1$s + %1$s above you + ▼ %1$s + %1$s below you + %1$s dB + Phone is too hot. Camera paused. + Phone is hot. Updating less often. + Tilt down %1$d° + Tilt up %1$d° + Turn left %1$d° + Turn right %1$d° + Waiting for the orientation sensor + Watch where you walk Mesh Map Location Enables the blue location dot for your phone in the mesh map. diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt index a0de1823817..0398ed4c1cc 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt @@ -301,6 +301,12 @@ class FakeMapPrefs : MapPrefs { } override suspend fun awaitCameraPosition(): MapCameraPosition? = cameraPosition + + override val meshHudCoachSeen = MutableStateFlow(false) + + override fun setMeshHudCoachSeen(seen: Boolean) { + meshHudCoachSeen.value = seen + } } class FakeMapConsentPrefs : MapConsentPrefs { diff --git a/core/ui/detekt-baseline.xml b/core/ui/detekt-baseline.xml index 8ce27952e14..91ee21b266d 100644 --- a/core/ui/detekt-baseline.xml +++ b/core/ui/detekt-baseline.xml @@ -18,7 +18,7 @@ CompositionLocalAllowlist:LocalBarcodeScannerProvider.kt:val LocalBarcodeScannerProvider = compositionLocalOf<@Composable (onResult: (String?) -> Unit) -> BarcodeScanner> { { object : BarcodeScanner { override fun startScan() { // Default NO-OP } } } } CompositionLocalAllowlist:LocalBarcodeScannerProvider.kt:val LocalBarcodeScannerSupported = compositionLocalOf { false } CompositionLocalAllowlist:LocalInlineMapProvider.kt:val LocalInlineMapProvider = compositionLocalOf<@Composable (node: Node, modifier: Modifier) -> Unit> { { _, _ -> } } - CompositionLocalAllowlist:LocalMapMainScreenProvider.kt:/** * Provides the platform-specific Map Main Screen. On Desktop or JVM targets where native maps aren't available yet, it * falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalMapMainScreenProvider = compositionLocalOf< @Composable ( onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?, sitePlannerNodeNum: Int?, ) -> Unit, > { { _, _, _, _ -> PlaceholderScreen("Map") } } + CompositionLocalAllowlist:LocalMapMainScreenProvider.kt:/** * Provides the platform-specific Map Main Screen. On Desktop or JVM targets where native maps aren't available yet, it * falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalMapMainScreenProvider = compositionLocalOf< @Composable ( onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?, sitePlannerNodeNum: Int?, onOpenMeshHud: () -> Unit, ) -> Unit, > { { _, _, _, _, _ -> PlaceholderScreen("Map") } } CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerProvider = compositionLocalOf<@Composable (onResult: (String?) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _ -> } } CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerSupported = compositionLocalOf { false } CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcWriterProvider = compositionLocalOf<@Composable (url: String, onResult: (Boolean) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _, _ -> } } diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.android.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.android.kt new file mode 100644 index 00000000000..bed619c1ad9 --- /dev/null +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.android.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.util + +import android.content.Context +import android.content.pm.PackageManager +import android.hardware.Sensor +import android.hardware.SensorManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext + +@Composable +actual fun rememberMeshHudSupported(): Boolean { + val context = LocalContext.current + return remember(context) { + val sensors = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager + val hasOrientation = + sensors?.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) != null || + sensors?.getDefaultSensor(Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR) != null + // The view binds the back camera, which FEATURE_CAMERA names; FEATURE_CAMERA_ANY is met by a front one alone. + context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA) && hasOrientation + } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt index fb9634e63d9..3761776142c 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt @@ -25,6 +25,7 @@ import org.meshtastic.core.resources.ic_bluetooth_connected import org.meshtastic.core.resources.ic_bluetooth_searching import org.meshtastic.core.resources.ic_build import org.meshtastic.core.resources.ic_cached +import org.meshtastic.core.resources.ic_center_focus_strong import org.meshtastic.core.resources.ic_display_settings import org.meshtastic.core.resources.ic_memory import org.meshtastic.core.resources.ic_mesh_radio @@ -39,10 +40,16 @@ import org.meshtastic.core.resources.ic_wifi /** The maker hardware mark from meshtastic/design#160: a wrench, Material Symbols "build". */ val MeshtasticIcons.Wrench: ImageVector @Composable get() = vectorResource(Res.drawable.ic_build) + +/** Material Symbols "center_focus_strong", a viewfinder: the camera mesh view. */ +val MeshtasticIcons.CenterFocusStrong: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_center_focus_strong) + val MeshtasticIcons.BluetoothConnected: ImageVector @Composable get() = vectorResource(Res.drawable.ic_bluetooth_connected) val MeshtasticIcons.BluetoothSearching: ImageVector @Composable get() = vectorResource(Res.drawable.ic_bluetooth_searching) + val MeshtasticIcons.UsbOff: ImageVector @Composable get() = vectorResource(Res.drawable.ic_usb_off) val MeshtasticIcons.Antenna: ImageVector diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt index 70d02f4426f..4fe92da3a33 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt @@ -32,7 +32,8 @@ val LocalMapMainScreenProvider = navigateToNodeDetails: (Int) -> Unit, waypointId: Int?, sitePlannerNodeNum: Int?, + onOpenMeshHud: () -> Unit, ) -> Unit, > { - { _, _, _, _ -> PlaceholderScreen("Map") } + { _, _, _, _, _ -> PlaceholderScreen("Map") } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.kt new file mode 100644 index 00000000000..2157c00639d --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.util + +import androidx.compose.runtime.Composable + +/** Whether this device can show the camera mesh view: it needs a back camera and an orientation sensor. */ +@Composable expect fun rememberMeshHudSupported(): Boolean diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.ios.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.ios.kt new file mode 100644 index 00000000000..2fff4f68cb5 --- /dev/null +++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.ios.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.util + +import androidx.compose.runtime.Composable + +@Composable actual fun rememberMeshHudSupported(): Boolean = false diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.jvm.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.jvm.kt new file mode 100644 index 00000000000..2fff4f68cb5 --- /dev/null +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.jvm.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.util + +import androidx.compose.runtime.Composable + +@Composable actual fun rememberMeshHudSupported(): Boolean = false diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt index 83b4a5f01bb..21d5d2020ff 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -477,7 +477,7 @@ private fun ApplicationScope.MeshtasticWindow( // mapGraph renders the Map tab through this seam, not through LocalMapViewProvider // directly; without it the tab falls back to the "Map" placeholder. LocalMapMainScreenProvider provides - { onClickNodeChip, navigateToNodeDetails, waypointId, sitePlannerNodeNum -> + { onClickNodeChip, navigateToNodeDetails, waypointId, sitePlannerNodeNum, _ -> MapScreen( viewModel = koinViewModel(), onClickNodeChip = onClickNodeChip, diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt index 6882a7da060..772b2aa9717 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt @@ -20,10 +20,13 @@ import org.koin.core.annotation.Module import org.koin.core.annotation.Single import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.LocationRepository +import org.meshtastic.core.repository.MagneticFieldProvider import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.MeshWorkerManager +import org.meshtastic.core.repository.PhoneLocationProvider import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.desktop.stub.NoopAppWidgetUpdater +import org.meshtastic.desktop.stub.NoopCameraPoseProvider import org.meshtastic.desktop.stub.NoopCompassHeadingProvider import org.meshtastic.desktop.stub.NoopLocationRepository import org.meshtastic.desktop.stub.NoopMagneticFieldProvider @@ -31,9 +34,8 @@ import org.meshtastic.desktop.stub.NoopMeshLocationManager import org.meshtastic.desktop.stub.NoopMeshWorkerManager import org.meshtastic.desktop.stub.NoopPhoneLocationProvider import org.meshtastic.desktop.stub.NoopPlatformAnalytics +import org.meshtastic.feature.map.hud.CameraPoseProvider import org.meshtastic.feature.node.compass.CompassHeadingProvider -import org.meshtastic.feature.node.compass.MagneticFieldProvider -import org.meshtastic.feature.node.compass.PhoneLocationProvider /** * Stubs for interfaces whose only real implementation needs Android APIs — WorkManager, widgets, location, sensors and @@ -57,4 +59,6 @@ class DesktopStubsModule { @Single fun phoneLocationProvider(): PhoneLocationProvider = NoopPhoneLocationProvider() @Single fun magneticFieldProvider(): MagneticFieldProvider = NoopMagneticFieldProvider() + + @Single fun cameraPoseProvider(): CameraPoseProvider = NoopCameraPoseProvider() } diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt index b0761522de4..546af73b0db 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt @@ -18,11 +18,13 @@ package org.meshtastic.desktop.stub import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf +import org.meshtastic.core.repository.MagneticFieldProvider +import org.meshtastic.core.repository.PhoneLocationProvider +import org.meshtastic.core.repository.PhoneLocationState +import org.meshtastic.feature.map.hud.CameraPoseProvider +import org.meshtastic.feature.map.hud.CameraPoseState import org.meshtastic.feature.node.compass.CompassHeadingProvider import org.meshtastic.feature.node.compass.HeadingState -import org.meshtastic.feature.node.compass.MagneticFieldProvider -import org.meshtastic.feature.node.compass.PhoneLocationProvider -import org.meshtastic.feature.node.compass.PhoneLocationState /** No-op [CompassHeadingProvider] — desktop has no compass sensor. */ class NoopCompassHeadingProvider : CompassHeadingProvider { @@ -35,6 +37,11 @@ class NoopPhoneLocationProvider : PhoneLocationProvider { flowOf(PhoneLocationState(permissionGranted = false, providerEnabled = false)) } +/** No-op [CameraPoseProvider] — desktop has no orientation sensor or back camera. */ +class NoopCameraPoseProvider : CameraPoseProvider { + override fun poseUpdates(): Flow = flowOf(CameraPoseState(hasSensor = false)) +} + /** No-op [MagneticFieldProvider] — always returns zero declination. */ class NoopMagneticFieldProvider : MagneticFieldProvider { override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float = 0f diff --git a/docs/en/user.md b/docs/en/user.md index 97bf469302c..a7a73445f21 100644 --- a/docs/en/user.md +++ b/docs/en/user.md @@ -17,6 +17,8 @@ Documentation for using the Meshtastic Android and Desktop app. Keep the last 5–8 entries and archive older ones by removing them. --> +**October 2026** — [Map & Waypoints](user/map-and-waypoints) — The AR mesh view draws nodes and waypoints over the phone's camera and turns you toward the one you are finding. + **September 2026** — [Notifications](user/notifications) — A new page lists every notification category, what each opens when tapped, when a message stays quiet, and what a message notification lets you do from the shade or a watch. **September 2026** — [Nodes](user/nodes) — Nodes your node has not heard since its LoRa settings changed now carry an orange marker, a **Hide unheard nodes** filter, and a banner whose **Remove** action deletes them in bulk. diff --git a/docs/en/user/map-and-waypoints.md b/docs/en/user/map-and-waypoints.md index c8fc906ad14..8cd19d890f9 100644 --- a/docs/en/user/map-and-waypoints.md +++ b/docs/en/user/map-and-waypoints.md @@ -2,7 +2,7 @@ title: Map & Waypoints parent: User Guide nav_order: 6 -last_updated: 2026-10-07 +last_updated: 2026-10-20 description: View node positions on the map, create and share waypoints, manage map layers and Site Planner, and control position sharing and privacy. aliases: - map @@ -47,6 +47,20 @@ Tap the filter button in the floating toolbar to open **Filter map**. **Display* A dot on the filter button means at least one filter is hiding something — check it before concluding the mesh is quiet. Turning **Show Waypoints** off hides every waypoint, including your own. **Show ignored nodes** adds them to the map rather than showing only them — unlike the node list's **Only show ignored Nodes**. +## AR mesh view + +The mesh view draws nodes and waypoints over the phone's camera, each in the direction and at the height where it stands, with its distance and the age of its position. It needs a back camera and an orientation sensor, so it appears only on Android phones that have both. + +Tap **Open mesh view** in the map's top bar to see the whole mesh, filtered by the same **Filter map** settings as the map. A node's detail page, its compass sheet, its position log, and a traceroute map open the view too. Opened from a node, it shows only that node, plus the track or route you came from. + +- **Tap** a node to open its detail page, or a waypoint to open its info. +- **Touch & hold** a node or waypoint to find it. The view says how far to turn and tilt, the phone ticks as you turn closer, and it pulses once you are on target. +- In the whole-mesh view, nodes and waypoints farther than 50 km (30 mi) stay hidden until you tap the range label in the top bar. +- A node whose position uncertainty covers your own spot has no meaningful direction, so it goes on the radar at the bottom instead. Point the phone down to enlarge the radar. +- Waypoint geofences are outlined, and the view says when you are inside one. + +When your phone's position has no altitude, targets sit level with you and the view says elevation is approximate. Without camera access, or while the phone is too hot, the view carries on as a compass. + ## Waypoints Waypoints are shared points of interest, visible to everyone on your mesh. diff --git a/docs/en/user/nodes.md b/docs/en/user/nodes.md index 3121885c259..120130508a2 100644 --- a/docs/en/user/nodes.md +++ b/docs/en/user/nodes.md @@ -2,7 +2,7 @@ title: Nodes parent: User Guide nav_order: 4 -last_updated: 2026-10-02 +last_updated: 2026-10-20 description: Browse, filter, and sort mesh nodes — view details, signal quality, roles, and quick actions. aliases: - node-list @@ -106,6 +106,8 @@ From the node list, you can: - Mute/unmute notifications - Send a direct message - Trace route + - Open Compass, for a node with a position + - Open mesh view, for a node with a position on a phone that supports the [AR mesh view](map-and-waypoints#ar-mesh-view) - Ignore/unignore - Remove diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts index e9544ebebd6..f1c322244ef 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -45,6 +45,14 @@ kotlin { implementation(libs.ktor.client.core) } + // The camera mesh view's preview and lens intrinsics. + androidMain.dependencies { + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.compose) + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.lifecycle) + } + // Compose UI tests live in jvmTest, not commonTest: this module enables Android host tests, and // `runComposeUiTest` has no Robolectric host there — the same tests NPE the moment they run under it. jvmTest.dependencies { diff --git a/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/hud/DeviceAttitudeTest.kt b/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/hud/DeviceAttitudeTest.kt new file mode 100644 index 00000000000..8d715636ec7 --- /dev/null +++ b/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/hud/DeviceAttitudeTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import android.view.Surface +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.math.sqrt +import kotlin.test.Test +import kotlin.test.assertEquals + +private const val TOLERANCE = 1e-5 + +private val EAST = Vec3(1.0, 0.0, 0.0) +private val NORTH = Vec3(0.0, 1.0, 0.0) +private val SOUTH = Vec3(0.0, -1.0, 0.0) +private val UP = Vec3(0.0, 0.0, 1.0) +private val DOWN = Vec3(0.0, 0.0, -1.0) + +private fun assertNear(expected: Vec3, actual: Vec3) { + assertEquals(expected.x, actual.x, TOLERANCE, "x of $actual") + assertEquals(expected.y, actual.y, TOLERANCE, "y of $actual") + assertEquals(expected.z, actual.z, TOLERANCE, "z of $actual") +} + +/** Quaternions are (x, y, z, w) device-to-east-north-up, the layout of both the rotation vector and FOP's attitude. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class DeviceAttitudeTest { + + @Test + fun `identity is the phone flat on its back with its top to the north`() { + val pose = attitudeToCameraPose(floatArrayOf(0f, 0f, 0f, 1f), Surface.ROTATION_0) + assertNear(DOWN, pose.forward) + assertNear(EAST, pose.right) + assertNear(NORTH, pose.up) + assertEquals(0.0, pose.azimuthDeg, TOLERANCE) + } + + @Test + fun `tilting the top up about device x faces the camera north`() { + val half = sqrt(0.5).toFloat() + val pose = attitudeToCameraPose(floatArrayOf(half, 0f, 0f, half), Surface.ROTATION_0) + assertNear(NORTH, pose.forward) + assertNear(EAST, pose.right) + assertNear(UP, pose.up) + assertEquals(0.0, pose.azimuthDeg, TOLERANCE) + } + + @Test + fun `an upright portrait phone facing east has south on its right`() { + val pose = attitudeToCameraPose(floatArrayOf(0.5f, -0.5f, -0.5f, 0.5f), Surface.ROTATION_0) + assertNear(EAST, pose.forward) + assertNear(SOUTH, pose.right) + assertNear(UP, pose.up) + assertEquals(90.0, pose.azimuthDeg, TOLERANCE) + } + + @Test + fun `a landscape phone with its top to the left takes the screen axes from the display rotation`() { + val pose = attitudeToCameraPose(floatArrayOf(0.5f, -0.5f, 0.5f, 0.5f), Surface.ROTATION_90) + assertNear(NORTH, pose.forward) + assertNear(EAST, pose.right) + assertNear(UP, pose.up) + assertEquals(0.0, pose.azimuthDeg, TOLERANCE) + } +} diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt new file mode 100644 index 00000000000..fe38bcb9a46 --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.hardware.display.DisplayManager +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import android.os.PowerManager +import android.view.Display +import android.view.Surface +import androidx.annotation.RequiresApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import org.koin.core.annotation.Single +import java.util.concurrent.Executor +import kotlin.math.max +import kotlin.math.sqrt + +private const val QUATERNION_SIZE = 4 + +private const val HEADING_ACCURACY_INDEX = 4 +private const val NINETY_FIVE_PERCENT_SIGMAS = 1.96 +private const val MAX_HEADING_ERROR_DEG = 180f + +/** + * Running hot, at most one pose per this long, the SENSOR_DELAY_UI period. The fused orientation provider ignores a + * slower requested period (measured on a Pixel 6a: 55 Hz when asked for 15), so the pose is thinned here. + */ +private const val REDUCED_INTERVAL_NS = 66_667_000L + +/** + * Prefers [fused] and falls back to the platform rotation vector, or the geomagnetic one on devices without a + * gyroscope. Running hot, either source is slowed. + */ +@Single +class AndroidCameraPoseProvider(context: Context, private val fused: FusedOrientationSource) : CameraPoseProvider { + private val context = context.applicationContext + + override fun poseUpdates(): Flow = callbackFlow { + // Every callback runs on this one thread, away from the main thread that draws the overlay, so the session + // needs no locking. + val thread = HandlerThread("mesh-view-pose").apply { start() } + val handler = Handler(thread.looper) + val session = PoseSession(context, fused, handler) { trySend(it) } + handler.post(session::start) + awaitClose { + handler.post { + session.stop() + thread.quitSafely() + } + } + } +} + +private class PoseSession( + context: Context, + private val fused: FusedOrientationSource, + private val handler: Handler, + private val send: (CameraPoseState) -> Unit, +) { + private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager + private val rotationSensor = + sensorManager?.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) + ?: sensorManager?.getDefaultSensor(Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR) + private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager + private val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager + private val executor = Executor { handler.post(it) } + + private val sample = FloatArray(QUATERNION_SIZE) + private val smoothed = FloatArray(QUATERNION_SIZE) + private val matrix = FloatArray(ROTATION_MATRIX_SIZE) + private var filter = OneEuroQuaternionFilter() + private var last = CameraPoseState() + private var thermal = HudThermal.NORMAL + private var fusedHandle: AutoCloseable? = null + private var sensorRegistered = false + private var stopThermal: (() -> Unit)? = null + private var stopped = false + private var lastPublishedNs = 0L + + // Some fusions (measured on a Pixel 9 Pro) report a fixed pi/6 here rather than an estimate, so the value is + // trusted only once it has moved. + private var firstAccuracy: Float? = null + private var accuracyIsLive = false + + private val reduced + get() = thermal != HudThermal.NORMAL + + private val sensorListener = + object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + event.toQuaternion(sample) + publish(sample, event.timestamp, event.accuracy, event.headingAccuracyDeg(), trueNorth = false) + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit + } + + private val fusedListener = FusedOrientationSource.Listener { attitude, headingErrorDeg, trueNorth, timestampNs -> + if (!stopped && fusedHandle != null) { + val error = headingErrorDeg.toOneSigmaDeg() + // An invalid heading error means an uncalibrated compass, so it asks for calibration like status 0. + val calibration = if (error == null) SensorManager.SENSOR_STATUS_UNRELIABLE else CALIBRATION_UNKNOWN + publish(attitude, timestampNs, calibration, error, trueNorth) + } + } + + fun start() { + val power = powerManager + if (power != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + stopThermal = power.watchThermal(executor, ::onThermal) + } + startOrientation() + } + + fun stop() { + stopped = true + fusedHandle?.close() + fusedHandle = null + if (sensorRegistered) sensorManager?.unregisterListener(sensorListener) + sensorRegistered = false + stopThermal?.invoke() + } + + private fun startOrientation() { + var handle: AutoCloseable? = null + handle = fused.start(reduced, executor, fusedListener) { onFusedFailure(handle) } + fusedHandle = handle + if (handle == null) startSensor() + } + + private fun onFusedFailure(handle: AutoCloseable?) { + if (stopped || handle == null || handle !== fusedHandle) return + fusedHandle = null + filter = OneEuroQuaternionFilter() + startSensor() + } + + private fun startSensor() { + val sensor = rotationSensor + val delay = if (reduced) SensorManager.SENSOR_DELAY_UI else SensorManager.SENSOR_DELAY_GAME + sensorRegistered = + sensor != null && sensorManager?.registerListener(sensorListener, sensor, delay, handler) == true + if (!sensorRegistered) emit(CameraPoseState(hasSensor = false)) + } + + private fun onThermal(next: HudThermal) { + if (stopped || next == thermal) return + val wasReduced = reduced + thermal = next + if (reduced != wasReduced) restartOrientation() + emit(last) + } + + private fun restartOrientation() { + val handle = fusedHandle + if (handle != null) { + fusedHandle = null + handle.close() + startOrientation() + } else if (sensorRegistered) { + sensorManager?.unregisterListener(sensorListener) + startSensor() + } + } + + private fun publish( + attitude: FloatArray, + timestampNs: Long, + calibration: Int, + headingAccuracyDeg: Double?, + trueNorth: Boolean, + ) { + if (reduced && timestampNs - lastPublishedNs < REDUCED_INTERVAL_NS) return + lastPublishedNs = timestampNs + filter.filter(attitude, timestampNs, smoothed) + val rotation = displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0 + emit( + CameraPoseState( + pose = attitudeToCameraPose(smoothed, rotation, matrix), + calibration = calibration, + headingAccuracyDeg = headingAccuracyDeg, + trueNorth = trueNorth, + ), + ) + } + + private fun emit(state: CameraPoseState) { + last = state.copy(thermal = thermal) + send(last) + } + + private fun SensorEvent.headingAccuracyDeg(): Double? = headingAccuracyRad()?.let { raw -> + if (firstAccuracy == null) firstAccuracy = raw + if (raw != firstAccuracy) accuracyIsLive = true + raw.takeIf { accuracyIsLive }?.let { Math.toDegrees(it.toDouble()) / NINETY_FIVE_PERCENT_SIGMAS } + } +} + +/** Rotation-vector values as (x, y, z, w); older sensors omit w, which is recovered from the unit norm. */ +@Suppress("MagicNumber") +private fun SensorEvent.toQuaternion(out: FloatArray) { + val x = values[0] + val y = values[1] + val z = values[2] + out[0] = x + out[1] = y + out[2] = z + out[3] = if (values.size > 3) values[3] else sqrt(max(0f, 1f - x * x - y * y - z * z)) +} + +/** The rotation vector's heading error estimate, which it reports in radians and as -1 when it has none. */ +private fun SensorEvent.headingAccuracyRad(): Float? = values.getOrNull(HEADING_ACCURACY_INDEX)?.takeIf { it >= 0f } + +/** A 95% heading error bound in degrees, as the one-sigma figure every other accuracy on screen uses. */ +private fun Float.toOneSigmaDeg(): Double? = takeIf { + it >= 0f && it < MAX_HEADING_ERROR_DEG +} + ?.let { it / NINETY_FIVE_PERCENT_SIGMAS } + +private fun Int.toHudThermal(): HudThermal = when { + this >= PowerManager.THERMAL_STATUS_CRITICAL -> HudThermal.CRITICAL + this >= PowerManager.THERMAL_STATUS_SEVERE -> HudThermal.HOT + else -> HudThermal.NORMAL +} + +/** Reports the current thermal status, then every change, and returns the call that stops it. */ +@RequiresApi(Build.VERSION_CODES.Q) +private fun PowerManager.watchThermal(executor: Executor, onChange: (HudThermal) -> Unit): () -> Unit { + onChange(currentThermalStatus.toHudThermal()) + val listener = PowerManager.OnThermalStatusChangedListener { onChange(it.toHudThermal()) } + addThermalStatusListener(executor, listener) + return { removeThermalStatusListener(listener) } +} diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/DeviceAttitude.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/DeviceAttitude.kt new file mode 100644 index 00000000000..5c292a441bd --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/DeviceAttitude.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import android.hardware.SensorManager +import android.view.Surface + +internal const val ROTATION_MATRIX_SIZE = 9 + +/** + * The camera pose for a device-to-east-north-up [attitude] quaternion in (x, y, z, w) order, which both the rotation + * vector and the Fused Orientation Provider report. [matrix] is scratch space of [ROTATION_MATRIX_SIZE]. + */ +internal fun attitudeToCameraPose( + attitude: FloatArray, + displayRotation: Int, + matrix: FloatArray = FloatArray(ROTATION_MATRIX_SIZE), +): CameraPose { + SensorManager.getRotationMatrixFromVector(matrix, attitude) + return matrix.toCameraPose(displayRotation) +} + +/** + * The matrix maps device coordinates into east-north-up, so its columns are the device axes in that frame. The back + * camera looks down device -Z, and which device axis is the screen's right depends on the display rotation. + */ +@Suppress("MagicNumber") +private fun FloatArray.toCameraPose(displayRotation: Int): CameraPose { + val deviceX = Vec3(this[0].toDouble(), this[3].toDouble(), this[6].toDouble()) + val deviceY = Vec3(this[1].toDouble(), this[4].toDouble(), this[7].toDouble()) + val forward = Vec3(-this[2].toDouble(), -this[5].toDouble(), -this[8].toDouble()) + val (right, up) = + when (displayRotation) { + Surface.ROTATION_90 -> -deviceY to deviceX + Surface.ROTATION_180 -> -deviceX to -deviceY + Surface.ROTATION_270 -> deviceY to -deviceX + else -> deviceX to deviceY + } + return CameraPose(right = right, up = up, forward = forward) +} + +private operator fun Vec3.unaryMinus() = Vec3(-x, -y, -z) diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/FusedOrientationSource.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/FusedOrientationSource.kt new file mode 100644 index 00000000000..c0555c8f18e --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/FusedOrientationSource.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import java.util.concurrent.Executor + +/** + * An orientation fusion outside the platform sensors, such as Play services' Fused Orientation Provider. Each flavor + * binds one; [NONE] is the flavor without it, and the camera pose falls back to the rotation vector. + */ +interface FusedOrientationSource { + + /** + * Starts delivering samples on [executor], slower when [reduced], and returns the handle that stops them, or null + * when the source is unavailable. [onFailure] runs on [executor] if the source fails after it was started. + */ + fun start(reduced: Boolean, executor: Executor, listener: Listener, onFailure: () -> Unit): AutoCloseable? + + fun interface Listener { + /** + * [attitude] is the device-to-east-north-up quaternion as (x, y, z, w), the rotation vector's layout. + * [headingErrorDeg] is the 95% half-cone of the heading error, 180 or more when unknown. [trueNorth] says the + * attitude is referenced to true north. + */ + fun onSample(attitude: FloatArray, headingErrorDeg: Float, trueNorth: Boolean, timestampNs: Long) + } + + companion object { + val NONE = + object : FusedOrientationSource { + override fun start( + reduced: Boolean, + executor: Executor, + listener: Listener, + onFailure: () -> Unit, + ): AutoCloseable? = null + } + } +} diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.android.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.android.kt new file mode 100644 index 00000000000..36ead24db24 --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.android.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import android.hardware.camera2.CameraCharacteristics +import androidx.camera.camera2.interop.Camera2CameraInfo +import androidx.camera.camera2.interop.ExperimentalCamera2Interop +import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.CameraState +import androidx.camera.core.Preview +import androidx.camera.core.SurfaceRequest +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.lifecycle.Observer +import androidx.lifecycle.compose.LocalLifecycleOwner +import co.touchlab.kermit.Logger +import kotlin.math.max +import kotlin.math.min + +@Composable +actual fun HudCameraPreview( + onIntrinsics: (CameraIntrinsics?) -> Unit, + onAvailable: (Boolean) -> Unit, + modifier: Modifier, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val currentOnIntrinsics by rememberUpdatedState(onIntrinsics) + val currentOnAvailable by rememberUpdatedState(onAvailable) + var surfaceRequest by remember { mutableStateOf(null) } + + DisposableEffect(lifecycleOwner) { + var disposed = false + var provider: ProcessCameraProvider? = null + var sensorIntrinsics: CameraIntrinsics? = null + var boundCamera: Camera? = null + val stateObserver = + Observer { state -> + when { + state.error != null -> currentOnAvailable(false) + state.type == CameraState.Type.OPEN -> currentOnAvailable(true) + } + } + // Stabilization stays off: it crops up to a fifth of the field of view, which the intrinsics would not see. + val preview = Preview.Builder().build() + + // The stream CameraX picked, and its crop, decide how much of the sensor the view shows. + fun report() { + val info = preview.resolutionInfo + currentOnIntrinsics( + sensorIntrinsics?.let { base -> + info?.let { + base.forStream( + streamLong = max(it.resolution.width, it.resolution.height), + streamShort = min(it.resolution.width, it.resolution.height), + cropLong = max(it.cropRect.width(), it.cropRect.height()), + cropShort = min(it.cropRect.width(), it.cropRect.height()), + ) + } ?: base + }, + ) + } + preview.setSurfaceProvider { request -> + if (disposed) { + request.willNotProvideSurface() + } else { + surfaceRequest = request + report() + } + } + + val future = ProcessCameraProvider.getInstance(context) + future.addListener( + { + if (disposed) return@addListener + @Suppress("TooGenericExceptionCaught") + try { + val bound = future.get() + provider = bound + val camera = bound.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview) + sensorIntrinsics = camera.intrinsics() + report() + // Another app holding the camera, or a HAL fault, arrives here rather than as a bind failure. + camera.cameraInfo.cameraState.observe(lifecycleOwner, stateObserver) + boundCamera = camera + } catch (e: Exception) { + Logger.w(e) { "Mesh view camera bind failed" } + currentOnIntrinsics(null) + currentOnAvailable(false) + } + }, + ContextCompat.getMainExecutor(context), + ) + + onDispose { + disposed = true + boundCamera?.cameraInfo?.cameraState?.removeObserver(stateObserver) + surfaceRequest?.willNotProvideSurface() + surfaceRequest = null + provider?.unbind(preview) + } + } + + // Crop, not fit: CameraIntrinsics.focalPx assumes the preview fills the view and overflows the long side. + surfaceRequest?.let { + CameraXViewfinder(surfaceRequest = it, modifier = modifier, contentScale = ContentScale.Crop) + } +} + +/** + * Focal length and the active sensor area in millimeters, or null when the camera does not report them. CameraX 1.7 + * replaces Camera2CameraInfo with CameraInfo.cameraCharacteristics. + */ +@androidx.annotation.OptIn(ExperimentalCamera2Interop::class) +private fun Camera.intrinsics(): CameraIntrinsics? { + val info = Camera2CameraInfo.from(cameraInfo) + val focal = info.getCameraCharacteristic(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)?.firstOrNull() + val physical = info.getCameraCharacteristic(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE) + val pixels = info.getCameraCharacteristic(CameraCharacteristics.SENSOR_INFO_PIXEL_ARRAY_SIZE) + val active = info.getCameraCharacteristic(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE) + if (focal == null || physical == null || pixels == null || active == null) return null + // The physical size covers the whole pixel array; the preview streams only the active area. + val width = physical.width * active.width() / pixels.width + val height = physical.height * active.height() / pixels.height + // A calibrated focal length, in pre-correction active-array pixels, beats the nominal one when the camera has it: + // on a logical multi-camera the first listed focal length need not be the lens in use. + val calibratedFx = info.getCameraCharacteristic(CameraCharacteristics.LENS_INTRINSIC_CALIBRATION)?.firstOrNull() + // Pixels to millimeters by the sensor's pixel pitch. + val nominalOrCalibrated = calibratedFx?.takeIf { it > 0f }?.let { it * physical.width / pixels.width } ?: focal + val zoom = cameraInfo.zoomState.value?.zoomRatio ?: 1f + return CameraIntrinsics( + focalLengthMm = nominalOrCalibrated * zoom, + sensorLongMm = max(width, height), + sensorShortMm = min(width, height), + ) +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index 0e45c88f16d..01328ac1a7d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -30,7 +30,9 @@ import org.meshtastic.core.model.ContactKey import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.model.NodeMapFilter import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.core.model.decodeExcludedRoles import org.meshtastic.core.model.geofence.activeWaypointPackets import org.meshtastic.core.model.isFromLocal import org.meshtastic.core.network.repository.NetworkRepository @@ -264,6 +266,19 @@ open class BaseMapViewModel( excludeMqtt || !includeUnknown || lastHeardFilter != LastHeardFilter.Any + + fun toNodeMapFilter() = NodeMapFilter( + onlyFavorites = onlyFavorites, + showIgnored = showIgnored, + excludedRoles = excludedRoles, + includeUnknown = includeUnknown, + onlyOnline = onlyOnline, + onlyDirect = onlyDirect, + excludeMqtt = excludeMqtt, + lastHeardSeconds = lastHeardFilter.seconds, + onlySigned = onlySigned, + onlyEncrypted = onlyEncrypted, + ) } // One flow, one map: the store hands every filter back as a single value, so there is nothing to recombine. diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapNodePolicy.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapNodePolicy.kt index 59689b0527f..230c21e5bb8 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapNodePolicy.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapNodePolicy.kt @@ -17,8 +17,7 @@ package org.meshtastic.feature.map import org.meshtastic.core.model.Node -import org.meshtastic.core.model.util.ONLINE_WINDOW_SECONDS -import org.meshtastic.proto.Config +import org.meshtastic.core.model.passesMapFilter /** * Which nodes a map shows, and which of them draws on top. @@ -62,63 +61,10 @@ object MapNodePolicy { filterState: BaseMapViewModel.MapFilterState, nowSeconds: Long, myNodeNum: Int?, - ): List = nodes.filter { node -> - node.validPosition != null && (node.num == myNodeNum || node.passesFilters(filterState, nowSeconds)) + ): List { + val filter = filterState.toNodeMapFilter() + return nodes.filter { node -> + node.validPosition != null && (node.num == myNodeNum || node.passesMapFilter(filter, nowSeconds)) + } } - - /** - * One node against every filter. - * - * The node-level rules match the node list's query exactly, because they carry its labels: direct means `hopsAway - * == 0` — a node whose distance was never measured (-1) is not direct — and unknown means a node that has not sent - * a short name. - * - * [BaseMapViewModel.MapFilterState.showIgnored] is the one place the map deliberately differs. The node list - * segregates (`isIgnored == showIgnored`, so switching it on shows *only* ignored nodes); here it reads literally - * and adds them, because a map of nothing but ignored nodes is not a view anyone asked for. - */ - private fun Node.passesFilters(filterState: BaseMapViewModel.MapFilterState, nowSeconds: Long): Boolean { - val secondsSinceHeard = nowSeconds - lastHeard - return passesIdentityFilters(filterState) && - passesSecurityFilters(filterState) && - passesReachabilityFilters(filterState, secondsSinceHeard) - } - - /** Who the node is: favourited, ignored, its role, and whether its name has arrived yet. */ - private fun Node.passesIdentityFilters(filterState: BaseMapViewModel.MapFilterState): Boolean = - (!filterState.onlyFavorites || isFavorite) && - (!isIgnored || filterState.showIgnored) && - user.role !in filterState.excludedRoles && - (filterState.includeUnknown || user.short_name.isNotEmpty()) - - /** How the node is reachable: online, hop count, bearer, and how recently it was heard. */ - private fun Node.passesReachabilityFilters( - filterState: BaseMapViewModel.MapFilterState, - secondsSinceHeard: Long, - ): Boolean { - val cutoff = filterState.lastHeardFilter.seconds - return (!filterState.onlyOnline || secondsSinceHeard <= ONLINE_WINDOW_SECONDS) && - (!filterState.onlyDirect || (hopsAway == 0 && !viaMqtt)) && - (!filterState.excludeMqtt || !viaMqtt) && - (cutoff == LastHeardFilter.Any.seconds || secondsSinceHeard <= cutoff) - } - - /** - * The two security filters (design#149). - * - * Signing reads the raw flag rather than the version-gated indicator: the filter answers "has the radio verified - * this node's signature", which is what the badge beside it claims. Encrypted excludes a stored key that stopped - * matching — a mismatch is not a key you can safely encrypt to. - */ - private fun Node.passesSecurityFilters(filterState: BaseMapViewModel.MapFilterState): Boolean = - (!filterState.onlySigned || signsPackets) && (!filterState.onlyEncrypted || (hasPKC && !mismatchKey)) } - -/** - * Resolves persisted role names back to roles, dropping any the current protobufs no longer define. - * - * Names rather than ordinals, and forgiving of names it does not know: a set written by a newer build (or a role - * removed from the protos) must not throw on the way back in. - */ -fun decodeExcludedRoles(names: Set): Set = - names.mapNotNullTo(mutableSetOf()) { name -> Config.DeviceConfig.Role.entries.firstOrNull { it.name == name } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index 950d2716339..193061886f5 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -18,6 +18,8 @@ package org.meshtastic.feature.map import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -26,7 +28,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.map +import org.meshtastic.core.resources.mesh_hud_open import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.core.ui.icon.CenterFocusStrong +import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.util.LocalMapViewProvider @Composable @@ -37,6 +42,8 @@ fun MapScreen( viewModel: SharedMapViewModel, waypointId: Int? = null, sitePlannerNodeNum: Int? = null, + /** Null hides the mesh view button, for platforms with no camera. */ + onOpenMeshHud: (() -> Unit)? = null, ) { val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle() val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() @@ -51,7 +58,16 @@ fun MapScreen( showNodeChip = ourNodeInfo != null && isConnected, canNavigateUp = false, onNavigateUp = {}, - actions = {}, + actions = { + onOpenMeshHud?.let { onClick -> + IconButton(onClick = onClick) { + Icon( + MeshtasticIcons.CenterFocusStrong, + contentDescription = stringResource(Res.string.mesh_hud_open), + ) + } + } + }, onClickChip = { onClickNodeChip(it.num) }, ) }, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/CameraPoseProvider.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/CameraPoseProvider.kt new file mode 100644 index 00000000000..b55b5035602 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/CameraPoseProvider.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import kotlinx.coroutines.flow.Flow + +/** + * [calibration] is the platform's sensor status, 3 high down to 0 unreliable, and -1 before the first reading. + * [headingAccuracyDeg] is the sensor's own estimate of its heading error, null when it gives none. [trueNorth] is true + * when the source already applied the declination, so the pose is referenced to true north rather than magnetic. + * [thermal] is how hot the phone runs, so the view can shed work before the system throttles it. + */ +data class CameraPoseState( + val pose: CameraPose? = null, + val hasSensor: Boolean = true, + val calibration: Int = CALIBRATION_UNKNOWN, + val headingAccuracyDeg: Double? = null, + val trueNorth: Boolean = false, + val thermal: HudThermal = HudThermal.NORMAL, +) + +/** [HOT] slows the sensors and says so; [CRITICAL] also releases the camera, leaving the overlay as a compass. */ +enum class HudThermal { + NORMAL, + HOT, + CRITICAL, +} + +const val CALIBRATION_UNKNOWN = -1 + +interface CameraPoseProvider { + fun poseUpdates(): Flow +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/FixAverager.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/FixAverager.kt new file mode 100644 index 00000000000..482b8252813 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/FixAverager.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import org.meshtastic.core.common.util.latLongToMeter +import org.meshtastic.core.repository.PhoneLocation + +private const val WINDOW_MS = 30_000L + +/** Above this ground speed the user is walking, and old fixes describe somewhere they no longer are. */ +private const val MOVING_MPS = 0.7f + +/** A fix this many accuracy radii from the running mean is a real move, not noise. */ +private const val JUMP_RADII = 3.0 + +/** No fix is trusted below this, so a reported zero neither divides by zero nor makes every wobble a jump. */ +private const val MIN_ACCURACY_M = 1f + +/** Weight for a fix that reports no accuracy, as if it were this many meters. */ +private const val UNKNOWN_ACCURACY_M = 20f + +/** + * Steadies the phone's own position while it is held still: a mean of the last [WINDOW_MS] of fixes, each weighted by + * the inverse square of its accuracy, restarted whenever the phone moves. Bearings to nodes a few tens of meters away + * swing with every meter of GPS wander, so this is what keeps nearby chips from jumping. + * + * The reported accuracy is the best single fix's, not divided by the count: GPS error is correlated over this window, + * so averaging steadies the position without proving it is that much closer to the truth. + */ +internal class FixAverager { + private val fixes = ArrayDeque() + + /** How many fixes the last result averaged. */ + var count: Int = 0 + private set + + fun add(fix: PhoneLocation): PhoneLocation { + if (fixes.lastOrNull()?.timeMillis == fix.timeMillis && fixes.lastOrNull() == fix) return mean() + val moving = (fix.speedMps ?: 0f) > MOVING_MPS + val current = fixes.takeIf { it.isNotEmpty() }?.let { mean() } + val jumped = + current != null && + latLongToMeter(current.latitude, current.longitude, fix.latitude, fix.longitude) > + JUMP_RADII * (fix.accuracyM ?: UNKNOWN_ACCURACY_M).coerceAtLeast(MIN_ACCURACY_M) + if (moving || jumped) fixes.clear() + fixes.addLast(fix) + while (fixes.size > 1 && fix.timeMillis - fixes.first().timeMillis > WINDOW_MS) fixes.removeFirst() + return mean() + } + + private fun mean(): PhoneLocation { + val newest = fixes.last() + var weightSum = 0.0 + var lat = 0.0 + var lon = 0.0 + fixes.forEach { f -> + val accuracy = (f.accuracyM ?: UNKNOWN_ACCURACY_M).coerceAtLeast(MIN_ACCURACY_M).toDouble() + val w = 1 / (accuracy * accuracy) + weightSum += w + lat += w * f.latitude + lon += w * f.longitude + } + count = fixes.size + return newest.copy( + latitude = lat / weightSum, + longitude = lon / weightSum, + accuracyM = fixes.mapNotNull { it.accuracyM }.minOrNull(), + ) + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.kt new file mode 100644 index 00000000000..939ec2f0c07 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlin.math.max +import kotlin.math.tan + +/** Fallback when the camera reports no intrinsics: a typical phone main camera's horizontal field of view. */ +private const val FALLBACK_HORIZONTAL_FOV_DEG = 66.0 + +/** The back camera's focal length and the size of the sensor area the preview shows, both in millimeters. */ +data class CameraIntrinsics(val focalLengthMm: Float, val sensorLongMm: Float, val sensorShortMm: Float) { + /** + * The sensor area a preview stream of [streamLong] by [streamShort] pixels shows after its [cropLong] by + * [cropShort] crop. The camera fills a stream whose aspect differs from the sensor's by cropping the sensor to that + * aspect about its center, so a 16:9 stream from a 4:3 sensor loses sensor height before any crop is applied. + */ + fun forStream(streamLong: Int, streamShort: Int, cropLong: Int, cropShort: Int): CameraIntrinsics { + if (streamLong <= 0 || streamShort <= 0) return this + val streamAspect = streamLong.toFloat() / streamShort + val (coveredLong, coveredShort) = + if (streamAspect >= sensorLongMm / sensorShortMm) { + sensorLongMm to sensorLongMm / streamAspect + } else { + sensorShortMm * streamAspect to sensorShortMm + } + return copy( + sensorLongMm = coveredLong * cropLong.coerceIn(1, streamLong) / streamLong, + sensorShortMm = coveredShort * cropShort.coerceIn(1, streamShort) / streamShort, + ) + } + + /** + * Focal length in view pixels. The preview fills the view and crops the overflow, so the scale is the larger of the + * two axis ratios; the sensor's long side lies along the view's long side whatever the display rotation. + */ + fun focalPx(viewWidth: Float, viewHeight: Float): Float { + val viewLong = max(viewWidth, viewHeight) + val viewShort = minOf(viewWidth, viewHeight) + return focalLengthMm * max(viewLong / sensorLongMm, viewShort / sensorShortMm) + } +} + +fun fallbackFocalPx(viewWidth: Float, viewHeight: Float): Float = + (max(viewWidth, viewHeight) / 2 / tan(FALLBACK_HORIZONTAL_FOV_DEG.toRadians() / 2)).toFloat() + +/** + * The back camera's live preview, filling [modifier]; reports intrinsics once the camera is bound, and [onAvailable] + * false when it cannot bind or the camera fails, true once it opens. + */ +@Composable +expect fun HudCameraPreview( + onIntrinsics: (CameraIntrinsics?) -> Unit, + onAvailable: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt new file mode 100644 index 00000000000..980409ebf69 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt @@ -0,0 +1,367 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.mesh_hud_more +import kotlin.math.abs + +private const val OFFSCREEN_ALPHA = 0.7f +private const val LEADER_ALPHA = 0.8f + +/** Rows a chip may be lifted to clear nearer ones; the rest of a crowded column is counted, not drawn. */ +private const val MAX_CHIP_LEVEL = 3 +private const val NEAR_SCALE_M = 1_000.0 +private const val MID_SCALE_M = 10_000.0 +private const val MID_SCALE = 0.9f +private const val FAR_SCALE = 0.8f +private val ChipGap = 4.dp + +/** A chip on its point scales about its foot; a lifted one about the corner its leader meets. */ +private val BottomCenter = TransformOrigin(pivotFractionX = 0.5f, pivotFractionY = 1f) +private val BottomLeft = TransformOrigin(pivotFractionX = 0f, pivotFractionY = 1f) +private val BottomRight = TransformOrigin(pivotFractionX = 1f, pivotFractionY = 1f) + +/** How far past its point a lifted chip's near corner sits, so the leader leans and never reads as a height. */ +private val LeaderRun = 16.dp +private val LeaderWidth = 1.dp +private val LeaderEdgeWidth = 3.dp +private val LeaderDashes = floatArrayOf(10f, 8f) +private val EdgeGutter = 28.dp +private val ArrowSize = 10.dp +private val ArrowGap = 2.dp +private val AnchorRadius = 4.dp +private val LeaderEdge = Color.Black.copy(alpha = 0.4f) +private val OverflowPadding = 6.dp +private val OverflowBand = 28.dp +private val OverflowScrim = Color.Black.copy(alpha = HUD_SCRIM_ALPHA) + +/** + * Where a chip went, so the draw pass can mark its true point, lead a lifted chip back to it from [port], or point an + * off-screen chip toward the turn. [dashed] is true when the point sits level only as a guess, with no altitude. + */ +private class PlacedChip( + val rect: IntRect, + val anchor: Offset?, + val port: Offset?, + val edge: PinEdge?, + val color: Color, + val dashed: Boolean, +) + +/** A column of chips cut off by the top limit: its center and how many did not fit. */ +private class Overflow(val centerX: Int, var count: Int) + +/** Written by the placement pass and read by the draw pass of the same frame. */ +private class ChipPlacement { + val chips = mutableListOf() + val overflow = mutableListOf() + + /** The edge each off-screen target was last pinned to; kept across frames, unlike the rest. */ + val lastEdge = mutableMapOf() + + fun clear() { + chips.clear() + overflow.clear() + } + + fun addOverflow(centerX: Int, width: Int) { + overflow.firstOrNull { abs(it.centerX - centerX) < width }?.let { it.count++ } + ?: overflow.add(Overflow(centerX, 1)) + } +} + +/** Nearer chips draw larger, so depth reads at a glance without reading the distance label. */ +private fun distanceScale(distanceM: Double): Float = when { + distanceM < NEAR_SCALE_M -> 1f + distanceM < MID_SCALE_M -> MID_SCALE + else -> FAR_SCALE +} + +/** + * Places each chip with its bottom center on the target's projected point, focused then nearest first, lifting a chip + * above any earlier one it would overlap. The lift comes from [stackLevels], which works in view angles and so does not + * change as the phone moves. A lifted chip steps aside, alternating right and left, and a thin neutral leader runs from + * its near bottom corner to the point: a callout, not a height. Chips lifted past [MAX_CHIP_LEVEL] rows, or that would + * leave the band between [topLimitPx] and [bottomLimitPx] are dropped into a "+N" count for their column. Targets out + * of view are pinned to the edge they lie beyond (see [pinEdge]) with an arrow toward them. + * + * The pose is read in the placement and draw blocks, so sensor updates relayout and redraw without recomposing. + */ +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +internal fun HudChipLayer( + targets: List, + currentPose: () -> CameraPose?, + focalPx: Float, + topLimitPx: () -> Int, + bottomLimitPx: () -> Int, + modifier: Modifier = Modifier, + chip: @Composable (HudTarget) -> Unit, +) { + val placement = remember { ChipPlacement() } + val textMeasurer = rememberTextMeasurer() + val overflowStyle = MaterialTheme.typography.labelMedium.copy(color = Color.White) + val overflowTemplate = stringResource(Res.string.mesh_hud_more) + Layout( + content = { targets.forEach { key(it.subject.key) { chip(it) } } }, + // A traversal group, so TalkBack reads the chips in the order each sets, nearest the center first. + modifier = + modifier + .semantics { isTraversalGroup = true } + .drawBehind { + if (currentPose() == null) return@drawBehind + placement.chips.forEach { drawLeaderOrArrow(it) } + placement.overflow.forEach { + drawOverflow(it, topLimitPx(), textMeasurer, overflowStyle, overflowTemplate) + } + }, + ) { measurables, constraints -> + val placeables = measurables.map { it.measure(Constraints()) } + val width = constraints.maxWidth + val height = constraints.maxHeight + val gap = ChipGap.roundToPx() + val gutter = EdgeGutter.roundToPx() + val overflowBand = OverflowBand.roundToPx() + val keys = targets.mapTo(HashSet()) { it.subject.key } + val levels = + stackLevels( + targets.mapIndexed { i, target -> + AngularBox( + bearingDeg = target.bearingDeg, + elevationDeg = target.elevationDeg, + halfWidthDeg = pxToDeg(placeables[i].width, focalPx) / 2, + heightDeg = pxToDeg(placeables[i].height, focalPx), + ) + }, + gapDeg = pxToDeg(gap, focalPx), + leaderRunDeg = pxToDeg(LeaderRun.roundToPx(), focalPx), + maxLevel = MAX_CHIP_LEVEL, + ) + val leaderRun = LeaderRun.roundToPx() + val arrowRoomPx = (ArrowSize + ArrowGap).roundToPx() + layout(width, height) { + placement.clear() + placement.lastEdge.keys.retainAll(keys) + val pose = currentPose() ?: return@layout + // The overflow badges get their own row under the limit, so no chip is placed over them. + val topLimit = topLimitPx() + overflowBand + val bottomLimit = bottomLimitPx().takeIf { it > topLimit } ?: height + // Only the edge-pinned chips stack on screen; they sit at fixed places, so that holds still too. + val edgeOccupied = mutableListOf() + placeables.forEachIndexed { index, placeable -> + val target = targets[index] + val direction = directionEnu(target.bearingDeg, target.elevationDeg) + val projected = project(direction, pose, focalPx, width.toFloat(), height.toFloat()) + val point = projected?.takeIf { it.x in 0f..width.toFloat() && it.y in 0f..height.toFloat() } + val key = target.subject.key + val yaw = signedTurnDeg(pose.azimuthDeg, target.bearingDeg) + val pitchDelta = target.elevationDeg - pose.pitchDeg + val edge = + if (point != null) { + null + } else { + pinEdge(projected, width.toFloat(), yaw, pitchDelta, placement.lastEdge[key]).also { + placement.lastEdge[key] = it + } + } + val rowHeight = placeable.height + gap + if (point != null && levels[index] == OVERFLOW_LEVEL) { + placement.addOverflow(point.x.toInt(), placeable.width) + return@forEachIndexed + } + val level = if (point != null) levels[index] else 0 + // A chip that would step off the screen steps to the other side of its point instead. + val side = + leaderSide(level).let { preferred -> + val x = point?.x?.toInt() ?: 0 + when { + preferred > 0 && x + leaderRun + placeable.width > width - gutter -> -1 + preferred < 0 && x - leaderRun - placeable.width < gutter -> 1 + else -> preferred + } + } + // Pinned chips spread along their edge by where the target lies, rather than piling in one place. + val along = edge?.let { edgeOffsetPx(it, yaw, pitchDelta, focalPx) } ?: 0f + val alongX = + (width / 2 + along.toInt() - placeable.width / 2).coerceIn( + gutter, + maxOf(gutter, width - placeable.width - gutter), + ) + val alongY = + (height / 2 - along.toInt() - placeable.height / 2).coerceIn( + topLimit, + maxOf(topLimit, bottomLimit - placeable.height), + ) + val left = + when { + point != null && side > 0 -> point.x.toInt() + leaderRun + point != null && side < 0 -> point.x.toInt() - leaderRun - placeable.width + point != null -> point.x.toInt() - placeable.width / 2 + edge == PinEdge.RIGHT -> width - placeable.width - gutter + edge == PinEdge.LEFT -> gutter + else -> alongX + } + var top = + when { + point != null -> point.y.toInt() - placeable.height - level * rowHeight + edge == PinEdge.TOP -> topLimit + gutter + edge == PinEdge.BOTTOM -> bottomLimit - placeable.height - gutter + else -> alongY + } + // Top-pinned chips stack downward, away from the edge; every other kind stacks upward. A top or bottom + // pin's arrow sits outside its chip, so those rows leave room for it. + val arrowRoom = if (edge == PinEdge.TOP || edge == PinEdge.BOTTOM) arrowRoomPx else 0 + val step = if (edge == PinEdge.TOP) rowHeight + arrowRoom else -(rowHeight + arrowRoom) + var rect = IntRect(left, top, left + placeable.width, top + placeable.height) + while (edge != null && edgeOccupied.any { it.overlaps(rect) }) { + top += step + rect = IntRect(left, top, left + placeable.width, top + placeable.height) + } + if (rect.top < topLimit || rect.bottom > bottomLimit) { + placement.addOverflow(rect.center.x, placeable.width) + return@forEachIndexed + } + if (edge != null) edgeOccupied += rect + val port = + when { + point == null || side == 0 -> null + side > 0 -> Offset(rect.left.toFloat(), rect.bottom.toFloat()) + else -> Offset(rect.right.toFloat(), rect.bottom.toFloat()) + } + placement.chips += + PlacedChip( + rect = rect, + anchor = point?.let { Offset(it.x, it.y) }, + port = port, + edge = edge, + color = Color(target.subject.color), + dashed = !target.elevationKnown, + ) + val scale = distanceScale(target.distanceM) + placeable.placeWithLayer(left, top) { + alpha = if (edge == null) 1f else OFFSCREEN_ALPHA + scaleX = scale + scaleY = scale + transformOrigin = + when { + side > 0 -> BottomLeft + side < 0 -> BottomRight + else -> BottomCenter + } + } + } + } + } +} + +private fun DrawScope.drawLeaderOrArrow(chip: PlacedChip) { + chip.anchor?.let { anchor -> + chip.port?.let { port -> + // Neutral white over a dark edge: the node's color stays on the chip and the point it marks. + val effect = if (chip.dashed) PathEffect.dashPathEffect(LeaderDashes) else null + drawLine(LeaderEdge, port, anchor, LeaderEdgeWidth.toPx(), pathEffect = effect) + drawLine(Color.White.copy(alpha = LEADER_ALPHA), port, anchor, LeaderWidth.toPx(), pathEffect = effect) + } + drawCircle(Color.White, radius = AnchorRadius.toPx() + 1.dp.toPx(), center = anchor) + drawCircle(chip.color, radius = AnchorRadius.toPx(), center = anchor) + } + chip.edge?.let { drawEdgeArrow(chip.rect, it) } +} + +/** A triangle just outside [rect], pointing past the edge the target lies beyond. */ +private fun DrawScope.drawEdgeArrow(rect: IntRect, edge: PinEdge) { + val size = ArrowSize.toPx() + val gap = ArrowGap.toPx() + val cx = rect.center.x.toFloat() + val cy = rect.center.y.toFloat() + val arrow = + Path().apply { + when (edge) { + PinEdge.LEFT -> { + val base = rect.left - gap + moveTo(base - size, cy) + lineTo(base, cy - size) + lineTo(base, cy + size) + } + + PinEdge.RIGHT -> { + val base = rect.right + gap + moveTo(base + size, cy) + lineTo(base, cy - size) + lineTo(base, cy + size) + } + + PinEdge.TOP -> { + val base = rect.top - gap + moveTo(cx, base - size) + lineTo(cx - size, base) + lineTo(cx + size, base) + } + + PinEdge.BOTTOM -> { + val base = rect.bottom + gap + moveTo(cx, base + size) + lineTo(cx - size, base) + lineTo(cx + size, base) + } + } + close() + } + drawPath(arrow, Color.White) +} + +private fun DrawScope.drawOverflow( + overflow: Overflow, + topLimit: Int, + textMeasurer: TextMeasurer, + style: TextStyle, + template: String, +) { + val layout = textMeasurer.measure(formatString(template, overflow.count), style) + val pad = OverflowPadding.toPx() + val boxSize = Size(layout.size.width + 2 * pad, layout.size.height + pad) + val topLeft = Offset(overflow.centerX - boxSize.width / 2, topLimit.toFloat()) + drawRoundRect(OverflowScrim, topLeft, boxSize, CornerRadius(pad)) + drawText(layout, topLeft = topLeft + Offset(pad, pad / 2)) +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt new file mode 100644 index 00000000000..1e576fd15d6 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.model.util.toCodePointString +import org.meshtastic.core.model.util.waypointIconOrDefault +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.duration_ago +import org.meshtastic.core.resources.formatDuration +import org.meshtastic.core.resources.mesh_hud_chip_description +import org.meshtastic.core.resources.mesh_hud_distance_no_altitude +import org.meshtastic.core.resources.mesh_hud_find +import org.meshtastic.core.resources.mesh_hud_joined +import org.meshtastic.core.resources.mesh_hud_open_node +import org.meshtastic.core.resources.mesh_hud_open_waypoint +import org.meshtastic.core.resources.mesh_hud_rise_above +import org.meshtastic.core.resources.mesh_hud_rise_above_spoken +import org.meshtastic.core.resources.mesh_hud_rise_below +import org.meshtastic.core.resources.mesh_hud_rise_below_spoken +import org.meshtastic.core.ui.component.NodeChip +import org.meshtastic.proto.Waypoint +import kotlin.math.roundToInt +import kotlin.time.DurationUnit + +private const val FRESH_POSITION_SEC = 15 * 60L +private const val OLD_POSITION_SEC = 6 * 60 * 60L +private const val SECONDS_PER_HOUR = 60 * 60L +private const val SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR +private const val OLDEST_ALPHA = 0.45f +private const val WAYPOINT_SHADOW_ALPHA = 0.8f +private const val WAYPOINT_SHADOW_BLUR = 8f + +/** Fully opaque while fresh, fading toward [OLDEST_ALPHA] as the position ages, so stale fixes read as stale. */ +private fun ageAlpha(ageSec: Long?): Float { + val age = ageSec ?: 0L + val t = ((age - FRESH_POSITION_SEC).toFloat() / (OLD_POSITION_SEC - FRESH_POSITION_SEC)).coerceIn(0f, 1f) + return 1f - t * (1f - OLDEST_ALPHA) +} + +private const val HOURS_ON_DIAL = 12 +private const val DEG_PER_HOUR = 30.0 + +/** Where [bearingDeg] lies on a clock face whose 12 is the camera's heading, 1 to 12. */ +internal fun clockHour(bearingDeg: Double, headingDeg: Double): Int { + val hour = (signedTurnDeg(headingDeg, bearingDeg) / DEG_PER_HOUR).roundToInt().mod(HOURS_ON_DIAL) + return if (hour == 0) HOURS_ON_DIAL else hour +} + +/** + * What TalkBack calls a subject: a node's long name, else its short name, else its node id, since a node never heard + * from has neither name; a waypoint's name, else its emoji. + */ +internal fun HudSubject.spokenName(): String = when (this) { + is HudSubject.NodeSubject -> + node.user.long_name.ifBlank { node.user.short_name.ifBlank { NodeAddress.numToDefaultId(node.num) } } + + is HudSubject.WaypointSubject -> + waypoint.name.ifBlank { waypoint.icon.waypointIconOrDefault().toCodePointString() } +} + +/** + * Tap opens a node's detail; long press makes it, or a waypoint, the finder's target. TalkBack hears its name, distance + * and clock position from the camera's heading, and reaches the chips nearest straight ahead first. + */ +@Composable +internal fun HudChip( + target: HudTarget, + currentPose: () -> CameraPose?, + onOpen: () -> Unit, + onToggleFocus: () -> Unit, +) { + // Derived, so the chip recomposes when its clock hour changes rather than at the sensor rate. + val hour by + remember(target.bearingDeg) { + derivedStateOf { currentPose()?.let { clockHour(target.bearingDeg, it.azimuthDeg) } } + } + val distanceText = chipDistanceText(target, details = target.focused) + // TalkBack always hears the rise and age: speech does not crowd the view, and fade and placement are visual only. + val spokenDistance = chipDistanceText(target, details = true, spoken = true) + val description = hour?.let { + stringResource(Res.string.mesh_hud_chip_description, target.subject.spokenName(), spokenDistance, it) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier.alpha(ageAlpha(target.positionAgeSec)) + .clip(MaterialTheme.shapes.small) + .minimumInteractiveComponentSize() + .combinedClickable( + onClickLabel = + stringResource( + if (target.subject is HudSubject.WaypointSubject) { + Res.string.mesh_hud_open_waypoint + } else { + Res.string.mesh_hud_open_node + }, + ), + onLongClickLabel = stringResource(Res.string.mesh_hud_find), + onClick = onOpen, + onLongClick = onToggleFocus, + ) + .semantics(mergeDescendants = true) { + description?.let { contentDescription = it } + hour?.let { traversalIndex = minOf(it, HOURS_ON_DIAL - it).toFloat() } + }, + ) { + val chipModifier = + if (target.focused) Modifier.border(2.dp, Color.White, MaterialTheme.shapes.small) else Modifier + SubjectChip(target.subject, chipModifier) + HudLabel(distanceText) + } +} + +/** + * The distance under a chip, marked when the altitude is missing, then, with [details], how far above or below the + * phone it is and how old its position is in its largest unit. [details] is false for an unfocused chip on screen: + * there the fade marks staleness and the point's place marks height, and more text would widen every label in a crowded + * view. [spoken] words the rise for TalkBack rather than with an arrow glyph. + */ +@Composable +private fun chipDistanceText(target: HudTarget, details: Boolean, spoken: Boolean = false): String { + val showsMissingAltitude = target.subject is HudSubject.NodeSubject && !target.elevationKnown + val distance = + if (showsMissingAltitude) { + stringResource(Res.string.mesh_hud_distance_no_altitude, target.distanceText) + } else { + target.distanceText + } + if (!details) return distance + return listOfNotNull(distance, target.rise?.let { riseText(it, spoken) }, ageText(target.positionAgeSec)).reduce { + text, + part, + -> + stringResource(Res.string.mesh_hud_joined, text, part) + } +} + +@Composable +private fun riseText(rise: HudRise, spoken: Boolean): String = when { + spoken && rise.above -> stringResource(Res.string.mesh_hud_rise_above_spoken, rise.text) + spoken -> stringResource(Res.string.mesh_hud_rise_below_spoken, rise.text) + rise.above -> stringResource(Res.string.mesh_hud_rise_above, rise.text) + else -> stringResource(Res.string.mesh_hud_rise_below, rise.text) +} + +/** A stale position's age in its largest unit, or null while it is fresh. */ +@Composable +private fun ageText(ageSec: Long?): String? { + val age = ageSec?.takeIf { it > FRESH_POSITION_SEC } ?: return null + val unit = + when { + age >= SECONDS_PER_DAY -> DurationUnit.DAYS + age >= SECONDS_PER_HOUR -> DurationUnit.HOURS + else -> DurationUnit.MINUTES + } + return stringResource(Res.string.duration_ago, formatDuration(age, unit)) +} + +@Composable +internal fun SubjectChip(subject: HudSubject, modifier: Modifier = Modifier) { + when (subject) { + is HudSubject.NodeSubject -> NodeChip(modifier = modifier, node = subject.node) + is HudSubject.WaypointSubject -> WaypointMarker(subject.waypoint, modifier) + } +} + +/** + * A waypoint as a bare emoji over its name, with no chip behind it, so a place reads apart from the nodes. The shadow + * keeps the emoji legible over sky and ground alike; the name sits on the same plate as every other label. + */ +@Composable +private fun WaypointMarker(waypoint: Waypoint, modifier: Modifier = Modifier) { + val shadow = Shadow(color = Color.Black.copy(alpha = WAYPOINT_SHADOW_ALPHA), blurRadius = WAYPOINT_SHADOW_BLUR) + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) { + Text( + waypoint.icon.waypointIconOrDefault().toCodePointString(), + style = MaterialTheme.typography.headlineMedium.copy(shadow = shadow), + ) + if (waypoint.name.isNotBlank()) HudLabel(waypoint.name) + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudEdges.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudEdges.kt new file mode 100644 index 00000000000..1ecc2487774 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudEdges.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import kotlin.math.abs +import kotlin.math.tan + +private const val HALF_CIRCLE = 180.0 + +/** The screen edge an out-of-view target pins to. */ +enum class PinEdge { + LEFT, + RIGHT, + TOP, + BOTTOM, +} + +/** A target further than this to either side pins to a side edge: turning toward it beats tilting. */ +private const val SIDE_YAW_DEG = 35.0 + +/** Hysteresis on [SIDE_YAW_DEG], and the window near straight behind where a side edge holds. */ +private const val EDGE_HOLD_DEG = 12.0 +private const val MAX_EDGE_ANGLE_DEG = 80.0 + +/** + * Where to pin a target that falls outside the view, chosen the way someone finds it: a target well to the side, or + * behind, pins to the side to turn toward ([yawDeg], positive clockwise), and one roughly ahead pins top or bottom to + * tilt toward ([pitchDeltaDeg], positive up). Choosing on the screen's own axes instead puts a node behind the user on + * the bottom edge while the camera points at the ground, which reads as inverted. [previous] holds through small + * wobbles: near [SIDE_YAW_DEG] and near straight behind, where the side would otherwise flip. + */ +fun pinEdge(projected: ScreenPoint?, width: Float, yawDeg: Double, pitchDeltaDeg: Double, previous: PinEdge?): PinEdge { + val wasSide = previous == PinEdge.LEFT || previous == PinEdge.RIGHT + val sideYaw = if (wasSide) SIDE_YAW_DEG - EDGE_HOLD_DEG else SIDE_YAW_DEG + EDGE_HOLD_DEG + val ahead = projected != null && projected.x in 0f..width + if (ahead || abs(yawDeg) < sideYaw) return if (pitchDeltaDeg > 0) PinEdge.TOP else PinEdge.BOTTOM + val nearBehind = abs(yawDeg) > HALF_CIRCLE - EDGE_HOLD_DEG + return if (wasSide && nearBehind) { + requireNotNull(previous) + } else if (yawDeg > 0) { + PinEdge.RIGHT + } else { + PinEdge.LEFT + } +} + +/** + * Where along its pinned [edge] a target sits, in pixels from the view center: across the top and bottom by how far it + * is to the side, up and down the sides by how far it is above or below. Positive is rightward and upward. + */ +fun edgeOffsetPx(edge: PinEdge, yawDeg: Double, pitchDeltaDeg: Double, focalPx: Float): Float { + val angle = + when (edge) { + PinEdge.TOP, + PinEdge.BOTTOM, + -> yawDeg + + PinEdge.LEFT, + PinEdge.RIGHT, + -> pitchDeltaDeg + } + return (tan(angle.coerceIn(-MAX_EDGE_ANGLE_DEG, MAX_EDGE_ANGLE_DEG).toRadians()) * focalPx).toFloat() +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudGeometry.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudGeometry.kt new file mode 100644 index 00000000000..5d5fe861c09 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudGeometry.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.asin +import kotlin.math.atan +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sin + +private const val DEGREES_PER_RADIAN = 180.0 / PI + +/** Earth radius scaled by 4/3, the standard allowance for atmospheric refraction near the horizon. */ +private const val EFFECTIVE_EARTH_RADIUS_M = 6_371_000.0 * 4.0 / 3.0 + +/** Points nearer than this to the camera plane are treated as behind it, so a projection never divides by ~0. */ +private const val MIN_FORWARD_COMPONENT = 0.05 + +/** A vector in the local east-north-up frame. */ +data class Vec3(val x: Double, val y: Double, val z: Double) { + infix fun dot(o: Vec3): Double = x * o.x + y * o.y + z * o.z +} + +/** + * Where the back camera points, as the screen's right, up and forward axes in east-north-up, referenced to magnetic + * north. + */ +data class CameraPose(val right: Vec3, val up: Vec3, val forward: Vec3) { + /** + * Magnetic azimuth the camera faces, 0..360. Steeply down or up, the line of sight has no usable horizontal part, + * so the heading follows the screen's top edge instead, which faces the same way when the phone is tipped forward + * and the opposite way when it is tipped back. + */ + val azimuthDeg: Double + get() { + val facing = + when { + forward.z < -STEEP_Z -> up + forward.z > STEEP_Z -> Vec3(-up.x, -up.y, -up.z) + else -> forward + } + return (atan2(facing.x, facing.y) * DEGREES_PER_RADIAN + FULL_CIRCLE) % FULL_CIRCLE + } +} + +/** This pose turned clockwise about the vertical by [deg]. */ +fun CameraPose.rotatedAboutVertical(deg: Double): CameraPose { + if (deg == 0.0) return this + val c = cos(deg.toRadians()) + val s = sin(deg.toRadians()) + fun Vec3.turn() = Vec3(x = x * c + y * s, y = -x * s + y * c, z = z) + return CameraPose(right = right.turn(), up = up.turn(), forward = forward.turn()) +} + +data class ScreenPoint(val x: Float, val y: Float) + +private const val FULL_CIRCLE = 360.0 + +/** sin(60°): a camera pitched further than this has its heading read from the screen's top edge. */ +private const val STEEP_Z = 0.87 +private const val HALF_CIRCLE = 180.0 + +internal fun Double.toRadians(): Double = this / DEGREES_PER_RADIAN + +/** Unit vector toward a bearing (degrees from north, clockwise) at an elevation above the horizontal. */ +fun directionEnu(bearingDeg: Double, elevationDeg: Double): Vec3 { + val b = bearingDeg.toRadians() + val e = elevationDeg.toRadians() + return Vec3(x = sin(b) * cos(e), y = cos(b) * cos(e), z = sin(e)) +} + +/** + * Angle above the observer's horizontal to a target [distanceM] away and [riseM] above, including the curvature drop at + * that range. A null [riseM] places the target level with the observer. + */ +fun elevationDeg(distanceM: Double, riseM: Double?): Double { + if (distanceM <= 0.0) return 0.0 + val drop = distanceM * distanceM / (2 * EFFECTIVE_EARTH_RADIUS_M) + return atan2((riseM ?: 0.0) - drop, distanceM) * DEGREES_PER_RADIAN +} + +/** A target with no shared altitude frame could sit anywhere in a band this tall either side of level. */ +private const val UNKNOWN_ELEVATION_SPREAD_DEG = 4.0 + +/** + * Half the vertical angle a target [distanceM] away could lie within, from a vertical uncertainty of [verticalM]. When + * the target has no [elevationKnown], its level placement is a guess, so the band is at least + * [UNKNOWN_ELEVATION_SPREAD_DEG] tall. + */ +fun elevationSpreadDeg(distanceM: Double, verticalM: Double, elevationKnown: Boolean): Double { + val measured = atan(verticalM / max(distanceM, 1.0)) * DEGREES_PER_RADIAN + return if (elevationKnown) measured else max(measured, UNKNOWN_ELEVATION_SPREAD_DEG) +} + +/** Half the angle a circle of [radiusM] subtends at [distanceM], or null when the observer is inside it. */ +fun precisionHalfAngleDeg(radiusM: Double, distanceM: Double): Double? = + if (radiusM >= distanceM) null else atan(radiusM / max(distanceM, 1.0)) * DEGREES_PER_RADIAN + +/** + * Pinhole projection of [direction] onto a [width] x [height] view with focal length [focalPx], or null when it is + * behind the camera. + */ +fun project(direction: Vec3, pose: CameraPose, focalPx: Float, width: Float, height: Float): ScreenPoint? { + val z = direction dot pose.forward + if (z < MIN_FORWARD_COMPONENT) return null + val x = direction dot pose.right + val y = direction dot pose.up + return ScreenPoint(x = width / 2 + (focalPx * x / z).toFloat(), y = height / 2 - (focalPx * y / z).toFloat()) +} + +/** A chip's footprint in view angles: centered on [bearingDeg], rising [heightDeg] from [elevationDeg]. */ +data class AngularBox(val bearingDeg: Double, val elevationDeg: Double, val halfWidthDeg: Double, val heightDeg: Double) + +private const val MAX_STACK_LEVEL = 64 + +/** The level [stackLevels] gives a chip that would need more than its cap: it is counted, not drawn. */ +const val OVERFLOW_LEVEL = -1 + +/** Which way a chip at [level] steps aside from its point: none at level 0, then right and left in turn. */ +fun leaderSide(level: Int): Int = when { + level <= 0 -> 0 + level % 2 == 1 -> 1 + else -> -1 +} + +/** + * How many rows each chip is lifted so none overlaps an earlier one, in list order. Computed from view angles rather + * than screen positions, so it holds still while the phone moves and changes only when the targets do. A lifted chip + * also steps aside by its half width plus [leaderRunDeg], to the side [leaderSide] gives, so its leader leans rather + * than rising plumb; a chip that would need more than [maxLevel] rows gets [OVERFLOW_LEVEL]. + */ +fun stackLevels( + boxes: List, + gapDeg: Double, + leaderRunDeg: Double = 0.0, + maxLevel: Int = MAX_STACK_LEVEL, +): IntArray { + val levels = IntArray(boxes.size) + fun center(index: Int, level: Int): Double { + val box = boxes[index] + val side = leaderSide(level) + return if (side == 0 || leaderRunDeg == 0.0) { + box.bearingDeg + } else { + box.bearingDeg + side * (box.halfWidthDeg + leaderRunDeg) + } + } + boxes.forEachIndexed { i, box -> + fun bottom(index: Int, level: Int) = boxes[index].elevationDeg + level * (boxes[index].heightDeg + gapDeg) + fun overlaps(j: Int, level: Int): Boolean { + val other = boxes[j] + val theirLevel = levels[j] + return abs(signedTurnDeg(center(j, theirLevel), center(i, level))) < + box.halfWidthDeg + other.halfWidthDeg && + bottom(i, level) < bottom(j, theirLevel) + other.heightDeg && + bottom(j, theirLevel) < bottom(i, level) + box.heightDeg + } + fun clashes(level: Int) = (0 until i).any { j -> levels[j] != OVERFLOW_LEVEL && overlaps(j, level) } + var level = 0 + while (level <= maxLevel && clashes(level)) level++ + levels[i] = if (level > maxLevel) OVERFLOW_LEVEL else level + } + return levels +} + +/** View angle, in degrees, that [px] spans at the center of a view with focal length [focalPx]. */ +fun pxToDeg(px: Int, focalPx: Float): Double = px / focalPx.toDouble() * DEGREES_PER_RADIAN + +/** The camera's tilt above the horizontal, in degrees; negative when it points at the ground. */ +val CameraPose.pitchDeg: Double + get() = asin(forward.z.coerceIn(-1.0, 1.0)) * DEGREES_PER_RADIAN + +/** The shortest signed turn from [fromDeg] to [toDeg], in -180..180; positive is clockwise. */ +fun signedTurnDeg(fromDeg: Double, toDeg: Double): Double = + ((toDeg - fromDeg) % FULL_CIRCLE + HALF_CIRCLE + FULL_CIRCLE) % FULL_CIRCLE - HALF_CIRCLE diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt new file mode 100644 index 00000000000..dd57aa8862d --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.ceil +import kotlin.math.floor + +private const val HORIZON_SPAN_DEG = 90 +private const val HORIZON_STEP_DEG = 5 +private const val HORIZON_ALPHA = 0.4f +private const val ZONE_ALPHA = 0.3f +private const val TAPE_MINOR_DEG = 5 +private const val TAPE_MAJOR_DEG = 15 +private const val TAPE_LABEL_DEG = 30 +private const val QUARTER_TURN = 90 +private const val HALF_TURN = 180 +private const val THREE_QUARTER_TURN = 270 +private const val LOCKED_FILL_ALPHA = 0.35f +private const val FULL_TURN = 360 +private val ZoneMinRadius = 6.dp +private val TapeHeight = 30.dp +private val TapeMinorTick = 5.dp +private val TapeMajorTick = 9.dp +private val TapeMarker = 5.dp +private val ReticleRadius = 36.dp + +/** Light enough to read as part of the view; the labels carry their own shadow for contrast. */ +private val TapeScrim = Color.Black.copy(alpha = 0.3f) + +/** The four cardinal letters, localized, for the compass tape. */ +internal data class Cardinals(val north: String, val east: String, val south: String, val west: String) { + fun at(deg: Int): String? = when (deg) { + 0 -> north + QUARTER_TURN -> east + HALF_TURN -> south + THREE_QUARTER_TURN -> west + else -> null + } +} + +/** The true horizontal (elevation 0) across the view, as a check that the overlay sits level with the world. */ +internal fun DrawScope.drawHorizon(pose: CameraPose, focalPx: Float) { + val points = + (-HORIZON_SPAN_DEG..HORIZON_SPAN_DEG step HORIZON_STEP_DEG).mapNotNull { offset -> + project(directionEnu(pose.azimuthDeg + offset, 0.0), pose, focalPx, size.width, size.height) + } + points.zipWithNext { a, b -> + drawLine(Color.White.copy(alpha = HORIZON_ALPHA), Offset(a.x, a.y), Offset(b.x, b.y), 1.dp.toPx()) + } +} + +/** + * The zone the target could lie in, drawn as a soft oval in its color, strongest at its best-estimate point and fading + * to clear at the edge. Its width is the bearing uncertainty, widened by [headingAccuracyDeg] since a heading error + * moves every target sideways; its height is the elevation uncertainty; it lies along the horizon even when the phone + * rolls. + */ +internal fun DrawScope.drawPrecisionZone( + target: HudTarget, + pose: CameraPose, + focalPx: Float, + headingAccuracyDeg: Double?, +) { + val across = (target.precisionHalfAngleDeg ?: 0.0) + (headingAccuracyDeg ?: 0.0) + val tall = target.elevationSpreadDeg + fun at(bearingOffset: Double, elevationOffset: Double) = project( + directionEnu(target.bearingDeg + bearingOffset, target.elevationDeg + elevationOffset), + pose, + focalPx, + size.width, + size.height, + ) + ?.let { Offset(it.x, it.y) } + val c = at(0.0, 0.0) ?: return + val left = at(-across, 0.0) + val right = at(across, 0.0) + val top = at(0.0, tall) + val limit = size.maxDimension + val minRadius = ZoneMinRadius.toPx() + val rx = (if (left != null && right != null) (right - left).getDistance() / 2 else limit).coerceIn(minRadius, limit) + val ry = (top?.let { (it - c).getDistance() } ?: limit).coerceIn(minRadius, limit) + val degreesPerRad = (HALF_TURN / PI).toFloat() + val angle = if (left != null && right != null) atan2(right.y - left.y, right.x - left.x) * degreesPerRad else 0f + val color = Color(target.subject.color) + withTransform({ + rotate(angle, pivot = c) + scale(scaleX = 1f, scaleY = ry / rx, pivot = c) + }) { + drawCircle( + brush = + Brush.radialGradient( + colors = + listOf(color.copy(alpha = ZONE_ALPHA), color.copy(alpha = ZONE_ALPHA / 2), Color.Transparent), + center = c, + radius = rx, + ), + radius = rx, + center = c, + ) + } +} + +/** + * A heading tape across the top at [topPx], scaled to the camera so its ticks sit over the directions they name. + * [trueHeadingDeg] is at the center; each target shows as a marker at its true bearing, larger when focused, so + * everything around the user is visible without turning. + */ +@Suppress("LongParameterList") +internal fun DrawScope.drawCompassTape( + trueHeadingDeg: Double, + declinationDeg: Float, + targets: List, + focalPx: Float, + topPx: Float, + cardinals: Cardinals, + textMeasurer: TextMeasurer, + labelStyle: TextStyle, +) { + val pxPerDeg = focalPx * (PI / HALF_TURN).toFloat() + val height = TapeHeight.toPx() + val centerX = size.width / 2 + drawRect(TapeScrim, Offset(0f, topPx), Size(size.width, height)) + val halfSpanDeg = centerX / pxPerDeg + val first = ceil((trueHeadingDeg - halfSpanDeg) / TAPE_MINOR_DEG).toInt() * TAPE_MINOR_DEG + val last = floor((trueHeadingDeg + halfSpanDeg) / TAPE_MINOR_DEG).toInt() * TAPE_MINOR_DEG + for (deg in first..last step TAPE_MINOR_DEG) { + val x = centerX + ((deg - trueHeadingDeg) * pxPerDeg).toFloat() + val bearing = ((deg % FULL_TURN) + FULL_TURN) % FULL_TURN + val major = bearing % TAPE_MAJOR_DEG == 0 + val tick = if (major) TapeMajorTick.toPx() else TapeMinorTick.toPx() + drawLine(Color.White, Offset(x, topPx + height - tick), Offset(x, topPx + height), 1.dp.toPx()) + val label = cardinals.at(bearing) ?: bearing.takeIf { it % TAPE_LABEL_DEG == 0 }?.toString() + if (label != null) { + val layout = textMeasurer.measure(label, labelStyle) + drawText(layout, topLeft = Offset(x - layout.size.width / 2f, topPx + 2.dp.toPx())) + } + } + targets.forEach { target -> + val trueBearing = target.bearingDeg + declinationDeg + val offset = signedTurnDeg(trueHeadingDeg, trueBearing) + if (abs(offset) > halfSpanDeg) return@forEach + val x = centerX + (offset * pxPerDeg).toFloat() + val r = if (target.focused) TapeMarker.toPx() * 2 else TapeMarker.toPx() + drawCircle(Color(target.subject.color), radius = r, center = Offset(x, topPx + height - r)) + } + val caret = + Path().apply { + moveTo(centerX, topPx + height + 6.dp.toPx()) + lineTo(centerX - 6.dp.toPx(), topPx + height + 14.dp.toPx()) + lineTo(centerX + 6.dp.toPx(), topPx + height + 14.dp.toPx()) + close() + } + drawPath(caret, Color.White) +} + +/** The finder's center ring, filled with the target's color once it is on target. */ +internal fun DrawScope.drawReticle(color: Color, locked: Boolean) { + val radius = ReticleRadius.toPx() + if (locked) drawCircle(color.copy(alpha = LOCKED_FILL_ALPHA), radius = radius) + drawCircle(if (locked) color else Color.White, radius = radius, style = Stroke(width = 2.dp.toPx())) + val tick = 8.dp.toPx() + val c = center + listOf(Offset(1f, 0f), Offset(-1f, 0f), Offset(0f, 1f), Offset(0f, -1f)).forEach { d -> + drawLine(Color.White, c + d * radius, c + d * (radius + tick), 2.dp.toPx()) + } + drawCircle(Color.White, radius = 2.dp.toPx(), center = c) +} + +/** Where the phone's camera sits above the ground it stands on, for drawing things that lie on that ground. */ +private const val EYE_HEIGHT_M = 1.5 + +/** How tall a geofence's wall stands, so the fence reads as a boundary rather than a line on the grass. */ +private const val FENCE_WALL_M = 3.0 +private const val FENCE_WALL_ALPHA = 0.3f +private const val FENCE_LINE_ALPHA = 0.85f +private val FenceColor = Color(0xFFFFB300) +private val FenceLineWidth = 2.dp + +/** The direction to a fence point [riseM] above the phone's height. */ +private fun groundDirection(point: FencePoint, riseM: Double) = + directionEnu(point.bearingDeg, elevationDeg(point.distanceM, riseM)) + +/** + * A geofence as a wall standing on the ground along its edge, solid at the foot and fading upward, so the boundary + * reads in depth from inside or outside. Segments that fall behind the camera are left out. + */ +internal fun DrawScope.drawGeofence(fence: HudFence, pose: CameraPose, focalPx: Float) { + fun at(point: FencePoint, riseM: Double) = project( + groundDirection(point, riseM), + pose, + focalPx, + size.width, + size.height, + ) + ?.let { Offset(it.x, it.y) } + val edge = fence.edge + edge.indices.forEach { i -> + val p = edge[i] + val q = edge[(i + 1) % edge.size] + // The foot line is clipped at the near plane, so the boundary runs to the view's edge from inside the fence. + val clippedFoot = clipToNearPlane(groundDirection(p, -EYE_HEIGHT_M), groundDirection(q, -EYE_HEIGHT_M), pose) + clippedFoot?.let { (a, b) -> + val fa = project(a, pose, focalPx, size.width, size.height) + val fb = project(b, pose, focalPx, size.width, size.height) + if (fa != null && fb != null) { + drawLine( + FenceColor.copy(alpha = FENCE_LINE_ALPHA), + Offset(fa.x, fa.y), + Offset(fb.x, fb.y), + FenceLineWidth.toPx(), + ) + } + } + // The wall is decoration: drawn only where its whole segment is in front of the camera. + val footP = at(p, -EYE_HEIGHT_M) ?: return@forEach + val footQ = at(q, -EYE_HEIGHT_M) ?: return@forEach + val topP = at(p, FENCE_WALL_M - EYE_HEIGHT_M) ?: return@forEach + val topQ = at(q, FENCE_WALL_M - EYE_HEIGHT_M) ?: return@forEach + val wall = + Path().apply { + moveTo(footP.x, footP.y) + lineTo(footQ.x, footQ.y) + lineTo(topQ.x, topQ.y) + lineTo(topP.x, topP.y) + close() + } + drawPath( + wall, + Brush.linearGradient( + colors = listOf(FenceColor.copy(alpha = FENCE_WALL_ALPHA), Color.Transparent), + start = (footP + footQ) / 2f, + end = (topP + topQ) / 2f, + ), + ) + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt new file mode 100644 index 00000000000..27e5967054e --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.model.util.toDistanceString +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.chirpy +import org.meshtastic.core.resources.compass_location_disabled +import org.meshtastic.core.resources.compass_no_location_fix +import org.meshtastic.core.resources.compass_no_magnetometer +import org.meshtastic.core.resources.img_chirpy +import org.meshtastic.core.resources.map_filter_title +import org.meshtastic.core.resources.mesh_hud_camera_unavailable +import org.meshtastic.core.resources.mesh_hud_chirpy_says +import org.meshtastic.core.resources.mesh_hud_coach_dismiss +import org.meshtastic.core.resources.mesh_hud_coach_find +import org.meshtastic.core.resources.mesh_hud_coach_point +import org.meshtastic.core.resources.mesh_hud_coach_tap +import org.meshtastic.core.resources.mesh_hud_elevation_approximate +import org.meshtastic.core.resources.mesh_hud_finding +import org.meshtastic.core.resources.mesh_hud_heading +import org.meshtastic.core.resources.mesh_hud_heading_accuracy +import org.meshtastic.core.resources.mesh_hud_heading_low_accuracy +import org.meshtastic.core.resources.mesh_hud_inside_geofence +import org.meshtastic.core.resources.mesh_hud_inside_unnamed_geofence +import org.meshtastic.core.resources.mesh_hud_location_rationale +import org.meshtastic.core.resources.mesh_hud_nearby +import org.meshtastic.core.resources.mesh_hud_no_nodes +import org.meshtastic.core.resources.mesh_hud_on_target +import org.meshtastic.core.resources.mesh_hud_own_accuracy +import org.meshtastic.core.resources.mesh_hud_thermal_critical +import org.meshtastic.core.resources.mesh_hud_thermal_hot +import org.meshtastic.core.resources.mesh_hud_tilt_down +import org.meshtastic.core.resources.mesh_hud_tilt_up +import org.meshtastic.core.resources.mesh_hud_turn_left +import org.meshtastic.core.resources.mesh_hud_turn_right +import org.meshtastic.core.resources.mesh_hud_waiting_sensor +import org.meshtastic.core.resources.mesh_hud_watch_step +import org.meshtastic.core.resources.navigate_back +import org.meshtastic.core.resources.traceroute_outgoing_route +import org.meshtastic.core.resources.traceroute_return_route +import org.meshtastic.core.resources.traceroute_showing_nodes +import org.meshtastic.core.ui.component.PermissionRecoveryCard +import org.meshtastic.core.ui.icon.ArrowBack +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.MyLocation +import org.meshtastic.core.ui.icon.Tune +import org.meshtastic.core.ui.theme.TracerouteColors +import org.meshtastic.core.ui.util.PermissionUiState + +/** Dark enough that white label text keeps about 4.5:1 over a white sky. */ +internal const val HUD_SCRIM_ALPHA = 0.8f +private val HudScrim = Color.Black.copy(alpha = HUD_SCRIM_ALPHA) +private const val COACH_BACKDROP_ALPHA = 0.5f +private val CoachMaxWidth = 360.dp + +/** Where the finder's target is from the center of the view, in whole hint steps. */ +internal sealed interface FinderHint { + data object OnTarget : FinderHint + + data class Turn(val degrees: Int, val right: Boolean) : FinderHint + + data class Tilt(val degrees: Int, val up: Boolean) : FinderHint +} + +/** What the sensors and the phone itself report, for the top bar. */ +internal data class HudSensorStatus( + val headingDeg: Int?, + val headingAccuracyDeg: Int?, + val hasSensor: Boolean, + val needsCalibration: Boolean, + val waitingForSensor: Boolean, + val thermal: HudThermal, +) + +/** The heading and the phone's own accuracy beside it; [onOpenFilters] is null in a view scoped to one purpose. */ +@Composable +internal fun HudTopBar( + status: HudSensorStatus, + targets: MeshHudTargets, + onNavigateUp: () -> Unit, + onOpenFilters: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onNavigateUp) { + Icon(MeshtasticIcons.ArrowBack, stringResource(Res.string.navigate_back), tint = Color.White) + } + status.headingDeg?.let { heading -> + val headingText = formatString("%d°", heading) + HudLabel( + status.headingAccuracyDeg?.let { + stringResource(Res.string.mesh_hud_heading_accuracy, headingText, formatString("%d°", it)) + } ?: stringResource(Res.string.mesh_hud_heading, headingText), + ) + } + targets.ownAccuracyM?.let { accuracy -> + Spacer(Modifier.width(6.dp)) + HudLabel( + stringResource( + Res.string.mesh_hud_own_accuracy, + accuracy.toInt().coerceAtLeast(1).toDistanceString(targets.units), + ), + ) + } + Spacer(Modifier.weight(1f)) + onOpenFilters?.let { open -> + IconButton(onClick = open) { + Icon(MeshtasticIcons.Tune, stringResource(Res.string.map_filter_title), tint = Color.White) + } + } + } +} + +/** Sensor and phone notices, under the compass tape so they never push it down. */ +@Composable +internal fun HudTopNotices(status: HudSensorStatus, modifier: Modifier = Modifier) { + Column(modifier.padding(horizontal = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + when { + !status.hasSensor -> HudLabel(stringResource(Res.string.compass_no_magnetometer)) + status.waitingForSensor -> HudLabel(stringResource(Res.string.mesh_hud_waiting_sensor)) + status.needsCalibration -> HudLabel(stringResource(Res.string.mesh_hud_heading_low_accuracy)) + } + when (status.thermal) { + HudThermal.NORMAL -> Unit + HudThermal.HOT -> HudLabel(stringResource(Res.string.mesh_hud_thermal_hot)) + HudThermal.CRITICAL -> HudLabel(stringResource(Res.string.mesh_hud_thermal_critical)) + } + } +} + +/** What the bottom panel reports besides the targets themselves. */ +internal data class HudNotices(val cameraUnavailable: Boolean, val walking: Boolean) + +@Suppress("LongParameterList") +@Composable +internal fun HudBottomPanel( + targets: MeshHudTargets, + hint: FinderHint?, + route: HudRoute?, + notices: HudNotices, + location: PermissionUiState, + onOpenLocationSettings: () -> Unit, + radar: @Composable () -> Unit, + cameraContent: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier.fillMaxWidth().padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (notices.walking) HudLabel(stringResource(Res.string.mesh_hud_watch_step)) + targets.finding?.let { + HudLabel( + stringResource(Res.string.mesh_hud_finding, it.subject.spokenName(), it.distanceText), + Modifier.align(Alignment.CenterHorizontally), + ) + } + hint?.let { FinderHintLabel(it, Modifier.align(Alignment.CenterHorizontally)) } + route?.let { RouteLegend(it) } + cameraContent() + if (notices.cameraUnavailable) HudLabel(stringResource(Res.string.mesh_hud_camera_unavailable)) + LocationWarnings(targets.warnings, location, onOpenLocationSettings) + targets.fences + .filter { it.inside } + .forEach { fence -> + HudLabel( + if (fence.name.isNotBlank()) { + stringResource(Res.string.mesh_hud_inside_geofence, fence.name) + } else { + stringResource(Res.string.mesh_hud_inside_unnamed_geofence) + }, + ) + } + if (targets.ready && route == null && targets.showsNothing) { + HudLabel(stringResource(Res.string.mesh_hud_no_nodes)) + } + if (targets.elevationApproximate && targets.targets.isNotEmpty()) { + HudLabel(stringResource(Res.string.mesh_hud_elevation_approximate)) + } + if (targets.directionUnknown.isNotEmpty()) { + HudLabel(stringResource(Res.string.mesh_hud_nearby)) + radar() + } + } +} + +/** Each location warning with the way out of it: the permission card, or the system's location settings. */ +@Composable +private fun LocationWarnings( + warnings: List, + location: PermissionUiState, + onOpenLocationSettings: () -> Unit, +) { + warnings.forEach { warning -> + when (warning) { + HudWarning.NO_LOCATION_PERMISSION -> + PermissionRecoveryCard(location, stringResource(Res.string.mesh_hud_location_rationale)) + + HudWarning.LOCATION_DISABLED -> + Button(onClick = onOpenLocationSettings) { + Icon(MeshtasticIcons.MyLocation, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(stringResource(Res.string.compass_location_disabled)) + } + + HudWarning.NO_LOCATION_FIX -> HudLabel(stringResource(Res.string.compass_no_location_fix)) + } + } +} + +/** + * The first open's coaching: how to hold the phone, and what a tap and a long press do. Shown once, over the view, and + * gone at the first tap on its button. + */ +@Composable +internal fun HudCoach(onDismiss: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier.fillMaxSize().background(Color.Black.copy(alpha = COACH_BACKDROP_ALPHA)), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = + Modifier.widthIn(max = CoachMaxWidth) + .padding(24.dp) + .background(HudScrim, MaterialTheme.shapes.large) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + ChirpyWarning(Modifier.align(Alignment.CenterHorizontally)) + listOf(Res.string.mesh_hud_coach_point, Res.string.mesh_hud_coach_tap, Res.string.mesh_hud_coach_find) + .forEach { Text(stringResource(it), color = Color.White, style = MaterialTheme.typography.bodyLarge) } + Button(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { + Text(stringResource(Res.string.mesh_hud_coach_dismiss)) + } + } + } +} + +/** Chirpy's word on what camera, GPS and sensors running together cost, as on the firmware update's warning. */ +@Composable +private fun ChirpyWarning(modifier: Modifier = Modifier) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = "🔋🔥", style = MaterialTheme.typography.headlineLarge) + Image( + painter = painterResource(Res.drawable.img_chirpy), + contentScale = ContentScale.Fit, + contentDescription = stringResource(Res.string.chirpy), + modifier = Modifier.size(48.dp), + ) + } + Text( + text = stringResource(Res.string.mesh_hud_chirpy_says), + color = Color.White, + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + ) + } +} + +/** The traceroute's two colors, as on the map, and how many of its nodes have a position to draw. */ +@Composable +private fun RouteLegend(route: HudRoute) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + HudLabel(stringResource(Res.string.traceroute_showing_nodes, route.mappable, route.total)) + HudLabel( + stringResource(Res.string.traceroute_outgoing_route), + Modifier.border(2.dp, TracerouteColors.OutgoingRoute, MaterialTheme.shapes.small), + ) + if (route.back != null) { + HudLabel( + stringResource(Res.string.traceroute_return_route), + Modifier.border(2.dp, TracerouteColors.ReturnRoute, MaterialTheme.shapes.small), + ) + } + } +} + +@Composable +private fun FinderHintLabel(hint: FinderHint, modifier: Modifier = Modifier) { + val text = + when (hint) { + FinderHint.OnTarget -> stringResource(Res.string.mesh_hud_on_target) + + is FinderHint.Turn -> + stringResource( + if (hint.right) Res.string.mesh_hud_turn_right else Res.string.mesh_hud_turn_left, + hint.degrees, + ) + + is FinderHint.Tilt -> + stringResource( + if (hint.up) Res.string.mesh_hud_tilt_up else Res.string.mesh_hud_tilt_down, + hint.degrees, + ) + } + Text( + text = text, + color = Color.White, + style = MaterialTheme.typography.titleMedium, + modifier = + modifier + .semantics { liveRegion = LiveRegionMode.Polite } + .background(HudScrim, MaterialTheme.shapes.medium) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) +} + +@Composable +internal fun HudLabel(text: String, modifier: Modifier = Modifier) { + Text( + text = text, + color = Color.White, + style = MaterialTheme.typography.labelMedium, + modifier = + modifier.background(HudScrim, MaterialTheme.shapes.small).padding(horizontal = 6.dp, vertical = 2.dp), + ) +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPaths.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPaths.kt new file mode 100644 index 00000000000..bbc53a7140a --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPaths.kt @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import org.meshtastic.core.common.util.bearing +import org.meshtastic.core.common.util.latLongToMeter +import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.model.Position +import org.meshtastic.core.model.hasFix +import org.meshtastic.core.model.util.fixTimeSecondsOrNull +import org.meshtastic.core.model.util.uncertaintyRadiusMetersOrNull +import org.meshtastic.core.repository.PhoneLocation +import kotlin.math.PI +import kotlin.math.hypot +import kotlin.math.sin +import org.meshtastic.proto.Position as ProtoPosition + +private const val FULL_CIRCLE = 360.0 +private const val SNR_UNKNOWN = -128 +private const val QUARTER_DB = 4f +private const val ARC_SAMPLES = 24 +private const val ARC_LIFT_FRACTION = 0.15 +private const val ARC_MIN_LIFT_DEG = 2.0 +private const val ARC_MAX_LIFT_DEG = 15.0 + +/** Points this close are drawn from the viewer: the phone's own fix cannot tell them apart from where it stands. */ +private const val MIN_VIEWER_RADIUS_M = 5.0 + +/** The most breadcrumbs a track draws, as the Google track map caps them. */ +private const val MAX_TRACK_POINTS = 500 + +/** + * A point on a path through the air, seen from the phone. [bearingDeg] is magnetic, like the camera's. [atViewer] is + * true when the point is inside the phone's own accuracy, so the path starts from the viewer rather than a direction. + */ +data class PathPoint(val bearingDeg: Double, val elevationDeg: Double, val distanceM: Double, val atViewer: Boolean) + +/** + * One direction of a traceroute. [points] runs hop by hop, null for a hop with no position; [snrDb] holds the signal of + * the link into each hop after the first, null where it was not reported. + */ +data class RouteLeg( + val nodeNums: List, + val points: List, + val snrDb: List, + val forward: Boolean, +) + +/** A traceroute to draw: its legs, and how many of its known nodes have a position to draw them at. */ +data class HudRoute(val forward: RouteLeg, val back: RouteLeg?, val mappable: Int, val total: Int) { + /** Node nums the route passes through, the relays the user wants to see whatever the map filters say. */ + val nodeNums: Set + get() = (forward.nodeNums + back?.nodeNums.orEmpty()).filterTo(mutableSetOf()) { it != UNKNOWN_HOP } +} + +data class TrackPoint(val point: PathPoint, val ageSec: Long?, val altitudeKnown: Boolean) + +/** A node's position history as breadcrumbs, oldest first, in the node's [color]. */ +data class HudTrack(val nodeNum: Int, val color: Int, val points: List) + +/** The node num a traceroute uses for a relay it could not identify. */ +internal const val UNKNOWN_HOP = NodeAddress.NODENUM_BROADCAST + +/** Where [position] is from here, or null when it has no fix. */ +private fun PhoneLocation.pathPoint(position: ProtoPosition, declination: Float): PathPoint? { + if (!position.hasFix()) return null + val lat = Position.degD(position.latitude_i ?: 0) + val lon = Position.degD(position.longitude_i ?: 0) + val distance = latLongToMeter(latitude, longitude, lat, lon) + val reach = + maxOf(MIN_VIEWER_RADIUS_M, hypot(position.uncertaintyRadiusMetersOrNull() ?: 0.0, accuracyM?.toDouble() ?: 0.0)) + return PathPoint( + bearingDeg = (bearing(latitude, longitude, lat, lon) - declination + FULL_CIRCLE) % FULL_CIRCLE, + elevationDeg = elevationDeg(distance, riseTo(position)), + distanceM = distance, + atViewer = distance <= reach, + ) +} + +/** Quarter-dB link SNRs as dB, valid only when there is one per link of [route]; unknowns become null. */ +private fun snrFor(route: List, quarterDb: List?): List = + if (quarterDb != null && quarterDb.size == route.size - 1) { + quarterDb.map { q -> q.takeIf { it != SNR_UNKNOWN }?.let { it / QUARTER_DB } } + } else { + List((route.size - 1).coerceAtLeast(0)) { null } + } + +/** The phone itself, where a leg to or from the connected radio starts or ends. */ +private val ViewerPoint = PathPoint(bearingDeg = 0.0, elevationDeg = 0.0, distanceM = 0.0, atViewer = true) + +/** + * A traceroute as legs of points from here. Each hop takes [positionOf]'s answer, which should prefer the position the + * route was recorded with and fall back to the live one, so a hop is only blank when neither is known. [viewerNum], the + * connected radio, is always at the viewer: it is within Bluetooth or cable reach of the phone, and often has no + * position of its own. + */ +@Suppress("LongParameterList") +internal fun buildHudRoute( + here: PhoneLocation, + declination: Float, + forward: List, + back: List, + snrTowards: List?, + snrBack: List?, + viewerNum: Int? = null, + positionOf: (Int) -> ProtoPosition?, +): HudRoute { + fun pointOf(num: Int) = + if (num == viewerNum) ViewerPoint else positionOf(num)?.let { here.pathPoint(it, declination) } + fun leg(route: List, snr: List?, isForward: Boolean) = RouteLeg( + nodeNums = route, + points = route.map(::pointOf), + snrDb = snrFor(route, snr), + forward = isForward, + ) + val known = (forward + back).filter { it != UNKNOWN_HOP }.distinct() + return HudRoute( + forward = leg(forward, snrTowards, isForward = true), + back = back.takeIf { it.isNotEmpty() }?.let { leg(it, snrBack, isForward = false) }, + mappable = known.count { num -> positionOf(num)?.hasFix() == true }, + total = known.size, + ) +} + +/** A node's positions as breadcrumbs from here, oldest first, keeping the newest [MAX_TRACK_POINTS] with a fix. */ +internal fun buildHudTrack( + here: PhoneLocation, + declination: Float, + nodeNum: Int, + color: Int, + positions: List, + nowSeconds: Long, +): HudTrack { + val points = + positions + .filter { it.hasFix() } + .sortedBy { it.fixTimeSecondsOrNull() ?: 0 } + .takeLast(MAX_TRACK_POINTS) + .mapNotNull { position -> + here.pathPoint(position, declination)?.let { point -> + TrackPoint( + point = point, + ageSec = position.fixTimeSecondsOrNull()?.let { maxOf(0, nowSeconds - it) }, + altitudeKnown = position.altitude != null || position.altitude_hae != null, + ) + } + } + return HudTrack(nodeNum, color, points) +} + +/** + * The directions along a lifted arc from [a] to [b], so a link reads as a span through the air rather than a line on + * the ground. [liftSign] is 1 for the forward leg and -1 for the return, so the two directions of a route part. + */ +internal fun arcDirections(a: PathPoint, b: PathPoint, liftSign: Int, samples: Int = ARC_SAMPLES): List { + val turn = signedTurnDeg(a.bearingDeg, b.bearingDeg) + val rise = b.elevationDeg - a.elevationDeg + val lift = (hypot(turn, rise) * ARC_LIFT_FRACTION).coerceIn(ARC_MIN_LIFT_DEG, ARC_MAX_LIFT_DEG) * liftSign + return (0..samples).map { i -> + val t = i.toDouble() / samples + directionEnu(a.bearingDeg + turn * t, a.elevationDeg + rise * t + lift * sin(PI * t)) + } +} + +/** + * Clips the segment [a]-[b] to the part in front of the camera, where [project] can place it. A segment wholly behind + * gives null; one crossing the near plane has its behind end moved onto the plane, which projects far off screen in the + * right direction, so a line through the viewer's side still reaches the edge instead of wrapping around. + */ +internal fun clipToNearPlane(a: Vec3, b: Vec3, pose: CameraPose): Pair? { + val za = a dot pose.forward + val zb = b dot pose.forward + return when { + za >= NEAR_PLANE && zb >= NEAR_PLANE -> a to b + + za < NEAR_PLANE && zb < NEAR_PLANE -> null + + else -> { + val t = (NEAR_PLANE - za) / (zb - za) + val q = Vec3(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t) + if (za < NEAR_PLANE) q to b else a to q + } + } +} + +/** Just inside [project]'s own cutoff, so a point clipped onto it still projects despite rounding. */ +internal const val NEAR_PLANE = 0.051 diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt new file mode 100644 index 00000000000..84fa536662c --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.lerp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.unit.dp +import org.meshtastic.core.common.util.NumberFormatter +import org.meshtastic.core.common.util.formatString + +private const val SNR_FLOOR_DB = -20f +private const val SNR_SPAN_DB = 30f +private const val UNKNOWN_SNR_QUALITY = 0.5f +private const val ARC_MIN_ALPHA = 0.55f +private const val TRACK_LEVELS = 8 + +/** + * How far along a stem from the viewer its tag sits. The two legs' stems leave the same point, so their tags sit at + * different heights rather than on top of each other. + */ +private const val FORWARD_STEM_TAG_AT = 0.7f +private const val RETURN_STEM_TAG_AT = 0.45f +private const val OLDEST_TRACK_ALPHA = 0.25f +private val ArcMinWidth = 2.dp +private val ArcExtraWidth = 4.dp +private val TrackWidth = 3.dp +private val TrackRing = 4.dp +private val LabelPadding = 4.dp +private val LabelScrim = Color.Black.copy(alpha = HUD_SCRIM_ALPHA) +private val Dashes = floatArrayOf(18f, 12f) + +/** Projects [direction], or null when it is behind the camera. */ +private fun DrawScope.screen(direction: Vec3, pose: CameraPose, focalPx: Float): Offset? = + project(direction, pose, focalPx, size.width, size.height)?.let { Offset(it.x, it.y) } + +/** Draws the polyline through [directions], clipping each piece at the near plane so nothing wraps around the view. */ +private fun DrawScope.drawPolyline( + directions: List, + pose: CameraPose, + focalPx: Float, + color: Color, + width: Float, + dashed: Boolean, +) { + val effect = if (dashed) PathEffect.dashPathEffect(Dashes) else null + directions.zipWithNext { a, b -> + val (ca, cb) = clipToNearPlane(a, b, pose) ?: return@zipWithNext + val pa = screen(ca, pose, focalPx) ?: return@zipWithNext + val pb = screen(cb, pose, focalPx) ?: return@zipWithNext + drawLine(color, pa, pb, strokeWidth = width, cap = StrokeCap.Round, pathEffect = effect) + } +} + +/** The localized patterns for a route's apex tags, read in composition since drawing cannot reach resources. */ +internal class RouteLabels(val skippedTemplate: String, val snrTemplate: String) + +/** A small label on a dark plate, centered on [at]. */ +private fun DrawScope.drawTag(text: String, at: Offset, textMeasurer: TextMeasurer, style: TextStyle) { + val layout = textMeasurer.measure(text, style) + val pad = LabelPadding.toPx() + val box = Size(layout.size.width + 2 * pad, layout.size.height + pad) + val topLeft = at - Offset(box.width / 2, box.height / 2) + drawRoundRect(LabelScrim, topLeft, box, CornerRadius(pad)) + drawText(layout, topLeft = topLeft + Offset(pad, pad / 2)) +} + +/** + * A traceroute in the air: each link a lifted arc between its hops, the forward leg arching up and the return leg down, + * in the traceroute map's two colors. A link's reported SNR sets its width and strength and labels its apex. Hops with + * no position break the chain: the arc jumps them dashed, tagged with how many it skipped. A leg starting at the phone, + * the usual case for a handheld radio, rises from the bottom of the view. + */ +@Suppress("LongParameterList", "CyclomaticComplexMethod") +internal fun DrawScope.drawRoute( + route: HudRoute, + pose: CameraPose, + focalPx: Float, + forwardColor: Color, + backColor: Color, + textMeasurer: TextMeasurer, + labelStyle: TextStyle, + labels: RouteLabels, +) { + listOfNotNull(route.forward, route.back).forEach { leg -> + val color = if (leg.forward) forwardColor else backColor + val liftSign = if (leg.forward) 1 else -1 + var from: Int? = null + leg.points.forEachIndexed { index, point -> + if (point == null) return@forEachIndexed + val previous = from + val start = previous?.let { leg.points[it] } + if (previous != null && start != null) { + val skipped = index - previous - 1 + // One reported SNR per link, so a link across skipped hops has none. + val snr = if (skipped == 0) leg.snrDb.getOrNull(index - 1) else null + val quality = snr?.let { ((it - SNR_FLOOR_DB) / SNR_SPAN_DB).coerceIn(0f, 1f) } ?: UNKNOWN_SNR_QUALITY + val width = ArcMinWidth.toPx() + ArcExtraWidth.toPx() * quality + val stroke = color.copy(alpha = ARC_MIN_ALPHA + (1 - ARC_MIN_ALPHA) * quality) + val viewer = Offset(size.width / 2, size.height) + val tag = + when { + skipped > 0 -> formatString(labels.skippedTemplate, skipped) + snr != null -> formatString(labels.snrTemplate, NumberFormatter.format(snr, 1)) + else -> null + } + if (start.atViewer != point.atViewer) { + // A link to or from the phone itself: the forward leg leaves the viewer, the return leg comes home. + val far = if (start.atViewer) point else start + val end = screen(directionEnu(far.bearingDeg, far.elevationDeg), pose, focalPx) + if (end != null) { + drawLine(stroke, viewer, end, width, StrokeCap.Round) + // Toward the hop, so the tag stays clear of the bottom panel and the reticle. + val along = if (leg.forward) FORWARD_STEM_TAG_AT else RETURN_STEM_TAG_AT + tag?.let { drawTag(it, lerp(viewer, end, along), textMeasurer, labelStyle) } + } + } else if (!start.atViewer) { + val arc = arcDirections(start, point, liftSign) + drawPolyline(arc, pose, focalPx, stroke, width, dashed = skipped > 0) + val apex = screen(arc[arc.size / 2], pose, focalPx) + if (apex != null) tag?.let { drawTag(it, apex, textMeasurer, labelStyle) } + } + } + from = index + } + } +} + +/** + * A node's track as breadcrumbs in its color, fading with age in the same eight steps as the Google track map so the + * two views agree. A leg into a point with no altitude draws dashed, since that point only sits level as a guess. + */ +internal fun DrawScope.drawTrack(track: HudTrack, pose: CameraPose, focalPx: Float) { + val points = track.points + if (points.isEmpty()) return + val base = Color(track.color) + fun alphaAt(index: Int): Float { + val level = (index * TRACK_LEVELS / points.size).coerceAtMost(TRACK_LEVELS - 1) + return OLDEST_TRACK_ALPHA + (1 - OLDEST_TRACK_ALPHA) * level / (TRACK_LEVELS - 1) + } + fun TrackPoint.direction() = directionEnu(point.bearingDeg, point.elevationDeg) + points.zipWithNext().forEachIndexed { i, (a, b) -> + drawPolyline( + listOf(a.direction(), b.direction()), + pose, + focalPx, + base.copy(alpha = alphaAt(i + 1)), + TrackWidth.toPx(), + dashed = !b.altitudeKnown, + ) + } + points.forEachIndexed { i, p -> + if (p.point.atViewer) return@forEachIndexed + screen(p.direction(), pose, focalPx)?.let { at -> + drawCircle(base.copy(alpha = alphaAt(i)), TrackRing.toPx(), at, style = Stroke(2.dp.toPx())) + } + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt new file mode 100644 index 00000000000..245f1149d81 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.MeasurementSystem +import org.meshtastic.core.model.util.toCodePointString +import org.meshtastic.core.model.util.toDistanceString +import org.meshtastic.core.model.util.waypointIconOrDefault +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.mesh_hud_radar_description +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.pow +import kotlin.math.sin + +private const val MIN_SCALE_M = 10.0 +private const val SCALE_HEADROOM = 1.15 +private const val OWN_RING_SCALE = 1.5 +private const val NICE_STEP_TWO = 2.0 +private const val NICE_STEP_FIVE = 5.0 +private const val TEN = 10.0 +private const val RING_ALPHA = 0.5f +private const val OWN_FILL_ALPHA = 0.18f +private const val NODE_RING_ALPHA = 0.25f +private val CompactRadar = 168.dp +private val ExpandedRadar = 300.dp +private val RadarDot = 30.dp +private val RadarScrim = Color.Black.copy(alpha = HUD_SCRIM_ALPHA) + +/** The smallest of 1, 2 or 5 times a power of ten that is at least [meters]. */ +internal fun niceScaleM(meters: Double): Double { + val magnitude = TEN.pow(floor(log10(meters))) + return listOf(1.0, NICE_STEP_TWO, NICE_STEP_FIVE, TEN).map { it * magnitude }.first { it >= meters } +} + +/** + * Where a nearby target sits on a heading-up radar of [radiusPx], [scaleM] to the rim, as an offset from its center: + * the camera's heading [headingDeg] points up the screen, like the view behind it. + */ +internal fun radarOffset(target: NearbyTarget, headingDeg: Double, scaleM: Double, radiusPx: Float): Offset { + val rel = (target.bearingDeg - headingDeg).toRadians() + val r = (target.distanceM / scaleM).coerceAtMost(1.0) * radiusPx + return Offset((r * sin(rel)).toFloat(), (-r * cos(rel)).toFloat()) +} + +/** + * A heading-up plan view of the targets too close to point at, with the phone at the center inside its own accuracy + * ring and each target inside its precision ring. Tilting the phone toward the ground [expanded]s it, since that is the + * posture of someone looking for something close by. The open [route] and [track] are drawn under the dots. The heading + * is read in layout and draw, so it turns with the camera without recomposing. + */ +@Suppress("LongMethod") +@Composable +internal fun NearbyRadar( + nearby: List, + ownAccuracyM: Float?, + units: MeasurementSystem, + northLabel: String, + declinationDeg: Float, + currentHeadingDeg: () -> Double?, + expanded: Boolean, + onOpen: (HudSubject) -> Unit, + modifier: Modifier = Modifier, + route: HudRoute? = null, + track: HudTrack? = null, +) { + val radarSize by animateDpAsState(if (expanded) ExpandedRadar else CompactRadar) + val scaleM = + remember(nearby, ownAccuracyM) { + val farthest = nearby.maxOfOrNull { it.distanceM + it.radiusM } ?: 0.0 + niceScaleM(maxOf(MIN_SCALE_M, farthest * SCALE_HEADROOM, (ownAccuracyM ?: 0f) * OWN_RING_SCALE)) + } + val scaleText = scaleM.toInt().toDistanceString(units) + val textMeasurer = rememberTextMeasurer() + val labelStyle = MaterialTheme.typography.labelSmall.copy(color = Color.White, textAlign = TextAlign.Center) + Layout( + content = { + nearby.forEach { + key(it.subject.key) { + val description = + stringResource( + Res.string.mesh_hud_radar_description, + it.subject.spokenName(), + it.distanceM.toInt().toDistanceString(units), + ) + RadarDot(it.subject, description) { onOpen(it.subject) } + } + } + }, + modifier = + modifier.size(radarSize).clip(CircleShape).background(RadarScrim).drawBehind { + val heading = currentHeadingDeg() ?: 0.0 + val radius = this.size.minDimension / 2 + val pxPerM = radius / scaleM.toFloat() + drawCircle(Color.White.copy(alpha = RING_ALPHA), radius - 1.dp.toPx(), style = Stroke(1.dp.toPx())) + drawCircle(Color.White.copy(alpha = RING_ALPHA), radius / 2, style = Stroke(1.dp.toPx())) + ownAccuracyM?.let { drawCircle(Color.White.copy(alpha = OWN_FILL_ALPHA), it * pxPerM) } + drawRadarPaths(route, track, heading, pxPerM) + drawCircle(Color.White, 3.dp.toPx()) + nearby.forEach { target -> + if (target.radiusM > 0) { + drawCircle( + Color(target.subject.color).copy(alpha = NODE_RING_ALPHA), + (target.radiusM * pxPerM).toFloat(), + center + radarOffset(target, heading, scaleM, radius), + ) + } + } + // True north on the rim, matching the tape and heading, so the plan view reads against a map. The radar + // turns with the magnetic camera heading, and true north lies the declination west of magnetic. + val north = textMeasurer.measure(northLabel, labelStyle) + val northRad = (-declinationDeg - heading).toRadians() + val northR = radius - north.size.height + val northAt = center + Offset((northR * sin(northRad)).toFloat(), (-northR * cos(northRad)).toFloat()) + drawText(north, topLeft = northAt - Offset(north.size.width / 2f, north.size.height / 2f)) + val scale = textMeasurer.measure(scaleText, labelStyle) + drawText( + scale, + topLeft = Offset(center.x - scale.size.width / 2f, this.size.height - scale.size.height - 2f), + ) + }, + ) { measurables, constraints -> + val placeables = measurables.map { it.measure(Constraints()) } + layout(constraints.maxWidth, constraints.maxHeight) { + val heading = currentHeadingDeg() ?: 0.0 + val radius = minOf(constraints.maxWidth, constraints.maxHeight) / 2f + placeables.forEachIndexed { i, placeable -> + val at = radarOffset(nearby[i], heading, scaleM, radius) + placeable.place( + (radius + at.x - placeable.width / 2f).toInt(), + (radius + at.y - placeable.height / 2f).toInt(), + ) + } + } + } +} + +/** A compact marker for the radar: the node's color and short name, or the waypoint's emoji. */ +@Composable +private fun RadarDot(subject: HudSubject, description: String, onClick: () -> Unit) { + val label = + when (subject) { + is HudSubject.NodeSubject -> subject.node.user.short_name.ifEmpty { "?" } + is HudSubject.WaypointSubject -> subject.waypoint.icon.waypointIconOrDefault().toCodePointString() + } + val textColor = + when (subject) { + is HudSubject.NodeSubject -> Color(subject.node.colors.first) + is HudSubject.WaypointSubject -> Color.Black + } + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier.minimumInteractiveComponentSize().clickable(onClick = onClick).clearAndSetSemantics { + contentDescription = description + role = Role.Button + }, + ) { + when (subject) { + is HudSubject.NodeSubject -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(RadarDot).clip(CircleShape).background(Color(subject.color)), + ) { + Text(label, color = textColor, style = MaterialTheme.typography.labelSmall, maxLines = 1) + } + + // A place, not a node: the bare emoji, as in the camera view. + is HudSubject.WaypointSubject -> Text(label, style = MaterialTheme.typography.titleLarge) + } + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadarPaths.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadarPaths.kt new file mode 100644 index 00000000000..f2e2919e8a3 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadarPaths.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import org.meshtastic.core.ui.theme.TracerouteColors +import kotlin.math.cos +import kotlin.math.sin + +private const val RADAR_TRACK_MIN_ALPHA = 0.3f +private val RadarPathWidth = 2.dp +private val RadarReturnShift = 3.dp +private val RadarTrackRing = 3.dp +private val RadarDashes = floatArrayOf(10f, 8f) + +/** + * Where a point [distanceM] away on magnetic [bearingDeg] sits on a heading-up radar, as an offset from its center at + * [pxPerM]. Unlike [radarOffset] it does not stop at the rim, so a path toward a farther point leaves the radar heading + * the right way. + */ +internal fun radarPathOffset(bearingDeg: Double, distanceM: Double, headingDeg: Double, pxPerM: Float): Offset { + val rel = (bearingDeg - headingDeg).toRadians() + val r = distanceM * pxPerM + return Offset((r * sin(rel)).toFloat(), (-r * cos(rel)).toFloat()) +} + +/** + * The open traceroute and track in plan view, under the radar's dots: a scoped view whose nodes are all too close to + * point at still shows what it was opened for. The return leg is shifted aside so it does not hide the forward one, and + * a hop with no position is bridged dashed, as in the camera view. + */ +internal fun DrawScope.drawRadarPaths(route: HudRoute?, track: HudTrack?, headingDeg: Double, pxPerM: Float) { + fun at(p: PathPoint) = center + radarPathOffset(p.bearingDeg, p.distanceM, headingDeg, pxPerM) + track?.let { drawRadarTrack(it, ::at) } + route?.let { + drawRadarLeg(it.forward, TracerouteColors.OutgoingRoute, Offset.Zero, ::at) + it.back?.let { back -> + val shift = RadarReturnShift.toPx() + drawRadarLeg(back, TracerouteColors.ReturnRoute, Offset(shift, shift), ::at) + } + } +} + +private fun DrawScope.drawRadarLeg(leg: RouteLeg, color: Color, shift: Offset, at: (PathPoint) -> Offset) { + var previous: Offset? = null + var skipped = false + leg.points.forEach { point -> + if (point == null) { + skipped = previous != null + return@forEach + } + val here = at(point) + shift + previous?.let { from -> + drawLine( + color, + from, + here, + RadarPathWidth.toPx(), + StrokeCap.Round, + pathEffect = if (skipped) PathEffect.dashPathEffect(RadarDashes) else null, + ) + } + previous = here + skipped = false + } +} + +private fun DrawScope.drawRadarTrack(track: HudTrack, at: (PathPoint) -> Offset) { + val points = track.points.map { at(it.point) } + if (points.isEmpty()) return + val base = Color(track.color) + fun alphaAt(index: Int) = RADAR_TRACK_MIN_ALPHA + (1 - RADAR_TRACK_MIN_ALPHA) * (index + 1) / points.size.toFloat() + points.zipWithNext().forEachIndexed { i, (a, b) -> + drawLine(base.copy(alpha = alphaAt(i + 1)), a, b, RadarPathWidth.toPx(), StrokeCap.Round) + } + points.forEachIndexed { i, p -> + drawCircle(base.copy(alpha = alphaAt(i)), RadarTrackRing.toPx(), p, style = Stroke(1.dp.toPx())) + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt new file mode 100644 index 00000000000..b11a0e9bc92 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import org.meshtastic.core.common.util.MeasurementSystem +import org.meshtastic.core.common.util.bearing +import org.meshtastic.core.common.util.latLongToMeter +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.Position +import org.meshtastic.core.model.geofence.Geofence +import org.meshtastic.core.model.geofence.toGeofence +import org.meshtastic.core.model.util.fixTimeSecondsOrNull +import org.meshtastic.core.model.util.toDistanceString +import org.meshtastic.core.model.util.toElevationString +import org.meshtastic.core.model.util.uncertaintyRadiusMetersOrNull +import org.meshtastic.core.model.util.verticalUncertaintyMetersOrNull +import org.meshtastic.core.repository.PhoneLocation +import org.meshtastic.core.repository.PhoneLocationState +import org.meshtastic.proto.Waypoint +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.roundToInt +import kotlin.math.sin +import org.meshtastic.proto.Position as ProtoPosition + +private const val FULL_CIRCLE = 360.0 +private const val WAYPOINT_COLOR = 0xFFFFFFFF.toInt() + +/** What a chip stands for. [key] is stable across updates and unique across both kinds. */ +sealed interface HudSubject { + val key: String + + /** ARGB fill for the chip's anchor dot and precision bar. */ + val color: Int + + data class NodeSubject(val node: Node) : HudSubject { + override val key: String + get() = "node-${node.num}" + + override val color: Int + get() = node.colors.second + } + + data class WaypointSubject(val waypoint: Waypoint) : HudSubject { + override val key: String + get() = "waypoint-${waypoint.id}" + + override val color: Int + get() = WAYPOINT_COLOR + } +} + +/** + * A positioned node or waypoint as the HUD draws it. [bearingDeg] is magnetic, to match the camera pose, and + * [precisionHalfAngleDeg] spans the target's uncertainty circle widened by the phone's own fix accuracy, or is null + * when neither is known. [elevationKnown] is false when the target and the phone share no altitude frame, so the target + * sits level with the phone. + */ +data class HudTarget( + val subject: HudSubject, + val bearingDeg: Double, + val elevationDeg: Double, + val distanceM: Double, + val distanceText: String, + val precisionHalfAngleDeg: Double?, + /** Half the vertical angle the target could lie within, from both altitudes' accuracy. */ + val elevationSpreadDeg: Double, + val elevationKnown: Boolean, + val positionAgeSec: Long?, + val focused: Boolean, + /** How far above or below the phone the target is, when that is known and beyond its own uncertainty. */ + val rise: HudRise? = null, +) + +/** A rise worth saying: [text] is the magnitude in the user's units, [above] its direction. */ +data class HudRise(val text: String, val above: Boolean) + +/** A rise smaller than this, or than the altitudes' own uncertainty, is not worth a label. */ +private const val MIN_SHOWN_RISE_M = 30.0 + +/** + * A target too close to point at: the phone sits inside its uncertainty circle. The radar plots it by offset instead, + * [eastM] and [northM] from the phone, inside a ring of [radiusM]. [bearingDeg] is magnetic, like the camera's. + */ +data class NearbyTarget( + val subject: HudSubject, + val eastM: Double, + val northM: Double, + val bearingDeg: Double, + val distanceM: Double, + val radiusM: Double, +) + +/** A point on a geofence's edge as seen from the phone. [bearingDeg] is magnetic, like the camera's. */ +data class FencePoint(val bearingDeg: Double, val distanceM: Double) + +/** A waypoint's geofence outline, sampled around its edge, and whether the phone stands inside it. */ +data class HudFence(val key: String, val name: String, val edge: List, val inside: Boolean) + +enum class HudWarning { + NO_LOCATION_PERMISSION, + LOCATION_DISABLED, + NO_LOCATION_FIX, +} + +data class MeshHudTargets( + /** + * Sorted focused first, then nearest, so the declutter pass keeps the focused chip on its point and stacks farther + * chips above nearer ones. + */ + val targets: List = emptyList(), + /** Nodes and waypoints whose uncertainty circle contains the phone, so their direction means nothing. */ + val directionUnknown: List = emptyList(), + val fences: List = emptyList(), + /** The phone's own fix accuracy after averaging, in meters, and how many fixes went into it. */ + val ownAccuracyM: Float? = null, + val averagedFixes: Int = 0, + /** True when the phone's fix has no altitude, so every target sits level with it. */ + val elevationApproximate: Boolean = false, + /** Added to the camera's magnetic azimuth to give a true heading. */ + val declinationDeg: Float = 0f, + val units: MeasurementSystem = MeasurementSystem.METRIC, + /** True when the view was opened for one purpose, so it shows only that and has no filters to change. */ + val scoped: Boolean = false, + val warnings: List = emptyList(), + /** False until the first computation, so the screen does not report an empty mesh before it has looked. */ + val ready: Boolean = false, + /** True while the phone is carried at walking pace, so the screen can remind the user to look up. */ + val moving: Boolean = false, + /** The map's precision circle setting, which the precision zones follow. */ + val showPrecision: Boolean = true, + /** The target the finder is on, whether placed in the view or on the radar. */ + val finding: HudFinding? = null, +) + +/** True when there is nothing to show and no warning explaining why. */ +internal val MeshHudTargets.showsNothing: Boolean + get() = warnings.isEmpty() && targets.isEmpty() && directionUnknown.isEmpty() + +/** What the finder is looking for and how far away it is. */ +data class HudFinding(val subject: HudSubject, val distanceText: String) + +/** + * The HUD's view of [nodes] and [waypoints] from where the phone is. Pure, so the rules are testable without sensors: + * targets inside their uncertainty circle go to [MeshHudTargets.directionUnknown], and the rest are sorted nearest + * first. + */ +@Suppress("LongParameterList") +internal fun buildHudTargets( + locationState: PhoneLocationState, + nodes: List, + waypoints: List, + units: MeasurementSystem, + focusKey: String?, + nowSeconds: Long, + declinationOf: (PhoneLocation) -> Float, +): MeshHudTargets { + val warnings = locationWarnings(locationState) + val here = locationState.location?.takeIf { warnings.isEmpty() } ?: return MeshHudTargets(warnings = warnings) + val declination = declinationOf(here) + val placed = mutableListOf() + val unknown = mutableListOf() + nodes.forEach { node -> + val subject = HudSubject.NodeSubject(node) + val target = + here.targetAt( + subject = subject, + latitude = node.latitude, + longitude = node.longitude, + targetRadiusM = node.position.uncertaintyRadiusMetersOrNull(), + riseM = here.riseTo(node.position), + declination = declination, + units = units, + targetVerticalM = node.position.verticalUncertaintyMetersOrNull(), + ) + if (target == null) { + unknown += + here.nearby( + subject, + node.latitude, + node.longitude, + node.position.uncertaintyRadiusMetersOrNull(), + declination, + ) + } else { + placed += + target.copy( + positionAgeSec = node.position.fixTimeSecondsOrNull()?.let { maxOf(0, nowSeconds - it) }, + focused = subject.key == focusKey, + ) + } + } + waypoints.forEach { waypoint -> + val lat = waypoint.latitude_i?.let { Position.degD(it) } ?: return@forEach + val lon = waypoint.longitude_i?.let { Position.degD(it) } ?: return@forEach + val subject = HudSubject.WaypointSubject(waypoint) + val target = here.targetAt(subject, lat, lon, null, null, declination, units) + if (target == null) { + unknown += here.nearby(subject, lat, lon, null, declination) + } else { + placed += target.copy(focused = subject.key == focusKey) + } + } + return MeshHudTargets( + targets = placed.sortedWith(compareByDescending { it.focused }.thenBy { it.distanceM }), + directionUnknown = unknown, + fences = waypoints.mapNotNull { here.fenceFor(it, declination) }, + elevationApproximate = here.altitude == null && here.mslAltitude == null, + ownAccuracyM = here.accuracyM, + declinationDeg = declination, + ) +} + +/** + * How far above the phone [position] is, in one altitude frame: ellipsoid to ellipsoid when both report it, else sea + * level to sea level, else the phone's ellipsoid height moved to sea level by the node's geoid separation (the geoid is + * near flat over mesh distances). Null when no frame is shared. + */ +internal fun PhoneLocation.riseTo(position: ProtoPosition): Double? { + val nodeHae = position.altitude_hae + val nodeMsl = position.altitude + val separation = position.altitude_geoidal_separation + val phoneHae = altitude + val phoneMsl = mslAltitude + return when { + nodeHae != null && phoneHae != null -> nodeHae - phoneHae + nodeMsl != null && phoneMsl != null -> nodeMsl - phoneMsl + nodeMsl != null && phoneHae != null && separation != null -> nodeMsl - (phoneHae - separation) + else -> null + } +} + +/** Null when the phone sits inside the target's uncertainty circle widened by its own fix accuracy. */ +@Suppress("LongParameterList") +private fun PhoneLocation.targetAt( + subject: HudSubject, + latitude: Double, + longitude: Double, + targetRadiusM: Double?, + riseM: Double?, + declination: Float, + units: MeasurementSystem, + targetVerticalM: Double? = null, +): HudTarget? { + val distance = latLongToMeter(this.latitude, this.longitude, latitude, longitude) + // The two errors are independent, so they combine as a root sum of squares, not a sum. + val radius = hypot(targetRadiusM ?: 0.0, accuracyM?.toDouble() ?: 0.0) + val halfAngle = + if (radius > 0.0) { + precisionHalfAngleDeg(radius, distance) ?: return null + } else { + null + } + val trueBearing = bearing(this.latitude, this.longitude, latitude, longitude) + val verticalM = hypot(targetVerticalM ?: 0.0, verticalAccuracyM?.toDouble() ?: 0.0) + return HudTarget( + subject = subject, + bearingDeg = (trueBearing - declination + FULL_CIRCLE) % FULL_CIRCLE, + elevationDeg = elevationDeg(distance, riseM), + distanceM = distance, + distanceText = distance.toInt().toDistanceString(units), + precisionHalfAngleDeg = halfAngle, + elevationSpreadDeg = elevationSpreadDeg(distance, verticalM, riseM != null), + elevationKnown = riseM != null, + positionAgeSec = null, + focused = false, + rise = + riseM + ?.takeIf { abs(it) > maxOf(MIN_SHOWN_RISE_M, verticalM) } + ?.let { HudRise(abs(it).roundToInt().toElevationString(units), above = it > 0) }, + ) +} + +private fun PhoneLocation.nearby( + subject: HudSubject, + latitude: Double, + longitude: Double, + targetRadiusM: Double?, + declination: Float, +): NearbyTarget { + val distance = latLongToMeter(this.latitude, this.longitude, latitude, longitude) + val trueBearing = bearing(this.latitude, this.longitude, latitude, longitude) + val rad = trueBearing.toRadians() + return NearbyTarget( + subject = subject, + eastM = distance * sin(rad), + northM = distance * cos(rad), + bearingDeg = (trueBearing - declination + FULL_CIRCLE) % FULL_CIRCLE, + distanceM = distance, + radiusM = targetRadiusM ?: 0.0, + ) +} + +private const val CIRCLE_SAMPLES = 64 +private const val BOX_EDGE_SAMPLES = 16 +private const val METERS_PER_DEGREE_LAT = 111_320.0 + +/** The waypoint's geofence as edge points around the phone, or null when it has none. */ +private fun PhoneLocation.fenceFor(waypoint: Waypoint, declination: Float): HudFence? { + val fence = waypoint.toGeofence() + return fence?.outline()?.let { outline -> + HudFence( + key = "fence-${waypoint.id}", + name = waypoint.name, + edge = + outline.map { (lat, lon) -> + FencePoint( + bearingDeg = (bearing(latitude, longitude, lat, lon) - declination + FULL_CIRCLE) % FULL_CIRCLE, + distanceM = latLongToMeter(latitude, longitude, lat, lon), + ) + }, + inside = fence.contains(latitude, longitude), + ) + } +} + +/** + * Points around the fence's edge, the circle when it has one, else the box. Fences are a few hundred meters to a few + * kilometers across, where a flat-earth offset is well within GPS error. + */ +private fun Geofence.outline(): List>? = circle?.let { c -> + (0 until CIRCLE_SAMPLES).map { i -> + val theta = 2 * PI * i / CIRCLE_SAMPLES + val dLat = c.radiusMeters * cos(theta) / METERS_PER_DEGREE_LAT + val dLon = c.radiusMeters * sin(theta) / (METERS_PER_DEGREE_LAT * cos(c.centerLat.toRadians())) + (c.centerLat + dLat) to (c.centerLon + dLon) + } +} + ?: box?.let { b -> + val corners = listOf(b.south to b.west, b.north to b.west, b.north to b.east, b.south to b.east) + corners.indices.flatMap { i -> + val (lat0, lon0) = corners[i] + val (lat1, lon1) = corners[(i + 1) % corners.size] + (0 until BOX_EDGE_SAMPLES).map { k -> + val t = k.toDouble() / BOX_EDGE_SAMPLES + (lat0 + (lat1 - lat0) * t) to (lon0 + (lon1 - lon0) * t) + } + } + } + +private fun locationWarnings(state: PhoneLocationState): List = when { + !state.permissionGranted -> listOf(HudWarning.NO_LOCATION_PERMISSION) + !state.providerEnabled -> listOf(HudWarning.LOCATION_DISABLED) + !state.hasFix -> listOf(HudWarning.NO_LOCATION_FIX) + else -> emptyList() +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt new file mode 100644 index 00000000000..b029811e1ef --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt @@ -0,0 +1,425 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.mesh_hud_camera_rationale +import org.meshtastic.core.resources.mesh_hud_cardinal_east +import org.meshtastic.core.resources.mesh_hud_cardinal_north +import org.meshtastic.core.resources.mesh_hud_cardinal_south +import org.meshtastic.core.resources.mesh_hud_cardinal_west +import org.meshtastic.core.resources.mesh_hud_more +import org.meshtastic.core.resources.mesh_hud_snr +import org.meshtastic.core.ui.component.PermissionRecoveryCard +import org.meshtastic.core.ui.theme.TracerouteColors +import org.meshtastic.core.ui.util.KeepScreenOn +import org.meshtastic.core.ui.util.PermissionStatus +import org.meshtastic.core.ui.util.rememberCameraPermissionState +import org.meshtastic.core.ui.util.rememberLocationPermissionState +import org.meshtastic.core.ui.util.rememberOpenLocationSettings +import org.meshtastic.feature.map.SharedMapViewModel +import org.meshtastic.feature.map.component.MapFilterSheet +import org.meshtastic.feature.map.component.WaypointInfoDialog +import org.meshtastic.feature.map.component.mapFilterActions +import org.meshtastic.proto.Waypoint +import kotlin.math.abs +import kotlin.math.roundToInt + +/** Calibration status at or below this (SENSOR_STATUS_ACCURACY_LOW) asks the user to calibrate. */ +private const val LOW_CALIBRATION = 1 + +/** A heading error estimate above this asks the user to calibrate too. */ +private const val LOW_ACCURACY_DEG = 20.0 +private const val FULL_CIRCLE_DEG = 360.0 + +/** How long the sensor may stay silent before the screen says it is waiting for it. */ +private const val SENSOR_WAIT_MS = 2_000L + +/** The finder counts as on target within this many degrees, or the target's own spread when that is wider. */ +private const val ON_TARGET_DEG = 5.0 + +/** Turn hints move in steps this coarse, so the label does not churn with every sensor sample. */ +private const val HINT_STEP_DEG = 5 +private const val TAPE_SHADOW_ALPHA = 0.9f +private const val TAPE_SHADOW_BLUR = 6f + +/** Pitched further down than this, the user is looking for something close by, so the radar grows. */ +private const val POINTING_DOWN_DEG = -45.0 + +/** The band under the top bar the compass tape and its caret take, which chips and notices stay out of. */ +private val TapeClearance = 48.dp + +/** Walking pace held this long brings up the reminder to look around, so a step or two does not. */ +private const val WALK_NOTICE_MS = 3_000L + +/** Neither turned nor carried for this long, the phone has likely been set down, so the screen may sleep. */ +private const val IDLE_SCREEN_MS = 10 * 60_000L + +/** Turning the view this far counts as the user still looking. */ +private const val ACTIVITY_TURN_DEG = 15 + +@Suppress("LongMethod", "UnnecessaryLaunchedEffect", "CyclomaticComplexMethod") +@Composable +fun MeshHudScreen( + focusNodeNum: Int?, + onNavigateUp: () -> Unit, + onOpenNode: (Int) -> Unit, + modifier: Modifier = Modifier, + tracerouteDestNum: Int? = null, + tracerouteRequestId: Int? = null, + tracerouteLogUuid: String? = null, + trackNodeNum: Int? = null, + onEditWaypoint: ((Int) -> Unit)? = null, + viewModel: MeshHudViewModel = koinViewModel(), +) { + LaunchedEffect(tracerouteDestNum, tracerouteRequestId, tracerouteLogUuid) { + viewModel.showTraceroute(tracerouteDestNum, tracerouteRequestId, tracerouteLogUuid) + } + LaunchedEffect(trackNodeNum) { viewModel.showTrack(trackNodeNum) } + // Opened for a purpose (a node, its track, a traceroute) the view shows only that; from the map, the whole mesh. + LaunchedEffect(focusNodeNum) { viewModel.openFor(focusNodeNum) } + val route by viewModel.route.collectAsStateWithLifecycle() + val alertOptIns by viewModel.geofenceAlertOptIns.collectAsStateWithLifecycle() + val coachSeen by viewModel.coachSeen.collectAsStateWithLifecycle() + var infoWaypoint by remember { mutableStateOf(null) } + var showFilters by remember { mutableStateOf(false) } + var cameraAvailable by remember { mutableStateOf(true) } + val track by viewModel.track.collectAsStateWithLifecycle() + val camera = rememberCameraPermissionState() + val location = rememberLocationPermissionState() + val openLocationSettings = rememberOpenLocationSettings() + // One system dialog at a time, and only ever asked once: a recreated screen must not prompt again. These stay + // keyed effects because each must fire once per key change, which SideEffect does not do. + LaunchedEffect(Unit) { if (camera.status == PermissionStatus.NOT_REQUESTED) camera.request() } + LaunchedEffect(camera.status) { + if (camera.status != PermissionStatus.NOT_REQUESTED && location.status == PermissionStatus.NOT_REQUESTED) { + location.request() + } + } + SideEffect { viewModel.setLocationPermissionGranted(location.isGranted) } + val targets by viewModel.targets.collectAsStateWithLifecycle() + val poseState = viewModel.pose.collectAsStateWithLifecycle() + var intrinsics by remember { mutableStateOf(null) } + var topBarBottomPx by remember { mutableIntStateOf(0) } + var noticesHeightPx by remember { mutableIntStateOf(0) } + var bottomPanelTopPx by remember { mutableIntStateOf(0) } + var sensorSilent by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(SENSOR_WAIT_MS) + sensorSilent = true + } + + val currentTargets by rememberUpdatedState(targets) + val focused by remember { derivedStateOf { currentTargets.targets.firstOrNull { it.focused } } } + // Derived, so the labels recompose on a whole-degree change rather than at the sensor rate. + val headingDeg by remember { + derivedStateOf { + poseState.value.pose?.let { + ((it.azimuthDeg + currentTargets.declinationDeg + FULL_CIRCLE_DEG) % FULL_CIRCLE_DEG).roundToInt() + } + } + } + val headingAccuracyDeg by remember { derivedStateOf { poseState.value.headingAccuracyDeg?.roundToInt() } } + val needsCalibration by remember { + derivedStateOf { + val pose = poseState.value + pose.pose != null && + (pose.calibration in 0..LOW_CALIBRATION || (pose.headingAccuracyDeg ?: 0.0) > LOW_ACCURACY_DEG) + } + } + val hasSensor by remember { derivedStateOf { poseState.value.hasSensor } } + val thermal by remember { derivedStateOf { poseState.value.thermal } } + val waitingForSensor by remember { + derivedStateOf { sensorSilent && poseState.value.hasSensor && poseState.value.pose == null } + } + val hint by remember { derivedStateOf { focused?.let { finderHint(it, poseState.value.pose) } } } + val pointingDown by remember { derivedStateOf { (poseState.value.pose?.pitchDeg ?: 0.0) < POINTING_DOWN_DEG } } + val northLabel = stringResource(Res.string.mesh_hud_cardinal_north) + FinderHaptics(hint) + + // The screen stays on while the user is looking: turning the view or walking restarts the clock. + val lookBucket by remember { derivedStateOf { headingDeg?.div(ACTIVITY_TURN_DEG) } } + var idle by remember { mutableStateOf(false) } + LaunchedEffect(lookBucket, targets.moving) { + idle = false + delay(IDLE_SCREEN_MS) + idle = true + } + KeepScreenOn(!idle) + var walking by remember { mutableStateOf(false) } + LaunchedEffect(targets.moving) { + if (targets.moving) delay(WALK_NOTICE_MS) + walking = targets.moving + } + // A critical thermal state gives the camera back to the system; the overlay carries on as a compass. + val showCamera = camera.isGranted && thermal != HudThermal.CRITICAL + + val tapeClearancePx = with(LocalDensity.current) { TapeClearance.roundToPx() } + val textMeasurer = rememberTextMeasurer() + // A shadow rather than a solid band keeps the tape's figures legible over a bright sky. + val tapeStyle = + MaterialTheme.typography.labelSmall.copy( + color = Color.White, + shadow = Shadow(Color.Black.copy(alpha = TAPE_SHADOW_ALPHA), blurRadius = TAPE_SHADOW_BLUR), + ) + val routeLabels = + RouteLabels( + skippedTemplate = stringResource(Res.string.mesh_hud_more), + snrTemplate = stringResource(Res.string.mesh_hud_snr), + ) + val cardinals = + Cardinals( + north = stringResource(Res.string.mesh_hud_cardinal_north), + east = stringResource(Res.string.mesh_hud_cardinal_east), + south = stringResource(Res.string.mesh_hud_cardinal_south), + west = stringResource(Res.string.mesh_hud_cardinal_west), + ) + + BoxWithConstraints(modifier = modifier.fillMaxSize().background(Color.Black)) { + val width = constraints.maxWidth.toFloat() + val height = constraints.maxHeight.toFloat() + val focalPx = intrinsics?.focalPx(width, height) ?: fallbackFocalPx(width, height) + + if (showCamera) { + HudCameraPreview( + onIntrinsics = { intrinsics = it }, + onAvailable = { cameraAvailable = it }, + modifier = Modifier.fillMaxSize(), + ) + } + + // Decoration only: every target it marks is a chip or radar dot with its own semantics. + Canvas(Modifier.fillMaxSize().clearAndSetSemantics {}) { + val state = poseState.value + val pose = state.pose ?: return@Canvas + drawHorizon(pose, focalPx) + targets.fences.forEach { drawGeofence(it, pose, focalPx) } + track?.let { drawTrack(it, pose, focalPx) } + route?.let { + drawRoute( + it, + pose, + focalPx, + TracerouteColors.OutgoingRoute, + TracerouteColors.ReturnRoute, + textMeasurer, + tapeStyle, + routeLabels, + ) + } + if (targets.showPrecision) { + targets.targets.forEach { drawPrecisionZone(it, pose, focalPx, state.headingAccuracyDeg) } + } + focused?.let { drawReticle(Color(it.subject.color), locked = finderHint(it, pose) == FinderHint.OnTarget) } + drawCompassTape( + trueHeadingDeg = (pose.azimuthDeg + targets.declinationDeg + FULL_CIRCLE_DEG) % FULL_CIRCLE_DEG, + declinationDeg = targets.declinationDeg, + targets = targets.targets, + focalPx = focalPx, + topPx = topBarBottomPx.toFloat(), + cardinals = cardinals, + textMeasurer = textMeasurer, + labelStyle = tapeStyle, + ) + } + HudChipLayer( + targets = targets.targets, + currentPose = { poseState.value.pose }, + focalPx = focalPx, + topLimitPx = { topBarBottomPx + tapeClearancePx + noticesHeightPx }, + bottomLimitPx = { bottomPanelTopPx }, + modifier = Modifier.fillMaxSize(), + ) { target -> + HudChip( + target = target, + currentPose = { poseState.value.pose }, + // A node opens its detail and a waypoint its info, as on the map; long press focuses either. + onOpen = { + when (val subject = target.subject) { + is HudSubject.NodeSubject -> onOpenNode(subject.node.num) + is HudSubject.WaypointSubject -> infoWaypoint = subject.waypoint + } + }, + onToggleFocus = { viewModel.toggleFocus(target.subject) }, + ) + } + + val sensorStatus = + HudSensorStatus( + headingDeg = headingDeg, + headingAccuracyDeg = headingAccuracyDeg, + hasSensor = hasSensor, + needsCalibration = needsCalibration, + waitingForSensor = waitingForSensor, + thermal = thermal, + ) + HudTopNotices( + status = sensorStatus, + modifier = + Modifier.align(Alignment.TopStart) + .offset { IntOffset(0, topBarBottomPx + tapeClearancePx) } + .onPlaced { noticesHeightPx = it.size.height }, + ) + HudTopBar( + status = sensorStatus, + targets = targets, + onNavigateUp = onNavigateUp, + onOpenFilters = if (targets.ready && !targets.scoped) ({ showFilters = true }) else null, + modifier = + Modifier.align(Alignment.TopStart).onPlaced { + topBarBottomPx = (it.positionInParent().y + it.size.height).toInt() + }, + ) + HudBottomPanel( + targets = targets, + hint = hint, + route = route, + notices = HudNotices(cameraUnavailable = showCamera && !cameraAvailable, walking = walking), + location = location, + onOpenLocationSettings = openLocationSettings, + radar = { + NearbyRadar( + nearby = targets.directionUnknown, + ownAccuracyM = targets.ownAccuracyM, + units = targets.units, + northLabel = northLabel, + declinationDeg = targets.declinationDeg, + currentHeadingDeg = { poseState.value.pose?.azimuthDeg }, + expanded = pointingDown, + onOpen = { subject -> + when (subject) { + is HudSubject.NodeSubject -> onOpenNode(subject.node.num) + is HudSubject.WaypointSubject -> infoWaypoint = subject.waypoint + } + }, + route = route, + track = track, + ) + }, + cameraContent = { + if (!camera.isGranted) { + PermissionRecoveryCard(camera, stringResource(Res.string.mesh_hud_camera_rationale)) + } + }, + modifier = + Modifier.align(Alignment.BottomStart).onPlaced { bottomPanelTopPx = it.positionInParent().y.toInt() }, + ) + if (!coachSeen && camera.isGranted) HudCoach(onDismiss = viewModel::dismissCoach) + } + + if (showFilters) { + // The map's own filters and sheet, so the two views always agree on what they show. + val mapViewModel: SharedMapViewModel = koinViewModel() + val filterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() + MapFilterSheet( + onDismissRequest = { showFilters = false }, + filterState = filterState, + actions = remember(mapViewModel) { mapViewModel.mapFilterActions() }, + ) + } + + // Editing hands off to the map, which has the pickers a waypoint needs. + infoWaypoint?.let { waypoint -> + WaypointInfoDialog( + waypoint = waypoint, + displayUnits = targets.units, + alertsEnabled = waypoint.id in alertOptIns, + onToggleAlerts = { viewModel.setGeofenceAlertOptIn(waypoint.id, it) }, + onDismissRequest = { infoWaypoint = null }, + onEdit = + onEditWaypoint?.let { edit -> + { + infoWaypoint = null + edit(waypoint.id) + } + }, + ) + } +} + +/** Where [target] is from the center of the view, or null before the first pose. */ +private fun finderHint(target: HudTarget, pose: CameraPose?): FinderHint? { + pose ?: return null + val yaw = signedTurnDeg(pose.azimuthDeg, target.bearingDeg) + val pitch = target.elevationDeg - pose.pitchDeg + val tolerance = maxOf(ON_TARGET_DEG, target.precisionHalfAngleDeg ?: 0.0) + fun step(deg: Double) = (abs(deg) / HINT_STEP_DEG).roundToInt().coerceAtLeast(1) * HINT_STEP_DEG + return when { + abs(yaw) > tolerance -> FinderHint.Turn(step(yaw), right = yaw > 0) + abs(pitch) > ON_TARGET_DEG -> FinderHint.Tilt(step(pitch), up = pitch > 0) + else -> FinderHint.OnTarget + } +} + +/** True when this turn hint is a step closer than [other] on the same side, so the user is closing in. */ +private fun FinderHint.Turn.closerThan(other: FinderHint?): Boolean = + other is FinderHint.Turn && other.right == right && degrees < other.degrees + +/** + * A tick for each hint step closer while turning toward the target, silent when sweeping away, and one firm pulse as + * the finder locks on, so the user can stop without looking at the screen. Tilt steps stay silent: they change with + * every movement of the wrist. + */ +@Suppress("UnnecessaryLaunchedEffect") // One haptic per hint change, not one per recomposition. +@Composable +private fun FinderHaptics(hint: FinderHint?) { + val haptics = LocalHapticFeedback.current + var previous by remember { mutableStateOf(null) } + LaunchedEffect(hint) { + val last = previous + previous = hint + when { + hint == FinderHint.OnTarget -> haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + + hint is FinderHint.Turn && hint.closerThan(last) -> + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + } + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt new file mode 100644 index 00000000000..e0060614ab6 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt @@ -0,0 +1,407 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn +import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.common.util.LocaleUnitsProvider +import org.meshtastic.core.common.util.MeasurementSystem +import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.common.util.nowSeconds +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.MeshLog +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.fullRouteDiscovery +import org.meshtastic.core.model.geofence.activeWaypointPackets +import org.meshtastic.core.model.passesMapFilter +import org.meshtastic.core.model.service.TracerouteResponse +import org.meshtastic.core.model.util.toDistanceString +import org.meshtastic.core.repository.MagneticFieldProvider +import org.meshtastic.core.repository.MapFilterPrefs +import org.meshtastic.core.repository.MapPrefs +import org.meshtastic.core.repository.MeshLogRepository +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.NotificationPrefs +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.PhoneLocation +import org.meshtastic.core.repository.PhoneLocationProvider +import org.meshtastic.core.repository.TracerouteResponseProvider +import org.meshtastic.core.repository.TracerouteSnapshotRepository +import org.meshtastic.core.repository.toNodeMapFilter +import org.meshtastic.core.ui.util.toPosition +import org.meshtastic.feature.map.mergeStationaryRuns +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Position as ProtoPosition + +private const val STOP_TIMEOUT_MS = 5_000L + +/** Above this ground speed the user is walking with the phone up, which is when to remind them to look around. */ +private const val WALKING_MPS = 1.0f + +private data class HudSettings( + val units: MeasurementSystem, + val focusKey: String?, + val filters: MapFilterPrefs, + /** Nodes a traceroute or track names, shown whatever the filters and range say. */ + val pinned: Set, + /** + * The node the view was opened for, from its detail, compass, track or traceroute. Then only it and the nodes its + * track or route names are shown. + */ + val solo: Int?, + /** Where the open route and track place the nodes they name (see `positionHints`). */ + val hints: PositionHints, +) + +/** + * Where to place the nodes an open route or track names. A route's [recorded] positions win over live ones, as on the + * traceroute map, so each chip sits where its arc ends; a track's newest point is only a [fallback] for a node the + * database has no live position for. + */ +private data class PositionHints( + val recorded: Map = emptyMap(), + val fallback: Map = emptyMap(), +) + +private fun Node.placedBy(hints: PositionHints): Node? { + val position = hints.recorded[num] ?: hints.fallback[num]?.takeIf { validPosition == null } + return (position?.let { copy(position = it) } ?: this).takeIf { it.validPosition != null } +} + +/** A traceroute to draw: its node nums each way, link SNRs in quarter dB, and the positions it was recorded with. */ +data class RouteSource( + val forward: List, + val back: List, + val snrTowards: List?, + val snrBack: List?, + val snapshot: Map, +) + +/** A node's position history to draw as breadcrumbs. */ +data class TrackSource(val nodeNum: Int, val positions: List) + +/** A traceroute the user opened, before it is matched to its logged response. */ +private data class RouteRequest(val destNum: Int, val requestId: Int, val logUuid: String?) { + fun resolve(logs: List, snapshot: Map, response: TracerouteResponse?): RouteSource? { + val logged = + if (logUuid != null) { + logs.find { it.uuid == logUuid } + } else { + logs.find { it.fromRadio.packet?.decoded?.request_id == requestId } + } + val discovery = logged?.fromRadio?.packet?.fullRouteDiscovery + val live = response?.takeIf { it.requestId == requestId && it.hasOverlay } + val forward = discovery?.route ?: live?.forwardRoute + val back = discovery?.route_back ?: live?.returnRoute + return if (forward.isNullOrEmpty() && back.isNullOrEmpty()) { + null + } else { + RouteSource(forward.orEmpty(), back.orEmpty(), discovery?.snr_towards, discovery?.snr_back, snapshot) + } + } +} + +private const val TRACK_FALLBACK_COLOR = 0xFFFFFFFF.toInt() + +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LongParameterList") +@KoinViewModel +class MeshHudViewModel( + poseProvider: CameraPoseProvider, + phoneLocationProvider: PhoneLocationProvider, + private val magneticFieldProvider: MagneticFieldProvider, + nodeRepository: NodeRepository, + packetRepository: PacketRepository, + meshLogRepository: MeshLogRepository, + tracerouteResponseProvider: TracerouteResponseProvider, + tracerouteSnapshotRepository: TracerouteSnapshotRepository, + private val mapPrefs: MapPrefs, + private val notificationPrefs: NotificationPrefs, + localeUnitsProvider: LocaleUnitsProvider, + dispatchers: CoroutineDispatchers, +) : ViewModel() { + + private val focusKey = MutableStateFlow(null) + + // The provider checks the permission once per collection, so a grant made while the screen is open needs a new one. + private val locationPermissionGranted = MutableStateFlow(false) + private val fixAverager = FixAverager() + private val routeRequest = MutableStateFlow(null) + private val soloNodeNum = MutableStateFlow(null) + private var opened = false + private val trackNodeNum = MutableStateFlow(null) + + /** + * The opened traceroute, resolved as the traceroute map resolves it: the logged response first (by its uuid, else + * its request id), then the service's latest response, with the hop positions recorded alongside it. + */ + private val routeSource = routeRequest.flatMapLatest { request -> + if (request == null) { + flowOf(null) + } else { + combine( + nodeRepository + .effectiveLogNodeId(request.destNum) + .flatMapLatest { meshLogRepository.getLogsFrom(it, PortNum.TRACEROUTE_APP.value) } + .onStart { emit(emptyList()) }, + request.logUuid?.let(tracerouteSnapshotRepository::getSnapshotPositions) ?: flowOf(emptyMap()), + tracerouteResponseProvider.tracerouteResponse, + ) { logs, snapshot, response -> + request.resolve(logs, snapshot, response) + } + } + } + + /** The opened node's position history, trimmed by the map's track filter and with stationary runs merged. */ + private val trackSource = trackNodeNum.flatMapLatest { num -> + if (num == null) { + flowOf(null) + } else { + combine( + nodeRepository.effectiveLogNodeId(num).flatMapLatest { + meshLogRepository.getMeshPacketsFrom(it, PortNum.POSITION_APP.value) + }, + mapPrefs.mapFilters, + ) { packets, filters -> + val cutoff = filters.lastHeardTrackSeconds.takeIf { it > 0 }?.let { nowSeconds - it } + val positions = + packets + .mapNotNull { it.toPosition() } + .filter { cutoff == null || it.time >= cutoff } + .sortedBy { it.time } + TrackSource(num, mergeStationaryRuns(positions).map { it.position }) + } + } + } + private val pinned = + combine(routeSource, trackSource) { route, track -> + buildSet { + route?.let { addAll(it.forward + it.back) } + track?.let { add(it.nodeNum) } + remove(UNKNOWN_HOP) + } + } + + /** + * The open route's recorded hop positions and the open track's newest point. A remote node, or one last heard while + * disconnected, often has only these, and without them its chip, and the edge arrow leading to it, would never + * appear. + */ + private val positionHints = + combine(routeSource, trackSource) { route, track -> + PositionHints( + recorded = route?.snapshot.orEmpty(), + fallback = track?.positions?.lastOrNull()?.let { mapOf(track.nodeNum to it) }.orEmpty(), + ) + } + + /** The phone's averaged fix, shared by the targets, the route and the track. */ + private val location = + locationPermissionGranted + .flatMapLatest { phoneLocationProvider.locationUpdates() } + .map { state -> state.location?.let { state.copy(location = fixAverager.add(it)) } ?: state } + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), replay = 1) + + /** The magnetic declination where the phone is, zero until it has a fix. */ + private val declinationDeg = + location.map { state -> state.location?.let(::declinationAt) ?: 0f }.onStart { emit(0f) }.distinctUntilChanged() + + /** + * The camera's pose, always referenced to magnetic north like the rotation vector: a source that already gives true + * north is turned back by the declination, which every target, the tape and the radar then add once. + */ + val pose: StateFlow = + combine(poseProvider.poseUpdates().conflate(), declinationDeg) { state, declination -> + if (state.trueNorth) { + state.copy(pose = state.pose?.rotatedAboutVertical(-declination.toDouble()), trueNorth = false) + } else { + state + } + } + .flowOn(dispatchers.default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), CameraPoseState()) + + val targets: StateFlow = + combine( + location, + nodeRepository.nodeDBbyNum, + nodeRepository.myNodeInfo, + combine( + localeUnitsProvider.measurementSystem, + focusKey, + mapPrefs.mapFilters, + combine(pinned, soloNodeNum, positionHints, ::Triple), + ) { units, focus, filters, (pinnedNums, solo, hints) -> + HudSettings(units, focus, filters, pinnedNums, solo, hints) + }, + packetRepository.getWaypoints(), + ) { location, nodes, myNode, settings, waypointPackets -> + val now = nowSeconds + val filter = settings.filters.toNodeMapFilter() + val solo = settings.solo + val shownNodes = + nodes.values + .mapNotNull { node -> node.placedBy(settings.hints) } + .filter { node -> + node.num != myNode?.myNodeNum && + if (solo != null) { + node.num == solo || node.num in settings.pinned + } else { + HudSubject.NodeSubject(node).key == settings.focusKey || + node.num in settings.pinned || + node.passesMapFilter(filter, now) + } + } + val waypoints = + if (solo == null && settings.filters.showWaypoints) { + waypointPackets.activeWaypointPackets(now).values.mapNotNull { it.waypoint } + } else { + emptyList() + } + buildHudTargets( + location, + shownNodes, + waypoints, + settings.units, + settings.focusKey, + now, + ::declinationAt, + ) + .let { built -> + built.copy( + units = settings.units, + averagedFixes = fixAverager.count, + scoped = solo != null, + ready = true, + moving = (location.location?.speedMps ?: 0f) > WALKING_MPS, + showPrecision = settings.filters.showPrecisionCircle, + finding = built.findingFor(settings.focusKey), + ) + } + } + .flowOn(dispatchers.default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), MeshHudTargets()) + + /** The traceroute in view, if one was opened, as legs of points from here. */ + val route: StateFlow = + combine(location, routeSource, nodeRepository.nodeDBbyNum, nodeRepository.myNodeInfo) { + state, + source, + nodes, + myNode, + -> + val here = state.location + if (source == null || here == null) { + null + } else { + buildHudRoute( + here, + declinationAt(here), + source.forward, + source.back, + source.snrTowards, + source.snrBack, + viewerNum = myNode?.myNodeNum, + ) { num -> + source.snapshot[num] ?: nodes[num]?.validPosition + } + } + } + .flowOn(dispatchers.default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null) + + /** The position track in view, if one was opened, as breadcrumbs from here. */ + val track: StateFlow = + combine(location, trackSource, nodeRepository.nodeDBbyNum) { state, source, nodes -> + val here = state.location + if (source == null || here == null) { + null + } else { + val color = nodes[source.nodeNum]?.colors?.second ?: TRACK_FALLBACK_COLOR + buildHudTrack(here, declinationAt(here), source.nodeNum, color, source.positions, nowSeconds) + } + } + .flowOn(dispatchers.default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null) + + private fun declinationAt(here: PhoneLocation): Float = + magneticFieldProvider.getDeclination(here.latitude, here.longitude, here.altitude ?: 0.0, nowMillis) + + /** Waypoint ids of foreign geofences the user opted in to crossing alerts for, as the map's dialog shows them. */ + val geofenceAlertOptIns: StateFlow> = notificationPrefs.geofenceAlertOptIns + + fun setGeofenceAlertOptIn(waypointId: Int, enabled: Boolean) = + notificationPrefs.setGeofenceAlertOptIn(waypointId, enabled) + + /** Draws traceroute [requestId] to [destNum] in the air; [logUuid] names its logged response when known. */ + fun showTraceroute(destNum: Int?, requestId: Int?, logUuid: String?) { + routeRequest.value = + if (destNum != null && requestId != null) RouteRequest(destNum, requestId, logUuid) else null + } + + /** Draws [nodeNum]'s position track, or clears it when null. */ + fun showTrack(nodeNum: Int?) { + trackNodeNum.value = nodeNum + } + + /** + * Opens the view for [nodeNum], from that node's detail, compass, track or traceroute: the finder points at it, and + * only it and the nodes its track or route names are shown. Null opens the whole mesh, as from the map. + */ + fun openFor(nodeNum: Int?) { + // A recreated screen opens again for the same node, and must keep the focus the user has chosen since. + if (opened && soloNodeNum.value == nodeNum) return + opened = true + focusKey.value = nodeNum?.let { "node-$it" } + soloNodeNum.value = nodeNum + } + + /** Focuses the finder on [subject], or clears the focus when it is already the one focused. */ + fun toggleFocus(subject: HudSubject) { + focusKey.value = subject.key.takeIf { it != focusKey.value } + } + + fun setLocationPermissionGranted(granted: Boolean) { + locationPermissionGranted.value = granted + } + + private fun MeshHudTargets.findingFor(focusKey: String?): HudFinding? = + targets.firstOrNull { it.focused }?.let { HudFinding(it.subject, it.distanceText) } + ?: directionUnknown + .firstOrNull { focusKey != null && it.subject.key == focusKey } + ?.let { HudFinding(it.subject, it.distanceM.toInt().toDistanceString(units)) } + + /** True once the user has dismissed the first-run coaching. */ + val coachSeen: StateFlow = mapPrefs.meshHudCoachSeen + + fun dismissCoach() = mapPrefs.setMeshHudCoachSeen(true) +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/OneEuroQuaternionFilter.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/OneEuroQuaternionFilter.kt new file mode 100644 index 00000000000..54fe4c0f65a --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/OneEuroQuaternionFilter.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.acos +import kotlin.math.sqrt + +private const val NANOS_PER_SECOND = 1e9 +private const val QUATERNION_SIZE = 4 + +/** A gap longer than this (the app was backgrounded) restarts the filter instead of sweeping across it. */ +private const val MAX_GAP_S = 0.5 + +/** + * Speed-adaptive smoothing for an orientation quaternion, the "1€ filter" (Casiez et al., CHI 2012). Held still it + * filters hard, so chips do not shimmer; panning raises the cutoff with angular speed, so the overlay does not trail + * the camera image. Quaternions are (x, y, z, w), the rotation vector's order. + */ +internal class OneEuroQuaternionFilter( + private val minCutoffHz: Double = 0.6, + private val beta: Double = 0.8, + private val speedCutoffHz: Double = 1.0, +) { + private val state = DoubleArray(QUATERNION_SIZE) + private var lastTimestampNs = 0L + private var speedRadPerS = 0.0 + + /** Filters [sample] taken at [timestampNs] and writes the smoothed quaternion into [out]. */ + fun filter(sample: FloatArray, timestampNs: Long, out: FloatArray) { + val dt = (timestampNs - lastTimestampNs) / NANOS_PER_SECOND + if (lastTimestampNs == 0L || dt <= 0.0 || dt > MAX_GAP_S || state.any { it.isNaN() }) { + for (i in 0 until QUATERNION_SIZE) state[i] = sample[i].toDouble() + speedRadPerS = 0.0 + } else { + var dot = 0.0 + for (i in 0 until QUATERNION_SIZE) dot += state[i] * sample[i] + // q and -q are the same rotation; blend toward whichever is nearer. + val sign = if (dot < 0) -1.0 else 1.0 + val angle = 2 * acos(abs(dot).coerceAtMost(1.0)) + speedRadPerS += smoothing(speedCutoffHz, dt) * (angle / dt - speedRadPerS) + val alpha = smoothing(minCutoffHz + beta * speedRadPerS, dt) + var norm = 0.0 + for (i in 0 until QUATERNION_SIZE) { + state[i] += alpha * (sign * sample[i] - state[i]) + norm += state[i] * state[i] + } + val inv = 1 / sqrt(norm) + for (i in 0 until QUATERNION_SIZE) state[i] *= inv + } + lastTimestampNs = timestampNs + for (i in 0 until QUATERNION_SIZE) out[i] = state[i].toFloat() + } + + /** The low-pass weight for a step of [dt] seconds at [cutoffHz]. */ + private fun smoothing(cutoffHz: Double, dt: Double): Double { + val tau = 1 / (2 * PI * cutoffHz) + return 1 / (1 + tau / dt) + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt index 54b049834d9..77766f1e0c3 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt @@ -16,11 +16,13 @@ */ package org.meshtastic.feature.map.navigation +import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import org.meshtastic.core.navigation.MapRoute import org.meshtastic.core.navigation.NodesRoute +import org.meshtastic.feature.map.hud.MeshHudScreen fun EntryProviderScope.mapGraph(backStack: NavBackStack) { entry { args -> @@ -30,6 +32,22 @@ fun EntryProviderScope.mapGraph(backStack: NavBackStack) { { id -> backStack.add(NodesRoute.NodeDetail(id)) }, // navigateToNodeDetails args.waypointId, args.sitePlannerNodeNum, + { backStack.add(MapRoute.MeshHud()) }, // onOpenMeshHud + ) + } + + // No pane metadata: the camera view takes the whole window. + entry { args -> + MeshHudScreen( + focusNodeNum = args.focusNodeNum, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + onOpenNode = { num -> backStack.add(NodesRoute.NodeDetail(num)) }, + tracerouteDestNum = args.tracerouteDestNum, + tracerouteRequestId = args.tracerouteRequestId, + tracerouteLogUuid = args.tracerouteLogUuid, + trackNodeNum = args.trackNodeNum, + // The map opens the waypoint's own dialog, whose edit has the pickers. + onEditWaypoint = { id -> backStack.add(MapRoute.Map(waypointId = id)) }, ) } } diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/FixAveragerTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/FixAveragerTest.kt new file mode 100644 index 00000000000..b2c49b1c76a --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/FixAveragerTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import org.meshtastic.core.repository.PhoneLocation +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull + +/** About 1.1 m of latitude. */ +private const val METER_DEG = 0.00001 + +private fun fix(lat: Double, t: Long, accuracy: Float? = 5f, speed: Float? = 0f) = + PhoneLocation(40.0 + lat, -105.0, 100.0, t, accuracyM = accuracy, speedMps = speed) + +class FixAveragerTest { + + @Test + fun `a still phone averages its wander`() { + val averager = FixAverager() + averager.add(fix(METER_DEG, 1_000)) + val mean = averager.add(fix(-METER_DEG, 2_000)) + assertEquals(40.0, mean.latitude, 1e-9) + assertEquals(2, averager.count) + } + + @Test + fun `better fixes count for more`() { + val averager = FixAverager() + averager.add(fix(0.0, 1_000, accuracy = 2f)) + val mean = averager.add(fix(4 * METER_DEG, 2_000, accuracy = 8f)) + // Weights 1/4 and 1/64: the mean sits close to the precise fix. + assertEquals(40.0 + 4 * METER_DEG / 17, mean.latitude, 1e-9) + assertEquals(2f, mean.accuracyM) + } + + @Test + fun `walking restarts the average`() { + val averager = FixAverager() + averager.add(fix(0.0, 1_000)) + val mean = averager.add(fix(METER_DEG, 2_000, speed = 1.4f)) + assertEquals(40.0 + METER_DEG, mean.latitude, 1e-12) + assertEquals(1, averager.count) + } + + @Test + fun `a jump far beyond the accuracy restarts the average`() { + val averager = FixAverager() + averager.add(fix(0.0, 1_000)) + averager.add(fix(100 * METER_DEG, 2_000)) + assertEquals(1, averager.count) + } + + @Test + fun `fixes without an accuracy are averaged at an assumed one`() { + val averager = FixAverager() + averager.add(fix(METER_DEG, 1_000, accuracy = null)) + val mean = averager.add(fix(-METER_DEG, 2_000, accuracy = null)) + assertEquals(40.0, mean.latitude, 1e-9) + assertNull(mean.accuracyM) + assertEquals(2, averager.count) + } + + @Test + fun `a fix without an accuracy weighs less than a precise one`() { + val averager = FixAverager() + averager.add(fix(0.0, 1_000, accuracy = 2f)) + val mean = averager.add(fix(4 * METER_DEG, 2_000, accuracy = null)) + // Weights 1/4 and 1/400. + assertEquals(40.0 + 4 * METER_DEG / 101, mean.latitude, 1e-9) + assertEquals(2f, mean.accuracyM) + } + + @Test + fun `a zero accuracy fix averages as one meter without dividing by zero`() { + val averager = FixAverager() + averager.add(fix(0.0, 1_000, accuracy = 0f)) + val mean = averager.add(fix(METER_DEG, 2_000, accuracy = 1f)) + assertFalse(mean.latitude.isNaN()) + assertEquals(40.0 + METER_DEG / 2, mean.latitude, 1e-9) + assertEquals(0f, mean.accuracyM) + assertEquals(2, averager.count) + } + + @Test + fun `a run of zero accuracy fixes keeps averaging through meter-scale wander`() { + val averager = FixAverager() + averager.add(fix(0.0, 1_000, accuracy = 0f)) + averager.add(fix(METER_DEG, 2_000, accuracy = 0f)) + averager.add(fix(-METER_DEG, 3_000, accuracy = 0f)) + assertEquals(3, averager.count) + } + + @Test + fun `a still fix after walking starts a fresh window`() { + val averager = FixAverager() + averager.add(fix(0.0, 1_000)) + averager.add(fix(0.0, 2_000)) + averager.add(fix(2 * METER_DEG, 3_000, speed = 1.4f)) + val mean = averager.add(fix(4 * METER_DEG, 4_000)) + assertEquals(40.0 + 3 * METER_DEG, mean.latitude, 1e-9) + assertEquals(2, averager.count) + } + + @Test + fun `a missing speed counts as still`() { + val averager = FixAverager() + averager.add(fix(0.0, 1_000, speed = null)) + averager.add(fix(0.0, 2_000, speed = null)) + assertEquals(2, averager.count) + } + + @Test + fun `a move within a few accuracy radii is wander not a jump`() { + val averager = FixAverager() + averager.add(fix(0.0, 1_000)) + averager.add(fix(8 * METER_DEG, 2_000)) + assertEquals(2, averager.count) + } + + @Test + fun `a move past three accuracy radii of the new fix restarts the average`() { + val averager = FixAverager() + averager.add(fix(0.0, 1_000)) + val mean = averager.add(fix(20 * METER_DEG, 2_000)) + assertEquals(1, averager.count) + assertEquals(40.0 + 20 * METER_DEG, mean.latitude, 1e-12) + } + + @Test + fun `old fixes leave the window`() { + val averager = FixAverager() + averager.add(fix(0.0, 1_000)) + averager.add(fix(0.0, 40_000)) + assertEquals(1, averager.count) + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudChipsTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudChipsTest.kt new file mode 100644 index 00000000000..ec5fa7dbc78 --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudChipsTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import org.meshtastic.core.model.Node +import org.meshtastic.proto.User +import kotlin.test.Test +import kotlin.test.assertEquals + +class HudChipsTest { + + @Test + fun `straight ahead is twelve o clock`() { + assertEquals(12, clockHour(bearingDeg = 0.0, headingDeg = 0.0)) + assertEquals(12, clockHour(bearingDeg = 214.0, headingDeg = 214.0)) + } + + @Test + fun `the quarters read three six and nine`() { + assertEquals(3, clockHour(bearingDeg = 90.0, headingDeg = 0.0)) + assertEquals(6, clockHour(bearingDeg = 180.0, headingDeg = 0.0)) + assertEquals(9, clockHour(bearingDeg = 270.0, headingDeg = 0.0)) + } + + @Test + fun `the hour is relative to the heading`() { + assertEquals(3, clockHour(bearingDeg = 180.0, headingDeg = 90.0)) + assertEquals(9, clockHour(bearingDeg = 0.0, headingDeg = 90.0)) + } + + @Test + fun `bearings wrap across north`() { + assertEquals(1, clockHour(bearingDeg = 20.0, headingDeg = 350.0)) + assertEquals(11, clockHour(bearingDeg = 340.0, headingDeg = 10.0)) + assertEquals(12, clockHour(bearingDeg = 5.0, headingDeg = 355.0)) + } + + @Test + fun `an angle rounds to the nearest hour`() { + assertEquals(12, clockHour(bearingDeg = 14.0, headingDeg = 0.0)) + assertEquals(1, clockHour(bearingDeg = 16.0, headingDeg = 0.0)) + assertEquals(11, clockHour(bearingDeg = 344.0, headingDeg = 0.0)) + } + + @Test + fun `a node with no names is spoken by its node id`() { + val node = Node(num = 0x1a2b3c4d, user = User.Builder().build()) + assertEquals("!1a2b3c4d", HudSubject.NodeSubject(node).spokenName()) + } + + @Test + fun `a node with only a short name is spoken by it`() { + val node = Node(num = 1, user = User.Builder().also { it.short_name = "ABCD" }.build()) + assertEquals("ABCD", HudSubject.NodeSubject(node).spokenName()) + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudEdgesTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudEdgesTest.kt new file mode 100644 index 00000000000..9569e5d5186 --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudEdgesTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import kotlin.math.PI +import kotlin.math.tan +import kotlin.test.Test +import kotlin.test.assertEquals + +private const val W = 1000f +private const val FOCAL = 800f + +private fun pin(yaw: Double, pitch: Double = -10.0, previous: PinEdge? = null, projected: ScreenPoint? = null) = + pinEdge(projected, W, yaw, pitch, previous) + +class HudEdgesTest { + + @Test + fun `a target just past the side threshold keeps its vertical edge inside the hysteresis band`() { + assertEquals(PinEdge.BOTTOM, pin(yaw = 40.0)) + assertEquals(PinEdge.BOTTOM, pin(yaw = 40.0, previous = PinEdge.BOTTOM)) + assertEquals(PinEdge.TOP, pin(yaw = -40.0, pitch = 10.0, previous = PinEdge.TOP)) + } + + @Test + fun `a target clearing the band to the side moves to that side`() { + assertEquals(PinEdge.RIGHT, pin(yaw = 48.0, previous = PinEdge.BOTTOM)) + assertEquals(PinEdge.LEFT, pin(yaw = -48.0, previous = PinEdge.TOP)) + } + + @Test + fun `a side pinned target holds its side until it clears the band toward ahead`() { + assertEquals(PinEdge.RIGHT, pin(yaw = 30.0, previous = PinEdge.RIGHT)) + assertEquals(PinEdge.LEFT, pin(yaw = -24.0, previous = PinEdge.LEFT)) + assertEquals(PinEdge.BOTTOM, pin(yaw = 20.0, previous = PinEdge.RIGHT)) + assertEquals(PinEdge.TOP, pin(yaw = -20.0, pitch = 5.0, previous = PinEdge.LEFT)) + } + + @Test + fun `a target behind pins to the side it is nearer`() { + assertEquals(PinEdge.RIGHT, pin(yaw = 170.0)) + assertEquals(PinEdge.LEFT, pin(yaw = -170.0)) + assertEquals(PinEdge.RIGHT, pin(yaw = 120.0, pitch = 60.0)) + } + + @Test + fun `a side pinned target near straight behind keeps its side as it crosses over`() { + assertEquals(PinEdge.RIGHT, pin(yaw = -175.0, previous = PinEdge.RIGHT)) + assertEquals(PinEdge.LEFT, pin(yaw = 175.0, previous = PinEdge.LEFT)) + } + + @Test + fun `a target past the behind window switches to its nearer side`() { + assertEquals(PinEdge.LEFT, pin(yaw = -160.0, previous = PinEdge.RIGHT)) + assertEquals(PinEdge.RIGHT, pin(yaw = 160.0, previous = PinEdge.LEFT)) + } + + @Test + fun `a target roughly ahead pins above or below by its pitch`() { + assertEquals(PinEdge.TOP, pin(yaw = 10.0, pitch = 30.0)) + assertEquals(PinEdge.BOTTOM, pin(yaw = 10.0, pitch = -30.0)) + assertEquals(PinEdge.BOTTOM, pin(yaw = 0.0, pitch = 0.0)) + } + + @Test + fun `a target projected within the view width pins vertically whatever its yaw`() { + assertEquals(PinEdge.TOP, pin(yaw = 100.0, pitch = 70.0, projected = ScreenPoint(500f, -50f))) + assertEquals(PinEdge.BOTTOM, pin(yaw = -100.0, previous = PinEdge.LEFT, projected = ScreenPoint(0f, 3000f))) + } + + @Test + fun `a target projected beyond the view width pins by yaw`() { + assertEquals(PinEdge.RIGHT, pin(yaw = 60.0, projected = ScreenPoint(W + 1f, 100f))) + } + + @Test + fun `top and bottom offsets follow yaw`() { + assertEquals(FOCAL, edgeOffsetPx(PinEdge.TOP, 45.0, 30.0, FOCAL), 0.01f) + assertEquals(-FOCAL, edgeOffsetPx(PinEdge.BOTTOM, -45.0, 30.0, FOCAL), 0.01f) + } + + @Test + fun `side offsets follow pitch`() { + assertEquals(FOCAL, edgeOffsetPx(PinEdge.LEFT, 120.0, 45.0, FOCAL), 0.01f) + assertEquals(-FOCAL, edgeOffsetPx(PinEdge.RIGHT, 120.0, -45.0, FOCAL), 0.01f) + assertEquals(0f, edgeOffsetPx(PinEdge.RIGHT, 120.0, 0.0, FOCAL), 0.01f) + } + + @Test + fun `offsets clamp at eighty degrees`() { + val max = (tan(80.0 * PI / 180.0) * FOCAL).toFloat() + assertEquals(max, edgeOffsetPx(PinEdge.TOP, 89.0, 0.0, FOCAL), 0.1f) + assertEquals(-max, edgeOffsetPx(PinEdge.LEFT, 0.0, -170.0, FOCAL), 0.1f) + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudGeometryTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudGeometryTest.kt new file mode 100644 index 00000000000..d0e9bf651a9 --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudGeometryTest.kt @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +private const val W = 1000f +private const val H = 2000f +private const val FOCAL = 800f + +/** Phone upright in portrait, back camera facing north, level. */ +private val facingNorth = + CameraPose(right = Vec3(1.0, 0.0, 0.0), up = Vec3(0.0, 0.0, 1.0), forward = Vec3(0.0, 1.0, 0.0)) + +/** Facing east: right is south, forward is east. */ +private val facingEast = + CameraPose(right = Vec3(0.0, -1.0, 0.0), up = Vec3(0.0, 0.0, 1.0), forward = Vec3(1.0, 0.0, 0.0)) + +class HudGeometryTest { + + @Test + fun `a target straight ahead lands at the view center`() { + val point = assertNotNull(project(directionEnu(0.0, 0.0), facingNorth, FOCAL, W, H)) + assertEquals(W / 2, point.x, 0.01f) + assertEquals(H / 2, point.y, 0.01f) + } + + @Test + fun `a target east of the line of sight lands right of center`() { + val point = assertNotNull(project(directionEnu(20.0, 0.0), facingNorth, FOCAL, W, H)) + assertTrue(point.x > W / 2) + assertEquals(H / 2, point.y, 0.01f) + } + + @Test + fun `a target above the horizon lands above center`() { + val point = assertNotNull(project(directionEnu(0.0, 10.0), facingNorth, FOCAL, W, H)) + assertTrue(point.y < H / 2) + } + + @Test + fun `a target behind the camera does not project`() { + assertNull(project(directionEnu(180.0, 0.0), facingNorth, FOCAL, W, H)) + } + + @Test + fun `the camera azimuth follows its forward axis`() { + assertEquals(0.0, facingNorth.azimuthDeg, 1e-9) + assertEquals(90.0, facingEast.azimuthDeg, 1e-9) + } + + @Test + fun `a node ahead and above the view pins to the top`() { + assertEquals(PinEdge.TOP, pinEdge(null, W, yawDeg = 5.0, pitchDeltaDeg = 49.0, previous = null)) + assertEquals(PinEdge.BOTTOM, pinEdge(ScreenPoint(W / 2, H + 300f), W, 0.0, -30.0, previous = null)) + } + + @Test + fun `a node behind pins to a side even with the camera pointing down`() { + assertEquals(PinEdge.RIGHT, pinEdge(null, W, yawDeg = 150.0, pitchDeltaDeg = 49.0, previous = null)) + assertEquals(PinEdge.LEFT, pinEdge(null, W, yawDeg = -150.0, pitchDeltaDeg = 49.0, previous = null)) + } + + @Test + fun `a node near straight behind keeps its side`() { + assertEquals(PinEdge.LEFT, pinEdge(null, W, yawDeg = 175.0, pitchDeltaDeg = 0.0, previous = PinEdge.LEFT)) + assertEquals(PinEdge.RIGHT, pinEdge(null, W, yawDeg = 150.0, pitchDeltaDeg = 0.0, previous = PinEdge.LEFT)) + } + + @Test + fun `the side threshold holds through small wobbles`() { + assertEquals(PinEdge.RIGHT, pinEdge(null, W, yawDeg = 30.0, pitchDeltaDeg = 40.0, previous = PinEdge.RIGHT)) + assertEquals(PinEdge.TOP, pinEdge(null, W, yawDeg = 40.0, pitchDeltaDeg = 40.0, previous = PinEdge.TOP)) + } + + @Test + fun `a pinned node sits along its edge by its angle`() { + assertEquals(800f, edgeOffsetPx(PinEdge.TOP, yawDeg = 45.0, pitchDeltaDeg = 0.0, focalPx = 800f), 0.5f) + assertEquals(-800f, edgeOffsetPx(PinEdge.LEFT, yawDeg = 0.0, pitchDeltaDeg = -45.0, focalPx = 800f), 0.5f) + } + + @Test + fun `a camera pointing straight down takes its heading from the screen top`() { + val down = CameraPose(right = Vec3(1.0, 0.0, 0.0), up = Vec3(0.0, 1.0, 0.0), forward = Vec3(0.0, 0.0, -1.0)) + assertEquals(0.0, down.azimuthDeg, 1e-9) + } + + @Test + fun `a heading correction turns the pose clockwise`() { + assertEquals(90.0, facingNorth.rotatedAboutVertical(90.0).azimuthDeg, 1e-9) + assertEquals(350.0, facingNorth.rotatedAboutVertical(-10.0).azimuthDeg, 1e-9) + } + + @Test + fun `a known altitude gives a band from its accuracy`() { + // 10 m of vertical uncertainty at 1 km is about 0.57 degrees. + assertEquals(0.573, elevationSpreadDeg(1_000.0, verticalM = 10.0, elevationKnown = true), 0.001) + } + + @Test + fun `an unknown altitude gives a tall band`() { + assertEquals(4.0, elevationSpreadDeg(1_000.0, verticalM = 0.0, elevationKnown = false), 1e-9) + } + + @Test + fun `turns take the short way round`() { + assertEquals(20.0, signedTurnDeg(350.0, 10.0), 1e-9) + assertEquals(-20.0, signedTurnDeg(10.0, 350.0), 1e-9) + } + + @Test + fun `a level camera has no pitch`() { + assertEquals(0.0, facingNorth.pitchDeg, 1e-9) + } + + @Test + fun `curvature puts a distant level target below the horizon`() { + // Dip at 10 km with 4/3 Earth radius is about 5.9 m, so about -0.034 degrees. + val elevation = elevationDeg(distanceM = 10_000.0, riseM = 0.0) + assertTrue(elevation < 0) + assertTrue(abs(elevation + 0.0337) < 0.001) + } + + @Test + fun `a missing altitude places the target level with the observer`() { + val elevation = elevationDeg(distanceM = 100.0, riseM = null) + assertTrue(abs(elevation) < 0.001) + } + + @Test + fun `a higher target nearby sits well above the horizon`() { + val elevation = elevationDeg(distanceM = 1_000.0, riseM = 100.0) + assertEquals(5.71, elevation, 0.01) + } + + @Test + fun `precision spans the angle its radius subtends`() { + // 13 bits is about 2918 m; at 10 km that is about 16.3 degrees each side. + assertEquals(16.27, assertNotNull(precisionHalfAngleDeg(2918.0, 10_000.0)), 0.01) + } + + @Test + fun `inside the precision circle the direction is unknown`() { + assertNull(precisionHalfAngleDeg(2918.0, 2_000.0)) + } + + @Test + fun `chips on the same bearing stack in list order`() { + val box = AngularBox(bearingDeg = 90.0, elevationDeg = 0.0, halfWidthDeg = 2.0, heightDeg = 1.0) + assertEquals(listOf(0, 1, 2), stackLevels(listOf(box, box, box), gapDeg = 0.1).toList()) + } + + @Test + fun `chips far apart in bearing do not stack`() { + val a = AngularBox(bearingDeg = 10.0, elevationDeg = 0.0, halfWidthDeg = 2.0, heightDeg = 1.0) + val b = a.copy(bearingDeg = 20.0) + assertEquals(listOf(0, 0), stackLevels(listOf(a, b), gapDeg = 0.1).toList()) + } + + @Test + fun `stacking sees across north`() { + val a = AngularBox(bearingDeg = 359.0, elevationDeg = 0.0, halfWidthDeg = 2.0, heightDeg = 1.0) + val b = a.copy(bearingDeg = 1.0) + assertEquals(listOf(0, 1), stackLevels(listOf(a, b), gapDeg = 0.1).toList()) + } + + @Test + fun `a stack past its cap overflows rather than rising further`() { + val box = AngularBox(bearingDeg = 90.0, elevationDeg = 0.0, halfWidthDeg = 2.0, heightDeg = 1.0) + val levels = stackLevels(List(5) { box }, gapDeg = 0.1, leaderRunDeg = 0.5, maxLevel = 3) + assertEquals(listOf(0, 1, 2, 3, OVERFLOW_LEVEL), levels.toList()) + } + + @Test + fun `lifted chips step aside right then left and a chip on its point does not`() { + assertEquals(listOf(0, 1, -1, 1), (0..3).map(::leaderSide)) + } + + @Test + fun `stepping aside still keeps lifted chips from overlapping`() { + val box = AngularBox(bearingDeg = 90.0, elevationDeg = 0.0, halfWidthDeg = 2.0, heightDeg = 1.0) + val levels = stackLevels(List(3) { box }, gapDeg = 0.1, leaderRunDeg = 0.5, maxLevel = 3) + assertEquals(listOf(0, 1, 2), levels.toList()) + } + + @Test + fun `a chip fills the lowest free row`() { + val low = AngularBox(bearingDeg = 0.0, elevationDeg = 0.0, halfWidthDeg = 2.0, heightDeg = 1.0) + val high = low.copy(elevationDeg = 5.0) + assertEquals(listOf(0, 0, 1), stackLevels(listOf(low, high, low), gapDeg = 0.1).toList()) + } + + @Test + fun `a wide stream from a squarer sensor loses sensor height`() { + // A 16:9 stream from a 4 by 3 mm sensor covers the full 4 mm but only 2.25 mm of the height. + val streamed = CameraIntrinsics(4f, 4f, 3f).forStream(1920, 1080, cropLong = 1920, cropShort = 1080) + assertEquals(4f, streamed.sensorLongMm, 0.001f) + assertEquals(2.25f, streamed.sensorShortMm, 0.001f) + } + + @Test + fun `a crop inside the stream narrows the sensor area further`() { + val streamed = CameraIntrinsics(4f, 4f, 3f).forStream(1600, 1200, cropLong = 800, cropShort = 1200) + assertEquals(2f, streamed.sensorLongMm, 0.001f) + assertEquals(3f, streamed.sensorShortMm, 0.001f) + } + + @Test + fun `intrinsics scale by the axis that fills the view`() { + // A 4 by 3 mm sensor behind a 4 mm lens, filling a 1000 by 2000 portrait view: the long axis governs. + val focal = CameraIntrinsics(focalLengthMm = 4f, sensorLongMm = 4f, sensorShortMm = 3f).focalPx(W, H) + assertEquals(2000f, focal, 0.01f) + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudPathsTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudPathsTest.kt new file mode 100644 index 00000000000..ac0eadd3598 --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudPathsTest.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import org.meshtastic.core.repository.PhoneLocation +import org.meshtastic.proto.Position +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +private val here = PhoneLocation(40.0, -105.0, 100.0, 0L, accuracyM = 5f) +private val northPose = CameraPose(right = Vec3(1.0, 0.0, 0.0), up = Vec3(0.0, 0.0, 1.0), forward = Vec3(0.0, 1.0, 0.0)) + +private fun at(lat: Double, time: Int = 0, altitude: Int? = 100) = Position.Builder() + .also { b -> + b.latitude_i = (lat * 1e7).toInt() + b.longitude_i = (-105.0 * 1e7).toInt() + b.altitude = altitude + b.time = time + } + .build() + +class HudPathsTest { + + @Test + fun `a segment wholly in front is left alone`() { + val a = Vec3(0.0, 1.0, 0.0) + val b = Vec3(0.5, 1.0, 0.0) + assertEquals(a to b, clipToNearPlane(a, b, northPose)) + } + + @Test + fun `a segment wholly behind is dropped`() { + assertNull(clipToNearPlane(Vec3(0.0, -1.0, 0.0), Vec3(0.5, -1.0, 0.0), northPose)) + } + + @Test + fun `a segment crossing the near plane is cut at it`() { + val (a, b) = assertNotNull(clipToNearPlane(Vec3(0.0, -1.0, 0.0), Vec3(0.0, 1.0, 0.0), northPose)) + assertEquals(NEAR_PLANE, a.y, 1e-9) + assertEquals(1.0, b.y, 1e-9) + assertNotNull(project(a, northPose, 800f, 1000f, 2000f)) + } + + @Test + fun `an arc starts and ends at its hops and lifts between them`() { + val a = PathPoint(bearingDeg = 0.0, elevationDeg = 0.0, distanceM = 1000.0, atViewer = false) + val b = PathPoint(bearingDeg = 40.0, elevationDeg = 0.0, distanceM = 1000.0, atViewer = false) + val arc = arcDirections(a, b, liftSign = 1) + assertEquals(directionEnu(0.0, 0.0).x, arc.first().x, 1e-9) + assertEquals(directionEnu(40.0, 0.0).x, arc.last().x, 1e-9) + assertTrue(arc[arc.size / 2].z > 0) + assertTrue(arcDirections(a, b, liftSign = -1)[arc.size / 2].z < 0) + } + + @Test + fun `a route counts unknown relays apart and reads SNR in dB`() { + val positions = mapOf(1 to at(40.0), 3 to at(40.01)) + val route = + buildHudRoute( + here, + declination = 0f, + forward = listOf(1, UNKNOWN_HOP, 3), + back = emptyList(), + snrTowards = listOf(20, -128), + snrBack = null, + positionOf = positions::get, + ) + assertEquals(2, route.total) + assertEquals(2, route.mappable) + assertNull(route.forward.points[1]) + assertEquals(listOf(5f, null), route.forward.snrDb) + assertNull(route.back) + assertEquals(setOf(1, 3), route.nodeNums) + } + + @Test + fun `SNR is ignored when it does not match the route`() { + val route = buildHudRoute(here, 0f, listOf(1, 2, 3), emptyList(), listOf(12), null) { at(40.01) } + assertEquals(listOf(null, null), route.forward.snrDb) + } + + @Test + fun `a hop at the phone starts the route from the viewer`() { + val route = + buildHudRoute(here, 0f, listOf(1, 2), emptyList(), null, null) { num -> + if (num == 1) at(40.0) else at(40.01) + } + assertTrue(assertNotNull(route.forward.points[0]).atViewer) + assertFalse(assertNotNull(route.forward.points[1]).atViewer) + } + + @Test + fun `the connected radio is at the viewer even with no position or a distant one`() { + val unplaced = + buildHudRoute(here, 0f, listOf(1, 2), listOf(2, 1), null, null, viewerNum = 1) { num -> + if (num == 1) null else at(40.01) + } + assertTrue(assertNotNull(unplaced.forward.points[0]).atViewer) + assertTrue(assertNotNull(assertNotNull(unplaced.back).points[1]).atViewer) + val distant = buildHudRoute(here, 0f, listOf(1, 2), emptyList(), null, null, viewerNum = 1) { at(40.01) } + assertTrue(assertNotNull(distant.forward.points[0]).atViewer) + } + + @Test + fun `a track runs oldest first and flags points with no altitude`() { + val track = + buildHudTrack( + here, + declination = 0f, + nodeNum = 7, + color = 0, + positions = listOf(at(40.02, time = 300), at(40.01, time = 100, altitude = null)), + nowSeconds = 400, + ) + assertEquals(listOf(300L, 100L), track.points.map { it.ageSec }) + assertFalse(track.points.first().altitudeKnown) + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudRadarTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudRadarTest.kt new file mode 100644 index 00000000000..dadd47fee72 --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudRadarTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import org.meshtastic.core.model.Node +import kotlin.test.Test +import kotlin.test.assertEquals + +private fun nearby(bearing: Double, distance: Double) = + NearbyTarget(HudSubject.NodeSubject(Node(num = 1)), 0.0, 0.0, bearing, distance, 0.0) + +class HudRadarTest { + + @Test + fun `the scale rounds up to one two or five`() { + assertEquals(20.0, niceScaleM(13.0)) + assertEquals(50.0, niceScaleM(21.0)) + assertEquals(100.0, niceScaleM(51.0)) + assertEquals(10.0, niceScaleM(10.0)) + } + + @Test + fun `a target straight ahead plots straight up`() { + val at = radarOffset(nearby(bearing = 90.0, distance = 10.0), headingDeg = 90.0, scaleM = 20.0, radiusPx = 100f) + assertEquals(0f, at.x, 0.01f) + assertEquals(-50f, at.y, 0.01f) + } + + @Test + fun `a target to the right of the heading plots to the right`() { + val at = + radarOffset(nearby(bearing = 180.0, distance = 20.0), headingDeg = 90.0, scaleM = 20.0, radiusPx = 100f) + assertEquals(100f, at.x, 0.01f) + assertEquals(0f, at.y, 0.01f) + } + + @Test + fun `a target beyond the scale stays on the rim`() { + val at = radarOffset(nearby(bearing = 0.0, distance = 500.0), headingDeg = 0.0, scaleM = 20.0, radiusPx = 100f) + assertEquals(-100f, at.y, 0.01f) + } + + @Test + fun `a path point beyond the scale runs past the rim the same way`() { + val at = radarPathOffset(bearingDeg = 180.0, distanceM = 500.0, headingDeg = 90.0, pxPerM = 1f) + assertEquals(500f, at.x, 0.01f) + assertEquals(0f, at.y, 0.01f) + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudTargetsTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudTargetsTest.kt new file mode 100644 index 00000000000..e863e8d4c3c --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudTargetsTest.kt @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import org.meshtastic.core.common.util.MeasurementSystem +import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.PhoneLocation +import org.meshtastic.core.repository.PhoneLocationState +import org.meshtastic.proto.Position +import org.meshtastic.proto.Waypoint +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +private const val LAT = 40.0 +private const val LON = -105.0 + +/** About 1.1 km north of the phone. */ +private const val NORTH_LAT = 40.01 + +private fun phone(hae: Double? = 100.0, msl: Double? = null, accuracyM: Float? = null) = PhoneLocationState( + permissionGranted = true, + providerEnabled = true, + location = PhoneLocation(LAT, LON, hae, 0L, mslAltitude = msl, accuracyM = accuracyM), +) + +private fun node(num: Int, lat: Double = NORTH_LAT, hae: Int? = null, msl: Int? = null, separation: Int? = null) = Node( + num = num, + position = + Position.Builder() + .also { b -> + b.latitude_i = (lat * 1e7).toInt() + b.longitude_i = (LON * 1e7).toInt() + b.altitude_hae = hae + b.altitude = msl + b.altitude_geoidal_separation = separation + } + .build(), +) + +private fun waypoint(id: Int, lat: Double) = Waypoint.Builder() + .also { b -> + b.id = id + b.latitude_i = (lat * 1e7).toInt() + b.longitude_i = (LON * 1e7).toInt() + } + .build() + +private fun build( + location: PhoneLocationState = phone(), + nodes: List = emptyList(), + waypoints: List = emptyList(), + focus: String? = null, + declination: Float = 0f, +) = buildHudTargets(location, nodes, waypoints, MeasurementSystem.METRIC, focus, nowSeconds = 0L) { declination } + +class HudTargetsTest { + + @Test + fun `ellipsoid heights are compared to each other first`() { + val target = build(nodes = listOf(node(1, hae = 200, msl = 5))).targets.single() + assertTrue(target.elevationKnown) + // 100 m up over about 1112 m. + assertEquals(5.1, target.elevationDeg, 0.1) + } + + @Test + fun `sea level heights are compared when either side lacks the ellipsoid`() { + val target = build(phone(hae = null, msl = 100.0), listOf(node(1, msl = 200))).targets.single() + assertEquals(5.1, target.elevationDeg, 0.1) + } + + @Test + fun `the node geoid separation bridges a sea level node to an ellipsoid phone`() { + // Phone HAE 100 with separation -20 is 120 MSL, so a node at 220 MSL is 100 m up. + val target = build(phone(hae = 100.0), listOf(node(1, msl = 220, separation = -20))).targets.single() + assertEquals(5.1, target.elevationDeg, 0.1) + } + + @Test + fun `with no shared frame the target sits level and says so`() { + val target = build(phone(hae = null), listOf(node(1, msl = 300))).targets.single() + assertFalse(target.elevationKnown) + assertTrue(target.elevationDeg < 0 && target.elevationDeg > -0.1) + } + + @Test + fun `a node at sea level is a reading and not a missing altitude`() { + val target = build(phone(hae = null, msl = 100.0), listOf(node(1, msl = 0))).targets.single() + assertTrue(target.elevationKnown) + assertEquals(-5.1, target.elevationDeg, 0.1) + } + + @Test + fun `a phone at zero ellipsoid height is a reading and not a missing altitude`() { + val target = build(phone(hae = 0.0), listOf(node(1, hae = 100))).targets.single() + assertTrue(target.elevationKnown) + assertEquals(5.1, target.elevationDeg, 0.1) + } + + @Test + fun `a node closer than the phone fix accuracy has no direction`() { + val result = build(phone(accuracyM = 2_000f), listOf(node(1))) + assertTrue(result.targets.isEmpty()) + assertEquals("node-1", result.directionUnknown.single().subject.key) + } + + @Test + fun `independent errors combine as a root sum of squares`() { + // 800 m and 600 m make 1000 m: a node 1.1 km off stays pointable, where adding them would not. + val node = + Node( + num = 1, + position = + Position.Builder() + .also { b -> + b.latitude_i = (NORTH_LAT * 1e7).toInt() + b.longitude_i = (LON * 1e7).toInt() + b.gps_accuracy = 800_000 + b.PDOP = 100 + } + .build(), + ) + assertEquals(1, build(phone(accuracyM = 600f), listOf(node)).targets.size) + } + + @Test + fun `a waypoint closer than the phone fix accuracy has no direction either`() { + val result = build(phone(accuracyM = 2_000f), waypoints = listOf(waypoint(7, NORTH_LAT))) + assertEquals("waypoint-7", result.directionUnknown.single().subject.key) + } + + @Test + fun `a waypoint on the equator and prime meridian is placed`() { + val nullIsland = + Waypoint.Builder() + .also { b -> + b.id = 3 + b.latitude_i = 0 + b.longitude_i = 0 + } + .build() + assertEquals("waypoint-3", build(waypoints = listOf(nullIsland)).targets.single().subject.key) + } + + @Test + fun `a waypoint without coordinates is left out`() { + val unplaced = Waypoint.Builder().also { it.id = 4 }.build() + assertTrue(build(waypoints = listOf(unplaced)).targets.isEmpty()) + } + + @Test + fun `a circular geofence is traced around its edge`() { + val fenced = waypoint(9, NORTH_LAT).newBuilder().also { it.geofence_radius = 100 }.build() + val fence = build(waypoints = listOf(fenced)).fences.single() + assertEquals(64, fence.edge.size) + assertFalse(fence.inside) + // The near edge is about 1012 m off, the far edge about 1212 m. + assertEquals(1012.0, fence.edge.minOf { it.distanceM }, 5.0) + } + + @Test + fun `standing inside a geofence is reported`() { + val fenced = waypoint(9, LAT).newBuilder().also { it.geofence_radius = 100 }.build() + assertTrue(build(waypoints = listOf(fenced)).fences.single().inside) + } + + @Test + fun `bearings are magnetic so they match the camera`() { + val target = build(nodes = listOf(node(1)), declination = 10f).targets.single() + assertEquals(350.0, target.bearingDeg, 0.01) + } + + @Test + fun `the focused target comes first and the rest nearest first`() { + val result = build(nodes = listOf(node(1, lat = 40.03), node(2, lat = 40.02), node(3)), focus = "node-1") + assertEquals(listOf("node-1", "node-3", "node-2"), result.targets.map { it.subject.key }) + assertTrue(result.targets.first().focused) + } + + @Test + fun `a rise well beyond the altitudes uncertainty is worded with its direction`() { + val above = build(nodes = listOf(node(1, hae = 220))).targets.single().rise + assertTrue(assertNotNull(above).above) + assertTrue(above.text.contains("120")) + val below = build(nodes = listOf(node(1, hae = 40))).targets.single().rise + assertFalse(assertNotNull(below).above) + } + + @Test + fun `a small rise or an unknown altitude is not worded`() { + assertNull(build(nodes = listOf(node(1, hae = 120))).targets.single().rise) + assertNull(build(nodes = listOf(node(1))).targets.single().rise) + } + + @Test + fun `without a fix there are warnings and no targets`() { + val result = build(PhoneLocationState(permissionGranted = false, providerEnabled = true), listOf(node(1))) + assertEquals(listOf(HudWarning.NO_LOCATION_PERMISSION), result.warnings) + assertTrue(result.targets.isEmpty()) + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt new file mode 100644 index 00000000000..5bef647acc6 --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.mock +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.service.TracerouteResponse +import org.meshtastic.core.repository.MagneticFieldProvider +import org.meshtastic.core.repository.MapFilterPrefs +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.PhoneLocation +import org.meshtastic.core.repository.PhoneLocationProvider +import org.meshtastic.core.repository.PhoneLocationState +import org.meshtastic.core.repository.TracerouteResponseProvider +import org.meshtastic.core.testing.FakeLocaleUnitsProvider +import org.meshtastic.core.testing.FakeMapPrefs +import org.meshtastic.core.testing.FakeMeshLogRepository +import org.meshtastic.core.testing.FakeNodeRepository +import org.meshtastic.core.testing.FakeNotificationPrefs +import org.meshtastic.core.testing.FakeTracerouteSnapshotRepository +import org.meshtastic.proto.Position +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private const val LAT = 40.0 +private const val LON = -105.0 + +/** About 1.1 km north of the phone. */ +private const val NEAR_LAT = 40.01 + +/** About 111 km north of the phone. */ +private const val FAR_LAT = 41.0 +private const val FAR_ENOUGH_M = 100_000.0 + +private fun node(num: Int, lat: Double = NEAR_LAT, favorite: Boolean = false) = Node( + num = num, + isFavorite = favorite, + position = + Position.Builder() + .also { b -> + b.latitude_i = (lat * 1e7).toInt() + b.longitude_i = (LON * 1e7).toInt() + } + .build(), +) + +private class FakePoseProvider : CameraPoseProvider { + override fun poseUpdates(): Flow = MutableStateFlow(CameraPoseState()) +} + +private class FakePhoneLocationProvider : PhoneLocationProvider { + val state = + MutableStateFlow( + PhoneLocationState( + permissionGranted = true, + providerEnabled = true, + location = PhoneLocation(LAT, LON, 100.0, 0L, accuracyM = 5f, speedMps = 0f), + ), + ) + + override fun locationUpdates(): Flow = state +} + +private class ZeroDeclination : MagneticFieldProvider { + override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long) = 0f +} + +private class FakeTracerouteResponses : TracerouteResponseProvider { + override val tracerouteResponse = MutableStateFlow(null) + + override fun clearTracerouteResponse() { + tracerouteResponse.value = null + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class MeshHudViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val nodeRepository = FakeNodeRepository() + private val mapPrefs = FakeMapPrefs() + private val traceroutes = FakeTracerouteResponses() + private val snapshots = FakeTracerouteSnapshotRepository() + private val packetRepository: PacketRepository = mock() + private lateinit var viewModel: MeshHudViewModel + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + every { packetRepository.getWaypoints() } returns MutableStateFlow(emptyList()) + viewModel = + MeshHudViewModel( + poseProvider = FakePoseProvider(), + phoneLocationProvider = FakePhoneLocationProvider(), + magneticFieldProvider = ZeroDeclination(), + nodeRepository = nodeRepository, + packetRepository = packetRepository, + meshLogRepository = FakeMeshLogRepository(), + tracerouteResponseProvider = traceroutes, + tracerouteSnapshotRepository = snapshots, + mapPrefs = mapPrefs, + notificationPrefs = FakeNotificationPrefs(), + localeUnitsProvider = FakeLocaleUnitsProvider(), + dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher), + ) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun TestScope.collectTargets(): StateFlow { + backgroundScope.launch { viewModel.targets.collect {} } + runCurrent() + return viewModel.targets + } + + private val StateFlow.keys + get() = value.targets.map { it.subject.key }.toSet() + + @Test + fun `targets are not ready before the first computation and ready after`() = runTest(testDispatcher) { + nodeRepository.setNodes(listOf(node(1))) + assertFalse(viewModel.targets.value.ready) + val targets = collectTargets() + assertTrue(targets.value.ready) + } + + @Test + fun `the whole mesh view shows every positioned node passing the map filter`() = runTest(testDispatcher) { + nodeRepository.setNodes(listOf(node(1, favorite = true), node(2), Node(num = 3))) + mapPrefs.mapFilters.value = MapFilterPrefs(onlyFavorites = true) + viewModel.openFor(null) + val targets = collectTargets() + assertEquals(setOf("node-1"), targets.keys) + assertFalse(targets.value.scoped) + + mapPrefs.mapFilters.value = MapFilterPrefs() + runCurrent() + assertEquals(setOf("node-1", "node-2"), targets.keys) + } + + @Test + fun `a view opened for a node shows only that node`() = runTest(testDispatcher) { + nodeRepository.setNodes(listOf(node(1), node(2), node(3))) + viewModel.openFor(2) + val targets = collectTargets() + assertEquals(setOf("node-2"), targets.keys) + assertTrue(targets.value.scoped) + assertTrue(targets.value.targets.single().focused) + } + + @Test + fun `opening again for the same node keeps the focus the user chose`() = runTest(testDispatcher) { + nodeRepository.setNodes(listOf(node(1), node(2))) + viewModel.openFor(null) + val targets = collectTargets() + viewModel.toggleFocus(HudSubject.NodeSubject(node(2))) + viewModel.openFor(null) + runCurrent() + assertEquals("node-2", targets.value.targets.single { it.focused }.subject.key) + + viewModel.openFor(1) + runCurrent() + assertEquals("node-1", targets.value.targets.single { it.focused }.subject.key) + } + + @Test + fun `a scoped view ignores the map filter for its node`() = runTest(testDispatcher) { + nodeRepository.setNodes(listOf(node(1, favorite = true), node(2))) + mapPrefs.mapFilters.value = MapFilterPrefs(onlyFavorites = true) + viewModel.openFor(2) + assertEquals(setOf("node-2"), collectTargets().keys) + } + + @Test + fun `a scoped view also shows the node its open track names`() = runTest(testDispatcher) { + nodeRepository.setNodes(listOf(node(1), node(2), node(3))) + viewModel.openFor(1) + viewModel.showTrack(2) + assertEquals(setOf("node-1", "node-2"), collectTargets().keys) + } + + @Test + fun `a scoped view also shows the hops its open traceroute names`() = runTest(testDispatcher) { + nodeRepository.setNodes(listOf(node(1), node(2), node(3), node(4), node(5))) + traceroutes.tracerouteResponse.value = + TracerouteResponse("", 2, requestId = 7, forwardRoute = listOf(3), returnRoute = listOf(4)) + viewModel.openFor(2) + viewModel.showTraceroute(destNum = 2, requestId = 7, logUuid = null) + assertEquals(setOf("node-2", "node-3", "node-4"), collectTargets().keys) + } + + @Test + fun `a hop is placed where its traceroute recorded it rather than where it is now`() = runTest(testDispatcher) { + nodeRepository.setNodes(listOf(node(2), node(3))) + val recorded = mapOf(3 to node(3, lat = FAR_LAT).position) + snapshots.upsertSnapshotPositions("log-1", requestId = 7, positions = recorded) + traceroutes.tracerouteResponse.value = + TracerouteResponse("", 2, requestId = 7, forwardRoute = listOf(3), returnRoute = emptyList()) + viewModel.openFor(2) + viewModel.showTraceroute(destNum = 2, requestId = 7, logUuid = "log-1") + val hop = collectTargets().value.targets.first { it.subject.key == "node-3" } + assertTrue(hop.distanceM > FAR_ENOUGH_M) + } + + @Test + fun `the whole mesh view leaves distance to the map filters`() = runTest(testDispatcher) { + nodeRepository.setNodes(listOf(node(1), node(3, lat = FAR_LAT))) + viewModel.openFor(null) + assertEquals(setOf("node-1", "node-3"), collectTargets().keys) + } + + @Test + fun `a scoped view keeps its node when the focus is cleared`() = runTest(testDispatcher) { + val far = node(3, lat = FAR_LAT) + nodeRepository.setNodes(listOf(node(1), far)) + viewModel.openFor(3) + val targets = collectTargets() + assertEquals(setOf("node-3"), targets.keys) + + viewModel.toggleFocus(HudSubject.NodeSubject(far)) + runCurrent() + assertEquals(setOf("node-3"), targets.keys) + assertFalse(targets.value.targets.single().focused) + } + + @Test + fun `a tracked node stays in the whole mesh view`() = runTest(testDispatcher) { + nodeRepository.setNodes(listOf(node(1), node(3, lat = FAR_LAT))) + viewModel.openFor(null) + viewModel.showTrack(3) + assertEquals(setOf("node-1", "node-3"), collectTargets().keys) + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/OneEuroQuaternionFilterTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/OneEuroQuaternionFilterTest.kt new file mode 100644 index 00000000000..81df0a0cb4b --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/OneEuroQuaternionFilterTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import kotlin.math.cos +import kotlin.math.sin +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val STEP_NS = 20_000_000L + +/** A rotation of [deg] about the vertical, as (x, y, z, w). */ +private fun yaw(deg: Double): FloatArray { + val half = deg * kotlin.math.PI / 360 + return floatArrayOf(0f, 0f, sin(half).toFloat(), cos(half).toFloat()) +} + +class OneEuroQuaternionFilterTest { + + @Test + fun `the first sample passes straight through`() { + val out = FloatArray(4) + OneEuroQuaternionFilter().filter(yaw(30.0), STEP_NS, out) + assertEquals(yaw(30.0).toList(), out.toList()) + } + + @Test + fun `a jittery still phone is smoothed toward its mean`() { + val filter = OneEuroQuaternionFilter() + val out = FloatArray(4) + repeat(200) { i -> filter.filter(yaw(if (i % 2 == 0) 1.0 else -1.0), (i + 1) * STEP_NS, out) } + // The raw samples swing two degrees; the output should hold within a fraction of one. + assertTrue(kotlin.math.abs(out[2]) < sin(0.5 * kotlin.math.PI / 360)) + } + + @Test + fun `a fast pan is followed without long lag`() { + val filter = OneEuroQuaternionFilter() + val out = FloatArray(4) + filter.filter(yaw(0.0), STEP_NS, out) + repeat(25) { i -> filter.filter(yaw(90.0), (i + 2) * STEP_NS, out) } + // Half a second after a 90 degree turn the output is most of the way there. + assertTrue(out[2] > sin(60.0 * kotlin.math.PI / 360)) + } + + @Test + fun `q and minus q are the same rotation`() { + val filter = OneEuroQuaternionFilter() + val out = FloatArray(4) + filter.filter(yaw(10.0), STEP_NS, out) + filter.filter(yaw(10.0).map { -it }.toFloatArray(), 2 * STEP_NS, out) + assertEquals(yaw(10.0)[2], out[2], 1e-4f) + } +} diff --git a/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.ios.kt b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.ios.kt new file mode 100644 index 00000000000..aa3f21d672a --- /dev/null +++ b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.ios.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +actual fun HudCameraPreview( + onIntrinsics: (CameraIntrinsics?) -> Unit, + onAvailable: (Boolean) -> Unit, + modifier: Modifier, +) = Unit diff --git a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.jvm.kt b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.jvm.kt new file mode 100644 index 00000000000..aa3f21d672a --- /dev/null +++ b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.jvm.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.hud + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +actual fun HudCameraPreview( + onIntrinsics: (CameraIntrinsics?) -> Unit, + onAvailable: (Boolean) -> Unit, + modifier: Modifier, +) = Unit diff --git a/feature/node/detekt-baseline.xml b/feature/node/detekt-baseline.xml index 7db65a1758b..6f40e2209e8 100644 --- a/feature/node/detekt-baseline.xml +++ b/feature/node/detekt-baseline.xml @@ -112,6 +112,6 @@ UseOrEmpty:PowerMetrics.kt:state.node?.user?.long_name ?: "" UseOrEmpty:SignalMetrics.kt:state.node?.user?.long_name ?: "" UseOrEmpty:TracerouteLog.kt:state.node?.user?.long_name ?: "" - ViewModelForwarding:NodeDetailScreens.kt:NodeDetailScaffold( modifier = modifier, uiState = uiState, viewModel = viewModel, navigateToMessages = navigateToMessages, onNavigate = onNavigate, onNavigateUp = onNavigateUp, compassViewModel = compassViewModel, ) + ViewModelForwarding:NodeDetailScreens.kt:NodeDetailScaffold( modifier = modifier, uiState = uiState, viewModel = viewModel, navigateToMessages = navigateToMessages, onNavigate = onNavigate, onNavigateUp = onNavigateUp, compassViewModel = compassViewModel, openCompass = openCompass, ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt index 390109b9e62..ead4cb7a34f 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt @@ -34,21 +34,20 @@ import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.Node -import org.meshtastic.core.model.util.precisionRadiusMetersOrNull import org.meshtastic.core.model.util.toDistanceString +import org.meshtastic.core.model.util.uncertaintyRadiusMetersOrNull +import org.meshtastic.core.repository.MagneticFieldProvider +import org.meshtastic.core.repository.PhoneLocationProvider +import org.meshtastic.core.repository.PhoneLocationState import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.proto.Position import kotlin.math.abs import kotlin.math.atan2 import kotlin.math.min -import kotlin.math.pow -import kotlin.math.sqrt private const val ALIGNMENT_TOLERANCE_DEGREES = 5f private const val FULL_CIRCLE_DEGREES = 360f private const val BEARING_FORMAT = "%.0f°" -private const val HUNDRED = 100f -private const val MILLIMETERS_PER_METER = 1000f @Suppress("TooManyFunctions") @KoinViewModel @@ -125,8 +124,8 @@ class CompassViewModel( val isAligned = isAligned(trueHeading, bearingDegrees) val lastUpdateAgeSeconds = targetPositionTimeSec?.let { maxOf(0, nowSeconds - it) } val angularErrorDeg = calculateAngularError(positionalAccuracyMeters, distanceMeters) - val errorRadiusText = - positionalAccuracyMeters?.toInt()?.let { "± ${it.toDistanceString(current.displayUnits)}" } + // The string resource carries the ±. + val errorRadiusText = positionalAccuracyMeters?.toInt()?.toDistanceString(current.displayUnits) return current.copy( heading = trueHeading, @@ -191,7 +190,8 @@ class CompassViewModel( private fun applyTrueNorthCorrection(heading: Float?, locationState: PhoneLocationState): Float? { val loc = locationState.location ?: return heading val baseHeading = heading ?: return null - val declination = magneticFieldProvider.getDeclination(loc.latitude, loc.longitude, loc.altitude, nowMillis) + val declination = + magneticFieldProvider.getDeclination(loc.latitude, loc.longitude, loc.altitude ?: 0.0, nowMillis) return (baseHeading + declination + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES } @@ -201,34 +201,7 @@ class CompassViewModel( val positionTime = targetPositionTimeSec if (positionTime == null || positionTime <= 0) return null - val gpsAccuracyMm = position.gps_accuracy.toFloat() - val pdop = position.PDOP - val hdop = position.HDOP - val vdop = position.VDOP - val dop: Float? = - when { - pdop > 0 -> pdop / HUNDRED - - hdop > 0 && vdop > 0 -> - sqrt((hdop / HUNDRED).toDouble().pow(2.0) + (vdop / HUNDRED).toDouble().pow(2.0)).toFloat() - - hdop > 0 -> hdop / HUNDRED - - else -> null - } - - if (gpsAccuracyMm > 0f && dop != null) { - return (gpsAccuracyMm / MILLIMETERS_PER_METER) * dop - } - - // Fallback: infer radius from precision bits if provided - // Only a degraded position implies a radius; `0` and `32` both mean "not degraded", and the old formula - // answered 23,905 km and 5 mm for them respectively. - precisionRadiusMetersOrNull(position.precision_bits)?.let { - return it.toFloat() - } - - return null + return position.uncertaintyRadiusMetersOrNull()?.toFloat() } @Suppress("ReturnCount") diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt index f92d01b842e..88d4ce39b70 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -69,7 +70,9 @@ import org.meshtastic.core.resources.elevation_suffix import org.meshtastic.core.resources.exchange_position import org.meshtastic.core.resources.formatDuration import org.meshtastic.core.resources.last_position_update +import org.meshtastic.core.resources.mesh_hud_open import org.meshtastic.core.ui.component.ElevationInfo +import org.meshtastic.core.ui.icon.CenterFocusStrong import org.meshtastic.core.ui.icon.ErrorOutline import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.MyLocation @@ -89,6 +92,8 @@ fun CompassSheetContent( onOpenLocationSettings: () -> Unit, onRequestPosition: () -> Unit, modifier: Modifier = Modifier, + /** Null hides the mesh view button, for platforms with no camera. */ + onOpenMeshHud: (() -> Unit)? = null, ) { val haptics = LocalHapticFeedback.current @@ -166,6 +171,14 @@ fun CompassSheetContent( } } + onOpenMeshHud?.let { onClick -> + FilledTonalButton(onClick = onClick, modifier = Modifier.fillMaxWidth()) { + Icon(imageVector = MeshtasticIcons.CenterFocusStrong, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(Res.string.mesh_hud_open)) + } + } + if (uiState.warnings.isNotEmpty()) { WarningList( warnings = uiState.warnings, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt index 51fecd6bbb7..9eb1922b745 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.nowMillis @@ -38,14 +39,18 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.add_favorite import org.meshtastic.core.resources.ignore +import org.meshtastic.core.resources.mesh_hud_open import org.meshtastic.core.resources.message import org.meshtastic.core.resources.mute_notifications +import org.meshtastic.core.resources.open_compass import org.meshtastic.core.resources.remove import org.meshtastic.core.resources.remove_favorite import org.meshtastic.core.resources.remove_ignored import org.meshtastic.core.resources.trace_route import org.meshtastic.core.resources.unmute import org.meshtastic.core.resources.update_status +import org.meshtastic.core.ui.icon.CenterFocusStrong +import org.meshtastic.core.ui.icon.Compass import org.meshtastic.core.ui.icon.DeleteNode import org.meshtastic.core.ui.icon.DoDisturb import org.meshtastic.core.ui.icon.Favorite @@ -57,9 +62,10 @@ import org.meshtastic.core.ui.icon.Route import org.meshtastic.core.ui.icon.VolumeOff import org.meshtastic.core.ui.icon.VolumeUp import org.meshtastic.core.ui.theme.StatusColors.StatusRed +import org.meshtastic.core.ui.util.rememberMeshHudSupported /** - * Shared context menu for node actions (favorite, mute, message, trace route, ignore, remove). + * Shared context menu for node actions (favorite, mute, message, trace route, compass, mesh view, ignore, remove). * * Used by both Android and Desktop adaptive node list screens. */ @@ -75,6 +81,8 @@ fun NodeContextMenu( onIgnore: () -> Unit, onRemove: () -> Unit, onDismiss: () -> Unit, + onOpenMeshView: () -> Unit = {}, + onOpenCompass: () -> Unit = {}, ) { DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) { @@ -84,6 +92,17 @@ fun NodeContextMenu( } MessageMenuItem(node, onMessage, onDismiss) TraceRouteMenuItem(node, lastTracerouteTime, onTraceRoute, onDismiss) + if (node.validPosition != null) { + MenuItem(MeshtasticIcons.Compass, stringResource(Res.string.open_compass), onOpenCompass, onDismiss) + if (rememberMeshHudSupported()) { + MenuItem( + MeshtasticIcons.CenterFocusStrong, + stringResource(Res.string.mesh_hud_open), + onOpenMeshView, + onDismiss, + ) + } + } } DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) { IgnoreMenuItem(node, onIgnore, onDismiss) @@ -212,6 +231,18 @@ private fun TraceRouteMenuItem( private const val COOLDOWN_TICK_MS = 500L +@Composable +private fun MenuItem(icon: ImageVector, text: String, onClick: () -> Unit, onDismiss: () -> Unit) { + DropdownMenuItem( + onClick = { + onClick() + onDismiss() + }, + leadingIcon = { Icon(imageVector = icon, contentDescription = null) }, + text = { Text(text = text) }, + ) +} + @Composable private fun RemoveMenuItem(node: Node, onRemove: () -> Unit, onDismiss: () -> Unit) { DropdownMenuItem( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt index 950b36ffa09..bd5eced47ea 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt @@ -41,14 +41,17 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.navigation.MapRoute import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.mesh_hud_open import org.meshtastic.core.resources.open_compass import org.meshtastic.core.resources.site_planner_estimate import org.meshtastic.core.ui.icon.CellTower +import org.meshtastic.core.ui.icon.CenterFocusStrong import org.meshtastic.core.ui.icon.Compass import org.meshtastic.core.ui.icon.Distance import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.util.LocalInlineMapProvider import org.meshtastic.core.ui.util.LocalSitePlannerAvailable +import org.meshtastic.core.ui.util.rememberMeshHudSupported import org.meshtastic.feature.node.model.NodeDetailAction private const val MAP_HEIGHT_DP = 200 @@ -83,6 +86,23 @@ internal fun PositionInlineContent( overflow = TextOverflow.Ellipsis, ) } + if (rememberMeshHudSupported() && node.validPosition != null) { + Spacer(Modifier.height(8.dp)) + FilledTonalButton( + onClick = { onAction(NodeDetailAction.Navigate(MapRoute.MeshHud(focusNodeNum = node.num))) }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + ) { + Icon(MeshtasticIcons.CenterFocusStrong, null, Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text( + text = stringResource(Res.string.mesh_hud_open), + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } // Estimate RF coverage for this node in the Site Planner (Google flavor, position-gated). if (LocalSitePlannerAvailable.current && node.validPosition != null) { Spacer(Modifier.height(8.dp)) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt index c5ba3964370..e3b9a23a20a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -38,12 +39,14 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.MeasurementSystem import org.meshtastic.core.model.FirmwareRelease import org.meshtastic.core.model.Node +import org.meshtastic.core.navigation.MapRoute import org.meshtastic.core.navigation.Route import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.details import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.ShareContactDialog import org.meshtastic.core.ui.util.ActiveWhileStarted +import org.meshtastic.core.ui.util.rememberMeshHudSupported import org.meshtastic.feature.node.compass.CompassUiState import org.meshtastic.feature.node.compass.CompassViewModel import org.meshtastic.feature.node.component.CompassSheetContent @@ -68,6 +71,7 @@ fun NodeDetailScreen( onNavigate: (Route) -> Unit = {}, onNavigateUp: () -> Unit = {}, compassViewModel: CompassViewModel? = null, + openCompass: Boolean = false, ) { SideEffect(nodeId) { viewModel.start(nodeId) } val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -81,9 +85,25 @@ fun NodeDetailScreen( onNavigate = onNavigate, onNavigateUp = onNavigateUp, compassViewModel = compassViewModel, + openCompass = openCompass, ) } +/** Opens the compass sheet once per arrival: closing it, or coming back to this screen, must not reopen it. */ +@Suppress("UnnecessaryLaunchedEffect") // Fires once per node, not once per recomposition. +@Composable +private fun OpenCompassOnArrival(uiState: NodeDetailUiState, open: (NodeDetailOverlay) -> Unit) { + var opened by rememberSaveable { mutableStateOf(false) } + val currentOpen by rememberUpdatedState(open) + val node = uiState.node?.takeIf { !opened && it.validPosition != null } + LaunchedEffect(node?.num) { + node?.let { + currentOpen(NodeDetailOverlay.Compass(it.num, uiState.metricsState.displayUnits)) + opened = true + } + } +} + @Composable @Suppress("LongParameterList") private fun NodeDetailScaffold( @@ -94,8 +114,10 @@ private fun NodeDetailScaffold( onNavigate: (Route) -> Unit, onNavigateUp: () -> Unit, compassViewModel: CompassViewModel? = null, + openCompass: Boolean = false, ) { var activeOverlay by remember { mutableStateOf(null) } + if (openCompass) OpenCompassOnArrival(uiState) { activeOverlay = it } val actualCompassViewModel = compassViewModel val compassUiState by actualCompassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) } @@ -153,6 +175,15 @@ private fun NodeDetailScaffold( compassUiState, actualCompassViewModel, { activeOverlay = null }, + onOpenMeshHud = + if (rememberMeshHudSupported()) { + { num -> + activeOverlay = null + onNavigate(MapRoute.MeshHud(num)) + } + } else { + null + }, ) { viewModel.handleNodeMenuAction(NodeMenuAction.RequestPosition(it)) } @@ -167,6 +198,7 @@ private fun NodeDetailOverlays( compassUiState: CompassUiState, compassViewModel: CompassViewModel?, onDismiss: () -> Unit, + onOpenMeshHud: ((Int) -> Unit)?, onRequestPosition: (Node) -> Unit, ) { val locationPermission = org.meshtastic.core.ui.util.rememberLocationPermissionState() @@ -213,6 +245,7 @@ private fun NodeDetailOverlays( onOpenLocationSettings = { openLocationSettings() }, onRequestPosition = { node?.let { onRequestPosition(it) } }, modifier = Modifier.padding(bottom = 24.dp), + onOpenMeshHud = onOpenMeshHud?.let { open -> { open(overlay.nodeNum) } }, ) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index b6cd2b0f3f3..55442ffc994 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -130,6 +130,8 @@ fun NodeListScreen( onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, onNavigateToConnections: () -> Unit = {}, onEditStatusMessage: () -> Unit = {}, + navigateToMeshView: (Int) -> Unit = {}, + navigateToCompass: (Int) -> Unit = {}, ) { val showToast = org.meshtastic.core.ui.util.rememberShowToastResource() val scope = rememberCoroutineScope() @@ -274,6 +276,8 @@ fun NodeListScreen( onIgnore = { viewModel.ignoreNode(node) }, onRemove = { viewModel.removeNodes(listOf(node)) }, onDismiss = { expanded = false }, + onOpenMeshView = { navigateToMeshView(node.num) }, + onOpenCompass = { navigateToCompass(node.num) }, ) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt index 89d90b6d487..fbd14d0148c 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt @@ -36,8 +36,10 @@ import org.meshtastic.core.resources.clear_position_track_message import org.meshtastic.core.resources.clear_position_track_title import org.meshtastic.core.resources.delete import org.meshtastic.core.resources.export_gpx +import org.meshtastic.core.resources.mesh_hud_open import org.meshtastic.core.resources.position_log import org.meshtastic.core.ui.component.MeshtasticResourceDialog +import org.meshtastic.core.ui.icon.CenterFocusStrong import org.meshtastic.core.ui.icon.Delete import org.meshtastic.core.ui.icon.FileDownload import org.meshtastic.core.ui.icon.MeshtasticIcons @@ -46,7 +48,12 @@ import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider import org.meshtastic.core.ui.util.rememberSaveFileLauncher @Composable -fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { +fun PositionLogScreen( + viewModel: MetricsViewModel, + onNavigateUp: () -> Unit, + /** Null hides the mesh view button, for platforms with no camera. */ + onOpenMeshHud: (() -> Unit)? = null, +) { val state by viewModel.state.collectAsStateWithLifecycle() val positions = state.positionLogs @@ -67,6 +74,14 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { timeProvider = { it.time.toDouble() }, onExportCsv = { exportPositionLauncher("position.csv", "text/csv") }, extraActions = { + if (hasTrackPoint && onOpenMeshHud != null) { + IconButton(onClick = onOpenMeshHud) { + Icon( + imageVector = MeshtasticIcons.CenterFocusStrong, + contentDescription = stringResource(Res.string.mesh_hud_open), + ) + } + } if (hasTrackPoint) { IconButton(onClick = { exportGpxLauncher("track.gpx", "application/gpx+xml") }) { Icon( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt index 470bcf69308..2727874de7b 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Card import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -44,11 +45,13 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.mesh_hud_open import org.meshtastic.core.resources.traceroute import org.meshtastic.core.resources.traceroute_outgoing_route import org.meshtastic.core.resources.traceroute_return_route import org.meshtastic.core.resources.traceroute_showing_nodes import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.core.ui.icon.CenterFocusStrong import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Route import org.meshtastic.core.ui.theme.TracerouteColors @@ -61,6 +64,8 @@ fun TracerouteMapScreen( metricsViewModel: MetricsViewModel, requestId: Int, logUuid: String? = null, + /** Null hides the mesh view button, for platforms with no camera. */ + onOpenMeshHud: (() -> Unit)? = null, onNavigateUp: () -> Unit, ) { val state by metricsViewModel.state.collectAsStateWithLifecycle() @@ -89,6 +94,7 @@ fun TracerouteMapScreen( overlay = overlay, snapshotPositions = snapshotPositions, onNavigateUp = onNavigateUp, + onOpenMeshHud = onOpenMeshHud, ) } @@ -98,6 +104,7 @@ private fun TracerouteMapScaffold( overlay: TracerouteOverlay?, snapshotPositions: Map, onNavigateUp: () -> Unit, + onOpenMeshHud: (() -> Unit)?, modifier: Modifier = Modifier, ) { var tracerouteNodesShown by remember { mutableStateOf(0) } @@ -111,7 +118,16 @@ private fun TracerouteMapScaffold( showNodeChip = false, canNavigateUp = true, onNavigateUp = onNavigateUp, - actions = {}, + actions = { + onOpenMeshHud?.let { onClick -> + IconButton(onClick = onClick) { + Icon( + MeshtasticIcons.CenterFocusStrong, + contentDescription = stringResource(Res.string.mesh_hud_open), + ) + } + } + }, onClickChip = {}, ) }, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt index 081980a6382..605e59f9e5e 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt @@ -22,6 +22,7 @@ import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.flow.Flow import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.navigation.ContactsRoute +import org.meshtastic.core.navigation.MapRoute import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.ui.component.ScrollToTopEvent @@ -47,5 +48,7 @@ fun AdaptiveNodeListScreen( onNavigateToConnections = onNavigateToConnections, // A bare radio-config route on this tab's stack resolves to the local session, the same as Connections -> LoRa. onEditStatusMessage = { backStack.add(SettingsRoute.UserStatusMessage) }, + navigateToMeshView = { num -> backStack.add(MapRoute.MeshHud(focusNodeNum = num)) }, + navigateToCompass = { num -> backStack.add(NodesRoute.NodeDetail(num, openCompass = true)) }, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStack.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStack.kt new file mode 100644 index 00000000000..b1045babf03 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStack.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.navigation + +import androidx.navigation3.runtime.NavKey +import org.meshtastic.core.navigation.MapRoute +import org.meshtastic.core.navigation.NodeDetailRoute +import org.meshtastic.core.navigation.NodesRoute + +/** + * Pushes [route] from a node detail screen. A mesh view opened from node screens that were themselves opened from a + * mesh view replaces that mesh view rather than stacking a second one. + */ +internal fun MutableList.navigateFromNodeDetail(route: NavKey) { + val meshHudIndex = if (route is MapRoute.MeshHud) indexOfMeshHudUnderNodeDetail() else -1 + // Add before removing so the back stack is never momentarily empty. + add(route) + if (meshHudIndex >= 0) { + repeat(lastIndex - meshHudIndex) { removeAt(meshHudIndex) } + } +} + +private fun List.indexOfMeshHudUnderNodeDetail(): Int { + var index = lastIndex + while (index >= 0 && this[index].isNodeScreen()) index-- + return if (index in 0 until lastIndex && this[index] is MapRoute.MeshHud) index else -1 +} + +private fun NavKey.isNodeScreen(): Boolean = this is NodesRoute.NodeDetail || this is NodeDetailRoute diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt index 11eba202cde..b327b0167e8 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -30,6 +30,7 @@ import org.jetbrains.compose.resources.StringResource import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import org.meshtastic.core.navigation.ContactsRoute +import org.meshtastic.core.navigation.MapRoute import org.meshtastic.core.navigation.NodeDetailRoute import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.navigation.Route @@ -57,6 +58,7 @@ import org.meshtastic.core.resources.remote_shell import org.meshtastic.core.resources.signal import org.meshtastic.core.resources.traceroute import org.meshtastic.core.ui.component.ScrollToTopEvent +import org.meshtastic.core.ui.util.rememberMeshHudSupported import org.meshtastic.feature.node.compass.CompassViewModel import org.meshtastic.feature.node.detail.NodeDetailScreen import org.meshtastic.feature.node.detail.NodeDetailViewModel @@ -107,8 +109,9 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack) nodeId = destNum, viewModel = nodeDetailViewModel, compassViewModel = compassViewModel, + openCompass = args.openCompass, navigateToMessages = { key -> backStack.add(ContactsRoute.Messages(key)) }, - onNavigate = { route -> backStack.add(route) }, + onNavigate = { route -> backStack.navigateFromNodeDetail(route) }, onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, ) } @@ -141,6 +144,21 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack) requestId = args.requestId, logUuid = args.logUuid, onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + onOpenMeshHud = + if (rememberMeshHudSupported()) { + { + backStack.navigateFromNodeDetail( + MapRoute.MeshHud( + focusNodeNum = args.destNum, + tracerouteDestNum = args.destNum, + tracerouteRequestId = args.requestId, + tracerouteLogUuid = args.logUuid, + ), + ) + } + } else { + null + }, ) } @@ -154,13 +172,31 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack) ) } + entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> + val metricsViewModel = koinViewModel { parametersOf(args.destNum) } + metricsViewModel.setNodeId(args.destNum) + PositionLogScreen( + viewModel = metricsViewModel, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + onOpenMeshHud = + if (rememberMeshHudSupported()) { + { + backStack.navigateFromNodeDetail( + MapRoute.MeshHud(focusNodeNum = args.destNum, trackNodeNum = args.destNum), + ) + } + } else { + null + }, + ) + } + NodeDetailScreen.entries.forEach { routeInfo -> when (routeInfo.routeClass) { NodeDetailRoute.DeviceMetrics::class -> addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } - NodeDetailRoute.PositionLog::class -> - addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } + // NodeDetailRoute.PositionLog has its own entry above, for its mesh view action. NodeDetailRoute.EnvironmentMetrics::class -> addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt index 2ecd57d4125..1029a1774aa 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt @@ -33,6 +33,10 @@ import kotlinx.coroutines.test.setMain import org.meshtastic.core.common.util.MeasurementSystem import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.MagneticFieldProvider +import org.meshtastic.core.repository.PhoneLocation +import org.meshtastic.core.repository.PhoneLocationProvider +import org.meshtastic.core.repository.PhoneLocationState import org.meshtastic.proto.User import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -168,7 +172,7 @@ class CompassViewModelTest { .build(), ) - assertEquals("± 12 m", state.errorRadiusText) + assertEquals("12 m", state.errorRadiusText) } @Test @@ -188,7 +192,7 @@ class CompassViewModelTest { .build(), ) - assertEquals("± 25 m", state.errorRadiusText) + assertEquals("25 m", state.errorRadiusText) } @Test @@ -207,7 +211,7 @@ class CompassViewModelTest { .build(), ) - assertEquals("± 8 m", state.errorRadiusText) + assertEquals("8 m", state.errorRadiusText) } @Test @@ -225,7 +229,7 @@ class CompassViewModelTest { .build(), ) - assertEquals("± 729 m", state.errorRadiusText) + assertEquals("729 m", state.errorRadiusText) } @Test @@ -264,7 +268,7 @@ class CompassViewModelTest { location = PhoneLocation(1.0, 1.0, 0.0, 1000L), ) - assertEquals("± 12 m", state.errorRadiusText) + assertEquals("12 m", state.errorRadiusText) assertNotNull(state.angularErrorDeg) assertEquals(180f, state.angularErrorDeg) } @@ -286,7 +290,7 @@ class CompassViewModelTest { location = PhoneLocation(1.0, 1.0, 0.0, 1000L), ) - assertEquals("± 12 m", state.errorRadiusText) + assertEquals("12 m", state.errorRadiusText) assertNotNull(state.angularErrorDeg) assertEquals(85.4f, state.angularErrorDeg, 0.5f) } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStackTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStackTest.kt new file mode 100644 index 00000000000..1b9e548f1ea --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStackTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.navigation + +import androidx.navigation3.runtime.NavKey +import org.meshtastic.core.navigation.ContactsRoute +import org.meshtastic.core.navigation.MapRoute +import org.meshtastic.core.navigation.NodeDetailRoute +import org.meshtastic.core.navigation.NodesRoute +import kotlin.test.Test +import kotlin.test.assertEquals + +class MeshHudBackStackTest { + + @Test + fun `mesh view from a detail opened by a mesh view replaces that mesh view`() { + val stack = mutableListOf(NodesRoute.Nodes, MapRoute.MeshHud(1), NodesRoute.NodeDetail(2)) + + stack.navigateFromNodeDetail(MapRoute.MeshHud(focusNodeNum = 2)) + + assertEquals(listOf(NodesRoute.Nodes, MapRoute.MeshHud(focusNodeNum = 2)), stack) + } + + @Test + fun `mesh view from a position log under a mesh view pops through the detail screens`() { + val stack = + mutableListOf( + NodesRoute.Nodes, + MapRoute.MeshHud(1), + NodesRoute.NodeDetail(2), + NodeDetailRoute.PositionLog(2), + ) + val track = MapRoute.MeshHud(focusNodeNum = 2, trackNodeNum = 2) + + stack.navigateFromNodeDetail(track) + + assertEquals(listOf(NodesRoute.Nodes, track), stack) + } + + @Test + fun `mesh view from a detail opened by the node list is pushed`() { + val stack = mutableListOf(NodesRoute.Nodes, NodesRoute.NodeDetail(2)) + + stack.navigateFromNodeDetail(MapRoute.MeshHud(focusNodeNum = 2)) + + assertEquals( + listOf(NodesRoute.Nodes, NodesRoute.NodeDetail(2), MapRoute.MeshHud(focusNodeNum = 2)), + stack, + ) + } + + @Test + fun `other routes from a detail above a mesh view are pushed`() { + val stack = mutableListOf(NodesRoute.Nodes, MapRoute.MeshHud(1), NodesRoute.NodeDetail(2)) + + stack.navigateFromNodeDetail(ContactsRoute.Messages("0!00000002")) + + assertEquals( + listOf( + NodesRoute.Nodes, + MapRoute.MeshHud(1), + NodesRoute.NodeDetail(2), + ContactsRoute.Messages("0!00000002"), + ), + stack, + ) + } + + @Test + fun `mesh view directly on top of a mesh view is pushed`() { + val stack = mutableListOf(NodesRoute.Nodes, MapRoute.MeshHud(1)) + + stack.navigateFromNodeDetail(MapRoute.MeshHud(focusNodeNum = 2)) + + assertEquals(listOf(NodesRoute.Nodes, MapRoute.MeshHud(1), MapRoute.MeshHud(focusNodeNum = 2)), stack) + } + + @Test + fun `mesh view onto an empty stack is pushed`() { + val stack = mutableListOf() + + stack.navigateFromNodeDetail(MapRoute.MeshHud(focusNodeNum = 2)) + + assertEquals(listOf(MapRoute.MeshHud(focusNodeNum = 2)), stack) + } +} diff --git a/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailCompassLifecycleTest.kt b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailCompassLifecycleTest.kt index a5949c42aa0..1fc92e28205 100644 --- a/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailCompassLifecycleTest.kt +++ b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailCompassLifecycleTest.kt @@ -51,7 +51,10 @@ import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCas import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase import org.meshtastic.core.model.Node import org.meshtastic.core.model.SessionStatus +import org.meshtastic.core.repository.MagneticFieldProvider import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.PhoneLocationProvider +import org.meshtastic.core.repository.PhoneLocationState import org.meshtastic.core.repository.QueryController import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText @@ -62,9 +65,6 @@ import org.meshtastic.core.ui.util.SnackbarManager import org.meshtastic.feature.node.compass.CompassHeadingProvider import org.meshtastic.feature.node.compass.CompassViewModel import org.meshtastic.feature.node.compass.HeadingState -import org.meshtastic.feature.node.compass.MagneticFieldProvider -import org.meshtastic.feature.node.compass.PhoneLocationProvider -import org.meshtastic.feature.node.compass.PhoneLocationState import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase import org.meshtastic.feature.node.model.MetricsState import org.meshtastic.proto.Position