feat(mobile): Karoo GPU crash fix, server-side extraction, upload fix, feed redesign
- Skip MapLibre on Android <29 (Karoo): SELinux denies kgsl-3d0 access from untrusted_app context, crashing the GPU driver on any OpenGL surface. Replace with SvgRouteView — equirectangular SVG route trace using react-native-svg, no native GL surface needed. - Add +/- zoom buttons to full-screen MapLibre map on modern devices via Camera ref and onRegionDidChange. - Skip PyodideWebView on Android <29: same GPU driver conflict; set _engineUnavailable at module init via API level gate (< 29). - Add engine_unavailable fast path in PyodideWebView: post message immediately if WebAssembly.Global is absent (Chrome <69) instead of attempting 30 MB Pyodide download. - Add server-side extraction fallback (extractServer.ts): when engine unavailable, POST raw file as base64 to /api/upload/raw; server runs full Python pipeline and returns extracted data. - Add /api/upload/raw endpoint in server.py. - Add pre-flight auth check (checkServerAuth) before batch import so an expired token errors immediately rather than after N files. - Fix uploadLocalActivities in sync.ts: was reading original_path as JSON (binary FIT file, always threw), silently skipping every upload. Now reads detail_json from DB directly. - Redesign Feed header: replace single Sync button with Upload / Download / Refresh. Pull-to-refresh and Refresh button are local-only. Auto-refresh on tab focus via useFocusEffect. - Replace ActivityIndicator with plain Text everywhere (native animation also crashes Karoo GPU driver). - Raise macOS open-file limit in dev_test.py to prevent EMFILE errors from Astro file watcher. - Document all Karoo hardware constraints in docs/mobile-app.md.
This commit is contained in:
+111
-29
@@ -6,7 +6,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AppState, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { insertActivity, isSourcePathImported, getSetting } from '@/db/queries';
|
||||
import { PyodideWebView } from '@/extraction/PyodideWebView';
|
||||
import { extractFile, waitForEngine } from '@/extraction/extractActivity';
|
||||
import { extractFile, waitForEngine, onEngineProgress, isEngineAvailable } from '@/extraction/extractActivity';
|
||||
import { extractFileViaServer, checkServerAuth } from '@/extraction/extractServer';
|
||||
import { useTheme } from '@/ThemeContext';
|
||||
|
||||
const FIT_EXTENSIONS = ['.fit', '.fit.gz'];
|
||||
@@ -24,8 +25,18 @@ export default function ImportScreen() {
|
||||
const theme = useTheme();
|
||||
const [state, setState] = useState<ImportState>({ status: 'idle' });
|
||||
const [watchPath, setWatchPath] = useState('');
|
||||
const [engineAvailable, setEngineAvailable] = useState<boolean | null>(null);
|
||||
const isImporting = useRef(false);
|
||||
|
||||
// Track engine availability so we can show the server-extraction notice.
|
||||
useEffect(() => {
|
||||
waitForEngine(30_000)
|
||||
.then(() => setEngineAvailable(true))
|
||||
.catch((e: unknown) => {
|
||||
if (e instanceof Error && e.message === 'engine_unavailable') setEngineAvailable(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Reload watch path every time the Import tab comes into focus so changes
|
||||
// saved in Settings are picked up without remounting the tab.
|
||||
useFocusEffect(useCallback(() => {
|
||||
@@ -56,8 +67,18 @@ export default function ImportScreen() {
|
||||
const instanceUrl = await getSetting(db, 'instance_url');
|
||||
if (!instanceUrl) return;
|
||||
|
||||
// Wait for the extraction engine — but don't block forever on auto-scan.
|
||||
try { await waitForEngine(120_000); } catch { return; }
|
||||
// Wait for engine — skip auto-scan on init failure, but continue if device is
|
||||
// too old for local extraction (importNativeFile will use the server instead).
|
||||
try { await waitForEngine(120_000); } catch (e: unknown) {
|
||||
if (!(e instanceof Error) || e.message !== 'engine_unavailable') return;
|
||||
}
|
||||
|
||||
// Server-mode requires a valid token — verify before touching any files.
|
||||
if (isEngineAvailable() === false) {
|
||||
const token = await getSetting(db, 'api_token');
|
||||
if (!token) return;
|
||||
try { await checkServerAuth(instanceUrl, token); } catch { return; }
|
||||
}
|
||||
|
||||
const newFiles = await discoverNewFiles(db, path);
|
||||
if (newFiles.length === 0) return;
|
||||
@@ -80,12 +101,37 @@ export default function ImportScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ status: 'loading', msg: 'Preparing extraction engine…', current: 0, total: 0 });
|
||||
try {
|
||||
await waitForEngine();
|
||||
} catch (e: unknown) {
|
||||
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
|
||||
return;
|
||||
const serverMode = isEngineAvailable() === false;
|
||||
if (!serverMode) {
|
||||
setState({ status: 'loading', msg: 'Preparing extraction engine…', current: 0, total: 0 });
|
||||
const unsubScan = onEngineProgress((msg) =>
|
||||
setState({ status: 'loading', msg, current: 0, total: 0 }),
|
||||
);
|
||||
try {
|
||||
await waitForEngine();
|
||||
} catch (e: unknown) {
|
||||
if (!(e instanceof Error) || e.message !== 'engine_unavailable') {
|
||||
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
|
||||
return;
|
||||
}
|
||||
// engine_unavailable — fall through to server mode
|
||||
} finally {
|
||||
unsubScan();
|
||||
}
|
||||
} else {
|
||||
const token = await getSetting(db, 'api_token');
|
||||
if (!token) {
|
||||
setState({ status: 'error', message: 'Server extraction requires a Bincio account. Connect in Settings.' });
|
||||
return;
|
||||
}
|
||||
// Verify the token is valid before processing any files.
|
||||
setState({ status: 'loading', msg: 'Checking connection…', current: 0, total: 0 });
|
||||
try {
|
||||
await checkServerAuth(instanceUrl, token);
|
||||
} catch (e: unknown) {
|
||||
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState({ status: 'loading', msg: 'Scanning…', current: 0, total: 0 });
|
||||
@@ -132,9 +178,13 @@ export default function ImportScreen() {
|
||||
return;
|
||||
}
|
||||
isImporting.current = true;
|
||||
const unsubPick = onEngineProgress((msg) =>
|
||||
setState({ status: 'loading', msg, current: 0, total: 0 }),
|
||||
);
|
||||
try {
|
||||
await processBatch(result.assets.map(a => ({ uri: a.uri, name: a.name ?? '', sourcePath: null })));
|
||||
} finally {
|
||||
unsubPick();
|
||||
isImporting.current = false;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
@@ -211,7 +261,7 @@ export default function ImportScreen() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── FIT / GPX / TCX import via Pyodide extraction ──────────────────────────
|
||||
// ── FIT / GPX / TCX import via Pyodide (local) or server fallback ───────────
|
||||
|
||||
async function importNativeFile(
|
||||
uri: string,
|
||||
@@ -221,20 +271,32 @@ export default function ImportScreen() {
|
||||
) {
|
||||
onStatus('Reading file…');
|
||||
|
||||
// Read the original file as base64 so we can (a) pass it to the WebView
|
||||
// Read the original file as base64 so we can (a) pass it to the extractor
|
||||
// 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(db);
|
||||
onStatus('Fetching Bincio engine…');
|
||||
const { base64: wheelBase64, filename: wheelFilename } = await fetchWheelBase64(instanceUrl);
|
||||
let result;
|
||||
|
||||
const result = await extractFile(name, base64, wheelBase64, wheelFilename, onStatus);
|
||||
if (isEngineAvailable() === false) {
|
||||
// Device WebView is too old for WebAssembly.Global (Chrome <69).
|
||||
// Send the raw file to the Bincio instance for server-side extraction.
|
||||
const instanceUrl = await getInstanceUrl(db);
|
||||
const token = db.getFirstSync<{ value: string }>(
|
||||
'SELECT value FROM settings WHERE key = ?', ['api_token'],
|
||||
)?.value ?? '';
|
||||
if (!token) throw new Error('Server extraction requires a Bincio account — connect in Settings.');
|
||||
result = await extractFileViaServer(name, base64, instanceUrl, token, onStatus);
|
||||
} else {
|
||||
// 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(db);
|
||||
onStatus('Fetching Bincio engine…');
|
||||
const { base64: wheelBase64, filename: wheelFilename } = await fetchWheelBase64(instanceUrl);
|
||||
result = await extractFile(name, base64, wheelBase64, wheelFilename, onStatus);
|
||||
}
|
||||
|
||||
onStatus('Saving…');
|
||||
|
||||
@@ -259,12 +321,14 @@ export default function ImportScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
{/* 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. */}
|
||||
<View style={styles.hiddenEngine}>
|
||||
<PyodideWebView />
|
||||
</View>
|
||||
{/* Hidden WebView for Pyodide — only mounted on devices that can run it.
|
||||
Android <29 has a system WebView (Chrome <69) that lacks WebAssembly.Global
|
||||
AND causes GPU SurfaceView crashes on old drivers. Skip it entirely there. */}
|
||||
{(Platform.OS !== 'android' || (Platform.Version as number) >= 29) && (
|
||||
<View style={styles.hiddenEngine}>
|
||||
<PyodideWebView />
|
||||
</View>
|
||||
)}
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.header}>Import</Text>
|
||||
|
||||
@@ -273,6 +337,15 @@ export default function ImportScreen() {
|
||||
You can also import pre-extracted BAS <Text style={[styles.code, { color: theme.accent }]}>.json</Text> files.
|
||||
</Text>
|
||||
|
||||
{engineAvailable === false && (
|
||||
<View style={styles.serverNotice}>
|
||||
<Text style={styles.serverNoticeText}>
|
||||
This device's Android WebView is too old to run local extraction (requires Chrome 69+).
|
||||
Activities are processed by your Bincio instance instead — a connected account is required.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{watchPath ? (
|
||||
<View style={styles.watchBox}>
|
||||
<Text style={styles.watchLabel}>Watch folder</Text>
|
||||
@@ -305,9 +378,11 @@ export default function ImportScreen() {
|
||||
</Text>
|
||||
)}
|
||||
<Text style={[styles.statusMsg, { color: theme.accent }]}>{state.msg}</Text>
|
||||
<Text style={styles.statusHint}>
|
||||
First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant.
|
||||
</Text>
|
||||
{engineAvailable !== false && (
|
||||
<Text style={styles.statusHint}>
|
||||
First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -353,8 +428,10 @@ export default function ImportScreen() {
|
||||
|
||||
<View style={styles.notice}>
|
||||
<Text style={styles.noticeText}>
|
||||
FIT/GPX/TCX extraction runs entirely on your device.{'\n'}
|
||||
A Bincio instance must be reachable on first run to download the extraction engine (~35 MB, then cached).{'\n\n'}
|
||||
{engineAvailable === false
|
||||
? 'Activities are sent to your Bincio instance for extraction and stored there + locally. A connected account is required.'
|
||||
: `FIT/GPX/TCX extraction runs entirely on your device.\nA Bincio instance must be reachable on first run to download the extraction engine (~35 MB, then cached).`}
|
||||
{'\n\n'}
|
||||
On Karoo: set Watch directory to <Text style={styles.noticeCode}>/sdcard/FitFiles</Text> in Settings to auto-import rides.
|
||||
</Text>
|
||||
</View>
|
||||
@@ -469,6 +546,11 @@ const styles = StyleSheet.create({
|
||||
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 12 },
|
||||
body: { color: '#a1a1aa', fontSize: 14, lineHeight: 20, marginBottom: 24 },
|
||||
code: { color: '#60a5fa', fontFamily: 'monospace' },
|
||||
serverNotice: {
|
||||
backgroundColor: '#1c1400', borderRadius: 8, borderWidth: 1,
|
||||
borderColor: '#854d0e', padding: 12, marginBottom: 16,
|
||||
},
|
||||
serverNoticeText: { color: '#fbbf24', fontSize: 13, lineHeight: 18 },
|
||||
watchBox: {
|
||||
backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1,
|
||||
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 10,
|
||||
|
||||
+121
-48
@@ -1,42 +1,73 @@
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||
import { deleteActivities, useActivities, type ActivitySummary } from '@/db/queries';
|
||||
import { syncFeed } from '@/db/sync';
|
||||
import { downloadFeed, uploadFeed } from '@/db/sync';
|
||||
import { useTheme } from '@/ThemeContext';
|
||||
|
||||
export default function FeedScreen() {
|
||||
const db = useSQLiteContext();
|
||||
const theme = useTheme();
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const activities = useActivities();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [statusMsg, setStatusMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const selecting = selected.size > 0;
|
||||
|
||||
const doSync = useCallback(async () => {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
const result = await syncFeed(db);
|
||||
setSyncing(false);
|
||||
// Auto-refresh the local list whenever the tab comes into focus.
|
||||
// SQLite getAllSync is sub-millisecond — no network, no lag.
|
||||
useFocusEffect(useCallback(() => {
|
||||
setRefreshKey(k => k + 1);
|
||||
}, []));
|
||||
|
||||
function showMsg(ok: boolean, text: string) {
|
||||
setStatusMsg({ ok, text });
|
||||
setTimeout(() => setStatusMsg(null), 3500);
|
||||
}
|
||||
|
||||
const doDownload = useCallback(async () => {
|
||||
setDownloading(true);
|
||||
setStatusMsg(null);
|
||||
const result = await downloadFeed(db);
|
||||
setDownloading(false);
|
||||
setRefreshKey(k => k + 1);
|
||||
if (result.error) {
|
||||
setSyncMsg(result.error);
|
||||
showMsg(false, result.error);
|
||||
} else if (result.total === 0) {
|
||||
setSyncMsg('No activities on instance');
|
||||
} else if (result.synced === 0 && !result.fetched && !result.uploaded) {
|
||||
setSyncMsg(`Up to date (${result.total} activities)`);
|
||||
showMsg(true, 'No activities on instance');
|
||||
} else if (result.synced === 0 && !result.fetched) {
|
||||
showMsg(true, `Up to date (${result.total} activities)`);
|
||||
} else {
|
||||
const parts = [];
|
||||
if (result.synced > 0) parts.push(`${result.synced} new`);
|
||||
if (result.fetched) parts.push(`${result.fetched} full dataset${result.fetched === 1 ? '' : 's'}`);
|
||||
if (result.uploaded) parts.push(`${result.uploaded} uploaded`);
|
||||
setSyncMsg(`Synced: ${parts.join(', ')} (${result.total} total)`);
|
||||
if (result.fetched) parts.push(`${result.fetched} full dataset${result.fetched === 1 ? '' : 's'}`);
|
||||
showMsg(true, `Downloaded: ${parts.join(', ')} (${result.total} total)`);
|
||||
}
|
||||
setTimeout(() => setSyncMsg(null), 3500);
|
||||
}, [db]);
|
||||
|
||||
const doUpload = useCallback(async () => {
|
||||
setUploading(true);
|
||||
setStatusMsg(null);
|
||||
const result = await uploadFeed(db);
|
||||
setUploading(false);
|
||||
if (result.error) {
|
||||
showMsg(false, result.error);
|
||||
} else if (!result.uploaded) {
|
||||
showMsg(true, 'Nothing to upload');
|
||||
} else {
|
||||
showMsg(true, `Uploaded ${result.uploaded} activit${result.uploaded === 1 ? 'y' : 'ies'}`);
|
||||
}
|
||||
}, [db]);
|
||||
|
||||
function doRefresh() {
|
||||
setRefreshKey(k => k + 1);
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -45,9 +76,7 @@ export default function FeedScreen() {
|
||||
});
|
||||
}
|
||||
|
||||
function cancelSelect() {
|
||||
setSelected(new Set());
|
||||
}
|
||||
function cancelSelect() { setSelected(new Set()); }
|
||||
|
||||
function confirmDeleteSelected() {
|
||||
const count = selected.size;
|
||||
@@ -64,9 +93,7 @@ export default function FeedScreen() {
|
||||
const paths = await deleteActivities(db, ids);
|
||||
setSelected(new Set());
|
||||
for (const p of paths) {
|
||||
if (p) {
|
||||
try { await FileSystem.deleteAsync(p, { idempotent: true }); } catch {}
|
||||
}
|
||||
if (p) try { await FileSystem.deleteAsync(p, { idempotent: true }); } catch {}
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -74,6 +101,8 @@ export default function FeedScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
const busy = downloading || uploading;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.headerRow}>
|
||||
@@ -87,32 +116,56 @@ export default function FeedScreen() {
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.header}>Feed</Text>
|
||||
<Pressable
|
||||
style={[styles.syncButton, { backgroundColor: theme.dim }, syncing && styles.syncButtonDisabled]}
|
||||
onPress={syncing ? undefined : doSync}
|
||||
>
|
||||
<Text style={[styles.syncText, { color: theme.accent }]}>{syncing ? 'Syncing…' : '↓ Sync'}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.actionButtons}>
|
||||
<ActionButton
|
||||
icon="↑"
|
||||
label="Upload"
|
||||
loading={uploading}
|
||||
disabled={busy}
|
||||
accent={theme.accent}
|
||||
dim={theme.dim}
|
||||
onPress={doUpload}
|
||||
/>
|
||||
<ActionButton
|
||||
icon="↓"
|
||||
label="Download"
|
||||
loading={downloading}
|
||||
disabled={busy}
|
||||
accent={theme.accent}
|
||||
dim={theme.dim}
|
||||
onPress={doDownload}
|
||||
/>
|
||||
<ActionButton
|
||||
icon="↺"
|
||||
label="Refresh"
|
||||
loading={false}
|
||||
disabled={busy}
|
||||
accent={theme.accent}
|
||||
dim={theme.dim}
|
||||
onPress={doRefresh}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{syncMsg && (
|
||||
<Text style={styles.syncMsg}>{syncMsg}</Text>
|
||||
{statusMsg && (
|
||||
<Text style={statusMsg.ok ? styles.msgOk : styles.msgErr}>{statusMsg.text}</Text>
|
||||
)}
|
||||
|
||||
{activities.length === 0 && !syncing ? (
|
||||
{activities.length === 0 && !busy ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyIcon}>🚴</Text>
|
||||
<Text style={styles.emptyTitle}>No activities yet</Text>
|
||||
<Text style={styles.emptyBody}>
|
||||
Import a file or tap Sync to pull from your instance.
|
||||
Import a file or tap ↓ to pull from your instance.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={activities}
|
||||
keyExtractor={(a) => a.id}
|
||||
extraData={refreshKey}
|
||||
renderItem={({ item }) => (
|
||||
<ActivityCard
|
||||
activity={item}
|
||||
@@ -125,8 +178,8 @@ export default function FeedScreen() {
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={syncing}
|
||||
onRefresh={doSync}
|
||||
refreshing={false}
|
||||
onRefresh={doRefresh}
|
||||
tintColor="#60a5fa"
|
||||
/>
|
||||
}
|
||||
@@ -144,6 +197,30 @@ export default function FeedScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
icon, label, loading, disabled, accent, dim, onPress,
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
accent: string;
|
||||
dim: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.actionBtn, { backgroundColor: dim }, disabled && styles.actionBtnDisabled]}
|
||||
onPress={disabled ? undefined : onPress}
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
<Text style={[styles.actionBtnIcon, { color: loading ? '#52525b' : accent }]}>
|
||||
{loading ? '…' : icon}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityCard({
|
||||
activity,
|
||||
selecting,
|
||||
@@ -166,11 +243,8 @@ function ActivityCard({
|
||||
});
|
||||
|
||||
function handlePress() {
|
||||
if (selecting) {
|
||||
onToggleSelect();
|
||||
} else {
|
||||
router.push(`/activity/${activity.id}`);
|
||||
}
|
||||
if (selecting) onToggleSelect();
|
||||
else router.push(`/activity/${activity.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -228,21 +302,20 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
|
||||
},
|
||||
header: { color: '#fff', fontSize: 22, fontWeight: '700' },
|
||||
syncButton: {
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 14, paddingVertical: 7,
|
||||
actionButtons: { flexDirection: 'row', gap: 8 },
|
||||
actionBtn: {
|
||||
width: 36, height: 36, borderRadius: 8,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
syncButtonDisabled: { opacity: 0.5 },
|
||||
syncText: { fontSize: 13, fontWeight: '600' },
|
||||
actionBtnDisabled: { opacity: 0.4 },
|
||||
actionBtnIcon: { fontSize: 18, fontWeight: '700', lineHeight: 22 },
|
||||
cancelButton: {
|
||||
backgroundColor: '#27272a', borderRadius: 8,
|
||||
paddingHorizontal: 14, paddingVertical: 7,
|
||||
},
|
||||
cancelText: { color: '#a1a1aa', fontSize: 13, fontWeight: '600' },
|
||||
syncMsg: {
|
||||
color: '#a1a1aa', fontSize: 12, textAlign: 'center',
|
||||
paddingHorizontal: 16, paddingBottom: 8,
|
||||
},
|
||||
msgOk: { color: '#86efac', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
|
||||
msgErr: { color: '#fca5a5', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
|
||||
list: { padding: 16, gap: 12, paddingBottom: 80 },
|
||||
card: {
|
||||
backgroundColor: '#18181b', borderRadius: 12,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { deleteActivity, useActivity, useSetting } from '@/db/queries';
|
||||
@@ -161,16 +161,25 @@ export default function ActivityScreen() {
|
||||
|
||||
function RouteMap({ geojson, loading, accent }: { geojson: object | null; loading: boolean; accent: string }) {
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [currentZoom, setCurrentZoom] = useState(12);
|
||||
const cameraRef = useRef<any>(null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.mapPlaceholder}>
|
||||
<ActivityIndicator color={accent} />
|
||||
<Text style={{ color: accent, fontSize: 13 }}>Loading map…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!geojson) return null;
|
||||
|
||||
// MapLibre uses OpenGL/SurfaceView which crashes the Karoo's Qualcomm GPU
|
||||
// driver (Android <29) even without any interaction. Render a pure SVG route
|
||||
// trace instead — no native GL surface, no crash.
|
||||
if (Platform.OS === 'android' && (Platform.Version as number) < 29) {
|
||||
return <SvgRouteView geojson={geojson} accent={accent} />;
|
||||
}
|
||||
|
||||
const bounds = geoJsonBounds(geojson);
|
||||
const routeSource = (
|
||||
<GeoJSONSource id="route" data={geojson as GeoJSON.FeatureCollection}>
|
||||
@@ -182,28 +191,16 @@ function RouteMap({ geojson, loading, accent }: { geojson: object | null; loadin
|
||||
/>
|
||||
</GeoJSONSource>
|
||||
);
|
||||
const camera = bounds ? (
|
||||
<Camera
|
||||
initialViewState={{
|
||||
bounds,
|
||||
padding: { top: 24, bottom: 24, left: 24, right: 24 },
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
const cameraBounds = bounds
|
||||
? { bounds, padding: { top: 24, bottom: 24, left: 24, right: 24 } }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Thumbnail — tap to expand */}
|
||||
<Pressable style={styles.mapContainer} onPress={() => setFullscreen(true)}>
|
||||
<Map
|
||||
style={styles.map}
|
||||
mapStyle={MAP_STYLE}
|
||||
dragPan={false}
|
||||
touchZoom={false}
|
||||
touchPitch={false}
|
||||
touchRotate={false}
|
||||
>
|
||||
{camera}
|
||||
<Map style={styles.map} mapStyle={MAP_STYLE} dragPan={false} touchZoom={false} touchPitch={false} touchRotate={false}>
|
||||
{cameraBounds && <Camera initialViewState={cameraBounds} />}
|
||||
{routeSource}
|
||||
</Map>
|
||||
<View style={styles.mapExpandHint}>
|
||||
@@ -211,22 +208,89 @@ function RouteMap({ geojson, loading, accent }: { geojson: object | null; loadin
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Full-screen interactive map */}
|
||||
{/* Full-screen map with +/- zoom buttons */}
|
||||
<Modal visible={fullscreen} animationType="slide" onRequestClose={() => setFullscreen(false)}>
|
||||
<View style={styles.fullscreenMap}>
|
||||
<Map style={styles.map} mapStyle={MAP_STYLE}>
|
||||
{camera}
|
||||
<Map
|
||||
style={styles.map}
|
||||
mapStyle={MAP_STYLE}
|
||||
onRegionDidChange={(e: any) => {
|
||||
const z = e?.properties?.zoomLevel;
|
||||
if (typeof z === 'number') setCurrentZoom(z);
|
||||
}}
|
||||
>
|
||||
{cameraBounds && <Camera ref={cameraRef} initialViewState={cameraBounds} />}
|
||||
{routeSource}
|
||||
</Map>
|
||||
<Pressable style={styles.closeButton} onPress={() => setFullscreen(false)}>
|
||||
<Text style={styles.closeText}>✕</Text>
|
||||
</Pressable>
|
||||
<View style={styles.zoomButtons}>
|
||||
<Pressable style={styles.zoomBtn} onPress={() => cameraRef.current?.setCamera({ zoomLevel: currentZoom + 1, animationDuration: 200 })}>
|
||||
<Text style={styles.zoomBtnText}>+</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.zoomBtn} onPress={() => cameraRef.current?.setCamera({ zoomLevel: Math.max(1, currentZoom - 1), animationDuration: 200 })}>
|
||||
<Text style={styles.zoomBtnText}>−</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// SVG route trace — used on Android <29 where MapLibre crashes the GPU driver.
|
||||
// Renders the GPS track as a colored path on a dark background with no tiles.
|
||||
function SvgRouteView({ geojson, accent }: { geojson: object; accent: string }) {
|
||||
const W = 320;
|
||||
const H = 180;
|
||||
const PAD = 16;
|
||||
|
||||
const all: [number, number][] = [];
|
||||
function collect(obj: unknown) {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
const o = obj as Record<string, unknown>;
|
||||
if (o.type === 'Feature') { collect(o.geometry); return; }
|
||||
if (o.type === 'FeatureCollection') { (o.features as unknown[]).forEach(collect); return; }
|
||||
if (o.type === 'LineString') { all.push(...(o.coordinates as [number, number][])); return; }
|
||||
if (o.type === 'MultiLineString') { (o.coordinates as [number, number][][]).forEach(c => all.push(...c)); return; }
|
||||
}
|
||||
collect(geojson);
|
||||
if (!all.length) return null;
|
||||
|
||||
const step = Math.max(1, Math.floor(all.length / 500));
|
||||
const pts = all.filter((_, i) => i % step === 0);
|
||||
|
||||
const lons = pts.map(c => c[0]);
|
||||
const lats = pts.map(c => c[1]);
|
||||
const minLon = Math.min(...lons), maxLon = Math.max(...lons);
|
||||
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
|
||||
const spanLon = maxLon - minLon || 0.001;
|
||||
const spanLat = maxLat - minLat || 0.001;
|
||||
|
||||
// Correct longitude for latitude (equirectangular)
|
||||
const midLat = (minLat + maxLat) / 2;
|
||||
const lonFactor = Math.cos((midLat * Math.PI) / 180);
|
||||
const adjLon = spanLon * lonFactor;
|
||||
|
||||
const scale = Math.min((W - PAD * 2) / adjLon, (H - PAD * 2) / spanLat);
|
||||
const offX = (W - adjLon * scale) / 2;
|
||||
const offY = (H - spanLat * scale) / 2;
|
||||
|
||||
const toX = (lon: number) => offX + (lon - minLon) * lonFactor * scale;
|
||||
const toY = (lat: number) => H - offY - (lat - minLat) * scale;
|
||||
|
||||
const d = pts.map((c, i) => `${i === 0 ? 'M' : 'L'}${toX(c[0]).toFixed(1)},${toY(c[1]).toFixed(1)}`).join(' ');
|
||||
|
||||
return (
|
||||
<View style={[styles.mapContainer, { alignItems: 'center', justifyContent: 'center' }]}>
|
||||
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
|
||||
<Path d={d} fill="none" stroke={accent} strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</Svg>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Metric charts ─────────────────────────────────────────────────────────────
|
||||
|
||||
type TabKey = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power';
|
||||
@@ -245,7 +309,7 @@ function MetricCharts({ timeseries, loading, accent }: { timeseries: Timeseries
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.chartPlaceholder}>
|
||||
<ActivityIndicator color={accent} />
|
||||
<Text style={{ color: accent, fontSize: 13 }}>Loading chart…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -414,6 +478,9 @@ const styles = StyleSheet.create({
|
||||
fullscreenMap: { flex: 1, backgroundColor: '#09090b' },
|
||||
closeButton: { position: 'absolute', top: 56, right: 16, backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
|
||||
closeText: { color: '#fff', fontSize: 16 },
|
||||
zoomButtons: { position: 'absolute', bottom: 40, right: 16, gap: 8 },
|
||||
zoomBtn: { backgroundColor: 'rgba(0,0,0,0.65)', borderRadius: 20, width: 40, height: 40, alignItems: 'center', justifyContent: 'center' },
|
||||
zoomBtnText: { color: '#fff', fontSize: 22, fontWeight: '600', lineHeight: 28 },
|
||||
chartContainer: { marginHorizontal: 16, marginBottom: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', overflow: 'hidden' },
|
||||
chartPlaceholder: { height: 120, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', marginHorizontal: 16, marginBottom: 16 },
|
||||
chartTabs: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#27272a' },
|
||||
|
||||
+43
-39
@@ -1,4 +1,3 @@
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import type { SQLiteDatabase } from 'expo-sqlite';
|
||||
import { getSetting, upsertRemoteActivity } from './queries';
|
||||
|
||||
@@ -10,13 +9,17 @@ export type SyncResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||
async function resolveCredentials(db: SQLiteDatabase): Promise<{ instanceUrl: string; token: string } | { error: string }> {
|
||||
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
|
||||
const token = await getSetting(db, 'api_token');
|
||||
if (!instanceUrl || !token) return { error: 'No instance configured — add one in Settings.' };
|
||||
return { instanceUrl, token };
|
||||
}
|
||||
|
||||
if (!instanceUrl || !token) {
|
||||
return { synced: 0, total: 0, error: 'No instance configured — add one in Settings.' };
|
||||
}
|
||||
export async function downloadFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||
const creds = await resolveCredentials(db);
|
||||
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
|
||||
const { instanceUrl, token } = creds;
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
@@ -27,16 +30,11 @@ export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||
return { synced: 0, total: 0, error: 'Could not reach instance — check your connection.' };
|
||||
}
|
||||
|
||||
if (resp.status === 401) {
|
||||
return { synced: 0, total: 0, error: 'Session expired — reconnect in Settings.' };
|
||||
}
|
||||
if (!resp.ok) {
|
||||
return { synced: 0, total: 0, error: `Server error (${resp.status})` };
|
||||
}
|
||||
if (resp.status === 401) return { synced: 0, total: 0, error: 'Session expired — reconnect in Settings.' };
|
||||
if (!resp.ok) return { synced: 0, total: 0, error: `Server error (${resp.status})` };
|
||||
|
||||
const data: { activities?: RemoteSummary[] } = await resp.json();
|
||||
const activities = data.activities ?? [];
|
||||
|
||||
const syncMode = (await getSetting(db, 'sync_mode')) ?? 'summaries';
|
||||
|
||||
let synced = 0;
|
||||
@@ -57,18 +55,9 @@ export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||
if (changed) synced++;
|
||||
}
|
||||
|
||||
// Upload local activities to the server if enabled
|
||||
const uploadEnabled = (await getSetting(db, 'sync_upload')) === 'true';
|
||||
let uploaded = 0;
|
||||
if (uploadEnabled) {
|
||||
uploaded = await uploadLocalActivities(db, instanceUrl, token);
|
||||
}
|
||||
if (syncMode !== 'full') return { synced, total: activities.length };
|
||||
|
||||
if (syncMode !== 'full') {
|
||||
return { synced, total: activities.length, uploaded: uploaded || undefined };
|
||||
}
|
||||
|
||||
// Full mode: fetch geojson + timeseries for any activity missing them
|
||||
// Full mode: fetch geojson + timeseries for activities missing them
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
let fetched = 0;
|
||||
for (const a of activities) {
|
||||
@@ -94,7 +83,7 @@ export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||
if (gj !== null || ts !== null) {
|
||||
await db.runAsync(
|
||||
`UPDATE activities SET
|
||||
geojson = COALESCE(geojson, ?),
|
||||
geojson = COALESCE(geojson, ?),
|
||||
timeseries_json = COALESCE(timeseries_json, ?)
|
||||
WHERE id = ? AND origin = 'remote'`,
|
||||
[gj, ts, a.id],
|
||||
@@ -103,7 +92,30 @@ export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||
}
|
||||
}
|
||||
|
||||
return { synced, total: activities.length, fetched, uploaded: uploaded || undefined };
|
||||
return { synced, total: activities.length, fetched };
|
||||
}
|
||||
|
||||
export async function uploadFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||
const creds = await resolveCredentials(db);
|
||||
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
|
||||
const { instanceUrl, token } = creds;
|
||||
|
||||
const uploaded = await uploadLocalActivities(db, instanceUrl, token);
|
||||
return { synced: 0, total: 0, uploaded };
|
||||
}
|
||||
|
||||
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||
const dl = await downloadFeed(db);
|
||||
if (dl.error) return dl;
|
||||
|
||||
const uploadEnabled = (await getSetting(db, 'sync_upload')) === 'true';
|
||||
let uploaded = 0;
|
||||
if (uploadEnabled) {
|
||||
const ul = await uploadFeed(db);
|
||||
uploaded = ul.uploaded ?? 0;
|
||||
}
|
||||
|
||||
return { ...dl, uploaded: uploaded || undefined };
|
||||
}
|
||||
|
||||
async function uploadLocalActivities(
|
||||
@@ -111,8 +123,8 @@ async function uploadLocalActivities(
|
||||
instanceUrl: string,
|
||||
token: string,
|
||||
): Promise<number> {
|
||||
const rows = db.getAllSync<{ id: string; original_path: string | null; timeseries_json: string | null; geojson: string | null }>(
|
||||
`SELECT id, original_path, timeseries_json, geojson
|
||||
const rows = db.getAllSync<{ id: string; detail_json: string; timeseries_json: string | null; geojson: string | null }>(
|
||||
`SELECT id, detail_json, timeseries_json, geojson
|
||||
FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
|
||||
);
|
||||
|
||||
@@ -122,14 +134,9 @@ async function uploadLocalActivities(
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
let activity: object | null = null;
|
||||
|
||||
if (row.original_path) {
|
||||
const text = await FileSystem.readAsStringAsync(row.original_path);
|
||||
activity = JSON.parse(text);
|
||||
}
|
||||
|
||||
if (!activity) continue;
|
||||
const detail = JSON.parse(row.detail_json);
|
||||
// /api/upload/bas expects { activity: { id, ...detail }, timeseries?, geojson? }
|
||||
const activity = { id: row.id, ...detail };
|
||||
|
||||
const body: Record<string, unknown> = { activity };
|
||||
if (row.timeseries_json) body.timeseries = JSON.parse(row.timeseries_json);
|
||||
@@ -142,10 +149,7 @@ async function uploadLocalActivities(
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
await db.runAsync(
|
||||
`UPDATE activities SET synced_at = ? WHERE id = ?`,
|
||||
[now, row.id],
|
||||
);
|
||||
await db.runAsync(`UPDATE activities SET synced_at = ? WHERE id = ?`, [now, row.id]);
|
||||
uploaded++;
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -77,6 +77,14 @@ var initError = null;
|
||||
|
||||
(async function init() {
|
||||
try {
|
||||
// WebAssembly.Global was added in Chrome 69. Without it Pyodide cannot
|
||||
// initialise on any version. Bail out immediately so the mobile app can
|
||||
// fall back to server-side extraction without attempting a 35 MB download.
|
||||
if (typeof WebAssembly === 'undefined' || typeof WebAssembly.Global === 'undefined') {
|
||||
_post({ type: 'engine_unavailable', reason: 'wasm_global' });
|
||||
return;
|
||||
}
|
||||
|
||||
_post({ type: 'progress', msg: 'Loading Python runtime…' });
|
||||
|
||||
// Chrome <80 is missing features that modern Pyodide uses in its JS wrapper:
|
||||
@@ -108,7 +116,7 @@ var initError = null;
|
||||
var _pyResp = await fetch(_CDN_COMPAT + 'pyodide.js');
|
||||
if (!_pyResp.ok) throw new Error('Could not fetch pyodide.js (' + _pyResp.status + ')');
|
||||
var _pyCode = await _pyResp.text();
|
||||
_pyCode = 'var globalThis=typeof globalThis!=="undefined"?globalThis:self;\n' + _pyCode;
|
||||
_pyCode = 'var globalThis=typeof globalThis!=="undefined"?globalThis:self;\\n' + _pyCode;
|
||||
_pyCode = _pyCode.split('import(').join('__loadScript(');
|
||||
_pyCode = _pyCode.split('for await(').join('for(');
|
||||
await new Promise(function(res, rej) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRef } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import type WebView from 'react-native-webview';
|
||||
import type { WebViewMessageEvent } from 'react-native-webview';
|
||||
|
||||
@@ -25,11 +26,31 @@ let isExtracting = false;
|
||||
// Engine readiness — tracked so callers can wait before batching files.
|
||||
let _engineReady = false;
|
||||
let _engineError: string | null = null;
|
||||
// Android <29 (API 27 = Android 8.1, e.g. Karoo) ships with a system WebView
|
||||
// (Chrome <69) that lacks WebAssembly.Global, so Pyodide cannot run. Mounting
|
||||
// a WebView on those devices also causes GPU driver crashes (SurfaceView
|
||||
// conflicts). Skip the engine entirely and route to server extraction instead.
|
||||
let _engineUnavailable = Platform.OS === 'android' && (Platform.Version as number) < 29;
|
||||
const _engineResolvers: Array<() => void> = [];
|
||||
const _engineRejecters: Array<(e: Error) => void> = [];
|
||||
|
||||
// Init-phase progress listeners (messages sent before any extraction starts).
|
||||
const _progressListeners = new Set<(msg: string) => void>();
|
||||
export function onEngineProgress(cb: (msg: string) => void): () => void {
|
||||
_progressListeners.add(cb);
|
||||
return () => _progressListeners.delete(cb);
|
||||
}
|
||||
|
||||
export function isEngineAvailable(): boolean | null {
|
||||
// null = not yet determined; true = ready; false = unavailable
|
||||
if (_engineReady) return true;
|
||||
if (_engineUnavailable || _engineError) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function waitForEngine(timeoutMs = 300_000): Promise<void> {
|
||||
if (_engineReady) return Promise.resolve();
|
||||
if (_engineUnavailable) return Promise.reject(new Error('engine_unavailable'));
|
||||
if (_engineError) return Promise.reject(new Error(_engineError));
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -52,6 +73,10 @@ export function handleWebViewMessage(e: WebViewMessageEvent): void {
|
||||
_engineReady = true;
|
||||
_engineResolvers.splice(0).forEach(fn => fn());
|
||||
break;
|
||||
case 'engine_unavailable':
|
||||
_engineUnavailable = true;
|
||||
_engineRejecters.splice(0).forEach(fn => fn(new Error('engine_unavailable')));
|
||||
break;
|
||||
case 'init_error':
|
||||
_engineError = msg.message as string;
|
||||
_engineRejecters.splice(0).forEach(fn => fn(new Error(_engineError!)));
|
||||
@@ -75,7 +100,11 @@ export function handleWebViewMessage(e: WebViewMessageEvent): void {
|
||||
}
|
||||
break;
|
||||
case 'progress':
|
||||
p?.onStatus(msg.msg as string);
|
||||
if (p) {
|
||||
p.onStatus(msg.msg as string);
|
||||
} else {
|
||||
_progressListeners.forEach(fn => fn(msg.msg as string));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ExtractionResult } from './extractActivity';
|
||||
|
||||
export async function checkServerAuth(instanceUrl: string, token: string): Promise<void> {
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${instanceUrl}/api/feed`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch {
|
||||
throw new Error('Could not reach Bincio instance — check your connection.');
|
||||
}
|
||||
if (resp.status === 401) throw new Error('Session expired — reconnect in Settings.');
|
||||
if (!resp.ok) throw new Error(`Server error (${resp.status})`);
|
||||
}
|
||||
|
||||
export async function extractFileViaServer(
|
||||
filename: string,
|
||||
base64: string,
|
||||
instanceUrl: string,
|
||||
token: string,
|
||||
onStatus: (msg: string) => void = () => {},
|
||||
): Promise<ExtractionResult> {
|
||||
onStatus('Uploading to Bincio instance…');
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${instanceUrl}/api/upload/raw`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ filename, base64 }),
|
||||
});
|
||||
} catch {
|
||||
throw new Error('Could not reach Bincio instance — check your connection.');
|
||||
}
|
||||
|
||||
if (resp.status === 401) throw new Error('Session expired — reconnect in Settings.');
|
||||
if (resp.status === 422) {
|
||||
const body = await resp.json().catch(() => ({})) as { detail?: string };
|
||||
throw new Error(body.detail ?? 'Server could not process this file.');
|
||||
}
|
||||
if (!resp.ok) throw new Error(`Server error (${resp.status})`);
|
||||
|
||||
onStatus('Processing on server…');
|
||||
const data = await resp.json() as {
|
||||
ok: boolean;
|
||||
id: string;
|
||||
detail: object;
|
||||
timeseries: object | null;
|
||||
geojson: object | null;
|
||||
source_hash: string;
|
||||
};
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
detail: data.detail,
|
||||
timeseries: data.timeseries,
|
||||
geojson: data.geojson,
|
||||
sourceHash: data.source_hash,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user