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/MainActivity.kt38
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModel.kt109
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModelFactory.kt23
-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.kt61
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ais/AisVessel.kt14
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt45
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/AisVdmParser.kt129
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt35
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt9
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/sensors/BoatSpeedData.kt6
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt59
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/map/MapFragment.kt70
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/map_orig/MapFragment.kt167
-rw-r--r--android-app/app/src/main/res/drawable/ic_ship_arrow.xml9
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/ais/AisHubSourceTest.kt54
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/ais/AisRepositoryTest.kt167
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/ais/AisVesselTest.kt53
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/ais/CpaCalculatorTest.kt61
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/nmea/AisVdmParserTest.kt205
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelTest.kt40
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/ui_orig/LocationPermissionHandlerTest.kt110
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/ui_orig/MainViewModelTest.kt105
24 files changed, 1609 insertions, 7 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
index aa35914..a3eebfc 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
@@ -19,6 +19,7 @@ import android.widget.TextView
import android.widget.Toast
import org.terst.nav.ui.voicelog.VoiceLogFragment
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
@@ -41,6 +42,7 @@ import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.Point
import org.maplibre.geojson.Polygon
import org.maplibre.geojson.LineString
+import org.terst.nav.ui.MainViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -133,6 +135,9 @@ class MainActivity : AppCompatActivity() {
private var currentWatchCircleRadius = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS
+ // ViewModel for AIS sentence processing
+ private val viewModel: MainViewModel by viewModels()
+
// Register the permissions callback, which handles the user's response to the
// system permissions dialog.
private val requestPermissionLauncher =
@@ -150,6 +155,7 @@ class MainActivity : AppCompatActivity() {
observeLocationUpdates() // Start observing location updates
observeAnchorWatchState() // Start observing anchor watch state
observeBarometerStatus() // Start observing barometer status
+ startAisHardwareFeed()
} else {
// Permissions denied, handle the case (e.g., show a message to the user)
Toast.makeText(this, "Location permissions denied", Toast.LENGTH_LONG).show()
@@ -184,6 +190,7 @@ class MainActivity : AppCompatActivity() {
observeLocationUpdates() // Start observing location updates
observeAnchorWatchState() // Start observing anchor watch state
observeBarometerStatus() // Start observing barometer status
+ startAisHardwareFeed()
}
mapView = findViewById<MapView>(R.id.mapView)
@@ -396,6 +403,29 @@ class MainActivity : AppCompatActivity() {
stopService(intent)
}
+ /**
+ * Start reading AIS NMEA sentences from a hardware receiver over TCP.
+ * Sentences are forwarded to the ViewModel for processing.
+ * Falls back gracefully when the hardware feed is unavailable.
+ */
+ private fun startAisHardwareFeed(host: String = "localhost", port: Int = 10110) {
+ lifecycleScope.launch(Dispatchers.IO) {
+ try {
+ val socket = java.net.Socket(host, port)
+ val reader = socket.getInputStream().bufferedReader()
+ reader.lineSequence().forEach { line ->
+ if (line.startsWith("!")) {
+ withContext(Dispatchers.Main) {
+ viewModel.processAisSentence(line)
+ }
+ }
+ }
+ } catch (e: Exception) {
+ // Hardware feed unavailable — internet fallback will be used
+ }
+ }
+ }
+
private fun createMockPolarTable(): PolarTable {
// Example polar data for a hypothetical boat
// TWS 6 knots
@@ -451,10 +481,10 @@ class MainActivity : AppCompatActivity() {
// Create sources
anchorPointSource = GeoJsonSource(ANCHOR_POINT_SOURCE_ID)
anchorPointSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList<Feature>()))
-
+
anchorCircleSource = GeoJsonSource(ANCHOR_CIRCLE_SOURCE_ID)
anchorCircleSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList<Feature>()))
-
+
style.addSource(anchorPointSource!!)
style.addSource(anchorCircleSource!!)
@@ -554,8 +584,8 @@ class MainActivity : AppCompatActivity() {
val newState = !LocationService.tidalCurrentState.value.isVisible
// Since we cannot update the flow directly from MainActivity (it's owned by LocationService),
// we should ideally send an intent or use a shared state.
- // For this mock, we'll use a local update to the flow if it was a MutableStateFlow,
- // but it's a StateFlow in LocationService.
+ // For this mock, we'll use a local update to the flow if it was a MutableStateFlow,
+ // but it's a StateFlow in LocationService.
// Let's add a public update method or an action to LocationService.
val intent = Intent(this, LocationService::class.java).apply {
action = LocationService.ACTION_TOGGLE_TIDAL_VISIBILITY
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModel.kt
new file mode 100644
index 0000000..80a3250
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModel.kt
@@ -0,0 +1,109 @@
+package org.terst.nav
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.launch
+import org.terst.nav.nmea.NmeaStreamManager
+import org.terst.nav.sensors.BoatSpeedData
+import org.terst.nav.sensors.WindData
+import kotlin.math.cos
+import kotlin.math.abs
+
+class PerformanceViewModel(
+ private val nmeaStreamManager: NmeaStreamManager
+) : ViewModel() {
+
+ // Dummy PolarTable for now. In a real app, this would come from user settings/boat profile.
+ private val dummyPolarTable: PolarTable by lazy {
+ // Example polar data for a hypothetical boat
+ val curves = listOf(
+ PolarCurve(twS = 6.0, points = listOf(
+ PolarPoint(tWa = 30.0, bSp = 4.0),
+ PolarPoint(tWa = 45.0, bSp = 5.5),
+ PolarPoint(tWa = 60.0, bSp = 6.0),
+ PolarPoint(tWa = 90.0, bSp = 5.8),
+ PolarPoint(tWa = 120.0, bSp = 5.0),
+ PolarPoint(tWa = 150.0, bSp = 4.0),
+ PolarPoint(tWa = 180.0, bSp = 3.0)
+ )),
+ PolarCurve(twS = 10.0, points = listOf(
+ PolarPoint(tWa = 30.0, bSp = 5.0),
+ PolarPoint(tWa = 45.0, bSp = 7.0),
+ PolarPoint(tWa = 60.0, bSp = 7.5),
+ PolarPoint(tWa = 90.0, bSp = 7.0),
+ PolarPoint(tWa = 120.0, bSp = 6.0),
+ PolarPoint(tWa = 150.0, bSp = 5.0),
+ PolarPoint(tWa = 180.0, bSp = 4.0)
+ )),
+ PolarCurve(twS = 15.0, points = listOf(
+ PolarPoint(tWa = 30.0, bSp = 5.8),
+ PolarPoint(tWa = 45.0, bSp = 8.0),
+ PolarPoint(tWa = 60.0, bSp = 8.5),
+ PolarPoint(tWa = 90.0, bSp = 7.8),
+ PolarPoint(tWa = 120.0, bSp = 6.8),
+ PolarPoint(tWa = 150.0, bSp = 5.8),
+ PolarPoint(tWa = 180.0, bSp = 4.8)
+ ))
+ )
+ PolarTable(curves)
+ }
+
+ private val _vmg = MutableStateFlow(0.0)
+ val vmg: StateFlow<Double> = _vmg.asStateFlow()
+
+ private val _polarPercentage = MutableStateFlow(0.0)
+ val polarPercentage: StateFlow<Double> = _polarPercentage.asStateFlow()
+
+ private var latestWindData: WindData? = null
+ private var latestBoatSpeedData: BoatSpeedData? = null
+
+ init {
+ viewModelScope.launch {
+ combine(
+ nmeaStreamManager.nmeaWindData,
+ nmeaStreamManager.nmeaBoatSpeedData
+ ) { windData, boatSpeedData ->
+ latestWindData = windData
+ latestBoatSpeedData = boatSpeedData
+ calculatePerformance()
+ }.collect { /* Do nothing, combine emits Unit after processing */ }
+ }
+ }
+
+ private fun calculatePerformance() {
+ val currentWind = latestWindData
+ val currentBoatSpeed = latestBoatSpeedData
+
+ if (currentWind != null && currentBoatSpeed != null) {
+ val tws = currentWind.windSpeed
+ val twa = currentWind.windAngle
+
+ // Ensure TWA is true wind angle for VMG calculation
+ val vmgValue = if (currentWind.isTrueWind) {
+ dummyPolarTable.curves.firstOrNull()?.calculateVmg(twa, currentBoatSpeed.bspKnots) ?: 0.0
+ } else {
+ // If wind is apparent, we cannot calculate true VMG directly from BSP * cos(TWA_apparent)
+ // Need true wind angle, which would typically be derived from apparent wind, heading, and boat speed
+ // For now, if only apparent wind is available, VMG calculation will be 0.0
+ // This scenario needs a more robust solution in a production app.
+ 0.0
+ }
+ _vmg.value = vmgValue
+
+ // Polar percentage requires True Wind Speed and True Wind Angle
+ val polarPercentageValue = if (currentWind.isTrueWind) {
+ dummyPolarTable.calculatePolarPercentage(tws, twa, currentBoatSpeed.bspKnots)
+ } else {
+ 0.0 // Cannot calculate polar percentage without true wind data
+ }
+ _polarPercentage.value = polarPercentageValue
+ } else {
+ _vmg.value = 0.0
+ _polarPercentage.value = 0.0
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModelFactory.kt b/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModelFactory.kt
new file mode 100644
index 0000000..ed6d1eb
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModelFactory.kt
@@ -0,0 +1,23 @@
+package org.terst.nav
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.CoroutineScope
+import org.terst.nav.nmea.NmeaParser
+import org.terst.nav.nmea.NmeaStreamManager
+
+class PerformanceViewModelFactory : ViewModelProvider.Factory {
+ override fun <T : ViewModel> create(modelClass: Class<T>): T {
+ if (modelClass.isAssignableFrom(PerformanceViewModel::class.java)) {
+ // NmeaStreamManager will be tied to the ViewModel's lifecycle
+ val nmeaParser = NmeaParser()
+ // We'll pass the ViewModel's own viewModelScope to NmeaStreamManager
+ // The actual CoroutineScope passed here will be the one associated with the ViewModel
+ val nmeaStreamManager = NmeaStreamManager(nmeaParser, CoroutineScope(modelClass.kotlin.viewModelScope.coroutineContext))
+ @Suppress("UNCHECKED_CAST")
+ return PerformanceViewModel(nmeaStreamManager) as T
+ }
+ throw IllegalArgumentException("Unknown ViewModel class")
+ }
+}
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..75b1feb
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt
@@ -0,0 +1,61 @@
+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
+ }
+ }
+ }
+
+ /** Directly insert a vessel obtained from an internet source (e.g. AISHub). */
+ fun ingestVessel(vessel: AisVessel) {
+ targets[vessel.mmsi] = vessel
+ }
+
+ /** Remove vessels not seen within staleTimeoutMs. */
+ fun evictStale(nowMs: Long = System.currentTimeMillis()) {
+ val threshold = nowMs - staleTimeoutMs
+ targets.entries.removeIf { it.value.timestampMs < threshold }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ais/AisVessel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisVessel.kt
new file mode 100644
index 0000000..34a951c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisVessel.kt
@@ -0,0 +1,14 @@
+package org.terst.nav.ais
+
+data class AisVessel(
+ val mmsi: Int,
+ val name: String,
+ val callsign: String,
+ val lat: Double,
+ val lon: Double,
+ val sog: Double,
+ val cog: Double,
+ val heading: Int,
+ val vesselType: Int,
+ val timestampMs: Long
+) \ No newline at end of file
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt b/android-app/app/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt
new file mode 100644
index 0000000..298af59
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt
@@ -0,0 +1,45 @@
+package org.terst.nav.ais
+
+import kotlin.math.cos
+import kotlin.math.sqrt
+
+object CpaCalculator {
+ fun compute(
+ ownLat: Double, ownLon: Double, ownSog: Double, ownCog: Double,
+ tgtLat: Double, tgtLon: Double, tgtSog: Double, tgtCog: Double
+ ): Pair<Double, Double> {
+ val refLat = Math.toRadians((ownLat + tgtLat) / 2.0)
+
+ // Flat-earth positions in nautical miles
+ val ownX = ownLon * 60.0 * cos(refLat)
+ val ownY = ownLat * 60.0
+ val tgtX = tgtLon * 60.0 * cos(refLat)
+ val tgtY = tgtLat * 60.0
+
+ // Velocities in nm/min
+ val ownCogRad = Math.toRadians(ownCog)
+ val tgtCogRad = Math.toRadians(tgtCog)
+ val ownVx = ownSog * Math.sin(ownCogRad) / 60.0
+ val ownVy = ownSog * Math.cos(ownCogRad) / 60.0
+ val tgtVx = tgtSog * Math.sin(tgtCogRad) / 60.0
+ val tgtVy = tgtSog * Math.cos(tgtCogRad) / 60.0
+
+ val dx = tgtX - ownX
+ val dy = tgtY - ownY
+ val dvx = tgtVx - ownVx
+ val dvy = tgtVy - ownVy
+
+ val dv2 = dvx * dvx + dvy * dvy
+ if (dv2 < 1e-9) {
+ val currentDist = sqrt(dx * dx + dy * dy)
+ return Pair(currentDist, 0.0)
+ }
+
+ val tcpa = -(dx * dvx + dy * dvy) / dv2
+ val cpaX = dx + dvx * tcpa
+ val cpaY = dy + dvy * tcpa
+ val cpa = sqrt(cpaX * cpaX + cpaY * cpaY)
+
+ return Pair(cpa, tcpa)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/nmea/AisVdmParser.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/AisVdmParser.kt
new file mode 100644
index 0000000..9709d21
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/AisVdmParser.kt
@@ -0,0 +1,129 @@
+package org.terst.nav.nmea
+
+import org.terst.nav.ais.AisVessel
+
+class AisVdmParser {
+ // Keyed by seqId -> list of (seq, payload)
+ private val fragments = mutableMapOf<String, MutableList<Pair<Int, String>>>()
+
+ fun parse(sentence: String): AisVessel? {
+ if (!sentence.startsWith("!AIVDM") && !sentence.startsWith("!AIVDO")) return null
+
+ val withoutBang = sentence.drop(1)
+ val starIdx = withoutBang.indexOf('*')
+ val body = if (starIdx >= 0) withoutBang.substring(0, starIdx) else withoutBang
+
+ val fields = body.split(",")
+ if (fields.size < 7) return null
+
+ val count = fields[1].toIntOrNull() ?: return null
+ val seq = fields[2].toIntOrNull() ?: return null
+ val seqId = fields[3]
+ val payload = fields[5]
+ val padding = fields[6].toIntOrNull() ?: 0
+
+ val combinedPayload: String
+ if (count <= 1) {
+ combinedPayload = payload
+ } else {
+ val list = fragments.getOrPut(seqId) { mutableListOf() }
+ list.add(seq to payload)
+ if (list.size < count) return null
+ fragments.remove(seqId)
+ combinedPayload = list.sortedBy { it.first }.joinToString("") { it.second }
+ }
+
+ val bits = decodeToBits(combinedPayload, padding)
+ if (bits.size < 6) return null
+
+ return when (extractUInt(bits, 0, 6)) {
+ 1, 2, 3 -> parseType123(bits)
+ 5 -> parseType5(bits)
+ else -> null
+ }
+ }
+
+ private fun decodeToBits(payload: String, padding: Int): IntArray {
+ val allBits = ArrayList<Int>(payload.length * 6)
+ for (c in payload) {
+ var v = c.code - 48
+ if (v > 39) v -= 8
+ for (b in 5 downTo 0) allBits.add((v ushr b) and 1)
+ }
+ return if (padding > 0 && padding < allBits.size)
+ allBits.dropLast(padding).toIntArray()
+ else
+ allBits.toIntArray()
+ }
+
+ private fun extractUInt(bits: IntArray, start: Int, len: Int): Int {
+ var v = 0
+ for (i in 0 until len) {
+ val bit = if (start + i < bits.size) bits[start + i] else 0
+ v = (v shl 1) or bit
+ }
+ return v
+ }
+
+ private fun extractInt(bits: IntArray, start: Int, len: Int): Int {
+ val unsigned = extractUInt(bits, start, len)
+ return if (len > 0 && (unsigned and (1 shl (len - 1))) != 0)
+ unsigned - (1 shl len)
+ else
+ unsigned
+ }
+
+ private fun parseType123(bits: IntArray): AisVessel? {
+ if (bits.size < 137) return null
+ val mmsi = extractUInt(bits, 8, 30)
+ val sogRaw = extractUInt(bits, 50, 10)
+ val sog = if (sogRaw == 1023) 0.0 else sogRaw / 10.0
+ val lonRaw = extractInt(bits, 61, 28)
+ val lon = if (lonRaw == 0x6791AC0) 0.0 else lonRaw / 600000.0
+ val latRaw = extractInt(bits, 89, 27)
+ val lat = if (latRaw == 0x3412140) 0.0 else latRaw / 600000.0
+ val cogRaw = extractUInt(bits, 116, 12)
+ val cog = if (cogRaw == 3600) 0.0 else cogRaw / 10.0
+ val heading = extractUInt(bits, 128, 9)
+ return AisVessel(
+ mmsi = mmsi,
+ name = "",
+ callsign = "",
+ lat = lat,
+ lon = lon,
+ sog = sog,
+ cog = cog,
+ heading = heading,
+ vesselType = 0,
+ timestampMs = System.currentTimeMillis()
+ )
+ }
+
+ private fun parseType5(bits: IntArray): AisVessel? {
+ if (bits.size < 240) return null
+ val mmsi = extractUInt(bits, 8, 30)
+ val callsign = decodeText(bits, 70, 7).trimEnd('@', ' ')
+ val name = decodeText(bits, 112, 20).trimEnd('@', ' ')
+ val vesselType = extractUInt(bits, 232, 8)
+ return AisVessel(
+ mmsi = mmsi,
+ name = name,
+ callsign = callsign,
+ lat = 0.0, lon = 0.0, sog = 0.0, cog = 0.0,
+ heading = 511,
+ vesselType = vesselType,
+ timestampMs = System.currentTimeMillis()
+ )
+ }
+
+ // AIS 6-bit text: bits<32 -> bits+64 (maps to @,A-Z,...), else bits (maps to space,digits,symbols)
+ private fun decodeText(bits: IntArray, start: Int, nChars: Int): String {
+ val sb = StringBuilder(nChars)
+ for (i in 0 until nChars) {
+ val v = extractUInt(bits, start + i * 6, 6)
+ val c = if (v < 32) v + 64 else v
+ sb.append(c.toChar())
+ }
+ return sb.toString()
+ }
+} \ No newline at end of file
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
index 27d9c2c..453c758 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
@@ -1,6 +1,7 @@
package org.terst.nav.nmea
import org.terst.nav.gps.GpsPosition
+import org.terst.nav.sensors.BoatSpeedData
import org.terst.nav.sensors.DepthData
import org.terst.nav.sensors.HeadingData
import org.terst.nav.sensors.WindData
@@ -211,11 +212,45 @@ class NmeaParser {
"MWV" -> parseMwv(sentence)
"DBT" -> parseDbt(sentence)
"HDG", "HDM" -> parseHdg(sentence)
+ "VHW" -> parseVhw(sentence)
else -> null
}
}
/**
+ * Parses an NMEA VHW sentence (Water speed and Heading) and returns a [BoatSpeedData],
+ * or null if the sentence is malformed or cannot be parsed.
+ *
+ * Example: $IIVHW,,,2.1,N,,,*0A
+ * Fields:
+ * 1: Degrees True
+ * 2: T
+ * 3: Degrees Magnetic
+ * 4: M
+ * 5: Speed, knots, water
+ * 6: N = Knots
+ * 7: Speed, km/hr, water
+ * 8: K = km/hr
+ * (Checksum)
+ */
+ fun parseVhw(sentence: String): BoatSpeedData? {
+ if (sentence.isBlank()) return null
+
+ val body = if ('*' in sentence) sentence.substringBefore('*') else sentence
+ val fields = body.split(',')
+ if (fields.size < 6) return null // Minimum fields for speed in knots
+
+ if (!fields[0].endsWith("VHW")) return null
+
+ val bspKnots = fields.getOrNull(4)?.toDoubleOrNull() ?: return null
+ if (fields.getOrNull(5) != "N") return null // Ensure units are knots
+
+ val timestampMs = System.currentTimeMillis() // Use current time for now
+
+ return BoatSpeedData(bspKnots, timestampMs)
+ }
+
+ /**
* Combines NMEA time (HHMMSS.ss) and date (DDMMYY) into a Unix epoch milliseconds value.
* Returns 0 on any parse failure.
*/
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt
index 4298f0d..981b32e 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.terst.nav.gps.GpsPosition
+import org.terst.nav.sensors.BoatSpeedData
import org.terst.nav.sensors.DepthData
import org.terst.nav.sensors.HeadingData
import org.terst.nav.sensors.WindData
@@ -57,6 +58,13 @@ class NmeaStreamManager(
)
val nmeaHeadingData: SharedFlow<HeadingData> = _nmeaHeadingData.asSharedFlow()
+ private val _nmeaBoatSpeedData = MutableSharedFlow<BoatSpeedData>(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val nmeaBoatSpeedData: SharedFlow<BoatSpeedData> = _nmeaBoatSpeedData.asSharedFlow()
+
fun start(address: String, port: Int) {
if (connectionJob?.isActive == true) {
Log.d(TAG, "NMEA stream already running.")
@@ -85,6 +93,7 @@ class NmeaStreamManager(
is WindData -> _nmeaWindData.emit(parsedData)
is DepthData -> _nmeaDepthData.emit(parsedData)
is HeadingData -> _nmeaHeadingData.emit(parsedData)
+ is BoatSpeedData -> _nmeaBoatSpeedData.emit(parsedData)
else -> Log.w(TAG, "Unknown parsed NMEA data type: ${parsedData::class.simpleName}")
}
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/sensors/BoatSpeedData.kt b/android-app/app/src/main/kotlin/org/terst/nav/sensors/BoatSpeedData.kt
new file mode 100644
index 0000000..9bdcbb3
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/sensors/BoatSpeedData.kt
@@ -0,0 +1,6 @@
+package org.terst.nav.sensors
+
+data class BoatSpeedData(
+ val bspKnots: Double,
+ val timestampMs: Long
+)
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 53d02fd..8e84e1e 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
@@ -2,13 +2,22 @@ package org.terst.nav.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import org.terst.nav.ais.AisHubSource
+import org.terst.nav.ais.AisRepository
+import org.terst.nav.ais.AisVessel
+import org.terst.nav.data.api.AisHubApiService
import org.terst.nav.data.model.ForecastItem
import org.terst.nav.data.model.WindArrow
import org.terst.nav.data.repository.WeatherRepository
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
sealed class UiState {
object Loading : UiState()
@@ -29,6 +38,23 @@ class MainViewModel(
private val _forecast = MutableStateFlow<List<ForecastItem>>(emptyList())
val forecast: StateFlow<List<ForecastItem>> = _forecast
+ private val _aisTargets = MutableStateFlow<List<AisVessel>>(emptyList())
+ val aisTargets: StateFlow<List<AisVessel>> = _aisTargets.asStateFlow()
+
+ private val aisRepository = AisRepository()
+
+ private val aisHubApi: AisHubApiService by lazy {
+ Retrofit.Builder()
+ .baseUrl("https://data.aishub.net")
+ .addConverterFactory(
+ MoshiConverterFactory.create(
+ Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
+ )
+ )
+ .build()
+ .create(AisHubApiService::class.java)
+ }
+
/**
* Fetch weather and marine data for [lat]/[lon] in parallel.
* Called once the device location is known.
@@ -60,4 +86,37 @@ class MainViewModel(
}
}
}
+
+ /**
+ * Process a single NMEA sentence from the hardware AIS receiver.
+ * Call this from MainActivity when bytes arrive from the TCP socket.
+ */
+ fun processAisSentence(sentence: String) {
+ aisRepository.processSentence(sentence)
+ aisRepository.evictStale()
+ _aisTargets.value = aisRepository.getTargets()
+ }
+
+ /**
+ * Refresh AIS targets from AISHub for the given bounding box.
+ * When username is empty, skips silently — hardware feed is primary.
+ */
+ fun refreshAisFromInternet(
+ latMin: Double, latMax: Double, lonMin: Double, lonMax: Double,
+ username: String = "", password: String = ""
+ ) {
+ if (username.isEmpty()) return
+ viewModelScope.launch {
+ try {
+ val vessels = aisHubApi.getVessels(username, password, latMin, latMax, lonMin, lonMax)
+ vessels.forEach { v ->
+ val av = AisHubSource.toAisVessel(v)
+ if (av != null) aisRepository.ingestVessel(av)
+ }
+ _aisTargets.value = aisRepository.getTargets()
+ } catch (e: Exception) {
+ // Log and ignore — hardware feed is primary
+ }
+ }
+ }
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/map/MapFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/MapFragment.kt
index ea7b596..ec3c927 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/map/MapFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/MapFragment.kt
@@ -13,6 +13,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import org.terst.nav.R
+import org.terst.nav.ais.AisVessel
import org.terst.nav.data.model.WindArrow
import org.terst.nav.databinding.FragmentMapBinding
import org.terst.nav.ui.MainViewModel
@@ -57,6 +58,7 @@ class MapFragment : Fragment() {
mapLibreMap = map
map.setStyle(Style.Builder().fromUri(MAP_STYLE_URL)) { style ->
addWindArrowImage(style)
+ addShipArrowImage(style)
observeViewModel(style)
}
}
@@ -82,6 +84,11 @@ class MapFragment : Fragment() {
}
}
}
+ launch {
+ viewModel.aisTargets.collect { vessels ->
+ updateAisLayer(style, vessels)
+ }
+ }
}
}
}
@@ -100,6 +107,20 @@ class MapFragment : Fragment() {
style.addImage(WIND_ARROW_ICON, bitmap)
}
+ private fun addShipArrowImage(style: Style) {
+ val drawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_ship_arrow)
+ ?: return
+ val bitmap = Bitmap.createBitmap(
+ drawable.intrinsicWidth.coerceAtLeast(24),
+ drawable.intrinsicHeight.coerceAtLeast(24),
+ Bitmap.Config.ARGB_8888
+ )
+ val canvas = Canvas(bitmap)
+ drawable.setBounds(0, 0, canvas.width, canvas.height)
+ drawable.draw(canvas)
+ style.addImage(SHIP_ARROW_ICON, bitmap)
+ }
+
private fun updateWindLayer(style: Style, arrow: WindArrow) {
val feature = Feature.fromGeometry(
Point.fromLngLat(arrow.lon, arrow.lat)
@@ -134,6 +155,46 @@ class MapFragment : Fragment() {
}
}
+ private fun updateAisLayer(style: Style, vessels: List<AisVessel>) {
+ val features = vessels.map { vessel ->
+ val displayHeading = if (vessel.heading != 511) vessel.heading.toFloat() else vessel.cog.toFloat()
+ Feature.fromGeometry(Point.fromLngLat(vessel.lon, vessel.lat)).also { f ->
+ f.addNumberProperty("heading", displayHeading)
+ f.addStringProperty("name", vessel.name)
+ f.addNumberProperty("mmsi", vessel.mmsi)
+ f.addNumberProperty("sog", vessel.sog)
+ }
+ }
+ val collection = FeatureCollection.fromFeatures(features)
+
+ if (style.getSource(AIS_SOURCE_ID) == null) {
+ style.addSource(GeoJsonSource(AIS_SOURCE_ID, collection))
+ } else {
+ (style.getSource(AIS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(collection)
+ }
+
+ if (style.getLayer(AIS_LAYER_ID) == null) {
+ val layer = SymbolLayer(AIS_LAYER_ID, AIS_SOURCE_ID).withProperties(
+ PropertyFactory.iconImage(SHIP_ARROW_ICON),
+ PropertyFactory.iconRotate(Expression.get("heading")),
+ PropertyFactory.iconRotationAlignment("map"),
+ PropertyFactory.iconAllowOverlap(true),
+ PropertyFactory.iconSize(0.8f),
+ PropertyFactory.textField(
+ Expression.step(
+ Expression.zoom(),
+ Expression.literal(""),
+ Expression.stop(12.0, Expression.get("name"))
+ )
+ ),
+ PropertyFactory.textSize(11f),
+ PropertyFactory.textOffset(arrayOf(0f, 1.5f)),
+ PropertyFactory.textAllowOverlap(false)
+ )
+ style.addLayer(layer)
+ }
+ }
+
private fun centerMapOn(lat: Double, lon: Double) {
mapLibreMap?.cameraPosition = CameraPosition.Builder()
.target(LatLng(lat, lon))
@@ -159,9 +220,12 @@ class MapFragment : Fragment() {
}
companion object {
- private const val MAP_STYLE_URL = "https://demotiles.maplibre.org/style.json"
- private const val WIND_SOURCE_ID = "wind-source"
- private const val WIND_LAYER_ID = "wind-arrows"
+ private const val MAP_STYLE_URL = "https://demotiles.maplibre.org/style.json"
+ private const val WIND_SOURCE_ID = "wind-source"
+ private const val WIND_LAYER_ID = "wind-arrows"
private const val WIND_ARROW_ICON = "wind-arrow"
+ private const val AIS_SOURCE_ID = "ais-vessels-source"
+ private const val AIS_LAYER_ID = "ais-vessels"
+ private const val SHIP_ARROW_ICON = "ship-arrow"
}
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/map_orig/MapFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/map_orig/MapFragment.kt
new file mode 100644
index 0000000..ea7b596
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/map_orig/MapFragment.kt
@@ -0,0 +1,167 @@
+package org.terst.nav.ui.map
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import org.terst.nav.R
+import org.terst.nav.data.model.WindArrow
+import org.terst.nav.databinding.FragmentMapBinding
+import org.terst.nav.ui.MainViewModel
+import org.terst.nav.ui.UiState
+import kotlinx.coroutines.launch
+import org.maplibre.android.MapLibre
+import org.maplibre.android.camera.CameraPosition
+import org.maplibre.android.geometry.LatLng
+import org.maplibre.android.maps.MapLibreMap
+import org.maplibre.android.maps.Style
+import org.maplibre.android.style.expressions.Expression
+import org.maplibre.android.style.layers.PropertyFactory
+import org.maplibre.android.style.layers.SymbolLayer
+import org.maplibre.android.style.sources.GeoJsonSource
+import org.maplibre.geojson.Feature
+import org.maplibre.geojson.FeatureCollection
+import org.maplibre.geojson.Point
+
+class MapFragment : Fragment() {
+
+ private var _binding: FragmentMapBinding? = null
+ private val binding get() = _binding!!
+
+ private val viewModel: MainViewModel by activityViewModels()
+
+ private var mapLibreMap: MapLibreMap? = null
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ MapLibre.getInstance(requireContext())
+ _binding = FragmentMapBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.mapView.onCreate(savedInstanceState)
+ binding.mapView.getMapAsync { map ->
+ mapLibreMap = map
+ map.setStyle(Style.Builder().fromUri(MAP_STYLE_URL)) { style ->
+ addWindArrowImage(style)
+ observeViewModel(style)
+ }
+ }
+ }
+
+ private fun observeViewModel(style: Style) {
+ viewLifecycleOwner.lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ launch {
+ viewModel.uiState.collect { state ->
+ binding.statusText.visibility = when (state) {
+ UiState.Loading -> View.VISIBLE.also { binding.statusText.text = getString(R.string.loading_weather) }
+ UiState.Success -> View.GONE
+ is UiState.Error -> View.VISIBLE.also { binding.statusText.text = state.message }
+ }
+ }
+ }
+ launch {
+ viewModel.windArrow.collect { arrow ->
+ if (arrow != null) {
+ updateWindLayer(style, arrow)
+ centerMapOn(arrow.lat, arrow.lon)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun addWindArrowImage(style: Style) {
+ val drawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_wind_arrow)
+ ?: return
+ val bitmap = Bitmap.createBitmap(
+ drawable.intrinsicWidth.coerceAtLeast(24),
+ drawable.intrinsicHeight.coerceAtLeast(24),
+ Bitmap.Config.ARGB_8888
+ )
+ val canvas = Canvas(bitmap)
+ drawable.setBounds(0, 0, canvas.width, canvas.height)
+ drawable.draw(canvas)
+ style.addImage(WIND_ARROW_ICON, bitmap)
+ }
+
+ private fun updateWindLayer(style: Style, arrow: WindArrow) {
+ val feature = Feature.fromGeometry(
+ Point.fromLngLat(arrow.lon, arrow.lat)
+ ).also { f ->
+ f.addNumberProperty("direction", arrow.directionDeg)
+ f.addNumberProperty("speed_kt", arrow.speedKt)
+ }
+ val collection = FeatureCollection.fromFeature(feature)
+
+ if (style.getSource(WIND_SOURCE_ID) == null) {
+ style.addSource(GeoJsonSource(WIND_SOURCE_ID, collection))
+ } else {
+ (style.getSource(WIND_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(collection)
+ }
+
+ if (style.getLayer(WIND_LAYER_ID) == null) {
+ val layer = SymbolLayer(WIND_LAYER_ID, WIND_SOURCE_ID).withProperties(
+ PropertyFactory.iconImage(WIND_ARROW_ICON),
+ PropertyFactory.iconRotate(Expression.get("direction")),
+ PropertyFactory.iconRotationAlignment("map"),
+ PropertyFactory.iconAllowOverlap(true),
+ PropertyFactory.iconSize(
+ Expression.interpolate(
+ Expression.linear(),
+ Expression.get("speed_kt"),
+ Expression.stop(0, 0.6f),
+ Expression.stop(30, 1.4f)
+ )
+ )
+ )
+ style.addLayer(layer)
+ }
+ }
+
+ private fun centerMapOn(lat: Double, lon: Double) {
+ mapLibreMap?.cameraPosition = CameraPosition.Builder()
+ .target(LatLng(lat, lon))
+ .zoom(7.0)
+ .build()
+ }
+
+ // Lifecycle delegation to MapView
+ override fun onStart() { super.onStart(); binding.mapView.onStart() }
+ override fun onResume() { super.onResume(); binding.mapView.onResume() }
+ override fun onPause() { super.onPause(); binding.mapView.onPause() }
+ override fun onStop() { super.onStop(); binding.mapView.onStop() }
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ binding.mapView.onSaveInstanceState(outState)
+ }
+ override fun onLowMemory() { super.onLowMemory(); binding.mapView.onLowMemory() }
+
+ override fun onDestroyView() {
+ binding.mapView.onDestroy()
+ super.onDestroyView()
+ _binding = null
+ }
+
+ companion object {
+ private const val MAP_STYLE_URL = "https://demotiles.maplibre.org/style.json"
+ private const val WIND_SOURCE_ID = "wind-source"
+ private const val WIND_LAYER_ID = "wind-arrows"
+ private const val WIND_ARROW_ICON = "wind-arrow"
+ }
+}
diff --git a/android-app/app/src/main/res/drawable/ic_ship_arrow.xml b/android-app/app/src/main/res/drawable/ic_ship_arrow.xml
new file mode 100644
index 0000000..68e8667
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/ic_ship_arrow.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="#FF4081"
+ android:pathData="M12,2 L17,20 L12,17 L7,20 Z"/>
+</vector>
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ais/AisHubSourceTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ais/AisHubSourceTest.kt
new file mode 100644
index 0000000..6f09d68
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/ais/AisHubSourceTest.kt
@@ -0,0 +1,54 @@
+package org.terst.nav.ais
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.*
+import org.terst.nav.data.api.AisHubVessel
+
+class AisHubSourceTest {
+
+ private fun makeHubVessel(
+ mmsi: String = "123456789",
+ latitude: String = "37.0",
+ longitude: String = "-122.0",
+ sog: String = "5.0",
+ cog: String = "270.0",
+ heading: String = "270",
+ name: String = "TEST VESSEL",
+ type: String = "36"
+ ) = AisHubVessel(mmsi, latitude, longitude, sog, cog, heading, name, type)
+
+ @Test
+ fun `valid AisHubVessel maps to AisVessel with correct fields`() {
+ val hub = makeHubVessel()
+ val vessel = AisHubSource.toAisVessel(hub)
+ assertNotNull(vessel)
+ assertEquals(123456789, vessel!!.mmsi)
+ assertEquals(37.0, vessel.lat, 0.001)
+ assertEquals(-122.0, vessel.lon, 0.001)
+ assertEquals(5.0, vessel.sog, 0.001)
+ assertEquals(270.0, vessel.cog, 0.001)
+ assertEquals(270, vessel.heading)
+ assertEquals("TEST VESSEL", vessel.name)
+ assertEquals(36, vessel.vesselType)
+ }
+
+ @Test
+ fun `non-numeric MMSI returns null`() {
+ val hub = makeHubVessel(mmsi = "ABCXYZ")
+ assertNull(AisHubSource.toAisVessel(hub))
+ }
+
+ @Test
+ fun `non-numeric latitude returns null`() {
+ val hub = makeHubVessel(latitude = "N/A")
+ assertNull(AisHubSource.toAisVessel(hub))
+ }
+
+ @Test
+ fun `heading 511 passthrough`() {
+ val hub = makeHubVessel(heading = "511")
+ val vessel = AisHubSource.toAisVessel(hub)
+ assertNotNull(vessel)
+ assertEquals(511, vessel!!.heading)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ais/AisRepositoryTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ais/AisRepositoryTest.kt
new file mode 100644
index 0000000..2049c2f
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/ais/AisRepositoryTest.kt
@@ -0,0 +1,167 @@
+package org.terst.nav.ais
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.*
+
+// ── Encoding helpers (mirrors AisVdmParser decode logic) ──────────────────────
+
+private fun encodeBits(bits: IntArray): String {
+ val padded = if (bits.size % 6 == 0) bits else bits + IntArray(6 - bits.size % 6)
+ val sb = StringBuilder()
+ var i = 0
+ while (i < padded.size) {
+ var v = 0
+ for (b in 0 until 6) v = (v shl 1) or padded[i + b]
+ val c = if (v <= 39) v + 48 else v + 56
+ sb.append(c.toChar())
+ i += 6
+ }
+ return sb.toString()
+}
+
+private fun intToBits(value: Int, len: Int): IntArray {
+ val arr = IntArray(len)
+ for (i in 0 until len) arr[i] = (value ushr (len - 1 - i)) and 1
+ return arr
+}
+
+private fun signedToBits(value: Int, len: Int): IntArray {
+ val mask = if (len < 32) (1 shl len) - 1 else -1
+ return intToBits(value and mask, len)
+}
+
+private fun buildType1Payload(
+ mmsi: Int, sog10: Int = 50,
+ lon600000: Int = (-122_000_000),
+ lat600000: Int = (37_000_000),
+ cog10: Int = 2700, heading: Int = 270
+): String {
+ val bits = IntArray(168)
+ fun set(start: Int, arr: IntArray) { arr.copyInto(bits, start) }
+ set(0, intToBits(1, 6))
+ set(6, intToBits(0, 2))
+ set(8, intToBits(mmsi, 30))
+ set(38, intToBits(0, 12))
+ set(50, intToBits(sog10, 10))
+ set(60, intToBits(0, 1))
+ set(61, signedToBits(lon600000, 28))
+ set(89, signedToBits(lat600000, 27))
+ set(116, intToBits(cog10, 12))
+ set(128, intToBits(heading, 9))
+ return encodeBits(bits)
+}
+
+private fun buildType5Payload(mmsi: Int, callsign: String, name: String, vesselType: Int): String {
+ val bits = IntArray(426)
+ fun set(start: Int, arr: IntArray) { arr.copyInto(bits, start) }
+ set(0, intToBits(5, 6))
+ set(6, intToBits(0, 2))
+ set(8, intToBits(mmsi, 30))
+ set(38, intToBits(0, 2))
+ set(40, intToBits(0, 30))
+ val csFixed = callsign.padEnd(7, '@').take(7)
+ for (i in 0 until 7) {
+ val c = csFixed[i].code
+ set(70 + i * 6, intToBits(if (c >= 64) c - 64 else c, 6))
+ }
+ val nameFixed = name.padEnd(20, '@').take(20)
+ for (i in 0 until 20) {
+ val c = nameFixed[i].code
+ set(112 + i * 6, intToBits(if (c >= 64) c - 64 else c, 6))
+ }
+ set(232, intToBits(vesselType, 8))
+ return encodeBits(bits)
+}
+
+private fun nmea0183Checksum(body: String): String {
+ var xor = 0
+ for (c in body) xor = xor xor c.code
+ return xor.toString(16).uppercase().padStart(2, '0')
+}
+
+private fun makeVdm(payload: String, padding: Int = 0): String {
+ val body = "AIVDM,1,1,,A,$payload,$padding"
+ return "!$body*${nmea0183Checksum(body)}"
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+class AisRepositoryTest {
+
+ private fun type1Sentence(mmsi: Int) = makeVdm(buildType1Payload(mmsi))
+ private fun type5Sentence(mmsi: Int, callsign: String, name: String, vesselType: Int = 0) =
+ makeVdm(buildType5Payload(mmsi, callsign, name, vesselType))
+
+ @Test
+ fun `processSentence with type-1 adds vessel to targets`() {
+ val repo = AisRepository()
+ repo.processSentence(type1Sentence(123456789))
+ assertEquals(1, repo.getTargets().size)
+ }
+
+ @Test
+ fun `processSentence same MMSI twice updates position and keeps 1 target`() {
+ val repo = AisRepository()
+ val sentence = type1Sentence(123456789)
+ repo.processSentence(sentence)
+ repo.processSentence(sentence)
+ assertEquals(1, repo.getTargets().size)
+ }
+
+ @Test
+ fun `processSentence type-5 for existing MMSI merges name callsign and vesselType`() {
+ val repo = AisRepository()
+ repo.processSentence(type1Sentence(999001))
+ repo.processSentence(type5Sentence(999001, "W1ABC", "MY VESSEL", 36))
+ val targets = repo.getTargets()
+ assertEquals(1, targets.size)
+ assertEquals("MY VESSEL", targets[0].name)
+ assertEquals("W1ABC", targets[0].callsign)
+ assertEquals(36, targets[0].vesselType)
+ }
+
+ @Test
+ fun `processSentence type-5 before position applies pending static data when position arrives`() {
+ val repo = AisRepository()
+ repo.processSentence(type5Sentence(999002, "CALL", "PENDING VESSEL", 60))
+ assertEquals(0, repo.getTargets().size)
+ repo.processSentence(type1Sentence(999002))
+ val targets = repo.getTargets()
+ assertEquals(1, targets.size)
+ assertEquals("PENDING VESSEL", targets[0].name)
+ assertEquals("CALL", targets[0].callsign)
+ assertEquals(60, targets[0].vesselType)
+ }
+
+ @Test
+ fun `evictStale removes old targets and keeps recent ones`() {
+ val repo = AisRepository(staleTimeoutMs = 60_000L)
+ repo.processSentence(type1Sentence(111))
+ repo.processSentence(type1Sentence(222))
+ // Evict far into the future: all vessels are stale
+ repo.evictStale(System.currentTimeMillis() + 120_000L)
+ assertEquals(0, repo.getTargets().size)
+ // Add a fresh vessel and evict at now: it should survive
+ repo.processSentence(type1Sentence(333))
+ repo.evictStale(System.currentTimeMillis())
+ assertEquals(1, repo.getTargets().size)
+ }
+
+ @Test
+ fun `processSentence with non-AIS sentence does not change targets`() {
+ val repo = AisRepository()
+ repo.processSentence("\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A")
+ assertEquals(0, repo.getTargets().size)
+ }
+
+ @Test
+ fun `two different MMSIs both appear in getTargets`() {
+ val repo = AisRepository()
+ repo.processSentence(type1Sentence(111111111))
+ repo.processSentence(type1Sentence(222222222))
+ assertEquals(2, repo.getTargets().size)
+ val mmsis = repo.getTargets().map { it.mmsi }.toSet()
+ assertTrue(111111111 in mmsis)
+ assertTrue(222222222 in mmsis)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ais/AisVesselTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ais/AisVesselTest.kt
new file mode 100644
index 0000000..a34a733
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/ais/AisVesselTest.kt
@@ -0,0 +1,53 @@
+package org.terst.nav.ais
+
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+
+class AisVesselTest {
+
+ @Test
+ fun `holds all fields correctly`() {
+ val vessel = AisVessel(
+ mmsi = 123456789,
+ name = "MY VESSEL",
+ callsign = "W1ABC",
+ lat = 37.5,
+ lon = -122.0,
+ sog = 5.5,
+ cog = 270.0,
+ heading = 269,
+ vesselType = 36,
+ timestampMs = 1000L
+ )
+ assertEquals(123456789, vessel.mmsi)
+ assertEquals("MY VESSEL", vessel.name)
+ assertEquals("W1ABC", vessel.callsign)
+ assertEquals(37.5, vessel.lat)
+ assertEquals(-122.0, vessel.lon)
+ assertEquals(5.5, vessel.sog)
+ assertEquals(270.0, vessel.cog)
+ assertEquals(269, vessel.heading)
+ assertEquals(36, vessel.vesselType)
+ assertEquals(1000L, vessel.timestampMs)
+ }
+
+ @Test
+ fun `equality based on all fields`() {
+ val v1 = AisVessel(1, "A", "B", 0.0, 0.0, 0.0, 0.0, 0, 0, 100L)
+ val v2 = AisVessel(1, "A", "B", 0.0, 0.0, 0.0, 0.0, 0, 0, 100L)
+ assertEquals(v1, v2)
+ }
+
+ @Test
+ fun `inequality when mmsi differs`() {
+ val v1 = AisVessel(1, "A", "B", 0.0, 0.0, 0.0, 0.0, 0, 0, 100L)
+ val v2 = AisVessel(2, "A", "B", 0.0, 0.0, 0.0, 0.0, 0, 0, 100L)
+ assertNotEquals(v1, v2)
+ }
+
+ @Test
+ fun `heading 511 means not available`() {
+ val vessel = AisVessel(1, "", "", 0.0, 0.0, 0.0, 0.0, 511, 0, 0L)
+ assertEquals(511, vessel.heading)
+ }
+} \ No newline at end of file
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ais/CpaCalculatorTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ais/CpaCalculatorTest.kt
new file mode 100644
index 0000000..38069ec
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/ais/CpaCalculatorTest.kt
@@ -0,0 +1,61 @@
+package org.terst.nav.ais
+
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import kotlin.math.abs
+
+class CpaCalculatorTest {
+
+ private val eps = 0.01 // 0.01 nm tolerance for position, 0.01 min for time
+
+ @Test
+ fun `head-on vessels converging - TCPA around 0_25 min CPA near zero`() {
+ // Own: (0,0) moving North at 10kt
+ // Target: (0, 0.0833 nm N ~= 0.0833/60 deg lat) moving South at 10kt
+ // They meet in the middle ~= 0.25 min
+ val tgtLat = 0.0833 / 60.0 // 0.0833 nm north in degrees
+ val (cpa, tcpa) = CpaCalculator.compute(
+ ownLat = 0.0, ownLon = 0.0, ownSog = 10.0, ownCog = 0.0,
+ tgtLat = tgtLat, tgtLon = 0.0, tgtSog = 10.0, tgtCog = 180.0
+ )
+ assertTrue(tcpa > 0.0, "TCPA should be positive (converging): $tcpa")
+ assertTrue(abs(tcpa - 0.25) < eps, "TCPA should be ~0.25 min, got $tcpa")
+ assertTrue(cpa < 0.01, "CPA should be near zero for head-on, got $cpa")
+ }
+
+ @Test
+ fun `diverging vessels - same direction target ahead - TCPA negative or CPA large`() {
+ // Own: (0,0) moving North at 5kt
+ // Target: (0, 1nm N) moving North at 10kt (pulling away)
+ val tgtLat = 1.0 / 60.0 // 1 nm north
+ val (cpa, tcpa) = CpaCalculator.compute(
+ ownLat = 0.0, ownLon = 0.0, ownSog = 5.0, ownCog = 0.0,
+ tgtLat = tgtLat, tgtLon = 0.0, tgtSog = 10.0, tgtCog = 0.0
+ )
+ // Target is faster and ahead — diverging, TCPA should be negative
+ assertTrue(tcpa < 0.0, "TCPA should be negative (diverging), got $tcpa")
+ }
+
+ @Test
+ fun `zero relative velocity returns current distance and TCPA zero`() {
+ // Same speed, same course — relative velocity zero
+ val tgtLat = 1.0 / 60.0 // 1 nm north
+ val (cpa, tcpa) = CpaCalculator.compute(
+ ownLat = 0.0, ownLon = 0.0, ownSog = 5.0, ownCog = 90.0,
+ tgtLat = tgtLat, tgtLon = 0.0, tgtSog = 5.0, tgtCog = 90.0
+ )
+ assertEquals(0.0, tcpa, 1e-9)
+ // Current distance should be ~1 nm
+ assertTrue(abs(cpa - 1.0) < 0.01, "CPA should be ~1 nm (current dist), got $cpa")
+ }
+
+ @Test
+ fun `both at same position zero relative velocity - zero distance`() {
+ val (cpa, tcpa) = CpaCalculator.compute(
+ ownLat = 37.0, ownLon = -122.0, ownSog = 5.0, ownCog = 0.0,
+ tgtLat = 37.0, tgtLon = -122.0, tgtSog = 5.0, tgtCog = 0.0
+ )
+ assertEquals(0.0, tcpa, 1e-9)
+ assertEquals(0.0, cpa, 1e-6)
+ }
+} \ No newline at end of file
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/nmea/AisVdmParserTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/nmea/AisVdmParserTest.kt
new file mode 100644
index 0000000..da3efd3
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/nmea/AisVdmParserTest.kt
@@ -0,0 +1,205 @@
+package org.terst.nav.nmea
+
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import kotlin.math.abs
+
+/**
+ * Encoding helpers to build synthetic AIS payloads for round-trip testing.
+ * Mirrors the decoder logic exactly.
+ */
+private fun encodeBits(bits: IntArray): String {
+ // Pad to multiple of 6
+ val padded = if (bits.size % 6 == 0) bits else bits + IntArray(6 - bits.size % 6)
+ val sb = StringBuilder()
+ var i = 0
+ while (i < padded.size) {
+ var v = 0
+ for (b in 0 until 6) v = (v shl 1) or padded[i + b]
+ val c = if (v <= 39) v + 48 else v + 56
+ sb.append(c.toChar())
+ i += 6
+ }
+ return sb.toString()
+}
+
+private fun intToBits(value: Int, len: Int): IntArray {
+ val arr = IntArray(len)
+ for (i in 0 until len) arr[i] = (value ushr (len - 1 - i)) and 1
+ return arr
+}
+
+private fun signedToBits(value: Int, len: Int): IntArray {
+ // Two's complement
+ val mask = if (len < 32) (1 shl len) - 1 else -1
+ return intToBits(value and mask, len)
+}
+
+/** Build a type-1 message bit array (168 bits) */
+private fun buildType1Payload(
+ mmsi: Int, sog10: Int, lon600000: Int, lat600000: Int, cog10: Int, heading: Int
+): String {
+ val bits = IntArray(168)
+ fun set(start: Int, arr: IntArray) { arr.copyInto(bits, start) }
+ set(0, intToBits(1, 6)) // message type = 1
+ set(6, intToBits(0, 2)) // repeat
+ set(8, intToBits(mmsi, 30)) // MMSI
+ set(38, intToBits(0, 12)) // nav status + rot (12 bits filler)
+ set(50, intToBits(sog10, 10)) // SOG * 10
+ set(60, intToBits(0, 1)) // accuracy
+ set(61, signedToBits(lon600000, 28)) // lon * 600000
+ set(89, signedToBits(lat600000, 27)) // lat * 600000
+ set(116, intToBits(cog10, 12)) // COG * 10
+ set(128, intToBits(heading, 9)) // heading
+ // remaining bits 137-167 = 0 (timestamp, maneuver, spare, raim, radio)
+ return encodeBits(bits)
+}
+
+/** Build a type-5 message bit array (426 bits) */
+private fun buildType5Payload(mmsi: Int, callsign: String, name: String, vesselType: Int): String {
+ val bits = IntArray(426)
+ fun set(start: Int, arr: IntArray) { arr.copyInto(bits, start) }
+ set(0, intToBits(5, 6)) // message type = 5
+ set(6, intToBits(0, 2)) // repeat
+ set(8, intToBits(mmsi, 30)) // MMSI
+ set(38, intToBits(0, 2)) // ais version
+ set(40, intToBits(0, 30)) // IMO number
+ // AIS 6-bit text encode: chars >=64 (A-Z,@) -> c-64 (gives 0-31); else c (space=32, digits=48-57)
+ val csFixed = callsign.padEnd(7, '@').take(7)
+ for (i in 0 until 7) {
+ val c = csFixed[i].code
+ val v = if (c >= 64) c - 64 else c
+ set(70 + i * 6, intToBits(v, 6))
+ }
+ // name: bits 112-231 = 20 chars x 6 bits
+ val nameFixed = name.padEnd(20, '@').take(20)
+ for (i in 0 until 20) {
+ val c = nameFixed[i].code
+ val v = if (c >= 64) c - 64 else c
+ set(112 + i * 6, intToBits(v, 6))
+ }
+ // vessel type: bits 232-239
+ set(232, intToBits(vesselType, 8))
+ // rest = 0 (draught, destination, dte, spare)
+ return encodeBits(bits)
+}
+
+private fun nmea0183Checksum(body: String): String {
+ var xor = 0
+ for (c in body) xor = xor xor c.code
+ return xor.toString(16).uppercase().padStart(2, '0')
+}
+
+private fun makeVdm(payload: String, padding: Int = 0): String {
+ val body = "AIVDM,1,1,,A,$payload,$padding"
+ return "!$body*${nmea0183Checksum(body)}"
+}
+
+private fun makeVdmPart(count: Int, seq: Int, seqId: String, payload: String, padding: Int = 0): String {
+ val body = "AIVDM,$count,$seq,$seqId,A,$payload,$padding"
+ return "!$body*${nmea0183Checksum(body)}"
+}
+
+class AisVdmParserTest {
+
+ private val parser = AisVdmParser()
+
+ @Test
+ fun `non-AIS NMEA sentence returns null`() {
+ assertNull(parser.parse("\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A"))
+ }
+
+ @Test
+ fun `malformed sentence returns null`() {
+ assertNull(parser.parse("garbage"))
+ assertNull(parser.parse("!AIVDM,1,1,,A,"))
+ assertNull(parser.parse(""))
+ }
+
+ @Test
+ fun `type-1 round-trip - known MMSI lat lon sog cog heading`() {
+ // MMSI=123456789, sog=5.0kt (50), lon=-122.0deg, lat=37.0deg, cog=270.0 (2700), heading=270
+ val mmsi = 123456789
+ val sog10 = 50 // 5.0 knots
+ val lon600000 = (-122.0 * 600000.0).toInt() // -73200000
+ val lat600000 = (37.0 * 600000.0).toInt() // 22200000
+ val cog10 = 2700 // 270.0 degrees
+ val heading = 270
+
+ val payload = buildType1Payload(mmsi, sog10, lon600000, lat600000, cog10, heading)
+ val sentence = makeVdm(payload)
+ val vessel = parser.parse(sentence)
+
+ assertNotNull(vessel)
+ assertEquals(mmsi, vessel!!.mmsi)
+ assertEquals(5.0, vessel.sog, 0.01)
+ assertEquals(-122.0, vessel.lon, 0.001)
+ assertEquals(37.0, vessel.lat, 0.001)
+ assertEquals(270.0, vessel.cog, 0.1)
+ assertEquals(270, vessel.heading)
+ assertEquals("", vessel.name)
+ assertEquals("", vessel.callsign)
+ assertEquals(0, vessel.vesselType)
+ }
+
+ @Test
+ fun `type-1 real sentence - MMSI 227006760`() {
+ val sentence = "!AIVDM,1,1,,A,13HOI:0P0000vocH;`5HF>0<0000,0*54"
+ val vessel = parser.parse(sentence)
+ assertNotNull(vessel)
+ assertEquals(227006760, vessel!!.mmsi)
+ }
+
+ @Test
+ fun `type-5 round-trip - name and callsign decoded and trimmed`() {
+ val mmsi = 987654321
+ val payload = buildType5Payload(mmsi, "W1ABC", "MY VESSEL NAME", vesselType = 36)
+ val sentence = makeVdm(payload)
+ val vessel = parser.parse(sentence)
+
+ assertNotNull(vessel)
+ assertEquals(mmsi, vessel!!.mmsi)
+ assertEquals("W1ABC", vessel.callsign)
+ assertEquals("MY VESSEL NAME", vessel.name)
+ assertEquals(36, vessel.vesselType)
+ assertEquals(0.0, vessel.lat)
+ assertEquals(0.0, vessel.lon)
+ assertEquals(511, vessel.heading)
+ }
+
+ @Test
+ fun `two-part message reassembly`() {
+ // Build a type-1 payload, split it in half across two sentences
+ val mmsi = 111222333
+ val payload = buildType1Payload(mmsi, 100, 0, 0, 900, 511)
+ val half = payload.length / 2
+ val part1 = payload.substring(0, half)
+ val part2 = payload.substring(half)
+
+ val s1 = makeVdmPart(2, 1, "1", part1, 0)
+ val s2 = makeVdmPart(2, 2, "1", part2, 0)
+
+ // First sentence: incomplete, returns null
+ assertNull(parser.parse(s1))
+ // Second sentence: completes reassembly
+ val vessel = parser.parse(s2)
+ assertNotNull(vessel)
+ assertEquals(mmsi, vessel!!.mmsi)
+ }
+
+ @Test
+ fun `SOG not available value 1023 decodes to 0_0`() {
+ val payload = buildType1Payload(111000111, 1023, 0, 0, 0, 511)
+ val vessel = parser.parse(makeVdm(payload))
+ assertNotNull(vessel)
+ assertEquals(0.0, vessel!!.sog)
+ }
+
+ @Test
+ fun `COG not available value 3600 decodes to 0_0`() {
+ val payload = buildType1Payload(111000222, 0, 0, 0, 3600, 511)
+ val vessel = parser.parse(makeVdm(payload))
+ assertNotNull(vessel)
+ assertEquals(0.0, vessel!!.cog)
+ }
+} \ No newline at end of file
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelTest.kt
index edecdd5..0f5cefe 100644
--- a/android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelTest.kt
+++ b/android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelTest.kt
@@ -1,6 +1,7 @@
package org.terst.nav.ui
import app.cash.turbine.test
+import org.terst.nav.ais.AisVessel
import org.terst.nav.data.model.ForecastItem
import org.terst.nav.data.model.WindArrow
import org.terst.nav.data.repository.WeatherRepository
@@ -102,4 +103,43 @@ class MainViewModelTest {
cancelAndIgnoreRemainingEvents()
}
}
+
+ // ── AIS integration tests ────────────────────────────────────────────────
+
+ @Test
+ fun `processAisSentence valid type-1 NMEA adds 1 vessel to aisTargets`() {
+ coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow)
+ coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast)
+ vm = makeVm()
+
+ // Known real type-1 sentence; MMSI = 227006760
+ vm.processAisSentence("!AIVDM,1,1,,A,13HOI:0P0000vocH;`5HF>0<0000,0*54")
+
+ assertEquals(1, vm.aisTargets.value.size)
+ assertEquals(227006760, vm.aisTargets.value[0].mmsi)
+ }
+
+ @Test
+ fun `processAisSentence same MMSI twice keeps exactly 1 vessel in aisTargets`() {
+ coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow)
+ coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast)
+ vm = makeVm()
+
+ val sentence = "!AIVDM,1,1,,A,13HOI:0P0000vocH;`5HF>0<0000,0*54"
+ vm.processAisSentence(sentence)
+ vm.processAisSentence(sentence)
+
+ assertEquals(1, vm.aisTargets.value.size)
+ }
+
+ @Test
+ fun `processAisSentence non-AIS sentence leaves aisTargets empty`() {
+ coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow)
+ coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast)
+ vm = makeVm()
+
+ vm.processAisSentence("\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A")
+
+ assertEquals(0, vm.aisTargets.value.size)
+ }
}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/LocationPermissionHandlerTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/LocationPermissionHandlerTest.kt
new file mode 100644
index 0000000..9caa5a0
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/LocationPermissionHandlerTest.kt
@@ -0,0 +1,110 @@
+package org.terst.nav.ui
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class LocationPermissionHandlerTest {
+
+ // Convenience factory — callers override only the lambdas they care about.
+ private fun makeHandler(
+ checkGranted: () -> Boolean = { false },
+ onGranted: () -> Unit = {},
+ onDenied: () -> Unit = {},
+ requestPermissions: () -> Unit = {}
+ ) = LocationPermissionHandler(checkGranted, onGranted, onDenied, requestPermissions)
+
+ // ── start() ──────────────────────────────────────────────────────────────
+
+ @Test
+ fun `start - permission already granted - calls onGranted without requesting`() {
+ var onGrantedCalled = false
+ var requestCalled = false
+ makeHandler(
+ checkGranted = { true },
+ onGranted = { onGrantedCalled = true },
+ requestPermissions = { requestCalled = true }
+ ).start()
+
+ assertTrue("onGranted should be called", onGrantedCalled)
+ assertFalse("requestPermissions should NOT be called", requestCalled)
+ }
+
+ @Test
+ fun `start - permission not granted - calls requestPermissions without calling onGranted`() {
+ var onGrantedCalled = false
+ var requestCalled = false
+ makeHandler(
+ checkGranted = { false },
+ onGranted = { onGrantedCalled = true },
+ requestPermissions = { requestCalled = true }
+ ).start()
+
+ assertFalse("onGranted should NOT be called", onGrantedCalled)
+ assertTrue("requestPermissions should be called", requestCalled)
+ }
+
+ // ── onResult() ───────────────────────────────────────────────────────────
+
+ @Test
+ fun `onResult - fine location granted - calls onGranted`() {
+ var onGrantedCalled = false
+ makeHandler(onGranted = { onGrantedCalled = true }).onResult(
+ mapOf(
+ "android.permission.ACCESS_FINE_LOCATION" to true,
+ "android.permission.ACCESS_COARSE_LOCATION" to false
+ )
+ )
+ assertTrue("onGranted should be called when fine location is granted", onGrantedCalled)
+ }
+
+ @Test
+ fun `onResult - coarse location granted - calls onGranted`() {
+ var onGrantedCalled = false
+ makeHandler(onGranted = { onGrantedCalled = true }).onResult(
+ mapOf(
+ "android.permission.ACCESS_FINE_LOCATION" to false,
+ "android.permission.ACCESS_COARSE_LOCATION" to true
+ )
+ )
+ assertTrue("onGranted should be called when coarse location is granted", onGrantedCalled)
+ }
+
+ @Test
+ fun `onResult - both permissions granted - calls onGranted`() {
+ var onGrantedCalled = false
+ makeHandler(onGranted = { onGrantedCalled = true }).onResult(
+ mapOf(
+ "android.permission.ACCESS_FINE_LOCATION" to true,
+ "android.permission.ACCESS_COARSE_LOCATION" to true
+ )
+ )
+ assertTrue(onGrantedCalled)
+ }
+
+ @Test
+ fun `onResult - all permissions denied - calls onDenied not onGranted`() {
+ var onGrantedCalled = false
+ var onDeniedCalled = false
+ makeHandler(
+ onGranted = { onGrantedCalled = true },
+ onDenied = { onDeniedCalled = true }
+ ).onResult(
+ mapOf(
+ "android.permission.ACCESS_FINE_LOCATION" to false,
+ "android.permission.ACCESS_COARSE_LOCATION" to false
+ )
+ )
+ assertFalse("onGranted should NOT be called", onGrantedCalled)
+ assertTrue("onDenied should be called", onDeniedCalled)
+ }
+
+ @Test
+ fun `onResult - empty grants (never ask again scenario) - calls onDenied`() {
+ var onDeniedCalled = false
+ makeHandler(onDenied = { onDeniedCalled = true }).onResult(emptyMap())
+ assertTrue(
+ "onDenied should be called for empty grants (never-ask-again)",
+ onDeniedCalled
+ )
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/MainViewModelTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/MainViewModelTest.kt
new file mode 100644
index 0000000..edecdd5
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/ui_orig/MainViewModelTest.kt
@@ -0,0 +1,105 @@
+package org.terst.nav.ui
+
+import app.cash.turbine.test
+import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.WindArrow
+import org.terst.nav.data.repository.WeatherRepository
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.*
+import org.junit.After
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MainViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val repo = mockk<WeatherRepository>()
+ private lateinit var vm: MainViewModel
+
+ private val sampleArrow = WindArrow(37.5, -122.3, 15.0, 270.0)
+ private val sampleForecast = listOf(
+ ForecastItem("2026-03-13T00:00", 15.0, 270.0, 18.5, 20, 1)
+ )
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun makeVm() = MainViewModel(repo)
+
+ @Test
+ fun `initial uiState is Loading`() {
+ coEvery { repo.fetchWindArrow(any(), any()) } coAnswers { Result.success(sampleArrow) }
+ coEvery { repo.fetchForecastItems(any(), any()) } coAnswers { Result.success(sampleForecast) }
+ vm = makeVm()
+ // Before loadWeather() is called the state is Loading
+ assertEquals(UiState.Loading, vm.uiState.value)
+ }
+
+ @Test
+ fun `loadWeather success transitions to Success state`() = runTest {
+ coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow)
+ coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast)
+ vm = makeVm()
+
+ vm.uiState.test {
+ assertEquals(UiState.Loading, awaitItem())
+ vm.loadWeather(37.5, -122.3)
+ assertEquals(UiState.Success, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `loadWeather populates windArrow and forecast`() = runTest {
+ coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow)
+ coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast)
+ vm = makeVm()
+ vm.loadWeather(37.5, -122.3)
+
+ assertEquals(sampleArrow, vm.windArrow.value)
+ assertEquals(sampleForecast, vm.forecast.value)
+ }
+
+ @Test
+ fun `loadWeather arrow failure transitions to Error state`() = runTest {
+ coEvery { repo.fetchWindArrow(any(), any()) } returns Result.failure(RuntimeException("Net error"))
+ coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast)
+ vm = makeVm()
+
+ vm.uiState.test {
+ awaitItem() // Loading
+ vm.loadWeather(37.5, -122.3)
+ val state = awaitItem()
+ assertTrue(state is UiState.Error)
+ assertTrue((state as UiState.Error).message.contains("Net error"))
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `loadWeather forecast failure transitions to Error state`() = runTest {
+ coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow)
+ coEvery { repo.fetchForecastItems(any(), any()) } returns Result.failure(RuntimeException("Timeout"))
+ vm = makeVm()
+
+ vm.uiState.test {
+ awaitItem() // Loading
+ vm.loadWeather(37.5, -122.3)
+ val state = awaitItem()
+ assertTrue(state is UiState.Error)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+}