Current architecture and code map
July 29, 2026 · View on GitHub
Use this page to find the owner of an extension bug. Current release gaps are listed separately in Status and release gates.
Start with the main path
user action or Worker
-> feature repository creates a typed request
-> ExtensionRuntime invokes one Hook
-> built-in handler or external transport
-> feature repository validates the typed result
-> Room, UI, or player
The runtime completes one call. The feature repository decides what the result means and whether it may change product state. Keeping these jobs separate prevents a generic Hook runner from writing search, EPG, channel, or playback data without feature-specific checks.
There are two implementations:
- A built-in extension runs its handler in the M3UAndroid process. Emby/Jellyfin uses this path.
- An external extension runs in another process through
AndroidBoundExtensionTransport.
Both use the same HookSpec<Request, Result> and runtime policy.
ExtensionContractCatalog is the host's single contract list. Each entry binds one supported
Hook/schema pair to its official typed serializers and base capabilities. Built-in registration
and host calls must use that exact HookSpec; external manifests are checked against the same
entries and registration rules before a transport is admitted.
Ownership by layer
| Owner | Responsibility | Start here |
|---|---|---|
| API contract | Extension identity, manifest, settings, Hook request/result, wire fields, supported contract catalog | :extension:api, ExtensionContractCatalog |
| Runtime | Registration, API/schema negotiation, per-Hook capabilities, payload limits, per-extension and host-wide invocation admission caps, one invocation deadline, cancellation, health | ExtensionRuntime |
| Android transport | Service discovery, identity, binding, handshake, file-backed JSON payloads over ParcelFileDescriptor, Binder death | :extension:transport-android |
| External SDK | Decode a call and run the registered typed handler | TypedExtensionService |
| Plugin lifecycle | Trust, certificate pin, enablement, grants, reconnect, reauthorization, diagnostics | ExtensionPluginRepositoryImpl |
| Settings lifecycle | Rendered schema, saved values, secret handles, and edit authorization | ExtensionSettingsRepositoryImpl |
| Network scope | Choose approved origins and credentials for one external Hook call | ExtensionHookBrokerScopeProvider |
| Network execution | Validate scope, URL, redirect, values, size, and timeout; then send HTTP | HostNetworkBrokerImpl |
| Provider flow | Discover, validate, refresh, playback resolve, and session close | SubscriptionProviderRepositoryImpl |
| Result application | Validate ownership and write host data, or map a result to UI/player | data/extension, data/repository/extension |
| Background tasks | Reconcile periodic declarations and invoke the task Hook from WorkManager | ExtensionBackgroundTaskScheduler, ExtensionBackgroundTaskWorker |
What happens during one Hook call
- A feature repository chooses a
HookSpec, extension ID, and request. - The runtime confirms that the extension is enabled and declares that Hook.
- The runtime checks API and Hook schema versions.
CapabilityPolicycomputes the user's grants. The runtime keeps only capabilities declared by this Hook. Capabilities declared by another Hook never enter the call.- The runtime starts one deadline before request preparation, then applies payload limits and waits for both per-extension and host-wide invocation admission within that deadline.
- If an external Hook declares and receives
network, the broker scope provider may open one short-lived scope. The serialized external envelope carries a host-computed upper bound on the remaining invocation budget, reduced again for transport queueing before dispatch. - A built-in handler runs directly, or the external request crosses the Android transport.
- The runtime decodes and validates the result before the same deadline, closes the broker scope, and records health.
- The feature repository checks ownership and applies the result.
A Hook does not receive a fresh timeout after preparation or while waiting in a queue. Preparation, admission, handler or transport execution, and response validation all consume the same invocation deadline. An external broker bridge derives that deadline from the remaining budget in the envelope. Every broker request in the invocation shares it and also consumes cumulative limits for request count, encoded request bytes, and encoded response bytes. Starting another broker request does not reset any of those limits. The runtime's host-side deadline remains authoritative.
A missing broker scope does not grant a fallback network path. The Hook can still return an offline result, but broker operations fail.
How network scope is chosen
The external broker supports provider Validate/Refresh/Resolve/Close, settings, search, metadata,
EPG, and background tasks. Provider Discover is always offline.
| Request | Scope source |
|---|---|
Provider Validate | Authentication scope created from the submitted provider origin. |
Provider Refresh, ResolvePlayback, ClosePlayback | Account scope created by the provider repository. |
Search, metadata, or EPG with account + credential | Account scope created from the matching stored provider account. |
| Settings, background, or search/metadata/EPG without an account | Hook scope created from approved manifest and setting origins. |
For a general Hook scope, the host combines:
- fixed
manifest.networkOriginsthat were approved for the trusted extension; and - current text settings marked
networkOriginthat the user explicitly saved.
The trust store keeps only approved fixed origins. Reconnecting an extension does not approve a new origin. A certificate repin keeps the intersection of old and current origins. A network-origin setting has no default; saving it grants the current value, clearing it revokes the value, and a settings schema change requires another save.
Every scope binds the external principal, Hook, approved origins, and a short lifetime. Account
scopes also bind the account. The scope closes after normal return, failure, timeout, or
cancellation. HostNetworkBrokerImpl checks the first URL and every redirect against the exact
scheme, host, and port.
Credential handles enter a scope only when the current Hook declares credential.read and the user
approved it. The broker resolves SecretReference and ContextReference only while constructing a
request. It never serializes their resolved value directly back to the extension.
Provider authentication and refresh
External provider authentication uses a separate one-time flow:
Validate Hook
-> broker.authenticate sends the login exchange
-> broker captures the credential and selected account fields
-> extension receives a one-time receipt
-> provider repository consumes the receipt
-> vault stores encrypted credential material
The extension does not receive the login response body. The built-in Emby/Jellyfin implementation
is trusted host code, so it returns ProviderValidationEvidence.TrustedDirect. An external provider
must return ProviderValidationEvidence.HostBrokerReceipt.
Refresh then follows the normal product path:
ProviderWorker or user refresh
-> SubscriptionProviderRepositoryImpl
-> SubscriptionHookSpecs.Refresh
-> ExtensionRuntime
-> provider handler
-> SubscriptionProviderImporter
-> Room
-> metadata and EPG contribution Hooks
The importer updates only the current account and preserves host-owned local channel state. An
external provider uses the same repository and importer as Emby/Jellyfin; only the handler call
crosses Android IPC. Phone and TV build their new-subscription choices from selectable descriptor
variants. A non-selectable variant remains valid for an existing account but is not offered for a
new subscription. Every new or restored provider playlist is stored as DataSource.Provider;
the older Emby/Jellyfin source values are decode-and-migrate inputs only.
The broker prevents direct host-side credential disclosure. It cannot stop a malicious extension from colluding with an origin that the user approved. That remaining threat is one reason external extensions stay behind the developer switch.
Background task path
An extension declares periodic jobs in manifest.backgroundTasks.
enable, reauthorize, or restore extension
-> ExtensionBackgroundTaskScheduler.reconcile
-> WorkManager stores or updates periodic work
-> ExtensionBackgroundTaskWorker restores enabled plugins
-> ExtensionRuntime invokes HostHookSpecs.BackgroundTask
The scheduler removes stale declarations and cancels all jobs when the extension is disabled or
loses a required capability. A declaration with requiresNetwork = true receives a connected
network constraint. The Worker retries only recoverable failures and stops after the bounded retry
count.
External extension lifecycle
discover service
-> inspect manifest and identity; issue a short-lived review token
-> user approves identity, capabilities, and fixed origins
-> repository consumes that token and records trust
-> transport registers with the runtime
Enable and reauthorization must consume the token created for the details shown to the user. The token is single-use, expires after five minutes, and is bound to the discovered service and certificate. If it is missing, expired, or belongs to different details, the user must review the extension again.
Settings use the same boundary at field level. Rendering a configuration issues a short-lived, single-use edit token for each field. Saving succeeds only against that displayed section schema; if the schema changed, the host rejects the edit and reloads the form.
Keep these states separate:
- Discovered: the service is visible to the host.
- Enabled: the user allows calls and required grants are present.
- Registered: this host process currently has a working transport.
Each registration receives a new opaque lease. Dynamic settings, schema revalidation, broker credentials, and broker origins are usable only while that exact lease is current. Reconnect does not transfer them to the replacement registration: the old settings session is suspended and the new registration must activate and verify its own session. If activation fails, the repository removes the transport, runtime registration, and active Android identity together.
After process restart, the repository restores trusted, enabled extensions. After an update or Binder disconnect, a restore or plugin-list refresh rebuilds registration. The runtime does not own Android Service lifecycle.
Rules that should remain true
- The runtime does not write Room and does not update UI.
- A result importer changes data only inside the current request and owner scope.
- One extension's failure does not remove another extension's data or its own last valid result.
- The plugin repository owns trust, enablement, and grants.
- The vault owns secrets. Contracts carry opaque handles and one-time receipts.
Before editing, continue with Change by task.