feat: APK distribution pipeline — server hosting + Pi auto-update + /app routes
This commit is contained in:
@@ -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,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
@@ -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
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user