mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Merge branch 'fix/player-parity-small-gaps'
This commit is contained in:
commit
20d36e8923
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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** | |
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue