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:
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user