Releases: drzeeb/VeloSpot
Release list
VeloSpot v1.0.27
VeloSpot v1.0.27
Added
- Pause & resume a ride — for commuters who hop on a train or ferry mid-way — ride recording can now be paused and later resumed, so a daily bike-to-work commute that includes, say, a 30 km train leg (or a ferry crossing) is captured as one ride without the in-between counting. While paused, incoming GPS fixes are discarded so the pause adds no distance and no time, and the elapsed timer freezes and continues exactly where it left off; the first fix after resuming opens a new track segment, so the skipped stretch is stored as a real gap rather than a straight line drawn across it. The gap survives everywhere the track does: it is persisted (and crash-recovered) via a new
TrackPoint.segmentStartflag, exported as a separate<trkseg>in the GPX file (the portable, Strava/Garmin-friendly way to store an interruption), and drawn as multiple polyline segments on the map (live and for saved rides) via a pure, JVM-testableList<TrackPoint>.splitIntoSegments(). Pause/resume is reachable from every recording surface: the live map overlay, the ongoing notification, the home-screen widget, the quick-settings tile, and the navigation screen (the recording pauses while the route guidance keeps running). Covered byRideTrackerTest,GpxWriterTestandTrackSegmentsTest. Localised across all eight supported languages.
Changed
- Drop the F-Droid product flavor — VeloSpot is now a single Google Play build — the
distributionflavor dimension (fdroid/googlePlay) is removed. The app builds one unbundled variant that uses Google Play Services'FusedLocationProviderClientfor location (the previousLocationManager-based F-DroidLocationRepositoryImpl/LocationModuleare gone). The flavor-specificsrc/googlePlay/…sources moved intosrc/main/…,play-services-locationis now a plainimplementationdependency, and all tooling that was pinned to a flavor was retargeted to the plain variant: Kover (koverXmlReportDebug), the CycloneDX SBOM (releaseRuntimeClasspath), and the CI/lint workflows (assembleDebug,testDebugUnitTest,lintDebug). The F-Droid-only reproducibilityvcsInfo { include = false }block was dropped (the AGP dependency-metadata block is still kept out of the APK). Docs (README,CONTRIBUTING,RELEASING,ATTRIBUTIONS,SCREENSHOTS) updated accordingly. - Automated Google Play release — signed AAB + fastlane upload from CI —
release.ymlnow builds a signed App Bundle (bundleRelease) alongside the sideload APK and, on avX.Y.Ztag, uploads the AAB plus the store listing and per-version "What's New" changelogs (fastlane/metadata/android) to Google Play via fastlanesupply(newGemfile,fastlane/Appfile,fastlane/Fastfile). The upload runs only when thePLAY_SERVICE_ACCOUNT_JSONsecret is set and defaults to theproductiontrack withdraftstatus (the release is staged in the Play Console for a final manual "Publish"); a manual Run workflow lets you pick the track (internal/alpha/beta/production) and status. The GitHub Release now attaches the AAB and the universal APK (VeloSpot-vX.Y.Z.apk) instead of two flavor APKs. - Bike paths are now preferred over gravel tracks (
trekking.brf,shortest.brf,PROFILES_VERSION11 → 12) — a rider reported that the trekking profile (the default for ordinary riders on a normal bike) picked a gravel track (Schotterweg) even though a dedicated cycleway (Fahrradweg) ran only a few metres away. Both profiles now detect gravel / loose unpaved surfaces (surface=gravel|pebblestone|rock|unpaved|ground|dirt|earth|mud|sand) and add a surcharge to such tracks, so a proper bike path — or a paved way — clearly wins, while the gravel track stays usable when there is no reasonable alternative. The shortest profile previously ignored the surface entirely (every way cost the same), so it crossed gravel whenever it was marginally shorter; it now applies the same surcharge. The fastbike profile already priced unpaved tracks well above a cycleway and is left unchanged.PROFILES_VERSIONis bumped so the updated profiles replace the stale copies in internal storage on existing installs. Covered by a newBRouterGravelAvoidanceTest(which evaluates the real BRouter cost factor and asserts trekking, shortest and fastbike all price a gravel track well above a cycleway), plus the existingBRouterProfileIntegrityTestandBRouterProfileRoutabilityTest.
Fixed
- Crash when the address search returned duplicate hits — searching for a place could crash the app with
IllegalArgumentException: Key "…" was already usedfrom the resultsLazyColumn. The list key wasdisplayName + latitude, which is not unique: Nominatim can return several hits that share the same display name and latitude (e.g. a place plus its administrative boundary), so two rows collided. The results list is ephemeral and never reordered, soAddressSearchBarnow keys theLazyColumnby the item index (viaitemsIndexed), which is guaranteed unique, and draws the row divider usingindex < results.lastIndex.
📥 Artifacts
| File | Description |
|---|---|
VeloSpot-v1.0.27.apk |
Universal APK, ready to sideload |
app-release.aab |
Android App Bundle (uploaded to Google Play) |
VeloSpot-v1.0.27-sbom.cdx.json / .xml |
CycloneDX SBOM – full dependency + license inventory |
🔐 Supply-chain verification
All release artifacts ship with signed build provenance attestations.
Verify any downloaded file with the GitHub CLI:
gh attestation verify VeloSpot-v1.0.27.apk --repo drzeeb/VeloSpotInstallation (sideload)
- Download the APK below.
- On your Android device: Settings → Apps → Install unknown apps → allow your browser or file manager.
- Open the APK and tap Install.
VeloSpot v1.0.26
VeloSpot v1.0.26
Added
- AMOLED pure-black map (a dark-mode sub-option) — a new AMOLED toggle in Settings → Appearance & map switches the dark map to a true-black style (
map_style_amoled.json, derived frommap_style_dark.jsonwith a#000000background, near-black fills and black text halos) so OLED panels can switch pixels off and save battery on night rides. It's an extension of dark mode: enabling it also turns dark mode on if it wasn't already, and it has no effect in light mode. Off by default and persisted via the DataStore-backedMapSettingsRepository(amoledEnabled,MapViewModel.amoledEnabled); the style reloads live when toggled (a newMAP_STYLE_URL_AMOLEDresolved bymapStyleUrl(isDarkTheme, amoled)). Localised across all eight supported languages (menu_amoled_mode). - Recent destinations in the search bar — focusing the (empty) address search field now initially expands its dropdown to show the last 3 destinations the rider navigated to, so a frequent place is one tap away without re-typing the address. Every navigation start (parking spot, address, saved place, custom pin) is recorded — de-duplicated by rounded coordinate and capped — in a dedicated, isolated Room store (
DestinationHistoryDatabase/recent_destinations, independent of the parking / rides / places stores), exposed reactively via a newDestinationHistoryRepositoryandMapViewModel.recentDestinations. Localised across all eight supported languages (search_recent_title,recent_destination_fallback). - Download a whole route corridor for offline use — the saved-route preview card gains a Download for offline use action that fetches the entire route corridor — the map tiles along the route (not just a 40 km box around the start) and every BRouter routing tile the route passes through — so a long tour into a dead zone works end-to-end offline. Built on a pure, JVM-testable
RouteCorridor.corridorBoxes(splitting the route polyline into overlapping, padded map-tile boxes) plusBRouterSegmentManager.downloadSegmentsForRoute/requiredSegmentNamesForPointsandOfflineMapTilesManager.downloadRouteCorridor, wired through a newOfflineRegionsController.downloadRouteCorridor(MapViewModel.downloadRouteForOffline). The corridor is registered as one offline-region pack (reusing the existing download progress overlay, listing and Wi-Fi gating);OfflineRegionPacknow carries the exactroutingTilesit needs so deleting a region only removes routing tiles no other region still depends on. Localised across all eight supported languages (offline_download_route). - First-launch welcome onboarding — the very first app start now greets the rider with a compact three-card welcome sheet instead of dropping them straight onto the bare map. The cards mirror VeloSpot's own building blocks: a short "what is VeloSpot" intro, an "offline maps & routing" card that explains navigation works without a connection and offers a direct "Set up offline routing" button (which opens the offline-regions manager), and a "ready to ride" card pointing at search, the map and the menus. It's a swipeable
HorizontalPagerbottom sheet with page-indicator dots and a Skip / Next / Get started button — deliberately not a coordinate-based coach-mark tour (the previous attempt broke against the map overlays, insets and async layout). Shown only once, gated behind a new DataStore flag (onboardingCompletedonMapSettingsRepository, exposed viaMapViewModel.onboardingCompleted/completeOnboarding/replayOnboarding) that only reveals the sheet after the launch splash and once the stored value has definitely loaded asfalse(so returning users never see a flash). It can be re-opened any time from a new App tour row on the About VeloSpot sheet. Localised across all eight supported languages (onboarding_*,about_replay_onboarding*). - Unified, multi-region offline use — map tiles and routing, downloaded together per region — the two separate Settings → Navigation & routing entries (offline routing segments and offline map tiles) are merged into a single Offline regions manager, so a region always gets both halves at once — no more "offline route on a blank grey map", and no more "map with no offline route". Each region is one combined pack: the visible vector map (a ~40 km box) plus the BRouter 5°×5° routing tile covering the same spot, downloaded in two clearly-labelled phases with an inline progress bar. Crucially the manager is now multi-region: a region is anchored on the rider's current position or a spot they pick on the map (a centre crosshair they pan the map under, then confirm), so a rider based in, say, Frankfurt can add a second region while on holiday in Sydney — or pre-download a destination before leaving home — and have the map + routing for both offline. The add screen shows a coverage hint (the map box is a ~40 km radius; bike routing covers a much larger surrounding area). Each downloaded region is listed with its reverse-geocoded place name (via
NominatimGeocoder) and can be deleted individually — the shared 5° routing tile is only removed when no other region still needs it (so a separate "disable offline routing" button is no longer needed and was removed from the routing-profile sheet). The huge "whole area (DE/FR/LU)" download is dropped in favour of these lightweight, on-the-go regions. Built on a newOfflineRegionsController+OfflineRegionsStore(a smallorg.jsonSharedPreferenceslist, the source of truth) over the existingOfflineMapTilesManager/BRouterSegmentManager(extended with a per-region name + tile helpers), a singleOfflineRegionsUiState, and a newOfflineRegionsSheet, replacing the old splitOfflineRoutingController/OfflineMapController, their UI states and setup sheets. Nothing is bundled into the APK and it adds no new dependency (F-Droid- and reproducibility-safe). Localised across all eight supported languages (offline_regions_*,menu_offline_regions_*).
Changed
- Polished "My routes" list — each saved route is now a richer card: a rounded route-icon avatar, the name, compact stat pills (distance · ↑ ascent · waypoints), a prominent Ride button plus an outlined Reverse, and a visible secondary action row (Map · Ranking · Offline) — the offline-corridor download is now reachable straight from the list, not only the preview. Localised across all eight supported languages (
route_map_short,route_leaderboard_short,route_offline_short,common_more_options).
Security
- Hardened GitHub Actions workflows (OpenSSF Scorecard) — tightened CI token permissions to least privilege (the
ciworkflow now defaults tocontents: readat the top level and only the coverage job elevates topull-requests: write/id-token: write, dropping the unusedactions: write), addedpersist-credentials: falseto all read-onlyactions/checkoutsteps (so the repo token is never left on disk for build/lint/test/scan jobs), and enabled Renovate'shelpers:pinGitHubActionDigestspreset so every GitHub Action is pinned to a full commit SHA (with a version comment) and kept up to date automatically — satisfying Scorecard's Pinned-Dependencies and Token-Permissions checks.
📥 APK Variants
| File | Description |
|---|---|
VeloSpot-v1.0.26.apk |
F-Droid flavor – no Google services, ready to sideload; this is the APK F-Droid reproducibly verifies |
VeloSpot-v1.0.26-googlePlay.apk |
Google Play flavor – includes Google Play Services Location |
VeloSpot-v1.0.26-sbom.cdx.json / .xml |
CycloneDX SBOM – full dependency + license inventory |
🔐 Supply-chain verification
All release artifacts ship with signed build provenance attestations.
Verify any downloaded file with the GitHub CLI:
gh attestation verify VeloSpot-v1.0.26.apk --repo drzeeb/VeloSpotInstallation (sideload)
- Download the APK below.
- On your Android device: Settings → Apps → Install unknown apps → allow your browser or file manager.
- Open the APK and tap Install.
The F-Droid flavor is also available on F-Droid.
VeloSpot v1.0.25
VeloSpot v1.0.25
Added
- Discord community link on the About sheet — the About VeloSpot sheet gains a Discord row (a community/groups icon) linking to https://discord.velospot.app, opened externally in the browser via
ACTION_VIEW(no in-app integration). Placed right under the website link and localised across all eight supported languages (about_discord). Play-Store- and F-Droid-safe (a plain external link adds no proprietary dependency). - Rounded 3D buildings — a new toggle in Settings → Appearance & map draws the extruded 3D buildings with softly rounded corners instead of hard, square edges, for a gentler look on the tilted 3D map (idle and navigation). It's off by default (sharp corners) and the chosen state is persisted via the DataStore-backed
MapSettingsRepository(roundedBuildingsEnabled,MapViewModel.roundedBuildingsEnabled). Built on MapLibre 13.4.0's newfill-extrusion-rounded-corner-distancestyle property (setBuildingRoundedCornersinMapStyleLayers.kt), applied throughNavigationManager.setRoundedBuildingsand re-asserted after every style (re)load — including dark-mode reloads that rebuild the extrusion layer. Localised across all eight supported languages (menu_rounded_buildings). - Offline-routing setup stays in context, with an inline download progress bar — activating offline routing (or opening the routing-profile sheet) from Settings → Navigation & routing no longer closes that sheet, so the rider keeps their place. While segment files download, the sheet now shows a real progress bar with a live MB counter (
downloaded / total MB) inline instead of a bare spinner. Selecting a routing profile keeps the profile sheet open, and both the routing-profile and offline-setup sheets now open fully expanded so the whole profile list, hilliness slider and actions are visible at once. - "Save as route" hidden for rides already ridden from a route — the ride detail screen only offers Save as route for free rides now. When a ride was recorded while riding a saved route, that route already exists, so the button is hidden. Rides are tagged with their source route (
RecordedRide.sourceRouteId, persisted via Room migration v6 → v7 and set when a planned-route ride finishes). - Upgrade CycloneDX SBOM tooling to 3.x — bumped the CycloneDX Gradle plugin to
3.3.0. The 3.x rewrite replaced the old setter API with a lazyPropertyDSL and split BOM generation into per-project direct and aggregate tasks, so the SBOM config moved from the root project to the:appmodule and now uses thecyclonedxDirectBomtask (scanningfdroidReleaseRuntimeClasspath, which transitively covers the:broutermodule). Output moved toapp/build/reports/cyclonedx/bom.{json,xml}; the release workflow and docs were updated accordingly. The generated SBOM is unchanged in content (~184 components). - Higher, more representative test coverage — added focused JVM unit tests for pure, previously-untested logic (
BikeParkingMappersentity⇄domain mapping, theBRouterProfilespeed/file-name invariants, theMapScreenUiStatesheet/menu state machine andSavedPlacesRepositoryImplvia an in-memory fake DAO) and tightened the Kover coverage filters to exclude code that genuinely cannot be unit-tested (DI wiring, Room DAO/database/entity declarations, Moshi adapters, Android entry points — Activity/Application/service/tile/widget — and MapLibre/Canvas rendering & camera glue). Together these lift the measured line coverage from ~30 % to ~43 %. - Supply-chain hardening & test coverage — the project gained several open-source security & quality building blocks: an OpenSSF Scorecard workflow (
.github/workflows/scorecard.yml) that weekly analyses the repo's security posture and publishes a public badge; signed build-provenance attestations (SLSA/Sigstore) for every released APK and the SBOM, verifiable withgh attestation verify; a CycloneDX SBOM (*-sbom.cdx.json/.xml, generated via thecyclonedxBomGradle task) attached to each release for a full dependency + license inventory; and Kotlin test coverage via Kover — CI generates a report on every pull request, posts a summary comment and uploads to Codecov (README badge), with generated (Hilt/Room) and pure Compose UI code excluded so the figure reflects testable logic. - Lock the screen to portrait orientation — a new toggle in Settings → Appearance & map keeps the map screen fixed in portrait so the display no longer rotates while cycling (e.g. when the phone is bar-mounted and tilts). It's off by default (the app follows the device's auto-rotate) and persisted via the DataStore-backed
MapSettingsRepository(portraitLockEnabled,MapViewModel.portraitLockEnabled); the map screen applies it through the activity'srequestedOrientationand restores the previous orientation when left. Localised across all eight supported languages (menu_portrait_lock). - Preview a saved route on the map before riding — with a leaderboard digest — My routes now has a Show on map action (a map icon per route) that draws the saved route's line on the idle map, frames the whole route with the camera, and opens a non-modal preview card (the map stays pan/zoom-able above it, exactly like a recorded ride's detail). The card shows the route's distance, ascent ↑, descent ↓, stop count and estimated calories, plus a rich per-direction leaderboard digest pulled from the route's attempts: the best time (with average speed), the average and median times, how much the best beats the average (a "2:15 faster than your average" line), a mini improvement sparkline of the attempt times over time (green and rising when getting faster), and how often / when last the route was ridden. Forward and reverse are shown separately. From the card you can start the ride forward or reversed, or open the full leaderboard. Built on a pure
RouteLeaderboard.summarize→RouteDirectionStats(covered byRouteLeaderboardTest) and a newRoutePreviewSheet, wired throughRoutePlanningController.previewRouteOnMap/MapViewModel.showRouteOnMap; closing the preview returns to the My routes list. Localised across all eight supported languages. - Delete confirmation for rides, routes and favourites — deleting a recorded ride, a saved route or removing a favourite (a parking spot or a saved place) now asks for confirmation first via a shared
ConfirmDeleteDialog, instead of removing the entry on a single tap. The destructive confirm action is tinted with the error colour, and the delete icons are now consistently red across all three lists (the My routes delete icon was previously grey, unlike the red ones in Favourites and My rides). Localised across all eight supported languages. - Save any ride as a re-rideable route — with its time on the leaderboard — My rides now lets you turn a finished ride (a manual recording, a navigated ride or a generated round trip) into a saved route: the ride detail sheet gains a Save as route action. It converts the ride's GPS track into a
PlannedRoute(the track is reused verbatim for drawing; a capped, evenly-spaced set of waypoints is sampled from it so the route can be re-routed and reversed — keeping the start/end preserves a round trip's loop), stores it in My routes, and seeds the route's leaderboard with that ride's own time as the first (forward) attempt, before opening the leaderboard so the entry is visible immediately. From there the route can be ridden again (forward or reversed) to add more attempts, exactly like a planned route. Mock (simulator) rides are excluded. Built on a pure, JVM-unit-testedRideRouteFactory(covered byRideRouteFactoryTest) wired throughRoutePlanningController.saveRideAsRoute; the wiring is covered byMapViewModelTest. Localised across all eight supported languages. - A bike garage — one profile per bike, with statistics split per bike — riders who own several bikes (the "pros" this was asked for) can now keep a profile for each bike and see their ride history broken down per bike. A new My bikes entry in Settings opens the garage, where each bike shows its own aggregated stats (ride count, total distance and total ascent, real rides only — simulator rides excluded). Adding or editing a bike captures the details a cyclist actually cares about: a name/nickname, brand & model, the discipline/type (road, mountain, gravel, trekking, city, e-bike, cargo, folding, BMX, other), tyre size, weight, colour, model year and free-form notes. Exactly one bike can be marked the default, and — for a quick pre-ride switch — any bike can be set as the one to "ride next", so the next recording is tagged with it (falling back to the default when nothing is explicitly picked). Finished rides are automatically tagged with the resolved bike (
RideRecordingManager→BikeProfilesRepository.resolveActiveProfileId); deleting a bike keeps its rides but detaches them (no dangling references). Bikes live in the same isolated rides Room store (bike_profilestable + abikeProfileIdcolumn onrecorded_rides, migration v4 → v5), the "ride next" selection is a small DataStore preference, and the whole feature is exposed through aBikeGarageSheetdriven byBikeProfilesViewModel. Localised across all eight supported languages.- Service reminders — each bike can carry a service interval in kilometres (e.g. 500 km). When the bike's total ridden distance crosses each multiple of that interval (500, 1000, 1500 km, …), the rider gets a one-time notification that a shop service is due — fired exactly once per milestone (tracked via
lastServiceNotifiedKm, so it never repeats or double-fires). The garage lists each bike's next service distance ("Service in X km"), the ch...
- Service reminders — each bike can carry a service interval in kilometres (e.g. 500 km). When the bike's total ridden distance crosses each multiple of that interval (500, 1000, 1500 km, …), the rider gets a one-time notification that a shop service is due — fired exactly once per milestone (tracked via
VeloSpot v1.0.24
VeloSpot v1.0.24
Added
- Animated branded launch screen — opening the app no longer shows a blank white screen while the map style and tiles load. A full-screen VeloSpot splash now covers the load with the brand-green gradient and the app logo: the location pin drops in with a bounce, gently breathes, and emits expanding GPS-ping rings (as if acquiring a fix), under the app name and a row of pulsing loading dots. It fades and scales away the moment the map is ready (
styleVersion > 0, with a short minimum so the entrance animation is seen). The cold-start window background is also themed (green gradient + logo viasplash_window_background) so even the very first pre-Compose frame is branded instead of white (VeloSpotSplash,MainMapScreen). - Top-speed marker & speed-coloured track when reviewing a ride — opening a recorded ride from My rides can now visualise where and how fast you rode. A red speech bubble marks the exact spot the ride hit its top speed and shows that value (anchored to the GPS fix whose recorded speed best matches the ride's stored peak,
RideMaxSpeedPoint), and the drawn track can be coloured by the speed ridden — a smooth green → amber → red ramp with red mapped to the ride's own peak (RideSpeedSegments, the polyline split into ≤ 240 speed-tagged segments off the main thread,updateTrackSpeedLayer). Both overlays are driven by a compact toggle stack on the right edge, just below the menu button, that appears only while a ride is open; the two switches are persisted globally (RideViewOptions/RideViewPreferences) so your last-used choices apply to every ride you open next. Pure helpers covered byRideMaxSpeedPointTestandRideSpeedSegmentsTest. Localised in English and German. - Mock rides are flagged and kept out of your statistics — a ride recorded with the (debug) route simulator / "Mock tool" is now persisted as such (
RecordedRide.isMock, newisMockcolumn via Room migration v2 → v3) and clearly marked in My rides with a small "Mock" badge in the list and a "Recorded mock ride" indicator in the detail sheet. Synthetic rides no longer skew your real numbers: they are excluded from the statistics dashboard (totals, averages, personal records, streaks, fun facts viacomputeRideStatistics) and from the Ride heatmap and Ridden tracks map layers. The manager tags the ride the moment it receives its first simulated fix (RideRecordingManager.feedExternal). Covered by newRideStatisticsTestcases. Fully localised across all eight supported languages. - Archive rides — recorded rides can now be archived to declutter the timeline without deleting them. The ride-detail sheet gains an Archive / Restore action; archived rides drop out of the main My rides list (and its ride count) into a collapsible "Show archived (n)" section from which they can be reopened and restored at any time. The archive state is persisted (
RecordedRide.archivedAt, newarchivedAtcolumn via Room migration v2 → v3) and exposed through the repository (setRideArchived). Fully localised across all eight supported languages.
Changed
- Recording stack no longer depends "upwards" on the UI layer — the background ride-recording components in
core.tracking(theRideRecordingService,RideRecordingManager, the Quick Settings tile and the home-screen widget) used to import types and helpers straight from thefeature.map.presentationpackage — a Clean-Architecture inversion (inner layer depending on the outer UI). The three shared symbols were moved down into the core layer: the recording state holderRideTrackingUiStatenow lives next to its producer incore.tracking, the locale-neutral ride formatters (formatRideDistance/Duration/Speed/Elevation) moved to a newcore.formatpackage, and thehasLocationPermissionhelper moved to a newcore.locationpackage. The presentation layer now depends on core (the correct direction) and nocore/**file imports fromfeature/**anymore. Pure refactor — no behaviour change. - Far less map work on every GPS fix — a fresh GPS fix used to re-run the whole marker pass, re-serialising the entire parking / favourites / saved-places / route GeoJSON just to move the blue location dot. The live-location dot now has its own lightweight effect (
updateLocationDot, a tinySOURCE_LOCATION-only update) and the heavy marker pass is no longer keyed on the live position, so panning a fix no longer rebuilds every other layer (MainMapScreen,MapMarkerRenderer). Separately, the live recording track redraw — which rebuilds the full polyline each time — is now coalesced: a burst of fixes (e.g. a batched delivery after Doze, or the debug simulator) collapses into a single redraw via a short debounce instead of one redraw per fix. Together these cut the per-fix UI cost during navigation and ride recording. - GPX file I/O moved out of the map ViewModel — the ride GPX export/import Storage-Access-Framework plumbing (
ContentResolverstreams +DocumentsContractdocument creation) lived directly inMapViewModel, mixing Android framework / data-layer concerns into the presentation layer. It now lives in a dedicated, Hilt-injectedGpxFileStore(data.gpx) exposing three off-main-thread suspend functions —writeDocument,writeDocumentsToTree,importRides— so the ViewModel just hands over the picked URIs / validated documents and reacts to a simple success / count / rides result. Pure refactor — no behaviour change.
Fixed
-
A mock ride's track is drawn again when you open it — a ride recorded with the debug route simulator carries no speed samples (its max speed is
0), so with the globally-persisted "colour track by speed" option on, the speed-coloured line rendered invisible (the colour ramp needs a positive peak) while the plain line was suppressed — leaving the mock ride with no track on the map. Mock rides are now always drawn as the plain line regardless of the toggle, and the "colour by speed" switch in the ride-inspection controls is disabled (greyed out) while a mock ride is open, keyed offRecordedRide.isMock(MainMapScreen,RideViewOptionsControls). -
A ride recording is no longer lost when the system kills the app mid-ride — the active recording lives in an in-memory singleton kept alive by the foreground service, but the OS can still kill the process under memory pressure, which used to drop the whole partial track. Every accepted fix is now streamed to disk as the ride is recorded (an append-only point file plus a tiny running-aggregates meta file,
RideRecordingPersistence); on the next launch an orphaned session is recovered and saved as a finished ride (held to the same "too short to keep" bar as a normal stop) so the track survives a crash/kill, then the recovery files are cleared. Writes are funnelled through a single ordered IO worker sobegin → append … → clearnever race, mock (simulator) rides are excluded, and the whole flow degrades to a no-op if storage is unavailable. Covered by a newRideRecordingPersistenceTest. -
Recorded ride no longer thins out or freezes when the screen is off — a
location-typed foreground service keeps the process alive and location access allowed, but does not keep the CPU awake, so with the screen off and the device dozing the OS could defer/batch location callbacks and leave gaps in the recorded track.RideRecordingServicenow holds aPARTIAL_WAKE_LOCKfor the duration of an active recording (acquired when it goes foreground, released the moment recording ends, with a 12 h safety-net timeout against a leaked lock), so every GPS fix is delivered on time throughout a screen-off ride. Adds theWAKE_LOCKpermission. -
Bike routing no longer sends you onto motorways / trunk roads when a parallel cycleway exists — across all five BRouter profiles (
trekking,fastbike,gravel,mtb,shortest)highway=motorway/motorway_linkandhighway=trunk/trunk_link("Kraftfahrstraßen", e.g. A1 feeders & ramps) are now hard-blocked for bikes (cost10000, placed before the engine'smin 9999cap so it is a real exclusion just like motorways — not a high-but-passable cost) unless cycling is explicitly permitted there (bicycle=yes|designated|permissive) or the way carries its own bike infrastructure. In return, dedicated cycling infrastructure is rewarded: ahighway=cycleway, and any road carrying a mapped cycle track / lane (cycleway=track|laneand thecycleway:left|right|bothvariants, detected via newhascycleway/hasonroadcycleway/has_cycleway_infrahelpers), now gets a tangible discount so it clearly beats the bare main road of the same class running right next to it. Only tags present in the standardlookups.datare used. -
Cyclist avatar redrawn as a proper 3rd-person rider (no more "wheelie" or flat look) — the live-location avatar used to be a top-down sprite, which on the tilted 3D navigation map either stood straight up on its rear wheel (a "wheelie") or, when laid flat, looked squished. The vector (
ic_cyclist_avatar.xml) is now drawn as a true rear view: the back of the rider, the back of the helmet and the rear wheel nearest the viewer, with the arms reaching forward to a (narrowed) handlebar. Rendered as the existing upright billboard it now simply reads as a rider standing on the map seen from behind — no perspective faking needed, so the earlier bitmap pre-tilt (tiltBackwards) was removed. The programmatic pedalling/standstill legs (MarkerIconFactory.drawCyclistLegs) were re-positioned to straddle the rear wheel, and the navigation avatar is rendered a bit larger for presence (MarkerIconFactory.kt,MapStyleLayers.kt). -
Exported GPX files some apps refused to open — the ride GPX export is now hardened to be s...
VeloSpot v1.0.23
VeloSpot v1.0.23
Added
- GPX export & import for rides — My rides now has Import and Export actions. Tapping Export turns the list into a multi-select (checkboxes + a Cancel/Export bar); confirming with several rides asks whether to combine them into one GPX file or write one file per ride, then asks for the destination: Share (system share sheet → save to Files, Drive, e-mail, …) or Save to file (a Storage Access Framework picker — Create document for a single file, or pick a folder for separate files). A single selection skips the layout question, the file named after the ride. Import opens a document picker and reads each
<trk>of the chosen GPX file(s) back into a ride (keeping its<name>), deriving distance, elevation and — when the GPX is time-stamped — duration/speeds. Built on a dependency-freeGpxWriter/GpxParser/GpxRideFactory(core/gpx) and a cache +FileProvidershare path / SAF save path (GpxExporter). Fully localised across all eight supported languages. - Named rides — recorded rides now carry a name instead of just a date. An auto-recorded navigation ride is named after its destination (the reverse-geocoded place of the destination coordinate, e.g. "Trier", falling back to the destination's own label); a generated round trip becomes "Round trip - {current place}" (localised,
ride_round_trip_name). Finishing a manual recording now opens a name prompt pre-filled with the reverse-geocoded current place (cancel keeps the ride recording, an empty field saves it unnamed). The name is shown in the My rides list and the ride-detail sheet, and can be edited anytime via the pencil in the detail sheet. Resolved inMapViewModel.resolveAndSetAutoRideName/requestStopRideTracking(reverse-geocoded viaNominatimGeocoder.reverseGeocodePlace) and persisted with the ride (RecordedRide.name, Room migration). Fully localised across all eight supported languages. - Pedalling cyclist avatar during navigation — the live-location cyclist now visibly pedals while you ride. The rider's legs, shoes and pedals are no longer baked into the static
ic_cyclist_avatarvector; they're drawn programmatically (MarkerIconFactory.drawCyclistLegs) as two knees-bent legs whose feet sweep forward/back 180° out of phase, mimicking a turning crank seen from above. The navigation controller pre-renders a strip of pedal frames (NAV_PEDAL_FRAME_COUNT,navPedalFrameImageId) and its per-frameChoreographerloop swaps the avatar image each tick, with the crank phase tied to the rider's along-route distance (PEDAL_METERS_PER_REV) so the cadence matches the real ground speed — and naturally freezes when you stop. Whenever the rider has (nearly) stopped — whether idle on the map or waiting at a traffic light mid-navigation — the avatar now drops into a believable standstill pose: the rider plants one foot flat on the ground beside the bike (a wider, flattened shoe sells the ground contact) while the other foot stays on the raised pedal, instead of hovering frozen mid-stroke. Off-route the resting marker uses it by default; during navigation a dedicated foot-down frame (navIdleFrameImageId) is shown once the eased ground speed drops belowPEDAL_STANDSTILL_SPEED_MPS(idleflag increateLocationMarkerIcon/drawCyclistLegs). - "Keep screen on while riding" toggle — the display is now kept awake during both active navigation and a live ride recording (previously only navigation prevented the screen from dimming/locking). A new switch in the Settings → Appearance & map sheet lets you turn this off (e.g. to save battery and rely on voice guidance). It's on by default and persisted (
KeepScreenOnPreferences,MapViewModel.keepScreenOnEnabled). Fully localised across all eight supported languages. - Legal notice (Impressum) — the project now carries a proper imprint with the responsible party's name and postal address (§ 5 DDG). It's reachable everywhere: a new Imprint section in the in-app About sheet (shown inline, so it stays offline-accessible and localised across all eight languages), a dedicated
imprint.htmlpage on the website linked from the navigation and footers of the home and privacy pages, a rootIMPRINT.md, and the contact/data-controller blocks of bothPRIVACY.mdfiles and the privacy page now name the full address. CODEOWNERS— a.github/CODEOWNERSfile (* @drzeeb) so GitHub automatically requests the maintainer's review on every pull request (including Renovate bot PRs) and branch protection can require code-owner reviews.- "Ridden tracks" map layer — a new Layers overlay draws every recorded ride as its own thin, translucent line, so you can see everywhere you've been at a glance. Because the lines are semi-transparent, overlapping passes build up colour and frequently used streets read stronger — a lightweight, route-preserving complement to the existing Ride heatmap. Each track is reduced with Ramer–Douglas–Peucker simplification (
RideTrackLines, ~8 m tolerance, typically −80–95 % points) off the main thread before drawing, the hairline width scales with zoom, and the layer sits beneath the map pins (which stay tappable). It's off by default and persisted (MapLayerCategory.TRACKS,updateTracksHistoryLayer); the pure aggregation is unit-tested (RideTrackLinesTest). Fully localised across all eight supported languages.
Changed
- Closing a ride returns to the rides list — closing a recorded ride's detail sheet (the ✕ or the back gesture) now reopens the My rides list it was opened from, instead of dropping back to the bare map, so browsing several rides in a row no longer means reopening the list each time (
MainMapScreen). - Saved-ride detail no longer blocks the map — opening a recorded ride from My rides used to show its statistics in a modal bottom sheet, whose scrim swallowed all touches so the drawn ride track couldn't be panned, pinched or zoomed. The detail view is now a non-modal, draggable sheet: it overlays the map without a scrim, only its own surface consumes touches, and it can be dragged down to a small peek (or closed with the ✕ button) to free up the map while the ride polyline stays drawn. It starts fully expanded so all stats are visible immediately (
RideDetailSheet, rendered inside the map layout). Fully localised across all eight supported languages (ride_detail_close,ride_detail_drag_hint).
Fixed
- Debug route simulation can now be paused & resumed (and no longer makes the rider run away) — the (debug-only) GPS route simulator's play/stop button now works as a proper play / pause: pressing it again pauses the run (keeping the position) and pressing play resumes from where it left off instead of restarting at the route start (
RouteSimulator.travelledMeters+startOffsetMeters; a reroute/new route still restarts fresh). Separately, stopping the simulator used to leave the navigation avatar coasting on to the end of the route: the simulator stops sending fixes, but the navigation puck advances by dead-reckoning (NavigationManagerpredicts position from the last speed between sparse GPS fixes) and never saw a "standing still" fix to slow it down. Pausing/stopping now feeds one final stationary fix (speed 0) at the current position so the puck eases to a stop. Covered byRouteSimulatorTest(resume/reset) andNavigationControllerTest(stationary fix). - Flaky
MapViewModelTeston CI — the test built freshMapViewModels but never tore them down, so two kinds of coroutine outlived each test and kept running on background threads: the view-models' ownviewModelScopecollectors (location, favorites, route-simulation flows) and — because navigation auto-starts a ride recording that the tests never stop — the process-levelRideRecordingManager's endless 1 s stats ticker and GPS collector. An exception from such a leaked coroutine (often afterresetMain()) then surfaced against the next test asUncaughtExceptionsBeforeTest(e.g.toggleFavorite…,startInAppNavigation…), failing the suite intermittently.RideRecordingManager's background scope is now injectable (Hilt still supplies the realDispatchers.Defaultone); the test hands each manager a real, cancellable scope and, intearDown, cancels every manager scope and clears every view-model — so nothing leaks across tests. (A test scheduler is deliberately not used for the manager, asadvanceUntilIdle()would spin forever on the ticker's endlessdelayloop.) - Bogus ride "max speed" from GPS Doppler spikes — a recorded ride could report a wildly wrong top speed (e.g. 70 km/h / 52 km/h on rides where nothing close was ridden). The cause:
RideTrackertook the GPS-reported instantaneous speed (speedMps, the receiver's Doppler velocity) directly as the peak, gated only by an absolute 90 km/h ceiling. That sensor value can briefly glitch to 2–5× the real speed — typically on a low-accuracy fix — while the position barely moved; the existing "teleport" filter only validates the position-derived speed and so never caught these. A peak-speed sample is now accepted only when corroborated by the track geometry: there must be a reliable position-derived baseline (fixes ≥ 1 s apart) and the reported speed may not exceed it by more than 1.5× (SPEED_CORROBORATION_FACTOR), which discards the Doppler spikes while still honouring genuine fast (e.g. downhill) stretches. Covered by a newRideTrackerTestcase. - Accessibility (a11y) — TalkBack/screen-reader support across the map UI: sheet titles are now exposed as headings (
SheetHeader+ every sheet title) so screen-reader users can navigate by heading; the record-ride FAB (idle red-dot state) now has an accessible name (ride_start); the **2D/3D n...
VeloSpot v1.0.22
VeloSpot v1.0.22
Added
- Ride heatmap overlay — a new Ride heatmap map layer turns your recorded rides into a colour heatmap that reveals where you cycle most. All recorded GPS tracks are aggregated into a compact grid (≈11 m cells) weighted by how often you've ridden through each spot, so frequently used streets glow hotter (cool blue → red). Toggle it in Layers; it's off by default and persisted, sits beneath the map pins (which stay tappable), and is built/aggregated off the main thread. The pure aggregation logic (
RideHeatmap) is unit-tested and the overlay reuses the existing recorded-ride data, so no extra storage is needed. Fully localised across all eight supported languages (MapLayerCategory.HEATMAP,updateHeatmapLayer). - Spoken turn-by-turn voice guidance (TTS) — navigation can now read the upcoming-turn instructions aloud via Android
TextToSpeech, building on the existing on-screen turn banner. It speaks an early prepare cue when a turn comes within ~150 m ("In 150 m, turn left"), a final imminent cue within ~30 m ("Now turn left") and an arrival cue at the destination; each cue fires once per turn (re-arming after you pass it), and off-route situations are suppressed until a reroute lands. A new "Voice guidance" switch in the Settings sheet (Appearance & map) toggles it — disabled by default (opt-in) and persisted across sessions. The decision logic (NavigationVoiceCues) is pure and unit-tested, the engine wrapper (NavigationVoiceGuide) matches the app locale and uses navigation-guidance audio attributes, and the flag lives inVoiceGuidancePreferences. Fully localised across all eight supported languages. - "Route hilliness" slider — flatter offline routes on demand — the offline Routing Profile sheet now carries a discrete Route hilliness slider with five steps (Any → Gentle → Flatter → Low climb → Flattest) that lets you trade a bit of distance for less climbing. Each step maps to an extra uphill penalty (
ElevationPreference.uphillExtraCost) handed to BRouter as a uniformuphill_extraprofile parameter, which is added on top of every bundled profile's own uphill cost — so Any leaves routing unchanged and flatter levels progressively avoid ascents. The choice is persisted (OfflineRoutingPreferences.getElevationPreference) and applied to point-to-point, on-demand and round-trip routing; changing it while navigating immediately recomputes the active route. Offline (BRouter) only — the online OSRM fallback has no elevation model and ignores it. Theuphill_extraparameter was added to all five profiles (trekking,fastbike,mtb,gravel,shortest,PROFILES_VERSION4 → 5) and is parse-checked byBRouterProfileIntegrityTest; the level→cost mapping is covered byElevationPreferenceTest. Fully localised across all eight supported languages.
Fixed
- No profile starts the route on the sidewalk anymore — opening navigation on the pavement was possible in two ways, both now closed for every bundled profile (
PROFILES_VERSION4 → 8): (1) the start-snapping guard (check_start_way+noStartWay=footway,sidewalk) was only in trekking/gravel, so fastbike/mtb/shortest could snap the start onto afootway=sidewalk— it's now declared in all five; and (2) cycling afootway=sidewalkwas cheaper than the parallel carriageway in several profiles, so the route hugged the pavement: trekking/shortest rode a bicycle-allowed sidewalk almost for free, and the MTB profile (which heavily penalises paved roads) made even a plain sidewalk cheaper than a residential/tertiary road. Afootway=sidewalksurcharge now keeps every profile on the carriageway (the MTB surcharge is large enough to beat its high road cost). A regression test (BRouterNoStartWayProbeTest) asserts every profile both populatesnoStartWaysand prices a sidewalk above a tertiary road. - Round trips now work with every cycling profile — generating a Round trip with a profile that enables
consider_elevation/consider_forest/consider_river(trekking, fastbike, mtb) silently produced no route ("route data incomplete"). With no explicit start heading, BRouter derived one viagetRandomDirectionFromData, which for those profiles reads area-info data and parses adummy.brfthat VeloSpot doesn't bundle. The round-trip generator now always hands BRouter a concrete start direction (a random heading when the rider doesn't pick one), bypassing that path entirely so loops build for every profile (BRouterEngine.calculateRoundTrip, regression-guarded byBRouterRoundTripDirectionTest). - Offline routing no longer fails when the start-U-turn correction can't reroute — the standstill second pass (which re-runs BRouter with a forced forward direction to drop a spurious start hairpin) used to overwrite the valid first-pass route, so if BRouter couldn't satisfy the forced direction (one-way nets, dead-ends) the whole request failed with "route data incomplete". The second pass is now best-effort: its result is only adopted when it actually produces a route, otherwise the good first-pass route is kept (
BRouterEngine.calculateRoute). - Offline routing with "Any" hilliness no longer breaks on some profiles — selecting Any used to send routing down a different BRouter code path (no key-value injection, a different profile-cache key) than the other levels, which left some profiles (e.g. gravel) unable to produce a route on Any while every other setting worked. The
uphill_extraparameter is now always passed as a key-value map — Any simply carries a zero penalty — so every hilliness level uses the identical, working path (BRouterEngine.elevationKeyValues, guarded byBRouterProfileIntegrityTest).
📥 APK Variants
| File | Description |
|---|---|
VeloSpot-v1.0.22.apk |
F-Droid flavor – no Google services, ready to sideload; this is the APK F-Droid reproducibly verifies |
VeloSpot-v1.0.22-googlePlay.apk |
Google Play flavor – includes Google Play Services Location |
Installation (sideload)
- Download the APK below.
- On your Android device: Settings → Apps → Install unknown apps → allow your browser or file manager.
- Open the APK and tap Install.
The F-Droid flavor is also available on F-Droid.
VeloSpot v1.0.21
VeloSpot v1.0.21
Added
-
Share a location with other apps — the detail sheets for a custom map pin, an address search result, a bike parking space and a saved favourite now carry a Share action that opens the system share sheet with a universal OpenStreetMap web link (plus the resolved name/address as the first line), so you can send a spot to WhatsApp, Telegram, e-mail, etc. Built on a small
LocationSharer(incore/share, symmetric toImageSharer). Fully localised across all eight supported languages. -
Leaner, cleaner map UI + new ride features — a focused pass that declutters the map and adds practical navigation tools, all localised across the eight supported languages:
- Unified top bar + Settings sheet — the cramped top-bar dropdown menu (which mixed quick actions and settings) is gone. The map now carries just a search field and a single round menu button that opens a tidy Settings bottom sheet, grouping everything into clear sections: Quick actions (favourites, parked bike, rides, round trip), Appearance & map (dark mode, language, map view, layers), Routing (offline routing, about) and a Developer section (the GPS simulator in debug builds). The menu button is tinted when offline routing is active (
SettingsSheet,MapMenuCard,MapScreenUiState). - Slimmer navigation card — the bulky active-navigation card is now a compact, glanceable pill showing distance · ETA and live speed with a round stop button; tapping it expands to reveal the destination name and the route's elevation profile (
MapNavigationOverlay). - Minimal navigation mode — while navigating, all map clutter that isn't part of the trip (other parking spots, saved places, search pins) is hidden, leaving just the route, the destination and the live position for a clean, focused view (
MapMarkerRenderer.minimalNavMode). - Turn-by-turn banner — a top banner now announces the next turn ("In 120 m — Turn left") with an arrow that rotates to point the way. It's derived purely from the route geometry, so it works for both BRouter offline and OSRM online routes, and animates in/out as a turn approaches (
RouteMatcher.nextTurn,NavigationProgress,MapTurnBanner). - Round-trip generator — a new Round trip action generates a circular route that starts and ends at your current position; pick a target distance (5–50 km) and BRouter builds a loop back home (offline routing required). Uses BRouter's native round-trip support (
BRouterEngine.calculateRoundTrip,RoutingRepository.getRoundTrip,RoundTripSheet,NavigationController.startRoundTrip). - Route elevation profile — the expanded navigation pill now shows a compact elevation graph of the route (distance vs. terrain height) with total ascent ↑ / descent ↓, drawn from BRouter's per-node elevation data with a plain Canvas (
RouteElevationProfile). - Cancellable route calculation with progress — while a route is being computed the loading card now shows a progress bar and a running elapsed-seconds counter, plus a Cancel button. Cancelling propagates into the BRouter engine, which aborts its search at the next loop check (
RoutingEngine.terminate) instead of running to completion — handy for long trips you didn't mean to start (BRouterEngine.runEngine,NavigationController.cancelRouteCalculation,MapNavigationOverlay).
- Unified top bar + Settings sheet — the cramped top-bar dropdown menu (which mixed quick actions and settings) is gone. The map now carries just a search field and a single round menu button that opens a tidy Settings bottom sheet, grouping everything into clear sections: Quick actions (favourites, parked bike, rides, round trip), Appearance & map (dark mode, language, map view, layers), Routing (offline routing, about) and a Developer section (the GPS simulator in debug builds). The menu button is tinted when offline routing is active (
-
Offline routing: full Germany/France/Luxembourg download + automatic on-demand tiles — the offline-routing setup now offers two choices instead of silently grabbing one tile: "Download my region" (the single 5°×5° BRouter tile around your current position, ~250 MB) and "Download all of Germany, France & Luxembourg" (the curated set of 12 land tiles covering the three supported countries, ~2–2.5 GB), with a resumable per-file progress indicator (
BRouterSegmentManager.COUNTRY_SEGMENTS/downloadCountrySegments,OfflineRoutingController, two-buttonOfflineRoutingSetupSheet). On top of that, offline navigation is no longer limited to the pre-downloaded tile: when you route to a destination whose tile is missing, the router now downloads the needed tile(s) for that route on demand (RoutingRepositoryImpl→BRouterSegmentManager.ensureSegments) and routes offline, only falling back to the online OSRM router when the download can't happen (no connectivity). Governed by a newOfflineRoutingPreferences.isOnDemandDownloadEnabledflag (default on). Fully localised across all eight supported languages.
Changed
- Faster offline route calculation on long trips: the in-memory segment node cache (
RoutingContext.memoryclass) is no longer left at BRouter's conservative 64 MB default but sized to the device (≈ half the app heap budget, clamped 96–256 MB), so long routes stop thrashing the cache (evict → re-read → re-decode segments from disk); and the standstill start-U-turn correction — which recomputes the entire route a second time — is now skipped beyond ~30 km, where the start spur is negligible relative to the trip and a full recompute is the dominant cost (BRouterEngine). - "Download my region" is bound to your actual position — the single-tile download no longer falls back to a hard-coded default region when there's no GPS fix; it now requires a real location and surfaces
LocationUnavailableotherwise, so it always fetches the tile you're actually in (OfflineRoutingController). - BRouter cycling profiles de-prefer pavements and favour quiet streets in town (
gravel.brf,trekking.brf,PROFILES_VERSION2 → 4) — footways/sidewalks with bike permission are made more expensive and quiet carriageways (residential / living-street / unclassified / service) cheaper, so urban routes stop hugging the pavement next to the road; dedicated cycleways stay preferred for safety.
Fixed
- Saved-place pins now actually show their star — the green saved-place pin (custom pins stored as named favourites) was documented and intended to carry a white star to set it apart from the transient blue custom pin and the red address-search pin, but the star was never drawn. The pin now renders a crisp white five-pointed star on its green body, so saved favourites are instantly recognisable on the map again (
MarkerIconFactory.createSavedPlaceIcon). - Smoother live navigation — no more "kangaroo" hopping — GPS fixes arrive only every few seconds, so easing the puck/camera straight to each fix made it lurch forward then freeze until the next fix. While on-route, the puck is now dead-reckoned: it advances continuously along the route at the rider's measured speed (derived from along-route progress, so it works even when a fix carries no speed) and is gently corrected to the snapped GPS position on every fix (a large drift / reroute hard-resyncs). The result is continuous, fluid motion between fixes (
NavigationManager). While dead-reckoning, the camera/puck also hugs the route more tightly (a shorter position smoothing constant) so curves aren't visibly cut. - Offline routes no longer open with a spurious "make a U-turn" — BRouter used to connect the start waypoint to the nearest network node, which can sit behind the rider, opening the route with an out-and-back hairpin the online router never shows. The rider's live GPS heading is now passed to BRouter as a
startDirectionhint; for a start from a complete standstill (no heading) a lightweight second pass detects a start that heads away from the destination and re-runs BRouter with the route's own forward direction — BRouter then drops the spur itself, so the geometry stays real on-road (no path surgery) (BRouterEngine). - Offline routes start on the carriageway, not the sidewalk — BRouter was snapping the start/end onto the nearest way, which in town is often a
footway=sidewalk, opening the route with a pavement + crossing detour before reaching the road. The cycling profiles now declarenoStartWay=footway,sidewalk, so the start/end snaps to the carriageway like the online router does (and cycling on sidewalks isn't suggested) (gravel.brf,trekking.brf). - Navigation marker/camera heading no longer skewed by sub-metre route stubs — BRouter occasionally emits a tiny (~0.1 m) first segment, and the camera/marker took its heading from that single segment, pointing slightly off the route at the start. The route heading is now sampled ~15 m ahead along the polyline, so a degenerate stub can't skew it; the marker also seeds to the route's forward direction at the very start instead of a stale bearing (
RouteMatcher,NavigationManager).
📥 APK Variants
| File | Description |
|---|---|
VeloSpot-v1.0.21.apk |
F-Droid flavor – no Google services, ready to sideload; this is the APK F-Droid reproducibly verifies |
VeloSpot-v1.0.21-googlePlay.apk |
Google Play flavor – includes Google Play Services Location |
Installation (sideload)
- Download the APK below.
- On your Android device: Settings → Apps → Install unknown apps → allow your browser or file manager.
- Open the APK and tap Install.
The F-Droid flavor is also available on F-Droid.
VeloSpot v1.0.20
VeloSpot v1.0.20
Added
- GitHub community health files — added the standard community documents to meet the GitHub Community Standards and make the project easier and safer to contribute to: a Code of Conduct (
CODE_OF_CONDUCT.md, Contributor Covenant v2.1), a Contributing guide (CONTRIBUTING.md, tailored to VeloSpot'sgooglePlay/fdroidflavours, JDK 17, BRouter submodule and the real Gradle build/test commands), a Security policy (SECURITY.md, private vulnerability reporting via GitHub Security Advisories), structured issue templates (.github/ISSUE_TEMPLATE/— bug report, feature request and a chooser config), a pull request template (.github/PULL_REQUEST_TEMPLATE.md) and a funding link (.github/FUNDING.yml, Buy Me a Coffee). - Comprehensive ride statistics dashboard in "My rides" — the My rides sheet now leads with a rich, collapsible Statistics card that crunches your whole ride history into every metric a data nerd could want, all derived purely from the already-stored rides (no extra storage). It's collapsed by default (tap the header to expand) so the ride list stays uncluttered, and groups the numbers into five sections:
- Totals — ride count, total distance, total & moving time, cumulative elevation gain ↑ and loss ↓.
- Averages — Ø distance, Ø duration, Ø speed (distance-weighted across moving time) and Ø climb per ride.
- Personal records (highlighted chips) — top speed, longest ride, longest duration, best Ø speed and biggest single climb.
- Activity — first-ride date, distinct active days, current & longest day streak (consecutive calendar days), plus rides + distance this week and this month.
- Fun facts — CO₂ saved vs. an average car (~120 g/km), calories burned (~30 kcal/km) and your share of a full lap around the Earth (40,075 km).
- Pure, side-effect-free computation (
computeRideStatistics) feeding a chip-based, wrapping flow layout (RideStatisticsSection). Fully localised across all eight supported languages.
- Share a recorded ride as a "VeloSpot Wrapped" card — a new Share ride button in the ride detail sheet ("My rides" → tap a ride) opens a preview dialog that renders the ride as a bold, vertical 1080×1350 (4:5) social-media tile, ready for WhatsApp, Telegram, Instagram and the like. The card shows a real 2D map cutout of the route behind a glowing GPS track, the headline distance, the date and the key ride statistics (time, Ø speed, elevation gain, max speed).
- Off-screen, deterministic rendering — the card is drawn directly onto a
Bitmapwith the platformCanvas(no Compose lifecycle, no charting dependency) on a background thread (RideShareCardRenderer), so it is fully reproducible. The tile has slightly rounded corners. - Map cutout that lines up with the route —
RideRouteMapSnapshotteruses MapLibre'sMapSnapshotterto render the ride's bounding box off-screen (no on-screenMapView); track points are projected with the snapshot's ownpixelForLatLng, so the polyline sits exactly on the streets. OSM attribution is drawn into the panel. If the snapshot can't be produced (offline, error or 8 s timeout), the card falls back to a clean themed-gradient panel, so sharing always works. - Colour theme picker with live preview — six hand-picked themes (Aurora, Sunset, Forest, Ocean, Berry, Midnight) restyle the gradient, accent and route/marker colours; tapping a swatch re-renders the preview live. The (expensive) map snapshot is fetched once and reused across theme changes. The route uses a contrast halo + a theme-coloured glow over a tinted scrim so it stays legible on any basemap.
- Privacy-friendly sharing — the image is written to the app's private cache and handed to the system share sheet via a
FileProvider(ImageSharer); nothing is uploaded by VeloSpot itself — the image only leaves the device once you pick a target app. Fully localised across all eight supported languages.
- Off-screen, deterministic rendering — the card is drawn directly onto a
- Ride recording keeps running in the background — with a notification, a Quick Settings tile and a home-screen widget — recording a ride no longer freezes the moment you leave the map screen. The recording lifecycle (the GPS feed, the live stats and the persistence) moved out of the
viewModelScopeinto a process-level@SingletonRideRecordingManager, paired with alocation-typed foreground service (RideRecordingService) that keeps the process + GPS alive while the app is backgrounded or closed.- Persistent notification — while recording, an ongoing notification shows the live time • distance • speed and offers Stop & save and Discard actions, so you can finish the ride straight from the shade without reopening the app. Tapping it returns to the map. A dedicated low-importance notification channel is created on Android 8+, and
POST_NOTIFICATIONSis requested on Android 13+ when a recording starts (the recording still runs if denied — only the notification is hidden). - Quick Settings tile — a
RideRecordingTileServicetile starts/stops a recording with a single tap from the notification shade, reflecting the active/inactive state with label + subtitle; it bounces you into the app to grant location permission if it's missing. - Home-screen widget — a
RideRecordingWidget(AppWidgetProvider) with a single start/stop control that shows the live time + distance while recording. Both the tile and the widget share the very same singleton manager as the in-app FAB, the notification and the service, so the recording state stays consistent across every entry point; the manager pushes state changes to them (AppWidgetManagerrefresh broadcast +TileService.requestListeningState) even while the app's UI is closed. - GPS stays alive only while needed —
MapViewModelno longer tears down location updates on background/onClearedwhile a recording is active (the manager owns the GPS radio then, at high accuracy); when nothing is recording the existing battery-friendly teardown is unchanged. New permissions:FOREGROUND_SERVICE,FOREGROUND_SERVICE_LOCATION,POST_NOTIFICATIONS. Works in both the Google-Play (Fused) and F-Droid (LocationManager) flavours via the sharedLocationRepository. Fully localised across all eight supported languages.
- Persistent notification — while recording, an ongoing notification shows the live time • distance • speed and offers Stop & save and Discard actions, so you can finish the ride straight from the shade without reopening the app. Tapping it returns to the map. A dedicated low-importance notification channel is created on Android 8+, and
Changed
- Privacy policy: disclose the ride-share card — the new share-card feature is now documented across
PRIVACY.md,docs/PRIVACY.mdanddocs/privacy.html: a new section "3.4 Ride Sharing (Share Card)" explains that the card image is generated and cached locally and only leaves the device when you pick a target app in the Android share sheet (after which that app's policy applies), and that drawing the map cutout loads OpenFreeMap tiles for the ride's area (the OpenFreeMap row and notes were updated accordingly). - Privacy policy: disclose the one-time BRouter offline-routing download — the policy previously implied BRouter is entirely offline, but the one-time download of the offline routing data fetches map-segment tiles from
brouter.de(the requested 5°×5° tile name reveals the rider's approximate region + IP). This connection is now listed in the third-party services table and the BRouter note is clarified acrossPRIVACY.md,docs/PRIVACY.mdanddocs/privacy.html; theINTERNETpermission description was updated accordingly.
Removed
- Dead "parking photos" feature — the parking-photo UI was never functional: the bundled OpenStreetMap dataset always stores
imageUrl = NULL(the extraction script never populates it), so theAsyncImageblock inSelectedSpaceSheetcould never render and no image request was ever made. Removed the dead photo UI and the now-unused Coil image-loading dependency (coil-compose, plus its version-catalog and attribution entries), and stripped the misleading "parking photos" mentions from the README, the store descriptions and the privacy policy. The dormantimageUrlcolumn is intentionally kept in the Room schema to avoid a destructive migration / regenerating the bundled databases.
Fixed
- Navigation now reliably detects arrival and ends itself — for every destination — turn-by-turn navigation previously only auto-finished when riding to a genuine bike parking spot (via the auto-park path), so navigating to an address-search result, a saved place, a custom map pin or the parked bike would keep running indefinitely even after you'd arrived; you had to stop it by hand. On top of that, arrival detection relied solely on the along-route remaining distance and was suppressed entirely while off-route, so pulling onto the pavement at the door (a couple of metres of GPS noise) or a BRouter route that stops just short of the destination could leave navigation stuck. Arrival is now handled centrally for all destinations (
maybeHandleArrivalinNavigationController):- Works off-route — when the rider is off the route line near the destination, a straight-line (crow-flies) distance from the raw GPS fix to the actual destination coordinate is used as a fallback, independent of the route, so arriving a few metres beside the line still registers; while on-route the precise along-route remaining distance is still used.
- Debounced — two consecutive fixes inside the 25 m arrival radius are required before navigation ends, rejecting a single stray GPS sample that briefly snaps onto the destination.
- Ends every navigation — reaching a real parking spot still auto-parks the bike with the "arrived — bike parked here" confirmation; reaching any other destination now ends navigation with a new generic "you've arrived at your destination" confirmation. Covered by updated and new unit tests; the new string is localised (English + German).
- Cyclist marker no longer disappears behind 3D buildings — in the tilted 3D view the live-loc...
VeloSpot v1.0.19
VeloSpot v1.0.19
Added
- Cyclist avatar as the live-location marker — the plain blue location dot (and the green navigation heading-arrow puck) is replaced by a full-colour 2D cyclist sprite shown both on the idle map and during turn-by-turn navigation. It's a clean 3rd-person / top-down rider (helmet, jersey shoulders, arms reaching to the handlebar, frame and two wheels with rims, legs on the pedals) drawn with a soft contact shadow and a thin white keyline so it pops on any basemap (new
R.drawable.ic_cyclist_avatar, reworkedcreateLocationMarkerIconwith a stamped outline). The sprite is rendered as an upright billboard (iconRotationAlignment/iconPitchAlignment = viewport) so the tilted 3D navigation map never flattens or squishes it; since the navigation camera keeps the heading pointing "up", the rider naturally appears from behind for a true 3rd-person feel. The now-unusedcreateNavigationArrowIconarrow puck was removed. - Ride tracking — record your ride as a timeline ("My rides") — riders can now record a ride and review it afterwards with full statistics:
- One-tap recording from a dedicated red record/stop FAB stacked above the My location button (keeps the already-busy menu uncluttered). While recording, a compact live-stats card at the top of the map shows the running time, distance and current speed, with Stop and Discard actions, and the travelled track is drawn live on the map as a coloured polyline (new
velospot-tracksource/layer). - Automatic recording during navigation — starting turn-by-turn navigation auto-starts a recording for the whole trip and saves it when navigation ends (or on auto-park arrival); a manually-started recording is never interrupted by navigation.
- Persistent history in a dedicated, isolated Room database (
RidesDatabase/recorded_rides, independent of the parking and saved-places stores) — a new "My rides" menu entry opens a timeline list of past rides (date, distance, duration, average speed). - Ride detail with statistics + speed timeline — tapping a ride redraws its track on the map and opens a sheet with moving time, max speed, elevation gain/loss and a Canvas-drawn speed-over-time chart, plus a delete action.
- Elevation support — elevation gain/loss now comes from BRouter's accurate terrain data (the SRTM elevation baked into its
.rd5segment files, read per route node —RoutePoint.elevationMeters) whenever a ride is recorded while navigating offline; the rider's live position is snapped to the route and its terrain elevation is fed to the tracker instead of the very noisy raw GPS altitude. For manual rides (or the online OSRM fallback, which carries no elevation) it falls back to GPS altitude, now low-pass filtered with a 3 m dead-band so a parked bike no longer racks up phantom metres (GeoCoordinate.altitudeMeters, pure unit-testedRideTracker). GPS altitude is requested whenever navigation or a recording is active. Fully localised across all eight supported languages and covered by newRideTrackerunit tests.
- One-tap recording from a dedicated red record/stop FAB stacked above the My location button (keeps the already-busy menu uncluttered). While recording, a compact live-stats card at the top of the map shows the running time, distance and current speed, with Stop and Discard actions, and the travelled track is drawn live on the map as a coloured polyline (new
- Follow camera you can break and re-lock — for navigation and recording — the map now stays centred on the live position not only during turn-by-turn navigation but also while recording a ride. In both modes you can freely pan/zoom the map by hand mid-trip: a touch gesture unlocks the follow camera (the heading arrow / location keeps tracking and route progress keeps updating), and a dedicated re-centre button appears on the map (an extended FAB stacked clear of the location/record buttons) that snaps the camera back onto you and resumes following until you pan again. The button disappears and the lock is dropped automatically once neither navigation nor a recording is active. Navigation's per-frame 3D camera glides smoothly back from wherever you left the map (
MapViewModel.isFollowingLocation,NavigationManager.setFollowing, gesture detection viaREASON_API_GESTURE). Fully localised across all eight supported languages.
Changed
- Faster, interruptible navigation start-up tilt-in — when navigation begins, the camera's "tilt-in" into the 3D view eases in noticeably faster (zoom/pitch time constants scaled by 0.28) and snaps the last imperceptible bit so it finishes crisply instead of lingering on the exponential tail. Crucially the intro is now interruptible: a pan/zoom touch is detected directly on the map and instantly cancels the intro and breaks follow, so the map reacts to your gesture without any wait (no more hard input lock). Follow resumes via the re-centre button (
NavigationManagerintro phase + direct gesture handling). - Ride tracking — GPS drift filtering + moving-average smoothing — recorded tracks no longer show the long "spike" lines and implausible peak speeds (e.g. 95 km/h on a leisurely ride) that GPS multipath produces in urban canyons / under 3D building shadow. Each fix now carries its horizontal accuracy (
GeoCoordinate.accuracyMeters/TrackPoint.accuracyMeters, read fromLocation.accuracyin both the Google-Play and F-Droid location sources) and the pureRideTrackerrejects a fix outright when its accuracy is worse than 30 m or when the implied segment speed exceeds a plausible bike bound (lowered from ~108 to ~90 km/h). Rejected fixes are no longer appended to the track, counted towards distance/max-speed, or drawn on the live polyline (theRideTrackingControllernow mirrors only accepted points onto the map). Accepted positions are additionally passed through a small moving-average window (3 fixes) so the residual side-to-side jitter is smoothed out, while distance/speed/moving-time are still measured on the raw fixes so the totals stay accurate. Covered by newRideTrackerunit tests. - Favourites isolated in their own database (no more accidental wipes) — user favourites used to share the asset-seeded
BikeParkingDatabase, which is configured withfallbackToDestructiveMigration; any future parking-schema bump without an explicit migration would have silently dropped the favourites along with the parking data. Favourites now live in a dedicated, isolatedFavoritesDatabase(mirroringSavedPlacesDatabase/RidesDatabase) that a parking-data migration can never touch. Existing favourites are copied over once from the legacy table on first launch (ATTACH … INSERT OR IGNORE … SELECT, guarded by a one-time flag); the legacy parking-database schema is left byte-for-byte unchanged so the bundled SQLite asset still validates. - Per-API HTTP clients with appropriate timeouts — the single shared
OkHttpClientwas split into dedicated,@Namedclients inNetworkModule: a Nominatim client (carrying the new rate limiter), a segments client with a 5-minute read timeout for the 100 MB+ BRouter offline-routing downloads (so a slow-but-progressing download is no longer aborted at 30 s), and the default client for OSRM/everything else. - OSRM DTOs use Moshi code-gen adapters —
OsrmRouteResponseDto/OsrmRouteDto/OsrmGeometryDtonow carry@JsonClass(generateAdapter = true), matching the Nominatim DTOs (faster parsing, ProGuard-safe, no reflection fallback). - Single source of truth for the OSRM host — the OSRM bicycle URL is no longer duplicated;
OsrmApi.getBikeRoutetakes a relative path resolved against the one base URL configured inNetworkModule, removing the constant-drift risk betweenNetworkModuleandRoutingRepositoryImpl. - Faster ride-track drawing during recording — the live ride polyline used to be rebuilt from scratch on every GPS fix (re-mapping the entire, ever-growing track — O(n²) work and N fresh
RoutePointallocations per ride), which churned the GC and triggered redundant recompositions on long rides. Since each fix appends exactly one point, the track now grows by a single incremental append. The route-elevation lookup was likewise changed from a full route rescan per fix to a cursor that resumes from the last match (amortised ~O(1)), so accurate BRouter terrain elevation no longer costs an O(route) scan on every fix. - Smoother navigation on long routes — skip redundant route-line rebuilds — during live navigation the travelled/remaining route split (
NavigationManager.renderRouteSplit) was rebuilt from scratch on every GPS fix (~1 Hz), reconstructing the full polyline (O(route)) and re-uploading it to the GPU even when the rider had barely moved within the same segment — a source of frame drops on long routes and lower-end devices. The rebuild is now skipped while the rider stays on the same route segment and the snapped position has moved less than 12 m (the imperceptible shift of the single split-boundary point); the smoothly-eased location puck, redrawn every frame, still carries the live motion. Forced renders on route start and after a style reload keep the line correct. MapViewModelsplit into focused controllers (Single Responsibility) — the ~1170-line map view-model that mixed address search, offline routing, ride tracking, saved places, the parked bike and live navigation was decomposed into six small, independently testable controllers (AddressSearchController,OfflineRoutingController,RideTrackingController,SavedPlacesController,ParkedBikeController,NavigationController), shrinking the view-model to ~650 lines. Each owns its own state; the view-model now orchestrates and re-exposes their flows, and cross-feature effects (camera, selection reset, location-accuracy, toasts, geocoding, route calculation, auto-park-on-arrival, off-route reroute and the debug GPS simulator) are passed as callbacks. Pure internal refactor — the public view-model API and all exposed UI state are unchanged.
Removed
- Dead Overpass live-fetch code — the unused
OverpassApi,OverpassDtoandOverpassMapper(leftovers from the pre-bundled-datab...
VeloSpot v1.0.18
VeloSpot v1.0.18
Added
- More countries — France 🇫🇷 and Luxembourg 🇱🇺 bike-parking coverage — the bundled OSM parking dataset is no longer Germany-only. Two additional pre-built SQLite assets (
app/src/main/assets/bike_parking_france.db,bike_parking_luxembourg.db, generated by the samescripts/extract_osm_parking.py) are now merged into the single on-device database, so the map's bounding-box queries transparently return parking spots in these countries too. Implemented as a data-only Room migration (BikeParkingDatabasev3 → v4) that bulk-imports the extra datasets into the Germany-seededbike_parking_spacestable on first open / app upgrade — preserving existing favorites and Germany data (INSERT OR IGNORE, OSM element IDs are globally unique). - Multi-country address search with home-country bias — the Nominatim forward-geocoding search now covers all bundled countries (
countrycodes=de,fr,lu) instead of Germany only. When the user's location is known, results are biased toward their surroundings via aviewbox(≈ 110 km half-span) withbounded=0, so matches in the country the user is currently in rank first without excluding the other countries (NominatimApi.search,NominatimGeocoder.searchAddress,MapViewModel.onSearchQueryChanged). - About screen — a new "About" entry in the menu opens a sheet showing the app name, a link to the website (https://velospot.app), the per-country dataset status (Germany 08.08.2026, France & Luxembourg 18.06.2026) and a link to the privacy policy (https://velospot.app/privacy). Fully localised across all eight supported languages (
AboutSheet). - Keep the screen awake during navigation — while a route is being navigated the display no longer dims or locks; the
keepScreenOnflag is set on the map view for the duration of active navigation and cleared automatically when navigation ends (MainMapScreen). - "Where did I park my bike?" — remember your parked-bike location — a new feature lets riders save exactly where they left their bike and find it again later:
- Park your bike from the map menu ("Park bike here" drops the marker at your current GPS position) or from any tapped custom pin (a "Park bike here" action in the pin sheet), with a confirmation toast.
- Persistent amber bike marker — a distinctive amber pin carrying a white bike glyph (
createParkedBikeIcon(), newvelospot-parked-bikesource/layer/image) stays on the map across app restarts until you collect the bike, clearly distinct from the green saved-place star, blue custom pin and red search pin. - Find-my-bike sheet — tapping the marker (or the menu entry, which then reads "My parked bike") opens a sheet showing how long ago you parked, the reverse-geocoded address, and the live distance from your current position, with Navigate to my bike (full in-app routing) and I picked up my bike (clears the marker) actions.
- Lightweight, isolated persistence — exactly one parked bike is stored at a time in its own
SharedPreferences-backed store, exposed reactively (ParkedBikeRepository+ impl, new domain modelParkedBike, Hilt provider andMapViewModelwiring:parkBikeAtCurrentLocation,parkBikeAt,showParkedBike,navigateToParkedBike,pickUpBike). Fully localised across all eight supported languages and covered by newMapViewModelunit tests. - Auto-park on arrival — when you navigate to a genuine bike parking spot, reaching it is detected automatically (the live route progress drops below a 25 m arrival radius) and the bike is parked at the destination without any extra tap, ending navigation and dropping the persistent marker with an "arrived — bike parked here" confirmation (
maybeAutoParkOnArrivalinMapViewModel, reusing the existingNavigationManagerroute-progress tracking; only real map parking spots auto-park — synthetic destinations like custom pins, address-search results, saved places and the parked bike itself never do). Covered by two new unit tests. - Bigger, more legible parked-bike pin — the amber marker icon was scaled up (~18 %) so it reads clearly at a glance, and the parked-bike detail sheet's action buttons now match the standard button height used across the saved-place and custom-pin sheets (the bike-rack detail sheet's previously taller buttons were aligned to the same default).
- No more pin overlap at the parked spot — when the bike is parked on a real parking spot, that spot's marker is now hidden so only the single amber parked-bike pin shows (the spot reappears once the bike is picked up), and the parked-bike pin takes click priority during hit-testing — so tapping it always opens the "my parked bike" sheet (with parked-ago time, distance, navigate and "I picked up my bike") instead of falling through to the underlying parking-spot sheet. Implemented via a location match in
buildParkingFeatures(isParkedAt, ~12 m radius) and a reorderedqueryRenderedFeatureschain inMapInitializer. - Consistent dismiss action on the parking-spot sheet — the bike parking-spot detail sheet (
SelectedSpaceSheet) now offers a bottom "Remove pin" text button that closes the sheet, mirroring the custom-pin and address-search sheets so dismissing a marker's detail view is consistent across the whole map.
- Marker clustering for the parking layer — major map performance boost — at city-level zoom the ~100 000 bike-parking markers are now aggregated into native MapLibre clusters instead of being drawn as thousands of overlapping individual symbols. The parking
GeoJsonSourceis created with clustering enabled (clusterMaxZoom = 13,clusterRadius = 60); the existing icon layer is filtered to non-clustered points (!has("point_count")), and two new layers render the cluster bubble (CircleLayerwith a step-scaled radius) and its count label (SymbolLayer,point_count_abbreviated, "Noto Sans Bold"). Tapping a cluster animates the camera to the source'sgetClusterExpansionZoom, so it smoothly breaks apart. The currently selected spot and the active navigation destination are rendered on a dedicated non-clustered highlight layer (velospot-parking-highlight-*) so they always stay visible on top, never disappearing into a cluster. This drastically reduces the number of rendered symbols when panning/zooming dense areas. Implemented inMapStyleLayers.kt,MapMarkerRenderer.kt(newClusterRenderStyle, split bulk/highlight feature building) and the click handling inMapInitializer.kt. - Live 3D turn-by-turn navigation — navigation is now a real, mitlaufende 3D experience similar to Google Maps, driven by a new self-contained
NavigationManagerand a pure, unit-testedcore/navigationpackage:- 3D follow camera — once navigation starts the camera centres on the live GPS position with a fixed 60° pitch, a speed/turn-dependent zoom (closer at standstill / before turns, further out while cruising) and a bearing that smoothly follows the direction of travel. A
Choreographerframe loop interpolates position, bearing, zoom and tilt every frame (frame-rate-independent exponential smoothing) so the motion stays ruckelfrei despite the ~3 s GPS cadence. - Map matching (snap-to-route) — each raw GPS fix is snapped onto the active BRouter polyline (
RouteMatcher) so the heading arrow rides the road instead of jittering beside it; the location puck is a rotating navigation arrow (IMG_LOCATION_NAV) aligned to the live heading. - Live route progress + ETA — the navigation card now shows the dynamically shrinking remaining distance and a remaining-time estimate (ETA), recomputed on every fix; the already-travelled part of the route is greyed out while the remaining part stays in the theme colour (split
velospot-route/velospot-route-traveledline layers). - Off-route detection & auto-reroute — straying more than ~30 m from the route for several consecutive fixes triggers a silent BRouter recalculation from the current position to the destination (throttled, self re-arming), with an "off route – recalculating…" hint in the overlay.
- 3D buildings — a
fill-extrusionlayer pulls the OpenMapTiles building footprints into 3D (usingrender_height/render_min_height); enabled during navigation and for the 3D resting view. - GPS heading + speed —
GeoCoordinatenow carries optionalbearing+speedMetersPerSecond, populated by both flavorLocationRepositoryImpls, feeding the heading arrow and the speed-dependent zoom.
- 3D follow camera — once navigation starts the camera centres on the live GPS position with a fixed 60° pitch, a speed/turn-dependent zoom (closer at standstill / before turns, further out while cruising) and a bearing that smoothly follows the direction of travel. A
- 2D / 3D map view switch — a new "Map view" entry in the menu opens a sheet with a segmented 2D/3D selector (animated preview tiles). The choice is persisted (
NavigationModePreferences) and applied live to the resting map (flat north-up vs. 45° tilt + 3D buildings); after navigation ends the map returns to the saved perspective. Active navigation always uses the full 3D camera regardless of the setting. - GPS route simulator (debug) — a debug-only "Simulate route" menu entry drives a synthetic GPS track along the active BRouter route (with bearing + speed, snap/off-route compatible), so the whole live-navigation pipeline can be tested from the couch without moving. Backed by a unit-tested
RouteSimulator; real GPS updates are ignored while simulating.
Fixed
- Crash on first open with the multi-country database — bumping the database to v4 made Room run its full post-migration
TableInfovalidation, which then failed because theidx_parking_lat_lonindex present in the bundled assets was not declared onBikeParkingSpaceEntity(Migration didn't properly handle: bike_parking_spaces). The index is now declared on the entity so the expected schema matches the seeded database (and it speeds up the bounding-box queries). - Parking pins no longer get stuck at the wrong size — the zoom-bucket-scaled parking marker icons (
IMG_NORMAL…IMG_MUTED_SELECTED...