Stage B of the ridden-roads work. GPS.detectWalk() flags contiguous low-speed
runs (< walkDetectKmh, or the single-speed 30-rpm push speed, for >= walkMinRunSec)
as walking inside a bike/other ride. Persisted per point (gps_points.walk, via
DB.setWalkFlags in recalculateSession + recomputeAllStats). rebuild_roads maps a
walk point to atype 'walk' so it colours as walking and is not treated as a
rideable road. Track detail colours walk runs and shows 'пешком: N мин / X км'.
Verified detectWalk on real tracks: 98% on a mostly-pushed 4km ride vs 1% on a
normal 16km ride.
Rust export_image(filename,base64,action) + read_file_base64(path) (registered).
Kotlin exportimg: branch — base64-decode, then SAF-save the PNG (CreateDocument
image/png) or share it via FileProvider + ACTION_SEND chooser. Settings gets a
'Фон для картинки' row: pick a photo (pickLocalFile image/*), read it as base64,
downscale to <=1080 and store as a data URL in localStorage for the 'С фото'
variant. Rust compiles in tauri dev; native paths need an APK rebuild to test.
Stage 1 (pure JS). buildShareImage() draws a 9:16 story PNG: track shape (colored
glowing line), big Distance/Time/Speed + small elevation/kcal/date, on a
transparent or photo background. showShareModal() previews it with a
transparent/photo toggle and Share/Save buttons; a 'Похвастаться' button in the
track detail opens it. Native export (SAF save + share intent) and the settings
background picker come in Stage 2 (needs an APK rebuild); on desktop the export
buttons toast an error.
Stage 2. Recording-map button ↪ builds a detour from the current GPS position
back ONTO the active route past the blockage: reference = nearest nogo on the
route (else current pos), target = a route point ~1.5km further (steps further on
failure to the route end), avoiding nogos and 180° turns (bearing helper). Drawn
as a transient magenta navigation overlay with the rejoin point; long-press the
button clears it. Does not modify the saved route or interrupt recording.
Stage 1 of the nogo/reroute work. Mark impassable circles that BRouter routes
around, end to end:
- DB nogo_areas table + CRUD; drawNogoAreas() red circles (tap to remove).
- BRouter.route(points, profile, nogos): &nogos=lon,lat,r on the RouteServer and
nogos in the embedded-engine request; Rust brouter_route passes it through;
Kotlin builds OsmNodeNamed(radius,isNogo,nogoWeight=NaN) -> rc.nogopoints +
prepareNogoPoints (needs an APK rebuild for offline use).
- Planner: ⛔ button toggles nogo-placement (tap map/line), each leg re-routes
around nogos. Recording map: ⛔ marks the current spot impassable. Detail map
shows the circles.
Verified the RouteServer detours around a nogo (2873m -> 3329m).
Found in desktop testing: saving a built route showed 'Маршрут пуст' and dropped
to the list — addRoute returned the INSERT lastInsertId, an i64 the SQL plugin
corrupts by ±1, so showRouteDetail opened a non-existent id. Re-read the id via
CAST(id AS TEXT) (same guard importGpxSession uses). Session log: desktop
verification of all 6 fixes.
- Editor shows the route with casing + slope/elevation colouring like the detail
view (drawRoutePolyline over the routed geometry); day/night, slope/elev and
locate controls added. Root cause of the plain thin line: ele was dropped when
building leg geometry — now kept from BRouter, so casing/slope work here and in
showRouteDetail. Straight legs (no ele) stay a thin line.
- Insert a point is now a TAP on the line (fat invisible hit target,
pointer-events:stroke); long-press toggles road/straight.
- Routes store editable waypoints (routes.waypoints_json {waypoints,legMode})
alongside the dense geometry, so re-editing shows the 5-6 points, not thousands.
- Bottom bar reworked (.re-bottom): hint on top, buttons in a row, cancel below.
- Gap-fill is conservative: only real long dropouts (dt>60s, chord>150m), skips
big detours (>2x chord) and no-gain fills (road ~ straight).
Found in desktop testing: showSessionDetail() from the save/revert handlers
pushed a second detail overlay whose map rendered blank. reopenDetail() collapses
the stack and re-renders in place (the pattern recalc/refine already use). Session
log updated with the desktop verification results.
Gap-fill (track detail): GPS.fillGaps now returns {points,gaps} with per-gap
status; the 🧩 control previews ONLY the filled dropouts (cyan casing; unfilled
= red dashed) with a Save/Cancel panel. Save is a reversible in-place patch
(estimated points added, original untouched; DB.saveGapFill/revertGapFill +
estimated/gap_filled columns), then recalculateSession. Revert removes them.
Route planner: renamed menu 'Нарисовать маршрут' → 'Нарисовать тренировку'
(it makes a workout session, not a route); routes empty-state now points at the
on-phone planner. showRouteEditor rewritten per-leg: each leg road (BRouter) or
straight independently, road by default, tap a leg to toggle it, long-press to
insert, silent straight fallback where no road; the global button sets all legs.
Remove the home-city name from docs/comments/UI strings and the external
mirror IP (WAN) from code, docs and the Android cleartext allowlist, replacing
the IP with mirror.invalid (set the real host locally, uncommitted). LAN mirror
and routing keep working; the WAN fallback needs the real host set. Generic
big-city map presets are kept (not identifying).
leaflet-hotline has a single outlineColor, so render the colored core thin
(outlineWidth 0) and add two plain polylines beneath it in a dedicated pane
just under the hotline pane: a wide black one and a narrower white one — black
rim, white rim, colored core, legible on any map background. Map captured via
L.Map addInitHook to build the casing pane.
- .height-focus.label (on-map hover box) lifted 42px so it no longer covers
the route line.
- Repoint the 'Route quality' overlay's incline provider from signed incline
(degrees, blue->green->red) to |grade %| with BalDav's discrete bands
(green->yellow->red->brown->black, >=10% black), via addInitHook before the
control is built; best-effort auto-enable the overlay.
The .focusbox tooltip rect stayed white and our .elevation text rule turned
its text light — light-on-light, unreadable on the dark chart. Give the box a
dark ground with light text (same for the on-line height label), and lighten
the near-black guide lines/markers so they show on the dark chart.
doBrouter() ended with changed(), which nulled routeGeom and re-queued
doBrouter — an endless re-route loop where the road line was never drawn
(editor kept the straight line, distance unchanged). Draw directly instead
(updatePolyline/updateStats).
Verified end-to-end in the UI on a virtual display (Xvfb) against the
RouteServer: editor 'По дорогам' turns a 1.69km straight line into a 2.25km
road-following route; the gap-fill '🧩' overlay draws a shortest-road cyan
dashed line across a track's GPS dropouts. Docs (README/PROJECT_MAP/plan/log)
updated to match; router.js/poi.js now listed in PROJECT_MAP.
Stage 4 — gap fill: GPS.fillGaps(points) routes each GPS dropout (dt>gap
threshold, chord>60m) with the shortest road path and splices the routed points
in (flagged estimated), falling back to the chord when the road is >3x the chord
(graph detoured). A 🧩 button on the track detail draws the road-filled path as
a cyan dashed overlay — non-destructive (stats recompute over filled points is a
follow-up). Also fixes doBrouter: parseRouteGPX returns {name,points}, not an
array (planning would have kept the straight line).
Release hardening: network_security_config.xml permits cleartext only to the
mirror/RouteServer hosts (base off, debug-overrides on) — wired via
android:networkSecurityConfig; proguard keeps btools.** so release minification
doesn't strip the embedded engine.
Stage 3 — planning: showRouteEditor gets a "🚲 По дорогам" toggle. Waypoints
stay editable markers; with it on, the line between them (and the saved
geometry) follows roads via BRouter (brouterMode/routeGeom, 450ms debounce on
edits). BRouter.route (router.js) now: Android -> embedded engine, otherwise
(desktop / at home) -> the PC RouteServer over HTTP (http_request bypasses the
WebView CSP; LAN 192.168.1.18:17777 / WAN mirror.invalid:17777). Lets planning be
debugged on the desktop.
Stage 2 — segment UI: a "Сегменты" button on the roads screen opens
showBrouterSegments — region tiles (E35_N45/E40_N45), "есть" status, a
Download/Update button with percent progress via BRouter.downloadTileAndWait.
Verified: RouteServer returns 83 trkpt for a 3-waypoint trekking route (3594 m),
parseRouteGPX reads trkpt; both files compile into the apk. End-to-end UI (tap
waypoints -> routed line; in-app segment download) pending desktop/phone run.
Cover the elements that stayed light: right vertical sidebar tab strip, search
fields, the profile dropdown and dropdown menus, light-styled buttons, the
elevation chart's SVG axis text (was black on dark) and its background/footer/
stats. Injected as a <style> keyed on body.bw-dark.
Points of interest (cafe, shops, water, toilets, bank/ATM, bus, shelter,
viewpoint, ...) as circular contrasting badges (emoji in a white/dark circle,
readable on light and dark tiles) — not pins. A 📍 control toggles the layer
and opens a per-category filter (checkboxes, remembered). Markers are culled to
the visible bounds and shown from zoom 13 (cap 500/view), on the recording,
track-detail and roads maps.
Data is bundled and fully offline: frontend/data/pois.json ([{la,lo,c,n}], 358KB,
5662 POIs) fetched once from Overpass for ~100km around region (the whole
bbox returned in one query; adaptive subdivision was ready if it had truncated).
Verified functionally on device: 124 badges rendered in view at z16 over
region with correct bounds/category culling; the live visual could not be
screenshotted (the WebView surface is black under CDP). frontend/data + poi.js.
The navbar toggle was invisible/zero-width; replaced with a fixed floating
button (bottom-right). Dark mode now injects a <style> keyed on body.bw-dark
covering the tile layer, right sidebar, left toolbar (zoom/draw controls) and
the bottom elevation profile panel (incl. the chart drawn later on route).
Static brouter-web on the Keenetic mirror pointed at the PC RouteServer.
config.js (host/layers/centre), baldav-custom.{css,js} (day/night toggle that
inverts only the tile layer + dark sidebar, one-time default POI overlays),
and a README with the deploy steps. Kept in-repo so the mirror is reproducible.
Two fixes to the dark map + offline tiles:
- Dark backdrop: body.dark-map also darkens .leaflet-container (was #ddd), so
the light Leaflet background no longer flashes bright between/behind tiles
while zooming or where a tile is missing.
- Tile caching: createTile fetched the display <img> from the URL but cached
via a SEPARATE fetch — on a flaky mobile link that second request often
failed, so tiles showed but were never cached and re-zooming re-fetched them.
Now one fetch caches and displays from the same blob, so every shown tile is
cached. CSP already allows connect-src to the OSM tile host.
Verified on device: container bg rgb(27,31,36) under dark map; IndexedDB tile
cache populated.
A moon button on the recording, track-detail and roads maps inverts ONLY the
tile layer: body.dark-map applies `invert(1) hue-rotate(180deg) brightness(1.5)`
to .leaflet-tile-pane, so the track/route/roads/puck overlays keep their colours.
Works the same on offline (tiles_*.db) and online tiles — no keys, quotas or
tile repacking. The choice persists in localStorage (baldav_dark_map).
Chosen after ruling out dark tile providers (CARTO/Thunderforest/2GIS/Yandex —
keys/quotas/ToS/projection/RF-reachability) and the transport style (inverts
badly in dense city); plain OSM inverted looks good city and countryside.
Verified on device: filter on the tile pane only, overlay pane filter none,
button active and remembered.
Per the user's decision, BalDav pulls .rd5 tiles from their own mirror instead
of brouter.de directly. downloadTile now tries, in order: LAN
http://192.168.1.1:88/brouter/segments4, WAN http://mirror.invalid:8088/... (via
the router port-forward), then brouter.de as a last resort; a base that 4xx's
or fails is skipped. The mirror is the Keenetic's existing uhttpd (:88, docroot
/opt/share/www) serving /opt/share/www/brouter/ off USB — always on.
Verified on device: the phone downloads a tile from the LAN mirror in <2s and
routes on it. Routing stays local (embedded engine); the PC RouteServer +
systemd unit are the user's separate remote endpoint, not used by BalDav.
Debug builds allow cleartext; release needs a network-security-config for the
mirror hosts. WAN port-forwards on the router are outward-facing (added
separately with confirmation).
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.
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.
End-to-end offline routing confirmed on the phone: shortest ~5.4 km in Rostov
(3.2s cold), trekking cross-tile Rostov->region 44.8 km / 316 m ascend
(1.2s), 617 ms on the apk-shipped data. JS -> Rust brouter_route -> Kotlin
engine -> GPX works through the whole chain.
The 1.6.3 distribution ships lookups.dat v10, but the live brouter.de/segments4
tiles are v11 ("lookup version mismatch"). Replaced the vendored lookups.dat +
trekking/shortest profiles with the v11 set from abrensch/brouter master
(misc/profiles2); the 1.6.3 engine accepts them. Profiles and lookups MUST match
the segments' version.
Analysis in docs/brouter-findings.md: how BRouter knows roads (.rd5
segments), routes and reaches low elevation gain (profile uphill/downhill
cost + kinematics), the three call surfaces, and an evaluation of two ideas
— fill GPS gaps along roads, and offline waypoint route planning. License is
MIT; the engine has no desktop-only deps, so it embeds on Android ART.
Stage 1 (embed + bridge, compiles into the apk; on-device route not yet run):
- vendored brouter-1.6.3-all.jar into the Android module (dexes cleanly) and
bundled the trekking/shortest profiles + lookups.dat as assets, copied to
files/brouter/profiles2 on launch
- MainActivity: a brouter_command.txt handler on the existing FileObserver
runs btools.router.RoutingEngine off the UI thread (recipe taken via javap
from ServerHandler) and returns the track as GPX
- Rust brouter_route command: same file bridge as GPS (request + command,
poll for the result), registered in the invoke handler
- frontend/js/router.js: BRouter.route(points, profile) -> GPX (parseRouteGPX)
- LICENSE-brouter (MIT notice) kept alongside the vendored engine
Segments (.rd5, ~13-23 MB per 5x5 tile) are delivered later (Stage 2, wired
download inside BalDav). IMPLEMENTATION_PLAN.md tracks the 5 stages.
The recording map is edge-to-edge and drew behind the system status bar,
leaving its icons unreadable over the tiles (the map controls were already
offset by --safe-area-top, so the design intended a visible bar). Paint a
solid strip the height of the top inset behind the bar — like the header on
every other screen. Disabled in the rotated landscape modes.
Variant 5 from the findings doc, as GROUND TRUTH for validating the
from-track wind estimate — it does NOT feed kcal/power. Chosen scope
(per user): a toggle button now, binary "in the face" only, voice later.
- rec screen button "Ветер в лицо": tap opens a headwind interval, tap
closes it; the open state is highlighted and survives a screen re-render
- GPS.toggleWindMark()/_closeOpenWindMark(); recording.windMarks =
[{startTs,endTs}] ms, persisted to localStorage, written to the DB on
finish and restored on crash recovery (a dangling interval auto-closes)
- gps_sessions.wind_marks (JSON) + DB.setSessionWindMarks (separate from
finishGpsSession so recompute paths preserve marks); parsed in getGpsSession
- rec-bottom wraps now so the third button never overflows a narrow screen
Analysis (marks vs estimate) stays in tools/track once enough km accumulate.
Acted on docs/gps-model-findings.md (independent analysis of 156 rides).
Implemented:
- crrFromAccel extended past the old 0.008 ceiling: 7-11 -> 0.008,
11-15 -> 0.012 (packed dirt), >15 -> 0.020 (grass/rut/gravel). Rough
surfaces were undercounted ~3x. Applied to session median and per-point Crr.
- Auto walk-the-bike detection (single-speed only, gear_count 1/unset):
a normal-fix segment slower than the 30-rpm-cadence speed is the rider
pushing, not riding. Excluded from ride distance/speed/elevation (elevRef
reset so a pushed climb doesn't merge with the ridden one); effort counted
as walking, not bike physics. Every sub-threshold point excluded regardless
of duration; only runs >=30s reported as walked (walkBikeSec/walkBikeMeters).
gearCount threaded through every computeStats/finish caller.
Documented as known limitations (left as-is by decision, need calibration/DEM):
- kcal max(physics, MET) never sums the biological overhead, so climbs are
slightly undercounted (comment + README + PROJECT_MAP).
- absolute elevation gain is bounded by the raw GPS altimeter (tens of
percent; needs a terrain model / wired DEM preload).
Manual pause (hand-paused at foot of a climb, walked up, resumed) was
counted as riding: computeStats bridged the GPS gap for distance and
elevRef survived the gap, leaking the on-foot climb into elevation gain.
- gps_sessions.pauses (JSON) now persists manual-pause intervals
- computeStats(opts.pauses): a segment crossing a pause is excluded
entirely (no distance/speed/elevation/kcal), elevRef reset, paused
time subtracted from duration; automatic GPS gaps still interpolated
- altitude glitch guard: an adjacent smoothed step steeper than
maxRealGradePct (100% = 45deg) is not banked and does not move elevRef
(covers Android no-fix 0.0); a smooth change to any value (a real 0
near a river) still passes
- recorder writes "alt":null when hasAltitude() is false (real fixed 0 kept)
- pauses threaded through live-stop, recalculateSession, recovery and bulk recompute
- new-tracks only (old tracks never stored pauses; no manual segment-cut UI)
Also: track-detail header shows a click-to-copy track ID.
Two-pass extractor for the steepest sections of recorded bike tracks:
- pulls gps_points/gps_sessions from baldav.db (adb run-as), bike only;
- refines elevation online (--gpxz Copernicus GLO-30 with the user's key, deduped
to ~33m cells + cached; falls back to open-elevation / on-device altitude_srtm_m);
- distance-window smoothing, grade over a configurable base (default 120m) with a
GPS-gap guard, keeps the steepest base-window core per section (so a real steep
core is not averaged away by a long gentle stretch), spatial de-dup of repeats;
- outputs steep_segments.gpx (named by grade/length/drop) + a brouter lonlats string.
Verified against two ground-truth spots the rider confirmed; open-elevation/SRTM30
flatten short steep features that GLO-30 resolves. Key + data stay gitignored.
WryActivity's generated back handler calls WebView.goBack(), which does NOT
dispatch popstate for same-document history.pushState() entries — so the OS back
gesture never reached the app's popstate handler and couldn't dismiss modals
(new product, add-to-eaten, ЧЗ scanner) or navigate screens; only the on-screen
back arrow worked. WryActivity.kt is auto-regenerated each build, so override in
the app-owned MainActivity instead: set handleBackNavigation=false and register
our own OnBackPressedCallback that runs window.history.back() via evaluateJavascript,
which fires popstate. Verified on-device: swipe-back now closes modals and the ЧЗ
scanner. (adb `input keyevent BACK` does NOT go through OnBackPressedDispatcher on
Android 13+, so it can't be used to test this — only the real gesture does.)
- Zoom slider: many Android WebViews don't report a `zoom` track capability
(torch works, zoom doesn't), so the slider never showed. Add a digital-zoom
fallback (always-visible slider 1-5x): CSS-scale the preview + crop the decode
ROI tighter to center. Hardware zoom still used when available.
- Back gesture did nothing after scanning: onDecoded closed the scan modal with
the async history.go(-1) and then synchronously opened the add-product modal,
so history.go unwound the NEW modal's entry and desynced the winStack/history.
Add closeThen(): await the close's popstate(s) before opening the next modal.
- The tight guide box forced the phone closer than it can focus. Drop it: decode
the whole visible center square (WYSIWYG under object-fit:cover) at up to 768px;
zxing-wasm locates the code anywhere in it.
- Add a zoom slider and a torch toggle driven by MediaStreamTrack capabilities
(getCapabilities/applyConstraints), plus continuous focus and a 1920x1080 request
— the user enlarges a small code optically instead of moving too close.
- CSP: add "script-src ... 'wasm-unsafe-eval'". Tauri's generated hash-based
script-src dropped unsafe-eval, so WebAssembly.instantiate was blocked
("violates CSP") — verified fixed on-device via CDP (zxReady ok, code_33 decodes).
The pure-JS @zxing/library locator is weak on real ЧЗ codes (decoded 1/4 of the
test photos with correct grayscale input; libdmtx and zxing-wasm read 3/4 — the
4th is physically undecodable). Symptom: scanner takes very long to even find a
code, 2 of 4 never read.
- Bundle zxing-wasm reader as an IIFE (frontend/lib/zxing-wasm.js, global
ZXingWASM) + zxing_reader.wasm; drop frontend/lib/zxing.js. Init fetches the
.wasm as an arrayBuffer and hands it over as wasmBinary (no streaming/MIME/CDN).
- ChZ.zxReady()/ChZ.decodeImageData() (chzn.js): decode an ImageData -> raw GS1
bytes (result.bytes keeps the GS separators) with tryHarder/tryRotate/tryInvert.
- Scanner (main.js) rewritten to a canvas loop: crop the central ROI, upscale to
540px, decode each frame — full-frame search was the slow/unreliable part.
Photo path decodes the picked image the same way.
Not yet verified on-device (WebView WASM instantiation); decoder logic validated
in a Node harness against the 4 sample photos.
- Native BarcodeDetector needs Google Play Services (fails "Barcode detection
service unavailable" on de-Googled ROMs). Replace it with a bundled, fully
offline ZXing decoder (frontend/lib/zxing.js, @zxing/library UMD): scanner uses
BrowserDatamatrixCodeReader (TRY_HARDER) for live camera and decodeFromImageUrl
for the photo fallback. No Google, codes never leave the device.
- Source badge "ЧЗ" (src-chzn) for products added via the scanner.
- tools/chzn_export.sh: adb push the exported session to the device Downloads so
BalDav's file picker can import it directly.