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/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 new file mode 100644 index 0000000000..38cb88d0dc --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt @@ -0,0 +1,65 @@ +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. */ +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 + +internal fun isAllowedShopHost(host: String?): Boolean { + val normalized = host?.lowercase()?.trim('.') ?: return false + return normalized == BITREFILL_ROOT_HOST || normalized.endsWith(".$BITREFILL_ROOT_HOST") +} + +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 + return isAllowedShopHost(parsed.host) +} + +private val bitrefillEmbedOrigin = URI(Env.BITREFILL_URL) + +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 + 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 +} + +internal fun shopPaymentOriginRules(): Set = setOf(Env.BITREFILL_URL) + +internal fun shopMessageBridgeScript(): String = """ + 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)); + }); + } +""".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..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 @@ -12,8 +12,13 @@ import to.bitkit.utils.Logger */ class ShopWebViewClient( private val onLoadingStateChanged: (Boolean) -> Unit, - private val onError: () -> Unit + private val onError: () -> Unit, + private val onBlockedNavigation: () -> Unit, + private val isPaymentBridgeSupported: () -> Boolean, ) : WebViewClient() { + private companion object { + const val TAG = "ShopWebViewClient" + } override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) @@ -24,29 +29,18 @@ 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); - } - }; + if (isPaymentBridgeSupported() && isAllowedShopPaymentPage(url)) { + 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) + onBlockedNavigation() + return true } @Suppress("ComplexCondition") @@ -58,7 +52,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..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 @@ -1,70 +1,92 @@ package to.bitkit.ui.screens.shop.shopWebView -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 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. - * - * 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 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 { + const val TAG = "ShopWebViewInterface" + const val JS_OBJECT_NAME = "Android" + const val PAYMENT_INTENT_EVENT = "payment_intent" + } + private val json = Json { ignoreUnknownKeys = true } + private val webMessageListenerSupported by lazy(isWebMessageListenerSupported) - /** - * Handles messages posted from JavaScript. - * This method is called on a background thread - ensure thread safety. - * - * @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") + internal fun supportsPaymentBridge() = webMessageListenerSupported + + fun attachTo(webView: WebView) { + if (!supportsPaymentBridge()) { + Logger.warn("Disabled shop payment bridge because WebMessageListener is unavailable", 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") - } + addWebMessageListener( + webView, + JS_OBJECT_NAME, + shopPaymentOriginRules(), + ) { _, message, sourceOrigin, _, _ -> + onWebMessage(message, sourceOrigin.toString()) + } + } - else -> { - Logger.debug("Unknown event type: ${data.event}", context = "WebView") - } - } - }.onFailure { - Logger.error("Error parsing message: $message", it, context = "WebView") + 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 + } + handlePaymentMessage(data, sourceOrigin) } - /** - * 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 + internal fun handlePaymentMessage(message: String, sourceOrigin: String?) { + if (!isAllowedShopPaymentOrigin(sourceOrigin)) { + Logger.warn("Rejected shop payment_intent from untrusted origin '$sourceOrigin'", context = TAG) + return + } + + 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 + } + onPaymentIntent(uri) + } + 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 77a3f12881..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 @@ -25,23 +25,30 @@ 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, onBack: () -> Unit, onPaymentIntent: (String) -> Unit, + onBlockedNavigation: () -> Unit, page: String, title: String, ) { var isLoading by remember { mutableStateOf(true) } var webView: WebView? by remember { mutableStateOf(null) } - val webViewInterface = remember { ShopWebViewInterface(onPaymentIntent) } + val webViewInterface = remember { + ShopWebViewInterface( + onPaymentIntent = onPaymentIntent, + ) + } val webViewClient = remember { ShopWebViewClient( onLoadingStateChanged = { loading -> isLoading = loading }, - onError = onClose + onError = onClose, + onBlockedNavigation = onBlockedNavigation, + isPaymentBridgeSupported = webViewInterface::supportsPaymentBridge, ) } @@ -54,7 +61,6 @@ fun ShopWebViewScreen( Box(modifier = Modifier.weight(1f)) { AndroidView( - modifier = Modifier.fillMaxSize(), factory = { context -> WebView(context).apply { layoutParams = ViewGroup.LayoutParams( @@ -62,13 +68,14 @@ fun ShopWebViewScreen( ViewGroup.LayoutParams.MATCH_PARENT, ) + webView = this this.webViewClient = webViewClient configureForBasicWebContent() - addJavascriptInterface(webViewInterface, "Android") + webViewInterface.attachTo(this) loadUrl(bitrefillUrlOf(page)) - webView = this } }, + modifier = Modifier.fillMaxSize() ) if (isLoading) { @@ -96,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 6ce64be2ae..e6cecbb97b 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 @@ -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 @@ -96,6 +99,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 @@ -251,10 +255,13 @@ class AppViewModel @Inject constructor( private val _quickPayData = MutableStateFlow(null) val quickPayData = _quickPayData.asStateFlow() - private var activeScanJob: Job? = null + private val scanMutex = Mutex() @Volatile - private var activeScanInput: String? = null + private var scheduledScan: ScheduledScan? = null + + private val deferredScanLock = Any() + private var deferredScan: DeferredScan? = null private val _sendEffect = MutableSharedFlow(extraBufferCapacity = 1) val sendEffect = _sendEffect.asSharedFlow() @@ -278,6 +285,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 @@ -318,6 +326,7 @@ class AppViewModel @Inject constructor( fun setIsAuthenticated(value: Boolean) { _isAuthenticated.value = value + if (value) flushDeferredScan() } val pinAttemptsRemaining = keychain.pinAttemptsRemaining() @@ -379,9 +388,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) flushDeferredScan() } } } @@ -395,10 +407,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) flushDeferredScan() } } } @@ -675,7 +688,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 -> @@ -693,7 +706,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) @@ -1523,7 +1536,7 @@ class AppViewModel @Inject constructor( private 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 -> @@ -1578,31 +1591,150 @@ class AppViewModel @Inject constructor( data: String, startDelay: Duration = Duration.ZERO, routePubkyKeys: Boolean = false, + contactPaymentContext: ContactPaymentContext? = null, + preserveUntilComplete: 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) { + enqueueDeferredScan( + source = source, + data = data, + startDelay = startDelay, + routePubkyKeys = routePubkyKeys, + contactPaymentContext = contactPaymentContext, + ) + return } - if (normalized == activeScanInput && activeScanJob?.isActive == true) { + val normalized = data.removeLightningSchemes() + val scanId = scanLogId(data) + + val scheduled = scheduledScan + val isSameActiveScan = normalized == scheduled?.normalizedInput && + scheduled.job.isActive && + (scheduled.contactPaymentContext == contactPaymentContext || contactPaymentContext == null) + if (isSameActiveScan) { Logger.info("Skipping duplicate scan from '${source.label}': '$scanId'", context = TAG) return } - activeScanJob?.let { + if (scheduled?.job?.isActive == true && scheduled.mustComplete) { + enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext) + return + } + + val previousJob = scheduled?.job + val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { + scanMutex.withLock { + setActiveContactPaymentContext(contactPaymentContext) + if (startDelay > Duration.ZERO) delay(startDelay) + handleScan(data, routePubkyKeys) + } + } + 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) + nextJob.start() + previousJob?.let { Logger.info("Cancelling prior scan for new '${source.label}': '$scanId'", context = TAG) it.cancel() } + } - activeScanInput = normalized - Logger.debug("Starting scan from '${source.label}': '$scanId'", context = TAG) - activeScanJob = viewModelScope.launch { - if (startDelay > Duration.ZERO) delay(startDelay) - handleScan(data, routePubkyKeys) - }.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 > SCAN_LOG_ID_MAX_LENGTH) { + "${scanLogInput.take(SCAN_LOG_ID_AFFIX_LENGTH)}…${scanLogInput.takeLast(SCAN_LOG_ID_AFFIX_LENGTH)}" + } else { + scanLogInput + } + } + + private fun enqueueDeferredScan( + source: ScanSource, + data: String, + startDelay: Duration, + routePubkyKeys: Boolean, + contactPaymentContext: ContactPaymentContext?, + ) { + val scanId = scanLogId(data) + val normalized = data.removeLightningSchemes() + synchronized(deferredScanLock) { + val queued = deferredScan + if (queued?.data?.removeLightningSchemes() == normalized) { + if (contactPaymentContext != null) { + deferredScan = DeferredScan( + source = source, + data = data, + startDelay = startDelay, + routePubkyKeys = routePubkyKeys, + contactPaymentContext = contactPaymentContext, + ) + return + } + Logger.info("Skipping duplicate queued scan from '${source.label}': '$scanId'", context = TAG) + return + } + if (queued != null) { + Logger.warn( + "Replacing deferred scan from '${queued.source.label}': '${scanLogId(queued.data)}'", + context = TAG, + ) + } + 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 isScanPendingOrActive(): Boolean { + 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 (scheduledScan?.job?.isActive == true) return + if (sheetTransitionJob?.isActive == true) return + if (_currentSheet.value != null) return + + val pending = synchronized(deferredScanLock) { + deferredScan.also { deferredScan = null } + } ?: run { + viewModelScope.launch { presentNextIncomingPaykitPaymentRequest() } + return + } + + launchScan( + source = pending.source, + data = pending.data, + startDelay = pending.startDelay, + routePubkyKeys = pending.routePubkyKeys, + contactPaymentContext = pending.contactPaymentContext, + preserveUntilComplete = true, + ) } private fun onAddressContinue(data: String) { @@ -1827,12 +1959,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, ) } @@ -1842,14 +1976,12 @@ class AppViewModel @Inject constructor( privatePaymentContext: PrivatePaykitPaymentContext? = null, incomingPaymentRequest: PaykitPaymentRequest? = null, ) { - synchronized(contactPaymentContextLock) { - activeContactPaymentContext = ContactPaymentContext( - publicKey = publicKey, - privatePaymentContext = privatePaymentContext, - incomingPaymentRequest = incomingPaymentRequest, - ) - } - onScanResult(paymentRequest) + val context = ContactPaymentContext( + publicKey = publicKey, + privatePaymentContext = privatePaymentContext, + incomingPaymentRequest = incomingPaymentRequest, + ) + onScanResult(paymentRequest, contactPaymentContext = context) } fun preserveContactPaymentContext(paymentHash: String) { @@ -1941,7 +2073,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() @@ -2021,6 +2153,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) @@ -2462,9 +2603,7 @@ class AppViewModel @Inject constructor( if (hasActiveContactPaymentContext()) return false val settings = settingsStore.data.first() - if (!settings.isQuickPayEnabled || amountSats == 0uL) { - return false - } + if (!settings.isQuickPayEnabled || amountSats == 0uL) return false val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() ?: return false @@ -3140,11 +3279,12 @@ class AppViewModel @Inject constructor( val handler = scanResultHandler val shouldHandleAsProtocol = SamRockSetupRequest.isProtocolUrl(data.removeLightningSchemes()) scanResultHandler = null - hideSheet() + hideSheet(shouldFlushDeferredScan = false) if (handler != null && !shouldHandleAsProtocol) { viewModelScope.launch { delay(SCREEN_TRANSITION_DELAY) handler(data) + flushDeferredScan() } } else { launchScan( @@ -3209,7 +3349,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 } @@ -3224,11 +3365,22 @@ class AppViewModel @Inject constructor( } _currentSheet.update { sheetType } } + sheetTransitionJob = nextJob + nextJob.invokeOnCompletion { + if (sheetTransitionJob === nextJob) sheetTransitionJob = null + } + previousJob?.cancel() + nextJob.start() } - fun hideSheet() { + fun hideSheet() = hideSheet(shouldFlushDeferredScan = true) + + private fun hideSheet(shouldFlushDeferredScan: Boolean) { scanResultHandler = null receiveSheetContext = null + sheetTransitionJob?.cancel() + sheetTransitionJob = null + clearActiveContactPaymentContext() when { currentSheet.value is Sheet.TimedSheet -> { // Only dismiss if manager still has a sheet (user initiated) @@ -3242,8 +3394,8 @@ class AppViewModel @Inject constructor( else -> _currentSheet.update { null } } - clearActiveContactPaymentContext() showQueuedPairingCodeSheet() + if (shouldFlushDeferredScan) flushDeferredScan() } // endregion @@ -3305,6 +3457,7 @@ class AppViewModel @Inject constructor( val settings = settingsStore.data.first() val needsAuth = settings.isPinEnabled _isAuthenticated.value = !needsAuth + if (!needsAuth) flushDeferredScan() } fun resetIsAuthenticatedState() { @@ -3691,14 +3844,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"), - } - companion object { private const val TAG = "AppViewModel" private val LIGHTNING_SCHEME_PATTERNS = listOf("lightning", "lnurl", "lnurlw", "lnurlc", "lnurlp") @@ -3728,6 +3873,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. */ @@ -3735,6 +3887,29 @@ 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 job: Job, + 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/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 new file mode 100644 index 0000000000..0ad40a9994 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopOriginTest.kt @@ -0,0 +1,69 @@ +package to.bitkit.ui.screens.shop.shopWebView + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ShopOriginTest { + + @Test + fun `bridge script accepts only the Bitrefill embed origin`() { + 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) + } + + @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 `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 + 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)) + assertFalse(isAllowedShopOrigin("")) + assertFalse(isAllowedShopOrigin("embed.bitrefill.com")) + 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..913a4c7536 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewClientTest.kt @@ -0,0 +1,105 @@ +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 +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 = {}, + onBlockedNavigation = {}, + isPaymentBridgeSupported = { true }, + ) + + @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 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`() { + 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 + fun `subframe requests are not blocked`() { + val request = request(url = "https://cdn.example/script.js", isForMainFrame = false) + + 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 = {}, + onBlockedNavigation = {}, + 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) + } + + @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) + 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..bf5689172f --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterfaceTest.kt @@ -0,0 +1,189 @@ +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) +class ShopWebViewInterfaceTest : BaseUnitTest() { + + @Test + fun `payment_intent from an allowed origin is forwarded`() { + var received: String? = null + val sut = interfaceOf( + onPaymentIntent = { received = it }, + ) + + sut.onWebMessage( + WebMessageCompat("""{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}"""), + "https://embed.bitrefill.com", + ) + + assertEquals("lightning:lnbcrt1shop", received) + } + + @Test + fun `payment_intent from a disallowed origin is ignored`() { + var received: String? = null + val sut = interfaceOf( + onPaymentIntent = { received = it }, + ) + + sut.onWebMessage( + WebMessageCompat("""{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}"""), + "https://evil.example", + ) + + assertNull(received) + } + + @Test + fun `payment_intent with a blank URI is ignored`() { + var received: String? = null + val sut = interfaceOf( + onPaymentIntent = { received = it }, + ) + + sut.onWebMessage( + WebMessageCompat("""{"event":"payment_intent","paymentUri":" "}"""), + "https://embed.bitrefill.com", + ) + + assertNull(received) + } + + @Test + fun `unknown events are ignored`() { + var received: String? = null + val sut = interfaceOf( + onPaymentIntent = { received = it }, + ) + + sut.onWebMessage( + WebMessageCompat("""{"event":"invoice","paymentUri":"lightning:lnbcrt1shop"}"""), + "https://embed.bitrefill.com", + ) + + 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 + val sut = interfaceOf( + onPaymentIntent = { received = it }, + ) + + sut.onWebMessage( + WebMessageCompat("""{"event":"payment_intent","paymentUri":"lightning:lnbcrt1shop"}"""), + "https://checkout.bitrefill.com", + ) + + assertNull(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 `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( + onPaymentIntent = { received = it }, + addWebMessageListener = { registeredWebView, jsObjectName, allowedOriginRules, listener -> + assertSame(webView, registeredWebView) + registeredObjectName = jsObjectName + registeredOriginRules = allowedOriginRules + registeredListener = listener + }, + ) + + 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(shopPaymentOriginRules(), 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( + onPaymentIntent: (String) -> Unit, + isWebMessageListenerSupported: () -> Boolean = { true }, + addWebMessageListener: ( + WebView, + String, + Set, + WebViewCompat.WebMessageListener, + ) -> Unit = { _, _, _, _ -> }, + ) = ShopWebViewInterface( + onPaymentIntent = onPaymentIntent, + 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 181f882999..afb29020ea 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -16,14 +16,19 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +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 org.junit.After import org.junit.Before import org.junit.Test @@ -102,9 +107,11 @@ 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 +import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.usecases.FormatMoneyValue import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError @@ -177,9 +184,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 +222,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) @@ -358,7 +367,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() @@ -386,11 +395,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( @@ -407,29 +417,28 @@ 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) } @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 @@ -452,7 +461,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() @@ -464,28 +473,58 @@ 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" + 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)) + + sut.onHomeResumed() + sut.currentSheet.first { + it is Sheet.Send && activeContactPaymentContext()?.incomingPaymentRequest == firstRequest + } + + sut.setIsAuthenticated(false) + sut.hideSheet() + runCurrent() + + assertNull(sut.currentSheet.value) + verify(privatePaykitRepo, never()).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 { + 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.startPaykitPaymentRequestPolling() - advanceTimeBy(30.seconds.inWholeMilliseconds) + sut.onHomeResumed() runCurrent() - sut.stopPaykitPaymentRequestPolling() verify(privatePaykitRepo).beginPaymentRequest(request) assertNull(sut.currentSheet.value) @@ -496,7 +535,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", @@ -506,7 +545,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) @@ -518,26 +557,363 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `unavailable request does not starve a later payable request`() = test { - val unavailableRequest = paymentRequest() - val payableRequest = unavailableRequest.copy(paymentRequestId = "payable-request") - val bolt11 = "lnbcrt1payablerequest" - whenever { privatePaykitRepo.beginPaymentRequest(unavailableRequest) } - .thenReturn(Result.success(PublicPaykitPaymentResult.NoEndpoint)) - whenever { privatePaykitRepo.beginPaymentRequest(payableRequest) }.thenReturn( + 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 = bolt11, + 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() + stubOpenedPaymentRequest(request, requestInvoice) + 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) + firstDecodeStarted.await() + sut.onScanResult(intermediateInvoice) + cleanupStarted.await() + sut.onScanResult(latestInvoice) + + assertEquals(0, intermediateDecodeCount) + assertEquals(0, latestDecodeCount) + + advanceTimeBy(1.seconds.inWholeMilliseconds) + 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 `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)) + } + stubOpenedPaymentRequest(request, requestInvoice) + 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) + stubOpenedPaymentRequest(request, requestInvoice) + 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() + val payableRequest = unavailableRequest.copy(paymentRequestId = "payable-request") + val bolt11 = "lnbcrt1payablerequest" + whenever(privatePaykitRepo.beginPaymentRequest(unavailableRequest)) + .thenReturn(Result.success(PublicPaykitPaymentResult.NoEndpoint)) + 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) @@ -553,11 +929,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) @@ -1460,7 +1836,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() @@ -1522,7 +1898,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 } @@ -1552,7 +1928,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 } @@ -1582,7 +1958,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( @@ -1689,6 +2065,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" @@ -1728,6 +2149,317 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } + @Test + fun `lightning scan uses 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() + + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) + } + + @Test + fun `QuickPay eligible scan remains deferred until authenticated`() = test { + val bolt11 = "lnbcrt1lockedscan" + enableQuickPay(thresholdSats = 1_000u) + settingsData.value = settingsData.value.copy( + isPinEnabled = true, + isPinForPaymentsEnabled = 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(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) + verify(coreService).decode(bolt11) + } + + @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 `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.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(600u, sut.sendUiState.value.amount) + assertNull(activeContactPaymentContext()) + verify(coreService, never()).decode(first) + verify(coreService).decode(second) + + sut.hideSheet() + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + } + + @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 `locked contact scan restores its payment context`() = test { + val contact = "lnbcrt1lockedcontact" + settingsData.value = SettingsData(isPinEnabled = true) + stubLightningScan(bolt11 = contact, amountSats = 600u) + + sut.openContactPayment(paymentRequest = contact, publicKey = "pubkycontact") + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(600u, sut.sendUiState.value.amount) + 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 `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" + 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 `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" @@ -1745,21 +2477,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() @@ -1784,15 +2508,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() @@ -1821,8 +2538,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( @@ -1920,7 +2637,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( @@ -1943,8 +2660,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( @@ -1981,7 +2698,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( @@ -2156,7 +2873,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( @@ -2192,7 +2909,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( @@ -2218,7 +2935,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( @@ -2410,7 +3127,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() @@ -2428,7 +3145,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() @@ -2463,10 +3180,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), @@ -2480,7 +3215,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) diff --git a/changelog.d/next/1158.security.md b/changelog.d/next/1158.security.md new file mode 100644 index 0000000000..177c3e82b5 --- /dev/null +++ b/changelog.d/next/1158.security.md @@ -0,0 +1 @@ +Shop checkout only accepts Bitrefill payment requests, and payment links wait until the wallet is unlocked. 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" }