Files
bincio-activity/docs/mobile-app.md
T
Davide Scaini 97c7fae9be feat: Phase 4 — MapLibre route map + SVG elevation chart on activity screen
- Add /api/activity/{id}/geojson and /api/activity/{id}/timeseries endpoints
  (bearer-token-gated, falls back from _merged to raw activities dir)
- Rewrite activity detail screen with MapLibreGL v11 API (Map, Camera,
  GeoJSONSource, Layer) and react-native-svg area chart with gradient fill
- On-demand fetch for remote activities that have no local geojson/timeseries
- Add react-native-svg dependency; requires dev build (npx expo run:android)
2026-04-24 15:40:10 +02:00

26 KiB
Raw Blame History

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.


Development setup

Two build modes: Expo Go vs Development Build

This is the most important thing to understand before starting.

Expo Go is the Expo app available on the Play Store / App Store. It runs any Expo project by scanning a QR code — no compilation step. However, it only supports Expo's own built-in modules. It does not support third-party native modules.

Development Build is a custom version of the Expo Go app compiled specifically for this project. It includes all third-party native modules (react-native-webview, maplibre). It is installed once on the device; after that, code changes still update instantly via Metro (the JS bundler) — no rebuild needed.

Expo Go Development Build
Setup Scan QR, instant Build APK once (local or EAS cloud)
expo-sqlite
expo-document-picker
react-native-webview (Pyodide)
@maplibre/maplibre-react-native
Code changes instant (Metro) instant (Metro)
Native changes need new Expo Go release rebuild APK

Phase 0 and 0.5 only use built-in Expo modules — Expo Go works. Phase 1 (Pyodide) and Phase 4 (MapLibre maps) require a Development Build because react-native-webview and @maplibre/maplibre-react-native are native modules.

The preferred path for Phase 1+: connect the phone via USB and run npx expo run:android once. After that, JS changes still update instantly via Metro — no rebuild needed unless you change native code.


Prerequisites

Tool Required for Install
Node.js 20 LTS everything nodejs.org or nvm install 20
npm everything ships with Node
Android Studio Android dev build / emulator developer.android.com/studio
Xcode 15+ iOS only, macOS only App Store → xcode-select --install
EAS CLI cloud builds (optional) npm install -g eas-cli

You do not need a physical Android device to start. The Android emulator (AVD Manager inside Android Studio) works fine for development.


First-time setup

# From the repo root:
bash mobile/setup.sh

The script checks Node, Android SDK, and Xcode availability; installs npm dependencies; and generates the required Expo type declarations.


Phase 0 — Expo Go (quickest start)

Since Phase 0 uses only built-in Expo modules, you can start with Expo Go:

cd mobile
npx expo start
  1. Install Expo Go on your Android phone from the Play Store.
  2. Scan the QR code printed in the terminal.
  3. The app loads instantly. Code changes in your editor appear on the phone within a second or two.

Limitation: once you add the Pyodide WebView in Phase 1, you must switch to a Development Build. Expo Go will show an error for react-native-webview.


Phase 1+ — Development Build

Option A: local build (Android Studio required)

Plug in an Android device via USB (or start an emulator in Android Studio), then:

cd mobile
npx expo run:android          # builds APK, installs it, starts Metro

This compiles the full native project once (~35 min). After that, JS changes reflect instantly without rebuilding.

For the emulator, create an AVD in Android Studio with API 33+ and start it before running the command.

Option B: local EAS build (no Android Studio, no external cloud)

eas build --local runs the entire build pipeline on your own machine (or VPS):

npm install -g eas-cli
eas build -p android --profile development --local

This produces an .apk you can transfer to the device via any means (USB, VPS download link, AirDrop). No Expo account or cloud service required.


iOS development (macOS only)

cd mobile
npx expo run:ios               # opens iOS simulator, builds, and runs

Requires Xcode 15+ and an active iOS simulator. Cloud builds via EAS:

eas build -p ios --profile development   # requires Apple Developer account ($99/yr)

Where Pyodide comes from

The hidden WebView loads Pyodide from the jsDelivr CDN — the same source as the /convert/ page on the web:

https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js  (~30 MB)

On first extraction after install, the WebView downloads and caches this runtime in expo-file-system's document directory. Subsequent extractions use the cached copy — no internet required.

The bincio wheel (~50 KB) is fetched from:

GET {instance_url}/api/wheel/version   → { version, url, api_url }
GET {instance_url}/bincio-{version}-py3-none-any.whl   (nginx, prod)
GET {instance_url}/api/wheel/download                  (FastAPI, local dev)

If no instance is configured, it falls back to https://bincio.org. The wheel is also cached locally and re-downloaded only when the version changes.

Local development (before bincio is published to PyPI): the wheel is not on PyPI, so there is a bundled fallback at mobile/assets/bincio.whl. The extraction code loads it from the app bundle when no cached wheel exists yet. This bundled copy is updated manually by running:

uv build --wheel              # builds dist/bincio-*.whl
cp dist/bincio-*.whl mobile/assets/bincio.whl

The server-side GET /api/wheel/download endpoint also serves the wheel directly from dist/ — useful when running a local bincio serve instance on the same WiFi network as the test device and wanting to exercise the update flow.

The common packages (fitdecode, gpxpy, lxml, pyyaml) are fetched from the Pyodide CDN via micropip on first use and cached by the WebView's internal storage.

Summary of what touches the network:

Asset Size When Cached
Pyodide runtime ~30 MB once (first extraction ever) permanently
Common packages ~5 MB once permanently
bincio wheel ~50 KB on version bump (bundled fallback in assets/) until next update
Map tiles per-tile on pan/zoom by MapLibre

Everything else — the activity files, the extracted BAS JSON — stays on device.


