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.
- 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)
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Yaris OBD</string>
<string name="app_name">yaris-obd</string>
</resources>
+10 -8
View File
@@ -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 [email protected]:~/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
+18
View File
@@ -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':
+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 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():
+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>