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
|
||||
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
|
||||
instantly via Metro (the JS bundler) — no rebuild needed.
|
||||
maplibre, react-native-svg). It is installed once on the device; after that, code
|
||||
changes still update instantly via Metro (the JS bundler) — no rebuild needed.
|
||||
|
||||
| | Expo Go | Development Build |
|
||||
|---|---|---|
|
||||
@@ -73,6 +73,7 @@ instantly via Metro (the JS bundler) — no rebuild needed.
|
||||
| expo-document-picker | ✅ | ✅ |
|
||||
| react-native-webview (Pyodide) | ✗ | ✅ |
|
||||
| @maplibre/maplibre-react-native | ✗ | ✅ |
|
||||
| react-native-svg | ✗ | ✅ |
|
||||
| Code changes | instant (Metro) | instant (Metro) |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| EAS CLI | cloud builds (optional) | `npm install -g eas-cli` |
|
||||
|
||||
You do **not** need a physical Android device to start. The Android emulator
|
||||
(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
|
||||
|
||||
```bash
|
||||
# From the repo root:
|
||||
bash mobile/setup.sh
|
||||
# From repo root:
|
||||
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
|
||||
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
|
||||
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
|
||||
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:**
|
||||
|
||||
| Asset | Size | When | Cached |
|
||||
@@ -253,21 +267,26 @@ adb install /path/to/bincio.apk # with Karoo connected via USB
|
||||
|
||||
## 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
|
||||
│ └── serve/server.py — FastAPI server (includes mobile API endpoints)
|
||||
├── 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
|
||||
│ ├── app/
|
||||
│ │ ├── (tabs)/
|
||||
│ │ │ ├── index.tsx — Feed screen
|
||||
│ │ │ ├── import.tsx — Import screen
|
||||
│ │ │ └── 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/
|
||||
└── 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
@@ -293,13 +312,35 @@ bincio_activity/
|
||||
- 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
|
||||
- `expo-file-system/legacy` — filesystem access (legacy import required with Expo SDK 54+)
|
||||
- `react-native-webview` — hidden WebView for Pyodide (Phase 1)
|
||||
- `@maplibre/maplibre-react-native` v11 — interactive maps, dark CartoDB tiles
|
||||
- `react-native-svg` ≥ 15.15.4 — SVG area charts; **must be ≥ 15.15.4** (earlier
|
||||
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
|
||||
|
||||
### 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
|
||||
@@ -344,18 +385,6 @@ React Native
|
||||
(~30 MB, CDN, cached once) and the bincio wheel (~50 KB, from bincio.org, updated
|
||||
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
|
||||
|
||||
| Scenario | Time |
|
||||
@@ -372,9 +401,6 @@ the Python execution time only.
|
||||
|
||||
## Android vs iOS: platform divergences
|
||||
|
||||
These two platforms share almost all code. The differences are confined to
|
||||
filesystem access and background behaviour.
|
||||
|
||||
### Filesystem access
|
||||
|
||||
| | 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" |
|
||||
| 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)
|
||||
@@ -456,59 +458,84 @@ CREATE TABLE settings (
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `api_token` | Bearer token for API auth (obtained via Connect, never stored in plaintext long-term) | Both |
|
||||
| `sync_mode` | `"summaries"` (default) or `"full"` — controls whether geojson+timeseries are downloaded during sync | 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** |
|
||||
|
||||
---
|
||||
|
||||
## Sync protocol
|
||||
|
||||
Sync is a two-way hash-based diff. No custom protocol is needed beyond the
|
||||
existing REST API.
|
||||
### Download (server → local)
|
||||
|
||||
### Push (local → server)
|
||||
Implemented in `mobile/db/sync.ts` → `syncFeed()`.
|
||||
|
||||
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()`.
|
||||
1. `GET {instance_url}/api/feed` with `Authorization: Bearer <token>` — returns all
|
||||
activity summaries as `{ activities: [...] }`.
|
||||
2. Upsert each summary into the local `activities` table with `origin = 'remote'`.
|
||||
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).
|
||||
2. Find remote IDs absent from local DB.
|
||||
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()`.
|
||||
Implemented in `mobile/db/sync.ts` → `uploadLocalActivities()`. Enabled when
|
||||
`sync_upload = "true"` in settings.
|
||||
|
||||
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).
|
||||
1. Query `activities WHERE origin = 'local' AND synced_at IS NULL`.
|
||||
2. For each: read the BAS JSON from `original_path` via `expo-file-system`.
|
||||
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
|
||||
|
||||
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.
|
||||
Activities are immutable. Dedup on download: `ON CONFLICT(id) DO UPDATE` only
|
||||
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
|
||||
|
||||
The server currently uses session cookies. For mobile, Bearer tokens are cleaner.
|
||||
A new endpoint is needed (Phase 3 server work):
|
||||
The server exposes `POST /api/auth/token`:
|
||||
|
||||
```
|
||||
POST /api/auth/token
|
||||
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
|
||||
`Authorization: Bearer abc123…` on all API requests.
|
||||
The token is stored in the `settings` table as `api_token` and sent as
|
||||
`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)
|
||||
- `GET /api/feed` — auth-gated; reads `_merged/index.json` shards and returns
|
||||
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
|
||||
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
|
||||
*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
|
||||
`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):**
|
||||
- Picks FIT/GPX/TCX, passes to `extractActivity`, stores result in SQLite
|
||||
(`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
|
||||
|
||||
**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)*
|
||||
*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:**
|
||||
- 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
|
||||
- Task polls `auto_import_path` every 5 minutes; for each `.fit` file whose
|
||||
`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
|
||||
*Goal: locally imported activities appear on the remote instance after one tap.*
|
||||
### Phase 3 — Push sync (original files)
|
||||
*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:**
|
||||
- `POST /api/upload` accepting a raw FIT/GPX/TCX file with Bearer token auth —
|
||||
same as the existing web upload endpoint but token-gated
|
||||
- Extend `POST /api/upload` to accept raw FIT/GPX/TCX with Bearer token auth
|
||||
(currently cookie-only)
|
||||
|
||||
**App:**
|
||||
- Push button (Settings or Feed header): iterates unsynced local activities
|
||||
(`synced_at IS NULL AND origin = 'local'`), uploads original file, marks synced
|
||||
- Progress indicator per activity; useful for first push with many files
|
||||
- When `original_path` is set, prefer uploading the raw file; fall back to BAS JSON
|
||||
- Progress indicator per activity for first push with many files
|
||||
|
||||
**Done when:** Tap **Push**, locally imported activities appear on bincio.org.
|
||||
|
||||
---
|
||||
|
||||
### 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).
|
||||
**Done when:** Tap **Sync**, locally imported FIT files are uploaded to the instance
|
||||
and re-extracted server-side.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
- **Share sheet** — Android intent filter for incoming `.fit`/`.gpx`/`.tcx` files
|
||||
- **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