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)