312 lines
13 KiB
Markdown
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 |
|