diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-04-06 09:41:32 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-04-06 09:41:32 +0000 |
| commit | f9b8801eb52c48986eb0123e8758f7ab78736dec (patch) | |
| tree | 7fbc4d06eaaf92223e1be0cc1d71a4b90f505948 /android-app | |
| parent | 36af31c9bda660706c3271380b13cba8486c0604 (diff) | |
feat(tracks): persist tracks as GPX in Documents/Nav/ — survives uninstall
GpxSerializer/GpxParser: full round-trip of all TrackPoint fields via
GPX 1.1 + nav: extensions namespace. 13 unit tests.
TrackStorage: MediaStore on API 29+ (no permission needed), direct file
I/O on API 24-28 (WRITE_EXTERNAL_STORAGE maxSdkVersion=28).
TrackRepository: stopTrack() is now suspend, writes GPX and returns
TrackSummary (distance nm, duration, max/avg SOG, avg wind, avg wave).
getPastTracks() lazy-loads from Documents/Nav/ on first call.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app')
10 files changed, 524 insertions, 12 deletions
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index e2e311d..2f23e87 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -8,6 +8,9 @@ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.RECORD_AUDIO" /> + <!-- Documents/ write access — not needed on API 29+ (MediaStore handles it) --> + <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" + android:maxSdkVersion="28" /> <application android:name=".NavApplication" diff --git a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt index 9b8cb8a..7c43dd5 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt @@ -13,7 +13,7 @@ class NavApplication : Application() { companion object { val logbookRepository = org.terst.nav.logbook.InMemoryLogbookRepository() - val trackRepository = org.terst.nav.track.TrackRepository() + lateinit var trackRepository: org.terst.nav.track.TrackRepository var isTesting: Boolean = false get() { if (field) return true @@ -28,6 +28,7 @@ class NavApplication : Application() { override fun onCreate() { super.onCreate() + trackRepository = org.terst.nav.track.TrackRepository(this) FirebaseCrashlytics.getInstance().sendUnsentReports() installCrashLogger() } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/GpxParser.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/GpxParser.kt new file mode 100644 index 0000000..3287f1d --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/GpxParser.kt @@ -0,0 +1,96 @@ +package org.terst.nav.track + +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory +import java.io.InputStream +import java.time.Instant + +/** + * Parses a GPX 1.1 file produced by [GpxSerializer] back into a list of [TrackPoint]s. + * + * Uses XmlPullParser (built into Android, also available on JVM via kxml2) so + * no additional XML dependencies are required. + */ +object GpxParser { + + fun parse(input: InputStream): List<TrackPoint> { + val points = mutableListOf<TrackPoint>() + val factory = XmlPullParserFactory.newInstance().apply { isNamespaceAware = true } + val xpp = factory.newPullParser() + xpp.setInput(input, "UTF-8") + + var lat = 0.0; var lon = 0.0; var timeMs = 0L + var sog = 0.0; var cog = 0.0 + var hdg: Double? = null; var bsp: Double? = null + var depth: Double? = null; var baro: Double? = null + var windSpd: Double? = null; var windAng: Double? = null + var trueWind = false; var airTemp: Double? = null + var waveHt: Double? = null; var currSpd: Double? = null; var currDir: Double? = null + var inExtensions = false; var currentTag = "" + + var event = xpp.eventType + while (event != XmlPullParser.END_DOCUMENT) { + when (event) { + XmlPullParser.START_TAG -> { + currentTag = xpp.name ?: "" + when (currentTag) { + "trkpt" -> { + lat = xpp.getAttributeValue(null, "lat")?.toDoubleOrNull() ?: 0.0 + lon = xpp.getAttributeValue(null, "lon")?.toDoubleOrNull() ?: 0.0 + // reset fields for this point + timeMs = 0L; sog = 0.0; cog = 0.0 + hdg = null; bsp = null; depth = null; baro = null + windSpd = null; windAng = null; trueWind = false + airTemp = null; waveHt = null; currSpd = null; currDir = null + } + "extensions" -> inExtensions = true + } + } + XmlPullParser.TEXT -> { + val text = xpp.text?.trim() ?: "" + if (text.isEmpty()) { event = xpp.next(); continue } + when { + currentTag == "time" -> timeMs = runCatching { + Instant.parse(text).toEpochMilli() + }.getOrDefault(0L) + inExtensions -> when (currentTag) { + "sog" -> sog = text.toDoubleOrNull() ?: sog + "cog" -> cog = text.toDoubleOrNull() ?: cog + "hdg" -> hdg = text.toDoubleOrNull() + "bsp" -> bsp = text.toDoubleOrNull() + "depth" -> depth = text.toDoubleOrNull() + "baro" -> baro = text.toDoubleOrNull() + "windSpd" -> windSpd = text.toDoubleOrNull() + "windAng" -> windAng = text.toDoubleOrNull() + "trueWind" -> trueWind = text == "true" + "airTemp" -> airTemp = text.toDoubleOrNull() + "waveHt" -> waveHt = text.toDoubleOrNull() + "currSpd" -> currSpd = text.toDoubleOrNull() + "currDir" -> currDir = text.toDoubleOrNull() + } + } + } + XmlPullParser.END_TAG -> { + val tag = xpp.name ?: "" + when (tag) { + "trkpt" -> points.add(TrackPoint( + lat = lat, lon = lon, + sogKnots = sog, cogDeg = cog, + headingDeg = hdg, waterSpeedKnots = bsp, + depthMeters = depth, baroHpa = baro, + windSpeedKnots = windSpd, windAngleDeg = windAng, + isTrueWind = trueWind, airTempC = airTemp, + waveHeightM = waveHt, currentSpeedKts = currSpd, + currentDirDeg = currDir, + timestampMs = if (timeMs > 0) timeMs else System.currentTimeMillis() + )) + "extensions" -> inExtensions = false + } + currentTag = "" + } + } + event = xpp.next() + } + return points + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/GpxSerializer.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/GpxSerializer.kt new file mode 100644 index 0000000..e4b9448 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/GpxSerializer.kt @@ -0,0 +1,62 @@ +package org.terst.nav.track + +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +/** + * Serializes a list of [TrackPoint]s to a GPX 1.1 XML string. + * + * Nav-specific fields (SOG, COG, depth, baro, wind) are stored in a + * `<extensions>` block under the `nav:` namespace so the file remains + * valid GPX while preserving full fidelity for round-trip reload. + */ +object GpxSerializer { + + private val ISO8601 = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") + .withZone(ZoneOffset.UTC) + + fun serialize(points: List<TrackPoint>, trackName: String): String = buildString { + appendLine("""<?xml version="1.0" encoding="UTF-8"?>""") + appendLine( + """<gpx version="1.1" creator="Nav" """ + + """xmlns="http://www.topografix.com/GPX/1/1" """ + + """xmlns:nav="https://terst.org/nav/gpx/1">""" + ) + appendLine(" <trk>") + appendLine(" <name>${escapeXml(trackName)}</name>") + appendLine(" <trkseg>") + + for (pt in points) { + appendLine(""" <trkpt lat="${pt.lat}" lon="${pt.lon}">""") + appendLine(" <ele>0</ele>") + appendLine(" <time>${ISO8601.format(Instant.ofEpochMilli(pt.timestampMs))}</time>") + appendLine(" <extensions>") + appendLine(" <nav:sog>${pt.sogKnots}</nav:sog>") + appendLine(" <nav:cog>${pt.cogDeg}</nav:cog>") + pt.headingDeg?.let { appendLine(" <nav:hdg>$it</nav:hdg>") } + pt.waterSpeedKnots?.let { appendLine(" <nav:bsp>$it</nav:bsp>") } + pt.depthMeters?.let { appendLine(" <nav:depth>$it</nav:depth>") } + pt.baroHpa?.let { appendLine(" <nav:baro>$it</nav:baro>") } + pt.windSpeedKnots?.let { appendLine(" <nav:windSpd>$it</nav:windSpd>") } + pt.windAngleDeg?.let { appendLine(" <nav:windAng>$it</nav:windAng>") } + if (pt.isTrueWind) { appendLine(" <nav:trueWind>true</nav:trueWind>") } + pt.airTempC?.let { appendLine(" <nav:airTemp>$it</nav:airTemp>") } + pt.waveHeightM?.let { appendLine(" <nav:waveHt>$it</nav:waveHt>") } + pt.currentSpeedKts?.let { appendLine(" <nav:currSpd>$it</nav:currSpd>") } + pt.currentDirDeg?.let { appendLine(" <nav:currDir>$it</nav:currDir>") } + appendLine(" </extensions>") + appendLine(" </trkpt>") + } + + appendLine(" </trkseg>") + appendLine(" </trk>") + append("</gpx>") + } + + private fun escapeXml(s: String) = s + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt index 85dd2dd..ed32497 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt @@ -1,23 +1,45 @@ package org.terst.nav.track -class TrackRepository { +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class TrackRepository(context: Context) { + + private val storage = TrackStorage(context) var isRecording: Boolean = false private set private val activePoints = mutableListOf<TrackPoint>() - private val pastTracks = mutableListOf<List<TrackPoint>>() + private var trackStartMs = 0L + + // Loaded lazily from Documents/Nav/ on first access + private val _pastTracks = mutableListOf<List<TrackPoint>>() + private var pastTracksLoaded = false fun startTrack() { activePoints.clear() + trackStartMs = System.currentTimeMillis() isRecording = true } - fun stopTrack() { - if (isRecording && activePoints.isNotEmpty()) { - pastTracks.add(activePoints.toList()) - } + /** + * Stops the active track, computes a [TrackSummary], persists the track + * to shared storage, and returns the summary. Returns null if no points + * were recorded. + */ + suspend fun stopTrack(): TrackSummary? = withContext(Dispatchers.IO) { + if (!isRecording) return@withContext null isRecording = false + val points = activePoints.toList() + activePoints.clear() + if (points.isEmpty()) return@withContext null + + val summary = summarise(points) + _pastTracks.add(0, points) // prepend so most recent is first + storage.saveTrack(points, trackStartMs) + summary } fun addPoint(point: TrackPoint): Boolean { @@ -28,5 +50,18 @@ class TrackRepository { fun getPoints(): List<TrackPoint> = activePoints.toList() - fun getPastTracks(): List<List<TrackPoint>> = pastTracks.toList() + /** + * Returns all completed tracks, loading from Documents/Nav/ on first call. + * Subsequent calls return the in-memory list (storage is source of truth + * only at startup). + */ + suspend fun getPastTracks(): List<List<TrackPoint>> = withContext(Dispatchers.IO) { + if (!pastTracksLoaded) { + pastTracksLoaded = true + val stored = storage.loadAllTracks() + // Merge: put stored tracks behind any in-memory tracks from this session + _pastTracks.addAll(stored) + } + _pastTracks.toList() + } } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt new file mode 100644 index 0000000..620431c --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt @@ -0,0 +1,120 @@ +package org.terst.nav.track + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import java.io.File +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +/** + * Persists completed tracks as GPX files in the shared Documents/Nav/ folder. + * + * Files written here survive app uninstall because they live in user-owned + * shared storage rather than app-private storage. + * + * API 29+: uses MediaStore (no permission required for Documents/). + * API < 29: writes directly to Environment.DIRECTORY_DOCUMENTS (requires + * WRITE_EXTERNAL_STORAGE permission declared in the manifest). + */ +class TrackStorage(private val context: Context) { + + private val fileTimestamp = DateTimeFormatter + .ofPattern("yyyy-MM-dd_HH-mm-ss") + .withZone(ZoneOffset.UTC) + + private val trackName = DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm") + .withZone(ZoneOffset.UTC) + + /** Write a completed track to Documents/Nav/. Returns true on success. */ + fun saveTrack(points: List<TrackPoint>, startMs: Long): Boolean { + if (points.isEmpty()) return false + val name = trackName.format(Instant.ofEpochMilli(startMs)) + val fileName = "nav_${fileTimestamp.format(Instant.ofEpochMilli(startMs))}.gpx" + val gpx = GpxSerializer.serialize(points, name) + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveViaMediaStore(fileName, gpx) + } else { + saveViaFile(fileName, gpx) + } + } + + /** Load all tracks previously saved to Documents/Nav/. */ + fun loadAllTracks(): List<List<TrackPoint>> { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + loadViaMediaStore() + } else { + loadViaFile() + } + } + + // ── API 29+ ────────────────────────────────────────────────────────────── + + private fun saveViaMediaStore(fileName: String, gpx: String): Boolean { + val values = ContentValues().apply { + put(MediaStore.Files.FileColumns.DISPLAY_NAME, fileName) + put(MediaStore.Files.FileColumns.MIME_TYPE, "application/gpx+xml") + put(MediaStore.Files.FileColumns.RELATIVE_PATH, "Documents/Nav/") + } + val uri = context.contentResolver.insert( + MediaStore.Files.getContentUri("external"), values + ) ?: return false + + return runCatching { + context.contentResolver.openOutputStream(uri)?.use { it.write(gpx.toByteArray()) } + true + }.getOrDefault(false) + } + + private fun loadViaMediaStore(): List<List<TrackPoint>> { + val tracks = mutableListOf<List<TrackPoint>>() + val uri = MediaStore.Files.getContentUri("external") + val projection = arrayOf(MediaStore.Files.FileColumns._ID) + val selection = "${MediaStore.Files.FileColumns.RELATIVE_PATH} = ? " + + "AND ${MediaStore.Files.FileColumns.DISPLAY_NAME} LIKE ?" + val args = arrayOf("Documents/Nav/", "nav_%.gpx") + + context.contentResolver.query(uri, projection, selection, args, null)?.use { cursor -> + val idCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID) + while (cursor.moveToNext()) { + val fileUri = android.net.Uri.withAppendedPath(uri, cursor.getLong(idCol).toString()) + runCatching { + context.contentResolver.openInputStream(fileUri)?.use { stream -> + val points = GpxParser.parse(stream) + if (points.isNotEmpty()) tracks.add(points) + } + } + } + } + return tracks + } + + // ── API < 29 ───────────────────────────────────────────────────────────── + + private fun navDir(): File { + val docs = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) + return File(docs, "Nav").also { it.mkdirs() } + } + + private fun saveViaFile(fileName: String, gpx: String): Boolean = runCatching { + File(navDir(), fileName).writeText(gpx) + true + }.getOrDefault(false) + + private fun loadViaFile(): List<List<TrackPoint>> { + val dir = navDir() + if (!dir.exists()) return emptyList() + return dir.listFiles { f -> f.name.startsWith("nav_") && f.name.endsWith(".gpx") } + ?.sortedBy { it.name } + ?.mapNotNull { file -> + runCatching { GpxParser.parse(file.inputStream()) } + .getOrNull() + ?.takeIf { it.isNotEmpty() } + } ?: emptyList() + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummary.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummary.kt new file mode 100644 index 0000000..3f2f3be --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummary.kt @@ -0,0 +1,54 @@ +package org.terst.nav.track + +import kotlin.math.* + +data class TrackSummary( + val distanceNm: Double, + val durationMs: Long, + val maxSogKt: Double, + val avgSogKt: Double, + val avgWindKt: Double?, // null if no wind data in track + val avgWaveHeightM: Double? // null if no wave data in track +) { + val durationMinutes: Long get() = durationMs / 60_000 +} + +/** Computes a [TrackSummary] from a completed list of [TrackPoint]s. */ +fun summarise(points: List<TrackPoint>): TrackSummary { + require(points.isNotEmpty()) + + var distanceNm = 0.0 + for (i in 1 until points.size) { + distanceNm += haversineNm(points[i - 1], points[i]) + } + + val durationMs = points.last().timestampMs - points.first().timestampMs + val maxSog = points.maxOf { it.sogKnots } + val avgSog = points.map { it.sogKnots }.average() + + val windReadings = points.mapNotNull { it.windSpeedKnots } + val avgWind = if (windReadings.isNotEmpty()) windReadings.average() else null + + val waveReadings = points.mapNotNull { it.waveHeightM } + val avgWave = if (waveReadings.isNotEmpty()) waveReadings.average() else null + + return TrackSummary( + distanceNm = distanceNm, + durationMs = durationMs, + maxSogKt = maxSog, + avgSogKt = avgSog, + avgWindKt = avgWind, + avgWaveHeightM = avgWave + ) +} + +private fun haversineNm(a: TrackPoint, b: TrackPoint): Double { + val r = 3440.065 // Earth radius in nautical miles + val dLat = Math.toRadians(b.lat - a.lat) + val dLon = Math.toRadians(b.lon - a.lon) + val sinDLat = sin(dLat / 2) + val sinDLon = sin(dLon / 2) + val h = sinDLat * sinDLat + + cos(Math.toRadians(a.lat)) * cos(Math.toRadians(b.lat)) * sinDLon * sinDLon + return 2 * r * asin(sqrt(h)) +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt index 2c56b06..c1707ab 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt @@ -67,10 +67,12 @@ class MainViewModel( } fun stopTrack() { - trackRepository.stopTrack() - _pastTracks.value = trackRepository.getPastTracks() - _trackPoints.value = emptyList() - _isRecording.value = false + viewModelScope.launch { + trackRepository.stopTrack() + _pastTracks.value = trackRepository.getPastTracks() + _trackPoints.value = emptyList() + _isRecording.value = false + } } fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) { diff --git a/android-app/app/src/test/kotlin/org/terst/nav/track/GpxRoundTripTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/track/GpxRoundTripTest.kt new file mode 100644 index 0000000..7ed7ec7 --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/track/GpxRoundTripTest.kt @@ -0,0 +1,83 @@ +package org.terst.nav.track + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GpxRoundTripTest { + + private fun roundTrip(points: List<TrackPoint>): List<TrackPoint> { + val gpx = GpxSerializer.serialize(points, "Test Track") + return GpxParser.parse(gpx.byteInputStream()) + } + + @Test + fun `round-trip preserves lat and lon`() { + val pt = TrackPoint(lat = 37.8044, lon = -122.2712, sogKnots = 6.1, cogDeg = 247.0) + val result = roundTrip(listOf(pt)).first() + assertEquals(37.8044, result.lat, 0.00001) + assertEquals(-122.2712, result.lon, 0.00001) + } + + @Test + fun `round-trip preserves sog and cog`() { + val pt = TrackPoint(lat = 0.0, lon = 0.0, sogKnots = 5.3, cogDeg = 183.0) + val result = roundTrip(listOf(pt)).first() + assertEquals(5.3, result.sogKnots, 0.001) + assertEquals(183.0, result.cogDeg, 0.001) + } + + @Test + fun `round-trip preserves optional nav fields`() { + val pt = TrackPoint( + lat = 1.0, lon = 2.0, sogKnots = 4.0, cogDeg = 90.0, + depthMeters = 12.5, baroHpa = 1013.2, windSpeedKnots = 14.0, + windAngleDeg = 45.0, isTrueWind = true, waveHeightM = 1.2 + ) + val result = roundTrip(listOf(pt)).first() + assertEquals(12.5, result.depthMeters!!, 0.001) + assertEquals(1013.2, result.baroHpa!!, 0.001) + assertEquals(14.0, result.windSpeedKnots!!, 0.001) + assertEquals(45.0, result.windAngleDeg!!, 0.001) + assertTrue(result.isTrueWind) + assertEquals(1.2, result.waveHeightM!!, 0.001) + } + + @Test + fun `round-trip with null optional fields leaves them null`() { + val pt = TrackPoint(lat = 0.0, lon = 0.0, sogKnots = 0.0, cogDeg = 0.0) + val result = roundTrip(listOf(pt)).first() + assertNull(result.depthMeters) + assertNull(result.baroHpa) + assertNull(result.windSpeedKnots) + } + + @Test + fun `round-trip preserves multiple points in order`() { + val points = listOf( + TrackPoint(lat = 1.0, lon = 1.0, sogKnots = 1.0, cogDeg = 0.0, timestampMs = 1000L), + TrackPoint(lat = 2.0, lon = 2.0, sogKnots = 2.0, cogDeg = 90.0, timestampMs = 2000L), + TrackPoint(lat = 3.0, lon = 3.0, sogKnots = 3.0, cogDeg = 180.0, timestampMs = 3000L), + ) + val result = roundTrip(points) + assertEquals(3, result.size) + assertEquals(1.0, result[0].lat, 0.00001) + assertEquals(2.0, result[1].lat, 0.00001) + assertEquals(3.0, result[2].lat, 0.00001) + } + + @Test + fun `round-trip preserves timestamp`() { + val ts = 1712345678000L + val pt = TrackPoint(lat = 0.0, lon = 0.0, sogKnots = 0.0, cogDeg = 0.0, timestampMs = ts) + val result = roundTrip(listOf(pt)).first() + assertEquals(ts, result.timestampMs) + } + + @Test + fun `track name with special chars is escaped`() { + val gpx = GpxSerializer.serialize(emptyList(), "Track & \"Fun\" <test>") + assertTrue(gpx.contains("Track & "Fun" <test>")) + } +} diff --git a/android-app/app/src/test/kotlin/org/terst/nav/track/TrackSummaryTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/track/TrackSummaryTest.kt new file mode 100644 index 0000000..2daaf45 --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/track/TrackSummaryTest.kt @@ -0,0 +1,56 @@ +package org.terst.nav.track + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TrackSummaryTest { + + private fun pt(lat: Double, lon: Double, sog: Double = 5.0, ts: Long = 0L) = + TrackPoint(lat = lat, lon = lon, sogKnots = sog, cogDeg = 0.0, timestampMs = ts) + + @Test + fun `distance between two points one nm apart`() { + // 1 nautical mile north along the prime meridian ≈ 0.01667° latitude + val a = pt(0.0, 0.0, ts = 0L) + val b = pt(0.016667, 0.0, ts = 60_000L) + val s = summarise(listOf(a, b)) + assertEquals(1.0, s.distanceNm, 0.01) + } + + @Test + fun `duration is last minus first timestamp`() { + val points = listOf(pt(0.0, 0.0, ts = 1_000L), pt(0.0, 0.0, ts = 61_000L)) + assertEquals(60_000L, summarise(points).durationMs) + } + + @Test + fun `max sog picks highest value`() { + val points = listOf(pt(0.0, 0.0, sog = 4.0), pt(0.0, 0.0, sog = 9.2), pt(0.0, 0.0, sog = 6.0)) + assertEquals(9.2, summarise(points).maxSogKt, 0.001) + } + + @Test + fun `avg wind is null when no wind data`() { + val s = summarise(listOf(pt(0.0, 0.0))) + assertNull(s.avgWindKt) + } + + @Test + fun `avg wind averages available readings`() { + val points = listOf( + TrackPoint(0.0, 0.0, 5.0, 0.0, windSpeedKnots = 10.0), + TrackPoint(0.0, 0.0, 5.0, 0.0, windSpeedKnots = 20.0), + ) + assertEquals(15.0, summarise(points).avgWindKt!!, 0.001) + } + + @Test + fun `avg wave height averages available readings`() { + val points = listOf( + TrackPoint(0.0, 0.0, 5.0, 0.0, waveHeightM = 1.0), + TrackPoint(0.0, 0.0, 5.0, 0.0, waveHeightM = 3.0), + ) + assertEquals(2.0, summarise(points).avgWaveHeightM!!, 0.001) + } +} |
