From 30b0ac49f3ea353c163d10d5231fa86b66ee375f Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 25 May 2026 15:20:24 +0300 Subject: [PATCH] Max speed filter in native service, fix recalculate bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/js/gps.js | 46 ++-- frontend/js/main.js | 10 +- .../java/dev/baldav/app/GpsRecorderService.kt | 205 ++++++++++++++++++ .../main/java/dev/baldav/app/MainActivity.kt | 4 +- 4 files changed, 244 insertions(+), 21 deletions(-) create mode 100644 src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 9c67778..b587c24 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -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, diff --git a/frontend/js/main.js b/frontend/js/main.js index 6cd93dc..612216d 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -2738,6 +2738,13 @@ async function renderSettings(container) { +
+
+
Макс. скорость (км/ч)
+
Выше = глитч GPS, игнор
+
+ +
@@ -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(); }; } diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt new file mode 100644 index 0000000..a89b1a8 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt @@ -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() + 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) + } + } +} diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt index 0cf3364..ea05df4 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt @@ -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)