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
+103
View File
@@ -539,6 +539,109 @@ async def upload_bas_activity(
return JSONResponse({"ok": True, "id": activity_id, "status": "imported"})
@app.post("/api/upload/raw")
async def upload_raw_activity(
request: Request,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
"""Accept a raw FIT/GPX file (base64-encoded) from the mobile app, extract it
server-side, store it in the user's activity library, and return the full
extracted data so the mobile can cache it locally.
Used when the device WebView is too old to run Pyodide (e.g. Karoo / Chrome <69).
Body (JSON):
filename original filename (used only to determine file extension)
base64 base64-encoded raw file bytes
Auth: Authorization: Bearer <token>
Returns:
{"ok": true, "id": "...", "detail": {...}, "timeseries": {...}|null,
"geojson": {...}|null, "source_hash": "<sha256-hex>"}
"""
import base64 as _b64
import hashlib
user = _require_auth(request, bincio_session)
body = await request.json()
filename_hint: str = body.get("filename") or "activity.fit"
b64: str = body.get("base64") or ""
if not b64:
raise HTTPException(400, "Missing base64 field")
try:
raw = _b64.b64decode(b64)
except Exception:
raise HTTPException(400, "Invalid base64 encoding")
source_hash = hashlib.sha256(raw).hexdigest()
suffix = Path(filename_hint).suffix or ".fit"
tmp_in = Path(f"/tmp/bincio_raw_{uuid.uuid4()}{suffix}")
tmp_out = Path(f"/tmp/bincio_out_{uuid.uuid4()}")
try:
tmp_in.write_bytes(raw)
tmp_out.mkdir()
from bincio.extract.parsers.factory import parse_file
from bincio.extract.metrics import compute
from bincio.extract.writer import make_activity_id, write_activity
from bincio.extract.timeseries import build_timeseries
activity = parse_file(tmp_in)
metrics = compute(activity)
write_activity(activity, metrics, tmp_out, privacy="public", rdp_epsilon=0.0001)
act_id = make_activity_id(activity)
acts_tmp = tmp_out / "activities"
detail_path = acts_tmp / f"{act_id}.json"
ts_path = acts_tmp / f"{act_id}.timeseries.json"
geojson_path = acts_tmp / f"{act_id}.geojson"
if not ts_path.exists():
ts_data = build_timeseries(activity.points, activity.started_at, "public")
if ts_data.get("t"):
ts_path.write_text(json.dumps(ts_data))
detail = json.loads(detail_path.read_text())
timeseries = json.loads(ts_path.read_text()) if ts_path.exists() else None
geojson = json.loads(geojson_path.read_text()) if geojson_path.exists() else None
# Also store on the server so the activity appears in the user's feed.
user_dir = _get_data_dir() / user.handle
acts_dir = user_dir / "activities"
acts_dir.mkdir(parents=True, exist_ok=True)
out = acts_dir / f"{act_id}.json"
if not out.exists():
out.write_text(json.dumps(detail, ensure_ascii=False, indent=2), encoding="utf-8")
if timeseries and not (acts_dir / f"{act_id}.timeseries.json").exists():
(acts_dir / f"{act_id}.timeseries.json").write_text(json.dumps(timeseries), encoding="utf-8")
if geojson and not (acts_dir / f"{act_id}.geojson").exists():
(acts_dir / f"{act_id}.geojson").write_text(json.dumps(geojson), encoding="utf-8")
from bincio.render.merge import merge_all
merge_all(user_dir)
except Exception as exc:
log.warning("upload/raw[%s]: extraction failed: %s", user.handle, exc)
raise HTTPException(422, f"Could not extract activity: {exc}") from exc
finally:
tmp_in.unlink(missing_ok=True)
shutil.rmtree(tmp_out, ignore_errors=True)
log.info("upload/raw[%s]: imported %s", user.handle, act_id)
return JSONResponse({
"ok": True,
"id": act_id,
"detail": detail,
"timeseries": timeseries,
"geojson": geojson,
"source_hash": source_hash,
})
@app.get("/api/wheel/version")
async def wheel_version() -> JSONResponse:
"""Public endpoint: current bincio wheel version for mobile app update checks."""
+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.
---
+94 -12
View File
@@ -6,7 +6,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { insertActivity, isSourcePathImported, getSetting } from '@/db/queries';
import { PyodideWebView } from '@/extraction/PyodideWebView';
import { extractFile, waitForEngine } from '@/extraction/extractActivity';
import { extractFile, waitForEngine, onEngineProgress, isEngineAvailable } from '@/extraction/extractActivity';
import { extractFileViaServer, checkServerAuth } from '@/extraction/extractServer';
import { useTheme } from '@/ThemeContext';
const FIT_EXTENSIONS = ['.fit', '.fit.gz'];
@@ -24,8 +25,18 @@ export default function ImportScreen() {
const theme = useTheme();
const [state, setState] = useState<ImportState>({ status: 'idle' });
const [watchPath, setWatchPath] = useState('');
const [engineAvailable, setEngineAvailable] = useState<boolean | null>(null);
const isImporting = useRef(false);
// Track engine availability so we can show the server-extraction notice.
useEffect(() => {
waitForEngine(30_000)
.then(() => setEngineAvailable(true))
.catch((e: unknown) => {
if (e instanceof Error && e.message === 'engine_unavailable') setEngineAvailable(false);
});
}, []);
// Reload watch path every time the Import tab comes into focus so changes
// saved in Settings are picked up without remounting the tab.
useFocusEffect(useCallback(() => {
@@ -56,8 +67,18 @@ export default function ImportScreen() {
const instanceUrl = await getSetting(db, 'instance_url');
if (!instanceUrl) return;
// Wait for the extraction engine — but don't block forever on auto-scan.
try { await waitForEngine(120_000); } catch { return; }
// Wait for engine — skip auto-scan on init failure, but continue if device is
// too old for local extraction (importNativeFile will use the server instead).
try { await waitForEngine(120_000); } catch (e: unknown) {
if (!(e instanceof Error) || e.message !== 'engine_unavailable') return;
}
// Server-mode requires a valid token — verify before touching any files.
if (isEngineAvailable() === false) {
const token = await getSetting(db, 'api_token');
if (!token) return;
try { await checkServerAuth(instanceUrl, token); } catch { return; }
}
const newFiles = await discoverNewFiles(db, path);
if (newFiles.length === 0) return;
@@ -80,13 +101,38 @@ export default function ImportScreen() {
return;
}
const serverMode = isEngineAvailable() === false;
if (!serverMode) {
setState({ status: 'loading', msg: 'Preparing extraction engine…', current: 0, total: 0 });
const unsubScan = onEngineProgress((msg) =>
setState({ status: 'loading', msg, current: 0, total: 0 }),
);
try {
await waitForEngine();
} catch (e: unknown) {
if (!(e instanceof Error) || e.message !== 'engine_unavailable') {
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
return;
}
// engine_unavailable — fall through to server mode
} finally {
unsubScan();
}
} else {
const token = await getSetting(db, 'api_token');
if (!token) {
setState({ status: 'error', message: 'Server extraction requires a Bincio account. Connect in Settings.' });
return;
}
// Verify the token is valid before processing any files.
setState({ status: 'loading', msg: 'Checking connection…', current: 0, total: 0 });
try {
await checkServerAuth(instanceUrl, token);
} catch (e: unknown) {
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
return;
}
}
setState({ status: 'loading', msg: 'Scanning…', current: 0, total: 0 });
const newFiles = await discoverNewFiles(db, path);
@@ -132,9 +178,13 @@ export default function ImportScreen() {
return;
}
isImporting.current = true;
const unsubPick = onEngineProgress((msg) =>
setState({ status: 'loading', msg, current: 0, total: 0 }),
);
try {
await processBatch(result.assets.map(a => ({ uri: a.uri, name: a.name ?? '', sourcePath: null })));
} finally {
unsubPick();
isImporting.current = false;
}
} catch (e: unknown) {
@@ -211,7 +261,7 @@ export default function ImportScreen() {
});
}
// ── FIT / GPX / TCX import via Pyodide extraction ──────────────────────────
// ── FIT / GPX / TCX import via Pyodide (local) or server fallback ───────────
async function importNativeFile(
uri: string,
@@ -221,20 +271,32 @@ export default function ImportScreen() {
) {
onStatus('Reading file…');
// Read the original file as base64 so we can (a) pass it to the WebView
// Read the original file as base64 so we can (a) pass it to the extractor
// and (b) copy it to permanent storage without a second read.
const base64 = await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
let result;
if (isEngineAvailable() === false) {
// Device WebView is too old for WebAssembly.Global (Chrome <69).
// Send the raw file to the Bincio instance for server-side extraction.
const instanceUrl = await getInstanceUrl(db);
const token = db.getFirstSync<{ value: string }>(
'SELECT value FROM settings WHERE key = ?', ['api_token'],
)?.value ?? '';
if (!token) throw new Error('Server extraction requires a Bincio account — connect in Settings.');
result = await extractFileViaServer(name, base64, instanceUrl, token, onStatus);
} else {
// Fetch the bincio wheel here (React Native networking), not inside the
// WebView. WKWebView blocks HTTP requests via ATS; RN native networking
// allows local-network HTTP (NSAllowsLocalNetworking=true in Info.plist).
const instanceUrl = await getInstanceUrl(db);
onStatus('Fetching Bincio engine…');
const { base64: wheelBase64, filename: wheelFilename } = await fetchWheelBase64(instanceUrl);
const result = await extractFile(name, base64, wheelBase64, wheelFilename, onStatus);
result = await extractFile(name, base64, wheelBase64, wheelFilename, onStatus);
}
onStatus('Saving…');
@@ -259,12 +321,14 @@ export default function ImportScreen() {
return (
<View style={styles.screen}>
{/* Hidden WebView for Pyodide — mounted here so it lives inside the tab
(Expo Router keeps tabs mounted after first visit, preserving Pyodide state).
The 1×1 container clips it out of the scroll layout entirely. */}
{/* Hidden WebView for Pyodide — only mounted on devices that can run it.
Android <29 has a system WebView (Chrome <69) that lacks WebAssembly.Global
AND causes GPU SurfaceView crashes on old drivers. Skip it entirely there. */}
{(Platform.OS !== 'android' || (Platform.Version as number) >= 29) && (
<View style={styles.hiddenEngine}>
<PyodideWebView />
</View>
)}
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.header}>Import</Text>
@@ -273,6 +337,15 @@ export default function ImportScreen() {
You can also import pre-extracted BAS <Text style={[styles.code, { color: theme.accent }]}>.json</Text> files.
</Text>
{engineAvailable === false && (
<View style={styles.serverNotice}>
<Text style={styles.serverNoticeText}>
This device's Android WebView is too old to run local extraction (requires Chrome 69+).
Activities are processed by your Bincio instance instead — a connected account is required.
</Text>
</View>
)}
{watchPath ? (
<View style={styles.watchBox}>
<Text style={styles.watchLabel}>Watch folder</Text>
@@ -305,9 +378,11 @@ export default function ImportScreen() {
</Text>
)}
<Text style={[styles.statusMsg, { color: theme.accent }]}>{state.msg}</Text>
{engineAvailable !== false && (
<Text style={styles.statusHint}>
First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant.
</Text>
)}
</View>
)}
@@ -353,8 +428,10 @@ export default function ImportScreen() {
<View style={styles.notice}>
<Text style={styles.noticeText}>
FIT/GPX/TCX extraction runs entirely on your device.{'\n'}
A Bincio instance must be reachable on first run to download the extraction engine (~35 MB, then cached).{'\n\n'}
{engineAvailable === false
? 'Activities are sent to your Bincio instance for extraction and stored there + locally. A connected account is required.'
: `FIT/GPX/TCX extraction runs entirely on your device.\nA Bincio instance must be reachable on first run to download the extraction engine (~35 MB, then cached).`}
{'\n\n'}
On Karoo: set Watch directory to <Text style={styles.noticeCode}>/sdcard/FitFiles</Text> in Settings to auto-import rides.
</Text>
</View>
@@ -469,6 +546,11 @@ const styles = StyleSheet.create({
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 12 },
body: { color: '#a1a1aa', fontSize: 14, lineHeight: 20, marginBottom: 24 },
code: { color: '#60a5fa', fontFamily: 'monospace' },
serverNotice: {
backgroundColor: '#1c1400', borderRadius: 8, borderWidth: 1,
borderColor: '#854d0e', padding: 12, marginBottom: 16,
},
serverNoticeText: { color: '#fbbf24', fontSize: 13, lineHeight: 18 },
watchBox: {
backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1,
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 10,
+120 -47
View File
@@ -1,42 +1,73 @@
import * as FileSystem from 'expo-file-system';
import { useFocusEffect } from 'expo-router';
import { useSQLiteContext } from 'expo-sqlite';
import { useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { deleteActivities, useActivities, type ActivitySummary } from '@/db/queries';
import { syncFeed } from '@/db/sync';
import { downloadFeed, uploadFeed } from '@/db/sync';
import { useTheme } from '@/ThemeContext';
export default function FeedScreen() {
const db = useSQLiteContext();
const theme = useTheme();
const [refreshKey, setRefreshKey] = useState(0);
const activities = useActivities();
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<string | null>(null);
const [downloading, setDownloading] = useState(false);
const [uploading, setUploading] = useState(false);
const [statusMsg, setStatusMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const selecting = selected.size > 0;
const doSync = useCallback(async () => {
setSyncing(true);
setSyncMsg(null);
const result = await syncFeed(db);
setSyncing(false);
// Auto-refresh the local list whenever the tab comes into focus.
// SQLite getAllSync is sub-millisecond — no network, no lag.
useFocusEffect(useCallback(() => {
setRefreshKey(k => k + 1);
}, []));
function showMsg(ok: boolean, text: string) {
setStatusMsg({ ok, text });
setTimeout(() => setStatusMsg(null), 3500);
}
const doDownload = useCallback(async () => {
setDownloading(true);
setStatusMsg(null);
const result = await downloadFeed(db);
setDownloading(false);
setRefreshKey(k => k + 1);
if (result.error) {
setSyncMsg(result.error);
showMsg(false, result.error);
} else if (result.total === 0) {
setSyncMsg('No activities on instance');
} else if (result.synced === 0 && !result.fetched && !result.uploaded) {
setSyncMsg(`Up to date (${result.total} activities)`);
showMsg(true, 'No activities on instance');
} else if (result.synced === 0 && !result.fetched) {
showMsg(true, `Up to date (${result.total} activities)`);
} else {
const parts = [];
if (result.synced > 0) parts.push(`${result.synced} new`);
if (result.fetched) parts.push(`${result.fetched} full dataset${result.fetched === 1 ? '' : 's'}`);
if (result.uploaded) parts.push(`${result.uploaded} uploaded`);
setSyncMsg(`Synced: ${parts.join(', ')} (${result.total} total)`);
showMsg(true, `Downloaded: ${parts.join(', ')} (${result.total} total)`);
}
setTimeout(() => setSyncMsg(null), 3500);
}, [db]);
const doUpload = useCallback(async () => {
setUploading(true);
setStatusMsg(null);
const result = await uploadFeed(db);
setUploading(false);
if (result.error) {
showMsg(false, result.error);
} else if (!result.uploaded) {
showMsg(true, 'Nothing to upload');
} else {
showMsg(true, `Uploaded ${result.uploaded} activit${result.uploaded === 1 ? 'y' : 'ies'}`);
}
}, [db]);
function doRefresh() {
setRefreshKey(k => k + 1);
}
function toggleSelect(id: string) {
setSelected(prev => {
const next = new Set(prev);
@@ -45,9 +76,7 @@ export default function FeedScreen() {
});
}
function cancelSelect() {
setSelected(new Set());
}
function cancelSelect() { setSelected(new Set()); }
function confirmDeleteSelected() {
const count = selected.size;
@@ -64,9 +93,7 @@ export default function FeedScreen() {
const paths = await deleteActivities(db, ids);
setSelected(new Set());
for (const p of paths) {
if (p) {
try { await FileSystem.deleteAsync(p, { idempotent: true }); } catch {}
}
if (p) try { await FileSystem.deleteAsync(p, { idempotent: true }); } catch {}
}
},
},
@@ -74,6 +101,8 @@ export default function FeedScreen() {
);
}
const busy = downloading || uploading;
return (
<View style={styles.container}>
<View style={styles.headerRow}>
@@ -87,32 +116,56 @@ export default function FeedScreen() {
) : (
<>
<Text style={styles.header}>Feed</Text>
<Pressable
style={[styles.syncButton, { backgroundColor: theme.dim }, syncing && styles.syncButtonDisabled]}
onPress={syncing ? undefined : doSync}
>
<Text style={[styles.syncText, { color: theme.accent }]}>{syncing ? 'Syncing…' : '↓ Sync'}</Text>
</Pressable>
<View style={styles.actionButtons}>
<ActionButton
icon="↑"
label="Upload"
loading={uploading}
disabled={busy}
accent={theme.accent}
dim={theme.dim}
onPress={doUpload}
/>
<ActionButton
icon="↓"
label="Download"
loading={downloading}
disabled={busy}
accent={theme.accent}
dim={theme.dim}
onPress={doDownload}
/>
<ActionButton
icon="↺"
label="Refresh"
loading={false}
disabled={busy}
accent={theme.accent}
dim={theme.dim}
onPress={doRefresh}
/>
</View>
</>
)}
</View>
{syncMsg && (
<Text style={styles.syncMsg}>{syncMsg}</Text>
{statusMsg && (
<Text style={statusMsg.ok ? styles.msgOk : styles.msgErr}>{statusMsg.text}</Text>
)}
{activities.length === 0 && !syncing ? (
{activities.length === 0 && !busy ? (
<View style={styles.empty}>
<Text style={styles.emptyIcon}>🚴</Text>
<Text style={styles.emptyTitle}>No activities yet</Text>
<Text style={styles.emptyBody}>
Import a file or tap Sync to pull from your instance.
Import a file or tap to pull from your instance.
</Text>
</View>
) : (
<FlatList
data={activities}
keyExtractor={(a) => a.id}
extraData={refreshKey}
renderItem={({ item }) => (
<ActivityCard
activity={item}
@@ -125,8 +178,8 @@ export default function FeedScreen() {
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl
refreshing={syncing}
onRefresh={doSync}
refreshing={false}
onRefresh={doRefresh}
tintColor="#60a5fa"
/>
}
@@ -144,6 +197,30 @@ export default function FeedScreen() {
);
}
function ActionButton({
icon, label, loading, disabled, accent, dim, onPress,
}: {
icon: string;
label: string;
loading: boolean;
disabled: boolean;
accent: string;
dim: string;
onPress: () => void;
}) {
return (
<Pressable
style={[styles.actionBtn, { backgroundColor: dim }, disabled && styles.actionBtnDisabled]}
onPress={disabled ? undefined : onPress}
accessibilityLabel={label}
>
<Text style={[styles.actionBtnIcon, { color: loading ? '#52525b' : accent }]}>
{loading ? '…' : icon}
</Text>
</Pressable>
);
}
function ActivityCard({
activity,
selecting,
@@ -166,11 +243,8 @@ function ActivityCard({
});
function handlePress() {
if (selecting) {
onToggleSelect();
} else {
router.push(`/activity/${activity.id}`);
}
if (selecting) onToggleSelect();
else router.push(`/activity/${activity.id}`);
}
return (
@@ -228,21 +302,20 @@ const styles = StyleSheet.create({
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
},
header: { color: '#fff', fontSize: 22, fontWeight: '700' },
syncButton: {
borderRadius: 8,
paddingHorizontal: 14, paddingVertical: 7,
actionButtons: { flexDirection: 'row', gap: 8 },
actionBtn: {
width: 36, height: 36, borderRadius: 8,
alignItems: 'center', justifyContent: 'center',
},
syncButtonDisabled: { opacity: 0.5 },
syncText: { fontSize: 13, fontWeight: '600' },
actionBtnDisabled: { opacity: 0.4 },
actionBtnIcon: { fontSize: 18, fontWeight: '700', lineHeight: 22 },
cancelButton: {
backgroundColor: '#27272a', borderRadius: 8,
paddingHorizontal: 14, paddingVertical: 7,
},
cancelText: { color: '#a1a1aa', fontSize: 13, fontWeight: '600' },
syncMsg: {
color: '#a1a1aa', fontSize: 12, textAlign: 'center',
paddingHorizontal: 16, paddingBottom: 8,
},
msgOk: { color: '#86efac', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
msgErr: { color: '#fca5a5', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
list: { padding: 16, gap: 12, paddingBottom: 80 },
card: {
backgroundColor: '#18181b', borderRadius: 12,
+91 -24
View File
@@ -1,8 +1,8 @@
import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native';
import * as FileSystem from 'expo-file-system';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useEffect, useState } from 'react';
import { ActivityIndicator, Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useEffect, useRef, useState } from 'react';
import { Alert, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
import { useSQLiteContext } from 'expo-sqlite';
import { deleteActivity, useActivity, useSetting } from '@/db/queries';
@@ -161,16 +161,25 @@ export default function ActivityScreen() {
function RouteMap({ geojson, loading, accent }: { geojson: object | null; loading: boolean; accent: string }) {
const [fullscreen, setFullscreen] = useState(false);
const [currentZoom, setCurrentZoom] = useState(12);
const cameraRef = useRef<any>(null);
if (loading) {
return (
<View style={styles.mapPlaceholder}>
<ActivityIndicator color={accent} />
<Text style={{ color: accent, fontSize: 13 }}>Loading map</Text>
</View>
);
}
if (!geojson) return null;
// MapLibre uses OpenGL/SurfaceView which crashes the Karoo's Qualcomm GPU
// driver (Android <29) even without any interaction. Render a pure SVG route
// trace instead — no native GL surface, no crash.
if (Platform.OS === 'android' && (Platform.Version as number) < 29) {
return <SvgRouteView geojson={geojson} accent={accent} />;
}
const bounds = geoJsonBounds(geojson);
const routeSource = (
<GeoJSONSource id="route" data={geojson as GeoJSON.FeatureCollection}>
@@ -182,28 +191,16 @@ function RouteMap({ geojson, loading, accent }: { geojson: object | null; loadin
/>
</GeoJSONSource>
);
const camera = bounds ? (
<Camera
initialViewState={{
bounds,
padding: { top: 24, bottom: 24, left: 24, right: 24 },
}}
/>
) : null;
const cameraBounds = bounds
? { bounds, padding: { top: 24, bottom: 24, left: 24, right: 24 } }
: undefined;
return (
<>
{/* Thumbnail — tap to expand */}
<Pressable style={styles.mapContainer} onPress={() => setFullscreen(true)}>
<Map
style={styles.map}
mapStyle={MAP_STYLE}
dragPan={false}
touchZoom={false}
touchPitch={false}
touchRotate={false}
>
{camera}
<Map style={styles.map} mapStyle={MAP_STYLE} dragPan={false} touchZoom={false} touchPitch={false} touchRotate={false}>
{cameraBounds && <Camera initialViewState={cameraBounds} />}
{routeSource}
</Map>
<View style={styles.mapExpandHint}>
@@ -211,22 +208,89 @@ function RouteMap({ geojson, loading, accent }: { geojson: object | null; loadin
</View>
</Pressable>
{/* Full-screen interactive map */}
{/* Full-screen map with +/- zoom buttons */}
<Modal visible={fullscreen} animationType="slide" onRequestClose={() => setFullscreen(false)}>
<View style={styles.fullscreenMap}>
<Map style={styles.map} mapStyle={MAP_STYLE}>
{camera}
<Map
style={styles.map}
mapStyle={MAP_STYLE}
onRegionDidChange={(e: any) => {
const z = e?.properties?.zoomLevel;
if (typeof z === 'number') setCurrentZoom(z);
}}
>
{cameraBounds && <Camera ref={cameraRef} initialViewState={cameraBounds} />}
{routeSource}
</Map>
<Pressable style={styles.closeButton} onPress={() => setFullscreen(false)}>
<Text style={styles.closeText}></Text>
</Pressable>
<View style={styles.zoomButtons}>
<Pressable style={styles.zoomBtn} onPress={() => cameraRef.current?.setCamera({ zoomLevel: currentZoom + 1, animationDuration: 200 })}>
<Text style={styles.zoomBtnText}>+</Text>
</Pressable>
<Pressable style={styles.zoomBtn} onPress={() => cameraRef.current?.setCamera({ zoomLevel: Math.max(1, currentZoom - 1), animationDuration: 200 })}>
<Text style={styles.zoomBtnText}></Text>
</Pressable>
</View>
</View>
</Modal>
</>
);
}
// SVG route trace — used on Android <29 where MapLibre crashes the GPU driver.
// Renders the GPS track as a colored path on a dark background with no tiles.
function SvgRouteView({ geojson, accent }: { geojson: object; accent: string }) {
const W = 320;
const H = 180;
const PAD = 16;
const all: [number, number][] = [];
function collect(obj: unknown) {
if (!obj || typeof obj !== 'object') return;
const o = obj as Record<string, unknown>;
if (o.type === 'Feature') { collect(o.geometry); return; }
if (o.type === 'FeatureCollection') { (o.features as unknown[]).forEach(collect); return; }
if (o.type === 'LineString') { all.push(...(o.coordinates as [number, number][])); return; }
if (o.type === 'MultiLineString') { (o.coordinates as [number, number][][]).forEach(c => all.push(...c)); return; }
}
collect(geojson);
if (!all.length) return null;
const step = Math.max(1, Math.floor(all.length / 500));
const pts = all.filter((_, i) => i % step === 0);
const lons = pts.map(c => c[0]);
const lats = pts.map(c => c[1]);
const minLon = Math.min(...lons), maxLon = Math.max(...lons);
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
const spanLon = maxLon - minLon || 0.001;
const spanLat = maxLat - minLat || 0.001;
// Correct longitude for latitude (equirectangular)
const midLat = (minLat + maxLat) / 2;
const lonFactor = Math.cos((midLat * Math.PI) / 180);
const adjLon = spanLon * lonFactor;
const scale = Math.min((W - PAD * 2) / adjLon, (H - PAD * 2) / spanLat);
const offX = (W - adjLon * scale) / 2;
const offY = (H - spanLat * scale) / 2;
const toX = (lon: number) => offX + (lon - minLon) * lonFactor * scale;
const toY = (lat: number) => H - offY - (lat - minLat) * scale;
const d = pts.map((c, i) => `${i === 0 ? 'M' : 'L'}${toX(c[0]).toFixed(1)},${toY(c[1]).toFixed(1)}`).join(' ');
return (
<View style={[styles.mapContainer, { alignItems: 'center', justifyContent: 'center' }]}>
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
<Path d={d} fill="none" stroke={accent} strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" />
</Svg>
</View>
);
}
// ── Metric charts ─────────────────────────────────────────────────────────────
type TabKey = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power';
@@ -245,7 +309,7 @@ function MetricCharts({ timeseries, loading, accent }: { timeseries: Timeseries
if (loading) {
return (
<View style={styles.chartPlaceholder}>
<ActivityIndicator color={accent} />
<Text style={{ color: accent, fontSize: 13 }}>Loading chart</Text>
</View>
);
}
@@ -414,6 +478,9 @@ const styles = StyleSheet.create({
fullscreenMap: { flex: 1, backgroundColor: '#09090b' },
closeButton: { position: 'absolute', top: 56, right: 16, backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
closeText: { color: '#fff', fontSize: 16 },
zoomButtons: { position: 'absolute', bottom: 40, right: 16, gap: 8 },
zoomBtn: { backgroundColor: 'rgba(0,0,0,0.65)', borderRadius: 20, width: 40, height: 40, alignItems: 'center', justifyContent: 'center' },
zoomBtnText: { color: '#fff', fontSize: 22, fontWeight: '600', lineHeight: 28 },
chartContainer: { marginHorizontal: 16, marginBottom: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', overflow: 'hidden' },
chartPlaceholder: { height: 120, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', marginHorizontal: 16, marginBottom: 16 },
chartTabs: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#27272a' },
+42 -38
View File
@@ -1,4 +1,3 @@
import * as FileSystem from 'expo-file-system/legacy';
import type { SQLiteDatabase } from 'expo-sqlite';
import { getSetting, upsertRemoteActivity } from './queries';
@@ -10,14 +9,18 @@ export type SyncResult = {
error?: string;
};
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
async function resolveCredentials(db: SQLiteDatabase): Promise<{ instanceUrl: string; token: string } | { error: string }> {
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
const token = await getSetting(db, 'api_token');
if (!instanceUrl || !token) {
return { synced: 0, total: 0, error: 'No instance configured — add one in Settings.' };
if (!instanceUrl || !token) return { error: 'No instance configured — add one in Settings.' };
return { instanceUrl, token };
}
export async function downloadFeed(db: SQLiteDatabase): Promise<SyncResult> {
const creds = await resolveCredentials(db);
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
const { instanceUrl, token } = creds;
let resp: Response;
try {
resp = await fetch(`${instanceUrl}/api/feed`, {
@@ -27,16 +30,11 @@ export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
return { synced: 0, total: 0, error: 'Could not reach instance — check your connection.' };
}
if (resp.status === 401) {
return { synced: 0, total: 0, error: 'Session expired — reconnect in Settings.' };
}
if (!resp.ok) {
return { synced: 0, total: 0, error: `Server error (${resp.status})` };
}
if (resp.status === 401) return { synced: 0, total: 0, error: 'Session expired — reconnect in Settings.' };
if (!resp.ok) return { synced: 0, total: 0, error: `Server error (${resp.status})` };
const data: { activities?: RemoteSummary[] } = await resp.json();
const activities = data.activities ?? [];
const syncMode = (await getSetting(db, 'sync_mode')) ?? 'summaries';
let synced = 0;
@@ -57,18 +55,9 @@ export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
if (changed) synced++;
}
// Upload local activities to the server if enabled
const uploadEnabled = (await getSetting(db, 'sync_upload')) === 'true';
let uploaded = 0;
if (uploadEnabled) {
uploaded = await uploadLocalActivities(db, instanceUrl, token);
}
if (syncMode !== 'full') return { synced, total: activities.length };
if (syncMode !== 'full') {
return { synced, total: activities.length, uploaded: uploaded || undefined };
}
// Full mode: fetch geojson + timeseries for any activity missing them
// Full mode: fetch geojson + timeseries for activities missing them
const headers = { Authorization: `Bearer ${token}` };
let fetched = 0;
for (const a of activities) {
@@ -103,7 +92,30 @@ export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
}
}
return { synced, total: activities.length, fetched, uploaded: uploaded || undefined };
return { synced, total: activities.length, fetched };
}
export async function uploadFeed(db: SQLiteDatabase): Promise<SyncResult> {
const creds = await resolveCredentials(db);
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
const { instanceUrl, token } = creds;
const uploaded = await uploadLocalActivities(db, instanceUrl, token);
return { synced: 0, total: 0, uploaded };
}
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
const dl = await downloadFeed(db);
if (dl.error) return dl;
const uploadEnabled = (await getSetting(db, 'sync_upload')) === 'true';
let uploaded = 0;
if (uploadEnabled) {
const ul = await uploadFeed(db);
uploaded = ul.uploaded ?? 0;
}
return { ...dl, uploaded: uploaded || undefined };
}
async function uploadLocalActivities(
@@ -111,8 +123,8 @@ async function uploadLocalActivities(
instanceUrl: string,
token: string,
): Promise<number> {
const rows = db.getAllSync<{ id: string; original_path: string | null; timeseries_json: string | null; geojson: string | null }>(
`SELECT id, original_path, timeseries_json, geojson
const rows = db.getAllSync<{ id: string; detail_json: string; timeseries_json: string | null; geojson: string | null }>(
`SELECT id, detail_json, timeseries_json, geojson
FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
);
@@ -122,14 +134,9 @@ async function uploadLocalActivities(
for (const row of rows) {
try {
let activity: object | null = null;
if (row.original_path) {
const text = await FileSystem.readAsStringAsync(row.original_path);
activity = JSON.parse(text);
}
if (!activity) continue;
const detail = JSON.parse(row.detail_json);
// /api/upload/bas expects { activity: { id, ...detail }, timeseries?, geojson? }
const activity = { id: row.id, ...detail };
const body: Record<string, unknown> = { activity };
if (row.timeseries_json) body.timeseries = JSON.parse(row.timeseries_json);
@@ -142,10 +149,7 @@ async function uploadLocalActivities(
});
if (resp.ok) {
await db.runAsync(
`UPDATE activities SET synced_at = ? WHERE id = ?`,
[now, row.id],
);
await db.runAsync(`UPDATE activities SET synced_at = ? WHERE id = ?`, [now, row.id]);
uploaded++;
}
} catch {
+9 -1
View File
@@ -77,6 +77,14 @@ var initError = null;
(async function init() {
try {
// WebAssembly.Global was added in Chrome 69. Without it Pyodide cannot
// initialise on any version. Bail out immediately so the mobile app can
// fall back to server-side extraction without attempting a 35 MB download.
if (typeof WebAssembly === 'undefined' || typeof WebAssembly.Global === 'undefined') {
_post({ type: 'engine_unavailable', reason: 'wasm_global' });
return;
}
_post({ type: 'progress', msg: 'Loading Python runtime…' });
// Chrome <80 is missing features that modern Pyodide uses in its JS wrapper:
@@ -108,7 +116,7 @@ var initError = null;
var _pyResp = await fetch(_CDN_COMPAT + 'pyodide.js');
if (!_pyResp.ok) throw new Error('Could not fetch pyodide.js (' + _pyResp.status + ')');
var _pyCode = await _pyResp.text();
_pyCode = 'var globalThis=typeof globalThis!=="undefined"?globalThis:self;\n' + _pyCode;
_pyCode = 'var globalThis=typeof globalThis!=="undefined"?globalThis:self;\\n' + _pyCode;
_pyCode = _pyCode.split('import(').join('__loadScript(');
_pyCode = _pyCode.split('for await(').join('for(');
await new Promise(function(res, rej) {
+30 -1
View File
@@ -1,4 +1,5 @@
import { createRef } from 'react';
import { Platform } from 'react-native';
import type WebView from 'react-native-webview';
import type { WebViewMessageEvent } from 'react-native-webview';
@@ -25,11 +26,31 @@ let isExtracting = false;
// Engine readiness — tracked so callers can wait before batching files.
let _engineReady = false;
let _engineError: string | null = null;
// Android <29 (API 27 = Android 8.1, e.g. Karoo) ships with a system WebView
// (Chrome <69) that lacks WebAssembly.Global, so Pyodide cannot run. Mounting
// a WebView on those devices also causes GPU driver crashes (SurfaceView
// conflicts). Skip the engine entirely and route to server extraction instead.
let _engineUnavailable = Platform.OS === 'android' && (Platform.Version as number) < 29;
const _engineResolvers: Array<() => void> = [];
const _engineRejecters: Array<(e: Error) => void> = [];
// Init-phase progress listeners (messages sent before any extraction starts).
const _progressListeners = new Set<(msg: string) => void>();
export function onEngineProgress(cb: (msg: string) => void): () => void {
_progressListeners.add(cb);
return () => _progressListeners.delete(cb);
}
export function isEngineAvailable(): boolean | null {
// null = not yet determined; true = ready; false = unavailable
if (_engineReady) return true;
if (_engineUnavailable || _engineError) return false;
return null;
}
export function waitForEngine(timeoutMs = 300_000): Promise<void> {
if (_engineReady) return Promise.resolve();
if (_engineUnavailable) return Promise.reject(new Error('engine_unavailable'));
if (_engineError) return Promise.reject(new Error(_engineError));
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
@@ -52,6 +73,10 @@ export function handleWebViewMessage(e: WebViewMessageEvent): void {
_engineReady = true;
_engineResolvers.splice(0).forEach(fn => fn());
break;
case 'engine_unavailable':
_engineUnavailable = true;
_engineRejecters.splice(0).forEach(fn => fn(new Error('engine_unavailable')));
break;
case 'init_error':
_engineError = msg.message as string;
_engineRejecters.splice(0).forEach(fn => fn(new Error(_engineError!)));
@@ -75,7 +100,11 @@ export function handleWebViewMessage(e: WebViewMessageEvent): void {
}
break;
case 'progress':
p?.onStatus(msg.msg as string);
if (p) {
p.onStatus(msg.msg as string);
} else {
_progressListeners.forEach(fn => fn(msg.msg as string));
}
break;
}
}
+63
View File
@@ -0,0 +1,63 @@
import type { ExtractionResult } from './extractActivity';
export async function checkServerAuth(instanceUrl: string, token: string): Promise<void> {
let resp: Response;
try {
resp = await fetch(`${instanceUrl}/api/feed`, {
headers: { Authorization: `Bearer ${token}` },
});
} catch {
throw new Error('Could not reach Bincio instance — check your connection.');
}
if (resp.status === 401) throw new Error('Session expired — reconnect in Settings.');
if (!resp.ok) throw new Error(`Server error (${resp.status})`);
}
export async function extractFileViaServer(
filename: string,
base64: string,
instanceUrl: string,
token: string,
onStatus: (msg: string) => void = () => {},
): Promise<ExtractionResult> {
onStatus('Uploading to Bincio instance…');
let resp: Response;
try {
resp = await fetch(`${instanceUrl}/api/upload/raw`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ filename, base64 }),
});
} catch {
throw new Error('Could not reach Bincio instance — check your connection.');
}
if (resp.status === 401) throw new Error('Session expired — reconnect in Settings.');
if (resp.status === 422) {
const body = await resp.json().catch(() => ({})) as { detail?: string };
throw new Error(body.detail ?? 'Server could not process this file.');
}
if (!resp.ok) throw new Error(`Server error (${resp.status})`);
onStatus('Processing on server…');
const data = await resp.json() as {
ok: boolean;
id: string;
detail: object;
timeseries: object | null;
geojson: object | null;
source_hash: string;
};
return {
id: data.id,
detail: data.detail,
timeseries: data.timeseries,
geojson: data.geojson,
sourceHash: data.source_hash,
};
}
+16
View File
@@ -18,6 +18,8 @@ URL: http://localhost:4321
"""
import argparse
import platform
import resource
import shutil
import subprocess
import sys
@@ -163,6 +165,18 @@ def start_dev(mobile: bool = False) -> None:
# ── main ──────────────────────────────────────────────────────────────────────
def raise_open_file_limit() -> None:
# Astro's file watcher opens many handles; macOS defaults to 256, which
# causes EMFILE errors under a large project tree.
if platform.system() != "Darwin":
return
target = 65536
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
if soft < target:
resource.setrlimit(resource.RLIMIT_NOFILE, (min(target, hard), hard))
ok(f"open-file limit raised to {min(target, hard)}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
@@ -171,6 +185,8 @@ def main() -> None:
parser.add_argument("--mobile", action="store_true", help="Bind API to 0.0.0.0 for local mobile testing")
args = parser.parse_args()
raise_open_file_limit()
print(f"\033[1mbincio dev test\033[0m → {DATA_DIR}")
if args.fresh and DATA_DIR.exists():