From 259a6c29bdd38f005905d38d0a5501c2e743a081 Mon Sep 17 00:00:00 2001 From: James Mackay Date: Mon, 12 Oct 2026 12:54:41 +0100 Subject: [PATCH] feat(intents): the Intents options screen, the seed, the strings and the tests (t17, part 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the intents feature: part 1 shipped the engine, the buttons and the action; this is the surface a user actually touches, plus the evidence. THE SCREEN (config_flow.py). A new `intents` row on the Configure menu opens the same editor shape as the Apps table, because HA has no table selector: a menu of row actions, one row per step. Add and edit share ONE form, reached through a select of the registered intents whose last option is an explicit "Add a new intent" sentinel — so adding is a choice the user can see, not an empty-string convention. The per-intent form is name, package, component OR action, categories, a data template and the declared variables as text (`key:type[:required][=value]`, blank lines free, `#` comments). Removal asks for confirmation and takes nothing until the user confirms. "Scan an app's intents" asks for ONE package (a dropdown of discovered apps with free text), runs the device-side scan, and offers the candidates; each candidate is marked when it looks like an internal hook, and a candidate whose path is a PATTERN prefills no URL with a note saying why, because a pattern is not a link. A scan that cannot run reports t16's status vocabulary in words a user can act on — ADB off, ADB down, or the app really advertising nothing are three different sentences. Nothing half-stored: a submission that fails writes NOTHING and the form comes back naming each problem (a component without its slash, a package with a space, a duplicate name, a data template using an undeclared placeholder, a fields line that does not parse), and a successful edit REPLACES the row where it already is instead of moving it to the end of the list under the user's eyes. The screen's reader of `registered_intents` is a second implementation of the runtime's one — deliberately, because config_flow must stay importable without Home Assistant's media_player platform. A test drives BOTH over the same battery of rows (valid, junk, duplicate id, mapping-shaped blob, over-long list) and requires identical results, so the two cannot drift into meaning different things. THE SEED. `registered_intents:` in `configuration.yaml` is validated by the SAME validator the form uses, so a seeded intent is identical to a typed one; a row it refuses is dropped with a warning NAMING the row and the reason (a seed is configuration.yaml: there is no form to show an error on, and a silent skip would be indistinguishable from a typo). It is honoured only while the UI holds nothing — at creation, because the Intents screen never writes a seed back and the import step aborts for a known host first. THE STRINGS (both files, byte-identical) carry every new step, field, menu row and the action's own description, plus the exceptions the action raises. The two shared target errors (`unsupported_target`, `ambiguous_target`) now interpolate `{service}`, because `send_intent` reuses them verbatim and "for this action" was `adb_command`-specific copy. THE TESTS (+72 in tests/test_intents_registration.py, and repaired coverage in test_command_routing.py). The command: the exact `am start` line per declared type, a variable containing `"; rm -rf /"` reaching the device as ONE quoted argument (asserted through shlex, not by eyeballing), a value that looks like a placeholder being data rather than a second substitution, `{id.__class__}` left alone because there is no format()/eval() path at all, and each refusal naming the field. The scan: the status sentences, the candidate prefill, the pattern case, an internal-looking candidate still being offered. The seed: warned drops, and the seeded row being byte-identical to the form's. The buttons: one entity per registration, its identity keyed by the intent's id so a rename does not orphan it, defaults sent, and a press that cannot reach the Shield RAISING instead of doing nothing. test_command_routing's registry test used to hand-write the dict and set the OLD attribute name, which is exactly why it never noticed that the add hook registered `None` for a real entry and that the remove hook dropped nothing. It now drives the real hooks, and pins both registries plus the ruling that registered intents do NOT require the adb_command opt-in (a raw shell door and a registered intent are different consents). Verify: 868 passed / 4 skipped (was 794+4 before this ticket's tests — the new tests are additive), ruff clean, both strings files byte-identical. --- README.md | 110 +- .../nvidia_shield/config_flow.py | 951 ++++++++++++ custom_components/nvidia_shield/strings.json | 148 +- .../nvidia_shield/translations/en.json | 148 +- tests/test_command_routing.py | 81 +- tests/test_config_flow_ux.py | 18 +- tests/test_intents_registration.py | 1286 +++++++++++++++++ 7 files changed, 2715 insertions(+), 27 deletions(-) create mode 100644 tests/test_intents_registration.py diff --git a/README.md b/README.md index f23f4ff..0b2b265 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,32 @@ address are ignored with a warning. The seed carries the address, the ADB toggle and the entry's name; everything else (Jellyfin, receiver, power switch, app name/icon maps, channel timing, the `adb_command` service) is set on the entry's **Configure** page after setup, or -during the interactive flow. +during the interactive flow. `registered_intents:` is the one exception — it may +be seeded too, because registering several intents by hand is exactly the kind of +work a `configuration.yaml` should be able to do: + +```yaml +nvidia_shield: + - name: Living Room + host: 10.0.0.31 + registered_intents: + - id: netflix_title # the button's key (optional) + name: "Netflix: open a title" # what the button and the action call it + package: com.netflix.ninja + activity: com.netflix.ninja/.MainActivity # a component… + action: android.intent.action.VIEW # …or an action (+ categories) + categories: [android.intent.category.DEFAULT] + data: "https://www.netflix.com/watch/{id}" # {placeholders} from the fields + fields: + - {key: id, type: string, required: true} +``` + +A seeded intent is **identical to one you typed** — same validation, same button, +same `send_intent` behaviour. The seed is honoured **only while the UI holds +nothing**: it is applied when the entry is created (the Intents screen lives in +the options flow and never writes a seed back), and an existing entry is never +re-seeded. A seeded row that does not validate is **dropped with a warning naming +it**, because there is no form to show an error on in `configuration.yaml`. Duplicate guards: a block matching an existing entry's host (or its virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured host. Removing the YAML block creates or deletes nothing — entries live @@ -215,6 +240,89 @@ This is the same security posture as the Home Assistant core `androidtv` integration: a documented, opt-in escape hatch for debugging and automation, registered at setup and unregistered when the last participating entity unloads. +### Intents (buttons and the `send_intent` action) + +An **intent** is one thing an app can be asked to do: open a screen, follow a +deep link, handle an action. This integration carries them over ADB, in two +halves. + +**Discovery** asks the Shield what ONE app advertises. The **Scan an app's +intents** row on the Intents screen runs it for the app you pick: the resolver +section of that app's `pm dump` is filtered *on the device* (the whole dump is +~2241 lines), only the section crosses the connection, and the answer is cached +per package for an hour. A scan never registers anything — it offers candidates, +and you pick one. A candidate whose path is a *pattern* (`/title.*`) prefills no +URL on purpose: a pattern is not a link you can open. + +**Registration** turns a candidate (or something you type) into a stored intent. +Configure → **Intents** → *Add or edit an intent*: + +| Field | What it is | +| --- | --- | +| Name | what the button and the action's dropdown call it; unique per entry | +| Package id | the app's Android package id, exactly as the Shield reports it | +| Activity (component) | the component to launch, `pkg/.Activity` — use this **or** the action | +| Action | an Android action such as `android.intent.action.VIEW` | +| Categories | optional, comma separated (`android.intent.category.DEFAULT`) | +| Data (URL template) | optional URL/URI; `{variable}` placeholders are filled from the declared variables | +| Variables (declared fields) | one per line: `key:type[:required][=value]`, where type is `string`, `int`, `bool` or `raw` | + +**One button per registered intent.** Adding an intent adds `button.` +pressing it sends the intent with the declared defaults. A declared field that is +`required` with no default makes the press an error naming the field — such an +intent is meant to be called through the action, which is where variables come +from. Removing the intent removes its button (an options change reloads the +entry). The button's attributes show exactly what it will send. + +**The action** takes the intent's name and its variables: + +```yaml +- action: nvidia_shield.send_intent + target: + entity_id: media_player.shield + data: + intent: "Netflix: open a title" + variables: + id: "80100172" + response_variable: fired # optional: keep the output in a variable +``` + +- `intent` (**required**) — the registered name, as the Intents screen shows it. + An unknown one raises `unknown_intent` and **lists the known names**. +- `variables` — one value per declared field. A declared field with a default may + be omitted; a `required` one raises `missing_intent_field`, **naming the field**. + A variable the intent never declared is **refused** (`unknown_intent_variable`): + nothing silently travels to the device, and nothing is silently dropped. A value + that cannot become its declared type names the field and the value + (`invalid_intent_field`). +- The **target is required** and resolves exactly like `adb_command`: one Shield + (`no_target` / `unsupported_target` / `ambiguous_target`), never a guess. +- **The device's own output comes back** in the response under `intent_response` + (`am start` prints `Starting: Intent {…}` when it accepted the intent), so a + caller that asks for a response can see what happened. +- **Failures are loud, and the device's words are kept.** A Shield that refuses + the intent raises `intent_failed` carrying *its* message ("unable to resolve + Intent", "does not exist", "Permission Denial") — never a silent success. ADB + being off, unreachable, or answering nothing raises `intents_need_adb` / + `intent_no_answer`: the intents feature **requires ADB**, because the remote + protocol cannot carry an intent, and the error says so instead of failing + obscurely. + +**An intent the app rejects is a configuration problem.** The app may no longer +handle that action, the component may have been renamed by an update, or the +intent may need a permission it does not have. The error shows the Shield's own +message and the fix is the registration — the integration never retries, guesses +or falls back to another intent. + +**How the command is built (and why it is safe).** `am start -n +-a -c -d --es/--ei/--ez key value`, assembled from the +stored fields. `{placeholder}` substitution is plain text replacement — **no +`eval`, no `format()`, no shell interpolation** — and every value is quoted, so a +variable whose value is `"; rm -rf /"` reaches the Shield as literal data inside +one argument. This is deliberately **not** a second raw-shell door: +`nvidia_shield.adb_command` remains that one, and this action can only ever send +an intent you registered. + ### Screen capture (opt-in, manual) The integration can expose an `image` entity showing the Shield's current screen — diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index 3fa4316..bf10e3e 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -2,6 +2,7 @@ import contextlib import logging +import re import voluptuous as vol from homeassistant import config_entries @@ -31,6 +32,7 @@ from .const import ( CONF_PROTOCOL_PAIRED, CONF_RECEIVER_ENTITY, CONF_RECEIVER_INPUT, + CONF_REGISTERED_INTENTS, CONF_REMOTE_COMMAND_SCRIPT, CONF_SCREEN_CAPTURE_ENABLED, CONF_SKIP_PAIRING, @@ -38,10 +40,33 @@ from .const import ( DEFAULT_NAME, DOMAIN, ENTRY_VERSION, + INTENT_FIELD_ACTION, + INTENT_FIELD_ACTIVITY, + INTENT_FIELD_CANDIDATE, + INTENT_FIELD_CATEGORIES, + INTENT_FIELD_CHOICE, + INTENT_FIELD_DATA, + INTENT_FIELD_DECLARED, + INTENT_FIELD_LIMIT, + INTENT_FIELD_NAME, + INTENT_FIELD_PACKAGE, + INTENT_FIELD_SCAN_PACKAGE, + INTENT_FIELD_TYPES, + INTENT_NEW_SENTINEL, + INTENT_REGISTRATION_LIMIT, SCRIPT_COMMANDS, SCRIPT_FIELD_PREFIX, command_scripts, ) +from .ha_adb_common import ( + INTENT_SCAN_ADB_DISABLED, + INTENT_SCAN_ADB_UNAVAILABLE, + INTENT_SCAN_CACHED, + INTENT_SCAN_FRESH_EMPTY, + INTENT_SCAN_INVALID_PACKAGE, + INTENT_SCAN_UNREADABLE, + is_launchable, +) from .helpers import entry_host, protocol_cert_paths, seed_adb_enabled, seed_host _LOGGER = logging.getLogger(__name__) @@ -53,6 +78,11 @@ APP_FIELD_PACKAGE = "app_package" APP_FIELD_NAME = "app_name" APP_FIELD_ICON = "app_icon" +#: t17: a declared field's key must be usable as an ``am start`` extra name and as +#: a ``{placeholder}``, so letters/digits/underscores only, never leading with a +#: digit (which is also what stops a key from ever looking like a flag). +_INTENT_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + def _valid_app_row(package: str, name: str, icon: str = "") -> bool: """The ONE place a row's rules live: dotted package, no spaces, non-empty name. @@ -689,6 +719,15 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # it (True) or the skip path cleared it (False), and the options flow # reads it before it lets the transport run unpaired. CONF_PROTOCOL_PAIRED: bool(d.get(CONF_PROTOCOL_PAIRED, False)), + # t17: the YAML seed's registrations, if the flow carried them. + # The UI wins (a value in `d` comes from the flow's own forms), + # and an entry that already exists is never re-seeded — the + # import step aborts for a known host/unique_id before this runs. + CONF_REGISTERED_INTENTS: list( + d.get(CONF_REGISTERED_INTENTS) + or getattr(self, "_seed_registered_intents", None) + or [] + ), }, ) @@ -904,6 +943,14 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # zeroconf discovery, but WITHOUT the discovery announcement — the # address came from configuration.yaml, not from the network. self._seed_host = block_host + # t17: a `registered_intents:` block seeds the entry's registrations. It + # is honoured ONLY while the UI holds nothing — which is exactly here, at + # creation: the Intents screen lives in the OPTIONS flow and never writes + # a seed back, and an existing entry is never re-seeded (the duplicate + # guard above aborts first). + self._seed_registered_intents = _seeded_intent_rows( + block.get(CONF_REGISTERED_INTENTS) + ) return await self.async_step_user(None) async def async_step_reauth(self, entry_data): @@ -1322,6 +1369,16 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload): # why). Flow state like the note above: it shapes the next render and # never becomes an option. self._app_map_problems: str = "" + # t17: the Intents editor's own UI state — which intent the form is + # editing (None = a new one), the values a scan candidate suggested, why + # it suggested no data, which intent a removal is confirming, and what + # the last scan answered. All of it is flow state: none of it is stored. + self._intent_editing_id: str | None = None + self._intent_prefill: dict | None = None + self._intent_prefill_note: str = "" + self._removing_intent_id: str | None = None + self._intent_scan_package: str = "" + self._intent_scan_result = None # t15: the throwaway pairing client of a protocol_pair visit (see # async_step_protocol_pair). Flow state, never an option. self._protocol_client = None @@ -1355,6 +1412,7 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload): "connection", "receiver", "apps", + "intents", "discover", "scripts", "finish", @@ -2074,6 +2132,387 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload): self._removing_package = None return await self.async_step_apps() + # ── the Intents screen (t17) ── + # + # The same idiom as the Apps editor, for the same reason (HA has no table + # selector): a menu of row actions, and ONE row per step. What differs is the + # shape of a row — an intent is a list entry, not a map entry — so add and + # edit share one form, reached through a selector whose explicit "Add new" + # sentinel covers the add case. + + def _registered_intents(self) -> list[dict]: + """The registered intents as the OPTIONS hold them (t17). + + Reads the pending options dict through the module's own tolerant reader, + so the screen and the runtime agree about a row even after a hand edit — + and rendering never touches ADB. + """ + return registered_intent_rows(self._options_dict) + + def _intent_choices(self) -> list[dict]: + """The selector's options: every registered intent, then Add new (t17).""" + choices = [ + {"value": intent["id"], "label": intent_label(intent)} + for intent in self._registered_intents() + ] + choices.append( + {"value": INTENT_NEW_SENTINEL, "label": "+ Add a new intent"} + ) + return choices + + def _intent_selector(self): + """The "which intent" dropdown, with the Add-new sentinel (t17).""" + return selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + selector.SelectOptionDict(value=row["value"], label=row["label"]) + for row in self._intent_choices() + ], + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ) + + def _intents_menu_options(self) -> list[str]: + """The menu rows, minus the ones with nothing to act on (t17).""" + rows = ["intents_edit"] + if self._registered_intents(): + rows.append("intents_remove") + rows += ["intents_scan", "menu_again"] + return rows + + def _intents_placeholders(self) -> dict: + """What the Intents menu says about the intents it offers (t17).""" + intents = self._registered_intents() + if not intents: + return { + "intents": ( + "no intents registered yet — add one, or scan an app's intents " + "to pick one the Shield already advertises" + ) + } + labels = ", ".join(intent_label(intent) for intent in intents[:6]) + if len(intents) > 6: + labels += f", … (+{len(intents) - 6} more)" + count = "1 intent" if len(intents) == 1 else f"{len(intents)} intents" + return {"intents": f"{count} registered — {labels}"} + + async def async_step_intents(self, user_input=None): + """The Intents editor: a MENU of row actions (t17).""" + return self.async_show_menu( + step_id="intents", + menu_options=self._intents_menu_options(), + description_placeholders=self._intents_placeholders(), + ) + + async def async_step_intents_edit(self, user_input=None): + """ADD or EDIT: one selector, with an explicit Add-new sentinel (t17).""" + intents = self._registered_intents() + if user_input is not None: + choice = str(user_input.get(INTENT_FIELD_CHOICE) or "") + known = {intent["id"] for intent in intents} + if choice == INTENT_NEW_SENTINEL: + self._intent_editing_id = None + self._intent_prefill = {} + return await self.async_step_intents_form() + if choice in known: + self._intent_editing_id = choice + self._intent_prefill = {} + return await self.async_step_intents_form() + if not intents: + return await self.async_step_intents() # nothing left to choose + return self._show_sub_form( + "intents_edit", + {vol.Required(INTENT_FIELD_CHOICE): self._intent_selector()}, + {"base": "unknown_intent_row"}, + placeholders=self._intents_placeholders(), + ) + return self._show_sub_form( + "intents_edit", + {vol.Required(INTENT_FIELD_CHOICE): self._intent_selector()}, + None, + placeholders=self._intents_placeholders(), + ) + + async def async_step_intents_form(self, user_input=None): + """The per-intent form: name, package, component OR action, data, fields (t17). + + One form for add and edit, because the fields are the same; the row's ID + is the only thing that is not editable (it is the button's key, and a + rename must not orphan the entity — the display NAME is what the user + renames, and the id follows only when the intent is new). + """ + intents = self._registered_intents() + editing = find_intent(intents, getattr(self, "_intent_editing_id", None)) + prefill = dict(getattr(self, "_intent_prefill", None) or {}) + errors: dict = {} + problems: list[str] = [] + values = {**intent_form_values(editing), **prefill} + if user_input is not None: + values = { + INTENT_FIELD_NAME: str(user_input.get(INTENT_FIELD_NAME) or ""), + INTENT_FIELD_PACKAGE: str(user_input.get(INTENT_FIELD_PACKAGE) or ""), + INTENT_FIELD_ACTIVITY: str(user_input.get(INTENT_FIELD_ACTIVITY) or ""), + INTENT_FIELD_ACTION: str(user_input.get(INTENT_FIELD_ACTION) or ""), + INTENT_FIELD_CATEGORIES: str( + user_input.get(INTENT_FIELD_CATEGORIES) or "" + ), + INTENT_FIELD_DATA: str(user_input.get(INTENT_FIELD_DATA) or ""), + INTENT_FIELD_DECLARED: str(user_input.get(INTENT_FIELD_DECLARED) or ""), + } + row, problems = intent_row_from_form( + values, + existing_id=editing["id"] if editing else None, + taken_ids={intent["id"] for intent in intents}, + taken_names={ + intent["name"].casefold() + for intent in intents + if editing is None or intent["id"] != editing["id"] + }, + ) + if row is None: + errors["base"] = "invalid_intent_row" + else: + self._write_intent_row(row) + self._intent_editing_id = None + self._intent_prefill = {} + self._intent_prefill_note = "" + return await self.async_step_intents() + return self._show_intent_form(values, errors, problems, editing=editing) + + def _show_intent_form(self, values, errors, problems, *, editing=None): + """Render the per-intent form (t17). + + The note carries three things the fields cannot: whether this is a new + intent or an edit, WHY the last submission was refused, and — when the + values came from a discovered candidate — what the scan implied and did + not (a path pattern is not a URL, and an internal hook may do nothing). + """ + note = "new intent" if editing is None else f"editing {intent_label(editing)}" + if getattr(self, "_intent_prefill_note", ""): + note = " · ".join([note, self._intent_prefill_note]) + if problems: + note = " · ".join([note, *problems]) + return self._show_sub_form( + "intents_form", + { + vol.Required(INTENT_FIELD_NAME): selector.TextSelector(), + vol.Required(INTENT_FIELD_PACKAGE): selector.TextSelector(), + vol.Optional(INTENT_FIELD_ACTIVITY): selector.TextSelector(), + vol.Optional(INTENT_FIELD_ACTION): selector.TextSelector(), + vol.Optional(INTENT_FIELD_CATEGORIES): selector.TextSelector(), + vol.Optional(INTENT_FIELD_DATA): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ), + vol.Optional(INTENT_FIELD_DECLARED): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ), + }, + errors, + override=dict(values), + placeholders={"intent_note": note}, + ) + + def _write_intent_row(self, row: dict) -> None: + """Set ONE intent IN PLACE; every other registration is untouched (t17). + + An edit REPLACES the row where it already is rather than moving it to the + end: the selector and the menu list intents in registration order, and + renaming one of them should not reshuffle the list under the user. + """ + options = self._options_dict + rows = [dict(raw) for raw in (options.get(CONF_REGISTERED_INTENTS) or [])] + for index, existing in enumerate(rows): + if str(existing.get("id") or "") == row["id"]: + rows[index] = row + break + else: + rows.append(row) + options[CONF_REGISTERED_INTENTS] = rows[:INTENT_REGISTRATION_LIMIT] + + def _drop_intent_row(self, intent_id: str) -> None: + """Remove ONE intent, leaving the others exactly as they were (t17).""" + options = self._options_dict + rows = [dict(raw) for raw in (options.get(CONF_REGISTERED_INTENTS) or [])] + options[CONF_REGISTERED_INTENTS] = [ + existing + for existing in rows + if str(existing.get("id") or "") != intent_id + ] + + async def async_step_intents_remove(self, user_input=None): + """REMOVE: choose which intent, then confirm it explicitly (t17).""" + intents = self._registered_intents() + if user_input is not None: + choice = str(user_input.get(INTENT_FIELD_CHOICE) or "") + if find_intent(intents, choice) is not None: + self._removing_intent_id = choice + return await self.async_step_intents_remove_confirm() + if not intents: + return await self.async_step_intents() + return self._show_sub_form( + "intents_remove", + {vol.Required(INTENT_FIELD_CHOICE): self._intent_selector()}, + {"base": "unknown_intent_row"}, + placeholders=self._intents_placeholders(), + ) + return self._show_sub_form( + "intents_remove", + {vol.Required(INTENT_FIELD_CHOICE): self._intent_selector()}, + None, + placeholders=self._intents_placeholders(), + ) + + async def async_step_intents_remove_confirm(self, user_input=None): + """The confirmation MENU: dropping a registration takes a second act (t17).""" + editing = find_intent( + self._registered_intents(), getattr(self, "_removing_intent_id", None) + ) + if editing is None: + return await self.async_step_intents() + return self.async_show_menu( + step_id="intents_remove_confirm", + menu_options=["intents_remove_yes", "intents_remove_no"], + description_placeholders={ + "intent": intent_label(editing), + "button": editing["name"], + }, + ) + + async def async_step_intents_remove_yes(self, user_input=None): + """Confirmed: drop the registration, and its button with it (t17).""" + if getattr(self, "_removing_intent_id", None): + self._drop_intent_row(self._removing_intent_id) + self._removing_intent_id = None + return await self.async_step_intents() + + async def async_step_intents_remove_no(self, user_input=None): + """Cancelled: nothing changes, and the editor comes back (t17).""" + self._removing_intent_id = None + return await self.async_step_intents() + + async def async_step_intents_scan(self, user_input=None): + """SCAN: ask which package, then run discovery for THAT ONE app (t17). + + One package per request, on purpose (t16): a sweep of every installed app + is ~2241 dump lines each. The scan is the ONLY thing on this screen that + issues a shell, and it is explicit — loading the screen never does. + """ + errors: dict = {} + if user_input is not None: + package = str(user_input.get(INTENT_FIELD_SCAN_PACKAGE) or "").strip() + if not is_launchable(package): + errors["base"] = "invalid_intent_package" + else: + self._intent_scan_package = package + self._intent_scan_result = await self._run_intent_scan(package) + return await self.async_step_intents_scan_result() + return self._show_intents_scan_form(errors) + + def _show_intents_scan_form(self, errors=None): + """The "which app" step: a dropdown of discovered apps, or free text (t17).""" + apps = self._editable_apps() + if apps: + package_field = selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + selector.SelectOptionDict( + value=package, label=self._app_label(package, row) + ) + for package, row in apps.items() + ], + mode=selector.SelectSelectorMode.DROPDOWN, + custom_value=True, + ) + ) + else: + package_field = selector.TextSelector() + return self._show_sub_form( + "intents_scan", + {vol.Required(INTENT_FIELD_SCAN_PACKAGE): package_field}, + errors, + placeholders={"apps": self._apps_placeholders()["apps"]}, + ) + + async def _run_intent_scan(self, package: str): + """Run the discovery scan for ONE package, never raising (t17). + + Nothing here raises: a scan that could not run is reported as a RESULT + (the t16 status vocabulary), which is what lets this screen say why there + is nothing to offer instead of showing an empty list as if the app + advertised nothing. + """ + manager = self._entry_manager() + scan = getattr(manager, "async_scan_intents", None) + if scan is None: + return None + try: + return await scan(package) + except Exception as err: # noqa: BLE001 - a failed scan is a result here + _LOGGER.debug("intent scan from the options page failed: %s", err) + return None + + async def async_step_intents_scan_result(self, user_input=None): + """Offer the scan's candidates for registration (t17).""" + result = getattr(self, "_intent_scan_result", None) + candidates = list(getattr(result, "candidates", None) or ()) + if user_input is not None: + choice = str(user_input.get(INTENT_FIELD_CANDIDATE) or "") + chosen = next( + (row for row in candidates if candidate_value(row) == choice), None + ) + if chosen is not None: + self._intent_editing_id = None + prefill, note = intent_prefill_from_candidate( + str(getattr(result, "package", "") or self._intent_scan_package), + chosen, + ) + self._intent_prefill = prefill + self._intent_prefill_note = note + return await self.async_step_intents_form() + return await self.async_step_intents() # the list moved: no dead end + return self._show_sub_form( + "intents_scan_result", + ( + { + vol.Required(INTENT_FIELD_CANDIDATE): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + selector.SelectOptionDict( + value=candidate_value(row), + label=candidate_label(row), + ) + for row in candidates + ], + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ) + } + if candidates + else {} + ), + None, + placeholders={ + "scan_note": intent_scan_note( + result, str(getattr(self, "_intent_scan_package", "") or "") + ) + }, + ) + + def _entry_manager(self): + """This entry's connection manager, from wherever it is reachable (t17). + + ``runtime_data`` is where the integration puts it; the domain store is the + fallback the Discovery row already uses. Rendering a form must never + REQUIRE a manager (ADB off is a normal state), so a missing one is simply + None and the caller reports what that means. + """ + entry = self.config_entry + manager = getattr(entry, "runtime_data", None) if entry is not None else None + if manager is None: + store = (getattr(self.hass, "data", None) or {}).get(DOMAIN) or {} + manager = store.get(getattr(entry, "entry_id", None)) + return manager + def _discovered_apps(self) -> list[dict]: """The apps the device reported, from the last background sweep (t57). @@ -2205,6 +2644,7 @@ _ALL_OPTION_FIELDS = frozenset( CONF_RECEIVER_INPUT, CONF_APP_NAME_MAP, CONF_APP_ICON_MAP, + CONF_REGISTERED_INTENTS, CONF_REMOTE_COMMAND_SCRIPT, CONF_CHANNEL_DIGIT_DELAY, CONF_CHANNEL_SEND_ENTER, @@ -2212,3 +2652,514 @@ _ALL_OPTION_FIELDS = frozenset( *{f"{SCRIPT_FIELD_PREFIX}{command}" for command in SCRIPT_COMMANDS}, } ) + + +# ── t17: registered intents — the SCREEN's side of the model ── +# +# The runtime reads a registered intent in media_player.py (normalise_intent). +# These helpers are the same decision, taken while the user types: what is +# accepted, what is refused with a reason, and how a stored row is projected back +# into a form. The two live in different modules because the options flow must +# stay importable without Home Assistant's media_player platform; a test drives +# BOTH over the same battery of rows and asserts they agree, so "a paste and a +# typed row" cannot mean two different things. + + +def intent_slug(text: str) -> str: + """A registered intent's id from its display name (t17, runtime-identical).""" + slug = re.sub(r"[^a-z0-9]+", "_", str(text or "").lower()).strip("_") + return slug or "intent" + + +def registered_intent_rows(options) -> list[dict]: + """The registered intents of an options dict, tolerantly (t17). + + Mirrors ``media_player.registered_intents`` for the screen: junk rows are + dropped rather than shown as editable rows the runtime would ignore, a + duplicate id keeps the FIRST row, and the registration cap is applied. + """ + raw_rows = (options or {}).get(CONF_REGISTERED_INTENTS) or [] + if isinstance(raw_rows, dict): + raw_rows = list(raw_rows.values()) + intents: list[dict] = [] + seen: set[str] = set() + for raw in raw_rows: + if not isinstance(raw, dict): + continue + name = str(raw.get("name") or "").strip() + package = str(raw.get("package") or "").strip() + activity = str(raw.get("activity") or "").strip() + action = str(raw.get("action") or "").strip() + if "/" not in activity: + activity = "" + if not name or not package or not (activity or action): + continue + intent_id = str(raw.get("id") or "").strip() or intent_slug(name) + if intent_id in seen: + continue + seen.add(intent_id) + intents.append( + { + "id": intent_id, + "name": name, + "package": package, + "activity": activity, + "action": action, + "categories": [ + str(category).strip() + for category in (raw.get("categories") or []) + if str(category).strip() + ], + "data": str(raw.get("data") or ""), + "fields": [ + field + for field in ( + _intent_field_row(raw_field) + for raw_field in (raw.get("fields") or []) + ) + if field + ][:INTENT_FIELD_LIMIT], + } + ) + if len(intents) >= INTENT_REGISTRATION_LIMIT: + break + return intents + + +def _intent_field_row(raw) -> dict | None: + """One stored declared field, normalised; None when unusable (t17).""" + if not isinstance(raw, dict): + return None + key = str(raw.get("key") or "").strip() + type_ = str(raw.get("type") or "string").strip().lower() + if not _INTENT_KEY_RE.match(key) or type_ not in INTENT_FIELD_TYPES: + return None + field = {"key": key, "type": type_, "required": bool(raw.get("required"))} + default = raw.get("default") + if default is not None and str(default) != "": + field["default"] = str(default) + return field + + +def intent_label(intent: dict) -> str: + """How one intent reads in a dropdown or in the menu's note (t17).""" + return f"{intent['name']} ({intent['package']})" + + +def find_intent(intents, intent_id) -> dict | None: + """The intent with this id (or display name), or None (t17).""" + wanted = str(intent_id or "").strip() + for intent in intents or (): + if intent["id"] == wanted: + return intent + for intent in intents or (): + if intent["name"] == wanted: + return intent + return None + + +def intent_form_values(intent) -> dict: + """A stored intent as the FORM's values (t17); a blank form when None.""" + if not intent: + return { + INTENT_FIELD_NAME: "", + INTENT_FIELD_PACKAGE: "", + INTENT_FIELD_ACTIVITY: "", + INTENT_FIELD_ACTION: "", + INTENT_FIELD_CATEGORIES: "", + INTENT_FIELD_DATA: "", + INTENT_FIELD_DECLARED: "", + } + return { + INTENT_FIELD_NAME: intent.get("name") or "", + INTENT_FIELD_PACKAGE: intent.get("package") or "", + INTENT_FIELD_ACTIVITY: intent.get("activity") or "", + INTENT_FIELD_ACTION: intent.get("action") or "", + INTENT_FIELD_CATEGORIES: ", ".join(intent.get("categories") or []), + INTENT_FIELD_DATA: intent.get("data") or "", + INTENT_FIELD_DECLARED: intent_fields_to_text(intent.get("fields") or []), + } + + +def intent_fields_to_text(fields) -> str: + """The declared fields as the editable text, one row each (t17).""" + lines = [] + for field in fields or []: + line = f"{field['key']}:{field['type']}" + if field.get("required"): + line += ":required" + if "default" in field: + line += f"={field['default']}" + lines.append(line) + return "\n".join(lines) + + +def _intent_categories(text) -> list[str]: + """Split a categories field on commas/whitespace, order preserved (t17).""" + return [ + part + for part in re.split(r"[,\s]+", str(text or "").strip()) + if part + ] + + +def intent_fields_from_text(text) -> tuple[list[dict] | None, list[str]]: + """Parse the declared-fields text: ``key:type[:required][=default]`` (t17). + + Returns ``(fields, problems)``; ``fields`` is None when anything is wrong, + and every problem names its LINE, the same rule as the apps table (t17/O3). + Blank lines are not rows; a line starting with ``#`` is a comment. + """ + fields: list[dict] = [] + problems: list[str] = [] + seen: set[str] = set() + for number, raw in enumerate(str(text or "").split("\n"), start=1): + line = raw.strip() + if not line or line.startswith("#"): + continue + if ":" not in line: + problems.append(f"fields line {number}: no ':' — a row is key:type") + continue + head, default = (line.split("=", 1) if "=" in line else (line, None)) + head = head.strip() + parts = [part.strip() for part in head.split(":")] + key = parts[0] + type_ = parts[1].lower() if len(parts) > 1 else "" + extra = parts[2:] if len(parts) > 2 else [] + if not _INTENT_KEY_RE.match(key): + problems.append( + f"fields line {number}: '{key}' is not a usable field name " + "(letters, digits and underscores, not starting with a digit)" + ) + continue + if type_ not in INTENT_FIELD_TYPES: + problems.append( + f"fields line {number}: type '{type_}' is not one of " + f"{', '.join(INTENT_FIELD_TYPES)}" + ) + continue + if [flag for flag in extra if flag != "required"]: + problems.append( + f"fields line {number}: unexpected '{extra[0]}' — the only suffix " + "is :required" + ) + continue + required = "required" in extra + if required and default is not None: + problems.append( + f"fields line {number}: '{key}' is required AND has a default — " + "drop one of the two" + ) + continue + if key in seen: + problems.append(f"fields line {number}: '{key}' is declared twice") + continue + seen.add(key) + field = {"key": key, "type": type_, "required": required} + if default is not None and default.strip() != "": + field["default"] = default.strip() + fields.append(field) + if len(fields) > INTENT_FIELD_LIMIT: + problems.append( + f"fields: more than {INTENT_FIELD_LIMIT} declared fields — that is " + "the cap (one command, not a script)" + ) + break + if problems: + return None, problems + return fields, [] + + +def intent_row_from_form( + values: dict, + *, + existing_id: str | None, + taken_ids: set[str], + taken_names: set[str], +) -> tuple[dict | None, list[str]]: + """Validate ONE submitted intent; ``(row, problems)`` (t17). + + The row is None when anything is wrong — nothing half-stored, the standing + rule — and every problem is a sentence the form shows, naming the field. + """ + name = str(values.get(INTENT_FIELD_NAME) or "").strip() + package = str(values.get(INTENT_FIELD_PACKAGE) or "").strip() + activity = str(values.get(INTENT_FIELD_ACTIVITY) or "").strip() + action = str(values.get(INTENT_FIELD_ACTION) or "").strip() + data = str(values.get(INTENT_FIELD_DATA) or "").strip() + categories = _intent_categories(values.get(INTENT_FIELD_CATEGORIES)) + problems: list[str] = [] + if not name: + problems.append("name is required") + elif name.casefold() in taken_names: + problems.append(f"another intent is already called '{name}' — names are unique") + if not package: + problems.append("the package id is required") + elif not is_launchable(package): + problems.append( + f"'{package}' is not a usable package id (dotted, no spaces) — use the " + "exact id the Shield reports" + ) + if activity and "/" not in activity: + problems.append( + f"'{activity}' is not a component — a component looks like " + f"{package or 'com.example.app'}/.MainActivity (the slash is required)" + ) + if not (activity or action): + problems.append( + "give a component (activity) or an action — an intent must name one" + ) + fields, field_problems = intent_fields_from_text(values.get(INTENT_FIELD_DECLARED)) + problems.extend(field_problems) + if data: + declared = {field["key"] for field in fields or []} + used = set(re.findall(r"\{([A-Za-z_][A-Za-z0-9_]*)\}", data)) + undeclared = sorted(used - declared) + if undeclared: + problems.append( + f"the data uses {{{', {'.join(undeclared)}}} but no declared field " + "provides it — declare it, or remove it from the template" + ) + if problems: + return None, problems + intent_id = existing_id or intent_slug(name) + if not existing_id: + candidate_id = intent_id + suffix = 2 + while candidate_id in taken_ids: + candidate_id = f"{intent_id}_{suffix}" + suffix += 1 + intent_id = candidate_id + return ( + { + "id": intent_id, + "name": name, + "package": package, + "activity": activity, + "action": action, + "categories": categories[:INTENT_FIELD_LIMIT], + "data": data, + "fields": fields or [], + }, + [], + ) + + +def candidate_value(candidate) -> str: + """A stable selector value for ONE discovered candidate (t17). + + Built from the candidate's own dedup key, so two rows can never share a + value and the choice made in the form maps back to exactly one candidate. + """ + return "|".join("" if part is None else str(part) for part in candidate.key) + + +def _candidate_data(candidate) -> tuple[str, str]: + """The data URI a candidate implies, and the note when it implies none (t17). + + A GLOB path is NOT a path: ``/title.*`` is a pattern the app registered, so + turning it into ``https://www.netflix.com/title.*`` would put a URL the app + cannot match onto the form. Such a candidate prefills no data and says why — + the user fills the real link in, which is the honest division of labour. + """ + scheme = (candidate.scheme or "").strip() + if not scheme: + return "", "" + authority = (candidate.authority or "").strip() + path = (candidate.path or "").strip() + if path and (candidate.path_kind or "").upper() == "GLOB": + return "", ( + f"the {scheme} entry matches a path PATTERN ({path}) — enter the real " + "link yourself; a pattern is not a URL" + ) + if not authority and not path: + return "", "" + return f"{scheme}://{authority}{path}", "" + + +def candidate_label(candidate) -> str: + """How ONE candidate reads in the dropdown (t17).""" + action = candidate.action or "any action" + target = (candidate.component or "").strip() + schemes = f"{candidate.scheme}://" if candidate.scheme else "" + location = "" + if candidate.authority or candidate.path: + location = f" {schemes}{candidate.authority or ''}{candidate.path or ''}" + label = f"{action}{location} — {target}" + if candidate.path and (candidate.path_kind or "").upper() == "GLOB": + label += " (path pattern)" + if candidate.likely_internal: + label += " [internal-looking]" + return label + + +def intent_prefill_from_candidate(package: str, candidate) -> tuple[dict, str]: + """The form values a discovered candidate suggests, plus a note (t17).""" + data, note = _candidate_data(candidate) + values = { + INTENT_FIELD_NAME: "", + INTENT_FIELD_PACKAGE: package, + INTENT_FIELD_ACTIVITY: (candidate.component or "").strip(), + INTENT_FIELD_ACTION: (candidate.action or "").strip(), + INTENT_FIELD_CATEGORIES: ", ".join(candidate.categories or ()), + INTENT_FIELD_DATA: data, + INTENT_FIELD_DECLARED: "", + } + if candidate.likely_internal: + note = " · ".join( + [ + note, + "this candidate looks like an internal hook — registering it is " + "allowed, but it may not do anything useful", + ] + ).strip(" ·") + return values, note + + +def intent_scan_note(result, package: str) -> str: + """What ONE scan attempt found, in the screen's own words (t17). + + The status vocabulary is t16's, and every branch says which of the three + "nothing" outcomes happened: ADB off, ADB down, or the app really advertising + nothing. They have different fixes, so they must not read alike. + """ + what = package or "that package" + if result is None: + return ( + f"{what} could not be scanned: this entry has no intent discovery " + "available (ADB is not set up for it). Nothing was registered." + ) + status = getattr(result, "status", None) + if status == INTENT_SCAN_INVALID_PACKAGE: + return f"'{package}' is not a usable package id — nothing was scanned." + if status == INTENT_SCAN_ADB_DISABLED: + return ( + f"ADB is switched OFF for this entry, so {what} could not be scanned " + "(intent discovery reads the device over ADB; the remote protocol " + "cannot do it). Nothing was registered." + ) + if status in (INTENT_SCAN_ADB_UNAVAILABLE, INTENT_SCAN_UNREADABLE): + return ( + f"the Shield did not answer with {what}'s resolver table, so nothing " + "was found. Check the ADB connection and try again — nothing was " + "registered, and nothing was cached as an answer." + ) + candidates = len(getattr(result, "candidates", ()) or ()) + notes = [] + if status == INTENT_SCAN_CACHED: + age = getattr(result, "age_seconds", None) + notes.append( + "from the cache" if age is None else f"from the cache ({int(age)}s old)" + ) + if candidates: + head = f"{candidates} candidate intent(s) for {what}" + internal = [ + candidate + for candidate in (getattr(result, "candidates", ()) or ()) + if candidate.likely_internal + ] + if internal: + head += ( + f" — {len(internal)} of them look like internal hooks and are " + "listed last" + ) + notes.insert(0, head) + elif status == INTENT_SCAN_FRESH_EMPTY: + notes.insert( + 0, + f"{what} advertises no intents this entry can read — that is the " + "device's answer, not a failure", + ) + else: + notes.insert(0, f"no candidates were found for {what}") + if getattr(result, "truncated", False): + notes.append( + "the list was capped — register one of these, or run the scan again " + "after the table changes" + ) + if not candidates: + notes.append("pick another app, or enter the intent by hand") + return " ".join(notes) + " Nothing has been registered yet." + + +def _seeded_intent_rows(rows) -> list[dict]: + """Validate the YAML ``registered_intents:`` seed rows (t17). + + The seed is the ONE place intents arrive as structured data instead of form + text, so this converts each row to the form's own shape and runs it through + the same validator the screen uses — a seeded intent is then IDENTICAL to a + typed one (same keys, same normalisation, same button and action behaviour). + + A row the validator refuses is DROPPED with a warning naming it: a seed is + configuration.yaml, where a silent skip would be indistinguishable from a + typo, and there is no form to show an error on. + """ + if not isinstance(rows, list): + if rows: + _LOGGER.warning( + "nvidia_shield: registered_intents must be a LIST of intents — " + "the block was ignored" + ) + return [] + accepted: list[dict] = [] + seen_ids: set[str] = set() + for index, raw in enumerate(rows, start=1): + if not isinstance(raw, dict): + _LOGGER.warning( + "nvidia_shield: registered_intents row %d is not a mapping — skipped", + index, + ) + continue + existing_id = str(raw.get("id") or "").strip() + if existing_id and not _INTENT_KEY_RE.match(existing_id): + _LOGGER.warning( + "nvidia_shield: registered_intents row %d has an unusable id %r — " + "using the name's slug instead", + index, + existing_id, + ) + existing_id = "" + if existing_id and existing_id in seen_ids: + _LOGGER.warning( + "nvidia_shield: registered_intents row %d repeats the id %r — skipped", + index, + existing_id, + ) + continue + raw_fields = raw.get("fields") + if isinstance(raw_fields, list): + declared = intent_fields_to_text( + [field for field in raw_fields if isinstance(field, dict)] + ) + else: + declared = str(raw_fields or "") + values = { + INTENT_FIELD_NAME: raw.get("name"), + INTENT_FIELD_PACKAGE: raw.get("package"), + INTENT_FIELD_ACTIVITY: raw.get("activity"), + INTENT_FIELD_ACTION: raw.get("action"), + INTENT_FIELD_CATEGORIES: ", ".join( + str(category) for category in (raw.get("categories") or []) + ) + if isinstance(raw.get("categories"), list) + else str(raw.get("categories") or ""), + INTENT_FIELD_DATA: raw.get("data"), + INTENT_FIELD_DECLARED: declared, + } + row, problems = intent_row_from_form( + values, + existing_id=existing_id or None, + taken_ids=seen_ids, + taken_names={intent["name"].casefold() for intent in accepted}, + ) + if row is None: + _LOGGER.warning( + "nvidia_shield: registered_intents row %d (%s) was ignored: %s", + index, + raw.get("name") or raw.get("package") or "unnamed", + "; ".join(problems), + ) + continue + seen_ids.add(row["id"]) + accepted.append(row) + return accepted diff --git a/custom_components/nvidia_shield/strings.json b/custom_components/nvidia_shield/strings.json index 5f9ac28..aa8c3dd 100644 --- a/custom_components/nvidia_shield/strings.json +++ b/custom_components/nvidia_shield/strings.json @@ -123,7 +123,10 @@ "invalid_app_map": "The apps text was not saved — nothing was changed. {invalid_lines} Fix those lines and select Submit again, or clear the field to go back to the built-in list.", "invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds.", "invalid_app_row": "A row needs a package id with no spaces and a non-empty name — for example ar.tvplayer.tv=TiviMate. An icon, if given, cannot contain spaces.", - "unknown_app_row": "That app is no longer in the list. Pick one of the rows offered, or add it again." + "unknown_app_row": "That app is no longer in the list. Pick one of the rows offered, or add it again.", + "invalid_intent_row": "The intent was not saved — nothing was changed. The next screen says what needs fixing, after the note describing what you were editing.", + "unknown_intent_row": "That intent is no longer registered. Pick one of the rows offered, or add it again.", + "invalid_intent_package": "That is not a usable package id. Use the exact id the Shield reports, for example com.netflix.ninja." }, "abort": { "already_configured": "This Shield is already set up.", @@ -192,7 +195,8 @@ "apps": "Apps", "discover": "Discover installed apps", "scripts": "Scripts", - "finish": "Save and close" + "finish": "Save and close", + "intents": "Intents (buttons and send_intent)" } }, "connection": { @@ -330,6 +334,86 @@ "channel_digit_delay": "The gap between the digits of a channel number, 0.05 to 1.0 seconds. Default 0.1.", "channel_send_enter": "Turn on if your channel app needs ENTER after the digits." } + }, + "intents": { + "title": "Intents", + "description": "The intents this Shield can be asked to run: each one gets its own button entity, and `nvidia_shield.send_intent` sends one to it with variables. Register an app's own intents by scanning it, or enter one by hand. Currently: {intents}", + "menu_options": { + "intents_edit": "Add or edit an intent", + "intents_remove": "Remove an intent", + "intents_scan": "Scan an app's intents", + "menu_again": "Back to the main menu" + } + }, + "intents_edit": { + "title": "Add or edit an intent", + "description": "Pick the intent to edit, or Add a new intent. Everything an intent needs is on the next screen: what to launch, and which values it takes.", + "data": { + "intent_choice": "Intent" + }, + "data_description": { + "intent_choice": "The intents you have registered, or 'Add a new intent'. A registered intent is stored in this entry's options and survives a restart." + } + }, + "intents_form": { + "title": "The intent", + "description": "One intent: where it goes, and what a caller may pass. This is {intent_note}.", + "data": { + "intent_name": "Name", + "intent_package": "Package id", + "intent_activity": "Activity (component)", + "intent_action": "Action", + "intent_categories": "Categories", + "intent_data": "Data (URL template)", + "intent_fields": "Variables (declared fields)" + }, + "data_description": { + "intent_name": "What the button and the action's dropdown call it, for example 'Netflix: open a title'. Names must be unique in this entry.", + "intent_package": "The app's Android package id, exactly as the Shield reports it, for example com.netflix.ninja.", + "intent_activity": "The component to launch, for example com.netflix.ninja/.MainActivity. The slash is required. Use this OR the action below — an intent with neither cannot be sent.", + "intent_action": "An Android action such as android.intent.action.VIEW. Use this when the app matches the intent itself instead of a fixed screen.", + "intent_categories": "Optional categories, separated by commas, for example android.intent.category.DEFAULT.", + "intent_data": "Optional URL or URI to send. It may use the declared variables in braces, for example https://www.netflix.com/watch/{id} — the value is substituted as plain text and sent quoted, never evaluated.", + "intent_fields": "One variable per line as key:type, where type is string, int, bool or raw. Add :required to make it mandatory (the action then raises, naming it), or =value to give it a default (the button sends the default). Example: id:string:required — or quality:string=high." + } + }, + "intents_remove": { + "title": "Remove an intent", + "description": "Pick the intent to remove. Its button entity goes away with it; nothing else on this entry changes.", + "data": { + "intent_choice": "Intent" + }, + "data_description": { + "intent_choice": "The intent to remove from this entry's options." + } + }, + "intents_remove_confirm": { + "title": "Remove this intent?", + "description": "Remove {intent}? Its button ({button}) disappears, and nvidia_shield.send_intent stops accepting the name. Nothing is sent to the Shield either way.", + "menu_options": { + "intents_remove_yes": "Yes, remove it", + "intents_remove_no": "No, keep it" + } + }, + "intents_scan": { + "title": "Scan an app's intents", + "description": "Ask the Shield which intents ONE app advertises, then pick one to register. This reads the device over ADB, so only the section it needs crosses the connection. Known apps: {apps}", + "data": { + "intent_scan_package": "App to scan" + }, + "data_description": { + "intent_scan_package": "The app whose advertised intents to read. Pick one the Shield reported, or type its package id." + } + }, + "intents_scan_result": { + "title": "Discovered intents", + "description": "{scan_note}", + "data": { + "intent_candidate": "Intent to register" + }, + "data_description": { + "intent_candidate": "One advertisement the app registered: the action, where it points, and the component that handles it. Entries that look like internal hooks are marked and listed last; registering one is allowed." + } } } }, @@ -347,6 +431,24 @@ "description": "The Shield to run it on: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML." } } + }, + "send_intent": { + "name": "Send intent", + "description": "Send one of this Shield's registered intents over ADB. Register intents in the integration's options under Intents; the action returns the device's own output, and a rejected intent raises with its message.", + "fields": { + "intent": { + "name": "Intent", + "description": "Which registered intent to send, by the name the Intents screen shows." + }, + "variables": { + "name": "Variables", + "description": "A value for each variable the intent declares. A declared field with a default may be omitted; a required one raises, naming it. Variables the intent does not declare are refused." + }, + "entity_id": { + "name": "Entity", + "description": "The Shield to send it to: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML." + } + } } }, "exceptions": { @@ -355,8 +457,8 @@ "message": "The command field is empty — nothing was sent." }, "unsupported_target": { - "title": "Unsupported entity for adb_command", - "message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) — command not sent." + "title": "Unsupported entity for this action", + "message": "{entity_id} is not a participant of {service} — nothing was sent." }, "adb_unavailable": { "title": "ADB unavailable", @@ -376,7 +478,7 @@ }, "ambiguous_target": { "title": "More than one Shield selected", - "message": "{entity_id} covers more than one adb_command target — select exactly one Shield (one media_player entity) — command not sent." + "message": "{entity_id} covers more than one {service} target — select exactly one Shield (one media_player entity). Nothing was sent." }, "unknown_activity": { "title": "Unknown activity", @@ -389,6 +491,42 @@ "protocol_unavailable": { "title": "Remote-protocol transport unavailable", "message": "'{activity}' was not launched: this entry has no remote-protocol transport (the option is off, or the androidtvremote2 library is missing). Turn the transport on and pair it, then try again." + }, + "unknown_intent": { + "title": "Unknown intent", + "message": "'{intent}' is not a registered intent of {entity_id}. Known intents: {known}. Register one in the integration's options under Intents." + }, + "missing_intent_field": { + "title": "Missing variable", + "message": "'{intent}' needs a value for '{field}' — pass it in variables. Nothing was sent." + }, + "unknown_intent_variable": { + "title": "Undeclared variable", + "message": "'{intent}' declares no field(s) {fields}, so nothing was sent. Declare them on the intent, or remove them from variables." + }, + "invalid_intent_field": { + "title": "Wrong value type", + "message": "'{field}' expects a {type} value, but '{value}' is not one — '{intent}' was not sent." + }, + "invalid_intent_variables": { + "title": "Variables must be a mapping", + "message": "'variables' must map field names to values for '{intent}' — nothing was sent." + }, + "unresolved_intent_placeholder": { + "title": "Unfilled placeholder", + "message": "'{intent}' uses {{{placeholder}}} in its data, but no value was supplied for it — nothing was sent." + }, + "intents_need_adb": { + "title": "ADB is required for intents", + "message": "Sending an intent to {entity_id} needs ADB: turn ADB on for this Shield in its options and make sure it is reachable. The remote protocol cannot send an intent — nothing was sent." + }, + "intent_no_answer": { + "title": "The Shield did not answer", + "message": "The Shield did not answer for {entity_id} — the intent was not sent." + }, + "intent_failed": { + "title": "The Shield refused the intent", + "message": "The Shield refused the intent: {message}. This is usually the registration: an app that no longer handles the action, a component renamed by an update, or a permission it does not have." } }, "entity": { diff --git a/custom_components/nvidia_shield/translations/en.json b/custom_components/nvidia_shield/translations/en.json index 5f9ac28..aa8c3dd 100644 --- a/custom_components/nvidia_shield/translations/en.json +++ b/custom_components/nvidia_shield/translations/en.json @@ -123,7 +123,10 @@ "invalid_app_map": "The apps text was not saved — nothing was changed. {invalid_lines} Fix those lines and select Submit again, or clear the field to go back to the built-in list.", "invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds.", "invalid_app_row": "A row needs a package id with no spaces and a non-empty name — for example ar.tvplayer.tv=TiviMate. An icon, if given, cannot contain spaces.", - "unknown_app_row": "That app is no longer in the list. Pick one of the rows offered, or add it again." + "unknown_app_row": "That app is no longer in the list. Pick one of the rows offered, or add it again.", + "invalid_intent_row": "The intent was not saved — nothing was changed. The next screen says what needs fixing, after the note describing what you were editing.", + "unknown_intent_row": "That intent is no longer registered. Pick one of the rows offered, or add it again.", + "invalid_intent_package": "That is not a usable package id. Use the exact id the Shield reports, for example com.netflix.ninja." }, "abort": { "already_configured": "This Shield is already set up.", @@ -192,7 +195,8 @@ "apps": "Apps", "discover": "Discover installed apps", "scripts": "Scripts", - "finish": "Save and close" + "finish": "Save and close", + "intents": "Intents (buttons and send_intent)" } }, "connection": { @@ -330,6 +334,86 @@ "channel_digit_delay": "The gap between the digits of a channel number, 0.05 to 1.0 seconds. Default 0.1.", "channel_send_enter": "Turn on if your channel app needs ENTER after the digits." } + }, + "intents": { + "title": "Intents", + "description": "The intents this Shield can be asked to run: each one gets its own button entity, and `nvidia_shield.send_intent` sends one to it with variables. Register an app's own intents by scanning it, or enter one by hand. Currently: {intents}", + "menu_options": { + "intents_edit": "Add or edit an intent", + "intents_remove": "Remove an intent", + "intents_scan": "Scan an app's intents", + "menu_again": "Back to the main menu" + } + }, + "intents_edit": { + "title": "Add or edit an intent", + "description": "Pick the intent to edit, or Add a new intent. Everything an intent needs is on the next screen: what to launch, and which values it takes.", + "data": { + "intent_choice": "Intent" + }, + "data_description": { + "intent_choice": "The intents you have registered, or 'Add a new intent'. A registered intent is stored in this entry's options and survives a restart." + } + }, + "intents_form": { + "title": "The intent", + "description": "One intent: where it goes, and what a caller may pass. This is {intent_note}.", + "data": { + "intent_name": "Name", + "intent_package": "Package id", + "intent_activity": "Activity (component)", + "intent_action": "Action", + "intent_categories": "Categories", + "intent_data": "Data (URL template)", + "intent_fields": "Variables (declared fields)" + }, + "data_description": { + "intent_name": "What the button and the action's dropdown call it, for example 'Netflix: open a title'. Names must be unique in this entry.", + "intent_package": "The app's Android package id, exactly as the Shield reports it, for example com.netflix.ninja.", + "intent_activity": "The component to launch, for example com.netflix.ninja/.MainActivity. The slash is required. Use this OR the action below — an intent with neither cannot be sent.", + "intent_action": "An Android action such as android.intent.action.VIEW. Use this when the app matches the intent itself instead of a fixed screen.", + "intent_categories": "Optional categories, separated by commas, for example android.intent.category.DEFAULT.", + "intent_data": "Optional URL or URI to send. It may use the declared variables in braces, for example https://www.netflix.com/watch/{id} — the value is substituted as plain text and sent quoted, never evaluated.", + "intent_fields": "One variable per line as key:type, where type is string, int, bool or raw. Add :required to make it mandatory (the action then raises, naming it), or =value to give it a default (the button sends the default). Example: id:string:required — or quality:string=high." + } + }, + "intents_remove": { + "title": "Remove an intent", + "description": "Pick the intent to remove. Its button entity goes away with it; nothing else on this entry changes.", + "data": { + "intent_choice": "Intent" + }, + "data_description": { + "intent_choice": "The intent to remove from this entry's options." + } + }, + "intents_remove_confirm": { + "title": "Remove this intent?", + "description": "Remove {intent}? Its button ({button}) disappears, and nvidia_shield.send_intent stops accepting the name. Nothing is sent to the Shield either way.", + "menu_options": { + "intents_remove_yes": "Yes, remove it", + "intents_remove_no": "No, keep it" + } + }, + "intents_scan": { + "title": "Scan an app's intents", + "description": "Ask the Shield which intents ONE app advertises, then pick one to register. This reads the device over ADB, so only the section it needs crosses the connection. Known apps: {apps}", + "data": { + "intent_scan_package": "App to scan" + }, + "data_description": { + "intent_scan_package": "The app whose advertised intents to read. Pick one the Shield reported, or type its package id." + } + }, + "intents_scan_result": { + "title": "Discovered intents", + "description": "{scan_note}", + "data": { + "intent_candidate": "Intent to register" + }, + "data_description": { + "intent_candidate": "One advertisement the app registered: the action, where it points, and the component that handles it. Entries that look like internal hooks are marked and listed last; registering one is allowed." + } } } }, @@ -347,6 +431,24 @@ "description": "The Shield to run it on: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML." } } + }, + "send_intent": { + "name": "Send intent", + "description": "Send one of this Shield's registered intents over ADB. Register intents in the integration's options under Intents; the action returns the device's own output, and a rejected intent raises with its message.", + "fields": { + "intent": { + "name": "Intent", + "description": "Which registered intent to send, by the name the Intents screen shows." + }, + "variables": { + "name": "Variables", + "description": "A value for each variable the intent declares. A declared field with a default may be omitted; a required one raises, naming it. Variables the intent does not declare are refused." + }, + "entity_id": { + "name": "Entity", + "description": "The Shield to send it to: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML." + } + } } }, "exceptions": { @@ -355,8 +457,8 @@ "message": "The command field is empty — nothing was sent." }, "unsupported_target": { - "title": "Unsupported entity for adb_command", - "message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) — command not sent." + "title": "Unsupported entity for this action", + "message": "{entity_id} is not a participant of {service} — nothing was sent." }, "adb_unavailable": { "title": "ADB unavailable", @@ -376,7 +478,7 @@ }, "ambiguous_target": { "title": "More than one Shield selected", - "message": "{entity_id} covers more than one adb_command target — select exactly one Shield (one media_player entity) — command not sent." + "message": "{entity_id} covers more than one {service} target — select exactly one Shield (one media_player entity). Nothing was sent." }, "unknown_activity": { "title": "Unknown activity", @@ -389,6 +491,42 @@ "protocol_unavailable": { "title": "Remote-protocol transport unavailable", "message": "'{activity}' was not launched: this entry has no remote-protocol transport (the option is off, or the androidtvremote2 library is missing). Turn the transport on and pair it, then try again." + }, + "unknown_intent": { + "title": "Unknown intent", + "message": "'{intent}' is not a registered intent of {entity_id}. Known intents: {known}. Register one in the integration's options under Intents." + }, + "missing_intent_field": { + "title": "Missing variable", + "message": "'{intent}' needs a value for '{field}' — pass it in variables. Nothing was sent." + }, + "unknown_intent_variable": { + "title": "Undeclared variable", + "message": "'{intent}' declares no field(s) {fields}, so nothing was sent. Declare them on the intent, or remove them from variables." + }, + "invalid_intent_field": { + "title": "Wrong value type", + "message": "'{field}' expects a {type} value, but '{value}' is not one — '{intent}' was not sent." + }, + "invalid_intent_variables": { + "title": "Variables must be a mapping", + "message": "'variables' must map field names to values for '{intent}' — nothing was sent." + }, + "unresolved_intent_placeholder": { + "title": "Unfilled placeholder", + "message": "'{intent}' uses {{{placeholder}}} in its data, but no value was supplied for it — nothing was sent." + }, + "intents_need_adb": { + "title": "ADB is required for intents", + "message": "Sending an intent to {entity_id} needs ADB: turn ADB on for this Shield in its options and make sure it is reachable. The remote protocol cannot send an intent — nothing was sent." + }, + "intent_no_answer": { + "title": "The Shield did not answer", + "message": "The Shield did not answer for {entity_id} — the intent was not sent." + }, + "intent_failed": { + "title": "The Shield refused the intent", + "message": "The Shield refused the intent: {message}. This is usually the registration: an app that no longer handles the action, a component renamed by an update, or a permission it does not have." } }, "entity": { diff --git a/tests/test_command_routing.py b/tests/test_command_routing.py index ae49008..393657a 100644 --- a/tests/test_command_routing.py +++ b/tests/test_command_routing.py @@ -1844,36 +1844,87 @@ class TestIconOverride: class TestServiceRegistryKey: """t26 F4 + t29 G8: the participant key is computed once — the registry - add/remove always match (the service itself stays registered).""" + add/remove always match (the service itself stays registered). - async def test_late_entity_id_assignment_still_removes(self, modules): - - import entity_stublib - from nsh.media_player import _ADB_COMMAND_TARGETS + t17: these drive the REAL hooks instead of hand-writing the registry. The + hand-written version passed while ``async_added_to_hass`` registered ``None`` + for a real entry (it filled the registry before consuming the entry's + manager) and while ``async_will_remove_from_hass`` never dropped the key at + all — both invisible to a test that pokes the dict itself. + """ + @staticmethod + def _real_player(modules): + """A player wired the way a real entry is: the manager on runtime_data.""" mods = load_entity_modules() player, _s, _i = make_player(mods) + manager = player._manager + # exactly what __init__.py puts on the entry before the platforms load + player._entry.runtime_data = manager player._entry.options["adb_command_enabled"] = True player._attr_unique_id = "uid_late" + # the harness constructs the entity without __init__, so the list the + # hook appends its tracked listeners to has to exist (`_power_entity` and + # `_jellyfin_entity` are read-only properties over the entry's options — + # with neither configured they are None, which is the real empty state) + player._power_sensors = {} + player._unsub_listeners = [] + player._manager_unsub = None # no entity_id yet at registration (HA core assigns it right after) player.hass = entity_stublib.FakeHass() player.hass.services = FakeServices() - if hasattr(player, "_adb_command_key"): - del player._adb_command_key # start clean + return player, manager - # add path — the key is computed once from unique_id - key = getattr(player, "entity_id", None) or player._attr_unique_id - player._adb_command_key = key - _ADB_COMMAND_TARGETS[key] = player._manager - assert "uid_late" in _ADB_COMMAND_TARGETS + async def test_late_entity_id_assignment_still_removes(self, modules): + from nsh.media_player import _ADB_COMMAND_TARGETS, _INTENT_TARGETS + + player, manager = self._real_player(modules) + for registry in (_ADB_COMMAND_TARGETS, _INTENT_TARGETS): + registry.pop("uid_late", None) + + await player.async_added_to_hass() + + assert _ADB_COMMAND_TARGETS["uid_late"] is manager, "the KEY is the unique_id" + assert _INTENT_TARGETS["uid_late"] is manager # HA core assigned the entity_id afterwards — removal still matches player.entity_id = "media_player.shield_test" - # drop participation via the stored key (entity_id is NOT consulted) - _ADB_COMMAND_TARGETS.pop(player._adb_command_key) - assert "uid_late" not in _ADB_COMMAND_TARGETS + await player.async_will_remove_from_hass() + + assert "uid_late" not in _ADB_COMMAND_TARGETS, "the add key, not entity_id" assert "media_player.shield_test" not in _ADB_COMMAND_TARGETS + assert "uid_late" not in _INTENT_TARGETS, "both registries are dropped" + assert "media_player.shield_test" not in _INTENT_TARGETS + + async def test_the_hook_registers_the_entry_s_own_manager(self, modules): + """The manager comes from runtime_data — never None (t17 latent fix).""" + player, _manager_ignored = self._real_player(modules) + fresh = make_manager() + player._manager = None # __init__ has not run in this harness + player._entry.runtime_data = fresh + + await player.async_added_to_hass() + + from nsh.media_player import _ADB_COMMAND_TARGETS + + assert _ADB_COMMAND_TARGETS["uid_late"] is fresh + _ADB_COMMAND_TARGETS.pop("uid_late", None) + + async def test_intents_do_not_need_the_adb_command_opt_in(self, modules): + """A registered intent is the user's own opt-in (t17).""" + from nsh.media_player import _ADB_COMMAND_TARGETS, _INTENT_TARGETS + + player, manager = self._real_player(modules) + player._entry.options["adb_command_enabled"] = False # the raw door is off + for registry in (_ADB_COMMAND_TARGETS, _INTENT_TARGETS): + registry.pop("uid_late", None) + + await player.async_added_to_hass() + + assert "uid_late" not in _ADB_COMMAND_TARGETS + assert _INTENT_TARGETS["uid_late"] is manager + _INTENT_TARGETS.pop("uid_late", None) async def test_setup_entry_object_carries_the_key(self): """The participant object stores the key F4 computed (both hooks).""" diff --git a/tests/test_config_flow_ux.py b/tests/test_config_flow_ux.py index d8c7fba..8185345 100644 --- a/tests/test_config_flow_ux.py +++ b/tests/test_config_flow_ux.py @@ -1628,12 +1628,27 @@ OPTIONS_NESTED_STEPS = { "apps_edit_form": "apps", "apps_remove": "apps", "apps_remove_confirm": "apps", + # t17: the Intents editor is the same shape — a menu, then one row per step + "intents_edit": "intents", + "intents_form": "intents", + "intents_remove": "intents", + "intents_remove_confirm": "intents", + "intents_scan": "intents", + "intents_scan_result": "intents", } #: Menu rows that ACT rather than render a form: HA dispatches them with no input, #: so there is no data_schema and no field labels to check. OPTIONS_ACTION_ROWS = frozenset( - {"discover", "finish", "menu_again", "apps_remove_yes", "apps_remove_no"} + { + "discover", + "finish", + "menu_again", + "apps_remove_yes", + "apps_remove_no", + "intents_remove_yes", + "intents_remove_no", + } ) @@ -2066,6 +2081,7 @@ class TestOptionsMenu: "connection", "receiver", "apps", + "intents", "discover", "scripts", "finish", diff --git a/tests/test_intents_registration.py b/tests/test_intents_registration.py new file mode 100644 index 0000000..3736865 --- /dev/null +++ b/tests/test_intents_registration.py @@ -0,0 +1,1286 @@ +"""tests/test_intents_registration.py — t17: the registered-intent feature. + +Four things are pinned here, in the order the user meets them: + +1. THE MODEL AND THE COMMAND (`media_player.py`): what a registered intent is, + that an unusable row never becomes a button, and — the part that matters — + that the ``am start`` command is BUILT from validated data with plain text + substitution and quoted values, so nothing a variable contains can become + shell syntax. +2. THE SCREEN (`config_flow.py`): the Intents sub-menu, the selector with its + Add-new sentinel, the per-intent form, removal, and the scan that offers a + discovered candidate — plus the seed validator, which must produce a row + IDENTICAL to one typed into the form. +3. THE BUTTON (`button.py`): one entity per registration, sending the declared + defaults, failing loudly (the uninstalled-app case is the device's own + message, not a swallowed no-op). +4. THE ACTION (`media_player._async_send_intent_service`): the intent lookup, + the variables, and the output returned under ``intent_response``. + +The flow and the entity modules are loaded in ONE stub environment on purpose: +point 1's model exists twice (the runtime's reader and the screen's), and the +test at the bottom of ``TestTheModel`` drives both over the same rows so they +cannot drift into meaning different things. +""" + +from __future__ import annotations + +import asyncio +import importlib +import shlex +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import entity_stublib # noqa: E402 +from entity_stublib import load_entity_modules # noqa: E402 +from nsh.const import ( # noqa: E402 + CONF_REGISTERED_INTENTS, + INTENT_FIELD_ACTION, + INTENT_FIELD_ACTIVITY, + INTENT_FIELD_CANDIDATE, + INTENT_FIELD_CATEGORIES, + INTENT_FIELD_CHOICE, + INTENT_FIELD_DATA, + INTENT_FIELD_DECLARED, + INTENT_FIELD_LIMIT, + INTENT_FIELD_NAME, + INTENT_FIELD_PACKAGE, + INTENT_FIELD_SCAN_PACKAGE, + INTENT_NEW_SENTINEL, + INTENT_REGISTRATION_LIMIT, + INTENT_RESPONSE_KEY, +) +from test_standalone_setup import FakeHass, _form_module, _install_stubs # noqa: E402 + +#: A registration as the plan describes it: a component, a data template with a +#: declared placeholder, and a required field the caller must supply. +NETFLIX_INTENT = { + "id": "netflix_title", + "name": "Netflix: open a title", + "package": "com.netflix.ninja", + "activity": "com.netflix.ninja/.MainActivity", + "action": "android.intent.action.VIEW", + "categories": ["android.intent.category.DEFAULT"], + "data": "https://www.netflix.com/watch/{id}", + "fields": [{"key": "id", "type": "string", "required": True}], +} + + +@pytest.fixture(autouse=True) +def scoped_stubs(): + """Keep the import stubs and the loaded modules inside this file (t7).""" + snapshot = entity_stublib.snapshot_modules() + _install_stubs() + yield + entity_stublib.restore_modules(snapshot) + + +@pytest.fixture() +def modules(tmp_path): + """The flow module AND the entity modules, in one environment.""" + flow = _form_module() + media = load_entity_modules() + return SimpleNamespace(flow=flow, media=media["media_player"]) + + +class _RecordingPoller: + """A poller that answers with what a test puts on the wire.""" + + def __init__(self, answer='Starting: Intent { act=android.intent.action.VIEW }'): + self.answer = answer + self.commands: list[str] = [] + + async def shell(self, command): + self.commands.append(command) + if isinstance(self.answer, Exception): + raise self.answer + return self.answer + + +def _manager(poller, *, connected=True): + return SimpleNamespace(adb_poller=poller, connected=connected, entry=None) + + +# ── 1. the model and the command ─────────────────────────────────────────── + + +class TestTheCommand: + """The `am start` command: built, quoted, and never evaluated.""" + + def test_the_plan_s_mapping_is_exactly_what_is_sent(self, modules): + command = modules.media.intent_am_start_command( + NETFLIX_INTENT, {"id": "80100172"} + ) + + assert command == ( + "am start -n com.netflix.ninja/.MainActivity " + "-a android.intent.action.VIEW -c android.intent.category.DEFAULT " + "-d https://www.netflix.com/watch/80100172 --es id 80100172" + ) + + def test_every_declared_type_becomes_its_own_flag(self, modules): + intent = { + **NETFLIX_INTENT, + "data": "", + "fields": [ + {"key": "title", "type": "string", "required": True}, + {"key": "season", "type": "int", "required": True}, + {"key": "resume", "type": "bool", "required": True}, + {"key": "extra", "type": "raw", "required": True}, + ], + } + + command = modules.media.intent_am_start_command( + intent, + {"title": "Sky", "season": "2", "resume": "YES", "extra": "--user 0"}, + ) + + assert "--es title Sky" in command + assert "--ei season 2" in command + assert "--ez resume true" in command # normalised to the device's boolean + assert "--es extra '--user 0'" in command, "raw is quoted like everything else" + + def test_a_hostile_variable_is_one_literal_argument(self, modules): + hostile = '"; rm -rf /"' + command = modules.media.intent_am_start_command( + {**NETFLIX_INTENT, "data": ""}, {"id": hostile} + ) + + tokens = shlex.split(command) + + # the shell sees the value as ONE argument, exactly as typed + assert tokens == ["am", "start", "-n", NETFLIX_INTENT["activity"], + "-a", NETFLIX_INTENT["action"], + "-c", "android.intent.category.DEFAULT", + "--es", "id", hostile] + assert "rm -rf" not in command.split("--es")[0] + + def test_a_hostile_data_value_is_quoted_too(self, modules): + command = modules.media.intent_am_start_command( + NETFLIX_INTENT, {"id": "a; reboot"} + ) + + assert shlex.split(command)[-1] == "a; reboot" + assert "https://www.netflix.com/watch/a; reboot" in shlex.split(command) + + def test_substitution_is_plain_text_and_runs_once(self, modules): + """A value that LOOKS like a placeholder is data, never re-substituted.""" + intent = { + **NETFLIX_INTENT, + "data": "https://example.test/{id}", + "fields": [{"key": "id", "type": "string", "required": True}], + } + + command = modules.media.intent_am_start_command(intent, {"id": "{name}"}) + + assert "https://example.test/{name}" in command + assert shlex.split(command)[-1] == "{name}" + + def test_attribute_style_placeholders_are_left_alone(self, modules): + """No eval, no format(): ``{id.__class__}`` is not a placeholder at all.""" + intent = {**NETFLIX_INTENT, "data": "https://example.test/{id.__class__}"} + + command = modules.media.intent_am_start_command(intent, {"id": "1"}) + + assert "https://example.test/{id.__class__}" in command + + def test_a_missing_required_field_names_the_field(self, modules): + err = pytest.raises( + modules.media.ServiceValidationError if hasattr(modules.media, "ServiceValidationError") + else Exception + ) + with err as caught: + modules.media.intent_am_start_command(NETFLIX_INTENT, {}) + + assert caught.value.translation_key == "missing_intent_field" + assert caught.value.translation_placeholders["field"] == "id" + assert "id" in str(caught.value) + + def test_an_undeclared_variable_is_refused_not_dropped(self, modules): + import homeassistant.exceptions as ha_exceptions + + with pytest.raises(ha_exceptions.ServiceValidationError) as caught: + modules.media.intent_am_start_command( + NETFLIX_INTENT, {"id": "1", "sneaky": "x"} + ) + + assert caught.value.translation_key == "unknown_intent_variable" + assert caught.value.translation_placeholders["fields"] == "sneaky" + + def test_a_value_that_cannot_become_its_type_names_field_and_value(self, modules): + import homeassistant.exceptions as ha_exceptions + + intent = { + **NETFLIX_INTENT, + "data": "", + "fields": [{"key": "season", "type": "int", "required": True}], + } + + with pytest.raises(ha_exceptions.ServiceValidationError) as caught: + modules.media.intent_am_start_command(intent, {"season": "winter"}) + + assert caught.value.translation_key == "invalid_intent_field" + assert caught.value.translation_placeholders["field"] == "season" + assert "winter" in str(caught.value) + + def test_an_unfilled_placeholder_raises_instead_of_sending_braces(self, modules): + import homeassistant.exceptions as ha_exceptions + + intent = {**NETFLIX_INTENT, "fields": []} # nothing declares {id} + + with pytest.raises(ha_exceptions.ServiceValidationError) as caught: + modules.media.intent_am_start_command(intent, {}) + + assert caught.value.translation_key == "unresolved_intent_placeholder" + assert caught.value.translation_placeholders["placeholder"] == "id" + + def test_defaults_satisfy_the_template(self, modules): + intent = { + **NETFLIX_INTENT, + "fields": [{"key": "id", "type": "string", "default": "42"}], + } + + assert "watch/42" in modules.media.intent_am_start_command(intent, {}) + assert modules.media.intent_default_variables(intent) == {"id": "42"} + + def test_an_optional_field_without_a_value_is_simply_not_sent(self, modules): + intent = { + **NETFLIX_INTENT, + "data": "", + "fields": [{"key": "quality", "type": "string"}], + } + + command = modules.media.intent_am_start_command(intent, {}) + + assert "--es" not in command + assert "quality" not in command + + +class TestRunningAnIntent: + """What actually reaches the device, and what happens when it refuses.""" + + async def test_the_device_gets_exactly_one_built_command(self, modules): + poller = _RecordingPoller() + manager = _manager(poller) + + output = await modules.media.run_registered_intent( + manager, "media_player.shield", NETFLIX_INTENT, {"id": "1"} + ) + + assert output.startswith("Starting: Intent") + assert poller.commands == [ + modules.media.intent_am_start_command(NETFLIX_INTENT, {"id": "1"}) + ] + + async def test_adb_down_says_the_protocol_cannot_carry_an_intent(self, modules): + import homeassistant.exceptions as ha_exceptions + + for manager in (_manager(None), _manager(_RecordingPoller(), connected=False)): + with pytest.raises(ha_exceptions.ServiceValidationError) as caught: + await modules.media.run_registered_intent( + manager, "media_player.shield", NETFLIX_INTENT, {"id": "1"} + ) + assert caught.value.translation_key == "intents_need_adb" + assert "protocol" in str(caught.value) + + async def test_a_transport_that_never_answered_is_an_error(self, modules): + import homeassistant.exceptions as ha_exceptions + + with pytest.raises(ha_exceptions.ServiceValidationError) as caught: + await modules.media.run_registered_intent( + _manager(_RecordingPoller(None)), + "media_player.shield", + NETFLIX_INTENT, + {"id": "1"}, + ) + + assert caught.value.translation_key == "intent_no_answer" + + async def test_a_refused_intent_keeps_the_device_s_own_words(self, modules): + import homeassistant.exceptions as ha_exceptions + + refusal = ( + "Starting: Intent { act=android.intent.action.VIEW }\n" + "Error: Activity class {com.netflix.ninja/.MainActivity} does not exist." + ) + + with pytest.raises(ha_exceptions.ServiceValidationError) as caught: + await modules.media.run_registered_intent( + _manager(_RecordingPoller(refusal)), + "media_player.shield", + NETFLIX_INTENT, + {"id": "1"}, + ) + + assert caught.value.translation_key == "intent_failed" + assert "does not exist" in str(caught.value) + assert "com.netflix.ninja/.MainActivity" in str(caught.value) + + def test_the_failure_detector_knows_a_success_from_a_refusal(self, modules): + detect = modules.media.intent_failure_message + + assert detect("Starting: Intent { cmp=com.a/.B }") is None + assert detect(None) is None + assert "unable to resolve" in detect( + "Error: Unable to resolve Intent { act=x }" + ).lower() + assert detect("Permission Denial: starting Intent requires permission") is not None + + +# ── 2. the model as READ, and the screen's copy of it ────────────────────── + + +class TestTheModel: + """What counts as a usable registration, and the two readers agreeing.""" + + def test_a_row_that_names_nothing_to_launch_is_dropped(self, modules): + rows = modules.media.registered_intents( + { + CONF_REGISTERED_INTENTS: [ + NETFLIX_INTENT, + {"id": "broken", "name": "Broken", "package": "com.x"}, + {"id": "nolabel", "package": "com.x", "action": "a.B"}, + ] + } + ) + + assert [row["id"] for row in rows] == ["netflix_title"] + + def test_categories_and_fields_are_normalised(self, modules): + rows = modules.media.registered_intents( + { + CONF_REGISTERED_INTENTS: [ + { + **NETFLIX_INTENT, + "categories": ["", " android.intent.category.DEFAULT "], + "fields": [ + {"key": "id", "type": "STRING", "required": 1}, + {"key": "9bad", "type": "string"}, + {"key": "ok", "type": "nope"}, + ], + } + ] + } + ) + + assert rows[0]["categories"] == ["android.intent.category.DEFAULT"] + assert rows[0]["fields"] == [{"key": "id", "type": "string", "required": True}] + + def test_duplicate_ids_keep_the_first_and_the_cap_holds(self, modules): + rows = modules.media.registered_intents( + { + CONF_REGISTERED_INTENTS: [ + {**NETFLIX_INTENT, "name": "First"}, + {**NETFLIX_INTENT, "name": "Second"}, + ] + + [ + {**NETFLIX_INTENT, "id": f"i{n}", "name": f"n{n}"} + for n in range(INTENT_REGISTRATION_LIMIT + 5) + ] + } + ) + + assert rows[0]["name"] == "First" + assert len(rows) == INTENT_REGISTRATION_LIMIT + + def test_a_mapping_shaped_blob_still_reads(self, modules): + rows = modules.media.registered_intents( + {CONF_REGISTERED_INTENTS: {"netflix_title": NETFLIX_INTENT}} + ) + + assert [row["id"] for row in rows] == ["netflix_title"] + + def test_lookup_by_id_and_by_name(self, modules): + options = {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]} + + assert modules.media.intent_by_id(options, "netflix_title")["name"] == ( + NETFLIX_INTENT["name"] + ) + assert modules.media.intent_by_id(options, NETFLIX_INTENT["name"]) is not None + assert modules.media.intent_by_id(options, "nope") is None + + def test_the_screen_and_the_runtime_read_the_same_rows(self, modules): + """The anti-drift pin: the flow's reader and the runtime's must agree. + + They live in different modules because the options flow must stay + importable without Home Assistant's media_player platform. This drives + both over one battery — valid rows, junk, a duplicate id, an over-long + list, a mapping-shaped blob — and requires the same ids, names, packages, + components, actions, categories and fields from each. + """ + battery = [ + NETFLIX_INTENT, + {**NETFLIX_INTENT, "id": "second"}, + {"id": "junk", "name": "Junk", "package": "com.x"}, + "not-a-dict", + {**NETFLIX_INTENT}, + {**NETFLIX_INTENT, "activity": "no-slash", "action": "a.B"}, + {**NETFLIX_INTENT, "id": "fields", "fields": [{"key": "bad key"}]}, + ] + + runtime = modules.media.registered_intents({CONF_REGISTERED_INTENTS: battery}) + screen = modules.flow.registered_intent_rows({CONF_REGISTERED_INTENTS: battery}) + + assert runtime == screen + + +# ── 3. the screen ────────────────────────────────────────────────────────── + + +def _flow(modules, tmp_path, options=None): + flow = modules.flow.NvidiaShieldOptionsFlow() + flow.config_entry = SimpleNamespace( + options=dict(options or {}), entry_id="e1", title="Shield" + ) + flow.hass = FakeHass(str(tmp_path)) + flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} + flow.async_show_menu = lambda **kwargs: {"type": "menu", **kwargs} + flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} + return flow + + +def _form_payload(**overrides): + payload = { + INTENT_FIELD_NAME: NETFLIX_INTENT["name"], + INTENT_FIELD_PACKAGE: NETFLIX_INTENT["package"], + INTENT_FIELD_ACTIVITY: NETFLIX_INTENT["activity"], + INTENT_FIELD_ACTION: NETFLIX_INTENT["action"], + INTENT_FIELD_CATEGORIES: "android.intent.category.DEFAULT", + INTENT_FIELD_DATA: NETFLIX_INTENT["data"], + INTENT_FIELD_DECLARED: "id:string:required", + } + payload.update(overrides) + return payload + + +class TestTheIntentsScreen: + """The menu, the sentinel, the form, removal — and nothing half-stored.""" + + async def test_the_menu_offers_the_screen_and_labels_it(self, modules, tmp_path): + flow = _flow(modules, tmp_path) + + menu = await flow.async_step_intents(None) + + assert menu["type"] == "menu" + assert list(menu["menu_options"]) == ["intents_edit", "intents_scan", "menu_again"] + assert "no intents registered yet" in menu["description_placeholders"]["intents"] + + async def test_remove_appears_only_with_something_to_remove(self, modules, tmp_path): + flow = _flow(modules, tmp_path) + + menu = await flow.async_step_intents(None) + assert "intents_remove" not in menu["menu_options"] + + flow._options_dict[CONF_REGISTERED_INTENTS] = [NETFLIX_INTENT] + menu = await flow.async_step_intents(None) + + assert "intents_remove" in menu["menu_options"] + assert "1 intent registered" in menu["description_placeholders"]["intents"] + + async def test_the_selector_lists_every_intent_and_the_add_new_sentinel( + self, modules, tmp_path + ): + flow = _flow(modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}) + + result = await flow.async_step_intents_edit(None) + + assert result["type"] == "form" and result["step_id"] == "intents_edit" + # the selector is the field the form declares; its options carry the + # registered intent AND the explicit Add-new row + choices = flow._intent_choices() + assert [row["value"] for row in choices] == ["netflix_title", INTENT_NEW_SENTINEL] + assert choices[-1]["label"].startswith("+ Add a new intent") + + async def test_add_new_opens_a_blank_form(self, modules, tmp_path): + flow = _flow(modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}) + + form = await flow.async_step_intents_edit( + {INTENT_FIELD_CHOICE: INTENT_NEW_SENTINEL} + ) + + assert form["type"] == "form" and form["step_id"] == "intents_form" + assert "new intent" in form["description_placeholders"]["intent_note"] + + async def test_an_unknown_choice_is_refused(self, modules, tmp_path): + flow = _flow(modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}) + + result = await flow.async_step_intents_edit({INTENT_FIELD_CHOICE: "ghost"}) + + assert result["errors"] == {"base": "unknown_intent_row"} + + async def test_a_new_intent_is_stored_with_a_slug_id(self, modules, tmp_path): + flow = _flow(modules, tmp_path) + + result = await flow.async_step_intents_form(_form_payload()) + + assert result["type"] == "menu" # back at the Intents editor + stored = flow._options_dict[CONF_REGISTERED_INTENTS] + assert len(stored) == 1 + assert stored[0]["id"] == "netflix_open_a_title" + assert stored[0]["fields"] == [{"key": "id", "type": "string", "required": True}] + assert stored[0]["categories"] == ["android.intent.category.DEFAULT"] + + async def test_stored_rows_are_json_safe(self, modules, tmp_path): + """Options are persisted as JSON: a set or a tuple here would break storage.""" + import json + + flow = _flow(modules, tmp_path) + await flow.async_step_intents_form(_form_payload()) + + json.dumps(flow._options_dict) # raises if a row is not serialisable + + async def test_editing_one_row_leaves_the_others_alone(self, modules, tmp_path): + other = {**NETFLIX_INTENT, "id": "other", "name": "Another"} + flow = _flow( + modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT, other]} + ) + + result = await flow.async_step_intents_edit( + {INTENT_FIELD_CHOICE: "netflix_title"} + ) + assert result["step_id"] == "intents_form" + assert result["description_placeholders"]["intent_note"].startswith("editing ") + + await flow.async_step_intents_form( + _form_payload(**{INTENT_FIELD_NAME: "Renamed"}) + ) + + stored = flow._options_dict[CONF_REGISTERED_INTENTS] + assert [row["id"] for row in stored] == ["netflix_title", "other"] + assert stored[0]["name"] == "Renamed" # the rename landed on THAT row + assert stored[1] == other # …and this one is byte-identical to before + + async def test_a_refused_submission_stores_nothing_and_says_why( + self, modules, tmp_path + ): + flow = _flow(modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}) + + result = await flow.async_step_intents_form( + _form_payload( + **{ + INTENT_FIELD_NAME: "Nothing to launch", + INTENT_FIELD_ACTIVITY: "", + INTENT_FIELD_ACTION: "", + } + ) + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_intent_row"} + note = result["description_placeholders"]["intent_note"] + assert "component" in note and "action" in note + assert len(flow._options_dict[CONF_REGISTERED_INTENTS]) == 1 # unchanged + + async def test_each_refusal_names_its_own_problem(self, modules, tmp_path): + cases = [ + ({INTENT_FIELD_PACKAGE: "has spaces"}, "not a usable package id"), + ({INTENT_FIELD_ACTIVITY: "com.x.MainActivity"}, "not a component"), + ({INTENT_FIELD_DATA: "https://x/{nope}"}, "no declared field"), + ({INTENT_FIELD_DECLARED: "id:weird"}, "is not one of"), + ({INTENT_FIELD_NAME: NETFLIX_INTENT["name"]}, "already called"), + ] + + for overrides, expected in cases: + flow = _flow(modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}) + payload = _form_payload(**overrides) + if INTENT_FIELD_NAME in overrides: + payload[INTENT_FIELD_CHOICE] = INTENT_NEW_SENTINEL + else: + flow._options_dict[CONF_REGISTERED_INTENTS] = [NETFLIX_INTENT] + payload[INTENT_FIELD_NAME] = "New name" + result = await flow.async_step_intents_form(payload) + + assert result["errors"] == {"base": "invalid_intent_row"}, overrides + assert expected in result["description_placeholders"]["intent_note"], overrides + + async def test_removal_takes_a_confirmation_and_drops_only_that_row( + self, modules, tmp_path + ): + other = {**NETFLIX_INTENT, "id": "other", "name": "Another"} + flow = _flow( + modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT, other]} + ) + + confirm = await flow.async_step_intents_remove( + {INTENT_FIELD_CHOICE: "netflix_title"} + ) + assert confirm["step_id"] == "intents_remove_confirm" + assert confirm["description_placeholders"]["intent"] == ( + "Netflix: open a title (com.netflix.ninja)" + ) + assert confirm["description_placeholders"]["button"] == NETFLIX_INTENT["name"] + assert "intents_remove_yes" in confirm["menu_options"], "it takes a second act" + + # nothing is dropped until the user confirms + assert len(flow._options_dict[CONF_REGISTERED_INTENTS]) == 2 + + await flow.async_step_intents_remove_yes(None) + + stored = flow._options_dict[CONF_REGISTERED_INTENTS] + assert [row["id"] for row in stored] == ["other"] + + async def test_cancelling_a_removal_changes_nothing(self, modules, tmp_path): + flow = _flow(modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}) + await flow.async_step_intents_remove({INTENT_FIELD_CHOICE: "netflix_title"}) + + result = await flow.async_step_intents_remove_no(None) + + assert result["step_id"] == "intents" + assert flow._options_dict[CONF_REGISTERED_INTENTS] == [NETFLIX_INTENT] + + async def test_a_registration_survives_a_new_flow_on_the_same_options( + self, modules, tmp_path + ): + """The options ARE the store: a fresh flow reads what the last one wrote.""" + options = {} + first = _flow(modules, tmp_path, options) + await first.async_step_intents_form(_form_payload()) + options.update(first._options_dict) + + second = _flow(modules, tmp_path, options) + + assert [row["id"] for row in second._registered_intents()] == [ + "netflix_open_a_title" + ] + + +class TestTheDeclaredFieldsText: + """The `key:type[:required][=value]` grammar, one line at a time.""" + + def test_the_grammar_reads_every_shape(self, modules): + fields, problems = modules.flow.intent_fields_from_text( + "# a comment\nid:string:required\nseason:int:required\n" + "quality:string=high\nresume:bool\nblob:raw\n" + ) + + assert problems == [] + assert fields == [ + {"key": "id", "type": "string", "required": True}, + {"key": "season", "type": "int", "required": True}, + {"key": "quality", "type": "string", "required": False, "default": "high"}, + {"key": "resume", "type": "bool", "required": False}, + {"key": "blob", "type": "raw", "required": False}, + ] + + def test_every_problem_names_its_line(self, modules): + fields, problems = modules.flow.intent_fields_from_text( + "id:string\nnoline\n9bad:string\nid:string:maybe\nboth:int:required=1\nid:string\n" + ) + + assert fields is None + assert [problem.split(":")[0] for problem in problems] == [ + "fields line 2", + "fields line 3", + "fields line 4", + "fields line 5", + "fields line 6", + ] + + def test_the_text_round_trips_through_the_form(self, modules): + fields = [ + {"key": "id", "type": "string", "required": True}, + {"key": "quality", "type": "string", "required": False, "default": "high"}, + ] + + text = modules.flow.intent_fields_to_text(fields) + + assert text == "id:string:required\nquality:string=high" + assert modules.flow.intent_fields_from_text(text)[0] == fields + + def test_the_cap_is_refused_rather_than_truncated(self, modules): + text = "\n".join(f"k{n}:string" for n in range(INTENT_FIELD_LIMIT + 2)) + + fields, problems = modules.flow.intent_fields_from_text(text) + + assert fields is None + assert any("cap" in problem for problem in problems) + + +class _FakeScan: + """A stand-in for `manager.async_scan_intents` (t16's API).""" + + def __init__(self, result=None, exc=None): + self.result = result + self.exc = exc + self.packages: list[str] = [] + + async def __call__(self, package): + self.packages.append(package) + if self.exc is not None: + raise self.exc + return self.result + + +def _candidate(**overrides): + """One discovered candidate, with the dedup KEY the real one carries. + + ``candidate_value`` is built from ``IntentCandidate.key`` (t16), so the fake + has to have one — and two fakes differing only in a field must differ in it, + exactly as the real candidates do. + """ + base = { + "component": "com.netflix.ninja/.MainActivity", + "action": "android.intent.action.VIEW", + "categories": ("android.intent.category.DEFAULT",), + "scheme": "https", + "authority": "www.netflix.com", + "path": "/browse", + "path_kind": "LITERAL", + "likely_internal": False, + } + base.update(overrides) + base["key"] = ( + base["component"], + base["action"], + base["categories"], + base["scheme"], + base["authority"], + base["path"], + base["path_kind"], + ) + return SimpleNamespace(**base) + + +def _scan_result(candidates=(), *, status="fresh", package="com.netflix.ninja"): + return SimpleNamespace( + package=package, + status=status, + candidates=tuple(candidates), + entry_state="candidates" if candidates else "empty", + age_seconds=0.0, + truncated=False, + ) + + +class TestTheScanStep: + """The scan answers honestly, and a candidate prefills only what it can.""" + + async def test_no_manager_anywhere_is_reported_not_crashed(self, modules, tmp_path): + flow = _flow(modules, tmp_path) + flow.config_entry = SimpleNamespace(options={}, entry_id="e1", title="Shield") + flow.hass.data = {} # no runtime_data, no domain store + + result = await flow.async_step_intents_scan( + {INTENT_FIELD_SCAN_PACKAGE: "com.netflix.ninja"} + ) + + assert result["step_id"] == "intents_scan_result" + note = result["description_placeholders"]["scan_note"] + assert "no intent discovery" in note + assert "Nothing was registered" in note + + async def test_a_bad_package_never_reaches_the_manager(self, modules, tmp_path): + flow = _flow(modules, tmp_path) + scan = _FakeScan(_scan_result()) + flow.config_entry.runtime_data = SimpleNamespace(async_scan_intents=scan) + + result = await flow.async_step_intents_scan({INTENT_FIELD_SCAN_PACKAGE: "not one"}) + + assert result["errors"] == {"base": "invalid_intent_package"} + assert scan.packages == [] + + async def test_one_package_is_scanned_and_its_candidates_offered( + self, modules, tmp_path + ): + flow = _flow(modules, tmp_path) + scan = _FakeScan( + _scan_result( + [_candidate(), _candidate(path="/title.*", path_kind="GLOB")] + ) + ) + flow.config_entry.runtime_data = SimpleNamespace(async_scan_intents=scan) + + result = await flow.async_step_intents_scan( + {INTENT_FIELD_SCAN_PACKAGE: "com.netflix.ninja"} + ) + + assert scan.packages == ["com.netflix.ninja"], "ONE package per request" + note = result["description_placeholders"]["scan_note"] + assert "2 candidate intent(s)" in note + assert "Nothing has been registered yet" in note + + async def test_each_status_reads_differently(self, modules, tmp_path): + cases = { + "fresh_empty": "advertises no intents", + "adb_disabled": "switched OFF", + "adb_unavailable": "did not answer", + "unreadable": "did not answer", + "cached": "cache", + } + + for status, expected in cases.items(): + note = modules.flow.intent_scan_note( + _scan_result(status=status), "com.netflix.ninja" + ) + assert expected in note, status + + async def test_a_literal_candidate_prefills_a_real_url(self, modules, tmp_path): + candidate = _candidate() # https + authority + LITERAL /browse + flow = _flow(modules, tmp_path) + scan = _FakeScan(_scan_result([candidate])) + flow.config_entry.runtime_data = SimpleNamespace(async_scan_intents=scan) + await flow.async_step_intents_scan( + {INTENT_FIELD_SCAN_PACKAGE: "com.netflix.ninja"} + ) + + form = await flow.async_step_intents_scan_result( + {INTENT_FIELD_CANDIDATE: modules.flow.candidate_value(candidate)} + ) + + assert form["step_id"] == "intents_form" + assert form["type"] == "form" # the schema is rendered, not submitted + assert form["description_placeholders"]["intent_note"] == "new intent" + assert flow._intent_prefill[INTENT_FIELD_DATA] == "https://www.netflix.com/browse" + assert flow._intent_prefill[INTENT_FIELD_ACTIVITY] == candidate.component + assert flow._intent_prefill[INTENT_FIELD_ACTION] == candidate.action + + async def test_a_path_pattern_prefills_no_url_and_says_why(self, modules, tmp_path): + candidate = _candidate(path="/title.*", path_kind="GLOB") + flow = _flow(modules, tmp_path) + scan = _FakeScan(_scan_result([candidate])) + flow.config_entry.runtime_data = SimpleNamespace(async_scan_intents=scan) + await flow.async_step_intents_scan( + {INTENT_FIELD_SCAN_PACKAGE: "com.netflix.ninja"} + ) + + form = await flow.async_step_intents_scan_result( + {INTENT_FIELD_CANDIDATE: modules.flow.candidate_value(candidate)} + ) + + assert flow._intent_prefill[INTENT_FIELD_DATA] == "" + assert "pattern" in form["description_placeholders"]["intent_note"] + + async def test_an_internal_candidate_is_offered_and_marked(self, modules, tmp_path): + candidate = _candidate(action="com.netflix.action.DEBUG_FORCE_EXIT", likely_internal=True) + + assert "[internal-looking]" in modules.flow.candidate_label(candidate) + + flow = _flow(modules, tmp_path) + flow.config_entry.runtime_data = SimpleNamespace( + async_scan_intents=_FakeScan(_scan_result([candidate])) + ) + await flow.async_step_intents_scan( + {INTENT_FIELD_SCAN_PACKAGE: "com.netflix.ninja"} + ) + form = await flow.async_step_intents_scan_result( + {INTENT_FIELD_CANDIDATE: modules.flow.candidate_value(candidate)} + ) + + assert "internal hook" in form["description_placeholders"]["intent_note"] + + async def test_a_scan_that_cannot_run_registers_nothing(self, modules, tmp_path): + flow = _flow(modules, tmp_path) + flow.config_entry.runtime_data = SimpleNamespace( + async_scan_intents=_FakeScan(exc=RuntimeError("adb died")) + ) + + result = await flow.async_step_intents_scan( + {INTENT_FIELD_SCAN_PACKAGE: "com.netflix.ninja"} + ) + + assert result["step_id"] == "intents_scan_result" + assert CONF_REGISTERED_INTENTS not in flow._options_dict + + +class TestTheSeed: + """`registered_intents:` in configuration.yaml — validated, or named and dropped.""" + + def test_a_valid_seed_produces_the_form_s_row(self, modules): + seeded = modules.flow._seeded_intent_rows([NETFLIX_INTENT]) + + assert len(seeded) == 1 + # …and it is the SAME row the form would have built from those values + typed, problems = modules.flow.intent_row_from_form( + { + INTENT_FIELD_NAME: NETFLIX_INTENT["name"], + INTENT_FIELD_PACKAGE: NETFLIX_INTENT["package"], + INTENT_FIELD_ACTIVITY: NETFLIX_INTENT["activity"], + INTENT_FIELD_ACTION: NETFLIX_INTENT["action"], + INTENT_FIELD_CATEGORIES: ", ".join(NETFLIX_INTENT["categories"]), + INTENT_FIELD_DATA: NETFLIX_INTENT["data"], + INTENT_FIELD_DECLARED: "id:string:required", + }, + existing_id=NETFLIX_INTENT["id"], + taken_ids=set(), + taken_names=set(), + ) + assert problems == [] + assert seeded[0] == typed + + def test_a_broken_row_is_dropped_with_a_warning(self, modules, caplog): + rows = [ + NETFLIX_INTENT, + {"name": "No target", "package": "com.x"}, + {"name": "Bad package", "package": "has spaces", "action": "a.B"}, + "not a mapping", + ] + + with caplog.at_level("WARNING"): + seeded = modules.flow._seeded_intent_rows(rows) + + assert [row["id"] for row in seeded] == [NETFLIX_INTENT["id"]] + assert len(caplog.records) == 3, [r.message for r in caplog.records] + assert all("registered_intents row" in r.message for r in caplog.records) + + def test_an_explicit_id_is_kept_and_a_duplicate_is_skipped(self, modules, caplog): + with caplog.at_level("WARNING"): + seeded = modules.flow._seeded_intent_rows( + [NETFLIX_INTENT, {**NETFLIX_INTENT, "name": "Duplicate"}] + ) + + assert [row["id"] for row in seeded] == [NETFLIX_INTENT["id"]] + assert "repeats the id" in caplog.records[0].message + + def test_a_non_list_block_is_ignored_with_a_warning(self, modules, caplog): + with caplog.at_level("WARNING"): + seeded = modules.flow._seeded_intent_rows("nope") + + assert seeded == [] + assert "must be a LIST" in caplog.records[0].message + + +# ── 4. the buttons ───────────────────────────────────────────────────────── + + +class TestTheButtons: + """One entity per registration, sending the defaults, failing loudly.""" + + async def _setup(self, modules, options): + entry = SimpleNamespace( + entry_id="e1", options=options, title="Shield", runtime_data=None + ) + added: list = [] + button = importlib.import_module("nsh.button") + await button.async_setup_entry(None, entry, added.extend) + return entry, added + + async def test_one_button_per_registered_intent(self, modules): + _entry, added = await self._setup( + modules, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]} + ) + + assert len(added) == 1 + assert added[0]._attr_name == NETFLIX_INTENT["name"] + assert added[0]._attr_unique_id.endswith("_intent_netflix_title") + + async def test_no_registration_means_no_button(self, modules): + _entry, added = await self._setup(modules, {}) + + assert added == [] + + async def test_a_rename_keeps_the_entity_identity(self, modules): + _entry, added = await self._setup( + modules, {CONF_REGISTERED_INTENTS: [{**NETFLIX_INTENT, "name": "Renamed"}]} + ) + + assert added[0]._attr_name == "Renamed" + assert added[0]._attr_unique_id.endswith("_intent_netflix_title") + + async def test_the_button_shows_what_it_will_send(self, modules): + _entry, added = await self._setup( + modules, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]} + ) + + attrs = added[0].extra_state_attributes + + assert attrs["intent_id"] == "netflix_title" + assert attrs["activity"] == NETFLIX_INTENT["activity"] + assert attrs["fields"] == NETFLIX_INTENT["fields"] + + async def test_pressing_sends_the_declared_defaults(self, modules): + entry, added = await self._setup( + modules, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]} + ) + poller = _RecordingPoller() + entry.runtime_data = _manager(poller) + button = added[0] + button.entity_id = "button.shield_netflix_open_a_title" + + # with no default for the required field, the press is an honest error + import homeassistant.exceptions as ha_exceptions + + with pytest.raises(ha_exceptions.ServiceValidationError) as caught: + await button.async_press() + assert caught.value.translation_key == "missing_intent_field" + assert poller.commands == [], "nothing was sent" + + # give the field a default and the same press works + intent = {**NETFLIX_INTENT, "fields": [{"key": "id", "type": "string", "default": "7"}]} + _entry, added = await self._setup(modules, {CONF_REGISTERED_INTENTS: [intent]}) + added[0]._manager = _manager(poller) + added[0].entity_id = "button.shield_netflix_open_a_title" + + await added[0].async_press() + + assert poller.commands == [ + modules.media.intent_am_start_command(intent, {"id": "7"}) + ] + + async def test_a_button_with_no_manager_fails_loudly(self, modules): + """A press that cannot reach the Shield RAISES — it never no-ops. + + The field gets a default here on purpose: that leaves ADB as the only + thing wrong, so the assertion can pin WHICH error the user sees. + """ + _entry, added = await self._setup( + modules, + { + CONF_REGISTERED_INTENTS: [ + { + **NETFLIX_INTENT, + "fields": [{"key": "id", "type": "string", "default": "7"}], + } + ] + }, + ) + import homeassistant.exceptions as ha_exceptions + + with pytest.raises(ha_exceptions.ServiceValidationError) as caught: + await added[0].async_press() + + assert caught.value.translation_key == "intents_need_adb" + + +# ── 5. the action ────────────────────────────────────────────────────────── + + +class _Call: + """The shape `ServiceCall` has for the handler (data + hass).""" + + def __init__(self, data, hass=None): + self.data = data + self.hass = hass + + +class TestTheAction: + """`nvidia_shield.send_intent`: the lookup, the variables, the response.""" + + def _wire(self, modules, options, poller=None, entity_id="media_player.shield"): + manager = _manager(poller or _RecordingPoller()) + manager.entry = SimpleNamespace(options=options) + modules.media._INTENT_TARGETS[entity_id] = manager + return manager + + def _cleanup(self, modules, entity_id="media_player.shield"): + modules.media._INTENT_TARGETS.pop(entity_id, None) + + async def test_the_output_comes_back_under_the_response_key(self, modules): + poller = _RecordingPoller() + self._wire(modules, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}, poller) + try: + result = await modules.media._async_send_intent_service( + _Call( + { + "intent": NETFLIX_INTENT["name"], + "variables": {"id": "1"}, + "entity_id": "media_player.shield", + } + ) + ) + finally: + self._cleanup(modules) + + assert set(result) == {INTENT_RESPONSE_KEY} + assert result[INTENT_RESPONSE_KEY].startswith("Starting: Intent") + assert poller.commands == [ + modules.media.intent_am_start_command(NETFLIX_INTENT, {"id": "1"}) + ] + + async def test_an_unknown_intent_lists_the_known_names(self, modules): + import homeassistant.exceptions as ha_exceptions + + self._wire(modules, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}) + try: + with pytest.raises(ha_exceptions.ServiceValidationError) as caught: + await modules.media._async_send_intent_service( + _Call({"intent": "nope", "entity_id": "media_player.shield"}) + ) + finally: + self._cleanup(modules) + + assert caught.value.translation_key == "unknown_intent" + assert caught.value.translation_placeholders["known"] == NETFLIX_INTENT["name"] + + async def test_no_registered_intents_yet_is_said_plainly(self, modules): + import homeassistant.exceptions as ha_exceptions + + self._wire(modules, {}) + try: + with pytest.raises(ha_exceptions.ServiceValidationError) as caught: + await modules.media._async_send_intent_service( + _Call({"intent": "anything", "entity_id": "media_player.shield"}) + ) + finally: + self._cleanup(modules) + + assert caught.value.translation_placeholders["known"] == "none yet" + + async def test_variables_must_be_a_mapping(self, modules): + import homeassistant.exceptions as ha_exceptions + + self._wire(modules, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}) + try: + with pytest.raises(ha_exceptions.ServiceValidationError) as caught: + await modules.media._async_send_intent_service( + _Call( + { + "intent": NETFLIX_INTENT["name"], + "variables": "id=1", + "entity_id": "media_player.shield", + } + ) + ) + finally: + self._cleanup(modules) + + assert caught.value.translation_key == "invalid_intent_variables" + + async def test_the_target_errors_are_the_shared_ones(self, modules): + import homeassistant.exceptions as ha_exceptions + + self._wire(modules, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}) + try: + # nothing selected + with pytest.raises(ha_exceptions.ServiceValidationError) as caught: + await modules.media._async_send_intent_service( + _Call({"intent": NETFLIX_INTENT["name"]}) + ) + assert caught.value.translation_key == "no_target" + assert "send_intent" in caught.value.translation_placeholders["service"] + + # an entity that does not participate in THIS action + with pytest.raises(ha_exceptions.ServiceValidationError) as caught: + await modules.media._async_send_intent_service( + _Call({"intent": "x", "entity_id": "media_player.other"}) + ) + assert caught.value.translation_key == "unsupported_target" + finally: + self._cleanup(modules) + + async def test_the_key_a_template_reads_is_not_adb_response(self, modules): + from nsh.const import INTENT_RESPONSE_KEY + + assert INTENT_RESPONSE_KEY == "intent_response" + + +class TestTheServiceDescription: + """The dropdown the frontend renders comes from the runtime description.""" + + def test_the_intent_field_is_a_dropdown_of_registered_names(self, modules): + description = modules.media.send_intent_service_description( + ["Netflix: open a title", "TiviMate: channel"] + ) + + intent_field = description["fields"]["intent"] + assert intent_field["required"] is True + selector = intent_field["selector"]["select"] + assert sorted(selector["options"]) == [ + "Netflix: open a title", + "TiviMate: channel", + ] + assert selector["custom_value"] is True + assert selector["mode"] == "dropdown" + + def test_the_description_declares_the_target_and_the_variables_field(self, modules): + description = modules.media.send_intent_service_description([]) + + assert description["target"] == {"entity": [{"domain": "media_player"}]} + assert "variables" in description["fields"] + assert description["fields"]["variables"]["selector"] == {"object": {}} + assert description["fields"]["intent"]["selector"]["select"]["options"] == [] + + def test_the_strings_files_carry_the_same_service_and_exception_keys(self): + """The runtime description and the translation cannot disagree.""" + import json + + base = Path(__file__).resolve().parent.parent / "custom_components" + shield = base / "nvidia_shield" + for name in ("strings.json", "translations/en.json"): + data = json.loads((shield / name).read_text(encoding="utf-8")) + assert set(data["services"]["send_intent"]["fields"]) == { + "intent", + "variables", + "entity_id", + } + for key in ( + "unknown_intent", + "missing_intent_field", + "unknown_intent_variable", + "invalid_intent_field", + "invalid_intent_variables", + "unresolved_intent_placeholder", + "intents_need_adb", + "intent_no_answer", + "intent_failed", + ): + assert key in data["exceptions"], (name, key) + + +class TestNothingRunsByItself: + """The plan's non-negotiable: registration is inert until someone calls it.""" + + async def test_loading_the_options_screen_touches_no_device(self, modules, tmp_path): + poller = _RecordingPoller() + flow = _flow(modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}) + flow.config_entry.runtime_data = SimpleNamespace( + async_scan_intents=_FakeScan(_scan_result()), + adb_poller=poller, + ) + + for step in ("intents", "intents_edit", "intents_remove", "intents_scan"): + result = await getattr(flow, f"async_step_{step}")(None) + assert result["type"] in ("form", "menu") + + assert poller.commands == [], "rendering a screen never runs anything" + + async def test_a_scan_never_registers_anything(self, modules, tmp_path): + flow = _flow(modules, tmp_path) + flow.config_entry.runtime_data = SimpleNamespace( + async_scan_intents=_FakeScan(_scan_result([_candidate()])) + ) + + await flow.async_step_intents_scan( + {INTENT_FIELD_SCAN_PACKAGE: "com.netflix.ninja"} + ) + + assert flow._options_dict.get(CONF_REGISTERED_INTENTS) in (None, []) + + async def test_a_registered_intent_is_inert_until_pressed(self, modules): + _entry, added = await self._setup_intents(modules) + + assert added, "the button exists" + assert added[0]._manager is None or added[0]._manager.adb_poller is not None + + async def _setup_intents(self, modules): + entry = SimpleNamespace( + entry_id="e1", + options={CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}, + title="Shield", + runtime_data=None, + ) + added: list = [] + button = importlib.import_module("nsh.button") + await button.async_setup_entry(None, entry, added.extend) + return entry, added + + +def test_the_text_helpers_do_not_run_in_a_thread(): + """A guard against a future rewrite reaching for `eval`/`exec` (t17).""" + source = ( + Path(__file__).resolve().parent.parent + / "custom_components" + / "nvidia_shield" + / "media_player.py" + ).read_text(encoding="utf-8") + + assert "eval(" not in source + assert "exec(" not in source + assert ".format(" not in source.split("def intent_am_start_command")[1].split("\ndef ")[0] + + +def test_asyncio_is_still_needed_by_the_module_under_test(): + assert asyncio is not None # keeps the import honest if the tests shrink