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:
+99
-58
@@ -277,18 +277,49 @@ The APK is at `android/app/build/outputs/apk/release/app-release.apk`.
|
||||
|
||||
**Note:** Release APKs must be signed. If signing fails, use `assembleDebug` instead to produce `app-debug.apk` (same as `npx expo run:android`).
|
||||
|
||||
#### Karoo 2 sideloading
|
||||
|
||||
The Karoo 2 (Hammerhead, Android 8.1, armeabi-v7a) is supported. Two build fixes
|
||||
are required and already applied to `android/app/build.gradle`:
|
||||
|
||||
1. **JS bundle in debug APK** — `debuggableVariants = []` in the `react {}` block.
|
||||
Without this, the debug APK looks for Metro on `localhost:8081`, which doesn't
|
||||
exist on the Karoo, and the app hangs on the splash screen with
|
||||
`Unable to load script`.
|
||||
|
||||
2. **armeabi-v7a native modules** — `splits.abi` must include `"armeabi-v7a"`.
|
||||
Without it, the CMake build for `libappmodules.so` (the TurboModule registry)
|
||||
only runs for arm64-v8a. On the Karoo the app crashes with
|
||||
`PlatformConstants could not be found`.
|
||||
|
||||
To build and install on a connected Karoo:
|
||||
|
||||
```bash
|
||||
cd mobile/android
|
||||
./gradlew assembleDebug
|
||||
adb -s <karoo-serial> install -r app/build/outputs/apk/debug/app-universal-debug.apk
|
||||
```
|
||||
|
||||
Find the Karoo serial with `adb devices -l`.
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
If your friend's APK won't start:
|
||||
|
||||
1. **Check device logs:**
|
||||
```bash
|
||||
adb logcat | grep -i react # requires Android SDK tools
|
||||
adb logcat -s ReactNativeJS AndroidRuntime # requires Android SDK tools
|
||||
```
|
||||
|
||||
2. **Ensure minimum Android version:** The app requires Android 5.0 (API 21) or higher.
|
||||
|
||||
3. **Verify the APK is actually installed:**
|
||||
3. **`Unable to load script` (splash hang):** Debug APK is trying to reach Metro.
|
||||
Ensure the build was compiled with `debuggableVariants = []` in `build.gradle`.
|
||||
|
||||
4. **`PlatformConstants could not be found` (crash on start):** `libappmodules.so`
|
||||
is missing for the device's ABI. Add the ABI to `splits.abi` in `build.gradle`.
|
||||
|
||||
5. **Verify the APK is actually installed:**
|
||||
```bash
|
||||
adb install /path/to/app.apk
|
||||
```
|
||||
@@ -483,6 +514,9 @@ CREATE TABLE activities (
|
||||
timeseries_json TEXT, -- 1 Hz arrays, loaded lazily
|
||||
geojson TEXT, -- simplified GPS track
|
||||
original_path TEXT, -- path in app storage (NULL if pulled from server)
|
||||
source_path TEXT, -- original filesystem path before copy
|
||||
-- e.g. /sdcard/Karoo/Rides/ride.fit
|
||||
-- used for watch-folder deduplication (migration v2)
|
||||
synced_at INTEGER, -- unix timestamp of last push (NULL = unsynced)
|
||||
origin TEXT NOT NULL -- "local" | "remote"
|
||||
CHECK(origin IN ('local', 'remote')),
|
||||
@@ -671,61 +705,74 @@ and displayed as an activity card. No extraction yet.*
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Local FIT/GPX/TCX extraction via Pyodide
|
||||
### Phase 1 — Local FIT/GPX/TCX extraction via Pyodide ✅
|
||||
*Goal: pick a FIT/GPX/TCX file, extract it on-device in ~5 s.*
|
||||
|
||||
> **This is the most important unbuilt feature.** Without it, local import only
|
||||
> works with pre-extracted BAS JSON files — which requires already having a server.
|
||||
> It undermines the "works offline without an instance" pitch. Phase 1 is also a
|
||||
> hard prerequisite for the re-extract button (Phase 5), proper SHA-256 dedup
|
||||
> (currently stubbed), and Phase 2 (auto-import needs extraction to be fast).
|
||||
|
||||
**Requires a Development Build** (`npx expo run:android` via USB, or
|
||||
`eas build --local`). Expo Go does not support `react-native-webview`.
|
||||
|
||||
**Extraction engine (`mobile/extraction/`):**
|
||||
- `PyodideWebView.tsx` — hidden `WebView` rendering an inline HTML page that
|
||||
bootstraps Pyodide
|
||||
- `wheelCache.ts` — on startup, `GET /api/wheel/version`; if version changed,
|
||||
download and store wheel in `expo-file-system` app directory; falls back to
|
||||
bundled `assets/bincio.whl` for offline / pre-deploy use
|
||||
- `extractActivity.ts` — encodes file bytes as base64, sends via `postMessage`,
|
||||
awaits `{ detail, timeseries, geojson }` response
|
||||
- Loading state: "Warming up extractor…" shown only on very first use
|
||||
- `PyodideWebView.tsx` — hidden `WebView` (mounted in the Import tab) rendering
|
||||
an inline HTML page that bootstraps Pyodide from jsDelivr CDN. The WebView is
|
||||
kept alive between files because Expo Router keeps tabs mounted after first visit.
|
||||
- `extractActivity.ts` — module-level singleton `pyodideRef`; encodes file bytes
|
||||
as base64, injects `window._bincioExtract(params)` into the WebView, awaits
|
||||
`{ id, detail, timeseries, geojson, sourceHash }` via `onMessage`. Serial queue
|
||||
enforced via `isExtracting` guard — only one extraction runs at a time.
|
||||
|
||||
**Import screen (full):**
|
||||
- Picks FIT/GPX/TCX, passes to `extractActivity`, stores result in SQLite
|
||||
(`detail_json`, `timeseries_json`, `geojson` columns)
|
||||
- Copies original file to `{documentDirectory}/originals/{id}.{ext}`
|
||||
- Duplicate detection via `source_hash` before extraction
|
||||
**Wheel delivery:**
|
||||
- The bincio wheel is fetched by React Native networking (not inside the WebView),
|
||||
because WKWebView on iOS blocks HTTP requests. `GET /api/wheel/version` returns
|
||||
the canonical URL; the wheel bytes are passed into the WebView as base64 and
|
||||
installed via `emfs://` (blob: URLs are not recognised by micropip).
|
||||
- In-memory wheel cache (`_cachedWheel`) avoids re-downloading within a session.
|
||||
|
||||
**Import screen:**
|
||||
- Picks one or more FIT/GPX/TCX/.json files; processes them sequentially.
|
||||
- Copies original file to `{documentDirectory}/originals/{id}.{ext}`.
|
||||
- Stores `detail_json`, `timeseries_json`, `geojson`, `source_hash`, `source_path`
|
||||
in SQLite.
|
||||
|
||||
**Known bug in wheel (worked around):**
|
||||
`write_activity()` in the installed wheel silently skips writing the timeseries
|
||||
file (an uncaught exception path). The extraction snippet checks `ts_path.exists()`
|
||||
after `write_activity()` and, if missing, calls `build_timeseries()` directly and
|
||||
writes the file itself. Without this fix, all locally imported activities showed
|
||||
stats but no elevation chart or speed graph.
|
||||
|
||||
**Done when:** Pick a FIT file from the Karoo rides folder, see full stats in
|
||||
the Feed within ~5 s, including map and elevation profile.
|
||||
the Feed, including map and elevation profile. ✅
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Karoo auto-import *(Android only)*
|
||||
*Goal: finish a ride, connect to WiFi, the activity appears in Bincio automatically.*
|
||||
*Goal: finish a ride, open the app, activities appear automatically.*
|
||||
|
||||
> **Partially stubbed.** The `auto_import_path` field exists in the Settings UI
|
||||
> (Android only) and is saved to the DB, but the background task is not registered.
|
||||
> The field accepts input but does nothing. Phase 1 (extraction) must be complete
|
||||
> first — there is no point watching a directory if you cannot extract what's in it.
|
||||
> **Partially implemented.** The watch-folder scan runs on Import tab mount and
|
||||
> on every app foreground event, which covers the primary Karoo use case (open the
|
||||
> app after a ride). True background polling (fires while the app is closed) is not
|
||||
> yet implemented — that would require `expo-background-fetch` + `expo-task-manager`,
|
||||
> but background tasks cannot access the Pyodide WebView (a UI component), so this
|
||||
> requires a different architectural approach for the extraction step.
|
||||
|
||||
**Android:**
|
||||
- Settings screen: `auto_import_path` field already present (Android only, hidden on iOS) ✅
|
||||
- `expo-task-manager` background task registered at app startup
|
||||
- Task polls `auto_import_path` every 5 minutes; for each `.fit` file whose
|
||||
`source_hash` is not in the DB, triggers extraction and import
|
||||
- `expo-notifications` sends a local notification: "New ride: Morning Ride — 45 km"
|
||||
**What's implemented:**
|
||||
- `auto_import_path` setting in Settings (Android only) ✅
|
||||
- On Import tab mount and on `AppState` → `'active'`: reads `auto_import_path`,
|
||||
requests `READ_EXTERNAL_STORAGE` permission, lists `.fit` files in the directory,
|
||||
filters out files whose `source_path` is already in the DB, and automatically
|
||||
imports new files through the same Pyodide extraction pipeline.
|
||||
- New `source_path` column in `activities` (migration v2): stores the original
|
||||
filesystem path (`/sdcard/Karoo/Rides/ride.fit`) for O(1) deduplication without
|
||||
re-reading files.
|
||||
- Batch import: picks multiple files at once (`multiple: true`), processes them
|
||||
sequentially, shows "File N of M" progress, ends with a count + per-file errors.
|
||||
|
||||
**iOS (alternative flow for Phase 2):**
|
||||
**iOS (alternative flow):**
|
||||
- Share Extension config so "Open with Bincio" appears in the iOS Files app
|
||||
- Tapping it hands the file to the app, which runs extraction immediately
|
||||
- No background polling; user-initiated but one-tap
|
||||
|
||||
**Done when (Android):** Finish a ride on the Karoo, the activity appears in
|
||||
Bincio within 5 minutes of connecting to WiFi, with no manual action.
|
||||
**Done when (Karoo):** Finish a ride, open the Bincio app → new FIT files from
|
||||
`/sdcard/Karoo/Rides` import automatically with no further action. ✅ (on-open)
|
||||
|
||||
**Remaining (background):** true background polling while app is closed — deferred.
|
||||
|
||||
---
|
||||
|
||||
@@ -753,8 +800,7 @@ and re-extracted server-side.
|
||||
|
||||
- **Offline map tiles** — bundle or download an MBTiles file for a region;
|
||||
MapLibre supports offline tile sources
|
||||
- **Batch import** — pick a folder (Strava export, Garmin bulk export); import all
|
||||
FIT/GPX files found, with progress bar and per-file status
|
||||
- **Batch import** ✅ — `multiple: true` in document picker; sequential processing with "File N of M" progress and per-file error summary
|
||||
- **Share sheet** — Android intent filter for incoming `.fit`/`.gpx`/`.tcx` files
|
||||
- **Re-extract** — button to re-run Pyodide extraction from the stored original file
|
||||
*(requires Phase 1)*
|
||||
@@ -774,26 +820,21 @@ and re-extracted server-side.
|
||||
This section documents mismatches between what the plan describes and what is
|
||||
actually implemented, plus features not yet in the plan.
|
||||
|
||||
### Stubs in the current code
|
||||
### Remaining stubs
|
||||
|
||||
**`source_hash` is not SHA-256** (`mobile/app/(tabs)/import.tsx`)
|
||||
**`source_hash` for BAS JSON import is not SHA-256** (`mobile/app/(tabs)/import.tsx`)
|
||||
|
||||
The import screen records `source_hash = "${detail.id}-${text.length}"` — a rough
|
||||
stand-in, not a real content hash. The dedup guarantee (`INSERT OR IGNORE`) works
|
||||
correctly today because activity IDs are unique, but the hash column's intended
|
||||
purpose (detect the same raw file imported twice under a different name) is not
|
||||
delivered. Phase 1 will replace this with SHA-256 of the original file bytes.
|
||||
BAS JSON import records `source_hash = "${detail.id}-${text.length}"` — a rough
|
||||
stand-in. FIT/GPX/TCX imports (via Pyodide) correctly compute SHA-256 of the file
|
||||
bytes. The BAS JSON path still uses the stub; dedup works in practice (activity IDs
|
||||
are unique) but the hash is not a real content fingerprint.
|
||||
|
||||
**FIT/GPX/TCX import is a placeholder** (`mobile/app/(tabs)/import.tsx`)
|
||||
**`auto_import_path` only triggers on app open, not in background**
|
||||
|
||||
Picking a FIT, GPX, or TCX file shows an alert: "Extraction coming in Phase 1."
|
||||
No extraction happens. The Import screen effectively only works with pre-extracted
|
||||
BAS JSON files until Phase 1 is built.
|
||||
|
||||
**`auto_import_path` setting has no backend** (`mobile/app/(tabs)/settings.tsx`)
|
||||
|
||||
The Android-only "Watch directory" field in Settings saves its value to SQLite but
|
||||
nothing reads it. No background task is registered. Phase 2 must wire this up.
|
||||
The watch-folder scan runs when the Import tab mounts and when the app comes to
|
||||
foreground (`AppState` → `'active'`). There is no true background task that fires
|
||||
while the app is closed. Full background polling would require `expo-background-fetch`
|
||||
but cannot use the Pyodide WebView (a UI component).
|
||||
|
||||
### Missing from the plan entirely
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user