Add VMC Mediabox OTA (admin upload + box update check) and appliance remote fixes.

Settings key opens VMC-menu, CHANNEL P+/P- zaps live TV, and Dokploy persists android-tv APK volume.
This commit is contained in:
Jos Vooges | STH 2026-09-26 03:43:27 +02:00
parent 035ec9dc98
commit 1ef0c4339f
30 changed files with 2505 additions and 77 deletions

View file

@ -0,0 +1,279 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Nav, useAuth } from "@/components/Nav";
type ApkMeta = {
available?: boolean;
flavor?: string;
versionCode?: number | null;
versionName?: string | null;
changelog?: string | null;
filename?: string;
sizeBytes?: number;
builtAt?: string | null;
productName?: string;
note?: string;
};
function formatBytes(n: number): string {
if (!n || n <= 0) return "—";
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export default function AndroidTvOtaPage() {
useAuth();
const fileRef = useRef<HTMLInputElement>(null);
const [meta, setMeta] = useState<ApkMeta | null>(null);
const [err, setErr] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [versionCode, setVersionCode] = useState("167");
const [versionName, setVersionName] = useState("0.15.4-box");
const [changelog, setChangelog] = useState("");
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState<number | null>(null);
const [deleting, setDeleting] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const res = await fetch("/api/v1/admin/install/android-tv", {
credentials: "include",
cache: "no-store",
});
const data = (await res.json()) as ApkMeta & {
error?: { message?: string };
};
if (!res.ok) {
throw new Error(data.error?.message || `Meta ophalen mislukt (${res.status})`);
}
setMeta(data);
if (data.versionCode != null) setVersionCode(String(data.versionCode));
if (data.versionName) setVersionName(String(data.versionName));
if (data.changelog) setChangelog(String(data.changelog));
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
setMeta(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
function uploadFile(file: File | null) {
if (!file) return;
if (!file.name.toLowerCase().endsWith(".apk")) {
setErr("Kies een .apk (appliance release)");
return;
}
const code = Number(versionCode);
if (!Number.isFinite(code) || code < 1) {
setErr("versionCode moet een positief getal zijn");
return;
}
if (!versionName.trim()) {
setErr("versionName is verplicht");
return;
}
setErr(null);
setMsg(null);
setUploading(true);
setProgress(0);
const fd = new FormData();
fd.append("file", file, file.name);
fd.append("versionCode", String(Math.floor(code)));
fd.append("versionName", versionName.trim());
fd.append("flavor", "appliance");
if (changelog.trim()) fd.append("changelog", changelog.trim());
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/v1/admin/install/android-tv");
xhr.withCredentials = true;
xhr.upload.onprogress = (ev) => {
if (!ev.lengthComputable) return;
setProgress(Math.round((ev.loaded / ev.total) * 100));
};
xhr.onload = () => {
setUploading(false);
setProgress(null);
if (fileRef.current) fileRef.current.value = "";
try {
const data = JSON.parse(xhr.responseText) as ApkMeta & {
error?: { message?: string };
};
if (xhr.status < 200 || xhr.status >= 300) {
setErr(data.error?.message || `Upload mislukt (${xhr.status})`);
return;
}
setMeta(data);
if (data.versionCode != null) setVersionCode(String(data.versionCode));
if (data.versionName) setVersionName(String(data.versionName));
setMsg(
`Live voor boxes: v${data.versionName} (${data.versionCode}) · ${formatBytes(data.sizeBytes ?? 0)}`
);
} catch {
setErr(`Upload mislukt (${xhr.status})`);
}
};
xhr.onerror = () => {
setUploading(false);
setProgress(null);
setErr("Netwerkfout tijdens upload — proxy/timeout?");
};
xhr.send(fd);
}
async function removeApk() {
if (!confirm("OTA-APK van Master verwijderen?")) return;
setDeleting(true);
setErr(null);
setMsg(null);
try {
const res = await fetch("/api/v1/admin/install/android-tv", {
method: "DELETE",
credentials: "include",
});
const data = (await res.json()) as ApkMeta & { error?: { message?: string } };
if (!res.ok) throw new Error(data.error?.message || `Verwijderen mislukt (${res.status})`);
setMeta(data);
setMsg("APK verwijderd");
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setDeleting(false);
}
}
return (
<>
<Nav />
<main className="page">
<header className="page-header">
<div>
<p className="page-kicker">Clients</p>
<h1>Android TV / Mediabox OTA</h1>
<p className="muted" style={{ marginTop: 8, maxWidth: 640 }}>
Upload een nieuwe <code>app-appliance-release.apk</code>. Boxes checken
periodiek en tonen een melding om te installeren — geen USB-flash nodig.
</p>
</div>
<button type="button" onClick={() => void load()} disabled={loading || uploading}>
Vernieuwen
</button>
</header>
{err && <p className="banner error">{err}</p>}
{msg && <p className="banner ok">{msg}</p>}
<section className="card" style={{ marginBottom: 24 }}>
<h2 className="section-title">Huidige release op Master</h2>
{loading ? (
<p className="muted">Laden…</p>
) : meta?.available ? (
<dl className="kv">
<dt>Product</dt>
<dd>{meta.productName || "VMC Mediabox"}</dd>
<dt>Versie</dt>
<dd>
{meta.versionName}{" "}
<span className="muted">(code {meta.versionCode})</span>
</dd>
<dt>Grootte</dt>
<dd>{formatBytes(meta.sizeBytes ?? 0)}</dd>
<dt>Geüpload</dt>
<dd>{meta.builtAt ? new Date(meta.builtAt).toLocaleString("nl-NL") : "—"}</dd>
{meta.changelog ? (
<>
<dt>Changelog</dt>
<dd style={{ whiteSpace: "pre-wrap" }}>{meta.changelog}</dd>
</>
) : null}
</dl>
) : (
<p style={{ marginTop: 0 }}>{meta?.note || "Nog geen APK op Master."}</p>
)}
{meta?.available && (
<button
type="button"
className="btn-danger"
style={{ marginTop: 16 }}
disabled={deleting || uploading}
onClick={() => void removeApk()}
>
{deleting ? "Verwijderen…" : "APK verwijderen"}
</button>
)}
</section>
<section className="card">
<h2 className="section-title">Nieuwe APK uploaden</h2>
<p className="muted" style={{ marginTop: 0 }}>
versionCode / versionName moeten{" "}
<strong>hoger</strong> zijn dan wat op de boxes staat (
<code>apps/android-tv/app/build.gradle.kts</code>).
</p>
<div className="form-grid" style={{ marginTop: 16 }}>
<label>
versionCode
<input
value={versionCode}
onChange={(e) => setVersionCode(e.target.value)}
disabled={uploading}
inputMode="numeric"
/>
</label>
<label>
versionName
<input
value={versionName}
onChange={(e) => setVersionName(e.target.value)}
disabled={uploading}
placeholder="0.15.4-box"
/>
</label>
</div>
<label style={{ display: "block", marginTop: 12 }}>
Changelog (optioneel, zichtbaar op de box)
<textarea
value={changelog}
onChange={(e) => setChangelog(e.target.value)}
disabled={uploading}
rows={3}
style={{ width: "100%", marginTop: 6 }}
/>
</label>
<label style={{ display: "block", marginTop: 16 }}>
APK-bestand
<input
ref={fileRef}
type="file"
accept=".apk,application/vnd.android.package-archive"
disabled={uploading}
onChange={(e) => uploadFile(e.target.files?.[0] ?? null)}
style={{ display: "block", marginTop: 8 }}
/>
</label>
{uploading && (
<p className="muted" style={{ marginTop: 12 }}>
Uploaden… {progress != null ? `${progress}%` : ""}
</p>
)}
</section>
<p className="muted" style={{ marginTop: 28 }}>
Gerelateerd:{" "}
<Link href="/agent">Token Tray agent</Link> ·{" "}
<Link href="/viewers">Gebruikers / devices</Link>
</p>
</main>
</>
);
}

View file

@ -57,8 +57,12 @@ function clearSessionCookie(response: NextResponse, request: NextRequest) {
}); });
} }
function isTokenTrayUpload(method: string, targetPath: string): boolean { function isLargeBinaryUpload(method: string, targetPath: string): boolean {
return method === "POST" && targetPath === "v1/admin/install/token-tray"; return (
method === "POST" &&
(targetPath === "v1/admin/install/token-tray" ||
targetPath === "v1/admin/install/android-tv")
);
} }
async function proxy(request: NextRequest, pathSegments: string[]) { async function proxy(request: NextRequest, pathSegments: string[]) {
@ -69,7 +73,7 @@ async function proxy(request: NextRequest, pathSegments: string[]) {
const isLogout = request.method === "POST" && targetPath === "v1/auth/logout"; const isLogout = request.method === "POST" && targetPath === "v1/auth/logout";
const isSse = const isSse =
request.method === "GET" && targetPath === "v1/admin/viewers/events"; request.method === "GET" && targetPath === "v1/admin/viewers/events";
const streamUpload = isTokenTrayUpload(request.method, targetPath); const streamUpload = isLargeBinaryUpload(request.method, targetPath);
const headers = new Headers(); const headers = new Headers();
const contentType = request.headers.get("content-type"); const contentType = request.headers.get("content-type");

View file

@ -17,6 +17,7 @@ const desktopLinks = [
{ href: "/drm", label: "DRM" }, { href: "/drm", label: "DRM" },
{ href: "/scripts", label: "Scripts" }, { href: "/scripts", label: "Scripts" },
{ href: "/agent", label: "Agent" }, { href: "/agent", label: "Agent" },
{ href: "/android-tv", label: "Android TV" },
{ href: "/viewers", label: "Gebruikers" }, { href: "/viewers", label: "Gebruikers" },
{ href: "/settings", label: "Instellingen" }, { href: "/settings", label: "Instellingen" },
]; ];

View file

@ -20,14 +20,31 @@ android {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 150 versionCode = 168
versionName = "0.14.87" versionName = "0.15.5"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
buildConfigField("boolean", "APPLIANCE_BUILD", "false")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")
} }
} }
flavorDimensions += "distribution"
productFlavors {
create("store") {
dimension = "distribution"
// Play Store / generieke Android TV — geen kiosk
buildConfigField("boolean", "APPLIANCE_BUILD", "false")
}
create("appliance") {
dimension = "distribution"
// X88 / dedicated VMC-box — never upload this flavor to Play
applicationId = "nl.vonas.mediacluster.tv.appliance"
buildConfigField("boolean", "APPLIANCE_BUILD", "true")
versionNameSuffix = "-box"
}
}
signingConfigs { signingConfigs {
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
create("release") { create("release") {

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Dedicated VMC-box (X88 e.d.): HOME + boot + watchdog + Settings-intercept. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter android:priority="1000">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<receiver
android:name=".BootReceiver"
android:exported="true"
android:directBootAware="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<service
android:name=".WatchdogService"
android:exported="false"
android:process=":watchdog"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="appliance_watchdog" />
</service>
<service
android:name=".ApplianceAccessibilityService"
android:exported="true"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/appliance_accessibility" />
</service>
</application>
</manifest>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">VMC Mediabox</string>
</resources>

View file

@ -4,6 +4,11 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature <uses-feature
android:name="android.software.leanback" android:name="android.software.leanback"
@ -25,9 +30,15 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name"
android:banner="@drawable/app_banner" android:banner="@drawable/app_banner"
android:configChanges="keyboard|keyboardHidden|navigation" android:configChanges="keyboard|keyboardHidden|navigation"
android:screenOrientation="landscape"> android:launchMode="singleTask"
android:screenOrientation="landscape"
android:clearTaskOnLaunch="true"
android:stateNotNeeded="true"
android:theme="@style/Theme.MediaClusterTv">
<!-- App drawer / Leanback row -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />

View file

@ -0,0 +1,288 @@
package nl.vonas.mediacluster.tv
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.provider.Settings
import android.util.Log
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import java.util.concurrent.TimeUnit
/** Default installer PIN for full systeeminstellingen (change per fleet later). */
const val APPLIANCE_INSTALLER_PIN = "2580"
/**
* Soft-kiosk helpers for X88 / Android TV.
*
* Debloat and MainSettings-disable belong in the one-shot adb scripts
* (debloat-x88.ps1 / set-vmc-home-x88.ps1). Do NOT run heavy `pm disable`
* storms from the app — that ANRs the HOME activity on X88.
*
* Settings remote key: MainSettings disabled via script; KEYCODE_SETTINGS
* is also handled in [MainActivity.dispatchKeyEvent] when focused.
*/
object ApplianceLockdown {
private const val TAG = "VmcLockdown"
private const val ALLOW_FILE = "vmc_settings_allow_until"
const val TV_SETTINGS_PKG = "com.android.tv.settings"
const val TV_SETTINGS_MAIN = "com.android.tv.settings.MainSettings"
const val TV_SETTINGS_NETWORK = "com.android.tv.settings.connectivity.NetworkActivity"
const val TV_SETTINGS_WIFI_SETUP = "com.android.tv.settings.connectivity.setup.WifiSetupActivity"
private val BLOCKED_COMPONENTS = listOf(
"$TV_SETTINGS_PKG/$TV_SETTINGS_MAIN",
"$TV_SETTINGS_PKG/com.android.tv.settings.advance_settings.AdvanceSettingsActivity",
)
/** App-shortcut keys + bloat — disable whole packages on the box. */
private val BLOCKED_APP_PACKAGES = listOf(
// Streaming / stores
"com.netflix.mediaclient",
"com.google.android.youtube.tv",
"com.amazon.amazonvideo.livingroom",
"com.disney.disneyplus",
"cm.aptoidetv.pt",
"com.ionitech.airscreen",
// OEM junk
"com.example.changeled",
"com.example.staticistall2",
"com.francis.wifitest",
"com.swe.myapplication",
"com.droidlogic.allapps",
"com.droidlogic.FileBrower",
"com.droidlogic.launcher.provider",
"com.rockchips.mediacenter",
"com.android.music",
"com.android.calculator2",
// OTA updaters
"com.amediatech.update",
"android.rockchip.update.service",
"com.chihi.fota",
// Google TV chrome (priv-apps sometimes re-enable after reboot)
"com.google.android.katniss",
"com.google.android.tvrecommendations",
"com.google.android.apps.mediashell",
"com.google.android.tts",
"com.google.android.marvin.talkback",
"com.google.android.syncadapters.calendar",
"com.google.android.feedback",
"com.google.android.tvlauncher",
"com.android.vending",
// Old Play-flavor VMC if still present
"nl.vonas.mediacluster.tv",
)
/** File in app dataDir — shared across main + :watchdog processes. */
private fun allowUntilFile(ctx: Context): java.io.File =
java.io.File(ctx.applicationContext.applicationInfo.dataDir, ALLOW_FILE)
fun allowFullSettingsFor(ctx: Context, durationMs: Long = 15 * 60_000L) {
if (!BuildConfig.APPLIANCE_BUILD) return
val until = System.currentTimeMillis() + durationMs
runCatching { allowUntilFile(ctx).writeText(until.toString()) }
setBlockedEnabled(enabled = true)
Log.i(TAG, "full settings allowed until $until")
}
fun clearFullSettingsAllow(ctx: Context) {
runCatching { allowUntilFile(ctx).delete() }
}
fun isFullSettingsAllowed(ctx: Context): Boolean {
if (!BuildConfig.APPLIANCE_BUILD) return false
val raw = runCatching { allowUntilFile(ctx).readText().trim() }.getOrNull() ?: return false
val until = raw.toLongOrNull() ?: return false
return until > System.currentTimeMillis()
}
/** OEM sometimes keeps bootanimation running; also windows stay invisible until exit=1. */
fun stopBootAnimation() {
runSu(
"setprop service.bootanim.exit 1; " +
"setprop ctl.stop bootanim; " +
"stop bootanim; " +
"killall bootanimation >/dev/null 2>&1; " +
"true",
)
}
fun clearBrokenAssistantRole() {
runSu(
"cmd role remove-role-holder android.app.role.ASSISTANT com.google.android.katniss >/dev/null 2>&1; " +
"cmd role clear-role-holders android.app.role.ASSISTANT >/dev/null 2>&1; " +
"true",
)
}
/**
* Light appliance boot: re-disable MainSettings + enable passive a11y (no key filter).
* Heavy debloat stays in adb scripts.
*/
fun applyIfAppliance(ctx: Context) {
if (!BuildConfig.APPLIANCE_BUILD) return
Thread({
disableMainSettingsComponents()
enableSettingsInterceptA11y(ctx)
stopBootAnimation()
}, "vmc-appliance-light").start()
}
fun applyIfApplianceAsync(ctx: Context) = applyIfAppliance(ctx)
fun disableMainSettingsComponents() {
setBlockedEnabled(enabled = false)
}
/** Passive window-bounce service only — never enable key filtering. */
fun enableSettingsInterceptA11y(ctx: Context) {
val cn = ComponentName(ctx, ApplianceAccessibilityService::class.java).flattenToString()
runSu(
"settings put secure enabled_accessibility_services '$cn'; " +
"settings put secure accessibility_enabled 1; true",
)
}
/** Script / installer PIN path only — never call from onCreate/onResume. */
fun disableBloatApps() {
for (pkg in BLOCKED_APP_PACKAGES) {
runSu("pm disable-user --user 0 $pkg >/dev/null 2>&1; pm disable $pkg >/dev/null 2>&1; true")
}
}
@Deprecated("Use disableBloatApps", ReplaceWith("disableBloatApps()"))
fun disableStreamingApps() = disableBloatApps()
fun isForbiddenForeground(top: String): Boolean {
if (top.startsWith(TV_SETTINGS_PKG) &&
(top.contains("MainSettings") || top.contains("AdvanceSettings"))
) {
return true
}
return BLOCKED_APP_PACKAGES.any { top.startsWith(it) }
}
fun setBlockedEnabled(enabled: Boolean) {
val state = if (enabled) "enable" else "disable"
for (comp in BLOCKED_COMPONENTS) {
runSu("pm $state $comp")
}
}
fun disableAccessibilityService() {
runSu(
"settings put secure accessibility_enabled 0; " +
"settings put secure enabled_accessibility_services ''; " +
"true",
)
}
@Deprecated("Do not enable — breaks remote key delivery on X88")
fun ensureAccessibilityEnabled() {
disableAccessibilityService()
}
/** Returns package/class of top resumed activity, or null. */
fun readTopActivity(): String? {
val out = runSuCapture(
"dumpsys activity activities | grep -E 'topResumedActivity|mResumedActivity' | head -3",
) ?: return null
val m = Regex(
"""(?:topResumedActivity|mResumedActivity)=ActivityRecord\{[^ ]+ [^ ]+ ([^ ]+)""",
).find(out)
return m?.groupValues?.getOrNull(1)
}
fun runSu(command: String): Boolean {
val (ok, _) = runSuDetailed(command)
return ok
}
fun runSuCapture(command: String): String? {
val (ok, out) = runSuDetailed(command)
return out.takeIf { ok || out.isNotBlank() }
}
private fun runSuDetailed(command: String): Pair<Boolean, String> {
return try {
val pb = ProcessBuilder("su", "0", "sh", "-c", command)
.redirectErrorStream(true)
val p = pb.start()
val out = p.inputStream.bufferedReader().readText()
val finished = p.waitFor(8, TimeUnit.SECONDS)
if (!finished) runCatching { p.destroyForcibly() }
val code = if (finished) p.exitValue() else -1
val ok = finished && code == 0
if (!ok) Log.w(TAG, "su failed ($command) code=$code: ${out.take(200)}")
ok to out
} catch (t: Throwable) {
Log.w(TAG, "su not available for: $command", t)
false to ""
}
}
}
fun Context.isVmcDefaultHome(): Boolean {
val home = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
val res = packageManager.resolveActivity(home, PackageManager.MATCH_DEFAULT_ONLY) ?: return false
return res.activityInfo?.packageName == packageName
}
fun Context.openWifiSettings() {
val candidates = listOf(
Intent().setClassName(
ApplianceLockdown.TV_SETTINGS_PKG,
ApplianceLockdown.TV_SETTINGS_NETWORK,
),
Intent().setClassName(
ApplianceLockdown.TV_SETTINGS_PKG,
ApplianceLockdown.TV_SETTINGS_WIFI_SETUP,
),
Intent(Settings.ACTION_WIFI_SETTINGS),
Intent(Settings.ACTION_WIRELESS_SETTINGS),
Intent("android.settings.WIFI_SETTINGS"),
Intent("android.settings.panel.action.WIFI"),
)
for (intent in candidates) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (intent.resolveActivity(packageManager) != null) {
runCatching { startActivity(intent) }
return
}
}
}
fun Context.openSystemSettings() {
ApplianceLockdown.allowFullSettingsFor(this)
val intent = Intent(Settings.ACTION_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.component = ComponentName(
ApplianceLockdown.TV_SETTINGS_PKG,
ApplianceLockdown.TV_SETTINGS_MAIN,
)
runCatching { startActivity(intent) }
.onFailure {
intent.component = null
runCatching { startActivity(intent) }
}
}
object ApplianceEvents {
const val ACTION_OPEN_HUB = "nl.vonas.mediacluster.tv.action.OPEN_APPLIANCE_HUB"
private val _openHub = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val openHub: SharedFlow<Unit> = _openHub.asSharedFlow()
fun requestOpenHub() {
_openHub.tryEmit(Unit)
}
/** Cross-process (watchdog → UI). */
fun requestOpenHub(context: Context) {
requestOpenHub()
val i = Intent(ACTION_OPEN_HUB).setPackage(context.packageName)
context.sendBroadcast(i)
}
}

View file

@ -0,0 +1,56 @@
package nl.vonas.mediacluster.tv
import android.accessibilityservice.AccessibilityService
import android.content.Intent
import android.os.SystemClock
import android.util.Log
import android.view.accessibility.AccessibilityEvent
/**
* Appliance only: intercept OEM Settings hard-launch (MainSettings).
* Does NOT filter key events — that broke remotes on X88.
*/
class ApplianceAccessibilityService : AccessibilityService() {
private var lastBounceElapsed = 0L
override fun onServiceConnected() {
Log.i(TAG, "a11y connected (window bounce only — no key filter)")
ApplianceLockdown.disableMainSettingsComponents()
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (!BuildConfig.APPLIANCE_BUILD) return
if (event?.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
if (ApplianceLockdown.isFullSettingsAllowed(this)) return
val pkg = event.packageName?.toString() ?: return
if (pkg != ApplianceLockdown.TV_SETTINGS_PKG) return
val cls = event.className?.toString().orEmpty()
if (!cls.contains("MainSettings") && !cls.contains("AdvanceSettings")) return
val now = SystemClock.elapsedRealtime()
if (now - lastBounceElapsed < 900) return
lastBounceElapsed = now
Log.i(TAG, "blocked $pkg/$cls → VMC-menu")
performGlobalAction(GLOBAL_ACTION_BACK)
val launch = Intent(this, MainActivity::class.java).apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or
Intent.FLAG_ACTIVITY_SINGLE_TOP,
)
putExtra(EXTRA_OPEN_HUB, true)
}
runCatching { startActivity(launch) }
ApplianceEvents.requestOpenHub(this)
}
override fun onInterrupt() = Unit
companion object {
private const val TAG = "VmcA11y"
const val EXTRA_OPEN_HUB = "open_appliance_hub"
}
}

View file

@ -0,0 +1,43 @@
package nl.vonas.mediacluster.tv
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
/**
* Starts VMC after boot when we are (or want to be) the appliance UI.
* Safe if another HOME is still default — just brings VMC to front once.
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
val action = intent?.action ?: return
if (action != Intent.ACTION_BOOT_COMPLETED &&
action != Intent.ACTION_LOCKED_BOOT_COMPLETED &&
action != Intent.ACTION_MY_PACKAGE_REPLACED
) {
return
}
Log.i(TAG, "boot/replace → start MainActivity ($action)")
if (BuildConfig.APPLIANCE_BUILD) {
ApplianceLockdown.applyIfAppliance(context)
} else {
Thread({ ApplianceLockdown.stopBootAnimation() }, "vmc-bootanim").start()
}
val launch = Intent(context, MainActivity::class.java).apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED or
Intent.FLAG_ACTIVITY_SINGLE_TOP,
)
this.action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_HOME)
}
runCatching { context.startActivity(launch) }
.onFailure { Log.w(TAG, "start MainActivity failed", it) }
}
companion object {
private const val TAG = "VmcBoot"
}
}

View file

@ -1,7 +1,13 @@
package nl.vonas.mediacluster.tv package nl.vonas.mediacluster.tv
import android.app.ActivityManager
import android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager import android.media.AudioManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@ -9,14 +15,84 @@ import nl.vonas.mediacluster.tv.ui.McNav
import nl.vonas.mediacluster.tv.ui.theme.MediaClusterTheme import nl.vonas.mediacluster.tv.ui.theme.MediaClusterTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val hubReceiver = object : BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
if (intent?.action == ApplianceEvents.ACTION_OPEN_HUB) {
ApplianceEvents.requestOpenHub()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val appLabel = getString(R.string.app_name)
title = appLabel
if (Build.VERSION.SDK_INT >= 33) {
setTaskDescription(
ActivityManager.TaskDescription.Builder()
.setLabel(appLabel)
.build(),
)
} else {
@Suppress("DEPRECATION")
setTaskDescription(ActivityManager.TaskDescription(appLabel))
}
volumeControlStream = AudioManager.STREAM_MUSIC volumeControlStream = AudioManager.STREAM_MUSIC
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
MediaClusterTheme { MediaClusterTheme {
McNav() McNav()
} }
} }
handleHubIntent(intent)
val filter = IntentFilter(ApplianceEvents.ACTION_OPEN_HUB)
if (Build.VERSION.SDK_INT >= 33) {
registerReceiver(hubReceiver, filter, RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(hubReceiver, filter)
}
// Appliance: light MainSettings-disable + passive a11y (no key filter / no watchdog).
if (BuildConfig.APPLIANCE_BUILD) {
ApplianceLockdown.applyIfAppliance(this)
}
}
override fun onDestroy() {
runCatching { unregisterReceiver(hubReceiver) }
super.onDestroy()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleHubIntent(intent)
}
override fun onResume() {
super.onResume()
// Do not re-run lockdown / debloat here — that caused restart loops.
}
private fun handleHubIntent(intent: Intent?) {
if (intent?.getBooleanExtra(ApplianceAccessibilityService.EXTRA_OPEN_HUB, false) == true) {
intent.removeExtra(ApplianceAccessibilityService.EXTRA_OPEN_HUB)
ApplianceEvents.requestOpenHub()
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (BuildConfig.APPLIANCE_BUILD &&
event.action == KeyEvent.ACTION_DOWN &&
(event.keyCode == KeyEvent.KEYCODE_SETTINGS ||
event.keyCode == KeyEvent.KEYCODE_TV_INPUT)
) {
ApplianceEvents.requestOpenHub()
return true
}
return super.dispatchKeyEvent(event)
} }
} }

View file

@ -0,0 +1,187 @@
package nl.vonas.mediacluster.tv
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.net.ConnectException
import java.net.InetAddress
import java.net.SocketTimeoutException
import java.net.URI
import java.net.UnknownHostException
import java.util.concurrent.atomic.AtomicBoolean
import javax.net.ssl.SSLHandshakeException
import kotlin.coroutines.resume
fun Context.connectivityManager(): ConnectivityManager =
applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
/** True when a network has INTERNET and (API 23+) VALIDATED. */
fun Context.hasUsableNetwork(): Boolean {
val cm = connectivityManager()
val network = cm.activeNetwork ?: return false
val caps = cm.getNetworkCapabilities(network) ?: return false
if (!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) return false
if (Build.VERSION.SDK_INT >= 23 &&
!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
) {
return false
}
return true
}
fun Throwable.isTransientNetworkFailure(): Boolean {
var t: Throwable? = this
while (t != null) {
when (t) {
is UnknownHostException,
is ConnectException,
is SocketTimeoutException,
is SSLHandshakeException,
-> return true
}
val msg = t.message.orEmpty().lowercase()
if (msg.contains("unable to resolve host") ||
msg.contains("failed to connect") ||
msg.contains("network is unreachable") ||
msg.contains("software caused connection abort") ||
msg.contains("timeout") ||
msg.contains("connection reset")
) {
return true
}
t = t.cause
}
return false
}
fun Throwable.toUserNetworkMessage(): String {
if (isTransientNetworkFailure()) {
return "Geen internetverbinding. Wachten op Wi‑Fi…"
}
return message?.takeIf { it.isNotBlank() } ?: "Kon server niet bereiken"
}
fun apiHostFromBase(baseUrl: String): String? {
val raw = baseUrl.trim().ifBlank { return null }
return try {
val withScheme = if ("://" in raw) raw else "https://$raw"
URI(withScheme).host?.takeIf { it.isNotBlank() }
} catch (_: Throwable) {
null
}
}
/**
* Suspend until a usable (validated) network is available, or [timeoutMs] elapses.
* @return true if network became ready
*/
suspend fun Context.awaitUsableNetwork(
timeoutMs: Long = 120_000L,
onStatus: ((String) -> Unit)? = null,
): Boolean {
if (hasUsableNetwork()) return true
onStatus?.invoke("Wachten op netwerk…")
val result = withTimeoutOrNull(timeoutMs) {
suspendCancellableCoroutine { cont ->
val cm = connectivityManager()
val done = AtomicBoolean(false)
lateinit var callback: ConnectivityManager.NetworkCallback
fun finish(ok: Boolean) {
if (!done.compareAndSet(false, true)) return
runCatching { cm.unregisterNetworkCallback(callback) }
if (cont.isActive) cont.resume(ok)
}
fun maybeReady() {
if (hasUsableNetwork()) {
onStatus?.invoke("Verbonden")
finish(true)
}
}
callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = maybeReady()
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
(Build.VERSION.SDK_INT < 23 ||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))
) {
onStatus?.invoke("Verbonden")
finish(true)
}
}
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
cont.invokeOnCancellation {
runCatching { cm.unregisterNetworkCallback(callback) }
}
try {
cm.registerNetworkCallback(request, callback)
maybeReady()
} catch (_: Throwable) {
finish(hasUsableNetwork())
}
}
}
return result == true
}
/**
* VALIDATED Wi‑Fi can still fail DNS for several seconds after boot.
* Probe until [host] resolves (or timeout).
*/
suspend fun awaitHostResolvable(
host: String,
timeoutMs: Long = 90_000L,
onStatus: ((String) -> Unit)? = null,
): Boolean {
if (host.isBlank()) return true
onStatus?.invoke("Server zoeken…")
val deadline = System.currentTimeMillis() + timeoutMs
var attempt = 0
while (System.currentTimeMillis() < deadline) {
val ok = withContext(Dispatchers.IO) {
runCatching {
val addrs = InetAddress.getAllByName(host)
addrs.isNotEmpty()
}.getOrDefault(false)
}
if (ok) {
onStatus?.invoke("Server gevonden")
// Brief settle — first TCP after resolve can still flap on some OEM stacks.
delay(400)
return true
}
attempt++
onStatus?.invoke("Wachten op DNS…")
delay(if (attempt < 5) 500L else 1_200L)
}
return false
}
/**
* Full gate used at app start: validated network + DNS for API host.
*/
suspend fun Context.awaitApiReady(
apiBase: String,
networkTimeoutMs: Long = 90_000L,
dnsTimeoutMs: Long = 90_000L,
onStatus: ((String) -> Unit)? = null,
): Boolean {
val online = awaitUsableNetwork(networkTimeoutMs, onStatus)
if (!online) return false
val host = apiHostFromBase(apiBase) ?: return true
return awaitHostResolvable(host, dnsTimeoutMs, onStatus)
}

