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/LocationService.kt15
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt12
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt5
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt6
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/settings/FeatureFlags.kt44
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt22
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt24
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt4
-rw-r--r--android-app/app/src/main/res/layout/fragment_safety.xml107
10 files changed, 233 insertions, 8 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
index 138fc6c..cc506a5 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
@@ -180,7 +180,10 @@ class LocationService : Service() {
startLocationUpdatesInternal(PowerMode.FULL)
}
barometerSensorManager.start()
- nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT)
+ val flags = (application as NavApplication).featureFlags
+ if (flags.isEnabled(org.terst.nav.settings.HardwareSource.NMEA_INSTRUMENTS)) {
+ nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT)
+ }
}
ACTION_STOP_FOREGROUND_SERVICE -> {
Log.d("LocationService", "Stopping foreground service")
@@ -211,6 +214,14 @@ class LocationService : Service() {
val isVisible = intent.getBooleanExtra(EXTRA_TIDAL_VISIBILITY, false)
_tidalCurrentState.update { it.copy(isVisible = isVisible) }
}
+ ACTION_START_NMEA -> {
+ Log.d("LocationService", "Starting NMEA stream")
+ nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT)
+ }
+ ACTION_STOP_NMEA -> {
+ Log.d("LocationService", "Stopping NMEA stream")
+ nmeaStreamManager.stop()
+ }
}
return START_NOT_STICKY
}
@@ -339,6 +350,8 @@ class LocationService : Service() {
const val ACTION_STOP_ANCHOR_WATCH = "ACTION_STOP_ANCHOR_WATCH"
const val ACTION_UPDATE_WATCH_RADIUS = "ACTION_UPDATE_WATCH_RADIUS"
const val ACTION_TOGGLE_TIDAL_VISIBILITY = "ACTION_TOGGLE_TIDAL_VISIBILITY"
+ const val ACTION_START_NMEA = "ACTION_START_NMEA"
+ const val ACTION_STOP_NMEA = "ACTION_STOP_NMEA"
const val EXTRA_WATCH_RADIUS = "extra_watch_radius"
const val EXTRA_TIDAL_VISIBILITY = "extra_tidal_visibility"
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 3880671..d417355 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
@@ -36,6 +36,7 @@ import org.maplibre.android.maps.Style
import org.maplibre.android.style.layers.RasterLayer
import org.maplibre.android.style.sources.RasterSource
import org.maplibre.android.style.sources.TileSet
+import org.terst.nav.settings.HardwareSource
import org.terst.nav.thermal.ThermalState
import org.terst.nav.thermal.thermalFlow
import org.terst.nav.ui.*
@@ -48,6 +49,8 @@ import java.util.*
class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
+ private val featureFlags get() = (application as NavApplication).featureFlags
+
private var mapView: MapView? = null
private var mobHandler: MobHandler? = null
private var instrumentHandler: InstrumentHandler? = null
@@ -80,6 +83,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
MapLibre.getInstance(this)
setContentView(R.layout.activity_main)
+ viewModel.featureFlags = featureFlags
checkForegroundPermissions()
initializeUI()
}
@@ -194,6 +198,14 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
anchorWatchHandler?.toggleVisibility()
}
+ override fun onHardwareSourceChanged(source: HardwareSource, enabled: Boolean) {
+ viewModel.onHardwareSourceChanged(source, enabled)
+ if (source == HardwareSource.NMEA_INSTRUMENTS) {
+ val action = if (enabled) LocationService.ACTION_START_NMEA else LocationService.ACTION_STOP_NMEA
+ startService(Intent(this, LocationService::class.java).apply { this.action = action })
+ }
+ }
+
private fun setupHandlers() {
instrumentHandler = InstrumentHandler(
valueAws = findViewById(R.id.value_aws),
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
index 0b507d2..934f486 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
@@ -2,6 +2,7 @@ package org.terst.nav
import android.app.Application
import com.google.firebase.crashlytics.FirebaseCrashlytics
+import org.terst.nav.settings.FeatureFlags
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
@@ -11,12 +12,16 @@ import java.util.Locale
class NavApplication : Application() {
+ lateinit var featureFlags: FeatureFlags
+ private set
+
companion object {
var isTesting: Boolean = false
}
override fun onCreate() {
super.onCreate()
+ featureFlags = FeatureFlags(this)
FirebaseCrashlytics.getInstance().sendUnsentReports()
installCrashLogger()
}
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 75b1feb..86bff97 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
@@ -58,4 +58,10 @@ class AisRepository(
val threshold = nowMs - staleTimeoutMs
targets.entries.removeIf { it.value.timestampMs < threshold }
}
+
+ /** Remove all tracked vessels — called when AIS source is disabled. */
+ fun clear() {
+ targets.clear()
+ pendingStatic.clear()
+ }
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/settings/FeatureFlags.kt b/android-app/app/src/main/kotlin/org/terst/nav/settings/FeatureFlags.kt
new file mode 100644
index 0000000..179fa6f
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/settings/FeatureFlags.kt
@@ -0,0 +1,44 @@
+package org.terst.nav.settings
+
+import android.content.Context
+import android.content.SharedPreferences
+
+/**
+ * Hardware sources that require external devices beyond what a phone provides natively.
+ * When disabled, all UI and processing for that source is suppressed.
+ */
+enum class HardwareSource(val prefKey: String, val label: String, val description: String) {
+ AIS_RECEIVER(
+ "hw_ais_receiver",
+ "AIS Receiver",
+ "Vessel tracking, CPA/TCPA collision alerts"
+ ),
+ NMEA_INSTRUMENTS(
+ "hw_nmea_instruments",
+ "NMEA Instruments",
+ "Wind, depth, boat speed, heading from onboard sensors"
+ )
+}
+
+/**
+ * Persists hardware source on/off state to SharedPreferences.
+ * All sources default to disabled so the app works out-of-the-box on a phone only.
+ */
+class FeatureFlags(context: Context) {
+
+ private val prefs: SharedPreferences =
+ context.getSharedPreferences("feature_flags", Context.MODE_PRIVATE)
+
+ fun isEnabled(source: HardwareSource): Boolean =
+ prefs.getBoolean(source.prefKey, false)
+
+ fun setEnabled(source: HardwareSource, enabled: Boolean) {
+ prefs.edit().putBoolean(source.prefKey, enabled).apply()
+ }
+
+ fun toggle(source: HardwareSource): Boolean {
+ val next = !isEnabled(source)
+ setEnabled(source, next)
+ return next
+ }
+}
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 bf57c1f..b457316 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
@@ -15,6 +15,8 @@ import org.terst.nav.thermal.ThermalState
import org.terst.nav.track.TrackPoint
import org.terst.nav.track.TrackRepository
import org.terst.nav.track.TrackStats
+import org.terst.nav.settings.FeatureFlags
+import org.terst.nav.settings.HardwareSource
import org.terst.nav.vessel.CrewMember
import org.terst.nav.vessel.Vessel
import org.terst.nav.vessel.VesselRepository
@@ -38,7 +40,8 @@ sealed class UiState {
}
class MainViewModel(
- private val repository: WeatherRepository = WeatherRepository.create()
+ private val repository: WeatherRepository = WeatherRepository.create(),
+ var featureFlags: FeatureFlags? = null
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
@@ -211,12 +214,28 @@ class MainViewModel(
* Call this from MainActivity when bytes arrive from the TCP socket.
*/
fun processAisSentence(sentence: String) {
+ if (featureFlags?.isEnabled(HardwareSource.AIS_RECEIVER) == false) return
aisRepository.processSentence(sentence)
aisRepository.evictStale()
_aisTargets.value = aisRepository.getTargets()
updateCpaAlerts()
}
+ fun onHardwareSourceChanged(source: HardwareSource, enabled: Boolean) {
+ when (source) {
+ HardwareSource.AIS_RECEIVER -> {
+ if (!enabled) {
+ aisRepository.clear()
+ _aisTargets.value = emptyList()
+ _cpaAlerts.value = emptyList()
+ }
+ }
+ HardwareSource.NMEA_INSTRUMENTS -> {
+ // NMEA instrument state is ephemeral — clearing handled by InstrumentHandler
+ }
+ }
+ }
+
/**
* Refresh AIS targets from AISHub for the given bounding box.
* When username is empty, skips silently — hardware feed is primary.
@@ -225,6 +244,7 @@ class MainViewModel(
latMin: Double, latMax: Double, lonMin: Double, lonMax: Double,
username: String = "", password: String = ""
) {
+ if (featureFlags?.isEnabled(HardwareSource.AIS_RECEIVER) == false) return
if (username.isEmpty()) return
viewModelScope.launch {
try {
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt
index 0169fc1..ae708af 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/ParticleWindView.kt
@@ -141,7 +141,7 @@ class ParticleWindView @JvmOverloads constructor(
val latSouth = bounds.latSouth.toFloat()
val lonWest = bounds.lonWest.toFloat()
val latRange = (bounds.latNorth - latSouth).toFloat()
- val lonRange = (bounds.lonEast - lonWest).toFloat()
+ val lonRange = (bounds.northEast.longitude - lonWest).toFloat()
for (i in 0 until N) {
particleLat[i] = latSouth + Random.nextFloat() * latRange
particleLon[i] = lonWest + Random.nextFloat() * lonRange
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt
index b248e8d..a6c038c 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt
@@ -8,10 +8,13 @@ import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import com.google.android.material.button.MaterialButton
+import com.google.android.material.materialswitch.MaterialSwitch
+import org.terst.nav.NavApplication
import org.terst.nav.R
import org.terst.nav.ais.CpaAlert
import org.terst.nav.ais.CpaSeverity
import org.terst.nav.ais.label
+import org.terst.nav.settings.HardwareSource
import org.terst.nav.ui.doc.DocFragment
class SafetyFragment : Fragment() {
@@ -19,6 +22,7 @@ class SafetyFragment : Fragment() {
interface SafetyListener {
fun onActivateMob()
fun onConfigureAnchor()
+ fun onHardwareSourceChanged(source: HardwareSource, enabled: Boolean)
}
private var listener: SafetyListener? = null
@@ -31,9 +35,7 @@ class SafetyFragment : Fragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
- ): View? {
- return inflater.inflate(R.layout.fragment_safety, container, false)
- }
+ ): View? = inflater.inflate(R.layout.fragment_safety, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -52,6 +54,22 @@ class SafetyFragment : Fragment() {
view.findViewById<MaterialButton>(R.id.button_anchor_config).setOnClickListener {
listener?.onConfigureAnchor()
}
+
+ val flags = (requireActivity().application as NavApplication).featureFlags
+
+ val switchAis = view.findViewById<MaterialSwitch>(R.id.switch_ais)
+ switchAis.isChecked = flags.isEnabled(HardwareSource.AIS_RECEIVER)
+ switchAis.setOnCheckedChangeListener { _, checked ->
+ flags.setEnabled(HardwareSource.AIS_RECEIVER, checked)
+ listener?.onHardwareSourceChanged(HardwareSource.AIS_RECEIVER, checked)
+ }
+
+ val switchNmea = view.findViewById<MaterialSwitch>(R.id.switch_nmea)
+ switchNmea.isChecked = flags.isEnabled(HardwareSource.NMEA_INSTRUMENTS)
+ switchNmea.setOnCheckedChangeListener { _, checked ->
+ flags.setEnabled(HardwareSource.NMEA_INSTRUMENTS, checked)
+ listener?.onHardwareSourceChanged(HardwareSource.NMEA_INSTRUMENTS, checked)
+ }
}
fun updateAnchorStatus(statusText: String) {
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt
index b00d899..d0d8515 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt
@@ -189,7 +189,7 @@ class VesselRegistryFragment : Fragment() {
.setPositiveButton("Save") { _, _ ->
val name = etName.text.toString().trim()
if (name.isBlank()) return@setPositiveButton
- val v = (vessel ?: Vessel(id = "")).copy(
+ val v = (vessel ?: Vessel(id = "", name = "")).copy(
name = name,
mmsi = etMmsi.text.toString().trim(),
callsign = etCallsign.text.toString().trim(),
@@ -235,7 +235,7 @@ class VesselRegistryFragment : Fragment() {
val role = runCatching {
CrewRole.valueOf(etRole.text.toString().trim().uppercase())
}.getOrDefault(CrewRole.CREW)
- val m = (member ?: CrewMember(id = "")).copy(
+ val m = (member ?: CrewMember(id = "", name = "")).copy(
name = name,
role = role,
emergencyContact = etIceName.text.toString().trim(),
diff --git a/android-app/app/src/main/res/layout/fragment_safety.xml b/android-app/app/src/main/res/layout/fragment_safety.xml
index c270d1f..806598e 100644
--- a/android-app/app/src/main/res/layout/fragment_safety.xml
+++ b/android-app/app/src/main/res/layout/fragment_safety.xml
@@ -139,4 +139,111 @@
</com.google.android.material.card.MaterialCardView>
+ <!-- Hardware Data Sources -->
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/card_data_sources"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ app:cardCornerRadius="16dp"
+ app:layout_constraintTop_toBottomOf="@id/card_anchor">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="HARDWARE DATA SOURCES"
+ android:textStyle="bold" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="4dp"
+ android:layout_marginBottom="12dp"
+ android:text="Enable only the hardware you have connected"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ <!-- AIS Receiver row -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="8dp">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="AIS Receiver"
+ android:textSize="15sp"
+ android:textColor="?attr/colorOnSurface" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Vessel tracking, collision alerts"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+
+ <com.google.android.material.materialswitch.MaterialSwitch
+ android:id="@+id/switch_ais"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content" />
+
+ </LinearLayout>
+
+ <!-- NMEA Instruments row -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="NMEA Instruments"
+ android:textSize="15sp"
+ android:textColor="?attr/colorOnSurface" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Wind, depth, boat speed"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+
+ <com.google.android.material.materialswitch.MaterialSwitch
+ android:id="@+id/switch_nmea"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content" />
+
+ </LinearLayout>
+
+ </LinearLayout>
+
+ </com.google.android.material.card.MaterialCardView>
+
</androidx.constraintlayout.widget.ConstraintLayout>