diff --git a/bincio/serve/server.py b/bincio/serve/server.py index b1da0f1..e220929 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -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 + + Returns: + {"ok": true, "id": "...", "detail": {...}, "timeseries": {...}|null, + "geojson": {...}|null, "source_hash": ""} + """ + 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.""" diff --git a/docs/mobile-app.md b/docs/mobile-app.md index ffe78a8..e7f9797 100644 --- a/docs/mobile-app.md +++ b/docs/mobile-app.md @@ -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 + ? + : 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` 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 `` 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 ` 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. --- diff --git a/mobile/app/(tabs)/import.tsx b/mobile/app/(tabs)/import.tsx index 38f5bba..3a457a5 100644 --- a/mobile/app/(tabs)/import.tsx +++ b/mobile/app/(tabs)/import.tsx @@ -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({ status: 'idle' }); const [watchPath, setWatchPath] = useState(''); + const [engineAvailable, setEngineAvailable] = useState(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,12 +101,37 @@ export default function ImportScreen() { return; } - setState({ status: 'loading', msg: 'Preparing extraction engine…', current: 0, total: 0 }); - try { - await waitForEngine(); - } catch (e: unknown) { - setState({ status: 'error', message: e instanceof Error ? e.message : String(e) }); - 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 }); @@ -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, }); - // 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); + let result; - const result = await extractFile(name, base64, wheelBase64, wheelFilename, onStatus); + 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); + result = await extractFile(name, base64, wheelBase64, wheelFilename, onStatus); + } onStatus('Saving…'); @@ -259,12 +321,14 @@ export default function ImportScreen() { return ( - {/* 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) && ( + + + + )} Import @@ -273,6 +337,15 @@ export default function ImportScreen() { You can also import pre-extracted BAS .json files. + {engineAvailable === false && ( + + + 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. + + + )} + {watchPath ? ( Watch folder @@ -305,9 +378,11 @@ export default function ImportScreen() { )} {state.msg} - - First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant. - + {engineAvailable !== false && ( + + First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant. + + )} )} @@ -353,8 +428,10 @@ export default function ImportScreen() { - 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 /sdcard/FitFiles in Settings to auto-import rides. @@ -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, diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index 7594f77..cb62f50 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -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(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>(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)`); + if (result.fetched) parts.push(`${result.fetched} full dataset${result.fetched === 1 ? '' : 's'}`); + 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 ( @@ -87,32 +116,56 @@ export default function FeedScreen() { ) : ( <> Feed - - {syncing ? 'Syncing…' : '↓ Sync'} - + + + + + )} - {syncMsg && ( - {syncMsg} + {statusMsg && ( + {statusMsg.text} )} - {activities.length === 0 && !syncing ? ( + {activities.length === 0 && !busy ? ( 🚴 No activities yet - Import a file or tap Sync to pull from your instance. + Import a file or tap ↓ to pull from your instance. ) : ( a.id} + extraData={refreshKey} renderItem={({ item }) => ( } @@ -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 ( + + + {loading ? '…' : icon} + + + ); +} + 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, diff --git a/mobile/app/activity/[id].tsx b/mobile/app/activity/[id].tsx index 3a37ece..edf091b 100644 --- a/mobile/app/activity/[id].tsx +++ b/mobile/app/activity/[id].tsx @@ -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(null); if (loading) { return ( - + Loading map… ); } 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 ; + } + const bounds = geoJsonBounds(geojson); const routeSource = ( @@ -182,28 +191,16 @@ function RouteMap({ geojson, loading, accent }: { geojson: object | null; loadin /> ); - const camera = bounds ? ( - - ) : null; + const cameraBounds = bounds + ? { bounds, padding: { top: 24, bottom: 24, left: 24, right: 24 } } + : undefined; return ( <> {/* Thumbnail — tap to expand */} setFullscreen(true)}> - - {camera} + + {cameraBounds && } {routeSource} @@ -211,22 +208,89 @@ function RouteMap({ geojson, loading, accent }: { geojson: object | null; loadin - {/* Full-screen interactive map */} + {/* Full-screen map with +/- zoom buttons */} setFullscreen(false)}> - - {camera} + { + const z = e?.properties?.zoomLevel; + if (typeof z === 'number') setCurrentZoom(z); + }} + > + {cameraBounds && } {routeSource} setFullscreen(false)}> + + cameraRef.current?.setCamera({ zoomLevel: currentZoom + 1, animationDuration: 200 })}> + + + + cameraRef.current?.setCamera({ zoomLevel: Math.max(1, currentZoom - 1), animationDuration: 200 })}> + + + ); } +// 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; + 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 ( + + + + + + ); +} + // ── Metric charts ───────────────────────────────────────────────────────────── type TabKey = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power'; @@ -245,7 +309,7 @@ function MetricCharts({ timeseries, loading, accent }: { timeseries: Timeseries if (loading) { return ( - + Loading chart… ); } @@ -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' }, diff --git a/mobile/db/sync.ts b/mobile/db/sync.ts index 79bfccc..c2b174f 100644 --- a/mobile/db/sync.ts +++ b/mobile/db/sync.ts @@ -1,4 +1,3 @@ -import * as FileSystem from 'expo-file-system/legacy'; import type { SQLiteDatabase } from 'expo-sqlite'; import { getSetting, upsertRemoteActivity } from './queries'; @@ -10,13 +9,17 @@ export type SyncResult = { error?: string; }; -export async function syncFeed(db: SQLiteDatabase): Promise { +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 { error: 'No instance configured — add one in Settings.' }; + return { instanceUrl, token }; +} - if (!instanceUrl || !token) { - return { synced: 0, total: 0, error: 'No instance configured — add one in Settings.' }; - } +export async function downloadFeed(db: SQLiteDatabase): Promise { + 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 { @@ -27,16 +30,11 @@ export async function syncFeed(db: SQLiteDatabase): Promise { 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 { 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) { @@ -94,7 +83,7 @@ export async function syncFeed(db: SQLiteDatabase): Promise { if (gj !== null || ts !== null) { await db.runAsync( `UPDATE activities SET - geojson = COALESCE(geojson, ?), + geojson = COALESCE(geojson, ?), timeseries_json = COALESCE(timeseries_json, ?) WHERE id = ? AND origin = 'remote'`, [gj, ts, a.id], @@ -103,7 +92,30 @@ export async function syncFeed(db: SQLiteDatabase): Promise { } } - return { synced, total: activities.length, fetched, uploaded: uploaded || undefined }; + return { synced, total: activities.length, fetched }; +} + +export async function uploadFeed(db: SQLiteDatabase): Promise { + 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 { + 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 { - 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 = { 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 { diff --git a/mobile/extraction/PyodideWebView.tsx b/mobile/extraction/PyodideWebView.tsx index d531b21..8dfa6f7 100644 --- a/mobile/extraction/PyodideWebView.tsx +++ b/mobile/extraction/PyodideWebView.tsx @@ -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) { diff --git a/mobile/extraction/extractActivity.ts b/mobile/extraction/extractActivity.ts index cf8cc56..c9c936b 100644 --- a/mobile/extraction/extractActivity.ts +++ b/mobile/extraction/extractActivity.ts @@ -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 { 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; } } diff --git a/mobile/extraction/extractServer.ts b/mobile/extraction/extractServer.ts new file mode 100644 index 0000000..1a93e61 --- /dev/null +++ b/mobile/extraction/extractServer.ts @@ -0,0 +1,63 @@ +import type { ExtractionResult } from './extractActivity'; + +export async function checkServerAuth(instanceUrl: string, token: string): Promise { + 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 { + 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, + }; +} diff --git a/scripts/dev_test.py b/scripts/dev_test.py index 2ed6f01..dc5d236 100755 --- a/scripts/dev_test.py +++ b/scripts/dev_test.py @@ -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():