diff options
Diffstat (limited to 'android-app/app/src/main')
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> |
