Deep Link Integration
Design and rollout plan for a graph-native, visitor-driven deep link layer, with BestBuds as first consumer.
- Owner Reaktor framework
- Audience framework + app teams
- Status design, unimplemented
- Scope Android · iOS · Web · Desktop
1. Executive summary
Reaktor already owns everything a deep link layer needs: RoutePattern compiles /{param} templates into a regex with named groups; RouteNode is a graph-attached destination; NavigationCapability drives an ObservableStack<BackStackEntry>; WebNavigationBridge already proves that an external URL can be resolved against the graph and pushed.
What is missing is the platform-agnostic glue. This document proposes a single DeepLinkResolver in commonMain that uses visitors over the graph to (a) discover all reachable routes including those nested in ContainerNode children, (b) plan a synthetic back stack so a link to /chats/abc still leaves /home and /chats underneath it, (c) run authorization guards declared as capabilities on the route or its ancestor graphs, and (d) defer the link until auth completes when needed.
Each platform supplies a thin bridge that converts its native entry point (Android Intent, iOS NSURL / NSUserActivity, browser window.location, JVM CLI args) into a string URI and hands it to the resolver. BestBuds adds an intent-filter, a CFBundleURLTypes entry, and a Universal Links / App Links pair served from the existing Cloudflare worker.
2. Current state
What is already in place, what is missing, and where the partial work lives.
2.1 What works
| Piece | File | Notes |
|---|---|---|
RoutePattern ready | reaktor-io/.../network/RoutePattern.kt | Parses /{name} templates into regex + paramNames. Already supports getParams(MatchResult) and fill(params) round-trip. |
RouteNode + NavigationEdge ready | reaktor-graph-runtime/.../core/node/RouteNode.kt | Each screen is a node with a pattern; navigation between two is a typed edge with a Payload. |
NavigationCapability ready | reaktor-graph-runtime/.../navigation/NavigationCapability.kt | Push/Replace/Pop/Return mutate ObservableStack. Cross-graph forwards bubble up through parentGraph and resolve via findContainerForGraph. |
Visitor + selectors ready | reaktor-graph-runtime/.../visitor/Visitor.kt | StructuralSelector descends into ContainerNode.graphs; RoutingSelector filters to RouteNode + ControllerNode; both reusable. |
WebNavigationBridge prototype | reaktor-graph/src/jsMain/.../WebNavigationBridge.kt | Builds a Map<RoutePattern, RouteNode> by recursing into containers, matches window.location.pathname, dispatches Push(sentinel.edge(routeNode), Payload(params)). Generalises into the new resolver. |
2.2 What's missing
| Gap | Where | Impact |
|---|---|---|
Android intent-filter missing | bestbuds/targets/appAndroid/src/main/AndroidManifest.xml — still only MAIN/LAUNCHER on 13 Sep 2026 | OS will not route any URL to the app. |
| iOS URL scheme & Universal Links missing | bestbuds/targets/appDarwin/iosApp/Info.plist — only Google OAuth scheme; BestBudsApplication.application(openURL:) hands every URL to GIDSignIn. | No custom or universal URL is ever delivered to the graph. |
| Desktop URI args missing | bestbuds/targets/reaktorDesktop/src/main/kotlin/Main.kt — fun main() ignores args | open bestbuds://... on macOS will not surface anywhere. |
.well-known association files missing | No assetlinks.json / apple-app-site-association served | Cannot upgrade to App Links / Universal Links — every link still asks "open in which app?" |
| Route-level auth guards missing | Decision happens inside StartBinding; a deep link to /chats/abc would render the chat regardless of session. | Privacy/UX hazard. No place to attach the guard today. |
Common DeepLinkResolver missing | The web bridge is the only thing that resembles one, and only on JS. | Each platform would otherwise reinvent matching, planning, and guard logic. |
3. Goals & non-goals
Goals
- One
DeepLinkResolverin commonMain, four thin platform bridges. - Build the route index from the live graph via a visitor — no parallel registry to maintain.
- Support custom scheme (
bestbuds://...) and Universal/App Links (https://bestbuds.app/...) with one implementation. - Synthesise a logical back stack so back from a deep link returns to the natural parent.
- Defer a link cleanly when the user must authenticate first.
- Round-trip: given a route + payload, produce a shareable URL.
- Emit a machine-readable schema of the URI surface (for docs, server, and AppLink autoVerify).
Non-goals
- Server-side rendering of deep linked content.
- Branch / AppsFlyer style attribution. The resolver is content-routing only; analytics tags can ride in query params.
- Reordering the existing
NavCommandsealed hierarchy. Deep links producePush/Replacecommands; the resolver does not introduce a new command kind. - Replacing
WebNavigationBridge's history sync. The bridge becomes the resolver's caller on JS — history sync stays put.
4. URI model
One canonical representation, multiple input syntaxes.
// commonMain — reaktor-graph-runtime/.../navigation/deeplink/DeepLink.kt
@JsExport
data class DeepLink(
val scheme: String, // "bestbuds" | "https" | ...
val host: String?, // null for custom-scheme paths
val path: String, // "/chats/abc123" (always leading /)
val query: Map<String, String> = emptyMap(),
val fragment: String? = null,
val source: Source = Source.Cold, // Cold launch, Foreground, NotificationTap, ShareSheet
) {
enum class Source { Cold, Foreground, NotificationTap, ShareSheet, Browser, Other }
companion object {
fun parse(uri: String): DeepLink? = /* RFC 3986 split */
}
}
Two accepted URI shapes converge to the same DeepLink:
bestbuds://chats/abc123?ref=push
scheme=bestbuds host=null path=/chats/abc123 query={ref:push}
https://bestbuds.app/chats/abc123?ref=push
scheme=https host=bestbuds.app path=/chats/abc123 query={ref:push}
Path matching ignores host once a configured allow-list passes. The allow-list lives in DeepLinkConfig:
@JsExport
data class DeepLinkConfig(
val customScheme: String = "bestbuds",
val universalHosts: Set<String> = setOf("bestbuds.app", "app.bestbuds.ai"),
val canonicalHost: String = "bestbuds.app", // for outgoing share URLs
val rejectUnknownHost: Boolean = true
)
5. Architecture
The resolver is a pure function of (uri, rootGraph) -> Resolution. Platform bridges decide when to call it; NavigationCapability executes the resulting plan.
┌────────────────────────── Platform bridge layer ──────────────────────────┐
│ AndroidDeepLinkBridge DarwinDeepLinkBridge DesktopDeepLinkBridge │
│ └─ onNewIntent(i) └─ application(openURL) └─ args[0] / open URL │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ WebNavigationBridge (already-extant JS) │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────┬───────────────────────────────────┘
▼
┌────────────────────────── commonMain — reaktor-graph ─────────────────────┐
│ DeepLink.parse(uri) │
│ │ │
│ ▼ │
│ DeepLinkResolver.resolve(link, rootGraph) │
│ │ │
│ ├────────────┬─────────────┬───────────────────┬─────────────────┐│
│ ▼ ▼ ▼ ▼ ▼│
│ RouteIndex RouteMatch BackStackPlanner GuardCollector ActionLookup
│ Visitor Visitor Visitor Visitor Visitor │
│ │ │ │ │ ││
│ └────────────┴─────────────┴────────┬──────────┴─────────────────┘│
│ ▼ │
│ Resolution │
│ / | \ │
│ Plan Gated Unknown │
│ │ │
│ ▼ │
│ rootGraph.dispatch(...) │
└─────────────────────────────────────────────────────────────────────────────┘
Module placement: the code belongs in reaktor-graph-runtime, alongside the rest of the navigation kernel. Graph, RouteNode and NavCommand are already there, and RoutePattern comes from reaktor-io, which the runtime already depends on. A separate module would re-export all of it for nothing.
reaktor-graph-runtime/src/commonMain/.../navigation/deeplink/
├── DeepLink.kt // URI value type + parser
├── DeepLinkConfig.kt // schemes, hosts, canonical
├── DeepLinkResolver.kt // public API
├── Resolution.kt // sealed Plan / Gated / Unknown / Action
├── DeepLinkGuard.kt // capability + sealed GuardResult
├── DeepLinkAction.kt // non-route handlers
├── PendingDeepLink.kt // queue across auth boundary
├── visitor/
│ ├── RouteIndexVisitor.kt
│ ├── BackStackPlannerVisitor.kt
│ ├── GuardCollectorVisitor.kt
│ └── SchemaEmitterVisitor.kt
reaktor-graph/src/androidMain/.../navigation/deeplink/AndroidDeepLinkBridge.kt
reaktor-graph/src/iosMain/.../navigation/deeplink/DarwinDeepLinkBridge.kt
reaktor-graph/src/jvmMain/.../navigation/deeplink/DesktopDeepLinkBridge.kt
reaktor-graph/src/jsMain/.../navigation/deeplink/WebDeepLinkBridge.kt // wraps existing
6. The four visitors
Each pass extends the existing open class Visitor in reaktor-graph/visitor/Visitor.kt and uses StructuralSelector as its selector so it descends into ContainerNode.graphs automatically — no hand-rolled recursion as in today's WebNavigationBridge.buildRouteIndex.
6.1 RouteIndexVisitor
Walks the root graph once at startup (or on invalidation). Produces:
byPattern: Map<RoutePattern, RouteNode<*, *>>— for matching.byNode: Map<RouteNode<*, *>, RoutePath>— for outgoing URL building.parentChain: Map<RouteNode<*, *>, List<RouteNode<*, *>>>— for back-stack planning.
class RouteIndexVisitor : Visitor() {
val byPattern = mutableMapOf<RoutePattern, RouteNode<*, *>>()
val parentChain = mutableMapOf<RouteNode<*, *>, MutableList<RouteNode<*, *>>>()
private val containerStack = ArrayDeque<RouteNode<*, *>>()
override fun visitRouteNode(node: RouteNode<*, *>): ExitScope {
if (node.pattern.original.isNotEmpty()) {
byPattern[node.pattern] = node
parentChain[node] = containerStack.toMutableList()
}
return NoOpExit
}
override fun visitContainerNode(node: ContainerNode): ExitScope {
containerStack.addLast(node.route)
return { containerStack.removeLast() }
}
}
Caching: the index is invalidated only on graph mutation, which we already track via Graph.attach/detach. Add a version counter on Graph and rebuild lazily.
6.2 BackStackPlannerVisitor
Given a matched RouteNode, returns an ordered list of Push commands so the back stack looks logical. For /chats/abc the plan is:
[ Push(sentinel->/home, Payload()),
Push(/home->/chats, Payload()),
Push(/chats->/chats/{id}, ChatPayload(id="abc")) ]
The planner consults parentChain from the index and walks every entry's existing NavigationEdge (lookup via start.edge(end), the same method WebNavigationBridge uses today). When no edge exists between two ancestors, the planner falls back to graph.sentinel.edge(node) — the same fallback the JS bridge uses on cold launch.
6.3 GuardCollectorVisitor
Walks from the matched route up to the root, harvesting DeepLinkGuard capabilities attached to any Graph or RouteNode on the path. Returns them in outermost-first order so the broadest gate runs first.
interface DeepLinkGuard : Capability {
suspend fun evaluate(link: DeepLink, route: RouteNode<*, *>): GuardResult
}
sealed interface GuardResult {
data object Allow : GuardResult
data class Redirect(val to: RoutePattern, val payload: Payload) : GuardResult
data class WaitFor(val signal: Flow<Boolean>) : GuardResult // auth pending
data class Deny(val reason: String) : GuardResult
}
Auth gating is one specialization: AuthGuard in reaktor-auth returns WaitFor(auth.isLoggedIn) while a user is being authenticated, and Allow after.
6.4 SchemaEmitterVisitor
Build-time only. Walks every RouteNode and emits JSON consumed by:
assetlinks.jsonautoVerify generation.- The bot/share server, so other services can
shareUrlFor("/chats/{id}", {id: "abc"})without depending on the graph. - Generated docs for the URI surface (this very HTML can be regenerated from it).
7. Common API surface
// commonMain — DeepLinkResolver.kt
@JsExport
class DeepLinkResolver(
private val rootGraph: Graph,
private val config: DeepLinkConfig,
private val pending: PendingDeepLink,
) {
private val index by lazy { RouteIndexVisitor().also { it.walk(rootGraph) } }
suspend fun resolve(uri: String): Resolution {
val link = DeepLink.parse(uri) ?: return Resolution.Unknown(uri, "parse-failed")
return resolve(link)
}
suspend fun resolve(link: DeepLink): Resolution {
if (!link.acceptedBy(config)) return Resolution.Unknown(link.toString(), "host-rejected")
val matched = matchPath(link.path, link.query) ?: return Resolution.Unknown(link.toString(), "no-route")
val guards = GuardCollectorVisitor(matched.node).also { it.walk(rootGraph) }.guards
for (g in guards) when (val r = g.evaluate(link, matched.node)) {
GuardResult.Allow -> {}
is GuardResult.Redirect -> return Resolution.Plan(plan(r.to.toRoute(), r.payload, link))
is GuardResult.WaitFor -> { pending.enqueue(link); return Resolution.Gated(link, r.signal) }
is GuardResult.Deny -> return Resolution.Unknown(link.toString(), "denied: ${r.reason}")
}
return Resolution.Plan(plan(matched.node, matched.payload, link))
}
fun apply(plan: Resolution.Plan) = plan.steps.forEach(rootGraph::dispatch)
fun urlFor(route: RouteNode<*, *>, payload: Payload): String =
"https://${config.canonicalHost}${route.pattern.fill(payload.routeParams)}"
}
// Resolution.kt
@JsExport
sealed interface Resolution {
data class Plan(val steps: List<NavCommand>) : Resolution
data class Gated(val link: DeepLink, val until: Flow<Boolean>) : Resolution
data class Action(val handler: suspend () -> Unit) : Resolution
data class Unknown(val uri: String, val reason: String) : Resolution
}
Each platform bridge is a five-line glue:
// platform pseudo-code, same shape on all four
val resolution = resolver.resolve(uri)
when (resolution) {
is Resolution.Plan -> resolver.apply(resolution)
is Resolution.Gated -> /* stays queued; UI shows nothing extra */
is Resolution.Action -> resolution.handler()
is Resolution.Unknown -> log.w("unhandled deep link: ${resolution.uri} (${resolution.reason})")
}
8. Android bridge
8.1 Manifest
bestbuds/targets/appAndroid/src/main/AndroidManifest.xml
<activity android:name="ai.bestbuds.app.BestBudsActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Custom scheme: works on cold launch, no autoVerify needed -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="bestbuds" />
</intent-filter>
<!-- App Link (https): autoVerify off until /.well-known/assetlinks.json is live -->
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="bestbuds.app" />
<data android:scheme="https" android:host="app.bestbuds.ai" />
</intent-filter>
</activity>
Once the worker is serving the association file, flip autoVerify to true.
8.2 Activity wiring
bestbuds/modules/app/src/androidMain/.../BestBudsActivity.kt
override fun onCreate(state: Bundle?) {
super.onCreate(state)
// ... existing Reaktor.start { ... } ...
Reaktor.DeepLink = AndroidDeepLinkBridge(BestBuds, resolver = DeepLinkResolver(BestBuds, config, pending))
intent?.data?.let { Reaktor.DeepLink?.handle(it.toString(), DeepLink.Source.Cold) }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.data?.let { Reaktor.DeepLink?.handle(it.toString(), DeepLink.Source.Foreground) }
}
9. iOS bridge
9.1 Info.plist
bestbuds/targets/appDarwin/iosApp/Info.plist — add a second URL type alongside the existing Google OAuth entry:
<dict>
<key>CFBundleTypeRole</key><string>Editor</string>
<key>CFBundleURLName</key><string>ai.bestbuds.deeplink</string>
<key>CFBundleURLSchemes</key>
<array><string>bestbuds</string></array>
</dict>
Universal Links additionally require an Associated Domains capability with entries applinks:bestbuds.app and applinks:app.bestbuds.ai in the Xcode project (and the matching apple-app-site-association at the host — see §14).
9.2 AppDelegate wiring
The existing BestBudsApplication.application(_:openURL:options:) currently routes all URLs to GIDSignIn. Change to scheme-aware dispatch:
override fun application(application: UIApplication, openURL: NSURL, options: Map<Any?, *>): Boolean {
if (openURL.scheme == "bestbuds") {
Reaktor.DeepLink?.handle(openURL.absoluteString!!, DeepLink.Source.Foreground)
return true
}
return handleGoogleSignInUrl(openURL, "openURL.options")
}
override fun application(
application: UIApplication,
continueUserActivity: NSUserActivity,
restorationHandler: (List<*>?) -> Unit
): Boolean {
if (continueUserActivity.activityType == NSUserActivityTypeBrowsingWeb) {
val url = continueUserActivity.webpageURL ?: return false
Reaktor.DeepLink?.handle(url.absoluteString!!, DeepLink.Source.Browser)
return true
}
return false
}
Cold launch: read didFinishLaunchingWithOptions[UIApplicationLaunchOptionsURLKey] after Reaktor.start { ... } resolves; if present, feed it to the bridge with Source.Cold.
10. Web bridge
The existing WebNavigationBridge already does 80% of this job — it discovers routes, matches paths, and dispatches Push. Refactor:
- Replace its private
buildRouteIndexrecursion withRouteIndexVisitor. - Replace its private
matchRoutewith a call toDeepLinkResolver.resolve(window.location.href). - Keep its
popstatelistener and back-stack ⇒historysync — that's a web-only concern unrelated to inbound deep links.
Effect: web stops carrying its own routing logic; it consumes the same resolver as native.
11. Desktop bridge
Two entry points to wire — bestbuds/targets/reaktorDesktop/src/main/kotlin/Main.kt:
fun main(args: Array<String>) {
initReaktorDesktopFeatures()
// Cold-launch URL (macOS: open bestbuds://..., Windows: protocol handler)
val pendingArg = args.firstOrNull { it.startsWith("bestbuds://") || it.startsWith("https://bestbuds.") }
application {
// ... existing window setup ...
LaunchedEffect(Unit) { pendingArg?.let { Reaktor.DeepLink?.handle(it, DeepLink.Source.Cold) } }
}
}
For runtime URLs on macOS use AWT's Desktop.getDesktop().setOpenURIHandler { Reaktor.DeepLink?.handle(it.uri.toString(), DeepLink.Source.Foreground) }. The bestbuds custom scheme needs an Info.plist entry in the .app bundle — Compose Desktop's Conveyor packaging already supports custom Info.plist injection.
12. Auth guards & pending links
12.1 The flow
- Cold launch with
bestbuds://chats/abc. - Resolver finds
/chats/{id}, matches paramid=abc. GuardCollectorVisitorwalks the parent chain —ChatGraphdeclares anAuthGuardviagraph.attach(AuthGuard())in its init block, so the guard is picked up.- If
auth.user.value == null, guard returnsWaitFor(auth.isLoggedIn). - Resolver enqueues the link in
PendingDeepLink, returnsResolution.Gated; bridge does nothing extra. StartBindingfinishes normally — onboarding or login.- Once
auth.isLoggedInemitstrue,PendingDeepLink.collectorre-runsresolver.resolve(link). This time the guard returnsAllowand the plan is dispatched.
12.2 PendingDeepLink
class PendingDeepLink(private val resolver: () -> DeepLinkResolver) {
private val pending = MutableStateFlow<DeepLink?>(null)
fun enqueue(link: DeepLink) { pending.value = link }
fun consume() { pending.value = null }
suspend fun watch(scope: CoroutineScope, until: Flow<Boolean>) = scope.launch {
until.first { it }
pending.value?.let { link ->
when (val r = resolver().resolve(link)) {
is Resolution.Plan -> { resolver().apply(r); consume() }
else -> { /* still gated, stay queued */ }
}
}
}
}
Survives process death by persisting the queued link to KeyValueStore (already wired in BestBudsActivity).
12.3 Declaring a guard
class ChatGraph(parent: Graph? = null): Graph(parent), BottomNavigable {
init {
attach(AuthGuard()) // every route in this graph is gated
// ... existing route definitions ...
}
}
// Or per-route:
val adminRoute = Route("/admin/{id}") { AdminBinding() }.guard(RoleGuard(Role.Admin))
13. Actions — non-route deep links
Not every link should add a screen. bestbuds://share/event?id=evt_42 opens a share sheet on top of whatever is current; bestbuds://refresh-feed just nudges a repository. These register as DeepLinkAction:
graph.registerAction("/share/event") { link ->
val eventId = link.query["id"] ?: return@registerAction
shareSheet.show(eventId)
}
The resolver consults the action registry before the route index, so an action with the same path takes precedence. Actions can also compose with a navigation plan — e.g., navigate to /events/{id} first, then show the share sheet on top.
14. .well-known association files
Serve from the existing Cloudflare worker. Place static files under bestbuds/cloud/.well-known/ and add a route in bestbuds/wrangler.json.
14.1 Android — assetlinks.json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "ai.bestbuds.app",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:EE:FF:..." // Play upload + Play app signing fingerprints
]
}
}]
14.2 iOS — apple-app-site-association
{
"applinks": {
"details": [{
"appIDs": ["TEAMID.ai.bestbuds.darwin"],
"components": [
{ "/": "/chats/*", "comment": "Chat threads" },
{ "/": "/events/*", "comment": "Events" },
{ "/": "/profiles/*", "comment": "User profiles" },
{ "/": "/onboarding", "exclude": true }
]
}]
}
}
Both files are generated by SchemaEmitterVisitor + the published Android/iOS bundle IDs, so the list of allowed paths can never drift from the actual routes.
15. BestBuds wiring
The current routes (from bestbuds/modules/app/src/commonMain/.../BestBuds.kt) need almost no change. The graph already names them with the patterns we'd want as URLs:
| Deep link | Resolves to | Guard |
|---|---|---|
bestbuds://chats | ChatGraph root, /chats | Auth |
bestbuds://chats/{id} | chatRoute | Auth + session-member check |
bestbuds://chats/profile/friend/{id} | friendProfileRoute | Auth |
bestbuds://campaigns/current | CampaignGraph | Auth |
bestbuds://campaigns/discover | DiscoverGraph | Auth |
bestbuds://events/private / /events/public | EventGraph tabbed container | Auth |
bestbuds://events/create | createEventRoute action overlay | Auth |
bestbuds://friends/{id} | friendProfileRoute in FriendGraph | Auth |
bestbuds://profiles/user | profileRoute | Auth |
bestbuds://onboarding | onboardingBinding | None (must be reachable while logged-out) |
bestbuds://dev / bestbuds://palette | DevGraph / PaletteGraph | canAccessDevTools() |
The single tab-container link (/events/private vs /events/public) is the only non-trivial case — the existing EventGraph Container(...) wraps both child graphs, and the planner just needs to (a) push the container, then (b) call container.activateGraphForRoute(matched) — which is already what handleCrossGraphForward in Graph.kt:104 does for cross-graph navigation. No new code on the bestbuds side; it falls out of the planner using the same code path the JS web bridge uses today.
15.1 What BestBuds adds
- Manifest + Info.plist edits (above).
Reaktor.DeepLink = ...assignment insideReaktor.start { ... }on each platform.- One
attach(AuthGuard())per protected feature graph. bestbuds/cloud/.well-known/{assetlinks.json,apple-app-site-association}served by the worker.- An
onboarding deep linkintercept that opens the existing screen instead of/home.
16. Testing strategy
commonTest
DeepLink.parse— table-driven: custom scheme, https, with/without query/fragment, percent-encoding, trailing slash.RouteIndexVisitor— fixture graph with nested containers; assert every leaf route is indexed exactly once and itsparentChainis correct.DeepLinkResolver.resolve— happy path, unknown host, unknown path, gated by stub guard, action wins over route.BackStackPlannerVisitor— round-trip: plan → dispatch → assertbackStack.entriesmatches expected pattern sequence.urlForround-trip:resolve(urlFor(route, payload)).Plan.last().payload == payload.
androidTest / iosTest
- Instrument
BestBudsActivitywith a fakeIntent, assertNavigationCapability.backStacktop equals expected route. - iOS XCUITest:
xcrun simctl openurl booted bestbuds://chats/abc+ screenshot assertion on the chat screen header.
e2e via Maestro (already wired in bestbuds/maestro)
- launchApp:
appId: ai.bestbuds.app
deepLink: bestbuds://chats/abc123
- assertVisible: "Chat with ..."
- back
- assertVisible: "Chats"
17. Phased plan
Phase 1 — Common resolver · ~1 day
DeepLink, DeepLinkConfig, DeepLinkResolver, Resolution, RouteIndexVisitor. Unit tests on commonTest with a fixture graph. Deliverable: green tests; no platform integration yet.
Phase 2 — Web bridge refactor · ~½ day
Make WebNavigationBridge consume the new resolver. Zero behavioral change for the user; proves the new abstraction matches what we already shipped.
Phase 3 — Android · ~1 day
Manifest intent-filters, singleTask, onNewIntent wiring, AndroidDeepLinkBridge. adb shell am start -a android.intent.action.VIEW -d "bestbuds://chats/abc" opens the chat.
Phase 4 — iOS · ~1 day
Info.plist URL type, scheme-aware application(_:openURL:options:), DarwinDeepLinkBridge, simulator smoke test via simctl openurl.
Phase 5 — Guards & pending queue · ~1 day
DeepLinkGuard, GuardCollectorVisitor, PendingDeepLink with KeyValueStore persistence. AuthGuard in reaktor-auth. attach(AuthGuard()) on every BestBuds feature graph.
Phase 6 — Synthetic back stack · ~1 day
BackStackPlannerVisitor. End-to-end test: cold launch into /chats/abc; press back → /chats; press back → /home.
Phase 7 — Desktop + .well-known + AppLink autoVerify · ~1 day
Desktop args + setOpenURIHandler; Conveyor Info.plist edit for the .app. Generate assetlinks.json + apple-app-site-association from SchemaEmitterVisitor; serve via Cloudflare worker. Flip Android autoVerify="true".
Phase 8 — Actions, share URLs, analytics · ~½ day
DeepLinkAction registry, Graph.shareUrlFor(...), DeepLink.Source tags propagating into analytics events.
18. Risks & open questions
Risks
- Existing iOS URL handler swallows everything. Today
application(_:openURL:options:)sends every URL toGIDSignInand returns its result. Switching to scheme-aware dispatch must be done carefully so OAuth still works. Mitigation: keepGIDSignIn.handleURLas the fallback branch and only intercept whenscheme == "bestbuds". - Android singleTask side-effects. Changing launch mode may alter task-stack behavior for other entry points (notifications). Run the existing Maestro suite before merging.
- Universal Link autoVerify race. Until the worker serves valid
assetlinks.jsonwith the right cert fingerprint, autoVerify will silently fail and links keep going to the browser. Ship Phase 3 withautoVerify="false"(chooser dialog) and flip in Phase 7 once verified viaadb shell pm verify-app-links --re-verify ai.bestbuds.app. - Guard collection vs. capability scoping. A
DeepLinkGuardon a parentGraphshould apply to all descendants but not leak acrossContainerNodepeer graphs. The visitor walks the parent chain of the matched node, not all neighbors, so this is correct — but worth covering with a fixture test in Phase 5. - Process-death persistence of pending links. Persisting to
KeyValueStoremeans a user who closes the app mid-auth and reopens an hour later still resolves the link. Add a TTL (default 15 min) and source tagging so we don't accidentally re-fire a stale push.
Open questions
- Should the URI surface live at
bestbuds.appor a dedicatedl.bestbuds.app? The latter avoids collision with the marketing site already served frombestbuds.appvia the existing worker. - Should
bestbuds://remain after Universal Links are verified? Custom schemes are still useful for desktop and dev tooling; recommendation is keep both. - Should
DeepLinkActionlive inreaktor-graphor stay app-side? Most actions touch app-specific repositories. Recommendation: keep the registry generic, register handlers from the app. - Bot/messaging server already produces share strings (Telegram/SMS). Should it call
shareUrlForvia a new endpoint, or import the schema JSON at build time? Cheaper to ship the JSON.
Source files referenced: reaktor/reaktor-graph/src/commonMain/.../visitor/Visitor.kt, core/node/RouteNode.kt, navigation/NavigationCapability.kt, navigation/NavCommand.kt, navigation/Payload.kt, core/Graph.kt, core/node/ContainerNode.kt, reaktor-io/.../network/RoutePattern.kt, reaktor-graph/src/jsMain/.../navigation/WebNavigationBridge.kt; BestBuds: modules/app/src/commonMain/.../BestBuds.kt, targets/appAndroid/src/main/AndroidManifest.xml, targets/appDarwin/iosApp/Info.plist, modules/app/src/iosMain/.../BestBudsApplication.kt, targets/reaktorDesktop/src/main/kotlin/Main.kt.
Related docs
- Graph Visitors - Use before designing a new graph pass or selector.
- Graph Migration - Use when evolving reaktor-graph without breaking product apps.
- Auth Architecture - Use before changing reaktor-auth or request authorization boundaries.
- UI & Design System - Use before touching reaktor-ui tokens/components/theming, designing the tactile runtime (interaction/physics/field/skins/host-tree/reconciler), or planning adaptive layouts and AI/server-driven UI.
- Perf and Observability - Use before optimizing or adding instrumentation.
- Server - Use before adding any production backend primitive (resilience, messaging, data patterns) or designing the Spring/Workers server runtime.