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

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/fitsdk or fit-file-parser (existing JS libraries)
  • GPX/TCX parsing: standard XML parsing (DOMParser or fast-xml-parser)
  • Metrics: distance (Haversine), speed, HR/power averages, lap splits — all simple loops
  • Elevation: direct port of the hysteresis algorithm from metrics.py and dem.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 blob
  • timeseries_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)

  1. Fetch {instance_url}/{handle}/index.json to get remote activity IDs.
  2. Find activities where synced_at IS NULL or origin = "local" and id NOT IN remote_ids.
  3. For each unsynced activity, POST /api/upload with the original file (stored separately from the extracted JSON) or reconstruct a minimal file from the BAS JSON.
  4. On 200, set synced_at = now().

Pull (server → local)

  1. Fetch {instance_url}/{handle}/index.json (and yearly shards if present).
  2. Find IDs in the remote list that are not in the local DB.
  3. For each missing activity:
    • GET {instance_url}/activities/{id}.json → store as detail_json
    • GET {instance_url}/activities/{id}.timeseries.json → store as timeseries_json (lazy, on demand)
    • GET {instance_url}/activities/{id}.geojson → store as geojson
  4. 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

  1. 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-bas endpoint already exists on the edit server for this use case (see convert/ page).

  2. 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. A POST /api/auth/token endpoint may be worth adding.

  3. iOS file access: FIT/GPX files from Garmin Connect, Komoot, etc. arrive via the share sheet or Files app. expo-document-picker handles this but needs testing with each source.

  4. 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