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:
+173
-14
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user