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:
+296
-149
@@ -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 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
|
### 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user