Skip to content

ADR-0031: Single deep-link router with whitelist grammar

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.kt owns the contract. Producers build links only through its builders (tab, transactionDetail, manual); consumers parse only through DeepLinks.parse(uriString), which takes a String so the grammar is unit-tested on the JVM. Routes.fromDeepLink stays as the thin android.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/manual with 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 the common/Currency tables), 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's SavedStateHandle and 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 MainActivity explicitly. 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 (permanently STATE_INACTIVE) that opens the manual() link, wrapped in unlockAndRun when the keyguard is up, using startActivityAndCollapse (API-34 Intent overload, PendingIntent fallback 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 RoutesTest pins the parity so refactors cannot silently change link meaning.
  • Third-party prefilled captures become possible without any permission or network surface: another app launches MainActivity explicitly 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.