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
+111 -29
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,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 (
<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. */}
<View style={styles.hiddenEngine}>
<PyodideWebView />
</View>
{/* 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>
<Text style={styles.statusHint}>
First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant.
</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,