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 check runs after every real ride is saved (BikeProfilesRepository.evaluateServiceDue+BikeServiceNotifieron its own low-priority notification channel; mock rides excluded), and changing the interval re-arms the milestones. Room migration v5 β v6 adds the two service columns. 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
-
Plan multi-waypoint routes, save them & race your own leaderboard β VeloSpot can now build, keep and re-ride custom routes, Komoot/Strava-style but fully offline and account-free:
- Route planning β a new Plan a route entry drops you into a planning mode where each tap on the map adds the next waypoint in order. Every chosen stop now gets a numbered pin on the map (the last stop drawn in a distinct amber colour so you can immediately see where the route currently ends). A non-modal panel at the bottom (the map stays tappable) shows the live routed distance and a rough calorie estimate, lets you undo the last stop, and saves the route under a name once at least two stops make a routable line. Each stop is auto-labelled with its reverse-geocoded place. The preview polyline is drawn on the map as you build it.
- My routes β saved routes live in a dedicated, isolated Room store (
PlannedRoutesDatabase, independent of the parking / rides / places stores) and are listed in a My routes sheet with their distance, stop count and ascent. From there you can ride a route, ride it reversed, open its leaderboard, or delete it. Multi-waypoint routing chains the pairwise BRouter (offline) / OSRM (fallback) legs into one continuous route, honouring the active profile and hilliness (RoutingRepository.getBikeRouteVia). - Leaderboard (personal best list) β riding a saved route auto-records the ride and, on finish, turns it into a leaderboard attempt ranked by elapsed time (fastest first, the top run flagged as your personal best). Since VeloSpot has no accounts/cloud, the board is your own attempts compared against each other. Besides time, each entry shows the natural secondary figures riders compare β average speed and the date β while distance and ascent are shown as fixed context (they don't change per direction, so they aren't ranked).
- Ride it backwards β on its own board β a route can be ridden in reverse (the waypoints are simply reversed before routing). Because reversing a route swaps its climbs and descents (and therefore the achievable time), reverse rides are kept on a separate leaderboard via a Forward / Reverse tab switch, so mixing the two directions never distorts the ranking.
- Built on a pure, JVM-unit-tested core (
RouteLeaderboardfor ranking / direction split / building an attempt from a finished ride,RouteGeometryStatsfor the route's ascent/descent), a focusedRoutePlanningControllerdecomposed out ofMapViewModel, and new domain models (PlannedRoute,RouteWaypoint,RouteAttempt). Mock (simulator) rides never create attempts. Fully localised across all eight supported languages. Covered byRouteLeaderboardTestandRouteGeometryStatsTest.
-
Best efforts (peak table) β the detailed ride analysis screen now lists your best efforts, just like the big cycling apps: the fastest time over standard distances (1 / 5 / 10 / 20 / 40 / 100 km) and the furthest distance within standard time windows (1 / 5 / 10 / 20 / 60 min), each with the average speed it was ridden at. Only the rows the ride was long/long-enough for are shown. Computed purely off the main thread from the GPS track with an O(n) sliding window over cumulative distance/time (
computeBestEffortsincore.analysis, with GPS "teleport" glitches clamped so they can't fake a record), covered byBestEffortsTest. Distances/times use the universal km / km/h / clock formatting, so no per-language strings are needed beyond the two table titles, which are localised across all eight supported languages. -
Ride achievements & badges β the detailed ride analysis screen now celebrates what you accomplished with a row of earned badges, right under the map. Per-ride milestones include Half-century / Century (50 / 100 km), Hill climber / Summit seeker (500 / 1000 m of ascent), King of the Mountain (a categorised climb, showing its HC / Cat 1β4 grade), Speed demon (40 km/h), Endurance (2 h riding), Calorie crusher (1000 kcal) and time-of-day Early bird / Night owl badges β each shown with a coloured icon and its achieved value. On top of those, personal records (longest ride, most climbing, fastest pace, top-speed record) are awarded when a ride beats every other recorded ride, highlighted with a gold trophy and accent. All earned purely from the ride and your history by a JVM-unit-tested engine (
evaluateAchievementsincore.analysis, covered byRideAchievementsTest); mock rides earn nothing and are excluded from record comparisons. Localised across all eight supported languages. -
Ride analysis map with an animated replay β the detailed ride analysis screen now opens with an embedded interactive map of the ride: the track is painted on a green β red speed ramp, and value markers are dropped along it as clean labelled speech bubbles β top speed, the steepest gradient (%), the high point (elevation) and longer stops (their duration) β plus plain start / finish dots. A play button + scrub slider drive an animated replay that retraces the whole ride with the app's cyclist avatar (pedalling by distance, foot-down at stops, turned to the travel heading) β sampled by elapsed time, so it slows and lingers exactly where the rider did. The embedded map is fully interactive even inside the scrolling screen. All geometry (polyline, markers, time-spaced replay frames) is derived purely off the main thread in
core.analysis(buildRideMapData, covered byRideMapDataTest) and rendered with self-contained MapLibre sources/layers in a newRideReplayMapcomposable (reusingRideSpeedSegments,RideMaxSpeedPoint,createSpeedBubbleIconand the cyclistcreateLocationMarkerIcon). The kilometre-split list on the screen collapses behind a "show all" toggle past eight rows. Only one MapLibre map is ever live at a time β navigating here disposes the main map'sMapView(and recreates it on return) β so the embedded map is cheap. Localised across all eight supported languages. -
Detailed ride analysis screen β a recorded ride's detail sheet now has an "Open detailed analysis" button that pushes a dedicated, full-screen analysis on top of the map. This is the first real second screen, so the app gained a proper Compose
NavHost(VeloSpotNavHost,mapβride_analysis/{rideId}); the map back-stack entry β and itsMapViewModelβ survive while the analysis is open, and the branded splash no longer replays when returning (itsshowSplashflag is nowrememberSaveable). The screen (RideAnalysisScreen+RideAnalysisViewModel, the ride loaded reactively by id) shows headline distance/duration, a tile grid (avg/max speed, moving/stopped time, ascent/descent, calories, average power, stop count & longest stop), the reused elevation profile, per-kilometre splits (bars sized by average speed, fastest/slowest km tinted), a speed distribution histogram, categorised climbs (HC / Cat 1β4 difficulty badges with length, ascent, average/max gradient and VAM), a gradient distribution chart (distance ridden per slope band), a moving-vs-stopped donut and a GPS track-quality summary. All metrics come from a pure, JVM-unit-testedanalyzeRide()/detectClimbs()pass over the captured GPS track (core.analysis, computed off the main thread), covered byRideAnalysisTest. The average-power figure reuses the calorie physics model's mechanical work (work / moving time). Localised across all eight supported languages. Later phases add an animated map replay and start/finish/top-speed/stop/km markers. -
Realistic ETA and a calorie estimate for planned routes β offline (BRouter) routes now use BRouter's own kinematic travel time for the ETA instead of a flat per-profile speed, so the estimate accounts for the rider + bike mass, power, air drag, rolling resistance and the climb profile (a hilly route now reads slower than a flat one of the same length). The same physics pass also yields the route's mechanical work, surfaced as a rough calorie estimate (the well-known "β 1 kJ of work β 1 kcal burned" rule of thumb) in the navigation preview card beneath the elevation profile. Read straight from BRouter's
OsmTrack(getTotalSeconds()/energy) inBRouterEngineand carried onBikeRoute.energyJoules/estimatedKcal; the live navigation ETA β which derives its average speed from the route's time β is now BRouter-consistent. The OSRM online fallback is unaffected (it provides no energy figure). Localised across all eight supported languages. -
Calories burned β per ride and across your whole history β every recorded ride now shows an estimated calorie burn in its detail sheet (under the elevation stats), and the My rides statistics dashboard's Calories total is now the sum of those per-ride estimates. The figure comes from a small physics model (
estimateRideCalories) that mirrors the planned-route one: mechanical work = rolling resistance + aerodynamic drag + climb (C_rrΒ·mΒ·gΒ·d + Β½Β·ΟΒ·CdAΒ·vΒ²Β·d + mΒ·gΒ·ΞhβΊ), with the "β 1 kJ of work β 1 kcal burned" conversion and the same constants as the bundled BRouter trekking profile (90 kg,C_r0.01, CdA 0.225). This replaces the previous flat 30 kcal/km total, so the number now reflects how hilly and how fast you actually rode (only net ascent costs energy; mock rides stay excluded). Covered byRideEnergyTestand updatedRideStatisticsTest. Reuses the existing localised Calories label across all eight supported languages. -
Elevation profile for recorded rides β a recorded ride's detail sheet now draws a compact elevation profile (the same chart shown at the start of navigation for a planned route), above the speed-over-time chart. The reusable
RouteElevationProfilewas generalised into a shared renderer with a newRideElevationProfilevariant that builds the curve from the ride's captured GPS altitude β low-pass filtered to tame GPS jitter (matching the recorder's own altitude smoothing) β while showing the ride's stored ascent/descent so the figures stay consistent with the elevation stat boxes. The section only appears when the ride actually captured altitude (β₯ 2 samples). Localised across all eight supported languages (ride_elevation_chart_title).
Changed
- Refreshed bundled OSM parking data (Germany, France, Luxembourg) & a one-shot extractor β the three pre-built parking assets (
app/src/main/assets/bike_parking_{germany,france,luxembourg}.db) were regenerated from the latest Geofabrik extracts, and the About sheet's per-country "data status" dates were bumped accordingly. The generator (scripts/extract_osm_parking.py) was reworked from a Germany-only tool into a multi-country, run-and-forget script: a single invocation downloads the latest PBF for each configured country (--countriesto subset), filters theamenity=bicycle_parkingnodes via the fast C++FileProcessor/TagFilterpath, writes each country's asset DB, deletes the multi-GB downloads afterwards (--keep-pbfto retain), and finally rewrites theDATA_DATE_*constants onAboutSheet.ktso the displayed dataset dates always match what was just built. Also made robust on Windows (UTF-8 stdout so redirecting the output to a log no longer crashes on the status glyphs) and each country now carries its ownsourceLayer(osm_germany/osm_france/osm_luxembourg). - Map actions speed-dial moved to the right edge and opens as a half-circle β the actions FAB (plan a route, round trip, my routes, my rides, park bike, favourites) is no longer anchored bottom-start, where it was awkward to reach with a bar/spider phone mount clamping the bottom of the device. It now sits centre-end (right edge, vertically centred) and, when tapped, fans its labelled mini-FABs out over a half-circle to the left (top β left β bottom) instead of stacking them in a vertical column. This keeps it clear of the bottom-right location / record FABs and within easy one-handed reach (
MapActionsSpeedDial). - Shared ride card is now a plain rectangle β the "VeloSpot Wrapped" share image no longer bakes in rounded (transparent) corners. Social apps crop/round shared images themselves, so the baked-in rounding was redundant and looked off (visible transparent notches on some backgrounds); the card is now a clean, fully opaque rectangle (
RideShareCardRenderer). - Home-screen widget now has a real Start/Stop button and a live recording timer β the ride-recording widget was reworked from a plain tap-anywhere text label into a clear control: it now shows a filled Start/Stop button (green Start ride with a play icon β red Stop with a stop icon) plus a live-ticking elapsed-time display while a recording is running. The running duration is driven by a native
Chronometerin the widget layout, so it counts up on its own in the launcher process without needing a per-second broadcast (battery-friendly), while the subtitle shows the live distance. Tapping the button toggles the recording (as before, bouncing into the app to grant location permission when needed); tapping the widget body now opens the app. The button stays visible even when the widget is resized small: the header (icon/title/subtitle) sits in a weighted block that shrinks first, while the button is a fixed-height element that is always laid out, and the provider'sminResizeHeightguarantees enough room for it. Implemented inRideRecordingWidget(RemoteViewsstate swap for the button icon/label/background and the chronometer base) with a newwidget_ride_recording.xml,ic_widget_play/ic_widget_stopvector icons and start/stop button backgrounds. - Trekking profile now rides like a normal cyclist that seeks out bike paths (
trekking.brf,PROFILES_VERSION9 β 11) β the first attempt only nudged ordinary roads a touch above ahighway=cycleway(cost 1.0), which still wasn't enough: with the highturncost, atertiaryroad that merely permits cycling (cost ~1.15) beat leaving the carriageway for a parallel cycleway, so the route "still used the road" while the gravel profile β which prices roads 3β5Γ a cycleway β nicely followed the bike paths. The trekking road costs are now raised to the same relative philosophy as gravel so cycling infrastructure clearly wins:tertiaryisbike1.15 β 2.2 (bare 1.4 β 3.0),secondary1.25 β 2.6 (1.6 β 3.6),primary1.4 β 3.0 (3 β 4.2),unclassified1.15 β 1.7 (1.3 β 2.2). A road carrying its own cycle track/lane (hasonroadcycleway) keeps a strong bonus (1.2β1.6, just above a separated cycleway), and a bicycle/cycle street (bicycle_road/cyclestreet) and quiet residential streets (1.1) stay cheap.turncostis lowered 90 β 65 (matching gravel) so the router commits to a nearby bike path instead of staying on the road to avoid a turn. Every road stays below thefootway=sidewalkcost (5.0), so the sidewalk/trunk/motorway guards and start-snapping behaviour are unchanged and every ordinary road stays routable. Covered by the existingBRouterProfileIntegrityTest,BRouterProfileRoutabilityTestandBRouterNoStartWayProbeTest(sidewalk still dearer than a tertiary road). - Map settings moved to Jetpack DataStore β no more main-thread preference reads β the map screen's UI toggles (pin/overlay layer visibility, 2D/3D navigation, voice guidance, keep screen on, and the reviewed-ride max-speed bubble / colour-by-speed overlays) are now persisted with Jetpack DataStore behind a single injected
MapSettingsRepository, instead of eightobject β¦PreferencesreadingSharedPreferencessynchronously on the main thread atMapViewModelconstruction. Each setting is now a non-blockingFlowthe ViewModel exposes viastateIn, and writes are transactionalsuspendedits β so opening the map no longer blocks on disk I/O, and settings changes propagate reactively. Existing users keep their choices: a one-offSharedPreferencesMigrationcopies the old values into the DataStore on first launch. As a side effect theMapViewModelno longer readsSharedPreferences(or needs aContext) for these, and is fully unit-testable with an in-memory fake (MapViewModelTest). Language and dark-mode stay onSharedPreferencesby design β both are read synchronously before Compose starts (locale wrapping / initial theme), where an async store can't be used. - Faster, leaner "My rides" timeline β the whole GPS track is no longer parsed to show the list β the recorded-rides timeline and its statistics dashboard now read a track-free projection (
RecordedRideSummary, only the denormalised aggregate columns) via a dedicated Room query, instead of deserialising every ride's fullpointsJsonon every database change (a rename, an archive, a new ride) β and on the collecting thread at that. A ride's full GPS track is now loaded on demand: when a ride is opened (detail sheet), exported, or when a map overlay needs it. The heatmap / ridden-tracks overlays only ever load and hold every track's geometry while their layer is actually visible; with the layers off, no track is deserialised or kept in memory at all. All remaining track deserialisation runs off the main thread (Dispatchers.Default). Because the summary carries only stable primitives (@Immutable), Compose can also skip recomposing unchanged timeline rows. Therecorded_ridestable gains indices onstartedAt(the newest-first ordering) andarchivedAt(Room migration v3 β v4) so the history scales to large ride counts. No behaviour change β the timeline, detail, export, analysis and overlays all look and work exactly as before. Covered by the existing (updated)RideStatisticsTestandMapViewModelTest. - Favourites are now a proper reactive stream β the map's favourite ids and their resolved parking spaces are derived directly from the database with
stateIn/mapLatest, replacing the hand-wired pair ofMutableStateFlows that had to be kept in sync imperatively (and re-queried by hand on every change). Less boilerplate, and the two lists can no longer drift out of sync (MapViewModel). - Decluttered the map menu β actions moved to a speed-dial, settings grouped β the single overloaded menu (which had grown to ~14 mixed entries) is split along the golden UX line between things you do and things you configure. The frequent actions β Plan a route, Round trip, My routes, My rides, Park bike / Show parked bike and Favorites β now fan out from a Material-3 speed-dial FAB anchored bottom-start on the map (a "+" that rotates to "Γ" and reveals labelled mini-FABs behind a tap-to-dismiss scrim), so they're one thumb-tap away and never buried in a scroll. The FAB hides during active turn-by-turn navigation, where the bottom area belongs to the navigation card, and sits on the opposite edge from the location/record FABs so nothing collides. The Settings sheet is now genuinely just settings, slimmed to three grouped rows with progressive disclosure β Appearance & map (dark mode, language, 2D/3D view, layers), Navigation & routing (voice guidance, keep screen on, offline routing) and About β each opening a focused sub-sheet instead of one long list. Two new strings (
map_actions,settings_group_navigation) are localised across all eight supported languages. - Slimmed down the simple ride detail sheet β the recorded-ride detail sheet no longer shows the speed-over-time chart or the elevation profile; both now live on the dedicated, richer detailed analysis screen (reached via the Open detailed analysis button). The sheet stays focused on the headline stats, calories and the quick actions, which declutters it and avoids duplicating the charts.
- Refreshed and expanded the screenshot galleries β the website preview gallery (
docs/index.html), the README screenshot grid and the Google Play phone screenshots (fastlane/metadata/android/{de-DE,en-US}/images/phoneScreenshots) now show updated captures plus five new screens: the 2D / 3D map view, bike routing profiles, the round-trip generator, ride tracking and the settings screen.
Fixed
- Round-trip routes now come out close to the requested distance instead of far too long β picking e.g. a 10 km round trip previously produced a much longer loop. BRouter's round-trip parameter is the search radius of the waypoint ring, not the total length, and the radius was derived with a length-to-radius factor of 3.0 β below the loop's pure geometric straight-line factor (with 5 ring points the polygon
start β 4 points on the circle β startmeasures β3.85 Γ radius), before any road detour is even added. Tuned against real generated loops, the factor is now 6.5 (β 3.85 geometric + real-road detour), so the generated loop lands close to the distance you selected (ROUND_TRIP_LENGTH_TO_RADIUSinBRouterEngine.calculateRoundTrip). - The "route hilliness / flatter routes" setting now actually flattens round trips β the uphill cost was already handed to BRouter, but a round trip's waypoint ring is placed geometrically in a single (random) direction, so the loop could be aimed straight at a hill no matter how high the uphill penalty was β BRouter can only choose how it routes between the fixed ring points, not move the ring off the hill. When a flatter route is requested (any non-Any hilliness level) VeloSpot now generates the loop for several evenly-spread ring directions and keeps the one that climbs the least (
ROUND_TRIP_FLAT_DIRECTION_SAMPLEScandidates, compared by total ascent), so "mΓΆglichst flach" finally steers round trips away from the steepest climbs. The extra routing cost is only paid when a flat preference is set, and is cancellable. Point-to-point routing is unchanged (it already honoured the setting directly). - Navigation now reliably ends on arrival for every trip type β including round trips β the "you've reached your destination, navigation stops" logic (
maybeHandleArrivalinNavigationController) was hardened so it finishes robustly for standard navigation, planned routes and generated round trips alike. The crow-flies arrival fallback (which catches an arrival when the rider is slightly off-route at the door, or when a BRouter route stops a few metres short) now applies regardless of on/off-route state, so a route that ends short no longer leaves navigation running. To keep that from mis-firing on a round trip β whose synthetic destination sits on the start, making the direct distance ~0 the instant navigation begins β the fallback is now armed only after the rider has genuinely travelled away from the destination (been at leastDEPARTURE_ARMING_METERS= 60 m from it at some point during the ride). The precise along-route remaining-distance signal is left ungated (it is the full loop length at the start of any real route, so it is inherently safe). Net effect: a round trip can no longer "arrive" at t=0, yet still ends the moment the rider loops back to the start; point-to-point trips are unchanged. Covered by a newNavigationControllerTestround-trip case and the existingMapViewModelTestarrival tests. - Edge-to-edge display now renders correctly for every user on Android 15+ (SDK 35+) β targeting SDK 35 and above makes apps draw edge-to-edge by default, and the legacy
Window.setStatusBarColor/setNavigationBarColorparameters used to tint the system bars are deprecated/ignored there (both flagged by the Play Console pre-launch report). The activity already calledenableEdgeToEdge()and the overlays already respect the insets (statusBarsPadding()on the top search/menu row,navigationBarsPadding()on every bottom sheet), but the status- and navigation-bar icon contrast was left toenableEdgeToEdge()'s system-driven default β which follows the OS dark-mode setting, not VeloSpot's own in-app dark-mode toggle. With the two out of sync (e.g. app in light theme while the phone is in dark mode) the transparent bars could show invisible light-on-light (or dark-on-dark) icons. The bar icon appearance is now driven explicitly from the app's theme state viaWindowInsetsControllerCompat(isAppearanceLightStatusBars/isAppearanceLightNavigationBars) β the non-deprecated replacement for setting bar colours β so the clock/battery and gesture pill stay legible in both themes and update live when dark mode is toggled.enableEdgeToEdge()is also called with explicit fully-transparentSystemBarStyles so the map and UI extend cleanly under the bars (MainActivity). The remaining on-map controls that lacked an inset were also fixed to clear the system bars: the right-edge location / record / re-centre FABs now keep theirnavigationBarsPadding()(so they no longer sit behind a 3-button navigation bar), and the map error banner now keeps itsstatusBarsPadding()(MapOverlays). - Favouriting can no longer race itself β toggling a parking space's favourite state used to read whether it was a favourite and then write the opposite in two separate steps, so tapping the star twice in quick succession could let both taps observe the same state and both add (or both remove). The toggle now runs as a single atomic Room
@Transaction(FavoriteSpaceDao.toggleFavorite), so rapid taps always settle on the correct state. - Recorded rides no longer report wildly inflated elevation, phantom end-of-track spikes or drift outliers β the GPS filtering thresholds in the ride tracker were re-tuned against real recorded rides pulled from the device. Three concrete glitches were found and fixed: (1) GPS-only altitude that jumps Β±15β60 m between two fixes was low-pass-filtered but still leaked large phantom steps, inflating a ride's recorded ascent by roughly an order of magnitude (β 400 m instead of the real β 5β10 m) β a new altitude outlier gate (
MAX_ALTITUDE_STEP_METERS, 12 m) now discards a fix whose altitude jumps more than a bike could plausibly climb/descend in one ~3 s fix; (2) a GPS burst/duplicate fix arriving only ~20β30 ms after the previous one produced an absurd 290β315 m/s point at the very end of the track β a new minimum-fix-interval gate (MIN_FIX_INTERVAL_MILLIS, 250 ms) drops such near-zero-interval (and non-monotonic) fixes; and (3) the accuracy (30 β 25 m), teleport speed (25 β 22 m/s β 79 km/h) and acceleration (6 β 4 m/sΒ²) gates were tightened to the levels the real data supports (rides cluster around ~4 m accuracy with a thin 20β28 m multipath tail, peak ~16 m/s, 95th-percentile |accel| ~0.8β1.6 m/sΒ²), trimming more drift while still passing genuine fast descents. Only affects future recordings. Covered by newRideTrackerTestcases (gross altitude spike does not inflate elevation,burst fix within the minimum interval is dropped).
β ending a real turn-by-turn navigation fed the puck one synthetic speed-0 brake fix through the same path the debug route simulator uses, which wrongly flagged the auto-recorded ride as a mock (excluding it from your statistics, heatmap and ridden tracks). The mock flag is now set only when the route simulator actually starts, decoupled from the fix feed (RideRecordingManager.markMockRecording, wired fromNavigationControllerβMapViewModel), so genuine rides count again. Covered by the newRideRecordingMockFlagTest. - Navigation no longer overlays the raw GPS track on the route β while navigating, the jagged live-recording polyline is no longer drawn on top of the planned route. The route already renders a clean travelled / remaining split (and reroutes when you leave it), so overlaying the raw fixes was noisy and redundant. The real GPS is still recorded for the ride analysis β only its on-map polyline is suppressed during navigation; manual recording and reopened rides still draw their track as before.
- The navigation marker now stops promptly at red lights β the between-fixes dead-reckoning that keeps the puck gliding smoothly used a symmetric ease, so the marker coasted on for a second or two after the rider actually stopped. Braking is now much quicker than acceleration (a short decel time-constant), the crawl snaps to a dead stop below walking pace, and the along-route position is resynced harder while stopped β so the puck freezes on the spot when you stop instead of drifting ahead (
NavigationManager).
Build
- The locally-generated Play Store marketing assets directory (
playstore/) is now git-ignored so large rasterised icons / feature graphics stay out of version control. - Fixed the F-Droid build under Hilt/Dagger 2.60 β the components Hilt now generates reference
@CanIgnoreReturnValuefromcom.google.errorprone:error_prone_annotations, which the Google Play flavor pulled in transitively via Play Services but the F-Droid flavor did not, so:app:hiltJavaCompileFdroidDebugfailed to compile. The annotation library is now declared explicitly (compileOnly) for all flavors.
π₯ APK Variants
| File | Description |
|---|---|
VeloSpot-v1.0.25.apk |
F-Droid flavor β no Google services, ready to sideload; this is the APK F-Droid reproducibly verifies |
VeloSpot-v1.0.25-googlePlay.apk |
Google Play flavor β includes Google Play Services Location |
VeloSpot-v1.0.25-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.25.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.