Skip to content

About

A Rust GPS track-analysis and ghost-racing engine with a Flutter mobile client.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Against Yesterday

A GPS run/cycling app built around one idea: every route is a race with your own personal best. You run against a "ghost" of your PB, and the live gap — AHEAD or BEHIND — is the hero metric of the whole experience.

Flutter app  →  EngineService (facade)  →  Rust engine (FFI)
                                   ↘  FakeEngineService (deterministic, for dev)

Features

  • Record runs — a state machine driving preparing → GPS acquiring → ready → running ⇄ paused → finishing → completed, with live distance, pace, elapsed time and the PB gap.
  • Ghost racing — every route carries a PB; run against its ghost live on the map, and finish the run to see where you were faster or slower.
  • Route map — self-contained painter (no map SDK): the route, the travelled portion, the YOU and PB-ghost markers, a follow camera with recenter.
  • Route library — course cards with PB/average/last stats, a performance chart of every attempt, and attempt history.
  • Results & history — run-complete interstitial with NEW PERSONAL BEST detection, a splits breakdown, activity detail, and home history.
  • Background recording — wall-clock timing and persisted run snapshots, so an interrupted run survives process death and resumes where it left off.
  • Polish — phase transitions, haptics, WCAG-AA contrast and semantics labels, error/empty/loading states, and repaint isolation.
  • Developer diagnostics — a DEV_TOOLS gated readout of the live engine / GPS / track / route / ghost / persistence state, plus one-tap export of the current run as a raw-GPS fixture for regression replay.

Repository layout

Path What it is
app/ The Flutter client (lib/, test/)
rust/gps-engine/ The Rust engine: GPX → track → route → ghost pipeline
specification/ Plans: app implementation, UI/UX, engine, datasets
Cargo.toml Cargo workspace root for the Rust crate

The app consumes the engine only through the EngineService abstraction, so it never talks to raw FFI — the native Rust engine is selected automatically when its library is present, and the fake is the fallback, without touching UI code.

Landing page

A static landing page lives in docs/ and is published at https://davidneurieder.github.io/AgainstYesterday/.

Getting started

# Flutter client
cd app
flutter pub get
flutter run

# Rust engine
cargo test
cargo run --example analyze

CI (/.github/workflows/ci.yml) runs cargo fmt --check, cargo clippy -D warnings, cargo test --all-features, cargo doc --no-deps, Flutter's flutter analyze + flutter test, a cross-compile of the engine cdylib for each Android ABI (asserting the FFI symbols are exported), and the on-device E2E suite on a headless Android emulator (flutter test integration_test).

Using the real Rust engine

The engine is selected at build time from USE_RUST_ENGINE:

  • not set (default) — auto: the native Rust engine is used when its library loads, otherwise the deterministic FakeEngineService (the dev/demo fallback, also used in tests);
  • =true — require the Rust engine (a missing library becomes a startup error);
  • =false — always the fake.

The Android cdylib is cross-compiled per ABI and packaged as a native library by app/tool/build_rust_engine_android.sh (NDK clang linker, API 24), which installs it into app/android/app/src/main/jniLibs/<abi>/ — where Gradle picks it up and the bare name libgps_engine.so resolves on the device. That is also why make install and make build-release run it first:

./app/tool/build_rust_engine_android.sh            # arm64-v8a + x86_64
cd app && flutter build apk --release

The binaries are gitignored build artifacts; rerun the script after a fresh checkout. To force the fake (demonstration/test builds), use --dart-define=USE_RUST_ENGINE=false. On host workloads point the build at a library with --dart-define=GPS_ENGINE_LIB=/path/to/libgps_engine.so; when empty the app opens the bare name libgps_engine.so. Add --dart-define=DEV_TOOLS=true to any of these builds to expose the M15 diagnostics entry point.

Using the real phone GPS

