diff options
| author | Claudomator Agent <agent@claudomator> | 2026-03-15 14:20:21 +0000 |
|---|---|---|
| committer | Claudomator Agent <agent@claudomator> | 2026-03-15 14:20:21 +0000 |
| commit | ff5854b75f2ba7c77d467fd9523e2a23060a7c46 (patch) | |
| tree | aa5212db097ef6dbdd024e2f41387acde8b8b085 /android-app/app/src | |
| parent | 13e4e30f351f06bda23a45b36c05970d1ef2c692 (diff) | |
feat: integrate AIS into ViewModel and MapFragment with vessel symbol layer
- MainViewModel: add _aisTargets StateFlow, processAisSentence(), refreshAisFromInternet()
- AisRepository: add ingestVessel() for internet-sourced vessels
- MapFragment: add AIS vessel SymbolLayer with heading-based rotation and zoom-gated labels
- MainActivity: add startAisHardwareFeed() TCP stub, wire viewModel
- ic_ship_arrow.xml: new vector drawable for AIS target icons
- MainViewModelTest: 3 new AIS tests (processAisSentence happy path, dedup, non-AIS sentence)
- JVM test harness: /tmp/ais-vm-test-runner/ — 3 tests GREEN
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app/app/src')
6 files changed, 214 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/ais/AisRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt index 4b90c38..75b1feb 100644 --- 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 @@ -48,6 +48,11 @@ class AisRepository( } } + /** 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 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/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/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) + } } |
