feat: deep link spending hw sign - #1176

Open
guzino wants to merge 4 commits into
synonymdev:masterfrom
guzino:feat/spending-hw-sign-deeplink
Open

feat: deep link spending hw sign#1176
guzino wants to merge 4 commits into
synonymdev:masterfrom
guzino:feat/spending-hw-sign-deeplink

Conversation

@guzino

Copy link
Copy Markdown
Contributor

Refs #1126
Refs #1119

This PR opens the hardware-wallet transfer Sign screen from bitkit://screen/spending-hw-sign/{walletId}/{orderId}.

Description

#1119 left six transfer destinations InternalOnly because they read activity-scoped TransferViewModel state. SpendingHwSign was the closest: it already took a wallet id (the issue still says deviceId) and bounced home when spendingUiState.order was null. A generated link therefore could not reconstruct the Blocktank order.

ContentView now parses that URI and calls prepareSpendingHwSign before navController.handleDeepLink. Unknown wallet or missing order is refused with the existing Unhandled screen deeplink warning and does not navigate. A matching in-memory order is reused; otherwise blocktankRepo.getOrder(orderId, refresh = true) loads it and adoptSpendingOrder writes the same state onOrderCreated already wrote, without emitting TransferEffect.OnOrderCreated. The pending URI is consumed after that suspend, so the LaunchedEffect is not cancelled mid-fetch.

OnOrderCreated now carries orderId. Amount → Sign navigates Routes.SpendingHwSign(walletId, orderId) from the effect. The dest reads both route args and matches state.order to orderId.

  • Promotes Routes.SpendingHwSign to DeepLinkable with path bitkit://screen/spending-hw-sign/{walletId}/{orderId}.
  • Parses that path in ScreenDeepLinks.spendingHwSignLink via kebabId(Routes.SpendingHwSign::class).
  • Keeps SavingsProgress, SettingUp, SpendingAdvanced, SpendingConfirm, and SpendingHwSigned as InternalOnly. The rest of feat: deep link the late transfer screens #1126 stays a follow-up.

Preview

N/A

QA Notes

Dev mode is on by default on debug builds (Settings ▸ Advanced ▸ Dev Settings). The app must be past onboarding. The Sign path needs a paired hardware wallet and a live Blocktank order id.

Manual Tests

  • 1. Hardware Wallet detail → Transfer To Spending → Amount → Continue → Sign: lands on Sign With Your Device with the created order.
  • 2. From that Sign screen, adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/spending-hw-sign/<walletId>/<orderId>" to.bitkit.dev → Sign opens with the same order.
  • 3. Cold start the same URI → Sign opens with that order, not Home.
  • 4. Same path with an unknown wallet id or a missing order → screen unchanged, logcat carries Unhandled screen deeplink.
  • 5.regression:bitkit://screen/spending-amount-hw/<walletId> → Amount still opens.

Automated Checks

  • Unit tests added in ScreenDeepLinksTest.kt: path pattern, wallet and order segments, missing order id.
  • Unit tests added in TransferViewModelTest.kt: known wallet loads the named order, unknown wallet and missing order are refused, in-memory order is reused without a second fetch.
  • Local: just compile, just test, just lint all pass, no new detekt findings.

Comment threadapp/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt Fixed
@greptile-apps

Copy link
Copy Markdown

Greptile Summary

The PR adds debug-only deep-link navigation into the hardware-wallet spending-sign flow, restoring the requested Blocktank order before navigation.

  • Extends the sign route and order-created effect with an order ID.
  • Validates the wallet, loads or reuses the requested order, and adopts it into transfer state.
  • Adds focused parsing, route-contract, preparation, and regression tests.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete blocking or independently actionable non-blocking issue identified.

The deep link remains restricted by the existing debug and Dev Mode gates, prepares the exact requested order before navigation, rejects unavailable prerequisites, and keeps internal navigation synchronized through the new order ID.

Important Files Changed

FilenameOverview
app/src/main/java/to/bitkit/ui/ContentView.ktCoordinates order preparation before deep-link navigation and registers the sign destination with both route arguments.
app/src/main/java/to/bitkit/viewmodels/TransferViewModel.ktAdds validated order restoration, centralizes spending-order adoption, and includes the order ID in creation effects.
app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.ktParses the hardware sign route's wallet and order path segments while retaining existing screen-link gating.
app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.ktEnsures the in-memory order matches the route order before rendering the signing flow.
app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.ktCovers successful restoration, unknown wallets, missing orders, and reuse of a matching in-memory order.
app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.ktCovers the generated URI contract and extraction of both required route identifiers.

Sequence Diagram

sequenceDiagram
participant Intent as Screen deep link
participant AppVM as AppViewModel
participant Content as ContentView
participant TransferVM as TransferViewModel
participant Blocktank as BlocktankRepo
participant Nav as NavController
Intent->>AppVM: queue URI when debug runtime and Dev Mode permit
AppVM-->>Content: pendingScreenDeepLink
Content->>TransferVM: prepareSpendingHwSign(walletId, orderId)
alt matching order already in memory
TransferVM-->>Content: true
else order must be restored
TransferVM->>Blocktank: "getOrder(orderId, refresh = true)"
Blocktank-->>TransferVM: order or missing
TransferVM-->>Content: preparation result
end
alt prepared
Content->>Nav: handleDeepLink(uri)
else rejected
Content->>Content: log unhandled link
end
Content->>AppVM: consumeScreenDeepLink()
Loading

Reviews (1): Last reviewed commit: "fix: consume deeplink after prepare" | Re-trigger Greptile

@jvsena42jvsena42 self-assigned this Aug 27, 2026

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed and validated on a regtest emulator against the deterministic Trezor Bridge emulator from bitkit-docker (T2T1, seed all all ..., paired as BITKIT TEST TREZOR with 26,890,661 sats).

The deep link itself works. Both branches of prepareSpendingHwSign were exercised end to end:

  • fresh order id → refreshOrders runs, order is adopted, isAdvanced resets (Advanced button flips back from "Use Defaults"), sign screen opens with the right amounts;
  • already-current order id → short-circuits on current?.id == orderId and opens directly;
  • unknown wallet and unknown order are both refused, no navigation.

One blocking issue and three smaller ones inline.

val state by viewModel.spendingUiState.collectAsStateWithLifecycle()

