Full DB backup: export all data to a file and restore it (swap DB on next launch)

This commit is contained in:
4aiman 2026-09-21 13:03:32 +03:00
commit 682bee6e9a
6 changed files with 205 additions and 1 deletions

View file

@ -182,6 +182,9 @@
| `ble_save_device` | Сохранение привязанного устройства |
| `import_tiles` | Чтение тайлового SQLite батчами (base64) |
| `download_file` | Скачивание бинарного файла по URL (без лимита размера) |
| `export_db` | Бэкап всей БД: `VACUUM INTO` чистой копии в app-root -> команда `exportdb:` -> Kotlin SAF-сохранение (`saveDbLauncher`, стрим файла) |
| `import_db` | Восстановление: валидирует `picked_import.db` (`quick_check` + таблицы BalDav), кладёт в `pending_restore.db`; Kotlin `onCreate` свопит на `baldav.db` до открытия БД |
| `restart_app` | Команда `restartapp` -> Kotlin перезапускает процесс (применить восстановление) |
| `rebuild_roads` | Перестройка road_points из всех треков (2м дедуп; пеший point (`walk=1`) → atype 'walk') |
| `get_road_points` | Чтение road_points батчами (обход лимита SQL plugin) |
| `get_ride_graph` | Граф проезженных дорог для роутинга по своим трекам: узлы на сетке ~20м (исключая `walk=1`), рёбра между последовательными точками сессии (вес = между узлами). `{nodes,edges}` |

View file

@ -43,6 +43,7 @@
- **Роутинг сначала по проезженному** - публичные данные BRouter часто не совпадают с землёй (роутит по несуществующим дорогам). Поэтому планировщик для каждой дорожной ноги сначала пробует проложить путь **по дорогам, где ты реально ездил** (граф из своих треков: узлы на сетке ~20м, исключая пешие куски), и только если проезженного рядом нет или путь выходит сильно кривой - обращается к BRouter. Ридден-нога рисуется тонкой линией (без высот), BRouter-нога - оковкой с раскраской. Граф строится из `gps_points` командой `get_ride_graph`, кэшируется.
- **Объединение поездок** - долгий тап по треку в истории включает выделение, тап отмечает ещё; когда выбрано 2+, кнопка **«Объединить»** склеивает их в одну тренировку (точки всех сессий переносятся в самую раннюю по времени, порядок по таймстампу, стата пересчитывается). Полезно, когда одна тренировка записалась несколькими кусками (например, отдельный заезд оказался в своей сессии - после склейки он попадёт в «Разбор повторов»). Разрыв между треками больше 5 минут - предупреждает, но не мешает. **Разные велосипеды склеить нельзя** - показывает предупреждение (передача/вес отличаются, мощность и каденс посчитались бы неверно); если это один велосипед, задай его трекам одинаково и склеивай. Обратимо: у каждой точки хранится исходная сессия (`gps_points.orig_session_id`).
- **Разрезание трека** - в детали трека кнопка **«Разрезать трек»** включает режим: тап по точке на карте -> в её окошке кнопка **«✂ Разрезать здесь»** -> подтверждение -> второй кусок становится отдельной тренировкой (с тем же велосипедом; порядок точек по времени). Зачем: если часть трека проехана на другом велосипеде или другой передаче - разрежь в точке смены, назначь каждому куску свой велосипед (в детали: «Велосипед» + «Пересчитать»), и мощность/каденс посчитаются по правильной передаче на каждом куске.
- **Резервная копия всех данных** - в Настройках **Экспорт** сливает всю базу (`baldav.db`) в файл через SAF (`export_db` -> `VACUUM INTO` чистой сжатой копии, ~на треть меньше), **Импорт** выбирает файл, проверяет его (`PRAGMA quick_check` + наличие таблиц BalDav) и восстанавливает с ЗАМЕНОЙ: файл кладётся в `pending_restore.db`, приложение перезапускается, и Kotlin в `onCreate` (до открытия БД) свопит его на `baldav.db`. Секретов в базе нет. Зачем: переносить наработки между сборками (**debug <-> release** - разные подписи, переустановка стирает данные) без сторонних бэкаперов. Внутриприложенческой авто-установки APK намеренно нет - только экспорт/импорт данных.
- **Разбор повторов (интервалы)** - катаешь один подъём несколько раз за поездку (один трек, без стоп/старт на каждом заезде) - деталь трека сама находит повторяемый участок по самоподобию (сетка ~10м, повторно посещённые ячейки, самый длинный эталонный проход) и в сворачиваемой секции «Разбор повторов» показывает таблицу заездов (номер, время, скорость, каденс, мощность) и главное число - **последний / первый**, с вердиктом: >=100% зелёный (можно завтра), 90-99% жёлтый (тяжёлая норма), <90% красный (два дня спокойно). Каждая точка проецируется на опорную линию (снос вбок в пределах ~30м, например при возне с седлом, не режет покрытие); время заезда - только секунды в движении (остановки вырезаются, скорость нормируется на длину участка). Найденный участок подсвечивается на карте. Только вело. `GPS.detectRepeats`; повторов нет (обычная поездка A-B) - так и пишет.
- **Похвастаться (шэр-картинка)** - в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (9:16) с формой трека и статами (Дистанция/Время/Средняя скорость крупно + набор/ккал/дата), **прозрачную** (PNG с альфой для наложения на своё фото) или **на фото-подложке** из настроек. В модалке предпросмотра галочками выбирается, что класть на картинку (форма трека, дистанция, время, средняя/макс. скорость, набор высоты, калории, дата; выбор запоминается), переключатель прозрачный/фото и кнопки **Поделиться** (системный шэр) и **Сохранить** (SAF). Подложка выбирается в Настройках.
- **Сегменты - сворачиваемо** - в детали трека список сегментов высот (climb/descent, до десятков штук) свёрнут в аккордеон (`<details>`, свёрнут по умолчанию, счётчик в заголовке); тап разворачивает/сворачивает. Кнопки внизу (Похвастаться, Экспорт, Пересчитать, Удалить) остаются доступны без долгого скролла.

