SecurityException Kills HDMI-CEC Power Control on Sideloads
This article may contain affiliate links. Its content is not affected by advertising.
In short
HdmiControlManager's CEC control APIs require signature|privileged permission, so an ordinary sideloaded app gets SecurityException on most devices, making the call an effective no-op.
Conclusion
Android’s HdmiControlManager exposes HDMI-CEC control APIs (standby, wakeup, etc.) that require signature|privileged permission, so calling them from a sideloaded app throws SecurityException on most devices, making the call an effective no-op. A fleet of signage devices (Google TVs locked down as Device Owner kiosks) wanted to turn the screen fully off at night via a genuine HDMI-CEC standby, but this constraint forced the real mechanism away from CEC and onto DevicePolicyManager.lockNow() instead.
Symptom
The signage devices are Google TVs mounted as hallway displays, locked into kiosk mode via Device Owner, with the screen meant to turn off at night and back on in the morning. The first approach considered was dropping the TV itself into a genuine HDMI-CEC standby. The comment in the resulting code spells out exactly why that approach doesn’t work.
// CecHelper.kt
/**
* Best-effort attempt to put the TV into real Standby / WakeUp via HDMI-CEC.
*
* Note:
* - HdmiControlManager's control APIs require signature|privileged permission,
* so an ordinary sideloaded app is normally rejected.
* - If it happens to work on some device, consider it a bonus.
* - Even on failure, the black-screen overlay still keeps the "looks off" appearance.
*
* Ideally this would call HdmiControlManager#setSystemAudioMode /
* HdmiControlManager.OneTouchPlay (API 30+), but in most cases it just
* throws SecurityException. For now this is absorbed via reflection + fallback.
*/
The actual tryStandby only logs whether the service exists — it never issues a single control command.
fun tryStandby(context: Context) {
runCatching {
val svc = context.getSystemService("hdmi_control")
if (svc != null) {
Log.i(TAG, "hdmi_control service available: ${svc.javaClass.name}")
// Depends on the vendor implementation. The public API has no
// direct way to issue control commands.
} else {
Log.i(TAG, "hdmi_control service not available")
}
}.onFailure {
Log.d(TAG, "tryStandby skipped: ${it.message}")
}
}
getSystemService("hdmi_control") itself does not return null — it does return a service object. The problem only shows up one step further, when actually issuing a “go to standby” or “wake up” control command.
Cause
Android’s HdmiControlManager exposes a public API for controlling a TV’s power and audio routing over HDMI-CEC, but its control methods require a permission with signature or privileged protection level. That permission is only granted to apps signed with the system certificate or placed in the vendor’s privileged partition — there is no way for a third-party sideloaded app to satisfy it.
What’s easy to conflate here is that the signage device is already a Device Owner under DevicePolicyManager. Device Owner is a strong “manage the whole device” privilege, but it belongs to the dpm (device management) permission system, a different layer from the signature|privileged permission that HdmiControlManager requires. Becoming Device Owner does nothing to satisfy the signing and placement conditions the CEC control APIs demand. The comment’s “consider it a bonus if it works on some device” is a hedge for the rare case where an OEM has loosened its CEC handler’s permission check; the design assumption is still “rejected by default.”
The fix
Once CEC was ruled out as the real mechanism, turning the screen off at night was moved to the alternative that PowerController’s design comment spells out. Letting the device fall into deep sleep stops polling and makes remote wake unreachable with no way to recover, so the device itself stays awake 24 hours a day, and only the screen (backlight) is turned off, via Device Owner’s lockNow().
// PowerController.kt
/**
* Design decision (2026-06-08):
* - Letting the device fall into *deep sleep* stops polling and makes remote
* wake unreachable, leaving it **unrecoverable** (confirmed with the user).
* So the device stays awake 24h, and only the **screen (backlight)** turns off.
* - The real mechanism for screen-off is DevicePolicyManager.lockNow()
* (available as Device Owner / an active device admin). This triggers a
* screen lock, i.e. backlight off, while the CPU/services/polling stay alive.
* - HDMI-CEC (CecHelper) needs privileged permission and is almost always a
* no-op, so it's kept only as a *secondary* attempt alongside lockNow.
* - BlackScreenActivity (the black overlay) is a purely *visual* fallback for
* devices where lockNow doesn't take effect.
*/
In the implementation, lockNow() is called as the primary mechanism, with CEC kept in the same call as a secondary attempt that’s “a bonus if it works.”
fun screenOff(context: Context) {
val locked = runCatching {
if (canLock(context)) {
dpm(context)?.lockNow()
Log.i(TAG, "screenOff: lockNow() issued")
true
} else {
Log.w(TAG, "screenOff: not device owner / admin -> lockNow skipped")
false
}
}.onFailure { Log.w(TAG, "screenOff: lockNow failed: ${it.message}") }
.getOrDefault(false)
// Secondary: HDMI-CEC standby (a no-op on most devices, but a bonus if it works)
runCatching { CecHelper.tryStandby(context) }
if (!locked) {
Log.i(TAG, "screenOff: relying on BlackScreenActivity overlay fallback")
}
}
In case lockNow() fails (the device is neither Device Owner nor an active admin), the BlackScreenActivity black-screen overlay is kept as a purely visual fallback. With this three-layer fallback (lockNow → CEC assist → black overlay), every device ends up looking “off, by some means” without exception.
Preventing a repeat
There is no automated test for this code. The actual safeguard against a repeat is the design-decision comment in PowerController.kt (2026-06-08) and the warning note inside CecHelper themselves. By leaving the conclusion — “CEC needs signature|privileged permission and is almost always a no-op; the real mechanism is lockNow” — right at the top of the code as a comment, it prevents a future reader (including the original author) from redoing the same investigation and adding back a CEC-based control implementation. Every call being wrapped in runCatching is also a deliberate design choice, letting execution fall through to the next fallback on devices that aren’t Device Owner or aren’t supported, instead of crashing — the implementation assumes it is running in an environment that may lack permission.
Frequently asked questions
Q1Why does HDMI-CEC power control throw SecurityException?
HdmiControlManager's control methods, such as setSystemAudioMode and OneTouchPlay, require signature|privileged permission. A sideloaded app only has an ordinary signature and ordinary permissions, so calling them throws SecurityException on most devices.
Q2Does becoming a Device Owner work around this?
No. Device Owner is a device-management privilege granted by DevicePolicyManager (dpm), a separate permission system from the signature|privileged permission HdmiControlManager requires. Device Owner unlocks dpm.lockNow(), but it does not remove the permission gap on the CEC control APIs.
Q3If CEC doesn't work, how is the screen turned off at night?
DevicePolicyManager.lockNow() locks the screen (backlight off) only, while the device itself stays awake 24 hours a day. Letting it go into deep sleep would stop polling and make remote wake unreachable, with no way to recover. CEC is kept as a best-effort extra, used only if it happens to work.
Q4Can the HdmiControlManager service itself be obtained at all?
Yes. context.getSystemService("hdmi_control") returns a service object rather than null. The problem appears only afterward, when actually calling control methods like standby or wakeup, which get rejected for lacking signature|privileged permission.
Environment verified
- Android (Device Owner kiosk-mode Google TV device / tv-ble-bridge)
- Design decision on 2026-06-08, implemented 2026-06-14 in commit e81029c7
What this article is based on
- Kotlin file lines 1-18commit e81029c
- Kotlin file lines 23-37commit e81029c
- Kotlin file lines 10-21commit e81029c
- Kotlin file lines 57-76commit e81029c
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.