summaryrefslogtreecommitdiff
path: root/android-app/app/src/main/kotlin
diff options
context:
space:
mode:
authorClaude Sonnet <agent@anthropic.com>2026-03-15 13:13:34 +0000
committerClaude Sonnet <agent@anthropic.com>2026-03-15 13:13:34 +0000
commit13e4e30f351f06bda23a45b36c05970d1ef2c692 (patch)
treeb625f4d308deb8070d2790bcad794d3fe932b612 /android-app/app/src/main/kotlin
parentd1de605e28bd8ac32d73420ef60235eac4c56a50 (diff)
feat: add AIS repository, AISHub API service, and AisHubSource
- AisRepository: processes NMEA sentences, upserts by MMSI, merges type-5 static data, evicts stale - AisHubApiService + AisHubVessel: Retrofit/Moshi model for AISHub REST polling API - AisHubSource: converts AisHubVessel REST responses to AisVessel domain objects - 11 JUnit 5 tests all GREEN via /tmp/ais-repo-test-runner/ JVM harness Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app/app/src/main/kotlin')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ais/AisHubApiService.kt31
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ais/AisHubSource.kt16
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt56
3 files changed, 103 insertions, 0 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ais/AisHubApiService.kt b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisHubApiService.kt
new file mode 100644
index 0000000..9946e5c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisHubApiService.kt
@@ -0,0 +1,31 @@
+package org.terst.nav.data.api
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+@JsonClass(generateAdapter = true)
+data class AisHubVessel(
+ @Json(name = "MMSI") val mmsi: String,
+ @Json(name = "LATITUDE") val latitude: String,
+ @Json(name = "LONGITUDE") val longitude: String,
+ @Json(name = "SOG") val sog: String,
+ @Json(name = "COG") val cog: String,
+ @Json(name = "HEADING") val heading: String,
+ @Json(name = "NAME") val name: String,
+ @Json(name = "TYPE") val type: String
+)
+
+interface AisHubApiService {
+ @GET("/0/down")
+ suspend fun getVessels(
+ @Query("username") username: String,
+ @Query("password") password: String,
+ @Query("latmin") latMin: Double,
+ @Query("latmax") latMax: Double,
+ @Query("lonmin") lonMin: Double,
+ @Query("lonmax") lonMax: Double,
+ @Query("output") output: String = "json"
+ ): List<AisHubVessel>
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ais/AisHubSource.kt b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisHubSource.kt
new file mode 100644
index 0000000..955f9d2
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisHubSource.kt
@@ -0,0 +1,16 @@
+package org.terst.nav.ais
+
+import org.terst.nav.data.api.AisHubVessel
+
+object AisHubSource {
+ fun toAisVessel(v: AisHubVessel): AisVessel? {
+ val mmsi = v.mmsi.toIntOrNull() ?: return null
+ val lat = v.latitude.toDoubleOrNull() ?: return null
+ val lon = v.longitude.toDoubleOrNull() ?: return null
+ val sog = v.sog.toDoubleOrNull() ?: 0.0
+ val cog = v.cog.toDoubleOrNull() ?: 0.0
+ val heading = v.heading.toIntOrNull() ?: 511
+ val vesselType = v.type.toIntOrNull() ?: 0
+ return AisVessel(mmsi, v.name.trim(), "", lat, lon, sog, cog, heading, vesselType, System.currentTimeMillis())
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt
new file mode 100644
index 0000000..4b90c38
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt
@@ -0,0 +1,56 @@
+package org.terst.nav.ais
+
+import kotlinx.coroutines.flow.Flow
+import org.terst.nav.nmea.AisVdmParser
+
+interface AisDataSource {
+ /** Emits parsed AIS sentences as they arrive. Close the source to stop. */
+ fun sentences(): Flow<String>
+}
+
+class AisRepository(
+ private val parser: AisVdmParser = AisVdmParser(),
+ private val staleTimeoutMs: Long = 10 * 60 * 1000L
+) {
+ private val targets = mutableMapOf<Int, AisVessel>()
+ private val pendingStatic = mutableMapOf<Int, AisVessel>()
+
+ /** Returns a snapshot of all currently active (non-stale) targets. */
+ fun getTargets(): List<AisVessel> = targets.values.toList()
+
+ /** Process one raw NMEA sentence. Updates internal state. */
+ fun processSentence(sentence: String) {
+ val vessel = parser.parse(sentence) ?: return
+ if (vessel.lat == 0.0 && vessel.lon == 0.0 && vessel.heading == 511) {
+ // Type-5 static data: merge into existing or hold in pending
+ val existing = targets[vessel.mmsi]
+ if (existing != null) {
+ targets[vessel.mmsi] = existing.copy(
+ name = vessel.name,
+ callsign = vessel.callsign,
+ vesselType = vessel.vesselType
+ )
+ } else {
+ pendingStatic[vessel.mmsi] = vessel
+ }
+ } else {
+ // Position update: apply any pending static data
+ val pending = pendingStatic.remove(vessel.mmsi)
+ targets[vessel.mmsi] = if (pending != null) {
+ vessel.copy(
+ name = pending.name,
+ callsign = pending.callsign,
+ vesselType = pending.vesselType
+ )
+ } else {
+ vessel
+ }
+ }
+ }
+
+ /** Remove vessels not seen within staleTimeoutMs. */
+ fun evictStale(nowMs: Long = System.currentTimeMillis()) {
+ val threshold = nowMs - staleTimeoutMs
+ targets.entries.removeIf { it.value.timestampMs < threshold }
+ }
+}