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:
@@ -539,6 +539,109 @@ async def upload_bas_activity(
|
|||||||
return JSONResponse({"ok": True, "id": activity_id, "status": "imported"})
|
return JSONResponse({"ok": True, "id": activity_id, "status": "imported"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/upload/raw")
|
||||||
|
async def upload_raw_activity(
|
||||||
|
request: Request,
|
||||||
|
bincio_session: Optional[str] = Cookie(default=None),
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Accept a raw FIT/GPX file (base64-encoded) from the mobile app, extract it
|
||||||
|
server-side, store it in the user's activity library, and return the full
|
||||||
|
extracted data so the mobile can cache it locally.
|
||||||
|
|
||||||
|
Used when the device WebView is too old to run Pyodide (e.g. Karoo / Chrome <69).
|
||||||
|
|
||||||
|
Body (JSON):
|
||||||
|
filename – original filename (used only to determine file extension)
|
||||||
|
base64 – base64-encoded raw file bytes
|
||||||
|
|
||||||
|
Auth: Authorization: Bearer <token>
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"ok": true, "id": "...", "detail": {...}, "timeseries": {...}|null,
|
||||||
|
"geojson": {...}|null, "source_hash": "<sha256-hex>"}
|
||||||
|
"""
|
||||||
|
import base64 as _b64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
user = _require_auth(request, bincio_session)
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
filename_hint: str = body.get("filename") or "activity.fit"
|
||||||
|
b64: str = body.get("base64") or ""
|
||||||
|
if not b64:
|
||||||
|
raise HTTPException(400, "Missing base64 field")
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = _b64.b64decode(b64)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(400, "Invalid base64 encoding")
|
||||||
|
|
||||||
|
source_hash = hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
suffix = Path(filename_hint).suffix or ".fit"
|
||||||
|
tmp_in = Path(f"/tmp/bincio_raw_{uuid.uuid4()}{suffix}")
|
||||||
|
tmp_out = Path(f"/tmp/bincio_out_{uuid.uuid4()}")
|
||||||
|
try:
|
||||||
|
tmp_in.write_bytes(raw)
|
||||||
|
tmp_out.mkdir()
|
||||||
|
|
||||||
|
from bincio.extract.parsers.factory import parse_file
|
||||||
|
from bincio.extract.metrics import compute
|
||||||
|
from bincio.extract.writer import make_activity_id, write_activity
|
||||||
|
from bincio.extract.timeseries import build_timeseries
|
||||||
|
|
||||||
|
activity = parse_file(tmp_in)
|
||||||
|
metrics = compute(activity)
|
||||||
|
write_activity(activity, metrics, tmp_out, privacy="public", rdp_epsilon=0.0001)
|
||||||
|
act_id = make_activity_id(activity)
|
||||||
|
|
||||||
|
acts_tmp = tmp_out / "activities"
|
||||||
|
detail_path = acts_tmp / f"{act_id}.json"
|
||||||
|
ts_path = acts_tmp / f"{act_id}.timeseries.json"
|
||||||
|
geojson_path = acts_tmp / f"{act_id}.geojson"
|
||||||
|
|
||||||
|
if not ts_path.exists():
|
||||||
|
ts_data = build_timeseries(activity.points, activity.started_at, "public")
|
||||||
|
if ts_data.get("t"):
|
||||||
|
ts_path.write_text(json.dumps(ts_data))
|
||||||
|
|
||||||
|
detail = json.loads(detail_path.read_text())
|
||||||
|
timeseries = json.loads(ts_path.read_text()) if ts_path.exists() else None
|
||||||
|
geojson = json.loads(geojson_path.read_text()) if geojson_path.exists() else None
|
||||||
|
|
||||||
|
# Also store on the server so the activity appears in the user's feed.
|
||||||
|
user_dir = _get_data_dir() / user.handle
|
||||||
|
acts_dir = user_dir / "activities"
|
||||||
|
acts_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
out = acts_dir / f"{act_id}.json"
|
||||||
|
if not out.exists():
|
||||||
|
out.write_text(json.dumps(detail, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
if timeseries and not (acts_dir / f"{act_id}.timeseries.json").exists():
|
||||||
|
(acts_dir / f"{act_id}.timeseries.json").write_text(json.dumps(timeseries), encoding="utf-8")
|
||||||
|
if geojson and not (acts_dir / f"{act_id}.geojson").exists():
|
||||||
|
(acts_dir / f"{act_id}.geojson").write_text(json.dumps(geojson), encoding="utf-8")
|
||||||
|
|
||||||
|
from bincio.render.merge import merge_all
|
||||||
|
merge_all(user_dir)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("upload/raw[%s]: extraction failed: %s", user.handle, exc)
|
||||||
|
raise HTTPException(422, f"Could not extract activity: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
tmp_in.unlink(missing_ok=True)
|
||||||
|
shutil.rmtree(tmp_out, ignore_errors=True)
|
||||||
|
|
||||||
|
log.info("upload/raw[%s]: imported %s", user.handle, act_id)
|
||||||
|
return JSONResponse({
|
||||||
|
"ok": True,
|
||||||
|
"id": act_id,
|
||||||
|
"detail": detail,
|
||||||
|
"timeseries": timeseries,
|
||||||
|
"geojson": geojson,
|
||||||
|
"source_hash": source_hash,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/wheel/version")
|
@app.get("/api/wheel/version")
|
||||||
async def wheel_version() -> JSONResponse:
|
async def wheel_version() -> JSONResponse:
|
||||||
"""Public endpoint: current bincio wheel version for mobile app update checks."""
|
"""Public endpoint: current bincio wheel version for mobile app update checks."""
|
||||||
|
|||||||
+173
-14
@@ -473,6 +473,141 @@ the Python execution time only.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Karoo 2: hardware and OS constraints
|
||||||
|
|
||||||
|
The Karoo 2 (Hammerhead, Android 8.1 / API 27 / Chrome 61 WebView, armeabi-v7a) is the primary Android target that drove most of the implementation decisions in this app. It surfaces three independent hardware limitations that affect the app design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1 — No WebAssembly.Global (Chrome 61)
|
||||||
|
|
||||||
|
**Symptom:** `TypeError: WebAssembly.Global is not a constructor` in the Pyodide WebView shortly after mounting.
|
||||||
|
|
||||||
|
**Root cause:** `WebAssembly.Global` was added in Chrome 69 (Android 10 / API 29). Chrome 61 — the system WebView that ships with Android 8.1 — does not have it. Pyodide requires it for internal module linking. There is no JavaScript-level polyfill for this primitive; it must be provided by the JS engine.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
`PyodideWebView.tsx` checks for the primitive at init time before attempting the 30 MB Pyodide download:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (typeof WebAssembly === 'undefined' || typeof WebAssembly.Global === 'undefined') {
|
||||||
|
_post({ type: 'engine_unavailable', reason: 'wasm_global' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`extractActivity.ts` also gates at the module level using the API level so the WebView is not even mounted on old Android:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let _engineUnavailable = Platform.OS === 'android' && (Platform.Version as number) < 29;
|
||||||
|
```
|
||||||
|
|
||||||
|
The `isEngineAvailable()` export returns `true` (ready), `false` (unavailable/error), or `null` (still initialising). The Import screen uses this to decide the extraction path.
|
||||||
|
|
||||||
|
**Fallback:** server-side extraction (see next section).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2 — GPU driver crash (OpenGL / SurfaceView)
|
||||||
|
|
||||||
|
**Symptom:** `Fatal signal 11 (SIGSEGV), code 2, fault addr 0xa7xxxxxx in tid RenderThread` — the app crashes within seconds of mounting any component that creates a native OpenGL surface.
|
||||||
|
|
||||||
|
**Root cause:** SELinux enforces that sideloaded apps (`untrusted_app` context) cannot access `sysfs_kgsl` (the Qualcomm Adreno GPU sysfs interface):
|
||||||
|
|
||||||
|
```
|
||||||
|
avc: denied { search } for comm=RenderThread
|
||||||
|
scontext=u:r:untrusted_app:s0
|
||||||
|
tcontext=u:object_r:sysfs_kgsl:s0
|
||||||
|
tclass=dir permissive=0
|
||||||
|
```
|
||||||
|
|
||||||
|
When the GPU driver (kgsl) is denied its sysfs entry point, its internal initialisation corrupts memory — leading to the SIGSEGV in GPU memory (`0xa7xxxxxx`). The crash is **not triggered by touch gestures**; it happens as soon as the OpenGL surface is created and the driver starts.
|
||||||
|
|
||||||
|
This affects **any** component backed by a native GL surface, including:
|
||||||
|
|
||||||
|
| Component | What it creates | Status on Karoo |
|
||||||
|
|---|---|---|
|
||||||
|
| `react-native-webview` | SurfaceView (Chrome WebView) | Mount crashes GPU |
|
||||||
|
| `@maplibre/maplibre-react-native` | TextureView / SurfaceView | Render crashes GPU |
|
||||||
|
| `ActivityIndicator` | Native animated View | Crashes GPU |
|
||||||
|
|
||||||
|
The native Karoo system app is signed with Hammerhead's platform key, which grants it `platform_app` or `system_app` SELinux context — a context that IS allowed to access `sysfs_kgsl`. Third-party sideloaded apps cannot obtain this privilege without being re-signed by Hammerhead.
|
||||||
|
|
||||||
|
**Implementation — WebView:**
|
||||||
|
|
||||||
|
The Pyodide WebView is not mounted on Android < 29 (the same API level used as the proxy for "Chrome 61 / no WebAssembly.Global"). `_engineUnavailable` is set at module load time and `PyodideWebView` is conditionally excluded from the render tree:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{Platform.OS !== 'android' || (Platform.Version as number) >= 29
|
||||||
|
? <View style={styles.hiddenEngine}><PyodideWebView /></View>
|
||||||
|
: null}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation — MapLibre:**
|
||||||
|
|
||||||
|
`RouteMap` in `app/activity/[id].tsx` skips all MapLibre components on Android < 29 and renders a pure SVG route trace instead:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (Platform.OS === 'android' && (Platform.Version as number) < 29) {
|
||||||
|
return <SvgRouteView geojson={geojson} accent={accent} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`SvgRouteView` extracts the GPS coordinates from the GeoJSON, applies an equirectangular projection with cosine correction for latitude, downsamples to ≤500 points, and renders the route as an SVG `Path` via `react-native-svg`. No native surface, no GPU access, no crash.
|
||||||
|
|
||||||
|
**Implementation — ActivityIndicator:**
|
||||||
|
|
||||||
|
`ActivityIndicator` is a native animated component that also creates GPU-backed layers. It is not used anywhere in the app. All loading states use plain `<Text>` with `…`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3 — Server-side extraction fallback
|
||||||
|
|
||||||
|
When Pyodide cannot run (Android < 29 / Chrome 61), FIT/GPX/TCX files are extracted by the Bincio server instead of on-device.
|
||||||
|
|
||||||
|
**Server endpoint:** `POST /api/upload/raw`
|
||||||
|
|
||||||
|
Accepts JSON `{ filename: string, base64: string }`. The server decodes the file, runs the full Python extraction pipeline (including DEM correction), stores the result in the user's feed, and returns the extracted data:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"id": "2026-04-17T074238Z",
|
||||||
|
"detail": { … },
|
||||||
|
"timeseries": { … },
|
||||||
|
"geojson": { … },
|
||||||
|
"source_hash": "sha256:…"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client:** `extractFileViaServer()` in `mobile/extraction/extractServer.ts`. The Import screen routes to this function when `isEngineAvailable() === false`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (isEngineAvailable() === false) {
|
||||||
|
result = await extractFileViaServer(name, base64, instanceUrl, token, onStatus);
|
||||||
|
} else {
|
||||||
|
result = await extractFile(name, base64, wheelBase64, wheelFilename, onStatus);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trade-offs vs. local extraction:**
|
||||||
|
|
||||||
|
| | Local (Pyodide) | Server-side |
|
||||||
|
|---|---|---|
|
||||||
|
| Requires internet | No (after wheel cached) | Yes |
|
||||||
|
| Requires Bincio account | No | Yes |
|
||||||
|
| File leaves device | Never | Yes (over HTTPS to your instance) |
|
||||||
|
| DEM correction | No | Yes |
|
||||||
|
| Supported on Karoo | No (Chrome 61) | Yes |
|
||||||
|
|
||||||
|
**Pre-flight auth check:** before starting a batch import via the server path, the Import screen calls `checkServerAuth()` which hits `GET /api/feed` to verify the token is still valid. If the token is expired, the error is shown immediately — not after processing hundreds of files.
|
||||||
|
|
||||||
|
**UI notice:** the Import screen shows an amber banner when running in server-extraction mode:
|
||||||
|
|
||||||
|
> ⚠ Your Android version doesn't support on-device extraction. Files will be processed by your Bincio instance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Android vs iOS: platform divergences
|
## Android vs iOS: platform divergences
|
||||||
|
|
||||||
### Filesystem access
|
### Filesystem access
|
||||||
@@ -561,12 +696,17 @@ Implemented in `mobile/db/sync.ts` → `syncFeed()`.
|
|||||||
### Upload (local → server)
|
### Upload (local → server)
|
||||||
|
|
||||||
Implemented in `mobile/db/sync.ts` → `uploadLocalActivities()`. Enabled when
|
Implemented in `mobile/db/sync.ts` → `uploadLocalActivities()`. Enabled when
|
||||||
`sync_upload = "true"` in settings.
|
`sync_upload = "true"` in settings, or triggered explicitly via the ↑ Upload button.
|
||||||
|
|
||||||
1. Query `activities WHERE origin = 'local' AND synced_at IS NULL`.
|
1. Query `activities WHERE origin = 'local' AND synced_at IS NULL`.
|
||||||
2. For each: read the BAS JSON from `original_path` via `expo-file-system`.
|
2. For each: parse `detail_json` from the DB row and construct `{ id: row.id, ...detail }`.
|
||||||
3. `POST {instance_url}/api/upload/bas` with body `{ activity, timeseries?, geojson? }`.
|
3. `POST {instance_url}/api/upload/bas` with body `{ activity, timeseries?, geojson? }`.
|
||||||
4. On 200/duplicate: set `synced_at = unixepoch()`.
|
4. On 200 (including `{ status: "duplicate" }`): set `synced_at = unixepoch()`.
|
||||||
|
|
||||||
|
**Note:** `original_path` is not used in upload. An earlier implementation tried to read
|
||||||
|
`original_path` as a JSON file, but `original_path` stores the path to the original binary
|
||||||
|
FIT/GPX/TCX file — `JSON.parse()` always throws, silently skipping every activity. The correct
|
||||||
|
approach is to use the already-extracted `detail_json` stored in SQLite.
|
||||||
|
|
||||||
The server endpoint (`bincio/serve/server.py` → `POST /api/upload/bas`) accepts
|
The server endpoint (`bincio/serve/server.py` → `POST /api/upload/bas`) accepts
|
||||||
pre-extracted BAS JSON rather than raw FIT/GPX/TCX. It deduplicates by checking
|
pre-extracted BAS JSON rather than raw FIT/GPX/TCX. It deduplicates by checking
|
||||||
@@ -610,7 +750,8 @@ All endpoints require `Authorization: Bearer <token>` from the mobile client.
|
|||||||
| `GET` | `/api/feed` | All activity summaries for the authenticated user |
|
| `GET` | `/api/feed` | All activity summaries for the authenticated user |
|
||||||
| `GET` | `/api/activity/{id}/geojson` | GeoJSON route for one activity |
|
| `GET` | `/api/activity/{id}/geojson` | GeoJSON route for one activity |
|
||||||
| `GET` | `/api/activity/{id}/timeseries` | Timeseries JSON for one activity |
|
| `GET` | `/api/activity/{id}/timeseries` | Timeseries JSON for one activity |
|
||||||
| `POST` | `/api/upload/bas` | Upload a pre-extracted BAS JSON activity |
|
| `POST` | `/api/upload/bas` | Upload a pre-extracted BAS JSON activity (body: `{ activity, timeseries?, geojson? }`) |
|
||||||
|
| `POST` | `/api/upload/raw` | Upload a raw FIT/GPX/TCX file for server-side extraction (body: `{ filename, base64 }`); returns full extracted data |
|
||||||
| `GET` | `/api/wheel/version` | Latest bincio wheel version + URL (public) |
|
| `GET` | `/api/wheel/version` | Latest bincio wheel version + URL (public) |
|
||||||
| `GET` | `/api/wheel/download` | Serve the wheel file (dev mode fallback) |
|
| `GET` | `/api/wheel/download` | Serve the wheel file (dev mode fallback) |
|
||||||
|
|
||||||
@@ -642,10 +783,15 @@ and displayed as an activity card. No extraction yet.*
|
|||||||
all activity summaries as JSON
|
all activity summaries as JSON
|
||||||
- Settings screen: Connect section (password field + Connect button + status);
|
- Settings screen: Connect section (password field + Connect button + status);
|
||||||
Disconnect button clears the stored token
|
Disconnect button clears the stored token
|
||||||
- Feed screen: **↓ Sync** button and pull-to-refresh; "cloud" badge on remote
|
- Feed screen: three header buttons — **↑ Upload**, **↓ Download**, **↺ Refresh**;
|
||||||
activities; `syncFeed()` upserts remote summaries without overwriting local imports
|
pull-to-refresh; "cloud" badge on remote activities
|
||||||
|
- **↓ Download** calls `downloadFeed()` — pulls summaries (and full data in full mode)
|
||||||
|
- **↑ Upload** calls `uploadFeed()` — pushes unsynced local activities to the server
|
||||||
|
- **↺ Refresh** and pull-to-refresh: local-only SQLite re-read, no network call
|
||||||
|
- Auto-refresh on tab focus via `useFocusEffect`: increments `refreshKey` → FlatList
|
||||||
|
picks up newly imported activities without any user action
|
||||||
|
|
||||||
**Done when:** Tap Connect, tap Sync, all instance activities appear in the Feed. ✅
|
**Done when:** Tap Connect, tap ↓ Download, all instance activities appear in the Feed. ✅
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -655,15 +801,28 @@ and displayed as an activity card. No extraction yet.*
|
|||||||
> Phase 4 was implemented before Phase 1 because it only requires the Development
|
> Phase 4 was implemented before Phase 1 because it only requires the Development
|
||||||
> Build already needed for MapLibre — no Pyodide work required.
|
> Build already needed for MapLibre — no Pyodide work required.
|
||||||
|
|
||||||
**Map (MapLibre v11):**
|
**Map (MapLibre v11 — modern Android and iOS):**
|
||||||
- Dark CartoDB tile base map via `mapStyle` prop
|
- Dark CartoDB tile base map via `mapStyle` prop
|
||||||
- Route drawn as a GeoJSON `LineLayer` (`line-color: #60a5fa`, `line-width: 3`)
|
- Route drawn as a GeoJSON `LineLayer` (`line-color: #60a5fa`, `line-width: 3`)
|
||||||
- Camera auto-fits the track bounding box via `initialViewState.bounds`
|
- Camera auto-fits the track bounding box via `initialViewState.bounds`
|
||||||
- Thumbnail (non-interactive) shown inline; tap **⤢ tap to explore** to open a
|
- Thumbnail (non-interactive) shown inline; tap **⤢ tap to explore** to open a
|
||||||
full-screen modal with pan/zoom/pitch/rotate enabled
|
full-screen modal with pan/zoom/pitch/rotate enabled
|
||||||
|
- Full-screen modal: **+/−** zoom buttons (bottom-right corner) adjust zoom level
|
||||||
|
via `cameraRef.current?.setCamera({ zoomLevel: … })`. Current zoom tracked via
|
||||||
|
`onRegionDidChange`.
|
||||||
- On-demand fetch for remote activities: `GET /api/activity/{id}/geojson` with
|
- On-demand fetch for remote activities: `GET /api/activity/{id}/geojson` with
|
||||||
Bearer auth; result cached in memory for the session
|
Bearer auth; result cached in memory for the session
|
||||||
|
|
||||||
|
**Map (SVG route trace — Android < 29 / Karoo):**
|
||||||
|
- MapLibre is not rendered on Android < 29 — doing so crashes the GPU driver (see
|
||||||
|
*Karoo 2: hardware and OS constraints* above).
|
||||||
|
- `SvgRouteView` in `app/activity/[id].tsx` renders an SVG path using `react-native-svg`.
|
||||||
|
Coordinates are projected via equirectangular projection with cosine correction for
|
||||||
|
latitude, downsampled to ≤500 points. No native OpenGL surface is created.
|
||||||
|
- The visual is identical to what a GPS watch shows: the route shape as a coloured
|
||||||
|
trace on a dark background, without map tiles. No zoom is provided (no native
|
||||||
|
interaction surface, no crashes).
|
||||||
|
|
||||||
**Metric charts (react-native-svg):**
|
**Metric charts (react-native-svg):**
|
||||||
- Tabbed interface: Elevation / Speed / HR / Cadence / Power
|
- Tabbed interface: Elevation / Speed / HR / Cadence / Power
|
||||||
- Only tabs with non-null data are shown
|
- Only tabs with non-null data are shown
|
||||||
@@ -869,13 +1028,13 @@ sync flow.
|
|||||||
The app uses Expo's default purple icon and white splash. These need to be replaced
|
The app uses Expo's default purple icon and white splash. These need to be replaced
|
||||||
with Bincio branding before any public distribution.
|
with Bincio branding before any public distribution.
|
||||||
|
|
||||||
**Upload only works for activities imported from a file**
|
**Upload only pushes `origin = 'local'` activities**
|
||||||
|
|
||||||
`uploadLocalActivities()` skips rows where `original_path IS NULL`. Activities
|
`uploadLocalActivities()` queries `WHERE origin = 'local' AND synced_at IS NULL`.
|
||||||
synced from the server (`origin = 'remote'`) are never uploaded back even if
|
Activities pulled from the server (`origin = 'remote'`) are never re-uploaded — correct
|
||||||
upload is enabled. This is correct behaviour, but it also means a locally created
|
behaviour. A locally created activity from a future recording feature that lacks
|
||||||
activity that somehow lacks an `original_path` (e.g. from a future recording
|
`detail_json` would throw during `JSON.parse` and be silently skipped; worth checking
|
||||||
feature) would be silently skipped. Worth documenting as an invariant.
|
if a recording path is ever added.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+111
-29
@@ -6,7 +6,8 @@ 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 } from '@/db/queries';
|
import { insertActivity, isSourcePathImported, getSetting } from '@/db/queries';
|
||||||
import { PyodideWebView } from '@/extraction/PyodideWebView';
|
import { PyodideWebView } from '@/extraction/PyodideWebView';
|
||||||
import { extractFile, waitForEngine } from '@/extraction/extractActivity';
|
import { extractFile, waitForEngine, onEngineProgress, isEngineAvailable } from '@/extraction/extractActivity';
|
||||||
|
import { extractFileViaServer, checkServerAuth } from '@/extraction/extractServer';
|
||||||
import { useTheme } from '@/ThemeContext';
|
import { useTheme } from '@/ThemeContext';
|
||||||
|
|
||||||
const FIT_EXTENSIONS = ['.fit', '.fit.gz'];
|
const FIT_EXTENSIONS = ['.fit', '.fit.gz'];
|
||||||
@@ -24,8 +25,18 @@ export default function ImportScreen() {
|
|||||||
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 [watchPath, setWatchPath] = useState('');
|
||||||
|
const [engineAvailable, setEngineAvailable] = useState<boolean | null>(null);
|
||||||
const isImporting = useRef(false);
|
const isImporting = useRef(false);
|
||||||
|
|
||||||
|
// Track engine availability so we can show the server-extraction notice.
|
||||||
|
useEffect(() => {
|
||||||
|
waitForEngine(30_000)
|
||||||
|
.then(() => setEngineAvailable(true))
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
if (e instanceof Error && e.message === 'engine_unavailable') setEngineAvailable(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Reload watch path every time the Import tab comes into focus so changes
|
// Reload watch path every time the Import tab comes into focus so changes
|
||||||
// saved in Settings are picked up without remounting the tab.
|
// saved in Settings are picked up without remounting the tab.
|
||||||
useFocusEffect(useCallback(() => {
|
useFocusEffect(useCallback(() => {
|
||||||
@@ -56,8 +67,18 @@ export default function ImportScreen() {
|
|||||||
const instanceUrl = await getSetting(db, 'instance_url');
|
const instanceUrl = await getSetting(db, 'instance_url');
|
||||||
if (!instanceUrl) return;
|
if (!instanceUrl) return;
|
||||||
|
|
||||||
// Wait for the extraction engine — but don't block forever on auto-scan.
|
// Wait for engine — skip auto-scan on init failure, but continue if device is
|
||||||
try { await waitForEngine(120_000); } catch { return; }
|
// too old for local extraction (importNativeFile will use the server instead).
|
||||||
|
try { await waitForEngine(120_000); } catch (e: unknown) {
|
||||||
|
if (!(e instanceof Error) || e.message !== 'engine_unavailable') return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-mode requires a valid token — verify before touching any files.
|
||||||
|
if (isEngineAvailable() === false) {
|
||||||
|
const token = await getSetting(db, 'api_token');
|
||||||
|
if (!token) return;
|
||||||
|
try { await checkServerAuth(instanceUrl, token); } catch { return; }
|
||||||
|
}
|
||||||
|
|
||||||
const newFiles = await discoverNewFiles(db, path);
|
const newFiles = await discoverNewFiles(db, path);
|
||||||
if (newFiles.length === 0) return;
|
if (newFiles.length === 0) return;
|
||||||
@@ -80,12 +101,37 @@ export default function ImportScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState({ status: 'loading', msg: 'Preparing extraction engine…', current: 0, total: 0 });
|
const serverMode = isEngineAvailable() === false;
|
||||||
try {
|
if (!serverMode) {
|
||||||
await waitForEngine();
|
setState({ status: 'loading', msg: 'Preparing extraction engine…', current: 0, total: 0 });
|
||||||
} catch (e: unknown) {
|
const unsubScan = onEngineProgress((msg) =>
|
||||||
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
|
setState({ status: 'loading', msg, current: 0, total: 0 }),
|
||||||
return;
|
);
|
||||||
|
try {
|
||||||
|
await waitForEngine();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (!(e instanceof Error) || e.message !== 'engine_unavailable') {
|
||||||
|
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// engine_unavailable — fall through to server mode
|
||||||
|
} finally {
|
||||||
|
unsubScan();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const token = await getSetting(db, 'api_token');
|
||||||
|
if (!token) {
|
||||||
|
setState({ status: 'error', message: 'Server extraction requires a Bincio account. Connect in Settings.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Verify the token is valid before processing any files.
|
||||||
|
setState({ status: 'loading', msg: 'Checking connection…', current: 0, total: 0 });
|
||||||
|
try {
|
||||||
|
await checkServerAuth(instanceUrl, token);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState({ status: 'loading', msg: 'Scanning…', current: 0, total: 0 });
|
setState({ status: 'loading', msg: 'Scanning…', current: 0, total: 0 });
|
||||||
@@ -132,9 +178,13 @@ export default function ImportScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isImporting.current = true;
|
isImporting.current = true;
|
||||||
|
const unsubPick = onEngineProgress((msg) =>
|
||||||
|
setState({ status: 'loading', msg, current: 0, total: 0 }),
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await processBatch(result.assets.map(a => ({ uri: a.uri, name: a.name ?? '', sourcePath: null })));
|
await processBatch(result.assets.map(a => ({ uri: a.uri, name: a.name ?? '', sourcePath: null })));
|
||||||
} finally {
|
} finally {
|
||||||
|
unsubPick();
|
||||||
isImporting.current = false;
|
isImporting.current = false;
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -211,7 +261,7 @@ export default function ImportScreen() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FIT / GPX / TCX import via Pyodide extraction ──────────────────────────
|
// ── FIT / GPX / TCX import via Pyodide (local) or server fallback ───────────
|
||||||
|
|
||||||
async function importNativeFile(
|
async function importNativeFile(
|
||||||
uri: string,
|
uri: string,
|
||||||
@@ -221,20 +271,32 @@ export default function ImportScreen() {
|
|||||||
) {
|
) {
|
||||||
onStatus('Reading file…');
|
onStatus('Reading file…');
|
||||||
|
|
||||||
// Read the original file as base64 so we can (a) pass it to the WebView
|
// Read the original file as base64 so we can (a) pass it to the extractor
|
||||||
// and (b) copy it to permanent storage without a second read.
|
// and (b) copy it to permanent storage without a second read.
|
||||||
const base64 = await FileSystem.readAsStringAsync(uri, {
|
const base64 = await FileSystem.readAsStringAsync(uri, {
|
||||||
encoding: FileSystem.EncodingType.Base64,
|
encoding: FileSystem.EncodingType.Base64,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch the bincio wheel here (React Native networking), not inside the
|
let result;
|
||||||
// WebView. WKWebView blocks HTTP requests via ATS; RN native networking
|
|
||||||
// allows local-network HTTP (NSAllowsLocalNetworking=true in Info.plist).
|
|
||||||
const instanceUrl = await getInstanceUrl(db);
|
|
||||||
onStatus('Fetching Bincio engine…');
|
|
||||||
const { base64: wheelBase64, filename: wheelFilename } = await fetchWheelBase64(instanceUrl);
|
|
||||||
|
|
||||||
const result = await extractFile(name, base64, wheelBase64, wheelFilename, onStatus);
|
if (isEngineAvailable() === false) {
|
||||||
|
// Device WebView is too old for WebAssembly.Global (Chrome <69).
|
||||||
|
// Send the raw file to the Bincio instance for server-side extraction.
|
||||||
|
const instanceUrl = await getInstanceUrl(db);
|
||||||
|
const token = db.getFirstSync<{ value: string }>(
|
||||||
|
'SELECT value FROM settings WHERE key = ?', ['api_token'],
|
||||||
|
)?.value ?? '';
|
||||||
|
if (!token) throw new Error('Server extraction requires a Bincio account — connect in Settings.');
|
||||||
|
result = await extractFileViaServer(name, base64, instanceUrl, token, onStatus);
|
||||||
|
} else {
|
||||||
|
// Fetch the bincio wheel here (React Native networking), not inside the
|
||||||
|
// WebView. WKWebView blocks HTTP requests via ATS; RN native networking
|
||||||
|
// allows local-network HTTP (NSAllowsLocalNetworking=true in Info.plist).
|
||||||
|
const instanceUrl = await getInstanceUrl(db);
|
||||||
|
onStatus('Fetching Bincio engine…');
|
||||||
|
const { base64: wheelBase64, filename: wheelFilename } = await fetchWheelBase64(instanceUrl);
|
||||||
|
result = await extractFile(name, base64, wheelBase64, wheelFilename, onStatus);
|
||||||
|
}
|
||||||
|
|
||||||
onStatus('Saving…');
|
onStatus('Saving…');
|
||||||
|
|
||||||
@@ -259,12 +321,14 @@ export default function ImportScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.screen}>
|
<View style={styles.screen}>
|
||||||
{/* Hidden WebView for Pyodide — mounted here so it lives inside the tab
|
{/* Hidden WebView for Pyodide — only mounted on devices that can run it.
|
||||||
(Expo Router keeps tabs mounted after first visit, preserving Pyodide state).
|
Android <29 has a system WebView (Chrome <69) that lacks WebAssembly.Global
|
||||||
The 1×1 container clips it out of the scroll layout entirely. */}
|
AND causes GPU SurfaceView crashes on old drivers. Skip it entirely there. */}
|
||||||
<View style={styles.hiddenEngine}>
|
{(Platform.OS !== 'android' || (Platform.Version as number) >= 29) && (
|
||||||
<PyodideWebView />
|
<View style={styles.hiddenEngine}>
|
||||||
</View>
|
<PyodideWebView />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
<Text style={styles.header}>Import</Text>
|
<Text style={styles.header}>Import</Text>
|
||||||
|
|
||||||
@@ -273,6 +337,15 @@ export default function ImportScreen() {
|
|||||||
You can also import pre-extracted BAS <Text style={[styles.code, { color: theme.accent }]}>.json</Text> files.
|
You can also import pre-extracted BAS <Text style={[styles.code, { color: theme.accent }]}>.json</Text> files.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{engineAvailable === false && (
|
||||||
|
<View style={styles.serverNotice}>
|
||||||
|
<Text style={styles.serverNoticeText}>
|
||||||
|
This device's Android WebView is too old to run local extraction (requires Chrome 69+).
|
||||||
|
Activities are processed by your Bincio instance instead — a connected account is required.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{watchPath ? (
|
{watchPath ? (
|
||||||
<View style={styles.watchBox}>
|
<View style={styles.watchBox}>
|
||||||
<Text style={styles.watchLabel}>Watch folder</Text>
|
<Text style={styles.watchLabel}>Watch folder</Text>
|
||||||
@@ -305,9 +378,11 @@ export default function ImportScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text style={[styles.statusMsg, { color: theme.accent }]}>{state.msg}</Text>
|
<Text style={[styles.statusMsg, { color: theme.accent }]}>{state.msg}</Text>
|
||||||
<Text style={styles.statusHint}>
|
{engineAvailable !== false && (
|
||||||
First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant.
|
<Text style={styles.statusHint}>
|
||||||
</Text>
|
First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -353,8 +428,10 @@ export default function ImportScreen() {
|
|||||||
|
|
||||||
<View style={styles.notice}>
|
<View style={styles.notice}>
|
||||||
<Text style={styles.noticeText}>
|
<Text style={styles.noticeText}>
|
||||||
FIT/GPX/TCX extraction runs entirely on your device.{'\n'}
|
{engineAvailable === false
|
||||||
A Bincio instance must be reachable on first run to download the extraction engine (~35 MB, then cached).{'\n\n'}
|
? 'Activities are sent to your Bincio instance for extraction and stored there + locally. A connected account is required.'
|
||||||
|
: `FIT/GPX/TCX extraction runs entirely on your device.\nA Bincio instance must be reachable on first run to download the extraction engine (~35 MB, then cached).`}
|
||||||
|
{'\n\n'}
|
||||||
On Karoo: set Watch directory to <Text style={styles.noticeCode}>/sdcard/FitFiles</Text> in Settings to auto-import rides.
|
On Karoo: set Watch directory to <Text style={styles.noticeCode}>/sdcard/FitFiles</Text> in Settings to auto-import rides.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -469,6 +546,11 @@ const styles = StyleSheet.create({
|
|||||||
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 12 },
|
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 12 },
|
||||||
body: { color: '#a1a1aa', fontSize: 14, lineHeight: 20, marginBottom: 24 },
|
body: { color: '#a1a1aa', fontSize: 14, lineHeight: 20, marginBottom: 24 },
|
||||||
code: { color: '#60a5fa', fontFamily: 'monospace' },
|
code: { color: '#60a5fa', fontFamily: 'monospace' },
|
||||||
|
serverNotice: {
|
||||||
|
backgroundColor: '#1c1400', borderRadius: 8, borderWidth: 1,
|
||||||
|
borderColor: '#854d0e', padding: 12, marginBottom: 16,
|
||||||
|
},
|
||||||
|
serverNoticeText: { color: '#fbbf24', fontSize: 13, lineHeight: 18 },
|
||||||
watchBox: {
|
watchBox: {
|
||||||
backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1,
|
backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1,
|
||||||
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 10,
|
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 10,
|
||||||
|
|||||||
+121
-48
@@ -1,42 +1,73 @@
|
|||||||
import * as FileSystem from 'expo-file-system';
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import { useFocusEffect } from 'expo-router';
|
||||||
import { useSQLiteContext } from 'expo-sqlite';
|
import { useSQLiteContext } from 'expo-sqlite';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||||
import { deleteActivities, useActivities, type ActivitySummary } from '@/db/queries';
|
import { deleteActivities, useActivities, type ActivitySummary } from '@/db/queries';
|
||||||
import { syncFeed } from '@/db/sync';
|
import { downloadFeed, uploadFeed } from '@/db/sync';
|
||||||
import { useTheme } from '@/ThemeContext';
|
import { useTheme } from '@/ThemeContext';
|
||||||
|
|
||||||
export default function FeedScreen() {
|
export default function FeedScreen() {
|
||||||
const db = useSQLiteContext();
|
const db = useSQLiteContext();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const activities = useActivities();
|
const activities = useActivities();
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [statusMsg, setStatusMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const selecting = selected.size > 0;
|
const selecting = selected.size > 0;
|
||||||
|
|
||||||
const doSync = useCallback(async () => {
|
// Auto-refresh the local list whenever the tab comes into focus.
|
||||||
setSyncing(true);
|
// SQLite getAllSync is sub-millisecond — no network, no lag.
|
||||||
setSyncMsg(null);
|
useFocusEffect(useCallback(() => {
|
||||||
const result = await syncFeed(db);
|
setRefreshKey(k => k + 1);
|
||||||
setSyncing(false);
|
}, []));
|
||||||
|
|
||||||
|
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) {
|
if (result.error) {
|
||||||
setSyncMsg(result.error);
|
showMsg(false, result.error);
|
||||||
} else if (result.total === 0) {
|
} else if (result.total === 0) {
|
||||||
setSyncMsg('No activities on instance');
|
showMsg(true, 'No activities on instance');
|
||||||
} else if (result.synced === 0 && !result.fetched && !result.uploaded) {
|
} else if (result.synced === 0 && !result.fetched) {
|
||||||
setSyncMsg(`Up to date (${result.total} activities)`);
|
showMsg(true, `Up to date (${result.total} activities)`);
|
||||||
} else {
|
} else {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (result.synced > 0) parts.push(`${result.synced} new`);
|
if (result.synced > 0) parts.push(`${result.synced} new`);
|
||||||
if (result.fetched) parts.push(`${result.fetched} full dataset${result.fetched === 1 ? '' : 's'}`);
|
if (result.fetched) parts.push(`${result.fetched} full dataset${result.fetched === 1 ? '' : 's'}`);
|
||||||
if (result.uploaded) parts.push(`${result.uploaded} uploaded`);
|
showMsg(true, `Downloaded: ${parts.join(', ')} (${result.total} total)`);
|
||||||
setSyncMsg(`Synced: ${parts.join(', ')} (${result.total} total)`);
|
|
||||||
}
|
}
|
||||||
setTimeout(() => setSyncMsg(null), 3500);
|
|
||||||
}, [db]);
|
}, [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) {
|
function toggleSelect(id: string) {
|
||||||
setSelected(prev => {
|
setSelected(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -45,9 +76,7 @@ export default function FeedScreen() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelSelect() {
|
function cancelSelect() { setSelected(new Set()); }
|
||||||
setSelected(new Set());
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDeleteSelected() {
|
function confirmDeleteSelected() {
|
||||||
const count = selected.size;
|
const count = selected.size;
|
||||||
@@ -64,9 +93,7 @@ export default function FeedScreen() {
|
|||||||
const paths = await deleteActivities(db, ids);
|
const paths = await deleteActivities(db, ids);
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
for (const p of paths) {
|
for (const p of paths) {
|
||||||
if (p) {
|
if (p) try { await FileSystem.deleteAsync(p, { idempotent: true }); } catch {}
|
||||||
try { await FileSystem.deleteAsync(p, { idempotent: true }); } catch {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -74,6 +101,8 @@ export default function FeedScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const busy = downloading || uploading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.headerRow}>
|
<View style={styles.headerRow}>
|
||||||
@@ -87,32 +116,56 @@ export default function FeedScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.header}>Feed</Text>
|
<Text style={styles.header}>Feed</Text>
|
||||||
<Pressable
|
<View style={styles.actionButtons}>
|
||||||
style={[styles.syncButton, { backgroundColor: theme.dim }, syncing && styles.syncButtonDisabled]}
|
<ActionButton
|
||||||
onPress={syncing ? undefined : doSync}
|
icon="↑"
|
||||||
>
|
label="Upload"
|
||||||
<Text style={[styles.syncText, { color: theme.accent }]}>{syncing ? 'Syncing…' : '↓ Sync'}</Text>
|
loading={uploading}
|
||||||
</Pressable>
|
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>
|
</View>
|
||||||
|
|
||||||
{syncMsg && (
|
{statusMsg && (
|
||||||
<Text style={styles.syncMsg}>{syncMsg}</Text>
|
<Text style={statusMsg.ok ? styles.msgOk : styles.msgErr}>{statusMsg.text}</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activities.length === 0 && !syncing ? (
|
{activities.length === 0 && !busy ? (
|
||||||
<View style={styles.empty}>
|
<View style={styles.empty}>
|
||||||
<Text style={styles.emptyIcon}>🚴</Text>
|
<Text style={styles.emptyIcon}>🚴</Text>
|
||||||
<Text style={styles.emptyTitle}>No activities yet</Text>
|
<Text style={styles.emptyTitle}>No activities yet</Text>
|
||||||
<Text style={styles.emptyBody}>
|
<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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={activities}
|
data={activities}
|
||||||
keyExtractor={(a) => a.id}
|
keyExtractor={(a) => a.id}
|
||||||
|
extraData={refreshKey}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<ActivityCard
|
<ActivityCard
|
||||||
activity={item}
|
activity={item}
|
||||||
@@ -125,8 +178,8 @@ export default function FeedScreen() {
|
|||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={syncing}
|
refreshing={false}
|
||||||
onRefresh={doSync}
|
onRefresh={doRefresh}
|
||||||
tintColor="#60a5fa"
|
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({
|
function ActivityCard({
|
||||||
activity,
|
activity,
|
||||||
selecting,
|
selecting,
|
||||||
@@ -166,11 +243,8 @@ function ActivityCard({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handlePress() {
|
function handlePress() {
|
||||||
if (selecting) {
|
if (selecting) onToggleSelect();
|
||||||
onToggleSelect();
|
else router.push(`/activity/${activity.id}`);
|
||||||
} else {
|
|
||||||
router.push(`/activity/${activity.id}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -228,21 +302,20 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
|
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
|
||||||
},
|
},
|
||||||
header: { color: '#fff', fontSize: 22, fontWeight: '700' },
|
header: { color: '#fff', fontSize: 22, fontWeight: '700' },
|
||||||
syncButton: {
|
actionButtons: { flexDirection: 'row', gap: 8 },
|
||||||
borderRadius: 8,
|
actionBtn: {
|
||||||
paddingHorizontal: 14, paddingVertical: 7,
|
width: 36, height: 36, borderRadius: 8,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
},
|
},
|
||||||
syncButtonDisabled: { opacity: 0.5 },
|
actionBtnDisabled: { opacity: 0.4 },
|
||||||
syncText: { fontSize: 13, fontWeight: '600' },
|
actionBtnIcon: { fontSize: 18, fontWeight: '700', lineHeight: 22 },
|
||||||
cancelButton: {
|
cancelButton: {
|
||||||
backgroundColor: '#27272a', borderRadius: 8,
|
backgroundColor: '#27272a', borderRadius: 8,
|
||||||
paddingHorizontal: 14, paddingVertical: 7,
|
paddingHorizontal: 14, paddingVertical: 7,
|
||||||
},
|
},
|
||||||
cancelText: { color: '#a1a1aa', fontSize: 13, fontWeight: '600' },
|
cancelText: { color: '#a1a1aa', fontSize: 13, fontWeight: '600' },
|
||||||
syncMsg: {
|
msgOk: { color: '#86efac', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
|
||||||
color: '#a1a1aa', fontSize: 12, textAlign: 'center',
|
msgErr: { color: '#fca5a5', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
|
||||||
paddingHorizontal: 16, paddingBottom: 8,
|
|
||||||
},
|
|
||||||
list: { padding: 16, gap: 12, paddingBottom: 80 },
|
list: { padding: 16, gap: 12, paddingBottom: 80 },
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: '#18181b', borderRadius: 12,
|
backgroundColor: '#18181b', borderRadius: 12,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native';
|
import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native';
|
||||||
import * as FileSystem from 'expo-file-system';
|
import * as FileSystem from 'expo-file-system';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { ActivityIndicator, Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { Alert, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
|
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
|
||||||
import { useSQLiteContext } from 'expo-sqlite';
|
import { useSQLiteContext } from 'expo-sqlite';
|
||||||
import { deleteActivity, useActivity, useSetting } from '@/db/queries';
|
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 }) {
|
function RouteMap({ geojson, loading, accent }: { geojson: object | null; loading: boolean; accent: string }) {
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
const [currentZoom, setCurrentZoom] = useState(12);
|
||||||
|
const cameraRef = useRef<any>(null);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.mapPlaceholder}>
|
<View style={styles.mapPlaceholder}>
|
||||||
<ActivityIndicator color={accent} />
|
<Text style={{ color: accent, fontSize: 13 }}>Loading map…</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!geojson) return null;
|
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 bounds = geoJsonBounds(geojson);
|
||||||
const routeSource = (
|
const routeSource = (
|
||||||
<GeoJSONSource id="route" data={geojson as GeoJSON.FeatureCollection}>
|
<GeoJSONSource id="route" data={geojson as GeoJSON.FeatureCollection}>
|
||||||
@@ -182,28 +191,16 @@ function RouteMap({ geojson, loading, accent }: { geojson: object | null; loadin
|
|||||||
/>
|
/>
|
||||||
</GeoJSONSource>
|
</GeoJSONSource>
|
||||||
);
|
);
|
||||||
const camera = bounds ? (
|
const cameraBounds = bounds
|
||||||
<Camera
|
? { bounds, padding: { top: 24, bottom: 24, left: 24, right: 24 } }
|
||||||
initialViewState={{
|
: undefined;
|
||||||
bounds,
|
|
||||||
padding: { top: 24, bottom: 24, left: 24, right: 24 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Thumbnail — tap to expand */}
|
{/* Thumbnail — tap to expand */}
|
||||||
<Pressable style={styles.mapContainer} onPress={() => setFullscreen(true)}>
|
<Pressable style={styles.mapContainer} onPress={() => setFullscreen(true)}>
|
||||||
<Map
|
<Map style={styles.map} mapStyle={MAP_STYLE} dragPan={false} touchZoom={false} touchPitch={false} touchRotate={false}>
|
||||||
style={styles.map}
|
{cameraBounds && <Camera initialViewState={cameraBounds} />}
|
||||||
mapStyle={MAP_STYLE}
|
|
||||||
dragPan={false}
|
|
||||||
touchZoom={false}
|
|
||||||
touchPitch={false}
|
|
||||||
touchRotate={false}
|
|
||||||
>
|
|
||||||
{camera}
|
|
||||||
{routeSource}
|
{routeSource}
|
||||||
</Map>
|
</Map>
|
||||||
<View style={styles.mapExpandHint}>
|
<View style={styles.mapExpandHint}>
|
||||||
@@ -211,22 +208,89 @@ function RouteMap({ geojson, loading, accent }: { geojson: object | null; loadin
|
|||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{/* Full-screen interactive map */}
|
{/* Full-screen map with +/- zoom buttons */}
|
||||||
<Modal visible={fullscreen} animationType="slide" onRequestClose={() => setFullscreen(false)}>
|
<Modal visible={fullscreen} animationType="slide" onRequestClose={() => setFullscreen(false)}>
|
||||||
<View style={styles.fullscreenMap}>
|
<View style={styles.fullscreenMap}>
|
||||||
<Map style={styles.map} mapStyle={MAP_STYLE}>
|
<Map
|
||||||
{camera}
|
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}
|
{routeSource}
|
||||||
</Map>
|
</Map>
|
||||||
<Pressable style={styles.closeButton} onPress={() => setFullscreen(false)}>
|
<Pressable style={styles.closeButton} onPress={() => setFullscreen(false)}>
|
||||||
<Text style={styles.closeText}>✕</Text>
|
<Text style={styles.closeText}>✕</Text>
|
||||||
</Pressable>
|
</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>
|
</View>
|
||||||
</Modal>
|
</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 ─────────────────────────────────────────────────────────────
|
// ── Metric charts ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type TabKey = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power';
|
type TabKey = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power';
|
||||||
@@ -245,7 +309,7 @@ function MetricCharts({ timeseries, loading, accent }: { timeseries: Timeseries
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.chartPlaceholder}>
|
<View style={styles.chartPlaceholder}>
|
||||||
<ActivityIndicator color={accent} />
|
<Text style={{ color: accent, fontSize: 13 }}>Loading chart…</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -414,6 +478,9 @@ const styles = StyleSheet.create({
|
|||||||
fullscreenMap: { flex: 1, backgroundColor: '#09090b' },
|
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' },
|
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 },
|
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' },
|
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 },
|
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' },
|
chartTabs: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#27272a' },
|
||||||
|
|||||||
+43
-39
@@ -1,4 +1,3 @@
|
|||||||
import * as FileSystem from 'expo-file-system/legacy';
|
|
||||||
import type { SQLiteDatabase } from 'expo-sqlite';
|
import type { SQLiteDatabase } from 'expo-sqlite';
|
||||||
import { getSetting, upsertRemoteActivity } from './queries';
|
import { getSetting, upsertRemoteActivity } from './queries';
|
||||||
|
|
||||||
@@ -10,13 +9,17 @@ export type SyncResult = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
async function resolveCredentials(db: SQLiteDatabase): Promise<{ instanceUrl: string; token: string } | { error: string }> {
|
||||||
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
|
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
|
||||||
const token = await getSetting(db, 'api_token');
|
const token = await getSetting(db, 'api_token');
|
||||||
|
if (!instanceUrl || !token) return { error: 'No instance configured — add one in Settings.' };
|
||||||
|
return { instanceUrl, token };
|
||||||
|
}
|
||||||
|
|
||||||
if (!instanceUrl || !token) {
|
export async function downloadFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||||
return { synced: 0, total: 0, error: 'No instance configured — add one in Settings.' };
|
const creds = await resolveCredentials(db);
|
||||||
}
|
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
|
||||||
|
const { instanceUrl, token } = creds;
|
||||||
|
|
||||||
let resp: Response;
|
let resp: Response;
|
||||||
try {
|
try {
|
||||||
@@ -27,16 +30,11 @@ export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
|||||||
return { synced: 0, total: 0, error: 'Could not reach instance — check your connection.' };
|
return { synced: 0, total: 0, error: 'Could not reach instance — check your connection.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.status === 401) {
|
if (resp.status === 401) return { synced: 0, total: 0, error: 'Session expired — reconnect in Settings.' };
|
||||||
return { synced: 0, total: 0, error: 'Session expired — reconnect in Settings.' };
|
if (!resp.ok) return { synced: 0, total: 0, error: `Server error (${resp.status})` };
|
||||||
}
|
|
||||||
if (!resp.ok) {
|
|
||||||
return { synced: 0, total: 0, error: `Server error (${resp.status})` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: { activities?: RemoteSummary[] } = await resp.json();
|
const data: { activities?: RemoteSummary[] } = await resp.json();
|
||||||
const activities = data.activities ?? [];
|
const activities = data.activities ?? [];
|
||||||
|
|
||||||
const syncMode = (await getSetting(db, 'sync_mode')) ?? 'summaries';
|
const syncMode = (await getSetting(db, 'sync_mode')) ?? 'summaries';
|
||||||
|
|
||||||
let synced = 0;
|
let synced = 0;
|
||||||
@@ -57,18 +55,9 @@ export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
|||||||
if (changed) synced++;
|
if (changed) synced++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload local activities to the server if enabled
|
if (syncMode !== 'full') return { synced, total: activities.length };
|
||||||
const uploadEnabled = (await getSetting(db, 'sync_upload')) === 'true';
|
|
||||||
let uploaded = 0;
|
|
||||||
if (uploadEnabled) {
|
|
||||||
uploaded = await uploadLocalActivities(db, instanceUrl, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (syncMode !== 'full') {
|
// Full mode: fetch geojson + timeseries for activities missing them
|
||||||
return { synced, total: activities.length, uploaded: uploaded || undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full mode: fetch geojson + timeseries for any activity missing them
|
|
||||||
const headers = { Authorization: `Bearer ${token}` };
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
let fetched = 0;
|
let fetched = 0;
|
||||||
for (const a of activities) {
|
for (const a of activities) {
|
||||||
@@ -94,7 +83,7 @@ export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
|||||||
if (gj !== null || ts !== null) {
|
if (gj !== null || ts !== null) {
|
||||||
await db.runAsync(
|
await db.runAsync(
|
||||||
`UPDATE activities SET
|
`UPDATE activities SET
|
||||||
geojson = COALESCE(geojson, ?),
|
geojson = COALESCE(geojson, ?),
|
||||||
timeseries_json = COALESCE(timeseries_json, ?)
|
timeseries_json = COALESCE(timeseries_json, ?)
|
||||||
WHERE id = ? AND origin = 'remote'`,
|
WHERE id = ? AND origin = 'remote'`,
|
||||||
[gj, ts, a.id],
|
[gj, ts, a.id],
|
||||||
@@ -103,7 +92,30 @@ export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { synced, total: activities.length, fetched, uploaded: uploaded || undefined };
|
return { synced, total: activities.length, fetched };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||||
|
const creds = await resolveCredentials(db);
|
||||||
|
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
|
||||||
|
const { instanceUrl, token } = creds;
|
||||||
|
|
||||||
|
const uploaded = await uploadLocalActivities(db, instanceUrl, token);
|
||||||
|
return { synced: 0, total: 0, uploaded };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||||
|
const dl = await downloadFeed(db);
|
||||||
|
if (dl.error) return dl;
|
||||||
|
|
||||||
|
const uploadEnabled = (await getSetting(db, 'sync_upload')) === 'true';
|
||||||
|
let uploaded = 0;
|
||||||
|
if (uploadEnabled) {
|
||||||
|
const ul = await uploadFeed(db);
|
||||||
|
uploaded = ul.uploaded ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...dl, uploaded: uploaded || undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadLocalActivities(
|
async function uploadLocalActivities(
|
||||||
@@ -111,8 +123,8 @@ async function uploadLocalActivities(
|
|||||||
instanceUrl: string,
|
instanceUrl: string,
|
||||||
token: string,
|
token: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const rows = db.getAllSync<{ id: string; original_path: string | null; timeseries_json: string | null; geojson: string | null }>(
|
const rows = db.getAllSync<{ id: string; detail_json: string; timeseries_json: string | null; geojson: string | null }>(
|
||||||
`SELECT id, original_path, timeseries_json, geojson
|
`SELECT id, detail_json, timeseries_json, geojson
|
||||||
FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
|
FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -122,14 +134,9 @@ async function uploadLocalActivities(
|
|||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
try {
|
try {
|
||||||
let activity: object | null = null;
|
const detail = JSON.parse(row.detail_json);
|
||||||
|
// /api/upload/bas expects { activity: { id, ...detail }, timeseries?, geojson? }
|
||||||
if (row.original_path) {
|
const activity = { id: row.id, ...detail };
|
||||||
const text = await FileSystem.readAsStringAsync(row.original_path);
|
|
||||||
activity = JSON.parse(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!activity) continue;
|
|
||||||
|
|
||||||
const body: Record<string, unknown> = { activity };
|
const body: Record<string, unknown> = { activity };
|
||||||
if (row.timeseries_json) body.timeseries = JSON.parse(row.timeseries_json);
|
if (row.timeseries_json) body.timeseries = JSON.parse(row.timeseries_json);
|
||||||
@@ -142,10 +149,7 @@ async function uploadLocalActivities(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
await db.runAsync(
|
await db.runAsync(`UPDATE activities SET synced_at = ? WHERE id = ?`, [now, row.id]);
|
||||||
`UPDATE activities SET synced_at = ? WHERE id = ?`,
|
|
||||||
[now, row.id],
|
|
||||||
);
|
|
||||||
uploaded++;
|
uploaded++;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -77,6 +77,14 @@ var initError = null;
|
|||||||
|
|
||||||
(async function init() {
|
(async function init() {
|
||||||
try {
|
try {
|
||||||
|
// WebAssembly.Global was added in Chrome 69. Without it Pyodide cannot
|
||||||
|
// initialise on any version. Bail out immediately so the mobile app can
|
||||||
|
// fall back to server-side extraction without attempting a 35 MB download.
|
||||||
|
if (typeof WebAssembly === 'undefined' || typeof WebAssembly.Global === 'undefined') {
|
||||||
|
_post({ type: 'engine_unavailable', reason: 'wasm_global' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_post({ type: 'progress', msg: 'Loading Python runtime…' });
|
_post({ type: 'progress', msg: 'Loading Python runtime…' });
|
||||||
|
|
||||||
// Chrome <80 is missing features that modern Pyodide uses in its JS wrapper:
|
// Chrome <80 is missing features that modern Pyodide uses in its JS wrapper:
|
||||||
@@ -108,7 +116,7 @@ var initError = null;
|
|||||||
var _pyResp = await fetch(_CDN_COMPAT + 'pyodide.js');
|
var _pyResp = await fetch(_CDN_COMPAT + 'pyodide.js');
|
||||||
if (!_pyResp.ok) throw new Error('Could not fetch pyodide.js (' + _pyResp.status + ')');
|
if (!_pyResp.ok) throw new Error('Could not fetch pyodide.js (' + _pyResp.status + ')');
|
||||||
var _pyCode = await _pyResp.text();
|
var _pyCode = await _pyResp.text();
|
||||||
_pyCode = 'var globalThis=typeof globalThis!=="undefined"?globalThis:self;\n' + _pyCode;
|
_pyCode = 'var globalThis=typeof globalThis!=="undefined"?globalThis:self;\\n' + _pyCode;
|
||||||
_pyCode = _pyCode.split('import(').join('__loadScript(');
|
_pyCode = _pyCode.split('import(').join('__loadScript(');
|
||||||
_pyCode = _pyCode.split('for await(').join('for(');
|
_pyCode = _pyCode.split('for await(').join('for(');
|
||||||
await new Promise(function(res, rej) {
|
await new Promise(function(res, rej) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createRef } from 'react';
|
import { createRef } from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
import type WebView from 'react-native-webview';
|
import type WebView from 'react-native-webview';
|
||||||
import type { WebViewMessageEvent } from 'react-native-webview';
|
import type { WebViewMessageEvent } from 'react-native-webview';
|
||||||
|
|
||||||
@@ -25,11 +26,31 @@ let isExtracting = false;
|
|||||||
// Engine readiness — tracked so callers can wait before batching files.
|
// Engine readiness — tracked so callers can wait before batching files.
|
||||||
let _engineReady = false;
|
let _engineReady = false;
|
||||||
let _engineError: string | null = null;
|
let _engineError: string | null = null;
|
||||||
|
// Android <29 (API 27 = Android 8.1, e.g. Karoo) ships with a system WebView
|
||||||
|
// (Chrome <69) that lacks WebAssembly.Global, so Pyodide cannot run. Mounting
|
||||||
|
// a WebView on those devices also causes GPU driver crashes (SurfaceView
|
||||||
|
// conflicts). Skip the engine entirely and route to server extraction instead.
|
||||||
|
let _engineUnavailable = Platform.OS === 'android' && (Platform.Version as number) < 29;
|
||||||
const _engineResolvers: Array<() => void> = [];
|
const _engineResolvers: Array<() => void> = [];
|
||||||
const _engineRejecters: Array<(e: Error) => void> = [];
|
const _engineRejecters: Array<(e: Error) => void> = [];
|
||||||
|
|
||||||
|
// Init-phase progress listeners (messages sent before any extraction starts).
|
||||||
|
const _progressListeners = new Set<(msg: string) => void>();
|
||||||
|
export function onEngineProgress(cb: (msg: string) => void): () => void {
|
||||||
|
_progressListeners.add(cb);
|
||||||
|
return () => _progressListeners.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEngineAvailable(): boolean | null {
|
||||||
|
// null = not yet determined; true = ready; false = unavailable
|
||||||
|
if (_engineReady) return true;
|
||||||
|
if (_engineUnavailable || _engineError) return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function waitForEngine(timeoutMs = 300_000): Promise<void> {
|
export function waitForEngine(timeoutMs = 300_000): Promise<void> {
|
||||||
if (_engineReady) return Promise.resolve();
|
if (_engineReady) return Promise.resolve();
|
||||||
|
if (_engineUnavailable) return Promise.reject(new Error('engine_unavailable'));
|
||||||
if (_engineError) return Promise.reject(new Error(_engineError));
|
if (_engineError) return Promise.reject(new Error(_engineError));
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -52,6 +73,10 @@ export function handleWebViewMessage(e: WebViewMessageEvent): void {
|
|||||||
_engineReady = true;
|
_engineReady = true;
|
||||||
_engineResolvers.splice(0).forEach(fn => fn());
|
_engineResolvers.splice(0).forEach(fn => fn());
|
||||||
break;
|
break;
|
||||||
|
case 'engine_unavailable':
|
||||||
|
_engineUnavailable = true;
|
||||||
|
_engineRejecters.splice(0).forEach(fn => fn(new Error('engine_unavailable')));
|
||||||
|
break;
|
||||||
case 'init_error':
|
case 'init_error':
|
||||||
_engineError = msg.message as string;
|
_engineError = msg.message as string;
|
||||||
_engineRejecters.splice(0).forEach(fn => fn(new Error(_engineError!)));
|
_engineRejecters.splice(0).forEach(fn => fn(new Error(_engineError!)));
|
||||||
@@ -75,7 +100,11 @@ export function handleWebViewMessage(e: WebViewMessageEvent): void {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'progress':
|
case 'progress':
|
||||||
p?.onStatus(msg.msg as string);
|
if (p) {
|
||||||
|
p.onStatus(msg.msg as string);
|
||||||
|
} else {
|
||||||
|
_progressListeners.forEach(fn => fn(msg.msg as string));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { ExtractionResult } from './extractActivity';
|
||||||
|
|
||||||
|
export async function checkServerAuth(instanceUrl: string, token: string): Promise<void> {
|
||||||
|
let resp: Response;
|
||||||
|
try {
|
||||||
|
resp = await fetch(`${instanceUrl}/api/feed`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new Error('Could not reach Bincio instance — check your connection.');
|
||||||
|
}
|
||||||
|
if (resp.status === 401) throw new Error('Session expired — reconnect in Settings.');
|
||||||
|
if (!resp.ok) throw new Error(`Server error (${resp.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractFileViaServer(
|
||||||
|
filename: string,
|
||||||
|
base64: string,
|
||||||
|
instanceUrl: string,
|
||||||
|
token: string,
|
||||||
|
onStatus: (msg: string) => void = () => {},
|
||||||
|
): Promise<ExtractionResult> {
|
||||||
|
onStatus('Uploading to Bincio instance…');
|
||||||
|
|
||||||
|
let resp: Response;
|
||||||
|
try {
|
||||||
|
resp = await fetch(`${instanceUrl}/api/upload/raw`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ filename, base64 }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new Error('Could not reach Bincio instance — check your connection.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.status === 401) throw new Error('Session expired — reconnect in Settings.');
|
||||||
|
if (resp.status === 422) {
|
||||||
|
const body = await resp.json().catch(() => ({})) as { detail?: string };
|
||||||
|
throw new Error(body.detail ?? 'Server could not process this file.');
|
||||||
|
}
|
||||||
|
if (!resp.ok) throw new Error(`Server error (${resp.status})`);
|
||||||
|
|
||||||
|
onStatus('Processing on server…');
|
||||||
|
const data = await resp.json() as {
|
||||||
|
ok: boolean;
|
||||||
|
id: string;
|
||||||
|
detail: object;
|
||||||
|
timeseries: object | null;
|
||||||
|
geojson: object | null;
|
||||||
|
source_hash: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
detail: data.detail,
|
||||||
|
timeseries: data.timeseries,
|
||||||
|
geojson: data.geojson,
|
||||||
|
sourceHash: data.source_hash,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ URL: http://localhost:4321
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import platform
|
||||||
|
import resource
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -163,6 +165,18 @@ def start_dev(mobile: bool = False) -> None:
|
|||||||
|
|
||||||
# ── main ──────────────────────────────────────────────────────────────────────
|
# ── main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def raise_open_file_limit() -> None:
|
||||||
|
# Astro's file watcher opens many handles; macOS defaults to 256, which
|
||||||
|
# causes EMFILE errors under a large project tree.
|
||||||
|
if platform.system() != "Darwin":
|
||||||
|
return
|
||||||
|
target = 65536
|
||||||
|
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||||
|
if soft < target:
|
||||||
|
resource.setrlimit(resource.RLIMIT_NOFILE, (min(target, hard), hard))
|
||||||
|
ok(f"open-file limit raised to {min(target, hard)}")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description=__doc__,
|
parser = argparse.ArgumentParser(description=__doc__,
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
@@ -171,6 +185,8 @@ def main() -> None:
|
|||||||
parser.add_argument("--mobile", action="store_true", help="Bind API to 0.0.0.0 for local mobile testing")
|
parser.add_argument("--mobile", action="store_true", help="Bind API to 0.0.0.0 for local mobile testing")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
raise_open_file_limit()
|
||||||
|
|
||||||
print(f"\033[1mbincio dev test\033[0m → {DATA_DIR}")
|
print(f"\033[1mbincio dev test\033[0m → {DATA_DIR}")
|
||||||
|
|
||||||
if args.fresh and DATA_DIR.exists():
|
if args.fresh and DATA_DIR.exists():
|
||||||
|
|||||||
Reference in New Issue
Block a user