Files
bincio-activity/mobile/db/sync.ts
T
Davide Scaini cbe3e0eeaf 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.
2026-04-26 21:00:12 +02:00

175 lines
5.6 KiB
TypeScript

import type { SQLiteDatabase } from 'expo-sqlite';
import { getSetting, upsertRemoteActivity } from './queries';
export type SyncResult = {
synced: number;
total: number;
fetched?: number;
uploaded?: number;
error?: string;
};
async function resolveCredentials(db: SQLiteDatabase): Promise<{ instanceUrl: string; token: string } | { error: string }> {
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
const token = await getSetting(db, 'api_token');
if (!instanceUrl || !token) return { error: 'No instance configured — add one in Settings.' };
return { instanceUrl, token };
}
export async function downloadFeed(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;
let resp: Response;
try {
resp = await fetch(`${instanceUrl}/api/feed`, {
headers: { Authorization: `Bearer ${token}` },
});
} catch {
return { synced: 0, total: 0, error: 'Could not reach instance — check your connection.' };
}
if (resp.status === 401) return { synced: 0, total: 0, error: 'Session expired — reconnect in Settings.' };
if (!resp.ok) return { synced: 0, total: 0, error: `Server error (${resp.status})` };
const data: { activities?: RemoteSummary[] } = await resp.json();
const activities = data.activities ?? [];
const syncMode = (await getSetting(db, 'sync_mode')) ?? 'summaries';
let synced = 0;
for (const a of activities) {
const detailJson = JSON.stringify({
id: a.id,
title: a.title ?? a.id,
sport: a.sport ?? null,
started_at: a.started_at ?? null,
distance_m: a.distance_m ?? null,
moving_time_s: a.moving_time_s ?? null,
elevation_gain_m: a.elevation_gain_m ?? null,
avg_speed_kmh: a.avg_speed_kmh ?? null,
avg_hr_bpm: a.avg_hr_bpm ?? null,
avg_power_w: a.avg_power_w ?? null,
});
const changed = await upsertRemoteActivity(db, a.id, detailJson);
if (changed) synced++;
}
if (syncMode !== 'full') return { synced, total: activities.length };
// Full mode: fetch geojson + timeseries for activities missing them
const headers = { Authorization: `Bearer ${token}` };
let fetched = 0;
for (const a of activities) {
const row = db.getFirstSync<{ g: number; t: number }>(
'SELECT (geojson IS NOT NULL) as g, (timeseries_json IS NOT NULL) as t FROM activities WHERE id = ?',
[a.id],
);
if (row?.g && row?.t) continue;
let gj: string | null = null;
let ts: string | null = null;
try {
if (!row?.g) {
const r = await fetch(`${instanceUrl}/api/activity/${a.id}/geojson`, { headers });
if (r.ok) gj = await r.text();
}
if (!row?.t) {
const r = await fetch(`${instanceUrl}/api/activity/${a.id}/timeseries`, { headers });
if (r.ok) ts = await r.text();
}
} catch {}
if (gj !== null || ts !== null) {
await db.runAsync(
`UPDATE activities SET
geojson = COALESCE(geojson, ?),
timeseries_json = COALESCE(timeseries_json, ?)
WHERE id = ? AND origin = 'remote'`,
[gj, ts, a.id],
);
fetched++;
}
}
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(
db: SQLiteDatabase,
instanceUrl: string,
token: string,
): Promise<number> {
const rows = db.getAllSync<{ id: string; detail_json: string; timeseries_json: string | null; geojson: string | null }>(
`SELECT id, detail_json, timeseries_json, geojson
FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
);
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
let uploaded = 0;
const now = Math.floor(Date.now() / 1000);
for (const row of rows) {
try {
const detail = JSON.parse(row.detail_json);
// /api/upload/bas expects { activity: { id, ...detail }, timeseries?, geojson? }
const activity = { id: row.id, ...detail };
const body: Record<string, unknown> = { activity };
if (row.timeseries_json) body.timeseries = JSON.parse(row.timeseries_json);
if (row.geojson) body.geojson = JSON.parse(row.geojson);
const resp = await fetch(`${instanceUrl}/api/upload/bas`, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (resp.ok) {
await db.runAsync(`UPDATE activities SET synced_at = ? WHERE id = ?`, [now, row.id]);
uploaded++;
}
} catch {
// skip failed activities, continue with others
}
}
return uploaded;
}
type RemoteSummary = {
id: string;
title?: string;
sport?: string;
started_at?: string;
distance_m?: number | null;
moving_time_s?: number | null;
elevation_gain_m?: number | null;
avg_speed_kmh?: number | null;
avg_hr_bpm?: number | null;
avg_power_w?: number | null;
};