mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Android declares what it can actually do, and can wake a panel it slept
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
parent
6bc709d2f7
commit
812e89f28f
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}") }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
Loading…
Reference in a new issue