ADR-0031: Single deep-link router with whitelist grammar
- Status: Accepted
- Date: 2026-08-25
- Deciders: maintainers
- Related: ADR-0009, ADR-0012, ADR-0015, ADR-0016
Context
The autobudget:// scheme was introduced ad hoc by the ADR-0016
captured-notification tap (autobudget://transaction/{id}), parsed inline in
Routes.fromDeepLink; top-level tab links exist for docs screenshot tooling.
More entry points were coming: a Quick Settings "Add transaction" tile now,
and later home-screen widgets plus third-party apps that want to open a
prefilled new-transaction form.
Hand-assembling URIs against an undocumented grammar invites drift between
producers and consumer, and nothing answered what untrusted input may
contain. That matters because MainActivity is exported as the launcher —
any app can already fire an explicit-component intent carrying a data URI at
it.
Decision
- One owner:
ui/navigation/DeepLinks.ktowns the contract. Producers build links only through its builders (tab,transactionDetail,manual); consumers parse only throughDeepLinks.parse(uriString), which takes aStringso the grammar is unit-tested on the JVM.Routes.fromDeepLinkstays as the thinandroid.net.Uri→ route-string adapter and delegates to the parsed model. - Whitelist grammar
autobudget://{target}[/{segment}][?key=value…]: top-level tab hosts,autobudget://transaction/{id},autobudget://transaction/manualwith optional query params. Unknown host or segment and malformed required args return null — the link is silently ignored, so old installs drop future link shapes gracefully. Unknown query params are dropped; a malformed param value drops only that param and keeps the link. The full table lives in ui.md. - Prefill params on the manual target:
amount(positive integer minor units, ADR-0009),currency(uppercased, must exist in thecommon/Currencytables),merchant/note/source(length-capped text; over-limit values are dropped, never truncated). All optional. Params carry display data only — never route names, flags, or control flow. They travel as optional navigation query args into the detail ViewModel'sSavedStateHandleand seed the otherwise-empty manual form. - Security posture: no intent-filters for the scheme and no exported
components beyond the launcher activity — entry points target
MainActivityexplicitly. Everything a link opens remains behind the ADR-0012 lock screen. External input can only select from the fixed target set and fill validated text fields. - First consumer ships with this ADR:
qs/AddTransactionTileService— an action-only Quick Settings tile (permanentlySTATE_INACTIVE) that opens themanual()link, wrapped inunlockAndRunwhen the keyguard is up, usingstartActivityAndCollapse(API-34Intentoverload,PendingIntentfallback below).
Consequences
- Adding an entry point (widget RemoteViews, third-party trigger, tooling) costs one builder call plus its own wiring and tests; the router and NavHost stay untouched.
- Legacy producer/consumer behavior is preserved bit-for-bit (numeric ids,
tab links ignoring extra segments); the instrumented
RoutesTestpins the parity so refactors cannot silently change link meaning. - Third-party prefilled captures become possible without any permission or
network surface: another app launches
MainActivityexplicitly with a well-formed URI. The user-facing grammar gets published when that use case ships.
Alternatives considered
- Keep parsing per feature / inside
Routes— rejected: grammar drifts and needs Robolectric to test. https://app-links with an intent-filter — rejected: Digital Asset Links verification requires network (against ADR-0015) and widens reachability for no current need.- Typed extras Bundle instead of URIs — rejected: unversioned, not visible as one inspectable string, and awkward for PendingIntent-centric producers (tiles, widgets, notifications).
- Android App Functions / App Actions wrappers — deferred until a concrete assistant integration exists; they would layer on the same router.