Merge: platform-native capability declaration

This commit is contained in:
ScreenTinker 2026-08-05 14:25:46 -05:00
commit 90eee4ce45
5 changed files with 210 additions and 7 deletions

View file

@ -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)

View file

@ -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}") }
}

View file

@ -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" }
}

View file

@ -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,

View file

@ -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<String>()
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 <video>,
// so this is a real claim rather than the half-truth the browser players carried.
"audio.mute", "audio.volume",
// Native view rotation: the ExoPlayer surface sits inside the rotated view, so video
// turns with the graphics. No hardware-plane problem here.
"display.rotation",
// Input is plain view dispatch and works regardless of privilege.
"remote.input",
// The player restarts itself; the OTA checker updates the APK.
"system.restart_player", "system.self_update",
// Clock-derived group sync is platform-independent.
"sync.clock",
// Content is cached to local storage and survives a server outage.
"offline.cache",
// App-UID `sh -c`. Deliberately NOT gated on device owner: it runs at any tier and
// is the diagnostic path the dashboard already relies on. Gated server-side instead.
"system.shell"
)
// ---- 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
// path — offering a control that sleeps a panel it cannot wake would be the worst
// possible version of this feature.
if (isOwner || policy.isAdminActive() || accessibility) caps += "display.power"
// Owner-only reboot. Off-owner it degrades to an accessibility power DIALOG, which needs
// someone standing at the screen — not a remote capability.
if (isOwner) caps += "system.reboot"
// Silent lock-task. Off-owner startLockTask() gives screen pinning, which prompts for
// confirmation — unusable on a panel with no input, so not claimed.
if (isOwner) caps += "system.kiosk"
// Owner-only clock control.
if (isOwner) caps += "system.time"
// Silent install: device owner, or a foreign DPC that delegated the install scope.
if (canInstall) caps += "system.install_apk"
// System-wide brightness and screen-off timeout: WRITE_SETTINGS, or an owner writing the
// setting directly. Per-window dimming works at any tier but is not what the operator
// means by "brightness", so it does not earn the claim on its own.
if (canWriteSettings || isOwner) caps += listOf("system.brightness", "system.screen_timeout")
Log.i(TAG, "Capabilities: ${caps.size} declared (owner=$isOwner install=$canInstall " +
"writeSettings=$canWriteSettings a11y=$accessibility)")
} catch (e: Throwable) {
// An empty array is honest here. Falling back to "everything" would put us straight back
// to buttons that do nothing, which is the failure this whole model exists to remove.
Log.w(TAG, "Capability detection failed: ${e.message}")
}
return JSONArray(caps)
}
private const val TAG = "PlayerCapabilities"
}
/*
* Deliberately NOT declared on Android, so the dashboard stops offering them:
*
* display.resolution Setting the output mode needs system/root. The panel runs at whatever the
* display negotiated and an app cannot change it.
* sync.native Frame-accurate hardware sync is a BrightSign SyncManager feature. Android's
* clock-derived group sync is declared instead, which is what it actually has.
*/