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.
|
dark top bar.
|
||||||
- Follow-up polish deferred: card shadows, blue `btn-primary` buttons, page titles.
|
- Follow-up polish deferred: card shadows, blue `btn-primary` buttons, page titles.
|
||||||
|
|
||||||
**28.8 — Polish + distribution 🚧 IN PROGRESS (2026-08-15)**
|
**28.8 — Polish + distribution ✅ DONE (2026-08-15)**
|
||||||
- ✅ App icon (adaptive, red bg + white gauge), native splash screen (API 31+), "Yaris OBD"
|
- ✅ App icon (adaptive, red bg + white gauge), native splash (API 31+), "yaris-obd"
|
||||||
label, OS-following dark mode.
|
label, OS-following dark mode.
|
||||||
- ✅ Release build: local signing keystore (gitignored), R8-verified — `app-release.apk`
|
- ✅ Release build: local signing keystore (gitignored), R8-verified signed APK (~43 MB).
|
||||||
(~43 MB) installs and launches on the Pixel.
|
- ✅ Distribution pipeline (server → Pi → phone): the server publishes the APK
|
||||||
- ⬜ Distribution: host the APK on the Pi (`GET /app` → `static/app/` + download page).
|
(`/api/v1/app/latest` + `/app/yaris-obd.apk` + a portal upload page), the Pi mirrors it
|
||||||
Play Store out of scope.
|
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)
|
#### Open questions (resolve during 28.1)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Yaris OBD</string>
|
<string name="app_name">yaris-obd</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
+10
-8
@@ -261,17 +261,19 @@ android/
|
|||||||
- [ ] Error handling audit — most screens have error states; dashboard still shows "—" when offline
|
- [ ] 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
|
- [ ] 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] Release signing key: `android/keystore/release.keystore` (gitignored) + `keystore.properties`
|
||||||
- [x] ProGuard/R8 rules verified — release APK builds AND launches on the Pixel
|
- [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)
|
- [x] APK output: `app/build/outputs/apk/release/app-release.apk` (~43 MB, signed)
|
||||||
- [ ] Self-hosted distribution:
|
- [x] Self-hosted distribution (server → Pi → phone):
|
||||||
- Copy APK to Pi: `scp app-release.apk [email protected]:~/app/yaris-obd/static/app/`
|
- Server: `GET /api/v1/app/latest` (public version+sha256) + `GET /app/yaris-obd.apk`
|
||||||
- Add a lightweight Pi route: `GET /app` → serves `static/app/index.html` with
|
(download) + `GET/POST /app/upload` (portal page to publish a build)
|
||||||
download link + install instructions
|
- Pi: `GET /app` (download page) + `GET /app/yaris-obd.apk`; `deploy/yaris_app_updater.py`
|
||||||
- Phone flow: connect to Pi hotspot → open `http://yaris.local/app` → download APK →
|
mirrors the latest APK from the server on a systemd timer
|
||||||
install (user must enable "Install unknown apps" once)
|
(`deploy/yaris-app-updater.{service,timer}` + `deploy/install_app_updater.sh`)
|
||||||
- [ ] Commit: `feat: self-hosted distribution`
|
- 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
|
## Working notes
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,24 @@ def settings():
|
|||||||
def status_page():
|
def status_page():
|
||||||
return render_template('status.html', account=account, car=car)
|
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'])
|
@app.route('/api/settings', methods=['GET', 'POST'])
|
||||||
def api_settings():
|
def api_settings():
|
||||||
if request.method == 'GET':
|
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 functools
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
@@ -14,7 +15,7 @@ from collections import defaultdict
|
|||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Flask, request, jsonify, g, render_template, redirect, url_for,
|
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
|
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).
|
# array via docker-compose - see DASHCAM_STORAGE_DIR in .env).
|
||||||
STORAGE_DIR = os.environ.get("DASHCAM_STORAGE_DIR", "/data/dashcam")
|
STORAGE_DIR = os.environ.get("DASHCAM_STORAGE_DIR", "/data/dashcam")
|
||||||
MAX_DASHCAM_UPLOAD_MB = int(os.environ.get("MAX_DASHCAM_UPLOAD_MB", "2048"))
|
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
|
app.config["MAX_CONTENT_LENGTH"] = MAX_DASHCAM_UPLOAD_MB * 1024 * 1024
|
||||||
|
|
||||||
# Sanity cap on tracking points per request - the Pi client batches in
|
# Sanity cap on tracking points per request - the Pi client batches in
|
||||||
@@ -145,6 +152,81 @@ def health():
|
|||||||
return jsonify(status="ok"), 200
|
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")
|
@app.post("/api/v1/tracking/batch")
|
||||||
@require_device_auth
|
@require_device_auth
|
||||||
def upload_tracking_batch():
|
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