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:
+111
-29
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user