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