diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index ba977db73..0be3ca485 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -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") + 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) @@ -332,6 +341,7 @@ fun ContentView( if (!handled) { Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView") } + appViewModel.consumeScreenDeepLink() } LaunchedEffect(appViewModel) { @@ -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 { entry -> - val walletId = entry.toRoute().walletId + deepLinkableComposable { entry -> + val route = entry.toRoute() SpendingHwSignScreen( - walletId = walletId, + walletId = route.walletId, + orderId = route.orderId, viewModel = transferViewModel, onBackClick = { navController.popBackStack() }, onCloseClick = { navController.navigateToHome() }, @@ -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 diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt index d91a05306..38732f282 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt @@ -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) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt index ec1b4349d..f9e9cc81c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt @@ -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 diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt index eef71f839..cd7ac3456 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt @@ -65,7 +65,7 @@ fun SpendingAmountHwScreen( viewModel: TransferViewModel, isOffline: Boolean, onBackClick: () -> Unit = {}, - onOrderCreated: () -> Unit = {}, + onOrderCreated: (String) -> Unit = {}, currencies: CurrencyState = LocalCurrencies.current, amountInputViewModel: AmountInputViewModel = hiltViewModel(), ) { @@ -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, diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt index 65901f5a1..9ad9504d6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt @@ -42,6 +42,7 @@ import to.bitkit.viewmodels.TransferViewModel @Composable fun SpendingHwSignScreen( walletId: String, + orderId: String, viewModel: TransferViewModel, onBackClick: () -> Unit, onCloseClick: () -> Unit, @@ -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 { onCloseClick() return } diff --git a/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt index e727b0f2d..73ccea57a 100644 --- a/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt +++ b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt @@ -38,6 +38,19 @@ object ScreenDeepLinks { fun isScreenDeepLink(uri: Uri): Boolean = uri.scheme?.lowercase() == SCHEME && uri.host?.lowercase() == HOST + fun spendingHwSignLink(uri: Uri): SpendingHwSignLink? { + 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 @@ -46,3 +59,8 @@ object ScreenDeepLinks { return true } } + +data class SpendingHwSignLink( + val walletId: String, + val orderId: String, +) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 5333266ec..c8b61dea4 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -225,7 +225,7 @@ class TransferViewModel @Inject constructor( hwMiningFeeSats = 0uL, ) } - setTransferEffect(TransferEffect.OnOrderCreated) + setTransferEffect(TransferEffect.OnOrderCreated(newOrder.id)) }.onFailure { e -> setTransferEffect(TransferEffect.ToastException(e)) } @@ -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 { + 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() + 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 @@ -593,7 +618,6 @@ class TransferViewModel @Inject constructor( hwMiningFeeSats = 0uL, ) } - setTransferEffect(TransferEffect.OnOrderCreated) } private fun updateAvailableAmount() { @@ -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 diff --git a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt index 3ca936d62..001a9bb92 100644 --- a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt +++ b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt @@ -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 diff --git a/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt index 03803aa52..60cae8221 100644 --- a/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt +++ b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt @@ -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 diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index a1f0f7d56..7b852ec85 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -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() diff --git a/changelog.d/next/1176.added.md b/changelog.d/next/1176.added.md new file mode 100644 index 000000000..8a84858b5 --- /dev/null +++ b/changelog.d/next/1176.added.md @@ -0,0 +1 @@ +Dev-mode deep links can open the hardware-wallet transfer sign screen from a wallet id and Blocktank order id.