# 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/fitsdk` or `fit-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: 1. Send the raw file to `POST /api/extract` (a new stateless endpoint — processes the file and returns BAS JSON, does **not** store anything). 2. The server runs the full Python pipeline: FIT `enhanced_altitude` detection, source-aware hysteresis, DEM correction, power metrics, laps. 3. 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 ```sql -- 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_path` to 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) 1. Fetch `{instance_url}/{handle}/index.json` to get remote activity IDs. 2. Find local activities where `synced_at IS NULL`. 3. For each unsynced activity, `POST /api/upload` with the original file. 4. On 200, set `synced_at = now()`. ### Pull (server → local) 1. Fetch `{instance_url}/{handle}/index.json` (and yearly shards). 2. Find remote IDs not in the local DB. 3. For each missing activity: - `GET {instance_url}/activities/{id}.json` → `detail_json` - `GET {instance_url}/activities/{id}.timeseries.json` → `timeseries_json` - `GET {instance_url}/activities/{id}.geojson` → `geojson` 4. 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: 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 |