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:
Davide Scaini
2026-04-26 21:00:12 +02:00
parent 4cabbea0d4
commit cbe3e0eeaf
10 changed files with 760 additions and 156 deletions
+121 -48
View File
@@ -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,