summaryrefslogtreecommitdiff
path: root/android-app/app/src/main/kotlin/org/terst
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src/main/kotlin/org/terst')
-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/ais/AisRepository.kt5
-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
4 files changed, 165 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"
}
}