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

312 lines
13 KiB
Markdown

# 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
```sql
-- 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}.json``detail_json`
- `GET {instance_url}/activities/{id}.timeseries.json``timeseries_json`
- `GET {instance_url}/activities/{id}.geojson``geojson`
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 |