diff --git a/.gitignore b/.gitignore index f197bca..310ebe7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,13 @@ fitotrack_src/ fitotrack_v156/ fitotrack_*.apk de.tadris.fitness_*.tar.gz + +# Offline map artifacts: raw PNG tiles and packed tile DBs (regenerable, large) +data/tiles/ +tiles_*.db + +# Local DB backups pulled from the phone +db_backups/ + +# Session-local analysis harnesses, copied DBs, pulled 50Hz sensor dumps (large) +test_recovery/ diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index e968f4d..04b19fb 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -23,7 +23,7 @@ |------|-------|------------| | `js/main.js` | ~5800 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги | | `js/db.js` | ~1500 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync | -| `js/gps.js` | ~1400 | GPS запись, физика ккал (power model), altitude filter, BLE HR polling, dead reckoning. Импорт нативных точек: JS-поллинг (экран-gated) + ПОЛНЫЙ до-импорт по множеству ts на «Стоп»/recoverSession (без потерь при экране-офф), отбор по точности (minAccuracyM), bearing_deg | +| `js/gps.js` | ~1600 | GPS запись, ккал (нетто-модель + MET-нетто floor, накат=базовый), altitude filter + гистерезис высоты, BLE HR polling, dead reckoning. Источник истины БД при живой записи — нативный файл (`_processIncomingPoint(p, fromNative)`: watchPosition только рисует, поллинг пишет). `recoverFromFiles` — восстановление точек с устройства (матч по id/дате + `_s` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. Отбор по точности (minAccuracyM), bearing_deg | | `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) | | `js/tilecache.js` | 178 | Оффлайн кэш тайлов карты (IndexedDB) | | `js/bmr.js` | ~70 | BMR (Mifflin-St Jeor), РСП из БД (33 нутриента), полураспад | @@ -38,7 +38,7 @@ | Файл | Строк | Назначение | |------|-------|------------| -| `src/lib.rs` | ~700 | Миграции v1, IPC команды, seed import, update check, tile import, road map rebuild | +| `src/lib.rs` | ~750 | Миграции v1, IPC команды, seed import, update check, tile import, road map rebuild. `native_root()` — выбор корня приложения по наличию `gps_live_points.jsonl`/`gps_points_*` (на этом Tauri `app_data_dir()` = корень напрямую; старый `.parent().parent()` читал мимо, отсюда потеря фоновых точек) | | `src/ble.rs` | ~470 | BLE HR: btleplug (desktop), file IPC (Android), FitPro + Standard HR | | `src/main.rs` | 5 | Точка входа | | `Cargo.toml` | — | tauri, tauri-plugin-sql, rusqlite, serde, btleplug, tokio, uuid | @@ -49,7 +49,7 @@ | Файл | Строк | Назначение | |------|-------|------------| | `MainActivity.kt` | ~300 | FileObserver IPC, seed DB, safe insets, SAF, BLE commands, OTA check | -| `GpsRecorderService.kt` | ~500 | Foreground service GPS, WakeLock, JSONL (один файл на сессию до Стоп, идемпотентный onStartCommand при START_STICKY-перезапуске ОС), 50Hz акселерометр/гироскоп, dead reckoning, raw sensor files | +| `GpsRecorderService.kt` | ~530 | Foreground service GPS, WakeLock, JSONL (один файл на сессию до Стоп, идемпотентный onStartCommand при START_STICKY-перезапуске ОС; `bindSessionIfPending()` дописывает id сессии в имя файла `_s` по `gps_session.txt`), 50Hz акселерометр/гироскоп, dead reckoning, raw sensor files | | `BleHrService.kt` | ~340 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect | | `generated/*.kt` | ~1100 | Tauri-сгенерированные: WebView, IPC, permissions | @@ -146,7 +146,11 @@ | `import_seed_products` | Импорт продуктов из seed DB, прогресс-события | | `get_safe_insets` | Отступы Android status/nav bar | | `gps_service_command` | start/stop GPS + BLE команды (ble_scan/connect/disconnect) | -| `gps_service_read_points` | Чтение JSONL файла точек | +| `gps_service_read_points` | Чтение активного JSONL файла точек (через `native_root`) | +| `gps_set_session_id` | Передать id сессии сервису (пишет `gps_session.txt` → сервис дописывает `_s` в имя файла) | +| `list_points_files` / `read_points_file` / `delete_points_file` | Список/чтение/удаление нативных `gps_points_*` (восстановление + чистка) | +| `get_points_filename` | Имя активного файла точек (по указателю `gps_live_points.jsonl`) | +| `debug_native_paths` | Диагностика путей нативных файлов | | `save_file` | SAF диалог сохранения (GPX экспорт) | | `get_app_info` | Версия, git hash, дата сборки | | `get_update_info` | Проверка OTA обновления (BUILD_NUMBER с VPS) | @@ -165,15 +169,21 @@ ## Формула ккал (GPS) -### Велосипед — физическая модель +### Велосипед — НЕТТО-модель (суточный баланс = intake − BMR*activityLevel − activity, поэтому активность считается СВЕРХ покоя) ``` Power = v * (m*g*sin(gradient) + Crr*m*g*cos(gradient) + 0.5*CdA*rho*v^2) - m = rider_kg + bike_kg + m = rider_kg + bike_kg; gradient из СГЛАЖЕННОЙ высоты Crr = 0.004 (асфальт) / 0.006 (смешанный) / 0.008 (грунт) CdA = 0.6, rho = 1.225, efficiency = 0.25 -Power > 0: педалирование → kcal = power*dt / (0.25 * 4184) -Power <= 0: каботаж → kcal = базальный метаболизм +Педалирование (power>0): kcal = max(физика, MET-нетто), физика = power*dt/(0.25*4184) + MET по скорости: <16км/ч=6.0, <19=6.8, <22=8.0, <25=10.0, <28=12.0, иначе 14.0 + MET-нетто = (dt/60)*((MET-1)*3.5*weight)/200 (−1 MET покоя, он уже в BMR) + Floor только при v≥5км/ч (ниже — дрожание GPS, не езда) +Накат / freewheel (power≤0, «велик катился бы сам»): kcal = базальный метаболизм + (детектор кручу/накат по IMU-каденсу проверен на 50Hz — сигнал слишком слаб, не используется; хук cadence_rpm — под BLE-датчик шатуна) + +Набор высоты: кумулятивный гистерезис (elevRef, порог 1м) — на плотных треках per-point разница <1м даёт 0 ``` ### Ходьба/бег — MET-таблица diff --git a/README.md b/README.md index aaa2eec..31c4ca2 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ ## Возможности - **Дневник питания** — 24400+ продуктов (Магнит, 5ка, Health-Diet.ru, Ашан, Макдоналдс, KFC, Додо Пицца, KDV, ИОН, Гриднев). 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50). -- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию, переживает START_STICKY-перезапуск ОС; полный до-импорт нативных точек по timestamp при финализации — без потерь, даже если экран был выключен всю запись, а при пробуждении экрана точки видимо нарастают на карте по мере импорта). Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация) и course-up по GPS-курсу (в ландшафте карта доворачивается вместе с экраном — курс к физическому верху). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Импорт тайлов по регионам (SQLite, роутер LAN). +- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points__s.jsonl` для однозначной привязки, переживает START_STICKY-перезапуск ОС; полный до-импорт нативных точек по timestamp при финализации — без потерь, даже если экран был выключен всю запись, а при пробуждении экрана точки видимо нарастают на карте по мере импорта). При живой записи нативный файл — источник истины для БД, watchPosition только рисует маркер (без дублей одного фикса с разными ts). Восстановление точек из файлов на устройстве — матч файла к сессии по id/дате, дозаполнение разрывов с дедупом по близости ±2.5с, безопасный пересчёт без удаления точек, чистка уже импортированных файлов. Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация) и course-up по GPS-курсу (в ландшафте карта доворачивается вместе с экраном — курс к физическому верху). Ландшафтный режим (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 с коэффициентом активности. +- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает BMR, поэтому активность считается сверх покоя). Кручение = max(физика мощности из gradient+Crr+CdA / КПД, эмпирический MET-нетто по скорости); накат (freewheel, `power≤0` — «велик катился бы сам») = только базовый обмен. MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Набор высоты — кумулятивный гистерезис (устойчив на плотных треках, где per-point разница <1м). Пункт меню «Пересчитать высоту/калории» пересчитывает статистику всех треков без изменения точек. Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. - **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу. - **Нутриенты** — 37 микронутриентов (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10). Нормы РСП/АУП (РФ/ЕС/США). Фармакокинетика — запасы в организме по полураспаду. - **Трекинг жидкости** — water_pct в продуктах, прогресс-бар, настраиваемая норма. diff --git a/frontend/js/db.js b/frontend/js/db.js index 36fc871..0d4bef2 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -711,6 +711,41 @@ async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs, seq, bleDat await execute(`INSERT INTO gps_points (${cols.join(",")}) VALUES (${ph})`, vals); } +// Bulk insert native points (recovery). Multi-row INSERT in chunks — MUCH faster +// than per-point addGpsPoint (one IPC per ~75 rows instead of per row). `points` +// are native point objects {ts, lat, lon, altitude, speed_ms, bearing, accel_*, gyro_*}. +// Returns the number of rows inserted. seq continues from startSeq. +async function addGpsPointsBulk(sessionId, points, startSeq) { + if (!points || !points.length) return 0; + if (!_gpsPointCols) { + try { const info = await select("PRAGMA table_info(gps_points)"); _gpsPointCols = new Set(info.map(r => r.name)); } + catch { _gpsPointCols = new Set(["session_id","ts","lat","lon","altitude_gps_m","speed_ms","seq"]); } + } + const base = ["session_id","ts","lat","lon","altitude_gps_m","speed_ms","seq"]; + const extra = ["accel_rms","accel_max","gyro_pitch","gyro_yaw","bearing_deg"].filter(c => _gpsPointCols.has(c)); + const allCols = base.concat(extra); + const perRow = allCols.length; + const maxRows = Math.max(1, Math.floor(900 / perRow)); // stay under SQLite bind-var limit + let seq = startSeq || 0; + let inserted = 0; + for (let i = 0; i < points.length; i += maxRows) { + const chunk = points.slice(i, i + maxRows); + const rowSql = []; + const vals = []; + let ph = 1; + for (const p of chunk) { + seq++; + const row = [sessionId, p.ts, p.lat, p.lon, p.altitude ?? null, p.speed_ms ?? null, seq]; + for (const c of extra) row.push(c === "bearing_deg" ? (p.bearing ?? null) : (p[c] ?? null)); + rowSql.push("(" + row.map(() => `?${ph++}`).join(",") + ")"); + vals.push(...row); + } + await execute(`INSERT INTO gps_points (${allCols.join(",")}) VALUES ${rowSql.join(",")}`, vals); + inserted += chunk.length; + } + return inserted; +} + async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss) { // Guard against NaN — SQLite may reject or corrupt NaN values const safe = v => (v != null && !isNaN(v)) ? v : null; @@ -724,10 +759,10 @@ async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGa // NOTE: tauri-plugin-sql corrupts large i64 ids when returned as JS numbers // (off by ±1). Always read/pass session ids via the text `id_str` column. -async function getGpsSessions(limit) { +async function getGpsSessions(limit, offset) { return select( - "SELECT *, CAST(id AS TEXT) AS id_str FROM gps_sessions ORDER BY date DESC, start_time DESC, id DESC LIMIT ?1", - [limit ?? 50] + "SELECT *, CAST(id AS TEXT) AS id_str FROM gps_sessions ORDER BY date DESC, start_time DESC, id DESC LIMIT ?1 OFFSET ?2", + [limit ?? 50, offset ?? 0] ); } @@ -1033,6 +1068,7 @@ window.DB.cacheRecipeNutrients = cacheRecipeNutrients; window.DB.addFoodFromRecipe = addFoodFromRecipe; window.DB.createGpsSession = createGpsSession; window.DB.addGpsPoint = addGpsPoint; +window.DB.addGpsPointsBulk = addGpsPointsBulk; window.DB.finishGpsSession = finishGpsSession; window.DB.getGpsSessions = getGpsSessions; window.DB.getGpsSessionsForDate = getGpsSessionsForDate; diff --git a/frontend/js/gps.js b/frontend/js/gps.js index ac2a6d0..c3a82c4 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -97,6 +97,7 @@ GPS.computeStats = function(points, weightKg, opts) { const sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts)); let distance = 0, elevGain = 0, elevLoss = 0; + let elevRef = null; // running reference for cumulative elevation hysteresis const speeds = []; // m/s per segment // 5-point moving average on altitude to kill GPS jitter (typically 5-15m noise) @@ -202,11 +203,16 @@ GPS.computeStats = function(points, weightKg, opts) { 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); + // Elevation: cumulative hysteresis against a running reference — NOT the diff + // between adjacent points. On dense tracks (e.g. 5000 pts / 16 km) the per-point + // change is < 1 m, so an adjacent-diff dead zone accumulates 0 even on real + // climbs. Accumulate the rise/fall from the reference and only bank it (and + // advance the reference) once it clears the 1 m dead zone. + if (smoothedAlt[i] != null) { + if (elevRef == null) elevRef = smoothedAlt[i]; + let rise = smoothedAlt[i] - elevRef; + if (rise > 1) { elevGain += rise; elevRef = smoothedAlt[i]; } + else if (rise < -1) { elevLoss += -rise; elevRef = smoothedAlt[i]; } } } @@ -227,9 +233,11 @@ GPS.computeStats = function(points, weightKg, opts) { } if (isBike) { - // Gradient from filtered (outlier-removed) raw altitude - const altHere = filteredAlt[i] ?? 0; - const altPrev = filteredAlt[i-1] ?? 0; + // Gradient from SMOOTHED altitude (terrain-preferred, outlier-removed, smoothed) + // — using the raw filtered altitude let GPS-altitude noise inflate the gradient + // (and kcal) on un-refined tracks, making sessions inconsistent. + const altHere = smoothedAlt[i] ?? filteredAlt[i] ?? 0; + const altPrev = smoothedAlt[i-1] ?? filteredAlt[i-1] ?? 0; const altDiff = altHere - altPrev; const gradient = d > 2 ? Math.max(-0.5, Math.min(0.5, altDiff / d)) : 0; const sinG = gradient; @@ -247,20 +255,44 @@ GPS.computeStats = function(points, weightKg, opts) { const pAero = 0.5 * CdA * rho * v * v; const power = v * (pGravity + pRolling + pAero); - if (power > 0) { - // Pedaling: convert mechanical power to metabolic energy - const mechEnergy = power * dt; // joules - const metabEnergy = mechEnergy / efficiency; // joules (total metabolic cost) - totalKcal += metabEnergy / 4184; // convert to kcal + // Pedaling vs coasting. If the bike is freewheeling (you're NOT turning the + // pedals) it costs only basal — no matter the speed. How we know: + // - Physics proxy (what we use): power<=0 means the bike would roll at >= + // this speed unpowered — a downhill coast → basal. Otherwise → pedaling. + // (This proxy misses FLAT coasting, but that's rare and minor.) + // - cadence_rpm hook: reserved for a real crank cadence sensor (BLE) if one + // is ever added — that's the only reliable source. Deriving cadence from + // the phone IMU was tested on real 50Hz data and does NOT separate + // pedaling from coasting (~76-81% ≈ baseline), so it is not used. + const cad = sorted[i].cadence_rpm; + const pedaling = (cad != null) ? (cad >= 20) : (power > 0); - // Cadence: wheel revolutions / gear ratio = pedal revolutions + if (pedaling) { + // Both estimates NET (above resting): the daily balance already books BMR + // for the whole day (expend = BMR*activityLevel + activity, main.js), so + // activity kcal must be NET — never add basal here. + // 1. physics: mechanical work / drivetrain efficiency. At 15-18 km/h the + // mechanical power is only ~50-80 W, so on the flat this UNDERESTIMATES + // real metabolic cost ~2x — a lower bound; wins only on steep climbs. + // 2. MET floor: Compendium cycling METs by speed, MINUS the 1 MET of rest + // BMR already covers — the empirically measured cost. + const physicsKcal = Math.max(0, power) * dt / efficiency / 4184; + const vKmh = v * 3.6; + if (vKmh >= 5) { + const met = vKmh < 16 ? 6.0 : vKmh < 19 ? 6.8 : vKmh < 22 ? 8.0 : vKmh < 25 ? 10.0 : vKmh < 28 ? 12.0 : 14.0; + const metNetKcal = (dt / 60) * ((met - 1) * 3.5 * wKg) / 200; + totalKcal += Math.max(physicsKcal, metNetKcal); + } else { + totalKcal += physicsKcal; // creeping < 5 km/h: negligible + } + // Cadence estimate from gearing (theoretical) for avgCadence output. if (hasGearData && wheelCirc > 0) { const wheelRevs = d / wheelCirc; totalPedalRevs += wheelRevs / gearRatio; pedalingSeconds += dt; } } else { - // Coasting downhill: only basal metabolic rate + // Coasting / freewheeling: only basal metabolic rate. totalKcal += basalPerMin * (dt / 60); } } else { @@ -517,36 +549,188 @@ GPS._stopHrMonitor = function() { }; // Read points from native service file +// Parse raw JSONL (one native point per line) into sorted point objects. +GPS._parseNativePoints = function(raw) { + const points = []; + if (!raw) return points; + for (const line of raw.trim().split("\n")) { + if (!line) continue; + try { + const d = JSON.parse(line); + if (d.lat == null || d.lon == null) continue; // skip marker/invalid lines + 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, + estimated: d.estimated || false, + }); + } catch { /* skip partial/corrupt lines */ } + } + points.sort((a, b) => new Date(a.ts) - new Date(b.ts)); + return points; +}; + GPS._readNativePoints = async function() { try { const raw = await window.__TAURI__.core.invoke("gps_service_read_points"); - if (!raw) return []; - const points = []; - for (const line of raw.trim().split("\n")) { - if (!line) continue; - try { - const d = JSON.parse(line); - if (d.lat == null || d.lon == null) continue; // skip marker/invalid lines - 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, - estimated: d.estimated || false, - }); - } catch { /* skip partial/corrupt lines */ } - } - // Sort by timestamp to fix any ordering issues - points.sort((a, b) => new Date(a.ts) - new Date(b.ts)); - return points; + return GPS._parseNativePoints(raw); } catch { return []; } }; -// Core point processing — used by both WebView GPS and native service polling -GPS._processIncomingPoint = async function(p) { +// On-device track recovery. The foreground service writes every point to a +// native gps_points_.jsonl regardless of screen state, so these files are +// the source of truth for screen-off stretches the WebView missed. +// +// MATCHING — both a file and a session carry their own start timestamp, so we +// match them directly by id/date, never by "which session has points here": +// * The filename's is when the service started; a session's id is +// Date.now() at first movement. Empirically the file always starts a few +// seconds to ~25 min BEFORE its session (warmup before moving), same date. +// * A file belongs to session S iff S.date == file's date AND the file's start +// falls inside S's point window ±30 min. That rejects orphan recordings that +// left no session (e.g. the 07-05 files, hours from any session) and can +// never glue a file onto a foreign date. +// +// SAFETY (learned the hard way — a naive version corrupted real rides): +// * Only import file points whose own date == the session's date, so a stray +// glitched timestamp inside a correctly-matched file can't leak in. +// * recalculateSession is called with {noDelete:true}: recovery ADDS points, it +// must never trigger the destructive glitch/>24h filter that once wiped the +// real 07-22/07-24 rides. +GPS.recoverFromFiles = async function(onProgress) { + if (GPS.recording.active) return { error: "recording" }; + const invoke = window.__TAURI__.core.invoke; + const sel = (q, v) => invoke("plugin:sql|select", { db: "sqlite:baldav.db", query: q, values: v || [] }); + let files = []; + try { files = JSON.parse(await invoke("list_points_files")); } + catch (e) { console.error("[recover] list_points_files FAILED", e); } + console.log(`[recover] files=${files.length}`); + + // Load every session's date + actual point window once. CAST id to TEXT — the + // SQL plugin corrupts large i64 ids returned as numbers. + let sessRows = []; + try { + sessRows = await sel( + "SELECT CAST(s.id AS TEXT) AS sid, s.date AS d, MIN(p.ts) AS mn, MAX(p.ts) AS mx " + + "FROM gps_sessions s JOIN gps_points p ON p.session_id = s.id GROUP BY s.id" + ); + } catch (e) { console.error("[recover] session windows FAILED", e); } + const sessions = sessRows.map(r => ({ + sid: r.sid, date: r.d, + mn: new Date(r.mn).getTime(), mx: new Date(r.mx).getTime(), + })); + + const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10); + const NEAR_MS = 30 * 60 * 1000; // file start may lead the session by up to ~25 min + + // The file the service is (or was last) writing — never delete it, a resumed + // session may still append to it. + let activeFile = ""; + try { activeFile = (await invoke("get_points_filename")) || ""; } catch {} + + const results = []; + for (let fi = 0; fi < files.length; fi++) { + const name = files[fi]; + if (onProgress) { try { onProgress(fi + 1, files.length); } catch {} } + + // The file's start ms and (for new files) its owning session id live in the + // name: gps_points_.jsonl or gps_points__s.jsonl. + const m = /gps_points_(\d+)(?:_s(\d+))?\.jsonl/.exec(name); + if (!m) { console.log(`[recover] f=${name} no id in name, skip`); continue; } + const fileMs = Number(m[1]); + const boundSid = m[2] || null; // service stamped the session id into the name + const fDate = dayOf(fileMs); + + let match = null; + if (boundSid) { + // Unambiguous binding: the file already names its session. Bind directly; + // if that session was deleted, leave the file unmatched (never inject). + match = sessions.find(s => s.sid === boundSid) || null; + if (!match) { + console.log(`[recover] f=${name} bound sid=${boundSid} but session gone — UNMATCHED`); + results.push({ file: name, imported: 0, unmatched: true }); + continue; + } + } else { + // Legacy files (no session id in name): match by id/date — same date, file + // start inside the session window ±30 min, nearest such session wins. + let bestGap = Infinity; + for (const s of sessions) { + if (s.date !== fDate) continue; + const gap = fileMs < s.mn ? s.mn - fileMs : (fileMs > s.mx ? fileMs - s.mx : 0); + if (gap <= NEAR_MS && gap < bestGap) { bestGap = gap; match = s; } + } + if (!match) { + console.log(`[recover] f=${name} start=${new Date(fileMs).toISOString()} UNMATCHED`); + results.push({ file: name, imported: 0, unmatched: true }); + continue; + } + } + const sid = match.sid; + + let raw = ""; + try { raw = await invoke("read_points_file", { filename: name }); } + catch (e) { console.error(`[recover] read FAILED ${name}`, e); continue; } + const pts = GPS._parseNativePoints(raw); + if (pts.length < 2) { results.push({ file: name, sessionId: sid, imported: 0 }); continue; } + + // Only points on the matched session's own date may be imported (blocks a + // stray glitched timestamp inside an otherwise-correct file). + const sameDay = pts.filter(p => p.ts.slice(0, 10) === match.date); + if (!sameDay.length) { results.push({ file: name, sessionId: sid, imported: 0 }); continue; } + + const existing = await DB.getGpsPoints(sid); + // Dedup by TIME PROXIMITY, not exact ms. The native service and the WebView + // watchPosition record the SAME fixes with slightly different millisecond + // stamps, so exact-ms dedup keeps both → interleaved zig-zag inflates + // distance/kcal. Skip a native point if any existing point is within PROX ms; + // keep only points in genuine gaps (screen-off stretches the WebView missed). + const PROX = 2500; + const existMs = existing.map(p => new Date(p.ts).getTime()).sort((a, b) => a - b); + const nearExisting = (t) => { + let lo = 0, hi = existMs.length - 1; + while (lo <= hi) { let mid = (lo + hi) >> 1; if (existMs[mid] < t) lo = mid + 1; else hi = mid - 1; } + let best = Infinity; + if (lo < existMs.length) best = Math.min(best, existMs[lo] - t); + if (lo > 0) best = Math.min(best, t - existMs[lo - 1]); + return best <= PROX; + }; + const missing = sameDay.filter(p => !nearExisting(new Date(p.ts).getTime())); + let imported = 0; + if (missing.length) imported = await DB.addGpsPointsBulk(sid, missing, existing.length); + // Recalc so elevation/kcal fixes apply even when nothing new was imported. + // noDelete: recovery must NEVER run the destructive glitch/>24h filter. + try { await GPS.recalculateSession(sid, { noDelete: true }); } + catch (e) { console.error("[recover] recalc failed", e); } + // The file's points are now safely in the DB — delete it to clear the backlog + // clutter. Skip the active file. Orphan/unmatched files are kept (their points + // have no session, so deleting would lose data). + let removed = false; + if (name !== activeFile) { + try { await invoke("delete_points_file", { filename: name }); removed = true; } + catch (e) { console.warn(`[recover] delete failed ${name}`, e); } + } + console.log(`[recover] f=${name} pts=${pts.length} match=${sid} existing=${existing.length} imported=${imported} removed=${removed}`); + results.push({ file: name, sessionId: sid, imported, removed }); + } + const removedN = results.filter(r => r.removed).length; + console.log(`[recover] DONE files=${results.length} imported=${results.reduce((a,r)=>a+(r.imported||0),0)} removed=${removedN}`); + return { results, removed: removedN }; +}; + +// Core point processing — used by both WebView GPS (watchPosition) and native +// service polling. `fromNative` = the point came from the native file via the +// poll. On Android the native file is the complete, screen-independent source of +// truth, so only native points are stored; watchPosition then just drives the +// live marker — the same fix is never written twice with different timestamps. +GPS._processIncomingPoint = async function(p, fromNative) { if (!GPS.recording.active) return; + // DB writes come only from the source of truth: native points on Android, or + // watchPosition on desktop (no native service). + const writeDb = !!fromNative || !GPS.recording.nativeService; // State: waiting_gps → ready (got first accurate fix) if (GPS.recording.state === "waiting_gps") { @@ -589,6 +773,12 @@ GPS._processIncomingPoint = async function(p) { // Show visible error — this is data loss if (typeof showToast === "function") showToast("DB: " + (e.message || e)); } + // Stamp the session id into the native file's name so recovery binds it + // unambiguously (the service renames gps_points_.jsonl -> ..._s.jsonl). + if (GPS.recording.sessionId != null) { + try { await window.__TAURI__.core.invoke("gps_set_session_id", { sessionId: String(GPS.recording.sessionId) }); } + catch (e) { console.warn("[GPS] gps_set_session_id failed (non-critical):", e); } + } GPS._persistState(); if (GPS.recording.onStateChange) GPS.recording.onStateChange("recording", p); } @@ -605,7 +795,7 @@ GPS._processIncomingPoint = async function(p) { GPS.recording.pauseStart = new Date(p.ts); } // Save point with HR even during pause (no seq = excluded from distance/kcal) - if (GPS.recording.sessionId && GPS.recording.currentBleData) { + if (writeDb && GPS.recording.sessionId && GPS.recording.currentBleData) { DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, 0, null, GPS.recording.currentBleData, p); } if (GPS.recording.onPoint) GPS.recording.onPoint(p); @@ -620,12 +810,16 @@ GPS._processIncomingPoint = async function(p) { if (GPS.recording.onStateChange) GPS.recording.onStateChange("resumed", p); } - // Save moving point with seq + // Save moving point with seq — only from the source of truth. watchPosition on + // Android skips the write (the poll stores the native equivalent), so distance + // and kcal aren't inflated by the same fix landing twice. GPS.recording.lastMovingPoint = p; - GPS.recording.points.push(p); - GPS.recording._pointSeq = (GPS.recording._pointSeq || 0) + 1; - if (GPS.recording.sessionId) { - await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, GPS.recording._pointSeq, GPS.recording.currentBleData, p); + if (writeDb) { + GPS.recording.points.push(p); + GPS.recording._pointSeq = (GPS.recording._pointSeq || 0) + 1; + if (GPS.recording.sessionId) { + await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, GPS.recording._pointSeq, GPS.recording.currentBleData, p); + } } if (GPS.recording.onPoint) GPS.recording.onPoint(p); }; @@ -784,11 +978,13 @@ GPS.startRecording = async function(activityType, weightKg) { if (GPS.recording.nativeService) { let polling = false; GPS.recording._pollTimer = setInterval(async () => { + console.log(`[poll] tick active=${GPS.recording.active} polling=${polling} hidden=${document.hidden} lastTs=${GPS.recording._lastProcessedTs}`); if (!GPS.recording.active) { clearInterval(GPS.recording._pollTimer); return; } if (polling) return; // skip if previous poll still running polling = true; try { const nativePoints = await GPS._readNativePoints(); + console.log(`[poll] native=${nativePoints.length} firstTs=${nativePoints[0]?.ts} lastNativeTs=${nativePoints[nativePoints.length-1]?.ts}`); let newCount = 0; // Keep the heavy per-point path off (onPoint runs computeStats over all // points + DB writes → O(n²) on a big backlog). Draw each imported point @@ -800,16 +996,21 @@ GPS.startRecording = async function(activityType, weightKg) { for (const p of nativePoints) { const pTs = new Date(p.ts).getTime(); if (pTs <= GPS.recording._lastProcessedTs) continue; - await GPS._processIncomingPoint(p); + await GPS._processIncomingPoint(p, true); GPS.recording._lastProcessedTs = pTs; newCount++; if (GPS.recording.onImportPoint) GPS.recording.onImportPoint(p); // Yield a frame so the growing track repaints — but ONLY when visible. // requestAnimationFrame never fires while the screen is off (page hidden), // so awaiting it there would hang this loop forever and freeze the import. - if (newCount % 20 === 0 && !document.hidden) await new Promise(r => requestAnimationFrame(r)); + if (newCount % 20 === 0 && !document.hidden) { + console.log(`[poll] raf-wait at ${newCount}`); + await new Promise(r => requestAnimationFrame(r)); + console.log(`[poll] raf-done at ${newCount}`); + } } GPS.recording.onPoint = origOnPoint; + console.log(`[poll] imported=${newCount} newLastTs=${GPS.recording._lastProcessedTs}`); // Final sync — marker, pan, course and stats from the full point set. if (newCount > 0 && GPS.recording.onBatchUpdate) { GPS.recording.onBatchUpdate(); @@ -856,12 +1057,14 @@ GPS.stopRecording = async function() { // Stop native service — read remaining points but DON'T delete the file yet let nativePointsFile = null; + console.log(`[stop] enter nativeService=${GPS.recording.nativeService} sessionId=${GPS.recording.sessionId}`); if (GPS.recording.nativeService) { // Import EVERY native point not yet in the DB. The live poll is screen-gated // (WebView freezes timers while the screen is off), so a walk with the screen // off leaves most points only in the native file. Idempotent by timestamp — // same pattern as recoverSession. Then recompute stats over the COMPLETE set. const nativePoints = await GPS._readNativePoints(); + let stopImported = 0; if (GPS.recording.sessionId && nativePoints.length) { const existing = await DB.getGpsPoints(GPS.recording.sessionId); const dbTs = new Set(existing.map(p => new Date(p.ts).getTime())); @@ -870,8 +1073,12 @@ GPS.stopRecording = async function() { if (dbTs.has(new Date(p.ts).getTime())) continue; seq++; await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, seq, null, p); + stopImported++; } + console.log(`[stop] native=${nativePoints.length} existing=${existing.length} imported=${stopImported}`); GPS.recording.points = await DB.getGpsPoints(GPS.recording.sessionId); + } else { + console.log(`[stop] SKIP backfill: sessionId=${GPS.recording.sessionId} native=${nativePoints.length}`); } // Remember the file name for cleanup after DB save try { @@ -1221,7 +1428,8 @@ GPS.exportGPX = async function(sessionId) { // --------------------------------------------------------------------------- // Recalculate session stats using physics-based formula // --------------------------------------------------------------------------- -GPS.recalculateSession = async function(sessionId) { +GPS.recalculateSession = async function(sessionId, opts) { + opts = opts || {}; const session = await DB.getGpsSession(sessionId); if (!session) return null; const points = await DB.getGpsPoints(sessionId); @@ -1271,54 +1479,54 @@ GPS.recalculateSession = async function(sessionId) { const isWalk = session.activity_type === "walk" || session.activity_type === "run"; const maxMs = (isWalk ? GPS.config.walkMaxSpeedKmh : GPS.config.maxSpeedKmh) / 3.6; - // First pass: remove points with absurd timestamps (>1 day jump) + // Glitch filtering DELETES points. It is destructive and MUST be skipped for + // recovery (opts.noDelete): if a wrong-date file ever slips in, the ">24h" + // rule below would wipe the real track. Recovery only adds points, never deletes. let deleted = 0; - if (sorted.length >= 3) { - const sessionStart = new Date(sorted[0].ts).getTime(); - const sessionEnd = new Date(sorted[sorted.length - 1].ts).getTime(); - const maxDuration = 24 * 3600 * 1000; // 24 hours max session - for (let i = sorted.length - 1; i >= 0; i--) { - const t = new Date(sorted[i].ts).getTime(); - if (t - sessionStart > maxDuration || t < sessionStart - 60000) { + if (!opts.noDelete) { + // First pass: remove points with absurd timestamps (>1 day jump) + if (sorted.length >= 3) { + const sessionStart = new Date(sorted[0].ts).getTime(); + const maxDuration = 24 * 3600 * 1000; // 24 hours max session + for (let i = sorted.length - 1; i >= 0; i--) { + const t = new Date(sorted[i].ts).getTime(); + if (t - sessionStart > maxDuration || t < sessionStart - 60000) { + await invoke("DELETE FROM gps_points WHERE session_id = ?1 AND ts = ?2", [sessionId, sorted[i].ts]); + sorted.splice(i, 1); + deleted++; + } + } + } + + // Second pass: speed + accel + altitude filter + 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; + const speedMs = dt > 0 ? d / dt : 0; + const accel = sorted[i].accel_rms; + + // Altitude jump: >100m between consecutive points = impossible + const alt = sorted[i].altitude_gps_m; + const prevAlt = sorted[i-1].altitude_gps_m; + const altJump = (alt != null && prevAlt != null) ? Math.abs(alt - prevAlt) : 0; + + // Glitch: speed over max, accel=0 but moving, fast+walking-accel, or >100m alt jump. + const isGlitch = + (dt > 0 && speedMs > maxMs) || + (accel != null && accel === 0 && speedMs > 3) || + (isWalk && accel != null && accel < 15 && speedMs > maxMs * 0.8) || + (altJump > 100); + + if (isGlitch) { 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}`); } - // Second pass: speed + accel + altitude filter - 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; - const speedMs = dt > 0 ? d / dt : 0; - const accel = sorted[i].accel_rms; - - // Altitude jump: >100m between consecutive points = impossible - const alt = sorted[i].altitude_gps_m; - const prevAlt = sorted[i-1].altitude_gps_m; - const altJump = (alt != null && prevAlt != null) ? Math.abs(alt - prevAlt) : 0; - - // Glitch detection: - // 1. Speed exceeds max for activity type - // 2. Accelerometer = 0 but GPS shows movement (phone was still → GPS drift) - // 3. High speed + normal walking accel (can't walk at 40 km/h) - // 4. Altitude jumps >100m in one step (impossible on foot or bike) - const isGlitch = - (dt > 0 && speedMs > maxMs) || - (accel != null && accel === 0 && speedMs > 3) || - (isWalk && accel != null && accel < 15 && speedMs > maxMs * 0.8) || - (altJump > 100); - - if (isGlitch) { - 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}`); - // Interpolate gaps where accelerometer shows movement // If gap > 30s but accel before/after > 2 → person was walking/riding, GPS lost signal let interpolated = 0; @@ -1407,6 +1615,54 @@ GPS.recalculateSession = async function(sessionId) { return stats; }; +// Recompute ONLY the stored stats (distance / kcal / elevation / speeds) for +// every recorded session with the current computeStats. No point is added, +// deleted, or retimed — this touches just the 7 stat columns. Use it to refresh +// historical sessions after a formula change (the elevation-hysteresis and +// smoothed-gradient kcal fix left old sessions showing ~0 gain / inflated kcal). +GPS.recomputeAllStats = async function(onProgress) { + const invoke = window.__TAURI__.core.invoke; + const sel = (q, v) => invoke("plugin:sql|select", { db: "sqlite:baldav.db", query: q, values: v || [] }); + let ids = []; + try { ids = (await sel("SELECT CAST(id AS TEXT) AS sid FROM gps_sessions ORDER BY id")).map(r => r.sid); } + catch (e) { console.error("[recalc-all] list FAILED", e); return { error: String(e) }; } + + const latestW = await DB.getLatestWeight(); + const profile = await DB.getFullProfile(); + const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80; + const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 }; + + let updated = 0, skipped = 0; + for (let i = 0; i < ids.length; i++) { + const sid = ids[i]; + if (onProgress) { try { onProgress(i + 1, ids.length); } catch {} } + try { + const session = await DB.getGpsSession(sid); + if (!session) { skipped++; continue; } + // Manual routes are user-drawn estimates (no altitude, fake timestamps) — + // leave them untouched. + if (session.source === "manual") { skipped++; continue; } + const points = await DB.getGpsPoints(sid); + if (points.length < 2) { skipped++; continue; } + const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike(); + const stats = GPS.computeStats(points, weightKg, { + bikeWeightKg: bike?.weight_kg ?? 0, + activityType: session.activity_type ?? "bike", + surfaceCrr: CRR_MAP[bike?.surface_type] ?? 0.006, + tirePressureBar: bike?.tire_pressure_bar ?? 3.0, + chainringTeeth: bike?.chainring_teeth, + cogTeeth: bike?.cog_teeth, + wheelDiameterInch: bike?.wheel_diameter_inch, + }); + await DB.finishGpsSession(sid, session.end_time ?? session.start_time, + stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss); + updated++; + } catch (e) { console.error(`[recalc-all] ${sid} failed`, e); skipped++; } + } + console.log(`[recalc-all] DONE updated=${updated} skipped=${skipped} total=${ids.length}`); + return { updated, skipped, total: ids.length }; +}; + // Build opts from current recording state (for live stats during recording) GPS.getRecordingOpts = function() { return { diff --git a/frontend/js/main.js b/frontend/js/main.js index 61974c9..77d2fd8 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -2337,7 +2337,21 @@ async function renderGpsScreen(container) { } catch {} } - let sessions = await DB.getGpsSessions(20); + let PAGE_SIZE = 20; + let sessionCard = (s) => ` +
+
+ ${s.date} ${s.start_time ?? ""} + ${s.activity_type ?? ""} +
+
+ ${s.total_distance_m != null ? GPS.formatDistance(s.total_distance_m) : "—"} + · ${s.total_kcal != null ? Math.round(s.total_kcal) + " кк" : ""} + · ${s.avg_speed_kmh != null ? s.avg_speed_kmh + " км/ч" : ""} + ${s.elevation_gain_m ? " · +" + Math.round(s.elevation_gain_m) + "м" : ""} +
+
`; + let sessions = await DB.getGpsSessions(PAGE_SIZE, 0); let isRec = GPS.recording.active; container.innerHTML = ` @@ -2359,6 +2373,8 @@ async function renderGpsScreen(container) {
Нарисовать маршрут
Скачать карту
Импорт тренировки
+
Восстановить точки из файлов
+
Пересчитать высоту/калории
Очистить кэш карт
@@ -2366,20 +2382,8 @@ async function renderGpsScreen(container) {

История

- ${sessions.length ? sessions.map(s => ` -
-
- ${s.date} ${s.start_time ?? ""} - ${s.activity_type ?? ""} -
-
- ${s.total_distance_m != null ? GPS.formatDistance(s.total_distance_m) : "—"} - · ${s.total_kcal != null ? Math.round(s.total_kcal) + " кк" : ""} - · ${s.avg_speed_kmh != null ? s.avg_speed_kmh + " км/ч" : ""} - ${s.elevation_gain_m ? " · +" + Math.round(s.elevation_gain_m) + "м" : ""} -
-
- `).join("") : `
Нет записей
`} +
${sessions.length ? sessions.map(sessionCard).join("") : `
Нет записей
`}
+
`; @@ -2417,9 +2421,30 @@ async function renderGpsScreen(container) { // Session click → detail view. Keep sid as a STRING (numeric id from the // SQL plugin is corrupted off-by-1 for large i64 values). - container.querySelectorAll(".gps-session-item").forEach(item => { - item.onclick = () => showSessionDetail(item.dataset.sid); - }); + let bindSessionClick = (item) => { item.onclick = () => showSessionDetail(item.dataset.sid); }; + container.querySelectorAll(".gps-session-item").forEach(bindSessionClick); + + // Pagination: "Показать ещё" loads the next page from the DB and appends it. + let sessOffset = sessions.length; + let moreBtn = container.querySelector("#gps-more"); + if (moreBtn) moreBtn.onclick = async () => { + moreBtn.disabled = true; + let label = moreBtn.textContent; + moreBtn.textContent = "Загрузка..."; + let next = await DB.getGpsSessions(PAGE_SIZE, sessOffset); + let listEl = container.querySelector("#gps-session-list"); + next.forEach(s => { + let tmp = document.createElement("div"); + tmp.innerHTML = sessionCard(s); + let item = tmp.firstElementChild; + bindSessionClick(item); + listEl.appendChild(item); + }); + sessOffset += next.length; + moreBtn.disabled = false; + moreBtn.textContent = label; + if (next.length < PAGE_SIZE) moreBtn.style.display = "none"; + }; // Three-dot menu let menuBtn = container.querySelector("#gps-menu-btn"); @@ -2445,6 +2470,67 @@ async function renderGpsScreen(container) { console.log("Cache cleared"); }; + // Recover points from native files — rebuilds sessions whose DB rows fell behind + // the service's JSONL (e.g. screen-off import glitch). Works fully on-device. + container.querySelector("#gps-recover").onclick = async () => { + menu.classList.add("hidden"); + if (GPS.recording.active) { showToast("Нельзя во время записи"); return; } + // Keep the screen awake — recovery runs in the WebView and stalls if it sleeps. + let wl = null; + try { if (navigator.wakeLock) wl = await navigator.wakeLock.request("screen"); } catch {} + // Visible progress bar (fixed at the bottom) so it's clear it's working. + let prog = document.createElement("div"); + prog.style.cssText = "position:fixed;left:0;right:0;bottom:0;z-index:99999;background:var(--primary);color:#000;text-align:center;padding:14px;font-size:15px;font-weight:600"; + prog.textContent = "Восстановление: подготовка..."; + document.body.appendChild(prog); + try { + let res = await GPS.recoverFromFiles((i, n) => { prog.textContent = `Восстановление: файл ${i}/${n}`; }); + if (res.error === "recording") { prog.remove(); showToast("Нельзя во время записи"); return; } + let list = res.results || []; + let totalPts = list.reduce((a, r) => a + (r.imported || 0), 0); + let sessions = list.filter(r => r.imported > 0).length; + let unmatched = list.filter(r => r.unmatched).length; + let removed = res.removed || 0; + // Show the result IN the bar — a toast gets overwritten by the Bluetooth + // check when the screen re-renders. Tap the bar to close and refresh. + prog.textContent = `Готово: файлов ${list.length}, вставлено ${totalPts} в ${sessions} сессий, удалено ${removed}, сирот ${unmatched}. Нажмите, чтобы закрыть`; + prog.style.cursor = "pointer"; + prog.onclick = () => { prog.remove(); renderScreen("gps"); }; + } catch (e) { + console.error("[recover] failed", e); + prog.textContent = "Ошибка восстановления: " + (e.message || e) + " — нажмите, чтобы закрыть"; + prog.onclick = () => prog.remove(); + } finally { + if (wl) { try { await wl.release(); } catch {} } + } + }; + + // Recompute stored elevation/kcal for every recorded session (formula fix). + // Stats-only: never touches points. Same progress-bar UX as recovery. + container.querySelector("#gps-recalc-stats").onclick = async () => { + menu.classList.add("hidden"); + if (GPS.recording.active) { showToast("Нельзя во время записи"); return; } + let wl = null; + try { if (navigator.wakeLock) wl = await navigator.wakeLock.request("screen"); } catch {} + let prog = document.createElement("div"); + prog.style.cssText = "position:fixed;left:0;right:0;bottom:0;z-index:99999;background:var(--primary);color:#000;text-align:center;padding:14px;font-size:15px;font-weight:600"; + prog.textContent = "Пересчёт: подготовка..."; + document.body.appendChild(prog); + try { + let res = await GPS.recomputeAllStats((i, n) => { prog.textContent = `Пересчёт: ${i}/${n}`; }); + if (res.error) { prog.textContent = "Ошибка: " + res.error + " — нажмите, чтобы закрыть"; prog.onclick = () => prog.remove(); return; } + prog.textContent = `Готово: пересчитано ${res.updated}, пропущено ${res.skipped}. Нажмите, чтобы закрыть`; + prog.style.cursor = "pointer"; + prog.onclick = () => { prog.remove(); renderScreen("gps"); }; + } catch (e) { + console.error("[recalc-all] failed", e); + prog.textContent = "Ошибка пересчёта: " + (e.message || e) + " — нажмите, чтобы закрыть"; + prog.onclick = () => prog.remove(); + } finally { + if (wl) { try { await wl.release(); } catch {} } + } + }; + // Unified import — detect by extension container.querySelector("#gps-import").onclick = () => { menu.classList.add("hidden"); @@ -4634,6 +4720,90 @@ function showEditSupplementModal(existing) { // SETTINGS screen // --------------------------------------------------------------------------- +// Offline maps live on the router under /baldav/tiles/. The list is read from the +// directory listing, so a new .db uploaded there appears without an app rebuild. +let TILES_BASE_URL = "http://192.168.1.1:88/baldav/tiles/"; + +// Regions the packing tools know about. Shown even when the router has no .db for +// them yet (greyed out) so nothing silently disappears from the list. +let KNOWN_REGIONS = { + novocherkassk: "город", + rostov_city: "Ростов-на-Дону", + rostov_oblast: "Ростовская обл.", + moscow_city: "Москва", + moscow_oblast: "Московская обл.", + spb: "Санкт-Петербург", + krasnodar_city: "Краснодар", + krasnodar_krai: "Краснодарский край", + sochi: "Сочи (+Адлер, Кр. Поляна)", + kazan: "Казань", + novosibirsk: "Новосибирск", + ekaterinburg: "Екатеринбург", +}; + +// Fetch [{ file, name, sizeMB }] from the router's tiles directory listing. +async function fetchRouterTiles() { + let html = await window.__TAURI__.core.invoke("http_request", { + url: TILES_BASE_URL, method: "GET", body: null, + }); + let out = []; + let re = /(tiles_[A-Za-z0-9_-]+\.db)<\/a>[\s\S]*?-\s*([\d.]+)\s*kbyte/g; + let m; + while ((m = re.exec(html)) !== null) { + out.push({ file: m[1], name: m[1].replace(/^tiles_|\.db$/g, ""), sizeMB: (parseFloat(m[2]) / 1024).toFixed(1) }); + } + if (!out.length) { + for (let f of [...new Set(html.match(/tiles_[A-Za-z0-9_-]+\.db/g) || [])]) { + out.push({ file: f, name: f.replace(/^tiles_|\.db$/g, ""), sizeMB: null }); + } + } + return out; +} + +// Open the Android SAF file picker and return the path of the picked file. +// A content:// URI has no path, so Kotlin copies the file into the app dir and +// writes the path to open_file_result.txt, which we poll here. Returns null if +// the user cancelled. Desktop has no picker — callers fall back to a path prompt. +async function pickLocalFile(mime) { + let invoke = window.__TAURI__.core.invoke; + await invoke("pick_file_open", { mime: mime || "*/*" }); + for (let i = 0; i < 600; i++) { // poll up to ~3 min while the picker is open + await new Promise(r => setTimeout(r, 300)); + let res = await invoke("get_picked_file"); + if (!res) continue; + if (res === "CANCELLED") return null; + if (res.startsWith("ERROR:")) throw new Error(res.slice(6)); + return res; + } + throw new Error("Таймаут выбора файла"); +} + +// Download a tile DB by URL into the app dir, then import it into the tile cache. +async function importTilesFromUrl(url, label, btn, container) { + let invoke = window.__TAURI__.core.invoke; + let origText = btn.textContent; + btn.disabled = true; + btn.textContent = "Скачивание..."; + try { + let appDir = await window.__TAURI__.path.appDataDir(); + let dest = appDir + "/" + (label || "tiles_download.db"); + let bytes = await invoke("download_file", { url, dest }); + btn.textContent = `Импорт (${(parseInt(bytes) / 1048576).toFixed(1)} MB)...`; + let count = await TileCache.importFromSqlite(dest, (done, total) => { + btn.textContent = `${Math.round(done / total * 100)}% (${done}/${total})`; + }); + showToast(`${label}: ${count} тайлов импортировано`); + TileCache.count().then(n => { + let el = container.querySelector("#s-tile-count"); + if (el) el.textContent = `${n} тайлов в кэше`; + }); + } catch (e) { + showToast("Ошибка: " + String(e)); + } + btn.disabled = false; + btn.textContent = origText; +} + async function renderSettings(container) { let p = state.profile; let profileSummary = [ @@ -4938,29 +5108,25 @@ async function renderSettings(container) { if (el) el.textContent = `${n} тайлов в кэше`; }); - // Import tiles from SQLite file - // User pushes .db to app dir: adb push tiles_region.db /data/user/0/dev.baldav.app/files/ + // Import tiles from a file the user picks on the device (SAF picker on Android). container.querySelector("#s-import-tiles").onclick = async () => { let btn = container.querySelector("#s-import-tiles"); - // Prompt for path — user can type full path or just filename in app files dir - let filePath = prompt("Путь к файлу тайлов (.db).\nНа Android: скопируйте файл через\nadb push file.db /data/user/0/dev.baldav.app/files/\nи введите имя файла:"); - if (!filePath) return; - // If just a filename, resolve relative to app files dir - if (!filePath.includes("/")) { + let filePath = null; + if (/Android/i.test(navigator.userAgent)) { + btn.disabled = true; + let prev = btn.textContent; + btn.textContent = "Выбор файла..."; try { - let appDir = await window.__TAURI__.path.appDataDir(); - // appDataDir on Android = /data/user/0/pkg/files/dev.baldav.app/ - // files dir = /data/user/0/pkg/files/ - // Try both locations - let candidates = [ - appDir + "/" + filePath, - appDir + "/../../files/" + filePath, - appDir + "/../" + filePath, - ]; - filePath = candidates[0]; // try appDataDir first - } catch { - // desktop: use as-is + filePath = await pickLocalFile("*/*"); + } catch (e) { + showToast("Ошибка выбора: " + (e.message || e)); } + btn.disabled = false; + btn.textContent = prev; + if (!filePath) return; // cancelled + } else { + filePath = prompt("Путь к файлу тайлов (.db):"); + if (!filePath) return; } let origText = btn.textContent; btn.disabled = true; @@ -4982,55 +5148,51 @@ async function renderSettings(container) { } }; - // Download tiles from router + // Download tiles from router — the list comes from the router's directory + // listing, so a newly uploaded .db shows up without rebuilding the app. container.querySelector("#s-download-tiles").onclick = async () => { - let regions = [ - ["novocherkassk", "region"], - ["rostov_city", "Rostov-on-Don"], - ["rostov_oblast", "Rostov Oblast"], - ["moscow_city", "Moscow"], - ["moscow_oblast", "Moscow Oblast"], - ["spb", "Saint Petersburg"], - ["krasnodar_city", "Krasnodar"], - ["krasnodar_krai", "Krasnodar Krai"], - ["kazan", "Kazan"], - ["novosibirsk", "Novosibirsk"], - ["ekaterinburg", "Ekaterinburg"], - ]; - let list = regions.map((r, i) => `${i + 1}. ${r[1]}`).join("\n"); - let choice = prompt("Выберите регион (номер):\n" + list); - if (!choice) return; - let idx = parseInt(choice) - 1; - if (idx < 0 || idx >= regions.length) { showToast("Неверный номер"); return; } - let regionKey = regions[idx][0]; - let regionName = regions[idx][1]; - let url = `http://192.168.1.1:88/baldav/tiles/tiles_${regionKey}.db`; - let invoke = window.__TAURI__.core.invoke; let btn = container.querySelector("#s-download-tiles"); + let origText = btn.textContent; btn.disabled = true; - btn.textContent = "Скачивание..."; + btn.textContent = "Список..."; + let files = []; try { - let appDir = await window.__TAURI__.path.appDataDir(); - let dest = appDir + `/tiles_${regionKey}.db`; - let bytes = await invoke("download_file", { url, dest }); - let sizeMB = (parseInt(bytes) / 1024 / 1024).toFixed(1); - btn.textContent = `Импорт (${sizeMB} MB)...`; - let count = await TileCache.importFromSqlite(dest, (done, total) => { - let pct = Math.round(done / total * 100); - btn.textContent = `${pct}% (${done}/${total})`; - }); - btn.textContent = "Регионы"; - btn.disabled = false; - showToast(`${regionName}: ${count} тайлов импортировано`); - TileCache.count().then(n => { - let el = container.querySelector("#s-tile-count"); - if (el) el.textContent = `${n} тайлов в кэше`; - }); + files = await fetchRouterTiles(); } catch (e) { - btn.textContent = "Регионы"; - btn.disabled = false; - showToast("Ошибка: " + String(e)); + btn.disabled = false; btn.textContent = origText; + showToast("Роутер недоступен: " + (e.message || e)); + return; } + btn.disabled = false; btn.textContent = origText; + + // Show every known region. Ones present on the router are tappable; the rest + // are greyed out so the list never silently loses entries. Any extra .db on + // the router that isn't a known region is listed too. + let byKey = {}; + for (let f of files) byKey[f.name] = f; + let rows = Object.keys(KNOWN_REGIONS).map(k => ({ key: k, label: KNOWN_REGIONS[k], f: byKey[k] })); + for (let f of files) if (!KNOWN_REGIONS[f.name]) rows.push({ key: f.name, label: f.name, f }); + + let items = rows.map((r, i) => r.f + ? `
${esc(r.label)} ${r.f.sizeMB ? r.f.sizeMB + " MB" : ""}
` + : `
${esc(r.label)} нет на роутере
` + ).join(""); + + // Tap a map to start downloading it; the OK button is unused here. + showModal(` +

Карты на роутере

+
Нажмите на карту, чтобы скачать и импортировать
+
${items}
`, + async () => "norender", + overlay => { + overlay.querySelectorAll("#t-file-list .supp-pick-item[data-idx]").forEach(el => { + el.onclick = () => { + let r = rows[parseInt(el.dataset.idx)]; + overlay.querySelector("#modal-cancel").click(); // closes the modal + importTilesFromUrl(TILES_BASE_URL + r.f.file, r.f.file, btn, container); + }; + }); + }); }; // Roads map diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ddfff3a..6396bbf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.366" +version = "0.1.382" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e311f81..c70d079 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.366" +version = "0.1.382" edition = "2021" [lib] 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 ad99823..987b07e 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 @@ -126,6 +126,9 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener { // Genuine start from JS — new session file pointsFile = File(appDataDir, "gps_points_${System.currentTimeMillis()}.jsonl") ptrFile.writeText(pointsFile!!.name) + // Drop any stale session id left from a previous recording so the new + // warmup file can't be bound to the wrong session before movement. + try { File(appDataDir, "gps_session.txt").writeText("") } catch (_: Exception) {} } // Raw sensor file (50Hz, ~5MB/hour) with rotation (keep last 15) @@ -207,6 +210,7 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener { // Flush buffers every 10 seconds (GPS) and 5 seconds (raw sensors) fun scheduleFlush() { flushHandler.postDelayed({ + bindSessionIfPending() flushBuffer() flushRawSensors() // Heartbeat: proves the loop is alive and shows GPS freshness. @@ -463,6 +467,33 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener { Log.d(TAG, "Buffered point #$pointCount: ${location.latitude},${location.longitude} spd=${location.speed} acc=${location.accuracy} (buf=${pointBuffer.size})") } + // Rename the active points file to carry its session id once JS reports it: + // gps_points_.jsonl -> gps_points__s.jsonl. Runs on the flush + // thread under bufferLock, so it can't race a concurrent write. flushBuffer + // reopens the file by path each time, so updating pointsFile is enough. + private fun bindSessionIfPending() { + val appDataDir = filesDir.parentFile ?: filesDir + val sessFile = File(appDataDir, "gps_session.txt") + val sid = try { if (sessFile.exists()) sessFile.readText().trim() else "" } catch (e: Exception) { "" } + if (sid.isEmpty() || !sid.all { it.isDigit() }) return + synchronized(bufferLock) { + val cur = pointsFile ?: return@synchronized + if (!cur.name.contains("_s")) { + val newName = cur.name.removeSuffix(".jsonl") + "_s" + sid + ".jsonl" + val dest = File(appDataDir, newName) + if (cur.renameTo(dest)) { + pointsFile = dest + File(appDataDir, "gps_live_points.jsonl").writeText(newName) + Log.d(TAG, "Bound session $sid -> $newName") + } else { + Log.w(TAG, "Bind rename FAILED for $newName") + return@synchronized // keep sess file for a retry next tick + } + } + } + try { sessFile.writeText("") } catch (_: Exception) {} + } + private fun flushBuffer() { synchronized(bufferLock) { if (pointBuffer.isEmpty()) return 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 ae77521..dd117dd 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 @@ -37,6 +37,31 @@ class MainActivity : TauriActivity() { pendingSaveContent = null } + // SAF "open document" picker. A content:// URI has no filesystem path, so the + // picked file is copied into the app dir and its path is written to + // open_file_result.txt, which the JS side polls via the Rust get_picked_file. + private val openFileLauncher = registerForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + val appDataDir = filesDir.parentFile ?: filesDir + val resultFile = File(appDataDir, "open_file_result.txt") + if (uri == null) { + resultFile.writeText("CANCELLED") + return@registerForActivityResult + } + try { + val dest = File(appDataDir, "picked_import.db") + contentResolver.openInputStream(uri)?.use { input -> + dest.outputStream().use { out -> input.copyTo(out, 1 shl 20) } + } + Log.d("BalDav", "Picked file copied to ${dest.absolutePath} (${dest.length()} bytes)") + resultFile.writeText(dest.absolutePath) + } catch (e: Exception) { + Log.e("BalDav", "Failed to copy picked file", e) + resultFile.writeText("ERROR:" + (e.message ?: "copy failed")) + } + } + override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -214,6 +239,13 @@ class MainActivity : TauriActivity() { saveFileLauncher.launch(filename) } } + cmd.startsWith("open:") -> { + // open: — show SAF open picker; result path lands in open_file_result.txt + val mime = cmd.substringAfter("open:").ifBlank { "*/*" } + File(appDataDir, "open_file_result.txt").writeText("") + Log.d("BalDav", "Opening file picker for mime=$mime") + openFileLauncher.launch(arrayOf(mime)) + } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 435165c..149b72d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,55 @@ use serde::Serialize; #[allow(unused, dead_code)] mod ble; +// Directory where the Kotlin GpsRecorderService writes native point files. +// Kotlin uses `filesDir.parentFile` (the app root, e.g. /data/user/0//). +// Rust's app_data_dir() nesting on Android is unreliable, so instead of a fixed +// `parent().parent()`, scan candidate ancestors and pick the one that actually +// holds the pointer / point files. Falls back to the old app_data_dir()/../.. . +fn native_root(app: &tauri::AppHandle) -> std::path::PathBuf { + let data_dir = app.path().app_data_dir().unwrap_or_default(); + let candidates: Vec = [ + data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()), + data_dir.parent().map(|p| p.to_path_buf()), + Some(data_dir.clone()), + ].into_iter().flatten().collect(); + // Prefer the dir that has the live pointer file. + for c in &candidates { + if c.join("gps_live_points.jsonl").exists() { return c.clone(); } + } + // Otherwise the dir that has any gps_points_*.jsonl. + for c in &candidates { + if let Ok(rd) = std::fs::read_dir(c) { + if rd.flatten().any(|e| e.file_name().to_str() + .map_or(false, |n| n.starts_with("gps_points_") && n.ends_with(".jsonl"))) { + return c.clone(); + } + } + } + data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()).unwrap_or(data_dir) +} + +// Diagnostic: report app_data_dir, each candidate root, whether the pointer +// exists there and how many gps_points_*.jsonl it holds. Surfaced to JS/logcat. +#[tauri::command] +fn debug_native_paths(app: tauri::AppHandle) -> Result { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let candidates: Vec<(String, std::path::PathBuf)> = vec![ + ("grandparent(../..)".into(), data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()).unwrap_or_default()), + ("parent(..)".into(), data_dir.parent().map(|p| p.to_path_buf()).unwrap_or_default()), + ("app_data_dir".into(), data_dir.clone()), + ]; + let mut out = format!("app_data_dir={} chosen={}", data_dir.display(), native_root(&app).display()); + for (label, dir) in &candidates { + let ptr = dir.join("gps_live_points.jsonl").exists(); + let cnt = std::fs::read_dir(dir).map(|rd| rd.flatten() + .filter(|e| e.file_name().to_str().map_or(false, |n| n.starts_with("gps_points_") && n.ends_with(".jsonl"))) + .count()).unwrap_or(0); + out.push_str(&format!(" | {}={} ptr={} gps_points={}", label, dir.display(), ptr, cnt)); + } + Ok(out) +} + // 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) @@ -38,8 +87,7 @@ async fn http_request(url: String, method: String, body: Option) -> Resu // Get the active points file name from gps_live_points.jsonl pointer #[tauri::command] fn get_points_filename(app: tauri::AppHandle) -> Result { - let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - let app_root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()).unwrap_or(data_dir); + let app_root = native_root(&app); let ptr_path = app_root.join("gps_live_points.jsonl"); if ptr_path.exists() { let content = std::fs::read_to_string(&ptr_path).unwrap_or_default().trim().to_string(); @@ -49,11 +97,26 @@ fn get_points_filename(app: tauri::AppHandle) -> Result { } } +// Hand the just-created session id to the running GPS service. The service, on +// its next flush tick, renames the active gps_points_.jsonl to +// gps_points__s.jsonl so recovery can bind the file to its session with +// no guessing. Written to gps_session.txt in the same dir the service watches. +#[tauri::command] +fn gps_set_session_id(app: tauri::AppHandle, session_id: String) -> Result { + // Guard: digits only (it becomes part of a filename). + if session_id.is_empty() || !session_id.chars().all(|c| c.is_ascii_digit()) { + return Err("invalid session id".to_string()); + } + let app_root = native_root(&app); + let path = app_root.join("gps_session.txt"); + std::fs::write(&path, session_id).map_err(|e| e.to_string())?; + Ok("ok".to_string()) +} + // Delete a GPS points file after confirmed DB insert #[tauri::command] fn delete_points_file(app: tauri::AppHandle, filename: String) -> Result { - let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - let app_root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()).unwrap_or(data_dir); + let app_root = native_root(&app); let path = app_root.join(&filename); if path.exists() && filename.starts_with("gps_points_") && filename.ends_with(".jsonl") { std::fs::remove_file(&path).map_err(|e| e.to_string())?; @@ -63,6 +126,71 @@ fn delete_points_file(app: tauri::AppHandle, filename: String) -> Result Result { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + std::fs::create_dir_all(&data_dir).ok(); + let m = if mime.is_empty() { "*/*".to_string() } else { mime }; + for dir in [Some(data_dir.clone()), data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf())] { + if let Some(d) = dir { + let _ = std::fs::write(d.join("open_file_result.txt"), ""); + let _ = std::fs::write(d.join("gps_command.txt"), format!("open:{}", m)); + } + } + Ok("ok".to_string()) +} + +// Poll the result of pick_file_open: "" while the picker is open, otherwise the +// copied file's path, or "CANCELLED" / "ERROR:...". +#[tauri::command] +fn get_picked_file(app: tauri::AppHandle) -> Result { + let app_root = native_root(&app); + let p = app_root.join("open_file_result.txt"); + if p.exists() { + Ok(std::fs::read_to_string(&p).unwrap_or_default().trim().to_string()) + } else { + Ok(String::new()) + } +} + +// List all native GPS point files (gps_points_*.jsonl) in the app root. +// Used by on-device track recovery: the foreground service writes every point +// here regardless of screen state, so these files can rebuild a session's DB +// rows if the JS import ever falls behind. +#[tauri::command] +fn list_points_files(app: tauri::AppHandle) -> Result { + let app_root = native_root(&app); + let mut names: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&app_root) { + for e in entries.flatten() { + if let Some(n) = e.file_name().to_str() { + if n.starts_with("gps_points_") && n.ends_with(".jsonl") { + names.push(n.to_string()); + } + } + } + } + names.sort(); + Ok(serde_json::to_string(&names).unwrap_or_else(|_| "[]".to_string())) +} + +// Read a specific native points file by name (validated, no path traversal). +#[tauri::command] +fn read_points_file(app: tauri::AppHandle, filename: String) -> Result { + if !(filename.starts_with("gps_points_") && filename.ends_with(".jsonl")) || filename.contains('/') { + return Err("bad filename".to_string()); + } + let app_root = native_root(&app); + let path = app_root.join(&filename); + if path.exists() { + std::fs::read_to_string(&path).map_err(|e| e.to_string()) + } else { + Ok(String::new()) + } +} + // Download binary file from URL to local path (for tile DB import from router) // read_to_vec() has a 10MB limit, so use as_reader() for unlimited binary reads #[tauri::command] @@ -229,8 +357,7 @@ fn gps_service_command(app: tauri::AppHandle, cmd: String) -> Result Result { - let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - let app_root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()).unwrap_or(data_dir.clone()); + let app_root = native_root(&app); // gps_live_points.jsonl now contains the FILENAME of the active points file let ptr_path = app_root.join("gps_live_points.jsonl"); let pts_file = if ptr_path.exists() { @@ -738,7 +865,7 @@ pub fn run() { .add_migrations("sqlite:baldav.db", migrations) .build(), ) - .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, rebuild_roads, get_road_points, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, http_request, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr]) + .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, http_request, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f8fc13e..712621e 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.366", + "version": "0.1.382", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend"