Rörelsevakt: accelerometern fryser spåret när man står stilla
All checks were successful
release / build-release (push) Successful in 4m19s

GPS-drift vid stillastående gav fejkspår/-distans/-höjdmeter (och 2 km/h
snittfart från en soffa). Nu mäter accelerometern vibrationsmönstret
(glidande 4 s-fönster av |accel|−g): under tröskeln = stilla → kartpositionen
uppdateras men spårpunkter, distans och höjdmeter fryses. GPS-rutan visar
'stilla'. Övergångar loggas i tekniska loggen med uppmätt nivå.

Inställningar → GPS-spårning: av/på-switch + känslighet (0,35 m/s² standard,
justerbar 0,10–1,50). Version 0.8.4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C2GA94E1f3cmrvdQLQhYdg
This commit is contained in:
2026-08-26 08:01:38 +02:00
parent 33a8e71ed1
commit 53e4f14b67
5 changed files with 138 additions and 5 deletions

View File

@@ -29,7 +29,7 @@ android {
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = ciRunNumber ?: 1 versionCode = ciRunNumber ?: 1
versionName = "0.8.3" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") versionName = "0.8.4" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
} }
signingConfigs { signingConfigs {

View File

@@ -40,6 +40,10 @@ data class AppSettings(
val gpsSpeedFactor: Double = 2.0, val gpsSpeedFactor: Double = 2.0,
/** GPS-filter: fartgolv (km/h) när Doppler-fart saknas/är noll */ /** GPS-filter: fartgolv (km/h) när Doppler-fart saknas/är noll */
val gpsSpeedFloorKmh: Int = 12, val gpsSpeedFloorKmh: Int = 12,
/** Rörelsevakt: frys spår/distans när accelerometern säger stilla */
val motionGuardEnabled: Boolean = true,
/** Rörelsevaktens känslighet (m/s² avvikelse från vila; lägre = känsligare) */
val motionThreshold: Double = 0.35,
) )
class SettingsStore(private val context: Context) { class SettingsStore(private val context: Context) {
@@ -70,9 +74,21 @@ class SettingsStore(private val context: Context) {
gpsAccuracyLimitM = prefs[KEY_GPS_ACC] ?: 35, gpsAccuracyLimitM = prefs[KEY_GPS_ACC] ?: 35,
gpsSpeedFactor = prefs[KEY_GPS_FACTOR]?.toDoubleOrNull() ?: 2.0, gpsSpeedFactor = prefs[KEY_GPS_FACTOR]?.toDoubleOrNull() ?: 2.0,
gpsSpeedFloorKmh = prefs[KEY_GPS_FLOOR] ?: 12, gpsSpeedFloorKmh = prefs[KEY_GPS_FLOOR] ?: 12,
motionGuardEnabled = prefs[KEY_MOTION_GUARD]?.toBooleanStrictOrNull() ?: true,
motionThreshold = prefs[KEY_MOTION_THRESHOLD]?.toDoubleOrNull() ?: 0.35,
) )
} }
suspend fun setMotionGuardEnabled(value: Boolean) {
context.settingsDataStore.edit { it[KEY_MOTION_GUARD] = value.toString() }
}
suspend fun setMotionThreshold(value: Double) {
context.settingsDataStore.edit {
it[KEY_MOTION_THRESHOLD] = (Math.round(value.coerceIn(0.1, 1.5) * 100) / 100.0).toString()
}
}
suspend fun setGpsAccuracyLimit(value: Int) { suspend fun setGpsAccuracyLimit(value: Int) {
context.settingsDataStore.edit { it[KEY_GPS_ACC] = value.coerceIn(10, 100) } context.settingsDataStore.edit { it[KEY_GPS_ACC] = value.coerceIn(10, 100) }
} }
@@ -164,5 +180,7 @@ class SettingsStore(private val context: Context) {
private val KEY_GPS_ACC = intPreferencesKey("gps_accuracy_limit_m") private val KEY_GPS_ACC = intPreferencesKey("gps_accuracy_limit_m")
private val KEY_GPS_FACTOR = stringPreferencesKey("gps_speed_factor") private val KEY_GPS_FACTOR = stringPreferencesKey("gps_speed_factor")
private val KEY_GPS_FLOOR = intPreferencesKey("gps_speed_floor_kmh") private val KEY_GPS_FLOOR = intPreferencesKey("gps_speed_floor_kmh")
private val KEY_MOTION_GUARD = stringPreferencesKey("motion_guard_enabled")
private val KEY_MOTION_THRESHOLD = stringPreferencesKey("motion_threshold")
} }
} }

