Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
},
)
Expand Down
26 changes: 26 additions & 0 deletions app/src/main/kotlin/soy/engindearing/omnitak/mobile/OmniTAKApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) }
}
}

Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading