BRouter Stage 2 (primitive): download .rd5 segments inside the app

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:<tile> streams brouter.de/brouter/segments4/<tile>.rd5 to a
.tmp then renames, writing progress/final state to brouter_dl_<tile>.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.
This commit is contained in:
4aiman 2026-09-09 01:11:20 +03:00
commit b4046841be
4 changed files with 143 additions and 5 deletions

View file

@ -40,10 +40,15 @@ app data dir; профиль trekking (идея 2) и shortest (идея 1); л
с `brouter.de/brouter/segments4/<tile>.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:<tile>`/`listtiles` в processBrouterCommand, прогресс/статус в brouter_dl_<tile>.json.
Rust-реле: `brouter_download_tile`/`brouter_download_status`/`brouter_list_tiles`. JS:
`BRouter.downloadTile/downloadStatus/listTiles/downloadTileAndWait`. ОСТАЛОСЬ: UI-экран выбора
тайлов по региону + сквозной тест на устройстве (скачать → офлайн-маршрут).
## Stage 3 — Идея 2: оффлайн-планирование по опорным точкам
**Goal:** тап опорных точек → дорожный маршрут trekking → существующий предпросмотр/профиль.

View file

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

View file

@ -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_<tile>.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()

View file

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