From 27dc96f32bcb901e7f13d078fbd4df84869161c1 Mon Sep 17 00:00:00 2001 From: Michael Shaffer Date: Sun, 9 Aug 2026 16:57:38 +0000 Subject: [PATCH] fix: validate screen timeout and drop invalid 0s hint (#41) Reject non-positive/malformed screen_timeout values locally, omit screenTimeout for SCREEN_TIMEOUT_USER_CHOICE, and correct docs that incorrectly suggested 0s means never. Co-authored-by: Michael Shaffer --- CHANGELOG.md | 4 ++ README.md | 4 +- .../android_management_api/config_flow.py | 48 +++++++++++---- .../android_management_api/helpers.py | 58 +++++++++++++++++++ .../android_management_api/services.py | 31 +++++++--- .../android_management_api/services.yaml | 5 +- .../android_management_api/strings.json | 4 +- .../translations/en.json | 4 +- 8 files changed, 134 insertions(+), 24 deletions(-) create mode 100644 custom_components/android_management_api/helpers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c8f9cc9..0b88e68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index e578f39..3a52351 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/custom_components/android_management_api/config_flow.py b/custom_components/android_management_api/config_flow.py index ce9033b..1cfb312 100644 --- a/custom_components/android_management_api/config_flow.py +++ b/custom_components/android_management_api/config_flow.py @@ -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__) @@ -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(), } @@ -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 @@ -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 ───────────────────────────────────────────────────────── @@ -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 diff --git a/custom_components/android_management_api/helpers.py b/custom_components/android_management_api/helpers.py new file mode 100644 index 0000000..144f33e --- /dev/null +++ b/custom_components/android_management_api/helpers.py @@ -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 diff --git a/custom_components/android_management_api/services.py b/custom_components/android_management_api/services.py index 0c955c9..c431e02 100644 --- a/custom_components/android_management_api/services.py +++ b/custom_components/android_management_api/services.py @@ -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__) @@ -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, @@ -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( diff --git a/custom_components/android_management_api/services.yaml b/custom_components/android_management_api/services.yaml index 008ff8f..244d15c 100644 --- a/custom_components/android_management_api/services.yaml +++ b/custom_components/android_management_api/services.yaml @@ -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: diff --git a/custom_components/android_management_api/strings.json b/custom_components/android_management_api/strings.json index 9342bdb..9076795 100644 --- a/custom_components/android_management_api/strings.json +++ b/custom_components/android_management_api/strings.json @@ -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": { @@ -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." } diff --git a/custom_components/android_management_api/translations/en.json b/custom_components/android_management_api/translations/en.json index 9342bdb..9076795 100644 --- a/custom_components/android_management_api/translations/en.json +++ b/custom_components/android_management_api/translations/en.json @@ -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": { @@ -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." }