diff --git a/docs/INTENTS_PLAN.md b/docs/INTENTS_PLAN.md new file mode 100644 index 0000000..f4dbad1 --- /dev/null +++ b/docs/INTENTS_PLAN.md @@ -0,0 +1,194 @@ +# Intent catalogue and registered actions (plan) + +Scope: let the user discover the Android **intents** an installed app advertises, register +the ones worth having, and fire them from Home Assistant — one button per registered +intent, plus a `send_intent` action for the ones that need inputs. + +Status: design agreed in principle by the user (2026-10-12). Not built. Companion docs: +`docs/MVP_PLAN.md` (architecture), `docs/RESEARCH_SHIELD.md` (device captures), +`docs/RESEARCH_IDEAS.md` (adjacent ideas). + +**Decisions locked (user):** + +- Registered **buttons**, set up inside our options menu, plus a **`send_intent`** action. + Verbatim: *"The registered buttons, pre-setup within our menu options and a send_intent + command seem the best method here. Less messy than the current adb command method of + doing that."* +- Discovery is **explicit** (the user scans an app), never an automatic sweep. +- YAML stays **seed-only** (the standing ruling: we load it only when no UI config exists). + +--- + +## 1. Where we are today + +| Surface | State | +|---|---| +| `media_player.play_media` with a URL | Works — sends a `VIEW` intent through the ADB path | +| `nvidia_shield.adb_command` | Works but is a raw escape hatch, and until t14 cannot even be aimed at the Shield from the UI | +| Intent **discovery** | Does not exist | +| Intent **registration** | Does not exist | +| `ha_adb_common/intent.py` | **Not Android intents** — it is `PlaybackIntent`, the optimistic-UI dataclass. The name collision is a readability trap worth fixing (§7.4) | + +So the user's memory of "scanning the available commands for ADB between applications" is +a real gap, not something built and forgotten. + +### What the device actually exposes (observed, one Shield, Android 11) + +From the user's capture of `pm dump com.netflix.ninja` on 2026-10-12: + +- the output is ~**2241 lines** and is a *whole* dumpsys — `DUMP OF SERVICE package:`, + then `activity`, `meminfo`, `procstats`, `usagestats`, `batterystats`; +- the package section begins with an **`Activity Resolver Table:`** listing, per + component/filter, the `Action:`, `Category:`, `Scheme:`, `Authority:` and `Path:` lines, + followed by `Receiver Resolver Table:` and `Service Resolver Table:`; +- Netflix's table contains real, usable entries (`cast:`, `http`/`https` with + `www.netflix.com` path globs, `nflx:`, `netflix:`, `MAIN` + `LEANBACK_LAUNCHER`) and a + large amount of internal noise (`com.netflix.action.DEBUG_*`, `androidx.*`, + `com.google.firebase.*`, `ConstraintProxy*`). + +Everything in §2 depends on that shape. It is **observed on one firmware**, so the parser +must degrade honestly rather than assume. + +--- + +## 2. Discovery + +Source of truth: the app's **activity resolver table**. + +Filter **on the device** — on this Shield an unfiltered `pm dump` ships 2241 lines over +ADB for one app: + +```sh +pm dump | sed -n '/Activity Resolver Table/,/Service Resolver Table/p' +``` + +with a bounded `grep -A` fallback when the device's `sed`/toolbox cannot do ranges. Keep +the transfer to the resolver section; the rest of the dump is never needed. + +Rules: + +- **One package at a time, on demand**, triggered by the user from the Intents sub-screen. + Never a sweep of every installed app (that is 20 × ~2241 lines on this device). +- **Cached** per package with a TTL, in the same spirit as the app-label cache; a scan + that *ran* and found nothing is a real answer, a scan whose transport failed is not + (the lesson from the discovery-cache defect). +- **Rank, do not hide.** Flag internal-looking filters (`DEBUG_*`, `androidx.*`, + `com.google.firebase.*`) rather than deleting them — the user decides what is worth + registering, and we do not get to guess. + +A cheaper action-first query also exists (`cmd package query-activities --brief -a …`). +**Not verified on this device**; treat as a possible optimisation, not the primary path. + +Candidates are deduplicated on (component, action, categories, scheme/authority/path). + +--- + +## 3. Data model — a registered intent + +```yaml +registered_intents: + - id: netflix_title # slug; also the button's key + name: Netflix — open title # what the user sees + package: com.netflix.ninja + activity: .MainActivity # either a component… + action: android.intent.action.VIEW # …or an action (+ categories) + categories: [android.intent.category.DEFAULT] + data: "https://www.netflix.com/watch/{id}" # URI template, optional + fields: # declared inputs, with defaults + - {key: id, type: string, required: true} +``` + +Mapping to `am start` (through the existing ADB path): + +| Declared | `am start` | +|---|---| +| component | `-n com.netflix.ninja/.MainActivity` | +| action | `-a android.intent.action.VIEW` | +| categories | `-c …` (repeated) | +| data template | `-d ` | +| `type: string` | `--es key value` | +| `type: int` | `--ei key value` | +| `type: bool` | `--ez key value` | + +Notes that matter: + +- **No eval, no shell interpolation.** `{placeholder}` substitution is plain string + replacement, and every value is `shlex.quote`d before it reaches the device. +- A missing **required** field is a `ServiceValidationError` naming the field. +- An optional `raw` field type passes extras verbatim for advanced cases, documented as + **unvalidated** — the escape hatch, not the default. + +--- + +## 4. Surfaces + +1. **One button entity per registered intent** — `button.`, pressing it runs the + intent with the declared defaults. This is the "register an action for each one" the + user asked for, and it is the whole point for no-input intents. +2. **`nvidia_shield.send_intent`** — `intent: ` (a dropdown of registered intents) + plus `variables: {…}` supplying that intent's declared fields. This is how per-intent + inputs arrive from automations, scripts and dashboards; it mirrors `script.turn_on` + with variables because HA offers no clean way to register a service per config entry. + The action **returns the device's `am start` output**, so a failure is visible. +3. **Intents sub-screen** in the options flow, the same idiom as the Apps screen: + *Add an intent* (pick package → pick a discovered candidate or enter one manually → + name it → declare its fields), *Edit*, *Remove*, *Scan an app's intents*. +4. **YAML seeding** — a `registered_intents:` block, honoured only when the UI holds + nothing (seed-only ruling). + +--- + +## 5. Failure modes and honesty + +- `am start` failures (unresolved intent, permission denied, activity gone after an app + update) **surface the device's own message** and raise; never a silent success. +- **ADB is required.** The protocol transport cannot send intents; when ADB is down the + error says so instead of failing obscurely. +- An unknown intent name raises, listing the known names. +- An intent that the app rejects is a **config** problem, and the docs say so plainly. +- Nothing registered ever runs by itself, and discovery never auto-registers anything. + +--- + +## 6. Costs + +- Discovery: one filtered dump per scanned package, cached. +- Invocation: one ADB shell per press. +- Storage: registered intents are ordinary options data. + +--- + +## 7. Open questions + +1. Does `send_intent` returning a response need `SupportsResponse.OPTIONAL` on our HA + floor? Verify against the real installed HA before building on it. +2. Typed fields (string/int/bool) for v1, or string-plus-raw only? Recommendation: typed, + since that is what makes the dropdown-plus-variables model pleasant. +3. Broadcasts/receivers (`am broadcast`) — the tables are right there — are **out of scope + for v1**; activities and actions only. +4. Rename `ha_adb_common/intent.py` → `playback_intent.py` so the new `intents.py` is not + confused with it? Small churn, real clarity gain. +5. Should discovery be exposed as a service as well as a UI screen? Recommendation: UI + only in v1. +6. `play_media(media_content_type="intent")`? Recommendation: **no** — the button and the + action are clearer, and play_media already carries the URL/VIEW case. + +--- + +## 8. Proposed tickets + +| Unit | Contents | +|---|---| +| Discovery | `ha_adb_common/intents.py` + the filtered on-device dump + parser + cache + tests, with a fixture cut from the user's real Netflix capture | +| Registration & invocation | Intents sub-screen, per-intent fields, button entities, `send_intent` action, mapping, docs, strings, tests | +| Review | Adversarial verification of both, including a rejected-intent and an ADB-down path | + +--- + +## 9. Non-goals + +- No automatic intent execution or auto-registration from discovery. +- No arbitrary shell passthrough in `send_intent` (`adb_command` remains that door). +- No broadcasts/receivers in v1. +- No extras beyond string/int/bool/raw. +- Not a replacement for the app source list or `play_media`.