diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index c6a93f2..b1da0f1 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -487,6 +487,58 @@ async def get_activity_timeseries(
raise HTTPException(404, "Timeseries not found")
+@app.post("/api/upload/bas")
+async def upload_bas_activity(
+ request: Request,
+ bincio_session: Optional[str] = Cookie(default=None),
+) -> JSONResponse:
+ """Accept a pre-extracted BAS activity JSON from the mobile app.
+
+ Body (JSON):
+ activity – full BAS activity dict (required, must have 'id')
+ timeseries – timeseries dict (optional)
+ geojson – GeoJSON dict (optional)
+
+ Returns:
+ {"ok": true, "id": "...", "status": "imported" | "duplicate"}
+ """
+ user = _require_auth(request, bincio_session)
+ body = await request.json()
+
+ activity = body.get("activity")
+ if not activity or not activity.get("id"):
+ raise HTTPException(400, "Missing activity.id")
+
+ activity_id = str(activity["id"])
+ _check_id(activity_id)
+
+ user_dir = _get_data_dir() / user.handle
+ acts_dir = user_dir / "activities"
+ acts_dir.mkdir(parents=True, exist_ok=True)
+
+ out = acts_dir / f"{activity_id}.json"
+ if out.exists():
+ return JSONResponse({"ok": True, "id": activity_id, "status": "duplicate"})
+
+ out.write_text(json.dumps(activity, ensure_ascii=False, indent=2), encoding="utf-8")
+
+ if body.get("timeseries"):
+ ts_path = acts_dir / f"{activity_id}.timeseries.json"
+ if not ts_path.exists():
+ ts_path.write_text(json.dumps(body["timeseries"], ensure_ascii=False), encoding="utf-8")
+
+ if body.get("geojson"):
+ gj_path = acts_dir / f"{activity_id}.geojson"
+ if not gj_path.exists():
+ gj_path.write_text(json.dumps(body["geojson"], ensure_ascii=False), encoding="utf-8")
+
+ from bincio.render.merge import merge_all
+ merge_all(user_dir)
+
+ log.info("upload/bas[%s]: imported %s", user.handle, activity_id)
+ return JSONResponse({"ok": True, "id": activity_id, "status": "imported"})
+
+
@app.get("/api/wheel/version")
async def wheel_version() -> JSONResponse:
"""Public endpoint: current bincio wheel version for mobile app update checks."""
diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx
index 144cb1b..d0f369b 100644
--- a/mobile/app/(tabs)/index.tsx
+++ b/mobile/app/(tabs)/index.tsx
@@ -20,12 +20,13 @@ export default function FeedScreen() {
setSyncMsg(result.error);
} else if (result.total === 0) {
setSyncMsg('No activities on instance');
- } else if (result.synced === 0 && !result.fetched) {
+ } else if (result.synced === 0 && !result.fetched && !result.uploaded) {
setSyncMsg(`Up to date (${result.total} activities)`);
} else {
const parts = [];
if (result.synced > 0) parts.push(`${result.synced} new`);
if (result.fetched) parts.push(`${result.fetched} full dataset${result.fetched === 1 ? '' : 's'}`);
+ if (result.uploaded) parts.push(`${result.uploaded} uploaded`);
setSyncMsg(`Synced: ${parts.join(', ')} (${result.total} total)`);
}
setTimeout(() => setSyncMsg(null), 3500);
diff --git a/mobile/app/(tabs)/settings.tsx b/mobile/app/(tabs)/settings.tsx
index b564075..f986250 100644
--- a/mobile/app/(tabs)/settings.tsx
+++ b/mobile/app/(tabs)/settings.tsx
@@ -13,7 +13,8 @@ export default function SettingsScreen() {
const storedHandle = useSetting('handle') ?? '';
const storedPath = useSetting('auto_import_path') ?? '';
const storedToken = useSetting('api_token');
- const storedSyncMode = (useSetting('sync_mode') ?? 'summaries') as 'summaries' | 'full';
+ const storedSyncMode = (useSetting('sync_mode') ?? 'summaries') as 'summaries' | 'full';
+ const storedSyncUpload = useSetting('sync_upload') === 'true';
const [instanceUrl, setInstanceUrl] = useState(storedUrl);
const [handle, setHandle] = useState(storedHandle);
@@ -176,6 +177,7 @@ export default function SettingsScreen() {
)}
+ Download
+ Upload
+
+ setSetting(db, 'sync_upload', 'false')}
+ />
+ setSetting(db, 'sync_upload', 'true')}
+ />
+
+
+ {storedSyncUpload
+ ? 'Local activities are uploaded to the instance during sync.'
+ : 'Local activities stay on device only.'}
+
@@ -314,6 +334,7 @@ const styles = StyleSheet.create({
disconnectText: { color: '#71717a', fontSize: 14 },
msgOk: { color: '#86efac', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
msgErr: { color: '#fca5a5', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
+ subLabel: { color: '#52525b', fontSize: 11, fontWeight: '600', letterSpacing: 0.6, paddingHorizontal: 12, paddingTop: 12, paddingBottom: 4 },
modeRow: { flexDirection: 'row', gap: 8, padding: 12 },
modeButton: { flex: 1, paddingVertical: 9, borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46', alignItems: 'center' },
modeButtonActive: { backgroundColor: '#1e3a5f', borderColor: '#2563eb' },
diff --git a/mobile/db/sync.ts b/mobile/db/sync.ts
index 2e46e3a..79bfccc 100644
--- a/mobile/db/sync.ts
+++ b/mobile/db/sync.ts
@@ -1,7 +1,14 @@
+import * as FileSystem from 'expo-file-system/legacy';
import type { SQLiteDatabase } from 'expo-sqlite';
import { getSetting, upsertRemoteActivity } from './queries';
-export type SyncResult = { synced: number; total: number; fetched?: number; error?: string };
+export type SyncResult = {
+ synced: number;
+ total: number;
+ fetched?: number;
+ uploaded?: number;
+ error?: string;
+};
export async function syncFeed(db: SQLiteDatabase): Promise {
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
@@ -50,8 +57,15 @@ export async function syncFeed(db: SQLiteDatabase): Promise {
if (changed) synced++;
}
+ // Upload local activities to the server if enabled
+ const uploadEnabled = (await getSetting(db, 'sync_upload')) === 'true';
+ let uploaded = 0;
+ if (uploadEnabled) {
+ uploaded = await uploadLocalActivities(db, instanceUrl, token);
+ }
+
if (syncMode !== 'full') {
- return { synced, total: activities.length };
+ return { synced, total: activities.length, uploaded: uploaded || undefined };
}
// Full mode: fetch geojson + timeseries for any activity missing them
@@ -89,7 +103,57 @@ export async function syncFeed(db: SQLiteDatabase): Promise {
}
}
- return { synced, total: activities.length, fetched };
+ return { synced, total: activities.length, fetched, uploaded: uploaded || undefined };
+}
+
+async function uploadLocalActivities(
+ db: SQLiteDatabase,
+ instanceUrl: string,
+ token: string,
+): Promise {
+ const rows = db.getAllSync<{ id: string; original_path: string | null; timeseries_json: string | null; geojson: string | null }>(
+ `SELECT id, original_path, 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 {
+ let activity: object | null = null;
+
+ if (row.original_path) {
+ const text = await FileSystem.readAsStringAsync(row.original_path);
+ activity = JSON.parse(text);
+ }
+
+ if (!activity) continue;
+
+ const body: Record = { 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 = {