View file

@ -0,0 +1,42 @@
# Сессия 2026-09-21 (2) - экспорт/импорт всей БД (резервная копия)
## Контекст
Смена сборки debug<->release стирает данные (разные подписи -> переустановка). Решили:
НЕ делать внутриприложенческую авто-установку APK, а сделать полный бэкап/восстановление БД.
Записано в память: `project_no_self_install`. Секретов в базе нет.
## Сделано
- Rust (`lib.rs`):
- `db_and_root(app)` - резолв пути `baldav.db` + app-root (как в rebuild_roads).
- `export_db(filename)` - `VACUUM INTO` чистой копии в `app-root/export_db.tmp` (консистентно
при живом соединении, сжимает ~на треть), команда `exportdb:<filename>` в gps_command.txt.
- `import_db()` - валидирует `picked_import.db` (`PRAGMA quick_check`=='ok' + есть хоть одна
таблица из gps_sessions/products/profile), копирует в `pending_restore.db`, удаляет picked.
- `restart_app()` - команда `restartapp`.
- Все три в generate_handler.
- Kotlin (`MainActivity.kt`):
- `pendingDbFile` + `saveDbLauncher` (CreateDocument octet-stream, СТРИМ файла, не в память -
файл десятки МБ).
- Ветка `exportdb:` -> SAF-сохранение `export_db.tmp`.
- Ветка `restartapp` -> relaunch (getLaunchIntentForPackage + FLAG_ACTIVITY_NEW_TASK) +
`Runtime.exit(0)`.
- `onCreate` (сразу после appDataDir, ДО открытия БД): если есть `pending_restore.db` -
скопировать на `baldav.db`, удалить journal/wal/shm и pending. Тот же паттерн, что копия сида.
- JS (`main.js`): в Настройках блок «Резервная копия всех данных» с кнопками Экспорт/Импорт.
Экспорт -> `export_db`. Импорт -> confirm (заменит всё) -> `pickLocalFile` -> `import_db` ->
confirm перезапуска -> `restart_app`. Импорт только на Android.
## Проверка
- JS синтаксис ок. Автономный тест `VACUUM INTO` на десктоп-БД: 53МБ -> 36МБ, quick_check ok,
сессии на месте (валидная сжатая копия). Ядро экспорта подтверждено.
- SAF-сохранение, своп при onCreate и рестарт - только на устройстве (Kotlin), десктоп не
воспроизводит. Проверка на телефоне: экспорт -> файл-бэкап валиден; импорт другого бэкапа ->
данные заменились после рестарта. (Сборка bump до 0.1.465.)
## Замечания
- Сжатие gzip не делал: VACUUM INTO уже компактит; flate2 в зависимостях нет. Добавлю, если
нужно меньше.
- Схема-совместимость: миграции в db.js идемпотентны (ALTER ... IF NOT EXISTS), старый бэкап
подтянется вверх, новый в старое приложение - лишние колонки безвредны.
## На роутере всё ещё 0.1.464 (build 464). Новый бэкап-билд (465) на роутер не выкатывал.

