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:
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user