feat(mobile): watch-folder scan button + Karoo file picker fix + docs

Import screen:
- Add "Scan for new rides" button (green) when auto_import_path is set;
  shows the configured path, lets user trigger manually in addition to
  the automatic scan on app open.
- Detect ActivityNotFoundException from DocumentPicker (Karoo and other
  stripped Android devices have no DocumentsUI app) and show a friendly
  message directing users to set a Watch directory instead.
- "No new rides found" feedback when manual scan finds nothing.

Docs (docs/mobile-app.md):
- Phase 1 marked complete with implementation notes (wheel delivery,
  timeseries workaround, source_path dedup).
- Phase 2 updated to reflect what is actually implemented (on-open scan,
  not background task) vs what remains (true background polling).
- Batch import moved from Phase 5 todo to done.
- Data model updated with source_path column.
- Known gaps section revised to remove fixed stubs.
- New Karoo sideloading section with debuggableVariants and armeabi-v7a
  troubleshooting notes.
This commit is contained in:
Davide Scaini
2026-04-25 21:52:03 +02:00
parent 749d90c79d
commit 44a70f4c18
2 changed files with 162 additions and 69 deletions
+63 -11
View File
@@ -3,7 +3,7 @@ import * as FileSystem from 'expo-file-system/legacy';
import { useSQLiteContext } from 'expo-sqlite';
import { 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 { insertActivity, isSourcePathImported, getSetting, useSetting } from '@/db/queries';
import { PyodideWebView } from '@/extraction/PyodideWebView';
import { extractFile } from '@/extraction/extractActivity';
import { useTheme } from '@/ThemeContext';
@@ -23,6 +23,7 @@ export default function ImportScreen() {
const theme = useTheme();
const [state, setState] = useState<ImportState>({ status: 'idle' });
const isImporting = useRef(false);
const watchPath = Platform.OS === 'android' ? (useSetting('auto_import_path') ?? '') : '';
// Auto-scan watch folder on mount and when app comes to foreground.
useEffect(() => {
@@ -38,10 +39,10 @@ export default function ImportScreen() {
async function runAutoScan() {
if (isImporting.current) return;
const watchPath = await getSetting(db, 'auto_import_path');
if (!watchPath) return;
const path = await getSetting(db, 'auto_import_path');
if (!path) return;
const newFiles = await discoverNewFiles(db, watchPath);
const newFiles = await discoverNewFiles(db, path);
if (newFiles.length === 0) return;
isImporting.current = true;
@@ -52,6 +53,26 @@ export default function ImportScreen() {
}
}
async function manualScan() {
if (isImporting.current) return;
const path = await getSetting(db, 'auto_import_path');
if (!path) return;
setState({ status: 'loading', msg: 'Scanning…', current: 0, total: 0 });
const newFiles = await discoverNewFiles(db, path);
if (newFiles.length === 0) {
setState({ status: 'done', count: 0, errors: [] });
return;
}
isImporting.current = true;
try {
await processBatch(newFiles.map(f => ({ uri: `file://${f}`, name: f.split('/').pop() ?? f, sourcePath: f })));
} finally {
isImporting.current = false;
}
}
async function pickFiles() {
if (isImporting.current) return;
setState({ status: 'loading', msg: 'Picking files…', current: 0, total: 0 });
@@ -72,8 +93,15 @@ export default function ImportScreen() {
isImporting.current = false;
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setState({ status: 'error', message: msg });
const raw = e instanceof Error ? e.message : String(e);
// Karoo and other stripped-down Android devices have no DocumentsUI app
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;
}
}
@@ -205,9 +233,23 @@ export default function ImportScreen() {
<Text style={styles.body}>
Import FIT, GPX, or TCX files extracted on your device, nothing uploaded.
You can also import pre-extracted BAS <Text style={[styles.code, { color: theme.accent }]}>.json</Text> files.
Select multiple files at once to import in batch.
</Text>
{watchPath ? (
<View style={styles.watchBox}>
<Text style={styles.watchLabel}>Watch folder</Text>
<Text style={styles.watchPath} numberOfLines={2}>{watchPath}</Text>
<Pressable
style={[styles.button, styles.buttonWatch, state.status === 'loading' && styles.buttonDisabled]}
onPress={state.status !== 'loading' ? manualScan : undefined}
>
<Text style={styles.buttonText}>
{state.status === 'loading' ? 'Working…' : '↺ Scan for new rides'}
</Text>
</Pressable>
</View>
) : null}
<Pressable
style={[styles.button, state.status === 'loading' && styles.buttonDisabled]}
onPress={state.status !== 'loading' ? pickFiles : undefined}
@@ -232,15 +274,17 @@ export default function ImportScreen() {
)}
{state.status === 'done' && (
<View style={styles.success}>
<View style={[styles.success, state.count === 0 && state.errors.length === 0 && styles.successEmpty]}>
<Text style={styles.successText}>
Imported {state.count} {state.count === 1 ? 'activity' : 'activities'}
{state.count === 0 && state.errors.length === 0
? 'No new rides found'
: `✓ Imported ${state.count} ${state.count === 1 ? 'activity' : 'activities'}`}
</Text>
{state.errors.map((e, i) => (
<Text key={i} style={styles.batchError}> {e.name}: {e.message}</Text>
))}
<Pressable onPress={() => setState({ status: 'idle' })}>
<Text style={styles.errorRetry}>Import more</Text>
<Text style={styles.errorRetry}>Dismiss</Text>
</Pressable>
</View>
)}
@@ -387,10 +431,17 @@ 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' },
watchBox: {
backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1,
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 8,
},
watchLabel: { color: '#71717a', fontSize: 11, fontWeight: '600', letterSpacing: 0.5 },
watchPath: { color: '#a1a1aa', fontSize: 13, fontFamily: 'monospace' },
button: {
backgroundColor: '#2563eb', borderRadius: 10,
paddingVertical: 14, alignItems: 'center', marginBottom: 16,
},
buttonWatch: { backgroundColor: '#16a34a', marginBottom: 0 },
buttonDisabled: { opacity: 0.5 },
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
statusBox: {
@@ -403,7 +454,8 @@ const styles = StyleSheet.create({
success: {
backgroundColor: '#14532d', borderRadius: 8, padding: 12, marginBottom: 16, gap: 6,
},
successText: { color: '#86efac', fontSize: 14 },
successEmpty: { backgroundColor: '#1c1c1e' },
successText: { color: '#86efac', fontSize: 14 },
batchError: { color: '#fca5a5', fontSize: 12 },
error: {
backgroundColor: '#450a0a', borderRadius: 8, padding: 12, marginBottom: 16, gap: 8,