13 KiB
Bincio Mobile App — Design Document
Vision
The long-term goal is full independence from Garmin Connect, Strava, and similar platforms. Today those platforms act as mandatory intermediaries: your device syncs to their cloud, you authorise third parties to pull from their API, and your data effectively lives on their servers.
The Bincio mobile app removes that dependency:
- Your FIT/GPX/TCX files live on your device.
- The app reads them directly — no platform sync required.
- A Bincio instance (bincio.org or self-hosted) is an optional upgrade for backup, sharing, and web access — not a prerequisite.
- Devices like the Karoo 2 (Android-based) are a first-class target: activities are already saved locally as FIT files, so the app can pick them up directly from the filesystem without any export step.
Philosophy
Local-first. All activity data lives on the device. The app works fully offline — no account, no internet connection, no platform authorisation required.
Original files as source of truth. The raw FIT/GPX/TCX file is always stored on device alongside the extracted BAS JSON. This means:
- You can re-extract at any time (e.g. when the algorithm improves, or to apply DEM correction after connecting to an instance).
- Sync to a remote instance is just pushing the original file — the server re-extracts with the full Python pipeline.
- No data is ever locked into a proprietary representation.
Sync is optional and explicit. Connecting to a Bincio instance (bincio.org or self-hosted) adds cloud backup, the web feed, and the ability to share activities. The app never silently overwrites local data. Sync is user-initiated.
Open format. Activities are stored in the BAS schema (the same JSON format the server uses). Any tool — in any language — can read them.
What already exists
Several pieces of the mobile app are already implemented or proven:
| Piece | Where | Notes |
|---|---|---|
| BAS schema | docs/schema.md |
The on-device data format — identical to the server format |
| In-browser FIT/GPX/TCX parsing | site/src/pages/convert/ |
Pyodide + the Python extractor running in a browser tab. Proves local extraction works. Not portable to mobile (Pyodide is 30 MB, browser-only). |
| Local activity storage | site/src/pages/convert/ |
IndexedDB + service worker in the web app. Proves the concept; the mobile app uses SQLite instead. |
| Content-addressed dedup | bincio/extract/dedup.py |
source_hash (SHA-256 of raw file) prevents duplicates on upload |
| Sync-ready REST API | bincio/serve/server.py |
Login, upload, activity detail, index.json — the sync primitives are already there |
| Settings persistence | bincio/serve/db.py |
settings table (key/value) for instance URL, auth token, sync preferences |
| Elevation algorithms | bincio/extract/metrics.py, bincio/extract/dem.py |
Hysteresis and DEM correction — need a TypeScript port for offline use |
Technology choice
Cross-platform framework: Expo (React Native)
Expo (React Native + the Expo SDK) is the recommended platform:
- TypeScript-first, large ecosystem
expo-sqlite(v2+) — fast on-device SQLite with WAL mode- File picking from device storage:
expo-document-picker - Direct filesystem access (important for Karoo):
expo-file-system - Maps: MapLibre React Native (
@maplibre/maplibre-react-native) — same tile standard as the web app, self-hostable - Background tasks:
expo-background-fetch/expo-task-manager - Expo Go for rapid development without a build step
- EAS Build for iOS and Android distribution
Why not alternatives:
| Option | Reason for skipping |
|---|---|
| Capacitor + Svelte | WebView performance is poor for map-heavy activity detail; Pyodide can't run on mobile |
| Flutter | Dart is a new language to learn; no practical advantage over RN for this use case |
| PWA | iOS limits background sync, local storage quotas, and filesystem access — not viable for an activity logger |
Extraction: hybrid model
Python (the server's extraction engine) cannot run on mobile without a specialised runtime. Rather than fully porting the extraction to TypeScript, the app uses a tiered extraction model:
Tier 1 — On-device TypeScript extraction (always available, offline)
A TypeScript extraction library (bincio-extract-ts) runs entirely on the device:
- FIT parsing:
@garmin/fitsdkorfit-file-parser(mature JS libraries) - GPX/TCX parsing: standard XML parsing (
fast-xml-parser) - Metrics: distance (Haversine), moving time, speed, HR/power averages, lap splits
- Elevation: direct port of the hysteresis algorithm from
metrics.py
This produces a valid BAS JSON that the app can display immediately. It is the default path and works with no network.
Tier 2 — Server-assisted extraction (when an instance is reachable)
When a Bincio instance is configured and online, the app can delegate extraction to the server:
- Send the raw file to
POST /api/extract(a new stateless endpoint — processes the file and returns BAS JSON, does not store anything). - The server runs the full Python pipeline: FIT
enhanced_altitudedetection, source-aware hysteresis, DEM correction, power metrics, laps. - The app stores the returned BAS JSON locally and marks it as server-extracted.
This gives full extraction quality without maintaining two implementations of every algorithm. The original file is always stored locally, so the app can re-extract via the server at any time (e.g. after a DEM correction improvement is deployed).
Re-extraction
Because the original file is always on device, the app can re-run either tier at any time:
- Re-extract offline: apply an updated TypeScript algorithm to an existing original file.
- Re-extract via server: send the original file to the server for higher-quality processing (e.g. after connecting to an instance for the first time).
This means extraction quality improves automatically as algorithms improve, without any data migration.
Architecture
Bincio Mobile
├── UI Layer (React Native / Expo)
│ ├── Feed screen — list of local activities, sorted by date
│ ├── Activity detail — map + elevation chart + stats
│ ├── Import screen — pick FIT/GPX/TCX from device or share sheet
│ ├── Sync screen — configure instance URL, push/pull
│ └── Settings screen — account, preferences, storage info
│
├── Extraction Engine (TypeScript — Tier 1)
│ ├── FIT parser — wraps @garmin/fitsdk
│ ├── GPX parser — XML → BAS points
│ ├── TCX parser — XML → BAS points
│ ├── Metrics — port of metrics.py (distance, elevation, HR, power)
│ └── Hysteresis — port of _hysteresis_gain_loss + _moving_average
│
├── Local Store (expo-sqlite)
│ ├── activities — BAS detail JSON + indexed summary columns
│ ├── timeseries — 1 Hz arrays as JSON blob per activity
│ ├── geojson — simplified GPS track per activity
│ ├── originals — original file paths (or blobs) per activity
│ └── settings — instance_url, handle, auth_token, sync prefs
│
└── Sync Layer
├── Auth — POST /api/auth/login → session token
├── Extract (Tier 2) — POST /api/extract → BAS JSON, no server storage
├── Push — POST /api/upload (original file)
└── Pull — GET index.json + activity/{id}.json + timeseries
Data model on device
-- activities table
id TEXT PRIMARY KEY, -- BAS ID: "2026-04-17T074238Z"
source_hash TEXT NOT NULL, -- SHA-256 of original file (dedup key)
detail_json TEXT NOT NULL, -- full BAS detail JSON blob
timeseries_json TEXT, -- 1 Hz arrays (loaded lazily)
geojson TEXT, -- simplified GPS track
original_path TEXT, -- path to original file in app storage
extraction_tier INTEGER, -- 1 = TypeScript, 2 = server-extracted
synced_at INTEGER, -- unix timestamp of last push to remote (NULL = unsynced)
origin TEXT NOT NULL, -- "local" | "remote"
created_at INTEGER NOT NULL
-- settings table
key TEXT PRIMARY KEY,
value TEXT NOT NULL
Settings keys:
| Key | Example value |
|---|---|
instance_url |
https://bincio.org |
handle |
brutsalvadi |
session_token |
abc123… |
last_sync_at |
2026-04-24T10:00:00Z |
auto_import_path |
/sdcard/Karoo/Rides/ (Android only) |
Karoo and Android-first devices
Devices like the Karoo 2 run Android and write FIT files directly to the
filesystem (e.g. /sdcard/Karoo/Rides/). The app can monitor this directory and
auto-import new files as rides complete, with no manual export step and no Hammerhead
(or Garmin, Wahoo, etc.) cloud sync required.
On Karoo specifically:
- Install the Bincio Android APK directly.
- Configure
auto_import_pathto point at the Karoo's ride directory. - When a new FIT file appears, the app imports it automatically (Tier 1 extraction), stores the original file, and shows the ride in the feed.
- When WiFi is available and an instance is configured, rides can be pushed to the instance (Tier 2 extraction for higher quality, or just raw upload).
This makes Bincio a complete replacement for Hammerhead's own sync infrastructure for users who want full control of their data.
Sync protocol
Sync is a two-way, hash-based diff — no custom server protocol needed beyond the existing REST API.
Push (local → server)
- Fetch
{instance_url}/{handle}/index.jsonto get remote activity IDs. - Find local activities where
synced_at IS NULL. - For each unsynced activity,
POST /api/uploadwith the original file. - On 200, set
synced_at = now().
Pull (server → local)
- Fetch
{instance_url}/{handle}/index.json(and yearly shards). - Find remote IDs not in the local DB.
- For each missing activity:
GET {instance_url}/activities/{id}.json→detail_jsonGET {instance_url}/activities/{id}.timeseries.json→timeseries_jsonGET {instance_url}/activities/{id}.geojson→geojson
- Insert with
origin = "remote",synced_at = now().
Conflict handling
Activities are immutable once created. The source_hash prevents double-counting —
if the same file is imported on two devices before sync, whichever copy arrives at
the server first wins; the duplicate is rejected with a 409.
New server endpoint needed: POST /api/extract
A stateless extraction endpoint: accepts a raw FIT/GPX/TCX file, runs the full Python extraction pipeline, returns BAS JSON. Does not write anything to disk.
POST /api/extract
Content-Type: multipart/form-data
file: <raw activity file>
200 OK
{
"detail": { ...BAS detail JSON... },
"timeseries": { ...1 Hz arrays... },
"geojson": { ...simplified track... }
}
No authentication required (the server is just a compute service here — the result is not stored). Rate limiting and file size cap apply.
Authentication
The multi-user server currently uses HTTP session cookies. For the mobile client, a Bearer token is cleaner:
POST /api/auth/token
{ "handle": "…", "password": "…" }
→ { "token": "abc123…", "expires_at": "…" }
The token is stored in the settings table and sent as
Authorization: Bearer abc123… on all subsequent requests.
What is out of scope for v1
- Live activity recording — GPS + sensor recording during a ride. This is a much harder problem (background GPS, Bluetooth/ANT+ sensors, real-time display) and is the eventual goal for full platform independence.
- Offline map tiles — v1 requires network for map rendering.
- Photo sync — deferred.
- Watch / ANT+ / Bluetooth sensors — deferred.
- Editing activities on mobile — read-only in v1; edits happen on the web.
Roadmap
| Phase | Scope |
|---|---|
| 0 — Foundation | Expo project scaffold, SQLite store, settings screen, file picker, display a BAS JSON read from disk |
| 1 — Import | TypeScript FIT/GPX/TCX parser + metrics engine (Tier 1), local feed, activity detail with map and chart, original file storage |
| 2 — Karoo integration | Auto-import from a watched directory, Android-specific file access |
| 3 — Sync | POST /api/extract endpoint, Bearer token auth, push/pull sync with an instance |
| 4 — Polish | Offline map tiles, share sheet, home screen widget, performance |
| Future | Live recording, Bluetooth sensors, full Garmin/Wahoo replacement |