Separate BleBaroService so the HR strap and the barometer each hold their own GATT link and work at the same time (one adapter, several LE connections). Verified on device against a PC BlueZ emulator of the sensor: BleHR and BleBaro deliver in parallel, no cross-talk. - BleBaroService: dedicated baro connection (scan-before-connect + direct-connect fallback, ESS subscribe, writes ble_baro_data.json). MainActivity routes baro connects to it and auto-connects both HR (ble_device.json) and baro (baro_device.json) at start. - Rust: ble_baro_disconnect, ble_save_baro_device. - Settings: a 'Датчики (BLE)' section - heart-rate and barometer as separate rows with their own slots, bind/connect/unbind, and a live pressure/temperature/altitude readout. - Tracking: altitude_baro_m stored per point, preferred in elevation stats (null without baro). - Record screen: 'Высота' from the barometer (1 Hz, dot marker), temperature from the sensor. - Fixes: direct-connect on scan timeout; Locale.US JSON (RU decimal comma broke the parse); parsing routed by characteristic (baro no longer written into the pulse); VR/HR stop returns to the tracks list instead of into details. - tools/baro-sensor/emulate_baro.py: PC BlueZ peripheral that emulates the sensor for testing. Emulator-verified only; real board still pending.
1321 lines
64 KiB
Rust
1321 lines
64 KiB
Rust
#[allow(unused_imports)]
|
|
use tauri_plugin_sql::{Migration, MigrationKind};
|
|
#[allow(unused_imports)]
|
|
use tauri::Manager;
|
|
#[allow(unused_imports)]
|
|
use tauri::Emitter; // app.emit(...) for progress events (Tauri v2)
|
|
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)
|
|
// never freezes the UI — it just fails after a few seconds in the background.
|
|
#[tauri::command]
|
|
async fn http_request(url: String, method: String, body: Option<String>) -> Result<String, String> {
|
|
tauri::async_runtime::spawn_blocking(move || -> Result<String, String> {
|
|
let agent: ureq::Agent = ureq::Agent::config_builder()
|
|
.timeout_connect(Some(std::time::Duration::from_secs(4)))
|
|
.timeout_global(Some(std::time::Duration::from_secs(12)))
|
|
.build()
|
|
.into();
|
|
let mut resp = match method.to_uppercase().as_str() {
|
|
"POST" => {
|
|
let b = body.unwrap_or_default();
|
|
agent.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.send(b.as_bytes())
|
|
.map_err(|e| e.to_string())?
|
|
}
|
|
_ => agent.get(&url).call().map_err(|e| e.to_string())?,
|
|
};
|
|
resp.body_mut().read_to_string().map_err(|e| e.to_string())
|
|
})
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
}
|
|
|
|
// HTTP proxy for the "Честный знак" API. Unlike http_request it (a) sends a
|
|
// browser-ish User-Agent (the CRPT gateway is picky) and (b) returns the HTTP
|
|
// status alongside the body as a JSON envelope {"status":N,"body":"..."} so the
|
|
// caller can drive session logic: 204 = no nutrition data, 401/400 = refresh sid,
|
|
// 427 = version/attestation gate. http_status_as_error(false) keeps 4xx/5xx as
|
|
// normal responses instead of errors so we can read their status/body.
|
|
#[tauri::command]
|
|
async fn chzn_http(url: String, method: String, body: Option<String>) -> Result<String, String> {
|
|
tauri::async_runtime::spawn_blocking(move || -> Result<String, String> {
|
|
let agent: ureq::Agent = ureq::Agent::config_builder()
|
|
.timeout_connect(Some(std::time::Duration::from_secs(5)))
|
|
.timeout_global(Some(std::time::Duration::from_secs(15)))
|
|
.http_status_as_error(false)
|
|
.build()
|
|
.into();
|
|
let ua = "Mozilla/5.0 (Linux; Android 15)";
|
|
let mut resp = match method.to_uppercase().as_str() {
|
|
"POST" => {
|
|
let b = body.unwrap_or_default();
|
|
agent.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.header("User-Agent", ua)
|
|
.send(b.as_bytes())
|
|
.map_err(|e| e.to_string())?
|
|
}
|
|
_ => agent.get(&url).header("User-Agent", ua).call().map_err(|e| e.to_string())?,
|
|
};
|
|
let status = resp.status().as_u16();
|
|
let text = resp.body_mut().read_to_string().unwrap_or_default();
|
|
Ok(serde_json::json!({ "status": status, "body": text }).to_string())
|
|
})
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
}
|
|
|
|
// 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 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();
|
|
if content.ends_with(".jsonl") { Ok(content) } else { Ok(String::new()) }
|
|
} else {
|
|
Ok(String::new())
|
|
}
|
|
}
|
|
|
|
// 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 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())?;
|
|
Ok("deleted".to_string())
|
|
} else {
|
|
Ok("not_found".to_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())
|
|
}
|
|
}
|
|
|
|
// Read a small app state file (allowlisted) written by Kotlin, e.g. the location on/off state.
|
|
#[tauri::command]
|
|
fn read_state_file(app: tauri::AppHandle, name: String) -> Result<String, String> {
|
|
if name != "location_state.json" { return Err("bad name".to_string()); }
|
|
let p = native_root(&app).join(&name);
|
|
if p.exists() { std::fs::read_to_string(&p).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]
|
|
fn download_file(url: String, dest: String) -> Result<String, String> {
|
|
use std::io::Read;
|
|
let resp = ureq::get(&url).call().map_err(|e| format!("fetch: {e}"))?;
|
|
let mut reader = resp.into_body().into_reader();
|
|
let mut data = Vec::new();
|
|
reader.read_to_end(&mut data).map_err(|e| format!("read: {e}"))?;
|
|
std::fs::write(&dest, &data).map_err(|e| format!("write: {e}"))?;
|
|
Ok(format!("{}", data.len()))
|
|
}
|
|
|
|
// Rebuild road_points from all GPS tracks with 2m deduplication.
|
|
// Runs entirely in Rust/SQLite — no Tauri SQL plugin row limits.
|
|
#[tauri::command]
|
|
fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result<String, String> {
|
|
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
let db_path = {
|
|
let p1 = data_dir.join("../../baldav.db");
|
|
let p2 = data_dir.join("baldav.db");
|
|
let p3 = std::path::PathBuf::from(
|
|
data_dir.to_str().unwrap_or("").replace("/files/dev.baldav.app", "")
|
|
).join("baldav.db");
|
|
if p1.exists() { p1 } else if p2.exists() { p2 } else { p3 }
|
|
};
|
|
let db = rusqlite::Connection::open(&db_path).map_err(|e| format!("open: {e}"))?;
|
|
|
|
db.execute("DROP TABLE IF EXISTS road_points", []).ok();
|
|
// `passes` = how many distinct times this ~2 m stretch was ridden (used for line thickness).
|
|
db.execute("CREATE TABLE road_points (id INTEGER PRIMARY KEY, lat REAL NOT NULL, lon REAL NOT NULL, atype TEXT, passes INTEGER NOT NULL DEFAULT 1)", [])
|
|
.map_err(|e| format!("create: {e}"))?;
|
|
db.execute("CREATE INDEX idx_road_lat_lon ON road_points(lat, lon)", [])
|
|
.map_err(|e| format!("index: {e}"))?;
|
|
|
|
// ~2 m cell in degrees at 47N: lat ~0.000018, lon ~0.000027.
|
|
let d_lat: f64 = 0.000018;
|
|
let d_lon: f64 = 0.000027;
|
|
|
|
// A "walking" point inside a ride (walk=1) is classified as walk, not the session's
|
|
// activity — so pushed-across-a-field bits don't become a rideable road. session_id is
|
|
// read too, so a cell re-entered in a later session counts as another pass.
|
|
let mut read_stmt = db.prepare(
|
|
"SELECT p.lat, p.lon, CASE WHEN COALESCE(p.walk,0)=1 THEN 'walk' ELSE s.activity_type END, p.session_id
|
|
FROM gps_points p
|
|
JOIN gps_sessions s ON s.id = p.session_id
|
|
WHERE p.speed_ms >= ?1
|
|
ORDER BY p.session_id, p.seq"
|
|
).map_err(|e| format!("prepare read: {e}"))?;
|
|
|
|
let points: Vec<(f64, f64, String, i64)> = read_stmt.query_map(rusqlite::params![min_speed_ms], |row| {
|
|
Ok((row.get::<_, f64>(0)?, row.get::<_, f64>(1)?, row.get::<_, String>(2).unwrap_or_default(), row.get::<_, i64>(3).unwrap_or(-1)))
|
|
}).map_err(|e| format!("query: {e}"))?
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
|
|
drop(read_stmt);
|
|
|
|
let total = points.len();
|
|
let _ = app.emit("roads_progress", (0u64, total as u64)); // start
|
|
|
|
// Glue + count passes in memory: one cell per ~2 m grid square, first-seen geometry kept,
|
|
// `passes` incremented once per ENTRY into the cell (not per point, so a slow crawl that
|
|
// drops many points in one cell still counts as a single pass). Leaving and re-entering
|
|
// the cell — or a new session — is a fresh entry. Much faster than a per-point SQL EXISTS.
|
|
use std::collections::HashMap;
|
|
let mut index: HashMap<(i64, i64), usize> = HashMap::new();
|
|
let mut cells: Vec<(f64, f64, String, u32)> = Vec::new(); // lat, lon, atype, passes
|
|
let mut prev_key: Option<(i64, i64)> = None;
|
|
let mut prev_session: i64 = i64::MIN;
|
|
for (i, (lat, lon, atype, session)) in points.iter().enumerate() {
|
|
if i % 20000 == 0 { let _ = app.emit("roads_progress", (i as u64, total as u64)); }
|
|
let key = ((lat / d_lat).floor() as i64, (lon / d_lon).floor() as i64);
|
|
let entry = prev_key != Some(key) || *session != prev_session;
|
|
match index.get(&key) {
|
|
Some(&idx) => { if entry { cells[idx].3 += 1; } }
|
|
None => { index.insert(key, cells.len()); cells.push((*lat, *lon, atype.clone(), 1)); }
|
|
}
|
|
prev_key = Some(key);
|
|
prev_session = *session;
|
|
}
|
|
|
|
db.execute("BEGIN", []).map_err(|e| format!("begin: {e}"))?;
|
|
{
|
|
let mut ins = db.prepare("INSERT INTO road_points (lat, lon, atype, passes) VALUES (?1, ?2, ?3, ?4)")
|
|
.map_err(|e| format!("prepare insert: {e}"))?;
|
|
for (lat, lon, atype, passes) in cells.iter() {
|
|
ins.execute(rusqlite::params![lat, lon, atype, passes])
|
|
.map_err(|e| format!("insert: {e}"))?;
|
|
}
|
|
}
|
|
db.execute("COMMIT", []).map_err(|e| format!("commit: {e}"))?;
|
|
let inserted = cells.len();
|
|
let _ = app.emit("roads_progress", (total as u64, total as u64)); // done
|
|
Ok(format!("{{\"total_points\":{},\"road_points\":{}}}", total, inserted))
|
|
}
|
|
|
|
// Read road_points in batches — bypasses Tauri SQL plugin's ~28K row limit
|
|
#[tauri::command]
|
|
fn get_road_points(app: tauri::AppHandle, offset: u32, batch_size: u32) -> Result<String, String> {
|
|
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
// Find main DB — same path resolution as import_seed_products
|
|
let db_path = {
|
|
let p1 = data_dir.join("../../baldav.db");
|
|
let p2 = data_dir.join("baldav.db");
|
|
let p3 = std::path::PathBuf::from(
|
|
data_dir.to_str().unwrap_or("").replace("/files/dev.baldav.app", "")
|
|
).join("baldav.db");
|
|
if p1.exists() { p1 } else if p2.exists() { p2 } else { p3 }
|
|
};
|
|
let db = rusqlite::Connection::open(&db_path).map_err(|e| format!("open: {e}"))?;
|
|
let total: u32 = db.query_row(
|
|
"SELECT count(*) FROM road_points", [], |r| r.get(0)
|
|
).unwrap_or(0);
|
|
// `passes` column exists only after a rebuild on this build; tolerate older DBs (passes=1).
|
|
let has_passes: bool = db.query_row(
|
|
"SELECT COUNT(*) FROM pragma_table_info('road_points') WHERE name='passes'", [], |r| r.get::<_, i64>(0)
|
|
).map(|n| n > 0).unwrap_or(false);
|
|
let sql = if has_passes {
|
|
"SELECT lat, lon, COALESCE(atype,'bike'), COALESCE(passes,1) FROM road_points ORDER BY id LIMIT ?1 OFFSET ?2"
|
|
} else {
|
|
"SELECT lat, lon, COALESCE(atype,'bike'), 1 FROM road_points ORDER BY id LIMIT ?1 OFFSET ?2"
|
|
};
|
|
let mut stmt = db.prepare(sql).map_err(|e| format!("prepare: {e}"))?;
|
|
let mut points = Vec::new();
|
|
let rows = stmt.query_map(rusqlite::params![batch_size, offset], |row| {
|
|
let lat: f64 = row.get(0)?;
|
|
let lon: f64 = row.get(1)?;
|
|
let atype: String = row.get(2)?;
|
|
let passes: i64 = row.get(3)?;
|
|
Ok((lat, lon, atype, passes))
|
|
}).map_err(|e| format!("query: {e}"))?;
|
|
for row in rows {
|
|
let (lat, lon, atype, passes) = row.map_err(|e| format!("row: {e}"))?;
|
|
let t = if atype == "walk" { "w" } else if atype == "run" { "r" } else { "b" };
|
|
points.push(format!("[{:.7},{:.7},\"{}\",{}]", lat, lon, t, passes));
|
|
}
|
|
let has_more = offset + batch_size < total;
|
|
Ok(format!("{{\"points\":[{}],\"total\":{},\"has_more\":{}}}", points.join(","), total, has_more))
|
|
}
|
|
|
|
// Build a routable graph from the user's RIDDEN tracks (excludes walk=1 points), so the planner
|
|
// can prefer real roads before public OSM data. Points are snapped to a ~20 m grid (nodes);
|
|
// consecutive points of the same session make an edge (weight = metres). Returns compact JSON
|
|
// { "nodes": [[lat,lon],...], "edges": [[a,b,dist],...] } (node index = position in nodes).
|
|
#[tauri::command]
|
|
fn get_ride_graph(app: tauri::AppHandle, min_speed_ms: f64) -> Result<String, String> {
|
|
use std::collections::HashMap;
|
|
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
let db_path = {
|
|
let p1 = data_dir.join("../../baldav.db");
|
|
let p2 = data_dir.join("baldav.db");
|
|
let p3 = std::path::PathBuf::from(data_dir.to_str().unwrap_or("").replace("/files/dev.baldav.app", "")).join("baldav.db");
|
|
if p1.exists() { p1 } else if p2.exists() { p2 } else { p3 }
|
|
};
|
|
let db = rusqlite::Connection::open(&db_path).map_err(|e| format!("open: {e}"))?;
|
|
fn hav(la1: f64, lo1: f64, la2: f64, lo2: f64) -> f64 {
|
|
let r = 6371000.0_f64;
|
|
let dla = (la2 - la1).to_radians();
|
|
let dlo = (lo2 - lo1).to_radians();
|
|
let a = (dla / 2.0).sin().powi(2) + la1.to_radians().cos() * la2.to_radians().cos() * (dlo / 2.0).sin().powi(2);
|
|
r * 2.0 * a.sqrt().asin()
|
|
}
|
|
let d_lat = 0.00018_f64; // ~20 m
|
|
let d_lon = 0.00027_f64;
|
|
let mut stmt = db.prepare(
|
|
"SELECT lat, lon, session_id, seq FROM gps_points
|
|
WHERE COALESCE(walk,0)=0 AND speed_ms >= ?1
|
|
ORDER BY session_id, COALESCE(seq,999999), ts"
|
|
).map_err(|e| format!("prepare: {e}"))?;
|
|
let rows = stmt.query_map(rusqlite::params![min_speed_ms], |r| {
|
|
Ok((r.get::<_, f64>(0)?, r.get::<_, f64>(1)?, r.get::<_, i64>(2)?))
|
|
}).map_err(|e| format!("query: {e}"))?;
|
|
|
|
let mut node_idx: HashMap<(i64, i64), usize> = HashMap::new();
|
|
let mut nodes: Vec<(f64, f64)> = Vec::new();
|
|
let mut edges: HashMap<(usize, usize), f64> = HashMap::new();
|
|
let mut prev: Option<(i64, usize, f64, f64)> = None; // (session_id, node, lat, lon)
|
|
for row in rows {
|
|
let (lat, lon, sid) = row.map_err(|e| format!("row: {e}"))?;
|
|
let c = ((lat / d_lat).round() as i64, (lon / d_lon).round() as i64);
|
|
let ni = match node_idx.get(&c) {
|
|
Some(&i) => i,
|
|
None => { let i = nodes.len(); nodes.push((lat, lon)); node_idx.insert(c, i); i }
|
|
};
|
|
if let Some((psid, pn, _plat, _plon)) = prev {
|
|
if psid == sid && pn != ni {
|
|
// Edge weight = distance between the two NODES (not the consecutive points),
|
|
// else intra-cell travel is lost and the path is badly under-weighted.
|
|
let (nla, nlo) = nodes[pn];
|
|
let d = hav(nla, nlo, lat, lon);
|
|
if d <= 200.0 { // skip absurd jumps (GPS gap) between consecutive points
|
|
let key = if pn < ni { (pn, ni) } else { (ni, pn) };
|
|
let e = edges.entry(key).or_insert(f64::INFINITY);
|
|
if d < *e { *e = d; }
|
|
}
|
|
}
|
|
}
|
|
prev = Some((sid, ni, lat, lon));
|
|
}
|
|
let nodes_json = nodes.iter().map(|(la, lo)| format!("[{:.6},{:.6}]", la, lo)).collect::<Vec<_>>().join(",");
|
|
let edges_json = edges.iter().map(|((a, b), d)| format!("[{},{},{:.1}]", a, b, d)).collect::<Vec<_>>().join(",");
|
|
Ok(format!("{{\"nodes\":[{}],\"edges\":[{}]}}", nodes_json, edges_json))
|
|
}
|
|
|
|
// Elevation correction grid from ridden tracks: per ~20m cell, the MEDIAN vertical
|
|
// offset (measured altitude_gps_m minus terrain altitude_srtm_m) over all passes.
|
|
// SRTM stays the base; this is only the up/down nudge where you've actually ridden.
|
|
// Returns [[cellLat, cellLon, medianDelta, passes], ...] for cells ridden on at least
|
|
// `min_passes` distinct sessions. The JS side adds a smoothed delta onto SRTM elevations.
|
|
#[tauri::command]
|
|
fn get_elev_correction(app: tauri::AppHandle, min_passes: u32) -> Result<String, String> {
|
|
use std::collections::{HashMap, HashSet};
|
|
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
let db_path = {
|
|
let p1 = data_dir.join("../../baldav.db");
|
|
let p2 = data_dir.join("baldav.db");
|
|
let p3 = std::path::PathBuf::from(data_dir.to_str().unwrap_or("").replace("/files/dev.baldav.app", "")).join("baldav.db");
|
|
if p1.exists() { p1 } else if p2.exists() { p2 } else { p3 }
|
|
};
|
|
let db = rusqlite::Connection::open(&db_path).map_err(|e| format!("open: {e}"))?;
|
|
let d_lat = 0.00018_f64; // ~20 m (same grid as the ride graph)
|
|
let d_lon = 0.00027_f64;
|
|
let mut stmt = db.prepare(
|
|
"SELECT lat, lon, altitude_gps_m, altitude_srtm_m, session_id FROM gps_points
|
|
WHERE altitude_gps_m IS NOT NULL AND altitude_srtm_m IS NOT NULL AND COALESCE(walk,0)=0"
|
|
).map_err(|e| format!("prepare: {e}"))?;
|
|
let rows = stmt.query_map([], |r| {
|
|
Ok((r.get::<_, f64>(0)?, r.get::<_, f64>(1)?, r.get::<_, f64>(2)?, r.get::<_, f64>(3)?, r.get::<_, i64>(4)?))
|
|
}).map_err(|e| format!("query: {e}"))?;
|
|
|
|
let mut cells: HashMap<(i64, i64), (Vec<f64>, HashSet<i64>)> = HashMap::new();
|
|
for row in rows {
|
|
let (lat, lon, gps, srtm, sid) = row.map_err(|e| format!("row: {e}"))?;
|
|
let delta = gps - srtm;
|
|
if !delta.is_finite() || delta.abs() > 300.0 { continue; } // guard against garbage fixes
|
|
let c = ((lat / d_lat).round() as i64, (lon / d_lon).round() as i64);
|
|
let e = cells.entry(c).or_insert_with(|| (Vec::new(), HashSet::new()));
|
|
e.0.push(delta);
|
|
e.1.insert(sid);
|
|
}
|
|
let mut out: Vec<String> = Vec::new();
|
|
for ((clat, clon), (mut deltas, sessions)) in cells {
|
|
if (sessions.len() as u32) < min_passes { continue; }
|
|
deltas.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
|
let m = deltas.len();
|
|
let median = if m % 2 == 1 { deltas[m / 2] } else { (deltas[m / 2 - 1] + deltas[m / 2]) / 2.0 };
|
|
let lat = (clat as f64) * d_lat;
|
|
let lon = (clon as f64) * d_lon;
|
|
out.push(format!("[{:.6},{:.6},{:.2},{}]", lat, lon, median, sessions.len()));
|
|
}
|
|
Ok(format!("[{}]", out.join(",")))
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct AppInfo {
|
|
version: String,
|
|
git_hash: String,
|
|
build_date: String,
|
|
debug: bool,
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_app_info() -> AppInfo {
|
|
AppInfo {
|
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
|
git_hash: env!("GIT_HASH").to_string(),
|
|
build_date: env!("BUILD_DATE").to_string(),
|
|
// Compiled-in build profile: on for `tauri dev` / `--debug`, off for release.
|
|
// The JS side uses this to silence diagnostic logs in release builds.
|
|
debug: cfg!(debug_assertions),
|
|
}
|
|
}
|
|
|
|
// Read update check result written by Kotlin (update_info.json)
|
|
#[tauri::command]
|
|
fn get_update_info(app: tauri::AppHandle) -> Result<String, String> {
|
|
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
let paths = [
|
|
data_dir.join("update_info.json"),
|
|
data_dir.parent().and_then(|p| p.parent()).map(|r| r.join("update_info.json")).unwrap_or_default(),
|
|
];
|
|
for p in &paths {
|
|
if p.exists() {
|
|
if let Ok(s) = std::fs::read_to_string(p) {
|
|
let s = s.trim().to_string();
|
|
if !s.is_empty() { return Ok(s); }
|
|
}
|
|
}
|
|
}
|
|
Ok("{}".to_string())
|
|
}
|
|
|
|
// GPS service control — writes command file, Kotlin reads and starts/stops service
|
|
#[tauri::command]
|
|
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))
|
|
}
|
|
|
|
// Write hydration.json to the app root, where the Hydration singleton reads it: the level-based
|
|
// drink reminder (accrue sweat during a session, beep when the deficit exceeds alert_ml).
|
|
// start_deficit_ml: how far behind on water you were at session start (from logged intake vs
|
|
// need-so-far), computed in JS and seeded into the native deficit. From the settings screen it is 0.
|
|
#[tauri::command]
|
|
fn set_hydration_config(app: tauri::AppHandle, enabled: bool, alert_ml: u32, sweat_ml_per_hour: u32, re_alert_min: u32, start_deficit_ml: u32, seed_seq: Option<u64>) -> Result<String, String> {
|
|
// seed_seq: bump it (e.g. a timestamp) whenever the base deficit is recomputed mid-session
|
|
// (after logging a drink) so the native side re-applies start_deficit_ml to its baseDeficit.
|
|
let json = format!(
|
|
"{{\"enabled\":{},\"alertMl\":{},\"sweatMlPerHour\":{},\"reAlertMin\":{},\"startDeficitMl\":{},\"seedSeq\":{}}}",
|
|
enabled, alert_ml.clamp(100, 3000), sweat_ml_per_hour.clamp(60, 2000), re_alert_min.clamp(2, 120), start_deficit_ml.clamp(0, 5000), seed_seq.unwrap_or(0)
|
|
);
|
|
write_app_root_file(&app, "hydration.json", &json);
|
|
Ok(json)
|
|
}
|
|
|
|
// Write live_share.json to the app root, where the LiveShare singleton reads it. Opt-in live
|
|
// position sharing: when enabled, the GPS service POSTs the current fix to `url` (botovod's
|
|
// /api/live/push) every ~10s, authenticated by `token`. url/token are kept only on the device
|
|
// (this runtime file) and never committed. From the UI, disabling just flips enabled=false.
|
|
#[tauri::command]
|
|
fn set_live_config(app: tauri::AppHandle, enabled: bool, url: String, token: String) -> Result<String, String> {
|
|
let json = serde_json::json!({
|
|
"enabled": enabled,
|
|
"url": url,
|
|
"token": token,
|
|
}).to_string();
|
|
write_app_root_file(&app, "live_share.json", &json);
|
|
Ok(json)
|
|
}
|
|
|
|
// SMS "я тут": write the request (number + prefilled text with a Yandex Maps link) where the
|
|
// Kotlin side reads it, then trigger the command. MainActivity sends it directly if SEND_SMS was
|
|
// granted, otherwise opens the system SMS composer prefilled. Works over 2G/EDGE, no internet.
|
|
#[tauri::command]
|
|
fn send_sms(app: tauri::AppHandle, number: String, text: String) -> Result<String, String> {
|
|
let json = serde_json::json!({ "number": number, "text": text }).to_string();
|
|
write_app_root_file(&app, "sms_request.json", &json);
|
|
gps_service_command(app, "send_sms".to_string())
|
|
}
|
|
|
|
// Launch the system contact picker (an Activity); the chosen number lands in contact_result.json,
|
|
// which the UI reads via get_contact_result.
|
|
#[tauri::command]
|
|
fn pick_contact(app: tauri::AppHandle) -> Result<String, String> {
|
|
gps_service_command(app, "pick_contact".to_string())
|
|
}
|
|
|
|
// Read and clear the contact picker result (JSON {number,name}); empty string if not ready yet.
|
|
#[tauri::command]
|
|
fn get_contact_result(app: tauri::AppHandle) -> Result<String, String> {
|
|
let mut candidates: Vec<std::path::PathBuf> = vec![native_root(&app).join("contact_result.json")];
|
|
if let Ok(data_dir) = app.path().app_data_dir() {
|
|
if let Some(root) = data_dir.parent().and_then(|p| p.parent()) {
|
|
candidates.push(root.join("contact_result.json"));
|
|
}
|
|
}
|
|
for p in candidates {
|
|
if p.exists() {
|
|
let content = std::fs::read_to_string(&p).unwrap_or_default();
|
|
let _ = std::fs::remove_file(&p);
|
|
if !content.trim().is_empty() {
|
|
return Ok(content);
|
|
}
|
|
}
|
|
}
|
|
Ok(String::new())
|
|
}
|
|
|
|
// Write a small file to the app root — both where native_root resolves and the explicit
|
|
// app_data_dir/../.. (= Kotlin filesDir.parentFile) — so the Kotlin side always finds it.
|
|
fn write_app_root_file(app: &tauri::AppHandle, name: &str, contents: &str) {
|
|
let _ = std::fs::write(native_root(app).join(name), contents);
|
|
if let Ok(data_dir) = app.path().app_data_dir() {
|
|
if let Some(root) = data_dir.parent().and_then(|p| p.parent()) {
|
|
let _ = std::fs::write(root.join(name), contents);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Offline routing via the embedded BRouter engine (Kotlin runs btools.router). Same
|
|
// file bridge as GPS: write the request + a "route:<id>" command where MainActivity's
|
|
// FileObserver watches, then poll for brouter_result_<id>.json. `lonlats` is a JSON
|
|
// array of [lon,lat] pairs; returns the Kotlin result JSON ({ok,gpx}|{ok:false,error}).
|
|
#[tauri::command]
|
|
fn brouter_route(app: tauri::AppHandle, lonlats: String, profile: String, nogos: Option<String>, format: Option<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 id = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH).map_err(|e| e.to_string())?
|
|
.as_millis().to_string();
|
|
// nogos is a JSON array string [{lat,lon,radius}] from JS; default to empty array.
|
|
let nogos_json = nogos.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "[]".to_string());
|
|
// format: "gpx" (default) or "geojson" (carries per-segment WayTags for danger detection).
|
|
let fmt = format.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "gpx".to_string());
|
|
let req = format!(
|
|
"{{\"profile\":{},\"lonlats\":{},\"nogos\":{},\"format\":{}}}",
|
|
serde_json::to_string(&profile).map_err(|e| e.to_string())?,
|
|
lonlats,
|
|
nogos_json,
|
|
serde_json::to_string(&fmt).map_err(|e| e.to_string())?
|
|
);
|
|
let result_name = format!("brouter_result_{}.json", id);
|
|
// Write request + command to app_data_dir AND app root (where Kotlin watches).
|
|
let root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf());
|
|
let write_both = |name: &str, content: &str| {
|
|
let _ = std::fs::write(data_dir.join(name), content);
|
|
if let Some(r) = &root { let _ = std::fs::write(r.join(name), content); }
|
|
};
|
|
write_both("brouter_request.json", &req);
|
|
write_both("brouter_command.txt", &format!("route:{}", id));
|
|
// Poll for the result (routing can take a few seconds; cap ~60s).
|
|
let mut candidates = vec![data_dir.join(&result_name)];
|
|
if let Some(r) = &root { candidates.push(r.join(&result_name)); }
|
|
for _ in 0..600 {
|
|
for p in &candidates {
|
|
if p.exists() {
|
|
if let Ok(s) = std::fs::read_to_string(p) {
|
|
let _ = std::fs::remove_file(p);
|
|
return Ok(s);
|
|
}
|
|
}
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
}
|
|
Err("brouter: timeout waiting for result".into())
|
|
}
|
|
|
|
// Segment delivery (Stage 2). Kotlin owns the download so it lands in the exact dir the
|
|
// engine reads (files/brouter/segments4). These commands just relay via the file bridge.
|
|
fn brouter_write_cmd(app: &tauri::AppHandle, cmd: &str) -> Result<(), String> {
|
|
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
std::fs::create_dir_all(&data_dir).ok();
|
|
let _ = std::fs::write(data_dir.join("brouter_command.txt"), cmd);
|
|
if let Some(r) = data_dir.parent().and_then(|p| p.parent()) {
|
|
let _ = std::fs::write(r.join("brouter_command.txt"), cmd);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// Start downloading a .rd5 tile (e.g. "E35_N45"). Returns immediately; poll status.
|
|
#[tauri::command]
|
|
fn brouter_download_tile(app: tauri::AppHandle, tile: String) -> Result<String, String> {
|
|
brouter_write_cmd(&app, &format!("dltile:{}", tile))?;
|
|
Ok("started".into())
|
|
}
|
|
|
|
// Read the download status Kotlin writes (brouter_dl_<tile>.json): downloading/done/error.
|
|
#[tauri::command]
|
|
fn brouter_download_status(app: tauri::AppHandle, tile: String) -> Result<String, String> {
|
|
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
let name = format!("brouter_dl_{}.json", tile);
|
|
let mut candidates = vec![data_dir.join(&name)];
|
|
if let Some(r) = data_dir.parent().and_then(|p| p.parent()) { candidates.push(r.join(&name)); }
|
|
for p in &candidates {
|
|
if let Ok(s) = std::fs::read_to_string(p) { if !s.trim().is_empty() { return Ok(s); } }
|
|
}
|
|
Ok("{}".into())
|
|
}
|
|
|
|
// List downloaded tiles (asks Kotlin to refresh brouter_tiles.json, then reads it).
|
|
#[tauri::command]
|
|
fn brouter_list_tiles(app: tauri::AppHandle) -> Result<String, String> {
|
|
brouter_write_cmd(&app, "listtiles")?;
|
|
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
let mut candidates = vec![data_dir.join("brouter_tiles.json")];
|
|
if let Some(r) = data_dir.parent().and_then(|p| p.parent()) { candidates.push(r.join("brouter_tiles.json")); }
|
|
for _ in 0..50 {
|
|
for p in &candidates {
|
|
if let Ok(s) = std::fs::read_to_string(p) { if !s.trim().is_empty() { return Ok(s); } }
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
}
|
|
Ok("[]".into())
|
|
}
|
|
|
|
// Read GPS points from service's JSONL file
|
|
#[tauri::command]
|
|
fn gps_service_read_points(app: tauri::AppHandle) -> Result<String, String> {
|
|
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() {
|
|
let content = std::fs::read_to_string(&ptr_path).unwrap_or_default().trim().to_string();
|
|
if content.ends_with(".jsonl") {
|
|
// New format: pointer to session-specific file
|
|
app_root.join(&content)
|
|
} else {
|
|
// Old format: file IS the points data (backward compat)
|
|
return Ok(content);
|
|
}
|
|
} else {
|
|
return Ok(String::new());
|
|
};
|
|
if pts_file.exists() {
|
|
std::fs::read_to_string(&pts_file).map_err(|e| e.to_string())
|
|
} else {
|
|
Ok(String::new())
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn save_file(app: tauri::AppHandle, filename: String, content: 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 content to temp file for Kotlin to read
|
|
let content_path = data_dir.join("save_content.txt");
|
|
std::fs::write(&content_path, &content).map_err(|e| e.to_string())?;
|
|
|
|
// Send save command to Kotlin (triggers SAF file picker on Android)
|
|
let cmd_path = data_dir.join("gps_command.txt");
|
|
std::fs::write(&cmd_path, format!("save:{}", filename)).map_err(|e| e.to_string())?;
|
|
|
|
// Also write to app root for Kotlin FileObserver
|
|
if let Some(root) = data_dir.parent().and_then(|p| p.parent()) {
|
|
let _ = std::fs::write(root.join("save_content.txt"), &content);
|
|
let _ = std::fs::write(root.join("gps_command.txt"), format!("save:{}", filename));
|
|
}
|
|
|
|
Ok("save_requested".to_string())
|
|
}
|
|
|
|
// Export a PNG (base64) via the file bridge → Kotlin decodes + SAF-saves ("save") or shares it
|
|
// via a chooser ("share"). Mirrors save_file but carries binary as base64.
|
|
#[tauri::command]
|
|
fn export_image(app: tauri::AppHandle, filename: String, base64: String, action: 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 root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf());
|
|
let write_both = |name: &str, content: &str| {
|
|
let _ = std::fs::write(data_dir.join(name), content);
|
|
if let Some(r) = &root { let _ = std::fs::write(r.join(name), content); }
|
|
};
|
|
write_both("export_image.b64", &base64);
|
|
write_both("gps_command.txt", &format!("exportimg:{}:{}", action, filename));
|
|
Ok("export_requested".to_string())
|
|
}
|
|
|
|
// Read any file and return its bytes base64-encoded (for loading a picked image into the WebView).
|
|
#[tauri::command]
|
|
fn read_file_base64(path: String) -> Result<String, String> {
|
|
use base64::Engine;
|
|
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
|
|
Ok(base64::engine::general_purpose::STANDARD.encode(&bytes))
|
|
}
|
|
|
|
// App root (holds baldav.db + point files + the command files Kotlin watches) and the DB path.
|
|
// MUST use native_root(): app_data_dir() nesting on Android is unreliable, so any ad-hoc
|
|
// app_data_dir().parent().parent() / db.parent() is wrong (measured: it lands in the
|
|
// unwritable /data/user and VACUUM INTO fails). native_root() scans candidates and returns the
|
|
// real writable app root that Kotlin's filesDir.parentFile also uses. Same as save points do.
|
|
fn db_and_root(app: &tauri::AppHandle) -> Result<(std::path::PathBuf, std::path::PathBuf), String> {
|
|
let root = native_root(app);
|
|
let db = root.join("baldav.db");
|
|
Ok((db, root))
|
|
}
|
|
|
|
// Full backup: VACUUM the live DB into a clean copy in the app root, then tell Kotlin to
|
|
// SAF-save it as `filename`. VACUUM INTO is consistent even while the app holds the DB open.
|
|
#[tauri::command]
|
|
fn export_db(app: tauri::AppHandle, filename: String) -> Result<String, String> {
|
|
let (db_path, root) = db_and_root(&app)?; // root = native_root: the writable app dir Kotlin watches
|
|
if !db_path.exists() { return Err("baldav.db не найдена".into()); }
|
|
let out = root.join("export_db.tmp");
|
|
let _ = std::fs::remove_file(&out);
|
|
{
|
|
let conn = rusqlite::Connection::open(&db_path).map_err(|e| format!("open: {e}"))?;
|
|
conn.execute("VACUUM INTO ?1", rusqlite::params![out.to_str().unwrap_or("")]).map_err(|e| format!("vacuum: {e}"))?;
|
|
}
|
|
let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
|
|
let _ = std::fs::write(root.join("gps_command.txt"), format!("exportdb:{}", filename));
|
|
Ok(format!("{{\"size\":{}}}", size))
|
|
}
|
|
|
|
// Full restore: validate the picked DB (picked_import.db) and stage it as pending_restore.db.
|
|
// Kotlin swaps it onto baldav.db on the next launch, BEFORE the app opens the DB. Returns ok;
|
|
// caller then restarts the app.
|
|
#[tauri::command]
|
|
fn import_db(app: tauri::AppHandle) -> Result<String, String> {
|
|
let (_db, root) = db_and_root(&app)?; // root = native_root: Kotlin wrote picked_import.db here
|
|
let picked = root.join("picked_import.db");
|
|
if !picked.exists() { return Err("файл не выбран".into()); }
|
|
{
|
|
let conn = rusqlite::Connection::open(&picked).map_err(|e| format!("не открыть файл: {e}"))?;
|
|
let ok: String = conn.query_row("PRAGMA quick_check", [], |r| r.get(0)).map_err(|e| format!("проверка: {e}"))?;
|
|
if ok != "ok" { return Err(format!("база повреждена: {ok}")); }
|
|
let has: i64 = conn.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('gps_sessions','products','profile')",
|
|
[], |r| r.get(0)).map_err(|e| format!("схема: {e}"))?;
|
|
if has < 1 { return Err("это не похоже на базу BalDav".into()); }
|
|
}
|
|
let pending = root.join("pending_restore.db");
|
|
std::fs::copy(&picked, &pending).map_err(|e| format!("подготовка: {e}"))?;
|
|
let _ = std::fs::remove_file(&picked);
|
|
Ok("staged".into())
|
|
}
|
|
|
|
// Ask Kotlin to restart the app process (used after import_db to load the restored DB).
|
|
#[tauri::command]
|
|
fn restart_app(app: tauri::AppHandle) -> Result<String, String> {
|
|
let (_db, root) = db_and_root(&app)?; // root = native_root, the dir Kotlin's FileObserver watches
|
|
let _ = std::fs::write(root.join("gps_command.txt"), "restartapp");
|
|
Ok("ok".into())
|
|
}
|
|
|
|
// Read tiles from a SQLite file in batches for import into IndexedDB
|
|
#[tauri::command]
|
|
fn import_tiles(path: String, offset: u32, batch_size: u32) -> Result<String, String> {
|
|
let db = rusqlite::Connection::open(&path).map_err(|e| format!("open: {e}"))?;
|
|
let total: u32 = db.query_row("SELECT count(*) FROM tiles", [], |r| r.get(0))
|
|
.map_err(|e| format!("count: {e}"))?;
|
|
let mut stmt = db.prepare("SELECT key, data FROM tiles LIMIT ?1 OFFSET ?2")
|
|
.map_err(|e| format!("prepare: {e}"))?;
|
|
let mut tiles = Vec::new();
|
|
let rows = stmt.query_map(rusqlite::params![batch_size, offset], |row| {
|
|
let key: String = row.get(0)?;
|
|
let data: Vec<u8> = row.get(1)?;
|
|
Ok((key, data))
|
|
}).map_err(|e| format!("query: {e}"))?;
|
|
for row in rows {
|
|
let (key, data) = row.map_err(|e| format!("row: {e}"))?;
|
|
use base64::Engine;
|
|
let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
|
|
tiles.push(format!("[\"{}\",\"{}\"]", key, b64));
|
|
}
|
|
let has_more = offset + batch_size < total;
|
|
Ok(format!("{{\"tiles\":[{}],\"total\":{},\"has_more\":{}}}", tiles.join(","), total, has_more))
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn import_seed_products(app: tauri::AppHandle) -> Result<String, String> {
|
|
// Find seed DB — try multiple locations
|
|
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
let seed_in_data = data_dir.join("products_seed.db");
|
|
let seed_in_files = data_dir.join("files").join("products_seed.db");
|
|
|
|
// Always try to refresh seed DB from bundled assets (resource_dir).
|
|
// This ensures app updates deliver new seed data even if an old copy exists.
|
|
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
|
let seed_resource = resource_dir.join("data").join("products_seed.db");
|
|
if seed_resource.exists() {
|
|
// Overwrite the local copy with the bundled one (may be newer)
|
|
let _ = std::fs::copy(&seed_resource, &seed_in_data);
|
|
}
|
|
|
|
let seed_path = if seed_in_data.exists() {
|
|
seed_in_data
|
|
} else if seed_in_files.exists() {
|
|
seed_in_files
|
|
} else {
|
|
return Err(format!("Seed DB not found. Checked: {:?}, {:?}", seed_in_data, seed_in_files));
|
|
};
|
|
|
|
let seed_db = rusqlite::Connection::open(&seed_path).map_err(|e| e.to_string())?;
|
|
|
|
let seed_version: i64 = seed_db.query_row("SELECT version FROM seed_meta LIMIT 1", [], |r| r.get(0)).map_err(|e| e.to_string())?;
|
|
|
|
// tauri-plugin-sql may store DB in app_config_dir (desktop: ~/.config/...)
|
|
// or app_data_dir (desktop: ~/.local/share/...). Check both.
|
|
let config_dir = app.path().app_config_dir().unwrap_or_default();
|
|
// tauri-plugin-sql stores DB in different locations depending on platform.
|
|
// Android: often in app root (/data/user/0/pkg/baldav.db), not in files/ subdir.
|
|
let main_path = {
|
|
let p_config = config_dir.join("baldav.db");
|
|
let p_data = data_dir.join("baldav.db");
|
|
let p_parent = data_dir.join("..").join("baldav.db");
|
|
// Android app root: THREE levels up from app_data_dir (files/dev.baldav.app/ → files/ → root/)
|
|
let p_root = data_dir.join("..").join("..").join("..").join("baldav.db");
|
|
if p_config.exists() { p_config }
|
|
else if p_data.exists() { p_data }
|
|
else if p_parent.exists() { p_parent }
|
|
else if p_root.exists() { p_root }
|
|
else { p_data }
|
|
};
|
|
let main_db = rusqlite::Connection::open(&main_path).map_err(|e| e.to_string())?;
|
|
|
|
// Ensure all columns exist in main DB
|
|
let extra_cols = [
|
|
"composition TEXT", "name_lower TEXT", "category TEXT",
|
|
"sodium_mg REAL", "potassium_mg REAL", "calcium_mg REAL",
|
|
"magnesium_mg REAL", "phosphorus_mg REAL", "iron_mg REAL",
|
|
"vit_a_mcg REAL", "vit_b1_mg REAL", "vit_b2_mg REAL",
|
|
"vit_b3_mg REAL", "vit_c_mg REAL", "vit_e_mg REAL", "carotene_mcg REAL",
|
|
"nutrients_guessed INTEGER DEFAULT 0",
|
|
"user_edited INTEGER DEFAULT 0",
|
|
];
|
|
for col in &extra_cols {
|
|
let _ = main_db.execute(&format!("ALTER TABLE products ADD COLUMN {}", col), []);
|
|
}
|
|
|
|
// Dedup ALWAYS runs (even if seed version matches) — fixes legacy duplicates.
|
|
|
|
let _ = main_db.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_products_name_lower ON products(name_lower)", []);
|
|
|
|
main_db.execute(
|
|
"UPDATE food_log SET product_id = (
|
|
SELECT MIN(p2.id) FROM products p2
|
|
WHERE p2.name_lower = (SELECT p3.name_lower FROM products p3 WHERE p3.id = food_log.product_id)
|
|
AND p2.source = (SELECT p3.source FROM products p3 WHERE p3.id = food_log.product_id)
|
|
AND COALESCE(p2.store_id,'') = COALESCE((SELECT p3.store_id FROM products p3 WHERE p3.id = food_log.product_id),'')
|
|
) WHERE product_id NOT IN (
|
|
SELECT MIN(id) FROM products WHERE name_lower IS NOT NULL
|
|
GROUP BY name_lower, source, COALESCE(store_id,'')
|
|
)", []).map_err(|e| format!("dedup food_log: {}", e))?;
|
|
|
|
main_db.execute(
|
|
"UPDATE recipe_ingredients SET product_id = (
|
|
SELECT MIN(p2.id) FROM products p2
|
|
WHERE p2.name_lower = (SELECT p3.name_lower FROM products p3 WHERE p3.id = recipe_ingredients.product_id)
|
|
AND p2.source = (SELECT p3.source FROM products p3 WHERE p3.id = recipe_ingredients.product_id)
|
|
AND COALESCE(p2.store_id,'') = COALESCE((SELECT p3.store_id FROM products p3 WHERE p3.id = recipe_ingredients.product_id),'')
|
|
) WHERE product_id NOT IN (
|
|
SELECT MIN(id) FROM products WHERE name_lower IS NOT NULL
|
|
GROUP BY name_lower, source, COALESCE(store_id,'')
|
|
)", []).map_err(|e| format!("dedup recipe_ingredients: {}", e))?;
|
|
|
|
// Reassign product_units (also has FK to products)
|
|
main_db.execute(
|
|
"DELETE FROM product_units WHERE product_id NOT IN (
|
|
SELECT MIN(id) FROM products WHERE name_lower IS NOT NULL
|
|
GROUP BY name_lower, source, COALESCE(store_id,'')
|
|
)", []).map_err(|e| format!("dedup product_units: {}", e))?;
|
|
|
|
main_db.execute(
|
|
"DELETE FROM products WHERE name_lower IS NOT NULL
|
|
AND id NOT IN (
|
|
SELECT MIN(id) FROM products WHERE name_lower IS NOT NULL
|
|
GROUP BY name_lower, source, COALESCE(store_id,'')
|
|
)", []).map_err(|e| format!("dedup delete: {}", e))?;
|
|
|
|
let _ = main_db.execute(
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_products_dedup
|
|
ON products(name_lower, source, COALESCE(store_id, ''))
|
|
WHERE name_lower IS NOT NULL", []);
|
|
|
|
// Check seed version — skip import if already up to date
|
|
let current_version: i64 = main_db.query_row(
|
|
"SELECT COALESCE((SELECT value FROM profile WHERE key='seed_version'), '0')",
|
|
[], |r| { let s: String = r.get(0)?; Ok(s.parse::<i64>().unwrap_or(0)) }
|
|
).unwrap_or(0);
|
|
|
|
if current_version >= seed_version {
|
|
return Ok(format!("up-to-date:v{}", current_version));
|
|
}
|
|
|
|
// ATTACH seed DB for import
|
|
let seed_str = seed_path.to_str().unwrap_or_default().to_string();
|
|
main_db.execute("ATTACH DATABASE ?1 AS seed", rusqlite::params![seed_str])
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let total: usize = seed_db.query_row("SELECT COUNT(*) FROM products", [], |r| r.get(0))
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
main_db.execute("BEGIN", []).map_err(|e| e.to_string())?;
|
|
|
|
// Ensure all nutrient columns exist in main DB (seed may have columns main doesn't)
|
|
let seed_cols: Vec<String> = main_db.prepare("PRAGMA seed.table_info(products)")
|
|
.and_then(|mut s| s.query_map([], |r| r.get::<_, String>(1)).and_then(|rows| rows.collect()))
|
|
.unwrap_or_default();
|
|
let main_cols: Vec<String> = main_db.prepare("PRAGMA main.table_info(products)")
|
|
.and_then(|mut s| s.query_map([], |r| r.get::<_, String>(1)).and_then(|rows| rows.collect()))
|
|
.unwrap_or_default();
|
|
for col in &seed_cols {
|
|
if !main_cols.contains(col) {
|
|
let _ = main_db.execute(&format!("ALTER TABLE main.products ADD COLUMN {} REAL", col), []);
|
|
}
|
|
}
|
|
|
|
// Bulk INSERT — copy ALL columns that exist in both seed and main
|
|
let shared: Vec<&String> = seed_cols.iter().filter(|c| {
|
|
let mc: Vec<String> = main_db.prepare("PRAGMA main.table_info(products)")
|
|
.and_then(|mut s| s.query_map([], |r| r.get::<_, String>(1)).and_then(|rows| rows.collect()))
|
|
.unwrap_or_default();
|
|
mc.contains(c)
|
|
}).collect();
|
|
let cols_str = shared.iter().map(|c| c.as_str()).collect::<Vec<_>>().join(", ");
|
|
let insert_sql = format!(
|
|
"INSERT OR IGNORE INTO main.products ({cols}) SELECT {cols} FROM seed.products",
|
|
cols = cols_str
|
|
);
|
|
let inserted = main_db.execute(&insert_sql, []).map_err(|e| e.to_string())?;
|
|
|
|
// Seed index for fast UPDATE lookups
|
|
let _ = main_db.execute_batch(
|
|
"CREATE INDEX IF NOT EXISTS idx_seed_name_lower ON seed.products(name_lower);"
|
|
);
|
|
|
|
// Update KBJU + micronutrients for existing store products (seed already attached above)
|
|
// Sync nutrients_guessed flag
|
|
let _ = main_db.execute_batch(
|
|
"UPDATE main.products SET nutrients_guessed = 1
|
|
WHERE (nutrients_guessed IS NULL OR nutrients_guessed = 0)
|
|
AND name_lower IN (SELECT s.name_lower FROM seed.products s WHERE s.nutrients_guessed = 1)"
|
|
);
|
|
// Update KBJU + micronutrients for store products that user hasn't manually edited.
|
|
// This ensures seed corrections (e.g. bread 268→243) flow through to app DB.
|
|
let _ = main_db.execute_batch(
|
|
"UPDATE main.products SET
|
|
kcal_per_100g = (SELECT s.kcal_per_100g FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1),
|
|
protein_per_100g = (SELECT s.protein_per_100g FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1),
|
|
fat_per_100g = (SELECT s.fat_per_100g FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1),
|
|
carbs_per_100g = (SELECT s.carbs_per_100g FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1),
|
|
fiber_per_100g = (SELECT s.fiber_per_100g FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1),
|
|
nutrients_guessed= COALESCE((SELECT s.nutrients_guessed FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1), nutrients_guessed),
|
|
sodium_mg = COALESCE((SELECT s.sodium_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1), sodium_mg),
|
|
potassium_mg = COALESCE((SELECT s.potassium_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1), potassium_mg),
|
|
calcium_mg = COALESCE((SELECT s.calcium_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1), calcium_mg),
|
|
magnesium_mg = COALESCE((SELECT s.magnesium_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1), magnesium_mg),
|
|
phosphorus_mg = COALESCE((SELECT s.phosphorus_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1), phosphorus_mg),
|
|
iron_mg = COALESCE((SELECT s.iron_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1), iron_mg),
|
|
vit_a_mcg = COALESCE((SELECT s.vit_a_mcg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1), vit_a_mcg),
|
|
vit_b1_mg = COALESCE((SELECT s.vit_b1_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1), vit_b1_mg),
|
|
vit_b2_mg = COALESCE((SELECT s.vit_b2_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1), vit_b2_mg),
|
|
vit_b3_mg = COALESCE((SELECT s.vit_b3_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1), vit_b3_mg),
|
|
vit_c_mg = COALESCE((SELECT s.vit_c_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1), vit_c_mg),
|
|
vit_e_mg = COALESCE((SELECT s.vit_e_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1), vit_e_mg),
|
|
carotene_mcg = COALESCE((SELECT s.carotene_mcg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1), carotene_mcg)
|
|
WHERE (main.products.user_edited IS NULL OR main.products.user_edited = 0)
|
|
AND main.products.source IN ('magnit', '5ka', 'kdv', 'ion')
|
|
AND main.products.name_lower IN (SELECT s2.name_lower FROM seed.products s2)"
|
|
);
|
|
|
|
// Update seed version AFTER all inserts+updates succeed
|
|
main_db.execute(
|
|
"INSERT OR REPLACE INTO profile (key, value) VALUES ('seed_version', ?1)",
|
|
[seed_version.to_string()],
|
|
).map_err(|e| e.to_string())?;
|
|
|
|
main_db.execute("COMMIT", []).map_err(|e| e.to_string())?;
|
|
|
|
let _ = main_db.execute("DETACH DATABASE seed", []);
|
|
|
|
Ok(format!("imported:{}:{}", inserted, total))
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SafeInsets {
|
|
top: i32,
|
|
bottom: i32,
|
|
left: i32,
|
|
right: i32,
|
|
}
|
|
|
|
#[cfg(target_os = "android")]
|
|
#[tauri::command]
|
|
fn get_safe_insets(app: tauri::AppHandle) -> SafeInsets {
|
|
let data_dir = app.path().app_data_dir().unwrap_or_default();
|
|
let insets_file = data_dir.join("safe_insets.json");
|
|
for _ in 0..10 {
|
|
if let Ok(content) = std::fs::read_to_string(&insets_file) {
|
|
if let Ok(insets) = serde_json::from_str::<serde_json::Value>(&content) {
|
|
return SafeInsets {
|
|
top: insets["top"].as_i64().unwrap_or(0) as i32,
|
|
bottom: insets["bottom"].as_i64().unwrap_or(0) as i32,
|
|
left: insets["left"].as_i64().unwrap_or(0) as i32,
|
|
right: insets["right"].as_i64().unwrap_or(0) as i32,
|
|
};
|
|
}
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
}
|
|
SafeInsets { top: 0, bottom: 0, left: 0, right: 0 }
|
|
}
|
|
|
|
#[cfg(not(target_os = "android"))]
|
|
#[tauri::command]
|
|
fn get_safe_insets() -> SafeInsets {
|
|
SafeInsets { top: 0, bottom: 0, left: 0, right: 0 }
|
|
}
|
|
|
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
pub fn run() {
|
|
let migrations: Vec<Migration> = vec![
|
|
Migration {
|
|
version: 1,
|
|
description: "full schema v1",
|
|
sql: r#"
|
|
PRAGMA foreign_keys = ON;
|
|
|
|
CREATE TABLE IF NOT EXISTS profile (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS products (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
brand TEXT,
|
|
category TEXT,
|
|
weight_g REAL,
|
|
kcal_per_100g REAL,
|
|
protein_per_100g REAL,
|
|
fat_per_100g REAL,
|
|
carbs_per_100g REAL,
|
|
fiber_per_100g REAL,
|
|
vit_a_mcg REAL,
|
|
vit_c_mg REAL,
|
|
vit_d_mcg REAL,
|
|
vit_e_mg REAL,
|
|
vit_k_mcg REAL,
|
|
vit_b1_mg REAL,
|
|
vit_b2_mg REAL,
|
|
vit_b3_mg REAL,
|
|
vit_b6_mg REAL,
|
|
vit_b12_mcg REAL,
|
|
folate_mcg REAL,
|
|
calcium_mg REAL,
|
|
iron_mg REAL,
|
|
magnesium_mg REAL,
|
|
zinc_mg REAL,
|
|
copper_mg REAL,
|
|
selenium_mcg REAL,
|
|
potassium_mg REAL,
|
|
sodium_mg REAL,
|
|
source TEXT,
|
|
store_id TEXT,
|
|
image_path TEXT,
|
|
created_at TEXT DEFAULT (datetime('now','localtime'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS supplements (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
daily_dose_g REAL,
|
|
vit_a_mcg REAL,
|
|
vit_c_mg REAL,
|
|
vit_d_mcg REAL,
|
|
vit_e_mg REAL,
|
|
vit_k_mcg REAL,
|
|
vit_b1_mg REAL,
|
|
vit_b2_mg REAL,
|
|
vit_b3_mg REAL,
|
|
vit_b6_mg REAL,
|
|
vit_b12_mcg REAL,
|
|
folate_mcg REAL,
|
|
calcium_mg REAL,
|
|
iron_mg REAL,
|
|
magnesium_mg REAL,
|
|
zinc_mg REAL,
|
|
copper_mg REAL,
|
|
selenium_mcg REAL,
|
|
potassium_mg REAL,
|
|
sodium_mg REAL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS recipes (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
cooking_method TEXT,
|
|
cooking_temp_c INTEGER,
|
|
servings INTEGER DEFAULT 1,
|
|
notes TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS recipe_ingredients (
|
|
id INTEGER PRIMARY KEY,
|
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
product_id INTEGER NOT NULL REFERENCES products(id),
|
|
amount_g REAL NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS weight_log (
|
|
id INTEGER PRIMARY KEY,
|
|
date TEXT NOT NULL,
|
|
time TEXT,
|
|
weight_kg REAL NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sleep_log (
|
|
id INTEGER PRIMARY KEY,
|
|
date TEXT NOT NULL,
|
|
bed_time TEXT,
|
|
asleep_time TEXT,
|
|
wake_time TEXT,
|
|
nap_minutes INTEGER DEFAULT 0,
|
|
notes TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS food_log (
|
|
id INTEGER PRIMARY KEY,
|
|
date TEXT NOT NULL,
|
|
time TEXT,
|
|
product_id INTEGER REFERENCES products(id),
|
|
recipe_id INTEGER REFERENCES recipes(id),
|
|
amount_g REAL NOT NULL,
|
|
kcal REAL,
|
|
protein_g REAL,
|
|
fat_g REAL,
|
|
carbs_g REAL,
|
|
fiber_g REAL,
|
|
note TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS supplement_log (
|
|
id INTEGER PRIMARY KEY,
|
|
date TEXT NOT NULL,
|
|
time TEXT,
|
|
supplement_id INTEGER NOT NULL REFERENCES supplements(id),
|
|
dose_g REAL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS gps_sessions (
|
|
id INTEGER PRIMARY KEY,
|
|
date TEXT NOT NULL,
|
|
start_time TEXT,
|
|
end_time TEXT,
|
|
activity_type TEXT,
|
|
total_distance_m REAL,
|
|
total_kcal REAL,
|
|
avg_speed_kmh REAL,
|
|
max_speed_kmh REAL,
|
|
elevation_gain_m REAL,
|
|
elevation_loss_m REAL,
|
|
source TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS gps_points (
|
|
id INTEGER PRIMARY KEY,
|
|
session_id INTEGER NOT NULL REFERENCES gps_sessions(id) ON DELETE CASCADE,
|
|
ts TEXT NOT NULL,
|
|
lat REAL NOT NULL,
|
|
lon REAL NOT NULL,
|
|
altitude_gps_m REAL,
|
|
altitude_srtm_m REAL,
|
|
speed_ms REAL,
|
|
heart_rate INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS activity_log (
|
|
id INTEGER PRIMARY KEY,
|
|
date TEXT NOT NULL,
|
|
time TEXT,
|
|
type TEXT,
|
|
desc TEXT,
|
|
minutes INTEGER,
|
|
kcal REAL,
|
|
gps_session_id INTEGER REFERENCES gps_sessions(id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_food_log_date ON food_log(date);
|
|
CREATE INDEX IF NOT EXISTS idx_activity_log_date ON activity_log(date);
|
|
CREATE INDEX IF NOT EXISTS idx_weight_log_date ON weight_log(date);
|
|
CREATE INDEX IF NOT EXISTS idx_sleep_log_date ON sleep_log(date);
|
|
CREATE INDEX IF NOT EXISTS idx_gps_points_sess ON gps_points(session_id);
|
|
CREATE INDEX IF NOT EXISTS idx_products_name ON products(name COLLATE NOCASE);
|
|
"#,
|
|
kind: MigrationKind::Up,
|
|
},
|
|
Migration {
|
|
version: 2,
|
|
description: "add composition column to products",
|
|
sql: "ALTER TABLE products ADD COLUMN composition TEXT;",
|
|
kind: MigrationKind::Up,
|
|
},
|
|
Migration {
|
|
version: 3,
|
|
description: "reserved",
|
|
sql: "SELECT 1;",
|
|
kind: MigrationKind::Up,
|
|
},
|
|
];
|
|
|
|
tauri::Builder::default()
|
|
.plugin(
|
|
tauri_plugin_sql::Builder::default()
|
|
.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, 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_ride_graph, get_elev_correction, get_app_info, gps_service_command, set_hydration_config, set_live_config, send_sms, pick_contact, get_contact_result, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_get_baro, ble::ble_baro_disconnect, ble::ble_save_baro_device, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr, ble::ble_read_hr_log, ble::ble_read_strap_uptime, ble::ble_reset_strap_uptime, ble::ble_request_enable, read_state_file])
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running tauri application");
|
|
}
|