Skip to content

feat: add deep links to every screen - #1119

Open
guzino wants to merge 7 commits into
synonymdev:masterfrom
guzino:feat/deep-link-screens
Open

feat: add deep links to every screen#1119
guzino wants to merge 7 commits into
synonymdev:masterfrom
guzino:feat/deep-link-screens

Conversation

@guzino

@guzino guzino commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #659
Refs #1118

This PR adds a bitkit://screen/... URI for every screen in the app, bottom sheets included, gated on dev mode.

Description

Screens in the root graph register themselves. ScreenDeepLinks derives the id from the route type by kebab-casing its class name and composableWithDefaultTransitions attaches the link, so Routes.RgsServer is reachable at bitkit://screen/rgs-server and a new screen works as soon as it joins the graph. Arguments without a default become path segments, arguments with a default become query parameters.

Bottom sheets needed a second mechanism. They are not in the root graph: they are Sheet values rendered by SheetHost, each carrying the start route of its own nested NavHost, so NavController.handleDeepLink cannot reach them. SheetDeepLinks maps a path to a Sheet and ContentView hands it to showSheet before falling through to the nav graph. Paths are still derived from class names via the shared kebabId helper, so bitkit://screen/widgets/price-edit comes from Sheet.Widgets plus WidgetsRoute.PriceEdit. A bare sheet id opens its first registered route.

  • Registers 38 sheet paths across send, receive, backup, widgets, hardware and the three sheets with no nested graph.
  • Gates screen links on isDevModeEnabled in AppViewModel.handleDeeplinkIntent, and holds one until the wallet is loaded; ContentView replays it.
  • Adds FLAG_ACTIVITY_CLEAR_TASK to screen links in MainActivity. The NavHost re-handles the intent when it sets its graph, and without the flag that restarts the whole task instead of navigating, costing an extra activity launch on every cold link.
  • Denies RecoveryMnemonic, AuthCheck, LegacyRnRecovery, LnurlChannel, CriticalUpdate, RecoveryMode and the three External* transfer routes, the backup/show-mnemonic and show-passphrase sheet routes, the pin / change-pin / disable-pin sheets, and force-transfer. Links navigate and prefill only, and never send, broadcast or change a setting without the usual confirmation.
  • Leaves payment URIs (bitcoin:, lightning:, lnurl*) on the scanner decode path, untouched.
  • Documents the URI shape, the reachable sheet routes and every exclusion in docs/deeplinks.md.
  • Keeps the thirteen unreachable sheet routes out of scope. None is tap-reachable, so none is a user-facing defect. Starting inside the FeeNav sub-graph, and letting the confirm and liquidity screens tolerate an empty flow, are follow-ups.

Sheet routes are registered by hand because not every route is a valid start destination. I fired all 51 against an emulator, then re-ran each rejected one in isolation against logcat. Thirteen are excluded, for three reasons:

  • SendRoute.FeeRate and FeeCustom sit inside navigationWithDefaultTransitions<SendRoute.FeeNav>. A nested graph's child cannot be a NavHost start destination, so both throw IllegalStateException: Cannot find startDestination ... from NavGraph. send/fee-nav resolves but renders only the screen title.
  • send/quick-pay throws on requireNotNull(quickPayData) (SendSheet.kt:309). send/confirm and the four receive confirm/liquidity routes do not crash but render empty or zero-amount screens. send/confirm offers "Swipe To Pay" over a payment that was never built.
  • backup/success reports a backup that never ran, and its OK button persists backupVerified = true (BackupNavSheetViewModel.onSuccessContinue). backup/warning is one tap upstream of the same write. hardware/searching waits forever because discovery starts from the intro's continue action, and hardware/paired claims a paired device over default state.

A test pins all thirteen, and a final sweep confirmed they no-op with the wallet overview intact and zero fatal exceptions.

Preview

N/A

QA Notes

Dev mode is on by default on debug builds (Settings ▸ Advanced ▸ Dev Settings). The app must be past onboarding.

