mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
Merge: platform-native capability declaration
This commit is contained in:
commit
90eee4ce45
|
|
@ -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