View file

@ -6861,6 +6861,16 @@ async function renderSettings(container) {
<button class="btn-secondary" id="s-share-bg-clear" style="padding:6px 12px;font-size:13px;color:#cf6679">Сброс</button>
</div>
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Резервная копия всех данных</div>
<div class="settings-item-desc">Экспорт всей базы в файл и восстановление из него - чтобы переносить наработки между версиями (debug/release) без потерь. Импорт заменяет ВСЁ и перезапускает приложение.</div>
</div>
<div style="display:flex;gap:6px">
<button class="btn-secondary" id="s-export-db" style="padding:6px 12px;font-size:13px;">Экспорт</button>
<button class="btn-secondary" id="s-import-db" style="padding:6px 12px;font-size:13px;">Импорт</button>
</div>
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Очистить продукты без магазина</div>
@ -7266,6 +7276,39 @@ async function renderSettings(container) {
let sbgClear = container.querySelector("#s-share-bg-clear");
if (sbgClear) sbgClear.onclick = () => { localStorage.removeItem("baldav_share_bg"); showToast("Подложка сброшена"); };
// Full-DB backup: export the whole database to a file (SAF save), and restore from one.
let expDbBtn = container.querySelector("#s-export-db");
if (expDbBtn) expDbBtn.onclick = async () => {
let prev = expDbBtn.textContent;
expDbBtn.disabled = true; expDbBtn.textContent = "Готовлю...";
try {
let filename = "baldav-backup-" + new Date().toISOString().slice(0, 10) + ".db";
let res = await window.__TAURI__.core.invoke("export_db", { filename });
let size = 0; try { size = JSON.parse(res).size || 0; } catch {}
showToast("Копия готова (" + (size / 1048576).toFixed(1) + " МБ) - выберите, куда сохранить");
} catch (e) { appAlert("Экспорт не удался: " + (e.message || e)); }
expDbBtn.disabled = false; expDbBtn.textContent = prev;
};
let impDbBtn = container.querySelector("#s-import-db");
if (impDbBtn) impDbBtn.onclick = async () => {
if (!/Android/i.test(navigator.userAgent)) { appAlert("Импорт базы доступен только на устройстве."); return; }
if (!(await appConfirm("Импорт ЗАМЕНИТ все текущие данные (треки, питание, профиль, велосипеды) данными из файла. Несохранённое пропадёт.\n\nСделайте экспорт текущей базы, если она нужна. Продолжить?", "Выбрать файл"))) return;
let prev = impDbBtn.textContent;
impDbBtn.disabled = true; impDbBtn.textContent = "Выбор...";
try {
let path = await pickLocalFile("*/*");
if (!path) { showToast("Файл не выбран"); return; }
impDbBtn.textContent = "Проверка...";
await window.__TAURI__.core.invoke("import_db"); // validates picked_import.db + stages it
if (await appConfirm("База проверена и готова. Приложение перезапустится, чтобы применить восстановление. Перезапустить сейчас?", "Перезапустить")) {
await window.__TAURI__.core.invoke("restart_app");
} else {
showToast("Восстановление применится при следующем запуске");
}
} catch (e) { appAlert("Импорт не удался: " + (e.message || e)); }
impDbBtn.disabled = false; impDbBtn.textContent = prev;
};
// 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 () => {

View file

@ -25,6 +25,7 @@ class MainActivity : TauriActivity() {
private var commandObserver: FileObserver? = null
private var pendingSaveContent: String? = null
private var pendingImageBytes: ByteArray? = null
private var pendingDbFile: File? = null
private var backWebView: WebView? = null
// Disable WryActivity's generated back handler: its mWebView.goBack() does NOT
@ -78,6 +79,22 @@ class MainActivity : TauriActivity() {
pendingImageBytes = null
}
// SAF "create document" for the full DB backup — streams the staged file (can be tens of MB)
// straight to the picked location instead of loading it into memory.
private val saveDbLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("application/octet-stream")
) { uri: Uri? ->
val src = pendingDbFile
if (uri != null && src != null && src.exists()) {
try {
contentResolver.openOutputStream(uri)?.use { out -> src.inputStream().use { it.copyTo(out, 1 shl 20) } }
Log.d("BalDav", "DB exported to $uri (${src.length()} bytes)")
src.delete()
} catch (e: Exception) { Log.e("BalDav", "Failed to export DB", e) }
}
pendingDbFile = null
}
// Share a PNG via the system chooser (write to cache, FileProvider content:// URI, ACTION_SEND).
private fun shareImage(bytes: ByteArray, filename: String) {
try {
@ -127,6 +144,22 @@ class MainActivity : TauriActivity() {
val appDataDir = filesDir.parentFile ?: filesDir
Log.d("BalDav", "App data dir: ${appDataDir.absolutePath}")
// Restore-on-launch: a full-DB import stages pending_restore.db; swap it onto baldav.db NOW,
// before the app opens the DB (tauri-plugin-sql opens it later, when db.js runs). Then clear
// any stale journal so the restored file is the single source of truth.
try {
val pending = File(appDataDir, "pending_restore.db")
if (pending.exists() && pending.length() > 0) {
val dbFile = File(appDataDir, "baldav.db")
pending.inputStream().use { i -> dbFile.outputStream().use { o -> i.copyTo(o, 1 shl 20) } }
File(appDataDir, "baldav.db-journal").delete()
File(appDataDir, "baldav.db-wal").delete()
File(appDataDir, "baldav.db-shm").delete()
pending.delete()
Log.d("BalDav", "Restored DB from pending_restore.db (${dbFile.length()} bytes)")
}
} catch (e: Exception) { Log.e("BalDav", "DB restore failed", e) }
// Create command files if not exist (FileObserver needs them)
val cmdFile = File(appDataDir, "gps_command.txt")
if (!cmdFile.exists()) cmdFile.createNewFile()
@ -515,6 +548,21 @@ class MainActivity : TauriActivity() {
}
}
}
cmd.startsWith("exportdb:") -> {
// exportdb:<filename> — SAF-save the DB copy Rust wrote to export_db.tmp.
val filename = cmd.substringAfter("exportdb:").ifBlank { "baldav-backup.db" }
val f = File(appDataDir, "export_db.tmp")
if (f.exists()) { pendingDbFile = f; saveDbLauncher.launch(filename) }
else Log.e("BalDav", "exportdb: export_db.tmp missing")
}
cmd == "restartapp" -> {
// Relaunch the process so the staged DB (pending_restore.db) is swapped in onCreate.
Log.d("BalDav", "Restarting app (DB restore)")
val intent = packageManager.getLaunchIntentForPackage(packageName)
intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
Runtime.getRuntime().exit(0)
}
}
}

View file

@ -624,6 +624,73 @@ fn read_file_base64(path: String) -> Result<String, String> {
Ok(base64::engine::general_purpose::STANDARD.encode(&bytes))
}
// Resolve the main baldav.db path (same fallbacks as rebuild_roads/get_road_points) and the
// app root (where Kotlin's FileObserver watches). Root = data_dir/../.. on Android.
fn db_and_root(app: &tauri::AppHandle) -> Result<(std::path::PathBuf, std::path::PathBuf), String> {
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
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");
let db = if p1.exists() { p1 } else if p2.exists() { p2.clone() } else { p3 };
let root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()).unwrap_or(data_dir.clone());
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 data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let (db_path, root) = db_and_root(&app)?;
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 cmd = format!("exportdb:{}", filename);
let _ = std::fs::write(data_dir.join("gps_command.txt"), &cmd);
let _ = std::fs::write(root.join("gps_command.txt"), &cmd);
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)?;
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let picked = if root.join("picked_import.db").exists() { root.join("picked_import.db") }
else { data_dir.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 data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let (_db, root) = db_and_root(&app)?;
let _ = std::fs::write(data_dir.join("gps_command.txt"), "restartapp");
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> {
@ -1088,7 +1155,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, 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_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, 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_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_ride_graph, get_app_info, gps_service_command, 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_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}