From 812e89f28f3ec74afdbdfc58c72c5c95cadde534 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Wed, 5 Aug 2026 14:08:52 -0500 Subject: [PATCH] Android declares what it can actually do, and can wake a panel it slept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of platform-native parity. THE DECLARATION. The player now sends a `capabilities` array on every register, using the vocabulary in server/lib/player-capabilities.js so the dashboard can stop offering controls that cannot work on a given panel. Computed at registration, never cached, because almost everything interesting is runtime state an APK cannot know about itself: accessibility gets switched on months after install, device owner arrives through a provisioning flow, and WRITE_SETTINGS is a grant an operator can revoke. A value captured once would be wrong on the same hardware from one boot to the next. The rule when uncertain is to UNDER-claim. A missing control is a support question; a control that looks like it works and does nothing is a bug report, and on a panel nobody can reach it is an expensive one. So: system.reboot / kiosk / time owner only. Off-owner, reboot degrades to an accessibility power DIALOG and kiosk to screen pinning — both need someone at the screen, which is not a remote capability. system.install_apk owner or a delegated install scope. system.brightness / timeout WRITE_SETTINGS or owner. Per-window dimming works at any tier but is not what an operator means by "brightness". remote.screenshot / stream accessibility only. Without it capture falls back to the app's own view. display.power see below. system.shell ALWAYS. It is app-UID `sh -c` and runs at any tier; the directive grouped it with Tier-2, but the code is not owner-gated and under-claiming would hide a working diagnostic. Never declared, so the dashboard stops offering them: display.resolution (needs system/root — an app cannot change the negotiated output mode) and sync.native (frame-accurate hardware sync is a BrightSign SyncManager feature; Android has the clock-derived group sync, which IS declared). THE WAKE PATH. display.power was asymmetric: screen_off worked via owner, admin FORCE_LOCK or accessibility, while screen_on was a logged no-op. The retired attempt was `input keyevent 224`, which exec denies to an app UID, and that one failure had been read as "no wake path exists". A wake LOCK is a different mechanism needing only WAKE_LOCK — a normal permission already in the manifest. That asymmetry is expensive on a fleet: an operator sleeps a panel overnight and cannot wake it remotely, so someone drives to the site. Losing the screen is the wrong direction to fail in. Handled in the service as well as the Activity, since the service is the only thing guaranteed alive, and paired with a keyguard dismiss because waking to a lock screen is half a fix. The lock is held briefly and self-expires, so a missed release cannot pin a panel on. display.power is therefore declared on the OFF path (owner/admin/accessibility), which is now the binding constraint — offering a control that sleeps a panel it cannot wake would be the worst version of this feature. DeviceInfo.isAccessibilityEnabled is internal rather than private so the declaration asks the same question as the telemetry shown beside it, instead of a second copy that drifts. Verified: APK compiles (9,023,669 bytes); all 25 declared strings are known to the server vocabulary, with zero unknown; and they survive R8 into classes4.dex along with the `capabilities` payload key. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- .../com/remotedisplay/player/MainActivity.kt | 32 ++++- .../player/service/WebSocketService.kt | 20 ++- .../player/system/SystemControl.kt | 37 ++++++ .../player/telemetry/DeviceInfo.kt | 8 +- .../player/telemetry/PlayerCapabilities.kt | 120 ++++++++++++++++++ 5 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 android/app/src/main/java/com/remotedisplay/player/telemetry/PlayerCapabilities.kt diff --git a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt index b4cf7b6..7b31c86 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -779,9 +779,35 @@ class MainActivity : AppCompatActivity() { ?: Log.w("MainActivity", "screen_off/lock_now: no owner/admin/accessibility — unsupported") } } - // No reliable privileged wake on a non-rooted panel (the old keyevent 224 was denied); - // retired to a logged no-op. - "screen_on" -> Log.w("MainActivity", "screen_on: no privileged wake path on a non-rooted panel — no-op") + // Was a logged no-op: the retired `input keyevent 224` is denied to an app UID, and + // that one failure was read as "no wake path exists". A wake LOCK is a different + // mechanism needing only WAKE_LOCK, which we already hold — so screen_off worked + // and screen_on did not, and an operator who slept a panel overnight had to drive + // out to wake it. Losing the screen is the expensive direction to fail in. + "screen_on" -> { + val woke = systemControl.wakeScreen() + // The wake lock lights the panel; on a locked device the keyguard is still in + // front of the player, so ask for it to be dismissed too. Both are best-effort + // and independent — a device that ignores one may honour the other. + runOnUiThread { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + (getSystemService(Context.KEYGUARD_SERVICE) as? android.app.KeyguardManager) + ?.requestDismissKeyguard(this, null) + } else { + @Suppress("DEPRECATION") + window.addFlags( + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + ) + } + } catch (e: Throwable) { Log.w("MainActivity", "screen_on keyguard: ${e.message}") } + } + Log.i("MainActivity", "screen_on: wake=$woke") + } // #161 Tier-2 (all no-op off-owner via STPolicy): kiosk lock-task, time/tz, status bar, // uninstall block. Device owner enters lock-task silently; others get screen-pinning. "kiosk_lock" -> setKioskMode(true) diff --git a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt index 92766bc..88cc282 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt @@ -596,8 +596,18 @@ class WebSocketService : Service() { } } catch (e: Throwable) { Log.e("WebSocketService", "screen_off: ${e.message}") } } - // No privileged wake on a non-rooted panel (keyevent 224 was denied); retired. - "screen_on" -> Log.w("WebSocketService", "screen_on: no privileged wake path — no-op") + // Was a no-op because `input keyevent 224` is denied to an app UID — but a + // wake LOCK is a different mechanism needing only WAKE_LOCK, which we hold. + // Handled here as well as in MainActivity so a panel whose Activity is not + // foregrounded can still be woken; the service is the only thing guaranteed + // to be alive, and "screen won't come back on" means a site visit. + "screen_on" -> { + val woke = com.remotedisplay.player.system.SystemControl(applicationContext).wakeScreen() + Log.i("WebSocketService", "screen_on: wake=$woke") + // Bring the player back in front of the keyguard too. Same fail-loud + // reasoning as Relauncher: waking to a lock screen is only half a fix. + handler.post { try { onCommand?.invoke("screen_on", payload) } catch (_: Throwable) {} } + } "set_debug" -> { val on = payload?.optBoolean("enabled", false) ?: false // Point the sink at this socket, then flip the flag. When on, @@ -657,6 +667,12 @@ class WebSocketService : Service() { put("client_version", deviceInfo.getAppVersion()) put("platform", "Android " + android.os.Build.VERSION.RELEASE) put("contract_version", "v4") + // What this panel can actually do, so the dashboard stops offering controls that + // cannot work on it. Recomputed on EVERY register rather than cached: accessibility + // gets switched on months after install, device owner arrives via provisioning, and + // WRITE_SETTINGS can be revoked — a value captured once would be wrong on the same + // hardware from one boot to the next. + put("capabilities", com.remotedisplay.player.telemetry.PlayerCapabilities.declare(this@WebSocketService)) } catch (e: Throwable) { Log.w("WebSocketService", "identity: ${e.message}") } } diff --git a/android/app/src/main/java/com/remotedisplay/player/system/SystemControl.kt b/android/app/src/main/java/com/remotedisplay/player/system/SystemControl.kt index 8bf670b..91fe082 100644 --- a/android/app/src/main/java/com/remotedisplay/player/system/SystemControl.kt +++ b/android/app/src/main/java/com/remotedisplay/player/system/SystemControl.kt @@ -94,5 +94,42 @@ class SystemControl(private val context: Context) { } catch (e: Throwable) { Log.w(TAG, "setScreenOffTimeout: ${e.message}"); false } } + /** + * Wake the panel. The other half of screen_off, which had none. + * + * screen_off has always worked — device owner / admin FORCE_LOCK, or the accessibility lock — + * but screen_on was a logged no-op on the belief that a non-rooted panel has no privileged + * wake path. The retired attempt was `input keyevent 224`, which exec denies to an app UID; a + * wake LOCK is a different mechanism and needs only WAKE_LOCK, a normal permission we already + * hold. So the conclusion ("no wake path") was drawn from one failed approach. + * + * That asymmetry is worse than it sounds on a signage fleet: an operator turns a panel off for + * the night and cannot turn it back on remotely, so someone drives to the site. Losing the + * screen is the expensive direction to fail in. + * + * ACQUIRE_CAUSES_WAKEUP + SCREEN_BRIGHT is deprecated (API 17) and still the only app-level + * wake there is; on a device that ignores it we return false rather than pretending. Held + * briefly and released — an indefinite wake lock would pin the panel on and defeat every + * screen-off command that follows. + */ + fun wakeScreen(holdMs: Long = 3000L): Boolean = try { + val pm = context.getSystemService(Context.POWER_SERVICE) as android.os.PowerManager + @Suppress("DEPRECATION") + val lock = pm.newWakeLock( + android.os.PowerManager.SCREEN_BRIGHT_WAKE_LOCK or + android.os.PowerManager.ACQUIRE_CAUSES_WAKEUP or + android.os.PowerManager.ON_AFTER_RELEASE, + "screentinker:wake" + ) + // Time out on its own as well as being released below: if the release is ever missed, a + // self-expiring lock still lets the panel sleep instead of burning in. + lock.acquire(holdMs) + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + try { if (lock.isHeld) lock.release() } catch (_: Throwable) { } + }, holdMs) + Log.i(TAG, "wakeScreen: wake lock acquired for ${holdMs}ms") + true + } catch (e: Throwable) { Log.w(TAG, "wakeScreen: ${e.message}"); false } + companion object { private const val TAG = "SystemControl" } } diff --git a/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt b/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt index b694336..03bbde2 100644 --- a/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt +++ b/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt @@ -107,8 +107,12 @@ class DeviceInfo(private val context: Context) { Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_OFF_TIMEOUT, 0) } catch (_: Throwable) { 0 } - /** #160: is OUR accessibility service currently enabled (drives remote-control availability). */ - private fun isAccessibilityEnabled(): Boolean = try { + /** + * #160: is OUR accessibility service currently enabled (drives remote-control availability). + * Internal rather than private because the capability declaration asks the same question, and + * a second copy of this check would drift from the telemetry the dashboard shows beside it. + */ + internal fun isAccessibilityEnabled(): Boolean = try { val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as android.view.accessibility.AccessibilityManager val mine = android.content.ComponentName(context, diff --git a/android/app/src/main/java/com/remotedisplay/player/telemetry/PlayerCapabilities.kt b/android/app/src/main/java/com/remotedisplay/player/telemetry/PlayerCapabilities.kt new file mode 100644 index 0000000..90b2a0e --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/telemetry/PlayerCapabilities.kt @@ -0,0 +1,120 @@ +package com.remotedisplay.player.telemetry + +import android.content.Context +import android.os.Build +import android.provider.Settings +import android.util.Log +import com.remotedisplay.player.admin.STPolicy +import org.json.JSONArray + +/** + * What THIS panel can actually do, right now. + * + * The dashboard used to offer every control to every display, so buttons that could never work on + * a given panel sat there and did nothing when pressed. The server-side vocabulary lives in + * `server/lib/player-capabilities.js`; these strings must match it exactly, because an unknown + * name is dropped on arrival and a renamed one silently removes a control from every panel still + * reporting the old spelling. + * + * ⚠️ Computed at REGISTRATION, not build time. Almost everything interesting here is runtime state + * an APK cannot know about itself: accessibility gets switched on months after install, device + * owner is granted by a provisioning flow, WRITE_SETTINGS is a per-device grant an operator may + * revoke. A static list would be wrong on the same hardware from one boot to the next. + * + * The rule when uncertain is to UNDER-claim. A missing control is a support question; a control + * that appears to work and does nothing is a bug report, and on a panel nobody can reach it is an + * expensive one. + */ +object PlayerCapabilities { + + /** + * The capability set for this device, as a JSON array ready to attach to the register payload. + * Never throws: a failure here must not cost the panel its registration, so the worst case is + * an empty declaration, which the server reads as "declares nothing meaningful". + */ + fun declare(context: Context): JSONArray { + val caps = mutableListOf() + try { + val policy = STPolicy(context) + val isOwner = policy.isDeviceOwner() + val canInstall = policy.canInstallSilently() + val canWriteSettings = try { Settings.System.canWrite(context) } catch (_: Throwable) { false } + val accessibility = DeviceInfo(context).isAccessibilityEnabled() + + // ---- always true on the Android player ------------------------------------------------- + // Every content type the playlist engine renders, plus the layout features built on it. + caps += listOf( + "playback.video", "playback.image", "playback.widget", "playback.youtube", + "playback.zones", "playback.transitions", "playback.pip", + // Mute reaches the YouTube embed through the IFrame API bridge, not just