GPX export + GPS background recording fixes

GPX export: button in session detail, standard GPX 1.1 format.

Background GPS fixes:
- Native service: batched writes (10s flush) instead of per-point
  appendText — eliminates race condition with reader
- JS reader: skip corrupt lines, sort by timestamp, track by ts
  instead of line count — handles partial writes and reordering
- Recovery: idempotent import using max DB timestamp
- Live recording: seq numbers on every point
- FileObserver watches correct path (app root, not files/dev.baldav.app)
- Removed ACCESS_BACKGROUND_LOCATION (causes throttling)

Nav: "Трекинг" button on GPS screen when recording active.
This commit is contained in:
4aiman 2026-05-16 10:49:55 +03:00
commit daf71333ff
10 changed files with 5911 additions and 52 deletions

View file

@ -1158,6 +1158,7 @@ body {
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
touch-action: pan-y pinch-zoom;
}
.sd-map-full {

View file

@ -375,11 +375,11 @@ async function createGpsSession(date, startTime, activityType) {
return id;
}
async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs) {
async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs, seq) {
await execute(
`INSERT INTO gps_points (session_id, ts, lat, lon, altitude_gps_m, speed_ms)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
[sessionId, ts, lat, lon, altGps ?? null, speedMs ?? null]
`INSERT INTO gps_points (session_id, ts, lat, lon, altitude_gps_m, speed_ms, seq)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
[sessionId, ts, lat, lon, altGps ?? null, speedMs ?? null, seq ?? null]
);
}

View file

