Merge branch 'fix/player-parity-small-gaps'

This commit is contained in:
ScreenTinker 2026-08-10 15:38:56 -05:00
commit 20d36e8923
5 changed files with 281 additions and 13 deletions

View file

@ -52,6 +52,27 @@ object PlayerCapabilities {
// Native view rotation: the ExoPlayer surface sits inside the rotated view, so video
// turns with the graphics. No hardware-plane problem here.
"display.rotation",
// Per-window overlay dim (WindowManager.LayoutParams.screenBrightness) — Tier 0, no
// permission, works at any privilege level. Distinct from "system.brightness" below,
// which writes the system-wide setting and DOES need WRITE_SETTINGS or owner.
// Declared here because the android BASELINE already grants it: without this line an
// updated panel replaces the baseline with a declared set that lacks it, and LOSES
// the dim slider it had before it updated.
"display.brightness",
// Capture, at ANY privilege level. captureScreen() is a three-rung fallback —
// MediaProjection (system-wide, needs consent), then the accessibility screenshot
// API, then ScreenshotCapture.captureView, which is a plain view draw with no
// permission check of any kind. The last rung is narrower than the others (the
// player's own window, foreground only) but on a kiosk panel that window IS the
// content, so the operator gets a picture rather than a refusal.
//
// Declared unconditionally for the same reason display.brightness is: the android
// BASELINE grants both, and a declared set replaces the baseline rather than
// merging with it. Gating on accessibility meant a panel LOST live view and
// screenshots by updating, while the fallback that still served them kept working.
// It also made granting MediaProjection invisible — consent was given, capture
// genuinely started, and the server went on refusing because nothing re-declared.
"remote.screenshot", "remote.stream",
// Input is plain view dispatch and works regardless of privilege.
"remote.input",
// The player restarts itself; the OTA checker updates the APK.
@ -67,10 +88,6 @@ object PlayerCapabilities {
// ---- conditional on runtime state -------------------------------------------------------
// Full-screen capture needs the accessibility service; without it capture falls back to
// the app's own view. Declared only for the real thing, per the capability contract.
if (accessibility) caps += listOf("remote.screenshot", "remote.stream")
// Display power is asymmetric and only honest when BOTH halves exist. screen_off needs
// owner, device-admin FORCE_LOCK, or accessibility; screen_on now works anywhere via a
// wake lock (WAKE_LOCK is a normal permission). So the binding constraint is the OFF
@ -86,6 +103,13 @@ object PlayerCapabilities {
// confirmation — unusable on a panel with no input, so not claimed.
if (isOwner) caps += "system.kiosk"
// The privilege itself, declared as a capability. Every #161 Tier-2 command
// (lock_now / power_menu / status_bar / block_uninstall / unblock_uninstall) gates on
// this name. No player declared it, so the server accepts "system.kiosk" as a stand-in —
// exact, because kiosk is itself owner-only, but a stand-in nonetheless. Declaring the
// canonical name makes those refusals say what they mean and lets the stand-in retire.
if (isOwner) caps += "system.device_owner"
// Owner-only clock control.
if (isOwner) caps += "system.time"

View file

@ -192,16 +192,25 @@ written and tested starts being true.
## Real gaps worth closing
Prioritised by how visible the failure is to an operator. **None of these are implemented here**
other agents own the player files.
Prioritised by how visible the failure is to an operator.
**Gaps 1, 2 and 5 are closed** (gap 1 shipped in 1.9.31; 2 and 5 are on
`fix/player-parity-small-gaps` and unreleased). ⚠️ **The baselines below have deliberately NOT been
moved** — per the rule in this document a baseline entry moves when the fix *reaches displays*,
which is the release AFTER the one carrying it. Moving them together would grant a capability to
every panel still running the old build.
⚠️ **Gaps 3 and 4 were implemented, audited, and REVERTED.** Both are still open, and both are now
known to be considerably more expensive than "small". Their rows record what the audit found, so the
next attempt starts from the traps rather than rediscovering them.
| # | gap | difficulty | why it matters |
|---|---|---|---|
| 1 | **`set_volume` payload mismatch** (`server/player/index.html`, `tizen/js/app.js`) — accept `level` (0..1) alongside `value` (0..100). | **trivial** — one line each | The volume slider is dead on 3 of 4 players. Highest visibility, lowest cost in the list. |
| 2 | **`PlayerCapabilities.kt` under-declares.** Add `display.brightness` (Tier 0, `setWindowBrightness`, always available) and `system.device_owner` under `if (isOwner)`. | **trivial** | Updating an Android panel currently *loses* it the per-window dim slider, and keeps the Tier-2 stand-in in `player-capabilities.js` necessary. |
| 3 | **BrightSign "Force update" is a dead button.** `index.html` has no `update` branch; the host self-updates on its own poll. Either handle `update` by posting a bridge message that triggers `CheckPackageUpdate` now, or stop declaring `system.self_update`. | **small** (a bridge message + a BrightScript branch) | The button is rendered on every host-bridged BrightSign and does nothing. |
| 4 | **`declaredCapabilities()` should defer to `BS.capabilities()` on BrightSign.** | **small** | Turns 199 lines of already-written, already-passing tests from decoration into enforcement, and fixes six wrong declarations at once — including `offline.cache`, the one that shipped a lie to a real customer panel. |
| 5 | **The capture-bootstrap button is hidden where it is needed** (`device-detail.js`, `can('remote.screenshot')`). Render it whenever the device is Android and lacks `remote.screenshot`. | **small** | Server-side gating is fixed; the UI half is not. |
| 1 | **`set_volume` payload mismatch** (`server/player/index.html`, `tizen/js/app.js`) — accept `level` (0..1) alongside `value` (0..100). | **trivial** — one line each | The volume slider is dead on 3 of 4 players. Highest visibility, lowest cost in the list. **Fixed and released in 1.9.31** (`volumeLevelFromCommand()`). |
| 2 | **`PlayerCapabilities.kt` under-declares.** Add `display.brightness` (Tier 0, `setWindowBrightness`, always available) and `system.device_owner` under `if (isOwner)`. | **trivial** | Updating an Android panel currently *loses* it the per-window dim slider, and keeps the Tier-2 stand-in in `player-capabilities.js` necessary. **Fixed** — both declared; the `system.kiosk` stand-in can retire one release after this ships. **The gap was wider than written**: `remote.screenshot` and `remote.stream` were gated on the accessibility service while `captureScreen()` falls through to `ScreenshotCapture.captureView`, a plain view draw with no permission check — so a Tier-0 panel *lost live view and screenshots by updating*, and a granted MediaProjection never became a capability at all (nothing re-declares on consent, so the operator granted it, capture started, and the server went on refusing). Both are now unconditional, matching the baseline's own reasoning. `display.power` remains conditional on purpose — see the DELIBERATE list in `player-parity-baselines.test.js`. |
| 3 | **REVERTED — BrightSign "Force update" is a dead button.** `index.html` has no `update` branch; the host self-updates on its own poll. | **NOT small — needs host work first** | Wiring the button to `CheckPackageUpdate` was tried and withdrawn. The update path is **synchronous and unbounded** (no `SetTimeout` on either transfer), so a slow failing download blocks the message loop past `WATCHDOG_MS` (120s), fabricating a crash event and rebuilding the widget. Worse, `MAX_ATTEMPTS_PER_VERSION` is 3 and the counter carries **no version binding**, so three presses on a bad link refuse that panel *every future version* until someone clears the registry by hand. `cfg.self_update` is not in the probe payload either, so an opted-out fleet shows a button guaranteed to do nothing while the dashboard toasts success — and `update` is allowed as a **group broadcast**, so one click can start N synchronous downloads. Prerequisites: a transfer timeout under the watchdog, a version-bound (or manual-exempt) attempt counter, a result message so the toast can tell the truth, and `self_update` in the probe. Until then, **withdrawing the claim is the cheaper honest fix.** |
| 4 | **REVERTED — `declaredCapabilities()` should defer to `BS.capabilities()` on BrightSign.** | **NOT small — the bridge's list is not a superset** | Deferring wholesale was tried and withdrawn: the two lists disagree in **both** directions. The bridge gates `remote.screenshot`/`remote.stream` on `storage_present` alongside `system.self_update`, but that reasoning is stale — the player captures via `@brightsign/screenshot` into **RAM** (`/tmp`) and falls back to canvas, so both work with no disk and deferring *removes working controls*. Only `system.self_update` is genuinely storage-gated. The bridge also declares `playback.transitions` unconditionally, where the page checks `transitionRuntimeReady()` — an over-declare on the one platform where the UMD/`nodejs_enabled` collision silently kills transitions. And `offline.cache` gets *looser*, not stricter: the bridge omits the page's `swRegistrationFailed` check. Compounding all of it, `probeHost`'s 3s timeout sets `answered = true`, so a late `probe-result` is discarded **for the page's lifetime** — and `autorun.brs` runs a blocking update check *before* entering the message loop, making a >3s answer plausible. A correct fix is a per-capability MERGE, not a wholesale hand-off, plus fixing the probe timeout. |
| 5 | **The capture-bootstrap button is hidden where it is needed** (`device-detail.js`, `can('remote.screenshot')`). | **small** | Server-side gating is fixed; the UI half is not. **Fixed** via `isAndroidDevice()`, mirroring `platformFamily()` in `server/lib/player-capabilities.js` — all four signals in the same order, since a Tizen TV registers `android_version: 'Tizen 6.5'` and an Android-test-only helper classifies every Samsung panel as Android. ⚠️ The gate is Android-and-nothing-else, **not** "Android that lacks `remote.screenshot`": `/api/devices/:id` ships `capabilitiesFor()`, which flattens declared and baseline into one array, and the android baseline *contains* `remote.screenshot` — so that condition hides the button from all ~440 undeclared panels. The dashboard cannot currently distinguish "declared" from "baseline-filled" at all; if a future gate needs that, the API must expose the raw declaration. |
| 6 | **Tizen `remote.screenshot` is images-only.** Video and YouTube return a status card. AVPlay has no readable surface for a canvas. | **hard**, possibly impossible | Honest today, but an operator checking a video panel gets a card instead of a picture. |
| 7 | **BrightSign transitions/PiP over video.** DOM composited over a hwz hardware plane may be invisible. The likely fix is `roVideoMode.SetGraphicsZOrder("front")`, deliberately not applied blind. | **medium**, ❓ **needs hardware** | Changing z-order blind risks hiding video entirely on a player that currently works. |
| 8 | **BrightSign offline caching is unproven either way.** See below. | ❓ **needs hardware** | |

View file

@ -177,6 +177,39 @@ function isBrightSignDevice(device) {
return String(device.platform || '').toLowerCase().includes('brightsign');
}
// Mirrors platformFamily() in server/lib/player-capabilities.js — SAME FOUR SIGNALS, SAME ORDER,
// so the UI and the server never disagree about what a device is.
//
// The precedence is the whole point and is easy to get wrong. An earlier version of this helper
// kept only the last test, and a Tizen TV registers `android_version: 'Tizen 6.5'` (see
// tizen/js/app.js) — non-empty, not "Web/..." — so every Samsung panel in the fleet classified as
// Android. It was invisible only because Tizen happens to declare remote.screenshot today; the
// moment that changes, a MediaProjection button appears on a TV that has no such API.
//
// Gates the MediaProjection capture bootstrap below, and that gate is deliberately Android-and-
// nothing-else — NOT "Android that cannot already capture".
//
// The tempting extra condition is to hide it once a panel declares remote.screenshot. Two reasons
// not to. First, the dashboard cannot tell "this device declared it" from "the server filled in a
// baseline": /api/devices/:id ships capabilitiesFor(), which resolves both into one array (see
// server/routes/devices.js), and the android baseline CONTAINS remote.screenshot — so that
// condition hides the button from every one of the ~440 undeclared panels in the field, which is
// exactly backwards. Second, even where capture already works it is the accessibility path;
// MediaProjection is the better one (WebSocketService tries it FIRST), so offering the upgrade to
// a panel that has the weaker path is a feature, not redundancy.
function isAndroidDevice(device) {
if (!device) return false;
const platform = String(device.platform || '').toLowerCase();
if (platform.includes('brightsign')) return false;
if (platform.includes('tizen')) return false;
// Second, independent signal for a Tizen TV: the .wgt player sends client_type 'wgt'. `platform`
// is the primary key, but it lives in a column an older client's register could overwrite.
if (device.client_type === 'wgt') return false;
if (device.client_type === 'apk') return true;
const av = String(device.android_version || '');
return av !== '' && !av.startsWith('Web/');
}
export function render(container, deviceId) {
container.innerHTML = `
<div class="device-detail">
@ -799,7 +832,7 @@ async function loadDevice(deviceId, activeTab = null) {
${device.tier === 2 ? `
<span style="font-size:10px;color:var(--success);line-height:1.2;display:block;margin-top:8px">${t('device.remote.system_view_owner')}</span>
` : `
${can('remote.screenshot') ? `
${isAndroidDevice(device) ? `
<button class="btn btn-primary btn-sm" id="enableSystemCaptureBtn" onclick="window._enableSystemView()" title="${t('device.remote.system_view_tooltip')}" style="margin-top:8px">
${t('device.remote.enable_system_view')}
</button>

View file

@ -53,6 +53,20 @@ function render(device) {
renderDeviceClock: () => '',
renderPlaylist: () => '',
isBrightSignDevice: (d) => String(d.platform || '').toLowerCase().includes('brightsign'),
// Same four signals, same order, as the real helper in device-detail.js and platformFamily()
// in server/lib/player-capabilities.js. Kept as a stub rather than imported because this file
// renders the template in a bare VM context — but if the real rule changes, change it here too.
// The brightsign/tizen/wgt short-circuits come FIRST and are load-bearing: a Tizen TV registers
// android_version 'Tizen 6.5', which satisfies the Android test below.
isAndroidDevice: (d) => {
if (!d) return false;
const p = String(d.platform || '').toLowerCase();
if (p.includes('brightsign') || p.includes('tizen')) return false;
if (d.client_type === 'wgt') return false;
if (d.client_type === 'apk') return true;
const av = String(d.android_version || '');
return av !== '' && !av.startsWith('Web/');
},
TERMINAL_PRESETS: [],
localStorage: { getItem: () => null, setItem: () => {} },
Math, Date, JSON, String, Array, Object,
@ -71,8 +85,12 @@ const WEB = {
capabilities: ['playback.video', 'audio.volume', 'remote.screenshot', 'remote.stream',
'remote.input', 'system.restart_player'],
};
// Exactly what tizen/js/app.js registers, including the android_version field — which reads
// 'Tizen 6.5' and NOT anything Android-shaped. An earlier version of this fixture omitted it, so
// every "not offered to Tizen" assertion below passed without ever exercising the case that
// actually matters.
const TIZEN = {
platform: 'Tizen 6.5',
platform: 'Tizen 6.5', client_type: 'wgt', android_version: 'Tizen 6.5',
capabilities: ['playback.video', 'audio.volume', 'display.rotation', 'remote.input',
'system.restart_player'],
};
@ -188,3 +206,103 @@ test('every gated control still renders balanced markup', () => {
assert.equal(bopen, bclose, 'unbalanced <button>');
}
});
// ---------------------------------------------------------------------------------------------
// The MediaProjection capture bootstrap.
//
// This button is what turns screen capture ON for an Android panel that cannot do it yet. It hung
// off can('remote.screenshot') — which is backwards twice over. Android declares that capability
// only once the accessibility service is running, so the gate hid the button from every panel that
// still needed pressing, and showed it on browsers and TVs that have no MediaProjection at all.
test('the capture bootstrap is offered to an Android panel that cannot capture yet', () => {
const html = render({ client_type: 'apk', android_version: '13',
capabilities: ['playback.video', 'remote.input'] });
assert.ok(has(html, 'enableSystemCaptureBtn'),
'a panel with no remote.screenshot is exactly the one that needs the bootstrap');
});
test('THE ~440: a legacy panel keeps the button, using the shape the API really returns', () => {
// Fed through the REAL capabilitiesFor(), not a fixture with the field missing. That distinction
// sank an earlier version of this test: it rendered a device with no `capabilities` key at all,
// which made the harness's caps null — a shape GET /api/devices/:id never produces, because it
// resolves declared-or-baseline into one populated array. The test passed while production did
// the opposite, and the android baseline CONTAINS remote.screenshot, so any gate keyed on
// "already has capture" hides the bootstrap from every undeclared panel in the field.
const { capabilitiesFor } = require('../lib/player-capabilities');
const row = { client_type: 'apk', android_version: '11' }; // declares nothing
const resolved = capabilitiesFor(row);
assert.ok(resolved.includes('remote.screenshot'),
'precondition: the baseline grants capture, which is what makes the naive gate wrong');
const html = render({ ...row, capabilities: resolved });
assert.ok(has(html, 'enableSystemCaptureBtn'), 'the ~440 must not lose the bootstrap');
});
test('a panel that already declares capture is still offered the better path', () => {
// Deliberately NOT hidden. Declaring remote.screenshot on Android means the accessibility path;
// MediaProjection is the one WebSocketService tries first and is strictly better, so this is an
// upgrade rather than a redundant control.
assert.ok(has(render(ANDROID_FULL), 'enableSystemCaptureBtn'));
});
test('nothing that lacks MediaProjection is offered it', () => {
// A browser tab, a Tizen TV and a BrightSign have no such API. The old gate showed the button on
// all three whenever they declared remote.screenshot by their own, unrelated means.
for (const [name, dev] of [['web', WEB], ['tizen', TIZEN], ['brightsign', BRIGHTSIGN]]) {
assert.equal(has(render(dev), 'enableSystemCaptureBtn'), false,
`${name} has no MediaProjection to bootstrap`);
}
});
test('a device-owner panel is told it already has system capture instead', () => {
// Tier 2 needs no consent flow at all, so it gets the explanatory line, not the button.
const html = render({ client_type: 'apk', android_version: '13', tier: 2,
capabilities: ['playback.video'] });
assert.equal(has(html, 'enableSystemCaptureBtn'), false, 'an owner does not need to be asked');
});
// ---------------------------------------------------------------------------------------------
// Pinning the REAL helper.
//
// Everything above renders the genuine template but runs it against the stubbed isAndroidDevice in
// the sandbox, because the template is evaluated in a bare VM context. That means the assertions
// about Tizen prove the STUB is right, not the shipped function — mutation-testing confirmed it:
// reverting device-detail.js to the buggy two-signal helper leaves every test above green.
//
// So assert against the source directly. It is a coarse check, but it is the difference between a
// convention ("if the real rule changes, change it here too") and something that fails.
test('the shipped isAndroidDevice short-circuits brightsign, tizen and wgt BEFORE the Android test', () => {
const fn = (() => {
const i = SRC.indexOf('function isAndroidDevice(device) {');
assert.notEqual(i, -1, 'device-detail.js no longer defines isAndroidDevice');
let depth = 0, end = -1;
for (let k = SRC.indexOf('{', i); k < SRC.length; k++) {
if (SRC[k] === '{') depth++;
else if (SRC[k] === '}' && --depth === 0) { end = k + 1; break; }
}
return SRC.slice(i, end);
})();
// A Tizen TV registers android_version 'Tizen 6.5' (tizen/js/app.js), which satisfies the
// Android test. Only an earlier short-circuit keeps a MediaProjection button off a Samsung panel.
const brightsign = fn.indexOf("includes('brightsign')");
const tizen = fn.indexOf("includes('tizen')");
const wgt = fn.indexOf("'wgt'");
const androidTest = fn.indexOf("startsWith('Web/')");
for (const [name, idx] of [['brightsign', brightsign], ['tizen', tizen], ['wgt', wgt]]) {
assert.notEqual(idx, -1, `isAndroidDevice lost its ${name} short-circuit`);
assert.ok(idx < androidTest, `the ${name} short-circuit must come BEFORE the android_version test`);
}
// And behave correctly when actually executed, not merely contain the right text.
const real = eval(`(${fn.replace('function isAndroidDevice', 'function')})`); // eslint-disable-line no-eval
assert.equal(real({ platform: 'Tizen 6.5', client_type: 'wgt', android_version: 'Tizen 6.5' }), false,
'a Tizen TV as it really registers');
assert.equal(real({ client_type: 'wgt' }), false, 'the .wgt signal alone is enough');
assert.equal(real({ platform: 'brightsign', android_version: 'Web/Chrome 120' }), false, 'a BrightSign');
assert.equal(real({ android_version: 'Web/Chrome' }), false, 'a browser tab');
assert.equal(real({ client_type: 'apk', android_version: '11' }), true, 'a legacy Android panel');
assert.equal(real({ android_version: '9' }), true, 'an Android panel paired before client_type existed');
assert.equal(real(null), false, 'and it never throws on a missing device');
});

View file

@ -357,3 +357,87 @@ test('capability names are spelled the same in the server and in all four player
}
}
});
// ---------------------------------------------------------------------------------------------
// THE INVARIANT THAT WAS MISSING: an Android panel must not lose controls by updating.
//
// A declared set REPLACES the baseline, it does not merge with it. So every capability the android
// baseline grants has to survive in the declaration a fielded panel will send after it updates —
// otherwise the reward for updating is a smaller control panel than before.
//
// This is not hypothetical. `display.brightness` was missing and was restored; `remote.screenshot`
// and `remote.stream` were gated on the accessibility service while the capture path that serves
// them (ScreenshotCapture.captureView) needs no permission at all, so a Tier-0 panel lost live view
// by updating. Both were invisible to this suite: the tests above assert things ABOUT the baseline
// and about hypothetical declared arrays, and nothing compared the two.
/** The capabilities PlayerCapabilities.kt declares for EVERY panel, whatever its privilege. */
function androidUnconditional() {
const src = read('android/app/src/main/java/com/remotedisplay/player/telemetry/PlayerCapabilities.kt');
const start = src.indexOf('caps += listOf(');
assert.notEqual(start, -1, 'PlayerCapabilities.kt no longer has the unconditional caps += listOf(...) block');
let depth = 0, end = -1;
for (let i = src.indexOf('(', start); i < src.length; i++) {
if (src[i] === '(') depth++;
else if (src[i] === ')' && --depth === 0) { end = i; break; }
}
assert.ok(end > start, 'could not find the end of the unconditional block');
// Strip comments first: the block is heavily commented and the prose quotes capability names.
const body = src.slice(start, end)
.split('\n').map((l) => l.replace(/\/\/.*$/, '')).join('\n');
return new Set([...body.matchAll(/"([a-z]+\.[a-z_]+)"/g)].map((m) => m[1]));
}
test('THE UPDATE INVARIANT: no Android panel loses a baseline capability by updating', () => {
const declared = androidUnconditional();
/*
* The one documented exception. display.power is asymmetric: screen_on works anywhere via a wake
* lock, but screen_off needs device owner, device-admin FORCE_LOCK, or accessibility so it is
* declared conditionally on purpose, and offering a control that sleeps a panel it cannot wake
* would be the worst version of this feature. PlayerCapabilities.kt reasons it out where it is
* declared, and player-capabilities.js reasons about the same asymmetry.
*
* Anything added to this set is a deliberate, argued regression. Adding one to silence a failure
* is how the two losses above survived a whole release cycle.
*/
const DELIBERATE = new Set(['display.power']);
const lost = caps.BASELINE.android.filter((c) => !declared.has(c) && !DELIBERATE.has(c));
assert.deepEqual(lost, [],
`updating an Android panel would silently remove ${JSON.stringify(lost)} — either declare them `
+ 'unconditionally, or add them to DELIBERATE with the reasoning written down');
});
test('the exception list stays honest — every entry is really absent and really argued', () => {
// A DELIBERATE entry that is actually declared unconditionally is stale bookkeeping, and hides
// the next real regression behind a name that no longer means anything.
const declared = androidUnconditional();
assert.equal(declared.has('display.power'), false,
'display.power is now unconditional — remove it from DELIBERATE above rather than leaving a lie');
const src = read('android/app/src/main/java/com/remotedisplay/player/telemetry/PlayerCapabilities.kt');
assert.ok(/isOwner \|\| policy\.isAdminActive\(\) \|\| accessibility/.test(src),
'display.power is excepted because of that specific three-way guard; if the guard changed, re-argue it');
});
test('capture is claimed at every tier, because the fallback that serves it needs no permission', () => {
// The regression this pins: gating these on `accessibility` while captureScreen() falls through
// to a plain view draw. It also made a granted MediaProjection invisible to the server — consent
// given, capture working, screenshots still refused, because nothing re-declared.
const declared = androidUnconditional();
for (const c of ['remote.screenshot', 'remote.stream']) {
assert.ok(declared.has(c), `${c} must be unconditional — the baseline grants it and captureView serves it`);
}
const shot = read('android/app/src/main/java/com/remotedisplay/player/remote/ScreenshotCapture.kt');
assert.equal(/checkSelfPermission|Settings\.canWrite|isDeviceOwner/.test(shot), false,
'the claim above rests on captureView needing no privilege — it now appears to check one');
});
test('the Tier-2 privilege is declared under the same guard as the kiosk it stands in for', () => {
// Mutation-proofs the other half of the change: deleting this line must fail something.
const src = read('android/app/src/main/java/com/remotedisplay/player/telemetry/PlayerCapabilities.kt');
assert.ok(/if \(isOwner\) caps \+= "system\.device_owner"/.test(src),
'system.device_owner must be declared, so Tier-2 refusals name the capability they mean');
assert.ok(/if \(isOwner\) caps \+= "system\.kiosk"/.test(src),
'and system.kiosk must remain until the stand-in retires a release later');
});