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:
parent
035ec9dc98
commit
1ef0c4339f
30 changed files with 2505 additions and 77 deletions
279
apps/admin-ui/src/app/android-tv/page.tsx
Normal file
279
apps/admin-ui/src/app/android-tv/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -57,8 +57,12 @@ function clearSessionCookie(response: NextResponse, request: NextRequest) {
|
|||
});
|
||||
}
|
||||
|
||||
function isTokenTrayUpload(method: string, targetPath: string): boolean {
|
||||
return method === "POST" && targetPath === "v1/admin/install/token-tray";
|
||||
function isLargeBinaryUpload(method: string, targetPath: string): boolean {
|
||||
return (
|
||||
method === "POST" &&
|
||||
(targetPath === "v1/admin/install/token-tray" ||
|
||||
targetPath === "v1/admin/install/android-tv")
|
||||
);
|
||||
}
|
||||
|
||||
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 isSse =
|
||||
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 contentType = request.headers.get("content-type");
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const desktopLinks = [
|
|||
{ href: "/drm", label: "DRM" },
|
||||
{ href: "/scripts", label: "Scripts" },
|
||||
{ href: "/agent", label: "Agent" },
|
||||
{ href: "/android-tv", label: "Android TV" },
|
||||
{ href: "/viewers", label: "Gebruikers" },
|
||||
{ href: "/settings", label: "Instellingen" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -20,14 +20,31 @@ android {
|
|||
applicationId = "nl.vonas.mediacluster.tv"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 150
|
||||
versionName = "0.14.87"
|
||||
versionCode = 168
|
||||
versionName = "0.15.5"
|
||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||
buildConfigField("boolean", "APPLIANCE_BUILD", "false")
|
||||
ndk {
|
||||
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 {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
create("release") {
|
||||
|
|
|
|||
50
apps/android-tv/app/src/appliance/AndroidManifest.xml
Normal file
50
apps/android-tv/app/src/appliance/AndroidManifest.xml
Normal 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>
|
||||
4
apps/android-tv/app/src/appliance/res/values/strings.xml
Normal file
4
apps/android-tv/app/src/appliance/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">VMC Mediabox</string>
|
||||
</resources>
|
||||
|
|
@ -4,6 +4,11 @@
|
|||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<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
|
||||
android:name="android.software.leanback"
|
||||
|
|
@ -25,9 +30,15 @@
|
|||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:banner="@drawable/app_banner"
|
||||
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>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
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.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
|
|
@ -9,14 +15,84 @@ import nl.vonas.mediacluster.tv.ui.McNav
|
|||
import nl.vonas.mediacluster.tv.ui.theme.MediaClusterTheme
|
||||
|
||||
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?) {
|
||||
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
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
MediaClusterTheme {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -911,6 +911,44 @@ class ApiClient(
|
|||
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? {
|
||||
val id = optClean("id") ?: return null
|
||||
val name = optClean("name") ?: return null
|
||||
|
|
@ -1158,6 +1196,15 @@ class ApiClient(
|
|||
|
||||
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(
|
||||
val code: String,
|
||||
val expiresAt: String,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -76,7 +76,13 @@ fun AuthChoiceScreen(
|
|||
.background(McColors.BgElevated.copy(alpha = 0.92f))
|
||||
.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(
|
||||
"Use your account email, or link this device with a code",
|
||||
|
|
@ -132,7 +138,7 @@ fun PasswordLoginScreen(
|
|||
try {
|
||||
val base = store.apiBase.first()
|
||||
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)
|
||||
store.setToken(linked.refreshToken)
|
||||
api.configure(base, linked.refreshToken)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -58,6 +58,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
|
|
@ -448,28 +449,39 @@ fun LiveTvPlayerScreen(
|
|||
.onPreviewKeyEvent { ev ->
|
||||
if (channelMenuOpen) return@onPreviewKeyEvent false
|
||||
if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
when (ev.key) {
|
||||
Key.Back, Key.Escape -> {
|
||||
val code = ev.nativeKeyEvent.keyCode
|
||||
when {
|
||||
ev.key == Key.Back || ev.key == Key.Escape ||
|
||||
code == AndroidKeyEvent.KEYCODE_BACK -> {
|
||||
onBack()
|
||||
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)
|
||||
true
|
||||
}
|
||||
Key.DirectionDown -> {
|
||||
code == AndroidKeyEvent.KEYCODE_CHANNEL_DOWN ||
|
||||
code == AndroidKeyEvent.KEYCODE_PAGE_DOWN ||
|
||||
ev.key == Key.ChannelDown ||
|
||||
ev.key == Key.DirectionDown -> {
|
||||
zapChannel(1)
|
||||
true
|
||||
}
|
||||
Key.DirectionRight -> {
|
||||
ev.key == Key.DirectionRight -> {
|
||||
zapChannel(1)
|
||||
true
|
||||
}
|
||||
Key.DirectionLeft, Key.Menu, Key.Guide -> {
|
||||
ev.key == Key.DirectionLeft || ev.key == Key.Menu || ev.key == Key.Guide -> {
|
||||
channelMenuOpen = 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
|
||||
true
|
||||
}
|
||||
|
|
@ -605,7 +617,7 @@ fun LiveTvPlayerScreen(
|
|||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"↑↓ zappen · ← zenderlijst · OK info",
|
||||
"P↑↓ zappen · ← zenderlijst · OK info",
|
||||
color = Color(0xFF888888),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package nl.vonas.mediacluster.tv.ui
|
|||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.BackHandler
|
||||
import nl.vonas.mediacluster.tv.isVmcDefaultHome
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
|
|
@ -85,9 +86,20 @@ import coil.request.ImageRequest
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import nl.vonas.mediacluster.tv.ApplianceEvents
|
||||
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.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.MediaItem
|
||||
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.parseHomeCacheJson
|
||||
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.HomeChrome
|
||||
import nl.vonas.mediacluster.tv.ui.components.HomeSkeleton
|
||||
|
|
@ -116,6 +130,13 @@ fun McNav() {
|
|||
val nav = rememberNavController()
|
||||
val scope = rememberCoroutineScope()
|
||||
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 hasProfile by remember { mutableStateOf(false) }
|
||||
var isOwner by remember { mutableStateOf(false) }
|
||||
|
|
@ -174,13 +195,134 @@ fun McNav() {
|
|||
},
|
||||
)
|
||||
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 token = store.refreshToken.first()
|
||||
api.configure(base, token)
|
||||
authed = !token.isNullOrBlank()
|
||||
if (authed) {
|
||||
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 {
|
||||
me?.profile != null -> {
|
||||
store.setProfile(
|
||||
|
|
@ -193,12 +335,17 @@ fun McNav() {
|
|||
hasProfile = true
|
||||
isOwner = me.profile.isOwner
|
||||
}
|
||||
!localProfile.isNullOrBlank() -> {
|
||||
// Lokaal bekend, server nog niet — switcher afdwingen
|
||||
me != null -> {
|
||||
// Server bereikbaar, geen actief profiel
|
||||
store.setProfile(null)
|
||||
hasProfile = false
|
||||
isOwner = false
|
||||
}
|
||||
!localProfile.isNullOrBlank() -> {
|
||||
// Tijdelijke DNS/netfout: lokaal profiel behouden, niet naar link/switcher schoppen
|
||||
hasProfile = true
|
||||
isOwner = false
|
||||
}
|
||||
else -> {
|
||||
hasProfile = false
|
||||
isOwner = false
|
||||
|
|
@ -222,8 +369,28 @@ fun McNav() {
|
|||
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) {
|
||||
Splash()
|
||||
if (needsNetworkSetup) {
|
||||
NetworkSetupScreen(statusLine = splashStatus)
|
||||
} else {
|
||||
Splash(status = splashStatus)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1192,7 +1359,11 @@ private fun MainHub(
|
|||
) {
|
||||
Text("VMC afsluiten?", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
|
||||
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,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
|
|
@ -1204,6 +1375,13 @@ private fun MainHub(
|
|||
if (!exitReady) return@McButton
|
||||
exitConfirm = false
|
||||
}
|
||||
if (activity?.isVmcDefaultHome() == true) {
|
||||
McButton("Opnieuw starten", primary = true) {
|
||||
if (!exitReady) return@McButton
|
||||
exitConfirm = false
|
||||
activity.recreate()
|
||||
}
|
||||
} else {
|
||||
McButton("Afsluiten", primary = true) {
|
||||
if (!exitReady) return@McButton
|
||||
activity?.finish()
|
||||
|
|
@ -1215,17 +1393,24 @@ private fun MainHub(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun Splash() {
|
||||
private fun Splash(status: String = "Laden…") {
|
||||
Box(Modifier.fillMaxSize().background(McColors.Bg), contentAlignment = Alignment.Center) {
|
||||
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))
|
||||
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))
|
||||
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("") }
|
||||
val qrImage = rememberQrImage(qrPayload)
|
||||
|
||||
val linkContext = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
val base = store.apiBase.first()
|
||||
serverLabel = base
|
||||
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 {
|
||||
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)
|
||||
code = created.code
|
||||
qrPayload = created.qrPayload.ifBlank {
|
||||
"vmc://link?code=${created.code}"
|
||||
}
|
||||
status = "Wachten op goedkeuring in de VMC-app…"
|
||||
error = null
|
||||
while (true) {
|
||||
delay(created.pollIntervalSeconds.coerceAtLeast(2) * 1000L)
|
||||
when (val poll = api.pollDeviceCode(created.code)) {
|
||||
|
|
@ -1266,21 +1461,34 @@ private fun LinkScreen(
|
|||
status = "Welkom, ${poll.viewerName}"
|
||||
delay(700)
|
||||
onLinked()
|
||||
break
|
||||
return@LaunchedEffect
|
||||
}
|
||||
PollResult.Expired -> {
|
||||
status = "Code verlopen — herstart de app"
|
||||
break
|
||||
return@LaunchedEffect
|
||||
}
|
||||
PollResult.AlreadyLinked -> {
|
||||
status = "Al gekoppeld — herstart de app"
|
||||
break
|
||||
return@LaunchedEffect
|
||||
}
|
||||
PollResult.Pending -> Unit
|
||||
}
|
||||
}
|
||||
} 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),
|
||||
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(
|
||||
"Open de VMC-app op je telefoon, scan de QR of voer de code in onder Account → Apparaten.",
|
||||
|
|
@ -1347,11 +1561,14 @@ private fun LinkScreen(
|
|||
Text(status, color = McColors.TextDim, fontSize = 14.sp)
|
||||
if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
McButton("Wi‑Fi") { linkContext.openWifiSettings() }
|
||||
McButton("Change server") { onChangeServer() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -1413,13 +1630,37 @@ private fun HomeScreen(
|
|||
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 {
|
||||
reloadHome()
|
||||
error = null
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
if (home == null) error = e.message ?: "Kon Home niet laden"
|
||||
} finally {
|
||||
loading = false
|
||||
if (e.isTransientNetworkFailure() && attempt < 40) {
|
||||
attempt++
|
||||
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()
|
||||
|
|
@ -2146,6 +2387,10 @@ private fun SettingsScreen(
|
|||
val scope = rememberCoroutineScope()
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
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) {
|
||||
try {
|
||||
|
|
@ -2294,12 +2539,59 @@ private fun SettingsScreen(
|
|||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Versie ${BuildConfig.VERSION_NAME}",
|
||||
"Versie ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
|
||||
color = Color(0xFF8E8E9A),
|
||||
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) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
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 {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionLabel("Afspelen")
|
||||
|
|
|
|||
|
|
@ -134,7 +134,13 @@ fun ServerChoiceScreen(
|
|||
.background(McColors.BgElevated.copy(alpha = 0.92f))
|
||||
.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(
|
||||
"VMC account or your own self-hosted server",
|
||||
|
|
|
|||
|
|
@ -44,8 +44,11 @@ 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 kotlinx.coroutines.flow.first
|
||||
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.SessionStore
|
||||
import nl.vonas.mediacluster.tv.data.ViewerProfile
|
||||
|
|
@ -98,26 +101,43 @@ fun ProfilesScreen(
|
|||
scope.launch {
|
||||
loading = true
|
||||
error = null
|
||||
runCatching { api.listProfiles() }
|
||||
.onSuccess {
|
||||
profiles = it.profiles
|
||||
canManage = it.canManage
|
||||
if (!switcherMode && !it.canManage) {
|
||||
var attempt = 0
|
||||
while (true) {
|
||||
val result = runCatching { api.listProfiles() }
|
||||
val success = result.getOrNull()
|
||||
if (success != null) {
|
||||
profiles = success.profiles
|
||||
canManage = success.canManage
|
||||
if (!switcherMode && !success.canManage) {
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(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)
|
||||
}
|
||||
}
|
||||
reload()
|
||||
runCatching { firstFocus.requestFocus() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">VMC</string>
|
||||
<string name="appliance_a11y_desc">VMC apparaatbeheer — vangt de Instellingen-knop af voor kiosk-modus</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -3,5 +3,8 @@
|
|||
<style name="Theme.MediaClusterTv" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">#04060A</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>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
5
apps/android-tv/app/src/store/AndroidManifest.xml
Normal file
5
apps/android-tv/app/src/store/AndroidManifest.xml
Normal 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>
|
||||
280
apps/master-api/src/install/android-tv-apk-store.ts
Normal file
280
apps/master-api/src/install/android-tv-apk-store.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -12,6 +12,15 @@ import {
|
|||
saveTokenTrayUpload,
|
||||
tokenTrayPaths,
|
||||
} from "./token-tray-store";
|
||||
import {
|
||||
ANDROID_TV_APK_MAX_BYTES,
|
||||
androidTvApkPaths,
|
||||
checkAndroidTvUpdate,
|
||||
deleteAndroidTvApk,
|
||||
getAndroidTvApkMeta,
|
||||
saveAndroidTvApkUpload,
|
||||
type AndroidTvApkFlavor,
|
||||
} from "./android-tv-apk-store";
|
||||
|
||||
function sendInstallScript(
|
||||
reply: {
|
||||
|
|
@ -128,4 +137,76 @@ export async function registerInstallRoutes(app: FastifyInstance, config: Config
|
|||
app.delete("/api/v1/admin/install/token-tray", { preHandler: requireAdmin }, async () => {
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { DownloadService } from "../downloads/service";
|
|||
import type { SubtitleSource } from "../opensubtitles/client";
|
||||
import { GooglePlayAccessService } from "../google-play/service";
|
||||
import { subscribeViewersChanged } from "./admin-events";
|
||||
import { registerAndroidTvClientUpdateRoutes } from "../install/routes";
|
||||
|
||||
export function registerViewerRoutes(
|
||||
app: FastifyInstance,
|
||||
|
|
@ -20,6 +21,10 @@ export function registerViewerRoutes(
|
|||
const profiles = new ViewerProfileService();
|
||||
const googlePlay = new GooglePlayAccessService(config);
|
||||
|
||||
registerAndroidTvClientUpdateRoutes(app, (authorization) =>
|
||||
viewers.authFromBearer(authorization)
|
||||
);
|
||||
|
||||
// ——— Admin: kijkers ———
|
||||
app.get("/api/v1/admin/viewers", { preHandler: requireAdmin }, async () => {
|
||||
return { viewers: await viewers.listViewers() };
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ services:
|
|||
- odido_data:/data/odido
|
||||
# Optional: mount/upload Token Tray NSIS installer here as token-tray-setup.exe
|
||||
- agent_install:/app/install/agent
|
||||
# VMC Mediabox OTA APK (Admin → Android TV)
|
||||
- android_tv_apk:/app/install/android-tv
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
interval: 15s
|
||||
|
|
@ -109,6 +111,7 @@ volumes:
|
|||
redis_data:
|
||||
odido_data:
|
||||
agent_install:
|
||||
android_tv_apk:
|
||||
|
||||
networks:
|
||||
media-cluster:
|
||||
|
|
|
|||
Loading…
Reference in a new issue