A plain flutter run/flutter test replays the deterministic demo timeline (roughly the ~4.8 km "River Loop" walkable on the host — the app's catalog itself stays empty until the user builds routes), so the flow is explorable with no phone. To record actual device fixes, build with USE_DEVICE_GPS:

cd app && flutter build apk --debug --dart-define=USE_DEVICE_GPS=true

make build, make install and make build-release pass the define for you, so the installed app races real GPS while hosts and the emulator E2E keep the deterministic timeline. Device mode reads 1 Hz fixes through the geolocator plugin (the app requests ACCESS_FINE_LOCATION on Android and uses NSLocationWhenInUseUsageDescription on iOS): live position, distance, pace and the raw-fix buffer all come from the receiver, a real run on a recognised route snaps onto that route's geometry for the ghost gap, and an unrecognised line accumulates ground distance as a new route. Movement is gated on the ground actually covered — a running fix must clear a 0.5 m/s floor (measured between fixes, or a receiver speed backed by matching displacement), so a parked phone doesn't count moving time, drift distance, or show a phantom cruise pace ("— /km") from a stale cached speed. Refusals (services off or permission denied) surface as the recoverable recording ERROR state. The diagnostics screen's GPS section prints which source is live.

Developer diagnostics

Build with the diagnostics entry point enabled (M15):

cd app
flutter run --dart-define=DEV_TOOLS=true

A small bug-report button floats at the top right of every tab and opens the diagnostics screen (gated in both the shell and the router): the active engine implementation, the live GPS fix, track / route / ghost readouts, and the persisted recovery snapshot. Export run as fixture JSON serializes the raw fixes retained for the in-flight run (or the most recent saved run) in the M15 fixture schema and copies it to the clipboard after a privacy confirmation:

{"schema_version":1,
 "route":[{"lat":..,"lon":..}],
 "fixes":[{"timestamp_ms":..,"latitude":..,"longitude":..,
           "accuracy_m":..,"altitude_m":..,"speed_mps":..,"bearing_deg":..}]}

Raw fixes are the receiver's own observations, captured before any processing and persisted with the completed activity; sensor fields the device never reported are emitted as explicit null — never invented — and a recording with no route geometry refuses export rather than substituting demo data. Paste the document into rust/gps-engine/tests/fixtures/ and drive it through the Rust pipeline (GpsTrace::from_json → process → invariants, see tests/gps_torture.rs) — a real-device GPS bug becomes a permanent regression test. On the device side the same readout answers "why did the ghost jump?" without guessing.

Tests

cd app && flutter analyze && flutter test   # Flutter: 201 tests
cargo test                                   # Rust: 204 tests + property cases

# the same Flutter suite against the real Rust engine over FFI
cargo build --release                        # produces target/release/libgps_engine.so
cd app && flutter test --dart-define=USE_RUST_ENGINE=true \
  --dart-define=GPS_ENGINE_LIB=../target/release/libgps_engine.so

The Flutter tests run headlessly with fake_async, an in-memory store, and the deterministic fake engine — no phone, GPS chip, or network needed. The Rust suite additionally replays five checked-in raw-GPS fixtures (tests/fixtures/clean_loop.json, gps_jitter.json, gps_jump.json, gps_dropout.json, out_and_back.json) through the filter/quality pipeline — cargo run --example generate_fixtures regenerates them — and asserts the trace round-trips through its own JSON. tests/gps_schema.rs pins the fixture schema (schema_version, explicit null sensor fields, rejection of newer versions) against the exact document the Flutter exporter emits, and tests/gps_pipeline.rs drives a fixture end-to-end from raw fixes to a ghost snapshot.

Status

Milestones M1–M29 are implemented (see CHANGELOG.md). The app ships with an empty catalog — no pre-recorded routes or demo history — and records a free run until the user builds routes by finishing runs. On the host/tests the deterministic fake GPS timeline (the ~4.8 km "River Loop" fixture) keeps the whole loop explorable with no phone; a USE_DEVICE_GPS=true build records the phone receiver's real fixes instead. M15 added the raw-GPS quality model, checked-in replay fixtures, ghost geometry invariants and continuity-aware matching on the Rust side, plus the developer diagnostics screen on the app side. It also retains the raw fixes through the recording lifecycle (exported, never fabricated) and gates the diagnostics route, not just its entry button. M16–M29 closed the loop the plan asks for: record, race, result, PB alerts, entrance motion, responsive layouts, settings, history, consistent error screens, a WCAG-checked palette and the three-screen first-launch intro — with main() now persisting settings, routes, activities and snapshots to the device's application documents directory across restarts.

License

AGPL-3.0-or-later for the whole repository — the gps-engine crate and the Flutter app alike. The full text is in LICENSE; the Rust workspace declares the SPDX id in Cargo.toml and the app in pubspec.yaml, and every source file carries a SPDX-License-Identifier: AGPL-3.0-or-later header.

About

A Rust GPS track-analysis and ghost-racing engine with a Flutter mobile client.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages