195 lines
8.6 KiB
Markdown
195 lines
8.6 KiB
Markdown
# 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 <pkg> | 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 <resolved>` |
|
||
| `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.<name>`, 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: <name>` (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`.
|