From 5a5df2b323413149e2e57e6ae1567af44544de85 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sat, 15 Aug 2026 20:52:38 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20APK=20distribution=20pipeline=20?= =?UTF-8?q?=E2=80=94=20server=20hosting=20+=20Pi=20auto-update=20+=20/app?= =?UTF-8?q?=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PLAN.md | 15 ++-- android/app/src/main/res/values/strings.xml | 2 +- android/plan.md | 18 +++-- app.py | 18 +++++ deploy/install_app_updater.sh | 20 +++++ deploy/yaris-app-updater.service | 11 +++ deploy/yaris-app-updater.timer | 11 +++ deploy/yaris_app_updater.py | 90 +++++++++++++++++++++ server/app.py | 84 ++++++++++++++++++- server/templates/app_upload.html | 35 ++++++++ templates/app.html | 32 ++++++++ 11 files changed, 320 insertions(+), 16 deletions(-) create mode 100644 deploy/install_app_updater.sh create mode 100644 deploy/yaris-app-updater.service create mode 100644 deploy/yaris-app-updater.timer create mode 100644 deploy/yaris_app_updater.py create mode 100644 server/templates/app_upload.html create mode 100644 templates/app.html diff --git a/PLAN.md b/PLAN.md index 954814a..77d9cb0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2229,13 +2229,16 @@ The app reuses the existing Pi REST API surface with no changes: dark top bar. - Follow-up polish deferred: card shadows, blue `btn-primary` buttons, page titles. -**28.8 — Polish + distribution 🚧 IN PROGRESS (2026-08-15)** -- ✅ App icon (adaptive, red bg + white gauge), native splash screen (API 31+), "Yaris OBD" +**28.8 — Polish + distribution ✅ DONE (2026-08-15)** +- ✅ App icon (adaptive, red bg + white gauge), native splash (API 31+), "yaris-obd" label, OS-following dark mode. -- ✅ Release build: local signing keystore (gitignored), R8-verified — `app-release.apk` - (~43 MB) installs and launches on the Pixel. -- ⬜ Distribution: host the APK on the Pi (`GET /app` → `static/app/` + download page). - Play Store out of scope. +- ✅ Release build: local signing keystore (gitignored), R8-verified signed APK (~43 MB). +- ✅ Distribution pipeline (server → Pi → phone): the server publishes the APK + (`/api/v1/app/latest` + `/app/yaris-obd.apk` + a portal upload page), the Pi mirrors it + on a systemd timer (`deploy/yaris_app_updater.py` + `yaris-app-updater.{service,timer}`), + and the phone installs from `http://10.42.0.1/app`. +- Deployment: run `install_app_updater.sh` on the Pi; publish a build via the portal upload + page (or drop the APK in the server's mounted `dashcam/app/` volume). Play Store out of scope. #### Open questions (resolve during 28.1) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 7ab76c9..60dbf37 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - Yaris OBD + yaris-obd diff --git a/android/plan.md b/android/plan.md index ed1926d..a04a0fb 100644 --- a/android/plan.md +++ b/android/plan.md @@ -261,17 +261,19 @@ android/ - [ ] Error handling audit — most screens have error states; dashboard still shows "—" when offline - [ ] Accessibility audit — content descriptions present on most icons; touch targets ≥48dp mostly -#### 28.8.2 — Build + distribution 🚧 (release APK signed; hosting pending) +#### 28.8.2 — Build + distribution ✅ (2026-08-15) - [x] Release signing key: `android/keystore/release.keystore` (gitignored) + `keystore.properties` - [x] ProGuard/R8 rules verified — release APK builds AND launches on the Pixel - [x] APK output: `app/build/outputs/apk/release/app-release.apk` (~43 MB, signed) -- [ ] Self-hosted distribution: - - Copy APK to Pi: `scp app-release.apk yaris@yaris.local:~/app/yaris-obd/static/app/` - - Add a lightweight Pi route: `GET /app` → serves `static/app/index.html` with - download link + install instructions - - Phone flow: connect to Pi hotspot → open `http://yaris.local/app` → download APK → - install (user must enable "Install unknown apps" once) -- [ ] Commit: `feat: self-hosted distribution` +- [x] Self-hosted distribution (server → Pi → phone): + - Server: `GET /api/v1/app/latest` (public version+sha256) + `GET /app/yaris-obd.apk` + (download) + `GET/POST /app/upload` (portal page to publish a build) + - Pi: `GET /app` (download page) + `GET /app/yaris-obd.apk`; `deploy/yaris_app_updater.py` + mirrors the latest APK from the server on a systemd timer + (`deploy/yaris-app-updater.{service,timer}` + `deploy/install_app_updater.sh`) + - Phone: connect to hotspot → `http://10.42.0.1/app` → download → install +- [ ] Deploy: run `install_app_updater.sh` on the Pi; publish a build via the portal (or scp + the APK into the server's mounted `dashcam/app/` volume) ## Working notes diff --git a/app.py b/app.py index f667d7a..f5034c8 100644 --- a/app.py +++ b/app.py @@ -48,6 +48,24 @@ def settings(): def status_page(): return render_template('status.html', account=account, car=car) +# ── Android app download (served on the car hotspot) ───────────────────── +# The APK is mirrored here from the remote server by deploy/yaris_app_updater.py +# so the phone can install/update over WiFi without leaving the hotspot. +APP_APK_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'app', 'yaris-obd.apk') + +@app.route('/app') +def app_download_page(): + apk_exists = os.path.isfile(APP_APK_PATH) + apk_size = os.path.getsize(APP_APK_PATH) if apk_exists else 0 + return render_template('app.html', apk_exists=apk_exists, apk_size=apk_size) + +@app.route('/app/yaris-obd.apk') +def app_download_apk(): + if not os.path.isfile(APP_APK_PATH): + return "APK not downloaded yet — try again once the Pi has internet.", 404 + return send_file(APP_APK_PATH, as_attachment=True, download_name='yaris-obd.apk', + mimetype='application/vnd.android.package-archive') + @app.route('/api/settings', methods=['GET', 'POST']) def api_settings(): if request.method == 'GET': diff --git a/deploy/install_app_updater.sh b/deploy/install_app_updater.sh new file mode 100644 index 0000000..4105441 --- /dev/null +++ b/deploy/install_app_updater.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# One-time install of the Android APK auto-updater on the Pi. +# Run ON THE PI as the `yaris` user (needs sudo): +# cd ~/app/yaris-obd && bash deploy/install_app_updater.sh +# +# Installs a systemd timer that polls the remote server for the latest APK +# and mirrors it to static/app/ so the phone can install from +# http://10.42.0.1/app without leaving the car hotspot. +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +sudo systemctl stop yaris-app-updater.timer yaris-app-updater.service 2>/dev/null || true +sudo install -m 755 "$REPO_DIR/deploy/yaris_app_updater.py" /usr/local/bin/yaris-app-updater +sudo install -m 644 "$REPO_DIR/deploy/yaris-app-updater.service" /etc/systemd/system/yaris-app-updater.service +sudo install -m 644 "$REPO_DIR/deploy/yaris-app-updater.timer" /etc/systemd/system/yaris-app-updater.timer +sudo systemctl daemon-reload +sudo systemctl enable --now yaris-app-updater.timer +sudo systemctl start yaris-app-updater.service +echo "APK updater installed. Check: systemctl status yaris-app-updater.service" diff --git a/deploy/yaris-app-updater.service b/deploy/yaris-app-updater.service new file mode 100644 index 0000000..14d183d --- /dev/null +++ b/deploy/yaris-app-updater.service @@ -0,0 +1,11 @@ +[Unit] +Description=yaris-obd Android APK mirror from the remote server +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +TimeoutStartSec=600 +Environment=YARIS_SERVER_URL=https://yaris.ceaf.dev +Environment=YARIS_APP_DIR=/home/yaris/app/yaris-obd/static/app +ExecStart=/usr/local/bin/yaris-app-updater diff --git a/deploy/yaris-app-updater.timer b/deploy/yaris-app-updater.timer new file mode 100644 index 0000000..5ff46fb --- /dev/null +++ b/deploy/yaris-app-updater.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Periodically mirror the Android APK to the car hotspot + +[Timer] +OnBootSec=2min +OnUnitActiveSec=1h +RandomizedDelaySec=5min +Unit=yaris-app-updater.service + +[Install] +WantedBy=timers.target diff --git a/deploy/yaris_app_updater.py b/deploy/yaris_app_updater.py new file mode 100644 index 0000000..3866f78 --- /dev/null +++ b/deploy/yaris_app_updater.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Mirror the latest Android APK from the remote server to the Pi's hotspot. + +Polls GET /api/v1/app/latest on the server, downloads /app/yaris-obd.apk when +the sha256 differs from the local copy, verifies it, and atomically replaces +static/app/yaris-obd.apk — so the phone can install from http://10.42.0.1/app +without leaving the car WiFi. + +Runs on a systemd timer (see deploy/yaris-app-updater.{service,timer}). +Stdlib only — no pip dependencies. +""" +import hashlib +import json +import os +import sys +import urllib.error +import urllib.request + +SERVER_URL = os.environ.get("YARIS_SERVER_URL", "https://yaris.ceaf.dev").rstrip("/") +APP_DIR = os.environ.get("YARIS_APP_DIR", "/home/yaris/app/yaris-obd/static/app") +APK_PATH = os.path.join(APP_DIR, "yaris-obd.apk") + + +def sha256_file(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def fetch_json(url, timeout=20): + with urllib.request.urlopen(url, timeout=timeout) as r: + return json.load(r) + + +def main(): + try: + manifest = fetch_json(f"{SERVER_URL}/api/v1/app/latest") + except urllib.error.HTTPError as e: + if e.code == 404: + print("No app published on the server yet; skipping.") + return 0 + print(f"latest check failed: HTTP {e.code}") + return 1 + except Exception as e: + print(f"latest check failed: {e}") + return 1 + + sha = manifest.get("sha256") + if not sha: + print("manifest missing sha256") + return 1 + + if os.path.isfile(APK_PATH): + try: + if sha256_file(APK_PATH) == sha: + print(f"APK up to date (version {manifest.get('version')})") + return 0 + except Exception as e: + print(f"local hash failed: {e}") + + os.makedirs(APP_DIR, exist_ok=True) + tmp = APK_PATH + ".part" + try: + with urllib.request.urlopen(f"{SERVER_URL}/app/yaris-obd.apk", timeout=600) as r, open(tmp, "wb") as f: + while True: + chunk = r.read(1024 * 1024) + if not chunk: + break + f.write(chunk) + if sha256_file(tmp) != sha: + print("downloaded APK sha256 mismatch — discarding") + os.remove(tmp) + return 1 + os.replace(tmp, APK_PATH) + print(f"Updated APK to version {manifest.get('version')} ({os.path.getsize(APK_PATH)} bytes)") + return 0 + except Exception as e: + print(f"download failed: {e}") + if os.path.isfile(tmp): + try: + os.remove(tmp) + except Exception: + pass + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server/app.py b/server/app.py index 76b97ff..07b2b27 100644 --- a/server/app.py +++ b/server/app.py @@ -4,6 +4,7 @@ Also serves the remote web portal for browsing and managing the configured car. """ import functools import hashlib +import json import os import re import secrets @@ -14,7 +15,7 @@ from collections import defaultdict from flask import ( Flask, request, jsonify, g, render_template, redirect, url_for, - flash, session, Response, abort + flash, session, Response, abort, send_file ) from flask_login import login_user, logout_user, current_user @@ -67,6 +68,12 @@ app.config.update( # array via docker-compose - see DASHCAM_STORAGE_DIR in .env). STORAGE_DIR = os.environ.get("DASHCAM_STORAGE_DIR", "/data/dashcam") MAX_DASHCAM_UPLOAD_MB = int(os.environ.get("MAX_DASHCAM_UPLOAD_MB", "2048")) + +# Where the Android APK (and its version manifest) live — under the mounted +# dashcam volume so the user can drop files in without a new mount. +APP_DIR = os.path.join(STORAGE_DIR, "app") +APP_APK_PATH = os.path.join(APP_DIR, "yaris-obd.apk") +APP_VERSION_PATH = os.path.join(APP_DIR, "version.json") app.config["MAX_CONTENT_LENGTH"] = MAX_DASHCAM_UPLOAD_MB * 1024 * 1024 # Sanity cap on tracking points per request - the Pi client batches in @@ -145,6 +152,81 @@ def health(): return jsonify(status="ok"), 200 +# ── Android app distribution ────────────────────────────────────────────── +# The Pi polls /api/v1/app/latest and mirrors the APK to its in-car hotspot so +# the phone can install/update without leaving the car WiFi. The APK lands here +# either via the portal upload page below or by dropping it directly into the +# mounted dashcam volume at app/yaris-obd.apk. + +def _app_manifest(): + if not os.path.isfile(APP_VERSION_PATH) or not os.path.isfile(APP_APK_PATH): + return None + try: + with open(APP_VERSION_PATH) as f: + return json.load(f) + except Exception: + return None + + +@app.get("/api/v1/app/latest") +def app_latest(): + """Latest APK version + checksum. Public — the Pi polls this unauthenticated.""" + manifest = _app_manifest() + if manifest is None: + return jsonify(error="no app published"), 404 + return jsonify(manifest) + + +@app.get("/app/yaris-obd.apk") +def app_download_apk(): + if not os.path.isfile(APP_APK_PATH): + return jsonify(error="no app published"), 404 + return send_file(APP_APK_PATH, as_attachment=True, + download_name="yaris-obd.apk", + mimetype="application/vnd.android.package-archive") + + +@app.get("/app/upload") +@require_login +def app_upload_page(): + csrf_token = secrets.token_hex(16) + session["csrf_token"] = csrf_token + return render_template("app_upload.html", csrf_token=csrf_token) + + +@app.post("/app/upload") +@require_login +def app_upload_submit(): + token = request.form.get("csrf_token", "") + if not token or token != session.pop("csrf_token", None): + return "Invalid request. Please try again.", 400 + if "file" not in request.files: + return "missing file", 400 + version = (request.form.get("version") or "").strip() + if not version: + return "version required", 400 + uploaded = request.files["file"] + if not uploaded.filename or not uploaded.filename.lower().endswith(".apk"): + return "expected a .apk file", 400 + + os.makedirs(APP_DIR, exist_ok=True) + hasher = hashlib.sha256() + tmp_path = APP_APK_PATH + ".part" + size_bytes = 0 + with open(tmp_path, "wb") as f: + while True: + chunk = uploaded.stream.read(1024 * 1024) + if not chunk: + break + hasher.update(chunk) + size_bytes += len(chunk) + f.write(chunk) + os.replace(tmp_path, APP_APK_PATH) + with open(APP_VERSION_PATH, "w") as f: + json.dump({"version": version, "sha256": hasher.hexdigest(), "size": size_bytes}, f) + return redirect(url_for("app_upload_page")) + + @app.post("/api/v1/tracking/batch") @require_device_auth def upload_tracking_batch(): diff --git a/server/templates/app_upload.html b/server/templates/app_upload.html new file mode 100644 index 0000000..8470d98 --- /dev/null +++ b/server/templates/app_upload.html @@ -0,0 +1,35 @@ + + + + + + Publish APK — Yaris OBD + + + + +
+
+ +
Remote server
+
+
Publish Android APK
+

+ Upload a new build. The Pi mirrors it to the car hotspot on its next + update check, so the phone can install from http://10.42.0.1/app. +

+
+ +
+ + +
+
+ + +
+ +
+
+ + diff --git a/templates/app.html b/templates/app.html new file mode 100644 index 0000000..96bc74e --- /dev/null +++ b/templates/app.html @@ -0,0 +1,32 @@ + + + + + + Install — Yaris OBD + + + + +
+
+ +
Android companion
+
+ {% if apk_exists %} + + Download yaris-obd.apk + +

+ {{ (apk_size / 1024 / 1024) | round(1) }} MB — after downloading, open the + file and enable "Install unknown apps" for your browser if prompted. +

+ {% else %} +
+ The APK hasn't been mirrored yet. The Pi downloads it from the server + automatically when it has internet — try again shortly. +
+ {% endif %} +
+ +