Manual Tests

  • 1. adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/settings" to.bitkit.dev → Settings opens. A mistyped id still resolves MainActivity, because the manifest accepts every bitkit: URI by scheme, so check logcat for Unhandled screen deeplink rather than relying on -W.
  • 2. Deep-linked screen → Back: returns to the wallet overview, not the launcher.
  • 3. bitkit://screen/send → Send sheet on the recipient picker. bitkit://screen/widgets/price-edit → Bitcoin Price editor.
  • 4. bitkit://screen/recovery-mnemonic and bitkit://screen/backup/show-mnemonic → screen unchanged, recovery phrase never shown, logcat carries Unhandled screen deeplink.
  • 5. bitkit://screen/send/fee-rate and bitkit://screen/backup/success → screen unchanged, no crash, and backupVerified is untouched.
  • 6. PIN screen up → fire any screen link: navigates behind the PIN, which is still required before anything is visible.
  • 7. Dev mode off → any bitkit://screen/... link is ignored, on warm start and on cold start. Settings ▸ Support, tap Version five times to toggle.
  • 8. regression: scan a bitcoin: / lightning: / lnurl URI → still decodes through the scanner path.

Automated Checks

  • Unit tests in ScreenDeepLinksTest.kt: id derivation, path vs query argument placement, and that every denied route yields no link.
  • Unit tests in SheetDeepLinksTest.kt: bare-id defaults, sub-route selection, case-insensitive lookup, and that sensitive and mid-flow paths resolve to null rather than falling back to the sheet default.
  • Two journeys under journeys/deeplinks/.
  • Fired all 38 sheet paths and the screen links against an API 33 emulator and confirmed each renders. The rejected paths no-op without crashing.
  • Local: just compile, just test, just lint all pass, no new detekt findings.

Comment on lines +49 to +50
Sheet.Hardware(HardwareRoute.Searching),
Sheet.Hardware(HardwareRoute.Paired),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Hardware flow state is bypassed

When hardware/searching or hardware/paired is opened directly, the sheet bypasses the intro action that starts discovery and establishes the paired-device state, causing an indefinitely waiting search screen or a paired screen backed by default state.

Suggested change
Sheet.Hardware(HardwareRoute.Searching),
Sheet.Hardware(HardwareRoute.Paired),

Knowledge Base Used: UI Screens: Navigation, Screens, and Components

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed both. Discovery is started by the intro's continue action rather than by the screen, so a deep-linked hardware/searching spins forever, and hardware/paired renders PAIRED TREZOR over default state with no device behind it.

Same class as send/confirm, which this PR already excluded for offering Swipe To Pay over a payment that was never built. My registry was inconsistent in keeping these two.

Removed both. hardware now exposes intro only.

Resolved in 511250c90.


Sheet.Backup(BackupRoute.Intro),
Sheet.Backup(BackupRoute.Warning),
Sheet.Backup(BackupRoute.Success),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Backup success state is bypassed

When backup/success is opened directly, the sheet starts at BackupRoute.Success without running or verifying a backup, causing the UI to report success for an action that never occurred.

Suggested change
Sheet.Backup(BackupRoute.Success),

Knowledge Base Used: UI Screens: Navigation, Screens, and Components

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and the write is worse than the render. onSuccessContinue persists backupVerified = true (BackupNavSheetViewModel.kt:139-141), so the OK button on a deep-linked success screen would mark a wallet backed up without the recovery phrase ever being shown. That breaks this PR's own rule that links never change a setting without the usual confirmation.

Removed backup/success, and also backup/warning, which is one tap upstream of the same write. backup now exposes intro, multiple-devices and metadata only.

Resolved in 511250c90.

@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown

Greptile Summary

Adds development-mode deep links for root navigation destinations and manually selected sheet routes.

  • Derives root-screen URI patterns from serializable route class names and arguments.
  • Queues incoming screen links until Compose navigation is available, then routes them to the root graph or sheet host.
  • Documents supported and excluded links and adds unit tests and emulator journeys.

Confidence Score: 4/5

The backup-success and hardware mid-flow links should be removed or made to establish their required state before this PR is merged.

Direct sheet entry can report a backup that never occurred or render hardware searching and paired states without starting discovery or establishing a paired device.

Files Needing Attention: app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt

Important Files Changed

Filename Overview
app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt Registers direct sheet entry points, including backup and hardware flow states that require work or initialization performed by earlier steps.
app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt Derives root-route deep links and excludes an explicit set of sensitive routes.
app/src/main/java/to/bitkit/ui/ContentView.kt Replays queued links through either the sheet host or root NavController.
app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt Gates screen links on development mode and stores one pending URI for UI consumption.
app/src/main/java/to/bitkit/ui/utils/Transitions.kt Makes generated screen links the default for root composable and nested-graph registrations.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A["bitkit://screen/... intent"] --> B["AppViewModel checks dev mode"]
  B -->|disabled| C["Ignore link"]
  B -->|enabled| D["pendingScreenDeepLink"]
  D --> E{"SheetDeepLinks match?"}
  E -->|yes| F["showSheet with nested start route"]
  E -->|no| G["NavController.handleDeepLink"]
