20 KiB
Bincio Mobile App — Design Document
Vision
The long-term goal is full independence from Garmin Connect, Strava, Hammerhead, 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.
This initial version focuses on post-ride import and local storage. Live recording (GPS + sensors during a ride) is the long-term goal that would complete full platform independence, but it is out of scope until the foundation is solid.
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.
The algorithm travels to the data — not the other way around. When internet is available, the app downloads a fresh copy of the extraction algorithm from bincio.org and runs it locally. Your activity files never touch the server. Only the Python wheel (the code) is downloaded; the data stays on device.
Sync is optional and explicit. Connecting to a Bincio instance adds cloud backup, the web feed, and sharing. 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.
Setup
Prerequisites
| Tool | Minimum version | Notes |
|---|---|---|
| Node.js | 18 | 20 LTS recommended — install via nodejs.org or nvm |
| npm | ships with Node | |
| Expo Go app | latest | Install on your phone — scan the QR code to run the app instantly during development |
| Xcode | 15+ | macOS only, iOS builds. Install from the App Store, then xcode-select --install |
| Android Studio | latest | Android builds / emulator. Includes the SDK and adb |
You do not need Xcode or Android Studio to start. Expo Go lets you run the app on your physical device by scanning a QR code — no native build required.
First-time setup
# From the repo root:
bash mobile/setup.sh
The script checks prerequisites, installs npm dependencies, and generates the required Expo type declarations. It prints next steps when done.
Running the app
cd mobile
# Development server — scan QR with Expo Go on your phone
npx expo start
# Run on a connected Android device or emulator
npx expo run:android
# Run on iOS simulator (macOS only)
npx expo run:ios
# Build a standalone APK for Karoo sideloading
npx eas build -p android --profile preview
Repository layout
The mobile app lives in mobile/ inside the main bincio repository (Option A).
This keeps it close to the bincio wheel it depends on and makes it easy to test
algorithm changes end-to-end. It can be extracted to its own repository later.
bincio_activity/
├── bincio/ — Python server + extractor
├── site/ — Astro web frontend
├── mobile/ — Expo React Native app ← this document
│ ├── app/ — Expo Router screens
│ ├── components/ — shared React Native components
│ ├── db/ — SQLite schema and queries
│ ├── extraction/ — WebView host + Pyodide bridge
│ └── sync/ — push/pull logic
└── docs/
What already exists
| Piece | Where | Notes |
|---|---|---|
| BAS schema | docs/schema.md |
The on-device data format — identical to the server format |
| Pyodide-based extraction | site/src/pages/convert/ |
FIT/GPX/TCX parsing via CPython→WASM in the browser — the proof of concept for mobile extraction. A hidden WebView uses the same mechanism. |
| Bincio wheel | dist/bincio-0.1.0-py3-none-any.whl, served at /bincio-0.1.0-py3-none-any.whl |
Pure-Python wheel already downloaded and run by the /convert/ page |
| Local storage concept | site/src/pages/convert/ |
IndexedDB + service worker in the web app. Mobile uses SQLite instead. |
| Content-addressed dedup | bincio/extract/dedup.py |
source_hash (SHA-256 of raw file) prevents duplicates |
| REST API | bincio/serve/server.py |
Login, upload, activity detail, index.json — sync primitives already there |
| Settings table | bincio/serve/db.py |
Key/value settings in the server DB; same pattern used on device |
Technology
Framework: Expo (React Native)
- TypeScript throughout
expo-sqlitev2 — on-device SQLite with WAL modeexpo-document-picker— file picking from device storageexpo-file-system— filesystem access (critical for Karoo directory watching on Android)react-native-webview— hidden WebView for Pyodide@maplibre/maplibre-react-native— maps, same tile standard as the web appexpo-background-fetch+expo-task-manager— background directory polling (Android)expo-notifications— import notifications- EAS Build — iOS and Android binaries; APK sideloading for Karoo
Extraction: Pyodide in a hidden WebView
The /convert/ page already demonstrates that the full Python extraction pipeline
runs in a browser via Pyodide (CPython compiled to WebAssembly). A React Native
app can host a hidden WebView running the exact same environment. No rewrite of
the extraction logic is required.
Package stack (proven in /convert/ today)
Pyodide v0.26 (CPython → WASM, ~30 MB)
├── lxml — pre-compiled WASM in Pyodide (XML / GPX parsing)
├── fitdecode — pure Python, installed via micropip (FIT parsing)
├── gpxpy — pure Python, installed via micropip (GPX parsing)
├── pyyaml — pure Python, installed via micropip
└── bincio wheel — pure Python, fetched from bincio.org
Every dependency is either pre-compiled in Pyodide or pure Python with no C extensions. Nothing needs recompilation for mobile.
Data flow
React Native
1. Read file bytes from device filesystem (expo-file-system)
2. postMessage({ type: 'extract', filename, bytes }) → hidden WebView
Hidden WebView (Pyodide)
3. Write bytes to Pyodide virtual FS (/tmp/activity.fit)
4. Run Python extraction → BAS dict (detail + timeseries + geojson)
5. postMessage({ type: 'result', detail, timeseries, geojson }) → RN
React Native
6. Store detail_json, timeseries_json, geojson in SQLite
7. Copy original file to app storage → record path in DB
Data never leaves the device. Network traffic: only the Pyodide runtime (~30 MB, CDN, cached once) and the bincio wheel (~50 KB, from bincio.org, updated on version bump).
Algorithm updates without App Store releases
The bincio wheel is versioned. On app startup the app calls:
GET /api/wheel/version → { "version": "0.2.1", "url": "/bincio-0.2.1-py3-none-any.whl" }
If the cached wheel is outdated, the new one is downloaded and the next extraction uses the updated algorithm. Improvements to hysteresis, DEM correction, or lap detection reach all devices within hours of a server deployment.
Performance
| Scenario | Time |
|---|---|
| First extraction (cold Pyodide + packages) | ~5–8 s |
| First extraction in session (warm WebView) | ~1–3 s |
| Subsequent extractions (warm WebView) | ~0.5–1 s |
| Pyodide RAM while active | ~100–150 MB |
For batch import the WebView is kept alive across files; per-file cost drops to the Python execution time only.
Android vs iOS: platform divergences
These two platforms share almost all code. The differences are confined to filesystem access and background behaviour.
Filesystem access
| Android | iOS | |
|---|---|---|
| App sandbox | App has its own private directory | App has its own private directory |
| External paths | Can read arbitrary paths on the filesystem with READ_EXTERNAL_STORAGE (≤ Android 12) or READ_MEDIA_* scoped permissions (Android 13+) |
Fully sandboxed. No access to paths outside the app container or Files app |
| Karoo rides dir | expo-file-system can read /sdcard/Karoo/Rides/ directly once permission is granted |
Not possible |
| Manual import | Document picker or share sheet | Document picker or share sheet |
Auto-import (Phase 2)
| Android | iOS | |
|---|---|---|
| Mechanism | Poll a configured directory path every few minutes via a background task | Not possible — iOS apps cannot read external directories |
| Background execution | expo-background-fetch fires reliably; Android allows longer background windows |
Background fetch is capped at ~30 s and is not guaranteed to fire; effectively unavailable |
| Import trigger | Automatic on new FIT file detected in watched directory | Manual: user shares file via Files app or "Open with Bincio" |
| Karoo auto-import | ✅ Full support — configure path once, rides appear automatically | ✗ Not applicable (Karoo is Android) |
Receiving files from other apps (share sheet)
| Android | iOS | |
|---|---|---|
| Mechanism | Android Intent filter: android.intent.action.SEND for .fit, .gpx, .tcx |
iOS Share Extension (Expo supports this via expo-intent-launcher / config plugin) |
| User experience | "Open with Bincio" in any file manager or app | "Share → Bincio" from Files, Komoot, etc. |
App distribution
| Android | iOS | |
|---|---|---|
| APK sideloading | ✅ Supported — critical for Karoo (no Google Play) | ✗ Not allowed |
| Store | Google Play (optional) | App Store required (or TestFlight for beta) |
| Karoo installation | Sideload APK directly onto the device | N/A |
WebView (Pyodide)
| Android | iOS | |
|---|---|---|
| WebView engine | Chrome WebView (system-provided, updateable) | WKWebView (WebKit, part of iOS) |
| WASM JIT | ✅ Full JIT via V8 | ✅ JIT allowed in WKWebView (Apple's exception for browser engine components) — works from iOS 14 |
| Memory limit | ~1 GB+ on modern Android | Varies by device; typically 300–600 MB. Pyodide (~150 MB) fits comfortably on iPhone XS and later |
| Performance | Slightly faster (V8 WASM JIT) | Adequate; extraction of a 1-hour FIT file well under 3 s on modern iPhone |
Summary: what is Android-only
- Auto-import from a watched directory (Phase 2)
auto_import_pathsetting (hidden in the UI on iOS)- APK sideloading (for Karoo)
Everything else — extraction, local feed, activity detail, sync — is identical on both platforms.
Data model on device
CREATE TABLE activities (
id TEXT PRIMARY KEY, -- BAS ID: "2026-04-17T074238Z"
source_hash TEXT NOT NULL, -- SHA-256 of raw 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 in app storage (NULL if pulled from server)
synced_at INTEGER, -- unix timestamp of last push (NULL = unsynced)
origin TEXT NOT NULL -- "local" | "remote"
CHECK(origin IN ('local', 'remote')),
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
Settings keys:
| Key | Description | Platform |
|---|---|---|
instance_url |
e.g. https://bincio.org |
Both |
handle |
User's handle on the remote instance | Both |
session_token |
Bearer token for API auth | Both |
last_sync_at |
ISO timestamp of last sync | Both |
wheel_version |
Cached bincio wheel version | Both |
auto_import_path |
Directory to watch for new FIT files | Android only |
Sync protocol
Sync is a two-way hash-based diff. No custom protocol is needed beyond the existing REST API.
Push (local → server)
GET {instance_url}/{handle}/index.json— collect remote activity IDs.- Find local rows where
synced_at IS NULLandoriginal_path IS NOT NULL. POST /api/uploadwith the original file for each.- On 200: set
synced_at = unixepoch().
Pull (server → local)
GET {instance_url}/{handle}/index.json(+ yearly shards if present).- Find remote IDs absent from local DB.
- For each missing activity:
GET …/activities/{id}.json→detail_jsonGET …/activities/{id}.timeseries.json→timeseries_jsonGET …/activities/{id}.geojson→geojson
- Insert with
origin = 'remote',synced_at = unixepoch().
Activities pulled from the server have no local original_path. Re-extraction
requires the original file to be available (either already on device or fetched
from the instance if it stored it).
Conflict handling
Activities are immutable. source_hash is the dedup key: if the same file arrives
at the server twice, the second upload is rejected with 409.
Authentication
The server currently uses session cookies. For mobile, Bearer tokens are cleaner. A new endpoint is needed (Phase 3 server work):
POST /api/auth/token
Body: { "handle": "…", "password": "…" }
→ { "token": "abc123…", "expires_at": "2027-04-24T00:00:00Z" }
The token is stored in the settings table and sent as
Authorization: Bearer abc123… on all API requests.
Implementation plan
Phase 0 — Foundation
Goal: app launches, settings can be configured, a BAS JSON file can be picked and displayed as an activity card. No extraction yet.
mobile/ scaffold:
expo init mobile --template expo-template-blank-typescript- Expo Router with three tabs: Feed, Import, Settings
expo-sqliteinitialised;activitiesandsettingstables created on first launch- Settings screen: instance URL and handle fields, saved to
settingstable
Import screen (stub):
expo-document-pickerfor.fit,.gpx,.tcx,.jsonfiles- If a
.jsonfile is picked: parse as BAS detail, insert intoactivities(no timeseries), show in feed - This lets the feed work before Pyodide is wired up
Feed screen:
- List of activities from
activitiestable, sorted bystarted_at - Each card: sport icon, title, date, distance, elevation
Server (one small addition):
GET /api/wheel/version→{ "version": "0.1.0", "url": "/bincio-0.1.0-py3-none-any.whl" }- No auth required; the wheel itself is already public
Done when: App launches on a phone, user enters instance URL and handle in
Settings, picks a .json BAS file, sees it in the Feed.
Phase 1 — Import via Pyodide
Goal: pick a FIT/GPX/TCX file, extract it on-device in ~5 s, see the full ride with map and chart.
Extraction engine (mobile/extraction/):
PyodideWebView.tsx— hiddenWebViewrendering an inline HTML page that bootstraps PyodidewheelCache.ts— on startup,GET /api/wheel/version; if version changed, download and store wheel inexpo-file-systemapp directoryextractActivity.ts— encodes file bytes as base64, sends viapostMessage, awaits{ detail, timeseries, geojson }response- Loading state: "Warming up extractor…" shown only on very first use
Import screen (full):
- Picks FIT/GPX/TCX, passes to
extractActivity, stores in SQLite - Copies original file to
{documentDirectory}/originals/{source_hash}.{ext} - Duplicate detection via
source_hashbefore extraction
Activity detail screen:
- Stats grid: distance, moving time, elevation gain/loss, avg speed, avg HR, avg power
- Map: MapLibre React Native with the GeoJSON track overlaid
- Elevation chart: simple SVG line chart from timeseries data
Done when: Drop a FIT file from a Karoo onto the phone, see the full ride stats, map, and elevation profile within ~5 s.
Phase 2 — Karoo auto-import (Android only)
Goal: finish a ride, connect to WiFi, the activity appears in Bincio automatically.
Android:
- Settings screen gains
auto_import_pathfield (Android only, hidden on iOS) expo-task-managerbackground task registered at app startup- Task polls
auto_import_pathevery 5 minutes; for each.fitfile whosesource_hashis not in the DB, triggers extraction and import expo-notificationssends a local notification: "New ride: Morning Ride — 45 km"
iOS (alternative flow for Phase 2):
- Share Extension config so "Open with Bincio" appears in the iOS Files app
- Tapping it hands the file to the app, which runs extraction immediately
- No background polling; user-initiated but one-tap
Done when (Android): Finish a ride on the Karoo, the activity appears in Bincio within 5 minutes of connecting to WiFi, with no manual action.
Phase 3 — Sync
Goal: activities recorded on the phone appear on bincio.org after one tap.
Server additions:
POST /api/auth/token— password login returning a Bearer token (long-lived, stored securely; complements existing cookie auth, does not replace it)
App:
- Login screen: instance URL + handle + password → stores token
- Sync screen: last sync time, unsynced count, Push and Pull buttons
- Push: iterates unsynced local activities,
POST /api/uploadwith original file - Pull: fetches
index.json, downloads missing activities, inserts asorigin = 'remote' - Progress indicator per activity (useful for first sync with many files)
Done when: Tap Push, activities appear on bincio.org with correct stats.
Phase 4 — Polish (ongoing)
- Offline map tiles — bundle or download an MBTiles file for a region; MapLibre supports offline tile sources
- Batch import — pick a folder (Strava export, Garmin bulk export); import all FIT/GPX files found, with progress bar and per-file status
- Share sheet — on Android, intent filter for incoming
.fit/.gpx/.tcxfiles from other apps; on iOS, Share Extension already set up in Phase 2 - Home screen widget — last activity summary or weekly km total
- Re-extract — button in activity detail to re-run Pyodide extraction from the stored original file (picks up algorithm improvements)
Out of scope for v1
- Live activity recording — GPS track + sensor data during a ride. This is the eventual goal for complete platform independence but requires significant additional work (background GPS, Bluetooth/ANT+ sensor integration, real-time display).
- Editing activities — read-only in v1; edits happen via the web interface.
- Photo sync — deferred.
Future: toward full platform independence
Once live recording is implemented, the stack becomes:
Ride starts → Bincio records GPS + sensors (BLE power meter, HR strap, etc.)
Ride ends → Bincio extracts the activity locally (Pyodide or native)
→ Activity visible in the mobile feed immediately
→ Original FIT file saved on device
→ Optional: push to bincio.org for web access
At that point Garmin Connect, Hammerhead sync, and Strava become entirely optional. The Karoo (or any Android head unit running the app) becomes a self-contained training ecosystem.