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

@ -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");
}