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