From 2051a604e1d8f256a372bd575e4b6b173e92b81c Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 7 Jul 2026 00:28:27 +0300 Subject: [PATCH] GPS accuracy-gated points + bearing; fix startup UI freeze - Native: hard-reject fixes coarser than the Min-accuracy setting (drops coarse cell ~800m that stuck the track); remove low-accuracy and adaptive relaxation branches so only accurate fixes are recorded, honest gaps otherwise - Store bearing_deg per point (gps_points migration + addGpsPoint) - Default Min-accuracy 20 -> 50m (one-time migration, respects user override) - Fix: http_request was synchronous with no timeout; an unreachable host (LAN sync on startup) blocked the whole UI ~30s. Now async + spawn_blocking + connect(4s)/global(12s) timeouts, fails in background without freezing - Bundled prior work: course-up map orientation, compass calibration, online elevation refine (open-elevation), distance gap-gate fix --- README.md | 2 +- frontend/css/styles.css | 69 +-- frontend/js/db.js | 16 + frontend/js/gps.js | 129 ++++-- frontend/js/main.js | 400 ++++++++++++------ src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- .../android/app/src/main/AndroidManifest.xml | 1 + .../java/dev/baldav/app/GpsRecorderService.kt | 226 ++++++---- .../main/java/dev/baldav/app/MainActivity.kt | 97 ++++- src-tauri/src/lib.rs | 38 +- src-tauri/tauri.conf.json | 2 +- 12 files changed, 717 insertions(+), 267 deletions(-) diff --git a/README.md b/README.md index 233eefa..4e318ea 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## Возможности - **Дневник питания** — 24400+ продуктов (Магнит, 5ка, Health-Diet.ru, Ашан, Макдоналдс, KFC, Додо Пицца, KDV, ИОН, Гриднев). 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50). -- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию, переживает START_STICKY-перезапуск ОС). Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Импорт тайлов по регионам (SQLite, роутер LAN). +- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию, переживает START_STICKY-перезапуск ОС). Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Импорт тайлов по регионам (SQLite, роутер LAN). - **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека. - **Расчёт ккал** — физическая модель для велосипеда (мощность из gradient + Crr + CdA). MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. - **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу. diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 7d5be93..9b667a7 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1098,8 +1098,30 @@ select { background: rgba(187, 134, 252, 0.4); border-color: var(--primary); } -.rec-orient-lock { right: 8px; bottom: 48px; } -.rec-roads-toggle { right: 8px; bottom: 86px; } +.rec-rotate-btn { right: 8px; bottom: 48px; font-size: 18px; transition: transform 0.15s; } +.rec-freeze-btn { right: 8px; bottom: 86px; } +.rec-roads-toggle { right: 8px; bottom: 124px; } +.rec-mode-btn { right: 8px; bottom: 162px; font-size: 11px; } +/* Always-on compass needle: red half = North, grey = South. Rotates to point to real north. + Top-RIGHT (zoom controls are top-left), below the status-bar safe area. */ +.rec-compass { + position: absolute; top: calc(8px + var(--safe-area-top, 0px)); right: 8px; z-index: 1001; + width: 40px; height: 40px; border-radius: 50%; + background: rgba(20,20,20,0.8); border: 1px solid var(--border); + cursor: pointer; +} +/* Keep Leaflet zoom controls below the status bar on the recording map. */ +.rec-map-wrap .leaflet-top { margin-top: var(--safe-area-top, 0px); } +.rec-compass-arrow { position: absolute; inset: 0; transition: transform 0.12s linear; } +.rec-compass-arrow::before { + content: "N"; position: absolute; top: 1px; left: 50%; transform: translateX(-50%); + color: #f44336; font: 700 11px sans-serif; +} +.rec-compass-arrow::after { + content: ""; position: absolute; left: 50%; top: 14px; bottom: 6px; width: 3px; margin-left: -1.5px; + background: linear-gradient(to bottom, #f44336 0 55%, #9e9e9e 55% 100%); + border-radius: 2px; +} .gps-placeholder { position: absolute; inset: 0; @@ -1117,10 +1139,11 @@ select { font-size: 18px; } -/* Landscape: CSS rotation compensates physical rotation. - User sees element coords directly — so flex-direction:row = panel on the RIGHT. */ -.rec-screen.landscape-right, -.rec-screen.landscape-left { +/* Manual screen rotation (rotate button). 90/270 = landscape (swap dims, panel to side). + 180 is intentionally not used — it would flip the panel text upside down. + Map compensates via setBearing(heading - rotOffset). */ +.rec-screen.rot-90, +.rec-screen.rot-270 { position: fixed; width: 100vh; height: 100vw; @@ -1129,18 +1152,12 @@ select { z-index: 9999; flex-direction: row; } -.rec-screen.landscape-right { - transform: rotate(-90deg); -} -.rec-screen.landscape-left { - transform: rotate(90deg); -} -.rec-screen.landscape-right .rec-map-wrap, -.rec-screen.landscape-left .rec-map-wrap { - min-height: 0; -} -.rec-screen.landscape-right .rec-panel, -.rec-screen.landscape-left .rec-panel { +.rec-screen.rot-90 { transform: rotate(90deg); } +.rec-screen.rot-270 { transform: rotate(270deg); } +.rec-screen.rot-90 .rec-map-wrap, +.rec-screen.rot-270 .rec-map-wrap { min-height: 0; } +.rec-screen.rot-90 .rec-panel, +.rec-screen.rot-270 .rec-panel { width: 200px; flex-shrink: 0; display: flex; @@ -1148,21 +1165,17 @@ select { justify-content: space-evenly; border-left: 1px solid var(--border); } -.rec-screen.landscape-right .rec-stats, -.rec-screen.landscape-left .rec-stats { +.rec-screen.rot-90 .rec-stats, +.rec-screen.rot-270 .rec-stats { flex-direction: column; border-top: none; padding: 8px; gap: 2px; } -.rec-screen.landscape-right .rec-bottom .rec-status, -.rec-screen.landscape-left .rec-bottom .rec-status { - display: none; -} -.rec-screen.landscape-right .rec-hr-row, -.rec-screen.landscape-left .rec-hr-row { - display: none; -} +.rec-screen.rot-90 .rec-bottom .rec-status, +.rec-screen.rot-270 .rec-bottom .rec-status { display: none; } +.rec-screen.rot-90 .rec-hr-row, +.rec-screen.rot-270 .rec-hr-row { display: none; } .rec-stats { display: flex; diff --git a/frontend/js/db.js b/frontend/js/db.js index 94a97f6..36fc871 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -702,6 +702,7 @@ async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs, seq, bleDat ["bp_sys", bleData?.bp_sys], ["bp_dia", bleData?.bp_dia], ["accel_rms", sensorData?.accel_rms], ["accel_max", sensorData?.accel_max], ["gyro_pitch", sensorData?.gyro_pitch], ["gyro_yaw", sensorData?.gyro_yaw], + ["bearing_deg", sensorData?.bearing], ]; for (const [col, val] of extra) { if (_gpsPointCols.has(col)) { cols.push(col); vals.push(val ?? null); } @@ -849,6 +850,19 @@ async function updateSessionBike(sessionId, bikeId) { await execute("UPDATE gps_sessions SET bike_id = ?1 WHERE id = ?2", [bikeId, sessionId]); } +// Bulk-set terrain altitude (altitude_srtm_m) for points by id. rows: [{id, srtm}]. +// Batched CASE update — values are numbers (ids small, no i64 corruption), safe to inline. +async function updatePointsSrtm(rows) { + const BATCH = 200; + for (let i = 0; i < rows.length; i += BATCH) { + const batch = rows.slice(i, i + BATCH).filter(r => r.id != null && r.srtm != null && isFinite(r.srtm)); + if (!batch.length) continue; + const cases = batch.map(r => `WHEN ${parseInt(r.id, 10)} THEN ${Number(r.srtm)}`).join(" "); + const ids = batch.map(r => parseInt(r.id, 10)).join(","); + await execute(`UPDATE gps_points SET altitude_srtm_m = CASE id ${cases} END WHERE id IN (${ids})`, []); + } +} + // --------------------------------------------------------------------------- // Bike maintenance log // --------------------------------------------------------------------------- @@ -1046,6 +1060,7 @@ window.DB.saveBike = saveBike; window.DB.updateBike = updateBike; window.DB.deleteBike = deleteBike; window.DB.updateSessionBike = updateSessionBike; +window.DB.updatePointsSrtm = updatePointsSrtm; window.DB.getBikeMaintenanceLog = getBikeMaintenanceLog; window.DB.addBikeMaintenance = addBikeMaintenance; window.DB.deleteBikeMaintenance = deleteBikeMaintenance; @@ -1232,6 +1247,7 @@ async function runMigrations() { if (!gpsCols.includes("accel_max")) await execute("ALTER TABLE gps_points ADD COLUMN accel_max REAL"); if (!gpsCols.includes("gyro_pitch")) await execute("ALTER TABLE gps_points ADD COLUMN gyro_pitch REAL"); if (!gpsCols.includes("gyro_yaw")) await execute("ALTER TABLE gps_points ADD COLUMN gyro_yaw REAL"); + if (!gpsCols.includes("bearing_deg")) await execute("ALTER TABLE gps_points ADD COLUMN bearing_deg REAL"); // --- Cooking methods table --- await execute(`CREATE TABLE IF NOT EXISTS cooking_methods ( diff --git a/frontend/js/gps.js b/frontend/js/gps.js index c466b92..a3b2ce5 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -7,7 +7,7 @@ const GPS = {}; // Configurable thresholds (can be changed in settings) GPS.config = { minSpeedMs: 0.42, // 1.5 km/h — below this = stationary - minAccuracyM: 20, // skip fixes less accurate than this + minAccuracyM: 50, // hard gate: reject fixes coarser than this (cell/wifi ~800m). Weak but real GPS (<=50m) kept. minDistanceM: 1, // skip points closer than this maxSpeedKmh: 60, // above this = GPS glitch, skip segment gapThresholdSec: 30, // gap longer than this = new track segment @@ -20,11 +20,14 @@ GPS.config = { bikeAvgSpeedKmh: 17, // средняя скорость вело (для ручных маршрутов) walkAvgSpeedKmh: 5, // средняя скорость ходьбы runAvgSpeedKmh: 10, // средняя скорость бега + useFusedLocation: true, // fused-провайдер (Google Play services) — как Яндекс/Google Maps. + // raw GPS_PROVIDER на устройстве приложению не отдаёт фиксы; fused смешивает GPS+ГЛОНАСС+сеть. + autoFinishIdleMin: 15, // авто-завершить тренировку после N мин без движения (забыл «Конец») }; // Load saved config try { - const saved = localStorage.getItem("baldav_gps_config"); + let saved = localStorage.getItem("baldav_gps_config"); if (saved) Object.assign(GPS.config, JSON.parse(saved)); } catch {} @@ -32,8 +35,47 @@ GPS.saveConfig = function() { localStorage.setItem("baldav_gps_config", JSON.stringify(GPS.config)); }; +// One-time migration: raw GPS_PROVIDER doesn't deliver on this device, so default +// tracking to fused (like Yandex/Google Maps). Runs once, then respects the toggle. +try { + if (localStorage.getItem("baldav_fused_migrated") !== "1") { + GPS.config.useFusedLocation = true; + localStorage.setItem("baldav_fused_migrated", "1"); + GPS.saveConfig(); + } +} catch {} + +// One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes +// "sit at the tower"). Bump the old default 20m -> 50m so weak but real GPS survives. +// Only touch the value if it's still the old default; respect a user-set threshold. +try { + if (localStorage.getItem("baldav_minacc50_migrated") !== "1") { + if (GPS.config.minAccuracyM <= 20) GPS.config.minAccuracyM = 50; + localStorage.setItem("baldav_minacc50_migrated", "1"); + GPS.saveConfig(); + } +} catch {} + // Haversine distance (meters) // --------------------------------------------------------------------------- +// Compass bearing in degrees (0=N, clockwise) from point 1 to point 2. +GPS.bearing = function(lat1, lon1, lat2, lon2) { + let toRad = d => d * Math.PI / 180; + let f1 = toRad(lat1), f2 = toRad(lat2), dl = toRad(lon2 - lon1); + let y = Math.sin(dl) * Math.cos(f2); + let x = Math.cos(f1) * Math.sin(f2) - Math.sin(f1) * Math.cos(f2) * Math.cos(dl); + return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360; +}; + +// Point [lat, lon] at `meters` distance along `bearingDeg` (0=N, CW) from a start point. +GPS.destination = function(lat, lon, bearingDeg, meters) { + let R = 6371000, d = meters / R, br = bearingDeg * Math.PI / 180; + let f1 = lat * Math.PI / 180, l1 = lon * Math.PI / 180; + let f2 = Math.asin(Math.sin(f1) * Math.cos(d) + Math.cos(f1) * Math.sin(d) * Math.cos(br)); + let l2 = l1 + Math.atan2(Math.sin(br) * Math.sin(d) * Math.cos(f1), Math.cos(d) - Math.sin(f1) * Math.sin(f2)); + return [f2 * 180 / Math.PI, ((l2 * 180 / Math.PI) + 540) % 360 - 180]; +}; + GPS.distance = function(lat1, lon1, lat2, lon2) { const R = 6371000; const toRad = d => d * Math.PI / 180; @@ -61,7 +103,9 @@ GPS.computeStats = function(points, weightKg, opts) { // Step 1: Extract raw altitudes and filter outliers. // An outlier is a point that jumps far from its neighbors and returns back — // e.g. [45, 45, 52, 45, 45] — the 52 is a GPS altitude glitch. - const rawAlt = sorted.map(p => p.altitude ?? p.altitude_gps_m ?? null); + // Prefer terrain (SRTM/open-elevation) altitude when available — GPS altitude is noisy + // and the stuck-filter destroys its real signal. Terrain is clean and accurate for slope. + const rawAlt = sorted.map(p => p.altitude_srtm_m ?? p.altitude ?? p.altitude_gps_m ?? null); const filteredAlt = rawAlt.slice(); for (let i = 1; i < filteredAlt.length - 1; i++) { if (filteredAlt[i] == null) continue; @@ -142,36 +186,39 @@ GPS.computeStats = function(points, weightKg, opts) { const gearRatio = hasGearData ? opts.chainringTeeth / opts.cogTeeth : 0; for (let i = 1; i < sorted.length; i++) { - const p = sorted[i], prev = sorted[i - 1]; - const d = GPS.distance(prev.lat, prev.lon, p.lat, p.lon); + let p = sorted[i], prev = sorted[i - 1]; + let d = GPS.distance(prev.lat, prev.lon, p.lat, p.lon); + let dt = (new Date(p.ts) - new Date(prev.ts)) / 1000; + let v = dt > 0 ? d / dt : 0; // m/s - const dt = (new Date(p.ts) - new Date(prev.ts)) / 1000; + let isEstimated = (sorted[i].accuracy >= 999) || sorted[i].estimated; + let segMaxMs = isBike ? (GPS.config.maxSpeedKmh / 3.6) : (GPS.config.walkMaxSpeedKmh / 3.6); + // Count a REAL segment: not a dead-reckoning guess, not a teleport glitch. We do NOT + // drop long-gap segments here — if GPS was lost briefly, the straight line is a fair + // lower-bound of real travel (implied speed v<=segMaxMs already rejects teleports). + // DR fakes are excluded via !isEstimated, so no gap-time gate is needed. + let validSeg = !isEstimated && dt > 0 && v <= segMaxMs; - distance += d; - - const v = dt > 0 ? d / dt : 0; // m/s - speeds.push(v); - - // Elevation: accumulate from smoothed altitude with 1m dead zone. - // Use consecutive smoothed points — the 50m window gradient is only for kcal. - if (smoothedAlt[i] != null && smoothedAlt[i - 1] != null) { - const diff = smoothedAlt[i] - smoothedAlt[i - 1]; - if (diff > 1) elevGain += diff; - else if (diff < -1) elevLoss += Math.abs(diff); + if (validSeg) { + distance += d; + speeds.push(v); + // Elevation: accumulate from smoothed altitude with 1m dead zone. + if (smoothedAlt[i] != null && smoothedAlt[i - 1] != null) { + let diff = smoothedAlt[i] - smoothedAlt[i - 1]; + if (diff > 1) elevGain += diff; + else if (diff < -1) elevLoss += Math.abs(diff); + } } // Kcal calculation per segment - const isEstimated = (sorted[i].accuracy >= 999) || sorted[i].estimated; if (isEstimated) { - // Estimated point (GPS dead, accel confirmed movement): use walking MET - const walkMet = 3.5; // moderate walking ~4-5 km/h + // Estimated point (GPS dead, accel confirmed movement): use walking MET. + // Time-based only — no distance (dead-reckoning drift is not real travel). + let walkMet = 3.5; // moderate walking ~4-5 km/h totalKcal += (dt / 60) * (walkMet * 3.5 * wKg) / 200; continue; } - if (dt <= 0) continue; - // Skip if speed is unreasonable (teleportation/glitch) - const segMaxMs = isBike ? (GPS.config.maxSpeedKmh / 3.6) : (GPS.config.walkMaxSpeedKmh / 3.6); - if (v > segMaxMs) continue; + if (!validSeg) continue; if (v < 0.3) { // Standing still: only basal metabolic rate @@ -397,7 +444,8 @@ GPS._startNativeService = async function() { const isWalk = type === "walk" || type === "run"; const minSpd = isWalk ? GPS.config.walkMinSpeedMs : GPS.config.minSpeedMs; const maxSpd = isWalk ? (GPS.config.walkMaxSpeedKmh / 3.6) : (GPS.config.maxSpeedKmh / 3.6); - const cmd = `start:${minSpd}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}:${maxSpd}`; + let fused = GPS.config.useFusedLocation ? 1 : 0; + let cmd = `start:${minSpd}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}:${maxSpd}:${fused}`; await window.__TAURI__.core.invoke("gps_service_command", { cmd }); console.log("[GPS] Native service started with config:", cmd); GPS.recording.nativeService = true; @@ -413,6 +461,14 @@ GPS._stopNativeService = async function() { } catch {} }; +// Ask Android for background location ("Allow all the time") — required to record +// while the screen is off. On Android 11+ this opens the system location settings. +GPS.requestBackgroundLocation = async function() { + try { + await window.__TAURI__.core.invoke("gps_service_command", { cmd: "reqbgloc" }); + } catch (e) { console.log("[GPS] reqbgloc:", e); } +}; + // BLE HR monitor — tied to GPS session (start→stop), but NOT paused when GPS pauses. // HR keeps measuring even when rider is stationary. GPS._startHrMonitor = async function() { @@ -474,6 +530,7 @@ GPS._readNativePoints = async function() { points.push({ lat: d.lat, lon: d.lon, altitude: d.alt, speed_ms: d.speed, accuracy: d.acc, + bearing: (d.bearing != null && d.bearing >= 0) ? d.bearing : null, // GPS course over ground ts: new Date(d.ts).toISOString(), accel_rms: d.accel_rms, accel_max: d.accel_max, gyro_pitch: d.gyro_pitch, gyro_yaw: d.gyro_yaw, @@ -503,9 +560,13 @@ GPS._processIncomingPoint = async function(p) { return; } - // Determine if moving - const speed = p.speed_ms != null ? p.speed_ms : 0; - let isMoving = speed > GPS.config.minSpeedMs; + // Determine if moving — same per-activity threshold as the native service + // (walk/run → walkMinSpeedMs, else minSpeedMs). 0 means "always moving": + // no auto-pause, no sticking on "начните движение". + let speed = p.speed_ms != null ? p.speed_ms : 0; + let mvType = GPS.recording.activityType; + let effMinSpeed = (mvType === "walk" || mvType === "run") ? GPS.config.walkMinSpeedMs : GPS.config.minSpeedMs; + let isMoving = effMinSpeed <= 0 ? true : speed > effMinSpeed; if (!isMoving && GPS.recording.lastMovingPoint) { const d = GPS.distance(GPS.recording.lastMovingPoint.lat, GPS.recording.lastMovingPoint.lon, p.lat, p.lon); const dt = (new Date(p.ts) - new Date(GPS.recording.lastMovingPoint.ts)) / 1000; @@ -627,6 +688,17 @@ GPS.resumeRecording = function() { if (GPS.recording.onStateChange) GPS.recording.onStateChange("manual_resumed"); }; +// True when an active session has had no movement for autoFinishIdleMin minutes — +// used to auto-finish a session the user forgot to stop, so the next outing is separate. +GPS.shouldAutoFinish = function() { + if (!GPS.recording.active || GPS.recording.manualPaused) return false; + if (!GPS.recording.sessionId) return false; // no session yet (no movement) + let last = GPS.recording.lastMovingPoint; + if (!last) return false; + let idleMs = Date.now() - new Date(last.ts).getTime(); + return idleMs > (GPS.config.autoFinishIdleMin || 15) * 60000; +}; + // Recover orphaned recording session on app startup GPS.recoverSession = async function() { const saved = localStorage.getItem("baldav_gps_active"); @@ -746,6 +818,7 @@ GPS.startRecording = async function(activityType, weightKg) { altitude: pos.coords.altitude, speed_ms: pos.coords.speed, accuracy: pos.coords.accuracy, + bearing: (pos.coords.heading != null && !isNaN(pos.coords.heading)) ? pos.coords.heading : null, ts: new Date(pos.timestamp).toISOString(), }); }, diff --git a/frontend/js/main.js b/frontend/js/main.js index 1dcdb92..bbc5aea 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -2618,25 +2618,34 @@ async function showAddGoalMarkerModal() { // --------------------------------------------------------------------------- async function fetchElevations(points) { - try { - // Batch up to 100 points per request - let elevations = new Array(points.length).fill(null); - for (let i = 0; i < points.length; i += 100) { - let batch = points.slice(i, i + 100); - let lats = batch.map(p => p.lat.toFixed(5)).join(","); - let lons = batch.map(p => p.lon.toFixed(5)).join(","); - let resp = await fetch(`https://api.open-meteo.com/v1/elevation?latitude=${lats}&longitude=${lons}`); - if (!resp.ok) continue; - let data = await resp.json(); - if (data.elevation) { - for (let j = 0; j < data.elevation.length; j++) elevations[i + j] = data.elevation[j]; + // Terrain elevation. Primary: open-elevation.com (reachable in RU). Fallback: open-meteo + // (blocked by RU carriers on this device, kept in case it opens up). Batches of 100. + let elevations = new Array(points.length).fill(null); + for (let i = 0; i < points.length; i += 100) { + let batch = points.slice(i, i + 100); + let got = null; + try { + let resp = await fetch("https://api.open-elevation.com/api/v1/lookup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ locations: batch.map(p => ({ latitude: p.lat, longitude: p.lon })) }), + }); + if (resp.ok) { + let d = await resp.json(); + if (d.results) got = d.results.map(r => r.elevation); } + } catch (e) { console.log("[open-elevation] batch fail:", e.message); } + if (!got) { + try { + let lats = batch.map(p => p.lat.toFixed(5)).join(","); + let lons = batch.map(p => p.lon.toFixed(5)).join(","); + let resp = await fetch(`https://api.open-meteo.com/v1/elevation?latitude=${lats}&longitude=${lons}`); + if (resp.ok) { let d = await resp.json(); if (d.elevation) got = d.elevation; } + } catch (e) { console.log("[open-meteo] batch fail:", e.message); } } - return elevations; - } catch (e) { - console.log("[Elevation API] failed:", e.message); - return null; + if (got) for (let j = 0; j < got.length; j++) elevations[i + j] = got[j]; } + return elevations.some(x => x != null) ? elevations : null; } function showDrawRouteScreen(container) { @@ -2858,33 +2867,154 @@ function renderRecordingInline(container) { let borderLine = L.polyline([], { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map); let trailLine = L.polyline([], { color: "#bb86fc", weight: 5 }).addTo(map); - // Compass heading + landscape detection from device orientation - let currentLandscape = null; // null | 'right' | 'left' - let orientMode = "off"; // "off" | "auto" | "lock" - let lastBeta = 0; - let lastGamma = 0; - let smoothedHeading = null; - let recScreen = document.querySelector('.rec-screen'); + // Map orientation: a mode button picks the source — magnetic compass or GPS course. + // Manual 90° steps (↑) rotate the whole screen on top. Two on-map arrows always show + // BOTH directions (orange = compass, green = GPS course) for visual comparison. + let rotOffset = 0; // 0/90/180/270 — whole-screen rotation + let compassFrozen = false; // X button: freeze current heading + let frozenHeading = 0; // heading captured at the moment of freezing + let smoothedHeading = null; // EMA of calibrated compass heading (deg, 0=N, CW) + let courseHeading = null; // EMA of GPS course over ground (deg, 0=N, CW) + let prevCoursePt = null; // for the computed-bearing fallback + let lastPos = null; // last position, for drawing the direction arrows + let orientMode = localStorage.getItem("baldav_orient_mode") || "compass"; // "compass" | "course" + // Compass calibration: magnetometer is offset ~53° here; tap the compass while pointing + // north to subtract the offset (deviceorientationabsolute alpha is CW → raw = alpha). + let northOffset = parseFloat(localStorage.getItem("baldav_north_offset2")) || 0; + let lastRawHeading = 0; // uncalibrated heading, kept for the calibration tap + let recScreen = container.querySelector(".rec-screen"); let btnWrap = mapEl.parentElement; - // Orient button — 3 states: X (off) → A (auto) → L (lock) - let lockBtn = document.createElement("button"); - lockBtn.className = "rec-map-btn rec-orient-lock"; - lockBtn.textContent = "X"; - lockBtn.onclick = () => { - if (orientMode === "off") { orientMode = "auto"; lockBtn.textContent = "A"; lockBtn.classList.remove("active"); } - else if (orientMode === "auto") { orientMode = "lock"; lockBtn.textContent = "L"; lockBtn.classList.add("active"); } - else { orientMode = "off"; lockBtn.textContent = "X"; lockBtn.classList.remove("active"); - // Reset landscape and bearing - currentLandscape = null; - if (recScreen) recScreen.classList.remove('landscape-right', 'landscape-left'); - let nav = document.querySelector('.bottom-nav'); - if (nav) nav.style.display = ''; - map.setBearing(0); - setTimeout(() => map.invalidateSize(), 100); - } + // Always-on compass needle showing where magnetic NORTH is. Tap = calibrate north. + let compassEl = document.createElement("div"); + compassEl.className = "rec-compass"; + compassEl.innerHTML = '
'; + compassEl.title = "Тап = выставить север по текущему направлению"; + compassEl.onclick = () => { + northOffset = lastRawHeading; + localStorage.setItem("baldav_north_offset2", String(northOffset)); + smoothedHeading = 0; // snap to north-up immediately + applyBearing(); + if (typeof showToast === "function") showToast("Север откалиброван"); }; - btnWrap.appendChild(lockBtn); + btnWrap.appendChild(compassEl); + let compassArrow = compassEl.firstChild; + function updateNorthArrow(bearing) { + compassArrow.style.transform = `rotate(${-bearing}deg)`; + } + + // Mode button: compass ("К") ↔ GPS course ("GPS"). + let modeBtn = document.createElement("button"); + modeBtn.className = "rec-map-btn rec-mode-btn"; + modeBtn.textContent = orientMode === "course" ? "GPS" : "К"; + modeBtn.title = "Поворот карты: компас / GPS-курс"; + modeBtn.onclick = () => { + orientMode = orientMode === "compass" ? "course" : "compass"; + localStorage.setItem("baldav_orient_mode", orientMode); + modeBtn.textContent = orientMode === "course" ? "GPS" : "К"; + applyBearing(); + }; + btnWrap.appendChild(modeBtn); + + // Two direction arrows drawn on the map from the current position (always visible): + // orange = compass, green = GPS course. Geo-anchored, so they turn with the map. + let compassDirLine = L.polyline([], { color: "#ff9800", weight: 4, opacity: 0.9, dashArray: "6,6" }).addTo(map); + let compassDirTip = L.circleMarker([0, 0], { radius: 5, color: "#ff9800", fillColor: "#ff9800", fillOpacity: 1, weight: 0 }).addTo(map); + let courseDirLine = L.polyline([], { color: "#4caf50", weight: 5, opacity: 0.95 }).addTo(map); + let courseDirTip = L.circleMarker([0, 0], { radius: 6, color: "#4caf50", fillColor: "#4caf50", fillOpacity: 1, weight: 0 }).addTo(map); + function updateArrows() { + if (!lastPos) return; + // Both arrows point the TRUE geographic direction (CW from north). + if (smoothedHeading != null) { + let end = GPS.destination(lastPos.lat, lastPos.lon, smoothedHeading, 40); + compassDirLine.setLatLngs([[lastPos.lat, lastPos.lon], end]); + compassDirTip.setLatLng(end); + } + if (courseHeading != null) { + let end = GPS.destination(lastPos.lat, lastPos.lon, courseHeading, 70); // travel direction (GPS) + courseDirLine.setLatLngs([[lastPos.lat, lastPos.lon], end]); + courseDirTip.setLatLng(end); + } + } + + // Base heading = chosen source. GPS mode rotates by course when we have it, else falls + // back to the compass so the map ALWAYS rotates to a known-good heading (never static). + function baseHeading() { + // Course-up: setBearing puts geo-dir (360-theta) up and net rotation = h, so to put + // courseHeading at the top we need h = 360 - courseHeading (derived from leaflet-rotate). + if (orientMode === "course" && courseHeading != null) return (360 - courseHeading + 360) % 360; + return smoothedHeading != null ? smoothedHeading : 0; + } + + function applyBearing() { + let h = compassFrozen ? frozenHeading : baseHeading(); + let bearing = h - rotOffset; + map.setBearing(bearing); + updateNorthArrow(bearing); + } + + // Update GPS course from a point. Prefer the chip's course-over-ground (p.bearing); + // fall back to bearing between positions ≥15 m apart (long baseline = less jitter). + function updateCourse(p) { + if (!p) return; + lastPos = { lat: p.lat, lon: p.lon }; + let b = null; + if (p.bearing != null && (p.speed_ms == null || p.speed_ms > 1.0)) { + b = p.bearing; + } else if (prevCoursePt && GPS.distance(prevCoursePt.lat, prevCoursePt.lon, p.lat, p.lon) >= 15) { + b = GPS.bearing(prevCoursePt.lat, prevCoursePt.lon, p.lat, p.lon); + prevCoursePt = { lat: p.lat, lon: p.lon }; + } + if (!prevCoursePt) prevCoursePt = { lat: p.lat, lon: p.lon }; + if (b != null) { + if (courseHeading === null) courseHeading = b; + else { + let diff = b - courseHeading; + if (diff > 180) diff -= 360; + if (diff < -180) diff += 360; + courseHeading = ((courseHeading + diff * 0.4) % 360 + 360) % 360; + } + if (orientMode === "course" && !compassFrozen) applyBearing(); + } + updateArrows(); + } + + function applyScreenRotation() { + if (recScreen) { + recScreen.classList.remove("rot-90", "rot-270"); + if (rotOffset) recScreen.classList.add("rot-" + rotOffset); + } + let nav = document.querySelector(".bottom-nav"); + if (nav) nav.style.display = rotOffset ? "none" : ""; + map.invalidateSize(); + } + + // Rotation button — 4 states, each tap +90°. Arrow shows chosen "up". + let rotBtn = document.createElement("button"); + rotBtn.className = "rec-map-btn rec-rotate-btn"; + rotBtn.innerHTML = "↑"; // ↑ + rotBtn.title = "Повернуть карту на 90°"; + rotBtn.onclick = () => { + // 3 readable states: portrait + both landscapes. 180° is skipped — + // it would flip the panel text upside down ("низ" would end up on top). + rotOffset = rotOffset === 0 ? 90 : (rotOffset === 90 ? 270 : 0); + applyScreenRotation(); + applyBearing(); + }; + btnWrap.appendChild(rotBtn); + + // Freeze button — X: lock/unlock compass following (does NOT rotate) + let freezeBtn = document.createElement("button"); + freezeBtn.className = "rec-map-btn rec-freeze-btn"; + freezeBtn.textContent = "X"; + freezeBtn.title = "Заморозить поворот карты"; + freezeBtn.onclick = () => { + compassFrozen = !compassFrozen; + if (compassFrozen) frozenHeading = baseHeading(); + freezeBtn.classList.toggle("active", compassFrozen); + applyBearing(); + }; + btnWrap.appendChild(freezeBtn); // Roads toggle button let roadsBtn = document.createElement("button"); @@ -2928,89 +3058,41 @@ function renderRecordingInline(container) { if (localStorage.getItem("baldav_show_roads") === "1") roadsBtn.click(); function handleOrientation(e) { - if (orientMode === "off") return; // no compass, no landscape - let heading = e.webkitCompassHeading || e.alpha || 0; - let beta = e.beta || 0; - let gamma = e.gamma || 0; - lastBeta = beta; - lastGamma = gamma; - - // Gimbal lock compensation: when phone tilts past horizontal, - // browser flips alpha by 180° (Euler angle discontinuity). - // Detect by checking if phone is "face-down" and compensate. - if (currentLandscape !== null && Math.abs(beta) > 90) { - heading = heading + 180; - } - if (currentLandscape === null && Math.abs(gamma) > 90) { - heading = heading + 180; - } - - // Landscape detection with hysteresis: enter at 75, exit at 25 - if (orientMode === "auto") { - let enterThreshold = 75; - let exitThreshold = 25; - let newLandscape = currentLandscape; - if (currentLandscape === null) { - if (gamma > enterThreshold) newLandscape = 'right'; - else if (gamma < -enterThreshold) newLandscape = 'left'; - } else if (currentLandscape === 'right') { - if (gamma < exitThreshold) newLandscape = null; - } else if (currentLandscape === 'left') { - if (gamma > -exitThreshold) newLandscape = null; - } - - if (newLandscape !== currentLandscape) { - currentLandscape = newLandscape; - if (recScreen) { - recScreen.classList.remove('landscape-right', 'landscape-left'); - if (newLandscape) recScreen.classList.add('landscape-' + newLandscape); - } - let nav = document.querySelector('.bottom-nav'); - if (nav) nav.style.display = newLandscape ? 'none' : ''; - setTimeout(() => map.invalidateSize(), 100); - } - } - - // Debug: show all 3 axes on map overlay - let dbgEl = document.querySelector("#rec-orient-debug"); - if (!dbgEl) { - dbgEl = document.createElement("div"); - dbgEl.id = "rec-orient-debug"; - dbgEl.style.cssText = "position:absolute;bottom:4px;left:4px;z-index:1001;background:rgba(0,0,0,0.7);color:#0f0;font:11px monospace;padding:4px 6px;border-radius:4px;pointer-events:none;"; - mapEl.parentElement.appendChild(dbgEl); - } - dbgEl.textContent = `a:${Math.round(e.alpha||0)} b:${Math.round(e.beta||0)} g:${Math.round(e.gamma||0)} h:${Math.round(heading)} m:${orientMode}`; - - // Smooth heading with EMA (exponential moving average) — reduces jitter from bumps - // Factor 0.3 = responsive but smooth (~3 samples to settle, 300ms at 10Hz) - let finalHeading = heading; - if (smoothedHeading === null) { - smoothedHeading = finalHeading; - } else { - let diff = finalHeading - smoothedHeading; + let raw; + if (e.webkitCompassHeading != null) raw = e.webkitCompassHeading; // iOS: true heading + else if (e.alpha != null) raw = e.alpha % 360; // this device: alpha is CW from N (not CCW) — no inversion + else return; + lastRawHeading = raw; // for the calibration tap + let heading = (raw - northOffset + 360) % 360; // subtract the fixed magnetometer offset + // EMA smoothing to reduce needle jitter (factor 0.3). + if (smoothedHeading === null) smoothedHeading = heading; + else { + let diff = heading - smoothedHeading; if (diff > 180) diff -= 360; if (diff < -180) diff += 360; - smoothedHeading = (smoothedHeading + diff * 0.3) % 360; - if (smoothedHeading < 0) smoothedHeading += 360; - finalHeading = smoothedHeading; + smoothedHeading = (smoothedHeading + diff * 0.3 + 360) % 360; } - - // Map bearing always updates in auto and lock modes - let bearingOffset = 0; - if (currentLandscape === 'right') bearingOffset = 90; - else if (currentLandscape === 'left') bearingOffset = -90; - map.setBearing(finalHeading + bearingOffset); + // DEBUG overlay: raw sensor values on screen (to verify calibration). + let dbg = document.getElementById("orient-dbg"); + if (!dbg) { + dbg = document.createElement("div"); + dbg.id = "orient-dbg"; + dbg.style.cssText = "position:absolute;bottom:4px;left:4px;z-index:1002;background:rgba(0,0,0,0.85);color:#0f0;font:11px monospace;padding:3px 5px;border-radius:4px;pointer-events:none;white-space:nowrap;"; + mapEl.parentElement.appendChild(dbg); + } + dbg.textContent = "a=" + Math.round(e.alpha || 0) + " raw=" + Math.round(raw) + " off=" + Math.round(northOffset) + " h=" + Math.round(heading) + " sm=" + Math.round(smoothedHeading); + updateArrows(); // keep the orange compass arrow live even in GPS mode + if (!compassFrozen) applyBearing(); } - let throttledOrientation = L.Util.throttle(handleOrientation, 100); - let orientEvent = 'ondeviceorientationabsolute' in window - ? 'deviceorientationabsolute' : 'deviceorientation'; + let orientEvent = 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation'; window.addEventListener(orientEvent, throttledOrientation); + applyBearing(); // initial: north-up until first compass reading GPS.recording._compassCleanup = () => { window.removeEventListener(orientEvent, throttledOrientation); - if (recScreen) recScreen.classList.remove('landscape-right', 'landscape-left'); - let nav = document.querySelector('.bottom-nav'); - if (nav) nav.style.display = ''; + if (recScreen) recScreen.classList.remove("rot-90", "rot-270"); + let nav = document.querySelector(".bottom-nav"); + if (nav) nav.style.display = ""; }; let posMarker = null; @@ -3075,6 +3157,9 @@ function renderRecordingInline(container) { return; } + // Orient map by direction of movement (GPS course) + update direction arrows + updateCourse(p); + // Speed let curSpeed = (p.speed_ms != null && p.speed_ms >= 0) ? p.speed_ms * 3.6 : 0; speedBuf.push(curSpeed); @@ -3206,6 +3291,7 @@ function renderRecordingInline(container) { map.panTo(last); if (posMarker) map.removeLayer(posMarker); posMarker = L.circleMarker(last, { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map); + updateCourse(sorted[sorted.length - 1]); // course from the last point object (has bearing) } let stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, GPS.getRecordingOpts()); if (distEl) distEl.textContent = GPS.formatDistance(stats.distance); @@ -3244,9 +3330,17 @@ function setupLiveUpdates(container) { if (spdEl) spdEl.textContent = stats.avgSpeed + " км/ч"; }; - // Timer for duration display - timer = setInterval(() => { + // Timer for duration display + auto-finish watchdog for a forgotten session + timer = setInterval(async () => { if (!GPS.recording.active) { clearInterval(timer); return; } + if (GPS.shouldAutoFinish()) { + clearInterval(timer); + if (GPS.recording._compassCleanup) GPS.recording._compassCleanup(); + await GPS.stopRecording(); + showToast("Тренировка завершена автоматически — нет движения"); + renderScreen("gps"); + return; + } let sec = Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000); if (timeEl) timeEl.textContent = GPS.formatDuration(sec); }, 1000); @@ -3524,6 +3618,7 @@ async function showSessionDetail(sessionId) { ${session.activity_type === 'bike' ? `
` : ''} + `, () => {}); @@ -3555,6 +3650,7 @@ async function showSessionDetail(sessionId) { // Manual routes: no splitting — points are connected as-is (synthetic timestamps have large gaps) let isManualRoute = session.source === "manual"; let gapSec = GPS.config.gapThresholdSec ?? 30; + let maxGapConnectSec = 120; // beyond this, never draw a connecting line — split instead let segments = []; // array of arrays of [lat, lon] let gapLines = []; // dashed lines for accel-confirmed gaps let curSeg = [[points[0].lat, points[0].lon]]; @@ -3564,7 +3660,10 @@ async function showSessionDetail(sessionId) { if (gap > gapSec) { let accelBefore = points[i-1].accel_rms; let accelAfter = points[i].accel_rms; - if (accelBefore != null && accelAfter != null && accelBefore > 2 && accelAfter > 2) { + // Only bridge a SHORT gap (movement confirmed by accel) with a dashed line. + // A long gap is not real travel — split with no connecting line, so we never + // draw a straight line across half the city. + if (gap <= maxGapConnectSec && accelBefore != null && accelAfter != null && accelBefore > 2 && accelAfter > 2) { curSeg.push([points[i].lat, points[i].lon]); gapLines.push([[points[i-1].lat, points[i-1].lon], [points[i].lat, points[i].lon]]); } else { @@ -3929,6 +4028,47 @@ async function showSessionDetail(sessionId) { } }; + let refineBtn = document.querySelector("#sd-refine"); + if (refineBtn) refineBtn.onclick = async () => { + let reset = (t) => { refineBtn.textContent = t; refineBtn.disabled = false; }; + refineBtn.disabled = true; + refineBtn.textContent = "Загрузка высот..."; + try { + let pts = await DB.getGpsPoints(sessionId); + if (pts.length < 2) return reset("Уточнить высоты (онлайн)"); + // Sample to ~300 points to limit API load; terrain is smooth → interpolate the rest. + let step = Math.max(1, Math.ceil(pts.length / 300)); + let sampleIdx = []; + for (let i = 0; i < pts.length; i += step) sampleIdx.push(i); + if (sampleIdx[sampleIdx.length - 1] !== pts.length - 1) sampleIdx.push(pts.length - 1); + let terr = await fetchElevations(sampleIdx.map(i => pts[i])); + if (!terr || !terr.some(x => x != null)) { + if (typeof showToast === "function") showToast("Сервис высот недоступен"); + return reset("Уточнить высоты (онлайн)"); + } + // Interpolate terrain to every point (linear by index between samples). + let alt = new Array(pts.length).fill(null); + for (let s = 0; s < sampleIdx.length - 1; s++) { + let i0 = sampleIdx[s], i1 = sampleIdx[s + 1], a0 = terr[s], a1 = terr[s + 1]; + if (a0 == null || a1 == null) continue; + for (let i = i0; i <= i1; i++) alt[i] = a0 + (a1 - a0) * (i1 > i0 ? (i - i0) / (i1 - i0) : 0); + } + let rows = pts.map((p, i) => ({ id: p.id, srtm: alt[i] })).filter(r => r.srtm != null); + refineBtn.textContent = "Сохранение..."; + await DB.updatePointsSrtm(rows); + await GPS.recalculateSession(sessionId); + if (typeof showToast === "function") showToast("Высоты уточнены по " + rows.length + " точкам"); + if (winStack.length > 0) winStack.pop().close(); + _skipPops = 1; + history.back(); + showSessionDetail(sessionId); + } catch (e) { + console.log("[refine] fail:", e.message); + if (typeof showToast === "function") showToast("Ошибка высот: " + e.message); + reset("Уточнить высоты (онлайн)"); + } + }; + let del = document.querySelector("#sd-delete"); if (del) del.onclick = async () => { await DB.deleteGpsSession(sessionId); @@ -4560,7 +4700,7 @@ async function renderSettings(container) {
Макс. погрешность GPS (м)
-
Хуже этого = пропуск (авто x2 при плохом сигнале)
+
Жёсткий порог: хуже этого = пропуск (рубит сотовые ~800м, оставляет слабый GPS). 50 = разумно.
@@ -4631,6 +4771,13 @@ async function renderSettings(container) { +
+
+
Fused-локация (Google Play)
+
Альтернатива raw GPS. Иногда стабильнее при выкл. экране. Требует сервисы Google.
+
+ +
@@ -4652,6 +4799,13 @@ async function renderSettings(container) {
+
+
+
Фоновая геолокация
+
Для записи при выключенном экране. Нужно выбрать «Разрешать всегда».
+
+ +
@@ -4877,6 +5031,8 @@ async function renderSettings(container) { GPS.config.altSmoothHalf = parseInt(container.querySelector("#s-alt-smooth").value) || 5; GPS.config.walkMaxSpeedKmh = parseFloat(container.querySelector("#s-walk-max-speed").value) || 12; GPS.config.walkMinSpeedMs = parseFloat(container.querySelector("#s-walk-min-speed").value) / 3.6; + let fusedEl = container.querySelector("#s-use-fused"); + if (fusedEl) GPS.config.useFusedLocation = fusedEl.checked; GPS.saveConfig(); }; } @@ -4943,6 +5099,12 @@ async function renderSettings(container) { ); }; + let bgLocBtn = container.querySelector("#s-bgloc-btn"); + if (bgLocBtn) bgLocBtn.onclick = async () => { + await GPS.requestBackgroundLocation(); + showToast("Настройки → Разрешения → Геоданные → «Разрешать всегда»"); + }; + // BLE HR device scan and bind let bleUnbindBtn = container.querySelector("#s-ble-unbind"); if (bleUnbindBtn) { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 32c09ea..a46d007 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.321" +version = "0.1.361" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ae2fde0..9afeb74 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.321" +version = "0.1.361" edition = "2021" [lib] diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 64a0863..8d9624e 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + 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 index 56474f1..ad99823 100644 --- 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 @@ -13,6 +13,12 @@ import android.os.Build import android.os.Handler import android.os.IBinder import android.os.Looper +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority import android.os.PowerManager import android.hardware.Sensor import android.hardware.SensorEvent @@ -34,6 +40,7 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener { const val EXTRA_MAX_ACC = "max_acc" const val EXTRA_MIN_DIST = "min_dist" const val EXTRA_MAX_SPEED = "max_speed" + const val EXTRA_USE_FUSED = "use_fused" var isRunning = false private set @@ -59,8 +66,16 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener { private val pointBuffer = mutableListOf() private val bufferLock = Object() + // GPS/sensor callbacks and flushing run on the MAIN looper. Delivering location to a + // background HandlerThread caused the OS to throttle updates to zero when the app was + // not visible (screen off) — main-thread delivery is what worked historically. private val flushHandler = Handler(Looper.getMainLooper()) + // Optional fused location provider (Google Play services) — opt-in via settings. + private var useFused = false + private var fusedClient: FusedLocationProviderClient? = null + private var fusedCallback: LocationCallback? = null + // Raw sensor file (50Hz for cadence analysis) private var rawSensorFile: File? = null private val rawSensorBuffer = mutableListOf() @@ -90,7 +105,9 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener { 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") + // Preserve the fused choice across OS (START_STICKY, null intent) restarts. + if (intent != null) useFused = intent.getBooleanExtra(EXTRA_USE_FUSED, false) + Log.d(TAG, "Starting GPS recording: minSpeed=${minSpeedMs}m/s maxAcc=${maxAccuracyM}m minDist=${minDistanceM}m maxSpeed=${maxSpeedMs}m/s useFused=$useFused") isRunning = true // Points file in app root data dir (same as Rust app_data_dir) @@ -143,23 +160,32 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener { startForeground(NOTIFICATION_ID, notification) - // WakeLock + // WakeLock — force-held for the whole session so the CPU keeps delivering + // GPS while the screen is off. Non-ref-counted + re-acquired if released. 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 + if (wakeLock == null) { + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "baldav:gps").apply { + setReferenceCounted(false) + } + } + if (wakeLock?.isHeld != true) { + wakeLock?.acquire(6 * 60 * 60 * 1000L) // 6 hours max + Log.d(TAG, "WakeLock acquired (held=${wakeLock?.isHeld})") + } - // Start GPS + // Start GPS — callbacks delivered on the MAIN looper (this is what historically + // kept working in the background; a background thread got throttled to zero). 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") + if (useFused) { + // Fused provider (Google Play services) — opt-in. Falls back to raw GPS. + if (!startFusedUpdates(Looper.getMainLooper())) { + Log.w(TAG, "Fused location unavailable — falling back to raw GPS_PROVIDER") + startRawGpsUpdates() + } + } else { + startRawGpsUpdates() + } // Register accelerometer + gyroscope (non-critical — if missing, GPS still works) try { @@ -183,6 +209,15 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener { flushHandler.postDelayed({ flushBuffer() flushRawSensors() + // Heartbeat: proves the loop is alive and shows GPS freshness. + val ageMs = if (lastTime > 0) System.currentTimeMillis() - lastTime else -1 + val bufSize = synchronized(bufferLock) { pointBuffer.size } + Log.d(TAG, "Heartbeat: held=${wakeLock?.isHeld} buf=$bufSize lastFixAgeMs=$ageMs pts=$pointCount") + // Re-acquire the wakelock if the system released it under us. + if (isRunning && wakeLock?.isHeld != true) { + wakeLock?.acquire(6 * 60 * 60 * 1000L) + Log.w(TAG, "WakeLock was released — re-acquired") + } if (isRunning) scheduleFlush() }, 10000) } @@ -240,6 +275,7 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener { private var lastLat = 0.0 private var lastLon = 0.0 private var lastTime = 0L + private var lastGpsFixTime = 0L // wall-clock of last real GPS fix — to prefer GPS over network private var pointCount = 0 @@ -312,76 +348,115 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener { } private var lastGoodPointTime = 0L - private var lastLowAccTime = 0L - private val recentAccuracies = mutableListOf() // last 10 accuracy values - private var adaptiveMaxAccuracy = 0f // 0 = use configured value + + private fun startRawGpsUpdates() { + val gpsEnabled = locationManager?.isProviderEnabled(LocationManager.GPS_PROVIDER) ?: false + Log.d(TAG, "GPS provider enabled: $gpsEnabled") + // 4-arg overload → callbacks delivered on the calling (main) thread's looper. + locationManager?.requestLocationUpdates( + LocationManager.GPS_PROVIDER, + 1000L, // min time 1s + 0f, // min distance 0m + this + ) + // Also NETWORK (wifi/cell): keeps tracking alive when raw GPS is silent + // (indoors/weak sky). Coarse, so processLocation prefers GPS when it's fresh. + if (locationManager?.isProviderEnabled(LocationManager.NETWORK_PROVIDER) == true) { + locationManager?.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, 1000L, 0f, this + ) + Log.d(TAG, "Network provider updates registered") + } + Log.d(TAG, "Location updates registered") + } + + // Returns false if the fused provider can't be started (e.g. no Play services), + // so the caller can fall back to raw GPS. SecurityException (missing permission) + // is rethrown so the outer handler stops the service, matching the raw path. + private fun startFusedUpdates(looper: Looper): Boolean { + return try { + val client = LocationServices.getFusedLocationProviderClient(this) + val req = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000L) + .setMinUpdateIntervalMillis(1000L) + .setMinUpdateDistanceMeters(0f) + .build() + val cb = object : LocationCallback() { + override fun onLocationResult(result: LocationResult) { + for (loc in result.locations) processLocation(loc) + } + } + client.requestLocationUpdates(req, cb, looper) + fusedClient = client + fusedCallback = cb + Log.d(TAG, "Fused location updates registered") + true + } catch (e: SecurityException) { + throw e + } catch (e: Throwable) { + Log.w(TAG, "Fused start failed: ${e.message}") + false + } + } override fun onLocationChanged(location: Location) { - Log.v(TAG, "Location: acc=${location.accuracy} spd=${location.speed} lat=${location.latitude}") + processLocation(location) + } + + private fun processLocation(location: Location) { + Log.v(TAG, "Location: prov=${location.provider} acc=${location.accuracy} spd=${location.speed} lat=${location.latitude}") + + // Hard accuracy gate: choose the MORE ACCURATE source. Reject coarse fixes + // (cell/wifi ~500-800m that "sit at the tower" and jump). Keep only fixes at + // least as accurate as the "Min. accuracy" setting — weak but real GPS (up to + // the threshold) is kept, coarse network fixes are dropped as honest gaps. + if (location.accuracy > maxAccuracyM) { + Log.d(TAG, "Rejected coarse fix: prov=${location.provider} acc=${location.accuracy} > ${maxAccuracyM}") + return + } + + // Prefer GPS: while real GPS is delivering, drop coarse NETWORK fixes so they + // don't jitter the track. When GPS goes silent, network fills in. + val nowRt = System.currentTimeMillis() + if (location.provider == LocationManager.NETWORK_PROVIDER) { + if (nowRt - lastGpsFixTime < 20000) return + } else if (location.provider == LocationManager.GPS_PROVIDER) { + lastGpsFixTime = nowRt + } val now = location.time val sensorData = consumeSensorData() - // Adaptive accuracy threshold: if signal is consistently bad, relax the limit - recentAccuracies.add(location.accuracy) - if (recentAccuracies.size > 10) recentAccuracies.removeAt(0) - val effectiveMaxAcc = if (recentAccuracies.size >= 5) { - val median = recentAccuracies.sorted()[recentAccuracies.size / 2] - if (median > maxAccuracyM) { - // Bad signal: use 2x configured threshold - val adapted = maxAccuracyM * 2f - if (adaptiveMaxAccuracy == 0f) Log.d(TAG, "Bad GPS signal, relaxing accuracy to ${adapted}m") - adaptiveMaxAccuracy = adapted - adapted - } else { - if (adaptiveMaxAccuracy > 0f) Log.d(TAG, "GPS signal OK, restoring accuracy to ${maxAccuracyM}m") - adaptiveMaxAccuracy = 0f - maxAccuracyM - } - } else maxAccuracyM + // Speed sanity + min-distance filtering (all fixes here already pass the gate) + if (location.speed > maxSpeedMs) return + if (location.speed < minSpeedMs) { + // Speed too low but check if accel shows movement — keep the point anyway + // (GPS speed unreliable in bad weather, accel is truth) + val rmsMatch = Regex(""""accel_rms":(\d+\.?\d*)""").find(sensorData) + val rms = rmsMatch?.groupValues?.get(1)?.toFloatOrNull() ?: 0f + if (rms < 2f) return // truly stationary + } + if (lastTime > 0) { + val results = FloatArray(1) + Location.distanceBetween(lastLat, lastLon, location.latitude, location.longitude, results) + if (results[0] < minDistanceM) return + } - // Good accuracy: normal filtering - if (location.accuracy <= effectiveMaxAcc) { - if (location.speed > maxSpeedMs) return - if (location.speed < minSpeedMs) { - // Speed too low but check if accel shows movement — keep the point anyway - // (GPS speed unreliable in bad weather, accel is truth) - val hasAccel = sensorData.contains("accel_rms") - val rmsMatch = Regex(""""accel_rms":(\d+\.?\d*)""").find(sensorData) - val rms = rmsMatch?.groupValues?.get(1)?.toFloatOrNull() ?: 0f - if (rms < 2f) return // truly stationary - } - 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 = now - lastGoodPointTime = now - // Calibrate DR speed from GPS - if (location.speed > 0.5f && location.speed < 10f) { - avgWalkSpeedMs = avgWalkSpeedMs * 0.8f + location.speed * 0.2f // EMA - } - // Reset DR heading from GPS bearing if available - if (location.hasBearing()) { - drHeading = Math.toRadians(location.bearing.toDouble()) - } - } else { - // Low accuracy: accept at most 1 point per 30 seconds (keeps track alive) - if (now - lastLowAccTime < 30000) return - lastLowAccTime = now - // Use coordinates but mark as low accuracy - lastLat = location.latitude - lastLon = location.longitude - lastTime = now - Log.d(TAG, "Low accuracy point: acc=${location.accuracy}") + lastLat = location.latitude + lastLon = location.longitude + lastTime = now + lastGoodPointTime = now + // Calibrate DR speed from GPS + if (location.speed > 0.5f && location.speed < 10f) { + avgWalkSpeedMs = avgWalkSpeedMs * 0.8f + location.speed * 0.2f // EMA + } + // Reset DR heading from GPS bearing if available + if (location.hasBearing()) { + drHeading = Math.toRadians(location.bearing.toDouble()) } pointCount++ - val line = """{"lat":${location.latitude},"lon":${location.longitude},"alt":${location.altitude},"speed":${location.speed},"acc":${location.accuracy},"ts":${now}${sensorData}}""" + val bearing = if (location.hasBearing()) location.bearing else -1f + val line = """{"lat":${location.latitude},"lon":${location.longitude},"alt":${location.altitude},"speed":${location.speed},"acc":${location.accuracy},"bearing":${bearing},"ts":${now}${sensorData}}""" synchronized(bufferLock) { pointBuffer.add(line) } @@ -433,6 +508,9 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener { flushBuffer() // flush remaining points flushRawSensors() // flush remaining raw sensor data locationManager?.removeUpdates(this) + fusedCallback?.let { cb -> try { fusedClient?.removeLocationUpdates(cb) } catch (_: Throwable) {} } + fusedClient = null + fusedCallback = null sensorManager?.unregisterListener(this) wakeLock?.let { if (it.isHeld) it.release() } stopForeground(true) 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 cfe70ea..ae77521 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 @@ -2,8 +2,12 @@ package dev.baldav.app import android.content.Intent import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager import android.net.Uri import android.os.Build +import android.provider.Settings import android.os.Bundle import android.os.FileObserver import android.util.Log @@ -52,6 +56,10 @@ class MainActivity : TauriActivity() { processBleCommand(appDataDir) Log.d("BalDav", "Command watcher started on ${appDataDir.absolutePath}") + // Diagnostic HTTP endpoint on localhost — GET /gps makes the app request a live + // GPS fix and returns it. Reachable from a host via `adb forward tcp:8787 tcp:8787`. + startProbeServer() + // Always copy seed DB from assets — ensures APK updates deliver new products. try { val seedDest = File(filesDir, "products_seed.db") @@ -122,12 +130,14 @@ class MainActivity : TauriActivity() { val maxAcc = parts.getOrNull(2)?.toFloatOrNull() ?: 20f val minDist = parts.getOrNull(3)?.toFloatOrNull() ?: 1f val maxSpeed = parts.getOrNull(4)?.toFloatOrNull() ?: 16.67f - Log.d("BalDav", "Starting GPS service: speed=$minSpeed acc=$maxAcc dist=$minDist maxSpeed=$maxSpeed") + val useFused = parts.getOrNull(5) == "1" + Log.d("BalDav", "Starting GPS service: speed=$minSpeed acc=$maxAcc dist=$minDist maxSpeed=$maxSpeed useFused=$useFused") 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) + putExtra(GpsRecorderService.EXTRA_USE_FUSED, useFused) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(intent) @@ -153,6 +163,9 @@ class MainActivity : TauriActivity() { // Allow screen to turn off window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } + cmd == "reqbgloc" -> { + requestBackgroundLocation() + } cmd == "ble_scan" -> { Log.d("BalDav", "Starting BLE scan") writeBtState(appDataDir) @@ -297,6 +310,88 @@ class MainActivity : TauriActivity() { } } + // Localhost HTTP server for on-demand GPS probing. GET /gps → request a live fix + // from GPS + NETWORK, wait up to 20s, return JSON. Reach it via adb forward + curl. + private var probeServer: Thread? = null + private fun startProbeServer() { + if (probeServer != null) return + probeServer = Thread { + try { + val server = java.net.ServerSocket() + server.reuseAddress = true + server.bind(java.net.InetSocketAddress("0.0.0.0", 8787)) + Log.d("BalDav", "Probe HTTP server on 0.0.0.0:8787") + while (true) { + val sock = server.accept() + try { + val reqLine = sock.getInputStream().bufferedReader().readLine() ?: "" + val json = if (reqLine.contains("/gps")) probeGpsBlocking() else """{"error":"use GET /gps"}""" + val out = sock.getOutputStream().bufferedWriter() + out.write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: ${json.toByteArray().size}\r\n\r\n") + out.write(json); out.flush() + } catch (e: Exception) { + Log.w("BalDav", "probe req: ${e.message}") + } finally { try { sock.close() } catch (_: Exception) {} } + } + } catch (e: Exception) { Log.e("BalDav", "Probe server failed: ${e.message}") } + }.apply { isDaemon = true; start() } + } + + // Requests a fresh fix from GPS and NETWORK, blocks up to 20s for the GPS one, + // then returns fresh + last-known for both providers. + private fun probeGpsBlocking(): String { + val lm = getSystemService(LOCATION_SERVICE) as LocationManager + val names = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) + val fresh = java.util.concurrent.ConcurrentHashMap() + val gotGps = java.util.concurrent.CountDownLatch(1) + val listeners = HashMap() + val handler = android.os.Handler(mainLooper) + for (n in names) { + if (!(try { lm.isProviderEnabled(n) } catch (e: Exception) { false })) continue + val l = object : LocationListener { + override fun onLocationChanged(loc: Location) { + fresh[n] = loc + if (n == LocationManager.GPS_PROVIDER) gotGps.countDown() + } + override fun onProviderDisabled(p: String) {} + override fun onProviderEnabled(p: String) {} + override fun onStatusChanged(p: String?, s: Int, e: android.os.Bundle?) {} + } + listeners[n] = l + handler.post { try { lm.requestLocationUpdates(n, 0L, 0f, l, mainLooper) } catch (e: SecurityException) {} } + } + try { gotGps.await(20, java.util.concurrent.TimeUnit.SECONDS) } catch (_: Exception) {} + handler.post { for (l in listeners.values) try { lm.removeUpdates(l) } catch (_: Exception) {} } + fun j(l: Location?): String = + if (l == null) "null" + else """{"lat":${l.latitude},"lon":${l.longitude},"acc":${l.accuracy},"ageMs":${System.currentTimeMillis() - l.time}}""" + val gpsLast = try { lm.getLastKnownLocation(LocationManager.GPS_PROVIDER) } catch (e: SecurityException) { null } + val netLast = try { lm.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) } catch (e: SecurityException) { null } + return """{"gpsFresh":${j(fresh[LocationManager.GPS_PROVIDER])},"networkFresh":${j(fresh[LocationManager.NETWORK_PROVIDER])},"gpsLast":${j(gpsLast)},"networkLast":${j(netLast)},"ts":${System.currentTimeMillis()}}""" + } + + // Open this app's system settings page so the user can set location to + // "Allow all the time" (background location can only be granted there on Android 11+). + // Always opens — predictable regardless of the current grant state. + private fun requestBackgroundLocation() { + // Android 10: a runtime dialog for background location still works. + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && + checkSelfPermission(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION) != PackageManager.PERMISSION_GRANTED && + checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { + requestPermissions(arrayOf(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION), 4242) + return + } + try { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", packageName, null)) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + Log.d("BalDav", "Opened app settings for background location") + } catch (e: Exception) { + Log.e("BalDav", "Failed to open app settings: ${e.message}") + } + } + private fun ensureBlePermissions(): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val needed = mutableListOf() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fc3c704..435165c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,20 +7,32 @@ use serde::Serialize; #[allow(unused, dead_code)] mod ble; -// Simple HTTP proxy for sync requests (WebView blocks mixed http/https content) +// Simple HTTP proxy for sync requests (WebView blocks mixed http/https content). +// Runs the blocking ureq call on a worker thread (spawn_blocking) with short +// timeouts, so an unreachable host (e.g. router 192.168.1.1 when off home wifi) +// never freezes the UI — it just fails after a few seconds in the background. #[tauri::command] -fn http_request(url: String, method: String, body: Option) -> Result { - let mut resp = match method.to_uppercase().as_str() { - "POST" => { - let b = body.unwrap_or_default(); - ureq::post(&url) - .header("Content-Type", "application/json") - .send(b.as_bytes()) - .map_err(|e| e.to_string())? - } - _ => ureq::get(&url).call().map_err(|e| e.to_string())?, - }; - resp.body_mut().read_to_string().map_err(|e| e.to_string()) +async fn http_request(url: String, method: String, body: Option) -> Result { + tauri::async_runtime::spawn_blocking(move || -> Result { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_connect(Some(std::time::Duration::from_secs(4))) + .timeout_global(Some(std::time::Duration::from_secs(12))) + .build() + .into(); + let mut resp = match method.to_uppercase().as_str() { + "POST" => { + let b = body.unwrap_or_default(); + agent.post(&url) + .header("Content-Type", "application/json") + .send(b.as_bytes()) + .map_err(|e| e.to_string())? + } + _ => agent.get(&url).call().map_err(|e| e.to_string())?, + }; + resp.body_mut().read_to_string().map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? } // Get the active points file name from gps_live_points.jsonl pointer diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 780ec52..7767888 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.321", + "version": "0.1.361", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend"