All checks were successful
release / build-release (push) Successful in 4m10s
1. Start väntar på GPS-fix: klockan startar vid första fixen eller via 'Starta ändå'-knappen (fasen SEARCHING_GPS → ACTIVE); tid utan fix räknas inte längre 2. Offline-sparning: aktiviteter (spårade + manuellt loggade) köas i Room (pending_activity, migration v5) och synkas av samma FIFO-kö som passen (OpKind.ADD_ACTIVITY); 'väntar på synk'-sektion i aktivitetslistan 3. Teknisk logg (TrackLog): ringbuffert med GPS-fixar (accepterade/förkastade + noggrannhet), fasbyten och sparningar — expanderbar på spårskärmen med 'Dela loggen'-knapp för enkel fältfelsökning Version 0.8.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2GA94E1f3cmrvdQLQhYdg
339 lines
13 KiB
Kotlin
339 lines
13 KiB
Kotlin
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<Pair<Double, Double>> = 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<Double>()
|
|
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<TrackingState> = _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 }
|
|
)
|
|
}
|
|
}
|
|
}
|