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