summaryrefslogtreecommitdiff
path: root/android-app/app/src/main/kotlin/org
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src/main/kotlin/org')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt3
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/GpxParser.kt96
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/GpxSerializer.kt62
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt49
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt120
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackSummary.kt54
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt10
7 files changed, 382 insertions, 12 deletions
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("&", "&amp;")
+ .replace("<", "&lt;")
+ .replace(">", "&gt;")
+ .replace("\"", "&quot;")
+}
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) {