BRouter Stage 2: resilient segment downloader (Range resume), verified on device

In-app download initially stalled at 7922 bytes — not the network: the phone
reaches brouter.de fully (confirmed via Termux). BalDav was not in the device's
split tunnel, so its traffic took the wrong path; fixed device-side. Over the
now-working but bursty link the fixed 30s readTimeout tripped, so the downloader
is rewritten to resume: HTTP Range (append to the .tmp, 206=resume / 200=restart),
readTimeout 60s, up to 30 retries.

Verified on device: E40_N45 (14.1 MB) downloaded by the app (done ~55s), a
cross-tile trekking route on the app-downloaded tile (44.8 km / 316 m, 1.66s)
matches the PC-pushed tile, and listTiles returns both. Remaining Stage 2 work:
the tile-selection UI.
This commit is contained in:
4aiman 2026-09-09 11:10:24 +03:00
commit d807e4ec3b
3 changed files with 63 additions and 19 deletions

View file

@ -47,8 +47,13 @@ app data dir; профиль trekking (идея 2) и shortest (идея 1); л
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-экран выбора
тайлов по региону + сквозной тест на устройстве (скачать → офлайн-маршрут).
`BRouter.downloadTile/downloadStatus/listTiles/downloadTileAndWait`.
ПРОВЕРЕНО НА УСТРОЙСТВЕ 2026-09-09: E40 (14.1 МБ) скачан самим BalDav (done за ~55с), маршрут на
скачанном тайле — кросс-тайл trekking 44.8км/316м за 1.66с (метрики = ПК-версии, тайл валиден),
listTiles отдаёт оба. Прежний стоп на 7922 байтах был из-за того, что BalDav не входил в
сплит-туннель устройства (НЕ сеть/код), — исправлено на устройстве. Загрузчик сделан устойчивым к
рывковой связи: HTTP Range-докачка (продолжает с места обрыва), readTimeout 60с, до 30 попыток.
ОСТАЛОСЬ: UI-экран выбора/загрузки тайлов по региону.
## Stage 3 — Идея 2: оффлайн-планирование по опорным точкам
**Goal:** тап опорных точек → дорожный маршрут trekking → существующий предпросмотр/профиль.

View file

@ -48,3 +48,15 @@ trekking Ростов→город 44.8км/набор 316м за 1.2с (кро
617мс. Мост JS→Rust→Kotlin(движок)→GPX работает целиком. Этап 1 = DONE.
Урок: профили и lookups.dat ОБЯЗАНЫ совпадать по версии с сегментами (сейчас v11).
## Дополнение 2026-09-09 (2): Этап 2 примитив проверен на устройстве
Загрузка в приложении сперва вставала ровно на 7922 байтах. Я ошибочно свалил на VPN/сеть —
неверно. Проверка через `ssh nubia` (Termux на телефоне) показала: сеть телефона достаёт brouter.de
целиком (HTTP 200, ranged GET 200КБ@90КБ/с). Причина: BalDav не был включён в сплит-туннель
устройства — трафик приложения шёл не туда. Пользователь добавил BalDav в туннель. После этого
загрузка пошла, но связь рывковая (плато >30с убивало прежний readTimeout). Загрузчик переписан:
HTTP Range-докачка (FileOutputStream append, Range: bytes=have-, 206=resume/200=restart),
readTimeout 60с, до 30 попыток. Итог на устройстве: E40 (14.1МБ) скачан приложением (done ~55с),
маршрут на нём — кросс-тайл trekking 44.8км/316м за 1.66с, listTiles отдаёт оба тайла.
Урок (себе): не диагностировать и не валить на сеть. Увидел недоступность — сообщить пользователю.

View file

@ -213,27 +213,54 @@ class MainActivity : TauriActivity() {
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")
val url = "https://brouter.de/brouter/segments4/$tile.rd5"
// Resilient to a bursty connection: HTTP Range resume (continue from the .tmp size
// instead of restarting) across retries, so a mid-stream stall doesn't lose progress.
var total = -1L
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)
statusFile.writeText("""{"state":"downloading","bytes":${if (tmp.exists()) tmp.length() else 0}}""")
var attempt = 0
while (attempt < 30) {
attempt++
val have = if (tmp.exists()) tmp.length() else 0L
try {
val conn = URL(url).openConnection() as java.net.HttpURLConnection
conn.connectTimeout = 20000; conn.readTimeout = 60000
if (have > 0) conn.setRequestProperty("Range", "bytes=$have-")
conn.connect()
val resuming = conn.responseCode == 206
if (!resuming && have > 0) tmp.delete() // server ignored Range → start over
val startAt = if (resuming) have else 0L
total = if (resuming) startAt + conn.contentLengthLong else conn.contentLengthLong
conn.inputStream.use { input ->
java.io.FileOutputStream(tmp, resuming).use { out ->
val buf = ByteArray(65536); var got = startAt; var last = 0L
var n = input.read(buf)
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 (total in 1..tmp.length()) break // complete
} catch (e: Exception) {
Log.d("BalDav", "tile $tile attempt $attempt stalled at ${tmp.length()}: ${e.message}")
statusFile.writeText("""{"state":"downloading","bytes":${tmp.length()},"total":$total,"retry":$attempt}""")
Thread.sleep(1000)
}
}}
conn.disconnect()
if (dest.exists()) dest.delete()
if (!tmp.renameTo(dest)) throw RuntimeException("rename failed")
statusFile.writeText("""{"state":"done","bytes":${dest.length()}}""")
}
if (total in 1..tmp.length()) {
if (dest.exists()) dest.delete()
if (!tmp.renameTo(dest)) throw RuntimeException("rename failed")
statusFile.writeText("""{"state":"done","bytes":${dest.length()}}""")
} else {
statusFile.writeText("""{"state":"error","error":"incomplete after retries: ${tmp.length()}/$total"}""")
}
} 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()