diff --git a/docs/mobile-app.md b/docs/mobile-app.md index d277d9e..cb7fe2c 100644 --- a/docs/mobile-app.md +++ b/docs/mobile-app.md @@ -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 ``` -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 ` — 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 ` 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. ---