9.1 KiB
Bincio Mobile App — Design Document
Philosophy
The Bincio mobile app follows a local-first model:
- All activity data lives on the device. The app works fully offline — no account or internet connection required.
- An online instance (bincio.org or a self-hosted server) is an optional upgrade, not a prerequisite.
- Activities are stored in the open BAS format (the same JSON schema the server uses), so data is always portable and readable without the app.
- Sync is explicit and user-initiated — the app never silently overwrites local data.
The goal is a personal activity log that you own. The cloud is a backup and a sharing mechanism, not the source of truth.
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 the extraction works without a server. Not suitable for mobile (Pyodide is 30 MB, browser-only). |
| Local activity storage | site/src/pages/convert/ |
IndexedDB + service worker serving local activities in the feed. Proves the concept; needs a native equivalent. |
| Content-addressed dedup | bincio/extract/dedup.py |
source_hash (SHA-256 of raw file) already 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) in the multi-user SQLite DB; the mobile_app branch added this |
| Elevation algorithms | bincio/extract/metrics.py, bincio/extract/dem.py |
Hysteresis accumulation and DEM correction — need a TypeScript port |
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+) provides a fast on-device SQLite database with WAL mode — good fit for BAS-style JSON blobs- File picking and sharing:
expo-document-picker,expo-sharing - 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 - OTA updates via Expo's update service
- Expo Go app for rapid development without a build step
- Builds for iOS and Android from a single codebase via EAS Build
Why not alternatives:
| Option | Reason for skipping |
|---|---|
| Capacitor + Svelte | Reuses web components but WebView performance is poor for map-heavy activities; Pyodide can't run on mobile |
| Flutter | Dart is unfamiliar, requires rewriting all logic; no advantage over RN for this use case |
| PWA | iOS severely limits background sync, local storage quotas, and file access — not viable for an activity logger |
Extraction engine: TypeScript port
The Python extractor (bincio/extract/) cannot run on mobile. It must be re-implemented in TypeScript. The math is straightforward:
- FIT parsing:
@garmin/fitsdkorfit-file-parser(existing JS libraries) - GPX/TCX parsing: standard XML parsing (
DOMParserorfast-xml-parser) - Metrics: distance (Haversine), speed, HR/power averages, lap splits — all simple loops
- Elevation: direct port of the hysteresis algorithm from
metrics.pyanddem.py - DEM correction: same HTTP API call to Open-Elevation, works from mobile
The TypeScript extraction library (bincio-extract-ts or similar) should produce BAS-compatible JSON — the same schema that the server writes. This makes the output directly uploadable.
Architecture
Bincio Mobile
├── UI Layer (React Native / Expo)
│ ├── Feed screen — list of local activities
│ ├── Activity detail — map + chart + stats
│ ├── Import screen — pick FIT/GPX/TCX from device or cloud storage
│ ├── Sync screen — configure instance, push/pull
│ └── Settings screen — instance URL, account, preferences
│
├── Extraction Engine (TypeScript)
│ ├── 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 dem.py _hysteresis_gain_loss + _moving_average
│
├── Local Store (expo-sqlite)
│ ├── activities — BAS detail JSON + summary fields as columns
│ ├── timeseries — 1 Hz arrays stored as JSON blobs per activity
│ ├── geojson — simplified GPS track per activity
│ └── settings — instance_url, auth_token, sync preferences
│
└── Sync Layer
├── Auth — POST /api/auth/login → session cookie or token
├── Manifest fetch — GET /{handle}/index.json → list of remote IDs
├── Push — POST /api/upload (multipart, original file)
└── Pull — GET /api/activity/{id} + timeseries + geojson
Data model on device
Each activity is stored in SQLite with:
id— BAS activity ID (2026-04-17T074238Z)source_hash— SHA-256 of the original file (deduplication key)detail_json— full BAS detail JSON blobtimeseries_json— 1 Hz arrays blob (nullable — loaded lazily)geojson— simplified GPS track (nullable)synced_at— timestamp of last successful push to remote instance (nullable = not yet synced)origin—"local"(parsed on device) |"remote"(pulled from instance)
The settings table stores:
| Key | Value |
|---|---|
instance_url |
https://bincio.org |
handle |
user's handle on the remote instance |
session_token |
auth cookie/token value |
last_sync_at |
ISO timestamp of last sync |
Sync protocol
Sync is a two-way, hash-based diff — no custom server protocol needed:
Push (local → server)
- Fetch
{instance_url}/{handle}/index.jsonto get remote activity IDs. - Find activities where
synced_at IS NULLororigin = "local"andid NOT IN remote_ids. - For each unsynced activity,
POST /api/uploadwith the original file (stored separately from the extracted JSON) or reconstruct a minimal file from the BAS JSON. - On 200, set
synced_at = now().
Pull (server → local)
- Fetch
{instance_url}/{handle}/index.json(and yearly shards if present). - Find IDs in the remote list that are not in the local DB.
- For each missing activity:
GET {instance_url}/activities/{id}.json→ store asdetail_jsonGET {instance_url}/activities/{id}.timeseries.json→ store astimeseries_json(lazy, on demand)GET {instance_url}/activities/{id}.geojson→ store asgeojson
- Insert with
origin = "remote",synced_at = now().
Conflict handling
Activities are immutable once created (same philosophy as the server). Conflicts only arise if the same source file is imported on two devices before sync. The source_hash prevents double-counting — whichever copy arrives at the server first wins; the duplicate is rejected silently.
What's NOT in scope (v1)
- Live activity recording (GPS track + sensor recording during a ride) — this is a separate, harder problem
- Offline map tiles — initial version requires network for map rendering
- Photo sync — photos are not included in the BAS timeseries, sync deferred
- Watch / ANT+ / Bluetooth sensors — out of scope for v1
- Editing activities on mobile — read-only feed in v1; edits happen on the web
Open questions
-
Original file storage: To push to the server, we need the original FIT/GPX/TCX file. Do we always keep it, or reconstruct a minimal file from extracted BAS JSON? The
POST /api/import-basendpoint already exists on the edit server for this use case (seeconvert/page). -
Auth model: The multi-user server uses HTTP session cookies. React Native handles cookies but a token-based auth (
Authorization: Bearer …) would be cleaner for a mobile client. APOST /api/auth/tokenendpoint may be worth adding. -
iOS file access: FIT/GPX files from Garmin Connect, Komoot, etc. arrive via the share sheet or Files app.
expo-document-pickerhandles this but needs testing with each source. -
Offline DEM: DEM correction requires a network call. On mobile, the hysteresis method (offline) should be the default; DEM correction is opt-in when connected.
Roadmap
| Phase | Scope |
|---|---|
| 0 — Foundation | Expo project scaffold, local SQLite store, settings screen, BAS reader (load an existing .json file into the feed) |
| 1 — Import | TypeScript FIT/GPX/TCX parser + metrics engine, local feed, activity detail with map and chart |
| 2 — Sync | Auth, push/pull sync with bincio.org or a self-hosted instance |
| 3 — Polish | Offline map tiles, share sheet integration, widgets, performance |