feat: Phase 1 — FIT/GPX/TCX extraction via Pyodide in hidden WebView

- extraction/PyodideWebView.tsx: hidden WebView (1×1 px, off-screen) that
  bootstraps Pyodide v0.26.4 from jsDelivr CDN on app startup; loads lxml,
  pyyaml, micropip, fitdecode, gpxpy automatically; installs the bincio wheel
  lazily on the first extraction call via a blob URL (avoids startup delay)
- extraction/extractActivity.ts: typed bridge — extractFile(filename, base64,
  wheelUrl, onStatus) injects JS into the WebView, tracks pending promises by
  request ID, resolves with { id, detail, timeseries, geojson, sourceHash }
- app/_layout.tsx: mounts <PyodideWebView> outside SQLiteProvider at root so
  the runtime warms up as soon as the app opens
- app/(tabs)/import.tsx: replaces the placeholder alert with real extraction;
  reads files as base64, calls extractFile with a progress callback, stores
  detail_json + timeseries_json + geojson + real SHA-256 source_hash; resolves
  wheel URL via GET /api/wheel/version with fallback to /api/wheel/download;
  falls back to bincio.org if no instance is configured
This commit is contained in:
Davide Scaini
2026-04-24 22:54:59 +02:00
parent ed738ffc97
commit 966528a0bf
4 changed files with 404 additions and 50 deletions
+135 -46
View File
@@ -4,10 +4,15 @@ import { useSQLiteContext } from 'expo-sqlite';
import { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { insertActivity } from '@/db/queries';
import { extractFile } from '@/extraction/extractActivity';
const FIT_EXTENSIONS = ['.fit', '.fit.gz'];
const OTHER_EXTENSIONS = ['.gpx', '.tcx', '.gpx.gz', '.tcx.gz'];
const ALL_NATIVE_EXTENSIONS = [...FIT_EXTENSIONS, ...OTHER_EXTENSIONS];
type ImportState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'loading'; msg: string }
| { status: 'done'; title: string; id: string }
| { status: 'error'; message: string };
@@ -16,7 +21,7 @@ export default function ImportScreen() {
const [state, setState] = useState<ImportState>({ status: 'idle' });
async function pickFile() {
setState({ status: 'loading' });
setState({ status: 'loading', msg: 'Picking file…' });
try {
const result = await DocumentPicker.getDocumentAsync({
type: ['*/*'],
@@ -28,42 +33,35 @@ export default function ImportScreen() {
}
const asset = result.assets[0];
const name = asset.name ?? '';
const uri = asset.uri;
const name = asset.name ?? '';
const uri = asset.uri;
const lower = name.toLowerCase();
if (lower.endsWith('.json')) {
await importBasJson(uri, db);
const detail = JSON.parse(await FileSystem.readAsStringAsync(uri));
setState({ status: 'done', title: detail.title ?? detail.id, id: detail.id });
} else if (['.fit', '.gpx', '.tcx', '.fit.gz', '.gpx.gz', '.tcx.gz'].some(ext => lower.endsWith(ext))) {
// Phase 1: Pyodide extraction. Placeholder for now.
Alert.alert(
'Extraction coming in Phase 1',
`File "${name}" received. FIT/GPX/TCX extraction via Pyodide will be added in Phase 1. For now, you can import a pre-extracted BAS .json file.`,
);
setState({ status: 'idle' });
await importBasJson(uri, name, db);
} else if (ALL_NATIVE_EXTENSIONS.some(ext => lower.endsWith(ext))) {
await importNativeFile(uri, name, db);
} else {
setState({ status: 'error', message: `Unsupported file type: ${name}` });
}
} catch (e: unknown) {
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
const msg = e instanceof Error ? e.message : String(e);
setState({ status: 'error', message: msg });
}
}
async function importBasJson(uri: string, dbCtx: typeof db) {
const text = await FileSystem.readAsStringAsync(uri);
// ── BAS JSON import (no extraction needed) ──────────────────────────────────
async function importBasJson(uri: string, name: string, dbCtx: typeof db) {
setState({ status: 'loading', msg: 'Importing…' });
const text = await FileSystem.readAsStringAsync(uri);
const detail = JSON.parse(text);
if (!detail.id || !detail.started_at) {
throw new Error('Not a valid BAS activity JSON (missing id or started_at)');
}
// Simple hash: SHA-256 not available without a library, use content length + id as stand-in.
// Phase 1 will use a proper hash.
const hash = `${detail.id}-${text.length}`;
// Copy to permanent storage
const hash = detail.source_hash ?? `${detail.id}-${text.length}`;
const origDir = `${FileSystem.documentDirectory}originals/`;
await FileSystem.makeDirectoryAsync(origDir, { intermediates: true });
const dest = `${origDir}${detail.id}.json`;
@@ -78,6 +76,53 @@ export default function ImportScreen() {
original_path: dest,
origin: 'local',
});
setState({ status: 'done', title: detail.title ?? detail.id, id: detail.id });
}
// ── FIT / GPX / TCX import via Pyodide extraction ──────────────────────────
async function importNativeFile(uri: string, name: string, dbCtx: typeof db) {
setState({ status: 'loading', msg: 'Reading file…' });
// Read the original file as base64 so we can (a) pass it to the WebView
// and (b) copy it to permanent storage without a second read.
const base64 = await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
// Wheel URL: prefer the configured instance; fall back to bincio.org.
const instanceUrl = await getInstanceUrl(dbCtx);
const wheelUrl = await resolveWheelUrl(instanceUrl);
const result = await extractFile(
name,
base64,
wheelUrl,
(msg) => setState({ status: 'loading', msg }),
);
setState({ status: 'loading', msg: 'Saving…' });
// Copy original file to permanent storage (keeps original bytes for future re-extraction)
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
const origDir = `${FileSystem.documentDirectory}originals/`;
await FileSystem.makeDirectoryAsync(origDir, { intermediates: true });
const dest = `${origDir}${result.id}${ext}`;
await FileSystem.copyAsync({ from: uri, to: dest });
await insertActivity(dbCtx, {
id: result.id,
source_hash: result.sourceHash,
detail_json: JSON.stringify(result.detail),
timeseries_json: result.timeseries ? JSON.stringify(result.timeseries) : null,
geojson: result.geojson ? JSON.stringify(result.geojson) : null,
original_path: dest,
origin: 'local',
});
const d = result.detail as Record<string, unknown>;
setState({ status: 'done', title: (d.title as string) ?? result.id, id: result.id });
}
return (
@@ -85,7 +130,7 @@ export default function ImportScreen() {
<Text style={styles.header}>Import</Text>
<Text style={styles.body}>
Import a FIT, GPX, or TCX file to extract and store it locally.
Import a FIT, GPX, or TCX file extracted on your device, nothing uploaded.
You can also import a pre-extracted BAS <Text style={styles.code}>.json</Text> file directly.
</Text>
@@ -94,10 +139,21 @@ export default function ImportScreen() {
onPress={state.status !== 'loading' ? pickFile : undefined}
>
<Text style={styles.buttonText}>
{state.status === 'loading' ? 'Importing…' : ' Pick file'}
{state.status === 'loading' ? 'Working…' : ' Pick file'}
</Text>
</Pressable>
{state.status === 'loading' && (
<View style={styles.statusBox}>
<Text style={styles.statusMsg}>{state.msg}</Text>
{state.msg.startsWith('Load') && (
<Text style={styles.statusHint}>
First run downloads ~35 MB of the Python runtime. Subsequent runs are instant.
</Text>
)}
</View>
)}
{state.status === 'done' && (
<View style={styles.success}>
<Text style={styles.successText}> Imported: {state.title}</Text>
@@ -107,18 +163,21 @@ export default function ImportScreen() {
{state.status === 'error' && (
<View style={styles.error}>
<Text style={styles.errorText}>{state.message}</Text>
<Pressable onPress={() => setState({ status: 'idle' })}>
<Text style={styles.errorRetry}>Try another file</Text>
</Pressable>
</View>
)}
<View style={styles.divider} />
<Text style={styles.sectionTitle}>Supported formats</Text>
{[
['FIT', 'Garmin, Wahoo, Karoo native format'],
['GPX', 'Most GPS devices and apps'],
['TCX', 'Garmin Training Center'],
['BAS JSON', 'Pre-extracted Bincio format'],
].map(([fmt, desc]) => (
{([
['FIT', 'Garmin, Wahoo, Karoo native format'],
['GPX', 'Most GPS devices and apps'],
['TCX', 'Garmin Training Center'],
['BAS JSON', 'Pre-extracted Bincio format (instant)'],
] as [string, string][]).map(([fmt, desc]) => (
<View key={fmt} style={styles.formatRow}>
<Text style={styles.formatName}>{fmt}</Text>
<Text style={styles.formatDesc}>{desc}</Text>
@@ -127,41 +186,71 @@ export default function ImportScreen() {
<View style={styles.notice}>
<Text style={styles.noticeText}>
FIT/GPX/TCX extraction runs entirely on your device via the Bincio
extraction engine. No data is uploaded.
FIT/GPX/TCX extraction runs entirely on your device.
A Bincio instance must be reachable on first run to download the extraction engine (~35 MB, then cached).
</Text>
</View>
</ScrollView>
);
}
// ── Helpers ─────────────────────────────────────────────────────────────────
async function getInstanceUrl(db: ReturnType<typeof useSQLiteContext>): Promise<string> {
const row = db.getFirstSync<{ value: string }>(
'SELECT value FROM settings WHERE key = ?',
['instance_url'],
);
return (row?.value ?? '').replace(/\/$/, '');
}
async function resolveWheelUrl(instanceUrl: string): Promise<string> {
const base = instanceUrl || 'https://bincio.org';
try {
const resp = await fetch(`${base}/api/wheel/version`, { signal: AbortSignal.timeout(5000) });
if (resp.ok) {
const d = await resp.json() as { api_url?: string; url?: string };
const path = d.api_url ?? d.url ?? '/api/wheel/download';
return path.startsWith('http') ? path : `${base}${path}`;
}
} catch {}
return `${base}/api/wheel/download`;
}
// ── Styles ───────────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#09090b' },
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 12 },
body: { color: '#a1a1aa', fontSize: 14, lineHeight: 20, marginBottom: 24 },
code: { color: '#60a5fa', fontFamily: 'monospace' },
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 12 },
body: { color: '#a1a1aa', fontSize: 14, lineHeight: 20, marginBottom: 24 },
code: { color: '#60a5fa', fontFamily: 'monospace' },
button: {
backgroundColor: '#2563eb', borderRadius: 10,
paddingVertical: 14, alignItems: 'center', marginBottom: 16,
},
buttonDisabled: { opacity: 0.5 },
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
statusBox: {
backgroundColor: '#18181b', borderRadius: 8, borderWidth: 1,
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 6,
},
statusMsg: { color: '#60a5fa', fontSize: 14, textAlign: 'center' },
statusHint: { color: '#52525b', fontSize: 12, textAlign: 'center', lineHeight: 16 },
success: {
backgroundColor: '#14532d', borderRadius: 8,
padding: 12, marginBottom: 16,
backgroundColor: '#14532d', borderRadius: 8, padding: 12, marginBottom: 16,
},
successText: { color: '#86efac', fontSize: 14 },
error: {
backgroundColor: '#450a0a', borderRadius: 8,
padding: 12, marginBottom: 16,
backgroundColor: '#450a0a', borderRadius: 8, padding: 12, marginBottom: 16, gap: 8,
},
errorText: { color: '#fca5a5', fontSize: 14 },
divider: { height: 1, backgroundColor: '#27272a', marginVertical: 24 },
errorText: { color: '#fca5a5', fontSize: 14 },
errorRetry: { color: '#71717a', fontSize: 13, textDecorationLine: 'underline' },
divider: { height: 1, backgroundColor: '#27272a', marginVertical: 24 },
sectionTitle: { color: '#a1a1aa', fontSize: 12, fontWeight: '600', marginBottom: 12, letterSpacing: 0.5 },
formatRow: { flexDirection: 'row', gap: 12, marginBottom: 10 },
formatName: { color: '#f4f4f5', fontSize: 13, fontWeight: '600', width: 72 },
formatDesc: { color: '#71717a', fontSize: 13, flex: 1 },
formatRow: { flexDirection: 'row', gap: 12, marginBottom: 10 },
formatName: { color: '#f4f4f5', fontSize: 13, fontWeight: '600', width: 72 },
formatDesc: { color: '#71717a', fontSize: 13, flex: 1 },
notice: {
marginTop: 8, backgroundColor: '#18181b',
borderRadius: 8, padding: 12, borderWidth: 1, borderColor: '#27272a',