From f833fb0948d14cf3fe0908a24e31398fe8eaf796 Mon Sep 17 00:00:00 2001 From: J Wylie Date: Thu, 11 Jun 2026 14:44:06 -0700 Subject: [PATCH] fix(location): persist self-marker across screen-off + instant fix on foreground (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: TacticalMap's ON_PAUSE handler disables the MapLibre LocationComponent to silence its compass animator (Android 16 crash), but nothing ever re-enabled it on ON_RESUME — after screen-off/on the self-marker stayed hidden until the composable was rebuilt, and after process death there was no persisted fix to render while GPS reacquired. - TacticalMap ON_RESUME now re-enables the puck (follow-me-aware camera mode) so the marker survives screen-off in-process - Persist the last real fix (lat/lon/hae/time) to DataStore, throttled to one write per 15 s — UserPrefs selfHae/selfFixTimeMs join the existing selfLat/selfLon, which finally gain a writer - Seed LocationProvider from the persisted fix on cold start so the 2D puck, Cesium self entity, HUD card, and PPLI prefs-fallback all render immediately; the puck renders dimmed (45% alpha, no pulse) while the fix is older than 30 s, matching MapLibre's own stale-state timeout - Force an immediate fused refresh (cache + single-shot getCurrentLocation) on every foreground ON_RESUME instead of waiting for the next passive interval tick - Newer-wins gate in LocationProvider so a restored fix can never clobber live GPS - No manifest/permission changes — foreground lifecycle + DataStore only Tests: 19-case SelfFixPersistenceTest (restore round-trip, staleness boundary, persist throttle, newer-wins) + persisted-prefs PPLI fallback case in SelfPositionBroadcasterFixSourcingTest. assembleDebug + testDebugUnitTest green (300 tests, 0 failures). Closes #75 Co-Authored-By: Claude Fable 5 --- .../omnitak/mobile/MainActivity.kt | 7 + .../engindearing/omnitak/mobile/OmniTAKApp.kt | 26 ++ .../omnitak/mobile/data/LocationProvider.kt | 54 +++- .../omnitak/mobile/data/SelfFixPersistence.kt | 85 ++++++ .../omnitak/mobile/data/UserPrefs.kt | 31 +++ .../mobile/domain/SelfPositionBroadcaster.kt | 6 +- .../mobile/ui/components/TacticalMap.kt | 247 +++++++++++++++--- .../omnitak/mobile/ui/screens/MapScreen.kt | 5 + .../mobile/data/SelfFixPersistenceTest.kt | 182 +++++++++++++ .../SelfPositionBroadcasterFixSourcingTest.kt | 23 ++ 10 files changed, 625 insertions(+), 41 deletions(-) create mode 100644 app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/SelfFixPersistence.kt create mode 100644 app/src/test/kotlin/soy/engindearing/omnitak/mobile/data/SelfFixPersistenceTest.kt diff --git a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/MainActivity.kt b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/MainActivity.kt index 4423aee..b935249 100644 --- a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/MainActivity.kt +++ b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/MainActivity.kt @@ -53,11 +53,18 @@ class MainActivity : ComponentActivity() { // standby, network swap). Cold-launch reconnect lives in // ServerManager.hydrate; this hook handles foreground-resume. // Issue #6. + // + // Issue #75 — also force an immediate location refresh on resume + // (fused cache + active single-shot) instead of waiting for the + // next passive interval tick, so the self-marker snaps back to a + // live position right after screen-on. Foreground-only; no + // background-location permission involved. val app = applicationContext as OmniTAKApp lifecycle.addObserver( LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { app.serverManager.reconnectIfNeeded() + app.locationProvider.requestImmediateFix() } }, ) diff --git a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/OmniTAKApp.kt b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/OmniTAKApp.kt index 873e6c8..5c6db87 100644 --- a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/OmniTAKApp.kt +++ b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/OmniTAKApp.kt @@ -18,6 +18,7 @@ import soy.engindearing.omnitak.mobile.data.AdminResponse import soy.engindearing.omnitak.mobile.data.CertVault import soy.engindearing.omnitak.mobile.data.LocationProvider import soy.engindearing.omnitak.mobile.data.MeshDeviceConfigStore +import soy.engindearing.omnitak.mobile.data.SelfFixPersistence import soy.engindearing.omnitak.mobile.data.TAKServerStore import soy.engindearing.omnitak.mobile.data.UserPrefsStore import soy.engindearing.omnitak.mobile.domain.ChatStore @@ -113,6 +114,31 @@ class OmniTAKApp : Application() { mapCameraStore.seedFromPrefs(saved) } + // Issue #75 — self-marker persistence across screen-off + process + // death. Seed the in-memory fix from the persisted one so every + // consumer (2D puck, Cesium self entity, HUD card, PPLI + // prefs-fallback) renders immediately on cold start — stale-marked + // downstream via SelfFix.timeMs — then persist real fixes + // (throttled) so the NEXT cold start has them. Seed-then-collect + // in one coroutine: the collector starts only after the seed is + // applied, and the seeded fix never re-persists itself because + // shouldPersist requires a strictly newer timestamp. + appScope.launch { + val saved = userPrefsStore.prefs.first() + SelfFixPersistence.restoredFixOrNull(saved)?.let { + locationProvider.seedFromPersisted(it) + } + var lastPersistedMs = saved.selfFixTimeMs + locationProvider.fix.collect { fix -> + if (fix != null && + SelfFixPersistence.shouldPersist(fix.timeMs, lastPersistedMs) + ) { + lastPersistedMs = fix.timeMs + userPrefsStore.setLastSelfFix(fix) + } + } + } + // Off-grid mesh plan Step 1b — broadcaster is now owned here so it // starts when EITHER a TAK server OR Meshtastic radio is connected. // ServerManager's own startPliBroadcast/stopPliBroadcast is kept diff --git a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/LocationProvider.kt b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/LocationProvider.kt index 1e9de86..a66276f 100644 --- a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/LocationProvider.kt +++ b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/LocationProvider.kt @@ -33,7 +33,7 @@ class LocationProvider(private val context: Context) { private val callback = object : LocationCallback() { override fun onLocationResult(result: LocationResult) { - result.lastLocation?.let { _fix.value = it.toSelfFix() } + result.lastLocation?.let { offerFix(it.toSelfFix()) } } } @@ -49,10 +49,11 @@ class LocationProvider(private val context: Context) { client.requestLocationUpdates(req, callback, Looper.getMainLooper()) client.lastLocation.addOnSuccessListener { loc -> // Suppress fixes older than 5 minutes — better to wait for a fresh - // one than show last-week's location on cold start. - if (loc != null && _fix.value == null && - System.currentTimeMillis() - loc.time < 5 * 60_000L) { - _fix.value = loc.toSelfFix() + // one than show last-week's location as LIVE on cold start. (The + // persisted-fix seed handles older positions, rendered stale — + // issue #75.) + if (loc != null && System.currentTimeMillis() - loc.time < 5 * 60_000L) { + offerFix(loc.toSelfFix()) } } started = true @@ -65,6 +66,49 @@ class LocationProvider(private val context: Context) { started = false } + /** + * Issue #75 — seed the in-memory fix from the persisted one so the + * self-marker renders immediately on cold start instead of vanishing + * until GPS reacquires. Newer-wins: a live fix that has already + * arrived is never replaced by the (older) persisted seed. No + * permission required — this only replays a position we recorded. + */ + fun seedFromPersisted(persisted: SelfFix) { + offerFix(persisted) + } + + /** + * Issue #75 — force a location refresh the moment the app regains + * foreground instead of waiting for the next passive interval tick: + * - fused cache ([com.google.android.gms.location.FusedLocationProviderClient.getLastLocation]) + * for an instant (≤5 min old) answer, + * - an active single-shot + * [com.google.android.gms.location.FusedLocationProviderClient.getCurrentLocation] + * for a fresh fix. + * Both funnel through the newer-wins gate, so out-of-order results + * can't regress the marker. Safe no-op without permission. + */ + @SuppressLint("MissingPermission") + fun requestImmediateFix(): Boolean { + if (!hasPermission()) return false + client.lastLocation.addOnSuccessListener { loc -> + if (loc != null && System.currentTimeMillis() - loc.time < 5 * 60_000L) { + offerFix(loc.toSelfFix()) + } + } + client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null) + .addOnSuccessListener { loc -> + if (loc != null) offerFix(loc.toSelfFix()) + } + return true + } + + /** Single write gate for [_fix]: an incoming fix only lands if it is + * at least as recent as the current one (issue #75 newer-wins). */ + private fun offerFix(candidate: SelfFix) { + _fix.value = SelfFixPersistence.newerOf(_fix.value, candidate) + } + private fun hasPermission(): Boolean { val fine = ContextCompat.checkSelfPermission( context, Manifest.permission.ACCESS_FINE_LOCATION diff --git a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/SelfFixPersistence.kt b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/SelfFixPersistence.kt new file mode 100644 index 0000000..65b5d0a --- /dev/null +++ b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/SelfFixPersistence.kt @@ -0,0 +1,85 @@ +package soy.engindearing.omnitak.mobile.data + +/** + * Issue #75 — self-marker persistence policy. Pure Kotlin (no Android + * imports) so the rules are unit-testable on the JVM. + * + * The bug: the self-marker vanished after screen-off and took until the + * next GPS fix to reappear. iOS is immune because its SDK-managed puck + * holds through backgrounding; Android mirrors that *intent* without any + * background-location permission by: + * + * 1. persisting the last real fix (lat/lon/hae/time) to DataStore + * (throttled via [shouldPersist]), + * 2. seeding [LocationProvider] from the persisted fix on cold start + * ([restoredFixOrNull]) so every consumer — 2D puck, Cesium self + * entity, HUD card, PPLI prefs-fallback — renders immediately, + * 3. marking the marker visually stale when the fix is older than + * [STALE_AFTER_MS] ([isStale]), and + * 4. never letting an old fix clobber a newer one ([newerOf]). + */ +object SelfFixPersistence { + + /** A fix older than this renders visually stale (dimmed puck). Matches + * MapLibre LocationComponent's default stale timeout so live-GPS-loss + * staleness and restored-fix staleness look identical. */ + const val STALE_AFTER_MS: Long = 30_000L + + /** Minimum spacing between DataStore writes of the self fix. GPS ticks + * every ~10 s; persisting each one would triple disk writes for no + * recovery benefit (PPLI interval is 30 s anyway). */ + const val MIN_PERSIST_INTERVAL_MS: Long = 15_000L + + /** True when a fix taken at [fixTimeMs] should render stale at [nowMs]. + * Unknown (non-positive) fix times are always stale. */ + fun isStale( + fixTimeMs: Long, + nowMs: Long, + staleAfterMs: Long = STALE_AFTER_MS, + ): Boolean { + if (fixTimeMs <= 0L) return true + return nowMs - fixTimeMs > staleAfterMs + } + + /** Throttle gate for persisting fixes: the candidate must be strictly + * newer than the last persisted fix AND at least [minIntervalMs] + * newer (first write always passes — lastPersistedFixTimeMs = 0). */ + fun shouldPersist( + fixTimeMs: Long, + lastPersistedFixTimeMs: Long, + minIntervalMs: Long = MIN_PERSIST_INTERVAL_MS, + ): Boolean { + if (fixTimeMs <= lastPersistedFixTimeMs) return false + if (lastPersistedFixTimeMs <= 0L) return true + return fixTimeMs - lastPersistedFixTimeMs >= minIntervalMs + } + + /** + * Rebuild a [SelfFix] from persisted prefs, or null when no fix was + * ever persisted (NaN sentinels — GAP-030b). The restored fix keeps + * its original wall-clock time so consumers can derive staleness, but + * deliberately carries NO speed and NaN accuracy: the PPLI broadcaster + * maps NaN accuracy to ce=9999999 ("unknown"), so a restored position + * is never broadcast pretending to have live GPS confidence. + */ + fun restoredFixOrNull(prefs: UserPrefs): SelfFix? { + if (prefs.selfLat.isNaN() || prefs.selfLon.isNaN()) return null + return SelfFix( + lat = prefs.selfLat, + lon = prefs.selfLon, + altitudeM = if (prefs.selfHae.isNaN()) 0.0 else prefs.selfHae, + speedKmh = 0.0, + accuracyM = Float.NaN, + timeMs = prefs.selfFixTimeMs, + ) + } + + /** Newer-wins merge: a candidate fix only replaces the current one if + * it is at least as recent. Protects the live GPS fix from being + * clobbered by a late-arriving persisted seed (and vice versa lets a + * fused cached fix upgrade a stale seed). */ + fun newerOf(current: SelfFix?, candidate: SelfFix): SelfFix { + if (current == null) return candidate + return if (candidate.timeMs >= current.timeMs) candidate else current + } +} diff --git a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/UserPrefs.kt b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/UserPrefs.kt index c042135..cdc16a2 100644 --- a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/UserPrefs.kt +++ b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/UserPrefs.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow @@ -45,6 +46,13 @@ data class UserPrefs( // real fix arrives, fixing issue #10 (HUD showing San Francisco). val selfLat: Double = Double.NaN, val selfLon: Double = Double.NaN, + // Issue #75 — the rest of the persisted self-fix. Together with + // selfLat/selfLon this lets the self-marker render immediately on + // cold start (stale-marked when the fix is old) instead of + // disappearing until GPS reacquires. Written (throttled) by the + // OmniTAKApp fix collector; restored via SelfFixPersistence. + val selfHae: Double = Double.NaN, + val selfFixTimeMs: Long = 0L, val distanceUnit: DistanceUnit = DistanceUnit.METRIC, val coordFormat: CoordFormat = CoordFormat.LATLON_DECIMAL, val mapProvider: MapProvider = MapProvider.TOPO_HINT, @@ -121,6 +129,9 @@ class UserPrefsStore(private val context: Context) { private val KEY_SELF_UID = stringPreferencesKey("self_uid") private val KEY_SELF_LAT = stringPreferencesKey("self_lat") private val KEY_SELF_LON = stringPreferencesKey("self_lon") + // Issue #75 — persisted self-fix altitude + fix wall-clock time. + private val KEY_SELF_HAE = stringPreferencesKey("self_hae") + private val KEY_SELF_FIX_TIME = longPreferencesKey("self_fix_time_ms") private val KEY_DIST = stringPreferencesKey("distance_unit") private val KEY_COORD = stringPreferencesKey("coord_format") private val KEY_MAP = stringPreferencesKey("map_provider") @@ -159,6 +170,8 @@ class UserPrefsStore(private val context: Context) { p[KEY_SELF_UID] = next.selfUid p[KEY_SELF_LAT] = next.selfLat.toString() p[KEY_SELF_LON] = next.selfLon.toString() + p[KEY_SELF_HAE] = next.selfHae.toString() + p[KEY_SELF_FIX_TIME] = next.selfFixTimeMs p[KEY_DIST] = next.distanceUnit.name p[KEY_COORD] = next.coordFormat.name p[KEY_MAP] = next.mapProvider.name @@ -204,6 +217,22 @@ class UserPrefsStore(private val context: Context) { update { it.copy(lastCameraLat = lat, lastCameraLon = lon, lastCameraZoom = zoom) } } + /** Issue #75 — persist the last real GPS fix so the self-marker can + * render immediately on the next cold start (stale-marked when old) + * instead of disappearing until GPS reacquires. Speed/accuracy are + * deliberately NOT stored: a restored fix must not masquerade as a + * live one (see [SelfFixPersistence.restoredFixOrNull]). */ + suspend fun setLastSelfFix(fix: SelfFix) { + update { + it.copy( + selfLat = fix.lat, + selfLon = fix.lon, + selfHae = fix.altitudeM, + selfFixTimeMs = fix.timeMs, + ) + } + } + /** Convenience writer for the Meshtastic auto-publish toggle so the * overflow menu doesn't have to reach for [update]. */ suspend fun setAutoPublishMeshToTak(value: Boolean) { @@ -249,6 +278,8 @@ class UserPrefsStore(private val context: Context) { selfUid = p[KEY_SELF_UID] ?: "", selfLat = p[KEY_SELF_LAT]?.toDoubleOrNull() ?: Double.NaN, selfLon = p[KEY_SELF_LON]?.toDoubleOrNull() ?: Double.NaN, + selfHae = p[KEY_SELF_HAE]?.toDoubleOrNull() ?: Double.NaN, + selfFixTimeMs = p[KEY_SELF_FIX_TIME] ?: 0L, distanceUnit = p[KEY_DIST]?.let { runCatching { DistanceUnit.valueOf(it) }.getOrNull() } ?: DistanceUnit.METRIC, coordFormat = p[KEY_COORD]?.let { runCatching { CoordFormat.valueOf(it) }.getOrNull() } diff --git a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/domain/SelfPositionBroadcaster.kt b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/domain/SelfPositionBroadcaster.kt index 92874db..717518a 100644 --- a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/domain/SelfPositionBroadcaster.kt +++ b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/domain/SelfPositionBroadcaster.kt @@ -145,9 +145,13 @@ class SelfPositionBroadcaster internal constructor( Log.d(TAG, "PPLI suppressed — no GPS fix yet") return } else { + // Issue #75 — selfLat/selfLon (+ selfHae) are now actually + // written: OmniTAKApp persists every real fix (throttled), so + // this fallback broadcasts the last known position with an + // honest "unknown" circular error instead of going silent. lat = prefs.selfLat lon = prefs.selfLon - hae = 0.0 + hae = if (prefs.selfHae.isNaN()) 0.0 else prefs.selfHae speedKmh = 0.0 ce = 9999999.0 } diff --git a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/components/TacticalMap.kt b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/components/TacticalMap.kt index 2ce1a42..babc885 100644 --- a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/components/TacticalMap.kt +++ b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/components/TacticalMap.kt @@ -27,6 +27,8 @@ import org.maplibre.android.maps.Style import soy.engindearing.omnitak.mobile.data.CoTEvent import soy.engindearing.omnitak.mobile.data.Drawing import soy.engindearing.omnitak.mobile.data.MapProvider +import soy.engindearing.omnitak.mobile.data.SelfFix +import soy.engindearing.omnitak.mobile.data.SelfFixPersistence import soy.engindearing.omnitak.mobile.data.TakTeamColor /** @@ -81,6 +83,13 @@ fun TacticalMap( * fall back to cyan (0xFF00FFFF) to match CivTAK's default for * unaffiliated friendlies. Sourced from [soy.engindearing.omnitak.mobile.data.UserPrefs.team]. */ selfTeamColor: String = "Cyan", + /** Issue #75 — the operator's current/last-known fix from + * [soy.engindearing.omnitak.mobile.data.LocationProvider]. Forwarded + * into the LocationComponent so the self-marker renders immediately + * from a restored fix on cold start (dimmed when stale) and snaps to + * the forced foreground-resume fix without waiting on the + * component's internal engine interval. */ + selfFix: SelfFix? = null, onCameraIdle: ((LatLng, Double) -> Unit)? = null, /** Fired once the MapLibre map is ready. Issue #16 — lasso uses * this to grab the [MapLibreMap] reference for screen↔geo @@ -106,6 +115,14 @@ fun TacticalMap( val currentMapReady by rememberUpdatedState(onMapReady) val currentStyleReady by rememberUpdatedState(onStyleReady) val currentTerrain3d by rememberUpdatedState(terrain3d) + val currentSelfFix by rememberUpdatedState(selfFix) + val currentFollowMe by rememberUpdatedState(followMeActive) + val currentUseMilStd by rememberUpdatedState(useMilStdSelfSymbol) + val currentTeamColor by rememberUpdatedState(selfTeamColor) + // Issue #75 — whether the puck is currently rendered dimmed (stale + // restored fix). Plain holder, not MutableState: nothing recomposes + // off it; the effects below read/write it imperatively. + val puckAppearance = remember { PuckAppearance() } // ContactSymbolLayer uses Style.addImage (bitmap) + SymbolLayer — the path // LocationComponent uses and that renders on Adreno 610 / SwiftShader when @@ -142,7 +159,10 @@ fun TacticalMap( // the live map handle — not from here. The `aircraft-src` // source stays empty until the plugin pushes into it. if (currentLocationEnabled) { - activateLocation(map, style, context, useMilStdSelfSymbol, selfTeamColor) + activateLocation( + map, style, context, currentUseMilStd, currentTeamColor, + seedFix = currentSelfFix, puck = puckAppearance, + ) } // Cold-start 3D: if the persisted pref has terrain on, // apply the tilt once the style (+ terrain source) is @@ -210,7 +230,10 @@ fun TacticalMap( mapView.getMapAsync { map -> val style = map.style if (style != null && !map.locationComponent.isLocationComponentActivated) { - activateLocation(map, style, context, useMilStdSelfSymbol, selfTeamColor) + activateLocation( + map, style, context, currentUseMilStd, currentTeamColor, + seedFix = currentSelfFix, puck = puckAppearance, + ) } if (map.locationComponent.isLocationComponentActivated) { safeEnableLocation(map) @@ -220,6 +243,37 @@ fun TacticalMap( onDispose { } } + // Issue #75 — drive the puck from LocationProvider's fix alongside + // the component's internal engine. This renders the restored fix the + // instant the map is up (cold start) and snaps the marker to the + // forced foreground-resume fix; the provider's newer-wins gate + // guarantees this flow never regresses the position. Also keeps the + // dimmed/stale appearance in sync: dim when all we have is an old + // restored fix, restore full opacity once a fresh fix lands. + DisposableEffect(mapView, selfFix) { + val fix = selfFix + if (fix != null && locationEnabled) { + mapView.getMapAsync { map -> + val style = map.style + if (style != null && map.locationComponent.isLocationComponentActivated) { + map.locationComponent.forceLocationUpdate(fix.toLocation()) + val staleNow = + SelfFixPersistence.isStale(fix.timeMs, System.currentTimeMillis()) + if (puckAppearance.dimmed != staleNow) { + map.locationComponent.applyStyle( + buildPuckOptions( + context, style, currentUseMilStd, currentTeamColor, + dimmed = staleNow, + ), + ) + puckAppearance.dimmed = staleNow + } + } + } + } + onDispose { } + } + // Each time [recenterTrigger] changes, briefly flip camera to // TRACKING to pan to the user, then restore NONE so the user can // still pan freely. @@ -304,6 +358,18 @@ fun TacticalMap( // ADS-B re-push after a style reload is handled by the // plugin overlay's LaunchedEffect(map, …), which re-runs // when the new MapLibreMap/style lands. Nothing to do here. + // Issue #75 — the self-marker bitmaps live on the style, + // so a basemap swap wipes them. Re-register + re-apply so + // the puck survives style reloads with its current + // (dimmed or live) appearance. + if (currentLocationEnabled && map.locationComponent.isLocationComponentActivated) { + map.locationComponent.applyStyle( + buildPuckOptions( + context, style, currentUseMilStd, currentTeamColor, + dimmed = puckAppearance.dimmed, + ), + ) + } currentStyleReady?.invoke(map, style) // Apply 3D tilt AFTER the style (which carries the // terrain source) finishes loading — deterministic vs @@ -388,7 +454,28 @@ fun TacticalMap( val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_START -> mapView.onStart() - Lifecycle.Event.ON_RESUME -> mapView.onResume() + Lifecycle.Event.ON_RESUME -> { + mapView.onResume() + // Issue #75 (root cause) — ON_PAUSE below silences the + // LocationComponent to kill its compass animator, but + // nothing ever re-enabled it: after screen-off/on the + // self-marker stayed hidden until the composable was + // rebuilt. Re-enable on every resume, restoring the + // camera mode follow-me expects. + if (currentLocationEnabled) { + runCatching { + mapView.getMapAsync { map -> + if (map.locationComponent.isLocationComponentActivated) { + map.locationComponent.isLocationComponentEnabled = true + map.locationComponent.renderMode = RenderMode.COMPASS + map.locationComponent.cameraMode = + if (currentFollowMe) CameraMode.TRACKING_COMPASS + else CameraMode.NONE + } + } + } + } + } Lifecycle.Event.ON_PAUSE -> { // Silence the LocationComponent on pause — its compass // animator keeps firing across lifecycle transitions @@ -431,6 +518,13 @@ fun TacticalMap( AndroidView(factory = { mapView }, modifier = modifier) } +/** Issue #75 — imperative holder for the self-marker's current dim state + * (stale restored fix vs live GPS). Shared between the activation path, + * the selfFix forwarding effect, and the style-reload re-apply. */ +internal class PuckAppearance { + @Volatile var dimmed: Boolean = false +} + @SuppressLint("MissingPermission") private fun activateLocation( map: org.maplibre.android.maps.MapLibreMap, @@ -438,7 +532,50 @@ private fun activateLocation( context: android.content.Context, useMilStdSelfSymbol: Boolean, selfTeamColor: String = "Cyan", + seedFix: SelfFix? = null, + puck: PuckAppearance = PuckAppearance(), ) { + // Issue #75 — when the best position available at activation is a + // restored (persisted) fix that is already old, start the puck dimmed + // so a last-known position can't masquerade as live GPS. + val dimmed = seedFix != null && + SelfFixPersistence.isStale(seedFix.timeMs, System.currentTimeMillis()) + puck.dimmed = dimmed + + val options = LocationComponentActivationOptions.builder(context, style) + .useDefaultLocationEngine(true) + .locationComponentOptions( + buildPuckOptions(context, style, useMilStdSelfSymbol, selfTeamColor, dimmed), + ) + .build() + map.locationComponent.activateLocationComponent(options) + safeEnableLocation(map) + + // Issue #75 — render the restored fix immediately instead of leaving + // the marker invisible until the engine's first delivery (the + // "disappears until GPS reacquires" bug). Live engine updates and the + // forced foreground-resume fix take over from here via newer-wins. + if (seedFix != null) { + map.locationComponent.forceLocationUpdate(seedFix.toLocation()) + } +} + +/** + * Issue #75 — build (and register the marker images for) the puck's + * styling options. [dimmed] renders the self-marker at reduced opacity — + * used while the only position available is a restored fix older than + * [SelfFixPersistence.STALE_AFTER_MS]. The stale variants are always + * registered/configured so MapLibre's own stale-state machinery (no + * location update for the same 30 s — e.g. GPS loss indoors) dims the + * puck identically. + */ +private fun buildPuckOptions( + context: android.content.Context, + style: Style, + useMilStdSelfSymbol: Boolean, + selfTeamColor: String, + dimmed: Boolean, +): LocationComponentOptions { // Resolve the ARGB tint from the operator's configured TAK team name // ("Cyan", "Red", "Orange", …). Falls back to cyan — CivTAK default // for unaffiliated friendlies — when the name isn't in the palette. @@ -453,39 +590,29 @@ private fun activateLocation( // tinted-disc drawable. val selfBitmap: android.graphics.Bitmap? = if (useMilStdSelfSymbol) { soy.engindearing.omnitak.mobile.data.symbology.MilStdIconCache - .bitmapFor(context, cotType = "a-f-G-U-C", sizePx = 96)?.let { raw -> - // Tint the MIL-STD SVG bitmap with the team color by - // drawing it through a SRC_IN PorterDuff color filter. - // The SVG renders as a transparent-background ARGB bitmap - // so SRC_IN recolors only the opaque glyph pixels — - // equivalent to the iOS UIImage.withTintColor approach. - val tinted = android.graphics.Bitmap.createBitmap( - raw.width, raw.height, android.graphics.Bitmap.Config.ARGB_8888 - ) - val canvas = android.graphics.Canvas(tinted) - val paint = android.graphics.Paint().apply { - colorFilter = android.graphics.PorterDuffColorFilter( - teamArgb, - android.graphics.PorterDuff.Mode.SRC_IN, - ) - } - canvas.drawBitmap(raw, 0f, 0f, paint) - tinted - } + .bitmapFor(context, cotType = "a-f-G-U-C", sizePx = 96) + ?.let { raw -> tintBitmap(raw, teamArgb) } } else { null } val markerOptionsBuilder = LocationComponentOptions.builder(context) - .pulseEnabled(true) + // No pulse on a dimmed marker — the pulse animation overstates + // liveness when all we have is a last-known position. + .pulseEnabled(!dimmed) .pulseColor(teamArgb) .pulseSingleDuration(2200f) .accuracyColor(teamArgb) .accuracyAlpha(0.18f) + // Issue #75 — let MapLibre dim the puck on its own when no update + // arrives for STALE_AFTER_MS (same threshold as restored-fix + // staleness, so both paths look identical). + .enableStaleState(true) + .staleStateTimeout(SelfFixPersistence.STALE_AFTER_MS) if (selfBitmap != null) { - // Register the bitmap with the style so LocationComponent can - // reference it by name — MapLibre's LocationComponentOptions + // Register the bitmaps with the style so LocationComponent can + // reference them by name — MapLibre's LocationComponentOptions // builder accepts resource IDs or names, not direct bitmaps. // Using the same bitmap for foreground and bearing keeps the // symbol upright regardless of heading; the bearing-arrow @@ -493,28 +620,78 @@ private fun activateLocation( // symbol bodies aren't meant to rotate. Operator heading is // still surfaced numerically in the SelfPositionCard. style.addImage(SELF_FOREGROUND_IMAGE, selfBitmap) + style.addImage(SELF_FOREGROUND_STALE_IMAGE, fadeBitmap(selfBitmap, STALE_MARKER_ALPHA)) + val foreground = if (dimmed) SELF_FOREGROUND_STALE_IMAGE else SELF_FOREGROUND_IMAGE markerOptionsBuilder - .foregroundName(SELF_FOREGROUND_IMAGE) - .bearingName(SELF_FOREGROUND_IMAGE) + .foregroundName(foreground) + .bearingName(foreground) + .foregroundStaleName(SELF_FOREGROUND_STALE_IMAGE) } else { // Legacy tinted-disc fallback — apply the team color as a tint // on the vector drawable so the disc still reflects team identity. + val tint = if (dimmed) fadeArgb(teamArgb, STALE_MARKER_ALPHA) else teamArgb markerOptionsBuilder .foregroundDrawable(R.drawable.ic_self_marker) .bearingDrawable(R.drawable.ic_self_marker_bearing) - .foregroundTintColor(teamArgb) - .bearingTintColor(teamArgb) + .foregroundTintColor(tint) + .bearingTintColor(tint) + .foregroundStaleTintColor(fadeArgb(teamArgb, STALE_MARKER_ALPHA)) } + return markerOptionsBuilder.build() +} - val options = LocationComponentActivationOptions.builder(context, style) - .useDefaultLocationEngine(true) - .locationComponentOptions(markerOptionsBuilder.build()) - .build() - map.locationComponent.activateLocationComponent(options) - safeEnableLocation(map) +/** Tint an ARGB bitmap's opaque pixels via a SRC_IN PorterDuff filter. + * The SVG renders as a transparent-background ARGB bitmap so SRC_IN + * recolors only the opaque glyph pixels — equivalent to the iOS + * UIImage.withTintColor approach. */ +private fun tintBitmap(raw: android.graphics.Bitmap, argb: Int): android.graphics.Bitmap { + val tinted = android.graphics.Bitmap.createBitmap( + raw.width, raw.height, android.graphics.Bitmap.Config.ARGB_8888 + ) + val canvas = android.graphics.Canvas(tinted) + val paint = android.graphics.Paint().apply { + colorFilter = android.graphics.PorterDuffColorFilter( + argb, + android.graphics.PorterDuff.Mode.SRC_IN, + ) + } + canvas.drawBitmap(raw, 0f, 0f, paint) + return tinted } +/** Issue #75 — a translucent copy of [src] for the stale self-marker. */ +private fun fadeBitmap(src: android.graphics.Bitmap, alpha: Int): android.graphics.Bitmap { + val faded = android.graphics.Bitmap.createBitmap( + src.width, src.height, android.graphics.Bitmap.Config.ARGB_8888 + ) + val canvas = android.graphics.Canvas(faded) + val paint = android.graphics.Paint().apply { this.alpha = alpha } + canvas.drawBitmap(src, 0f, 0f, paint) + return faded +} + +/** Issue #75 — [argb] with its alpha channel replaced by [alpha]. */ +private fun fadeArgb(argb: Int, alpha: Int): Int = + (argb and 0x00FFFFFF) or (alpha shl 24) + +/** Issue #75 — adapt a [SelfFix] for LocationComponent.forceLocationUpdate. + * NaN accuracy (restored fixes) is simply omitted. */ +private fun SelfFix.toLocation(): android.location.Location = + android.location.Location("omnitak-selffix").apply { + latitude = lat + longitude = lon + altitude = altitudeM + time = timeMs + if (!accuracyM.isNaN()) accuracy = accuracyM + if (speedKmh > 0.0) speed = (speedKmh / 3.6).toFloat() + } + private const val SELF_FOREGROUND_IMAGE = "omnitak-self-milstd-foreground" +private const val SELF_FOREGROUND_STALE_IMAGE = "omnitak-self-milstd-foreground-stale" + +/** Stale self-marker opacity (0–255). ~45% — subtle, but unmistakably + * dimmer than the live marker (issue #75). */ +private const val STALE_MARKER_ALPHA = 115 @SuppressLint("MissingPermission") private fun safeEnableLocation(map: org.maplibre.android.maps.MapLibreMap) { diff --git a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/screens/MapScreen.kt b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/screens/MapScreen.kt index ea09f5a..d1902e6 100644 --- a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/screens/MapScreen.kt +++ b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/screens/MapScreen.kt @@ -526,6 +526,11 @@ fun MapScreen(onOpenTab: (String) -> Unit = {}) { followMeActive = followMeActive, useMilStdSelfSymbol = userPrefs.useMilStdSelfSymbol, selfTeamColor = userPrefs.team, + // Issue #75 — feeds the puck so a restored fix renders + // immediately on cold start (dimmed when stale) and the + // forced foreground-resume fix lands without waiting on the + // LocationComponent's internal engine. + selfFix = selfFix, onStyleReady = { _, style -> soy.engindearing.omnitak.mobile.ui.components.KmlOverlayRenderer .apply(style, kmlOverlays, app.kmlOverlayStore) diff --git a/app/src/test/kotlin/soy/engindearing/omnitak/mobile/data/SelfFixPersistenceTest.kt b/app/src/test/kotlin/soy/engindearing/omnitak/mobile/data/SelfFixPersistenceTest.kt new file mode 100644 index 0000000..b97f5fd --- /dev/null +++ b/app/src/test/kotlin/soy/engindearing/omnitak/mobile/data/SelfFixPersistenceTest.kt @@ -0,0 +1,182 @@ +package soy.engindearing.omnitak.mobile.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Issue #75 — self-marker persistence rules. The marker used to vanish + * after screen-off and stay gone until GPS reacquired; these lock the + * pure logic behind the fix: persisted-fix restore (write/read), the + * 30 s staleness boundary, the DataStore write throttle, and the + * newer-wins merge that keeps a stale restored fix from ever clobbering + * live GPS. + */ +class SelfFixPersistenceTest { + + private val t0 = 1_700_000_000_000L + + private fun fix(timeMs: Long, lat: Double = 47.6, lon: Double = -117.4) = SelfFix( + lat = lat, + lon = lon, + altitudeM = 120.5, + speedKmh = 3.2, + accuracyM = 5f, + timeMs = timeMs, + ) + + // ── restore (read side of persistence) ────────────────────────────── + + @Test fun restore_returns_null_for_default_prefs() { + assertNull( + "NaN sentinels (no fix ever persisted) must restore to null", + SelfFixPersistence.restoredFixOrNull(UserPrefs()), + ) + } + + @Test fun restore_returns_null_when_only_one_coordinate_present() { + assertNull(SelfFixPersistence.restoredFixOrNull(UserPrefs(selfLat = 47.6))) + assertNull(SelfFixPersistence.restoredFixOrNull(UserPrefs(selfLon = -117.4))) + } + + @Test fun restore_round_trips_position_and_time() { + val prefs = UserPrefs( + selfLat = 47.6062, + selfLon = -117.4194, + selfHae = 562.0, + selfFixTimeMs = t0, + ) + val restored = SelfFixPersistence.restoredFixOrNull(prefs) + assertNotNull(restored) + assertEquals(47.6062, restored!!.lat, 0.0) + assertEquals(-117.4194, restored.lon, 0.0) + assertEquals(562.0, restored.altitudeM, 0.0) + assertEquals(t0, restored.timeMs) + } + + @Test fun restored_fix_never_claims_live_confidence() { + val restored = SelfFixPersistence.restoredFixOrNull( + UserPrefs(selfLat = 47.6, selfLon = -117.4, selfHae = 562.0, selfFixTimeMs = t0), + )!! + // NaN accuracy → the PPLI broadcaster emits ce=9999999 ("unknown"), + // and zero speed — a restored position must not look like live GPS. + assertTrue("restored accuracy must be NaN", restored.accuracyM.isNaN()) + assertEquals(0.0, restored.speedKmh, 0.0) + } + + @Test fun restore_maps_NaN_hae_to_zero() { + val restored = SelfFixPersistence.restoredFixOrNull( + UserPrefs(selfLat = 47.6, selfLon = -117.4, selfFixTimeMs = t0), + )!! + assertEquals(0.0, restored.altitudeM, 0.0) + } + + // ── write side of persistence (UserPrefs carries the fix) ─────────── + + @Test fun prefs_default_to_no_persisted_fix() { + assertTrue("selfHae default must be NaN", UserPrefs().selfHae.isNaN()) + assertEquals("selfFixTimeMs default must be 0", 0L, UserPrefs().selfFixTimeMs) + } + + @Test fun prefs_copy_round_trips_a_fix_write() { + // Mirrors UserPrefsStore.setLastSelfFix's copy() — the DataStore + // string/long codec is exercised by toString/toDoubleOrNull below. + val f = fix(t0) + val written = UserPrefs().copy( + selfLat = f.lat, selfLon = f.lon, selfHae = f.altitudeM, selfFixTimeMs = f.timeMs, + ) + assertEquals(f.lat, written.selfLat.toString().toDouble(), 0.0) + assertEquals(f.lon, written.selfLon.toString().toDouble(), 0.0) + assertEquals(f.altitudeM, written.selfHae.toString().toDouble(), 0.0) + val restored = SelfFixPersistence.restoredFixOrNull(written)!! + assertEquals(f.lat, restored.lat, 0.0) + assertEquals(f.timeMs, restored.timeMs) + } + + // ── staleness (~30 s boundary) ────────────────────────────────────── + + @Test fun fix_is_fresh_within_30_seconds() { + assertFalse(SelfFixPersistence.isStale(fixTimeMs = t0, nowMs = t0)) + assertFalse(SelfFixPersistence.isStale(fixTimeMs = t0, nowMs = t0 + 29_999L)) + assertFalse(SelfFixPersistence.isStale(fixTimeMs = t0, nowMs = t0 + 30_000L)) + } + + @Test fun fix_is_stale_after_30_seconds() { + assertTrue(SelfFixPersistence.isStale(fixTimeMs = t0, nowMs = t0 + 30_001L)) + assertTrue(SelfFixPersistence.isStale(fixTimeMs = t0, nowMs = t0 + 3_600_000L)) + } + + @Test fun unknown_fix_time_is_always_stale() { + assertTrue(SelfFixPersistence.isStale(fixTimeMs = 0L, nowMs = t0)) + assertTrue(SelfFixPersistence.isStale(fixTimeMs = -1L, nowMs = t0)) + } + + @Test fun clock_skewed_future_fix_is_not_stale() { + assertFalse(SelfFixPersistence.isStale(fixTimeMs = t0 + 60_000L, nowMs = t0)) + } + + // ── persist throttle ──────────────────────────────────────────────── + + @Test fun first_fix_always_persists() { + assertTrue(SelfFixPersistence.shouldPersist(fixTimeMs = t0, lastPersistedFixTimeMs = 0L)) + } + + @Test fun same_or_older_fix_never_persists() { + assertFalse(SelfFixPersistence.shouldPersist(fixTimeMs = t0, lastPersistedFixTimeMs = t0)) + assertFalse( + SelfFixPersistence.shouldPersist(fixTimeMs = t0 - 1L, lastPersistedFixTimeMs = t0), + ) + } + + @Test fun fix_inside_throttle_window_is_skipped() { + assertFalse( + "a fix 10 s after the last persisted one must be throttled", + SelfFixPersistence.shouldPersist( + fixTimeMs = t0 + 10_000L, lastPersistedFixTimeMs = t0, + ), + ) + } + + @Test fun fix_past_throttle_window_persists() { + assertTrue( + SelfFixPersistence.shouldPersist( + fixTimeMs = t0 + 15_000L, lastPersistedFixTimeMs = t0, + ), + ) + } + + // ── newer-wins merge ──────────────────────────────────────────────── + + @Test fun candidate_lands_when_no_current_fix() { + val seed = fix(t0) + assertSame(seed, SelfFixPersistence.newerOf(null, seed)) + } + + @Test fun newer_candidate_replaces_current() { + val old = fix(t0) + val fresh = fix(t0 + 5_000L) + assertSame(fresh, SelfFixPersistence.newerOf(old, fresh)) + } + + @Test fun stale_seed_never_clobbers_live_fix() { + val live = fix(t0) + val persistedSeed = fix(t0 - 3_600_000L) + assertSame( + "an hour-old restored fix must not replace live GPS", + live, + SelfFixPersistence.newerOf(live, persistedSeed), + ) + } + + @Test fun equal_timestamp_prefers_candidate() { + // Live updates can legitimately re-deliver the same instant — + // the latest delivery wins so the flow keeps moving. + val a = fix(t0, lat = 1.0) + val b = fix(t0, lat = 2.0) + assertSame(b, SelfFixPersistence.newerOf(a, b)) + } +} diff --git a/app/src/test/kotlin/soy/engindearing/omnitak/mobile/domain/SelfPositionBroadcasterFixSourcingTest.kt b/app/src/test/kotlin/soy/engindearing/omnitak/mobile/domain/SelfPositionBroadcasterFixSourcingTest.kt index 5c56029..cbff6e8 100644 --- a/app/src/test/kotlin/soy/engindearing/omnitak/mobile/domain/SelfPositionBroadcasterFixSourcingTest.kt +++ b/app/src/test/kotlin/soy/engindearing/omnitak/mobile/domain/SelfPositionBroadcasterFixSourcingTest.kt @@ -62,6 +62,29 @@ class SelfPositionBroadcasterFixSourcingTest { assertFalse("must not contain SF lon", xml.contains("-122.4")) } + @Test fun broadcasts_persisted_position_when_fix_null() = runTest { + // Issue #75 — the prefs fallback is now actually reachable: + // OmniTAKApp persists every real fix (throttled), so after process + // death the broadcaster keeps reporting the last known position + // with an honest unknown circular error instead of going silent. + val fixFlow = MutableStateFlow(null) + val sent = mutableListOf() + val prefs = UserPrefs( + selfUid = "ANDROID-test", + selfLat = 47.6062, + selfLon = -117.4194, + selfHae = 562.0, + selfFixTimeMs = 1_700_000_000_000L, + ) + broadcaster(fixFlow, sent, prefs).broadcastOnce(prefs) + assertTrue("expected one PPLI from persisted prefs", sent.size == 1) + val xml = sent.single() + assertTrue("persisted lat in XML, was: $xml", xml.contains("lat=\"47.6062\"")) + assertTrue("persisted lon in XML, was: $xml", xml.contains("lon=\"-117.4194\"")) + assertTrue("persisted hae in XML, was: $xml", xml.contains("hae=\"562.0\"")) + assertTrue("unknown CE for a persisted position, was: $xml", xml.contains("ce=\"9999999.0\"")) + } + @Test fun never_broadcasts_san_francisco_when_fix_null() = runTest { val fixFlow = MutableStateFlow(null) val sent = mutableListOf()