diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6d7e26d..b94ca06 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,7 +29,7 @@ android { minSdk = 31 targetSdk = 35 versionCode = ciRunNumber ?: 1 - versionName = "0.8.2" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") + versionName = "0.8.3" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") } signingConfigs { diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt index 228e88b..2e84821 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt @@ -34,6 +34,12 @@ data class AppSettings( val isFemale: Boolean? = null, /** Visa teknisk logg på spårskärmen (felsökning) */ val trackLogEnabled: Boolean = false, + /** GPS-filter: förkasta fixar med sämre noggrannhet än detta (meter) */ + val gpsAccuracyLimitM: Int = 35, + /** GPS-filter: max tillåten positionsfart som multipel av Doppler-farten */ + val gpsSpeedFactor: Double = 2.0, + /** GPS-filter: fartgolv (km/h) när Doppler-fart saknas/är noll */ + val gpsSpeedFloorKmh: Int = 12, ) class SettingsStore(private val context: Context) { @@ -61,9 +67,24 @@ class SettingsStore(private val context: Context) { birthYear = prefs[KEY_BIRTH_YEAR], isFemale = prefs[KEY_IS_FEMALE]?.toBooleanStrictOrNull(), trackLogEnabled = prefs[KEY_TRACK_LOG]?.toBooleanStrictOrNull() ?: false, + gpsAccuracyLimitM = prefs[KEY_GPS_ACC] ?: 35, + gpsSpeedFactor = prefs[KEY_GPS_FACTOR]?.toDoubleOrNull() ?: 2.0, + gpsSpeedFloorKmh = prefs[KEY_GPS_FLOOR] ?: 12, ) } + suspend fun setGpsAccuracyLimit(value: Int) { + context.settingsDataStore.edit { it[KEY_GPS_ACC] = value.coerceIn(10, 100) } + } + + suspend fun setGpsSpeedFactor(value: Double) { + context.settingsDataStore.edit { it[KEY_GPS_FACTOR] = value.coerceIn(1.2, 5.0).toString() } + } + + suspend fun setGpsSpeedFloor(value: Int) { + context.settingsDataStore.edit { it[KEY_GPS_FLOOR] = value.coerceIn(4, 40) } + } + suspend fun setTrackLogEnabled(value: Boolean) { context.settingsDataStore.edit { it[KEY_TRACK_LOG] = value.toString() } } @@ -140,5 +161,8 @@ class SettingsStore(private val context: Context) { private val KEY_BIRTH_YEAR = intPreferencesKey("birth_year") private val KEY_IS_FEMALE = stringPreferencesKey("is_female") private val KEY_TRACK_LOG = stringPreferencesKey("track_log_enabled") + private val KEY_GPS_ACC = intPreferencesKey("gps_accuracy_limit_m") + private val KEY_GPS_FACTOR = stringPreferencesKey("gps_speed_factor") + private val KEY_GPS_FLOOR = intPreferencesKey("gps_speed_floor_kmh") } } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackingService.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackingService.kt index 31ae986..3c7f3ca 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackingService.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/TrackingService.kt @@ -69,6 +69,13 @@ class TrackingService : Service(), LocationListener { private var weightKg: Double? = null private var activeSinceElapsed = 0L private var lastLocation: Location? = null + // GPS-filter (justerbara i inställningarna, skickas med vid start) + private var accuracyLimitM = 35f + private var speedFactor = 2.0 + private var speedFloorMs = 12.0 / 3.6 + // Glidande Doppler-fart (m/s) — GPS:ens egen fartmätning, stabilare än positionerna + private var dopplerEmaMs = 0.0 + private var rejectStreak = 0 // Glidande medel av höjd för att filtrera GPS-brus innan höjdmeter summeras private val altitudeWindow = ArrayDeque() private var smoothedAltitude: Double? = null @@ -99,6 +106,11 @@ class TrackingService : Service(), LocationListener { private fun startTracking(intent: Intent) { val distanceBased = intent.getBooleanExtra(EXTRA_DISTANCE_BASED, false) weightKg = intent.getDoubleExtra(EXTRA_WEIGHT_KG, -1.0).takeIf { it > 0 } + accuracyLimitM = intent.getIntExtra(EXTRA_GPS_ACC_LIMIT, 35).toFloat() + speedFactor = intent.getDoubleExtra(EXTRA_GPS_SPEED_FACTOR, 2.0) + speedFloorMs = intent.getIntExtra(EXTRA_GPS_SPEED_FLOOR, 12) / 3.6 + dopplerEmaMs = 0.0 + rejectStreak = 0 _state.value = TrackingState( isActive = true, @@ -116,6 +128,12 @@ class TrackingService : Service(), LocationListener { "start: ${_state.value.typeName} (distans=$distanceBased, vikt=${weightKg ?: "?"} kg)" + if (distanceBased) " — väntar på GPS-fix" else "" ) + if (distanceBased) { + TrackLog.log( + "filter: acc≤${accuracyLimitM.toInt()} m, fartgrind ${speedFactor}× Doppler " + + "(golv ${"%.0f".format(speedFloorMs * 3.6)} km/h)" + ) + } val type = if (distanceBased) { ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION @@ -217,11 +235,45 @@ class TrackingService : Service(), LocationListener { // Position för kartan uppdateras alltid, oavsett kvalitet _state.value = _state.value.copy(currentPosition = location.latitude to location.longitude) - // Spårpunkter kräver hygglig noggrannhet - if (location.hasAccuracy() && location.accuracy > 35f) { - TrackLog.log("${location.provider}: förkastad för spåret, acc=${"%.0f".format(acc)} m (>35)") + // Grind 1: spårpunkter kräver hygglig noggrannhet + if (location.hasAccuracy() && location.accuracy > accuracyLimitM) { + TrackLog.log("${location.provider}: förkastad, acc=${"%.0f".format(acc)} m (>${accuracyLimitM.toInt()})") return } + + // Uppdatera glidande Doppler-fart (GPS:ens fartmätning via frekvensskift) + if (location.hasSpeed()) { + dopplerEmaMs = if (dopplerEmaMs == 0.0) location.speed.toDouble() + else 0.7 * dopplerEmaMs + 0.3 * location.speed + } + + // Grind 2 (fartgrinden): en ny position som skulle kräva högre fart än + // speedFactor × Doppler-farten är orimlig — GPS-hopp, inte förflyttning. + lastLocation?.let { prev -> + val dtSec = (location.elapsedRealtimeNanos - prev.elapsedRealtimeNanos) / 1e9 + if (dtSec > 0.3) { + val jump = prev.distanceTo(location).toDouble() + val impliedMs = jump / dtSec + val allowedMs = maxOf(speedFloorMs, dopplerEmaMs * speedFactor) + if (impliedMs > allowedMs) { + rejectStreak++ + TrackLog.log( + "${location.provider}: fartgrind — hopp ${"%.0f".format(jump)} m på ${"%.1f".format(dtSec)} s " + + "= ${"%.1f".format(impliedMs * 3.6)} km/h (tillåtet ${"%.1f".format(allowedMs * 3.6)}), förkastad ($rejectStreak)" + ) + // Många orimliga i rad = det är NYA positionen som är sann + // (t.ex. efter tunnel) — sätt ny baslinje utan att räkna distans. + if (rejectStreak >= 4) { + TrackLog.log("fartgrind: ny baslinje efter $rejectStreak förkastade") + lastLocation = location + rejectStreak = 0 + } + return + } + } + } + rejectStreak = 0 + TrackLog.log( "${location.provider}: fix acc=${"%.0f".format(acc)} m" + " alt=${if (location.hasAltitude()) "%.0f".format(location.altitude) else "?"}" + @@ -232,10 +284,13 @@ class TrackingService : Service(), LocationListener { var distance = s.distanceMeters var elevGain = s.elevationGainMeters + var acceptedAsMovement = true lastLocation?.let { prev -> val d = prev.distanceTo(location).toDouble() - // Ignorera mikrobrus när man står stilla - if (d >= 2.0) distance += d + // Grind 3 (stillastående): vandring mindre än halva noggrannheten + // är brus, inte förflyttning — räkna varken distans eller spårpunkt. + val minMove = maxOf(2.0, (if (location.hasAccuracy()) location.accuracy else 5f) * 0.5) + if (d >= minMove) distance += d else acceptedAsMovement = false } if (location.hasAltitude()) { @@ -257,7 +312,9 @@ class TrackingService : Service(), LocationListener { _state.value = cur.copy( distanceMeters = distance, elevationGainMeters = elevGain, - points = cur.points + (location.latitude to location.longitude), + points = if (acceptedAsMovement || cur.points.isEmpty()) { + cur.points + (location.latitude to location.longitude) + } else cur.points, currentSpeedKmh = if (location.hasSpeed()) location.speed * 3.6 else null, gpsFix = true, ) @@ -333,12 +390,22 @@ class TrackingService : Service(), LocationListener { const val EXTRA_MET = "met" const val EXTRA_DISTANCE_BASED = "distanceBased" const val EXTRA_WEIGHT_KG = "weightKg" + const val EXTRA_GPS_ACC_LIMIT = "gpsAccLimit" + const val EXTRA_GPS_SPEED_FACTOR = "gpsSpeedFactor" + const val EXTRA_GPS_SPEED_FLOOR = "gpsSpeedFloor" 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?) { + fun start( + context: Context, + type: ActivityType, + weightKg: Double?, + gpsAccuracyLimitM: Int = 35, + gpsSpeedFactor: Double = 2.0, + gpsSpeedFloorKmh: Int = 12, + ) { val intent = Intent(context, TrackingService::class.java).apply { putExtra(EXTRA_TYPE_ID, type.id) putExtra(EXTRA_TYPE_KEY, type.key) @@ -347,6 +414,9 @@ class TrackingService : Service(), LocationListener { putExtra(EXTRA_MET, type.met) putExtra(EXTRA_DISTANCE_BASED, type.isDistanceBased) weightKg?.let { putExtra(EXTRA_WEIGHT_KG, it) } + putExtra(EXTRA_GPS_ACC_LIMIT, gpsAccuracyLimitM) + putExtra(EXTRA_GPS_SPEED_FACTOR, gpsSpeedFactor) + putExtra(EXTRA_GPS_SPEED_FLOOR, gpsSpeedFloorKmh) } context.startForegroundService(intent) } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt index 0747516..bf7c789 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt @@ -73,6 +73,16 @@ class SettingsViewModel( fun setTrackLog(value: Boolean) = viewModelScope.launch { store.setTrackLogEnabled(value) } + fun adjustGpsAccuracy(delta: Int) = viewModelScope.launch { + store.setGpsAccuracyLimit(settings.value.gpsAccuracyLimitM + delta) + } + fun adjustGpsSpeedFactor(delta: Double) = viewModelScope.launch { + store.setGpsSpeedFactor(settings.value.gpsSpeedFactor + delta) + } + fun adjustGpsSpeedFloor(delta: Int) = viewModelScope.launch { + store.setGpsSpeedFloor(settings.value.gpsSpeedFloorKmh + delta) + } + fun setAutoRelogin(value: Boolean) = viewModelScope.launch { store.setAutoRelogin(value) // Stängs funktionen av slängs de sparade uppgifterna direkt. @@ -147,6 +157,41 @@ fun SettingsScreen( } } + SettingsCard(title = "GPS-spårning") { + Text( + "Filter mot GPS-brus i aktivitetsspårningen. Fartgrinden förkastar " + + "positionshopp som skulle kräva högre fart än faktorn × GPS:ens " + + "egen fartmätning (Doppler).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + StepperRow( + label = "Noggrannhetsgräns", + value = "${settings.gpsAccuracyLimitM} m", + onMinus = { viewModel.adjustGpsAccuracy(-5) }, + onPlus = { viewModel.adjustGpsAccuracy(5) }, + ) + StepperRow( + label = "Fartgrind (× uppmätt fart)", + value = "${settings.gpsSpeedFactor}×", + onMinus = { viewModel.adjustGpsSpeedFactor(-0.5) }, + onPlus = { viewModel.adjustGpsSpeedFactor(0.5) }, + ) + StepperRow( + label = "Fartgolv", + value = "${settings.gpsSpeedFloorKmh} km/h", + onMinus = { viewModel.adjustGpsSpeedFloor(-2) }, + onPlus = { viewModel.adjustGpsSpeedFloor(2) }, + ) + Text( + "Gäller från nästa startade aktivitet. Standard: 35 m / 2,0× / 12 km/h.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + SettingsCard(title = "Felsökning") { Row( modifier = Modifier @@ -378,6 +423,30 @@ private fun SettingsCard(title: String, content: @Composable () -> Unit) { } } +@Composable +private fun StepperRow( + label: String, + value: String, + onMinus: () -> Unit, + onPlus: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge) + FilledTonalButton(onClick = onMinus) { Text("−") } + Text( + value, + modifier = Modifier.padding(horizontal = 10.dp), + style = MaterialTheme.typography.titleSmall, + ) + FilledTonalButton(onClick = onPlus) { Text("+") } + } +} + @Composable private fun RadioRow(label: String, description: String, selected: Boolean, onClick: () -> Unit) { Row( diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/track/TrackScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/track/TrackScreen.kt index 1969187..9679246 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/track/TrackScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/track/TrackScreen.kt @@ -67,6 +67,7 @@ import eu.brassepc.fitnessdroid.ui.appContainer import eu.brassepc.fitnessdroid.ui.common.compact import eu.brassepc.fitnessdroid.ui.common.openAppSettings import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.osmdroid.config.Configuration @@ -80,7 +81,7 @@ import java.time.Instant class TrackViewModel( private val gymApi: GymApi, private val repo: GymRepository, - settingsStore: eu.brassepc.fitnessdroid.data.SettingsStore, + private val settingsStore: eu.brassepc.fitnessdroid.data.SettingsStore, ) : ViewModel() { val tracking = TrackingService.state /** Teknisk logg visas bara om den slagits på i inställningarna. */ @@ -110,7 +111,13 @@ class TrackViewModel( fun start(context: android.content.Context) { val type = pendingType.value ?: return viewModelScope.launch { - TrackingService.start(context, type, repo.bodyWeightKg()) + val cfg = settingsStore.settings.first() + TrackingService.start( + context, type, repo.bodyWeightKg(), + gpsAccuracyLimitM = cfg.gpsAccuracyLimitM, + gpsSpeedFactor = cfg.gpsSpeedFactor, + gpsSpeedFloorKmh = cfg.gpsSpeedFloorKmh, + ) } }