Files
bincio-activity/mobile/app/(tabs)/import.tsx
T
Davide Scaini 966528a0bf 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
2026-04-24 22:54:59 +02:00

260 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy';
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'; msg: string }
| { status: 'done'; title: string; id: string }
| { status: 'error'; message: string };
export default function ImportScreen() {
const db = useSQLiteContext();
const [state, setState] = useState<ImportState>({ status: 'idle' });
async function pickFile() {
setState({ status: 'loading', msg: 'Picking file…' });
try {
const result = await DocumentPicker.getDocumentAsync({
type: ['*/*'],
copyToCacheDirectory: true,
});
if (result.canceled || !result.assets?.[0]) {
setState({ status: 'idle' });
return;
}
const asset = result.assets[0];
const name = asset.name ?? '';
const uri = asset.uri;
const lower = name.toLowerCase();
if (lower.endsWith('.json')) {
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) {
const msg = e instanceof Error ? e.message : String(e);
setState({ status: 'error', message: msg });
}
}
// ── 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)');
}
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`;
await FileSystem.copyAsync({ from: uri, to: dest });
await insertActivity(dbCtx, {
id: detail.id,
source_hash: hash,
detail_json: text,
timeseries_json: null,
geojson: null,
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 (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.header}>Import</Text>
<Text style={styles.body}>
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>
<Pressable
style={[styles.button, state.status === 'loading' && styles.buttonDisabled]}
onPress={state.status !== 'loading' ? pickFile : undefined}
>
<Text style={styles.buttonText}>
{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>
</View>
)}
{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 (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>
</View>
))}
<View style={styles.notice}>
<Text style={styles.noticeText}>
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' },
button: {
backgroundColor: '#2563eb', borderRadius: 10,
paddingVertical: 14, alignItems: 'center', marginBottom: 16,
},
buttonDisabled: { opacity: 0.5 },
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,
},
successText: { color: '#86efac', fontSize: 14 },
error: {
backgroundColor: '#450a0a', borderRadius: 8, padding: 12, marginBottom: 16, gap: 8,
},
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 },
notice: {
marginTop: 8, backgroundColor: '#18181b',
borderRadius: 8, padding: 12, borderWidth: 1, borderColor: '#27272a',
},
noticeText: { color: '#71717a', fontSize: 12, lineHeight: 18 },
});