Files
bincio-activity/docs/mobile-app.md
T

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/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

-- 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}.jsondetail_json
    • GET {instance_url}/activities/{id}.timeseries.jsontimeseries_json
    • GET {instance_url}/activities/{id}.geojsongeojson
  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: <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