Distributing the app

Target Method
Your own Android phone npx expo run:android via USB, or EAS development build
Karoo 2 EAS production build → download APK → sideload via adb install bincio.apk or Karoo's app sideloader
Other Android users EAS build → share APK download link (no Play Store needed)
Play Store EAS production build → upload .aab to Play Console
iOS users EAS build → TestFlight (beta) or App Store

For Karoo sideloading:

eas build -p android --profile preview   # produces a standalone APK
adb install /path/to/bincio.apk          # with Karoo connected via USB

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-sqlite v2 — on-device SQLite with WAL mode
  • expo-document-picker — file picking from device storage
  • expo-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 app
  • expo-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) ~58 s
First extraction in session (warm WebView) ~13 s
Subsequent extractions (warm WebView) ~0.51 s
Pyodide RAM while active ~100150 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 300600 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_path setting (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)

  1. GET {instance_url}/{handle}/index.json — collect remote activity IDs.
  2. Find local rows where synced_at IS NULL and original_path IS NOT NULL.
  3. POST /api/upload with the original file for each.
  4. On 200: set synced_at = unixepoch().

Pull (server → local)

  1. GET {instance_url}/{handle}/index.json (+ yearly shards if present).
  2. Find remote IDs absent from local DB.
  3. For each missing activity:
    • GET …/activities/{id}.jsondetail_json
    • GET …/activities/{id}.timeseries.jsontimeseries_json
    • GET …/activities/{id}.geojsongeojson
  4. 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.

  • Expo Router with three tabs: Feed, Import, Settings
  • expo-sqlite initialised; activities and settings tables created on first launch
  • Settings screen: instance URL and handle, saved to settings table
  • Import screen: expo-document-picker; BAS .json files parsed and inserted into feed
  • Feed screen: activity cards sorted by started_at, sport icon, distance, elevation
  • GET /api/wheel/version server endpoint (public, no auth)

Done when: App launches, user picks a .json BAS file, sees it in the Feed.


Phase 0.5 — Remote feed sync

Goal: pull all activities from a remote bincio instance into the local feed.

  • POST /api/auth/token — password login returning a Bearer token (stored in SQLite; password forgotten immediately after)
  • GET /api/feed — auth-gated; reads _merged/index.json shards and returns all activity summaries as JSON
  • Settings screen: Connect section (password field + Connect button + status)
  • Feed screen: ↓ Sync button and pull-to-refresh; "cloud" badge on remote activities; syncFeed() upserts remote summaries without overwriting local imports

Done when: Tap Connect, tap Sync, all instance activities appear in the Feed.


Phase 1 — Local FIT/GPX/TCX extraction via Pyodide

Goal: pick a FIT/GPX/TCX file, extract it on-device in ~5 s.

Requires a Development Build (npx expo run:android via USB, or eas build --local). Expo Go does not support react-native-webview.

Extraction engine (mobile/extraction/):

  • PyodideWebView.tsx — hidden WebView rendering an inline HTML page that bootstraps Pyodide
  • wheelCache.ts — on startup, GET /api/wheel/version; if version changed, download and store wheel in expo-file-system app directory; falls back to bundled assets/bincio.whl for offline / pre-deploy use
  • extractActivity.ts — encodes file bytes as base64, sends via postMessage, 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 result in SQLite (detail_json, timeseries_json, geojson columns)
  • Copies original file to {documentDirectory}/originals/{source_hash}.{ext}
  • Duplicate detection via source_hash before extraction

Done when: Pick a FIT file from the Karoo rides folder, see full stats in the Feed within ~5 s, including map and elevation profile.


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_path field (Android only, hidden on iOS)
  • expo-task-manager background task registered at app startup
  • Task polls auto_import_path every 5 minutes; for each .fit file whose source_hash is not in the DB, triggers extraction and import
  • expo-notifications sends 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 — Push sync

Goal: locally imported activities appear on the remote instance after one tap.

Auth (Bearer token + Connect UI) is already done in Phase 0.5. Remaining work:

Server:

  • POST /api/upload accepting a raw FIT/GPX/TCX file with Bearer token auth — same as the existing web upload endpoint but token-gated

App:

  • Push button (Settings or Feed header): iterates unsynced local activities (synced_at IS NULL AND origin = 'local'), uploads original file, marks synced
  • Progress indicator per activity; useful for first push with many files

Done when: Tap Push, locally imported activities appear on bincio.org.


Phase 4 — Activity detail: map + elevation chart

Goal: every activity shows a route map and elevation profile.

Requires a Development Build@maplibre/maplibre-react-native is a native module. Same dev build used for Phase 1 covers Phase 4.

Data strategy (on-demand fetch for remote activities):

  • Local activities (Phase 1 imports): geojson and timeseries_json stored in SQLite — map and chart render immediately, no network needed
  • Remote activities (Phase 0.5 synced): detail screen fetches GET /api/activity/{id}/geojson and GET /api/activity/{id}/timeseries on first open; both are Bearer-token-gated FastAPI endpoints

Server additions:

  • GET /api/activity/{id}/geojson — reads _merged/activities/{id}.geojson
  • GET /api/activity/{id}/timeseries — reads activities/{id}.timeseries.json

App:

  • @maplibre/maplibre-react-native: route drawn as GeoJSON LineLayer over a dark CartoDB base map; camera auto-fits track bounding box
  • react-native-svg: elevation area chart from elevation_m + t arrays; downsampled to ≤300 points; shows min/max elevation labels

Done when: Open any synced or locally imported activity — map and elevation profile are visible within 1 s (local) or after one network round-trip (remote).


Phase 5 — 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 — Android intent filter for incoming .fit/.gpx/.tcx files
  • Re-extract — button to re-run Pyodide extraction from the stored original file

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.