feat(mobile): Karoo GPU crash fix, server-side extraction, upload fix, feed redesign

- Skip MapLibre on Android <29 (Karoo): SELinux denies kgsl-3d0 access
  from untrusted_app context, crashing the GPU driver on any OpenGL
  surface. Replace with SvgRouteView — equirectangular SVG route trace
  using react-native-svg, no native GL surface needed.
- Add +/- zoom buttons to full-screen MapLibre map on modern devices
  via Camera ref and onRegionDidChange.
- Skip PyodideWebView on Android <29: same GPU driver conflict; set
  _engineUnavailable at module init via API level gate (< 29).
- Add engine_unavailable fast path in PyodideWebView: post message
  immediately if WebAssembly.Global is absent (Chrome <69) instead of
  attempting 30 MB Pyodide download.
- Add server-side extraction fallback (extractServer.ts): when engine
  unavailable, POST raw file as base64 to /api/upload/raw; server runs
  full Python pipeline and returns extracted data.
- Add /api/upload/raw endpoint in server.py.
- Add pre-flight auth check (checkServerAuth) before batch import so
  an expired token errors immediately rather than after N files.
- Fix uploadLocalActivities in sync.ts: was reading original_path as
  JSON (binary FIT file, always threw), silently skipping every upload.
  Now reads detail_json from DB directly.
- Redesign Feed header: replace single Sync button with Upload /
  Download / Refresh. Pull-to-refresh and Refresh button are local-only.
  Auto-refresh on tab focus via useFocusEffect.
- Replace ActivityIndicator with plain Text everywhere (native animation
  also crashes Karoo GPU driver).
- Raise macOS open-file limit in dev_test.py to prevent EMFILE errors
  from Astro file watcher.
