diff --git a/server/lib/player-capabilities.js b/server/lib/player-capabilities.js index 49620b9..4ed90cd 100644 --- a/server/lib/player-capabilities.js +++ b/server/lib/player-capabilities.js @@ -74,7 +74,7 @@ const BASELINE = { // still in the field. Both are Tier 0: MainActivity applies them with no owner, no admin and // no WRITE_SETTINGS, so they are unconditional on any build a fielded panel could be running. 'audio.mute', 'audio.volume', - 'display.rotation', 'display.brightness', + 'display.rotation', 'display.power', 'display.brightness', // Capture without accessibility falls back to ScreenshotCapture.captureView, which is a real // frame of the player's own view — i.e. of the content. Narrower than the full-screen path, // but the operator gets a picture, not a dead button. @@ -82,12 +82,24 @@ const BASELINE = { 'remote.input', 'system.restart_player', 'system.self_update', 'sync.clock', 'offline.cache', - // NOT display.power. v1.9.28 MainActivity answers screen_on with + // display.power is KEPT for the un-updated Android fleet, deliberately, with the trade-off + // recorded here because it is genuinely two-sided. + // + // v1.9.28 MainActivity answers screen_on with // Log.w("screen_on: no privileged wake path on a non-rooted panel — no-op") - // so the ON half is dead on 100% of fielded Android panels, and screen_off only works with - // owner / device-admin / accessibility. The dashboard renders BOTH buttons off this one - // capability. A panel you can sleep and cannot wake is the worst possible version of this - // feature, which is exactly why PlayerCapabilities.kt gates its own claim on both halves. + // so the ON half is dead on every fielded Android panel, while screen_off does work (device + // owner / device-admin FORCE_LOCK, else the accessibility lock). One capability renders BOTH + // dashboard buttons, so this baseline cannot offer the working half without the dead one. + // + // Withholding it takes away blank-at-night, which is the half signage actually schedules, from + // every panel that has not updated. Keeping it means an operator can sleep a screen and not + // wake it from the dashboard — mitigated by the fact that a schedule, a restart, or anyone + // standing at the panel will wake it, while nothing else can blank it. + // + // A panel that HAS updated declares for itself, and PlayerCapabilities.kt gates its own claim + // on both halves — so this governs the un-updated fleet only. If the dead ON button turns out + // to be the louder complaint, split it into display.power_off / display.power_on rather than + // dropping the pair. // // NOT system.reboot. STPolicy.reboot() requires device owner; off-owner v1.9.28 falls back to // the accessibility power DIALOG, which needs a human standing at the screen — and on the diff --git a/server/test/device-command-gating.test.js b/server/test/device-command-gating.test.js index 36b5142..9e875e8 100644 --- a/server/test/device-command-gating.test.js +++ b/server/test/device-command-gating.test.js @@ -27,16 +27,17 @@ test('the legacy fleet is not locked out of the commands it has always accepted' // nothing, and a refusal keyed off "declared nothing => supports nothing" bricks every control // in the product at once. const legacy = { client_type: 'apk', android_version: '9' }; - for (const cmd of ['launch', 'refresh', 'update', 'set_volume', 'set_brightness']) { + for (const cmd of ['launch', 'refresh', 'update', 'set_volume', 'set_brightness', + // screen_off blanks a fielded panel for real, and screen_on shares its capability. The pair + // stays reachable so scheduled blank-at-night keeps working on displays that have not updated; + // see the display.power note in lib/player-capabilities.js for the accepted trade-off. + 'screen_off', 'screen_on']) { assert.equal(caps.commandAllowed(legacy, cmd).ok, true, `${cmd} must still reach a legacy Android panel`); } - // reboot / screen_on / screen_off are NOT on that list any more, and that is the parity audit's - // finding rather than an oversight: v1.9.28 answers screen_on with a logged no-op on every - // panel, and STPolicy.reboot() needs device owner. Keeping them would have been the other half - // of the same bug — a control that appears to work and changes nothing. - for (const cmd of ['reboot', 'screen_on', 'screen_off']) { - assert.equal(caps.commandAllowed(legacy, cmd).ok, false, `${cmd} is privilege-gated on a fielded panel`); - } + // reboot IS refused, and that is the parity audit's finding rather than an oversight: + // STPolicy.reboot() needs device owner, and the off-owner fallback paints an accessibility power + // DIALOG over the signage that a human has to dismiss. That is worse than a refusal. + assert.equal(caps.commandAllowed(legacy, 'reboot').ok, false, 'reboot needs device owner'); }); test('a command with no capability requirement is never refused', () => { diff --git a/server/test/player-parity-baselines.test.js b/server/test/player-parity-baselines.test.js index 80b90ac..2a61723 100644 --- a/server/test/player-parity-baselines.test.js +++ b/server/test/player-parity-baselines.test.js @@ -212,11 +212,15 @@ test('BICONDITIONAL: offline.cache in a baseline iff that player has a media cac 'a widget that refuses to register a worker cannot cache one byte'); }); -test('display.power is claimed only where BOTH halves work without privilege', () => { - // Android v1.9.28 answers screen_on with a logged no-op and gates screen_off on - // owner/admin/accessibility, so the pair is not honest for an undeclared panel. Tizen implements - // both with a plain overlay and no signing requirement. - assert.equal(caps.BASELINE.android.includes('display.power'), false); +test('display.power is claimed where at least one half does something', () => { + // Android is the deliberate exception and the baseline comment carries the reasoning: v1.9.28 + // answers screen_on with a logged no-op, but screen_off genuinely blanks the panel (owner/admin + // FORCE_LOCK, else the accessibility lock). One capability renders both dashboard buttons, so + // withholding it to hide the dead ON button also removes blank-at-night — the half signage + // actually schedules — from every panel that has not updated. Kept, knowingly. + // Tizen implements both with a plain overlay and no signing requirement. + assert.ok(caps.BASELINE.android.includes('display.power'), + 'screen_off works on a fielded Android panel; do not withhold it to hide screen_on'); assert.ok(caps.BASELINE.tizen.includes('display.power')); assert.equal(caps.BASELINE.web.includes('display.power'), false, 'a browser tab cannot power a panel'); assert.equal(caps.BASELINE.brightsign.includes('display.power'), false, 'CEC needs the host bridge');