feat: APK distribution pipeline — server hosting + Pi auto-update + /app routes

This commit is contained in:
Mathieu Moran
2026-08-15 20:52:38 -04:00
parent 5039b90bc6
commit 5a5df2b323
11 changed files with 320 additions and 16 deletions
+9 -6
View File
@@ -2229,13 +2229,16 @@ The app reuses the existing Pi REST API surface with no changes:
dark top bar. dark top bar.
- Follow-up polish deferred: card shadows, blue `btn-primary` buttons, page titles. - Follow-up polish deferred: card shadows, blue `btn-primary` buttons, page titles.
**28.8 — Polish + distribution 🚧 IN PROGRESS (2026-08-15)** **28.8 — Polish + distribution ✅ DONE (2026-08-15)**
- ✅ App icon (adaptive, red bg + white gauge), native splash screen (API 31+), "Yaris OBD" - ✅ App icon (adaptive, red bg + white gauge), native splash (API 31+), "yaris-obd"
label, OS-following dark mode. label, OS-following dark mode.
- ✅ Release build: local signing keystore (gitignored), R8-verified — `app-release.apk` - ✅ Release build: local signing keystore (gitignored), R8-verified signed APK (~43 MB).
(~43 MB) installs and launches on the Pixel. - ✅ Distribution pipeline (server → Pi → phone): the server publishes the APK
- ⬜ Distribution: host the APK on the Pi (`GET /app` → `static/app/` + download page). (`/api/v1/app/latest` + `/app/yaris-obd.apk` + a portal upload page), the Pi mirrors it
Play Store out of scope. 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) #### Open questions (resolve during 28.1)
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Yaris OBD</string> <string name="app_name">yaris-obd</string>
</resources> </resources>
+10 -8
View File
@@ -261,17 +261,19 @@ android/
- [ ] Error handling audit — most screens have error states; dashboard still shows "—" when offline - [ ] 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 - [ ] 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] Release signing key: `android/keystore/release.keystore` (gitignored) + `keystore.properties`
- [x] ProGuard/R8 rules verified — release APK builds AND launches on the Pixel - [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) - [x] APK output: `app/build/outputs/apk/release/app-release.apk` (~43 MB, signed)
- [ ] Self-hosted distribution: - [x] Self-hosted distribution (server → Pi → phone):
- Copy APK to Pi: `scp app-release.apk [email protected]:~/app/yaris-obd/static/app/` - Server: `GET /api/v1/app/latest` (public version+sha256) + `GET /app/yaris-obd.apk`
- Add a lightweight Pi route: `GET /app` → serves `static/app/index.html` with (download) + `GET/POST /app/upload` (portal page to publish a build)
download link + install instructions - Pi: `GET /app` (download page) + `GET /app/yaris-obd.apk`; `deploy/yaris_app_updater.py`
- Phone flow: connect to Pi hotspot → open `http://yaris.local/app` → download APK → mirrors the latest APK from the server on a systemd timer
install (user must enable "Install unknown apps" once) (`deploy/yaris-app-updater.{service,timer}` + `deploy/install_app_updater.sh`)
- [ ] Commit: `feat: self-hosted distribution` - 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 ## Working notes
+18
View File
@@ -48,6 +48,24 @@ def settings():
def status_page(): def status_page():
return render_template('status.html', account=account, car=car) 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']) @app.route('/api/settings', methods=['GET', 'POST'])
def api_settings(): def api_settings():
if request.method == 'GET': if request.method == 'GET':
+20
View File
@@ -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"
+11
View File
@@ -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
+11
View File
@@ -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
+90
View File
@@ -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())
+83 -1
View File
@@ -4,6 +4,7 @@ Also serves the remote web portal for browsing and managing the configured car.
""" """
import functools import functools
import hashlib import hashlib
import json
import os import os
import re import re
import secrets import secrets
@@ -14,7 +15,7 @@ from collections import defaultdict
from flask import ( from flask import (
Flask, request, jsonify, g, render_template, redirect, url_for, 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 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). # array via docker-compose - see DASHCAM_STORAGE_DIR in .env).
STORAGE_DIR = os.environ.get("DASHCAM_STORAGE_DIR", "/data/dashcam") STORAGE_DIR = os.environ.get("DASHCAM_STORAGE_DIR", "/data/dashcam")
MAX_DASHCAM_UPLOAD_MB = int(os.environ.get("MAX_DASHCAM_UPLOAD_MB", "2048")) 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 app.config["MAX_CONTENT_LENGTH"] = MAX_DASHCAM_UPLOAD_MB * 1024 * 1024
# Sanity cap on tracking points per request - the Pi client batches in # Sanity cap on tracking points per request - the Pi client batches in
@@ -145,6 +152,81 @@ def health():
return jsonify(status="ok"), 200 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") @app.post("/api/v1/tracking/batch")
@require_device_auth @require_device_auth
def upload_tracking_batch(): def upload_tracking_batch():
+35
View File
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html data-bs-theme="light" lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>Publish APK — Yaris OBD</title>
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/css/bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='yaris.css') }}">
</head>
<body class="bg-light d-flex align-items-center justify-content-center" style="min-height:100vh;">
<div class="card shadow-sm p-4" style="width:100%;max-width:480px;">
<div class="text-center mb-3">
<span class="sidebar-brand-logo" style="color:#1c1c1e;">YARIS-OBD</span>
<div class="badge bg-warning text-dark mt-1">Remote server</div>
</div>
<h5 class="fw-bold mb-1">Publish Android APK</h5>
<p class="text-muted small">
Upload a new build. The Pi mirrors it to the car hotspot on its next
update check, so the phone can install from <code>http://10.42.0.1/app</code>.
</p>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3">
<label for="version" class="form-label small fw-semibold">Version</label>
<input type="text" class="form-control" id="version" name="version" placeholder="0.1.0" required>
</div>
<div class="mb-3">
<label for="file" class="form-label small fw-semibold">APK file</label>
<input type="file" class="form-control" id="file" name="file" accept=".apk,application/vnd.android.package-archive" required>
</div>
<button type="submit" class="btn btn-primary w-100">Upload</button>
</form>
</div>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html data-bs-theme="light" lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>Install — Yaris OBD</title>
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/css/bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='yaris.css') }}">
</head>
<body class="bg-light d-flex align-items-center justify-content-center" style="min-height:100vh;">
<div class="card shadow-sm p-4" style="width:100%;max-width:420px;">
<div class="text-center mb-3">
<span class="sidebar-brand-logo" style="color:#1c1c1e;">YARIS-OBD</span>
<div class="badge bg-warning text-dark mt-1">Android companion</div>
</div>
{% if apk_exists %}
<a class="btn btn-primary w-100 btn-lg" href="{{ url_for('app_download_apk') }}">
<i class="fas fa-download me-1"></i>Download yaris-obd.apk
</a>
<p class="text-muted small text-center mt-2">
{{ (apk_size / 1024 / 1024) | round(1) }} MB — after downloading, open the
file and enable "Install unknown apps" for your browser if prompted.
</p>
{% else %}
<div class="alert alert-warning mb-0">
The APK hasn't been mirrored yet. The Pi downloads it from the server
automatically when it has internet — try again shortly.
</div>
{% endif %}
</div>
</body>
</html>