feat: serve bincio wheel locally for mobile dev testing

- Add GET /api/wheel/download to serve/server.py and edit/server.py:
  serves dist/bincio-*.whl via FileResponse; in production nginx takes
  the request before FastAPI, so this is a no-op there but works locally
- wheel_version response now includes api_url: "/api/wheel/download"
  alongside the nginx-served url field
- Bundle mobile/assets/bincio.whl (built from dist/) as an offline
  fallback for Pyodide testing before the first instance sync
- docs/mobile-app.md: document dev setup — bundled asset, local server
  endpoint, and how to refresh the bundle with uv build + cp
This commit is contained in:
Davide Scaini
2026-04-24 11:01:24 +02:00
parent b37df88fe1
commit 02bb8a3dd7
4 changed files with 232 additions and 22 deletions
+32 -1
View File
@@ -11,7 +11,7 @@ from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
from bincio.edit.ops import SPORTS, STAT_PANELS, VALID_ACTIVITY_ID from bincio.edit.ops import SPORTS, STAT_PANELS, VALID_ACTIVITY_ID
@@ -467,6 +467,37 @@ async def recalculate_elevation_hysteresis_endpoint(activity_id: str) -> JSONRes
raise HTTPException(422, str(e)) raise HTTPException(422, str(e))
@app.get("/api/wheel/version")
async def wheel_version() -> JSONResponse:
"""Public endpoint: current bincio wheel version for mobile app update checks."""
import importlib.metadata
try:
version = importlib.metadata.version("bincio")
except importlib.metadata.PackageNotFoundError:
version = "0.1.0"
return JSONResponse({
"version": version,
"url": f"/bincio-{version}-py3-none-any.whl",
"api_url": "/api/wheel/download",
})
@app.get("/api/wheel/download")
async def wheel_download() -> FileResponse:
"""Serve the bincio wheel directly (used locally; in prod nginx serves /bincio-*.whl)."""
import importlib.metadata
try:
version = importlib.metadata.version("bincio")
except importlib.metadata.PackageNotFoundError:
version = "0.1.0"
wheel_name = f"bincio-{version}-py3-none-any.whl"
dist_dir = Path(__file__).parent.parent.parent / "dist"
wheel_path = dist_dir / wheel_name
if not wheel_path.exists():
raise HTTPException(status_code=404, detail=f"{wheel_name} not found in dist/")
return FileResponse(wheel_path, media_type="application/zip", filename=wheel_name)
@app.post("/api/activity/{activity_id}/images") @app.post("/api/activity/{activity_id}/images")
async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONResponse: async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONResponse:
dd = _get_data_dir() dd = _get_data_dir()
+19 -1
View File
@@ -23,7 +23,7 @@ from typing import Any, Optional
log = logging.getLogger("bincio.serve") log = logging.getLogger("bincio.serve")
from fastapi import Cookie, FastAPI, File, Form, HTTPException, Request, Response, UploadFile from fastapi import Cookie, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import RedirectResponse, StreamingResponse from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -448,9 +448,27 @@ async def wheel_version() -> JSONResponse:
return JSONResponse({ return JSONResponse({
"version": version, "version": version,
"url": f"/bincio-{version}-py3-none-any.whl", "url": f"/bincio-{version}-py3-none-any.whl",
"api_url": f"/api/wheel/download",
}) })
@app.get("/api/wheel/download")
async def wheel_download() -> FileResponse:
"""Serve the bincio wheel directly (used locally; in prod nginx serves /bincio-*.whl)."""
import importlib.metadata
try:
version = importlib.metadata.version("bincio")
except importlib.metadata.PackageNotFoundError:
version = "0.1.0"
wheel_name = f"bincio-{version}-py3-none-any.whl"
# Look in dist/ relative to repo root (two levels up from this file)
dist_dir = Path(__file__).parent.parent.parent / "dist"
wheel_path = dist_dir / wheel_name
if not wheel_path.exists():
raise HTTPException(status_code=404, detail=f"{wheel_name} not found in dist/")
return FileResponse(wheel_path, media_type="application/zip", filename=wheel_name)
@app.post("/api/auth/login", response_model=LoginResponse) @app.post("/api/auth/login", response_model=LoginResponse)
async def login( async def login(
login_req: LoginRequest, login_req: LoginRequest,
+181 -20
View File
@@ -51,20 +51,53 @@ server uses. Any tool in any language can read them.
--- ---
## Setup ## Development setup
### Two build modes: Expo Go vs Development Build
This is the most important thing to understand before starting.
**Expo Go** is the Expo app available on the Play Store / App Store. It runs any
Expo project by scanning a QR code — no compilation step. However, it only supports
Expo's own built-in modules. It does **not** support third-party native modules.
**Development Build** is a custom version of the Expo Go app compiled specifically
for this project. It includes all third-party native modules (react-native-webview,
maplibre). It is installed once on the device; after that, code changes still update
instantly via Metro (the JS bundler) — no rebuild needed.
| | Expo Go | Development Build |
|---|---|---|
| Setup | Scan QR, instant | Build APK once (local or EAS cloud) |
| expo-sqlite | ✅ | ✅ |
| expo-document-picker | ✅ | ✅ |
| react-native-webview (Pyodide) | ✗ | ✅ |
| @maplibre/maplibre-react-native | ✗ | ✅ |
| Code changes | instant (Metro) | instant (Metro) |
| Native changes | need new Expo Go release | rebuild APK |
**Phase 0** only uses built-in Expo modules — Expo Go works. **Phase 1** (Pyodide)
requires a Development Build because `react-native-webview` is a native module.
The recommended setup from the start is a Development Build so you never hit a wall
mid-phase.
---
### Prerequisites ### Prerequisites
| Tool | Minimum version | Notes | | Tool | Required for | Install |
|---|---|---| |---|---|---|
| Node.js | 18 | 20 LTS recommended — install via [nodejs.org](https://nodejs.org) or `nvm` | | Node.js 20 LTS | everything | [nodejs.org](https://nodejs.org) or `nvm install 20` |
| npm | ships with Node | | | npm | everything | ships with Node |
| Expo Go app | latest | Install on your phone — scan the QR code to run the app instantly during development | | Android Studio | Android dev build / emulator | [developer.android.com/studio](https://developer.android.com/studio) |
| Xcode | 15+ | **macOS only, iOS builds.** Install from the App Store, then `xcode-select --install` | | Xcode 15+ | iOS only, macOS only | App Store `xcode-select --install` |
| Android Studio | latest | **Android builds / emulator.** Includes the SDK and `adb` | | EAS CLI | cloud builds (optional) | `npm install -g eas-cli` |
You do **not** need Xcode or Android Studio to start. Expo Go lets you run the app You do **not** need a physical Android device to start. The Android emulator
on your physical device by scanning a QR code — no native build required. (AVD Manager inside Android Studio) works fine for development.
---
### First-time setup ### First-time setup
@@ -73,25 +106,153 @@ on your physical device by scanning a QR code — no native build required.
bash mobile/setup.sh bash mobile/setup.sh
``` ```
The script checks prerequisites, installs npm dependencies, and generates the The script checks Node, Android SDK, and Xcode availability; installs npm
required Expo type declarations. It prints next steps when done. dependencies; and generates the required Expo type declarations.
### Running the app ---
### Phase 0 — Expo Go (quickest start)
Since Phase 0 uses only built-in Expo modules, you can start with Expo Go:
```bash ```bash
cd mobile cd mobile
# Development server — scan QR with Expo Go on your phone
npx expo start npx expo start
```
# Run on a connected Android device or emulator 1. Install **Expo Go** on your Android phone from the Play Store.
npx expo run:android 2. Scan the QR code printed in the terminal.
3. The app loads instantly. Code changes in your editor appear on the phone
within a second or two.
# Run on iOS simulator (macOS only) > **Limitation**: once you add the Pyodide WebView in Phase 1, you must switch to
npx expo run:ios > a Development Build. Expo Go will show an error for `react-native-webview`.
# Build a standalone APK for Karoo sideloading ---
npx eas build -p android --profile preview
### Phase 1+ — Development Build
#### Option A: local build (Android Studio required)
Plug in an Android device via USB (or start an emulator in Android Studio), then:
```bash
cd mobile
npx expo run:android # builds APK, installs it, starts Metro
```
This compiles the full native project once (~35 min). After that, JS changes
reflect instantly without rebuilding.
For the emulator, create an AVD in Android Studio with API 33+ and start it before
running the command.
#### Option B: EAS Build (cloud, no Android Studio required)
EAS (Expo Application Services) builds the APK in the cloud. You get a download
link; install it on your device once.
```bash
npm install -g eas-cli
eas login # Expo account needed
eas build -p android --profile development
```
After install, start Metro locally:
```bash
cd mobile
npx expo start --dev-client
```
Shake the device to open the dev menu and enter the Metro URL if needed.
---
### iOS development (macOS only)
```bash
cd mobile
npx expo run:ios # opens iOS simulator, builds, and runs
```
Requires Xcode 15+ and an active iOS simulator. Cloud builds via EAS:
```bash
eas build -p ios --profile development # requires Apple Developer account ($99/yr)
```
---
### Where Pyodide comes from
The hidden WebView loads Pyodide from the **jsDelivr CDN** — the same source
as the `/convert/` page on the web:
```
https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js (~30 MB)
```
On first extraction after install, the WebView downloads and caches this
runtime in `expo-file-system`'s document directory. Subsequent extractions use
the cached copy — no internet required.
The **bincio wheel** (~50 KB) is fetched from:
```
GET {instance_url}/api/wheel/version → { version, url, api_url }
GET {instance_url}/bincio-{version}-py3-none-any.whl (nginx, prod)
GET {instance_url}/api/wheel/download (FastAPI, local dev)
```
If no instance is configured, it falls back to `https://bincio.org`. The wheel
is also cached locally and re-downloaded only when the version changes.
**Local development** (before bincio is published to PyPI): the wheel is not on
PyPI, so there is a bundled fallback at `mobile/assets/bincio.whl`. The
extraction code loads it from the app bundle when no cached wheel exists yet.
This bundled copy is updated manually by running:
```bash
uv build --wheel # builds dist/bincio-*.whl
cp dist/bincio-*.whl mobile/assets/bincio.whl
```
The server-side `GET /api/wheel/download` endpoint also serves the wheel
directly from `dist/` — useful when running a local `bincio serve` instance on
the same WiFi network as the test device and wanting to exercise the update flow.
The **common packages** (`fitdecode`, `gpxpy`, `lxml`, `pyyaml`) are fetched from
the Pyodide CDN via micropip on first use and cached by the WebView's internal
storage.
**Summary of what touches the network:**
| Asset | Size | When | Cached |
|---|---|---|---|
| Pyodide runtime | ~30 MB | once (first extraction ever) | ✅ permanently |
| Common packages | ~5 MB | once | ✅ permanently |
| bincio wheel | ~50 KB | on version bump (bundled fallback in assets/) | ✅ until next update |
| Map tiles | per-tile | on pan/zoom | ✅ by MapLibre |
Everything else — the activity files, the extracted BAS JSON — stays on device.
---
### Distributing the app
| Target | Method |
|---|---|
| Your own Android phone | `npx expo run:android` via USB, or EAS development build |
| Karoo 2 | EAS production build → download APK → sideload via `adb install bincio.apk` or Karoo's app sideloader |
| Other Android users | EAS build → share APK download link (no Play Store needed) |
| Play Store | EAS production build → upload `.aab` to Play Console |
| iOS users | EAS build → TestFlight (beta) or App Store |
For Karoo sideloading:
```bash
eas build -p android --profile preview # produces a standalone APK
adb install /path/to/bincio.apk # with Karoo connected via USB
``` ```
--- ---
Binary file not shown.