Loading

Reviews (1): Last reviewed commit: "feat: add deep links to bottom sheet scr..." | Re-trigger Greptile

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the existing backup and hardware findings; I confirmed both fixes on 511250c.

I found three blocking issues: cold-start links bypass the dev-mode gate, bitkit://screen/recovery-mode is consumed by the legacy parser before the gate, and external-confirm can reach invalid fresh state and crash. I also left two non-blocking corrections for Home registration and the adb -W guidance.

return
}

intent.data?.let { if (ScreenDeepLinks.isScreenDeepLink(it)) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found that cold launches bypass the dev-mode gate. AndroidX automatically handles the Activity's data intent when this Activity-backed NavHost sets its graph; this line retains the screen URI, while AppViewModel gates only the later manual replay. A valid root link can therefore navigate with dev mode off. Please clear the screen URI from the Activity intent before graph creation and replay it only through the gated pipeline, with cold-start coverage for dev mode off and on.

@guzino guzino Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Cold start went through AndroidX automatic intent handling rather than the gated replay, so links still navigated with dev mode off. The data is now cleared from the activity intent once the gate has read it, which also made the CLEAR_TASK flag unnecessary.

Resolved in 09239fb


private val CAMEL_HUMP = Regex("(?<=[a-z0-9])(?=[A-Z])")

private val DENIED: Set<KClass<out Routes>> = setOf(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found that bitkit://screen/external-confirm creates a fresh ExternalNav-scoped view model with peer = null. Its enabled swipe reaches onConfirm(), which calls requireNotNull(peer) and crashes; external-amount can advance into the same state. Please deny these cold-start routes or restore and validate the required peer state before enabling confirmation, and cover both URIs from fresh state.

@guzino guzino Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Denied both, and external-success too, since it reports a completed channel open over the same empty state. Restoring the peer instead would mean re-running the connect flow validation from a URI, which belongs with that flow.

Resolved in 09239fb

inline fun <reified T : Any> NavGraphBuilder.composableWithDefaultTransitions(
typeMap: Map<KType, NavType<*>> = emptyMap(),
deepLinks: List<NavDeepLink> = emptyList(),
deepLinks: List<NavDeepLink> = ScreenDeepLinks.linksFor(T::class),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found that bitkit://screen/home is generated but never attached to the graph. Routes.Home still uses raw composable<Routes.Home> in ContentView, so this default is skipped and the URI is unhandled from another screen. Please attach the link explicitly and test matching against the assembled graph.

@guzino guzino Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, Home is the last destination still on raw composable, so it never picked up the default and nothing was attached to the graph. Passed the links explicitly at that call site and confirmed it resolves from another screen now.

Resolved in 09239fb

Comment thread docs/deeplinks.md Outdated
-d "bitkit://screen/settings" to.bitkit.dev
```

Escape `&` as `\&` when passing more than one query parameter. `-W` reports whether the intent

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found that -W verifies only Activity resolution, not the screen ID. The manifest accepts every bitkit: URI by scheme, so bitkit://screen/typo still resolves MainActivity; the in-app handler rejects it later. Please document a destination or logcat assertion instead of treating -W as route validation.

@guzino guzino Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, -W only proves an activity resolved. The doc and the first QA note now assert on the Unhandled screen deeplink warning in logcat instead.

Resolved in 09239fb

return@launch
}

if (ScreenDeepLinks.isScreenDeepLink(uri)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found that bitkit://screen/recovery-mode never reaches this deny gate. isRecoveryModeDeeplink() runs first and accepts any URI whose only path segment is recovery-mode, so this URL calls setRecoveryMode(true) and navigates before the dev-mode check. Please classify the screen namespace before the legacy recovery parser, or restrict that parser to the exact legacy URI, and add this denied URL to AppViewModel coverage.

@guzino guzino Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The legacy parser matched on the path segment alone, so it ran first and switched recovery mode on. The screen namespace is now classified ahead of it and that URL falls through to the deny list, with the legacy one unchanged.

Resolved in 09239fb

@ovitrif ovitrif self-assigned this Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add deep link linking to every screen in the app

2 participants