From 7dc11dca1c6b5964126422fbb27a33e05ce404eb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 18:58:01 +0000 Subject: [PATCH 01/12] fix: gate shop origin and pin pay Co-authored-by: benk10 --- .../ui/screens/shop/shopWebView/ShopOrigin.kt | 39 +++++++++++ .../shop/shopWebView/ShopWebViewClient.kt | 37 ++++------ .../shop/shopWebView/ShopWebViewInterface.kt | 62 ++++++++++------- .../shop/shopWebView/ShopWebViewScreen.kt | 13 ++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 57 +++++++++++++-- .../shop/shopWebView/ShopOriginTest.kt | 41 +++++++++++ .../shop/shopWebView/ShopWebViewClientTest.kt | 51 ++++++++++++++ .../shopWebView/ShopWebViewInterfaceTest.kt | 69 +++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 67 ++++++++++++++++++ .../next/shop-quickpay-auth.security.md | 1 + 10 files changed, 379 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt create mode 100644 app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt create mode 100644 app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt create mode 100644 app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt create mode 100644 changelog.d/next/shop-quickpay-auth.security.md diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt new file mode 100644 index 0000000000..afc3277bb8 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt @@ -0,0 +1,39 @@ +package to.bitkit.ui.screens.shop.shopWebView + +import java.net.URI + +/** Root host for Bitrefill shop pages and payment_intent messages. */ +const val BITREFILL_ROOT_HOST = "bitrefill.com" + +fun isAllowedShopHost(host: String?): Boolean { + val normalized = host?.lowercase()?.trim('.') ?: return false + return normalized == BITREFILL_ROOT_HOST || normalized.endsWith(".$BITREFILL_ROOT_HOST") +} + +fun isAllowedShopOrigin(url: String?): Boolean { + if (url.isNullOrBlank()) return false + val parsed = runCatching { URI(url.trim()) }.getOrNull() ?: return false + if (!parsed.scheme.equals("https", ignoreCase = true)) return false + return isAllowedShopHost(parsed.host) +} + +internal fun shopMessageBridgeScript(): String = """ + window.ReactNativeWebView = { + postMessage: function(data) { + Android.postMessage(typeof data === 'string' ? data : JSON.stringify(data)); + } + }; + window.addEventListener('message', function(event) { + try { + var originUrl = new URL(event.origin); + if (originUrl.protocol !== 'https:') return; + var host = originUrl.hostname.toLowerCase(); + if (host !== '$BITREFILL_ROOT_HOST' && !host.endsWith('.$BITREFILL_ROOT_HOST')) return; + } catch (e) { + return; + } + var data = event.data; + if (data == null) return; + Android.postMessage(typeof data === 'string' ? data : JSON.stringify(data)); + }); +""".trimIndent() diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt index fa5fb52f0a..19d04a10c5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt @@ -12,8 +12,11 @@ import to.bitkit.utils.Logger */ class ShopWebViewClient( private val onLoadingStateChanged: (Boolean) -> Unit, - private val onError: () -> Unit + private val onError: () -> Unit, ) : WebViewClient() { + private companion object { + const val TAG = "ShopWebViewClient" + } override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) @@ -24,29 +27,15 @@ class ShopWebViewClient( super.onPageFinished(view, url) onLoadingStateChanged(false) - // Inject JavaScript to bridge postMessage to Android - view?.evaluateJavascript( - """ - window.ReactNativeWebView = { - postMessage: function(data) { - Android.postMessage(data); - } - }; + view?.evaluateJavascript(shopMessageBridgeScript(), null) + } - // Override the default postMessage if it exists - if (window.postMessage) { - window.originalPostMessage = window.postMessage; - window.postMessage = function(data) { - if (typeof data === 'string') { - Android.postMessage(data); - } else { - Android.postMessage(JSON.stringify(data)); - } - }; - } - """.trimIndent(), - null - ) + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + if (request?.isForMainFrame != true) return false + val url = request.url?.toString() + if (isAllowedShopOrigin(url)) return false + Logger.warn("Blocked shop navigation to untrusted origin '$url'", context = TAG) + return true } @Suppress("ComplexCondition") @@ -58,7 +47,7 @@ class ShopWebViewClient( super.onReceivedError(view, request, error) Logger.warn( "Error: ${error?.description}, Code: ${error?.errorCode}, URL: ${request?.url}", - context = "ShopWebViewScreen" + context = TAG, ) onLoadingStateChanged(false) diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt index 9d14a6eb22..d2474cf532 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt @@ -1,5 +1,7 @@ package to.bitkit.ui.screens.shop.shopWebView +import android.os.Handler +import android.os.Looper import android.webkit.JavascriptInterface import kotlinx.serialization.json.Json import to.bitkit.utils.Logger @@ -17,7 +19,16 @@ import to.bitkit.utils.Logger */ class ShopWebViewInterface( private val onPaymentIntent: (String) -> Unit, + private val currentUrl: () -> String?, + private val runOnMain: (() -> Unit) -> Unit = { action -> + Handler(Looper.getMainLooper()).post(action) + }, ) { + private companion object { + const val TAG = "ShopWebViewInterface" + const val PAYMENT_INTENT_EVENT = "payment_intent" + } + private val json = Json { ignoreUnknownKeys = true } /** @@ -26,35 +37,13 @@ class ShopWebViewInterface( * * @param message JSON string containing the message data */ - @Suppress("NestedBlockDepth") @JavascriptInterface fun postMessage(message: String) { if (message.isBlank()) { - Logger.warn("Received empty message", context = "WebView") + Logger.warn("Received empty shop WebView message", context = TAG) return } - - runCatching { - val data = json.decodeFromString(message) - when (data.event) { - "payment_intent" -> { - data.paymentUri?.let { uri -> - // Validate URI before passing it along - if (uri.isNotBlank()) { - onPaymentIntent(uri) - } else { - Logger.warn("Received payment_intent with empty URI", context = "WebView") - } - } ?: Logger.warn("Received payment_intent without URI", context = "WebView") - } - - else -> { - Logger.debug("Unknown event type: ${data.event}", context = "WebView") - } - } - }.onFailure { - Logger.error("Error parsing message: $message", it, context = "WebView") - } + runOnMain { handlePaymentMessage(message) } } /** @@ -67,4 +56,29 @@ class ShopWebViewInterface( fun isReady(): Boolean { return true } + + private fun handlePaymentMessage(message: String) { + val pageUrl = currentUrl() + if (!isAllowedShopOrigin(pageUrl)) { + Logger.warn("Rejected shop payment_intent from untrusted origin '$pageUrl'", context = TAG) + return + } + + runCatching { + val data = json.decodeFromString(message) + when (data.event) { + PAYMENT_INTENT_EVENT -> { + val uri = data.paymentUri?.trim().orEmpty() + if (uri.isBlank()) { + Logger.warn("Received payment_intent with empty URI", context = TAG) + return + } + onPaymentIntent(uri) + } + else -> Logger.debug("Ignored shop WebView event '${data.event}'", context = TAG) + } + }.onFailure { + Logger.error("Failed to parse shop WebView message", it, context = TAG) + } + } } diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt index 77a3f12881..ee816eb720 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt @@ -37,11 +37,16 @@ fun ShopWebViewScreen( var isLoading by remember { mutableStateOf(true) } var webView: WebView? by remember { mutableStateOf(null) } - val webViewInterface = remember { ShopWebViewInterface(onPaymentIntent) } + val webViewInterface = remember { + ShopWebViewInterface( + onPaymentIntent = onPaymentIntent, + currentUrl = { webView?.url }, + ) + } val webViewClient = remember { ShopWebViewClient( onLoadingStateChanged = { loading -> isLoading = loading }, - onError = onClose + onError = onClose, ) } @@ -54,7 +59,6 @@ fun ShopWebViewScreen( Box(modifier = Modifier.weight(1f)) { AndroidView( - modifier = Modifier.fillMaxSize(), factory = { context -> WebView(context).apply { layoutParams = ViewGroup.LayoutParams( @@ -62,13 +66,14 @@ fun ShopWebViewScreen( ViewGroup.LayoutParams.MATCH_PARENT, ) + webView = this this.webViewClient = webViewClient configureForBasicWebContent() addJavascriptInterface(webViewInterface, "Android") loadUrl(bitrefillUrlOf(page)) - webView = this } }, + modifier = Modifier.fillMaxSize() ) if (isLoading) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6ce64be2ae..64357c82b4 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -256,6 +256,9 @@ class AppViewModel @Inject constructor( @Volatile private var activeScanInput: String? = null + @Volatile + private var pendingLockedScan: PendingLockedScan? = null + private val _sendEffect = MutableSharedFlow(extraBufferCapacity = 1) val sendEffect = _sendEffect.asSharedFlow() private fun setSendEffect(effect: SendEffect) = viewModelScope.launch { _sendEffect.emit(effect) } @@ -318,6 +321,7 @@ class AppViewModel @Inject constructor( fun setIsAuthenticated(value: Boolean) { _isAuthenticated.value = value + if (value) flushPendingLockedScan() } val pinAttemptsRemaining = keychain.pinAttemptsRemaining() @@ -1579,14 +1583,23 @@ class AppViewModel @Inject constructor( startDelay: Duration = Duration.ZERO, routePubkyKeys: Boolean = false, ) { - val normalized = data.removeLightningSchemes() - val scanLogInput = SamRockSetupRequest.sanitizedDescription(normalized) ?: data - val scanId = if (scanLogInput.length > 24) { - "${scanLogInput.take(11)}…${scanLogInput.takeLast(11)}" - } else { - scanLogInput + if (!_isAuthenticated.value) { + Logger.info( + "Queuing '${source.label}' scan until authenticated: '${scanLogId(data)}'", + context = TAG, + ) + pendingLockedScan = PendingLockedScan( + source = source, + data = data, + startDelay = startDelay, + routePubkyKeys = routePubkyKeys, + ) + return } + val normalized = data.removeLightningSchemes() + val scanId = scanLogId(data) + if (normalized == activeScanInput && activeScanJob?.isActive == true) { Logger.info("Skipping duplicate scan from '${source.label}': '$scanId'", context = TAG) return @@ -1605,6 +1618,26 @@ class AppViewModel @Inject constructor( }.also { it.invokeOnCompletion { if (activeScanInput == normalized) activeScanInput = null } } } + private fun scanLogId(data: String): String { + val scanLogInput = SamRockSetupRequest.sanitizedDescription(data.removeLightningSchemes()) ?: data + return if (scanLogInput.length > 24) { + "${scanLogInput.take(11)}…${scanLogInput.takeLast(11)}" + } else { + scanLogInput + } + } + + private fun flushPendingLockedScan() { + val pending = pendingLockedScan ?: return + pendingLockedScan = null + launchScan( + source = pending.source, + data = pending.data, + startDelay = pending.startDelay, + routePubkyKeys = pending.routePubkyKeys, + ) + } + private fun onAddressContinue(data: String) { clearActiveContactPaymentContext() launchScan(source = ScanSource.ADDRESS_CONTINUE, data = data, routePubkyKeys = true) @@ -2465,6 +2498,10 @@ class AppViewModel @Inject constructor( if (!settings.isQuickPayEnabled || amountSats == 0uL) { return false } + if (settings.isPinEnabled && settings.isPinForPaymentsEnabled) { + Logger.debug("Skipping QuickPay because PIN is required for payments", context = TAG) + return false + } val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() ?: return false @@ -3305,6 +3342,7 @@ class AppViewModel @Inject constructor( val settings = settingsStore.data.first() val needsAuth = settings.isPinEnabled _isAuthenticated.value = !needsAuth + if (!needsAuth) flushPendingLockedScan() } fun resetIsAuthenticatedState() { @@ -3699,6 +3737,13 @@ class AppViewModel @Inject constructor( DEEPLINK("deeplink"), } + private data class PendingLockedScan( + val source: ScanSource, + val data: String, + val startDelay: Duration, + val routePubkyKeys: Boolean, + ) + companion object { private const val TAG = "AppViewModel" private val LIGHTNING_SCHEME_PATTERNS = listOf("lightning", "lnurl", "lnurlw", "lnurlc", "lnurlp") diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt new file mode 100644 index 0000000000..f7ae51d84b --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt @@ -0,0 +1,41 @@ +package to.bitkit.ui.screens.shop.shopWebView + +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ShopOriginTest { + + @Test + fun `bridge script checks message origin and does not override postMessage`() { + val script = shopMessageBridgeScript() + + assertTrue("addEventListener('message'" in script) + assertFalse("window.postMessage =" in script) + assertTrue("bitrefill.com" in script) + } + + @Test + fun `https Bitrefill hosts are allowed`() { + assertTrue(isAllowedShopOrigin("https://embed.bitrefill.com")) + assertTrue(isAllowedShopOrigin("https://embed.bitrefill.com/gift-cards")) + assertTrue(isAllowedShopOrigin("https://bitrefill.com")) + assertTrue(isAllowedShopOrigin("https://www.bitrefill.com/esims")) + assertTrue(isAllowedShopHost("embed.bitrefill.com")) + assertTrue(isAllowedShopHost("BITREFILL.COM")) + } + + @Test + fun `non-Bitrefill and non-https origins are rejected`() { + assertFalse(isAllowedShopOrigin(null)) + assertFalse(isAllowedShopOrigin("")) + assertFalse(isAllowedShopOrigin("https://evil.example")) + assertFalse(isAllowedShopOrigin("https://bitrefill.com.evil.example")) + assertFalse(isAllowedShopOrigin("https://notbitrefill.com")) + assertFalse(isAllowedShopOrigin("http://embed.bitrefill.com")) + assertFalse(isAllowedShopOrigin("javascript:alert(1)")) + assertFalse(isAllowedShopOrigin("https://127.0.0.1")) + assertFalse(isAllowedShopHost("evil.example")) + assertFalse(isAllowedShopHost(null)) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt new file mode 100644 index 0000000000..5725582f7f --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt @@ -0,0 +1,51 @@ +package to.bitkit.ui.screens.shop.shopWebView + +import android.webkit.WebResourceRequest +import androidx.core.net.toUri +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class ShopWebViewClientTest : BaseUnitTest() { + + private val sut = ShopWebViewClient( + onLoadingStateChanged = {}, + onError = {}, + ) + + @Test + fun `main-frame Bitrefill https navigation is allowed`() { + val request = request(url = "https://embed.bitrefill.com/gift-cards", isForMainFrame = true) + + assertFalse(sut.shouldOverrideUrlLoading(null, request)) + } + + @Test + fun `main-frame navigation off Bitrefill is blocked`() { + val request = request(url = "https://evil.example/pay", isForMainFrame = true) + + assertTrue(sut.shouldOverrideUrlLoading(null, request)) + } + + @Test + fun `subframe requests are not blocked`() { + val request = request(url = "https://cdn.example/script.js", isForMainFrame = false) + + assertFalse(sut.shouldOverrideUrlLoading(null, request)) + } + + private fun request(url: String, isForMainFrame: Boolean): WebResourceRequest { + val request = mock() + whenever(request.isForMainFrame).thenReturn(isForMainFrame) + whenever(request.url).thenReturn(url.toUri()) + return request + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt new file mode 100644 index 0000000000..3cf49995e0 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt @@ -0,0 +1,69 @@ +package to.bitkit.ui.screens.shop.shopWebView + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ShopWebViewInterfaceTest { + + @Test + fun `payment_intent from an allowed origin is forwarded`() { + var received: String? = null + val sut = interfaceOf( + pageUrl = "https://embed.bitrefill.com/gift-cards", + onPaymentIntent = { received = it }, + ) + + sut.postMessage("""{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}""") + + assertEquals("lightning:lnbcrt1shop", received) + } + + @Test + fun `payment_intent from a disallowed origin is ignored`() { + var received: String? = null + val sut = interfaceOf( + pageUrl = "https://evil.example", + onPaymentIntent = { received = it }, + ) + + sut.postMessage("""{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}""") + + assertNull(received) + } + + @Test + fun `payment_intent with a blank URI is ignored`() { + var received: String? = null + val sut = interfaceOf( + pageUrl = "https://embed.bitrefill.com", + onPaymentIntent = { received = it }, + ) + + sut.postMessage("""{"event":"payment_intent","paymentUri":" "}""") + + assertNull(received) + } + + @Test + fun `unknown events are ignored`() { + var received: String? = null + val sut = interfaceOf( + pageUrl = "https://embed.bitrefill.com", + onPaymentIntent = { received = it }, + ) + + sut.postMessage("""{"event":"invoice","paymentUri":"lightning:lnbcrt1shop"}""") + + assertNull(received) + } + + private fun interfaceOf( + pageUrl: String?, + onPaymentIntent: (String) -> Unit, + ) = ShopWebViewInterface( + onPaymentIntent = onPaymentIntent, + currentUrl = { pageUrl }, + runOnMain = { it() }, + ) +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 181f882999..0f39f333f9 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1728,6 +1728,73 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } + @Test + fun `lightning scan skips QuickPay when PIN is required for payments`() = test { + val bolt11 = "lnbcrt1quickpaypin" + enableQuickPay(thresholdSats = 1000u) + settingsData.value = settingsData.value.copy( + isPinEnabled = true, + isPinForPaymentsEnabled = true, + ) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + sut.setIsAuthenticated(true) + + sut.onScanResult(bolt11) + advanceUntilIdle() + + assertNull(sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + + @Test + fun `lightning scan uses QuickPay when PIN is on without PIN for payments`() = test { + val bolt11 = "lnbcrt1quickpayunlocked" + enableQuickPay(thresholdSats = 1000u) + settingsData.value = settingsData.value.copy(isPinEnabled = true) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + sut.setIsAuthenticated(true) + + sut.onScanResult(bolt11) + advanceUntilIdle() + + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) + } + + @Test + fun `lightning scan is queued until authenticated when PIN is enabled`() = test { + val bolt11 = "lnbcrt1lockedscan" + settingsData.value = SettingsData(isPinEnabled = true) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + + sut.onScanResult(bolt11) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + + @Test + fun `payment deeplink is queued until authenticated when PIN is enabled`() = test { + val bolt11 = "lnbcrt1lockeddeeplink" + settingsData.value = SettingsData(isPinEnabled = true) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "lightning:$bolt11".toUri())) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + @Test fun `contact lightning payment skips QuickPay and opens confirm`() = test { val bolt11 = "lnbcrt1contact" diff --git a/changelog.d/next/shop-quickpay-auth.security.md b/changelog.d/next/shop-quickpay-auth.security.md new file mode 100644 index 0000000000..e7711bca4b --- /dev/null +++ b/changelog.d/next/shop-quickpay-auth.security.md @@ -0,0 +1 @@ +Shop checkout only accepts Bitrefill payment requests, and QuickPay and payment links now honor PIN protection. From d7f8e3028e17c64a55f5f261136d7914836662bb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 18:58:20 +0000 Subject: [PATCH 02/12] chore: rename changelog fragment Co-authored-by: benk10 --- .../next/{shop-quickpay-auth.security.md => 1158.security.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{shop-quickpay-auth.security.md => 1158.security.md} (100%) diff --git a/changelog.d/next/shop-quickpay-auth.security.md b/changelog.d/next/1158.security.md similarity index 100% rename from changelog.d/next/shop-quickpay-auth.security.md rename to changelog.d/next/1158.security.md From ae32fb127318bb2d9cefb65d86a18ef79001614d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 19:00:27 +0000 Subject: [PATCH 03/12] fix: handle null shop origin scheme Co-authored-by: benk10 --- .../to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt | 2 +- .../screens/shop/shopWebView/ShopWebViewInterfaceTest.kt | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt index afc3277bb8..368fcfb0ac 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt @@ -13,7 +13,7 @@ fun isAllowedShopHost(host: String?): Boolean { fun isAllowedShopOrigin(url: String?): Boolean { if (url.isNullOrBlank()) return false val parsed = runCatching { URI(url.trim()) }.getOrNull() ?: return false - if (!parsed.scheme.equals("https", ignoreCase = true)) return false + if (parsed.scheme?.equals("https", ignoreCase = true) != true) return false return isAllowedShopHost(parsed.host) } diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt index 3cf49995e0..7afce349d0 100644 --- a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt @@ -1,10 +1,16 @@ package to.bitkit.ui.screens.shop.shopWebView import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertNull -class ShopWebViewInterfaceTest { +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class ShopWebViewInterfaceTest : BaseUnitTest() { @Test fun `payment_intent from an allowed origin is forwarded`() { From d317fb845b7bbf9034cf67fd0b85374163e071d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 19:00:36 +0000 Subject: [PATCH 04/12] test: reject shop origin without scheme Co-authored-by: benk10 --- .../java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt index f7ae51d84b..d667b1ded8 100644 --- a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt @@ -29,6 +29,7 @@ class ShopOriginTest { fun `non-Bitrefill and non-https origins are rejected`() { assertFalse(isAllowedShopOrigin(null)) assertFalse(isAllowedShopOrigin("")) + assertFalse(isAllowedShopOrigin("embed.bitrefill.com")) assertFalse(isAllowedShopOrigin("https://evil.example")) assertFalse(isAllowedShopOrigin("https://bitrefill.com.evil.example")) assertFalse(isAllowedShopOrigin("https://notbitrefill.com")) From 553bd6577f7b5a749c9282fdb740662ecf2ef621 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 19:05:13 +0000 Subject: [PATCH 05/12] fix: satisfy detekt on scan log ids Co-authored-by: benk10 --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 64357c82b4..e5fa99a1c0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -71,6 +71,7 @@ import org.lightningdevkit.ldknode.Txid import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin @@ -1620,8 +1621,8 @@ class AppViewModel @Inject constructor( private fun scanLogId(data: String): String { val scanLogInput = SamRockSetupRequest.sanitizedDescription(data.removeLightningSchemes()) ?: data - return if (scanLogInput.length > 24) { - "${scanLogInput.take(11)}…${scanLogInput.takeLast(11)}" + return if (scanLogInput.length > SCAN_LOG_ID_MAX_LENGTH) { + "${scanLogInput.take(SCAN_LOG_ID_AFFIX_LENGTH)}…${scanLogInput.takeLast(SCAN_LOG_ID_AFFIX_LENGTH)}" } else { scanLogInput } @@ -2495,13 +2496,7 @@ class AppViewModel @Inject constructor( if (hasActiveContactPaymentContext()) return false val settings = settingsStore.data.first() - if (!settings.isQuickPayEnabled || amountSats == 0uL) { - return false - } - if (settings.isPinEnabled && settings.isPinForPaymentsEnabled) { - Logger.debug("Skipping QuickPay because PIN is required for payments", context = TAG) - return false - } + if (!canApplyQuickPay(settings, amountSats)) return false val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() ?: return false @@ -2534,6 +2529,15 @@ class AppViewModel @Inject constructor( return false } + private fun canApplyQuickPay(settings: SettingsData, amountSats: ULong): Boolean { + if (!settings.isQuickPayEnabled || amountSats == 0uL) return false + if (settings.isPinEnabled && settings.isPinForPaymentsEnabled) { + Logger.debug("Skipping QuickPay because PIN is required for payments", context = TAG) + return false + } + return true + } + private fun resetAmountInput() { _sendUiState.update { state -> state.copy( @@ -3773,6 +3777,13 @@ class AppViewModel @Inject constructor( private const val BITKIT_SCHEME = "bitkit" private const val PUBKYAUTH_SCHEME = "pubkyauth" private const val RECOVERY_MODE_DEEPLINK = "recovery-mode" + + /** Max characters kept in a scan log id before truncating. */ + private const val SCAN_LOG_ID_MAX_LENGTH = 24 + + /** Characters kept on each side of a truncated scan log id. */ + private const val SCAN_LOG_ID_AFFIX_LENGTH = 11 + private val LNURL_WITHDRAW_EXPIRY_SEC = 1.hours.inWholeSeconds.toUInt() /** Intent actions carrying a deeplink URI: browsers and apps send VIEW, NFC tag taps send NDEF_DISCOVERED. */ From d1fc2a3d4cda3fe8d0e6315a8a089ef22ea6128f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 19:11:00 +0000 Subject: [PATCH 06/12] fix: keep queued scans while locked Co-authored-by: benk10 --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 55 +++++++++++++++---- .../viewmodels/AppViewModelSendFlowTest.kt | 48 ++++++++++++++++ 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e5fa99a1c0..1a79059042 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -257,8 +257,8 @@ class AppViewModel @Inject constructor( @Volatile private var activeScanInput: String? = null - @Volatile - private var pendingLockedScan: PendingLockedScan? = null + private val pendingLockedScansLock = Any() + private val pendingLockedScans = ArrayDeque() private val _sendEffect = MutableSharedFlow(extraBufferCapacity = 1) val sendEffect = _sendEffect.asSharedFlow() @@ -1585,11 +1585,7 @@ class AppViewModel @Inject constructor( routePubkyKeys: Boolean = false, ) { if (!_isAuthenticated.value) { - Logger.info( - "Queuing '${source.label}' scan until authenticated: '${scanLogId(data)}'", - context = TAG, - ) - pendingLockedScan = PendingLockedScan( + enqueueLockedScan( source = source, data = data, startDelay = startDelay, @@ -1616,7 +1612,13 @@ class AppViewModel @Inject constructor( activeScanJob = viewModelScope.launch { if (startDelay > Duration.ZERO) delay(startDelay) handleScan(data, routePubkyKeys) - }.also { it.invokeOnCompletion { if (activeScanInput == normalized) activeScanInput = null } } + }.also { job -> + job.invokeOnCompletion { + if (activeScanInput == normalized) activeScanInput = null + if (job.isCancelled) return@invokeOnCompletion + viewModelScope.launch { flushPendingLockedScan() } + } + } } private fun scanLogId(data: String): String { @@ -1628,9 +1630,41 @@ class AppViewModel @Inject constructor( } } + private fun enqueueLockedScan( + source: ScanSource, + data: String, + startDelay: Duration, + routePubkyKeys: Boolean, + ) { + val scanId = scanLogId(data) + val normalized = data.removeLightningSchemes() + synchronized(pendingLockedScansLock) { + val alreadyQueued = pendingLockedScans.any { it.data.removeLightningSchemes() == normalized } + if (alreadyQueued) { + Logger.info("Skipping duplicate queued scan from '${source.label}': '$scanId'", context = TAG) + return + } + pendingLockedScans.addLast( + PendingLockedScan( + source = source, + data = data, + startDelay = startDelay, + routePubkyKeys = routePubkyKeys, + ), + ) + } + Logger.info("Queuing '${source.label}' scan until authenticated: '$scanId'", context = TAG) + } + private fun flushPendingLockedScan() { - val pending = pendingLockedScan ?: return - pendingLockedScan = null + if (!_isAuthenticated.value) return + if (activeScanJob?.isActive == true) return + if (_currentSheet.value != null) return + + val pending = synchronized(pendingLockedScansLock) { + pendingLockedScans.removeFirstOrNull() + } ?: return + launchScan( source = pending.source, data = pending.data, @@ -3285,6 +3319,7 @@ class AppViewModel @Inject constructor( } clearActiveContactPaymentContext() showQueuedPairingCodeSheet() + flushPendingLockedScan() } // endregion diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 0f39f333f9..03888d9dcc 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1795,6 +1795,54 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) } + @Test + fun `locked scans are replayed in order after authenticate`() = test { + val first = "lnbcrt1lockedfirst" + val second = "lnbcrt1lockedsecond" + settingsData.value = SettingsData(isPinEnabled = true) + stubLightningScan(bolt11 = first, amountSats = 500u) + stubLightningScan(bolt11 = second, amountSats = 600u) + + sut.onScanResult(first) + sut.onScanResult(second) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(500u, sut.sendUiState.value.amount) + + sut.hideSheet() + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(600u, sut.sendUiState.value.amount) + } + + @Test + fun `duplicate locked scans are queued once`() = test { + val bolt11 = "lnbcrt1lockeddup" + settingsData.value = SettingsData(isPinEnabled = true) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + + sut.onScanResult(bolt11) + sut.onScanResult(bolt11) + advanceUntilIdle() + + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + + sut.hideSheet() + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + } + @Test fun `contact lightning payment skips QuickPay and opens confirm`() = test { val bolt11 = "lnbcrt1contact" From 645d1142997ac9aa9f3b8617c3bcd4446f2ef3dc Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 13 Aug 2026 14:52:44 -0500 Subject: [PATCH 07/12] fix: harden shop bridge and scan queue Co-authored-by: Cursor --- .cursor/notes/libs.md | 4 + app/build.gradle.kts | 1 + .../ui/screens/shop/shopWebView/ShopOrigin.kt | 5 ++ .../shop/shopWebView/ShopWebViewInterface.kt | 52 +++++++++--- .../shop/shopWebView/ShopWebViewScreen.kt | 4 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 39 +++++++-- .../shop/shopWebView/ShopOriginTest.kt | 5 ++ .../shopWebView/ShopWebViewInterfaceTest.kt | 32 +++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 84 ++++++++++++++++++- gradle/libs.versions.toml | 1 + 10 files changed, 204 insertions(+), 23 deletions(-) diff --git a/.cursor/notes/libs.md b/.cursor/notes/libs.md index 8bf348e91c..3e3cddb284 100644 --- a/.cursor/notes/libs.md +++ b/.cursor/notes/libs.md @@ -31,6 +31,10 @@ This document provides a comprehensive reference for all libraries used in the b ### Layout - **ConstraintLayout Compose**: https://developer.android.com/jetpack/compose/layouts/constraintlayout +### WebKit +- **Documentation**: https://developer.android.com/jetpack/androidx/releases/webkit +- **WebMessageListener**: https://developer.android.com/develop/ui/views/layout/webapps/native-api-access-jsbridge + ## Architecture & Dependency Injection ### Hilt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 11a69278cc..1866008f4a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -498,6 +498,7 @@ dependencies { implementation(libs.core.ktx) implementation(libs.core.splashscreen) implementation(libs.appcompat) + implementation(libs.webkit) implementation(libs.activity.compose) implementation(libs.material) implementation(libs.datastore.preferences) diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt index 368fcfb0ac..955b79d987 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt @@ -17,6 +17,11 @@ fun isAllowedShopOrigin(url: String?): Boolean { return isAllowedShopHost(parsed.host) } +fun shopAllowedOriginRules(): Set = setOf( + "https://$BITREFILL_ROOT_HOST", + "https://*.$BITREFILL_ROOT_HOST", +) + internal fun shopMessageBridgeScript(): String = """ window.ReactNativeWebView = { postMessage: function(data) { diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt index d2474cf532..eb0727c290 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt @@ -1,18 +1,21 @@ package to.bitkit.ui.screens.shop.shopWebView +import android.annotation.SuppressLint import android.os.Handler import android.os.Looper import android.webkit.JavascriptInterface +import android.webkit.WebView +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature import kotlinx.serialization.json.Json import to.bitkit.utils.Logger /** * JavaScript interface for handling WebView messages. * - * SECURITY NOTE: This interface is exposed to JavaScript running in the WebView. - * Only methods annotated with @JavascriptInterface are accessible from JavaScript - * on API 17+ (Android 4.2+). All methods should validate input and handle errors - * gracefully since they run on a background thread. + * Prefer [attachTo], which uses an origin-scoped WebMessageListener when the + * WebView supports it. [addJavascriptInterface] is only a fallback and cannot + * tell which iframe called [postMessage]. * * Thread Safety: JavaScript interacts with this object on a private background * thread. All callbacks should be thread-safe or use appropriate dispatching. @@ -26,11 +29,31 @@ class ShopWebViewInterface( ) { private companion object { const val TAG = "ShopWebViewInterface" + const val JS_OBJECT_NAME = "Android" const val PAYMENT_INTENT_EVENT = "payment_intent" } private val json = Json { ignoreUnknownKeys = true } + @SuppressLint("JavascriptInterface") + fun attachTo(webView: WebView) { + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + WebViewCompat.addWebMessageListener( + webView, + JS_OBJECT_NAME, + shopAllowedOriginRules(), + ) { _, message, sourceOrigin, _, _ -> + onBridgeMessage(message.data.orEmpty(), sourceOrigin.toString()) + } + return + } + Logger.warn( + "Using JavascriptInterface shop bridge because WebMessageListener is unavailable", + context = TAG, + ) + webView.addJavascriptInterface(this, JS_OBJECT_NAME) + } + /** * Handles messages posted from JavaScript. * This method is called on a background thread - ensure thread safety. @@ -39,11 +62,7 @@ class ShopWebViewInterface( */ @JavascriptInterface fun postMessage(message: String) { - if (message.isBlank()) { - Logger.warn("Received empty shop WebView message", context = TAG) - return - } - runOnMain { handlePaymentMessage(message) } + onBridgeMessage(message, currentUrl()) } /** @@ -57,10 +76,17 @@ class ShopWebViewInterface( return true } - private fun handlePaymentMessage(message: String) { - val pageUrl = currentUrl() - if (!isAllowedShopOrigin(pageUrl)) { - Logger.warn("Rejected shop payment_intent from untrusted origin '$pageUrl'", context = TAG) + internal fun onBridgeMessage(message: String, sourceOrigin: String?) { + if (message.isBlank()) { + Logger.warn("Received empty shop WebView message", context = TAG) + return + } + runOnMain { handlePaymentMessage(message, sourceOrigin) } + } + + internal fun handlePaymentMessage(message: String, sourceOrigin: String?) { + if (!isAllowedShopOrigin(sourceOrigin)) { + Logger.warn("Rejected shop payment_intent from untrusted origin '$sourceOrigin'", context = TAG) return } diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt index ee816eb720..9e83a221b3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt @@ -25,7 +25,7 @@ import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.theme.AppThemeSurface -@SuppressLint("SetJavaScriptEnabled", "JavascriptInterface") +@SuppressLint("SetJavaScriptEnabled") @Composable fun ShopWebViewScreen( onClose: () -> Unit, @@ -69,7 +69,7 @@ fun ShopWebViewScreen( webView = this this.webViewClient = webViewClient configureForBasicWebContent() - addJavascriptInterface(webViewInterface, "Android") + webViewInterface.attachTo(this) loadUrl(bitrefillUrlOf(page)) } }, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1a79059042..5cff3bb5e3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -384,9 +384,12 @@ class AppViewModel @Inject constructor( showPairingCodeSheet(requestId) } else { queuedPairingCodeRequestId = null + val shouldFlush = _currentSheet.value is Sheet.Hardware && + (_currentSheet.value as? Sheet.Hardware)?.route is HardwareRoute.PairCode _currentSheet.update { sheet -> if (sheet is Sheet.Hardware && sheet.route is HardwareRoute.PairCode) null else sheet } + if (shouldFlush) flushPendingLockedScan() } } } @@ -400,10 +403,11 @@ class AppViewModel @Inject constructor( showSheet(Sheet.TimedSheet(sheetType)) } } else { - // Clear the timed sheet when manager sets it to null + val shouldFlush = _currentSheet.value is Sheet.TimedSheet _currentSheet.update { current -> if (current is Sheet.TimedSheet) null else current } + if (shouldFlush) flushPendingLockedScan() } } } @@ -1583,6 +1587,7 @@ class AppViewModel @Inject constructor( data: String, startDelay: Duration = Duration.ZERO, routePubkyKeys: Boolean = false, + contactPaymentContext: ContactPaymentContext? = null, ) { if (!_isAuthenticated.value) { enqueueLockedScan( @@ -1590,6 +1595,7 @@ class AppViewModel @Inject constructor( data = data, startDelay = startDelay, routePubkyKeys = routePubkyKeys, + contactPaymentContext = contactPaymentContext, ) return } @@ -1635,6 +1641,7 @@ class AppViewModel @Inject constructor( data: String, startDelay: Duration, routePubkyKeys: Boolean, + contactPaymentContext: ContactPaymentContext?, ) { val scanId = scanLogId(data) val normalized = data.removeLightningSchemes() @@ -1644,12 +1651,20 @@ class AppViewModel @Inject constructor( Logger.info("Skipping duplicate queued scan from '${source.label}': '$scanId'", context = TAG) return } + while (pendingLockedScans.size >= MAX_PENDING_LOCKED_SCANS) { + val dropped = pendingLockedScans.removeFirst() + Logger.warn( + "Dropping oldest queued scan from '${dropped.source.label}': '${scanLogId(dropped.data)}'", + context = TAG, + ) + } pendingLockedScans.addLast( PendingLockedScan( source = source, data = data, startDelay = startDelay, routePubkyKeys = routePubkyKeys, + contactPaymentContext = contactPaymentContext, ), ) } @@ -1665,11 +1680,15 @@ class AppViewModel @Inject constructor( pendingLockedScans.removeFirstOrNull() } ?: return + synchronized(contactPaymentContextLock) { + activeContactPaymentContext = pending.contactPaymentContext + } launchScan( source = pending.source, data = pending.data, startDelay = pending.startDelay, routePubkyKeys = pending.routePubkyKeys, + contactPaymentContext = pending.contactPaymentContext, ) } @@ -1895,12 +1914,14 @@ class AppViewModel @Inject constructor( data: String, startDelay: Duration = Duration.ZERO, routePubkyKeys: Boolean = false, + contactPaymentContext: ContactPaymentContext? = null, ) { launchScan( source = ScanSource.SCAN_RESULT, data = data, startDelay = startDelay, routePubkyKeys = routePubkyKeys, + contactPaymentContext = contactPaymentContext, ) } @@ -1910,14 +1931,15 @@ class AppViewModel @Inject constructor( privatePaymentContext: PrivatePaykitPaymentContext? = null, incomingPaymentRequest: PaykitPaymentRequest? = null, ) { + val context = ContactPaymentContext( + publicKey = publicKey, + privatePaymentContext = privatePaymentContext, + incomingPaymentRequest = incomingPaymentRequest, + ) synchronized(contactPaymentContextLock) { - activeContactPaymentContext = ContactPaymentContext( - publicKey = publicKey, - privatePaymentContext = privatePaymentContext, - incomingPaymentRequest = incomingPaymentRequest, - ) + activeContactPaymentContext = context } - onScanResult(paymentRequest) + onScanResult(paymentRequest, contactPaymentContext = context) } fun preserveContactPaymentContext(paymentHash: String) { @@ -3781,6 +3803,7 @@ class AppViewModel @Inject constructor( val data: String, val startDelay: Duration, val routePubkyKeys: Boolean, + val contactPaymentContext: ContactPaymentContext?, ) companion object { @@ -3819,6 +3842,8 @@ class AppViewModel @Inject constructor( /** Characters kept on each side of a truncated scan log id. */ private const val SCAN_LOG_ID_AFFIX_LENGTH = 11 + private const val MAX_PENDING_LOCKED_SCANS = 5 + private val LNURL_WITHDRAW_EXPIRY_SEC = 1.hours.inWholeSeconds.toUInt() /** Intent actions carrying a deeplink URI: browsers and apps send VIEW, NFC tag taps send NDEF_DISCOVERED. */ diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt index d667b1ded8..63c748410d 100644 --- a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.screens.shop.shopWebView import org.junit.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -23,6 +24,10 @@ class ShopOriginTest { assertTrue(isAllowedShopOrigin("https://www.bitrefill.com/esims")) assertTrue(isAllowedShopHost("embed.bitrefill.com")) assertTrue(isAllowedShopHost("BITREFILL.COM")) + assertEquals( + setOf("https://bitrefill.com", "https://*.bitrefill.com"), + shopAllowedOriginRules(), + ) } @Test diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt index 7afce349d0..50f12d6131 100644 --- a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt @@ -64,6 +64,38 @@ class ShopWebViewInterfaceTest : BaseUnitTest() { assertNull(received) } + @Test + fun `payment_intent from an iframe origin is rejected even if the top-level page is allowed`() { + var received: String? = null + val sut = interfaceOf( + pageUrl = "https://embed.bitrefill.com/gift-cards", + onPaymentIntent = { received = it }, + ) + + sut.onBridgeMessage( + """{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}""", + "https://evil.example", + ) + + assertNull(received) + } + + @Test + fun `payment_intent from a bitrefill iframe origin is forwarded`() { + var received: String? = null + val sut = interfaceOf( + pageUrl = "https://embed.bitrefill.com/gift-cards", + onPaymentIntent = { received = it }, + ) + + sut.onBridgeMessage( + """{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}""", + "https://checkout.bitrefill.com", + ) + + assertEquals("lightning:lnbcrt1shop", received) + } + private fun interfaceOf( pageUrl: String?, onPaymentIntent: (String) -> Unit, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 03888d9dcc..ca0deaaaa9 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -102,6 +102,7 @@ import to.bitkit.services.NodeServiceFgState import to.bitkit.test.BaseUnitTest import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet +import to.bitkit.ui.components.TimedSheetType import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.hardware.HardwareRoute @@ -177,9 +178,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private val timedSheetManager = mock() + private val timedSheetType = MutableStateFlow(null) @Before fun setUp() { + timedSheetType.value = null stubRepositories() sut = createViewModel() } @@ -213,7 +216,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) whenever { blocktankRepo.refreshInfo() }.thenReturn(Result.success(Unit)) - whenever(timedSheetManager.currentSheet).thenReturn(MutableStateFlow(null)) + whenever(timedSheetManager.currentSheet).thenReturn(timedSheetType) whenever(migrationService.isShowingMigrationLoading).thenReturn(MutableStateFlow(false)) whenever { migrationService.needsPostMigrationSync() }.thenReturn(false) whenever { migrationService.isMigrationChecked() }.thenReturn(true) @@ -1843,6 +1846,85 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(sut.currentSheet.value) } + @Test + fun `locked scans restore their own contact payment context`() = test { + val normal = "lnbcrt1lockednormal" + val contact = "lnbcrt1lockedcontact" + settingsData.value = SettingsData(isPinEnabled = true) + stubLightningScan(bolt11 = normal, amountSats = 500u) + stubLightningScan(bolt11 = contact, amountSats = 600u) + + sut.onScanResult(normal) + sut.openContactPayment(paymentRequest = contact, publicKey = "pubkycontact") + advanceUntilIdle() + + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(500u, sut.sendUiState.value.amount) + assertNull(activeContactPaymentContext()) + + sut.hideSheet() + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(600u, sut.sendUiState.value.amount) + assertEquals(ContactPaymentContext("pubkycontact"), activeContactPaymentContext()) + } + + @Test + fun `locked scan queue drops the oldest when full`() = test { + settingsData.value = SettingsData(isPinEnabled = true) + val invoices = (1..6).map { index -> + "lnbcrt1lockedcap$index" to (100uL * index.toULong()) + } + invoices.forEach { (bolt11, amount) -> stubLightningScan(bolt11 = bolt11, amountSats = amount) } + + invoices.forEach { (bolt11, _) -> sut.onScanResult(bolt11) } + advanceUntilIdle() + + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(200uL, sut.sendUiState.value.amount) + + repeat(4) { + sut.hideSheet() + advanceUntilIdle() + } + + assertEquals(600uL, sut.sendUiState.value.amount) + + sut.hideSheet() + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + } + + @Test + fun `queued scan flushes after timed sheet dismisses`() = test { + val bolt11 = "lnbcrt1lockedtimed" + settingsData.value = SettingsData(isPinEnabled = true) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + + timedSheetType.value = TimedSheetType.BACKUP + advanceUntilIdle() + + assertTrue(sut.currentSheet.value is Sheet.TimedSheet) + + sut.onScanResult(bolt11) + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertTrue(sut.currentSheet.value is Sheet.TimedSheet) + + timedSheetType.value = null + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + @Test fun `contact lightning payment skips QuickPay and opens confirm`() = test { val bolt11 = "lnbcrt1contact" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e94df2ee1f..b3a2a70469 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -90,6 +90,7 @@ test-mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = test-robolectric = { module = "org.robolectric:robolectric", version = "4.16.1" } test-turbine = { group = "app.cash.turbine", name = "turbine", version = "1.2.1" } vss-client = { module = "com.synonym:vss-client-android", version = "0.5.21" } +webkit = { module = "androidx.webkit:webkit", version = "1.16.0" } work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.0" } zxing = { module = "com.google.zxing:core", version = "3.5.4" } lottie = { module = "com.airbnb.android:lottie-compose", version = "6.7.1" } From 2aa9927f5077508fd7795ff0316ee3d785f6a65e Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 13 Aug 2026 17:39:01 -0500 Subject: [PATCH 08/12] fix: harden payment flow safeguards --- .../shop/shopWebView/ShopWebViewClient.kt | 5 +- .../shop/shopWebView/ShopWebViewInterface.kt | 85 +- .../shop/shopWebView/ShopWebViewScreen.kt | 2 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 146 ++- .../shop/shopWebView/ShopOriginTest.kt | 6 +- .../shop/shopWebView/ShopWebViewClientTest.kt | 28 + .../shopWebView/ShopWebViewInterfaceTest.kt | 109 ++- .../viewmodels/AppViewModelSendFlowTest.kt | 848 +++++++++++++++++- 8 files changed, 1117 insertions(+), 112 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt index 19d04a10c5..e92d4effd1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt @@ -13,6 +13,7 @@ import to.bitkit.utils.Logger class ShopWebViewClient( private val onLoadingStateChanged: (Boolean) -> Unit, private val onError: () -> Unit, + private val isPaymentBridgeSupported: () -> Boolean, ) : WebViewClient() { private companion object { const val TAG = "ShopWebViewClient" @@ -27,7 +28,9 @@ class ShopWebViewClient( super.onPageFinished(view, url) onLoadingStateChanged(false) - view?.evaluateJavascript(shopMessageBridgeScript(), null) + if (isPaymentBridgeSupported()) { + view?.evaluateJavascript(shopMessageBridgeScript(), null) + } } override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt index eb0727c290..d2074c4856 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt @@ -1,10 +1,7 @@ package to.bitkit.ui.screens.shop.shopWebView -import android.annotation.SuppressLint -import android.os.Handler -import android.os.Looper -import android.webkit.JavascriptInterface import android.webkit.WebView +import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import kotlinx.serialization.json.Json @@ -13,18 +10,22 @@ import to.bitkit.utils.Logger /** * JavaScript interface for handling WebView messages. * - * Prefer [attachTo], which uses an origin-scoped WebMessageListener when the - * WebView supports it. [addJavascriptInterface] is only a fallback and cannot - * tell which iframe called [postMessage]. - * - * Thread Safety: JavaScript interacts with this object on a private background - * thread. All callbacks should be thread-safe or use appropriate dispatching. + * [attachTo] uses an origin-scoped WebMessageListener. Payment handling is + * disabled when that listener is unavailable because legacy JavaScript + * interfaces cannot identify the calling frame. */ class ShopWebViewInterface( private val onPaymentIntent: (String) -> Unit, - private val currentUrl: () -> String?, - private val runOnMain: (() -> Unit) -> Unit = { action -> - Handler(Looper.getMainLooper()).post(action) + private val isWebMessageListenerSupported: () -> Boolean = { + WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) + }, + private val addWebMessageListener: ( + WebView, + String, + Set, + WebViewCompat.WebMessageListener, + ) -> Unit = { webView, jsObjectName, allowedOriginRules, listener -> + WebViewCompat.addWebMessageListener(webView, jsObjectName, allowedOriginRules, listener) }, ) { private companion object { @@ -34,54 +35,36 @@ class ShopWebViewInterface( } private val json = Json { ignoreUnknownKeys = true } + private val webMessageListenerSupported by lazy(isWebMessageListenerSupported) + + internal fun supportsPaymentBridge() = webMessageListenerSupported - @SuppressLint("JavascriptInterface") fun attachTo(webView: WebView) { - if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { - WebViewCompat.addWebMessageListener( - webView, - JS_OBJECT_NAME, - shopAllowedOriginRules(), - ) { _, message, sourceOrigin, _, _ -> - onBridgeMessage(message.data.orEmpty(), sourceOrigin.toString()) - } + if (!supportsPaymentBridge()) { + Logger.warn("Disabled shop payment bridge because WebMessageListener is unavailable", context = TAG) return } - Logger.warn( - "Using JavascriptInterface shop bridge because WebMessageListener is unavailable", - context = TAG, - ) - webView.addJavascriptInterface(this, JS_OBJECT_NAME) - } - /** - * Handles messages posted from JavaScript. - * This method is called on a background thread - ensure thread safety. - * - * @param message JSON string containing the message data - */ - @JavascriptInterface - fun postMessage(message: String) { - onBridgeMessage(message, currentUrl()) - } - - /** - * Returns whether the interface is ready to receive messages. - * - * @return true if the interface is initialized and ready - */ - @Suppress("FunctionOnlyReturningConstant") - @JavascriptInterface - fun isReady(): Boolean { - return true + addWebMessageListener( + webView, + JS_OBJECT_NAME, + shopAllowedOriginRules(), + ) { _, message, sourceOrigin, _, _ -> + onWebMessage(message, sourceOrigin.toString()) + } } - internal fun onBridgeMessage(message: String, sourceOrigin: String?) { - if (message.isBlank()) { + internal fun onWebMessage(message: WebMessageCompat, sourceOrigin: String?) { + if (message.type != WebMessageCompat.TYPE_STRING) { + Logger.warn("Rejected non-string shop WebView message", context = TAG) + return + } + val data = message.data.orEmpty() + if (data.isBlank()) { Logger.warn("Received empty shop WebView message", context = TAG) return } - runOnMain { handlePaymentMessage(message, sourceOrigin) } + handlePaymentMessage(data, sourceOrigin) } internal fun handlePaymentMessage(message: String, sourceOrigin: String?) { diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt index 9e83a221b3..7354fe9c78 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt @@ -40,13 +40,13 @@ fun ShopWebViewScreen( val webViewInterface = remember { ShopWebViewInterface( onPaymentIntent = onPaymentIntent, - currentUrl = { webView?.url }, ) } val webViewClient = remember { ShopWebViewClient( onLoadingStateChanged = { loading -> isLoading = loading }, onError = onClose, + isPaymentBridgeSupported = webViewInterface::supportsPaymentBridge, ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 5cff3bb5e3..6ca6629436 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.net.Uri import android.nfc.NfcAdapter import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -58,6 +59,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.lightningdevkit.ldknode.Bolt11Invoice @@ -97,6 +100,7 @@ import to.bitkit.ext.minSendableSat import to.bitkit.ext.minWithdrawableSat import to.bitkit.ext.rawId import to.bitkit.ext.removeSpaces +import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.setClipboardText import to.bitkit.ext.toHex import to.bitkit.ext.toUserMessage @@ -253,9 +257,16 @@ class AppViewModel @Inject constructor( val quickPayData = _quickPayData.asStateFlow() private var activeScanJob: Job? = null + private val scanMutex = Mutex() @Volatile - private var activeScanInput: String? = null + private var scheduledScanInput: String? = null + + @Volatile + private var scheduledScanContext: ContactPaymentContext? = null + + @Volatile + private var scheduledScanMustComplete = false private val pendingLockedScansLock = Any() private val pendingLockedScans = ArrayDeque() @@ -684,7 +695,7 @@ class AppViewModel @Inject constructor( private suspend fun presentNextIncomingPaykitPaymentRequest() { val requests = paykitPaymentRequestRepo.pendingRequests.value retainPaymentRequestPresentationState(requests) - if (currentSheet.value != null || isPresentingPaymentRequest || hasActiveContactPaymentContext()) return + if (isPresentingPaymentRequest || isPaymentRequestPresentationBlocked()) return isPresentingPaymentRequest = true try { for (request in requests.filter { request -> @@ -702,7 +713,7 @@ class AppViewModel @Inject constructor( private suspend fun presentIncomingPaymentRequestOrStop(request: PaykitPaymentRequest): Boolean { val result = privatePaykitRepo.beginPaymentRequest(request).getOrNull() - if (currentSheet.value != null || hasActiveContactPaymentContext()) return true + if (isPaymentRequestPresentationBlocked()) return true val isPending = paykitPaymentRequestRepo.isPending(request) if (result !is PublicPaykitPaymentResult.Opened || !isPending) { if (isPending) deferPaymentRequestPresentation(request) @@ -1530,9 +1541,10 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy(isAddressInputValid = true) } } - private suspend fun extractViableLightningInvoice(params: Map?): LightningInvoice? = + @VisibleForTesting + internal suspend fun extractViableLightningInvoice(params: Map?): LightningInvoice? = params?.get("lightning")?.let { bolt11 -> - runCatching { coreService.decode(bolt11) }.getOrNull() + runSuspendCatching { coreService.decode(bolt11) }.getOrNull() ?.let { it as? Scanner.Lightning } ?.invoice ?.takeIf { lnInv -> @@ -1588,6 +1600,7 @@ class AppViewModel @Inject constructor( startDelay: Duration = Duration.ZERO, routePubkyKeys: Boolean = false, contactPaymentContext: ContactPaymentContext? = null, + preserveUntilComplete: Boolean = false, ) { if (!_isAuthenticated.value) { enqueueLockedScan( @@ -1603,26 +1616,46 @@ class AppViewModel @Inject constructor( val normalized = data.removeLightningSchemes() val scanId = scanLogId(data) - if (normalized == activeScanInput && activeScanJob?.isActive == true) { + val isSameActiveScan = normalized == scheduledScanInput && + activeScanJob?.isActive == true && + (scheduledScanContext == contactPaymentContext || contactPaymentContext == null) + if (isSameActiveScan) { Logger.info("Skipping duplicate scan from '${source.label}': '$scanId'", context = TAG) return } + if (activeScanJob?.isActive == true && scheduledScanMustComplete) { + enqueueLockedScan(source, data, startDelay, routePubkyKeys, contactPaymentContext) + return + } + activeScanJob?.let { Logger.info("Cancelling prior scan for new '${source.label}': '$scanId'", context = TAG) it.cancel() } - activeScanInput = normalized + scheduledScanInput = normalized + scheduledScanContext = contactPaymentContext + scheduledScanMustComplete = preserveUntilComplete Logger.debug("Starting scan from '${source.label}': '$scanId'", context = TAG) activeScanJob = viewModelScope.launch { - if (startDelay > Duration.ZERO) delay(startDelay) - handleScan(data, routePubkyKeys) + scanMutex.withLock { + setActiveContactPaymentContext(contactPaymentContext) + if (startDelay > Duration.ZERO) delay(startDelay) + handleScan(data, routePubkyKeys) + } }.also { job -> job.invokeOnCompletion { - if (activeScanInput == normalized) activeScanInput = null + if (activeScanJob === job) { + scheduledScanInput = null + scheduledScanContext = null + scheduledScanMustComplete = false + } if (job.isCancelled) return@invokeOnCompletion - viewModelScope.launch { flushPendingLockedScan() } + viewModelScope.launch { + flushPendingLockedScan() + presentNextIncomingPaykitPaymentRequest() + } } } } @@ -1646,13 +1679,30 @@ class AppViewModel @Inject constructor( val scanId = scanLogId(data) val normalized = data.removeLightningSchemes() synchronized(pendingLockedScansLock) { - val alreadyQueued = pendingLockedScans.any { it.data.removeLightningSchemes() == normalized } - if (alreadyQueued) { + val queuedIndex = pendingLockedScans.indexOfFirst { it.data.removeLightningSchemes() == normalized } + if (queuedIndex >= 0) { + val queued = pendingLockedScans[queuedIndex] + if (contactPaymentContext != null) { + val queuedRequestId = queued.contactPaymentContext?.incomingPaymentRequest?.id + val incomingRequestId = contactPaymentContext.incomingPaymentRequest?.id + if (queuedRequestId != null && queuedRequestId != incomingRequestId) { + releasePaymentRequestPresentation(queued) + } + pendingLockedScans[queuedIndex] = PendingLockedScan( + source = source, + data = data, + startDelay = startDelay, + routePubkyKeys = routePubkyKeys, + contactPaymentContext = contactPaymentContext, + ) + return + } Logger.info("Skipping duplicate queued scan from '${source.label}': '$scanId'", context = TAG) return } while (pendingLockedScans.size >= MAX_PENDING_LOCKED_SCANS) { val dropped = pendingLockedScans.removeFirst() + releasePaymentRequestPresentation(dropped) Logger.warn( "Dropping oldest queued scan from '${dropped.source.label}': '${scanLogId(dropped.data)}'", context = TAG, @@ -1668,30 +1718,61 @@ class AppViewModel @Inject constructor( ), ) } - Logger.info("Queuing '${source.label}' scan until authenticated: '$scanId'", context = TAG) + Logger.info("Queuing '${source.label}' scan for deferred handling: '$scanId'", context = TAG) + } + + private fun releasePaymentRequestPresentation(scan: PendingLockedScan) { + val requestId = scan.contactPaymentContext?.incomingPaymentRequest?.id ?: return + presentedPaymentRequestIds -= requestId } + private fun isScanPendingOrActive(): Boolean { + if (activeScanJob?.isActive == true) return true + return synchronized(pendingLockedScansLock) { pendingLockedScans.isNotEmpty() } + } + + private fun isPaymentRequestPresentationBlocked() = currentSheet.value != null || + hasActiveContactPaymentContext() || + isScanPendingOrActive() + private fun flushPendingLockedScan() { if (!_isAuthenticated.value) return if (activeScanJob?.isActive == true) return if (_currentSheet.value != null) return - val pending = synchronized(pendingLockedScansLock) { - pendingLockedScans.removeFirstOrNull() - } ?: return - - synchronized(contactPaymentContextLock) { - activeContactPaymentContext = pending.contactPaymentContext + val pending = takeNextPendingLockedScan() ?: run { + viewModelScope.launch { presentNextIncomingPaykitPaymentRequest() } + return } + + setActiveContactPaymentContext(pending.contactPaymentContext) launchScan( source = pending.source, data = pending.data, startDelay = pending.startDelay, routePubkyKeys = pending.routePubkyKeys, contactPaymentContext = pending.contactPaymentContext, + preserveUntilComplete = true, ) } + private fun takeNextPendingLockedScan(): PendingLockedScan? { + while (true) { + val pending = synchronized(pendingLockedScansLock) { + pendingLockedScans.removeFirstOrNull() + } ?: return null + val request = pending.contactPaymentContext?.incomingPaymentRequest + if (request == null || paykitPaymentRequestRepo.isPending(request)) return pending + + releasePaymentRequestPresentation(pending) + synchronized(contactPaymentContextLock) { + if (activeContactPaymentContext == pending.contactPaymentContext) { + activeContactPaymentContext = null + } + } + } + } + private fun onAddressContinue(data: String) { clearActiveContactPaymentContext() launchScan(source = ScanSource.ADDRESS_CONTINUE, data = data, routePubkyKeys = true) @@ -1936,9 +2017,6 @@ class AppViewModel @Inject constructor( privatePaymentContext = privatePaymentContext, incomingPaymentRequest = incomingPaymentRequest, ) - synchronized(contactPaymentContextLock) { - activeContactPaymentContext = context - } onScanResult(paymentRequest, contactPaymentContext = context) } @@ -2031,7 +2109,7 @@ class AppViewModel @Inject constructor( } val safeLogInput = SamRockSetupRequest.sanitizedDescription(input) ?: input - val scan = runCatching { coreService.decode(input) } + val scan = runSuspendCatching { coreService.decode(input) } .onFailure { Logger.error("Failed to decode scan data: '$safeLogInput'", it, context = TAG) } .onSuccess { Logger.info("Handling decoded scan data: $it", context = TAG) } .getOrNull() @@ -2111,6 +2189,15 @@ class AppViewModel @Inject constructor( } } + private fun setActiveContactPaymentContext(context: ContactPaymentContext?) { + val replacedRequestId = synchronized(contactPaymentContextLock) { + val currentRequestId = activeContactPaymentContext?.incomingPaymentRequest?.id + activeContactPaymentContext = context + currentRequestId?.takeIf { it != context?.incomingPaymentRequest?.id } + } + if (replacedRequestId != null) presentedPaymentRequestIds -= replacedRequestId + } + private fun clearPendingContactPaymentContext(paymentHash: String) { synchronized(contactPaymentContextLock) { pendingContactPaymentContexts.remove(paymentHash) @@ -3237,11 +3324,12 @@ class AppViewModel @Inject constructor( val handler = scanResultHandler val shouldHandleAsProtocol = SamRockSetupRequest.isProtocolUrl(data.removeLightningSchemes()) scanResultHandler = null - hideSheet() + hideSheet(flushPendingScan = false) if (handler != null && !shouldHandleAsProtocol) { viewModelScope.launch { delay(SCREEN_TRANSITION_DELAY) handler(data) + flushPendingLockedScan() } } else { launchScan( @@ -3323,9 +3411,12 @@ class AppViewModel @Inject constructor( } } - fun hideSheet() { + fun hideSheet() = hideSheet(flushPendingScan = true) + + private fun hideSheet(flushPendingScan: Boolean) { scanResultHandler = null receiveSheetContext = null + clearActiveContactPaymentContext() when { currentSheet.value is Sheet.TimedSheet -> { // Only dismiss if manager still has a sheet (user initiated) @@ -3339,9 +3430,8 @@ class AppViewModel @Inject constructor( else -> _currentSheet.update { null } } - clearActiveContactPaymentContext() showQueuedPairingCodeSheet() - flushPendingLockedScan() + if (flushPendingScan) flushPendingLockedScan() } // endregion diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt index 63c748410d..01b1bf6b41 100644 --- a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt @@ -8,12 +8,14 @@ import kotlin.test.assertTrue class ShopOriginTest { @Test - fun `bridge script checks message origin and does not override postMessage`() { + fun `bridge script accepts only Bitrefill https message origins`() { val script = shopMessageBridgeScript() assertTrue("addEventListener('message'" in script) assertFalse("window.postMessage =" in script) - assertTrue("bitrefill.com" in script) + assertTrue("originUrl.protocol !== 'https:'" in script) + assertTrue("host !== 'bitrefill.com' && !host.endsWith('.bitrefill.com')" in script) + assertTrue("catch (e)" in script) } @Test diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt index 5725582f7f..2b1edb01a8 100644 --- a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt @@ -1,10 +1,14 @@ package to.bitkit.ui.screens.shop.shopWebView import android.webkit.WebResourceRequest +import android.webkit.WebView import androidx.core.net.toUri import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -19,6 +23,7 @@ class ShopWebViewClientTest : BaseUnitTest() { private val sut = ShopWebViewClient( onLoadingStateChanged = {}, onError = {}, + isPaymentBridgeSupported = { true }, ) @Test @@ -42,6 +47,29 @@ class ShopWebViewClientTest : BaseUnitTest() { assertFalse(sut.shouldOverrideUrlLoading(null, request)) } + @Test + fun `bridge script is not injected when the payment bridge is unsupported`() { + val webView = mock() + val sut = ShopWebViewClient( + onLoadingStateChanged = {}, + onError = {}, + isPaymentBridgeSupported = { false }, + ) + + sut.onPageFinished(webView, "https://embed.bitrefill.com") + + verify(webView, never()).evaluateJavascript(any(), any()) + } + + @Test + fun `bridge script is injected when the payment bridge is supported`() { + val webView = mock() + + sut.onPageFinished(webView, "https://embed.bitrefill.com") + + verify(webView).evaluateJavascript(shopMessageBridgeScript(), null) + } + private fun request(url: String, isForMainFrame: Boolean): WebResourceRequest { val request = mock() whenever(request.isForMainFrame).thenReturn(isForMainFrame) diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt index 50f12d6131..e17f3585fb 100644 --- a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt @@ -1,12 +1,22 @@ package to.bitkit.ui.screens.shop.shopWebView +import android.webkit.WebView +import androidx.core.net.toUri +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertSame @Config(sdk = [34]) @RunWith(RobolectricTestRunner::class) @@ -16,11 +26,13 @@ class ShopWebViewInterfaceTest : BaseUnitTest() { fun `payment_intent from an allowed origin is forwarded`() { var received: String? = null val sut = interfaceOf( - pageUrl = "https://embed.bitrefill.com/gift-cards", onPaymentIntent = { received = it }, ) - sut.postMessage("""{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}""") + sut.onWebMessage( + WebMessageCompat("""{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}"""), + "https://embed.bitrefill.com/gift-cards", + ) assertEquals("lightning:lnbcrt1shop", received) } @@ -29,11 +41,13 @@ class ShopWebViewInterfaceTest : BaseUnitTest() { fun `payment_intent from a disallowed origin is ignored`() { var received: String? = null val sut = interfaceOf( - pageUrl = "https://evil.example", onPaymentIntent = { received = it }, ) - sut.postMessage("""{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}""") + sut.onWebMessage( + WebMessageCompat("""{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}"""), + "https://evil.example", + ) assertNull(received) } @@ -42,11 +56,13 @@ class ShopWebViewInterfaceTest : BaseUnitTest() { fun `payment_intent with a blank URI is ignored`() { var received: String? = null val sut = interfaceOf( - pageUrl = "https://embed.bitrefill.com", onPaymentIntent = { received = it }, ) - sut.postMessage("""{"event":"payment_intent","paymentUri":" "}""") + sut.onWebMessage( + WebMessageCompat("""{"event":"payment_intent","paymentUri":" "}"""), + "https://embed.bitrefill.com", + ) assertNull(received) } @@ -55,53 +71,104 @@ class ShopWebViewInterfaceTest : BaseUnitTest() { fun `unknown events are ignored`() { var received: String? = null val sut = interfaceOf( - pageUrl = "https://embed.bitrefill.com", onPaymentIntent = { received = it }, ) - sut.postMessage("""{"event":"invoice","paymentUri":"lightning:lnbcrt1shop"}""") + sut.onWebMessage( + WebMessageCompat("""{"event":"invoice","paymentUri":"lightning:lnbcrt1shop"}"""), + "https://embed.bitrefill.com", + ) assertNull(received) } @Test - fun `payment_intent from an iframe origin is rejected even if the top-level page is allowed`() { + fun `payment_intent from a bitrefill iframe origin is forwarded`() { var received: String? = null val sut = interfaceOf( - pageUrl = "https://embed.bitrefill.com/gift-cards", onPaymentIntent = { received = it }, ) - sut.onBridgeMessage( - """{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}""", - "https://evil.example", + sut.onWebMessage( + WebMessageCompat("""{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}"""), + "https://checkout.bitrefill.com", + ) + + assertEquals("lightning:lnbcrt1shop", received) + } + + @Test + fun `array buffer messages are ignored`() { + var received: String? = null + val sut = interfaceOf(onPaymentIntent = { received = it }) + + sut.onWebMessage( + WebMessageCompat(byteArrayOf(1, 2, 3)), + "https://embed.bitrefill.com", ) assertNull(received) } @Test - fun `payment_intent from a bitrefill iframe origin is forwarded`() { + fun `supported WebMessageListener is registered and forwards messages`() { + val webView = mock() var received: String? = null + var registeredObjectName: String? = null + var registeredOriginRules: Set? = null + var registeredListener: WebViewCompat.WebMessageListener? = null val sut = interfaceOf( - pageUrl = "https://embed.bitrefill.com/gift-cards", onPaymentIntent = { received = it }, + addWebMessageListener = { registeredWebView, jsObjectName, allowedOriginRules, listener -> + assertSame(webView, registeredWebView) + registeredObjectName = jsObjectName + registeredOriginRules = allowedOriginRules + registeredListener = listener + }, ) - sut.onBridgeMessage( - """{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}""", - "https://checkout.bitrefill.com", + sut.attachTo(webView) + requireNotNull(registeredListener).onPostMessage( + webView, + WebMessageCompat("""{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}"""), + "https://embed.bitrefill.com".toUri(), + true, + mock(), ) + assertEquals("Android", registeredObjectName) + assertEquals(shopAllowedOriginRules(), registeredOriginRules) assertEquals("lightning:lnbcrt1shop", received) } + @Test + fun `unsupported WebMessageListener does not register a JavaScript interface`() { + val webView = mock() + var webMessageListenerRegistered = false + val sut = interfaceOf( + onPaymentIntent = {}, + isWebMessageListenerSupported = { false }, + addWebMessageListener = { _, _, _, _ -> webMessageListenerRegistered = true }, + ) + + sut.attachTo(webView) + + verify(webView, never()).addJavascriptInterface(any(), any()) + assertFalse(webMessageListenerRegistered) + } + private fun interfaceOf( - pageUrl: String?, onPaymentIntent: (String) -> Unit, + isWebMessageListenerSupported: () -> Boolean = { true }, + addWebMessageListener: ( + WebView, + String, + Set, + WebViewCompat.WebMessageListener, + ) -> Unit = { _, _, _, _ -> }, ) = ShopWebViewInterface( onPaymentIntent = onPaymentIntent, - currentUrl = { pageUrl }, - runOnMain = { it() }, + isWebMessageListenerSupported = isWebMessageListenerSupported, + addWebMessageListener = addWebMessageListener, ) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ca0deaaaa9..b0cc1d5d85 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -16,14 +16,21 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.junit.After import org.junit.Before import org.junit.Test @@ -77,6 +84,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.NodeEventUpdate import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo @@ -113,6 +121,7 @@ import to.bitkit.utils.timedsheets.TimedSheetManager import java.net.URLEncoder import java.nio.charset.StandardCharsets import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -410,21 +419,20 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyPublicKey.value = testPublicKey runCurrent() - sut.startPaykitPaymentRequestPolling() - advanceTimeBy(30.seconds.inWholeMilliseconds) + sut.onHomeResumed() runCurrent() assertNull(sut.currentSheet.value) verify(privatePaykitRepo).beginPaymentRequest(request) + clearInvocations(privatePaykitRepo) advanceTimeBy(29.seconds.inWholeMilliseconds) runCurrent() - verify(privatePaykitRepo).beginPaymentRequest(request) + verify(privatePaykitRepo, never()).beginPaymentRequest(request) advanceTimeBy(1.seconds.inWholeMilliseconds) runCurrent() - sut.stopPaykitPaymentRequestPolling() - verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + verify(privatePaykitRepo).beginPaymentRequest(request) assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) } @@ -467,6 +475,59 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(activeRequest, activeContactPaymentContext()?.incomingPaymentRequest) } + @Test + fun `dismissing a payment request presents the next request with its context`() = test { + sut.setIsAuthenticated(true) + val firstRequest = paymentRequest() + val secondRequest = firstRequest.copy(paymentRequestId = "next-request") + val firstInvoice = "lnbcrt1firstpendingrequest" + val secondInvoice = "lnbcrt1secondpendingrequest" + whenever { privatePaykitRepo.beginPaymentRequest(firstRequest) }.thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = firstInvoice, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + ), + ), + ) + whenever { privatePaykitRepo.beginPaymentRequest(secondRequest) }.thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = secondInvoice, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + ), + ), + ) + stubLightningScan(bolt11 = firstInvoice, amountSats = 0u) + stubLightningScan(bolt11 = secondInvoice, amountSats = 0u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(firstRequest, secondRequest) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + + sut.onHomeResumed() + sut.currentSheet.first { + it is Sheet.Send && activeContactPaymentContext()?.incomingPaymentRequest == firstRequest + } + + sut.setIsAuthenticated(false) + sut.hideSheet() + runCurrent() + + assertNull(sut.currentSheet.value) + verify(privatePaykitRepo).beginPaymentRequest(secondRequest) + + sut.setIsAuthenticated(true) + sut.currentSheet.first { + it is Sheet.Send && activeContactPaymentContext()?.incomingPaymentRequest == secondRequest + } + + verify(privatePaykitRepo).beginPaymentRequest(firstRequest) + verify(privatePaykitRepo).beginPaymentRequest(secondRequest) + assertTrue(sut.sendUiState.value.isPaymentRequest) + } + @Test fun `request removed during endpoint resolution is not presented`() = test { val request = paymentRequest() @@ -485,10 +546,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyPublicKey.value = testPublicKey whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) - sut.startPaykitPaymentRequestPolling() - advanceTimeBy(30.seconds.inWholeMilliseconds) + sut.onHomeResumed() runCurrent() - sut.stopPaykitPaymentRequestPolling() verify(privatePaykitRepo).beginPaymentRequest(request) assertNull(sut.currentSheet.value) @@ -520,6 +579,389 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(sut.currentSheet.value) } + @Test + fun `payment request waits for scan started during endpoint resolution`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + val requestInvoice = "lnbcrt1resolvedrequest" + val scanInvoice = "lnbcrt1concurrentscan" + val resolutionStarted = CompletableDeferred() + val resumeResolution = CompletableDeferred() + val scanStarted = CompletableDeferred() + val resumeScan = CompletableDeferred() + whenever { privatePaykitRepo.beginPaymentRequest(request) }.doSuspendableAnswer { + resolutionStarted.complete(Unit) + resumeResolution.await() + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = requestInvoice, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + ), + ) + } + whenever { coreService.decode(scanInvoice) }.doSuspendableAnswer { + scanStarted.complete(Unit) + resumeScan.await() + Scanner.Lightning(lightningInvoice(scanInvoice, 500u)) + } + stubLightningScan(bolt11 = requestInvoice, amountSats = 0u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(request) + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + + sut.onHomeResumed() + resolutionStarted.await() + + sut.onScanResult(scanInvoice) + scanStarted.await() + resumeResolution.complete(Unit) + runCurrent() + + assertNull(sut.currentSheet.value) + assertNull(activeContactPaymentContext()) + + resumeScan.complete(Unit) + sut.currentSheet.first { it is Sheet.Send } + + assertEquals(500u, sut.sendUiState.value.amount) + assertFalse(sut.sendUiState.value.isPaymentRequest) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + + sut.hideSheet() + sut.currentSheet.first { it is Sheet.Send && sut.sendUiState.value.isPaymentRequest } + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertTrue(sut.sendUiState.value.isPaymentRequest) + verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + } + + @Test + fun `new scan does not inherit an active payment request context`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + val requestInvoice = "lnbcrt1interruptedrequest" + val replacementInvoice = "lnbcrt1replacementscan" + val requestScanStarted = CompletableDeferred() + val holdRequestScan = CompletableDeferred() + whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = requestInvoice, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + ), + ), + ) + whenever { coreService.decode(requestInvoice) }.doSuspendableAnswer { + requestScanStarted.complete(Unit) + holdRequestScan.await() + Scanner.Lightning(lightningInvoice(requestInvoice, 0u)) + } + stubLightningScan(bolt11 = replacementInvoice, amountSats = request.amountSats) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + + sut.onHomeResumed() + requestScanStarted.await() + sut.onScanResult(replacementInvoice) + sut.currentSheet.first { it is Sheet.Send } + sut.sendUiState.first { it.addressInput == replacementInvoice } + + assertFalse(sut.sendUiState.value.isPaymentRequest) + assertNull(activeContactPaymentContext()) + assertEquals(replacementInvoice, sut.sendUiState.value.addressInput) + + runCurrent() + holdRequestScan.complete(Unit) + sut.hideSheet() + runCurrent() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertTrue(sut.sendUiState.value.isPaymentRequest) + verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + assertEquals(request, activeContactPaymentContext()?.incomingPaymentRequest) + } + + @Test + fun `same active scan restarts when payment request context changes`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + val bolt11 = "lnbcrt1sameactivescan" + val firstScanStarted = CompletableDeferred() + val holdFirstScan = CompletableDeferred() + var decodeCount = 0 + whenever { coreService.decode(bolt11) }.doSuspendableAnswer { + decodeCount += 1 + if (decodeCount == 1) { + firstScanStarted.complete(Unit) + holdFirstScan.await() + } + Scanner.Lightning(lightningInvoice(bolt11, 0u)) + } + whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + + sut.onScanResult(bolt11) + firstScanStarted.await() + sut.openContactPayment( + paymentRequest = bolt11, + publicKey = testPublicKey, + incomingPaymentRequest = request, + ) + sut.currentSheet.first { it is Sheet.Send } + + assertEquals(2, decodeCount) + assertTrue(sut.sendUiState.value.isPaymentRequest) + assertEquals(request, activeContactPaymentContext()?.incomingPaymentRequest) + } + + @Test + fun `same context-free scan does not downgrade an active payment request`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + val bolt11 = "lnbcrt1paykitduplicateresult" + val scanStarted = CompletableDeferred() + val finishScan = CompletableDeferred() + var decodeCount = 0 + whenever { coreService.decode(bolt11) }.doSuspendableAnswer { + decodeCount += 1 + scanStarted.complete(Unit) + finishScan.await() + Scanner.Lightning(lightningInvoice(bolt11, 0u)) + } + whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + + sut.openContactPayment( + paymentRequest = bolt11, + publicKey = testPublicKey, + incomingPaymentRequest = request, + ) + scanStarted.await() + sut.onScanResult(bolt11) + finishScan.complete(Unit) + sut.currentSheet.first { it is Sheet.Send } + + assertEquals(1, decodeCount) + assertTrue(sut.sendUiState.value.isPaymentRequest) + assertEquals(request, activeContactPaymentContext()?.incomingPaymentRequest) + } + + @Test + fun `latest scan waits for prior cancellation and skips intermediate replacement`() = test { + sut.setIsAuthenticated(true) + val firstInvoice = "lnbcrt1firstcanceledscan" + val intermediateInvoice = "lnbcrt1intermediatescan" + val latestInvoice = "lnbcrt1latestscan" + val firstDecodeStarted = CompletableDeferred() + val cleanupStarted = CompletableDeferred() + var intermediateDecodeCount = 0 + var latestDecodeCount = 0 + whenever { coreService.decode(firstInvoice) }.doSuspendableAnswer { + firstDecodeStarted.complete(Unit) + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { + cleanupStarted.complete(Unit) + delay(1.seconds) + } + } + } + whenever { coreService.decode(intermediateInvoice) }.doSuspendableAnswer { + intermediateDecodeCount += 1 + Scanner.Lightning(lightningInvoice(intermediateInvoice, 500u)) + } + whenever { coreService.decode(latestInvoice) }.doSuspendableAnswer { + latestDecodeCount += 1 + Scanner.Lightning(lightningInvoice(latestInvoice, 600u)) + } + whenever(lightningRepo.canSend(any())).thenReturn(true) + balanceState.value = BalanceState( + maxSendLightningSats = 100_000u, + maxSendOnchainSats = 100_000u, + ) + + sut.onScanResult(firstInvoice) + withTimeout(5.seconds) { firstDecodeStarted.await() } + sut.onScanResult(intermediateInvoice) + withTimeout(5.seconds) { cleanupStarted.await() } + sut.onScanResult(latestInvoice) + + assertEquals(0, intermediateDecodeCount) + assertEquals(0, latestDecodeCount) + + advanceTimeBy(1.seconds.inWholeMilliseconds) + withTimeout(5.seconds) { sut.sendUiState.first { it.addressInput == latestInvoice } } + + assertEquals(0, intermediateDecodeCount) + assertEquals(1, latestDecodeCount) + assertEquals(latestInvoice, sut.sendUiState.value.addressInput) + assertEquals(600u, sut.sendUiState.value.amount) + assertNull(activeContactPaymentContext()) + verify(toastManager, never()).enqueue(any()) + } + + @Test + fun `embedded invoice decode preserves cancellation`() = test { + val embeddedInvoice = "lnbcrt1embeddedcancel" + val embeddedDecodeStarted = CompletableDeferred() + whenever { coreService.decode(embeddedInvoice) }.doSuspendableAnswer { + embeddedDecodeStarted.complete(Unit) + awaitCancellation() + } + + val extraction = async { + sut.extractViableLightningInvoice(mapOf("lightning" to embeddedInvoice)) + } + withTimeout(5.seconds) { embeddedDecodeStarted.await() } + extraction.cancel() + + assertFailsWith { extraction.await() } + } + + @Test + fun `payment request retries when a blocking scan finishes without a sheet`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + val requestInvoice = "lnbcrt1requestafterfailedscan" + val scanInput = "invalid-active-scan" + val resolutionStarted = CompletableDeferred() + val resumeResolution = CompletableDeferred() + val scanStarted = CompletableDeferred() + val resumeScan = CompletableDeferred() + whenever { privatePaykitRepo.beginPaymentRequest(request) }.doSuspendableAnswer { + resolutionStarted.complete(Unit) + resumeResolution.await() + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = requestInvoice, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + ), + ) + } + whenever { coreService.decode(scanInput) }.doSuspendableAnswer { + scanStarted.complete(Unit) + resumeScan.await() + throw AppError("Invalid scan") + } + stubLightningScan(bolt11 = requestInvoice, amountSats = 0u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + + sut.onHomeResumed() + resolutionStarted.await() + sut.onScanResult(scanInput) + scanStarted.await() + resumeResolution.complete(Unit) + runCurrent() + + verify(privatePaykitRepo).beginPaymentRequest(request) + assertNull(sut.currentSheet.value) + + resumeScan.complete(Unit) + sut.currentSheet.first { it is Sheet.Send && sut.sendUiState.value.isPaymentRequest } + + verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + assertTrue(sut.sendUiState.value.isPaymentRequest) + } + + @Test + fun `payment request waits for an active scan and its send sheet`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + val requestInvoice = "lnbcrt1requestafterscan" + val scanInvoice = "lnbcrt1activescan" + val scanStarted = CompletableDeferred() + val resumeScan = CompletableDeferred() + whenever { coreService.decode(scanInvoice) }.doSuspendableAnswer { + scanStarted.complete(Unit) + resumeScan.await() + Scanner.Lightning(lightningInvoice(scanInvoice, 500u)) + } + whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = requestInvoice, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + ), + ), + ) + stubLightningScan(bolt11 = requestInvoice, amountSats = 0u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + + sut.onScanResult(scanInvoice) + scanStarted.await() + pendingPaykitPaymentRequests.value = listOf(request) + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + sut.onHomeResumed() + runCurrent() + + verify(privatePaykitRepo, never()).beginPaymentRequest(request) + + resumeScan.complete(Unit) + sut.currentSheet.first { it is Sheet.Send } + + verify(privatePaykitRepo, never()).beginPaymentRequest(request) + + sut.hideSheet() + sut.currentSheet.first { it is Sheet.Send && sut.sendUiState.value.isPaymentRequest } + + verify(privatePaykitRepo).beginPaymentRequest(request) + assertTrue(sut.sendUiState.value.isPaymentRequest) + } + + @Test + fun `payment request waits for a queued locked scan and its send sheet`() = test { + val request = paymentRequest() + val requestInvoice = "lnbcrt1requestafterlockedscan" + val scanInvoice = "lnbcrt1queuedlockedscan" + settingsData.value = SettingsData(isPinEnabled = true) + whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = requestInvoice, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + ), + ), + ) + stubLightningScan(bolt11 = requestInvoice, amountSats = 0u) + stubLightningScan(bolt11 = scanInvoice, amountSats = 500u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + + sut.onScanResult(scanInvoice) + runCurrent() + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + sut.onHomeResumed() + runCurrent() + + verify(privatePaykitRepo, never()).beginPaymentRequest(request) + + sut.setIsAuthenticated(true) + sut.currentSheet.first { it is Sheet.Send } + + assertFalse(sut.sendUiState.value.isPaymentRequest) + verify(privatePaykitRepo, never()).beginPaymentRequest(request) + + sut.hideSheet() + sut.currentSheet.first { it is Sheet.Send && sut.sendUiState.value.isPaymentRequest } + + verify(privatePaykitRepo).beginPaymentRequest(request) + assertTrue(sut.sendUiState.value.isPaymentRequest) + } + @Test fun `unavailable request does not starve a later payable request`() = test { val unavailableRequest = paymentRequest() @@ -1692,6 +2134,51 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) } + @Test + fun `scanner result does not discard a queued locked scan`() = test { + val queuedInvoice = "lnbcrt1queuedbeforescanner" + val scannerInvoice = "lnbcrt1scannerauthresult" + settingsData.value = SettingsData(isPinEnabled = true) + stubLightningScan(bolt11 = queuedInvoice, amountSats = 500u) + stubLightningScan(bolt11 = scannerInvoice, amountSats = 600u) + + sut.onScanResult(queuedInvoice) + sut.showScannerSheet() + advanceUntilIdle() + sut.setIsAuthenticated(true) + + sut.onScannerSheetResult(scannerInvoice) + sut.currentSheet.first { it is Sheet.Send } + + assertEquals(600u, sut.sendUiState.value.amount) + + sut.hideSheet() + sut.currentSheet.first { it is Sheet.Send && sut.sendUiState.value.amount == 500uL } + + assertEquals(500u, sut.sendUiState.value.amount) + } + + @Test + fun `custom scanner result flushes a queued locked scan`() = test { + val queuedInvoice = "lnbcrt1queuedbeforecustomscanner" + val customResult = "pubky-custom-result" + var receivedResult: String? = null + settingsData.value = SettingsData(isPinEnabled = true) + stubLightningScan(bolt11 = queuedInvoice, amountSats = 500u) + + sut.onScanResult(queuedInvoice) + sut.showScannerSheet(onResult = { receivedResult = it }) + advanceUntilIdle() + sut.setIsAuthenticated(true) + + sut.onScannerSheetResult(customResult) + advanceUntilIdle() + sut.currentSheet.first { it is Sheet.Send } + + assertEquals(customResult, receivedResult) + assertEquals(500u, sut.sendUiState.value.amount) + } + @Test fun `main scanner zero amount lightning scan opens amount sheet`() = test { val bolt11 = "lnbcrt1zeroamount" @@ -1873,6 +2360,209 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(ContactPaymentContext("pubkycontact"), activeContactPaymentContext()) } + @Test + fun `duplicate locked scan keeps incoming payment request context`() = test { + val request = paymentRequest() + val bolt11 = "lnbcrt1lockedrequest" + settingsData.value = SettingsData(isPinEnabled = true) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + + sut.onScanResult(bolt11) + sut.openContactPayment( + paymentRequest = bolt11, + publicKey = testPublicKey, + incomingPaymentRequest = request, + ) + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertTrue(sut.sendUiState.value.isPaymentRequest) + assertEquals(request, activeContactPaymentContext()?.incomingPaymentRequest) + } + + @Test + fun `context-free locked duplicate does not replace incoming payment request`() = test { + val request = paymentRequest() + val bolt11 = "lnbcrt1lockedrequestduplicate" + settingsData.value = SettingsData(isPinEnabled = true) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + + sut.openContactPayment( + paymentRequest = bolt11, + publicKey = testPublicKey, + incomingPaymentRequest = request, + ) + sut.onScanResult(bolt11) + sut.setIsAuthenticated(true) + sut.currentSheet.first { it is Sheet.Send } + + assertTrue(sut.sendUiState.value.isPaymentRequest) + assertEquals(request, activeContactPaymentContext()?.incomingPaymentRequest) + } + + @Test + fun `new scan waits for an active locked replay`() = test { + val lockedInvoice = "lnbcrt1activelockedreplay" + val newInvoice = "lnbcrt1afteractivelockedreplay" + val lockedScanStarted = CompletableDeferred() + val finishLockedScan = CompletableDeferred() + var newScanDecodeCount = 0 + settingsData.value = SettingsData(isPinEnabled = true) + whenever { coreService.decode(lockedInvoice) }.doSuspendableAnswer { + lockedScanStarted.complete(Unit) + finishLockedScan.await() + Scanner.Lightning(lightningInvoice(lockedInvoice, 500u)) + } + whenever { coreService.decode(newInvoice) }.doSuspendableAnswer { + newScanDecodeCount += 1 + Scanner.Lightning(lightningInvoice(newInvoice, 600u)) + } + whenever(lightningRepo.canSend(any())).thenReturn(true) + + sut.onScanResult(lockedInvoice) + sut.setIsAuthenticated(true) + lockedScanStarted.await() + sut.onScanResult(newInvoice) + runCurrent() + + assertEquals(0, newScanDecodeCount) + + finishLockedScan.complete(Unit) + sut.currentSheet.first { it is Sheet.Send } + + assertEquals(500u, sut.sendUiState.value.amount) + + sut.hideSheet() + sut.currentSheet.first { it is Sheet.Send && sut.sendUiState.value.amount == 600uL } + + assertEquals(1, newScanDecodeCount) + } + + @Test + fun `active locked replay ignores the same scan`() = test { + val bolt11 = "lnbcrt1duplicatelockedreplay" + val scanStarted = CompletableDeferred() + val finishScan = CompletableDeferred() + var decodeCount = 0 + settingsData.value = SettingsData(isPinEnabled = true) + whenever { coreService.decode(bolt11) }.doSuspendableAnswer { + decodeCount += 1 + scanStarted.complete(Unit) + finishScan.await() + Scanner.Lightning(lightningInvoice(bolt11, 500u)) + } + whenever(lightningRepo.canSend(any())).thenReturn(true) + + sut.onScanResult(bolt11) + sut.setIsAuthenticated(true) + scanStarted.await() + sut.onScanResult(bolt11) + finishScan.complete(Unit) + sut.currentSheet.first { it is Sheet.Send } + + assertEquals(1, decodeCount) + + sut.hideSheet() + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + assertEquals(1, decodeCount) + } + + @Test + fun `distinct locked payment requests each present once`() = test { + val firstRequest = paymentRequest() + val secondRequest = firstRequest.copy(paymentRequestId = "second-locked-request") + val firstInvoice = "lnbcrt1firstlockedrequest" + val secondInvoice = "lnbcrt1secondlockedrequest" + settingsData.value = SettingsData(isPinEnabled = true) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + stubLightningScan(bolt11 = firstInvoice, amountSats = 0u) + stubLightningScan(bolt11 = secondInvoice, amountSats = 0u) + pendingPaykitPaymentRequests.value = listOf(firstRequest, secondRequest) + setPresentedPaymentRequests(firstRequest, secondRequest) + + sut.openContactPayment( + paymentRequest = firstInvoice, + publicKey = testPublicKey, + incomingPaymentRequest = firstRequest, + ) + sut.openContactPayment( + paymentRequest = secondInvoice, + publicKey = testPublicKey, + incomingPaymentRequest = secondRequest, + ) + + assertNull(activeContactPaymentContext()) + + sut.setIsAuthenticated(true) + sut.currentSheet.first { + it is Sheet.Send && activeContactPaymentContext()?.incomingPaymentRequest == firstRequest + } + + sut.hideSheet() + sut.currentSheet.first { + it is Sheet.Send && activeContactPaymentContext()?.incomingPaymentRequest == secondRequest + } + + sut.hideSheet() + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(privatePaykitRepo, never()).beginPaymentRequest(any()) + } + + @Test + fun `replacing a queued payment request keeps the prior request available`() = test { + val firstRequest = paymentRequest() + val replacementRequest = firstRequest.copy(paymentRequestId = "replacement-request") + val bolt11 = "lnbcrt1replacedrequest" + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + settingsData.value = SettingsData(isPinEnabled = true) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + whenever { privatePaykitRepo.beginPaymentRequest(firstRequest) }.thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = bolt11, + privatePaymentContext = privateContext, + ), + ), + ) + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + + pendingPaykitPaymentRequests.value = listOf(firstRequest) + sut.startPaykitPaymentRequestPolling() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + sut.stopPaykitPaymentRequestPolling() + + sut.openContactPayment( + paymentRequest = bolt11, + publicKey = testPublicKey, + privatePaymentContext = privateContext, + incomingPaymentRequest = replacementRequest, + ) + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(replacementRequest, activeContactPaymentContext()?.incomingPaymentRequest) + + sut.hideSheet() + sut.onHomeResumed() + sut.currentSheet.first { + it is Sheet.Send && activeContactPaymentContext()?.incomingPaymentRequest == firstRequest + } + + assertEquals(firstRequest, activeContactPaymentContext()?.incomingPaymentRequest) + verify(privatePaykitRepo, times(2)).beginPaymentRequest(firstRequest) + } + @Test fun `locked scan queue drops the oldest when full`() = test { settingsData.value = SettingsData(isPinEnabled = true) @@ -1902,6 +2592,140 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(sut.currentSheet.value) } + @Test + fun `incoming payment request remains available when locked scan queue reaches capacity`() = test { + val request = paymentRequest() + val paymentRequestInvoice = "lnbcrt1queuedrequest" + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val queuedInvoices = (1..5).map { index -> + "lnbcrt1afterrequest$index" to (100uL * index.toULong()) + } + settingsData.value = SettingsData(isPinEnabled = true) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + stubLightningScan(bolt11 = paymentRequestInvoice, amountSats = 0u) + queuedInvoices.forEach { (bolt11, amount) -> stubLightningScan(bolt11, amount) } + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = paymentRequestInvoice, + privatePaymentContext = privateContext, + ), + ), + ) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + + pendingPaykitPaymentRequests.value = listOf(request) + sut.startPaykitPaymentRequestPolling() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + sut.stopPaykitPaymentRequestPolling() + + queuedInvoices.forEach { (bolt11, _) -> sut.onScanResult(bolt11) } + sut.setIsAuthenticated(true) + advanceUntilIdle() + + repeat(5) { + sut.hideSheet() + advanceUntilIdle() + } + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertTrue(sut.sendUiState.value.isPaymentRequest) + verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + } + + @Test + fun `stale locked payment request is skipped after authenticate`() = test { + val request = paymentRequest() + val requestInvoice = "lnbcrt1stalelockedrequest" + val nextInvoice = "lnbcrt1afterstalerequest" + settingsData.value = SettingsData(isPinEnabled = true) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + stubLightningScan(bolt11 = requestInvoice, amountSats = 0u) + stubLightningScan(bolt11 = nextInvoice, amountSats = 500u) + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = requestInvoice, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + ), + ), + ) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + + pendingPaykitPaymentRequests.value = listOf(request) + sut.onHomeResumed() + runCurrent() + sut.onScanResult(nextInvoice) + whenever(paykitPaymentRequestRepo.isPending(request)).thenReturn(false) + + sut.setIsAuthenticated(true) + sut.currentSheet.first { it is Sheet.Send } + + assertFalse(sut.sendUiState.value.isPaymentRequest) + assertEquals(500u, sut.sendUiState.value.amount) + assertNull(activeContactPaymentContext()) + verify(privatePaykitRepo).beginPaymentRequest(request) + + whenever(paykitPaymentRequestRepo.isPending(request)).thenReturn(true) + pendingPaykitPaymentRequests.value = listOf(request) + sut.hideSheet() + sut.onHomeResumed() + sut.currentSheet.first { it is Sheet.Send && sut.sendUiState.value.isPaymentRequest } + + verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + } + + @Test + fun `pending payment request presents when stale locked request is discarded`() = test { + val staleRequest = paymentRequest() + val pendingRequest = staleRequest.copy(paymentRequestId = "pending-after-stale") + val staleInvoice = "lnbcrt1stalelockedonly" + val pendingInvoice = "lnbcrt1pendingafterstale" + settingsData.value = SettingsData(isPinEnabled = true) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + stubLightningScan(bolt11 = staleInvoice, amountSats = 0u) + stubLightningScan(bolt11 = pendingInvoice, amountSats = 0u) + whenever(privatePaykitRepo.beginPaymentRequest(staleRequest)).thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = staleInvoice, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + ), + ), + ) + whenever(privatePaykitRepo.beginPaymentRequest(pendingRequest)).thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = pendingInvoice, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + ), + ), + ) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + + pendingPaykitPaymentRequests.value = listOf(staleRequest) + sut.onHomeResumed() + runCurrent() + whenever(paykitPaymentRequestRepo.isPending(staleRequest)).thenReturn(false) + pendingPaykitPaymentRequests.value = listOf(pendingRequest) + + sut.setIsAuthenticated(true) + sut.currentSheet.first { + it is Sheet.Send && activeContactPaymentContext()?.incomingPaymentRequest == pendingRequest + } + + assertTrue(sut.sendUiState.value.isPaymentRequest) + verify(privatePaykitRepo).beginPaymentRequest(staleRequest) + verify(privatePaykitRepo).beginPaymentRequest(pendingRequest) + } + @Test fun `queued scan flushes after timed sheet dismisses`() = test { val bolt11 = "lnbcrt1lockedtimed" @@ -2781,6 +3605,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { return field.get(sut) as ContactPaymentContext? } + @Suppress("UNCHECKED_CAST") + private fun setPresentedPaymentRequests(vararg requests: PaykitPaymentRequest) { + val field = AppViewModel::class.java.getDeclaredField("presentedPaymentRequestIds") + field.isAccessible = true + val requestIds = field.get(sut) as MutableSet + requestIds += requests.map { it.id } + } + private fun isPresentingPaymentRequest(): Boolean { val field = AppViewModel::class.java.getDeclaredField("isPresentingPaymentRequest") field.isAccessible = true From 6f5d3462fd9860c7059f5278296301a5b3368dbd Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 13 Aug 2026 17:45:34 -0500 Subject: [PATCH 09/12] fix: scope shop payment origin --- .../ui/screens/shop/shopWebView/ShopOrigin.kt | 33 ++++++++++++------- .../shop/shopWebView/ShopWebViewInterface.kt | 4 +-- .../shop/shopWebView/ShopOriginTest.kt | 24 +++++++++----- .../shop/shopWebView/ShopWebViewClientTest.kt | 7 ++++ .../shopWebView/ShopWebViewInterfaceTest.kt | 8 ++--- 5 files changed, 50 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt index 955b79d987..9c38bb5dbc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt @@ -1,10 +1,14 @@ package to.bitkit.ui.screens.shop.shopWebView +import to.bitkit.env.Env import java.net.URI /** Root host for Bitrefill shop pages and payment_intent messages. */ const val BITREFILL_ROOT_HOST = "bitrefill.com" +/** Default HTTPS port accepted for the trusted shop payment origin. */ +private const val HTTPS_DEFAULT_PORT = 443 + fun isAllowedShopHost(host: String?): Boolean { val normalized = host?.lowercase()?.trim('.') ?: return false return normalized == BITREFILL_ROOT_HOST || normalized.endsWith(".$BITREFILL_ROOT_HOST") @@ -17,10 +21,22 @@ fun isAllowedShopOrigin(url: String?): Boolean { return isAllowedShopHost(parsed.host) } -fun shopAllowedOriginRules(): Set = setOf( - "https://$BITREFILL_ROOT_HOST", - "https://*.$BITREFILL_ROOT_HOST", -) +private val bitrefillEmbedOrigin = URI(Env.BITREFILL_URL) + +fun isAllowedShopPaymentOrigin(origin: String?): Boolean { + if (origin.isNullOrBlank()) return false + val parsed = runCatching { URI(origin.trim()) }.getOrNull() ?: return false + val hasTrustedScheme = parsed.scheme.equals(bitrefillEmbedOrigin.scheme, ignoreCase = true) + val hasTrustedHost = parsed.host.equals(bitrefillEmbedOrigin.host, ignoreCase = true) + val hasTrustedPort = parsed.port == -1 || parsed.port == HTTPS_DEFAULT_PORT + if (!hasTrustedScheme || !hasTrustedHost || !hasTrustedPort) return false + return parsed.rawUserInfo == null && + parsed.rawPath.isNullOrEmpty() && + parsed.rawQuery == null && + parsed.rawFragment == null +} + +fun shopPaymentOriginRules(): Set = setOf(Env.BITREFILL_URL) internal fun shopMessageBridgeScript(): String = """ window.ReactNativeWebView = { @@ -29,14 +45,7 @@ internal fun shopMessageBridgeScript(): String = """ } }; window.addEventListener('message', function(event) { - try { - var originUrl = new URL(event.origin); - if (originUrl.protocol !== 'https:') return; - var host = originUrl.hostname.toLowerCase(); - if (host !== '$BITREFILL_ROOT_HOST' && !host.endsWith('.$BITREFILL_ROOT_HOST')) return; - } catch (e) { - return; - } + if (event.origin !== '${Env.BITREFILL_URL}') return; var data = event.data; if (data == null) return; Android.postMessage(typeof data === 'string' ? data : JSON.stringify(data)); diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt index d2074c4856..7c944007eb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt @@ -48,7 +48,7 @@ class ShopWebViewInterface( addWebMessageListener( webView, JS_OBJECT_NAME, - shopAllowedOriginRules(), + shopPaymentOriginRules(), ) { _, message, sourceOrigin, _, _ -> onWebMessage(message, sourceOrigin.toString()) } @@ -68,7 +68,7 @@ class ShopWebViewInterface( } internal fun handlePaymentMessage(message: String, sourceOrigin: String?) { - if (!isAllowedShopOrigin(sourceOrigin)) { + if (!isAllowedShopPaymentOrigin(sourceOrigin)) { Logger.warn("Rejected shop payment_intent from untrusted origin '$sourceOrigin'", context = TAG) return } diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt index 01b1bf6b41..4da4ec3d92 100644 --- a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt @@ -8,14 +8,13 @@ import kotlin.test.assertTrue class ShopOriginTest { @Test - fun `bridge script accepts only Bitrefill https message origins`() { + fun `bridge script accepts only the Bitrefill embed origin`() { val script = shopMessageBridgeScript() assertTrue("addEventListener('message'" in script) assertFalse("window.postMessage =" in script) - assertTrue("originUrl.protocol !== 'https:'" in script) - assertTrue("host !== 'bitrefill.com' && !host.endsWith('.bitrefill.com')" in script) - assertTrue("catch (e)" in script) + assertTrue("event.origin !== 'https://embed.bitrefill.com'" in script) + assertFalse("endsWith('.bitrefill.com')" in script) } @Test @@ -26,10 +25,19 @@ class ShopOriginTest { assertTrue(isAllowedShopOrigin("https://www.bitrefill.com/esims")) assertTrue(isAllowedShopHost("embed.bitrefill.com")) assertTrue(isAllowedShopHost("BITREFILL.COM")) - assertEquals( - setOf("https://bitrefill.com", "https://*.bitrefill.com"), - shopAllowedOriginRules(), - ) + } + + @Test + fun `payment messages accept only the Bitrefill embed origin`() { + assertTrue(isAllowedShopPaymentOrigin("https://embed.bitrefill.com")) + assertTrue(isAllowedShopPaymentOrigin("HTTPS://EMBED.BITREFILL.COM")) + assertFalse(isAllowedShopPaymentOrigin("https://bitrefill.com")) + assertFalse(isAllowedShopPaymentOrigin("https://checkout.bitrefill.com")) + assertFalse(isAllowedShopPaymentOrigin("https://embed.bitrefill.com/gift-cards")) + assertFalse(isAllowedShopPaymentOrigin("https://embed.bitrefill.com.evil.example")) + assertFalse(isAllowedShopPaymentOrigin("http://embed.bitrefill.com")) + assertFalse(isAllowedShopPaymentOrigin("https://embed.bitrefill.com:444")) + assertEquals(setOf("https://embed.bitrefill.com"), shopPaymentOriginRules()) } @Test diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt index 2b1edb01a8..9249da067a 100644 --- a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt @@ -33,6 +33,13 @@ class ShopWebViewClientTest : BaseUnitTest() { assertFalse(sut.shouldOverrideUrlLoading(null, request)) } + @Test + fun `main-frame Bitrefill sibling navigation remains allowed`() { + val request = request(url = "https://www.bitrefill.com/esims", isForMainFrame = true) + + assertFalse(sut.shouldOverrideUrlLoading(null, request)) + } + @Test fun `main-frame navigation off Bitrefill is blocked`() { val request = request(url = "https://evil.example/pay", isForMainFrame = true) diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt index e17f3585fb..414a410b11 100644 --- a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt @@ -31,7 +31,7 @@ class ShopWebViewInterfaceTest : BaseUnitTest() { sut.onWebMessage( WebMessageCompat("""{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}"""), - "https://embed.bitrefill.com/gift-cards", + "https://embed.bitrefill.com", ) assertEquals("lightning:lnbcrt1shop", received) @@ -83,7 +83,7 @@ class ShopWebViewInterfaceTest : BaseUnitTest() { } @Test - fun `payment_intent from a bitrefill iframe origin is forwarded`() { + fun `payment_intent from a Bitrefill sibling origin is ignored`() { var received: String? = null val sut = interfaceOf( onPaymentIntent = { received = it }, @@ -94,7 +94,7 @@ class ShopWebViewInterfaceTest : BaseUnitTest() { "https://checkout.bitrefill.com", ) - assertEquals("lightning:lnbcrt1shop", received) + assertNull(received) } @Test @@ -137,7 +137,7 @@ class ShopWebViewInterfaceTest : BaseUnitTest() { ) assertEquals("Android", registeredObjectName) - assertEquals(shopAllowedOriginRules(), registeredOriginRules) + assertEquals(shopPaymentOriginRules(), registeredOriginRules) assertEquals("lightning:lnbcrt1shop", received) } From ad154ff07391aa313b564d547ea327ea90e053a1 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 13 Aug 2026 19:13:50 -0500 Subject: [PATCH 10/12] refactor: simplify deferred payment flow --- .../ui/screens/shop/shopWebView/ShopOrigin.kt | 27 +- .../shop/shopWebView/ShopWebViewClient.kt | 2 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 172 +++--- .../shop/shopWebView/ShopOriginTest.kt | 11 + .../shop/shopWebView/ShopWebViewClientTest.kt | 9 + .../viewmodels/AppViewModelSendFlowTest.kt | 538 ++++-------------- 6 files changed, 212 insertions(+), 547 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt index 9c38bb5dbc..322f37ec29 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt @@ -4,17 +4,17 @@ import to.bitkit.env.Env import java.net.URI /** Root host for Bitrefill shop pages and payment_intent messages. */ -const val BITREFILL_ROOT_HOST = "bitrefill.com" +internal const val BITREFILL_ROOT_HOST = "bitrefill.com" /** Default HTTPS port accepted for the trusted shop payment origin. */ private const val HTTPS_DEFAULT_PORT = 443 -fun isAllowedShopHost(host: String?): Boolean { +internal fun isAllowedShopHost(host: String?): Boolean { val normalized = host?.lowercase()?.trim('.') ?: return false return normalized == BITREFILL_ROOT_HOST || normalized.endsWith(".$BITREFILL_ROOT_HOST") } -fun isAllowedShopOrigin(url: String?): Boolean { +internal fun isAllowedShopOrigin(url: String?): Boolean { if (url.isNullOrBlank()) return false val parsed = runCatching { URI(url.trim()) }.getOrNull() ?: return false if (parsed.scheme?.equals("https", ignoreCase = true) != true) return false @@ -23,20 +23,29 @@ fun isAllowedShopOrigin(url: String?): Boolean { private val bitrefillEmbedOrigin = URI(Env.BITREFILL_URL) -fun isAllowedShopPaymentOrigin(origin: String?): Boolean { - if (origin.isNullOrBlank()) return false - val parsed = runCatching { URI(origin.trim()) }.getOrNull() ?: return false +private fun hasTrustedPaymentOrigin(parsed: URI): Boolean { val hasTrustedScheme = parsed.scheme.equals(bitrefillEmbedOrigin.scheme, ignoreCase = true) val hasTrustedHost = parsed.host.equals(bitrefillEmbedOrigin.host, ignoreCase = true) val hasTrustedPort = parsed.port == -1 || parsed.port == HTTPS_DEFAULT_PORT - if (!hasTrustedScheme || !hasTrustedHost || !hasTrustedPort) return false - return parsed.rawUserInfo == null && + return hasTrustedScheme && hasTrustedHost && hasTrustedPort && parsed.rawUserInfo == null +} + +internal fun isAllowedShopPaymentPage(url: String?): Boolean { + if (url.isNullOrBlank()) return false + val parsed = runCatching { URI(url.trim()) }.getOrNull() ?: return false + return hasTrustedPaymentOrigin(parsed) +} + +internal fun isAllowedShopPaymentOrigin(origin: String?): Boolean { + if (origin.isNullOrBlank()) return false + val parsed = runCatching { URI(origin.trim()) }.getOrNull() ?: return false + return hasTrustedPaymentOrigin(parsed) && parsed.rawPath.isNullOrEmpty() && parsed.rawQuery == null && parsed.rawFragment == null } -fun shopPaymentOriginRules(): Set = setOf(Env.BITREFILL_URL) +internal fun shopPaymentOriginRules(): Set = setOf(Env.BITREFILL_URL) internal fun shopMessageBridgeScript(): String = """ window.ReactNativeWebView = { diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt index e92d4effd1..04c66c4d9c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt @@ -28,7 +28,7 @@ class ShopWebViewClient( super.onPageFinished(view, url) onLoadingStateChanged(false) - if (isPaymentBridgeSupported()) { + if (isPaymentBridgeSupported() && isAllowedShopPaymentPage(url)) { view?.evaluateJavascript(shopMessageBridgeScript(), null) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6ca6629436..666a2a1553 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5,7 +5,6 @@ import android.content.Intent import android.net.Uri import android.nfc.NfcAdapter import androidx.annotation.StringRes -import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -260,16 +259,10 @@ class AppViewModel @Inject constructor( private val scanMutex = Mutex() @Volatile - private var scheduledScanInput: String? = null + private var scheduledScan: ScheduledScan? = null - @Volatile - private var scheduledScanContext: ContactPaymentContext? = null - - @Volatile - private var scheduledScanMustComplete = false - - private val pendingLockedScansLock = Any() - private val pendingLockedScans = ArrayDeque() + private val deferredScanLock = Any() + private var deferredScan: DeferredScan? = null private val _sendEffect = MutableSharedFlow(extraBufferCapacity = 1) val sendEffect = _sendEffect.asSharedFlow() @@ -333,7 +326,7 @@ class AppViewModel @Inject constructor( fun setIsAuthenticated(value: Boolean) { _isAuthenticated.value = value - if (value) flushPendingLockedScan() + if (value) flushDeferredScan() } val pinAttemptsRemaining = keychain.pinAttemptsRemaining() @@ -400,7 +393,7 @@ class AppViewModel @Inject constructor( _currentSheet.update { sheet -> if (sheet is Sheet.Hardware && sheet.route is HardwareRoute.PairCode) null else sheet } - if (shouldFlush) flushPendingLockedScan() + if (shouldFlush) flushDeferredScan() } } } @@ -418,7 +411,7 @@ class AppViewModel @Inject constructor( _currentSheet.update { current -> if (current is Sheet.TimedSheet) null else current } - if (shouldFlush) flushPendingLockedScan() + if (shouldFlush) flushDeferredScan() } } } @@ -1541,8 +1534,7 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy(isAddressInputValid = true) } } - @VisibleForTesting - internal suspend fun extractViableLightningInvoice(params: Map?): LightningInvoice? = + private suspend fun extractViableLightningInvoice(params: Map?): LightningInvoice? = params?.get("lightning")?.let { bolt11 -> runSuspendCatching { coreService.decode(bolt11) }.getOrNull() ?.let { it as? Scanner.Lightning } @@ -1603,7 +1595,7 @@ class AppViewModel @Inject constructor( preserveUntilComplete: Boolean = false, ) { if (!_isAuthenticated.value) { - enqueueLockedScan( + enqueueDeferredScan( source = source, data = data, startDelay = startDelay, @@ -1616,16 +1608,17 @@ class AppViewModel @Inject constructor( val normalized = data.removeLightningSchemes() val scanId = scanLogId(data) - val isSameActiveScan = normalized == scheduledScanInput && + val scheduled = scheduledScan + val isSameActiveScan = normalized == scheduled?.normalizedInput && activeScanJob?.isActive == true && - (scheduledScanContext == contactPaymentContext || contactPaymentContext == null) + (scheduled.contactPaymentContext == contactPaymentContext || contactPaymentContext == null) if (isSameActiveScan) { Logger.info("Skipping duplicate scan from '${source.label}': '$scanId'", context = TAG) return } - if (activeScanJob?.isActive == true && scheduledScanMustComplete) { - enqueueLockedScan(source, data, startDelay, routePubkyKeys, contactPaymentContext) + if (activeScanJob?.isActive == true && scheduled?.mustComplete == true) { + enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext) return } @@ -1634,9 +1627,11 @@ class AppViewModel @Inject constructor( it.cancel() } - scheduledScanInput = normalized - scheduledScanContext = contactPaymentContext - scheduledScanMustComplete = preserveUntilComplete + scheduledScan = ScheduledScan( + normalizedInput = normalized, + contactPaymentContext = contactPaymentContext, + mustComplete = preserveUntilComplete, + ) Logger.debug("Starting scan from '${source.label}': '$scanId'", context = TAG) activeScanJob = viewModelScope.launch { scanMutex.withLock { @@ -1647,15 +1642,10 @@ class AppViewModel @Inject constructor( }.also { job -> job.invokeOnCompletion { if (activeScanJob === job) { - scheduledScanInput = null - scheduledScanContext = null - scheduledScanMustComplete = false + scheduledScan = null } if (job.isCancelled) return@invokeOnCompletion - viewModelScope.launch { - flushPendingLockedScan() - presentNextIncomingPaykitPaymentRequest() - } + viewModelScope.launch { flushDeferredScan() } } } } @@ -1669,7 +1659,7 @@ class AppViewModel @Inject constructor( } } - private fun enqueueLockedScan( + private fun enqueueDeferredScan( source: ScanSource, data: String, startDelay: Duration, @@ -1678,17 +1668,11 @@ class AppViewModel @Inject constructor( ) { val scanId = scanLogId(data) val normalized = data.removeLightningSchemes() - synchronized(pendingLockedScansLock) { - val queuedIndex = pendingLockedScans.indexOfFirst { it.data.removeLightningSchemes() == normalized } - if (queuedIndex >= 0) { - val queued = pendingLockedScans[queuedIndex] + synchronized(deferredScanLock) { + val queued = deferredScan + if (queued?.data?.removeLightningSchemes() == normalized) { if (contactPaymentContext != null) { - val queuedRequestId = queued.contactPaymentContext?.incomingPaymentRequest?.id - val incomingRequestId = contactPaymentContext.incomingPaymentRequest?.id - if (queuedRequestId != null && queuedRequestId != incomingRequestId) { - releasePaymentRequestPresentation(queued) - } - pendingLockedScans[queuedIndex] = PendingLockedScan( + deferredScan = DeferredScan( source = source, data = data, startDelay = startDelay, @@ -1700,52 +1684,45 @@ class AppViewModel @Inject constructor( Logger.info("Skipping duplicate queued scan from '${source.label}': '$scanId'", context = TAG) return } - while (pendingLockedScans.size >= MAX_PENDING_LOCKED_SCANS) { - val dropped = pendingLockedScans.removeFirst() - releasePaymentRequestPresentation(dropped) + if (queued != null) { Logger.warn( - "Dropping oldest queued scan from '${dropped.source.label}': '${scanLogId(dropped.data)}'", + "Replacing deferred scan from '${queued.source.label}': '${scanLogId(queued.data)}'", context = TAG, ) } - pendingLockedScans.addLast( - PendingLockedScan( - source = source, - data = data, - startDelay = startDelay, - routePubkyKeys = routePubkyKeys, - contactPaymentContext = contactPaymentContext, - ), + deferredScan = DeferredScan( + source = source, + data = data, + startDelay = startDelay, + routePubkyKeys = routePubkyKeys, + contactPaymentContext = contactPaymentContext, ) } Logger.info("Queuing '${source.label}' scan for deferred handling: '$scanId'", context = TAG) } - private fun releasePaymentRequestPresentation(scan: PendingLockedScan) { - val requestId = scan.contactPaymentContext?.incomingPaymentRequest?.id ?: return - presentedPaymentRequestIds -= requestId - } - private fun isScanPendingOrActive(): Boolean { if (activeScanJob?.isActive == true) return true - return synchronized(pendingLockedScansLock) { pendingLockedScans.isNotEmpty() } + return synchronized(deferredScanLock) { deferredScan != null } } - private fun isPaymentRequestPresentationBlocked() = currentSheet.value != null || + private fun isPaymentRequestPresentationBlocked() = !_isAuthenticated.value || + currentSheet.value != null || hasActiveContactPaymentContext() || isScanPendingOrActive() - private fun flushPendingLockedScan() { + private fun flushDeferredScan() { if (!_isAuthenticated.value) return if (activeScanJob?.isActive == true) return if (_currentSheet.value != null) return - val pending = takeNextPendingLockedScan() ?: run { + val pending = synchronized(deferredScanLock) { + deferredScan.also { deferredScan = null } + } ?: run { viewModelScope.launch { presentNextIncomingPaykitPaymentRequest() } return } - setActiveContactPaymentContext(pending.contactPaymentContext) launchScan( source = pending.source, data = pending.data, @@ -1756,23 +1733,6 @@ class AppViewModel @Inject constructor( ) } - private fun takeNextPendingLockedScan(): PendingLockedScan? { - while (true) { - val pending = synchronized(pendingLockedScansLock) { - pendingLockedScans.removeFirstOrNull() - } ?: return null - val request = pending.contactPaymentContext?.incomingPaymentRequest - if (request == null || paykitPaymentRequestRepo.isPending(request)) return pending - - releasePaymentRequestPresentation(pending) - synchronized(contactPaymentContextLock) { - if (activeContactPaymentContext == pending.contactPaymentContext) { - activeContactPaymentContext = null - } - } - } - } - private fun onAddressContinue(data: String) { clearActiveContactPaymentContext() launchScan(source = ScanSource.ADDRESS_CONTINUE, data = data, routePubkyKeys = true) @@ -3324,12 +3284,12 @@ class AppViewModel @Inject constructor( val handler = scanResultHandler val shouldHandleAsProtocol = SamRockSetupRequest.isProtocolUrl(data.removeLightningSchemes()) scanResultHandler = null - hideSheet(flushPendingScan = false) + hideSheet(shouldFlushDeferredScan = false) if (handler != null && !shouldHandleAsProtocol) { viewModelScope.launch { delay(SCREEN_TRANSITION_DELAY) handler(data) - flushPendingLockedScan() + flushDeferredScan() } } else { launchScan( @@ -3411,9 +3371,9 @@ class AppViewModel @Inject constructor( } } - fun hideSheet() = hideSheet(flushPendingScan = true) + fun hideSheet() = hideSheet(shouldFlushDeferredScan = true) - private fun hideSheet(flushPendingScan: Boolean) { + private fun hideSheet(shouldFlushDeferredScan: Boolean) { scanResultHandler = null receiveSheetContext = null clearActiveContactPaymentContext() @@ -3431,7 +3391,7 @@ class AppViewModel @Inject constructor( else -> _currentSheet.update { null } } showQueuedPairingCodeSheet() - if (flushPendingScan) flushPendingLockedScan() + if (shouldFlushDeferredScan) flushDeferredScan() } // endregion @@ -3493,7 +3453,7 @@ class AppViewModel @Inject constructor( val settings = settingsStore.data.first() val needsAuth = settings.isPinEnabled _isAuthenticated.value = !needsAuth - if (!needsAuth) flushPendingLockedScan() + if (!needsAuth) flushDeferredScan() } fun resetIsAuthenticatedState() { @@ -3880,22 +3840,6 @@ class AppViewModel @Inject constructor( } } - private enum class ScanSource(val label: String) { - PASTE("paste"), - SCAN_RESULT("scan result"), - SCANNER_SHEET("scanner sheet"), - ADDRESS_CONTINUE("address continue"), - DEEPLINK("deeplink"), - } - - private data class PendingLockedScan( - val source: ScanSource, - val data: String, - val startDelay: Duration, - val routePubkyKeys: Boolean, - val contactPaymentContext: ContactPaymentContext?, - ) - companion object { private const val TAG = "AppViewModel" private val LIGHTNING_SCHEME_PATTERNS = listOf("lightning", "lnurl", "lnurlw", "lnurlc", "lnurlp") @@ -3932,8 +3876,6 @@ class AppViewModel @Inject constructor( /** Characters kept on each side of a truncated scan log id. */ private const val SCAN_LOG_ID_AFFIX_LENGTH = 11 - private const val MAX_PENDING_LOCKED_SCANS = 5 - private val LNURL_WITHDRAW_EXPIRY_SEC = 1.hours.inWholeSeconds.toUInt() /** Intent actions carrying a deeplink URI: browsers and apps send VIEW, NFC tag taps send NDEF_DISCOVERED. */ @@ -3941,6 +3883,28 @@ class AppViewModel @Inject constructor( } } +private enum class ScanSource(val label: String) { + PASTE("paste"), + SCAN_RESULT("scan result"), + SCANNER_SHEET("scanner sheet"), + ADDRESS_CONTINUE("address continue"), + DEEPLINK("deeplink"), +} + +private data class ScheduledScan( + val normalizedInput: String, + val contactPaymentContext: ContactPaymentContext?, + val mustComplete: Boolean, +) + +private data class DeferredScan( + val source: ScanSource, + val data: String, + val startDelay: Duration, + val routePubkyKeys: Boolean, + val contactPaymentContext: ContactPaymentContext?, +) + // region send contract @Stable data class SendUiState( diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt index 4da4ec3d92..e68f3a909b 100644 --- a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt @@ -40,6 +40,17 @@ class ShopOriginTest { assertEquals(setOf("https://embed.bitrefill.com"), shopPaymentOriginRules()) } + @Test + fun `payment bridge pages use only the Bitrefill embed origin`() { + assertTrue(isAllowedShopPaymentPage("https://embed.bitrefill.com/gift-cards?region=us")) + assertTrue(isAllowedShopPaymentPage("https://embed.bitrefill.com:443/gift-cards")) + assertFalse(isAllowedShopPaymentPage("https://www.bitrefill.com/esims")) + assertFalse(isAllowedShopPaymentPage("https://embed.bitrefill.com.evil.example")) + assertFalse(isAllowedShopPaymentPage("http://embed.bitrefill.com")) + assertFalse(isAllowedShopPaymentPage("https://embed.bitrefill.com:444/gift-cards")) + assertFalse(isAllowedShopPaymentPage("https://user@embed.bitrefill.com/gift-cards")) + } + @Test fun `non-Bitrefill and non-https origins are rejected`() { assertFalse(isAllowedShopOrigin(null)) diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt index 9249da067a..0d22b3be0e 100644 --- a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt @@ -77,6 +77,15 @@ class ShopWebViewClientTest : BaseUnitTest() { verify(webView).evaluateJavascript(shopMessageBridgeScript(), null) } + @Test + fun `bridge script is not injected on a Bitrefill sibling origin`() { + val webView = mock() + + sut.onPageFinished(webView, "https://www.bitrefill.com/esims") + + verify(webView, never()).evaluateJavascript(any(), any()) + } + private fun request(url: String, isForMainFrame: Boolean): WebResourceRequest { val request = mock() whenever(request.isForMainFrame).thenReturn(isForMainFrame) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index b0cc1d5d85..1b29322551 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -30,7 +29,6 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout import org.junit.After import org.junit.Before import org.junit.Test @@ -84,7 +82,6 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.NodeEventUpdate import to.bitkit.repositories.PaykitPaymentRequest -import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo @@ -121,7 +118,6 @@ import to.bitkit.utils.timedsheets.TimedSheetManager import java.net.URLEncoder import java.nio.charset.StandardCharsets import kotlin.test.assertEquals -import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -370,7 +366,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `payment requests refresh periodically only while polling is active`() = test { isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) runCurrent() sut.startPaykitPaymentRequestPolling() @@ -398,11 +394,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `payment request waiting for a newer private list is retried after backoff`() = test { + sut.setIsAuthenticated(true) val request = paymentRequest() val bolt11 = "lnbcrt1updatedpaymentrequest" val privateContext = PrivatePaykitPaymentContext("bitkit/server", 8uL) - whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), Result.success( PublicPaykitPaymentResult.Opened( @@ -439,8 +436,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `unresolvable payment request retries are bounded`() = test { val request = paymentRequest() - whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.beginPaymentRequest(request) } + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.beginPaymentRequest(request)) .thenReturn(Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList)) pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true @@ -463,7 +460,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(pendingRequest) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) runCurrent() sut.startPaykitPaymentRequestPolling() @@ -482,29 +479,15 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val secondRequest = firstRequest.copy(paymentRequestId = "next-request") val firstInvoice = "lnbcrt1firstpendingrequest" val secondInvoice = "lnbcrt1secondpendingrequest" - whenever { privatePaykitRepo.beginPaymentRequest(firstRequest) }.thenReturn( - Result.success( - PublicPaykitPaymentResult.Opened( - paymentRequest = firstInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), - ), - ), - ) - whenever { privatePaykitRepo.beginPaymentRequest(secondRequest) }.thenReturn( - Result.success( - PublicPaykitPaymentResult.Opened( - paymentRequest = secondInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), - ), - ), - ) + stubOpenedPaymentRequest(firstRequest, firstInvoice) + stubOpenedPaymentRequest(secondRequest, secondInvoice, privateListIndex = 8uL) stubLightningScan(bolt11 = firstInvoice, amountSats = 0u) stubLightningScan(bolt11 = secondInvoice, amountSats = 0u) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) pendingPaykitPaymentRequests.value = listOf(firstRequest, secondRequest) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) sut.onHomeResumed() sut.currentSheet.first { @@ -516,7 +499,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() assertNull(sut.currentSheet.value) - verify(privatePaykitRepo).beginPaymentRequest(secondRequest) + verify(privatePaykitRepo, never()).beginPaymentRequest(secondRequest) sut.setIsAuthenticated(true) sut.currentSheet.first { @@ -530,21 +513,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `request removed during endpoint resolution is not presented`() = test { + sut.setIsAuthenticated(true) val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) - whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( - Result.success( - PublicPaykitPaymentResult.Opened( - paymentRequest = "lnbcrt1stale", - privatePaymentContext = privateContext, - ), - ), - ) + stubOpenedPaymentRequest(request, "lnbcrt1stale") whenever(paykitPaymentRequestRepo.isPending(request)).thenReturn(false) pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) sut.onHomeResumed() runCurrent() @@ -558,7 +534,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `contact payment opened during request resolution is not overwritten`() = test { val request = paymentRequest() val manualContext = ContactPaymentContext("pubkymanual") - whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenAnswer { + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenAnswer { setActiveContactPaymentContext(manualContext.publicKey) PublicPaykitPaymentResult.Opened( paymentRequest = "lnbcrt1incoming", @@ -568,7 +544,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() advanceTimeBy(30.seconds.inWholeMilliseconds) @@ -589,7 +565,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val resumeResolution = CompletableDeferred() val scanStarted = CompletableDeferred() val resumeScan = CompletableDeferred() - whenever { privatePaykitRepo.beginPaymentRequest(request) }.doSuspendableAnswer { + whenever(privatePaykitRepo.beginPaymentRequest(request)).doSuspendableAnswer { resolutionStarted.complete(Unit) resumeResolution.await() Result.success( @@ -599,7 +575,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ), ) } - whenever { coreService.decode(scanInvoice) }.doSuspendableAnswer { + whenever(coreService.decode(scanInvoice)).doSuspendableAnswer { scanStarted.complete(Unit) resumeScan.await() Scanner.Lightning(lightningInvoice(scanInvoice, 500u)) @@ -609,7 +585,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) sut.onHomeResumed() resolutionStarted.await() @@ -645,15 +621,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val replacementInvoice = "lnbcrt1replacementscan" val requestScanStarted = CompletableDeferred() val holdRequestScan = CompletableDeferred() - whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( - Result.success( - PublicPaykitPaymentResult.Opened( - paymentRequest = requestInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), - ), - ), - ) - whenever { coreService.decode(requestInvoice) }.doSuspendableAnswer { + stubOpenedPaymentRequest(request, requestInvoice) + whenever(coreService.decode(requestInvoice)).doSuspendableAnswer { requestScanStarted.complete(Unit) holdRequestScan.await() Scanner.Lightning(lightningInvoice(requestInvoice, 0u)) @@ -663,7 +632,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) sut.onHomeResumed() requestScanStarted.await() @@ -694,7 +663,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val firstScanStarted = CompletableDeferred() val holdFirstScan = CompletableDeferred() var decodeCount = 0 - whenever { coreService.decode(bolt11) }.doSuspendableAnswer { + whenever(coreService.decode(bolt11)).doSuspendableAnswer { decodeCount += 1 if (decodeCount == 1) { firstScanStarted.complete(Unit) @@ -727,7 +696,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val scanStarted = CompletableDeferred() val finishScan = CompletableDeferred() var decodeCount = 0 - whenever { coreService.decode(bolt11) }.doSuspendableAnswer { + whenever(coreService.decode(bolt11)).doSuspendableAnswer { decodeCount += 1 scanStarted.complete(Unit) finishScan.await() @@ -761,7 +730,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val cleanupStarted = CompletableDeferred() var intermediateDecodeCount = 0 var latestDecodeCount = 0 - whenever { coreService.decode(firstInvoice) }.doSuspendableAnswer { + whenever(coreService.decode(firstInvoice)).doSuspendableAnswer { firstDecodeStarted.complete(Unit) try { awaitCancellation() @@ -772,11 +741,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } } - whenever { coreService.decode(intermediateInvoice) }.doSuspendableAnswer { + whenever(coreService.decode(intermediateInvoice)).doSuspendableAnswer { intermediateDecodeCount += 1 Scanner.Lightning(lightningInvoice(intermediateInvoice, 500u)) } - whenever { coreService.decode(latestInvoice) }.doSuspendableAnswer { + whenever(coreService.decode(latestInvoice)).doSuspendableAnswer { latestDecodeCount += 1 Scanner.Lightning(lightningInvoice(latestInvoice, 600u)) } @@ -787,16 +756,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) sut.onScanResult(firstInvoice) - withTimeout(5.seconds) { firstDecodeStarted.await() } + firstDecodeStarted.await() sut.onScanResult(intermediateInvoice) - withTimeout(5.seconds) { cleanupStarted.await() } + cleanupStarted.await() sut.onScanResult(latestInvoice) assertEquals(0, intermediateDecodeCount) assertEquals(0, latestDecodeCount) advanceTimeBy(1.seconds.inWholeMilliseconds) - withTimeout(5.seconds) { sut.sendUiState.first { it.addressInput == latestInvoice } } + sut.sendUiState.first { it.addressInput == latestInvoice } assertEquals(0, intermediateDecodeCount) assertEquals(1, latestDecodeCount) @@ -806,24 +775,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(toastManager, never()).enqueue(any()) } - @Test - fun `embedded invoice decode preserves cancellation`() = test { - val embeddedInvoice = "lnbcrt1embeddedcancel" - val embeddedDecodeStarted = CompletableDeferred() - whenever { coreService.decode(embeddedInvoice) }.doSuspendableAnswer { - embeddedDecodeStarted.complete(Unit) - awaitCancellation() - } - - val extraction = async { - sut.extractViableLightningInvoice(mapOf("lightning" to embeddedInvoice)) - } - withTimeout(5.seconds) { embeddedDecodeStarted.await() } - extraction.cancel() - - assertFailsWith { extraction.await() } - } - @Test fun `payment request retries when a blocking scan finishes without a sheet`() = test { sut.setIsAuthenticated(true) @@ -834,7 +785,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val resumeResolution = CompletableDeferred() val scanStarted = CompletableDeferred() val resumeScan = CompletableDeferred() - whenever { privatePaykitRepo.beginPaymentRequest(request) }.doSuspendableAnswer { + whenever(privatePaykitRepo.beginPaymentRequest(request)).doSuspendableAnswer { resolutionStarted.complete(Unit) resumeResolution.await() Result.success( @@ -844,7 +795,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ), ) } - whenever { coreService.decode(scanInput) }.doSuspendableAnswer { + whenever(coreService.decode(scanInput)).doSuspendableAnswer { scanStarted.complete(Unit) resumeScan.await() throw AppError("Invalid scan") @@ -854,7 +805,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) sut.onHomeResumed() resolutionStarted.await() @@ -881,19 +832,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val scanInvoice = "lnbcrt1activescan" val scanStarted = CompletableDeferred() val resumeScan = CompletableDeferred() - whenever { coreService.decode(scanInvoice) }.doSuspendableAnswer { + whenever(coreService.decode(scanInvoice)).doSuspendableAnswer { scanStarted.complete(Unit) resumeScan.await() Scanner.Lightning(lightningInvoice(scanInvoice, 500u)) } - whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( - Result.success( - PublicPaykitPaymentResult.Opened( - paymentRequest = requestInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), - ), - ), - ) + stubOpenedPaymentRequest(request, requestInvoice) stubLightningScan(bolt11 = requestInvoice, amountSats = 0u) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) @@ -902,7 +846,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) sut.onHomeResumed() runCurrent() @@ -926,14 +870,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val requestInvoice = "lnbcrt1requestafterlockedscan" val scanInvoice = "lnbcrt1queuedlockedscan" settingsData.value = SettingsData(isPinEnabled = true) - whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( - Result.success( - PublicPaykitPaymentResult.Opened( - paymentRequest = requestInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), - ), - ), - ) + stubOpenedPaymentRequest(request, requestInvoice) stubLightningScan(bolt11 = requestInvoice, amountSats = 0u) stubLightningScan(bolt11 = scanInvoice, amountSats = 500u) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) @@ -943,7 +880,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() pubkyPublicKey.value = testPublicKey - whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) sut.onHomeResumed() runCurrent() @@ -967,22 +904,15 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val unavailableRequest = paymentRequest() val payableRequest = unavailableRequest.copy(paymentRequestId = "payable-request") val bolt11 = "lnbcrt1payablerequest" - whenever { privatePaykitRepo.beginPaymentRequest(unavailableRequest) } + whenever(privatePaykitRepo.beginPaymentRequest(unavailableRequest)) .thenReturn(Result.success(PublicPaykitPaymentResult.NoEndpoint)) - whenever { privatePaykitRepo.beginPaymentRequest(payableRequest) }.thenReturn( - Result.success( - PublicPaykitPaymentResult.Opened( - paymentRequest = bolt11, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), - ), - ), - ) + stubOpenedPaymentRequest(payableRequest, bolt11) stubLightningScan(bolt11 = bolt11, amountSats = 0u) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) pendingPaykitPaymentRequests.value = listOf(unavailableRequest, payableRequest) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() advanceTimeBy(30.seconds.inWholeMilliseconds) @@ -998,11 +928,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `cancelled request resolution releases the presentation guard`() = test { val request = paymentRequest() - whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenThrow(CancellationException()) + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenThrow(CancellationException()) pendingPaykitPaymentRequests.value = listOf(request) isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey - whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) sut.startPaykitPaymentRequestPolling() advanceTimeBy(30.seconds.inWholeMilliseconds) @@ -1905,7 +1835,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `received onchain payment preserves a replacement receive sheet`() = test { val processingStarted = CompletableDeferred() val resumeProcessing = CompletableDeferred() - whenever { privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(any>()) } + whenever(privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(any>())) .doSuspendableAnswer { processingStarted.complete(Unit) resumeProcessing.await() @@ -1967,7 +1897,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) whenever(notifyPaymentReceivedHandler(any())) .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowSheet(details))) - whenever { notifyPaymentReceivedHandler.present(any(), any(), any()) }.thenAnswer { + whenever(notifyPaymentReceivedHandler.present(any(), any(), any())).thenAnswer { it.getArgument<() -> Unit>(2).invoke() true } @@ -1997,7 +1927,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) whenever(notifyPaymentReceivedHandler(any())) .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowSheet(details))) - whenever { notifyPaymentReceivedHandler.present(any(), any(), any()) }.thenAnswer { + whenever(notifyPaymentReceivedHandler.present(any(), any(), any())).thenAnswer { it.getArgument<() -> Unit>(2).invoke() true } @@ -2027,7 +1957,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) whenever(notifyPaymentReceivedHandler(any())) .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowSheet(details))) - whenever { notifyPaymentReceivedHandler.present(any(), any(), any()) }.thenReturn(false) + whenever(notifyPaymentReceivedHandler.present(any(), any(), any())).thenReturn(false) App.currentActivity = CurrentActivity() emitNodeEvent( @@ -2252,20 +2182,25 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `lightning scan is queued until authenticated when PIN is enabled`() = test { + fun `QuickPay eligible scan remains deferred until authenticated`() = test { val bolt11 = "lnbcrt1lockedscan" - settingsData.value = SettingsData(isPinEnabled = true) + enableQuickPay(thresholdSats = 1_000u) + settingsData.value = settingsData.value.copy(isPinEnabled = true) stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.onScanResult(bolt11) advanceUntilIdle() assertNull(sut.currentSheet.value) + assertNull(sut.quickPayData.value) + verify(coreService, never()).decode(bolt11) sut.setIsAuthenticated(true) advanceUntilIdle() - assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) + verify(coreService).decode(bolt11) } @Test @@ -2286,30 +2221,34 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `locked scans are replayed in order after authenticate`() = test { + fun `latest locked scan replaces earlier input`() = test { val first = "lnbcrt1lockedfirst" val second = "lnbcrt1lockedsecond" settingsData.value = SettingsData(isPinEnabled = true) stubLightningScan(bolt11 = first, amountSats = 500u) stubLightningScan(bolt11 = second, amountSats = 600u) - sut.onScanResult(first) + sut.openContactPayment(paymentRequest = first, publicKey = "pubkyfirst") sut.onScanResult(second) advanceUntilIdle() assertNull(sut.currentSheet.value) + assertNull(activeContactPaymentContext()) + verify(coreService, never()).decode(any()) sut.setIsAuthenticated(true) advanceUntilIdle() assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) - assertEquals(500u, sut.sendUiState.value.amount) + assertEquals(600u, sut.sendUiState.value.amount) + assertNull(activeContactPaymentContext()) + verify(coreService, never()).decode(first) + verify(coreService).decode(second) sut.hideSheet() advanceUntilIdle() - assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) - assertEquals(600u, sut.sendUiState.value.amount) + assertNull(sut.currentSheet.value) } @Test @@ -2334,25 +2273,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `locked scans restore their own contact payment context`() = test { - val normal = "lnbcrt1lockednormal" + fun `locked contact scan restores its payment context`() = test { val contact = "lnbcrt1lockedcontact" settingsData.value = SettingsData(isPinEnabled = true) - stubLightningScan(bolt11 = normal, amountSats = 500u) stubLightningScan(bolt11 = contact, amountSats = 600u) - sut.onScanResult(normal) sut.openContactPayment(paymentRequest = contact, publicKey = "pubkycontact") advanceUntilIdle() - sut.setIsAuthenticated(true) - advanceUntilIdle() - - assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) - assertEquals(500u, sut.sendUiState.value.amount) - assertNull(activeContactPaymentContext()) + assertNull(sut.currentSheet.value) - sut.hideSheet() + sut.setIsAuthenticated(true) advanceUntilIdle() assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) @@ -2411,12 +2342,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val finishLockedScan = CompletableDeferred() var newScanDecodeCount = 0 settingsData.value = SettingsData(isPinEnabled = true) - whenever { coreService.decode(lockedInvoice) }.doSuspendableAnswer { + whenever(coreService.decode(lockedInvoice)).doSuspendableAnswer { lockedScanStarted.complete(Unit) finishLockedScan.await() Scanner.Lightning(lightningInvoice(lockedInvoice, 500u)) } - whenever { coreService.decode(newInvoice) }.doSuspendableAnswer { + whenever(coreService.decode(newInvoice)).doSuspendableAnswer { newScanDecodeCount += 1 Scanner.Lightning(lightningInvoice(newInvoice, 600u)) } @@ -2448,7 +2379,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val finishScan = CompletableDeferred() var decodeCount = 0 settingsData.value = SettingsData(isPinEnabled = true) - whenever { coreService.decode(bolt11) }.doSuspendableAnswer { + whenever(coreService.decode(bolt11)).doSuspendableAnswer { decodeCount += 1 scanStarted.complete(Unit) finishScan.await() @@ -2472,260 +2403,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(1, decodeCount) } - @Test - fun `distinct locked payment requests each present once`() = test { - val firstRequest = paymentRequest() - val secondRequest = firstRequest.copy(paymentRequestId = "second-locked-request") - val firstInvoice = "lnbcrt1firstlockedrequest" - val secondInvoice = "lnbcrt1secondlockedrequest" - settingsData.value = SettingsData(isPinEnabled = true) - balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - stubLightningScan(bolt11 = firstInvoice, amountSats = 0u) - stubLightningScan(bolt11 = secondInvoice, amountSats = 0u) - pendingPaykitPaymentRequests.value = listOf(firstRequest, secondRequest) - setPresentedPaymentRequests(firstRequest, secondRequest) - - sut.openContactPayment( - paymentRequest = firstInvoice, - publicKey = testPublicKey, - incomingPaymentRequest = firstRequest, - ) - sut.openContactPayment( - paymentRequest = secondInvoice, - publicKey = testPublicKey, - incomingPaymentRequest = secondRequest, - ) - - assertNull(activeContactPaymentContext()) - - sut.setIsAuthenticated(true) - sut.currentSheet.first { - it is Sheet.Send && activeContactPaymentContext()?.incomingPaymentRequest == firstRequest - } - - sut.hideSheet() - sut.currentSheet.first { - it is Sheet.Send && activeContactPaymentContext()?.incomingPaymentRequest == secondRequest - } - - sut.hideSheet() - advanceUntilIdle() - - assertNull(sut.currentSheet.value) - verify(privatePaykitRepo, never()).beginPaymentRequest(any()) - } - - @Test - fun `replacing a queued payment request keeps the prior request available`() = test { - val firstRequest = paymentRequest() - val replacementRequest = firstRequest.copy(paymentRequestId = "replacement-request") - val bolt11 = "lnbcrt1replacedrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) - settingsData.value = SettingsData(isPinEnabled = true) - enablePaykitUi() - pubkyPublicKey.value = testPublicKey - balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - stubLightningScan(bolt11 = bolt11, amountSats = 0u) - whenever { privatePaykitRepo.beginPaymentRequest(firstRequest) }.thenReturn( - Result.success( - PublicPaykitPaymentResult.Opened( - paymentRequest = bolt11, - privatePaymentContext = privateContext, - ), - ), - ) - whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) - - pendingPaykitPaymentRequests.value = listOf(firstRequest) - sut.startPaykitPaymentRequestPolling() - advanceTimeBy(30.seconds.inWholeMilliseconds) - runCurrent() - sut.stopPaykitPaymentRequestPolling() - - sut.openContactPayment( - paymentRequest = bolt11, - publicKey = testPublicKey, - privatePaymentContext = privateContext, - incomingPaymentRequest = replacementRequest, - ) - sut.setIsAuthenticated(true) - advanceUntilIdle() - - assertEquals(replacementRequest, activeContactPaymentContext()?.incomingPaymentRequest) - - sut.hideSheet() - sut.onHomeResumed() - sut.currentSheet.first { - it is Sheet.Send && activeContactPaymentContext()?.incomingPaymentRequest == firstRequest - } - - assertEquals(firstRequest, activeContactPaymentContext()?.incomingPaymentRequest) - verify(privatePaykitRepo, times(2)).beginPaymentRequest(firstRequest) - } - - @Test - fun `locked scan queue drops the oldest when full`() = test { - settingsData.value = SettingsData(isPinEnabled = true) - val invoices = (1..6).map { index -> - "lnbcrt1lockedcap$index" to (100uL * index.toULong()) - } - invoices.forEach { (bolt11, amount) -> stubLightningScan(bolt11 = bolt11, amountSats = amount) } - - invoices.forEach { (bolt11, _) -> sut.onScanResult(bolt11) } - advanceUntilIdle() - - sut.setIsAuthenticated(true) - advanceUntilIdle() - - assertEquals(200uL, sut.sendUiState.value.amount) - - repeat(4) { - sut.hideSheet() - advanceUntilIdle() - } - - assertEquals(600uL, sut.sendUiState.value.amount) - - sut.hideSheet() - advanceUntilIdle() - - assertNull(sut.currentSheet.value) - } - - @Test - fun `incoming payment request remains available when locked scan queue reaches capacity`() = test { - val request = paymentRequest() - val paymentRequestInvoice = "lnbcrt1queuedrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) - val queuedInvoices = (1..5).map { index -> - "lnbcrt1afterrequest$index" to (100uL * index.toULong()) - } - settingsData.value = SettingsData(isPinEnabled = true) - enablePaykitUi() - pubkyPublicKey.value = testPublicKey - balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - stubLightningScan(bolt11 = paymentRequestInvoice, amountSats = 0u) - queuedInvoices.forEach { (bolt11, amount) -> stubLightningScan(bolt11, amount) } - whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( - Result.success( - PublicPaykitPaymentResult.Opened( - paymentRequest = paymentRequestInvoice, - privatePaymentContext = privateContext, - ), - ), - ) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) - - pendingPaykitPaymentRequests.value = listOf(request) - sut.startPaykitPaymentRequestPolling() - advanceTimeBy(30.seconds.inWholeMilliseconds) - runCurrent() - sut.stopPaykitPaymentRequestPolling() - - queuedInvoices.forEach { (bolt11, _) -> sut.onScanResult(bolt11) } - sut.setIsAuthenticated(true) - advanceUntilIdle() - - repeat(5) { - sut.hideSheet() - advanceUntilIdle() - } - - assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) - assertTrue(sut.sendUiState.value.isPaymentRequest) - verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) - } - - @Test - fun `stale locked payment request is skipped after authenticate`() = test { - val request = paymentRequest() - val requestInvoice = "lnbcrt1stalelockedrequest" - val nextInvoice = "lnbcrt1afterstalerequest" - settingsData.value = SettingsData(isPinEnabled = true) - enablePaykitUi() - pubkyPublicKey.value = testPublicKey - balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - stubLightningScan(bolt11 = requestInvoice, amountSats = 0u) - stubLightningScan(bolt11 = nextInvoice, amountSats = 500u) - whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( - Result.success( - PublicPaykitPaymentResult.Opened( - paymentRequest = requestInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), - ), - ), - ) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) - - pendingPaykitPaymentRequests.value = listOf(request) - sut.onHomeResumed() - runCurrent() - sut.onScanResult(nextInvoice) - whenever(paykitPaymentRequestRepo.isPending(request)).thenReturn(false) - - sut.setIsAuthenticated(true) - sut.currentSheet.first { it is Sheet.Send } - - assertFalse(sut.sendUiState.value.isPaymentRequest) - assertEquals(500u, sut.sendUiState.value.amount) - assertNull(activeContactPaymentContext()) - verify(privatePaykitRepo).beginPaymentRequest(request) - - whenever(paykitPaymentRequestRepo.isPending(request)).thenReturn(true) - pendingPaykitPaymentRequests.value = listOf(request) - sut.hideSheet() - sut.onHomeResumed() - sut.currentSheet.first { it is Sheet.Send && sut.sendUiState.value.isPaymentRequest } - - verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) - } - - @Test - fun `pending payment request presents when stale locked request is discarded`() = test { - val staleRequest = paymentRequest() - val pendingRequest = staleRequest.copy(paymentRequestId = "pending-after-stale") - val staleInvoice = "lnbcrt1stalelockedonly" - val pendingInvoice = "lnbcrt1pendingafterstale" - settingsData.value = SettingsData(isPinEnabled = true) - enablePaykitUi() - pubkyPublicKey.value = testPublicKey - balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - stubLightningScan(bolt11 = staleInvoice, amountSats = 0u) - stubLightningScan(bolt11 = pendingInvoice, amountSats = 0u) - whenever(privatePaykitRepo.beginPaymentRequest(staleRequest)).thenReturn( - Result.success( - PublicPaykitPaymentResult.Opened( - paymentRequest = staleInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), - ), - ), - ) - whenever(privatePaykitRepo.beginPaymentRequest(pendingRequest)).thenReturn( - Result.success( - PublicPaykitPaymentResult.Opened( - paymentRequest = pendingInvoice, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), - ), - ), - ) - whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) - - pendingPaykitPaymentRequests.value = listOf(staleRequest) - sut.onHomeResumed() - runCurrent() - whenever(paykitPaymentRequestRepo.isPending(staleRequest)).thenReturn(false) - pendingPaykitPaymentRequests.value = listOf(pendingRequest) - - sut.setIsAuthenticated(true) - sut.currentSheet.first { - it is Sheet.Send && activeContactPaymentContext()?.incomingPaymentRequest == pendingRequest - } - - assertTrue(sut.sendUiState.value.isPaymentRequest) - verify(privatePaykitRepo).beginPaymentRequest(staleRequest) - verify(privatePaykitRepo).beginPaymentRequest(pendingRequest) - } - @Test fun `queued scan flushes after timed sheet dismisses`() = test { val bolt11 = "lnbcrt1lockedtimed" @@ -2766,21 +2443,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `incoming payment request opens the existing confirm flow with its fixed amount`() = test { val request = paymentRequest() val bolt11 = "lnbcrt1paymentrequest" - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) enablePaykitUi() pubkyPublicKey.value = testPublicKey balanceState.value = BalanceState(maxSendLightningSats = 100_000u) stubLightningScan(bolt11 = bolt11, amountSats = 0u) whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) - whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( - Result.success( - PublicPaykitPaymentResult.Opened( - paymentRequest = bolt11, - privatePaymentContext = privateContext, - ), - ), - ) - whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + val privateContext = stubOpenedPaymentRequest(request, bolt11) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) pendingPaykitPaymentRequests.value = listOf(request) sut.startPaykitPaymentRequestPolling() @@ -2805,15 +2474,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyPublicKey.value = testPublicKey balanceState.value = BalanceState(maxSendLightningSats = 100_000u) stubLightningScan(bolt11 = bolt11, amountSats = 0u) - whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( - Result.success( - PublicPaykitPaymentResult.Opened( - paymentRequest = bolt11, - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), - ), - ), - ) - whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + stubOpenedPaymentRequest(request, bolt11) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) pendingPaykitPaymentRequests.value = listOf(request) sut.startPaykitPaymentRequestPolling() @@ -2842,8 +2504,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - whenever { paykitPaymentRequestRepo.accept(request) }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext) } + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) whenever { lightningRepo.sendOnChain( @@ -2941,7 +2603,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = emptyList(), ) }.thenReturn(Result.success("txid")) - whenever { privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext) } + whenever(privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext)) .thenReturn(Result.success(Unit)) setActiveContactPaymentContext(contactKey, privateContext) setSendState( @@ -2964,8 +2626,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - whenever { paykitPaymentRequestRepo.accept(request) }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext) } + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) whenever { lightningRepo.sendOnChain( @@ -3002,7 +2664,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - whenever { privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext) } + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.failure(IllegalStateException("Payment list already consumed"))) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( @@ -3177,7 +2839,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)).thenReturn(Result.success(paymentHash)) - whenever { privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext) } + whenever(privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext)) .thenReturn(Result.success(Unit)) setActiveContactPaymentContext(contactKey, privateContext) setSendState( @@ -3213,7 +2875,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)) .thenReturn(Result.failure(PaymentPendingException(paymentHash))) - whenever { privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext) } + whenever(privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext)) .thenReturn(Result.success(Unit)) setActiveContactPaymentContext(contactKey, privateContext) setSendState( @@ -3239,7 +2901,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)) .thenReturn(Result.failure(AppError("DuplicatePayment"))) - whenever { privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext) } + whenever(privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext)) .thenReturn(Result.success(Unit)) setActiveContactPaymentContext(contactKey, privateContext) setSendState( @@ -3431,7 +3093,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `private Paykit refresh retries public cleanup while UI is disabled`() = test { settingsData.value = SettingsData(publicPaykitCleanupPending = true) - whenever { publicPaykitRepo.syncPublishedEndpoints(publish = false) }.thenReturn(Result.success(Unit)) + whenever(publicPaykitRepo.syncPublishedEndpoints(publish = false)).thenReturn(Result.success(Unit)) sut.refreshPrivatePaykitEndpoints() advanceUntilIdle() @@ -3449,7 +3111,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sharesPublicPaykitEndpoints = true, publicPaykitCleanupPending = true, ) - whenever { publicPaykitRepo.syncCurrentPublishedEndpoints() }.thenReturn(Result.success(Unit)) + whenever(publicPaykitRepo.syncCurrentPublishedEndpoints()).thenReturn(Result.success(Unit)) sut.refreshPrivatePaykitEndpoints() advanceUntilIdle() @@ -3484,10 +3146,28 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } private suspend fun stubLightningScan(bolt11: String, amountSats: ULong) { - whenever(coreService.decode(bolt11)).thenReturn(Scanner.Lightning(lightningInvoice(bolt11, amountSats))) + whenever { coreService.decode(bolt11) } + .thenReturn(Scanner.Lightning(lightningInvoice(bolt11, amountSats))) whenever(lightningRepo.canSend(amountSats)).thenReturn(true) } + private suspend fun stubOpenedPaymentRequest( + request: PaykitPaymentRequest, + paymentRequest: String, + privateListIndex: ULong = 7uL, + ): PrivatePaykitPaymentContext { + val privateContext = PrivatePaykitPaymentContext("bitkit/server", privateListIndex) + whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = paymentRequest, + privatePaymentContext = privateContext, + ), + ), + ) + return privateContext + } + private fun lightningInvoice(bolt11: String, amountSats: ULong) = LightningInvoice( bolt11 = bolt11, paymentHash = byteArrayOf(1, 2, 3), @@ -3501,7 +3181,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) private suspend fun enablePublicPaykitSharing() { - whenever { publicPaykitRepo.syncCurrentPublishedEndpoints(any(), any()) }.thenReturn(Result.success(Unit)) + whenever(publicPaykitRepo.syncCurrentPublishedEndpoints(any(), any())).thenReturn(Result.success(Unit)) walletState.value = WalletState(onchainAddress = "bc1qtest") isPaykitEnabled.value = true settingsData.value = SettingsData(sharesPublicPaykitEndpoints = true) @@ -3605,14 +3285,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { return field.get(sut) as ContactPaymentContext? } - @Suppress("UNCHECKED_CAST") - private fun setPresentedPaymentRequests(vararg requests: PaykitPaymentRequest) { - val field = AppViewModel::class.java.getDeclaredField("presentedPaymentRequestIds") - field.isAccessible = true - val requestIds = field.get(sut) as MutableSet - requestIds += requests.map { it.id } - } - private fun isPresentingPaymentRequest(): Boolean { val field = AppViewModel::class.java.getDeclaredField("isPresentingPaymentRequest") field.isAccessible = true From db928b5fd8badcc7a9a9ab08e132d025b056a4d2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 14 Aug 2026 07:02:05 -0500 Subject: [PATCH 11/12] fix: address payment review feedback --- app/src/main/java/to/bitkit/ui/ContentView.kt | 11 +++- .../ui/screens/shop/shopWebView/ShopOrigin.kt | 23 ++++--- .../shop/shopWebView/ShopWebViewClient.kt | 2 + .../shop/shopWebView/ShopWebViewInterface.kt | 25 ++++--- .../shop/shopWebView/ShopWebViewScreen.kt | 3 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 65 ++++++++++++------- app/src/main/res/values/strings.xml | 1 + .../shop/shopWebView/ShopOriginTest.kt | 1 + .../shop/shopWebView/ShopWebViewClientTest.kt | 10 +++ .../shopWebView/ShopWebViewInterfaceTest.kt | 15 +++++ .../viewmodels/AppViewModelSendFlowTest.kt | 46 +++++++++++++ 11 files changed, 153 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index defd01b527..43b604a6e9 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -51,6 +52,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import to.bitkit.R import to.bitkit.appwidget.AppWidgetRefreshReason import to.bitkit.appwidget.appWidgetRefreshScheduler import to.bitkit.env.Env @@ -1446,6 +1448,7 @@ private fun NavGraphBuilder.shop( ) } deepLinkableComposable { + val blockedNavigationMessage = stringResource(R.string.other__shop__external_link_blocked) ShopWebViewScreen( onClose = { navController.navigateToHome() }, onBack = { navController.popBackStack() }, @@ -1453,7 +1456,13 @@ private fun NavGraphBuilder.shop( title = it.toRoute().title, onPaymentIntent = { data -> appViewModel.onScanResult(data) - } + }, + onBlockedNavigation = { + appViewModel.toast( + type = Toast.ToastType.WARNING, + title = blockedNavigationMessage, + ) + }, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt index 322f37ec29..38cb88d0dc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt @@ -48,15 +48,18 @@ internal fun isAllowedShopPaymentOrigin(origin: String?): Boolean { internal fun shopPaymentOriginRules(): Set = setOf(Env.BITREFILL_URL) internal fun shopMessageBridgeScript(): String = """ - window.ReactNativeWebView = { - postMessage: function(data) { + if (!window.__bitkitShopBridgeInstalled) { + window.__bitkitShopBridgeInstalled = true; + window.ReactNativeWebView = { + postMessage: function(data) { + Android.postMessage(typeof data === 'string' ? data : JSON.stringify(data)); + } + }; + window.addEventListener('message', function(event) { + if (event.origin !== '${Env.BITREFILL_URL}') return; + var data = event.data; + if (data == null) return; Android.postMessage(typeof data === 'string' ? data : JSON.stringify(data)); - } - }; - window.addEventListener('message', function(event) { - if (event.origin !== '${Env.BITREFILL_URL}') return; - var data = event.data; - if (data == null) return; - Android.postMessage(typeof data === 'string' ? data : JSON.stringify(data)); - }); + }); + } """.trimIndent() diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt index 04c66c4d9c..b61133e6db 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClient.kt @@ -13,6 +13,7 @@ import to.bitkit.utils.Logger class ShopWebViewClient( private val onLoadingStateChanged: (Boolean) -> Unit, private val onError: () -> Unit, + private val onBlockedNavigation: () -> Unit, private val isPaymentBridgeSupported: () -> Boolean, ) : WebViewClient() { private companion object { @@ -38,6 +39,7 @@ class ShopWebViewClient( val url = request.url?.toString() if (isAllowedShopOrigin(url)) return false Logger.warn("Blocked shop navigation to untrusted origin '$url'", context = TAG) + onBlockedNavigation() return true } diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt index 7c944007eb..88166a7373 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt @@ -73,21 +73,20 @@ class ShopWebViewInterface( return } - runCatching { - val data = json.decodeFromString(message) - when (data.event) { - PAYMENT_INTENT_EVENT -> { - val uri = data.paymentUri?.trim().orEmpty() - if (uri.isBlank()) { - Logger.warn("Received payment_intent with empty URI", context = TAG) - return - } - onPaymentIntent(uri) + val data = runCatching { json.decodeFromString(message) }.getOrElse { + Logger.debug("Ignored unrecognized shop WebView message", context = TAG) + return + } + when (data.event) { + PAYMENT_INTENT_EVENT -> { + val uri = data.paymentUri?.trim().orEmpty() + if (uri.isBlank()) { + Logger.warn("Received payment_intent with empty URI", context = TAG) + return } - else -> Logger.debug("Ignored shop WebView event '${data.event}'", context = TAG) + onPaymentIntent(uri) } - }.onFailure { - Logger.error("Failed to parse shop WebView message", it, context = TAG) + else -> Logger.debug("Ignored shop WebView event '${data.event}'", context = TAG) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt index 7354fe9c78..2a42fe1c63 100644 --- a/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewScreen.kt @@ -31,6 +31,7 @@ fun ShopWebViewScreen( onClose: () -> Unit, onBack: () -> Unit, onPaymentIntent: (String) -> Unit, + onBlockedNavigation: () -> Unit, page: String, title: String, ) { @@ -46,6 +47,7 @@ fun ShopWebViewScreen( ShopWebViewClient( onLoadingStateChanged = { loading -> isLoading = loading }, onError = onClose, + onBlockedNavigation = onBlockedNavigation, isPaymentBridgeSupported = webViewInterface::supportsPaymentBridge, ) } @@ -101,6 +103,7 @@ private fun Preview() { onClose = {}, onBack = {}, onPaymentIntent = {}, + onBlockedNavigation = {}, page = "esims", title = "Gift Cards" ) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 666a2a1553..aa616e2eba 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -34,6 +34,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException @@ -255,7 +256,6 @@ class AppViewModel @Inject constructor( private val _quickPayData = MutableStateFlow(null) val quickPayData = _quickPayData.asStateFlow() - private var activeScanJob: Job? = null private val scanMutex = Mutex() @Volatile @@ -286,6 +286,7 @@ class AppViewModel @Inject constructor( private val _currentSheet: MutableStateFlow = MutableStateFlow(null) val currentSheet = _currentSheet.asStateFlow() + private var sheetTransitionJob: Job? = null private var queuedPairingCodeRequestId: Long? = null private var receiveSheetContext: ReceiveSheetContext? = null @@ -1610,43 +1611,45 @@ class AppViewModel @Inject constructor( val scheduled = scheduledScan val isSameActiveScan = normalized == scheduled?.normalizedInput && - activeScanJob?.isActive == true && + scheduled.job.isActive && (scheduled.contactPaymentContext == contactPaymentContext || contactPaymentContext == null) if (isSameActiveScan) { Logger.info("Skipping duplicate scan from '${source.label}': '$scanId'", context = TAG) return } - if (activeScanJob?.isActive == true && scheduled?.mustComplete == true) { + if (scheduled?.job?.isActive == true && scheduled.mustComplete) { enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext) return } - activeScanJob?.let { - Logger.info("Cancelling prior scan for new '${source.label}': '$scanId'", context = TAG) - it.cancel() + val previousJob = scheduled?.job + val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { + scanMutex.withLock { + setActiveContactPaymentContext(contactPaymentContext) + if (startDelay > Duration.ZERO) delay(startDelay) + handleScan(data, routePubkyKeys) + } } - - scheduledScan = ScheduledScan( + val nextScheduledScan = ScheduledScan( + job = nextJob, normalizedInput = normalized, contactPaymentContext = contactPaymentContext, mustComplete = preserveUntilComplete, ) + + scheduledScan = nextScheduledScan + nextJob.invokeOnCompletion { + if (scheduledScan === nextScheduledScan) scheduledScan = null + if (nextJob.isCancelled) return@invokeOnCompletion + viewModelScope.launch { flushDeferredScan() } + } + Logger.debug("Starting scan from '${source.label}': '$scanId'", context = TAG) - activeScanJob = viewModelScope.launch { - scanMutex.withLock { - setActiveContactPaymentContext(contactPaymentContext) - if (startDelay > Duration.ZERO) delay(startDelay) - handleScan(data, routePubkyKeys) - } - }.also { job -> - job.invokeOnCompletion { - if (activeScanJob === job) { - scheduledScan = null - } - if (job.isCancelled) return@invokeOnCompletion - viewModelScope.launch { flushDeferredScan() } - } + nextJob.start() + previousJob?.let { + Logger.info("Cancelling prior scan for new '${source.label}': '$scanId'", context = TAG) + it.cancel() } } @@ -1702,18 +1705,20 @@ class AppViewModel @Inject constructor( } private fun isScanPendingOrActive(): Boolean { - if (activeScanJob?.isActive == true) return true + if (scheduledScan?.job?.isActive == true) return true return synchronized(deferredScanLock) { deferredScan != null } } private fun isPaymentRequestPresentationBlocked() = !_isAuthenticated.value || currentSheet.value != null || + sheetTransitionJob?.isActive == true || hasActiveContactPaymentContext() || isScanPendingOrActive() private fun flushDeferredScan() { if (!_isAuthenticated.value) return - if (activeScanJob?.isActive == true) return + if (scheduledScan?.job?.isActive == true) return + if (sheetTransitionJob?.isActive == true) return if (_currentSheet.value != null) return val pending = synchronized(deferredScanLock) { @@ -3354,7 +3359,8 @@ class AppViewModel @Inject constructor( } fun showSheet(sheetType: Sheet) { - viewModelScope.launch { + val previousJob = sheetTransitionJob + val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { receiveSheetContext = null _currentSheet.value?.let { _currentSheet.update { null } @@ -3369,6 +3375,12 @@ class AppViewModel @Inject constructor( } _currentSheet.update { sheetType } } + sheetTransitionJob = nextJob + nextJob.invokeOnCompletion { + if (sheetTransitionJob === nextJob) sheetTransitionJob = null + } + previousJob?.cancel() + nextJob.start() } fun hideSheet() = hideSheet(shouldFlushDeferredScan = true) @@ -3376,6 +3388,8 @@ class AppViewModel @Inject constructor( private fun hideSheet(shouldFlushDeferredScan: Boolean) { scanResultHandler = null receiveSheetContext = null + sheetTransitionJob?.cancel() + sheetTransitionJob = null clearActiveContactPaymentContext() when { currentSheet.value is Sheet.TimedSheet -> { @@ -3892,6 +3906,7 @@ private enum class ScanSource(val label: String) { } private data class ScheduledScan( + val job: Job, val normalizedInput: String, val contactPaymentContext: ContactPaymentContext?, val mustComplete: Boolean, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 47fbad5ced..f0682904cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -564,6 +564,7 @@ Shop Book your ₿ holiday Travel + This link can’t be opened from the shop. Get your life on the Bitcoin standard. Spend your Bitcoin on digital gift cards, eSIMs, phone refills, and more. Shop Swipe To Confirm diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt index e68f3a909b..0ad40a9994 100644 --- a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt @@ -12,6 +12,7 @@ class ShopOriginTest { val script = shopMessageBridgeScript() assertTrue("addEventListener('message'" in script) + assertTrue("__bitkitShopBridgeInstalled" in script) assertFalse("window.postMessage =" in script) assertTrue("event.origin !== 'https://embed.bitrefill.com'" in script) assertFalse("endsWith('.bitrefill.com')" in script) diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt index 0d22b3be0e..913a4c7536 100644 --- a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt @@ -23,6 +23,7 @@ class ShopWebViewClientTest : BaseUnitTest() { private val sut = ShopWebViewClient( onLoadingStateChanged = {}, onError = {}, + onBlockedNavigation = {}, isPaymentBridgeSupported = { true }, ) @@ -42,9 +43,17 @@ class ShopWebViewClientTest : BaseUnitTest() { @Test fun `main-frame navigation off Bitrefill is blocked`() { + var wasReported = false + val sut = ShopWebViewClient( + onLoadingStateChanged = {}, + onError = {}, + onBlockedNavigation = { wasReported = true }, + isPaymentBridgeSupported = { true }, + ) val request = request(url = "https://evil.example/pay", isForMainFrame = true) assertTrue(sut.shouldOverrideUrlLoading(null, request)) + assertTrue(wasReported) } @Test @@ -60,6 +69,7 @@ class ShopWebViewClientTest : BaseUnitTest() { val sut = ShopWebViewClient( onLoadingStateChanged = {}, onError = {}, + onBlockedNavigation = {}, isPaymentBridgeSupported = { false }, ) diff --git a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt index 414a410b11..bf5689172f 100644 --- a/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt @@ -82,6 +82,21 @@ class ShopWebViewInterfaceTest : BaseUnitTest() { assertNull(received) } + @Test + fun `messages without an event are ignored`() { + var received: String? = null + val sut = interfaceOf( + onPaymentIntent = { received = it }, + ) + + sut.onWebMessage( + WebMessageCompat("""{"type":"navigation"}"""), + "https://embed.bitrefill.com", + ) + + assertNull(received) + } + @Test fun `payment_intent from a Bitrefill sibling origin is ignored`() { var received: String? = null diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 1b29322551..2ecc1014aa 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -111,6 +111,7 @@ import to.bitkit.ui.components.TimedSheetType import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.hardware.HardwareRoute +import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.usecases.FormatMoneyValue import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError @@ -2372,6 +2373,51 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(1, newScanDecodeCount) } + @Test + fun `deferred scan waits for an active sheet transition`() = test { + val firstInvoice = "lnbcrt1sheettransitionfirst" + val secondInvoice = "lnbcrt1sheettransitionsecond" + val firstScanStarted = CompletableDeferred() + val finishFirstScan = CompletableDeferred() + var secondScanDecodeCount = 0 + settingsData.value = SettingsData(isPinEnabled = true) + whenever(coreService.decode(firstInvoice)).doSuspendableAnswer { + firstScanStarted.complete(Unit) + finishFirstScan.await() + Scanner.Lightning(lightningInvoice(firstInvoice, 500u)) + } + whenever(coreService.decode(secondInvoice)).doSuspendableAnswer { + secondScanDecodeCount += 1 + Scanner.Lightning(lightningInvoice(secondInvoice, 600u)) + } + whenever(lightningRepo.canSend(any())).thenReturn(true) + + sut.onScanResult(firstInvoice) + sut.setIsAuthenticated(true) + firstScanStarted.await() + sut.showSheet(Sheet.ConnectionClosed) + sut.onScanResult(secondInvoice) + + finishFirstScan.complete(Unit) + runCurrent() + + assertNull(sut.currentSheet.value) + assertEquals(0, secondScanDecodeCount) + assertEquals(500u, sut.sendUiState.value.amount) + + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(0, secondScanDecodeCount) + assertEquals(500u, sut.sendUiState.value.amount) + + sut.hideSheet() + sut.currentSheet.first { it is Sheet.Send && sut.sendUiState.value.amount == 600uL } + + assertEquals(1, secondScanDecodeCount) + } + @Test fun `active locked replay ignores the same scan`() = test { val bolt11 = "lnbcrt1duplicatelockedreplay" From 49ab192728000d91d4f8edc4b89ec7614cc20c91 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 14 Aug 2026 07:19:18 -0500 Subject: [PATCH 12/12] fix: preserve quickpay pin behavior --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 12 +--------- .../viewmodels/AppViewModelSendFlowTest.kt | 22 +++++-------------- changelog.d/next/1158.security.md | 2 +- 3 files changed, 7 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index aa616e2eba..e6cecbb97b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -74,7 +74,6 @@ import org.lightningdevkit.ldknode.Txid import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore -import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin @@ -2604,7 +2603,7 @@ class AppViewModel @Inject constructor( if (hasActiveContactPaymentContext()) return false val settings = settingsStore.data.first() - if (!canApplyQuickPay(settings, amountSats)) return false + if (!settings.isQuickPayEnabled || amountSats == 0uL) return false val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() ?: return false @@ -2637,15 +2636,6 @@ class AppViewModel @Inject constructor( return false } - private fun canApplyQuickPay(settings: SettingsData, amountSats: ULong): Boolean { - if (!settings.isQuickPayEnabled || amountSats == 0uL) return false - if (settings.isPinEnabled && settings.isPinForPaymentsEnabled) { - Logger.debug("Skipping QuickPay because PIN is required for payments", context = TAG) - return false - } - return true - } - private fun resetAmountInput() { _sendUiState.update { state -> state.copy( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 2ecc1014aa..afb29020ea 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2150,7 +2150,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `lightning scan skips QuickPay when PIN is required for payments`() = test { + fun `lightning scan uses QuickPay when PIN is required for payments`() = test { val bolt11 = "lnbcrt1quickpaypin" enableQuickPay(thresholdSats = 1000u) settingsData.value = settingsData.value.copy( @@ -2163,21 +2163,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.onScanResult(bolt11) advanceUntilIdle() - assertNull(sut.quickPayData.value) - assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) - } - - @Test - fun `lightning scan uses QuickPay when PIN is on without PIN for payments`() = test { - val bolt11 = "lnbcrt1quickpayunlocked" - enableQuickPay(thresholdSats = 1000u) - settingsData.value = settingsData.value.copy(isPinEnabled = true) - stubLightningScan(bolt11 = bolt11, amountSats = 500u) - sut.setIsAuthenticated(true) - - sut.onScanResult(bolt11) - advanceUntilIdle() - assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } @@ -2186,7 +2171,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `QuickPay eligible scan remains deferred until authenticated`() = test { val bolt11 = "lnbcrt1lockedscan" enableQuickPay(thresholdSats = 1_000u) - settingsData.value = settingsData.value.copy(isPinEnabled = true) + settingsData.value = settingsData.value.copy( + isPinEnabled = true, + isPinForPaymentsEnabled = true, + ) stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.onScanResult(bolt11) diff --git a/changelog.d/next/1158.security.md b/changelog.d/next/1158.security.md index e7711bca4b..177c3e82b5 100644 --- a/changelog.d/next/1158.security.md +++ b/changelog.d/next/1158.security.md @@ -1 +1 @@ -Shop checkout only accepts Bitrefill payment requests, and QuickPay and payment links now honor PIN protection. +Shop checkout only accepts Bitrefill payment requests, and payment links wait until the wallet is unlocked.