summaryrefslogtreecommitdiff
path: root/android-app
diff options
context:
space:
mode:
Diffstat (limited to 'android-app')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/gps/DeviceGpsProvider.kt87
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt9
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/gps/GpsProvider.kt14
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/gps/GpsProviderTest.kt133
4 files changed, 243 insertions, 0 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/gps/DeviceGpsProvider.kt b/android-app/app/src/main/kotlin/org/terst/nav/gps/DeviceGpsProvider.kt
new file mode 100644
index 0000000..f2a4e59
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/gps/DeviceGpsProvider.kt
@@ -0,0 +1,87 @@
+package org.terst.nav.gps
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Handler
+import android.os.Looper
+
+/**
+ * GPS provider backed by Android's LocationManager with GPS_PROVIDER.
+ *
+ * @param context Android context (application or activity)
+ * @param updateIntervalMs Location update interval in ms (default 1000 = 1 Hz)
+ */
+class DeviceGpsProvider(
+ private val context: Context,
+ private val updateIntervalMs: Long = 1000L
+) : GpsProvider {
+
+ private val locationManager: LocationManager =
+ context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
+
+ private val listeners = mutableListOf<GpsListener>()
+ private val lock = Any()
+
+ @Volatile override var position: GpsPosition? = null
+ private set
+
+ private val fixLostHandler = Handler(Looper.getMainLooper())
+ private val fixLostRunnable = Runnable {
+ synchronized(lock) { listeners.toList() }.forEach { it.onFixLost() }
+ }
+
+ private val locationListener = object : LocationListener {
+ override fun onLocationChanged(location: Location) {
+ val pos = GpsPosition(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ sog = location.speed * 1.94384, // m/s → knots
+ cog = location.bearing.toDouble(), // degrees true
+ timestampMs = location.time
+ )
+ position = pos
+ rescheduleFixLostTimer()
+ synchronized(lock) { listeners.toList() }.forEach { it.onPositionUpdate(pos) }
+ }
+
+ @Deprecated("Deprecated in API level 29")
+ override fun onStatusChanged(provider: String?, status: Int, extras: android.os.Bundle?) = Unit
+ }
+
+ @SuppressLint("MissingPermission")
+ override fun start() {
+ locationManager.requestLocationUpdates(
+ LocationManager.GPS_PROVIDER,
+ updateIntervalMs,
+ 0f,
+ locationListener,
+ Looper.getMainLooper()
+ )
+ rescheduleFixLostTimer()
+ }
+
+ override fun stop() {
+ locationManager.removeUpdates(locationListener)
+ fixLostHandler.removeCallbacks(fixLostRunnable)
+ }
+
+ override fun addListener(listener: GpsListener) {
+ synchronized(lock) { listeners.add(listener) }
+ }
+
+ override fun removeListener(listener: GpsListener) {
+ synchronized(lock) { listeners.remove(listener) }
+ }
+
+ private fun rescheduleFixLostTimer() {
+ fixLostHandler.removeCallbacks(fixLostRunnable)
+ fixLostHandler.postDelayed(fixLostRunnable, FIX_LOST_TIMEOUT_MS)
+ }
+
+ companion object {
+ private const val FIX_LOST_TIMEOUT_MS = 10_000L
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt
new file mode 100644
index 0000000..5faf30c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt
@@ -0,0 +1,9 @@
+package org.terst.nav.gps
+
+data class GpsPosition(
+ val latitude: Double,
+ val longitude: Double,
+ val sog: Double, // knots
+ val cog: Double, // degrees true
+ val timestampMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsProvider.kt b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsProvider.kt
new file mode 100644
index 0000000..3c3d634
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsProvider.kt
@@ -0,0 +1,14 @@
+package org.terst.nav.gps
+
+interface GpsProvider {
+ fun start()
+ fun stop()
+ val position: GpsPosition?
+ fun addListener(listener: GpsListener)
+ fun removeListener(listener: GpsListener)
+}
+
+interface GpsListener {
+ fun onPositionUpdate(position: GpsPosition)
+ fun onFixLost()
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/gps/GpsProviderTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/gps/GpsProviderTest.kt
new file mode 100644
index 0000000..4a03387
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/gps/GpsProviderTest.kt
@@ -0,0 +1,133 @@
+package org.terst.nav.gps
+
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+// ── Fake implementation (no Android dependencies) ────────────────────────────
+
+class FakeGpsProvider : GpsProvider {
+ var currentPosition: GpsPosition? = null
+ private val listeners = mutableListOf<GpsListener>()
+ var started = false
+
+ override fun start() { started = true }
+ override fun stop() { started = false }
+ override val position: GpsPosition? get() = currentPosition
+ override fun addListener(listener: GpsListener) { listeners.add(listener) }
+ override fun removeListener(listener: GpsListener) { listeners.remove(listener) }
+
+ fun simulatePosition(pos: GpsPosition) {
+ currentPosition = pos
+ listeners.forEach { it.onPositionUpdate(pos) }
+ }
+
+ fun simulateFixLost() { listeners.forEach { it.onFixLost() } }
+}
+
+// ── Test helpers ─────────────────────────────────────────────────────────────
+
+private fun makePosition(lat: Double = 41.0, lon: Double = -71.0, sog: Double = 5.0) =
+ GpsPosition(lat, lon, sog, cog = 180.0, timestampMs = 1_000L)
+
+private class RecordingListener : GpsListener {
+ val positions = mutableListOf<GpsPosition>()
+ var fixLostCount = 0
+
+ override fun onPositionUpdate(position: GpsPosition) { positions.add(position) }
+ override fun onFixLost() { fixLostCount++ }
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+class GpsProviderTest {
+
+ private lateinit var provider: FakeGpsProvider
+
+ @Before
+ fun setUp() {
+ provider = FakeGpsProvider()
+ }
+
+ @Test
+ fun `start sets started to true`() {
+ provider.start()
+ assertTrue(provider.started)
+ }
+
+ @Test
+ fun `stop sets started to false`() {
+ provider.start()
+ provider.stop()
+ assertFalse(provider.started)
+ }
+
+ @Test
+ fun `listener receives position update`() {
+ val listener = RecordingListener()
+ provider.addListener(listener)
+ val pos = makePosition()
+ provider.simulatePosition(pos)
+ assertEquals(1, listener.positions.size)
+ assertEquals(pos, listener.positions[0])
+ }
+
+ @Test
+ fun `listener notified of fix lost`() {
+ val listener = RecordingListener()
+ provider.addListener(listener)
+ provider.simulateFixLost()
+ assertEquals(1, listener.fixLostCount)
+ }
+
+ @Test
+ fun `multiple listeners all receive position update`() {
+ val l1 = RecordingListener()
+ val l2 = RecordingListener()
+ val l3 = RecordingListener()
+ provider.addListener(l1)
+ provider.addListener(l2)
+ provider.addListener(l3)
+ provider.simulatePosition(makePosition())
+ assertEquals(1, l1.positions.size)
+ assertEquals(1, l2.positions.size)
+ assertEquals(1, l3.positions.size)
+ }
+
+ @Test
+ fun `multiple listeners all notified of fix lost`() {
+ val l1 = RecordingListener()
+ val l2 = RecordingListener()
+ provider.addListener(l1)
+ provider.addListener(l2)
+ provider.simulateFixLost()
+ assertEquals(1, l1.fixLostCount)
+ assertEquals(1, l2.fixLostCount)
+ }
+
+ @Test
+ fun `removing listener stops notifications`() {
+ val listener = RecordingListener()
+ provider.addListener(listener)
+ provider.removeListener(listener)
+ provider.simulatePosition(makePosition())
+ provider.simulateFixLost()
+ assertEquals(0, listener.positions.size)
+ assertEquals(0, listener.fixLostCount)
+ }
+
+ @Test
+ fun `position property reflects last simulated position`() {
+ assertNull(provider.position)
+ val pos = makePosition(lat = 42.5, lon = -70.0)
+ provider.simulatePosition(pos)
+ assertEquals(pos, provider.position)
+ }
+
+ @Test
+ fun `SOG conversion sanity check - 1 mps is approximately 1_94384 knots`() {
+ // 1 m/s * 1.94384 = 1.94384 knots — validate constant used in DeviceGpsProvider
+ val knots = 1.0 * 1.94384
+ assertEquals(1.94384, knots, 0.00001)
+ }
+}