Max speed filter in native service, fix recalculate bugs

Native service (Kotlin):
- maxSpeedMs filter: points with speed > max are not recorded
- Config passed as 4th param: start:minSpeed:maxAcc:minDist:maxSpeed
- Default 60 km/h (16.67 m/s)

Settings:
- "Макс. скорость (км/ч)" field in GPS config section
- Saved to localStorage with other GPS settings

Recalculate fixes:
- Used cleaned sorted array instead of original points (was computing
  stats on dirty data with glitches -> hundreds of thousands of kcal)
- parseFloat on weight from profile (string "88" + 12 = "8812" bug)
- seq re-assignment only runs when points were actually deleted
- Glitch points deleted from DB on recalculate (not just skipped)
This commit is contained in:
4aiman 2026-05-25 15:20:24 +03:00
commit 30b0ac49f3
4 changed files with 244 additions and 21 deletions

View file

@ -9,6 +9,7 @@ GPS.config = {
minSpeedMs: 0.42, // 1.5 km/h — below this = stationary
minAccuracyM: 20, // skip fixes less accurate than this
minDistanceM: 1, // skip points closer than this
maxSpeedKmh: 60, // above this = GPS glitch, skip segment
};
// Load saved config
@ -83,8 +84,6 @@ GPS.computeStats = function(points, weightKg, opts) {
const d = GPS.distance(prev.lat, prev.lon, p.lat, p.lon);
const dt = (new Date(p.ts) - new Date(prev.ts)) / 1000;
// Skip teleportation glitches (> 180 km/h = GPS jump)
if (dt > 0 && d / dt > 50) continue;
distance += d;
@ -224,8 +223,8 @@ GPS.recording = {
// Start native GPS service on Android (foreground service survives background)
GPS._startNativeService = async function() {
try {
// Send config along with start command: start:minSpeed:maxAcc:minDist
const cmd = `start:${GPS.config.minSpeedMs}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}`;
// Send config along with start command: start:minSpeed:maxAcc:minDist:maxSpeed
const cmd = `start:${GPS.config.minSpeedMs}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}:${GPS.config.maxSpeedKmh / 3.6}`;
await window.__TAURI__.core.invoke("gps_service_command", { cmd });
console.log("[GPS] Native service started with config:", cmd);
GPS.recording.nativeService = true;
@ -877,34 +876,43 @@ GPS.recalculateSession = async function(sessionId) {
const points = await DB.getGpsPoints(sessionId);
if (points.length < 2) return null;
// Fix point ordering: re-assign seq by timestamp
const invoke = (q, v) => window.__TAURI__.core.invoke("plugin:sql|execute", { db: "sqlite:baldav.db", query: q, values: v });
// Delete glitch points (speed > maxSpeedKmh) from DB
const sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
let needsReorder = false;
for (let i = 0; i < sorted.length; i++) {
if (sorted[i].seq !== i + 1) { needsReorder = true; break; }
}
if (needsReorder) {
for (let i = 0; i < sorted.length; i++) {
await window.__TAURI__.core.invoke("plugin:sql|execute", {
db: "sqlite:baldav.db",
query: "UPDATE gps_points SET seq = ?1 WHERE session_id = ?2 AND ts = ?3",
values: [i + 1, sessionId, sorted[i].ts],
});
const maxMs = GPS.config.maxSpeedKmh / 3.6;
let deleted = 0;
for (let i = 1; i < sorted.length; i++) {
const d = GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
const dt = (new Date(sorted[i].ts) - new Date(sorted[i-1].ts)) / 1000;
if (dt > 0 && d / dt > maxMs) {
await invoke("DELETE FROM gps_points WHERE session_id = ?1 AND ts = ?2", [sessionId, sorted[i].ts]);
sorted.splice(i, 1);
i--;
deleted++;
}
}
if (deleted) console.log(`[GPS] Deleted ${deleted} glitch points from session ${sessionId}`);
// Re-assign seq only if points were deleted or reordered
if (deleted > 0) {
for (let i = 0; i < sorted.length; i++) {
await invoke("UPDATE gps_points SET seq = ?1 WHERE session_id = ?2 AND ts = ?3", [i + 1, sessionId, sorted[i].ts]);
}
console.log(`[GPS] Reordered ${sorted.length} points for session ${sessionId}`);
}
// Get user weight: latest weight log, or profile, or 80
const latestW = await DB.getLatestWeight();
const profile = await DB.getFullProfile();
const weightKg = latestW?.weight_kg ?? profile?.weight_kg ?? 80;
const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
// Get bike if linked to session, otherwise use active bike
const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 };
const stats = GPS.computeStats(points, weightKg, {
// Use cleaned sorted array, not original points
const stats = GPS.computeStats(sorted, weightKg, {
bikeWeightKg: bike?.weight_kg ?? 0,
activityType: session.activity_type ?? "bike",
surfaceCrr: CRR_MAP[bike?.surface_type] ?? 0.006,

View file

@ -2738,6 +2738,13 @@ async function renderSettings(container) {
</div>
<input id="s-min-dist" type="number" step="0.5" value="${GPS.config.minDistanceM}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Макс. скорость (км/ч)</div>
<div class="settings-item-desc">Выше = глитч GPS, игнор</div>
</div>
<input id="s-max-speed" type="number" step="1" value="${GPS.config.maxSpeedKmh}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
</div>
<div class="settings-section">
@ -2784,13 +2791,14 @@ async function renderSettings(container) {
};
// GPS config inputs
const gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist"];
const gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed"];
for (const id of gpsInputs) {
const el = container.querySelector("#" + id);
if (el) el.onchange = () => {
GPS.config.minSpeedMs = parseFloat(container.querySelector("#s-min-speed").value) / 3.6;
GPS.config.minAccuracyM = parseFloat(container.querySelector("#s-min-acc").value);
GPS.config.minDistanceM = parseFloat(container.querySelector("#s-min-dist").value);
GPS.config.maxSpeedKmh = parseFloat(container.querySelector("#s-max-speed").value) || 60;
GPS.saveConfig();
};
}

View file

@ -0,0 +1,205 @@
package dev.baldav.app
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import java.io.FileOutputStream
import java.io.File
class GpsRecorderService : Service(), LocationListener {
companion object {
const val TAG = "GpsRecorder"
const val CHANNEL_ID = "baldav_gps"
const val NOTIFICATION_ID = 1
const val ACTION_STOP = "dev.baldav.app.STOP_RECORDING"
const val EXTRA_MIN_SPEED = "min_speed"
const val EXTRA_MAX_ACC = "max_acc"
const val EXTRA_MIN_DIST = "min_dist"
const val EXTRA_MAX_SPEED = "max_speed"
var isRunning = false
private set
var pointsFile: File? = null
private set
}
private var wakeLock: PowerManager.WakeLock? = null
private var locationManager: LocationManager? = null
private var minSpeedMs = 0.42f
private var maxAccuracyM = 20f
private var minDistanceM = 1f
private var maxSpeedMs = 16.67f // 60 km/h default
private val pointBuffer = mutableListOf<String>()
private val bufferLock = Object()
private val flushHandler = Handler(Looper.getMainLooper())
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Service created")
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stopSelf()
return START_NOT_STICKY
}
// Read config from intent extras
minSpeedMs = intent?.getFloatExtra(EXTRA_MIN_SPEED, 0.42f) ?: 0.42f
maxAccuracyM = intent?.getFloatExtra(EXTRA_MAX_ACC, 20f) ?: 20f
minDistanceM = intent?.getFloatExtra(EXTRA_MIN_DIST, 1f) ?: 1f
maxSpeedMs = intent?.getFloatExtra(EXTRA_MAX_SPEED, 16.67f) ?: 16.67f
Log.d(TAG, "Starting GPS recording: minSpeed=${minSpeedMs}m/s maxAcc=${maxAccuracyM}m minDist=${minDistanceM}m maxSpeed=${maxSpeedMs}m/s")
isRunning = true
// Points file in app root data dir (same as Rust app_data_dir)
val appDataDir = filesDir.parentFile ?: filesDir
pointsFile = File(appDataDir, "gps_live_points.jsonl")
pointsFile?.writeText("") // clear
// Foreground notification
val stopIntent = Intent(this, GpsRecorderService::class.java).apply { action = ACTION_STOP }
val stopPending = PendingIntent.getService(this, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE)
val openIntent = packageManager.getLaunchIntentForPackage(packageName)
val openPending = PendingIntent.getActivity(this, 0, openIntent, PendingIntent.FLAG_IMMUTABLE)
val notification = Notification.Builder(this, CHANNEL_ID)
.setContentTitle("BalDav GPS")
.setContentText("Recording track...")
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
.setContentIntent(openPending)
.addAction(Notification.Action.Builder(null, "Stop", stopPending).build())
.setOngoing(true)
.build()
startForeground(NOTIFICATION_ID, notification)
// WakeLock
val pm = getSystemService(POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "baldav:gps")
wakeLock?.acquire(4 * 60 * 60 * 1000L) // 4 hours max
// Start GPS
locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
try {
val gpsEnabled = locationManager?.isProviderEnabled(LocationManager.GPS_PROVIDER) ?: false
Log.d(TAG, "GPS provider enabled: $gpsEnabled")
locationManager?.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
1000L, // min time 1s
0f, // min distance 0m
this
)
Log.d(TAG, "Location updates registered successfully")
// Flush buffer every 10 seconds
fun scheduleFlush() {
flushHandler.postDelayed({
flushBuffer()
if (isRunning) scheduleFlush()
}, 10000)
}
scheduleFlush()
} catch (e: SecurityException) {
Log.e(TAG, "GPS permission denied", e)
stopSelf()
} catch (e: Exception) {
Log.e(TAG, "Failed to register location updates", e)
stopSelf()
}
return START_STICKY
}
private var lastLat = 0.0
private var lastLon = 0.0
private var lastTime = 0L
private var pointCount = 0
override fun onLocationChanged(location: Location) {
Log.v(TAG, "Location: acc=${location.accuracy} spd=${location.speed} lat=${location.latitude}")
if (location.accuracy > maxAccuracyM) return
if (location.speed < minSpeedMs) return
if (location.speed > maxSpeedMs) return
if (lastTime > 0) {
val results = FloatArray(1)
Location.distanceBetween(lastLat, lastLon, location.latitude, location.longitude, results)
if (results[0] < minDistanceM) return
}
lastLat = location.latitude
lastLon = location.longitude
lastTime = location.time
pointCount++
val line = """{"lat":${location.latitude},"lon":${location.longitude},"alt":${location.altitude},"speed":${location.speed},"acc":${location.accuracy},"ts":${location.time}}"""
synchronized(bufferLock) {
pointBuffer.add(line)
}
Log.d(TAG, "Buffered point #$pointCount: ${location.latitude},${location.longitude} spd=${location.speed} (buf=${pointBuffer.size})")
}
private fun flushBuffer() {
synchronized(bufferLock) {
if (pointBuffer.isEmpty()) return
val batch = pointBuffer.toList()
pointBuffer.clear()
pointsFile?.let { f ->
try {
FileOutputStream(f, true).bufferedWriter().use { writer ->
for (line in batch) writer.appendLine(line)
writer.flush()
}
Log.d(TAG, "Flushed ${batch.size} points to file")
} catch (e: Exception) {
Log.e(TAG, "Failed to flush points", e)
// Put points back
pointBuffer.addAll(0, batch)
}
}
}
}
override fun onDestroy() {
Log.d(TAG, "Service stopping")
isRunning = false
flushHandler.removeCallbacksAndMessages(null)
flushBuffer() // flush remaining points
locationManager?.removeUpdates(this)
wakeLock?.let { if (it.isHeld) it.release() }
stopForeground(true)
super.onDestroy()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID, "GPS Tracking",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Shows when GPS tracking is active"
}
val nm = getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(channel)
}
}
}

View file

@ -112,11 +112,13 @@ class MainActivity : TauriActivity() {
val minSpeed = parts.getOrNull(1)?.toFloatOrNull() ?: 0.42f
val maxAcc = parts.getOrNull(2)?.toFloatOrNull() ?: 20f
val minDist = parts.getOrNull(3)?.toFloatOrNull() ?: 1f
Log.d("BalDav", "Starting GPS service: speed=$minSpeed acc=$maxAcc dist=$minDist")
val maxSpeed = parts.getOrNull(4)?.toFloatOrNull() ?: 16.67f
Log.d("BalDav", "Starting GPS service: speed=$minSpeed acc=$maxAcc dist=$minDist maxSpeed=$maxSpeed")
val intent = Intent(this, GpsRecorderService::class.java).apply {
putExtra(GpsRecorderService.EXTRA_MIN_SPEED, minSpeed)
putExtra(GpsRecorderService.EXTRA_MAX_ACC, maxAcc)
putExtra(GpsRecorderService.EXTRA_MIN_DIST, minDist)
putExtra(GpsRecorderService.EXTRA_MAX_SPEED, maxSpeed)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)