Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -315,13 +315,22 @@ fun ContentView(
val uri = pendingScreenDeepLink ?: return@LaunchedEffect

navController.currentBackStackEntryFlow.first()
appViewModel.consumeScreenDeepLink()

SheetDeepLinks.sheetFor(uri)?.let {
appViewModel.showSheet(it)
appViewModel.consumeScreenDeepLink()
return@LaunchedEffect
}

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.

appViewModel.consumeScreenDeepLink()
return@LaunchedEffect
}
}

val request = Intent(Intent.ACTION_VIEW, uri)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
val handled = navController.handleDeepLink(request)
Expand All @@ -332,6 +341,7 @@ fun ContentView(
if (!handled) {
Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView")
}
appViewModel.consumeScreenDeepLink()
}

LaunchedEffect(appViewModel) {
Expand Down Expand Up @@ -840,13 +850,16 @@ private fun RootNavHost(
viewModel = transferViewModel,
isOffline = connectivityState != ConnectivityState.CONNECTED,
onBackClick = { navController.popBackStack() },
onOrderCreated = { navController.navigateTo(Routes.SpendingHwSign(walletId)) },
onOrderCreated = { orderId ->
navController.navigateTo(Routes.SpendingHwSign(walletId, orderId))
},
)
}
composableWithDefaultTransitions<Routes.SpendingHwSign> { entry ->
val walletId = entry.toRoute<Routes.SpendingHwSign>().walletId
deepLinkableComposable<Routes.SpendingHwSign> { entry ->
val route = entry.toRoute<Routes.SpendingHwSign>()
SpendingHwSignScreen(
walletId = walletId,
walletId = route.walletId,
orderId = route.orderId,
viewModel = transferViewModel,
onBackClick = { navController.popBackStack() },
onCloseClick = { navController.navigateToHome() },
Expand Down Expand Up @@ -2126,7 +2139,7 @@ sealed interface Routes {
data class SpendingAmountHw(val walletId: String) : Routes.DeepLinkable

@Serializable
data class SpendingHwSign(val walletId: String) : Routes.InternalOnly
data class SpendingHwSign(val walletId: String, val orderId: String) : Routes.DeepLinkable

@Serializable
data object SpendingHwSigned : Routes.InternalOnly
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ fun SpendingAdvancedScreen(
LaunchedEffect(Unit) {
viewModel.transferEffects.collect { effect ->
when (effect) {
TransferEffect.OnOrderCreated -> currentOnOrderCreated()
is TransferEffect.OnOrderCreated -> currentOnOrderCreated()
is TransferEffect.ToastException -> {
isLoading = false
app.toast(effect.e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ fun SpendingAmountScreen(
LaunchedEffect(Unit) {
viewModel.transferEffects.collect { effect ->
when (effect) {
TransferEffect.OnOrderCreated -> onOrderCreated()
is TransferEffect.OnOrderCreated -> onOrderCreated()
is TransferEffect.ToastError -> toast(effect.title, effect.description)
is TransferEffect.ToastException -> toastException(effect.e)
else -> Unit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ fun SpendingAmountHwScreen(
viewModel: TransferViewModel,
isOffline: Boolean,
onBackClick: () -> Unit = {},
onOrderCreated: () -> Unit = {},
onOrderCreated: (String) -> Unit = {},
currencies: CurrencyState = LocalCurrencies.current,
amountInputViewModel: AmountInputViewModel = hiltViewModel(),
) {
Expand All @@ -83,7 +83,7 @@ fun SpendingAmountHwScreen(
LaunchedEffect(Unit) {
viewModel.transferEffects.collect { effect ->
when (effect) {
TransferEffect.OnOrderCreated -> onOrderCreated()
is TransferEffect.OnOrderCreated -> onOrderCreated(effect.orderId)
is TransferEffect.ToastError -> ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = effect.title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import to.bitkit.viewmodels.TransferViewModel
@Composable
fun SpendingHwSignScreen(
walletId: String,
orderId: String,
viewModel: TransferViewModel,
onBackClick: () -> Unit,
onCloseClick: () -> Unit,
Expand All @@ -50,7 +51,7 @@ fun SpendingHwSignScreen(
) {
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.

onCloseClick()
return
}
Expand Down
18 changes: 18 additions & 0 deletions app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ object ScreenDeepLinks {
fun isScreenDeepLink(uri: Uri): Boolean =
uri.scheme?.lowercase() == SCHEME && uri.host?.lowercase() == HOST

fun spendingHwSignLink(uri: Uri): SpendingHwSignLink? {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

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.

if (!isScreenDeepLink(uri)) return null
val segments = uri.pathSegments.orEmpty()
val screenId = kebabId(Routes.SpendingHwSign::class)
if (segments.size != 3 || screenId == null || !segments[0].equals(screenId, ignoreCase = true)) {
return null
}
val walletId = segments[1]
val orderId = segments[2]
if (walletId.isBlank() || orderId.isBlank()) return null
return SpendingHwSignLink(walletId = walletId, orderId = orderId)
}

fun detachScreenUri(intent: Intent): Boolean {
val uri = intent.data ?: return false
if (!isScreenDeepLink(uri)) return false
Expand All @@ -46,3 +59,8 @@ object ScreenDeepLinks {
return true
}
}

data class SpendingHwSignLink(
val walletId: String,
val orderId: String,
)
30 changes: 27 additions & 3 deletions app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ class TransferViewModel @Inject constructor(
hwMiningFeeSats = 0uL,
)
}
setTransferEffect(TransferEffect.OnOrderCreated)
setTransferEffect(TransferEffect.OnOrderCreated(newOrder.id))
}.onFailure { e ->
setTransferEffect(TransferEffect.ToastException(e))
}
Expand Down Expand Up @@ -581,6 +581,31 @@ class TransferViewModel @Inject constructor(

private suspend fun onOrderCreated(order: IBtOrder) {
settingsStore.update { it.copy(lightningSetupStep = 0) }
adoptSpendingOrder(order)
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.

if (walletId.isBlank() || orderId.isBlank()) return false
if (hwWalletRepo.wallets.value.none { it.id == walletId }) {
Logger.warn("Refused spending hw sign deeplink, unknown wallet '$walletId'", context = TAG)
return false
}
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.

if (order == null) {
Logger.warn("Refused spending hw sign deeplink, missing order '$orderId'", context = TAG)
return false
}

settingsStore.update { it.copy(lightningSetupStep = 0) }
adoptSpendingOrder(order)
return true
}

private fun adoptSpendingOrder(order: IBtOrder) {
pendingHwFundingBroadcast = null
hwFeeEstimateJob?.cancel()
hwFeeEstimateJob = null
Expand All @@ -593,7 +618,6 @@ class TransferViewModel @Inject constructor(
hwMiningFeeSats = 0uL,
)
}
setTransferEffect(TransferEffect.OnOrderCreated)
}

private fun updateAvailableAmount() {
Expand Down Expand Up @@ -1673,7 +1697,7 @@ data class TransferValues(
)

sealed interface TransferEffect {
data object OnOrderCreated : TransferEffect
data class OnOrderCreated(val orderId: String) : TransferEffect
data object OnSpendingFundingPaid : TransferEffect
data object OnHwTxSigned : TransferEffect
data class ToastException(val e: Throwable) : TransferEffect
Expand Down
2 changes: 1 addition & 1 deletion app/src/test/java/to/bitkit/ui/ContentViewTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class ContentViewTest {
fun `transfer effect destinations cover funding paid and hw signed`() {
assertEquals(Routes.SettingUp, transferEffectDestination(TransferEffect.OnSpendingFundingPaid))
assertEquals(Routes.SpendingHwSigned, transferEffectDestination(TransferEffect.OnHwTxSigned))
assertNull(transferEffectDestination(TransferEffect.OnOrderCreated))
assertNull(transferEffectDestination(TransferEffect.OnOrderCreated("order")))
}

@Test
Expand Down
32 changes: 32 additions & 0 deletions app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,38 @@ class ScreenDeepLinksTest : BaseUnitTest() {
assertEquals("bitkit://screen/activity-assign-contact/{id}", links.single().uriPattern)
}

@Test
fun `SpendingHwSign required arguments are wallet and order path segments`() {
if (!ScreenDeepLinks.isEnabled) return
val links = ScreenDeepLinks.linksFor(Routes.SpendingHwSign::class)

assertEquals(
"bitkit://screen/spending-hw-sign/{walletId}/{orderId}",
links.single().uriPattern,
)
}

@Test
fun `spendingHwSignLink reads wallet and order ids from the path`() {
val screenId = ScreenDeepLinks.kebabId(Routes.SpendingHwSign::class)
val uri = Uri.parse("bitkit://screen/$screenId/hardware-wallet/order-1")

val link = ScreenDeepLinks.spendingHwSignLink(uri)

assertNotNull(link)
assertEquals("hardware-wallet", link.walletId)
assertEquals("order-1", link.orderId)
}

@Test
fun `spendingHwSignLink returns null when the order id is missing`() {
val uri = Uri.parse("bitkit://screen/spending-hw-sign/hardware-wallet")

val link = ScreenDeepLinks.spendingHwSignLink(uri)

assertNull(link)
}

@Test
fun `a route with both argument kinds keeps the required one in the path`() {
if (!ScreenDeepLinks.isEnabled) return
Expand Down
52 changes: 52 additions & 0 deletions app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,58 @@ class TransferViewModelTest : BaseUnitTest() {
verify(hwWalletRepo, never()).signFunding(any(), any())
}

@Test
fun `prepareSpendingHwSign loads the named order when the wallet is known`() = test {
val order = previewBtOrder()
whenever(hwWalletRepo.wallets)
.thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true))))
whenever { blocktankRepo.getOrder(eq(order.id), eq(true)) }.thenReturn(Result.success(order))

val prepared = sut.prepareSpendingHwSign(HARDWARE_WALLET_ID, order.id)

assertTrue(prepared)
assertEquals(order.id, sut.spendingUiState.value.order?.id)
}

@Test
fun `prepareSpendingHwSign refuses an unknown wallet`() = test {
val order = previewBtOrder()
whenever(hwWalletRepo.wallets).thenReturn(MutableStateFlow(persistentListOf()))

val prepared = sut.prepareSpendingHwSign(HARDWARE_WALLET_ID, order.id)

assertFalse(prepared)
assertNull(sut.spendingUiState.value.order)
verify(blocktankRepo, never()).getOrder(any(), any())
}

@Test
fun `prepareSpendingHwSign refuses a missing order`() = test {
val order = previewBtOrder()
whenever(hwWalletRepo.wallets)
.thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true))))
whenever { blocktankRepo.getOrder(eq(order.id), eq(true)) }.thenReturn(Result.success(null))

val prepared = sut.prepareSpendingHwSign(HARDWARE_WALLET_ID, order.id)

assertFalse(prepared)
assertNull(sut.spendingUiState.value.order)
}

@Test
fun `prepareSpendingHwSign reuses the in-memory order without fetching`() = test {
val order = previewBtOrder()
whenever(hwWalletRepo.wallets)
.thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true))))
whenever { blocktankRepo.getOrder(eq(order.id), eq(true)) }.thenReturn(Result.success(order))
sut.prepareSpendingHwSign(HARDWARE_WALLET_ID, order.id)

val prepared = sut.prepareSpendingHwSign(HARDWARE_WALLET_ID, order.id)

assertTrue(prepared)
verify(blocktankRepo, times(1)).getOrder(eq(order.id), eq(true))
}

@Test
fun `updateHwFundingFeeEstimate ignores superseded estimate`() = test {
val orderA = previewBtOrder()
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1176.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Dev-mode deep links can open the hardware-wallet transfer sign screen from a wallet id and Blocktank order id.
Loading