val order = state.order ?: run {
val order = state.order?.takeIf { it.id == orderId } ?: run {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking — this guard breaks the existing Advanced flow.

orderId is a route arg, frozen when SpendingHwSign was pushed. But onAdvancedClick pushes Routes.SpendingAdvanced, and onSpendingAdvancedContinue calls blocktankRepo.createOrder(...) and stores a new order with a new id as spendingUiState.order (the old one moves to defaultOrder). SpendingAdvancedScreen's onOrderCreated is navController.popBackStack() — which lands back on SpendingHwSign still carrying the old id. takeIf yields null and the composable calls onCloseClick()navigateToHome().

Reproduced on device: HW detail → Transfer To Spending → 25% → Continue → Sign → Advanced → MAX → Continue lands on the wallet home screen, and the freshly created order is stranded. Logcat:

INFO [BlocktankRepo.kt:285] Buying channel with lspBalanceSat: '341987', ...
DEBUG [BlocktankRepo.kt:222] Orders refreshed: 7 orders, 0 cjit entries, 2 paid orders

and blocktank.db then holds 3c10c207-… Created 341987 110227 with nothing pointing at it.

Suggest accepting defaultOrder?.id == orderId as a match too, or applying the id check only on first entry (deep-link admission) rather than on every recomposition.

val current = _spendingUiState.value.order
if (current?.id == orderId) return true

val order = blocktankRepo.getOrder(orderId, refresh = true).getOrNull()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Worth confirming this is the intent: BlocktankRepo.getOrder refreshes and then searches _blocktankState.value.orders, i.e. only orders this install already tracks locally. An order that exists on the LSP but was never created on this device is always refused.

Verified on device — I created a valid order via the Blocktank API with this node's clientNodeId, then deep-linked to it:

WARN [TransferViewModel.kt:599] Refused spending hw sign deeplink, missing order 'c464a24c-…'

while a locally-created order id opened the sign screen fine. That's the right behaviour if the link is only ever meant to resume a transfer started on this device; it does mean a link handed over from another device or from support tooling can never resolve. Fine to leave as-is — just flagging it so the constraint is deliberate.

setTransferEffect(TransferEffect.OnOrderCreated(order.id))
}

suspend fun prepareSpendingHwSign(walletId: String, orderId: String): Boolean {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

prepareSpendingHwSignadoptSpendingOrder clears pendingHwFundingBroadcast and hasPendingHwBroadcast. If the user has already signed a HW funding tx for order X that failed to broadcast, onTransferToSpendingHwConfirm relies on pendingHwFundingBroadcast?.matches(...) to retry without re-prompting the device — a deep link naming a different order Y silently discards that in-memory signed transaction, making it unrecoverable.

Dev-mode only, so low severity, but cheap to guard: refuse the link (or skip the clobber) while hasPendingHwBroadcast is set.

ScreenDeepLinks.spendingHwSignLink(uri)?.let { link ->
val prepared = transferViewModel.prepareSpendingHwSign(link.walletId, link.orderId)
if (!prepared) {
Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This warn duplicates the specific reason already logged inside prepareSpendingHwSign, and reuses the exact wording of the generic !handled warn a few lines below. Observed on device — one refused link produces two WARN lines, the second of which says "Unhandled" when the link was in fact recognized and deliberately refused:

WARN [TransferViewModel.kt:591] Refused spending hw sign deeplink, unknown wallet 'foo'
WARN [ContentView.kt:328] Unhandled screen deeplink 'bitkit://screen/spending-hw-sign/foo/bar'

Per the repo rule (NEVER duplicate error logging in .onFailure {} if the called method already logs the same error internally), drop this line or reword it so it doesn't collide with the generic one.

Separately, consumeScreenDeepLink() now appears three times in this effect. It can't be hoisted to the top — that's what f405cd3 fixed, since the effect is keyed on pendingScreenDeepLink and consuming early cancels the coroutine mid-prepareSpendingHwSign — but the three calls can collapse into a single one at the end by turning the two early returns into an if/else chain.

fun isScreenDeepLink(uri: Uri): Boolean =
uri.scheme?.lowercase() == SCHEME && uri.host?.lowercase() == HOST

fun spendingHwSignLink(uri: Uri): SpendingHwSignLink? {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Unlike linksFor/sheetFor, this isn't routed through ScreenDeepLinkRuntime/isEnabled, so it parses and returns a link in release builds too. Currently harmless because AppViewModel.processDeeplink gates queueing on ScreenDeepLinks.shouldQueue(...), but that single call site is the only thing stopping a release build from mutating live transfer state from a dev-only URI. An if (!isEnabled) return null here would make it fail safe.

@ovitrif

Copy link
Copy Markdown
Collaborator

General note from briefly looking over review comments: it may not have been specified in the issues or past comments but this screen deeplinks work is more intended to aid in development with ai agents, for example: it could add the possibility to open a specific screen and continue from there. Maybe it should not be a requirement that the rest of the flow(s) would work correctly, or even if it tries to, it should only add it in logic specific to the handler of that screen deeplink; while the impact on production code should try to stay limited to parametrizing the screen inputs (strings, bools, etc, whatever is needed as starting state / to mutate UI)

Sign up for freeto 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.

4 participants

@guzino@ovitrif@jvsena42@github-advanced-security
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat: deep link spending hw sign - #1176

Open
guzino wants to merge 4 commits into
synonymdev:masterfrom
guzino:feat/spending-hw-sign-deeplink
Open

feat: deep link spending hw sign#1176
guzino wants to merge 4 commits into
synonymdev:masterfrom
guzino:feat/spending-hw-sign-deeplink

Conversation

@guzino

Copy link
Copy Markdown
Contributor

Refs #1126
Refs #1119

This PR opens the hardware-wallet transfer Sign screen from bitkit://screen/spending-hw-sign/{walletId}/{orderId}.

Description

#1119 left six transfer destinations InternalOnly because they read activity-scoped TransferViewModel state. SpendingHwSign was the closest: it already took a wallet id (the issue still says deviceId) and bounced home when spendingUiState.order was null. A generated link therefore could not reconstruct the Blocktank order.

ContentView now parses that URI and calls prepareSpendingHwSign before navController.handleDeepLink. Unknown wallet or missing order is refused with the existing Unhandled screen deeplink warning and does not navigate. A matching in-memory order is reused; otherwise blocktankRepo.getOrder(orderId, refresh = true) loads it and adoptSpendingOrder writes the same state onOrderCreated already wrote, without emitting TransferEffect.OnOrderCreated. The pending URI is consumed after that suspend, so the LaunchedEffect is not cancelled mid-fetch.

OnOrderCreated now carries orderId. Amount → Sign navigates Routes.SpendingHwSign(walletId, orderId) from the effect. The dest reads both route args and matches state.order to orderId.

  • Promotes Routes.SpendingHwSign to DeepLinkable with path bitkit://screen/spending-hw-sign/{walletId}/{orderId}.
  • Parses that path in ScreenDeepLinks.spendingHwSignLink via kebabId(Routes.SpendingHwSign::class).
  • Keeps SavingsProgress, SettingUp, SpendingAdvanced, SpendingConfirm, and SpendingHwSigned as InternalOnly. The rest of feat: deep link the late transfer screens #1126 stays a follow-up.

Preview

N/A

QA Notes

Dev mode is on by default on debug builds (Settings ▸ Advanced ▸ Dev Settings). The app must be past onboarding. The Sign path needs a paired hardware wallet and a live Blocktank order id.

Manual Tests

  • 1. Hardware Wallet detail → Transfer To Spending → Amount → Continue → Sign: lands on Sign With Your Device with the created order.
  • 2. From that Sign screen, adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/spending-hw-sign/<walletId>/<orderId>" to.bitkit.dev → Sign opens with the same order.
  • 3. Cold start the same URI → Sign opens with that order, not Home.
  • 4. Same path with an unknown wallet id or a missing order → screen unchanged, logcat carries Unhandled screen deeplink.
  • 5.regression:bitkit://screen/spending-amount-hw/<walletId> → Amount still opens.

Automated Checks

  • Unit tests added in ScreenDeepLinksTest.kt: path pattern, wallet and order segments, missing order id.
  • Unit tests added in TransferViewModelTest.kt: known wallet loads the named order, unknown wallet and missing order are refused, in-memory order is reused without a second fetch.
  • Local: just compile, just test, just lint all pass, no new detekt findings.

Comment threadapp/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt Fixed
@greptile-apps

Copy link
Copy Markdown

Greptile Summary

The PR adds debug-only deep-link navigation into the hardware-wallet spending-sign flow, restoring the requested Blocktank order before navigation.

  • Extends the sign route and order-created effect with an order ID.
  • Validates the wallet, loads or reuses the requested order, and adopts it into transfer state.
  • Adds focused parsing, route-contract, preparation, and regression tests.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete blocking or independently actionable non-blocking issue identified.

The deep link remains restricted by the existing debug and Dev Mode gates, prepares the exact requested order before navigation, rejects unavailable prerequisites, and keeps internal navigation synchronized through the new order ID.

Important Files Changed

FilenameOverview
app/src/main/java/to/bitkit/ui/ContentView.ktCoordinates order preparation before deep-link navigation and registers the sign destination with both route arguments.
app/src/main/java/to/bitkit/viewmodels/TransferViewModel.ktAdds validated order restoration, centralizes spending-order adoption, and includes the order ID in creation effects.
app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.ktParses the hardware sign route's wallet and order path segments while retaining existing screen-link gating.
app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.ktEnsures the in-memory order matches the route order before rendering the signing flow.
app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.ktCovers successful restoration, unknown wallets, missing orders, and reuse of a matching in-memory order.
app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.ktCovers the generated URI contract and extraction of both required route identifiers.

Sequence Diagram

sequenceDiagram
participant Intent as Screen deep link
participant AppVM as AppViewModel
participant Content as ContentView
participant TransferVM as TransferViewModel
participant Blocktank as BlocktankRepo
participant Nav as NavController
Intent->>AppVM: queue URI when debug runtime and Dev Mode permit
AppVM-->>Content: pendingScreenDeepLink
Content->>TransferVM: prepareSpendingHwSign(walletId, orderId)
alt matching order already in memory
TransferVM-->>Content: true
else order must be restored
TransferVM->>Blocktank: "getOrder(orderId, refresh = true)"
Blocktank-->>TransferVM: order or missing
TransferVM-->>Content: preparation result
end
alt prepared
Content->>Nav: handleDeepLink(uri)
else rejected
Content->>Content: log unhandled link
end
Content->>AppVM: consumeScreenDeepLink()
Loading

Reviews (1): Last reviewed commit: "fix: consume deeplink after prepare" | Re-trigger Greptile

@jvsena42jvsena42 self-assigned this Aug 27, 2026

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed and validated on a regtest emulator against the deterministic Trezor Bridge emulator from bitkit-docker (T2T1, seed all all ..., paired as BITKIT TEST TREZOR with 26,890,661 sats).

The deep link itself works. Both branches of prepareSpendingHwSign were exercised end to end:

  • fresh order id → refreshOrders runs, order is adopted, isAdvanced resets (Advanced button flips back from "Use Defaults"), sign screen opens with the right amounts;
  • already-current order id → short-circuits on current?.id == orderId and opens directly;
  • unknown wallet and unknown order are both refused, no navigation.

One blocking issue and three smaller ones inline.

val state by viewModel.spendingUiState.collectAsStateWithLifecycle()

val order = state.order ?: run {
val order = state.order?.takeIf { it.id == orderId } ?: run {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking — this guard breaks the existing Advanced flow.

orderId is a route arg, frozen when SpendingHwSign was pushed. But onAdvancedClick pushes Routes.SpendingAdvanced, and onSpendingAdvancedContinue calls blocktankRepo.createOrder(...) and stores a new order with a new id as spendingUiState.order (the old one moves to defaultOrder). SpendingAdvancedScreen's onOrderCreated is navController.popBackStack() — which lands back on SpendingHwSign still carrying the old id. takeIf yields null and the composable calls onCloseClick()navigateToHome().

Reproduced on device: HW detail → Transfer To Spending → 25% → Continue → Sign → Advanced → MAX → Continue lands on the wallet home screen, and the freshly created order is stranded. Logcat:

INFO [BlocktankRepo.kt:285] Buying channel with lspBalanceSat: '341987', ...
DEBUG [BlocktankRepo.kt:222] Orders refreshed: 7 orders, 0 cjit entries, 2 paid orders

and blocktank.db then holds 3c10c207-… Created 341987 110227 with nothing pointing at it.

Suggest accepting defaultOrder?.id == orderId as a match too, or applying the id check only on first entry (deep-link admission) rather than on every recomposition.

val current = _spendingUiState.value.order
if (current?.id == orderId) return true

val order = blocktankRepo.getOrder(orderId, refresh = true).getOrNull()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Worth confirming this is the intent: BlocktankRepo.getOrder refreshes and then searches _blocktankState.value.orders, i.e. only orders this install already tracks locally. An order that exists on the LSP but was never created on this device is always refused.

Verified on device — I created a valid order via the Blocktank API with this node's clientNodeId, then deep-linked to it:

WARN [TransferViewModel.kt:599] Refused spending hw sign deeplink, missing order 'c464a24c-…'

while a locally-created order id opened the sign screen fine. That's the right behaviour if the link is only ever meant to resume a transfer started on this device; it does mean a link handed over from another device or from support tooling can never resolve. Fine to leave as-is — just flagging it so the constraint is deliberate.

setTransferEffect(TransferEffect.OnOrderCreated(order.id))
}

suspend fun prepareSpendingHwSign(walletId: String, orderId: String): Boolean {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

prepareSpendingHwSignadoptSpendingOrder clears pendingHwFundingBroadcast and hasPendingHwBroadcast. If the user has already signed a HW funding tx for order X that failed to broadcast, onTransferToSpendingHwConfirm relies on pendingHwFundingBroadcast?.matches(...) to retry without re-prompting the device — a deep link naming a different order Y silently discards that in-memory signed transaction, making it unrecoverable.

Dev-mode only, so low severity, but cheap to guard: refuse the link (or skip the clobber) while hasPendingHwBroadcast is set.

ScreenDeepLinks.spendingHwSignLink(uri)?.let { link ->
val prepared = transferViewModel.prepareSpendingHwSign(link.walletId, link.orderId)
if (!prepared) {
Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This warn duplicates the specific reason already logged inside prepareSpendingHwSign, and reuses the exact wording of the generic !handled warn a few lines below. Observed on device — one refused link produces two WARN lines, the second of which says "Unhandled" when the link was in fact recognized and deliberately refused:

WARN [TransferViewModel.kt:591] Refused spending hw sign deeplink, unknown wallet 'foo'
WARN [ContentView.kt:328] Unhandled screen deeplink 'bitkit://screen/spending-hw-sign/foo/bar'

Per the repo rule (NEVER duplicate error logging in .onFailure {} if the called method already logs the same error internally), drop this line or reword it so it doesn't collide with the generic one.

Separately, consumeScreenDeepLink() now appears three times in this effect. It can't be hoisted to the top — that's what f405cd3 fixed, since the effect is keyed on pendingScreenDeepLink and consuming early cancels the coroutine mid-prepareSpendingHwSign — but the three calls can collapse into a single one at the end by turning the two early returns into an if/else chain.

fun isScreenDeepLink(uri: Uri): Boolean =
uri.scheme?.lowercase() == SCHEME && uri.host?.lowercase() == HOST

fun spendingHwSignLink(uri: Uri): SpendingHwSignLink? {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Unlike linksFor/sheetFor, this isn't routed through ScreenDeepLinkRuntime/isEnabled, so it parses and returns a link in release builds too. Currently harmless because AppViewModel.processDeeplink gates queueing on ScreenDeepLinks.shouldQueue(...), but that single call site is the only thing stopping a release build from mutating live transfer state from a dev-only URI. An if (!isEnabled) return null here would make it fail safe.

@ovitrif

Copy link
Copy Markdown
Collaborator

General note from briefly looking over review comments: it may not have been specified in the issues or past comments but this screen deeplinks work is more intended to aid in development with ai agents, for example: it could add the possibility to open a specific screen and continue from there. Maybe it should not be a requirement that the rest of the flow(s) would work correctly, or even if it tries to, it should only add it in logic specific to the handler of that screen deeplink; while the impact on production code should try to stay limited to parametrizing the screen inputs (strings, bools, etc, whatever is needed as starting state / to mutate UI)

Sign up for freeto 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.

4 participants

@guzino@ovitrif@jvsena42@github-advanced-security
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: deep link spending hw sign - #1176

Open
guzino wants to merge 4 commits into
synonymdev:masterfrom
guzino:feat/spending-hw-sign-deeplink
Open

feat: deep link spending hw sign#1176
guzino wants to merge 4 commits into
synonymdev:masterfrom
guzino:feat/spending-hw-sign-deeplink

Conversation

@guzino

Copy link
Copy Markdown
Contributor

Refs #1126
Refs #1119

This PR opens the hardware-wallet transfer Sign screen from bitkit://screen/spending-hw-sign/{walletId}/{orderId}.

Description

#1119 left six transfer destinations InternalOnly because they read activity-scoped TransferViewModel state. SpendingHwSign was the closest: it already took a wallet id (the issue still says deviceId) and bounced home when spendingUiState.order was null. A generated link therefore could not reconstruct the Blocktank order.

ContentView now parses that URI and calls prepareSpendingHwSign before navController.handleDeepLink. Unknown wallet or missing order is refused with the existing Unhandled screen deeplink warning and does not navigate. A matching in-memory order is reused; otherwise blocktankRepo.getOrder(orderId, refresh = true) loads it and adoptSpendingOrder writes the same state onOrderCreated already wrote, without emitting TransferEffect.OnOrderCreated. The pending URI is consumed after that suspend, so the LaunchedEffect is not cancelled mid-fetch.

OnOrderCreated now carries orderId. Amount → Sign navigates Routes.SpendingHwSign(walletId, orderId) from the effect. The dest reads both route args and matches state.order to orderId.

  • Promotes Routes.SpendingHwSign to DeepLinkable with path bitkit://screen/spending-hw-sign/{walletId}/{orderId}.
  • Parses that path in ScreenDeepLinks.spendingHwSignLink via kebabId(Routes.SpendingHwSign::class).
  • Keeps SavingsProgress, SettingUp, SpendingAdvanced, SpendingConfirm, and SpendingHwSigned as InternalOnly. The rest of feat: deep link the late transfer screens #1126 stays a follow-up.

Preview

N/A

QA Notes

Dev mode is on by default on debug builds (Settings ▸ Advanced ▸ Dev Settings). The app must be past onboarding. The Sign path needs a paired hardware wallet and a live Blocktank order id.

Manual Tests

  • 1. Hardware Wallet detail → Transfer To Spending → Amount → Continue → Sign: lands on Sign With Your Device with the created order.
  • 2. From that Sign screen, adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/spending-hw-sign/<walletId>/<orderId>" to.bitkit.dev → Sign opens with the same order.
  • 3. Cold start the same URI → Sign opens with that order, not Home.
  • 4. Same path with an unknown wallet id or a missing order → screen unchanged, logcat carries Unhandled screen deeplink.
  • 5.regression:bitkit://screen/spending-amount-hw/<walletId> → Amount still opens.

Automated Checks

  • Unit tests added in ScreenDeepLinksTest.kt: path pattern, wallet and order segments, missing order id.
  • Unit tests added in TransferViewModelTest.kt: known wallet loads the named order, unknown wallet and missing order are refused, in-memory order is reused without a second fetch.
  • Local: just compile, just test, just lint all pass, no new detekt findings.

Comment threadapp/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt Fixed
@greptile-apps

Copy link
Copy Markdown

Greptile Summary

The PR adds debug-only deep-link navigation into the hardware-wallet spending-sign flow, restoring the requested Blocktank order before navigation.

  • Extends the sign route and order-created effect with an order ID.
  • Validates the wallet, loads or reuses the requested order, and adopts it into transfer state.
  • Adds focused parsing, route-contract, preparation, and regression tests.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete blocking or independently actionable non-blocking issue identified.

The deep link remains restricted by the existing debug and Dev Mode gates, prepares the exact requested order before navigation, rejects unavailable prerequisites, and keeps internal navigation synchronized through the new order ID.

Important Files Changed

FilenameOverview
app/src/main/java/to/bitkit/ui/ContentView.ktCoordinates order preparation before deep-link navigation and registers the sign destination with both route arguments.
app/src/main/java/to/bitkit/viewmodels/TransferViewModel.ktAdds validated order restoration, centralizes spending-order adoption, and includes the order ID in creation effects.
app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.ktParses the hardware sign route's wallet and order path segments while retaining existing screen-link gating.
app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.ktEnsures the in-memory order matches the route order before rendering the signing flow.
app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.ktCovers successful restoration, unknown wallets, missing orders, and reuse of a matching in-memory order.
app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.ktCovers the generated URI contract and extraction of both required route identifiers.

Sequence Diagram

sequenceDiagram
participant Intent as Screen deep link
participant AppVM as AppViewModel
participant Content as ContentView
participant TransferVM as TransferViewModel
participant Blocktank as BlocktankRepo
participant Nav as NavController
Intent->>AppVM: queue URI when debug runtime and Dev Mode permit
AppVM-->>Content: pendingScreenDeepLink
Content->>TransferVM: prepareSpendingHwSign(walletId, orderId)
alt matching order already in memory
TransferVM-->>Content: true
else order must be restored
TransferVM->>Blocktank: "getOrder(orderId, refresh = true)"
Blocktank-->>TransferVM: order or missing
TransferVM-->>Content: preparation result
end
alt prepared
Content->>Nav: handleDeepLink(uri)
else rejected
Content->>Content: log unhandled link
end
Content->>AppVM: consumeScreenDeepLink()
Loading

Reviews (1): Last reviewed commit: "fix: consume deeplink after prepare" | Re-trigger Greptile

@jvsena42jvsena42 self-assigned this Aug 27, 2026

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed and validated on a regtest emulator against the deterministic Trezor Bridge emulator from bitkit-docker (T2T1, seed all all ..., paired as BITKIT TEST TREZOR with 26,890,661 sats).

The deep link itself works. Both branches of prepareSpendingHwSign were exercised end to end:

  • fresh order id → refreshOrders runs, order is adopted, isAdvanced resets (Advanced button flips back from "Use Defaults"), sign screen opens with the right amounts;
  • already-current order id → short-circuits on current?.id == orderId and opens directly;
  • unknown wallet and unknown order are both refused, no navigation.

One blocking issue and three smaller ones inline.

val state by viewModel.spendingUiState.collectAsStateWithLifecycle()

val order = state.order ?: run {
val order = state.order?.takeIf { it.id == orderId } ?: run {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking — this guard breaks the existing Advanced flow.

orderId is a route arg, frozen when SpendingHwSign was pushed. But onAdvancedClick pushes Routes.SpendingAdvanced, and onSpendingAdvancedContinue calls blocktankRepo.createOrder(...) and stores a new order with a new id as spendingUiState.order (the old one moves to defaultOrder). SpendingAdvancedScreen's onOrderCreated is navController.popBackStack() — which lands back on SpendingHwSign still carrying the old id. takeIf yields null and the composable calls onCloseClick()navigateToHome().

Reproduced on device: HW detail → Transfer To Spending → 25% → Continue → Sign → Advanced → MAX → Continue lands on the wallet home screen, and the freshly created order is stranded. Logcat:

INFO [BlocktankRepo.kt:285] Buying channel with lspBalanceSat: '341987', ...
DEBUG [BlocktankRepo.kt:222] Orders refreshed: 7 orders, 0 cjit entries, 2 paid orders

and blocktank.db then holds 3c10c207-… Created 341987 110227 with nothing pointing at it.

Suggest accepting defaultOrder?.id == orderId as a match too, or applying the id check only on first entry (deep-link admission) rather than on every recomposition.

val current = _spendingUiState.value.order
if (current?.id == orderId) return true

val order = blocktankRepo.getOrder(orderId, refresh = true).getOrNull()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Worth confirming this is the intent: BlocktankRepo.getOrder refreshes and then searches _blocktankState.value.orders, i.e. only orders this install already tracks locally. An order that exists on the LSP but was never created on this device is always refused.

Verified on device — I created a valid order via the Blocktank API with this node's clientNodeId, then deep-linked to it:

WARN [TransferViewModel.kt:599] Refused spending hw sign deeplink, missing order 'c464a24c-…'

while a locally-created order id opened the sign screen fine. That's the right behaviour if the link is only ever meant to resume a transfer started on this device; it does mean a link handed over from another device or from support tooling can never resolve. Fine to leave as-is — just flagging it so the constraint is deliberate.

setTransferEffect(TransferEffect.OnOrderCreated(order.id))
}

suspend fun prepareSpendingHwSign(walletId: String, orderId: String): Boolean {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

prepareSpendingHwSignadoptSpendingOrder clears pendingHwFundingBroadcast and hasPendingHwBroadcast. If the user has already signed a HW funding tx for order X that failed to broadcast, onTransferToSpendingHwConfirm relies on pendingHwFundingBroadcast?.matches(...) to retry without re-prompting the device — a deep link naming a different order Y silently discards that in-memory signed transaction, making it unrecoverable.

Dev-mode only, so low severity, but cheap to guard: refuse the link (or skip the clobber) while hasPendingHwBroadcast is set.

ScreenDeepLinks.spendingHwSignLink(uri)?.let { link ->
val prepared = transferViewModel.prepareSpendingHwSign(link.walletId, link.orderId)
if (!prepared) {
Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This warn duplicates the specific reason already logged inside prepareSpendingHwSign, and reuses the exact wording of the generic !handled warn a few lines below. Observed on device — one refused link produces two WARN lines, the second of which says "Unhandled" when the link was in fact recognized and deliberately refused:

WARN [TransferViewModel.kt:591] Refused spending hw sign deeplink, unknown wallet 'foo'
WARN [ContentView.kt:328] Unhandled screen deeplink 'bitkit://screen/spending-hw-sign/foo/bar'

Per the repo rule (NEVER duplicate error logging in .onFailure {} if the called method already logs the same error internally), drop this line or reword it so it doesn't collide with the generic one.

Separately, consumeScreenDeepLink() now appears three times in this effect. It can't be hoisted to the top — that's what f405cd3 fixed, since the effect is keyed on pendingScreenDeepLink and consuming early cancels the coroutine mid-prepareSpendingHwSign — but the three calls can collapse into a single one at the end by turning the two early returns into an if/else chain.

fun isScreenDeepLink(uri: Uri): Boolean =
uri.scheme?.lowercase() == SCHEME && uri.host?.lowercase() == HOST

fun spendingHwSignLink(uri: Uri): SpendingHwSignLink? {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Unlike linksFor/sheetFor, this isn't routed through ScreenDeepLinkRuntime/isEnabled, so it parses and returns a link in release builds too. Currently harmless because AppViewModel.processDeeplink gates queueing on ScreenDeepLinks.shouldQueue(...), but that single call site is the only thing stopping a release build from mutating live transfer state from a dev-only URI. An if (!isEnabled) return null here would make it fail safe.

@ovitrif

Copy link
Copy Markdown
Collaborator

General note from briefly looking over review comments: it may not have been specified in the issues or past comments but this screen deeplinks work is more intended to aid in development with ai agents, for example: it could add the possibility to open a specific screen and continue from there. Maybe it should not be a requirement that the rest of the flow(s) would work correctly, or even if it tries to, it should only add it in logic specific to the handler of that screen deeplink; while the impact on production code should try to stay limited to parametrizing the screen inputs (strings, bools, etc, whatever is needed as starting state / to mutate UI)

Sign up for freeto 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.

4 participants

@guzino@ovitrif@jvsena42@github-advanced-security
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: deep link spending hw sign - #1176

Open
guzino wants to merge 4 commits into
synonymdev:masterfrom
guzino:feat/spending-hw-sign-deeplink
Open

feat: deep link spending hw sign#1176
guzino wants to merge 4 commits into
synonymdev:masterfrom
guzino:feat/spending-hw-sign-deeplink

Conversation

@guzino

Copy link
Copy Markdown
Contributor

Refs #1126
Refs #1119

This PR opens the hardware-wallet transfer Sign screen from bitkit://screen/spending-hw-sign/{walletId}/{orderId}.

Description

#1119 left six transfer destinations InternalOnly because they read activity-scoped TransferViewModel state. SpendingHwSign was the closest: it already took a wallet id (the issue still says deviceId) and bounced home when spendingUiState.order was null. A generated link therefore could not reconstruct the Blocktank order.

ContentView now parses that URI and calls prepareSpendingHwSign before navController.handleDeepLink. Unknown wallet or missing order is refused with the existing Unhandled screen deeplink warning and does not navigate. A matching in-memory order is reused; otherwise blocktankRepo.getOrder(orderId, refresh = true) loads it and adoptSpendingOrder writes the same state onOrderCreated already wrote, without emitting TransferEffect.OnOrderCreated. The pending URI is consumed after that suspend, so the LaunchedEffect is not cancelled mid-fetch.

OnOrderCreated now carries orderId. Amount → Sign navigates Routes.SpendingHwSign(walletId, orderId) from the effect. The dest reads both route args and matches state.order to orderId.

  • Promotes Routes.SpendingHwSign to DeepLinkable with path bitkit://screen/spending-hw-sign/{walletId}/{orderId}.
  • Parses that path in ScreenDeepLinks.spendingHwSignLink via kebabId(Routes.SpendingHwSign::class).
  • Keeps SavingsProgress, SettingUp, SpendingAdvanced, SpendingConfirm, and SpendingHwSigned as InternalOnly. The rest of feat: deep link the late transfer screens #1126 stays a follow-up.

Preview

N/A

QA Notes

Dev mode is on by default on debug builds (Settings ▸ Advanced ▸ Dev Settings). The app must be past onboarding. The Sign path needs a paired hardware wallet and a live Blocktank order id.

Manual Tests

  • 1. Hardware Wallet detail → Transfer To Spending → Amount → Continue → Sign: lands on Sign With Your Device with the created order.
  • 2. From that Sign screen, adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/spending-hw-sign/<walletId>/<orderId>" to.bitkit.dev → Sign opens with the same order.
  • 3. Cold start the same URI → Sign opens with that order, not Home.
  • 4. Same path with an unknown wallet id or a missing order → screen unchanged, logcat carries Unhandled screen deeplink.
  • 5.regression:bitkit://screen/spending-amount-hw/<walletId> → Amount still opens.

Automated Checks

  • Unit tests added in ScreenDeepLinksTest.kt: path pattern, wallet and order segments, missing order id.
  • Unit tests added in TransferViewModelTest.kt: known wallet loads the named order, unknown wallet and missing order are refused, in-memory order is reused without a second fetch.
  • Local: just compile, just test, just lint all pass, no new detekt findings.

Comment threadapp/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt Fixed
@greptile-apps

Copy link
Copy Markdown

Greptile Summary

The PR adds debug-only deep-link navigation into the hardware-wallet spending-sign flow, restoring the requested Blocktank order before navigation.

  • Extends the sign route and order-created effect with an order ID.
  • Validates the wallet, loads or reuses the requested order, and adopts it into transfer state.
  • Adds focused parsing, route-contract, preparation, and regression tests.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete blocking or independently actionable non-blocking issue identified.

The deep link remains restricted by the existing debug and Dev Mode gates, prepares the exact requested order before navigation, rejects unavailable prerequisites, and keeps internal navigation synchronized through the new order ID.

Important Files Changed

FilenameOverview
app/src/main/java/to/bitkit/ui/ContentView.ktCoordinates order preparation before deep-link navigation and registers the sign destination with both route arguments.
app/src/main/java/to/bitkit/viewmodels/TransferViewModel.ktAdds validated order restoration, centralizes spending-order adoption, and includes the order ID in creation effects.
app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.ktParses the hardware sign route's wallet and order path segments while retaining existing screen-link gating.
app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.ktEnsures the in-memory order matches the route order before rendering the signing flow.
app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.ktCovers successful restoration, unknown wallets, missing orders, and reuse of a matching in-memory order.
app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.ktCovers the generated URI contract and extraction of both required route identifiers.

Sequence Diagram

sequenceDiagram
participant Intent as Screen deep link
participant AppVM as AppViewModel
participant Content as ContentView
participant TransferVM as TransferViewModel
participant Blocktank as BlocktankRepo
participant Nav as NavController
Intent->>AppVM: queue URI when debug runtime and Dev Mode permit
AppVM-->>Content: pendingScreenDeepLink
Content->>TransferVM: prepareSpendingHwSign(walletId, orderId)
alt matching order already in memory
TransferVM-->>Content: true
else order must be restored
TransferVM->>Blocktank: "getOrder(orderId, refresh = true)"
Blocktank-->>TransferVM: order or missing
TransferVM-->>Content: preparation result
end
alt prepared
Content->>Nav: handleDeepLink(uri)
else rejected
Content->>Content: log unhandled link
end
Content->>AppVM: consumeScreenDeepLink()
Loading

Reviews (1): Last reviewed commit: "fix: consume deeplink after prepare" | Re-trigger Greptile

@jvsena42jvsena42 self-assigned this Aug 27, 2026

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed and validated on a regtest emulator against the deterministic Trezor Bridge emulator from bitkit-docker (T2T1, seed all all ..., paired as BITKIT TEST TREZOR with 26,890,661 sats).

The deep link itself works. Both branches of prepareSpendingHwSign were exercised end to end:

  • fresh order id → refreshOrders runs, order is adopted, isAdvanced resets (Advanced button flips back from "Use Defaults"), sign screen opens with the right amounts;
  • already-current order id → short-circuits on current?.id == orderId and opens directly;
  • unknown wallet and unknown order are both refused, no navigation.

One blocking issue and three smaller ones inline.

val state by viewModel.spendingUiState.collectAsStateWithLifecycle()

val order = state.order ?: run {
val order = state.order?.takeIf { it.id == orderId } ?: run {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking — this guard breaks the existing Advanced flow.

orderId is a route arg, frozen when SpendingHwSign was pushed. But onAdvancedClick pushes Routes.SpendingAdvanced, and onSpendingAdvancedContinue calls blocktankRepo.createOrder(...) and stores a new order with a new id as spendingUiState.order (the old one moves to defaultOrder). SpendingAdvancedScreen's onOrderCreated is navController.popBackStack() — which lands back on SpendingHwSign still carrying the old id. takeIf yields null and the composable calls onCloseClick()navigateToHome().

Reproduced on device: HW detail → Transfer To Spending → 25% → Continue → Sign → Advanced → MAX → Continue lands on the wallet home screen, and the freshly created order is stranded. Logcat:

INFO [BlocktankRepo.kt:285] Buying channel with lspBalanceSat: '341987', ...
DEBUG [BlocktankRepo.kt:222] Orders refreshed: 7 orders, 0 cjit entries, 2 paid orders

and blocktank.db then holds 3c10c207-… Created 341987 110227 with nothing pointing at it.

Suggest accepting defaultOrder?.id == orderId as a match too, or applying the id check only on first entry (deep-link admission) rather than on every recomposition.

val current = _spendingUiState.value.order
if (current?.id == orderId) return true

val order = blocktankRepo.getOrder(orderId, refresh = true).getOrNull()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Worth confirming this is the intent: BlocktankRepo.getOrder refreshes and then searches _blocktankState.value.orders, i.e. only orders this install already tracks locally. An order that exists on the LSP but was never created on this device is always refused.

Verified on device — I created a valid order via the Blocktank API with this node's clientNodeId, then deep-linked to it:

WARN [TransferViewModel.kt:599] Refused spending hw sign deeplink, missing order 'c464a24c-…'

while a locally-created order id opened the sign screen fine. That's the right behaviour if the link is only ever meant to resume a transfer started on this device; it does mean a link handed over from another device or from support tooling can never resolve. Fine to leave as-is — just flagging it so the constraint is deliberate.

setTransferEffect(TransferEffect.OnOrderCreated(order.id))
}

suspend fun prepareSpendingHwSign(walletId: String, orderId: String): Boolean {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

prepareSpendingHwSignadoptSpendingOrder clears pendingHwFundingBroadcast and hasPendingHwBroadcast. If the user has already signed a HW funding tx for order X that failed to broadcast, onTransferToSpendingHwConfirm relies on pendingHwFundingBroadcast?.matches(...) to retry without re-prompting the device — a deep link naming a different order Y silently discards that in-memory signed transaction, making it unrecoverable.

Dev-mode only, so low severity, but cheap to guard: refuse the link (or skip the clobber) while hasPendingHwBroadcast is set.

ScreenDeepLinks.spendingHwSignLink(uri)?.let { link ->
val prepared = transferViewModel.prepareSpendingHwSign(link.walletId, link.orderId)
if (!prepared) {
Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This warn duplicates the specific reason already logged inside prepareSpendingHwSign, and reuses the exact wording of the generic !handled warn a few lines below. Observed on device — one refused link produces two WARN lines, the second of which says "Unhandled" when the link was in fact recognized and deliberately refused:

WARN [TransferViewModel.kt:591] Refused spending hw sign deeplink, unknown wallet 'foo'
WARN [ContentView.kt:328] Unhandled screen deeplink 'bitkit://screen/spending-hw-sign/foo/bar'

Per the repo rule (NEVER duplicate error logging in .onFailure {} if the called method already logs the same error internally), drop this line or reword it so it doesn't collide with the generic one.

Separately, consumeScreenDeepLink() now appears three times in this effect. It can't be hoisted to the top — that's what f405cd3 fixed, since the effect is keyed on pendingScreenDeepLink and consuming early cancels the coroutine mid-prepareSpendingHwSign — but the three calls can collapse into a single one at the end by turning the two early returns into an if/else chain.

fun isScreenDeepLink(uri: Uri): Boolean =
uri.scheme?.lowercase() == SCHEME && uri.host?.lowercase() == HOST

fun spendingHwSignLink(uri: Uri): SpendingHwSignLink? {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Unlike linksFor/sheetFor, this isn't routed through ScreenDeepLinkRuntime/isEnabled, so it parses and returns a link in release builds too. Currently harmless because AppViewModel.processDeeplink gates queueing on ScreenDeepLinks.shouldQueue(...), but that single call site is the only thing stopping a release build from mutating live transfer state from a dev-only URI. An if (!isEnabled) return null here would make it fail safe.

@ovitrif

Copy link
Copy Markdown
Collaborator

General note from briefly looking over review comments: it may not have been specified in the issues or past comments but this screen deeplinks work is more intended to aid in development with ai agents, for example: it could add the possibility to open a specific screen and continue from there. Maybe it should not be a requirement that the rest of the flow(s) would work correctly, or even if it tries to, it should only add it in logic specific to the handler of that screen deeplink; while the impact on production code should try to stay limited to parametrizing the screen inputs (strings, bools, etc, whatever is needed as starting state / to mutate UI)

Sign up for freeto 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.

4 participants

@guzino@ovitrif@jvsena42@github-advanced-security
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat: deep link spending hw sign - #1176

Open
guzino wants to merge 4 commits into
synonymdev:masterfrom
guzino:feat/spending-hw-sign-deeplink
Open

feat: deep link spending hw sign#1176
guzino wants to merge 4 commits into
synonymdev:masterfrom
guzino:feat/spending-hw-sign-deeplink

Conversation

@guzino

Copy link
Copy Markdown
Contributor

Refs #1126
Refs #1119

This PR opens the hardware-wallet transfer Sign screen from bitkit://screen/spending-hw-sign/{walletId}/{orderId}.

Description

#1119 left six transfer destinations InternalOnly because they read activity-scoped TransferViewModel state. SpendingHwSign was the closest: it already took a wallet id (the issue still says deviceId) and bounced home when spendingUiState.order was null. A generated link therefore could not reconstruct the Blocktank order.

ContentView now parses that URI and calls prepareSpendingHwSign before navController.handleDeepLink. Unknown wallet or missing order is refused with the existing Unhandled screen deeplink warning and does not navigate. A matching in-memory order is reused; otherwise blocktankRepo.getOrder(orderId, refresh = true) loads it and adoptSpendingOrder writes the same state onOrderCreated already wrote, without emitting TransferEffect.OnOrderCreated. The pending URI is consumed after that suspend, so the LaunchedEffect is not cancelled mid-fetch.

OnOrderCreated now carries orderId. Amount → Sign navigates Routes.SpendingHwSign(walletId, orderId) from the effect. The dest reads both route args and matches state.order to orderId.

  • Promotes Routes.SpendingHwSign to DeepLinkable with path bitkit://screen/spending-hw-sign/{walletId}/{orderId}.
  • Parses that path in ScreenDeepLinks.spendingHwSignLink via kebabId(Routes.SpendingHwSign::class).
  • Keeps SavingsProgress, SettingUp, SpendingAdvanced, SpendingConfirm, and SpendingHwSigned as InternalOnly. The rest of feat: deep link the late transfer screens #1126 stays a follow-up.

Preview

N/A

QA Notes

Dev mode is on by default on debug builds (Settings ▸ Advanced ▸ Dev Settings). The app must be past onboarding. The Sign path needs a paired hardware wallet and a live Blocktank order id.

Manual Tests

  • 1. Hardware Wallet detail → Transfer To Spending → Amount → Continue → Sign: lands on Sign With Your Device with the created order.
  • 2. From that Sign screen, adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/spending-hw-sign/<walletId>/<orderId>" to.bitkit.dev → Sign opens with the same order.
  • 3. Cold start the same URI → Sign opens with that order, not Home.
  • 4. Same path with an unknown wallet id or a missing order → screen unchanged, logcat carries Unhandled screen deeplink.
  • 5.regression:bitkit://screen/spending-amount-hw/<walletId> → Amount still opens.

Automated Checks

  • Unit tests added in ScreenDeepLinksTest.kt: path pattern, wallet and order segments, missing order id.
  • Unit tests added in TransferViewModelTest.kt: known wallet loads the named order, unknown wallet and missing order are refused, in-memory order is reused without a second fetch.
  • Local: just compile, just test, just lint all pass, no new detekt findings.

Comment threadapp/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt Fixed
@greptile-apps

Copy link
Copy Markdown

Greptile Summary

The PR adds debug-only deep-link navigation into the hardware-wallet spending-sign flow, restoring the requested Blocktank order before navigation.

  • Extends the sign route and order-created effect with an order ID.
  • Validates the wallet, loads or reuses the requested order, and adopts it into transfer state.
  • Adds focused parsing, route-contract, preparation, and regression tests.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete blocking or independently actionable non-blocking issue identified.

The deep link remains restricted by the existing debug and Dev Mode gates, prepares the exact requested order before navigation, rejects unavailable prerequisites, and keeps internal navigation synchronized through the new order ID.

Important Files Changed

FilenameOverview
app/src/main/java/to/bitkit/ui/ContentView.ktCoordinates order preparation before deep-link navigation and registers the sign destination with both route arguments.
app/src/main/java/to/bitkit/viewmodels/TransferViewModel.ktAdds validated order restoration, centralizes spending-order adoption, and includes the order ID in creation effects.
app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.ktParses the hardware sign route's wallet and order path segments while retaining existing screen-link gating.
app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.ktEnsures the in-memory order matches the route order before rendering the signing flow.
app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.ktCovers successful restoration, unknown wallets, missing orders, and reuse of a matching in-memory order.
app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.ktCovers the generated URI contract and extraction of both required route identifiers.

Sequence Diagram

sequenceDiagram
participant Intent as Screen deep link
participant AppVM as AppViewModel
participant Content as ContentView
participant TransferVM as TransferViewModel
participant Blocktank as BlocktankRepo
participant Nav as NavController
Intent->>AppVM: queue URI when debug runtime and Dev Mode permit
AppVM-->>Content: pendingScreenDeepLink
Content->>TransferVM: prepareSpendingHwSign(walletId, orderId)
alt matching order already in memory
TransferVM-->>Content: true
else order must be restored
TransferVM->>Blocktank: "getOrder(orderId, refresh = true)"
Blocktank-->>TransferVM: order or missing
TransferVM-->>Content: preparation result
end
alt prepared
Content->>Nav: handleDeepLink(uri)
else rejected
Content->>Content: log unhandled link
end
Content->>AppVM: consumeScreenDeepLink()
Loading

Reviews (1): Last reviewed commit: "fix: consume deeplink after prepare" | Re-trigger Greptile

@jvsena42jvsena42 self-assigned this Aug 27, 2026

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed and validated on a regtest emulator against the deterministic Trezor Bridge emulator from bitkit-docker (T2T1, seed all all ..., paired as BITKIT TEST TREZOR with 26,890,661 sats).

The deep link itself works. Both branches of prepareSpendingHwSign were exercised end to end:

  • fresh order id → refreshOrders runs, order is adopted, isAdvanced resets (Advanced button flips back from "Use Defaults"), sign screen opens with the right amounts;
  • already-current order id → short-circuits on current?.id == orderId and opens directly;
  • unknown wallet and unknown order are both refused, no navigation.

One blocking issue and three smaller ones inline.

val state by viewModel.spendingUiState.collectAsStateWithLifecycle()

val order = state.order ?: run {
val order = state.order?.takeIf { it.id == orderId } ?: run {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking — this guard breaks the existing Advanced flow.

orderId is a route arg, frozen when SpendingHwSign was pushed. But onAdvancedClick pushes Routes.SpendingAdvanced, and onSpendingAdvancedContinue calls blocktankRepo.createOrder(...) and stores a new order with a new id as spendingUiState.order (the old one moves to defaultOrder). SpendingAdvancedScreen's onOrderCreated is navController.popBackStack() — which lands back on SpendingHwSign still carrying the old id. takeIf yields null and the composable calls onCloseClick()navigateToHome().

Reproduced on device: HW detail → Transfer To Spending → 25% → Continue → Sign → Advanced → MAX → Continue lands on the wallet home screen, and the freshly created order is stranded. Logcat:

INFO [BlocktankRepo.kt:285] Buying channel with lspBalanceSat: '341987', ...
DEBUG [BlocktankRepo.kt:222] Orders refreshed: 7 orders, 0 cjit entries, 2 paid orders

and blocktank.db then holds 3c10c207-… Created 341987 110227 with nothing pointing at it.

Suggest accepting defaultOrder?.id == orderId as a match too, or applying the id check only on first entry (deep-link admission) rather than on every recomposition.

val current = _spendingUiState.value.order
if (current?.id == orderId) return true

val order = blocktankRepo.getOrder(orderId, refresh = true).getOrNull()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Worth confirming this is the intent: BlocktankRepo.getOrder refreshes and then searches _blocktankState.value.orders, i.e. only orders this install already tracks locally. An order that exists on the LSP but was never created on this device is always refused.

Verified on device — I created a valid order via the Blocktank API with this node's clientNodeId, then deep-linked to it:

WARN [TransferViewModel.kt:599] Refused spending hw sign deeplink, missing order 'c464a24c-…'

while a locally-created order id opened the sign screen fine. That's the right behaviour if the link is only ever meant to resume a transfer started on this device; it does mean a link handed over from another device or from support tooling can never resolve. Fine to leave as-is — just flagging it so the constraint is deliberate.

setTransferEffect(TransferEffect.OnOrderCreated(order.id))
}

suspend fun prepareSpendingHwSign(walletId: String, orderId: String): Boolean {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

prepareSpendingHwSignadoptSpendingOrder clears pendingHwFundingBroadcast and hasPendingHwBroadcast. If the user has already signed a HW funding tx for order X that failed to broadcast, onTransferToSpendingHwConfirm relies on pendingHwFundingBroadcast?.matches(...) to retry without re-prompting the device — a deep link naming a different order Y silently discards that in-memory signed transaction, making it unrecoverable.

Dev-mode only, so low severity, but cheap to guard: refuse the link (or skip the clobber) while hasPendingHwBroadcast is set.

ScreenDeepLinks.spendingHwSignLink(uri)?.let { link ->
val prepared = transferViewModel.prepareSpendingHwSign(link.walletId, link.orderId)
if (!prepared) {
Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This warn duplicates the specific reason already logged inside prepareSpendingHwSign, and reuses the exact wording of the generic !handled warn a few lines below. Observed on device — one refused link produces two WARN lines, the second of which says "Unhandled" when the link was in fact recognized and deliberately refused:

WARN [TransferViewModel.kt:591] Refused spending hw sign deeplink, unknown wallet 'foo'
WARN [ContentView.kt:328] Unhandled screen deeplink 'bitkit://screen/spending-hw-sign/foo/bar'

Per the repo rule (NEVER duplicate error logging in .onFailure {} if the called method already logs the same error internally), drop this line or reword it so it doesn't collide with the generic one.

Separately, consumeScreenDeepLink() now appears three times in this effect. It can't be hoisted to the top — that's what f405cd3 fixed, since the effect is keyed on pendingScreenDeepLink and consuming early cancels the coroutine mid-prepareSpendingHwSign — but the three calls can collapse into a single one at the end by turning the two early returns into an if/else chain.

fun isScreenDeepLink(uri: Uri): Boolean =
uri.scheme?.lowercase() == SCHEME && uri.host?.lowercase() == HOST

fun spendingHwSignLink(uri: Uri): SpendingHwSignLink? {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Unlike linksFor/sheetFor, this isn't routed through ScreenDeepLinkRuntime/isEnabled, so it parses and returns a link in release builds too. Currently harmless because AppViewModel.processDeeplink gates queueing on ScreenDeepLinks.shouldQueue(...), but that single call site is the only thing stopping a release build from mutating live transfer state from a dev-only URI. An if (!isEnabled) return null here would make it fail safe.

@ovitrif

Copy link
Copy Markdown
Collaborator

General note from briefly looking over review comments: it may not have been specified in the issues or past comments but this screen deeplinks work is more intended to aid in development with ai agents, for example: it could add the possibility to open a specific screen and continue from there. Maybe it should not be a requirement that the rest of the flow(s) would work correctly, or even if it tries to, it should only add it in logic specific to the handler of that screen deeplink; while the impact on production code should try to stay limited to parametrizing the screen inputs (strings, bools, etc, whatever is needed as starting state / to mutate UI)

Sign up for freeto 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.

4 participants

@guzino@ovitrif@jvsena42@github-advanced-security
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: deep link spending hw sign - #1176

Open
guzino wants to merge 4 commits into
synonymdev:masterfrom
guzino:feat/spending-hw-sign-deeplink
Open

feat: deep link spending hw sign#1176
guzino wants to merge 4 commits into
synonymdev:masterfrom
guzino:feat/spending-hw-sign-deeplink

Conversation

@guzino

Copy link
Copy Markdown
Contributor

Refs #1126
Refs #1119

This PR opens the hardware-wallet transfer Sign screen from bitkit://screen/spending-hw-sign/{walletId}/{orderId}.

Description

#1119 left six transfer destinations InternalOnly because they read activity-scoped TransferViewModel state. SpendingHwSign was the closest: it already took a wallet id (the issue still says deviceId) and bounced home when spendingUiState.order was null. A generated link therefore could not reconstruct the Blocktank order.

ContentView now parses that URI and calls prepareSpendingHwSign before navController.handleDeepLink. Unknown wallet or missing order is refused with the existing Unhandled screen deeplink warning and does not navigate. A matching in-memory order is reused; otherwise blocktankRepo.getOrder(orderId, refresh = true) loads it and adoptSpendingOrder writes the same state onOrderCreated already wrote, without emitting TransferEffect.OnOrderCreated. The pending URI is consumed after that suspend, so the LaunchedEffect is not cancelled mid-fetch.

OnOrderCreated now carries orderId. Amount → Sign navigates Routes.SpendingHwSign(walletId, orderId) from the effect. The dest reads both route args and matches state.order to orderId.

  • Promotes Routes.SpendingHwSign to DeepLinkable with path bitkit://screen/spending-hw-sign/{walletId}/{orderId}.
  • Parses that path in ScreenDeepLinks.spendingHwSignLink via kebabId(Routes.SpendingHwSign::class).
  • Keeps SavingsProgress, SettingUp, SpendingAdvanced, SpendingConfirm, and SpendingHwSigned as InternalOnly. The rest of feat: deep link the late transfer screens #1126 stays a follow-up.

Preview

N/A

QA Notes

Dev mode is on by default on debug builds (Settings ▸ Advanced ▸ Dev Settings). The app must be past onboarding. The Sign path needs a paired hardware wallet and a live Blocktank order id.

Manual Tests

  • 1. Hardware Wallet detail → Transfer To Spending → Amount → Continue → Sign: lands on Sign With Your Device with the created order.
  • 2. From that Sign screen, adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/spending-hw-sign/<walletId>/<orderId>" to.bitkit.dev → Sign opens with the same order.
  • 3. Cold start the same URI → Sign opens with that order, not Home.
  • 4. Same path with an unknown wallet id or a missing order → screen unchanged, logcat carries Unhandled screen deeplink.
  • 5.regression:bitkit://screen/spending-amount-hw/<walletId> → Amount still opens.

Automated Checks

  • Unit tests added in ScreenDeepLinksTest.kt: path pattern, wallet and order segments, missing order id.
  • Unit tests added in TransferViewModelTest.kt: known wallet loads the named order, unknown wallet and missing order are refused, in-memory order is reused without a second fetch.
  • Local: just compile, just test, just lint all pass, no new detekt findings.

Comment threadapp/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt Fixed
@greptile-apps

Copy link
Copy Markdown

Greptile Summary

The PR adds debug-only deep-link navigation into the hardware-wallet spending-sign flow, restoring the requested Blocktank order before navigation.

  • Extends the sign route and order-created effect with an order ID.
  • Validates the wallet, loads or reuses the requested order, and adopts it into transfer state.
  • Adds focused parsing, route-contract, preparation, and regression tests.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete blocking or independently actionable non-blocking issue identified.

The deep link remains restricted by the existing debug and Dev Mode gates, prepares the exact requested order before navigation, rejects unavailable prerequisites, and keeps internal navigation synchronized through the new order ID.

Important Files Changed

FilenameOverview
app/src/main/java/to/bitkit/ui/ContentView.ktCoordinates order preparation before deep-link navigation and registers the sign destination with both route arguments.
app/src/main/java/to/bitkit/viewmodels/TransferViewModel.ktAdds validated order restoration, centralizes spending-order adoption, and includes the order ID in creation effects.
app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.ktParses the hardware sign route's wallet and order path segments while retaining existing screen-link gating.
app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.ktEnsures the in-memory order matches the route order before rendering the signing flow.
app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.ktCovers successful restoration, unknown wallets, missing orders, and reuse of a matching in-memory order.
app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.ktCovers the generated URI contract and extraction of both required route identifiers.

Sequence Diagram

sequenceDiagram
participant Intent as Screen deep link
participant AppVM as AppViewModel
participant Content as ContentView
participant TransferVM as TransferViewModel
participant Blocktank as BlocktankRepo
participant Nav as NavController
Intent->>AppVM: queue URI when debug runtime and Dev Mode permit
AppVM-->>Content: pendingScreenDeepLink
Content->>TransferVM: prepareSpendingHwSign(walletId, orderId)
alt matching order already in memory
TransferVM-->>Content: true
else order must be restored
TransferVM->>Blocktank: "getOrder(orderId, refresh = true)"
Blocktank-->>TransferVM: order or missing
TransferVM-->>Content: preparation result
end
alt prepared
Content->>Nav: handleDeepLink(uri)
else rejected
Content->>Content: log unhandled link
end
Content->>AppVM: consumeScreenDeepLink()
Loading

Reviews (1): Last reviewed commit: "fix: consume deeplink after prepare" | Re-trigger Greptile

@jvsena42jvsena42 self-assigned this Aug 27, 2026

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed and validated on a regtest emulator against the deterministic Trezor Bridge emulator from bitkit-docker (T2T1, seed all all ..., paired as BITKIT TEST TREZOR with 26,890,661 sats).

The deep link itself works. Both branches of prepareSpendingHwSign were exercised end to end:

  • fresh order id → refreshOrders runs, order is adopted, isAdvanced resets (Advanced button flips back from "Use Defaults"), sign screen opens with the right amounts;
  • already-current order id → short-circuits on current?.id == orderId and opens directly;
  • unknown wallet and unknown order are both refused, no navigation.

One blocking issue and three smaller ones inline.

val state by viewModel.spendingUiState.collectAsStateWithLifecycle()

val order = state.order ?: run {
val order = state.order?.takeIf { it.id == orderId } ?: run {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking — this guard breaks the existing Advanced flow.

orderId is a route arg, frozen when SpendingHwSign was pushed. But onAdvancedClick pushes Routes.SpendingAdvanced, and onSpendingAdvancedContinue calls blocktankRepo.createOrder(...) and stores a new order with a new id as spendingUiState.order (the old one moves to defaultOrder). SpendingAdvancedScreen's onOrderCreated is navController.popBackStack() — which lands back on SpendingHwSign still carrying the old id. takeIf yields null and the composable calls onCloseClick()navigateToHome().

Reproduced on device: HW detail → Transfer To Spending → 25% → Continue → Sign → Advanced → MAX → Continue lands on the wallet home screen, and the freshly created order is stranded. Logcat:

INFO [BlocktankRepo.kt:285] Buying channel with lspBalanceSat: '341987', ...
DEBUG [BlocktankRepo.kt:222] Orders refreshed: 7 orders, 0 cjit entries, 2 paid orders

and blocktank.db then holds 3c10c207-… Created 341987 110227 with nothing pointing at it.

Suggest accepting defaultOrder?.id == orderId as a match too, or applying the id check only on first entry (deep-link admission) rather than on every recomposition.

val current = _spendingUiState.value.order
if (current?.id == orderId) return true

val order = blocktankRepo.getOrder(orderId, refresh = true).getOrNull()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Worth confirming this is the intent: BlocktankRepo.getOrder refreshes and then searches _blocktankState.value.orders, i.e. only orders this install already tracks locally. An order that exists on the LSP but was never created on this device is always refused.

Verified on device — I created a valid order via the Blocktank API with this node's clientNodeId, then deep-linked to it:

WARN [TransferViewModel.kt:599] Refused spending hw sign deeplink, missing order 'c464a24c-…'

while a locally-created order id opened the sign screen fine. That's the right behaviour if the link is only ever meant to resume a transfer started on this device; it does mean a link handed over from another device or from support tooling can never resolve. Fine to leave as-is — just flagging it so the constraint is deliberate.

setTransferEffect(TransferEffect.OnOrderCreated(order.id))
}

suspend fun prepareSpendingHwSign(walletId: String, orderId: String): Boolean {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

prepareSpendingHwSignadoptSpendingOrder clears pendingHwFundingBroadcast and hasPendingHwBroadcast. If the user has already signed a HW funding tx for order X that failed to broadcast, onTransferToSpendingHwConfirm relies on pendingHwFundingBroadcast?.matches(...) to retry without re-prompting the device — a deep link naming a different order Y silently discards that in-memory signed transaction, making it unrecoverable.

Dev-mode only, so low severity, but cheap to guard: refuse the link (or skip the clobber) while hasPendingHwBroadcast is set.

ScreenDeepLinks.spendingHwSignLink(uri)?.let { link ->
val prepared = transferViewModel.prepareSpendingHwSign(link.walletId, link.orderId)
if (!prepared) {
Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This warn duplicates the specific reason already logged inside prepareSpendingHwSign, and reuses the exact wording of the generic !handled warn a few lines below. Observed on device — one refused link produces two WARN lines, the second of which says "Unhandled" when the link was in fact recognized and deliberately refused:

WARN [TransferViewModel.kt:591] Refused spending hw sign deeplink, unknown wallet 'foo'
WARN [ContentView.kt:328] Unhandled screen deeplink 'bitkit://screen/spending-hw-sign/foo/bar'

Per the repo rule (NEVER duplicate error logging in .onFailure {} if the called method already logs the same error internally), drop this line or reword it so it doesn't collide with the generic one.

Separately, consumeScreenDeepLink() now appears three times in this effect. It can't be hoisted to the top — that's what f405cd3 fixed, since the effect is keyed on pendingScreenDeepLink and consuming early cancels the coroutine mid-prepareSpendingHwSign — but the three calls can collapse into a single one at the end by turning the two early returns into an if/else chain.

fun isScreenDeepLink(uri: Uri): Boolean =
uri.scheme?.lowercase() == SCHEME && uri.host?.lowercase() == HOST

fun spendingHwSignLink(uri: Uri): SpendingHwSignLink? {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Unlike linksFor/sheetFor, this isn't routed through ScreenDeepLinkRuntime/isEnabled, so it parses and returns a link in release builds too. Currently harmless because AppViewModel.processDeeplink gates queueing on ScreenDeepLinks.shouldQueue(...), but that single call site is the only thing stopping a release build from mutating live transfer state from a dev-only URI. An if (!isEnabled) return null here would make it fail safe.

@ovitrif

Copy link
Copy Markdown
Collaborator

General note from briefly looking over review comments: it may not have been specified in the issues or past comments but this screen deeplinks work is more intended to aid in development with ai agents, for example: it could add the possibility to open a specific screen and continue from there. Maybe it should not be a requirement that the rest of the flow(s) would work correctly, or even if it tries to, it should only add it in logic specific to the handler of that screen deeplink; while the impact on production code should try to stay limited to parametrizing the screen inputs (strings, bools, etc, whatever is needed as starting state / to mutate UI)

Sign up for freeto 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.

4 participants

@guzino@ovitrif@jvsena42@github-advanced-security
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: deep link spending hw sign - #1176

Open
guzino wants to merge 4 commits into
synonymdev:masterfrom
guzino:feat/spending-hw-sign-deeplink
Open

feat: deep link spending hw sign#1176
guzino wants to merge 4 commits into
synonymdev:masterfrom
guzino:feat/spending-hw-sign-deeplink

Conversation

@guzino

Copy link
Copy Markdown
Contributor

Refs #1126
Refs #1119

This PR opens the hardware-wallet transfer Sign screen from bitkit://screen/spending-hw-sign/{walletId}/{orderId}.

Description

#1119 left six transfer destinations InternalOnly because they read activity-scoped TransferViewModel state. SpendingHwSign was the closest: it already took a wallet id (the issue still says deviceId) and bounced home when spendingUiState.order was null. A generated link therefore could not reconstruct the Blocktank order.

ContentView now parses that URI and calls prepareSpendingHwSign before navController.handleDeepLink. Unknown wallet or missing order is refused with the existing Unhandled screen deeplink warning and does not navigate. A matching in-memory order is reused; otherwise blocktankRepo.getOrder(orderId, refresh = true) loads it and adoptSpendingOrder writes the same state onOrderCreated already wrote, without emitting TransferEffect.OnOrderCreated. The pending URI is consumed after that suspend, so the LaunchedEffect is not cancelled mid-fetch.

OnOrderCreated now carries orderId. Amount → Sign navigates Routes.SpendingHwSign(walletId, orderId) from the effect. The dest reads both route args and matches state.order to orderId.

  • Promotes Routes.SpendingHwSign to DeepLinkable with path bitkit://screen/spending-hw-sign/{walletId}/{orderId}.
  • Parses that path in ScreenDeepLinks.spendingHwSignLink via kebabId(Routes.SpendingHwSign::class).
  • Keeps SavingsProgress, SettingUp, SpendingAdvanced, SpendingConfirm, and SpendingHwSigned as InternalOnly. The rest of feat: deep link the late transfer screens #1126 stays a follow-up.

Preview

N/A

QA Notes

Dev mode is on by default on debug builds (Settings ▸ Advanced ▸ Dev Settings). The app must be past onboarding. The Sign path needs a paired hardware wallet and a live Blocktank order id.

Manual Tests

  • 1. Hardware Wallet detail → Transfer To Spending → Amount → Continue → Sign: lands on Sign With Your Device with the created order.
  • 2. From that Sign screen, adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/spending-hw-sign/<walletId>/<orderId>" to.bitkit.dev → Sign opens with the same order.
  • 3. Cold start the same URI → Sign opens with that order, not Home.
  • 4. Same path with an unknown wallet id or a missing order → screen unchanged, logcat carries Unhandled screen deeplink.
  • 5.regression:bitkit://screen/spending-amount-hw/<walletId> → Amount still opens.

Automated Checks

  • Unit tests added in ScreenDeepLinksTest.kt: path pattern, wallet and order segments, missing order id.
  • Unit tests added in TransferViewModelTest.kt: known wallet loads the named order, unknown wallet and missing order are refused, in-memory order is reused without a second fetch.
  • Local: just compile, just test, just lint all pass, no new detekt findings.

Comment threadapp/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt Fixed
@greptile-apps

Copy link
Copy Markdown

Greptile Summary

The PR adds debug-only deep-link navigation into the hardware-wallet spending-sign flow, restoring the requested Blocktank order before navigation.

  • Extends the sign route and order-created effect with an order ID.
  • Validates the wallet, loads or reuses the requested order, and adopts it into transfer state.
  • Adds focused parsing, route-contract, preparation, and regression tests.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete blocking or independently actionable non-blocking issue identified.

The deep link remains restricted by the existing debug and Dev Mode gates, prepares the exact requested order before navigation, rejects unavailable prerequisites, and keeps internal navigation synchronized through the new order ID.

Important Files Changed

FilenameOverview
app/src/main/java/to/bitkit/ui/ContentView.ktCoordinates order preparation before deep-link navigation and registers the sign destination with both route arguments.
app/src/main/java/to/bitkit/viewmodels/TransferViewModel.ktAdds validated order restoration, centralizes spending-order adoption, and includes the order ID in creation effects.
app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.ktParses the hardware sign route's wallet and order path segments while retaining existing screen-link gating.
app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.ktEnsures the in-memory order matches the route order before rendering the signing flow.
app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.ktCovers successful restoration, unknown wallets, missing orders, and reuse of a matching in-memory order.
app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.ktCovers the generated URI contract and extraction of both required route identifiers.

Sequence Diagram

sequenceDiagram
participant Intent as Screen deep link
participant AppVM as AppViewModel
participant Content as ContentView
participant TransferVM as TransferViewModel
participant Blocktank as BlocktankRepo
participant Nav as NavController
Intent->>AppVM: queue URI when debug runtime and Dev Mode permit
AppVM-->>Content: pendingScreenDeepLink
Content->>TransferVM: prepareSpendingHwSign(walletId, orderId)
alt matching order already in memory
TransferVM-->>Content: true
else order must be restored
TransferVM->>Blocktank: "getOrder(orderId, refresh = true)"
Blocktank-->>TransferVM: order or missing
TransferVM-->>Content: preparation result
end
alt prepared
Content->>Nav: handleDeepLink(uri)
else rejected
Content->>Content: log unhandled link
end
Content->>AppVM: consumeScreenDeepLink()
Loading

Reviews (1): Last reviewed commit: "fix: consume deeplink after prepare" | Re-trigger Greptile

@jvsena42jvsena42 self-assigned this Aug 27, 2026

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed and validated on a regtest emulator against the deterministic Trezor Bridge emulator from bitkit-docker (T2T1, seed all all ..., paired as BITKIT TEST TREZOR with 26,890,661 sats).

The deep link itself works. Both branches of prepareSpendingHwSign were exercised end to end:

  • fresh order id → refreshOrders runs, order is adopted, isAdvanced resets (Advanced button flips back from "Use Defaults"), sign screen opens with the right amounts;
  • already-current order id → short-circuits on current?.id == orderId and opens directly;
  • unknown wallet and unknown order are both refused, no navigation.

One blocking issue and three smaller ones inline.

val state by viewModel.spendingUiState.collectAsStateWithLifecycle()

val order = state.order ?: run {
val order = state.order?.takeIf { it.id == orderId } ?: run {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking — this guard breaks the existing Advanced flow.

orderId is a route arg, frozen when SpendingHwSign was pushed. But onAdvancedClick pushes Routes.SpendingAdvanced, and onSpendingAdvancedContinue calls blocktankRepo.createOrder(...) and stores a new order with a new id as spendingUiState.order (the old one moves to defaultOrder). SpendingAdvancedScreen's onOrderCreated is navController.popBackStack() — which lands back on SpendingHwSign still carrying the old id. takeIf yields null and the composable calls onCloseClick()navigateToHome().

Reproduced on device: HW detail → Transfer To Spending → 25% → Continue → Sign → Advanced → MAX → Continue lands on the wallet home screen, and the freshly created order is stranded. Logcat:

INFO [BlocktankRepo.kt:285] Buying channel with lspBalanceSat: '341987', ...
DEBUG [BlocktankRepo.kt:222] Orders refreshed: 7 orders, 0 cjit entries, 2 paid orders

and blocktank.db then holds 3c10c207-… Created 341987 110227 with nothing pointing at it.

Suggest accepting defaultOrder?.id == orderId as a match too, or applying the id check only on first entry (deep-link admission) rather than on every recomposition.

val current = _spendingUiState.value.order
if (current?.id == orderId) return true

val order = blocktankRepo.getOrder(orderId, refresh = true).getOrNull()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Worth confirming this is the intent: BlocktankRepo.getOrder refreshes and then searches _blocktankState.value.orders, i.e. only orders this install already tracks locally. An order that exists on the LSP but was never created on this device is always refused.

Verified on device — I created a valid order via the Blocktank API with this node's clientNodeId, then deep-linked to it:

WARN [TransferViewModel.kt:599] Refused spending hw sign deeplink, missing order 'c464a24c-…'

while a locally-created order id opened the sign screen fine. That's the right behaviour if the link is only ever meant to resume a transfer started on this device; it does mean a link handed over from another device or from support tooling can never resolve. Fine to leave as-is — just flagging it so the constraint is deliberate.

setTransferEffect(TransferEffect.OnOrderCreated(order.id))
}

suspend fun prepareSpendingHwSign(walletId: String, orderId: String): Boolean {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

prepareSpendingHwSignadoptSpendingOrder clears pendingHwFundingBroadcast and hasPendingHwBroadcast. If the user has already signed a HW funding tx for order X that failed to broadcast, onTransferToSpendingHwConfirm relies on pendingHwFundingBroadcast?.matches(...) to retry without re-prompting the device — a deep link naming a different order Y silently discards that in-memory signed transaction, making it unrecoverable.

Dev-mode only, so low severity, but cheap to guard: refuse the link (or skip the clobber) while hasPendingHwBroadcast is set.

ScreenDeepLinks.spendingHwSignLink(uri)?.let { link ->
val prepared = transferViewModel.prepareSpendingHwSign(link.walletId, link.orderId)
if (!prepared) {
Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This warn duplicates the specific reason already logged inside prepareSpendingHwSign, and reuses the exact wording of the generic !handled warn a few lines below. Observed on device — one refused link produces two WARN lines, the second of which says "Unhandled" when the link was in fact recognized and deliberately refused:

WARN [TransferViewModel.kt:591] Refused spending hw sign deeplink, unknown wallet 'foo'
WARN [ContentView.kt:328] Unhandled screen deeplink 'bitkit://screen/spending-hw-sign/foo/bar'

Per the repo rule (NEVER duplicate error logging in .onFailure {} if the called method already logs the same error internally), drop this line or reword it so it doesn't collide with the generic one.

Separately, consumeScreenDeepLink() now appears three times in this effect. It can't be hoisted to the top — that's what f405cd3 fixed, since the effect is keyed on pendingScreenDeepLink and consuming early cancels the coroutine mid-prepareSpendingHwSign — but the three calls can collapse into a single one at the end by turning the two early returns into an if/else chain.

fun isScreenDeepLink(uri: Uri): Boolean =
uri.scheme?.lowercase() == SCHEME && uri.host?.lowercase() == HOST

fun spendingHwSignLink(uri: Uri): SpendingHwSignLink? {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Unlike linksFor/sheetFor, this isn't routed through ScreenDeepLinkRuntime/isEnabled, so it parses and returns a link in release builds too. Currently harmless because AppViewModel.processDeeplink gates queueing on ScreenDeepLinks.shouldQueue(...), but that single call site is the only thing stopping a release build from mutating live transfer state from a dev-only URI. An if (!isEnabled) return null here would make it fail safe.

@ovitrif

Copy link
Copy Markdown
Collaborator

General note from briefly looking over review comments: it may not have been specified in the issues or past comments but this screen deeplinks work is more intended to aid in development with ai agents, for example: it could add the possibility to open a specific screen and continue from there. Maybe it should not be a requirement that the rest of the flow(s) would work correctly, or even if it tries to, it should only add it in logic specific to the handler of that screen deeplink; while the impact on production code should try to stay limited to parametrizing the screen inputs (strings, bools, etc, whatever is needed as starting state / to mutate UI)

Sign up for freeto 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.

4 participants

@guzino@ovitrif@jvsena42@github-advanced-security
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat: deep link spending hw sign - #1176

Open
guzino wants to merge 4 commits into
synonymdev:masterfrom
guzino:feat/spending-hw-sign-deeplink
Open

feat: deep link spending hw sign#1176
guzino wants to merge 4 commits into
synonymdev:masterfrom
guzino:feat/spending-hw-sign-deeplink

Conversation

@guzino

Copy link
Copy Markdown
Contributor

Refs #1126
Refs #1119

This PR opens the hardware-wallet transfer Sign screen from bitkit://screen/spending-hw-sign/{walletId}/{orderId}.

Description

#1119 left six transfer destinations InternalOnly because they read activity-scoped TransferViewModel state. SpendingHwSign was the closest: it already took a wallet id (the issue still says deviceId) and bounced home when spendingUiState.order was null. A generated link therefore could not reconstruct the Blocktank order.

ContentView now parses that URI and calls prepareSpendingHwSign before navController.handleDeepLink. Unknown wallet or missing order is refused with the existing Unhandled screen deeplink warning and does not navigate. A matching in-memory order is reused; otherwise blocktankRepo.getOrder(orderId, refresh = true) loads it and adoptSpendingOrder writes the same state onOrderCreated already wrote, without emitting TransferEffect.OnOrderCreated. The pending URI is consumed after that suspend, so the LaunchedEffect is not cancelled mid-fetch.

OnOrderCreated now carries orderId. Amount → Sign navigates Routes.SpendingHwSign(walletId, orderId) from the effect. The dest reads both route args and matches state.order to orderId.

  • Promotes Routes.SpendingHwSign to DeepLinkable with path bitkit://screen/spending-hw-sign/{walletId}/{orderId}.
  • Parses that path in ScreenDeepLinks.spendingHwSignLink via kebabId(Routes.SpendingHwSign::class).
  • Keeps SavingsProgress, SettingUp, SpendingAdvanced, SpendingConfirm, and SpendingHwSigned as InternalOnly. The rest of feat: deep link the late transfer screens #1126 stays a follow-up.

Preview

N/A

QA Notes

Dev mode is on by default on debug builds (Settings ▸ Advanced ▸ Dev Settings). The app must be past onboarding. The Sign path needs a paired hardware wallet and a live Blocktank order id.

Manual Tests

  • 1. Hardware Wallet detail → Transfer To Spending → Amount → Continue → Sign: lands on Sign With Your Device with the created order.
  • 2. From that Sign screen, adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/spending-hw-sign/<walletId>/<orderId>" to.bitkit.dev → Sign opens with the same order.
  • 3. Cold start the same URI → Sign opens with that order, not Home.
  • 4. Same path with an unknown wallet id or a missing order → screen unchanged, logcat carries Unhandled screen deeplink.
  • 5.regression:bitkit://screen/spending-amount-hw/<walletId> → Amount still opens.

Automated Checks

  • Unit tests added in ScreenDeepLinksTest.kt: path pattern, wallet and order segments, missing order id.
  • Unit tests added in TransferViewModelTest.kt: known wallet loads the named order, unknown wallet and missing order are refused, in-memory order is reused without a second fetch.
  • Local: just compile, just test, just lint all pass, no new detekt findings.

Comment threadapp/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt Fixed
@greptile-apps

Copy link
Copy Markdown

Greptile Summary

The PR adds debug-only deep-link navigation into the hardware-wallet spending-sign flow, restoring the requested Blocktank order before navigation.

  • Extends the sign route and order-created effect with an order ID.
  • Validates the wallet, loads or reuses the requested order, and adopts it into transfer state.
  • Adds focused parsing, route-contract, preparation, and regression tests.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete blocking or independently actionable non-blocking issue identified.

The deep link remains restricted by the existing debug and Dev Mode gates, prepares the exact requested order before navigation, rejects unavailable prerequisites, and keeps internal navigation synchronized through the new order ID.

Important Files Changed

FilenameOverview
app/src/main/java/to/bitkit/ui/ContentView.ktCoordinates order preparation before deep-link navigation and registers the sign destination with both route arguments.
app/src/main/java/to/bitkit/viewmodels/TransferViewModel.ktAdds validated order restoration, centralizes spending-order adoption, and includes the order ID in creation effects.
app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.ktParses the hardware sign route's wallet and order path segments while retaining existing screen-link gating.
app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.ktEnsures the in-memory order matches the route order before rendering the signing flow.
app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.ktCovers successful restoration, unknown wallets, missing orders, and reuse of a matching in-memory order.
app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.ktCovers the generated URI contract and extraction of both required route identifiers.

Sequence Diagram

sequenceDiagram
participant Intent as Screen deep link
participant AppVM as AppViewModel
participant Content as ContentView
participant TransferVM as TransferViewModel
participant Blocktank as BlocktankRepo
participant Nav as NavController
Intent->>AppVM: queue URI when debug runtime and Dev Mode permit
AppVM-->>Content: pendingScreenDeepLink
Content->>TransferVM: prepareSpendingHwSign(walletId, orderId)
alt matching order already in memory
TransferVM-->>Content: true
else order must be restored
TransferVM->>Blocktank: "getOrder(orderId, refresh = true)"
Blocktank-->>TransferVM: order or missing
TransferVM-->>Content: preparation result
end
alt prepared
Content->>Nav: handleDeepLink(uri)
else rejected
Content->>Content: log unhandled link
end
Content->>AppVM: consumeScreenDeepLink()
Loading

Reviews (1): Last reviewed commit: "fix: consume deeplink after prepare" | Re-trigger Greptile

@jvsena42jvsena42 self-assigned this Aug 27, 2026

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed and validated on a regtest emulator against the deterministic Trezor Bridge emulator from bitkit-docker (T2T1, seed all all ..., paired as BITKIT TEST TREZOR with 26,890,661 sats).

The deep link itself works. Both branches of prepareSpendingHwSign were exercised end to end:

  • fresh order id → refreshOrders runs, order is adopted, isAdvanced resets (Advanced button flips back from "Use Defaults"), sign screen opens with the right amounts;
  • already-current order id → short-circuits on current?.id == orderId and opens directly;
  • unknown wallet and unknown order are both refused, no navigation.

One blocking issue and three smaller ones inline.

val state by viewModel.spendingUiState.collectAsStateWithLifecycle()

val order = state.order ?: run {
val order = state.order?.takeIf { it.id == orderId } ?: run {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking — this guard breaks the existing Advanced flow.

orderId is a route arg, frozen when SpendingHwSign was pushed. But onAdvancedClick pushes Routes.SpendingAdvanced, and onSpendingAdvancedContinue calls blocktankRepo.createOrder(...) and stores a new order with a new id as spendingUiState.order (the old one moves to defaultOrder). SpendingAdvancedScreen's onOrderCreated is navController.popBackStack() — which lands back on SpendingHwSign still carrying the old id. takeIf yields null and the composable calls onCloseClick()navigateToHome().

Reproduced on device: HW detail → Transfer To Spending → 25% → Continue → Sign → Advanced → MAX → Continue lands on the wallet home screen, and the freshly created order is stranded. Logcat:

INFO [BlocktankRepo.kt:285] Buying channel with lspBalanceSat: '341987', ...
DEBUG [BlocktankRepo.kt:222] Orders refreshed: 7 orders, 0 cjit entries, 2 paid orders

and blocktank.db then holds 3c10c207-… Created 341987 110227 with nothing pointing at it.

Suggest accepting defaultOrder?.id == orderId as a match too, or applying the id check only on first entry (deep-link admission) rather than on every recomposition.

val current = _spendingUiState.value.order
if (current?.id == orderId) return true

val order = blocktankRepo.getOrder(orderId, refresh = true).getOrNull()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Worth confirming this is the intent: BlocktankRepo.getOrder refreshes and then searches _blocktankState.value.orders, i.e. only orders this install already tracks locally. An order that exists on the LSP but was never created on this device is always refused.

Verified on device — I created a valid order via the Blocktank API with this node's clientNodeId, then deep-linked to it:

WARN [TransferViewModel.kt:599] Refused spending hw sign deeplink, missing order 'c464a24c-…'

while a locally-created order id opened the sign screen fine. That's the right behaviour if the link is only ever meant to resume a transfer started on this device; it does mean a link handed over from another device or from support tooling can never resolve. Fine to leave as-is — just flagging it so the constraint is deliberate.

setTransferEffect(TransferEffect.OnOrderCreated(order.id))
}

suspend fun prepareSpendingHwSign(walletId: String, orderId: String): Boolean {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

prepareSpendingHwSignadoptSpendingOrder clears pendingHwFundingBroadcast and hasPendingHwBroadcast. If the user has already signed a HW funding tx for order X that failed to broadcast, onTransferToSpendingHwConfirm relies on pendingHwFundingBroadcast?.matches(...) to retry without re-prompting the device — a deep link naming a different order Y silently discards that in-memory signed transaction, making it unrecoverable.

Dev-mode only, so low severity, but cheap to guard: refuse the link (or skip the clobber) while hasPendingHwBroadcast is set.

ScreenDeepLinks.spendingHwSignLink(uri)?.let { link ->
val prepared = transferViewModel.prepareSpendingHwSign(link.walletId, link.orderId)
if (!prepared) {
Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This warn duplicates the specific reason already logged inside prepareSpendingHwSign, and reuses the exact wording of the generic !handled warn a few lines below. Observed on device — one refused link produces two WARN lines, the second of which says "Unhandled" when the link was in fact recognized and deliberately refused:

WARN [TransferViewModel.kt:591] Refused spending hw sign deeplink, unknown wallet 'foo'
WARN [ContentView.kt:328] Unhandled screen deeplink 'bitkit://screen/spending-hw-sign/foo/bar'

Per the repo rule (NEVER duplicate error logging in .onFailure {} if the called method already logs the same error internally), drop this line or reword it so it doesn't collide with the generic one.

Separately, consumeScreenDeepLink() now appears three times in this effect. It can't be hoisted to the top — that's what f405cd3 fixed, since the effect is keyed on pendingScreenDeepLink and consuming early cancels the coroutine mid-prepareSpendingHwSign — but the three calls can collapse into a single one at the end by turning the two early returns into an if/else chain.

fun isScreenDeepLink(uri: Uri): Boolean =
uri.scheme?.lowercase() == SCHEME && uri.host?.lowercase() == HOST

fun spendingHwSignLink(uri: Uri): SpendingHwSignLink? {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Unlike linksFor/sheetFor, this isn't routed through ScreenDeepLinkRuntime/isEnabled, so it parses and returns a link in release builds too. Currently harmless because AppViewModel.processDeeplink gates queueing on ScreenDeepLinks.shouldQueue(...), but that single call site is the only thing stopping a release build from mutating live transfer state from a dev-only URI. An if (!isEnabled) return null here would make it fail safe.

@ovitrif

Copy link
Copy Markdown
Collaborator

General note from briefly looking over review comments: it may not have been specified in the issues or past comments but this screen deeplinks work is more intended to aid in development with ai agents, for example: it could add the possibility to open a specific screen and continue from there. Maybe it should not be a requirement that the rest of the flow(s) would work correctly, or even if it tries to, it should only add it in logic specific to the handler of that screen deeplink; while the impact on production code should try to stay limited to parametrizing the screen inputs (strings, bools, etc, whatever is needed as starting state / to mutate UI)

Sign up for freeto 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.

4 participants

@guzino@ovitrif@jvsena42@github-advanced-security