feat: add deep links to every screen - #1119
Conversation
Greptile SummaryAdds development-mode deep links for root navigation destinations and manually selected sheet routes.
Confidence Score: 4/5The 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
|
| 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"]
Reviews (1): Last reviewed commit: "feat: add deep links to bottom sheet scr..." | Re-trigger Greptile
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Thanks for addressing the earlier findings. Three blocking issues remain:
- Cold-start coverage is missing at the Activity/
NavHostboundary. - Late transfer destinations allow entry without the flow state they require.
- Root-screen links route behind an active sheet.
ovitrif
left a comment
There was a problem hiding this comment.
One blocking navigation regression remains: rejected screen links dismiss an active sheet before route handling determines that the URI is invalid.
| return@LaunchedEffect | ||
| } | ||
|
|
||
| if (shouldDismissSheetForScreenLink(uri, appViewModel.currentSheet.value)) { |
There was a problem hiding this comment.
backup/show-mnemonic is denied by SheetDeepLinks, but it reaches this branch and hides the active backup intro before handleDeepLink reports the URI as unhandled. The new sheet journey therefore returns to the wallet overview instead of leaving the visible screen unchanged; any rejected screen URI can similarly dismiss the current sheet. Could we confirm that the URI matches a root destination before hiding the sheet and add regression coverage for a denied URI while a sheet is open?
ovitrif
left a comment
There was a problem hiding this comment.
The deep-link implementation currently splits navigation policy across the route models and parallel registries, so four required corrections keep direct entry safe and make the route hierarchy the single source of truth:
- A denied root-screen link dismisses an active sheet before rejection, so rejected input changes the visible flow.
- Root-screen eligibility has a second owner in
ScreenDeepLinks.DENIED, so new routes become reachable without an explicit decision and every route change must synchronize a parallel policy. SheetDeepLinks.SHEETScorrectly fails closed while duplicating which nested states may start a flow outside the sealed route families that define them.- The changelog is incorrect because it describes an internal QA workflow rather than the public developer capability available to people running their own builds.
|
|
||
| private val CAMEL_HUMP = Regex("(?<=[a-z0-9])(?=[A-Z])") | ||
|
|
||
| private val DENIED: Set<KClass<out Routes>> = setOf( |
There was a problem hiding this comment.
ScreenDeepLinks becomes a second owner of the route model because DENIED duplicates direct-entry eligibility outside the sealed Routes hierarchy. Every destination change therefore needs to synchronize the route hierarchy, navigation graph, this registry, its tests, the documentation, and the journeys.
That duplication does not guard future changes:
- A new route becomes externally reachable automatically because
composableWithDefaultTransitionscallslinksFor(T::class)by default. - A route that should remain internal can remain externally reachable because its author must also know to add it to
DENIED. - The every-route test does not enforce an explicit eligibility decision because every unlisted route is accepted as deep-linkable.
The navigation model should follow the same ownership principle we use from DDD: behavior and invariants live with the model that owns them. Within this navigation boundary, the sealed Routes hierarchy should own whether a destination may be entered directly, while ScreenDeepLinks only adapts external URIs.
The sealed hierarchy should make that choice explicit:
sealed interface Routes {
sealed interface DeepLinkable : Routes
sealed interface InternalOnly : Routes
@Serializable
data object Settings : DeepLinkable
@Serializable
data object SpendingConfirm : InternalOnly
}The graph helper should then accept only Routes.DeepLinkable, making every new destination declare its policy in the same model that defines it. Could we move root-screen eligibility into the sealed Routes hierarchy and constrain the graph helper to Routes.DeepLinkable, leaving ScreenDeepLinks responsible only for URI adaptation?
| import to.bitkit.ui.sheets.hardware.HardwareRoute | ||
|
|
||
| object SheetDeepLinks { | ||
| private val SHEETS: List<Sheet> = listOf( |
There was a problem hiding this comment.
SheetDeepLinks correctly fails closed because SHEETS is a positive allowlist. The maintenance defect is that this object becomes a second catalog of which nested states may safely start a flow, separate from the sealed SendRoute, ReceiveRoute, BackupRoute, WidgetsRoute, and HardwareRoute families that define those states.
Future flow changes therefore need synchronized maintenance:
- A startable state needs to be declared in its route family and registered in
SHEETS. - Reclassifying a flow-only state therefore changes this catalog and its parallel tests instead of one route contract.
- The route declaration does not communicate whether the state is a valid independent entry point.
The nested navigation models should follow the same DDD ownership principle: each sealed route family owns the invariant that determines whether a state can start independently, while SheetDeepLinks only adapts the external URI.
Each sealed route family should make that choice explicit and own its lookup:
sealed interface SendRoute {
sealed interface DeepLinkStart : SendRoute
sealed interface InternalOnly : SendRoute
@Serializable
data object Recipient : DeepLinkStart
@Serializable
data object Confirm : InternalOnly
companion object {
fun fromDeepLink(path: String): DeepLinkStart? = TODO()
}
}SheetDeepLinks should keep the standalone Sheet entries it directly owns. For nested flows, it should select the sheet family, delegate the child path to that family’s route-owned lookup, and wrap the eligible state in the corresponding Sheet. Could we move nested sheet-entry eligibility and lookup into the existing sealed route families, leaving SheetDeepLinks as the family and standalone URI adapter?
| @@ -0,0 +1 @@ | |||
| Every screen can now be opened directly by a `bitkit://screen/...` link while dev mode is on, so QA and bug reports can jump straight to a screen instead of navigating to it. | |||
There was a problem hiding this comment.
Changelog fragments should describe public-facing capabilities. This entry belongs here because people can build the app themselves and provide their own Firebase configuration when needed. Could we remove the internal so QA and bug reports... framing and use: “Developers running their own build can now open supported screens directly with bitkit://screen/... links while dev mode is enabled”?
Fixes #659
Refs #1118
Refs #1126
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.
ScreenDeepLinksderives the id from the route type by kebab-casing its class name andcomposableWithDefaultTransitionsattaches the link, soRoutes.RgsServeris reachable atbitkit://screen/rgs-serverand 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
Sheetvalues rendered bySheetHost, each carrying the start route of its own nestedNavHost, soNavController.handleDeepLinkcannot reach them.SheetDeepLinksmaps a path to aSheetandContentViewhands it toshowSheetbefore falling through to the nav graph. Paths are still derived from class names via the sharedkebabIdhelper, sobitkit://screen/widgets/price-editcomes fromSheet.WidgetsplusWidgetsRoute.PriceEdit. A bare sheet id opens its first registered route.send,receive,backup,widgets,hardwareand the three sheets with no nested graph.isDevModeEnabledinAppViewModel.handleDeeplinkIntent, and holds one until the wallet is loaded;ContentViewreplays it.MainActivityonce the gated pipeline has read it, so graph creation cannot navigate outside the dev-mode gate.RecoveryMnemonic,AuthCheck,LegacyRnRecovery,LnurlChannel,CriticalUpdate,RecoveryMode, the threeExternal*routes and the six late transfer destinations, thebackup/show-mnemonicandshow-passphrasesheet routes, thepin/change-pin/disable-pinsheets, andforce-transfer. Links navigate and prefill only, and never send, broadcast or change a setting without the usual confirmation.bitcoin:,lightning:,lnurl*) on the scanner decode path, untouched.NavController, while a sheet URI still replaces the open sheet.docs/deeplinks.md.FeeNavsub-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.FeeRateandFeeCustomsit insidenavigationWithDefaultTransitions<SendRoute.FeeNav>. A nested graph's child cannot be aNavHoststart destination, so both throwIllegalStateException: Cannot find startDestination ... from NavGraph.send/fee-navresolves but renders only the screen title.send/quick-paythrows onrequireNotNull(quickPayData)(SendSheet.kt:309).send/confirmand the four receive confirm/liquidity routes do not crash but render empty or zero-amount screens.send/confirmoffers "Swipe To Pay" over a payment that was never built.backup/successreports a backup that never ran, and its OK button persistsbackupVerified = true(BackupNavSheetViewModel.onSuccessContinue).backup/warningis one tap upstream of the same write.hardware/searchingwaits forever because discovery starts from the intro's continue action, andhardware/pairedclaims 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
adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/settings" to.bitkit.dev→ Settings opens. A mistyped id still resolvesMainActivity, because the manifest accepts everybitkit:URI by scheme, so check logcat forUnhandled screen deeplinkrather than relying on-W.bitkit://screen/send→ Send sheet on the recipient picker.bitkit://screen/widgets/price-edit→ Bitcoin Price editor.bitkit://screen/recovery-mnemonicandbitkit://screen/backup/show-mnemonic→ screen unchanged, recovery phrase never shown, logcat carriesUnhandled screen deeplink.bitkit://screen/send/fee-rateandbitkit://screen/backup/success→ screen unchanged, no crash, andbackupVerifiedis untouched.bitkit://screen/...link is ignored, on warm start and on cold start. Settings ▸ Support, tap Version five times to toggle.regression:scan abitcoin:/lightning:/lnurlURI → still decodes through the scanner path.Automated Checks
ScreenDeepLinksTest.kt: id derivation, path vs query argument placement, and that every denied route yields no link.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.ScreenDeepLinkDetachmentTest: graph creation stays on Home after detachment, the captured URI reaches Settings only through the replay, and a denied route is not matched.journeys/deeplinks/, including cold-start cases for dev mode off and on.just compile,just test,just lintall pass, no new detekt findings.