docs: document current mobile app state — gaps, stubs, and missing plan items

- Mark Phase 1 as the critical unbuilt feature; note its prerequisite chain
  (SHA-256 dedup, re-extract button, Phase 2 auto-import all depend on it)
- Flag Phase 2 auto_import_path as stubbed in UI but unimplemented in background
- Add "Known gaps and technical debt" section covering:
  - source_hash stub (id+length, not SHA-256)
  - FIT/GPX/TCX import placeholder alert
  - auto_import_path field with no backend task
  - feed pagination (getAllSync with no LIMIT)
  - individual activity deletion (missing from plan)
  - feed search and filter (missing from plan)
  - token expiry / inline reconnect flow (missing from plan)
  - app icon and splash screen (Expo defaults)
  - upload skip behaviour for origin=remote rows
- Add Phase 5 items not previously in the plan: app icon, feed search/filter,
  individual deletion, token reconnect prompt
This commit is contained in:
Davide Scaini
2026-04-24 22:42:20 +02:00
parent c7c7fe9395
commit ed738ffc97
+296 -149
View File
@@ -63,8 +63,8 @@ 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 **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, 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 maplibre, react-native-svg). It is installed once on the device; after that, code
instantly via Metro (the JS bundler) — no rebuild needed. changes still update instantly via Metro (the JS bundler) — no rebuild needed.
| | Expo Go | Development Build | | | Expo Go | Development Build |
|---|---|---| |---|---|---|
@@ -73,6 +73,7 @@ instantly via Metro (the JS bundler) — no rebuild needed.
| expo-document-picker | ✅ | ✅ | | expo-document-picker | ✅ | ✅ |
| react-native-webview (Pyodide) | ✗ | ✅ | | react-native-webview (Pyodide) | ✗ | ✅ |
| @maplibre/maplibre-react-native | ✗ | ✅ | | @maplibre/maplibre-react-native | ✗ | ✅ |
| react-native-svg | ✗ | ✅ |
| Code changes | instant (Metro) | instant (Metro) | | Code changes | instant (Metro) | instant (Metro) |
| Native changes | need new Expo Go release | rebuild APK | | Native changes | need new Expo Go release | rebuild APK |
@@ -92,24 +93,43 @@ The preferred path for Phase 1+: connect the phone via USB and run
|---|---|---| |---|---|---|
| Node.js 20 LTS | everything | [nodejs.org](https://nodejs.org) or `nvm install 20` | | Node.js 20 LTS | everything | [nodejs.org](https://nodejs.org) or `nvm install 20` |
| npm | everything | ships with Node | | npm | everything | ships with Node |
| Android Studio | Android dev build / emulator | [developer.android.com/studio](https://developer.android.com/studio) | | JDK 17 | Android builds | `brew install --cask zulu@17` |
| Android Studio + SDK | Android dev build / emulator | [developer.android.com/studio](https://developer.android.com/studio) |
| Xcode 15+ | iOS only, macOS only | App Store → `xcode-select --install` | | Xcode 15+ | iOS only, macOS only | App Store → `xcode-select --install` |
| EAS CLI | cloud builds (optional) | `npm install -g eas-cli` | | EAS CLI | cloud builds (optional) | `npm install -g eas-cli` |
You do **not** need a physical Android device to start. The Android emulator You do **not** need a physical Android device to start. The Android emulator
(AVD Manager inside Android Studio) works fine for development. (AVD Manager inside Android Studio) works fine for development.
After installing Android Studio, create `mobile/android/local.properties`:
```
sdk.dir=/path/to/Library/Android/sdk
```
--- ---
### First-time setup ### First-time setup
```bash ```bash
# From the repo root: # From repo root:
bash mobile/setup.sh cd mobile && npm install
``` ```
The script checks Node, Android SDK, and Xcode availability; installs npm ---
dependencies; and generates the required Expo type declarations.
### Local dev with a phone on WiFi
To test sync against a locally running bincio instance from a phone on the same
WiFi network:
```bash
./scripts/dev_test.py --mobile
```
This binds the API server to `0.0.0.0` (all interfaces) instead of `127.0.0.1` and
prints the LAN IP to use as the instance URL in the app settings. The mobile option
detects the local IP automatically via a UDP socket trick.
--- ---
@@ -167,10 +187,22 @@ download link, AirDrop). No Expo account or cloud service required.
```bash ```bash
cd mobile cd mobile
npx expo run:ios # opens iOS simulator, builds, and runs npx expo run:ios --udid <device-udid>
``` ```
Requires Xcode 15+ and an active iOS simulator. Cloud builds via EAS: Requires Xcode 15+ and an iOS platform SDK matching the device's iOS version
(Xcode Settings → Platforms). After initial build, JS changes update via Metro.
MapLibre on iOS requires the following in `ios/Podfile`'s `post_install` block:
```ruby
$MLRN.post_install(installer)
```
This lets MapLibre inject its Swift Package Manager framework dependency. Without
it, the build fails with `MLNNetworkConfiguration.h not found`.
Cloud builds via EAS:
```bash ```bash
eas build -p ios --profile development # requires Apple Developer account ($99/yr) eas build -p ios --profile development # requires Apple Developer account ($99/yr)
@@ -202,24 +234,6 @@ GET {instance_url}/api/wheel/download (FastAPI, local dev)
If no instance is configured, it falls back to `https://bincio.org`. The wheel 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. 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:
```bash
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:** **Summary of what touches the network:**
| Asset | Size | When | Cached | | Asset | Size | When | Cached |
@@ -253,21 +267,26 @@ adb install /path/to/bincio.apk # with Karoo connected via USB
## Repository layout ## 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_activity/
├── bincio/ — Python server + extractor ├── bincio/ — Python server + extractor
│ └── serve/server.py — FastAPI server (includes mobile API endpoints)
├── site/ — Astro web frontend ├── site/ — Astro web frontend
├── mobile/ — Expo React Native app ← this document ├── mobile/ — Expo React Native app ← this document
│ ├── app/ — Expo Router screens │ ├── app/
│ ├── components/ — shared React Native components │ ├── (tabs)/
├── db/ — SQLite schema and queries ├── index.tsx — Feed screen
├── extraction/ — WebView host + Pyodide bridge │ │ ├── import.tsx — Import screen
└── sync/ — push/pull logic └── settings.tsx — Settings screen
│ │ └── activity/[id].tsx — Activity detail screen
│ └── db/
│ ├── index.ts — SQLite schema migrations
│ ├── queries.ts — typed query helpers + React hooks
│ └── sync.ts — bidirectional sync logic
├── scripts/
│ └── dev_test.py — local dev runner (--mobile binds to 0.0.0.0)
└── docs/ └── docs/
└── mobile-app.md — this document
``` ```
--- ---
@@ -281,7 +300,7 @@ bincio_activity/
| 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 | | 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. | | 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 | | 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 | | REST API | `bincio/serve/server.py` | Login, upload, activity detail, feed — sync primitives |
| Settings table | `bincio/serve/db.py` | Key/value settings in the server DB; same pattern used on device | | Settings table | `bincio/serve/db.py` | Key/value settings in the server DB; same pattern used on device |
--- ---
@@ -293,13 +312,35 @@ bincio_activity/
- TypeScript throughout - TypeScript throughout
- `expo-sqlite` v2 — on-device SQLite with WAL mode - `expo-sqlite` v2 — on-device SQLite with WAL mode
- `expo-document-picker` — file picking from device storage - `expo-document-picker` — file picking from device storage
- `expo-file-system` — filesystem access (critical for Karoo directory watching on Android) - `expo-file-system/legacy` — filesystem access (legacy import required with Expo SDK 54+)
- `react-native-webview` — hidden WebView for Pyodide - `react-native-webview` — hidden WebView for Pyodide (Phase 1)
- `@maplibre/maplibre-react-native` — maps, same tile standard as the web app - `@maplibre/maplibre-react-native` v11 — interactive maps, dark CartoDB tiles
- `expo-background-fetch` + `expo-task-manager` — background directory polling (Android) - `react-native-svg` ≥ 15.15.4 — SVG area charts; **must be ≥ 15.15.4** (earlier
- `expo-notifications` — import notifications versions crash in React Native New Architecture / Fabric)
- `expo-background-fetch` + `expo-task-manager` — background directory polling (Android, Phase 2)
- `expo-notifications` — import notifications (Phase 2)
- EAS Build — iOS and Android binaries; APK sideloading for Karoo - EAS Build — iOS and Android binaries; APK sideloading for Karoo
### React Native New Architecture (Fabric)
The app runs with `newArchEnabled=true` (React Native New Architecture). This is
required for compatibility with the MapLibre native module. Key implication:
`react-native-svg` must be **≥ 15.15.4** — earlier versions crash on startup in
Fabric with a `RNSVGUseProps::~RNSVGUseProps()` / `folly::dynamic::destroy()` error.
### MapLibre v11 API
`@maplibre/maplibre-react-native` v11 changed from a default export to named exports:
```typescript
// v11 — named imports required
import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native';
// Map props: mapStyle (not styleURL), dragPan/touchZoom/touchPitch/touchRotate
// Camera: initialViewState.bounds = [west, south, east, north]
// No scrollEnabled prop — use dragPan={false} instead
```
--- ---
## Extraction: Pyodide in a hidden WebView ## Extraction: Pyodide in a hidden WebView
@@ -344,18 +385,6 @@ React Native
(~30 MB, CDN, cached once) and the bincio wheel (~50 KB, from bincio.org, updated (~30 MB, CDN, cached once) and the bincio wheel (~50 KB, from bincio.org, updated
on version bump). 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 ### Performance
| Scenario | Time | | Scenario | Time |
@@ -372,9 +401,6 @@ the Python execution time only.
## Android vs iOS: platform divergences ## Android vs iOS: platform divergences
These two platforms share almost all code. The differences are confined to
filesystem access and background behaviour.
### Filesystem access ### Filesystem access
| | Android | iOS | | | Android | iOS |
@@ -393,30 +419,6 @@ filesystem access and background behaviour.
| Import trigger | Automatic on new FIT file detected in watched directory | Manual: user shares file via Files app or "Open with Bincio" | | 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) | | 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 ### Summary: what is Android-only
- Auto-import from a watched directory (Phase 2) - Auto-import from a watched directory (Phase 2)
@@ -456,59 +458,84 @@ CREATE TABLE settings (
|---|---|---| |---|---|---|
| `instance_url` | e.g. `https://bincio.org` | Both | | `instance_url` | e.g. `https://bincio.org` | Both |
| `handle` | User's handle on the remote instance | Both | | `handle` | User's handle on the remote instance | Both |
| `session_token` | Bearer token for API auth | Both | | `api_token` | Bearer token for API auth (obtained via Connect, never stored in plaintext long-term) | Both |
| `last_sync_at` | ISO timestamp of last sync | Both | | `sync_mode` | `"summaries"` (default) or `"full"` — controls whether geojson+timeseries are downloaded during sync | Both |
| `wheel_version` | Cached bincio wheel version | Both | | `sync_upload` | `"true"` or `"false"` — whether to push local activities during sync | Both |
| `auto_import_path` | Directory to watch for new FIT files | **Android only** | | `auto_import_path` | Directory to watch for new FIT files | **Android only** |
--- ---
## Sync protocol ## Sync protocol
Sync is a two-way hash-based diff. No custom protocol is needed beyond the ### Download (server → local)
existing REST API.
### Push (local → server) Implemented in `mobile/db/sync.ts``syncFeed()`.
1. `GET {instance_url}/{handle}/index.json` — collect remote activity IDs. 1. `GET {instance_url}/api/feed` with `Authorization: Bearer <token>` — returns all
2. Find local rows where `synced_at IS NULL` and `original_path IS NOT NULL`. activity summaries as `{ activities: [...] }`.
3. `POST /api/upload` with the original file for each. 2. Upsert each summary into the local `activities` table with `origin = 'remote'`.
4. On 200: set `synced_at = unixepoch()`. Returns `changes > 0` (new or updated) per row; counts synced entries.
3. **Summaries mode** (default): stops here. Geojson and timeseries are fetched
on-demand when the user opens an activity detail screen.
4. **Full mode**: for every activity where `geojson` or `timeseries_json` is NULL,
fetches `GET /api/activity/{id}/geojson` and `GET /api/activity/{id}/timeseries`.
Uses `COALESCE` in the UPDATE to avoid overwriting already-stored data.
### Pull (server → local) ### Upload (local → server)
1. `GET {instance_url}/{handle}/index.json` (+ yearly shards if present). Implemented in `mobile/db/sync.ts``uploadLocalActivities()`. Enabled when
2. Find remote IDs absent from local DB. `sync_upload = "true"` in settings.
3. For each missing activity:
- `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()`.
Activities pulled from the server have no local `original_path`. Re-extraction 1. Query `activities WHERE origin = 'local' AND synced_at IS NULL`.
requires the original file to be available (either already on device or fetched 2. For each: read the BAS JSON from `original_path` via `expo-file-system`.
from the instance if it stored it). 3. `POST {instance_url}/api/upload/bas` with body `{ activity, timeseries?, geojson? }`.
4. On 200/duplicate: set `synced_at = unixepoch()`.
The server endpoint (`bincio/serve/server.py``POST /api/upload/bas`) accepts
pre-extracted BAS JSON rather than raw FIT/GPX/TCX. It deduplicates by checking
if the activity file already exists, writes geojson and timeseries if provided, then
calls `merge_all()` to refresh the server's merged feed.
### Conflict handling ### Conflict handling
Activities are immutable. `source_hash` is the dedup key: if the same file arrives Activities are immutable. Dedup on download: `ON CONFLICT(id) DO UPDATE` only
at the server twice, the second upload is rejected with 409. updates `detail_json` and `synced_at`. Dedup on upload: the server returns
`{ ok: true, status: "duplicate" }` (still HTTP 200) if the file already exists.
--- ---
## Authentication ## Authentication
The server currently uses session cookies. For mobile, Bearer tokens are cleaner. The server exposes `POST /api/auth/token`:
A new endpoint is needed (Phase 3 server work):
``` ```
POST /api/auth/token POST /api/auth/token
Body: { "handle": "…", "password": "…" } Body: { "handle": "…", "password": "…" }
→ { "token": "abc123…", "expires_at": "2027-04-24T00:00:00Z" } → { "token": "abc123…", "display_name": "…" }
``` ```
The token is stored in the `settings` table and sent as The token is stored in the `settings` table as `api_token` and sent as
`Authorization: Bearer abc123…` on all API requests. `Authorization: Bearer abc123…` on all sync requests. The password is used once and
immediately discarded — it is never persisted.
The server's `_require_auth` helper accepts both cookie-based session auth (web) and
Bearer token auth (mobile), so no separate mobile-specific middleware is needed.
---
## Mobile API endpoints
All endpoints require `Authorization: Bearer <token>` from the mobile client.
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/auth/token` | Password login → Bearer token |
| `GET` | `/api/feed` | All activity summaries for the authenticated user |
| `GET` | `/api/activity/{id}/geojson` | GeoJSON route for one activity |
| `GET` | `/api/activity/{id}/timeseries` | Timeseries JSON for one activity |
| `POST` | `/api/upload/bas` | Upload a pre-extracted BAS JSON activity |
| `GET` | `/api/wheel/version` | Latest bincio wheel version + URL (public) |
| `GET` | `/api/wheel/download` | Serve the wheel file (dev mode fallback) |
--- ---
@@ -536,7 +563,8 @@ and displayed as an activity card. No extraction yet.*
SQLite; password forgotten immediately after) SQLite; password forgotten immediately after)
- `GET /api/feed` — auth-gated; reads `_merged/index.json` shards and returns - `GET /api/feed` — auth-gated; reads `_merged/index.json` shards and returns
all activity summaries as JSON all activity summaries as JSON
- Settings screen: Connect section (password field + Connect button + status) - Settings screen: Connect section (password field + Connect button + status);
Disconnect button clears the stored token
- Feed screen: **↓ Sync** button and pull-to-refresh; "cloud" badge on remote - Feed screen: **↓ Sync** button and pull-to-refresh; "cloud" badge on remote
activities; `syncFeed()` upserts remote summaries without overwriting local imports activities; `syncFeed()` upserts remote summaries without overwriting local imports
@@ -544,9 +572,71 @@ and displayed as an activity card. No extraction yet.*
--- ---
### Phase 4 — Activity detail: map + metric charts ✅
*Goal: every activity shows a route map and metric charts.*
> Phase 4 was implemented before Phase 1 because it only requires the Development
> Build already needed for MapLibre — no Pyodide work required.
**Map (MapLibre v11):**
- Dark CartoDB tile base map via `mapStyle` prop
- Route drawn as a GeoJSON `LineLayer` (`line-color: #60a5fa`, `line-width: 3`)
- Camera auto-fits the track bounding box via `initialViewState.bounds`
- Thumbnail (non-interactive) shown inline; tap **⤢ tap to explore** to open a
full-screen modal with pan/zoom/pitch/rotate enabled
- On-demand fetch for remote activities: `GET /api/activity/{id}/geojson` with
Bearer auth; result cached in memory for the session
**Metric charts (react-native-svg):**
- Tabbed interface: Elevation / Speed / HR / Cadence / Power
- Only tabs with non-null data are shown
- Each chart: SVG area chart with gradient fill and coloured stroke; min/max labels
- Downsampled to ≤300 points for performance
- Tab colours: elevation `#00c8ff`, speed `#ff6b35`, HR `#f87171`, cadence `#a78bfa`, power `#facc15`
- On-demand fetch for remote activities: `GET /api/activity/{id}/timeseries`
**Stats grid:** distance, moving time, elevation gain/loss, avg speed, avg HR, avg power.
**Done when:** Open any synced or locally imported activity — map and charts visible. ✅
---
### Phase 0.6 — Sync mode + bidirectional sync ✅
*Goal: control download depth and push local activities back to the server.*
> Implemented as an addition to Phase 0.5 sync, ahead of Phase 1.
**Download mode (Settings → Sync → Download):**
- **Summaries only** (default): sync downloads activity summaries only. Map and
charts are fetched on first open.
- **Full data**: sync downloads geojson and timeseries for every activity that is
missing them. Uses more storage; takes longer.
**Upload (Settings → Sync → Upload):**
- **Off** (default): local activities stay on device only.
- **Upload local activities**: during each sync, unsynced local activities are
POSTed to `POST /api/upload/bas` as pre-extracted BAS JSON (not original FIT).
The server deduplicates, writes the files, and re-merges the feed.
**Settings → Data:**
- **Reset synced data**: two-tap confirm button to delete all remote activities from
the local DB. Useful when switching instances or re-syncing from scratch.
**Feed sync message** shows counts: `Synced: 3 new, 2 full datasets, 1 uploaded (47 total)`.
**Done when:** Sync pushes local activities to the instance and reports them in the feed. ✅
---
### Phase 1 — Local FIT/GPX/TCX extraction via Pyodide ### Phase 1 — Local FIT/GPX/TCX extraction via Pyodide
*Goal: pick a FIT/GPX/TCX file, extract it on-device in ~5 s.* *Goal: pick a FIT/GPX/TCX file, extract it on-device in ~5 s.*
> **This is the most important unbuilt feature.** Without it, local import only
> works with pre-extracted BAS JSON files — which requires already having a server.
> It undermines the "works offline without an instance" pitch. Phase 1 is also a
> hard prerequisite for the re-extract button (Phase 5), proper SHA-256 dedup
> (currently stubbed), and Phase 2 (auto-import needs extraction to be fast).
**Requires a Development Build** (`npx expo run:android` via USB, or **Requires a Development Build** (`npx expo run:android` via USB, or
`eas build --local`). Expo Go does not support `react-native-webview`. `eas build --local`). Expo Go does not support `react-native-webview`.
@@ -563,7 +653,7 @@ and displayed as an activity card. No extraction yet.*
**Import screen (full):** **Import screen (full):**
- Picks FIT/GPX/TCX, passes to `extractActivity`, stores result in SQLite - Picks FIT/GPX/TCX, passes to `extractActivity`, stores result in SQLite
(`detail_json`, `timeseries_json`, `geojson` columns) (`detail_json`, `timeseries_json`, `geojson` columns)
- Copies original file to `{documentDirectory}/originals/{source_hash}.{ext}` - Copies original file to `{documentDirectory}/originals/{id}.{ext}`
- Duplicate detection via `source_hash` before extraction - Duplicate detection via `source_hash` before extraction
**Done when:** Pick a FIT file from the Karoo rides folder, see full stats in **Done when:** Pick a FIT file from the Karoo rides folder, see full stats in
@@ -574,8 +664,13 @@ the Feed within ~5 s, including map and elevation profile.
### Phase 2 — Karoo auto-import *(Android only)* ### Phase 2 — Karoo auto-import *(Android only)*
*Goal: finish a ride, connect to WiFi, the activity appears in Bincio automatically.* *Goal: finish a ride, connect to WiFi, the activity appears in Bincio automatically.*
> **Partially stubbed.** The `auto_import_path` field exists in the Settings UI
> (Android only) and is saved to the DB, but the background task is not registered.
> The field accepts input but does nothing. Phase 1 (extraction) must be complete
> first — there is no point watching a directory if you cannot extract what's in it.
**Android:** **Android:**
- Settings screen gains `auto_import_path` field (Android only, hidden on iOS) - Settings screen: `auto_import_path` field already present (Android only, hidden on iOS)
- `expo-task-manager` background task registered at app startup - `expo-task-manager` background task registered at app startup
- Task polls `auto_import_path` every 5 minutes; for each `.fit` file whose - Task polls `auto_import_path` every 5 minutes; for each `.fit` file whose
`source_hash` is not in the DB, triggers extraction and import `source_hash` is not in the DB, triggers extraction and import
@@ -591,49 +686,23 @@ Bincio within 5 minutes of connecting to WiFi, with no manual action.
--- ---
### Phase 3 — Push sync ### Phase 3 — Push sync (original files)
*Goal: locally imported activities appear on the remote instance after one tap.* *Goal: upload original FIT/GPX/TCX files to the server for server-side re-extraction.*
Auth (Bearer token + Connect UI) is already done in Phase 0.5. Remaining work: Phase 0.6 already pushes **pre-extracted BAS JSON** to the server, which covers
the common case. Phase 3 adds upload of the **original raw file** so the server
can re-extract with its full pipeline (DEM correction, hysteresis, etc.).
**Server:** **Server:**
- `POST /api/upload` accepting a raw FIT/GPX/TCX file with Bearer token auth - Extend `POST /api/upload` to accept raw FIT/GPX/TCX with Bearer token auth
same as the existing web upload endpoint but token-gated (currently cookie-only)
**App:** **App:**
- Push button (Settings or Feed header): iterates unsynced local activities - When `original_path` is set, prefer uploading the raw file; fall back to BAS JSON
(`synced_at IS NULL AND origin = 'local'`), uploads original file, marks synced - Progress indicator per activity for first push with many files
- Progress indicator per activity; useful for first push with many files
**Done when:** Tap **Push**, locally imported activities appear on bincio.org. **Done when:** Tap **Sync**, locally imported FIT files are uploaded to the instance
and re-extracted server-side.
---
### 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).
--- ---
@@ -645,6 +714,84 @@ profile are visible within 1 s (local) or after one network round-trip (remote).
FIT/GPX files found, with progress bar and per-file status FIT/GPX files found, with progress bar and per-file status
- **Share sheet** — Android intent filter for incoming `.fit`/`.gpx`/`.tcx` files - **Share sheet** — Android intent filter for incoming `.fit`/`.gpx`/`.tcx` files
- **Re-extract** — button to re-run Pyodide extraction from the stored original file - **Re-extract** — button to re-run Pyodide extraction from the stored original file
*(requires Phase 1)*
- **App icon and splash screen** — currently using Expo defaults
- **Feed search / filter** — by sport, date range, or title; necessary as the
feed grows past ~50 activities
- **Individual activity deletion** — currently users can only "Reset synced data"
(all remote) or nothing (local). No way to delete a single activity.
- **Token expiry / reconnect prompt** — when the server returns 401, the app shows
an error in the sync toast but leaves the user to navigate to Settings manually.
Should auto-show a reconnect prompt instead.
---
## Known gaps and technical debt
This section documents mismatches between what the plan describes and what is
actually implemented, plus features not yet in the plan.
### Stubs in the current code
**`source_hash` is not SHA-256** (`mobile/app/(tabs)/import.tsx`)
The import screen records `source_hash = "${detail.id}-${text.length}"` — a rough
stand-in, not a real content hash. The dedup guarantee (`INSERT OR IGNORE`) works
correctly today because activity IDs are unique, but the hash column's intended
purpose (detect the same raw file imported twice under a different name) is not
delivered. Phase 1 will replace this with SHA-256 of the original file bytes.
**FIT/GPX/TCX import is a placeholder** (`mobile/app/(tabs)/import.tsx`)
Picking a FIT, GPX, or TCX file shows an alert: "Extraction coming in Phase 1."
No extraction happens. The Import screen effectively only works with pre-extracted
BAS JSON files until Phase 1 is built.
**`auto_import_path` setting has no backend** (`mobile/app/(tabs)/settings.tsx`)
The Android-only "Watch directory" field in Settings saves its value to SQLite but
nothing reads it. No background task is registered. Phase 2 must wire this up.
### Missing from the plan entirely
**Feed pagination**
`useActivities()` in `mobile/db/queries.ts` calls `getAllSync` with no `LIMIT`.
This is fine for tens of activities, but will cause noticeable lag at hundreds.
Should add cursor-based pagination or a virtual list with lazy loading.
**Individual activity deletion**
There is no way to delete a single activity, local or remote. "Reset synced data"
nukes all remote activities at once. A long-press or swipe-to-delete gesture on
activity cards is needed, with a server-side `DELETE /api/activity/{id}` endpoint
for remote activities.
**Feed search and filter**
No search bar, no sport filter, no date picker. The feed is a flat reverse-
chronological list. As the feed grows this becomes a usability problem.
**Token expiry and the reconnect flow**
Bearer tokens are stored indefinitely and never refreshed. When the server rejects
one (HTTP 401 during sync), the error toast says "Session expired — reconnect in
Settings." The user must navigate there manually. A better flow: on 401, show a
modal with a password field so the user can reconnect inline without leaving the
sync flow.
**App icon and splash screen**
The app uses Expo's default purple icon and white splash. These need to be replaced
with Bincio branding before any public distribution.
**Upload only works for activities imported from a file**
`uploadLocalActivities()` skips rows where `original_path IS NULL`. Activities
synced from the server (`origin = 'remote'`) are never uploaded back even if
upload is enabled. This is correct behaviour, but it also means a locally created
activity that somehow lacks an `original_path` (e.g. from a future recording
feature) would be silently skipped. Worth documenting as an invariant.
--- ---