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
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
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 %}
+
+
+