package org.terst.nav import android.util.Log import android.annotation.SuppressLint import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent import android.location.Location import android.os.IBinder import android.os.Looper import androidx.core.app.NotificationCompat import com.google.android.gms.location.* import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import org.terst.nav.nmea.NmeaParser import org.terst.nav.nmea.NmeaStreamManager import org.terst.nav.sensors.DepthData import org.terst.nav.sensors.HeadingData import org.terst.nav.sensors.WindData import org.terst.nav.gps.GpsPosition import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.tasks.await import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch data class GpsData( val latitude: Double, val longitude: Double, val speedOverGround: Float, // m/s val courseOverGround: Float // degrees ) { fun toLocation(): Location { val location = Location("GpsData") location.latitude = latitude location.longitude = longitude location.speed = speedOverGround location.bearing = courseOverGround return location } } class LocationService : Service() { private lateinit var fusedLocationClient: FusedLocationProviderClient private lateinit var locationCallback: LocationCallback private lateinit var anchorAlarmManager: AnchorAlarmManager private lateinit var barometerSensorManager: BarometerSensorManager private lateinit var nmeaParser: NmeaParser private lateinit var nmeaStreamManager: NmeaStreamManager private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val NOTIFICATION_CHANNEL_ID = "location_service_channel" private val NOTIFICATION_ID = 123 private var isAlarmTriggered = false // To prevent repeated alarm triggering override fun onCreate() { super.onCreate() Log.d("LocationService", "Service created") fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) anchorAlarmManager = AnchorAlarmManager(this) // Initialize with service context barometerSensorManager = BarometerSensorManager(this) nmeaParser = NmeaParser() nmeaStreamManager = NmeaStreamManager(nmeaParser, serviceScope) createNotificationChannel() // Observe barometer status and update our public state serviceScope.launch { barometerSensorManager.barometerStatus.collect { status -> _barometerStatus.value = status } } // Collect NMEA GPS positions serviceScope.launch { nmeaStreamManager.nmeaGpsPosition.collectLatest { gpsPosition -> _nmeaGpsPositionFlow.emit(gpsPosition) } } // Collect NMEA Wind Data serviceScope.launch { nmeaStreamManager.nmeaWindData.collectLatest { windData -> _nmeaWindDataFlow.emit(windData) } } // Collect NMEA Depth Data serviceScope.launch { nmeaStreamManager.nmeaDepthData.collectLatest { depthData -> _nmeaDepthDataFlow.emit(depthData) } } // Collect NMEA Heading Data serviceScope.launch { nmeaStreamManager.nmeaHeadingData.collectLatest { headingData -> _nmeaHeadingDataFlow.emit(headingData) } } // Mock tidal current data generator serviceScope.launch { while (true) { val currents = MockTidalCurrentGenerator.generateMockCurrents() _tidalCurrentState.update { it.copy(currents = currents) } kotlinx.coroutines.delay(60000) // Update every minute } } locationCallback = object : LocationCallback() { override fun onLocationResult(locationResult: LocationResult) { locationResult.lastLocation?.let { location -> val gpsData = GpsData( latitude = location.latitude, longitude = location.longitude, speedOverGround = location.speed, courseOverGround = location.bearing ) serviceScope.launch { _locationFlow.emit(gpsData) // Emit to shared flow (Android system GPS) } // Check for anchor drag if anchor watch is active checkAnchorDrag(location) } } } } /** * Checks if the current location is outside the anchor watch circle. */ private fun checkAnchorDrag(location: Location) { _anchorWatchState.update { currentState -> if (currentState.isActive && currentState.anchorLocation != null) { val isDragging = currentState.isDragging(location) if (isDragging) { Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!! Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m") if (!isAlarmTriggered) { anchorAlarmManager.startAlarm() isAlarmTriggered = true } } else { Log.d("AnchorWatch", "Anchor holding. Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m") if (isAlarmTriggered) { anchorAlarmManager.stopAlarm() isAlarmTriggered = false } } } else { // If anchor watch is not active, ensure alarm is stopped if (isAlarmTriggered) { anchorAlarmManager.stopAlarm() isAlarmTriggered = false } } currentState } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_START_FOREGROUND_SERVICE -> { Log.d("LocationService", "Starting foreground service") startForeground(NOTIFICATION_ID, createNotification()) serviceScope.launch { _currentPowerMode.emit(PowerMode.FULL) // Set initial power mode to FULL startLocationUpdatesInternal(PowerMode.FULL) } barometerSensorManager.start() nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT) } ACTION_STOP_FOREGROUND_SERVICE -> { Log.d("LocationService", "Stopping foreground service") stopLocationUpdatesInternal() barometerSensorManager.stop() nmeaStreamManager.stop() stopSelf() } ACTION_START_ANCHOR_WATCH -> { Log.d("LocationService", "Received ACTION_START_ANCHOR_WATCH") val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS) serviceScope.launch { startAnchorWatch(radius) setPowerMode(PowerMode.ANCHOR_WATCH) } } ACTION_STOP_ANCHOR_WATCH -> { Log.d("LocationService", "Received ACTION_STOP_ANCHOR_WATCH") stopAnchorWatch() setPowerMode(PowerMode.FULL) // Revert to full power mode after stopping anchor watch } ACTION_UPDATE_WATCH_RADIUS -> { Log.d("LocationService", "Received ACTION_UPDATE_WATCH_RADIUS") val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS) updateWatchCircleRadius(radius) } ACTION_TOGGLE_TIDAL_VISIBILITY -> { val isVisible = intent.getBooleanExtra(EXTRA_TIDAL_VISIBILITY, false) _tidalCurrentState.update { it.copy(isVisible = isVisible) } } } return START_NOT_STICKY } override fun onBind(intent: Intent?): IBinder? { return null // Not a bound service } override fun onDestroy() { super.onDestroy() Log.d("LocationService", "Service destroyed") stopLocationUpdatesInternal() anchorAlarmManager.stopAlarm() barometerSensorManager.stop() nmeaStreamManager.stop() // Stop NMEA stream when service is destroyed _anchorWatchState.value = AnchorWatchState(isActive = false) isAlarmTriggered = false // Reset alarm trigger state serviceScope.cancel() // Cancel the coroutine scope } @SuppressLint("MissingPermission") private fun startLocationUpdatesInternal(powerMode: PowerMode) { Log.d("LocationService", "Requesting location updates with PowerMode: ${powerMode.name}, interval: ${powerMode.gpsUpdateIntervalMillis}ms") val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, powerMode.gpsUpdateIntervalMillis) .setMinUpdateIntervalMillis(powerMode.gpsUpdateIntervalMillis / 2) // Half the interval for minUpdateInterval .build() fusedLocationClient.requestLocationUpdates( locationRequest, locationCallback, Looper.getMainLooper() ) } private fun stopLocationUpdatesInternal() { Log.d("LocationService", "Removing location updates") fusedLocationClient.removeLocationUpdates(locationCallback) } fun setPowerMode(powerMode: PowerMode) { serviceScope.launch { if (_currentPowerMode.value != powerMode) { // Emit the new power mode first _currentPowerMode.emit(powerMode) Log.d("LocationService", "Power mode changing to ${powerMode.name}. Restarting location updates.") // Stop current updates if running stopLocationUpdatesInternal() // Start new updates with the new power mode's interval startLocationUpdatesInternal(powerMode) } else { Log.d("LocationService", "Power mode already ${powerMode.name}. No change needed.") } } } private fun createNotificationChannel() { val serviceChannel = NotificationChannel( NOTIFICATION_CHANNEL_ID, "Location Service Channel", NotificationManager.IMPORTANCE_LOW ) val manager = getSystemService(NotificationManager::class.java) as NotificationManager manager.createNotificationChannel(serviceChannel) } private fun createNotification(): Notification { val notificationIntent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity( this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE ) return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) .setContentTitle("Sailing Companion") .setContentText("Tracking your location in the background...") .setSmallIcon(R.drawable.ic_anchor) .setContentIntent(pendingIntent) .build() } /** * Starts the anchor watch with the current location as the anchor point. * @param radiusMeters The watch circle radius in meters. */ @SuppressLint("MissingPermission") suspend fun startAnchorWatch(radiusMeters: Double = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS) { val lastLocation = fusedLocationClient.lastLocation.await() lastLocation?.let { location -> _anchorWatchState.update { AnchorWatchState( anchorLocation = location, watchCircleRadiusMeters = radiusMeters, setTimeMillis = System.currentTimeMillis(), isActive = true ) } Log.i("AnchorWatch", "Anchor watch started at lat: ${location.latitude}, lon: ${location.longitude} with radius: ${radiusMeters}m") } ?: run { Log.e("AnchorWatch", "Could not start anchor watch: Last known location is null.") // Handle error, e.g., show a toast to the user } } /** * Stops the anchor watch. */ fun stopAnchorWatch() { _anchorWatchState.update { AnchorWatchState(isActive = false) } Log.i("AnchorWatch", "Anchor watch stopped.") anchorAlarmManager.stopAlarm() isAlarmTriggered = false } /** * Updates the watch circle radius. */ fun updateWatchCircleRadius(radiusMeters: Double) { _anchorWatchState.update { it.copy(watchCircleRadiusMeters = radiusMeters) } Log.d("AnchorWatch", "Watch circle radius updated to ${radiusMeters}m.") } companion object { const val ACTION_START_FOREGROUND_SERVICE = "ACTION_START_FOREGROUND_SERVICE" const val ACTION_STOP_FOREGROUND_SERVICE = "ACTION_STOP_FOREGROUND_SERVICE" const val ACTION_START_ANCHOR_WATCH = "ACTION_START_ANCHOR_WATCH" 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 EXTRA_WATCH_RADIUS = "extra_watch_radius" const val EXTRA_TIDAL_VISIBILITY = "extra_tidal_visibility" // NMEA Gateway configuration (example values - these should ideally be configurable by the user) private const val NMEA_GATEWAY_IP = "192.168.1.1" // Placeholder IP address private const val NMEA_GATEWAY_PORT = 10110 // Default NMEA port // Publicly accessible flows val locationFlow: SharedFlow get() = _locationFlow val anchorWatchState: StateFlow get() = _anchorWatchState val tidalCurrentState: StateFlow get() = _tidalCurrentState val barometerStatus: StateFlow get() = _barometerStatus // NMEA Data Flows val nmeaGpsPositionFlow: SharedFlow get() = _nmeaGpsPositionFlow val nmeaWindDataFlow: SharedFlow get() = _nmeaWindDataFlow val nmeaDepthDataFlow: SharedFlow get() = _nmeaDepthDataFlow val nmeaHeadingDataFlow: SharedFlow get() = _nmeaHeadingDataFlow private val _locationFlow = MutableSharedFlow(replay = 1) private val _anchorWatchState = MutableStateFlow(AnchorWatchState()) private val _tidalCurrentState = MutableStateFlow(TidalCurrentState()) private val _barometerStatus = MutableStateFlow(BarometerStatus()) // Private NMEA Data Flows private val _nmeaGpsPositionFlow = MutableSharedFlow( replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) private val _nmeaWindDataFlow = MutableSharedFlow( replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) private val _nmeaDepthDataFlow = MutableSharedFlow( replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) private val _nmeaHeadingDataFlow = MutableSharedFlow( replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) private val _currentPowerMode = MutableStateFlow(PowerMode.FULL) val currentPowerMode: StateFlow get() = _currentPowerMode } }