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:
+135
-46
@@ -4,10 +4,15 @@ import { useSQLiteContext } from 'expo-sqlite';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
import { insertActivity } from '@/db/queries';
|
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 =
|
type ImportState =
|
||||||
| { status: 'idle' }
|
| { status: 'idle' }
|
||||||
| { status: 'loading' }
|
| { status: 'loading'; msg: string }
|
||||||
| { status: 'done'; title: string; id: string }
|
| { status: 'done'; title: string; id: string }
|
||||||
| { status: 'error'; message: string };
|
| { status: 'error'; message: string };
|
||||||
|
|
||||||
@@ -16,7 +21,7 @@ export default function ImportScreen() {
|
|||||||
const [state, setState] = useState<ImportState>({ status: 'idle' });
|
const [state, setState] = useState<ImportState>({ status: 'idle' });
|
||||||
|
|
||||||
async function pickFile() {
|
async function pickFile() {
|
||||||
setState({ status: 'loading' });
|
setState({ status: 'loading', msg: 'Picking file…' });
|
||||||
try {
|
try {
|
||||||
const result = await DocumentPicker.getDocumentAsync({
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
type: ['*/*'],
|
type: ['*/*'],
|
||||||
@@ -28,42 +33,35 @@ export default function ImportScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const asset = result.assets[0];
|
const asset = result.assets[0];
|
||||||
const name = asset.name ?? '';
|
const name = asset.name ?? '';
|
||||||
const uri = asset.uri;
|
const uri = asset.uri;
|
||||||
const lower = name.toLowerCase();
|
const lower = name.toLowerCase();
|
||||||
|
|
||||||
if (lower.endsWith('.json')) {
|
if (lower.endsWith('.json')) {
|
||||||
await importBasJson(uri, db);
|
await importBasJson(uri, name, db);
|
||||||
const detail = JSON.parse(await FileSystem.readAsStringAsync(uri));
|
} else if (ALL_NATIVE_EXTENSIONS.some(ext => lower.endsWith(ext))) {
|
||||||
setState({ status: 'done', title: detail.title ?? detail.id, id: detail.id });
|
await importNativeFile(uri, name, db);
|
||||||
} 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' });
|
|
||||||
} else {
|
} else {
|
||||||
setState({ status: 'error', message: `Unsupported file type: ${name}` });
|
setState({ status: 'error', message: `Unsupported file type: ${name}` });
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} 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) {
|
// ── BAS JSON import (no extraction needed) ──────────────────────────────────
|
||||||
const text = await FileSystem.readAsStringAsync(uri);
|
|
||||||
|
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);
|
const detail = JSON.parse(text);
|
||||||
|
|
||||||
if (!detail.id || !detail.started_at) {
|
if (!detail.id || !detail.started_at) {
|
||||||
throw new Error('Not a valid BAS activity JSON (missing id or 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.
|
const hash = detail.source_hash ?? `${detail.id}-${text.length}`;
|
||||||
// Phase 1 will use a proper hash.
|
|
||||||
const hash = `${detail.id}-${text.length}`;
|
|
||||||
|
|
||||||
// Copy to permanent storage
|
|
||||||
const origDir = `${FileSystem.documentDirectory}originals/`;
|
const origDir = `${FileSystem.documentDirectory}originals/`;
|
||||||
await FileSystem.makeDirectoryAsync(origDir, { intermediates: true });
|
await FileSystem.makeDirectoryAsync(origDir, { intermediates: true });
|
||||||
const dest = `${origDir}${detail.id}.json`;
|
const dest = `${origDir}${detail.id}.json`;
|
||||||
@@ -78,6 +76,53 @@ export default function ImportScreen() {
|
|||||||
original_path: dest,
|
original_path: dest,
|
||||||
origin: 'local',
|
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 (
|
return (
|
||||||
@@ -85,7 +130,7 @@ export default function ImportScreen() {
|
|||||||
<Text style={styles.header}>Import</Text>
|
<Text style={styles.header}>Import</Text>
|
||||||
|
|
||||||
<Text style={styles.body}>
|
<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.
|
You can also import a pre-extracted BAS <Text style={styles.code}>.json</Text> file directly.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@@ -94,10 +139,21 @@ export default function ImportScreen() {
|
|||||||
onPress={state.status !== 'loading' ? pickFile : undefined}
|
onPress={state.status !== 'loading' ? pickFile : undefined}
|
||||||
>
|
>
|
||||||
<Text style={styles.buttonText}>
|
<Text style={styles.buttonText}>
|
||||||
{state.status === 'loading' ? 'Importing…' : '+ Pick file'}
|
{state.status === 'loading' ? 'Working…' : '+ Pick file'}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</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' && (
|
{state.status === 'done' && (
|
||||||
<View style={styles.success}>
|
<View style={styles.success}>
|
||||||
<Text style={styles.successText}>✓ Imported: {state.title}</Text>
|
<Text style={styles.successText}>✓ Imported: {state.title}</Text>
|
||||||
@@ -107,18 +163,21 @@ export default function ImportScreen() {
|
|||||||
{state.status === 'error' && (
|
{state.status === 'error' && (
|
||||||
<View style={styles.error}>
|
<View style={styles.error}>
|
||||||
<Text style={styles.errorText}>{state.message}</Text>
|
<Text style={styles.errorText}>{state.message}</Text>
|
||||||
|
<Pressable onPress={() => setState({ status: 'idle' })}>
|
||||||
|
<Text style={styles.errorRetry}>Try another file</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.divider} />
|
<View style={styles.divider} />
|
||||||
|
|
||||||
<Text style={styles.sectionTitle}>Supported formats</Text>
|
<Text style={styles.sectionTitle}>Supported formats</Text>
|
||||||
{[
|
{([
|
||||||
['FIT', 'Garmin, Wahoo, Karoo native format'],
|
['FIT', 'Garmin, Wahoo, Karoo native format'],
|
||||||
['GPX', 'Most GPS devices and apps'],
|
['GPX', 'Most GPS devices and apps'],
|
||||||
['TCX', 'Garmin Training Center'],
|
['TCX', 'Garmin Training Center'],
|
||||||
['BAS JSON', 'Pre-extracted Bincio format'],
|
['BAS JSON', 'Pre-extracted Bincio format (instant)'],
|
||||||
].map(([fmt, desc]) => (
|
] as [string, string][]).map(([fmt, desc]) => (
|
||||||
<View key={fmt} style={styles.formatRow}>
|
<View key={fmt} style={styles.formatRow}>
|
||||||
<Text style={styles.formatName}>{fmt}</Text>
|
<Text style={styles.formatName}>{fmt}</Text>
|
||||||
<Text style={styles.formatDesc}>{desc}</Text>
|
<Text style={styles.formatDesc}>{desc}</Text>
|
||||||
@@ -127,41 +186,71 @@ export default function ImportScreen() {
|
|||||||
|
|
||||||
<View style={styles.notice}>
|
<View style={styles.notice}>
|
||||||
<Text style={styles.noticeText}>
|
<Text style={styles.noticeText}>
|
||||||
FIT/GPX/TCX extraction runs entirely on your device via the Bincio
|
FIT/GPX/TCX extraction runs entirely on your device.
|
||||||
extraction engine. No data is uploaded.
|
A Bincio instance must be reachable on first run to download the extraction engine (~35 MB, then cached).
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</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({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#09090b' },
|
container: { flex: 1, backgroundColor: '#09090b' },
|
||||||
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
|
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
|
||||||
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 12 },
|
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 12 },
|
||||||
body: { color: '#a1a1aa', fontSize: 14, lineHeight: 20, marginBottom: 24 },
|
body: { color: '#a1a1aa', fontSize: 14, lineHeight: 20, marginBottom: 24 },
|
||||||
code: { color: '#60a5fa', fontFamily: 'monospace' },
|
code: { color: '#60a5fa', fontFamily: 'monospace' },
|
||||||
button: {
|
button: {
|
||||||
backgroundColor: '#2563eb', borderRadius: 10,
|
backgroundColor: '#2563eb', borderRadius: 10,
|
||||||
paddingVertical: 14, alignItems: 'center', marginBottom: 16,
|
paddingVertical: 14, alignItems: 'center', marginBottom: 16,
|
||||||
},
|
},
|
||||||
buttonDisabled: { opacity: 0.5 },
|
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: {
|
success: {
|
||||||
backgroundColor: '#14532d', borderRadius: 8,
|
backgroundColor: '#14532d', borderRadius: 8, padding: 12, marginBottom: 16,
|
||||||
padding: 12, marginBottom: 16,
|
|
||||||
},
|
},
|
||||||
successText: { color: '#86efac', fontSize: 14 },
|
successText: { color: '#86efac', fontSize: 14 },
|
||||||
error: {
|
error: {
|
||||||
backgroundColor: '#450a0a', borderRadius: 8,
|
backgroundColor: '#450a0a', borderRadius: 8, padding: 12, marginBottom: 16, gap: 8,
|
||||||
padding: 12, marginBottom: 16,
|
|
||||||
},
|
},
|
||||||
errorText: { color: '#fca5a5', fontSize: 14 },
|
errorText: { color: '#fca5a5', fontSize: 14 },
|
||||||
divider: { height: 1, backgroundColor: '#27272a', marginVertical: 24 },
|
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 },
|
sectionTitle: { color: '#a1a1aa', fontSize: 12, fontWeight: '600', marginBottom: 12, letterSpacing: 0.5 },
|
||||||
formatRow: { flexDirection: 'row', gap: 12, marginBottom: 10 },
|
formatRow: { flexDirection: 'row', gap: 12, marginBottom: 10 },
|
||||||
formatName: { color: '#f4f4f5', fontSize: 13, fontWeight: '600', width: 72 },
|
formatName: { color: '#f4f4f5', fontSize: 13, fontWeight: '600', width: 72 },
|
||||||
formatDesc: { color: '#71717a', fontSize: 13, flex: 1 },
|
formatDesc: { color: '#71717a', fontSize: 13, flex: 1 },
|
||||||
notice: {
|
notice: {
|
||||||
marginTop: 8, backgroundColor: '#18181b',
|
marginTop: 8, backgroundColor: '#18181b',
|
||||||
borderRadius: 8, padding: 12, borderWidth: 1, borderColor: '#27272a',
|
borderRadius: 8, padding: 12, borderWidth: 1, borderColor: '#27272a',
|
||||||
|
|||||||
+10
-4
@@ -2,12 +2,18 @@ import { Stack } from 'expo-router';
|
|||||||
import { SQLiteProvider } from 'expo-sqlite';
|
import { SQLiteProvider } from 'expo-sqlite';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { migrateDb } from '@/db';
|
import { migrateDb } from '@/db';
|
||||||
|
import { PyodideWebView } from '@/extraction/PyodideWebView';
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<SQLiteProvider databaseName="bincio.db" onInit={migrateDb}>
|
<>
|
||||||
<StatusBar style="light" />
|
{/* Hidden WebView: starts loading Pyodide immediately so the runtime
|
||||||
<Stack screenOptions={{ headerShown: false }} />
|
is warm by the time the user opens the Import tab. */}
|
||||||
</SQLiteProvider>
|
<PyodideWebView />
|
||||||
|
<SQLiteProvider databaseName="bincio.db" onInit={migrateDb}>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
|
</SQLiteProvider>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
import WebView from 'react-native-webview';
|
||||||
|
import { handleWebViewMessage, pyodideRef } from './extractActivity';
|
||||||
|
|
||||||
|
const CDN = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/';
|
||||||
|
|
||||||
|
// Python snippets embedded as JSON strings to avoid any JS/TS escaping issues.
|
||||||
|
const PY_INSTALL_PACKAGES = [
|
||||||
|
'import micropip',
|
||||||
|
'await micropip.install(["fitdecode", "gpxpy"])',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const PY_INSTALL_WHEEL = [
|
||||||
|
'import micropip',
|
||||||
|
'await micropip.install(_blobUrl, deps=False)',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const PY_EXTRACT = [
|
||||||
|
'import json, shutil',
|
||||||
|
'from pathlib import Path',
|
||||||
|
'from bincio.extract.parsers.factory import parse_file',
|
||||||
|
'from bincio.extract.metrics import compute',
|
||||||
|
'from bincio.extract.writer import make_activity_id, write_activity',
|
||||||
|
'',
|
||||||
|
'outdir = Path("/tmp/bincio_out")',
|
||||||
|
'if outdir.exists(): shutil.rmtree(outdir)',
|
||||||
|
'outdir.mkdir()',
|
||||||
|
'',
|
||||||
|
'activity = parse_file(Path("/tmp/" + _filename))',
|
||||||
|
'metrics = compute(activity)',
|
||||||
|
'write_activity(activity, metrics, outdir, privacy="public", rdp_epsilon=0.0001)',
|
||||||
|
'act_id = make_activity_id(activity)',
|
||||||
|
'',
|
||||||
|
'detail_path = outdir / "activities" / (act_id + ".json")',
|
||||||
|
'ts_path = outdir / "activities" / (act_id + ".timeseries.json")',
|
||||||
|
'geojson_path = outdir / "activities" / (act_id + ".geojson")',
|
||||||
|
'',
|
||||||
|
'json.dumps({',
|
||||||
|
' "id": act_id,',
|
||||||
|
' "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,',
|
||||||
|
'})',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// JSON.stringify gives us safely-quoted JS string literals for embedding in HTML.
|
||||||
|
const PYODIDE_HTML = `<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"></head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
var _PY_INSTALL_PACKAGES = ${JSON.stringify(PY_INSTALL_PACKAGES)};
|
||||||
|
var _PY_INSTALL_WHEEL = ${JSON.stringify(PY_INSTALL_WHEEL)};
|
||||||
|
var _PY_EXTRACT = ${JSON.stringify(PY_EXTRACT)};
|
||||||
|
var _CDN = ${JSON.stringify(CDN)};
|
||||||
|
|
||||||
|
function _post(m) { window.ReactNativeWebView.postMessage(JSON.stringify(m)); }
|
||||||
|
|
||||||
|
var pyodide = null;
|
||||||
|
var packagesReady = false;
|
||||||
|
var wheelReady = false;
|
||||||
|
var initError = null;
|
||||||
|
|
||||||
|
(async function init() {
|
||||||
|
try {
|
||||||
|
_post({ type: 'progress', msg: 'Loading Python runtime…' });
|
||||||
|
await new Promise(function(res, rej) {
|
||||||
|
var s = document.createElement('script');
|
||||||
|
s.src = _CDN + 'pyodide.js';
|
||||||
|
s.onload = res; s.onerror = rej;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
pyodide = await loadPyodide({ indexURL: _CDN });
|
||||||
|
|
||||||
|
_post({ type: 'progress', msg: 'Loading packages…' });
|
||||||
|
await pyodide.loadPackage(['lxml', 'pyyaml', 'micropip']);
|
||||||
|
|
||||||
|
_post({ type: 'progress', msg: 'Installing fitdecode, gpxpy…' });
|
||||||
|
await pyodide.runPythonAsync(_PY_INSTALL_PACKAGES);
|
||||||
|
|
||||||
|
packagesReady = true;
|
||||||
|
_post({ type: 'pyodide_ready' });
|
||||||
|
} catch(e) {
|
||||||
|
initError = String(e);
|
||||||
|
_post({ type: 'init_error', message: initError });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
window._bincioExtract = async function(params) {
|
||||||
|
var reqId = params.reqId;
|
||||||
|
var filename = params.filename;
|
||||||
|
var base64 = params.base64;
|
||||||
|
var wheelUrl = params.wheelUrl;
|
||||||
|
|
||||||
|
function post(m) { _post(Object.assign({}, m, { reqId: reqId })); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wait for base packages if still loading
|
||||||
|
if (!packagesReady && !initError) {
|
||||||
|
await new Promise(function(res, rej) {
|
||||||
|
var n = 0;
|
||||||
|
var id = setInterval(function() {
|
||||||
|
if (packagesReady) { clearInterval(id); res(undefined); }
|
||||||
|
else if (initError) { clearInterval(id); rej(new Error(initError)); }
|
||||||
|
else if (++n > 300) { clearInterval(id); rej(new Error('Pyodide init timed out')); }
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (initError) throw new Error(initError);
|
||||||
|
|
||||||
|
// Install bincio wheel on first extraction (lazy: keeps startup fast)
|
||||||
|
if (!wheelReady) {
|
||||||
|
post({ type: 'progress', msg: 'Loading Bincio…' });
|
||||||
|
var resp = await fetch(wheelUrl);
|
||||||
|
if (!resp.ok) throw new Error('Failed to fetch Bincio wheel (' + resp.status + ')');
|
||||||
|
var wheelBlob = new Blob([await resp.arrayBuffer()]);
|
||||||
|
var blobUrl = URL.createObjectURL(wheelBlob);
|
||||||
|
pyodide.globals.set('_blobUrl', blobUrl);
|
||||||
|
await pyodide.runPythonAsync(_PY_INSTALL_WHEEL);
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
wheelReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
post({ type: 'progress', msg: 'Extracting…' });
|
||||||
|
|
||||||
|
// Decode base64 file bytes and write to Pyodide's virtual filesystem
|
||||||
|
var bytes = Uint8Array.from(atob(base64), function(c) { return c.charCodeAt(0); });
|
||||||
|
pyodide.FS.writeFile('/tmp/' + filename, bytes);
|
||||||
|
|
||||||
|
// SHA-256 of original file bytes (replaces the stub source_hash)
|
||||||
|
var hashBuf = await crypto.subtle.digest('SHA-256', bytes.buffer);
|
||||||
|
var sourceHash = Array.from(new Uint8Array(hashBuf))
|
||||||
|
.map(function(b) { return b.toString(16).padStart(2, '0'); })
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// Run the bincio extraction pipeline
|
||||||
|
pyodide.globals.set('_filename', filename);
|
||||||
|
var resultJson = await pyodide.runPythonAsync(_PY_EXTRACT);
|
||||||
|
var result = JSON.parse(resultJson);
|
||||||
|
|
||||||
|
_post({
|
||||||
|
type: 'result',
|
||||||
|
reqId: reqId,
|
||||||
|
id: result.id,
|
||||||
|
detail: result.detail,
|
||||||
|
timeseries: result.timeseries,
|
||||||
|
geojson: result.geojson,
|
||||||
|
sourceHash: sourceHash,
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
_post({ type: 'error', reqId: reqId, message: e.message || String(e) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body></html>`;
|
||||||
|
|
||||||
|
export function PyodideWebView() {
|
||||||
|
return (
|
||||||
|
<WebView
|
||||||
|
ref={pyodideRef}
|
||||||
|
source={{ html: PYODIDE_HTML, baseUrl: '' }}
|
||||||
|
style={styles.hidden}
|
||||||
|
onMessage={handleWebViewMessage}
|
||||||
|
javaScriptEnabled
|
||||||
|
originWhitelist={['*']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
// Off-screen but still rendered — display:none / opacity:0 can suppress JS on some platforms.
|
||||||
|
hidden: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -2000,
|
||||||
|
left: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { createRef } from 'react';
|
||||||
|
import type WebView from 'react-native-webview';
|
||||||
|
import type { WebViewMessageEvent } from 'react-native-webview';
|
||||||
|
|
||||||
|
export type ExtractionResult = {
|
||||||
|
id: string;
|
||||||
|
detail: object;
|
||||||
|
timeseries: object | null;
|
||||||
|
geojson: object | null;
|
||||||
|
sourceHash: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Pending = {
|
||||||
|
resolve: (r: ExtractionResult) => void;
|
||||||
|
reject: (e: Error) => void;
|
||||||
|
onStatus: (msg: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pyodideRef = createRef<WebView>();
|
||||||
|
|
||||||
|
const pending = new Map<string, Pending>();
|
||||||
|
let reqCounter = 0;
|
||||||
|
let isExtracting = false;
|
||||||
|
|
||||||
|
export function handleWebViewMessage(e: WebViewMessageEvent): void {
|
||||||
|
let msg: Record<string, unknown>;
|
||||||
|
try { msg = JSON.parse(e.nativeEvent.data); } catch { return; }
|
||||||
|
|
||||||
|
const reqId = msg.reqId as string | undefined;
|
||||||
|
const p = reqId ? pending.get(reqId) : undefined;
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'result':
|
||||||
|
if (p) {
|
||||||
|
pending.delete(reqId!);
|
||||||
|
p.resolve({
|
||||||
|
id: msg.id as string,
|
||||||
|
detail: msg.detail as object,
|
||||||
|
timeseries: (msg.timeseries as object | null) ?? null,
|
||||||
|
geojson: (msg.geojson as object | null) ?? null,
|
||||||
|
sourceHash: msg.sourceHash as string,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
if (p) {
|
||||||
|
pending.delete(reqId!);
|
||||||
|
p.reject(new Error(msg.message as string));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'progress':
|
||||||
|
p?.onStatus(msg.msg as string);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractFile(
|
||||||
|
filename: string,
|
||||||
|
base64: string,
|
||||||
|
wheelUrl: string,
|
||||||
|
onStatus: (msg: string) => void = () => {},
|
||||||
|
): Promise<ExtractionResult> {
|
||||||
|
if (isExtracting) return Promise.reject(new Error('Another extraction is already in progress'));
|
||||||
|
|
||||||
|
const webview = pyodideRef.current;
|
||||||
|
if (!webview) return Promise.reject(new Error('Extraction engine not ready — restart the app'));
|
||||||
|
|
||||||
|
isExtracting = true;
|
||||||
|
const reqId = String(++reqCounter);
|
||||||
|
const args = JSON.stringify({ reqId, filename, base64, wheelUrl });
|
||||||
|
|
||||||
|
return new Promise<ExtractionResult>((resolve, reject) => {
|
||||||
|
pending.set(reqId, {
|
||||||
|
resolve: (r) => { isExtracting = false; resolve(r); },
|
||||||
|
reject: (e) => { isExtracting = false; reject(e); },
|
||||||
|
onStatus,
|
||||||
|
});
|
||||||
|
webview.injectJavaScript(`window._bincioExtract(${args}); true;`);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user