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 { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { insertActivity } from '@/db/queries'; import { PyodideWebView } from '@/extraction/PyodideWebView'; 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({ 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, }); // Fetch the bincio wheel here (React Native networking), not inside the // WebView. WKWebView blocks HTTP requests via ATS; RN native networking // allows local-network HTTP (NSAllowsLocalNetworking=true in Info.plist). const instanceUrl = await getInstanceUrl(dbCtx); setState({ status: 'loading', msg: 'Fetching Bincio engine…' }); const wheelBase64 = await fetchWheelBase64(instanceUrl); const result = await extractFile( name, base64, wheelBase64, (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 ( {/* Hidden WebView for Pyodide — mounted here so it lives inside the tab (Expo Router keeps tabs mounted after first visit, preserving Pyodide state). The 1×1 container clips it out of the scroll layout entirely. */} Import Import a FIT, GPX, or TCX file — extracted on your device, nothing uploaded. You can also import a pre-extracted BAS .json file directly. {state.status === 'loading' ? 'Working…' : '+ Pick file'} {state.status === 'loading' && ( {state.msg} First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant. )} {state.status === 'done' && ( ✓ Imported: {state.title} )} {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 (instant)'], ] as [string, string][]).map(([fmt, desc]) => ( {fmt} {desc} ))} 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(/\/$/, ''); } // In-memory cache so repeated imports in one session don't re-download the wheel. let _cachedWheelBase64: string | null = null; async function fetchWheelBase64(instanceUrl: string): Promise { if (_cachedWheelBase64) return _cachedWheelBase64; const base = instanceUrl || 'https://bincio.org'; // Ask the instance for the canonical wheel URL (handles both dev and prod layouts). let wheelUrl = `${base}/api/wheel/download`; try { const vr = await fetch(`${base}/api/wheel/version`, { signal: AbortSignal.timeout(5000) }); if (vr.ok) { const d = await vr.json() as { api_url?: string; url?: string }; const path = d.api_url ?? d.url ?? '/api/wheel/download'; wheelUrl = path.startsWith('http') ? path : `${base}${path}`; } } catch {} // Fetch via React Native networking (supports local HTTP; WKWebView would block it). const resp = await fetch(wheelUrl); if (!resp.ok) throw new Error(`Could not download Bincio engine (${resp.status}). Is the instance running?`); const buf = await resp.arrayBuffer(); _cachedWheelBase64 = arrayBufferToBase64(buf); return _cachedWheelBase64; } function arrayBufferToBase64(buf: ArrayBuffer): string { const bytes = new Uint8Array(buf); let binary = ''; // Process in chunks to avoid spread-operator stack overflow on large arrays. const CHUNK = 8192; for (let i = 0; i < bytes.length; i += CHUNK) { binary += String.fromCharCode(...(bytes.subarray(i, i + CHUNK) as unknown as number[])); } return btoa(binary); } // ── Styles ─────────────────────────────────────────────────────────────────── const styles = StyleSheet.create({ screen: { flex: 1, backgroundColor: '#09090b' }, hiddenEngine: { position: 'absolute', width: 1, height: 1, overflow: 'hidden' }, container: { flex: 1 }, 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 }, });