- Document all Karoo hardware constraints in docs/mobile-app.md.
This commit is contained in:
Davide Scaini
2026-04-26 21:00:12 +02:00
parent 4cabbea0d4
commit cbe3e0eeaf
10 changed files with 760 additions and 156 deletions
+173 -14
View File
@@ -473,6 +473,141 @@ the Python execution time only.
---
## Karoo 2: hardware and OS constraints
The Karoo 2 (Hammerhead, Android 8.1 / API 27 / Chrome 61 WebView, armeabi-v7a) is the primary Android target that drove most of the implementation decisions in this app. It surfaces three independent hardware limitations that affect the app design.
---
### 1 — No WebAssembly.Global (Chrome 61)
**Symptom:** `TypeError: WebAssembly.Global is not a constructor` in the Pyodide WebView shortly after mounting.
**Root cause:** `WebAssembly.Global` was added in Chrome 69 (Android 10 / API 29). Chrome 61 — the system WebView that ships with Android 8.1 — does not have it. Pyodide requires it for internal module linking. There is no JavaScript-level polyfill for this primitive; it must be provided by the JS engine.
**Implementation:**
`PyodideWebView.tsx` checks for the primitive at init time before attempting the 30 MB Pyodide download:
```javascript
if (typeof WebAssembly === 'undefined' || typeof WebAssembly.Global === 'undefined') {
_post({ type: 'engine_unavailable', reason: 'wasm_global' });
return;
}
```
`extractActivity.ts` also gates at the module level using the API level so the WebView is not even mounted on old Android:
```typescript
let _engineUnavailable = Platform.OS === 'android' && (Platform.Version as number) < 29;
```
The `isEngineAvailable()` export returns `true` (ready), `false` (unavailable/error), or `null` (still initialising). The Import screen uses this to decide the extraction path.
**Fallback:** server-side extraction (see next section).
---
### 2 — GPU driver crash (OpenGL / SurfaceView)
**Symptom:** `Fatal signal 11 (SIGSEGV), code 2, fault addr 0xa7xxxxxx in tid RenderThread` — the app crashes within seconds of mounting any component that creates a native OpenGL surface.
**Root cause:** SELinux enforces that sideloaded apps (`untrusted_app` context) cannot access `sysfs_kgsl` (the Qualcomm Adreno GPU sysfs interface):
```
avc: denied { search } for comm=RenderThread
scontext=u:r:untrusted_app:s0
tcontext=u:object_r:sysfs_kgsl:s0
tclass=dir permissive=0
```
When the GPU driver (kgsl) is denied its sysfs entry point, its internal initialisation corrupts memory — leading to the SIGSEGV in GPU memory (`0xa7xxxxxx`). The crash is **not triggered by touch gestures**; it happens as soon as the OpenGL surface is created and the driver starts.
This affects **any** component backed by a native GL surface, including:
| Component | What it creates | Status on Karoo |
|---|---|---|
| `react-native-webview` | SurfaceView (Chrome WebView) | Mount crashes GPU |
| `@maplibre/maplibre-react-native` | TextureView / SurfaceView | Render crashes GPU |
| `ActivityIndicator` | Native animated View | Crashes GPU |
The native Karoo system app is signed with Hammerhead's platform key, which grants it `platform_app` or `system_app` SELinux context — a context that IS allowed to access `sysfs_kgsl`. Third-party sideloaded apps cannot obtain this privilege without being re-signed by Hammerhead.
**Implementation — WebView:**
The Pyodide WebView is not mounted on Android < 29 (the same API level used as the proxy for "Chrome 61 / no WebAssembly.Global"). `_engineUnavailable` is set at module load time and `PyodideWebView` is conditionally excluded from the render tree:
```tsx
{Platform.OS !== 'android' || (Platform.Version as number) >= 29
? <View style={styles.hiddenEngine}><PyodideWebView /></View>
: null}
```
**Implementation — MapLibre:**
`RouteMap` in `app/activity/[id].tsx` skips all MapLibre components on Android < 29 and renders a pure SVG route trace instead:
```typescript
if (Platform.OS === 'android' && (Platform.Version as number) < 29) {
return <SvgRouteView geojson={geojson} accent={accent} />;
}
```
`SvgRouteView` extracts the GPS coordinates from the GeoJSON, applies an equirectangular projection with cosine correction for latitude, downsamples to ≤500 points, and renders the route as an SVG `Path` via `react-native-svg`. No native surface, no GPU access, no crash.
**Implementation — ActivityIndicator:**
`ActivityIndicator` is a native animated component that also creates GPU-backed layers. It is not used anywhere in the app. All loading states use plain `<Text>` with ``.
---
### 3 — Server-side extraction fallback
When Pyodide cannot run (Android < 29 / Chrome 61), FIT/GPX/TCX files are extracted by the Bincio server instead of on-device.
**Server endpoint:** `POST /api/upload/raw`
Accepts JSON `{ filename: string, base64: string }`. The server decodes the file, runs the full Python extraction pipeline (including DEM correction), stores the result in the user's feed, and returns the extracted data:
```json
{
"ok": true,
"id": "2026-04-17T074238Z",
"detail": { … },
"timeseries": { … },
"geojson": { … },
"source_hash": "sha256:…"
}
```
**Client:** `extractFileViaServer()` in `mobile/extraction/extractServer.ts`. The Import screen routes to this function when `isEngineAvailable() === false`:
```typescript
if (isEngineAvailable() === false) {
result = await extractFileViaServer(name, base64, instanceUrl, token, onStatus);
} else {
result = await extractFile(name, base64, wheelBase64, wheelFilename, onStatus);
}
```
**Trade-offs vs. local extraction:**
| | Local (Pyodide) | Server-side |
|---|---|---|
| Requires internet | No (after wheel cached) | Yes |
| Requires Bincio account | No | Yes |
| File leaves device | Never | Yes (over HTTPS to your instance) |
| DEM correction | No | Yes |
| Supported on Karoo | No (Chrome 61) | Yes |
**Pre-flight auth check:** before starting a batch import via the server path, the Import screen calls `checkServerAuth()` which hits `GET /api/feed` to verify the token is still valid. If the token is expired, the error is shown immediately — not after processing hundreds of files.
**UI notice:** the Import screen shows an amber banner when running in server-extraction mode:
> ⚠ Your Android version doesn't support on-device extraction. Files will be processed by your Bincio instance.
---
## Android vs iOS: platform divergences
### Filesystem access
@@ -561,12 +696,17 @@ Implemented in `mobile/db/sync.ts` → `syncFeed()`.
### Upload (local → server)
Implemented in `mobile/db/sync.ts` → `uploadLocalActivities()`. Enabled when
`sync_upload = "true"` in settings.
`sync_upload = "true"` in settings, or triggered explicitly via the ↑ Upload button.
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`.
2. For each: parse `detail_json` from the DB row and construct `{ id: row.id, ...detail }`.
3. `POST {instance_url}/api/upload/bas` with body `{ activity, timeseries?, geojson? }`.
4. On 200/duplicate: set `synced_at = unixepoch()`.
4. On 200 (including `{ status: "duplicate" }`): set `synced_at = unixepoch()`.
**Note:** `original_path` is not used in upload. An earlier implementation tried to read
`original_path` as a JSON file, but `original_path` stores the path to the original binary
FIT/GPX/TCX file — `JSON.parse()` always throws, silently skipping every activity. The correct
approach is to use the already-extracted `detail_json` stored in SQLite.
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
@@ -610,7 +750,8 @@ All endpoints require `Authorization: Bearer <token>` from the mobile client.
| `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 |
| `POST` | `/api/upload/bas` | Upload a pre-extracted BAS JSON activity (body: `{ activity, timeseries?, geojson? }`) |
| `POST` | `/api/upload/raw` | Upload a raw FIT/GPX/TCX file for server-side extraction (body: `{ filename, base64 }`); returns full extracted data |
| `GET` | `/api/wheel/version` | Latest bincio wheel version + URL (public) |
| `GET` | `/api/wheel/download` | Serve the wheel file (dev mode fallback) |
@@ -642,10 +783,15 @@ and displayed as an activity card. No extraction yet.*
all activity summaries as JSON
- 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
- Feed screen: three header buttons — **↑ Upload**, **↓ Download**, **↺ Refresh**;
pull-to-refresh; "cloud" badge on remote activities
- **↓ Download** calls `downloadFeed()` — pulls summaries (and full data in full mode)
- **↑ Upload** calls `uploadFeed()` — pushes unsynced local activities to the server
- **↺ Refresh** and pull-to-refresh: local-only SQLite re-read, no network call
- Auto-refresh on tab focus via `useFocusEffect`: increments `refreshKey` → FlatList
picks up newly imported activities without any user action
**Done when:** Tap Connect, tap Sync, all instance activities appear in the Feed. ✅
**Done when:** Tap Connect, tap ↓ Download, all instance activities appear in the Feed. ✅
---
@@ -655,15 +801,28 @@ and displayed as an activity card. No extraction yet.*
> 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):**
**Map (MapLibre v11 — modern Android and iOS):**
- 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
- Full-screen modal: **+/** zoom buttons (bottom-right corner) adjust zoom level
via `cameraRef.current?.setCamera({ zoomLevel: … })`. Current zoom tracked via
`onRegionDidChange`.
- On-demand fetch for remote activities: `GET /api/activity/{id}/geojson` with
Bearer auth; result cached in memory for the session
**Map (SVG route trace — Android < 29 / Karoo):**
- MapLibre is not rendered on Android < 29 — doing so crashes the GPU driver (see
*Karoo 2: hardware and OS constraints* above).
- `SvgRouteView` in `app/activity/[id].tsx` renders an SVG path using `react-native-svg`.
Coordinates are projected via equirectangular projection with cosine correction for
latitude, downsampled to ≤500 points. No native OpenGL surface is created.
- The visual is identical to what a GPS watch shows: the route shape as a coloured
trace on a dark background, without map tiles. No zoom is provided (no native
interaction surface, no crashes).
**Metric charts (react-native-svg):**
- Tabbed interface: Elevation / Speed / HR / Cadence / Power
- Only tabs with non-null data are shown
@@ -869,13 +1028,13 @@ sync flow.
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**
**Upload only pushes `origin = 'local'` activities**
`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.
`uploadLocalActivities()` queries `WHERE origin = 'local' AND synced_at IS NULL`.
Activities pulled from the server (`origin = 'remote'`) are never re-uploaded — correct
behaviour. A locally created activity from a future recording feature that lacks
`detail_json` would throw during `JSON.parse` and be silently skipped; worth checking
if a recording path is ever added.
---