@ -158,14 +158,22 @@ GPS._readNativePoints = async function() {
try {
const raw = await window.__TAURI__.core.invoke("gps_service_read_points");
if (!raw) return [];
return raw.trim().split("\n").filter(Boolean).map(line => {
const d = JSON.parse(line);
return {
lat: d.lat, lon: d.lon, altitude: d.alt,
speed_ms: d.speed, accuracy: d.acc,
ts: new Date(d.ts).toISOString(),
};
});
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,
ts: new Date(d.ts).toISOString(),
});
} 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;
} catch { return []; }
};
@ -231,11 +239,12 @@ GPS._processIncomingPoint = async function(p) {
if (GPS.recording.onStateChange) GPS.recording.onStateChange("resumed", p);
}
// Save moving point
// Save moving point with seq
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);
await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, GPS.recording._pointSeq);
}
if (GPS.recording.onPoint) GPS.recording.onPoint(p);
};
@ -298,13 +307,17 @@ GPS.recoverSession = async function() {
try {
const s = JSON.parse(saved);
if (!s.sessionId) { GPS._clearPersistedState(); return; }
// Import any remaining native points
// Import remaining native points not yet in DB (idempotent by timestamp)
const existingPts = await DB.getGpsPoints(s.sessionId);
const maxDbTs = existingPts.length ? Math.max(...existingPts.map(p => new Date(p.ts).getTime())) : 0;
const nativePoints = await GPS._readNativePoints();
if (nativePoints.length > (s.pointCount || 0)) {
for (let i = s.pointCount || 0; i < nativePoints.length; i++) {
const p = nativePoints[i];
await DB.addGpsPoint(s.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms);
}
let imported = 0;
let seq = existingPts.length;
for (const p of nativePoints) {
if (new Date(p.ts).getTime() <= maxDbTs) continue;
seq++;
await DB.addGpsPoint(s.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, seq);
imported++;
}
// Finalize the session
const pts = await DB.getGpsPoints(s.sessionId);
@ -341,7 +354,8 @@ GPS.startRecording = async function(activityType, weightKg) {
GPS.recording._currentPause = null;
GPS.recording.nativeService = false;
GPS.recording._pollTimer = null;
GPS.recording._lastNativeCount = 0;
GPS.recording._lastProcessedTs = 0;
GPS.recording._pointSeq = 0;
// Start native foreground service (Android)
await GPS._startNativeService();
@ -351,10 +365,13 @@ GPS.startRecording = async function(activityType, weightKg) {
GPS.recording._pollTimer = setInterval(async () => {
if (!GPS.recording.active) { clearInterval(GPS.recording._pollTimer); return; }
const nativePoints = await GPS._readNativePoints();
for (let i = GPS.recording._lastNativeCount; i < nativePoints.length; i++) {
await GPS._processIncomingPoint(nativePoints[i]);
// Process only points newer than last processed (by timestamp)
for (const p of nativePoints) {
const pTs = new Date(p.ts).getTime();
if (pTs <= GPS.recording._lastProcessedTs) continue;
await GPS._processIncomingPoint(p);
GPS.recording._lastProcessedTs = pTs;
}
GPS.recording._lastNativeCount = nativePoints.length;
}, 2000);
}
@ -660,5 +677,51 @@ GPS.formatDistance = function(meters) {
return meters >= 1000 ? (meters / 1000).toFixed(2) + " км" : meters + " м";
};
// ---------------------------------------------------------------------------
// GPX Export
// ---------------------------------------------------------------------------
GPS.exportGPX = async function(sessionId) {
const session = await DB.getGpsSession(sessionId);
if (!session) return;
const points = await DB.getGpsPoints(sessionId);
if (!points.length) return;
const esc = s => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
const name = esc(session.activity_type || "Track") + " " + esc(session.date);
let gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx creator="BalDav" version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>${name}</name><time>${points[0].ts}</time></metadata>
<trk>
<name>${name}</name>
<type>${esc(session.activity_type || "other")}</type>
<trkseg>
`;
for (const p of points) {
gpx += ` <trkpt lat="${p.lat}" lon="${p.lon}">
`;
if (p.altitude_gps_m != null) gpx += ` <ele>${p.altitude_gps_m}</ele>\n`;
if (p.ts) gpx += ` <time>${p.ts}</time>\n`;
if (p.speed_ms != null) gpx += ` <extensions><speed>${p.speed_ms}</speed></extensions>\n`;
gpx += ` </trkpt>\n`;
}
gpx += ` </trkseg>
</trk>
</gpx>`;
// Download
const blob = new Blob([gpx], { type: "application/gpx+xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `baldav-${session.date}-${session.activity_type || "track"}.gpx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
window.GPS = GPS;
console.log("[gps.js] window.GPS ready");

View file

@ -67,7 +67,12 @@ async function init() {
// ---------------------------------------------------------------------------
function renderScreen(name) {
const prevScreen = state.screen;
state.screen = name;
// Push history so "back" navigates between screens instead of exiting app
if (prevScreen && prevScreen !== name) {
history.pushState({ screen: name }, "");
}
const screens = {
today: renderToday,
products: renderProducts,
@ -112,7 +117,7 @@ function buildNav(active) {
[
{ id: "today", label: "Сегодня" },
{ id: "products", label: "Продукты" },
{ id: GPS.recording.active ? "recording" : "gps", label: GPS.recording.active ? "Запись" : "Трек" },
{ id: "gps", label: "Трек" },
{ id: "supplements", label: "Добавки" },
{ id: "settings", label: "Настройки" },
].forEach(item => {
@ -302,13 +307,28 @@ function showModal(html, onSubmit) {
document.body.appendChild(overlay);
history.pushState({ modal: true }, "");
const popHandler = () => closeModal(false);
const popHandler = () => {
// If fullscreen map is open inside this modal, collapse it instead of closing modal
const fsMap = overlay.querySelector(".sd-map-full");
if (fsMap) {
fsMap.classList.remove("sd-map-full");
const expandBtn = fsMap.querySelector(".sd-map-expand-btn");
if (expandBtn) expandBtn.innerHTML = "&#x26F6;";
try { map.dragging.disable(); } catch {}
history.pushState({ modal: true }, "");
window.addEventListener("popstate", popHandler, { once: true });
return;
}
closeModal(false);
};
window.addEventListener("popstate", popHandler, { once: true });
function closeModal(goBack) {
overlay.remove();
window.removeEventListener("popstate", popHandler);
if (goBack) history.back();
// Ensure history isn't empty after modal close (prevents app exit on next back)
history.replaceState({ screen: state.screen }, "");
}
overlay.querySelector("#modal-back").onclick = () => closeModal(true);
@ -733,7 +753,7 @@ async function renderGpsScreen(container) {
<option value="run">Бег</option>
</select>
<button id="gps-rec-btn" class="btn-primary gps-rec ${isRec ? "recording" : ""}">
${isRec ? "Стоп" : "Старт"}
${isRec ? "Трекинг" : "Старт"}
</button>
<div class="gps-menu-wrap">
<button id="gps-menu-btn" class="gps-menu-btn">&#x22EE;</button>
@ -1224,7 +1244,7 @@ async function showSessionDetail(sessionId) {
<div class="sd-stat"><span class="sd-val">${stats.avgSpeed} км/ч</span><span class="sd-lbl">Средняя</span></div>
<div class="sd-stat"><span class="sd-val" id="sd-max-speed">${stats.maxSpeed} км/ч</span><span class="sd-lbl">Макс.</span></div>
</div>
<canvas id="sd-speed-chart" height="150"></canvas>
<canvas id="sd-speed-chart" height="150" style="touch-action:pan-y"></canvas>
</div>
<div class="sd-section">
@ -1246,10 +1266,11 @@ async function showSessionDetail(sessionId) {
<div class="sd-stat"><span class="sd-val">${stats.elevGain} м</span><span class="sd-lbl">Подъём</span></div>
<div class="sd-stat"><span class="sd-val">${stats.elevLoss} м</span><span class="sd-lbl">Спуск</span></div>
</div>
<canvas id="sd-elev-chart" height="150"></canvas>
<canvas id="sd-elev-chart" height="150" style="touch-action:pan-y"></canvas>
</div>` : ""}
<button id="sd-delete" class="btn-secondary full-width" style="margin-top:16px;color:#cf6679">Удалить тренировку</button>
<button id="sd-export" class="btn-secondary full-width" style="margin-top:16px">Экспорт GPX</button>
<button id="sd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить тренировку</button>
`, () => {});
// Render map
@ -1258,7 +1279,7 @@ async function showSessionDetail(sessionId) {
if (!mapEl || !points.length) return;
L.Icon.Default.imagePath = "lib/";
const map = L.map(mapEl, { attributionControl: false, zoomControl: false, scrollWheelZoom: false });
const map = L.map(mapEl, { attributionControl: false, zoomControl: false, scrollWheelZoom: false, dragging: false, tap: false, touchZoom: true });
// Ctrl+wheel zoom on desktop
mapEl.addEventListener("wheel", (e) => {
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
@ -1309,29 +1330,33 @@ async function showSessionDetail(sessionId) {
// Fullscreen toggle button on map
// Fullscreen map toggle
let mapFsPopHandler = null;
function toggleMapFs() {
const isFull = mapEl.classList.toggle("sd-map-full");
fsDiv.innerHTML = isFull ? "&#x2715;" : "&#x26F6;";
if (isFull) {
history.pushState({ mapFs: true }, "");
mapFsPopHandler = () => {
mapEl.classList.remove("sd-map-full");
fsDiv.innerHTML = "&#x26F6;";
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
};
window.addEventListener("popstate", mapFsPopHandler, { once: true });
} else {
if (mapFsPopHandler) { window.removeEventListener("popstate", mapFsPopHandler); mapFsPopHandler = null; }
history.back();
}
let mapIsFullscreen = false;
function collapseMap() {
if (!mapIsFullscreen) return;
mapIsFullscreen = false;
mapEl.classList.remove("sd-map-full");
fsDiv.innerHTML = "&#x26F6;";
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
}
function toggleMapFs() {
if (mapIsFullscreen) {
collapseMap();
map.dragging.disable();
} else {
mapIsFullscreen = true;
mapEl.classList.add("sd-map-full");
fsDiv.innerHTML = "&#x2715;";
map.dragging.enable();
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
}
}
const fsBtn = L.control({ position: "topright" });
let fsDiv;
fsBtn.onAdd = () => {
fsDiv = L.DomUtil.create("div", "sd-map-fs-btn");
fsDiv = L.DomUtil.create("div", "sd-map-fs-btn sd-map-expand-btn");
fsDiv.innerHTML = "&#x26F6;";
fsDiv.onclick = (e) => { e.stopPropagation(); toggleMapFs(); };
return fsDiv;
@ -1391,7 +1416,6 @@ async function showSessionDetail(sessionId) {
plugins: {
legend: { display: false },
zoom: {
pan: { enabled: true, mode: "x" },
zoom: { wheel: { enabled: true, modifierKey: "ctrl" }, pinch: { enabled: true }, drag: { enabled: false }, mode: "x" },
pan: { enabled: false },
},
@ -1431,7 +1455,6 @@ async function showSessionDetail(sessionId) {
plugins: {
legend: { display: false },
zoom: {
pan: { enabled: true, mode: "x" },
zoom: { wheel: { enabled: true, modifierKey: "ctrl" }, pinch: { enabled: true }, drag: { enabled: false }, mode: "x" },
pan: { enabled: false },
},
@ -1448,6 +1471,9 @@ async function showSessionDetail(sessionId) {
// Delete button
setTimeout(() => {
const exp = document.querySelector("#sd-export");
if (exp) exp.onclick = () => GPS.exportGPX(sessionId);
const del = document.querySelector("#sd-delete");
if (del) del.onclick = async () => {
await DB.deleteGpsSession(sessionId);
@ -1992,6 +2018,16 @@ function esc(str) {
// Start
// ---------------------------------------------------------------------------
// Global back navigation between screens
window.addEventListener("popstate", (e) => {
// If a modal is open, don't handle here (modal has its own handler)
if (document.querySelector(".modal-overlay")) return;
// Navigate to previous screen or stay on today
if (e.state && e.state.screen) {
renderScreen(e.state.screen);
}
});
init().catch(err => {
console.error(err);
app.innerHTML = `<div class="loading" style="color:#cf6679">Ошибка: ${esc(String(err))}</div>`;

5733
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,12 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/core/tauri-config-schema/schema.json",
"identifier": "default",
"description": "BalDav default capabilities",
"windows": ["main"],
"permissions": [
"core:default",
"sql:default",
"sql:allow-execute",
"sql:allow-select"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

View file

@ -25,8 +25,14 @@ fn get_app_info() -> AppInfo {
fn gps_service_command(app: tauri::AppHandle, cmd: 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();
// Write to app_data_dir
let cmd_path = data_dir.join("gps_command.txt");
std::fs::write(&cmd_path, &cmd).map_err(|e| e.to_string())?;
// Also write to app root dir (Android: Kotlin watches there)
if let Some(root) = data_dir.parent().and_then(|p| p.parent()) {
let alt = root.join("gps_command.txt");
let _ = std::fs::write(&alt, &cmd);
}
Ok(format!("sent:{}", cmd))
}
@ -34,9 +40,17 @@ fn gps_service_command(app: tauri::AppHandle, cmd: String) -> Result<String, Str
#[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())?;
// Also check parent dir (Android: app_data_dir may be files/dev.baldav.app but service writes to app root)
let pts_path = data_dir.join("gps_live_points.jsonl");
let alt_path = data_dir.parent().and_then(|p| p.parent()).map(|p| p.join("gps_live_points.jsonl"));
if pts_path.exists() {
std::fs::read_to_string(&pts_path).map_err(|e| e.to_string())
} else if let Some(ref alt) = alt_path {
if alt.exists() {
std::fs::read_to_string(alt).map_err(|e| e.to_string())
} else {
Ok(String::new())
}
} else {
Ok(String::new())
}

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.39",
"version": "0.1.59",
"identifier": "dev.baldav.app",
"build": {
"frontendDist": "../frontend"