From 62c27bf28de30979bc58ef7808185ac189f71197 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Wed, 25 Mar 2026 02:00:17 +0000 Subject: feat(gps): implement NMEA/Android GPS sensor fusion in LocationService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds priority-based selection between NMEA GPS (dedicated marine GPS, higher priority) and Android system GPS (fallback) within LocationService. Selection policy: 1. Prefer NMEA when its most recent fix is fresh (≤ nmeaStalenessThresholdMs, default 5 s) 2. Fall back to Android GPS when NMEA is stale 3. Use stale NMEA only when Android has never reported a fix 4. bestPosition is null until at least one source reports New public API: - GpsSource enum (NONE, NMEA, ANDROID) - LocationService.updateNmeaGps(GpsPosition) - LocationService.updateAndroidGps(GpsPosition) - LocationService.bestPosition: StateFlow - LocationService.activeGpsSource: StateFlow - Injectable clockMs parameter for deterministic unit tests Adds 7 unit tests covering: no-data state, fresh NMEA priority, stale NMEA fallback, only-NMEA/only-Android scenarios, exact-threshold edge case, and NMEA recovery after Android takeover. Co-Authored-By: Claude Sonnet 4.6 --- .../example/androidapp/gps/LocationServiceTest.kt | 110 +++++++++++++++++++++ 1 file changed, 110 insertions(+) (limited to 'android-app/app/src/test') diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt index d9192c6..237004b 100644 --- a/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt +++ b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt @@ -114,4 +114,114 @@ class LocationServiceTest { assertEquals(data, svc.latestSensor.first()) } + + // ── GPS sensor fusion ───────────────────────────────────────────────────── + + private fun fusionService( + nmeaStalenessThresholdMs: Long = 5_000L, + clockMs: () -> Long = System::currentTimeMillis + ) = LocationService( + nmeaStalenessThresholdMs = nmeaStalenessThresholdMs, + clockMs = clockMs + ) + + private fun pos(lat: Double, lon: Double, timestampMs: Long) = + GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs) + + @Test + fun noGpsData_bestPositionNullAndSourceNone() = runBlocking { + val svc = fusionService() + assertNull(svc.bestPosition.first()) + assertEquals(GpsSource.NONE, svc.activeGpsSource.first()) + } + + @Test + fun freshNmea_preferredOverAndroid() = runBlocking { + val now = 10_000L + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + val nmeaFix = pos(41.0, -71.0, now) + val androidFix = pos(42.0, -72.0, now - 1_000L) + + svc.updateAndroidGps(androidFix) + svc.updateNmeaGps(nmeaFix) + + assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) + assertEquals(nmeaFix, svc.bestPosition.first()) + } + + @Test + fun staleNmea_androidFallback() = runBlocking { + val nmeaTime = 0L + val now = 10_000L // 10 s later — NMEA is stale (threshold 5 s) + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + val nmeaFix = pos(41.0, -71.0, nmeaTime) + val androidFix = pos(42.0, -72.0, now) + + svc.updateNmeaGps(nmeaFix) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) + assertEquals(androidFix, svc.bestPosition.first()) + } + + @Test + fun onlyNmeaAvailable_usedEvenWhenStale() = runBlocking { + val now = 60_000L // 60 s after fix — very stale + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + val nmeaFix = pos(41.0, -71.0, 0L) + svc.updateNmeaGps(nmeaFix) + + assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) + assertEquals(nmeaFix, svc.bestPosition.first()) + } + + @Test + fun onlyAndroidAvailable_isUsed() = runBlocking { + val svc = fusionService() + val androidFix = pos(42.0, -72.0, System.currentTimeMillis()) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) + assertEquals(androidFix, svc.bestPosition.first()) + } + + @Test + fun nmeaAtExactThreshold_isConsideredFresh() = runBlocking { + val fixTime = 0L + val now = 5_000L // exactly at threshold + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + val nmeaFix = pos(41.0, -71.0, fixTime) + val androidFix = pos(42.0, -72.0, now) + + svc.updateNmeaGps(nmeaFix) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) + } + + @Test + fun nmeaRecovery_switchesBackFromAndroid() = runBlocking { + var now = 0L + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + // Fresh NMEA + svc.updateNmeaGps(pos(41.0, -71.0, 0L)) + assertEquals(GpsSource.NMEA, svc.activeGpsSource.value) + + // NMEA goes stale; Android takes over + now = 10_000L + val androidFix = pos(42.0, -72.0, 10_000L) + svc.updateAndroidGps(androidFix) + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.value) + + // NMEA recovers with a fresh fix + val freshNmea = pos(41.1, -71.1, 10_000L) + svc.updateNmeaGps(freshNmea) + assertEquals(GpsSource.NMEA, svc.activeGpsSource.value) + assertEquals(freshNmea, svc.bestPosition.value) + } } -- cgit v1.2.3