View File

@@ -10,6 +10,10 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.location.Location import android.location.Location
import android.location.LocationListener import android.location.LocationListener
import android.location.LocationManager import android.location.LocationManager
@@ -53,6 +57,8 @@ data class TrackingState(
val currentSpeedKmh: Double? = null, val currentSpeedKmh: Double? = null,
val kcal: Double? = null, val kcal: Double? = null,
val gpsFix: Boolean = false, val gpsFix: Boolean = false,
/** false = accelerometern säger stilla → spår/distans fryst */
val isMoving: Boolean = true,
) )
/** /**
@@ -61,7 +67,7 @@ data class TrackingState(
* timern — ingen GPS. UI:t läser [state]; servicen äger sanningen så * timern — ingen GPS. UI:t läser [state]; servicen äger sanningen så
* spårningen överlever att appen swipas bort från recents. * spårningen överlever att appen swipas bort från recents.
*/ */
class TrackingService : Service(), LocationListener { class TrackingService : Service(), LocationListener, SensorEventListener {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var locationManager: LocationManager? = null private var locationManager: LocationManager? = null
@@ -76,6 +82,13 @@ class TrackingService : Service(), LocationListener {
// Glidande Doppler-fart (m/s) — GPS:ens egen fartmätning, stabilare än positionerna // Glidande Doppler-fart (m/s) — GPS:ens egen fartmätning, stabilare än positionerna
private var dopplerEmaMs = 0.0 private var dopplerEmaMs = 0.0
private var rejectStreak = 0 private var rejectStreak = 0
// Rörelsevakt (accelerometer): står man stilla fryses spår/distans/höjd
private var sensorManager: SensorManager? = null
private var motionGuard = true
private var motionThreshold = 0.35
private val motionWindow = ArrayDeque<Double>()
private var isMoving = true
private var lastMotionLogged = true
// Glidande medel av höjd för att filtrera GPS-brus innan höjdmeter summeras // Glidande medel av höjd för att filtrera GPS-brus innan höjdmeter summeras
private val altitudeWindow = ArrayDeque<Double>() private val altitudeWindow = ArrayDeque<Double>()
private var smoothedAltitude: Double? = null private var smoothedAltitude: Double? = null
@@ -109,8 +122,13 @@ class TrackingService : Service(), LocationListener {
accuracyLimitM = intent.getIntExtra(EXTRA_GPS_ACC_LIMIT, 35).toFloat() accuracyLimitM = intent.getIntExtra(EXTRA_GPS_ACC_LIMIT, 35).toFloat()
speedFactor = intent.getDoubleExtra(EXTRA_GPS_SPEED_FACTOR, 2.0) speedFactor = intent.getDoubleExtra(EXTRA_GPS_SPEED_FACTOR, 2.0)
speedFloorMs = intent.getIntExtra(EXTRA_GPS_SPEED_FLOOR, 12) / 3.6 speedFloorMs = intent.getIntExtra(EXTRA_GPS_SPEED_FLOOR, 12) / 3.6
motionGuard = intent.getBooleanExtra(EXTRA_MOTION_GUARD, true)
motionThreshold = intent.getDoubleExtra(EXTRA_MOTION_THRESHOLD, 0.35)
dopplerEmaMs = 0.0 dopplerEmaMs = 0.0
rejectStreak = 0 rejectStreak = 0
motionWindow.clear()
isMoving = true
lastMotionLogged = true
_state.value = TrackingState( _state.value = TrackingState(
isActive = true, isActive = true,
@@ -143,7 +161,10 @@ class TrackingService : Service(), LocationListener {
} }
startForeground(NOTIFICATION_ID, buildNotification(), type) startForeground(NOTIFICATION_ID, buildNotification(), type)
if (distanceBased) startGps() if (distanceBased) {
startGps()
if (motionGuard) startMotionGuard()
}
// Tick: tid + kcal + notis. Klockan räknar från att fasen blev ACTIVE. // Tick: tid + kcal + notis. Klockan räknar från att fasen blev ACTIVE.
scope.launch { scope.launch {
@@ -216,6 +237,44 @@ class TrackingService : Service(), LocationListener {
} }
} }
private fun startMotionGuard() {
val sm = getSystemService(Context.SENSOR_SERVICE) as SensorManager
sensorManager = sm
val accel = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
if (accel == null) {
TrackLog.log("rörelsevakt: ingen accelerometer — avstängd")
motionGuard = false
return
}
sm.registerListener(this, accel, SensorManager.SENSOR_DELAY_UI)
TrackLog.log("rörelsevakt: på (tröskel ${motionThreshold} m/s²)")
}
override fun onSensorChanged(event: SensorEvent) {
if (event.sensor.type != Sensor.TYPE_ACCELEROMETER) return
val x = event.values[0].toDouble()
val y = event.values[1].toDouble()
val z = event.values[2].toDouble()
// Avvikelse från vilomagnituden (gravitationen) — steg ger tydliga utslag
val deviation = kotlin.math.abs(kotlin.math.sqrt(x * x + y * y + z * z) - 9.81)
motionWindow.addLast(deviation)
if (motionWindow.size > 20) motionWindow.removeFirst() // ~4 s vid UI-takt
if (motionWindow.size < 8) return
val avg = motionWindow.average()
val moving = avg > motionThreshold
if (moving != isMoving) {
isMoving = moving
_state.value = _state.value.copy(isMoving = moving)
if (moving != lastMotionLogged) {
TrackLog.log("rörelsevakt: ${if (moving) "i rörelse" else "stilla"} (nivå ${"%.2f".format(avg)})")
lastMotionLogged = moving
}
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
/** Gå från GPS-sökning till aktiv (klockan börjar ticka). */ /** Gå från GPS-sökning till aktiv (klockan börjar ticka). */
private fun activate(reason: String) { private fun activate(reason: String) {
val s = _state.value val s = _state.value
@@ -274,6 +333,13 @@ class TrackingService : Service(), LocationListener {
} }
rejectStreak = 0 rejectStreak = 0
// Rörelsevakten: stilla ⇒ uppdatera bara kartpositionen, frys resten.
// GPS-drift när man står still blir annars fejkdistans/-spår/-höjdmeter.
if (motionGuard && !isMoving) {
lastLocation = location
return
}
TrackLog.log( TrackLog.log(
"${location.provider}: fix acc=${"%.0f".format(acc)} m" + "${location.provider}: fix acc=${"%.0f".format(acc)} m" +
" alt=${if (location.hasAltitude()) "%.0f".format(location.altitude) else "?"}" + " alt=${if (location.hasAltitude()) "%.0f".format(location.altitude) else "?"}" +
@@ -321,6 +387,7 @@ class TrackingService : Service(), LocationListener {
} }
private fun stopTracking() { private fun stopTracking() {
sensorManager?.unregisterListener(this)
val s = _state.value val s = _state.value
TrackLog.log( TrackLog.log(
"stopp: ${s.elapsedSeconds}s, ${"%.0f".format(s.distanceMeters)} m, " + "stopp: ${s.elapsedSeconds}s, ${"%.0f".format(s.distanceMeters)} m, " +
@@ -333,6 +400,7 @@ class TrackingService : Service(), LocationListener {
} }
override fun onDestroy() { override fun onDestroy() {
sensorManager?.unregisterListener(this)
locationManager?.removeUpdates(this) locationManager?.removeUpdates(this)
if (_state.value.isActive) _state.value = _state.value.copy(isActive = false) if (_state.value.isActive) _state.value = _state.value.copy(isActive = false)
scope.cancel() scope.cancel()
@@ -393,6 +461,8 @@ class TrackingService : Service(), LocationListener {
const val EXTRA_GPS_ACC_LIMIT = "gpsAccLimit" const val EXTRA_GPS_ACC_LIMIT = "gpsAccLimit"
const val EXTRA_GPS_SPEED_FACTOR = "gpsSpeedFactor" const val EXTRA_GPS_SPEED_FACTOR = "gpsSpeedFactor"
const val EXTRA_GPS_SPEED_FLOOR = "gpsSpeedFloor" const val EXTRA_GPS_SPEED_FLOOR = "gpsSpeedFloor"
const val EXTRA_MOTION_GUARD = "motionGuard"
const val EXTRA_MOTION_THRESHOLD = "motionThreshold"
private val _state = MutableStateFlow(TrackingState()) private val _state = MutableStateFlow(TrackingState())
/** Läses av UI:t — servicen äger sanningen. */ /** Läses av UI:t — servicen äger sanningen. */
@@ -405,6 +475,8 @@ class TrackingService : Service(), LocationListener {
gpsAccuracyLimitM: Int = 35, gpsAccuracyLimitM: Int = 35,
gpsSpeedFactor: Double = 2.0, gpsSpeedFactor: Double = 2.0,
gpsSpeedFloorKmh: Int = 12, gpsSpeedFloorKmh: Int = 12,
motionGuardEnabled: Boolean = true,
motionThreshold: Double = 0.35,
) { ) {
val intent = Intent(context, TrackingService::class.java).apply { val intent = Intent(context, TrackingService::class.java).apply {
putExtra(EXTRA_TYPE_ID, type.id) putExtra(EXTRA_TYPE_ID, type.id)
@@ -417,6 +489,8 @@ class TrackingService : Service(), LocationListener {
putExtra(EXTRA_GPS_ACC_LIMIT, gpsAccuracyLimitM) putExtra(EXTRA_GPS_ACC_LIMIT, gpsAccuracyLimitM)
putExtra(EXTRA_GPS_SPEED_FACTOR, gpsSpeedFactor) putExtra(EXTRA_GPS_SPEED_FACTOR, gpsSpeedFactor)
putExtra(EXTRA_GPS_SPEED_FLOOR, gpsSpeedFloorKmh) putExtra(EXTRA_GPS_SPEED_FLOOR, gpsSpeedFloorKmh)
putExtra(EXTRA_MOTION_GUARD, motionGuardEnabled)
putExtra(EXTRA_MOTION_THRESHOLD, motionThreshold)
} }
context.startForegroundService(intent) context.startForegroundService(intent)
} }

View File

@@ -82,6 +82,10 @@ class SettingsViewModel(
fun adjustGpsSpeedFloor(delta: Int) = viewModelScope.launch { fun adjustGpsSpeedFloor(delta: Int) = viewModelScope.launch {
store.setGpsSpeedFloor(settings.value.gpsSpeedFloorKmh + delta) store.setGpsSpeedFloor(settings.value.gpsSpeedFloorKmh + delta)
} }
fun setMotionGuard(value: Boolean) = viewModelScope.launch { store.setMotionGuardEnabled(value) }
fun adjustMotionThreshold(delta: Double) = viewModelScope.launch {
store.setMotionThreshold(settings.value.motionThreshold + delta)
}
fun setAutoRelogin(value: Boolean) = viewModelScope.launch { fun setAutoRelogin(value: Boolean) = viewModelScope.launch {
store.setAutoRelogin(value) store.setAutoRelogin(value)
@@ -184,8 +188,35 @@ fun SettingsScreen(
onMinus = { viewModel.adjustGpsSpeedFloor(-2) }, onMinus = { viewModel.adjustGpsSpeedFloor(-2) },
onPlus = { viewModel.adjustGpsSpeedFloor(2) }, onPlus = { viewModel.adjustGpsSpeedFloor(2) },
) )
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Rörelsevakt (accelerometer)", style = MaterialTheme.typography.bodyLarge)
Text(
"Fryser spår och distans när telefonen känner att du står stilla.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
androidx.compose.material3.Switch(
checked = settings.motionGuardEnabled,
onCheckedChange = viewModel::setMotionGuard,
)
}
if (settings.motionGuardEnabled) {
StepperRow(
label = "Rörelsekänslighet",
value = "${settings.motionThreshold} m/s²",
onMinus = { viewModel.adjustMotionThreshold(-0.05) },
onPlus = { viewModel.adjustMotionThreshold(0.05) },
)
}
Text( Text(
"Gäller från nästa startade aktivitet. Standard: 35 m / 2,0× / 12 km/h.", "Gäller från nästa startade aktivitet. Standard: 35 m / 2,0× / 12 km/h / 0,35 m/s².",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),

View File

@@ -117,6 +117,8 @@ class TrackViewModel(
gpsAccuracyLimitM = cfg.gpsAccuracyLimitM, gpsAccuracyLimitM = cfg.gpsAccuracyLimitM,
gpsSpeedFactor = cfg.gpsSpeedFactor, gpsSpeedFactor = cfg.gpsSpeedFactor,
gpsSpeedFloorKmh = cfg.gpsSpeedFloorKmh, gpsSpeedFloorKmh = cfg.gpsSpeedFloorKmh,
motionGuardEnabled = cfg.motionGuardEnabled,
motionThreshold = cfg.motionThreshold,
) )
} }
} }
@@ -425,7 +427,15 @@ private fun LiveContent(
} else "", } else "",
Modifier.weight(1f), Modifier.weight(1f),
) )
StatBox("GPS", if (tracking.gpsFix) "OK" else "söker…", Modifier.weight(1f)) StatBox(
"GPS",
when {
!tracking.gpsFix -> "söker…"
!tracking.isMoving -> "stilla"
else -> "OK"
},
Modifier.weight(1f),
)
} }
} }
Button( Button(