Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Screen timeout validation**: Reject non-positive / malformed `screen_timeout` values in the Display options step and `set_kiosk_policy` service before calling the API. Remove incorrect `0s (never)` hint text — the Android Management API requires a duration greater than 0. When timeout mode is `SCREEN_TIMEOUT_USER_CHOICE`, omit `screenTimeout` from the policy body as required by Google.

## [1.0.0] - 2026-07-17

### Added
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,8 @@ Create or update a kiosk policy with structured fields. Supports a primary kiosk
| `status_bar` | No | `NOTIFICATIONS_AND_SYSTEM_INFO_DISABLED` | Status bar behavior in kiosk mode. |
| `screen_brightness_mode` | No | `BRIGHTNESS_FIXED` | Brightness control mode. |
| `screen_brightness` | No | `180` | Brightness level (0–255). |
| `screen_timeout_mode` | No | `SCREEN_TIMEOUT_ENFORCED` | Screen timeout control mode. |
| `screen_timeout` | No | `220s` | Screen timeout duration. |
| `screen_timeout_mode` | No | `SCREEN_TIMEOUT_ENFORCED` | Screen timeout control mode. Use `SCREEN_TIMEOUT_USER_CHOICE` for user-controlled timeout (not `0s`). |
| `screen_timeout` | No | `220s` | Screen timeout duration greater than 0 (e.g. `220s`). Omitted when mode is `SCREEN_TIMEOUT_USER_CHOICE`. |
| `developer_settings` | No | `DEVELOPER_SETTINGS_ALLOWED` | Developer options access. |
| `app_auto_update_policy` | No | `ALWAYS` | Global app auto-update policy. |
| `keyguard_disabled` | No | `true` | Disable lock screen. |
Expand Down
48 changes: 36 additions & 12 deletions custom_components/android_management_api/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@
DEFAULT_SCAN_INTERVAL,
DOMAIN,
)
from .helpers import (
SCREEN_TIMEOUT_MODE_ENFORCED,
SCREEN_TIMEOUT_MODE_USER_CHOICE,
build_screen_timeout_settings,
parse_positive_duration,
)

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -187,8 +193,8 @@ def _schema_with_suggestions(
"BRIGHTNESS_FIXED", "BRIGHTNESS_USER_CHOICE", "BRIGHTNESS_AUTOMATIC",
]),
vol.Optional("screen_brightness", default=180): _number(0, 255),
vol.Optional("screen_timeout_mode", default="SCREEN_TIMEOUT_ENFORCED"): _select([
"SCREEN_TIMEOUT_ENFORCED", "SCREEN_TIMEOUT_USER_CHOICE",
vol.Optional("screen_timeout_mode", default=SCREEN_TIMEOUT_MODE_ENFORCED): _select([
SCREEN_TIMEOUT_MODE_ENFORCED, SCREEN_TIMEOUT_MODE_USER_CHOICE,
]),
vol.Optional("screen_timeout", default="220s"): _text(),
}
Expand Down Expand Up @@ -640,11 +646,10 @@ def build_policy_from_options(opts: dict[str, Any]) -> dict[str, Any]:
brightness["screenBrightnessMode"] = opts["screen_brightness_mode"]
if "screen_brightness" in opts:
brightness["screenBrightness"] = int(opts["screen_brightness"])
timeout: dict[str, Any] = {}
if opts.get("screen_timeout_mode"):
timeout["screenTimeoutMode"] = opts["screen_timeout_mode"]
if opts.get("screen_timeout"):
timeout["screenTimeout"] = opts["screen_timeout"]
timeout = build_screen_timeout_settings(
opts.get("screen_timeout_mode"),
opts.get("screen_timeout"),
)
display: dict[str, Any] = {}
if brightness:
display["screenBrightnessSettings"] = brightness
Expand Down Expand Up @@ -1329,13 +1334,27 @@ async def async_step_kiosk_ui(
async def async_step_display(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
errors: dict[str, str] = {}

if user_input is not None:
self._options.update(user_input)
return await self.async_step_init()
mode = user_input.get(
"screen_timeout_mode", SCREEN_TIMEOUT_MODE_ENFORCED
)
if mode == SCREEN_TIMEOUT_MODE_ENFORCED:
try:
parse_positive_duration(
user_input.get("screen_timeout") or "220s"
)
except ValueError:
errors["screen_timeout"] = "invalid_screen_timeout"
if not errors:
self._options.update(user_input)
return await self.async_step_init()

return self.async_show_form(
step_id="display",
data_schema=_schema_with_suggestions(DISPLAY_SCHEMA, self._options),
errors=errors,
)

# ── Security ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1422,9 +1441,14 @@ async def async_step_apply_policy(
self._options["policy_id"] = policy_id
try:
policy = build_policy_from_options(self._options)
except ValueError:
_LOGGER.exception("Invalid advanced policy JSON")
errors["base"] = "invalid_json_policy"
except ValueError as err:
message = str(err)
if "duration must be greater than 0" in message:
_LOGGER.exception("Invalid screen timeout duration")
errors["base"] = "invalid_screen_timeout"
else:
_LOGGER.exception("Invalid advanced policy JSON")
errors["base"] = "invalid_json_policy"
else:
try:
coordinator = self.config_entry.runtime_data
Expand Down
58 changes: 58 additions & 0 deletions custom_components/android_management_api/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Shared helpers for the Android Management API integration."""

from __future__ import annotations

import re
from typing import Any

# Google protobuf Duration: seconds with optional fraction, ending in "s".
# screenTimeout must be strictly greater than 0.
_POSITIVE_DURATION_RE = re.compile(r"^(\d+(?:\.\d+)?)s$")

SCREEN_TIMEOUT_MODE_ENFORCED = "SCREEN_TIMEOUT_ENFORCED"
SCREEN_TIMEOUT_MODE_USER_CHOICE = "SCREEN_TIMEOUT_USER_CHOICE"
DEFAULT_SCREEN_TIMEOUT = "220s"


def parse_positive_duration(value: str) -> str:
"""Validate a Duration string that must be greater than 0 seconds.

Raises:
ValueError: If the value is not a positive duration ending with 's'.
"""
text = value.strip()
match = _POSITIVE_DURATION_RE.fullmatch(text)
if match is None or float(match.group(1)) <= 0:
raise ValueError(
"duration must be greater than 0 and end with 's' (e.g. 220s, 60s)"
)
return text


def build_screen_timeout_settings(
mode: str | None,
timeout: str | None,
*,
default_timeout: str = DEFAULT_SCREEN_TIMEOUT,
) -> dict[str, Any]:
"""Build screenTimeoutSettings honoring Android Management API rules.

- USER_CHOICE: mode only; screenTimeout must not be set.
- ENFORCED (or timeout provided without USER_CHOICE): include a positive
screenTimeout (defaulting when mode is ENFORCED).
"""
if mode == SCREEN_TIMEOUT_MODE_USER_CHOICE:
return {"screenTimeoutMode": mode}

settings: dict[str, Any] = {}
if mode:
settings["screenTimeoutMode"] = mode

raw = timeout
if not raw and mode == SCREEN_TIMEOUT_MODE_ENFORCED:
raw = default_timeout
if raw:
settings["screenTimeout"] = parse_positive_duration(raw)
# API requires ENFORCED whenever screenTimeout is set.
settings.setdefault("screenTimeoutMode", SCREEN_TIMEOUT_MODE_ENFORCED)
return settings
31 changes: 24 additions & 7 deletions custom_components/android_management_api/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@

from .const import ALLOW_PERSONAL_USAGE_VALUES, DOMAIN
from .coordinator import AndroidManagementCoordinator
from .helpers import (
SCREEN_TIMEOUT_MODE_ENFORCED,
build_screen_timeout_settings,
parse_positive_duration,
)


def _cv_positive_duration(value: str) -> str:
"""Voluptuous wrapper around parse_positive_duration."""
try:
return parse_positive_duration(value)
except ValueError as err:
raise vol.Invalid(str(err)) from err

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -121,8 +134,12 @@
vol.Optional(ATTR_SCREEN_BRIGHTNESS, default=180): vol.All(
vol.Coerce(int), vol.Range(min=0, max=255)
),
vol.Optional(ATTR_SCREEN_TIMEOUT_MODE, default="SCREEN_TIMEOUT_ENFORCED"): cv.string,
vol.Optional(ATTR_SCREEN_TIMEOUT, default="220s"): cv.string,
vol.Optional(
ATTR_SCREEN_TIMEOUT_MODE, default=SCREEN_TIMEOUT_MODE_ENFORCED
): cv.string,
vol.Optional(ATTR_SCREEN_TIMEOUT, default="220s"): vol.All(
cv.string, _cv_positive_duration
),
vol.Optional(ATTR_DEVELOPER_SETTINGS, default="DEVELOPER_SETTINGS_ALLOWED"): cv.string,
vol.Optional(ATTR_APP_AUTO_UPDATE_POLICY, default="ALWAYS"): cv.string,
vol.Optional(ATTR_KEYGUARD_DISABLED, default=True): cv.boolean,
Expand Down Expand Up @@ -391,12 +408,12 @@ async def handle_set_kiosk_policy(call: ServiceCall) -> None:
),
"screenBrightness": call.data.get(ATTR_SCREEN_BRIGHTNESS, 180),
},
"screenTimeoutSettings": {
"screenTimeoutMode": call.data.get(
ATTR_SCREEN_TIMEOUT_MODE, "SCREEN_TIMEOUT_ENFORCED"
"screenTimeoutSettings": build_screen_timeout_settings(
call.data.get(
ATTR_SCREEN_TIMEOUT_MODE, SCREEN_TIMEOUT_MODE_ENFORCED
),
"screenTimeout": call.data.get(ATTR_SCREEN_TIMEOUT, "220s"),
},
call.data.get(ATTR_SCREEN_TIMEOUT),
),
},
"advancedSecurityOverrides": {
"developerSettings": call.data.get(
Expand Down
5 changes: 4 additions & 1 deletion custom_components/android_management_api/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,10 @@ set_kiosk_policy:
- "SCREEN_TIMEOUT_USER_CHOICE"
screen_timeout:
name: Screen Timeout
description: Screen timeout duration (e.g. "220s", "60s", "0s" for never).
description: >-
Screen timeout duration greater than 0 (e.g. "220s", "60s").
Required when timeout mode is SCREEN_TIMEOUT_ENFORCED.
For user-controlled timeout, use SCREEN_TIMEOUT_USER_CHOICE instead of "0s".
required: false
default: "220s"
selector:
Expand Down
4 changes: 3 additions & 1 deletion custom_components/android_management_api/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@
"screen_timeout": "Timeout duration"
},
"data_description": {
"screen_timeout": "Duration string, e.g. 220s, 60s, 0s (never)"
"screen_timeout_mode": "Use SCREEN_TIMEOUT_USER_CHOICE to let the user control timeout (do not use 0s).",
"screen_timeout": "Duration greater than 0 ending with s, e.g. 220s or 60s. Required when timeout mode is ENFORCED."
}
},
"security": {
Expand Down Expand Up @@ -313,6 +314,7 @@
"error": {
"apply_failed": "Failed to apply the policy. Check logs for details.",
"invalid_json_policy": "One of the advanced JSON policy fields is invalid. Fix the JSON and try again.",
"invalid_screen_timeout": "Timeout duration must be greater than 0 and end with s (e.g. 220s). Use Timeout mode USER_CHOICE instead of 0s.",
"enterprise_empty": "Configure at least one enterprise setting (Identity, Notifications, Contact, Terms, or Sign-in) before applying.",
"apply_enterprise_failed": "Failed to apply enterprise settings. Check logs for details."
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@
"screen_timeout": "Timeout duration"
},
"data_description": {
"screen_timeout": "Duration string, e.g. 220s, 60s, 0s (never)"
"screen_timeout_mode": "Use SCREEN_TIMEOUT_USER_CHOICE to let the user control timeout (do not use 0s).",
"screen_timeout": "Duration greater than 0 ending with s, e.g. 220s or 60s. Required when timeout mode is ENFORCED."
}
},
"security": {
Expand Down Expand Up @@ -313,6 +314,7 @@
"error": {
"apply_failed": "Failed to apply the policy. Check logs for details.",
"invalid_json_policy": "One of the advanced JSON policy fields is invalid. Fix the JSON and try again.",
"invalid_screen_timeout": "Timeout duration must be greater than 0 and end with s (e.g. 220s). Use Timeout mode USER_CHOICE instead of 0s.",
"enterprise_empty": "Configure at least one enterprise setting (Identity, Notifications, Contact, Terms, or Sign-in) before applying.",
"apply_enterprise_failed": "Failed to apply enterprise settings. Check logs for details."
}
Expand Down