package eu.brassepc.fitnessdroid.data import android.Manifest 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.content.pm.PackageManager import android.content.pm.ServiceInfo import android.location.Location import android.location.LocationListener import android.location.LocationManager import android.os.Looper import android.os.SystemClock import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import eu.brassepc.fitnessdroid.MainActivity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch /** Spårningens fas: söker GPS-fix innan klockan startar, sen aktiv. */ enum class TrackingPhase { IDLE, SEARCHING_GPS, ACTIVE } /** Läget för en pågående (eller nyss avslutad) aktivitetsspårning. */ data class TrackingState( val isActive: Boolean = false, val phase: TrackingPhase = TrackingPhase.IDLE, /** Aktivitetstypen som spåras */ val typeId: Int = 0, val typeKey: String = "", val typeName: String = "", val category: String = "OTHER", val met: Double = 4.0, val isDistanceBased: Boolean = false, val startedAtEpochMs: Long = 0, val elapsedSeconds: Int = 0, val distanceMeters: Double = 0.0, val elevationGainMeters: Double = 0.0, /** Senaste GPS-position (lat, lon) + hela spåret */ val points: List> = emptyList(), val currentSpeedKmh: Double? = null, val kcal: Double? = null, val gpsFix: Boolean = false, ) /** * Foreground-service som spårar en aktivitet: GPS-rutt, distans, höjdmeter, * tid och live-kcal. För icke-distansaktiviteter (fäktning m.m.) körs bara * timern — ingen GPS. UI:t läser [state]; servicen äger sanningen så * spårningen överlever att appen swipas bort från recents. */ class TrackingService : Service(), LocationListener { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private var locationManager: LocationManager? = null private var weightKg: Double? = null private var activeSinceElapsed = 0L private var lastLocation: Location? = null // Glidande medel av höjd för att filtrera GPS-brus innan höjdmeter summeras private val altitudeWindow = ArrayDeque() private var smoothedAltitude: Double? = null override fun onCreate() { super.onCreate() createChannel() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_STOP -> { stopTracking() return START_NOT_STICKY } ACTION_START_NOW -> { activate("starta ändå") return START_STICKY } else -> { if (intent == null) return START_NOT_STICKY startTracking(intent) } } return START_STICKY } private fun startTracking(intent: Intent) { val distanceBased = intent.getBooleanExtra(EXTRA_DISTANCE_BASED, false) weightKg = intent.getDoubleExtra(EXTRA_WEIGHT_KG, -1.0).takeIf { it > 0 } _state.value = TrackingState( isActive = true, phase = if (distanceBased) TrackingPhase.SEARCHING_GPS else TrackingPhase.ACTIVE, typeId = intent.getIntExtra(EXTRA_TYPE_ID, 0), typeKey = intent.getStringExtra(EXTRA_TYPE_KEY) ?: "", typeName = intent.getStringExtra(EXTRA_TYPE_NAME) ?: "Aktivitet", category = intent.getStringExtra(EXTRA_CATEGORY) ?: "OTHER", met = intent.getDoubleExtra(EXTRA_MET, 4.0), isDistanceBased = distanceBased, startedAtEpochMs = System.currentTimeMillis(), ) TrackLog.clear() TrackLog.log( "start: ${_state.value.typeName} (distans=$distanceBased, vikt=${weightKg ?: "?"} kg)" + if (distanceBased) " — väntar på GPS-fix" else "" ) val type = if (distanceBased) { ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION } else { // Timer-läge behöver ingen plats, men servicetypen är deklarerad som location ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION } startForeground(NOTIFICATION_ID, buildNotification(), type) if (distanceBased) startGps() // Tick: tid + kcal + notis. Klockan räknar från att fasen blev ACTIVE. scope.launch { while (_state.value.isActive) { val s = _state.value if (s.phase != TrackingPhase.ACTIVE) { delay(500) continue } if (activeSinceElapsed == 0L) activeSinceElapsed = SystemClock.elapsedRealtime() val elapsed = ((SystemClock.elapsedRealtime() - activeSinceElapsed) / 1000).toInt() _state.value = s.copy( elapsedSeconds = elapsed, kcal = ActivityKcal.estimate( met = s.met, category = s.category, isDistanceBased = s.isDistanceBased, weightKg = weightKg, durationSeconds = elapsed, distanceMeters = s.distanceMeters.takeIf { it > 0 }, elevationGainMeters = s.elevationGainMeters.takeIf { it > 0 }, rpe = null, ), ) if (elapsed % 10 == 0) { getSystemService(NotificationManager::class.java) .notify(NOTIFICATION_ID, buildNotification()) } delay(1000) } } } private fun startGps() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED ) { TrackLog.log("FEL: platsbehörighet saknas — ingen GPS-lyssning") return } locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager TrackLog.log("GPS-lyssning begärd (GPS_PROVIDER, 2 s / 3 m)") runCatching { locationManager?.requestLocationUpdates( LocationManager.GPS_PROVIDER, 2000L, // ms 3f, // meter this, Looper.getMainLooper(), ) } } /** Gå från GPS-sökning till aktiv (klockan börjar ticka). */ private fun activate(reason: String) { val s = _state.value if (!s.isActive || s.phase == TrackingPhase.ACTIVE) return TrackLog.log("aktiverad ($reason)") _state.value = s.copy( phase = TrackingPhase.ACTIVE, startedAtEpochMs = System.currentTimeMillis(), ) } override fun onLocationChanged(location: Location) { val s = _state.value if (!s.isActive || !s.isDistanceBased) return val acc = if (location.hasAccuracy()) location.accuracy else -1f // Släng riktigt dåliga fixar if (location.hasAccuracy() && location.accuracy > 30f) { TrackLog.log("fix förkastad: noggrannhet ${"%.0f".format(acc)} m (>30)") return } TrackLog.log( "fix: acc=${"%.0f".format(acc)} m alt=${if (location.hasAltitude()) "%.0f".format(location.altitude) else "?"}" + " fart=${if (location.hasSpeed()) "%.1f".format(location.speed * 3.6) else "?"} km/h" ) if (s.phase == TrackingPhase.SEARCHING_GPS) activate("GPS-fix") var distance = s.distanceMeters var elevGain = s.elevationGainMeters lastLocation?.let { prev -> val d = prev.distanceTo(location).toDouble() // Ignorera mikrobrus när man står stilla if (d >= 2.0) distance += d } if (location.hasAltitude()) { altitudeWindow.addLast(location.altitude) if (altitudeWindow.size > 5) altitudeWindow.removeFirst() val avg = altitudeWindow.average() smoothedAltitude?.let { prevAlt -> val delta = avg - prevAlt // Bara tydliga stigningar räknas som höjdmeter if (delta > 1.0) elevGain += delta } smoothedAltitude = avg } lastLocation = location _state.value = s.copy( distanceMeters = distance, elevationGainMeters = elevGain, points = s.points + (location.latitude to location.longitude), currentSpeedKmh = if (location.hasSpeed()) location.speed * 3.6 else null, gpsFix = true, ) } private fun stopTracking() { val s = _state.value TrackLog.log( "stopp: ${s.elapsedSeconds}s, ${"%.0f".format(s.distanceMeters)} m, " + "+${"%.0f".format(s.elevationGainMeters)} hm, ${s.points.size} punkter, kcal=${s.kcal?.toInt() ?: "?"}" ) locationManager?.removeUpdates(this) _state.value = _state.value.copy(isActive = false) stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } override fun onDestroy() { locationManager?.removeUpdates(this) if (_state.value.isActive) _state.value = _state.value.copy(isActive = false) scope.cancel() super.onDestroy() } override fun onBind(intent: Intent?) = null private fun createChannel() { val channel = NotificationChannel( CHANNEL_ID, "Aktivitetsspårning", NotificationManager.IMPORTANCE_LOW, ).apply { description = "Pågående aktivitet (GPS/timer)" } getSystemService(NotificationManager::class.java).createNotificationChannel(channel) } private fun buildNotification(): Notification { val s = _state.value val open = PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE, ) val text = buildString { append(formatElapsed(s.elapsedSeconds)) if (s.isDistanceBased) append(" · ${"%.2f".format(s.distanceMeters / 1000)} km") s.kcal?.let { append(" · ${it.toInt()} kcal") } } return NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(android.R.drawable.ic_menu_mylocation) .setContentTitle(s.typeName) .setContentText(text) .setOngoing(true) .setOnlyAlertOnce(true) .setContentIntent(open) .build() } private fun formatElapsed(sec: Int): String { val h = sec / 3600 val m = (sec % 3600) / 60 val s = sec % 60 return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s) } companion object { private const val CHANNEL_ID = "activity_tracking" private const val NOTIFICATION_ID = 44 const val ACTION_STOP = "eu.brassepc.fitnessdroid.TRACKING_STOP" const val ACTION_START_NOW = "eu.brassepc.fitnessdroid.TRACKING_START_NOW" const val EXTRA_TYPE_ID = "typeId" const val EXTRA_TYPE_KEY = "typeKey" const val EXTRA_TYPE_NAME = "typeName" const val EXTRA_CATEGORY = "category" const val EXTRA_MET = "met" const val EXTRA_DISTANCE_BASED = "distanceBased" const val EXTRA_WEIGHT_KG = "weightKg" private val _state = MutableStateFlow(TrackingState()) /** Läses av UI:t — servicen äger sanningen. */ val state: StateFlow = _state.asStateFlow() fun start(context: Context, type: ActivityType, weightKg: Double?) { val intent = Intent(context, TrackingService::class.java).apply { putExtra(EXTRA_TYPE_ID, type.id) putExtra(EXTRA_TYPE_KEY, type.key) putExtra(EXTRA_TYPE_NAME, type.nameSv) putExtra(EXTRA_CATEGORY, type.category) putExtra(EXTRA_MET, type.met) putExtra(EXTRA_DISTANCE_BASED, type.isDistanceBased) weightKg?.let { putExtra(EXTRA_WEIGHT_KG, it) } } context.startForegroundService(intent) } fun stop(context: Context) { context.startService( Intent(context, TrackingService::class.java).apply { action = ACTION_STOP } ) } /** "Starta ändå" — börja räkna tid utan att vänta på GPS-fixen. */ fun startNow(context: Context) { context.startService( Intent(context, TrackingService::class.java).apply { action = ACTION_START_NOW } ) } } }