docs: complete mobile app plan — phased roadmap, Android/iOS divergences, data model
This commit is contained in:
+322
-195
@@ -2,10 +2,10 @@
|
||||
|
||||
## 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 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:
|
||||
|
||||
@@ -17,6 +17,10 @@ The Bincio mobile app removes that dependency:
|
||||
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
|
||||
@@ -38,285 +42,408 @@ available, the app downloads a fresh copy of the extraction algorithm from binci
|
||||
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 (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.
|
||||
**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.
|
||||
**Open format.** Activities are stored in the BAS schema — the same JSON format the
|
||||
server uses. Any tool in any language can read them.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
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 |
|
||||
| Pyodide-based extraction | `site/src/pages/convert/` | FIT/GPX/TCX parsing via CPython→WASM running in the browser. **This is the proof of concept for mobile extraction** — a hidden WebView in the app uses the exact same mechanism. |
|
||||
| Bincio wheel | served at `/bincio-0.1.0-py3-none-any.whl` | The extraction code packaged as a pure-Python wheel. Already downloaded and run by the `/convert/` page. |
|
||||
| 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 |
|
||||
| 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 choice
|
||||
## Technology
|
||||
|
||||
### Cross-platform framework: Expo (React Native)
|
||||
### 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 (critical 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; same hidden-WebView trick for Pyodide applies either way |
|
||||
| Flutter | Dart is a new language; no practical advantage for this use case |
|
||||
| PWA | iOS limits background sync, local storage quotas, and filesystem access — not viable for an activity logger |
|
||||
- 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
|
||||
|
||||
This is the core technical insight. The `/convert/` page already demonstrates that
|
||||
the full Python extraction pipeline can run in a browser via **Pyodide** (CPython
|
||||
compiled to WebAssembly). A React Native app can host a hidden `WebView` component
|
||||
running the exact same code. No rewrite required.
|
||||
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.
|
||||
|
||||
### How the /convert/ page does it today
|
||||
### Package stack (proven in /convert/ today)
|
||||
|
||||
```
|
||||
Browser tab
|
||||
└── Pyodide (CPython → WASM, ~30 MB)
|
||||
├── lxml (pre-compiled in Pyodide — XML/GPX parsing)
|
||||
├── fitdecode (pure Python — FIT parsing)
|
||||
├── gpxpy (pure Python — GPX parsing)
|
||||
├── pyyaml (pure Python)
|
||||
└── bincio wheel (pure Python — metrics, hysteresis, writers)
|
||||
fetched from: /bincio-0.1.0-py3-none-any.whl
|
||||
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
|
||||
```
|
||||
|
||||
All dependencies are either pre-compiled in Pyodide or **pure Python with no C
|
||||
extensions**. This is the key: there is nothing to recompile for mobile.
|
||||
Every dependency is either pre-compiled in Pyodide or **pure Python with no C
|
||||
extensions**. Nothing needs recompilation for mobile.
|
||||
|
||||
### How the mobile app does it
|
||||
### Data flow
|
||||
|
||||
```
|
||||
React Native app
|
||||
└── Hidden WebView (WKWebView on iOS, Chrome WebView on Android)
|
||||
└── Same Pyodide environment as the /convert/ page
|
||||
├── Pyodide runtime (cached on device after first download)
|
||||
├── lxml, fitdecode, gpxpy, pyyaml (cached)
|
||||
└── bincio wheel (fetched from bincio.org on startup / version check)
|
||||
React Native
|
||||
1. Read file bytes from device filesystem (expo-file-system)
|
||||
2. postMessage({ type: 'extract', filename, bytes }) → hidden WebView
|
||||
|
||||
Data flow:
|
||||
1. App reads FIT file bytes from device filesystem
|
||||
2. Sends bytes to WebView via postMessage
|
||||
3. WebView writes bytes to Pyodide's virtual FS
|
||||
4. Python runs the extraction → BAS JSON dict
|
||||
5. WebView sends JSON back via postMessage
|
||||
6. App stores BAS JSON in SQLite, original file on disk
|
||||
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.** The only network traffic is:
|
||||
- Pyodide runtime (CDN or bundled, ~30 MB, cached)
|
||||
- Common packages (CDN or bundled, cached)
|
||||
- The bincio wheel from bincio.org (~50 KB, updated on version bump)
|
||||
**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
|
||||
### Algorithm updates without App Store releases
|
||||
|
||||
The bincio wheel is versioned and served from bincio.org. On app startup (or
|
||||
periodically), the app checks the current wheel version:
|
||||
The bincio wheel is versioned. On app startup the app calls:
|
||||
|
||||
```
|
||||
GET https://bincio.org/bincio-latest.whl (or a version manifest endpoint)
|
||||
GET /api/wheel/version → { "version": "0.2.1", "url": "/bincio-0.2.1-py3-none-any.whl" }
|
||||
```
|
||||
|
||||
If a new version is available, the wheel is downloaded and cached. The next
|
||||
extraction uses the updated algorithm. Improvements to hysteresis thresholds,
|
||||
DEM correction, lap detection, or any other metric are live on all devices
|
||||
within hours of deployment — **no App Store submission required**.
|
||||
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
|
||||
|
||||
- **First extraction after install**: ~5–8 s (Pyodide startup + package load)
|
||||
- **Subsequent extractions (warm WebView)**: ~1–3 s per activity
|
||||
- **Pyodide memory footprint**: ~100–150 MB RAM while active; the WebView can
|
||||
be suspended between extractions
|
||||
- **Wheel size**: the bincio extract code is ~50 KB; Pyodide + packages ~30 MB
|
||||
(downloaded once, cached on device)
|
||||
| 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 (many files at once), the WebView is kept warm across
|
||||
extractions, making the per-file cost just the Python execution time (~0.5–1 s
|
||||
per typical activity).
|
||||
For batch import the WebView is kept alive across files; per-file cost drops to
|
||||
the Python execution time only.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
## Android vs iOS: platform divergences
|
||||
|
||||
```
|
||||
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 (Pyodide in hidden WebView)
|
||||
│ ├── WebView host — manages lifecycle, message passing
|
||||
│ ├── Wheel cache — versioned bincio wheel stored on device
|
||||
│ └── Python runtime — Pyodide + fitdecode + gpxpy + lxml
|
||||
│ identical to the /convert/ page on the web
|
||||
│
|
||||
├── 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 per activity
|
||||
│ └── settings — instance_url, handle, auth_token, sync prefs
|
||||
│
|
||||
└── Sync Layer (optional)
|
||||
├── Auth — POST /api/auth/login → Bearer token
|
||||
├── Push — POST /api/upload (original file)
|
||||
└── Pull — GET index.json + activity/{id}.json + timeseries
|
||||
```
|
||||
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_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
|
||||
|
||||
```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 NOT NULL, -- path to original file in app storage
|
||||
synced_at INTEGER, -- unix timestamp of last push to remote
|
||||
origin TEXT NOT NULL, -- "local" | "remote"
|
||||
created_at INTEGER NOT NULL
|
||||
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())
|
||||
);
|
||||
|
||||
-- settings table
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
CREATE TABLE settings (
|
||||
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` |
|
||||
| `wheel_version` | `0.1.0` |
|
||||
| `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 — no manual export step, no Hammerhead
|
||||
cloud sync, no Garmin Connect, no Strava required.
|
||||
|
||||
On Karoo specifically:
|
||||
- Install the Bincio Android APK directly (sideload or via a store).
|
||||
- Configure `auto_import_path` to point at the Karoo's ride directory.
|
||||
- When a new FIT file appears, the app imports it automatically (Pyodide
|
||||
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 for web access and backup.
|
||||
|
||||
This makes Bincio a complete replacement for Hammerhead's own sync infrastructure
|
||||
for users who want full control of their data.
|
||||
| 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 server protocol needed.
|
||||
Sync is a two-way hash-based diff. No custom protocol is 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()`.
|
||||
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. Fetch `{instance_url}/{handle}/index.json` (and yearly shards).
|
||||
2. Find remote IDs not in the local DB.
|
||||
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 {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()`.
|
||||
- `GET …/activities/{id}.json` → `detail_json`
|
||||
- `GET …/activities/{id}.timeseries.json` → `timeseries_json`
|
||||
- `GET …/activities/{id}.geojson` → `geojson`
|
||||
4. Insert with `origin = 'remote'`, `synced_at = unixepoch()`.
|
||||
|
||||
Note: pulled activities don't have a local original file. If re-extraction is
|
||||
needed (e.g. for a DEM correction), the original must be uploaded to the instance
|
||||
first so the server can serve it back.
|
||||
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 once created. The `source_hash` prevents double-counting:
|
||||
if the same file is imported on two devices before sync, whichever arrives at the
|
||||
server first wins; the duplicate is rejected with 409.
|
||||
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 multi-user server currently uses HTTP session cookies. For the mobile client,
|
||||
a **Bearer token** is cleaner:
|
||||
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
|
||||
{ "handle": "…", "password": "…" }
|
||||
→ { "token": "abc123…", "expires_at": "…" }
|
||||
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 subsequent requests.
|
||||
`Authorization: Bearer abc123…` on all API requests.
|
||||
|
||||
---
|
||||
|
||||
## What is out of scope for v1
|
||||
## Implementation plan
|
||||
|
||||
- **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.
|
||||
### 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-sqlite` initialised; `activities` and `settings` tables created on first launch
|
||||
- Settings screen: instance URL and handle fields, saved to `settings` table
|
||||
|
||||
**Import screen (stub):**
|
||||
- `expo-document-picker` for `.fit`, `.gpx`, `.tcx`, `.json` files
|
||||
- If a `.json` file is picked: parse as BAS detail, insert into `activities` (no timeseries), show in feed
|
||||
- This lets the feed work before Pyodide is wired up
|
||||
|
||||
**Feed screen:**
|
||||
- List of activities from `activities` table, sorted by `started_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` — 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
|
||||
- `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 in SQLite
|
||||
- Copies original file to `{documentDirectory}/originals/{source_hash}.{ext}`
|
||||
- Duplicate detection via `source_hash` before 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_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 — 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/upload` with original file
|
||||
- Pull: fetches `index.json`, downloads missing activities, inserts as `origin = '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`/`.tcx`
|
||||
files 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.
|
||||
- **Watch / ANT+ / Bluetooth sensors** — deferred.
|
||||
- **Editing activities on mobile** — read-only in v1; edits happen on the web.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
## Future: toward full platform independence
|
||||
|
||||
| Phase | Scope |
|
||||
|---|---|
|
||||
| **0 — Foundation** | Expo project scaffold, SQLite store, settings screen, file picker, display a BAS JSON read from disk |
|
||||
| **1 — Import** | Hidden WebView + Pyodide extraction, wheel download and caching, local feed, activity detail with map and chart, original file storage |
|
||||
| **2 — Karoo integration** | Auto-import from a watched directory, Android-specific filesystem access |
|
||||
| **3 — Sync** | Bearer token auth, push/pull sync with a Bincio instance |
|
||||
| **4 — Polish** | Offline map tiles, share sheet, home screen widget, batch import performance |
|
||||
| **Future** | Live recording, Bluetooth/ANT+ sensors, full Garmin/Wahoo/Hammerhead replacement |
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user