diff --git a/mobile/app/(tabs)/import.tsx b/mobile/app/(tabs)/import.tsx index 9e41d04..0c6458e 100644 --- a/mobile/app/(tabs)/import.tsx +++ b/mobile/app/(tabs)/import.tsx @@ -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({ 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; + setState({ status: 'done', title: (d.title as string) ?? result.id, id: result.id }); } return ( @@ -85,7 +130,7 @@ export default function ImportScreen() { Import - 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 .json file directly. @@ -94,10 +139,21 @@ export default function ImportScreen() { onPress={state.status !== 'loading' ? pickFile : undefined} > - {state.status === 'loading' ? 'Importing…' : '+ Pick file'} + {state.status === 'loading' ? 'Working…' : '+ Pick file'} + {state.status === 'loading' && ( + + {state.msg} + {state.msg.startsWith('Load') && ( + + First run downloads ~35 MB of the Python runtime. Subsequent runs are instant. + + )} + + )} + {state.status === 'done' && ( ✓ Imported: {state.title} @@ -107,18 +163,21 @@ export default function ImportScreen() { {state.status === 'error' && ( {state.message} + setState({ status: 'idle' })}> + Try another file + )} Supported formats - {[ - ['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]) => ( {fmt} {desc} @@ -127,41 +186,71 @@ export default function ImportScreen() { - 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). ); } +// ── Helpers ───────────────────────────────────────────────────────────────── + +async function getInstanceUrl(db: ReturnType): Promise { + const row = db.getFirstSync<{ value: string }>( + 'SELECT value FROM settings WHERE key = ?', + ['instance_url'], + ); + return (row?.value ?? '').replace(/\/$/, ''); +} + +async function resolveWheelUrl(instanceUrl: string): Promise { + 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', diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index a2c9de5..45b3f75 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -2,12 +2,18 @@ import { Stack } from 'expo-router'; import { SQLiteProvider } from 'expo-sqlite'; import { StatusBar } from 'expo-status-bar'; import { migrateDb } from '@/db'; +import { PyodideWebView } from '@/extraction/PyodideWebView'; export default function RootLayout() { return ( - - - - + <> + {/* Hidden WebView: starts loading Pyodide immediately so the runtime + is warm by the time the user opens the Import tab. */} + + + + + + ); } diff --git a/mobile/extraction/PyodideWebView.tsx b/mobile/extraction/PyodideWebView.tsx new file mode 100644 index 0000000..f430e06 --- /dev/null +++ b/mobile/extraction/PyodideWebView.tsx @@ -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 = ` + + + +`; + +export function PyodideWebView() { + return ( + + ); +} + +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, + }, +}); diff --git a/mobile/extraction/extractActivity.ts b/mobile/extraction/extractActivity.ts new file mode 100644 index 0000000..5207f4d --- /dev/null +++ b/mobile/extraction/extractActivity.ts @@ -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(); + +const pending = new Map(); +let reqCounter = 0; +let isExtracting = false; + +export function handleWebViewMessage(e: WebViewMessageEvent): void { + let msg: Record; + 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 { + 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((resolve, reject) => { + pending.set(reqId, { + resolve: (r) => { isExtracting = false; resolve(r); }, + reject: (e) => { isExtracting = false; reject(e); }, + onStatus, + }); + webview.injectJavaScript(`window._bincioExtract(${args}); true;`); + }); +}