fix(mobile): watch-folder button visibility + Karoo file picker crash

- useFocusEffect re-reads auto_import_path from DB every time the Import
  tab is focused, so the scan button appears immediately after saving in
  Settings (was broken by conditional hook call which violated Rules of
  Hooks and never re-fired on setting changes)
- Nested inner try/catch isolates DocumentPicker.getDocumentAsync so
  ActivityNotFoundException (Karoo has no DocumentsUI) shows a friendly
  message instead of crashing the tab
- Settings watch-directory placeholder updated to /sdcard/FitFiles
This commit is contained in:
Davide Scaini
2026-04-25 22:08:32 +02:00
parent 44a70f4c18
commit e062ef5837
2 changed files with 49 additions and 25 deletions
+48 -24
View File
@@ -1,9 +1,10 @@
import * as DocumentPicker from 'expo-document-picker'; import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system/legacy';
import { useFocusEffect } from 'expo-router';
import { useSQLiteContext } from 'expo-sqlite'; import { useSQLiteContext } from 'expo-sqlite';
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { AppState, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { insertActivity, isSourcePathImported, getSetting, useSetting } from '@/db/queries'; import { insertActivity, isSourcePathImported, getSetting } from '@/db/queries';
import { PyodideWebView } from '@/extraction/PyodideWebView'; import { PyodideWebView } from '@/extraction/PyodideWebView';
import { extractFile } from '@/extraction/extractActivity'; import { extractFile } from '@/extraction/extractActivity';
import { useTheme } from '@/ThemeContext'; import { useTheme } from '@/ThemeContext';
@@ -22,8 +23,19 @@ export default function ImportScreen() {
const db = useSQLiteContext(); const db = useSQLiteContext();
const theme = useTheme(); const theme = useTheme();
const [state, setState] = useState<ImportState>({ status: 'idle' }); const [state, setState] = useState<ImportState>({ status: 'idle' });
const [watchPath, setWatchPath] = useState('');
const isImporting = useRef(false); const isImporting = useRef(false);
const watchPath = Platform.OS === 'android' ? (useSetting('auto_import_path') ?? '') : '';
// 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(() => {
if (Platform.OS !== 'android') return;
const row = db.getFirstSync<{ value: string }>(
'SELECT value FROM settings WHERE key = ?',
['auto_import_path'],
);
setWatchPath(row?.value ?? '');
}, [db]));
// Auto-scan watch folder on mount and when app comes to foreground. // Auto-scan watch folder on mount and when app comes to foreground.
useEffect(() => { useEffect(() => {
@@ -77,11 +89,26 @@ export default function ImportScreen() {
if (isImporting.current) return; if (isImporting.current) return;
setState({ status: 'loading', msg: 'Picking files…', current: 0, total: 0 }); setState({ status: 'loading', msg: 'Picking files…', current: 0, total: 0 });
try { try {
const result = await DocumentPicker.getDocumentAsync({ let result: DocumentPicker.DocumentPickerResult;
type: ['*/*'], try {
copyToCacheDirectory: true, result = await DocumentPicker.getDocumentAsync({
multiple: true, type: ['*/*'],
}); copyToCacheDirectory: true,
multiple: true,
});
} catch (pickerErr: unknown) {
// Some Android devices (e.g. Karoo) have no system file picker app.
const raw = pickerErr instanceof Error ? pickerErr.message : String(pickerErr);
const noApp = raw.includes('ActivityNotFoundException') || raw.includes('No Activity found');
setState({
status: 'error',
message: noApp
? 'No file picker available on this device. Set a Watch directory in Settings to import from a folder.'
: raw,
});
return;
}
if (result.canceled || !result.assets?.length) { if (result.canceled || !result.assets?.length) {
setState({ status: 'idle' }); setState({ status: 'idle' });
return; return;
@@ -93,15 +120,8 @@ export default function ImportScreen() {
isImporting.current = false; isImporting.current = false;
} }
} catch (e: unknown) { } catch (e: unknown) {
const raw = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
// Karoo and other stripped-down Android devices have no DocumentsUI app setState({ status: 'error', message: msg });
const noPickerAvailable = raw.includes('ActivityNotFoundException') || raw.includes('No Activity found');
setState({
status: 'error',
message: noPickerAvailable
? 'No file picker available on this device. Set a Watch directory in Settings to import from a folder automatically.'
: raw,
});
isImporting.current = false; isImporting.current = false;
} }
} }
@@ -240,7 +260,7 @@ export default function ImportScreen() {
<Text style={styles.watchLabel}>Watch folder</Text> <Text style={styles.watchLabel}>Watch folder</Text>
<Text style={styles.watchPath} numberOfLines={2}>{watchPath}</Text> <Text style={styles.watchPath} numberOfLines={2}>{watchPath}</Text>
<Pressable <Pressable
style={[styles.button, styles.buttonWatch, state.status === 'loading' && styles.buttonDisabled]} style={[styles.scanButton, state.status === 'loading' && styles.buttonDisabled]}
onPress={state.status !== 'loading' ? manualScan : undefined} onPress={state.status !== 'loading' ? manualScan : undefined}
> >
<Text style={styles.buttonText}> <Text style={styles.buttonText}>
@@ -317,7 +337,7 @@ export default function ImportScreen() {
<Text style={styles.noticeText}> <Text style={styles.noticeText}>
FIT/GPX/TCX extraction runs entirely on your device.{'\n'} 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'} A Bincio instance must be reachable on first run to download the extraction engine (~35 MB, then cached).{'\n\n'}
On Karoo or Android: set a Watch directory in Settings to auto-import new FIT files when the app opens. On Karoo: set Watch directory to <Text style={styles.noticeCode}>/sdcard/FitFiles</Text> in Settings to auto-import rides.
</Text> </Text>
</View> </View>
</ScrollView> </ScrollView>
@@ -433,15 +453,18 @@ const styles = StyleSheet.create({
code: { color: '#60a5fa', fontFamily: 'monospace' }, code: { color: '#60a5fa', fontFamily: 'monospace' },
watchBox: { watchBox: {
backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1,
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 8, borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 10,
},
watchLabel: { color: '#71717a', fontSize: 11, fontWeight: '600', letterSpacing: 0.5 },
watchPath: { color: '#a1a1aa', fontSize: 13, fontFamily: 'monospace' },
scanButton: {
backgroundColor: '#16a34a', borderRadius: 10,
paddingVertical: 14, alignItems: 'center',
}, },
watchLabel: { color: '#71717a', fontSize: 11, fontWeight: '600', letterSpacing: 0.5 },
watchPath: { color: '#a1a1aa', fontSize: 13, fontFamily: 'monospace' },
button: { button: {
backgroundColor: '#2563eb', borderRadius: 10, backgroundColor: '#2563eb', borderRadius: 10,
paddingVertical: 14, alignItems: 'center', marginBottom: 16, paddingVertical: 14, alignItems: 'center', marginBottom: 16,
}, },
buttonWatch: { backgroundColor: '#16a34a', marginBottom: 0 },
buttonDisabled: { opacity: 0.5 }, buttonDisabled: { opacity: 0.5 },
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 }, buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
statusBox: { statusBox: {
@@ -456,7 +479,7 @@ const styles = StyleSheet.create({
}, },
successEmpty: { backgroundColor: '#1c1c1e' }, successEmpty: { backgroundColor: '#1c1c1e' },
successText: { color: '#86efac', fontSize: 14 }, successText: { color: '#86efac', fontSize: 14 },
batchError: { color: '#fca5a5', fontSize: 12 }, batchError: { color: '#fca5a5', fontSize: 12 },
error: { error: {
backgroundColor: '#450a0a', borderRadius: 8, padding: 12, marginBottom: 16, gap: 8, backgroundColor: '#450a0a', borderRadius: 8, padding: 12, marginBottom: 16, gap: 8,
}, },
@@ -472,4 +495,5 @@ const styles = StyleSheet.create({
borderRadius: 8, padding: 12, borderWidth: 1, borderColor: '#27272a', borderRadius: 8, padding: 12, borderWidth: 1, borderColor: '#27272a',
}, },
noticeText: { color: '#71717a', fontSize: 12, lineHeight: 18 }, noticeText: { color: '#71717a', fontSize: 12, lineHeight: 18 },
noticeCode: { fontFamily: 'monospace', color: '#a1a1aa' },
}); });
+1 -1
View File
@@ -170,7 +170,7 @@ export default function SettingsScreen() {
<Section title="Auto-import (Android)"> <Section title="Auto-import (Android)">
<Field <Field
label="Watch directory" label="Watch directory"
placeholder="/sdcard/Karoo/Rides" placeholder="/sdcard/FitFiles"
value={autoPath} value={autoPath}
onChangeText={setAutoPath} onChangeText={setAutoPath}
autoCapitalize="none" autoCapitalize="none"