GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup

- native_root(): pick app root by presence of gps_live/gps_points files.
  app_data_dir() is the root directly on this Tauri; the old .parent().parent()
  read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
  into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
  (gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
  recalc with noDelete (never runs the destructive glitch/>24h filter that
  once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
  max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
  would roll unpowered) = basal only. Elevation via cumulative hysteresis.
  recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
  without touching points.
- live recording: the native file is the DB source of truth; watchPosition
  only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
This commit is contained in:
4aiman 2026-07-29 15:35:18 +03:00
commit 56c2ff4ec7
12 changed files with 854 additions and 190 deletions

10
.gitignore vendored
View file

@ -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/

View file

@ -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<id>` в имени, дедуп ±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<id>` по `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<id>` в имя файла) |
| `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-таблица

View file

@ -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_<ms>_s<id>.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 в продуктах, прогресс-бар, настраиваемая норма.

View file

@ -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;

View file

@ -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_<ms>.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 <ms> 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_<ms>.jsonl or gps_points_<ms>_s<sessionId>.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_<ms>.jsonl -> ..._s<id>.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 {

View file

@ -2337,7 +2337,21 @@ async function renderGpsScreen(container) {
} catch {}
}
let sessions = await DB.getGpsSessions(20);
let PAGE_SIZE = 20;
let sessionCard = (s) => `
<div class="gps-session-item" data-sid="${s.id_str ?? s.id}">
<div class="gps-session-head">
<span>${s.date} ${s.start_time ?? ""}</span>
<span class="gps-session-type">${s.activity_type ?? ""}</span>
</div>
<div class="gps-session-stats">
${s.total_distance_m != null ? GPS.formatDistance(s.total_distance_m) : "—"}
&middot; ${s.total_kcal != null ? Math.round(s.total_kcal) + " кк" : ""}
&middot; ${s.avg_speed_kmh != null ? s.avg_speed_kmh + " км/ч" : ""}
${s.elevation_gain_m ? " &middot; +" + Math.round(s.elevation_gain_m) + "м" : ""}
</div>
</div>`;
let sessions = await DB.getGpsSessions(PAGE_SIZE, 0);
let isRec = GPS.recording.active;
container.innerHTML = `
@ -2359,6 +2373,8 @@ async function renderGpsScreen(container) {
<div class="gps-menu-item" id="gps-draw-route">Нарисовать маршрут</div>
<div class="gps-menu-item" id="gps-download-map">Скачать карту</div>
<div class="gps-menu-item" id="gps-import">Импорт тренировки</div>
<div class="gps-menu-item" id="gps-recover">Восстановить точки из файлов</div>
<div class="gps-menu-item" id="gps-recalc-stats">Пересчитать высоту/калории</div>
<div class="gps-menu-item" id="gps-clear-cache">Очистить кэш карт</div>
</div>
</div>
@ -2366,20 +2382,8 @@ async function renderGpsScreen(container) {
</div>
<div class="gps-sessions">
<h3>История</h3>
${sessions.length ? sessions.map(s => `
<div class="gps-session-item" data-sid="${s.id_str ?? s.id}">
<div class="gps-session-head">
<span>${s.date} ${s.start_time ?? ""}</span>
<span class="gps-session-type">${s.activity_type ?? ""}</span>
</div>
<div class="gps-session-stats">
${s.total_distance_m != null ? GPS.formatDistance(s.total_distance_m) : "—"}
&middot; ${s.total_kcal != null ? Math.round(s.total_kcal) + " кк" : ""}
&middot; ${s.avg_speed_kmh != null ? s.avg_speed_kmh + " км/ч" : ""}
${s.elevation_gain_m ? " &middot; +" + Math.round(s.elevation_gain_m) + "м" : ""}
</div>
</div>
`).join("") : `<div class="prod-hint">Нет записей</div>`}
<div id="gps-session-list">${sessions.length ? sessions.map(sessionCard).join("") : `<div class="prod-hint">Нет записей</div>`}</div>
<button id="gps-more" class="btn-secondary" style="width:100%;margin-top:10px;${sessions.length < PAGE_SIZE ? "display:none" : ""}">Показать ещё</button>
</div>
</div>`;
@ -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
? `<div class="supp-pick-item" data-idx="${i}">${esc(r.label)} <span style="color:var(--muted);font-size:12px">${r.f.sizeMB ? r.f.sizeMB + " MB" : ""}</span></div>`
: `<div class="supp-pick-item" style="opacity:.4;pointer-events:none">${esc(r.label)} <span style="font-size:12px">нет на роутере</span></div>`
).join("");
// Tap a map to start downloading it; the OK button is unused here.
showModal(`
<h3>Карты на роутере</h3>
<div class="prod-hint">Нажмите на карту, чтобы скачать и импортировать</div>
<div id="t-file-list" class="supp-pick-list">${items}</div>`,
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

2
src-tauri/Cargo.lock generated
View file

@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "baldav"
version = "0.1.366"
version = "0.1.382"
dependencies = [
"base64 0.22.1",
"btleplug",

View file

@ -1,6 +1,6 @@
[package]
name = "baldav"
version = "0.1.366"
version = "0.1.382"
edition = "2021"
[lib]

View file

@ -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_<ms>.jsonl -> gps_points_<ms>_s<id>.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

View file

@ -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:<mime> — 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))
}
}
}

View file

@ -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/<pkg>/).
// 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<std::path::PathBuf> = [
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<String, String> {
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<String>) -> Resu
// Get the active points file name from gps_live_points.jsonl pointer
#[tauri::command]
fn get_points_filename(app: tauri::AppHandle) -> Result<String, String> {
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<String, String> {
}
}
// Hand the just-created session id to the running GPS service. The service, on
// its next flush tick, renames the active gps_points_<ms>.jsonl to
// gps_points_<ms>_s<id>.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<String, String> {
// 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<String, String> {
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<String,
}
}
// Ask Kotlin to show the SAF "open document" picker. Kotlin copies the chosen
// file into the app dir and writes its path to open_file_result.txt.
#[tauri::command]
fn pick_file_open(app: tauri::AppHandle, mime: String) -> Result<String, String> {
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<String, String> {
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<String, String> {
let app_root = native_root(&app);
let mut names: Vec<String> = 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<String, String> {
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<String, Str
// Read GPS points from service's JSONL file
#[tauri::command]
fn gps_service_read_points(app: tauri::AppHandle) -> Result<String, String> {
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");
}

View file

@ -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"