View file

@ -0,0 +1,127 @@
package nl.vonas.mediacluster.tv
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.Process
import android.os.SystemClock
import android.util.Log
import androidx.core.app.NotificationCompat
/**
* Minimal watchdog: only relaunch MainActivity if the UI process is gone.
* No logcat sniffing, no activity bouncing, no debloat — those caused restart loops
* and dead remotes on the X88.
*/
class WatchdogService : Service() {
private val handler = Handler(Looper.getMainLooper())
private var failStreak = 0
private var lastLaunchElapsed = 0L
private val tick = object : Runnable {
override fun run() {
try {
ensureUiAlive()
} catch (t: Throwable) {
Log.w(TAG, "tick error", t)
}
handler.postDelayed(this, POLL_MS)
}
}
override fun onCreate() {
super.onCreate()
ensureChannel()
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("VMC")
.setContentText("Watchdog actief")
.setSmallIcon(android.R.drawable.ic_media_play)
.setOngoing(true)
.setSilent(true)
.build()
if (Build.VERSION.SDK_INT >= 34) {
startForeground(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(NOTIF_ID, notification)
}
handler.postDelayed(tick, POLL_MS)
Log.i(TAG, "watchdog started pid=${Process.myPid()}")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
override fun onDestroy() {
handler.removeCallbacks(tick)
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun ensureUiAlive() {
if (isMainProcessAlive()) {
failStreak = 0
return
}
val now = SystemClock.elapsedRealtime()
val backoff = backoffMs(failStreak)
if (now - lastLaunchElapsed < backoff) return
failStreak = (failStreak + 1).coerceAtMost(8)
lastLaunchElapsed = now
Log.w(TAG, "UI process missing — relaunch (streak=$failStreak backoff=${backoff}ms)")
val launch = Intent(this, MainActivity::class.java).apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or
Intent.FLAG_ACTIVITY_SINGLE_TOP,
)
action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_HOME)
}
runCatching { startActivity(launch) }
.onFailure { Log.e(TAG, "relaunch failed", it) }
}
private fun isMainProcessAlive(): Boolean {
val am = getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager
@Suppress("DEPRECATION")
val procs = am.runningAppProcesses ?: return false
return procs.any { it.processName == packageName }
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < 26) return
val nm = getSystemService(NotificationManager::class.java) ?: return
val ch = NotificationChannel(CHANNEL_ID, "VMC watchdog", NotificationManager.IMPORTANCE_MIN)
ch.setShowBadge(false)
ch.enableLights(false)
ch.enableVibration(false)
nm.createNotificationChannel(ch)
}
companion object {
private const val TAG = "VmcWatchdog"
private const val CHANNEL_ID = "vmc_watchdog"
private const val NOTIF_ID = 42
private const val POLL_MS = 8_000L
/** Disabled — false "UI missing" + relaunch during ANR made the X88 unusable. */
fun start(context: Context) {
Log.i(TAG, "watchdog start ignored (disabled for stability)")
runCatching {
context.stopService(Intent(context, WatchdogService::class.java))
}
}
private fun backoffMs(streak: Int): Long {
val exp = (4_000L * (1L shl streak.coerceAtMost(5)))
return exp.coerceAtMost(120_000L)
}
}
}

