summaryrefslogtreecommitdiff
path: root/android-app
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator>2026-03-15 00:58:54 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-25 04:53:52 +0000
commitc943c22954132b21f3067b526b3c13f3300113dd (patch)
tree20357247605caa0043a2bcd60fdc4237f01f97c3 /android-app
parenta9d87b600848178b03b85a06ccdfd53b11e38c38 (diff)
Add GpsPosition data class and NMEA RMC parser with tests
- GpsPosition: latitude, longitude, sog (knots), cog (degrees true), timestampMs - NmeaParser.parseRmc: handles GP/GN talker IDs, void status, malformed input - SOG/COG default to 0.0 when fields absent; S/W coords are negative - 13 unit tests: GpsPositionTest (2), NmeaParserTest (11) — all GREEN Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app')
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt9
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt94
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt33
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt105
4 files changed, 241 insertions, 0 deletions
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt b/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt
new file mode 100644
index 0000000..6df685b
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt
@@ -0,0 +1,9 @@
+package com.example.androidapp.gps
+
+data class GpsPosition(
+ val latitude: Double, // degrees, positive = North
+ val longitude: Double, // degrees, positive = East
+ val sog: Double, // Speed Over Ground in knots
+ val cog: Double, // Course Over Ground in degrees true (0-360)
+ val timestampMs: Long // Unix millis UTC
+)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt b/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt
new file mode 100644
index 0000000..b1b186a
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt
@@ -0,0 +1,94 @@
+package com.example.androidapp.nmea
+
+import com.example.androidapp.gps.GpsPosition
+import java.util.Calendar
+import java.util.TimeZone
+
+class NmeaParser {
+
+ /**
+ * Parses an NMEA RMC sentence and returns a [GpsPosition], or null if the
+ * sentence is void (status=V), malformed, or cannot be parsed.
+ *
+ * Supported talker IDs: GP, GN, and any other standard prefix.
+ * SOG and COG default to 0.0 when the fields are absent.
+ */
+ fun parseRmc(sentence: String): GpsPosition? {
+ if (sentence.isBlank()) return null
+
+ val body = if ('*' in sentence) sentence.substringBefore('*') else sentence
+ val fields = body.split(',')
+ if (fields.size < 10) return null
+
+ if (!fields[0].endsWith("RMC")) return null
+ if (fields[2] != "A") return null
+
+ val latStr = fields.getOrNull(3) ?: return null
+ val latDir = fields.getOrNull(4) ?: return null
+ val lonStr = fields.getOrNull(5) ?: return null
+ val lonDir = fields.getOrNull(6) ?: return null
+
+ val latitude = parseNmeaDegrees(latStr) * if (latDir == "S") -1.0 else 1.0
+ val longitude = parseNmeaDegrees(lonStr) * if (lonDir == "W") -1.0 else 1.0
+
+ val sog = fields.getOrNull(7)?.toDoubleOrNull() ?: 0.0
+ val cog = fields.getOrNull(8)?.toDoubleOrNull() ?: 0.0
+
+ val timestampMs = parseTimestamp(
+ timeStr = fields.getOrNull(1) ?: "",
+ dateStr = fields.getOrNull(9) ?: ""
+ )
+ if (timestampMs == 0L) return null
+
+ return GpsPosition(latitude, longitude, sog, cog, timestampMs)
+ }
+
+ /**
+ * Converts NMEA degree-minutes format (DDDMM.MMMM) to decimal degrees.
+ */
+ private fun parseNmeaDegrees(value: String): Double {
+ val raw = value.toDoubleOrNull() ?: return 0.0
+ val degrees = (raw / 100.0).toInt()
+ val minutes = raw - degrees * 100.0
+ return degrees + minutes / 60.0
+ }
+
+ /**
+ * Combines NMEA time (HHMMSS.ss) and date (DDMMYY) into Unix epoch millis UTC.
+ * Returns 0 on any parse failure.
+ */
+ private fun parseTimestamp(timeStr: String, dateStr: String): Long {
+ return try {
+ val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
+ cal.isLenient = false
+
+ if (dateStr.length >= 6) {
+ val day = dateStr.substring(0, 2).toInt()
+ val month = dateStr.substring(2, 4).toInt() - 1
+ val yy = dateStr.substring(4, 6).toInt()
+ val year = if (yy < 70) 2000 + yy else 1900 + yy
+ cal.set(Calendar.YEAR, year)
+ cal.set(Calendar.MONTH, month)
+ cal.set(Calendar.DAY_OF_MONTH, day)
+ }
+
+ if (timeStr.length >= 6) {
+ val hours = timeStr.substring(0, 2).toInt()
+ val minutes = timeStr.substring(2, 4).toInt()
+ val seconds = timeStr.substring(4, 6).toInt()
+ val millis = if (timeStr.length > 7) {
+ val fracStr = timeStr.substring(7)
+ (("0.$fracStr").toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0
+ } else 0
+ cal.set(Calendar.HOUR_OF_DAY, hours)
+ cal.set(Calendar.MINUTE, minutes)
+ cal.set(Calendar.SECOND, seconds)
+ cal.set(Calendar.MILLISECOND, millis)
+ }
+
+ cal.timeInMillis
+ } catch (e: Exception) {
+ 0L
+ }
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt
new file mode 100644
index 0000000..8b2753c
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt
@@ -0,0 +1,33 @@
+package com.example.androidapp.gps
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class GpsPositionTest {
+
+ @Test
+ fun `GpsPosition holds correct values`() {
+ val pos = GpsPosition(
+ latitude = 41.5,
+ longitude = -71.0,
+ sog = 5.2,
+ cog = 180.0,
+ timestampMs = 1_000L
+ )
+ assertEquals(41.5, pos.latitude, 0.0)
+ assertEquals(-71.0, pos.longitude, 0.0)
+ assertEquals(5.2, pos.sog, 0.0)
+ assertEquals(180.0, pos.cog, 0.0)
+ assertEquals(1_000L, pos.timestampMs)
+ }
+
+ @Test
+ fun `GpsPosition equality works as expected for data class`() {
+ val pos1 = GpsPosition(41.5, -71.0, 5.2, 180.0, 1_000L)
+ val pos2 = GpsPosition(41.5, -71.0, 5.2, 180.0, 1_000L)
+ val pos3 = GpsPosition(42.0, -70.0, 3.0, 90.0, 2_000L)
+
+ assertEquals(pos1, pos2)
+ assertNotEquals(pos1, pos3)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt
new file mode 100644
index 0000000..b8a878a
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt
@@ -0,0 +1,105 @@
+package com.example.androidapp.nmea
+
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+class NmeaParserTest {
+
+ private lateinit var parser: NmeaParser
+
+ @Before
+ fun setUp() {
+ parser = NmeaParser()
+ }
+
+ // $GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A
+ // lat: 48 + 7.038/60 = 48.1173°N, lon: 11 + 31.000/60 = 11.51667°E
+ // SOG 22.4 kn, COG 84.4°
+
+ @Test
+ fun `valid RMC sentence parses latitude and longitude`() {
+ val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertEquals(48.1173, pos!!.latitude, 0.0001)
+ assertEquals(11.51667, pos.longitude, 0.0001)
+ }
+
+ @Test
+ fun `valid RMC sentence parses SOG and COG`() {
+ val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertEquals(22.4, pos!!.sog, 0.001)
+ assertEquals(84.4, pos.cog, 0.001)
+ }
+
+ @Test
+ fun `void status V returns null`() {
+ val sentence = "\$GPRMC,123519,V,4807.038,N,01131.000,E,,,230394,003.1,W"
+ assertNull(parser.parseRmc(sentence))
+ }
+
+ @Test
+ fun `malformed sentence with too few fields returns null`() {
+ assertNull(parser.parseRmc("\$GPRMC,123519,A"))
+ }
+
+ @Test
+ fun `empty string returns null`() {
+ assertNull(parser.parseRmc(""))
+ }
+
+ @Test
+ fun `non-RMC sentence returns null`() {
+ val sentence = "\$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,"
+ assertNull(parser.parseRmc(sentence))
+ }
+
+ @Test
+ fun `south latitude is negative`() {
+ // lat: -(42 + 50.5589/60) = -42.84265
+ val sentence = "\$GPRMC,092204.999,A,4250.5589,S,14718.5084,E,0.00,89.68,211200,,"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertTrue("South latitude must be negative", pos!!.latitude < 0)
+ assertEquals(-42.84265, pos.latitude, 0.0001)
+ }
+
+ @Test
+ fun `west longitude is negative`() {
+ // lon: -(11 + 31.000/60) = -11.51667
+ val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,W,022.4,084.4,230394,003.1,E"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertTrue("West longitude must be negative", pos!!.longitude < 0)
+ assertEquals(-11.51667, pos.longitude, 0.0001)
+ }
+
+ @Test
+ fun `SOG and COG parse with decimal precision`() {
+ val sentence = "\$GPRMC,093456,A,3352.1234,N,11801.5678,W,12.345,270.5,140326,,"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertEquals(12.345, pos!!.sog, 0.0001)
+ assertEquals(270.5, pos.cog, 0.0001)
+ }
+
+ @Test
+ fun `empty SOG and COG fields default to zero`() {
+ val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,,,230394,003.1,W"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertEquals(0.0, pos!!.sog, 0.001)
+ assertEquals(0.0, pos.cog, 0.001)
+ }
+
+ @Test
+ fun `GNRMC talker ID is also accepted`() {
+ val sentence = "\$GNRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertEquals(48.1173, pos!!.latitude, 0.0001)
+ }
+}