diff --git a/docs/mobile-app.md b/docs/mobile-app.md new file mode 100644 index 0000000..ef47453 --- /dev/null +++ b/docs/mobile-app.md @@ -0,0 +1,181 @@ +# 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 |