View file

@ -911,6 +911,44 @@ class ApiClient(
Unit Unit
} }
suspend fun checkAppUpdate(flavor: String, versionCode: Int): AppUpdateCheck =
withContext(Dispatchers.IO) {
val q = "flavor=${java.net.URLEncoder.encode(flavor, "UTF-8")}&versionCode=$versionCode"
val req = authed("$baseUrl/api/v1/client/app-update?$q").get().build()
parseObject(execute(req)) { o ->
val latest = o.optJSONObject("latest")
AppUpdateCheck(
updateAvailable = o.optBoolean("updateAvailable"),
currentVersionCode = o.optInt("currentVersionCode", versionCode),
latestVersionCode = latest?.optInt("versionCode")?.takeIf { it > 0 },
latestVersionName = latest?.optString("versionName")
?.takeIf { it.isNotBlank() && it != "null" },
changelog = latest?.optString("changelog")
?.takeIf { it.isNotBlank() && it != "null" },
sizeBytes = latest?.optLong("sizeBytes")?.takeIf { it > 0 },
)
}
}
suspend fun downloadAppUpdateTo(dest: java.io.File) = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/app-update/download").get().build()
val dlHttp = http.newBuilder()
.readTimeout(10, TimeUnit.MINUTES)
.writeTimeout(2, TimeUnit.MINUTES)
.build()
dlHttp.newCall(req).execute().use { res ->
if (!res.isSuccessful) {
val text = res.body?.string().orEmpty()
val msg = runCatching {
JSONObject(text).optJSONObject("error")?.optString("message")
}.getOrNull() ?: "HTTP ${res.code}"
throw ApiException(msg)
}
val body = res.body ?: throw ApiException("Lege download")
dest.outputStream().use { out -> body.byteStream().copyTo(out) }
}
}
private fun JSONObject.toScheduleEvent(): ScheduleEvent? { private fun JSONObject.toScheduleEvent(): ScheduleEvent? {
val id = optClean("id") ?: return null val id = optClean("id") ?: return null
val name = optClean("name") ?: return null val name = optClean("name") ?: return null
@ -1158,6 +1196,15 @@ class ApiClient(
class ApiException(message: String) : Exception(message) class ApiException(message: String) : Exception(message)
data class AppUpdateCheck(
val updateAvailable: Boolean,
val currentVersionCode: Int,
val latestVersionCode: Int? = null,
val latestVersionName: String? = null,
val changelog: String? = null,
val sizeBytes: Long? = null,
)
data class DeviceCodeResponse( data class DeviceCodeResponse(
val code: String, val code: String,
val expiresAt: String, val expiresAt: String,

View file

@ -0,0 +1,374 @@
package nl.vonas.mediacluster.tv.ui
import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import nl.vonas.mediacluster.tv.APPLIANCE_INSTALLER_PIN
import nl.vonas.mediacluster.tv.hasUsableNetwork
import nl.vonas.mediacluster.tv.isVmcDefaultHome
import nl.vonas.mediacluster.tv.openSystemSettings
import nl.vonas.mediacluster.tv.openWifiSettings
import nl.vonas.mediacluster.tv.ui.theme.McColors
/**
* Offline first-boot / power-cycle: big Wi‑Fi CTA for non-technical users.
* Stays until [hasUsableNetwork] becomes true (caller waits in parallel).
*/
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun NetworkSetupScreen(
statusLine: String = "Geen internetverbinding",
) {
val context = LocalContext.current
val focus = remember { FocusRequester() }
var online by remember { mutableStateOf(context.hasUsableNetwork()) }
LaunchedEffect(Unit) {
runCatching { focus.requestFocus() }
while (true) {
online = context.hasUsableNetwork()
if (online) break
delay(1_000)
}
}
Box(
Modifier
.fillMaxSize()
.background(
Brush.radialGradient(
listOf(Color(0xFF1A1020), McColors.Bg),
radius = 1_100f,
),
),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.widthIn(max = 720.dp)
.padding(horizontal = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
Brand.shortName,
color = McColors.Accent,
fontSize = 32.sp,
fontWeight = FontWeight.Black,
letterSpacing = 1.sp,
)
Spacer(Modifier.height(10.dp))
Text(
"Internet nodig",
color = Color.White,
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
"Koppel deze box eerst aan je Wi‑Fi. " +
"Daarna start Vonas Media Cluster automatisch.",
color = McColors.TextDim,
fontSize = 16.sp,
textAlign = TextAlign.Center,
lineHeight = 24.sp,
)
Spacer(Modifier.height(28.dp))
Text(
if (online) "Verbonden — doorgaan…" else statusLine,
color = if (online) Color(0xFF7DDA9A) else Color(0xFFB8B8C4),
fontSize = 15.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(28.dp))
Surface(
onClick = { context.openWifiSettings() },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focus),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.03f),
colors = ClickableSurfaceDefaults.colors(
containerColor = McColors.Accent,
focusedContainerColor = Color(0xFFE85A45),
),
) {
Text(
"Wi‑Fi instellen",
Modifier
.fillMaxWidth()
.padding(vertical = 18.dp),
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(14.dp))
Text(
"Kies je netwerk, vul het wachtwoord in, ga terug met Back.",
color = Color(0xFF7A7A88),
fontSize = 13.sp,
textAlign = TextAlign.Center,
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun ApplianceHubDialog(
onDismiss: () -> Unit,
onChangeServer: (() -> Unit)? = null,
) {
val context = LocalContext.current
val appliance = remember { context.isVmcDefaultHome() }
var showPin by remember { mutableStateOf(false) }
var pin by remember { mutableStateOf("") }
var pinError by remember { mutableStateOf(false) }
val firstFocus = remember { FocusRequester() }
var online by remember { mutableStateOf(context.hasUsableNetwork()) }
LaunchedEffect(Unit) {
runCatching { firstFocus.requestFocus() }
while (true) {
online = context.hasUsableNetwork()
delay(2_000)
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
Modifier
.fillMaxSize()
.background(Color(0xCC000000)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.widthIn(max = 520.dp)
.padding(24.dp)
.background(Color(0xFF16161E), RoundedCornerShape(18.dp))
.padding(28.dp),
) {
Text(
"VMC-menu",
color = Color.White,
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(6.dp))
Text(
if (online) "Internet: verbonden" else "Internet: niet verbonden",
color = if (online) Color(0xFF7DDA9A) else Color(0xFFE8A0A0),
fontSize = 14.sp,
)
Spacer(Modifier.height(22.dp))
if (!showPin) {
HubRow(
title = "Wi‑Fi instellen",
subtitle = "Netwerk kiezen of wachtwoord wijzigen",
modifier = Modifier.focusRequester(firstFocus),
onClick = {
context.openWifiSettings()
onDismiss()
},
)
Spacer(Modifier.height(10.dp))
if (onChangeServer != null) {
HubRow(
title = "Server wijzigen",
subtitle = "VMC-serveradres",
onClick = {
onDismiss()
onChangeServer()
},
)
Spacer(Modifier.height(10.dp))
}
if (appliance) {
HubRow(
title = "Systeeminstellingen…",
subtitle = "Alleen voor installateur (PIN)",
onClick = {
pin = ""
pinError = false
showPin = true
},
)
Spacer(Modifier.height(10.dp))
}
HubRow(
title = "Sluiten",
subtitle = "Terug naar VMC",
onClick = onDismiss,
)
} else {
Text(
"Installateurs-PIN",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(8.dp))
Text(
"Opent de volledige systeeminstellingen.",
color = McColors.TextDim,
fontSize = 14.sp,
)
Spacer(Modifier.height(16.dp))
BasicTextField(
value = pin,
onValueChange = {
pin = it.filter { ch -> ch.isDigit() }.take(6)
pinError = false
},
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
cursorBrush = SolidColor(McColors.Accent),
textStyle = TextStyle(
color = Color.White,
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 8.sp,
textAlign = TextAlign.Center,
),
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFF0E0E14), RoundedCornerShape(12.dp))
.padding(16.dp)
.onPreviewKeyEvent { ev ->
if (ev.nativeKeyEvent.action == AndroidKeyEvent.ACTION_DOWN &&
(ev.nativeKeyEvent.keyCode == AndroidKeyEvent.KEYCODE_DPAD_CENTER ||
ev.nativeKeyEvent.keyCode == AndroidKeyEvent.KEYCODE_ENTER)
) {
if (pin == APPLIANCE_INSTALLER_PIN) {
context.openSystemSettings()
onDismiss()
} else {
pinError = true
pin = ""
}
true
} else {
false
}
},
decorationBox = { inner ->
Box(contentAlignment = Alignment.Center) {
if (pin.isEmpty()) {
Text(
"····",
color = Color(0xFF555566),
fontSize = 28.sp,
letterSpacing = 8.sp,
)
}
inner()
}
},
)
if (pinError) {
Spacer(Modifier.height(8.dp))
Text("Onjuiste PIN", color = Color(0xFFE88A8A), fontSize = 14.sp)
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
HubRow(
title = "Annuleren",
subtitle = null,
modifier = Modifier.weight(1f),
onClick = { showPin = false },
)
HubRow(
title = "Openen",
subtitle = null,
modifier = Modifier.weight(1f),
onClick = {
if (pin == APPLIANCE_INSTALLER_PIN) {
context.openSystemSettings()
onDismiss()
} else {
pinError = true
pin = ""
}
},
)
}
}
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun HubRow(
title: String,
subtitle: String?,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = modifier.fillMaxWidth(),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF22222C),
focusedContainerColor = Color(0xFF2E2E3A),
),
) {
Column(Modifier.padding(horizontal = 20.dp, vertical = 14.dp)) {
Text(title, color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.SemiBold)
if (!subtitle.isNullOrBlank()) {
Spacer(Modifier.height(3.dp))
Text(subtitle, color = Color(0xFF9A9AA8), fontSize = 13.sp)
}
}
}
}

View file

@ -76,7 +76,13 @@ fun AuthChoiceScreen(
.background(McColors.BgElevated.copy(alpha = 0.92f)) .background(McColors.BgElevated.copy(alpha = 0.92f))
.padding(40.dp), .padding(40.dp),
) { ) {
Text("VMC", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Black, letterSpacing = 3.sp) Text(
Brand.shortName,
color = McColors.Accent,
fontSize = if (nl.vonas.mediacluster.tv.BuildConfig.APPLIANCE_BUILD) 16.sp else 18.sp,
fontWeight = FontWeight.Black,
letterSpacing = if (nl.vonas.mediacluster.tv.BuildConfig.APPLIANCE_BUILD) 1.5.sp else 3.sp,
)
Text("Sign in", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold) Text("Sign in", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
Text( Text(
"Use your account email, or link this device with a code", "Use your account email, or link this device with a code",
@ -132,7 +138,7 @@ fun PasswordLoginScreen(
try { try {
val base = store.apiBase.first() val base = store.apiBase.first()
api.configure(base, null) api.configure(base, null)
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android TV" } val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "VMC TV" }
val linked = api.login(email.trim(), password, deviceName, platform) val linked = api.login(email.trim(), password, deviceName, platform)
store.setToken(linked.refreshToken) store.setToken(linked.refreshToken)
api.configure(base, linked.refreshToken) api.configure(base, linked.refreshToken)

View file

@ -0,0 +1,12 @@
package nl.vonas.mediacluster.tv.ui
import nl.vonas.mediacluster.tv.BuildConfig
/** Visible product name on appliance boxes vs generic TV app. */
object Brand {
val shortName: String
get() = if (BuildConfig.APPLIANCE_BUILD) "VMC Mediabox" else "VMC"
val subtitle: String
get() = if (BuildConfig.APPLIANCE_BUILD) "Vonas Media Cluster" else "Vonas Media Cluster"
}

View file

@ -58,6 +58,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import android.view.KeyEvent as AndroidKeyEvent
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
@ -448,28 +449,39 @@ fun LiveTvPlayerScreen(
.onPreviewKeyEvent { ev -> .onPreviewKeyEvent { ev ->
if (channelMenuOpen) return@onPreviewKeyEvent false if (channelMenuOpen) return@onPreviewKeyEvent false
if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (ev.key) { val code = ev.nativeKeyEvent.keyCode
Key.Back, Key.Escape -> { when {
ev.key == Key.Back || ev.key == Key.Escape ||
code == AndroidKeyEvent.KEYCODE_BACK -> {
onBack() onBack()
true true
} }
Key.DirectionUp -> { // P↑ / P↓ op AB = CHANNEL_UP/DOWN (niet DPAD)
code == AndroidKeyEvent.KEYCODE_CHANNEL_UP ||
code == AndroidKeyEvent.KEYCODE_PAGE_UP ||
ev.key == Key.ChannelUp ||
ev.key == Key.DirectionUp -> {
zapChannel(-1) zapChannel(-1)
true true
} }
Key.DirectionDown -> { code == AndroidKeyEvent.KEYCODE_CHANNEL_DOWN ||
code == AndroidKeyEvent.KEYCODE_PAGE_DOWN ||
ev.key == Key.ChannelDown ||
ev.key == Key.DirectionDown -> {
zapChannel(1) zapChannel(1)
true true
} }
Key.DirectionRight -> { ev.key == Key.DirectionRight -> {
zapChannel(1) zapChannel(1)
true true
} }
Key.DirectionLeft, Key.Menu, Key.Guide -> { ev.key == Key.DirectionLeft || ev.key == Key.Menu || ev.key == Key.Guide -> {
channelMenuOpen = true channelMenuOpen = true
true true
} }
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> { ev.key == Key.DirectionCenter || ev.key == Key.Enter || ev.key == Key.NumPadEnter ||
code == AndroidKeyEvent.KEYCODE_DPAD_CENTER ||
code == AndroidKeyEvent.KEYCODE_ENTER -> {
infoOverlay = !infoOverlay infoOverlay = !infoOverlay
true true
} }
@ -605,7 +617,7 @@ fun LiveTvPlayerScreen(
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
"↑↓ zappen · ← zenderlijst · OK info", "P↑↓ zappen · ← zenderlijst · OK info",
color = Color(0xFF888888), color = Color(0xFF888888),
fontSize = 12.sp, fontSize = 12.sp,
) )

View file

@ -3,6 +3,7 @@ package nl.vonas.mediacluster.tv.ui
import android.app.Activity import android.app.Activity
import android.os.Build import android.os.Build
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import nl.vonas.mediacluster.tv.isVmcDefaultHome
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@ -85,9 +86,20 @@ import coil.request.ImageRequest
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.ApplianceEvents
import nl.vonas.mediacluster.tv.BuildConfig import nl.vonas.mediacluster.tv.BuildConfig
import nl.vonas.mediacluster.tv.apiHostFromBase
import nl.vonas.mediacluster.tv.awaitApiReady
import nl.vonas.mediacluster.tv.awaitHostResolvable
import nl.vonas.mediacluster.tv.awaitUsableNetwork
import nl.vonas.mediacluster.tv.cast.CastReceiverHost import nl.vonas.mediacluster.tv.cast.CastReceiverHost
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.AppUpdateCheck
import nl.vonas.mediacluster.tv.hasUsableNetwork
import nl.vonas.mediacluster.tv.isTransientNetworkFailure
import nl.vonas.mediacluster.tv.isVmcDefaultHome
import nl.vonas.mediacluster.tv.openWifiSettings
import nl.vonas.mediacluster.tv.toUserNetworkMessage
import nl.vonas.mediacluster.tv.data.HomePayload import nl.vonas.mediacluster.tv.data.HomePayload
import nl.vonas.mediacluster.tv.data.MediaItem import nl.vonas.mediacluster.tv.data.MediaItem
import nl.vonas.mediacluster.tv.data.PollResult import nl.vonas.mediacluster.tv.data.PollResult
@ -95,6 +107,8 @@ import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.data.ShelfPref import nl.vonas.mediacluster.tv.data.ShelfPref
import nl.vonas.mediacluster.tv.data.parseHomeCacheJson import nl.vonas.mediacluster.tv.data.parseHomeCacheJson
import nl.vonas.mediacluster.tv.data.toCacheJson import nl.vonas.mediacluster.tv.data.toCacheJson
import nl.vonas.mediacluster.tv.update.AppUpdateInstaller
import nl.vonas.mediacluster.tv.update.OtaInstallReceiver
import nl.vonas.mediacluster.tv.ui.components.EmptyState import nl.vonas.mediacluster.tv.ui.components.EmptyState
import nl.vonas.mediacluster.tv.ui.components.HomeChrome import nl.vonas.mediacluster.tv.ui.components.HomeChrome
import nl.vonas.mediacluster.tv.ui.components.HomeSkeleton import nl.vonas.mediacluster.tv.ui.components.HomeSkeleton
@ -116,6 +130,13 @@ fun McNav() {
val nav = rememberNavController() val nav = rememberNavController()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var ready by remember { mutableStateOf(false) } var ready by remember { mutableStateOf(false) }
var splashStatus by remember { mutableStateOf("Laden…") }
var needsNetworkSetup by remember { mutableStateOf(false) }
var showApplianceHub by remember { mutableStateOf(false) }
var pendingUpdate by remember { mutableStateOf<AppUpdateCheck?>(null) }
var showUpdateDialog by remember { mutableStateOf(false) }
var updateBusy by remember { mutableStateOf(false) }
var updateStatus by remember { mutableStateOf<String?>(null) }
var authed by remember { mutableStateOf(false) } var authed by remember { mutableStateOf(false) }
var hasProfile by remember { mutableStateOf(false) } var hasProfile by remember { mutableStateOf(false) }
var isOwner by remember { mutableStateOf(false) } var isOwner by remember { mutableStateOf(false) }
@ -174,13 +195,134 @@ fun McNav() {
}, },
) )
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
ApplianceEvents.openHub.collect { showApplianceHub = true }
}
DisposableEffect(Unit) {
val receiver = OtaInstallReceiver.register(context)
onDispose { runCatching { context.unregisterReceiver(receiver) } }
}
LaunchedEffect(authed, hasProfile) {
if (!authed || !hasProfile) return@LaunchedEffect
while (true) {
runCatching {
val check = AppUpdateInstaller.check(api)
pendingUpdate = check
if (check.updateAvailable) showUpdateDialog = true
}
delay(6 * 60 * 60_000L)
}
}
if (showUpdateDialog && pendingUpdate?.updateAvailable == true) {
val upd = pendingUpdate!!
Dialog(
onDismissRequest = { if (!updateBusy) showUpdateDialog = false },
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
Modifier
.fillMaxSize()
.background(Color(0xCC000000)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.fillMaxWidth(0.55f)
.background(Color(0xFF16161E), RoundedCornerShape(18.dp))
.padding(28.dp),
) {
Text(
"Update beschikbaar",
color = Color.White,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(8.dp))
Text(
"VMC ${upd.latestVersionName ?: "?"} " +
"(nu ${BuildConfig.VERSION_NAME})",
color = Color(0xFFB8B8C4),
fontSize = 15.sp,
)
if (!upd.changelog.isNullOrBlank()) {
Spacer(Modifier.height(12.dp))
Text(upd.changelog!!, color = Color(0xFF9A9AA8), fontSize = 14.sp)
}
if (updateStatus != null) {
Spacer(Modifier.height(12.dp))
Text(updateStatus!!, color = McColors.Accent, fontSize = 14.sp)
}
Spacer(Modifier.height(22.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
McButton(
if (updateBusy) "Bezig…" else "Installeren",
primary = true,
) {
if (updateBusy) return@McButton
scope.launch {
updateBusy = true
updateStatus = "Downloaden…"
val result = AppUpdateInstaller.downloadAndInstall(context, api)
updateBusy = false
updateStatus = if (result.isSuccess) {
"Installatie gestart — even geduld"
} else {
result.exceptionOrNull()?.message ?: "Installatie mislukt"
}
}
}
McButton("Later") {
if (!updateBusy) showUpdateDialog = false
}
}
}
}
}
}
LaunchedEffect(Unit) {
splashStatus = "Wachten op netwerk…"
val baseEarly = store.apiBase.first()
// VALIDATED alleen is niet genoeg na power-cycle — DNS komt vaak later.
var online = context.awaitApiReady(
apiBase = baseEarly,
networkTimeoutMs = 90_000L,
dnsTimeoutMs = 90_000L,
) { splashStatus = it }
if (!online) {
needsNetworkSetup = true
online = context.awaitApiReady(
apiBase = baseEarly,
networkTimeoutMs = 30 * 60_000L,
dnsTimeoutMs = 60_000L,
) { splashStatus = it }
needsNetworkSetup = false
}
splashStatus = if (online) "Verbinden…" else "Geen netwerk — starten…"
val base = store.apiBase.first() val base = store.apiBase.first()
val token = store.refreshToken.first() val token = store.refreshToken.first()
api.configure(base, token) api.configure(base, token)
authed = !token.isNullOrBlank() authed = !token.isNullOrBlank()
if (authed) { if (authed) {
val localProfile = store.profileId.first() val localProfile = store.profileId.first()
val me = runCatching { api.clientMe() }.getOrNull() splashStatus = "Account laden…"
var me = runCatching { api.clientMe() }.getOrNull()
var attempt = 0
while (me == null && online && attempt < 25) {
attempt++
splashStatus = "Verbinden…"
delay(1_200)
if (!context.hasUsableNetwork()) {
context.awaitUsableNetwork(timeoutMs = 30_000L) { splashStatus = it }
}
apiHostFromBase(base)?.let { host ->
awaitHostResolvable(host, timeoutMs = 15_000L) { splashStatus = it }
}
me = runCatching { api.clientMe() }.getOrNull()
}
when { when {
me?.profile != null -> { me?.profile != null -> {
store.setProfile( store.setProfile(
@ -193,12 +335,17 @@ fun McNav() {
hasProfile = true hasProfile = true
isOwner = me.profile.isOwner isOwner = me.profile.isOwner
} }
!localProfile.isNullOrBlank() -> { me != null -> {
// Lokaal bekend, server nog niet — switcher afdwingen // Server bereikbaar, geen actief profiel
store.setProfile(null) store.setProfile(null)
hasProfile = false hasProfile = false
isOwner = false isOwner = false
} }
!localProfile.isNullOrBlank() -> {
// Tijdelijke DNS/netfout: lokaal profiel behouden, niet naar link/switcher schoppen
hasProfile = true
isOwner = false
}
else -> { else -> {
hasProfile = false hasProfile = false
isOwner = false isOwner = false
@ -222,8 +369,28 @@ fun McNav() {
store.setLastTv(tvCategoryId, tvLastStreamId) store.setLastTv(tvCategoryId, tvLastStreamId)
} }
if (showApplianceHub) {
ApplianceHubDialog(
onDismiss = { showApplianceHub = false },
onChangeServer = if (ready && (!authed || !hasProfile)) {
{
showApplianceHub = false
nav.navigate("server") {
launchSingleTop = true
}
}
} else {
null
},
)
}
if (!ready) { if (!ready) {
Splash() if (needsNetworkSetup) {
NetworkSetupScreen(statusLine = splashStatus)
} else {
Splash(status = splashStatus)
}
return return
} }
@ -1192,7 +1359,11 @@ private fun MainHub(
) { ) {
Text("VMC afsluiten?", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold) Text("VMC afsluiten?", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
Text( Text(
"Weet je zeker dat je de app wilt sluiten?", if (activity?.isVmcDefaultHome() == true) {
"VMC is de start-app van dit apparaat. Afsluiten is uitgeschakeld — kies Opnieuw starten of Annuleren."
} else {
"Weet je zeker dat je de app wilt sluiten?"
},
color = McColors.TextDim, color = McColors.TextDim,
fontSize = 15.sp, fontSize = 15.sp,
) )
@ -1204,6 +1375,13 @@ private fun MainHub(
if (!exitReady) return@McButton if (!exitReady) return@McButton
exitConfirm = false exitConfirm = false
} }
if (activity?.isVmcDefaultHome() == true) {
McButton("Opnieuw starten", primary = true) {
if (!exitReady) return@McButton
exitConfirm = false
activity.recreate()
}
} else {
McButton("Afsluiten", primary = true) { McButton("Afsluiten", primary = true) {
if (!exitReady) return@McButton if (!exitReady) return@McButton
activity?.finish() activity?.finish()
@ -1214,18 +1392,25 @@ private fun MainHub(
} }
} }
} }
}
} }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun Splash() { private fun Splash(status: String = "Laden…") {
Box(Modifier.fillMaxSize().background(McColors.Bg), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize().background(McColors.Bg), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("VMC", color = McColors.Accent, fontSize = 42.sp, fontWeight = FontWeight.Black, letterSpacing = 2.sp) Text(
Brand.shortName,
color = McColors.Accent,
fontSize = if (BuildConfig.APPLIANCE_BUILD) 34.sp else 42.sp,
fontWeight = FontWeight.Black,
letterSpacing = if (BuildConfig.APPLIANCE_BUILD) 1.sp else 2.sp,
)
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text("Vonas Media Cluster", color = McColors.TextDim, fontSize = 14.sp, letterSpacing = 1.sp) Text(Brand.subtitle, color = McColors.TextDim, fontSize = 14.sp, letterSpacing = 1.sp)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text("Laden…", color = McColors.TextDim, fontSize = 15.sp) Text(status, color = McColors.TextDim, fontSize = 15.sp)
} }
} }
} }
@ -1245,18 +1430,28 @@ private fun LinkScreen(
var serverLabel by remember { mutableStateOf("") } var serverLabel by remember { mutableStateOf("") }
val qrImage = rememberQrImage(qrPayload) val qrImage = rememberQrImage(qrPayload)
val linkContext = LocalContext.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val base = store.apiBase.first() val base = store.apiBase.first()
serverLabel = base serverLabel = base
api.configure(base, null) api.configure(base, null)
status = "Wachten op netwerk…"
val baseForDns = store.apiBase.first()
linkContext.awaitApiReady(baseForDns, networkTimeoutMs = 120_000L, dnsTimeoutMs = 90_000L) {
status = it
}
status = "Verbinden…"
var attempt = 0
while (true) {
try { try {
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android TV" } val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "VMC TV" }
val created = api.createDeviceCode(deviceName) val created = api.createDeviceCode(deviceName)
code = created.code code = created.code
qrPayload = created.qrPayload.ifBlank { qrPayload = created.qrPayload.ifBlank {
"vmc://link?code=${created.code}" "vmc://link?code=${created.code}"
} }
status = "Wachten op goedkeuring in de VMC-app…" status = "Wachten op goedkeuring in de VMC-app…"
error = null
while (true) { while (true) {
delay(created.pollIntervalSeconds.coerceAtLeast(2) * 1000L) delay(created.pollIntervalSeconds.coerceAtLeast(2) * 1000L)
when (val poll = api.pollDeviceCode(created.code)) { when (val poll = api.pollDeviceCode(created.code)) {
@ -1266,21 +1461,34 @@ private fun LinkScreen(
status = "Welkom, ${poll.viewerName}" status = "Welkom, ${poll.viewerName}"
delay(700) delay(700)
onLinked() onLinked()
break return@LaunchedEffect
} }
PollResult.Expired -> { PollResult.Expired -> {
status = "Code verlopen — herstart de app" status = "Code verlopen — herstart de app"
break return@LaunchedEffect
} }
PollResult.AlreadyLinked -> { PollResult.AlreadyLinked -> {
status = "Al gekoppeld — herstart de app" status = "Al gekoppeld — herstart de app"
break return@LaunchedEffect
} }
PollResult.Pending -> Unit PollResult.Pending -> Unit
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
error = e.message ?: "Kon server niet bereiken" if (e.isTransientNetworkFailure() && attempt < 40) {
attempt++
status = "Wachten op netwerk…"
error = null
linkContext.awaitApiReady(base, networkTimeoutMs = 60_000L, dnsTimeoutMs = 45_000L) {
status = it
}
delay(1_000)
continue
}
error = e.toUserNetworkMessage()
status = "Kon niet verbinden"
break
}
} }
} }
@ -1333,7 +1541,13 @@ private fun LinkScreen(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
Text("VMC", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Black, letterSpacing = 3.sp) Text(
Brand.shortName,
color = McColors.Accent,
fontSize = if (BuildConfig.APPLIANCE_BUILD) 16.sp else 18.sp,
fontWeight = FontWeight.Black,
letterSpacing = if (BuildConfig.APPLIANCE_BUILD) 1.5.sp else 3.sp,
)
Text("Koppel dit apparaat", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold) Text("Koppel dit apparaat", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
Text( Text(
"Open de VMC-app op je telefoon, scan de QR of voer de code in onder Account → Apparaten.", "Open de VMC-app op je telefoon, scan de QR of voer de code in onder Account → Apparaten.",
@ -1347,10 +1561,13 @@ private fun LinkScreen(
Text(status, color = McColors.TextDim, fontSize = 14.sp) Text(status, color = McColors.TextDim, fontSize = 14.sp)
if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp) if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
McButton("Wi‑Fi") { linkContext.openWifiSettings() }
McButton("Change server") { onChangeServer() } McButton("Change server") { onChangeServer() }
} }
} }
} }
}
} }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@ -1413,13 +1630,37 @@ private fun HomeScreen(
loading = false loading = false
} }
} }
// Keep retrying transient DNS/Wi‑Fi failures instead of flashing "host not found".
var attempt = 0
val apiBase = store.apiBase.first()
while (true) {
if (!context.hasUsableNetwork()) {
error = null
if (home == null) loading = true
context.awaitUsableNetwork(timeoutMs = 90_000L)
}
apiHostFromBase(apiBase)?.let { host ->
awaitHostResolvable(host, timeoutMs = 30_000L)
}
try { try {
reloadHome() reloadHome()
error = null
break
} catch (e: Exception) { } catch (e: Exception) {
if (home == null) error = e.message ?: "Kon Home niet laden" if (e.isTransientNetworkFailure() && attempt < 40) {
} finally { attempt++
loading = false error = null
if (home == null) loading = true
delay(1_500)
continue
} }
if (home == null) {
error = e.toUserNetworkMessage().ifBlank { "Kon Home niet laden" }
}
break
}
}
loading = false
} }
val continueItems = home?.continueWatching.orEmpty() val continueItems = home?.continueWatching.orEmpty()
@ -2146,6 +2387,10 @@ private fun SettingsScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }
val audioPassthrough by store.audioPassthrough.collectAsState(initial = false) val audioPassthrough by store.audioPassthrough.collectAsState(initial = false)
var otaInfo by remember { mutableStateOf<AppUpdateCheck?>(null) }
var otaBusy by remember { mutableStateOf(false) }
var otaMsg by remember { mutableStateOf<String?>(null) }
val otaCtx = LocalContext.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
try { try {
@ -2294,12 +2539,59 @@ private fun SettingsScreen(
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
"Versie ${BuildConfig.VERSION_NAME}", "Versie ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
color = Color(0xFF8E8E9A), color = Color(0xFF8E8E9A),
fontSize = 14.sp, fontSize = 14.sp,
) )
} }
} }
Spacer(Modifier.height(10.dp))
SettingsActionRow(
title = when {
otaBusy -> "Update bezig…"
otaInfo?.updateAvailable == true ->
"Update beschikbaar: ${otaInfo?.latestVersionName}"
otaInfo != null -> "Je bent up-to-date"
else -> "Controleren op updates"
},
subtitle = otaMsg
?: if (otaInfo?.updateAvailable == true) {
"Tik om te downloaden en te installeren"
} else {
"Check of er een nieuwere VMC-app op de server staat"
},
onClick = {
if (otaBusy) return@SettingsActionRow
scope.launch {
otaBusy = true
otaMsg = null
try {
if (otaInfo?.updateAvailable == true) {
otaMsg = "Downloaden…"
val result = AppUpdateInstaller.downloadAndInstall(otaCtx, api)
otaMsg = if (result.isSuccess) {
"Installatie gestart"
} else {
result.exceptionOrNull()?.message ?: "Mislukt"
}
} else {
otaMsg = "Controleren…"
val check = AppUpdateInstaller.check(api)
otaInfo = check
otaMsg = if (check.updateAvailable) {
"Nieuwe versie ${check.latestVersionName}"
} else {
"Geen update — ${BuildConfig.VERSION_NAME}"
}
}
} catch (e: Exception) {
otaMsg = e.message ?: "Check mislukt"
} finally {
otaBusy = false
}
}
},
)
if (isOwner) { if (isOwner) {
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
SettingsActionRow( SettingsActionRow(
@ -2311,6 +2603,26 @@ private fun SettingsScreen(
} }
} }
item {
Spacer(Modifier.height(16.dp))
SectionLabel("Netwerk")
Spacer(Modifier.height(10.dp))
val netCtx = LocalContext.current
SettingsActionRow(
title = "Wi‑Fi instellen",
subtitle = "Netwerk kiezen of wachtwoord wijzigen",
onClick = { netCtx.openWifiSettings() },
)
if (netCtx.isVmcDefaultHome()) {
Spacer(Modifier.height(10.dp))
SettingsActionRow(
title = "VMC-menu",
subtitle = "Of druk op de Instellingen-knop op de afstandsbediening",
onClick = { ApplianceEvents.requestOpenHub() },
)
}
}
item { item {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
SectionLabel("Afspelen") SectionLabel("Afspelen")

View file

@ -134,7 +134,13 @@ fun ServerChoiceScreen(
.background(McColors.BgElevated.copy(alpha = 0.92f)) .background(McColors.BgElevated.copy(alpha = 0.92f))
.padding(40.dp), .padding(40.dp),
) { ) {
Text("VMC", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Black, letterSpacing = 3.sp) Text(
Brand.shortName,
color = McColors.Accent,
fontSize = if (BuildConfig.APPLIANCE_BUILD) 16.sp else 18.sp,
fontWeight = FontWeight.Black,
letterSpacing = if (BuildConfig.APPLIANCE_BUILD) 1.5.sp else 3.sp,
)
Text("Connect to your media", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold) Text("Connect to your media", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
Text( Text(
"VMC account or your own self-hosted server", "VMC account or your own self-hosted server",

View file

@ -44,8 +44,11 @@ import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.isTransientNetworkFailure
import nl.vonas.mediacluster.tv.toUserNetworkMessage
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SessionStore import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.data.ViewerProfile import nl.vonas.mediacluster.tv.data.ViewerProfile
@ -98,26 +101,43 @@ fun ProfilesScreen(
scope.launch { scope.launch {
loading = true loading = true
error = null error = null
runCatching { api.listProfiles() } var attempt = 0
.onSuccess { while (true) {
profiles = it.profiles val result = runCatching { api.listProfiles() }
canManage = it.canManage val success = result.getOrNull()
if (!switcherMode && !it.canManage) { if (success != null) {
profiles = success.profiles
canManage = success.canManage
if (!switcherMode && !success.canManage) {
val ownId = store.profileId.first() val ownId = store.profileId.first()
val own = it.profiles.find { p -> p.id == ownId } val own = success.profiles.find { p -> p.id == ownId }
if (own != null) openEdit(own) if (own != null) openEdit(own)
} }
error = null
break
}
val err = result.exceptionOrNull()
if (err != null && err.isTransientNetworkFailure() && attempt < 30) {
attempt++
error = null
delay(1_500)
continue
}
error = err?.toUserNetworkMessage() ?: "Laden mislukt"
break
} }
.onFailure { error = it.message ?: "Laden mislukt" }
loading = false loading = false
} }
} }
LaunchedEffect(switcherMode) { LaunchedEffect(switcherMode) {
if (switcherMode) { if (switcherMode) {
runCatching { api.clearProfile() } // Alleen wissen als de API echt bereikbaar is — anders blijven we lokaal bruikbaar.
val cleared = runCatching { api.clearProfile() }
if (cleared.isSuccess) {
store.setProfile(null) store.setProfile(null)
} }
}
reload() reload()
runCatching { firstFocus.requestFocus() } runCatching { firstFocus.requestFocus() }
} }

View file

@ -0,0 +1,108 @@
package nl.vonas.mediacluster.tv.update
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.os.Build
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import nl.vonas.mediacluster.tv.BuildConfig
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.AppUpdateCheck
import java.io.File
object AppUpdateInstaller {
private const val TAG = "VmcOta"
const val ACTION_INSTALL_STATUS = "nl.vonas.mediacluster.tv.action.OTA_INSTALL_STATUS"
suspend fun check(api: ApiClient): AppUpdateCheck {
val flavor = if (BuildConfig.APPLIANCE_BUILD) "appliance" else "store"
return api.checkAppUpdate(flavor, BuildConfig.VERSION_CODE)
}
/**
* Download APK to cache and commit via PackageInstaller.
* Same signing key + higher versionCode required.
*/
suspend fun downloadAndInstall(context: Context, api: ApiClient): Result<Unit> =
withContext(Dispatchers.IO) {
runCatching {
val cache = File(context.cacheDir, "vmc-ota.apk")
if (cache.exists()) cache.delete()
api.downloadAppUpdateTo(cache)
if (!cache.exists() || cache.length() < 1_000_000L) {
error("Download onvolledig (${cache.length()} bytes)")
}
commitSession(context, cache)
cache.delete()
Unit
}
}
private fun commitSession(context: Context, apk: File) {
val installer = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
params.setAppPackageName(context.packageName)
if (Build.VERSION.SDK_INT >= 31) {
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
val sessionId = installer.createSession(params)
installer.openSession(sessionId).use { session ->
apk.inputStream().use { input ->
session.openWrite("base.apk", 0, apk.length()).use { out ->
input.copyTo(out)
session.fsync(out)
}
}
val intent = Intent(ACTION_INSTALL_STATUS).setPackage(context.packageName)
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_MUTABLE else 0
val pi = PendingIntent.getBroadcast(context, sessionId, intent, flags)
session.commit(pi.intentSender)
}
Log.i(TAG, "PackageInstaller session $sessionId committed (${apk.length()} bytes)")
}
}
class OtaInstallReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action != AppUpdateInstaller.ACTION_INSTALL_STATUS) return
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
Log.i("VmcOta", "install status=$status msg=$msg")
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val confirm = if (Build.VERSION.SDK_INT >= 33) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_INTENT)
}
if (confirm != null) {
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(confirm) }
}
}
PackageInstaller.STATUS_SUCCESS -> Log.i("VmcOta", "OTA install OK")
else -> Log.w("VmcOta", "OTA install failed: $status $msg")
}
}
companion object {
fun register(context: Context): OtaInstallReceiver {
val r = OtaInstallReceiver()
val filter = IntentFilter(AppUpdateInstaller.ACTION_INSTALL_STATUS)
if (Build.VERSION.SDK_INT >= 33) {
context.registerReceiver(r, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(r, filter)
}
return r
}
}
}

View file

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">VMC</string> <string name="app_name">VMC</string>
<string name="appliance_a11y_desc">VMC apparaatbeheer — vangt de Instellingen-knop af voor kiosk-modus</string>
</resources> </resources>

View file

@ -3,5 +3,8 @@
<style name="Theme.MediaClusterTv" parent="android:Theme.Material.NoActionBar"> <style name="Theme.MediaClusterTv" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">#04060A</item> <item name="android:windowBackground">#04060A</item>
<item name="android:colorPrimary">#6EA8FF</item> <item name="android:colorPrimary">#6EA8FF</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowFullscreen">true</item>
</style> </style>
</resources> </resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault"
android:canRequestFilterKeyEvents="false"
android:canRetrieveWindowContent="false"
android:description="@string/appliance_a11y_desc"
android:notificationTimeout="200"
android:settingsActivity="nl.vonas.mediacluster.tv.MainActivity" />

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Store / Play: geen HOME-kiosk, geen watchdog, geen accessibility lockdown. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application />
</manifest>

View file

@ -0,0 +1,280 @@
import fs from "fs";
import path from "path";
import { pipeline } from "stream/promises";
import type { Readable } from "stream";
import { AppError } from "../security/errors";
import { resolveInstallRoot } from "./token-tray-store";
export const ANDROID_TV_APK_FILENAME = "vmc-appliance.apk";
export const ANDROID_TV_META_FILENAME = "android-tv-apk.json";
/** Appliance APKs typically 40–90 MB. */
export const ANDROID_TV_APK_MAX_BYTES = 150 * 1024 * 1024;
export const ANDROID_TV_APK_MIN_BYTES = 1 * 1024 * 1024;
export type AndroidTvApkFlavor = "appliance" | "store";
export type AndroidTvApkMeta = {
available: boolean;
flavor: AndroidTvApkFlavor;
versionCode: number | null;
versionName: string | null;
changelog: string | null;
filename: string;
sizeBytes: number;
builtAt: string | null;
productName: string;
note?: string;
};
function resolveAndroidTvDir(): string {
const preferred = [
path.join("/app/install", "android-tv"),
path.join(resolveInstallRoot(), "android-tv"),
path.join(process.cwd(), "install", "android-tv"),
];
for (const dir of preferred) {
try {
fs.mkdirSync(dir, { recursive: true });
fs.accessSync(dir, fs.constants.W_OK);
return dir;
} catch {
/* try next */
}
}
const fallback = preferred[preferred.length - 1];
fs.mkdirSync(fallback, { recursive: true });
return fallback;
}
export function androidTvApkPaths(): { apk: string; meta: string; dir: string } {
const dir = resolveAndroidTvDir();
return {
dir,
apk: path.join(dir, ANDROID_TV_APK_FILENAME),
meta: path.join(dir, ANDROID_TV_META_FILENAME),
};
}
function readStoredMeta(): Partial<AndroidTvApkMeta> {
const { meta } = androidTvApkPaths();
if (!fs.existsSync(meta)) return {};
try {
return JSON.parse(fs.readFileSync(meta, "utf8")) as Partial<AndroidTvApkMeta>;
} catch {
return {};
}
}
export function getAndroidTvApkMeta(): AndroidTvApkMeta {
const { apk } = androidTvApkPaths();
const stored = readStoredMeta();
const available = fs.existsSync(apk);
const base: AndroidTvApkMeta = {
available: false,
flavor: (stored.flavor as AndroidTvApkFlavor) || "appliance",
versionCode: stored.versionCode ?? null,
versionName: stored.versionName ?? null,
changelog: stored.changelog ?? null,
filename: ANDROID_TV_APK_FILENAME,
sizeBytes: 0,
builtAt: stored.builtAt ?? null,
productName: "VMC Mediabox (appliance)",
};
if (available) {
base.available = true;
base.sizeBytes = fs.statSync(apk).size;
delete base.note;
} else {
base.note =
"Nog geen APK — upload app-appliance-release.apk via Admin → Android TV.";
}
return base;
}
function assertZipApk(buf: Buffer) {
// APK = ZIP (PK\x03\x04 or empty PK\x05\x06)
if (buf.length < 4 || buf[0] !== 0x50 || buf[1] !== 0x4b) {
throw new AppError(
"INVALID_REQUEST",
"Bestand is geen APK (ZIP-header ontbreekt)",
400
);
}
}
function fieldValue(
fields: Record<string, unknown> | undefined,
name: string
): string | null {
const raw = fields?.[name];
if (!raw || Array.isArray(raw)) return null;
if (typeof raw === "object" && raw !== null && "value" in raw) {
const v = String((raw as { value: unknown }).value ?? "").trim();
return v || null;
}
return null;
}
export async function saveAndroidTvApkUpload(opts: {
stream: Readable;
filename?: string;
fields?: Record<string, unknown>;
versionCode?: number | null;
versionName?: string | null;
changelog?: string | null;
flavor?: AndroidTvApkFlavor;
}): Promise<AndroidTvApkMeta> {
const name = (opts.filename || "").toLowerCase();
if (name && !name.endsWith(".apk")) {
throw new AppError("INVALID_REQUEST", "Alleen .apk-bestanden toegestaan", 400);
}
const { dir, apk: apkPath, meta: metaPath } = androidTvApkPaths();
const tmpPath = path.join(dir, `${ANDROID_TV_APK_FILENAME}.uploading`);
try {
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
await pipeline(opts.stream, fs.createWriteStream(tmpPath));
} catch (err) {
try {
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
} catch {
/* ignore */
}
const msg = err instanceof Error ? err.message : String(err);
if (/Limit|fileSize|truncated/i.test(msg)) {
throw new AppError(
"INVALID_REQUEST",
`Bestand te groot (max ${Math.round(ANDROID_TV_APK_MAX_BYTES / (1024 * 1024))} MB)`,
400
);
}
throw err;
}
const stat = fs.statSync(tmpPath);
if (stat.size < ANDROID_TV_APK_MIN_BYTES) {
fs.unlinkSync(tmpPath);
throw new AppError(
"INVALID_REQUEST",
`Bestand te klein (${stat.size} bytes) — verwacht een release-APK`,
400
);
}
if (stat.size > ANDROID_TV_APK_MAX_BYTES) {
fs.unlinkSync(tmpPath);
throw new AppError(
"INVALID_REQUEST",
`Bestand te groot (max ${Math.round(ANDROID_TV_APK_MAX_BYTES / (1024 * 1024))} MB)`,
400
);
}
const fd = fs.openSync(tmpPath, "r");
const head = Buffer.alloc(4);
fs.readSync(fd, head, 0, 4, 0);
fs.closeSync(fd);
try {
assertZipApk(head);
} catch (e) {
fs.unlinkSync(tmpPath);
throw e;
}
const versionCodeRaw =
opts.versionCode ??
Number(fieldValue(opts.fields, "versionCode") || NaN);
const versionCode = Number.isFinite(versionCodeRaw) && versionCodeRaw > 0
? Math.floor(versionCodeRaw)
: null;
if (versionCode == null) {
fs.unlinkSync(tmpPath);
throw new AppError(
"INVALID_REQUEST",
"versionCode verplicht (zelfde als in build.gradle.kts)",
400
);
}
const versionName =
(opts.versionName || fieldValue(opts.fields, "versionName") || "").trim() ||
null;
if (!versionName) {
fs.unlinkSync(tmpPath);
throw new AppError("INVALID_REQUEST", "versionName verplicht", 400);
}
const changelog =
(opts.changelog || fieldValue(opts.fields, "changelog") || "").trim() || null;
const flavor: AndroidTvApkFlavor =
opts.flavor ||
(fieldValue(opts.fields, "flavor") === "store" ? "store" : "appliance");
fs.renameSync(tmpPath, apkPath);
const meta = {
flavor,
versionCode,
versionName,
changelog,
filename: ANDROID_TV_APK_FILENAME,
sizeBytes: stat.size,
builtAt: new Date().toISOString(),
productName:
flavor === "store" ? "Vonas Media Cluster (store)" : "VMC Mediabox (appliance)",
available: true,
};
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf8");
return getAndroidTvApkMeta();
}
export function deleteAndroidTvApk(): AndroidTvApkMeta {
const { dir, apk, meta } = androidTvApkPaths();
for (const p of [apk, meta]) {
try {
if (fs.existsSync(p)) fs.unlinkSync(p);
} catch {
/* ignore */
}
}
const placeholder = {
flavor: "appliance" as const,
versionCode: null,
versionName: null,
changelog: null,
filename: ANDROID_TV_APK_FILENAME,
sizeBytes: 0,
builtAt: null,
productName: "VMC Mediabox (appliance)",
available: false,
note: "APK verwijderd — upload een nieuwe build via Admin → Android TV.",
};
try {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(meta, JSON.stringify(placeholder, null, 2), "utf8");
} catch {
/* ignore */
}
return getAndroidTvApkMeta();
}
export function checkAndroidTvUpdate(opts: {
flavor: AndroidTvApkFlavor;
currentVersionCode: number;
}): {
updateAvailable: boolean;
currentVersionCode: number;
latest: AndroidTvApkMeta;
} {
const latest = getAndroidTvApkMeta();
const latestCode = latest.versionCode ?? 0;
const updateAvailable =
latest.available &&
latest.flavor === opts.flavor &&
latestCode > opts.currentVersionCode;
return {
updateAvailable,
currentVersionCode: opts.currentVersionCode,
latest,
};
}

View file

@ -12,6 +12,15 @@ import {
saveTokenTrayUpload, saveTokenTrayUpload,
tokenTrayPaths, tokenTrayPaths,
} from "./token-tray-store"; } from "./token-tray-store";
import {
ANDROID_TV_APK_MAX_BYTES,
androidTvApkPaths,
checkAndroidTvUpdate,
deleteAndroidTvApk,
getAndroidTvApkMeta,
saveAndroidTvApkUpload,
type AndroidTvApkFlavor,
} from "./android-tv-apk-store";
function sendInstallScript( function sendInstallScript(
reply: { reply: {
@ -128,4 +137,76 @@ export async function registerInstallRoutes(app: FastifyInstance, config: Config
app.delete("/api/v1/admin/install/token-tray", { preHandler: requireAdmin }, async () => { app.delete("/api/v1/admin/install/token-tray", { preHandler: requireAdmin }, async () => {
return deleteTokenTrayInstaller(publicUrl); return deleteTokenTrayInstaller(publicUrl);
}); });
// ——— Android TV / VMC Mediabox OTA APK ———
app.get("/api/v1/admin/install/android-tv", { preHandler: requireAdmin }, async () => {
return getAndroidTvApkMeta();
});
app.post(
"/api/v1/admin/install/android-tv",
{
preHandler: requireAdmin,
bodyLimit: ANDROID_TV_APK_MAX_BYTES + 1024 * 1024,
},
async (request) => {
const data = await request.file();
if (!data) {
throw new AppError(
"INVALID_REQUEST",
"Geen bestand — stuur multipart field 'file' (.apk)",
400
);
}
return saveAndroidTvApkUpload({
stream: data.file,
filename: data.filename,
fields: data.fields as Record<string, unknown>,
});
}
);
app.delete("/api/v1/admin/install/android-tv", { preHandler: requireAdmin }, async () => {
return deleteAndroidTvApk();
});
}
/** Client OTA check + download — registered from viewer routes (needs auth). */
export function registerAndroidTvClientUpdateRoutes(
app: FastifyInstance,
authFromBearer: (authorization: string | undefined) => Promise<unknown>
) {
app.get("/api/v1/client/app-update", async (request) => {
await authFromBearer(request.headers.authorization);
const q = request.query as {
platform?: string;
versionCode?: string;
flavor?: string;
};
const flavor: AndroidTvApkFlavor =
q.flavor === "store" || q.platform === "android_tv_store"
? "store"
: "appliance";
const current = Math.max(0, Number(q.versionCode) || 0);
return checkAndroidTvUpdate({ flavor, currentVersionCode: current });
});
app.get("/api/v1/client/app-update/download", async (request, reply) => {
await authFromBearer(request.headers.authorization);
const { apk } = androidTvApkPaths();
const meta = getAndroidTvApkMeta();
if (!meta.available || !fs.existsSync(apk)) {
throw new AppError("NOT_FOUND", "Geen update-APK beschikbaar", 404);
}
const name =
meta.versionName != null
? `vmc-appliance-${meta.versionName}.apk`
: "vmc-appliance.apk";
reply.header("Content-Type", "application/vnd.android.package-archive");
reply.header("Content-Disposition", `attachment; filename="${name}"`);
reply.header("Cache-Control", "no-store");
reply.header("X-VMC-Version-Code", String(meta.versionCode ?? ""));
reply.header("X-VMC-Version-Name", String(meta.versionName ?? ""));
return reply.send(fs.createReadStream(apk));
});
} }

View file

@ -9,6 +9,7 @@ import type { DownloadService } from "../downloads/service";
import type { SubtitleSource } from "../opensubtitles/client"; import type { SubtitleSource } from "../opensubtitles/client";
import { GooglePlayAccessService } from "../google-play/service"; import { GooglePlayAccessService } from "../google-play/service";
import { subscribeViewersChanged } from "./admin-events"; import { subscribeViewersChanged } from "./admin-events";
import { registerAndroidTvClientUpdateRoutes } from "../install/routes";
export function registerViewerRoutes( export function registerViewerRoutes(
app: FastifyInstance, app: FastifyInstance,
@ -20,6 +21,10 @@ export function registerViewerRoutes(
const profiles = new ViewerProfileService(); const profiles = new ViewerProfileService();
const googlePlay = new GooglePlayAccessService(config); const googlePlay = new GooglePlayAccessService(config);
registerAndroidTvClientUpdateRoutes(app, (authorization) =>
viewers.authFromBearer(authorization)
);
// ——— Admin: kijkers ——— // ——— Admin: kijkers ———
app.get("/api/v1/admin/viewers", { preHandler: requireAdmin }, async () => { app.get("/api/v1/admin/viewers", { preHandler: requireAdmin }, async () => {
return { viewers: await viewers.listViewers() }; return { viewers: await viewers.listViewers() };

View file

@ -74,6 +74,8 @@ services:
- odido_data:/data/odido - odido_data:/data/odido
# Optional: mount/upload Token Tray NSIS installer here as token-tray-setup.exe # Optional: mount/upload Token Tray NSIS installer here as token-tray-setup.exe
- agent_install:/app/install/agent - agent_install:/app/install/agent
# VMC Mediabox OTA APK (Admin → Android TV)
- android_tv_apk:/app/install/android-tv
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 15s interval: 15s
@ -109,6 +111,7 @@ volumes:
redis_data: redis_data:
odido_data: odido_data:
agent_install: agent_install:
android_tv_apk:
networks: networks:
media-cluster: media-cluster: