From b4046841be7f03f076ad1da128594cb8a4a43419 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Wed, 9 Sep 2026 01:11:20 +0300 Subject: [PATCH] BRouter Stage 2 (primitive): download .rd5 segments inside the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kotlin owns the download so tiles land in the exact dir the engine reads (files/brouter/segments4) — no Rust path guessing. processBrouterCommand now dispatches: dltile: streams brouter.de/brouter/segments4/.rd5 to a .tmp then renames, writing progress/final state to brouter_dl_.json; listtiles writes brouter_tiles.json. Tile names are sanitized ([A-Za-z0-9_]). Rust relays: brouter_download_tile / brouter_download_status / brouter_list_tiles. JS: BRouter.downloadTile/downloadStatus/listTiles/downloadTileAndWait. Compiles into the apk. Not yet exercised on device (phone offline) — the tile selection UI and the end-to-end download->offline-route test are the remaining Stage 2 work. IMPLEMENTATION_PLAN updated. --- IMPLEMENTATION_PLAN.md | 9 ++- frontend/js/router.js | 31 ++++++++++ .../main/java/dev/baldav/app/MainActivity.kt | 58 ++++++++++++++++++- src-tauri/src/lib.rs | 50 +++++++++++++++- 4 files changed, 143 insertions(+), 5 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 9cf1268..b0857e4 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -40,10 +40,15 @@ app data dir; профиль trekking (идея 2) и shortest (идея 1); л с `brouter.de/brouter/segments4/.rd5`, прогресс, размер, дата. Хранить в app data dir (путь, который читает Stage 1). - Управление: список скачанного, удалить/обновить. -**Success:** дома по проводу тайлы региона (E35_N45/E40_N45 — уточнить) скачиваются и лежат так, +**Success:** дома по проводу тайлы региона (E35_N45/E40_N45 — подтверждены) скачиваются и лежат так, что Stage 1 их читает; в поле сеть не нужна. **Tests:** скачать тайл, проложить маршрут офлайн (сеть выключить) — работает. -**Status:** not started +**Status:** ПРИМИТИВ ГОТОВ (компилируется; тест на железе завтра). Kotlin качает тайл в + files/brouter/segments4 (путь детерминирован — качает сам Kotlin, без Rust-догадок): команды + `dltile:`/`listtiles` в processBrouterCommand, прогресс/статус в brouter_dl_.json. + Rust-реле: `brouter_download_tile`/`brouter_download_status`/`brouter_list_tiles`. JS: + `BRouter.downloadTile/downloadStatus/listTiles/downloadTileAndWait`. ОСТАЛОСЬ: UI-экран выбора + тайлов по региону + сквозной тест на устройстве (скачать → офлайн-маршрут). ## Stage 3 — Идея 2: оффлайн-планирование по опорным точкам **Goal:** тап опорных точек → дорожный маршрут trekking → существующий предпросмотр/профиль. diff --git a/frontend/js/router.js b/frontend/js/router.js index b18f935..5322e18 100644 --- a/frontend/js/router.js +++ b/frontend/js/router.js @@ -17,4 +17,35 @@ window.BRouter = { if (!res.ok) throw new Error("brouter: " + (res.error || "unknown error")); return res.gpx; }, + + // --- Segment (.rd5) delivery (Stage 2) — Kotlin downloads into files/brouter/segments4 --- + + // Kick off a tile download (e.g. "E35_N45"); returns immediately. Poll downloadStatus. + async downloadTile(tile) { + return window.__TAURI__.core.invoke("brouter_download_tile", { tile }); + }, + + // { state: "downloading"|"done"|"error", bytes, total?, error? } — {} if not started yet. + async downloadStatus(tile) { + let raw = await window.__TAURI__.core.invoke("brouter_download_status", { tile }); + try { return JSON.parse(raw); } catch (e) { return {}; } + }, + + // Downloaded tiles present on device: [{ name, bytes }]. + async listTiles() { + let raw = await window.__TAURI__.core.invoke("brouter_list_tiles"); + try { return JSON.parse(raw); } catch (e) { return []; } + }, + + // Convenience: download a tile and resolve when done (or reject on error). onProgress(bytes,total). + async downloadTileAndWait(tile, onProgress) { + await this.downloadTile(tile); + for (;;) { + await new Promise(r => setTimeout(r, 700)); + let s = await this.downloadStatus(tile); + if (s.state === "downloading" && onProgress) onProgress(s.bytes || 0, s.total || 0); + if (s.state === "done") return s; + if (s.state === "error") throw new Error("brouter tile: " + (s.error || "download failed")); + } + }, }; diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt index 0469e4f..6ab8890 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt @@ -196,8 +196,62 @@ class MainActivity : TauriActivity() { val cmd = cmdFile.readText().trim() if (cmd.isEmpty()) return cmdFile.writeText("") - if (!cmd.startsWith("route:")) return - val id = cmd.substringAfter("route:") + when { + cmd.startsWith("dltile:") -> downloadTile(appDataDir, cmd.substringAfter("dltile:").trim()) + cmd == "listtiles" -> listTiles(appDataDir) + cmd.startsWith("route:") -> routeCommand(appDataDir, cmd.substringAfter("route:")) + } + } + + // Download one .rd5 segment tile from brouter.de into files/brouter/segments4 (the dir + // the engine reads). Progress/final state go to brouter_dl_.json for JS to poll. + private fun downloadTile(appDataDir: File, tileRaw: String) { + // Sanitize: tile names are like E35_N45 / W5_S10 — allow only [A-Za-z0-9_]. + val tile = tileRaw.filter { it.isLetterOrDigit() || it == '_' } + if (tile.isEmpty() || tile != tileRaw) return + Thread { + val statusFile = File(appDataDir, "brouter_dl_$tile.json") + val dir = File(filesDir, "brouter/segments4"); dir.mkdirs() + val dest = File(dir, "$tile.rd5"); val tmp = File(dir, "$tile.rd5.tmp") + try { + statusFile.writeText("""{"state":"downloading","bytes":0}""") + val conn = URL("https://brouter.de/brouter/segments4/$tile.rd5").openConnection() as java.net.HttpURLConnection + conn.connectTimeout = 15000; conn.readTimeout = 30000 + val total = conn.contentLength + conn.inputStream.use { input -> tmp.outputStream().use { out -> + val buf = ByteArray(65536); var n = input.read(buf); var got = 0L; var last = 0L + while (n >= 0) { + out.write(buf, 0, n); got += n + val now = System.currentTimeMillis() + if (now - last > 500) { statusFile.writeText("""{"state":"downloading","bytes":$got,"total":$total}"""); last = now } + n = input.read(buf) + } + }} + conn.disconnect() + if (dest.exists()) dest.delete() + if (!tmp.renameTo(dest)) throw RuntimeException("rename failed") + statusFile.writeText("""{"state":"done","bytes":${dest.length()}}""") + } catch (e: Exception) { + Log.e("BalDav", "BRouter tile download failed: $tile", e) + tmp.delete() + statusFile.writeText("""{"state":"error","error":${org.json.JSONObject.quote(e.message ?: e.toString())}}""") + } + }.start() + } + + // List downloaded .rd5 tiles → brouter_tiles.json ([{name, bytes}]). + private fun listTiles(appDataDir: File) { + val dir = File(filesDir, "brouter/segments4") + val arr = org.json.JSONArray() + for (f in (dir.listFiles() ?: arrayOf())) { + if (f.name.endsWith(".rd5")) { + val o = org.json.JSONObject(); o.put("name", f.name.removeSuffix(".rd5")); o.put("bytes", f.length()); arr.put(o) + } + } + try { File(appDataDir, "brouter_tiles.json").writeText(arr.toString()) } catch (e: Exception) { Log.e("BalDav", "listTiles write failed", e) } + } + + private fun routeCommand(appDataDir: File, id: String) { Thread { val resultFile = File(appDataDir, "brouter_result_$id.json") val out = org.json.JSONObject() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c7a6db0..3120ae7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -435,6 +435,54 @@ fn brouter_route(app: tauri::AppHandle, lonlats: String, profile: String) -> Res 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 { + brouter_write_cmd(&app, &format!("dltile:{}", tile))?; + Ok("started".into()) +} + +// Read the download status Kotlin writes (brouter_dl_.json): downloading/done/error. +#[tauri::command] +fn brouter_download_status(app: tauri::AppHandle, tile: String) -> Result { + 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 { + 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 { @@ -946,7 +994,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_app_info, gps_service_command, gps_service_read_points, brouter_route, save_file, 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_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, 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"); }