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
targetSdk = 35
versionCode = ciRunNumber ?: 1
versionName = "0.8.3" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
versionName = "0.8.4" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
}
signingConfigs {

View File

@@ -40,6 +40,10 @@ data class AppSettings(
val gpsSpeedFactor: Double = 2.0,
/** GPS-filter: fartgolv (km/h) när Doppler-fart saknas/är noll */
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) {
@@ -70,9 +74,21 @@ class SettingsStore(private val context: Context) {
gpsAccuracyLimitM = prefs[KEY_GPS_ACC] ?: 35,
gpsSpeedFactor = prefs[KEY_GPS_FACTOR]?.toDoubleOrNull() ?: 2.0,
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) {
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_FACTOR = stringPreferencesKey("gps_speed_factor")
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.pm.PackageManager
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.LocationListener
import android.location.LocationManager
@@ -53,6 +57,8 @@ data class TrackingState(
val currentSpeedKmh: Double? = null,
val kcal: Double? = null,
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å
* 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 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
private var dopplerEmaMs = 0.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
private val altitudeWindow = ArrayDeque<Double>()
private var smoothedAltitude: Double? = null
@@ -109,8 +122,13 @@ class TrackingService : Service(), LocationListener {
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
motionGuard = intent.getBooleanExtra(EXTRA_MOTION_GUARD, true)
motionThreshold = intent.getDoubleExtra(EXTRA_MOTION_THRESHOLD, 0.35)
dopplerEmaMs = 0.0
rejectStreak = 0
motionWindow.clear()
isMoving = true
lastMotionLogged = true
_state.value = TrackingState(
isActive = true,
@@ -143,7 +161,10 @@ class TrackingService : Service(), LocationListener {
}
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.
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). */
private fun activate(reason: String) {
val s = _state.value
@@ -274,6 +333,13 @@ class TrackingService : Service(), LocationListener {
}
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(
"${location.provider}: fix acc=${"%.0f".format(acc)} m" +
" alt=${if (location.hasAltitude()) "%.0f".format(location.altitude) else "?"}" +
@@ -321,6 +387,7 @@ class TrackingService : Service(), LocationListener {
}
private fun stopTracking() {
sensorManager?.unregisterListener(this)
val s = _state.value
TrackLog.log(
"stopp: ${s.elapsedSeconds}s, ${"%.0f".format(s.distanceMeters)} m, " +
@@ -333,6 +400,7 @@ class TrackingService : Service(), LocationListener {
}
override fun onDestroy() {
sensorManager?.unregisterListener(this)
locationManager?.removeUpdates(this)
if (_state.value.isActive) _state.value = _state.value.copy(isActive = false)
scope.cancel()
@@ -393,6 +461,8 @@ class TrackingService : Service(), LocationListener {
const val EXTRA_GPS_ACC_LIMIT = "gpsAccLimit"
const val EXTRA_GPS_SPEED_FACTOR = "gpsSpeedFactor"
const val EXTRA_GPS_SPEED_FLOOR = "gpsSpeedFloor"
const val EXTRA_MOTION_GUARD = "motionGuard"
const val EXTRA_MOTION_THRESHOLD = "motionThreshold"
private val _state = MutableStateFlow(TrackingState())
/** Läses av UI:t — servicen äger sanningen. */
@@ -405,6 +475,8 @@ class TrackingService : Service(), LocationListener {
gpsAccuracyLimitM: Int = 35,
gpsSpeedFactor: Double = 2.0,
gpsSpeedFloorKmh: Int = 12,
motionGuardEnabled: Boolean = true,
motionThreshold: Double = 0.35,
) {
val intent = Intent(context, TrackingService::class.java).apply {
putExtra(EXTRA_TYPE_ID, type.id)
@@ -417,6 +489,8 @@ class TrackingService : Service(), LocationListener {
putExtra(EXTRA_GPS_ACC_LIMIT, gpsAccuracyLimitM)
putExtra(EXTRA_GPS_SPEED_FACTOR, gpsSpeedFactor)
putExtra(EXTRA_GPS_SPEED_FLOOR, gpsSpeedFloorKmh)
putExtra(EXTRA_MOTION_GUARD, motionGuardEnabled)
putExtra(EXTRA_MOTION_THRESHOLD, motionThreshold)
}
context.startForegroundService(intent)
}

View File

@@ -82,6 +82,10 @@ class SettingsViewModel(
fun adjustGpsSpeedFloor(delta: Int) = viewModelScope.launch {
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 {
store.setAutoRelogin(value)
@@ -184,8 +188,35 @@ fun SettingsScreen(
onMinus = { 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(
"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,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),

View File

@@ -117,6 +117,8 @@ class TrackViewModel(
gpsAccuracyLimitM = cfg.gpsAccuracyLimitM,
gpsSpeedFactor = cfg.gpsSpeedFactor,
gpsSpeedFloorKmh = cfg.gpsSpeedFloorKmh,
motionGuardEnabled = cfg.motionGuardEnabled,
motionThreshold = cfg.motionThreshold,
)
}
}
@@ -425,7 +427,15 @@ private fun LiveContent(
} else "",
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(