diff --git a/app/src/main/java/to/bitkit/ui/Locals.kt b/app/src/main/java/to/bitkit/ui/Locals.kt index 3ed862eab5..2c02e1bc1c 100644 --- a/app/src/main/java/to/bitkit/ui/Locals.kt +++ b/app/src/main/java/to/bitkit/ui/Locals.kt @@ -21,6 +21,9 @@ val LocalBalances = compositionLocalOf { BalanceState() } val LocalCurrencies = compositionLocalOf { CurrencyState() } val LocalIs24HourFormat = compositionLocalOf { false } +/** True while the PIN screen covers the wallet; dialogs owning their own window must not render. */ +val LocalIsAppLocked = compositionLocalOf { false } + // Statics val LocalDrawerState = staticCompositionLocalOf { null } val LocalBottomSheetOverlayState = staticCompositionLocalOf { null } diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 41d3bd79df..458b947eba 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -9,15 +9,18 @@ import android.os.Looper import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn +import androidx.compose.animation.EnterTransition import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.core.content.IntentCompat @@ -129,6 +132,9 @@ class MainActivity : FragmentActivity() { val hazeState = rememberHazeState(blurEnabled = true) val bottomSheetOverlayState = remember { BottomSheetOverlayState() } val authSheetOverlayState = remember { BottomSheetOverlayState() } + val isAuthenticated by appViewModel.isAuthenticated.collectAsState() + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current LaunchedEffect( walletExists, @@ -155,26 +161,32 @@ class MainActivity : FragmentActivity() { walletViewModel = walletViewModel, ) } else { - val isAuthenticated by appViewModel.isAuthenticated.collectAsStateWithLifecycle() - IsOnlineTracker(appViewModel) - ContentView( - appViewModel = appViewModel, - walletViewModel = walletViewModel, - blocktankViewModel = blocktankViewModel, - currencyViewModel = currencyViewModel, - activityListViewModel = activityListViewModel, - transferViewModel = transferViewModel, - settingsViewModel = settingsViewModel, - backupsViewModel = backupsViewModel, - hazeState = hazeState, - bottomSheetOverlayState = bottomSheetOverlayState, - modifier = Modifier.hazeSource(hazeState, zIndex = 0f), - ) + LaunchedEffect(isAuthenticated) { + if (!isAuthenticated) { + focusManager.clearFocus(force = true) + keyboardController?.hide() + } + } + CompositionLocalProvider(LocalIsAppLocked provides !isAuthenticated) { + ContentView( + appViewModel = appViewModel, + walletViewModel = walletViewModel, + blocktankViewModel = blocktankViewModel, + currencyViewModel = currencyViewModel, + activityListViewModel = activityListViewModel, + transferViewModel = transferViewModel, + settingsViewModel = settingsViewModel, + backupsViewModel = backupsViewModel, + hazeState = hazeState, + bottomSheetOverlayState = bottomSheetOverlayState, + modifier = Modifier.hazeSource(hazeState, zIndex = 0f), + ) + } AnimatedVisibility( visible = !isAuthenticated, - enter = fadeIn(), + enter = EnterTransition.None, exit = fadeOut(), ) { AuthCheckView( @@ -208,7 +220,7 @@ class MainActivity : FragmentActivity() { } val transactionSheetDetails by appViewModel.transactionSheet.collectAsStateWithLifecycle() - if (transactionSheetDetails != NewTransactionSheetDetails.EMPTY) { + if (isAuthenticated && transactionSheetDetails != NewTransactionSheetDetails.EMPTY) { NewTransactionSheet( appViewModel = appViewModel, bottomSheetOverlayState = bottomSheetOverlayState, @@ -299,6 +311,11 @@ class MainActivity : FragmentActivity() { intent.launchKey()?.let { outState.putString(KEY_CONSUMED_LAUNCH_INTENT, it) } } + override fun onStop() { + super.onStop() + if (!isChangingConfigurations) appViewModel.lockOnBackground() + } + override fun onDestroy() { super.onDestroy() if (!settingsViewModel.notificationsGranted.value) { diff --git a/app/src/main/java/to/bitkit/ui/scaffold/AppAlertDialog.kt b/app/src/main/java/to/bitkit/ui/scaffold/AppAlertDialog.kt index 979eb6fca1..d27a48827f 100644 --- a/app/src/main/java/to/bitkit/ui/scaffold/AppAlertDialog.kt +++ b/app/src/main/java/to/bitkit/ui/scaffold/AppAlertDialog.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.window.DialogProperties import to.bitkit.R +import to.bitkit.ui.LocalIsAppLocked import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.Title @@ -71,6 +72,7 @@ fun AppAlertDialog( ), textContent: @Composable () -> Unit, ) { + if (LocalIsAppLocked.current) return AlertDialog( onDismissRequest = onDismissRequest, confirmButton = { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 914896aca9..b69318b8ef 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -330,6 +330,11 @@ class AppViewModel @Inject constructor( private val _isAuthenticated = MutableStateFlow(false) val isAuthenticated = _isAuthenticated.asStateFlow() + /** Cached so the app can be locked synchronously when the activity stops. */ + private val isPinEnabled = settingsStore.data + .map { it.isPinEnabled } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + private val _pendingScreenDeepLink = MutableStateFlow(null) val pendingScreenDeepLink = _pendingScreenDeepLink.asStateFlow() @@ -403,7 +408,28 @@ class AppViewModel @Inject constructor( fun setIsAuthenticated(value: Boolean) { _isAuthenticated.value = value - if (value) flushDeferredScan() + if (!value) return + + markDeferredScanUnlocked() + + val sheet = _currentSheet.value + if (sheet != null && hasDeferredScan() && canCloseSheetForDeferredScan(sheet)) { + Logger.info("Closing '${sheet::class.simpleName}' to flush scan deferred while locked", context = TAG) + hideSheet() + return + } + + flushDeferredScan() + } + + private fun canCloseSheetForDeferredScan(sheet: Sheet) = !isHighPrioritySheet(sheet) && sheet !is Sheet.QrScanner + + private fun markDeferredScanUnlocked() { + synchronized(deferredScanLock) { + val queued = deferredScan ?: return + if (queued.suppressQuickPay) return + deferredScan = queued.copy(suppressQuickPay = true) + } } val pinAttemptsRemaining = keychain.pinAttemptsRemaining() @@ -2214,16 +2240,19 @@ class AppViewModel @Inject constructor( contactPaymentContext: ContactPaymentContext? = null, preserveUntilComplete: Boolean = false, allowPubkyAuth: Boolean = isMainScanner, + suppressQuickPay: Boolean = false, ): Job? { + val deferrable = DeferredScan( + source = source, + data = data, + startDelay = startDelay, + routePubkyKeys = routePubkyKeys, + contactPaymentContext = contactPaymentContext, + allowPubkyAuth = allowPubkyAuth, + suppressQuickPay = suppressQuickPay, + ) if (!_isAuthenticated.value) { - enqueueDeferredScan( - source = source, - data = data, - startDelay = startDelay, - routePubkyKeys = routePubkyKeys, - contactPaymentContext = contactPaymentContext, - allowPubkyAuth = allowPubkyAuth, - ) + enqueueDeferredScan(deferrable) return null } @@ -2239,7 +2268,7 @@ class AppViewModel @Inject constructor( } if (scheduled?.job?.isActive == true && scheduled.mustComplete) { - enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext, allowPubkyAuth) + enqueueDeferredScan(deferrable) return null } @@ -2247,12 +2276,10 @@ class AppViewModel @Inject constructor( val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { scanMutex.withLock { if (!awaitPubkyDeeplinkInitialization(source, data, allowPubkyAuth)) return@withLock - if (deferLockedScan(source, data, startDelay, routePubkyKeys, contactPaymentContext, allowPubkyAuth)) { - return@withLock - } + if (deferLockedScan(deferrable)) return@withLock prepareContactPaymentContextForScan(normalized, allowPubkyAuth, contactPaymentContext) if (startDelay > Duration.ZERO) delay(startDelay) - handleScan(data, routePubkyKeys, contactPaymentContext, allowPubkyAuth) + handleScan(data, routePubkyKeys, contactPaymentContext, allowPubkyAuth, suppressQuickPay) } } val nextScheduledScan = ScheduledScan( @@ -2307,22 +2334,16 @@ class AppViewModel @Inject constructor( private suspend fun isPaykitUiEnabledFromSettings() = PaykitFeatureFlags.isUiEnabled(settingsStore.isPaykitEnabled.first()) - private fun deferLockedScan( - source: ScanSource, - data: String, - startDelay: Duration, - routePubkyKeys: Boolean, - contactPaymentContext: ContactPaymentContext?, - allowPubkyAuth: Boolean, - ): Boolean { + private fun deferLockedScan(scan: DeferredScan): Boolean { if (_isAuthenticated.value) return false synchronized(deferredScanLock) { if (deferredScan == null) { - enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext, allowPubkyAuth) + enqueueDeferredScan(scan) } else { Logger.info( - "Skipping '${source.label}' scan because another deferred scan is queued: '${scanLogId(data)}'", + "Skipping '${scan.source.label}' scan because another deferred scan is queued: " + + "'${scanLogId(scan.data)}'", context = TAG, ) } @@ -2340,31 +2361,19 @@ class AppViewModel @Inject constructor( } } - private fun enqueueDeferredScan( - source: ScanSource, - data: String, - startDelay: Duration, - routePubkyKeys: Boolean, - contactPaymentContext: ContactPaymentContext?, - allowPubkyAuth: Boolean, - ) { - val scanId = scanLogId(data, contactPaymentContext) - val normalized = data.removeLightningSchemes() + private fun enqueueDeferredScan(scan: DeferredScan) { + val scanId = scanLogId(scan.data, scan.contactPaymentContext) + val normalized = scan.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, - allowPubkyAuth = allowPubkyAuth, - ) + val suppressQuickPay = scan.suppressQuickPay || queued.suppressQuickPay + if (scan.contactPaymentContext != null) { + deferredScan = scan.copy(suppressQuickPay = suppressQuickPay) return } - Logger.info("Skipping duplicate queued scan from '${source.label}': '$scanId'", context = TAG) + deferredScan = queued.copy(suppressQuickPay = suppressQuickPay) + Logger.info("Skipping duplicate queued scan from '${scan.source.label}': '$scanId'", context = TAG) return } if (queued != null) { @@ -2374,21 +2383,16 @@ class AppViewModel @Inject constructor( context = TAG, ) } - deferredScan = DeferredScan( - source = source, - data = data, - startDelay = startDelay, - routePubkyKeys = routePubkyKeys, - contactPaymentContext = contactPaymentContext, - allowPubkyAuth = allowPubkyAuth, - ) + deferredScan = scan } - Logger.info("Queuing '${source.label}' scan for deferred handling: '$scanId'", context = TAG) + Logger.info("Queuing '${scan.source.label}' scan for deferred handling: '$scanId'", context = TAG) } + private fun hasDeferredScan() = synchronized(deferredScanLock) { deferredScan != null } + private fun isScanPendingOrActive(): Boolean { if (scheduledScan?.job?.isActive == true) return true - return synchronized(deferredScanLock) { deferredScan != null } + return hasDeferredScan() } private fun isPaymentRequestPresentationBlocked() = isPaymentRequestIdentityActivating || @@ -2420,6 +2424,7 @@ class AppViewModel @Inject constructor( contactPaymentContext = pending.contactPaymentContext, preserveUntilComplete = true, allowPubkyAuth = pending.allowPubkyAuth, + suppressQuickPay = pending.suppressQuickPay, ) } @@ -2822,6 +2827,7 @@ class AppViewModel @Inject constructor( routePubkyKeys: Boolean, contactPaymentContext: ContactPaymentContext?, allowPubkyAuth: Boolean, + suppressQuickPay: Boolean, ) = withContext(bgDispatcher) { if (rejectPubkyAuthScan(result, allowPubkyAuth, contactPaymentContext)) return@withContext @@ -2919,7 +2925,7 @@ class AppViewModel @Inject constructor( .onSuccess { logDecodedScan(it, isPaymentRequest) } .getOrNull() - handleDecodedScan(scan, input, fromMainScanner) + handleDecodedScan(scan, input, fromMainScanner, suppressQuickPay) } private fun logDecodedScan(scan: Scanner, isPaymentRequest: Boolean) { @@ -2935,6 +2941,7 @@ class AppViewModel @Inject constructor( scan: Scanner?, input: String, fromMainScanner: Boolean, + suppressQuickPay: Boolean, ) { if (activeHardwareWalletId != null && scan != null && scan !is Scanner.OnChain) { if (activeIncomingPaymentRequest() != null) { @@ -2955,9 +2962,9 @@ class AppViewModel @Inject constructor( } when (scan) { - is Scanner.OnChain -> onScanOnchain(scan.invoice, input, fromMainScanner) - is Scanner.Lightning -> onScanLightning(scan.invoice, input, fromMainScanner) - is Scanner.LnurlPay -> onScanLnurlPay(scan.data, fromMainScanner) + is Scanner.OnChain -> onScanOnchain(scan.invoice, input, fromMainScanner, suppressQuickPay) + is Scanner.Lightning -> onScanLightning(scan.invoice, input, fromMainScanner, suppressQuickPay) + is Scanner.LnurlPay -> onScanLnurlPay(scan.data, fromMainScanner, suppressQuickPay) is Scanner.LnurlWithdraw -> handleNonPaymentScan { onScanLnurlWithdraw(scan.data, fromMainScanner) } is Scanner.LnurlAuth -> handleNonPaymentScan { onScanLnurlAuth(scan.data, fromMainScanner) } is Scanner.LnurlChannel -> handleNonPaymentScan { onScanLnurlChannel(scan.data) } @@ -3160,6 +3167,7 @@ class AppViewModel @Inject constructor( invoice: OnChainInvoice, scanResult: String, fromMainScanner: Boolean, + suppressQuickPay: Boolean, ) { val validatedAddress = runCatching { coreService.validateBitcoinAddress(invoice.address) } .getOrElse { @@ -3284,6 +3292,7 @@ class AppViewModel @Inject constructor( amountSats = lnAmountSats, invoice = lnInvoice, fromMainScanner = fromMainScanner, + suppressQuickPay = suppressQuickPay, ) if (quickPayHandled) return @@ -3398,6 +3407,7 @@ class AppViewModel @Inject constructor( invoice: LightningInvoice, scanResult: String, fromMainScanner: Boolean, + suppressQuickPay: Boolean, ) { if (invoice.isExpired) { if (clearIncomingPaymentRequestTarget()) return @@ -3421,6 +3431,7 @@ class AppViewModel @Inject constructor( amountSats = amount, invoice = invoice, fromMainScanner = fromMainScanner, + suppressQuickPay = suppressQuickPay, ) if (quickPayHandled) return @@ -3462,7 +3473,7 @@ class AppViewModel @Inject constructor( navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) } - private suspend fun onScanLnurlPay(data: LnurlPayData, fromMainScanner: Boolean) { + private suspend fun onScanLnurlPay(data: LnurlPayData, fromMainScanner: Boolean, suppressQuickPay: Boolean) { Logger.debug("LNURL: $data", context = TAG) val isFixed = data.isFixedAmount() @@ -3512,6 +3523,7 @@ class AppViewModel @Inject constructor( amountSats = initialAmount, lnurlPay = data, fromMainScanner = fromMainScanner, + suppressQuickPay = suppressQuickPay, ) if (quickPayHandled) return @@ -3653,9 +3665,14 @@ class AppViewModel @Inject constructor( private suspend fun handleQuickPayIfApplicable( amountSats: ULong, fromMainScanner: Boolean, + suppressQuickPay: Boolean, lnurlPay: LnurlPayData? = null, invoice: LightningInvoice? = null, ): Boolean { + if (suppressQuickPay) { + Logger.info("Skipping QuickPay for '$amountSats' sats deferred across the lock", context = TAG) + return false + } if (hasActiveContactPaymentContext()) return false val invoiceHash = invoice?.paymentHash?.toHex()?.takeIf { it.isNotBlank() } val open = invoiceHash != null && quickPayRepo.hasOpen(invoiceHash) @@ -4839,6 +4856,19 @@ class AppViewModel @Inject constructor( } } + /** + * Requires the PIN again after the app process moves to the background. + * + * Runs without suspending so the lock is applied before the activity can resume. + */ + fun lockOnBackground() { + if (!isPinEnabled.value) return + if (!walletRepo.walletExists()) return + if (lightningRepo.isRecoveryMode.value) return + _isAuthenticated.update { false } + Logger.debug("Locked app on background", context = TAG) + } + fun validatePin(pin: String): Boolean { val storedPin = keychain.loadString(Keychain.Key.PIN.name) val isValid = storedPin == pin @@ -4904,6 +4934,7 @@ class AppViewModel @Inject constructor( } private fun onConfirmAmountWarning(warning: SanityWarning) { + if (!_isAuthenticated.value) return viewModelScope.launch { _sendUiState.update { it.copy( @@ -5675,6 +5706,7 @@ private data class DeferredScan( val routePubkyKeys: Boolean, val contactPaymentContext: ContactPaymentContext?, val allowPubkyAuth: Boolean, + val suppressQuickPay: Boolean, ) // region send contract diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 6ebf7254d0..9df3146d72 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -240,6 +240,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val needsPairingCode = MutableStateFlow(false) private val pairingCodeRequestId = MutableStateFlow(null) private val settingsData = MutableStateFlow(SettingsData()) + private val isRecoveryMode = MutableStateFlow(false) private val isPaykitEnabled = MutableStateFlow(false) private val walletState = MutableStateFlow(WalletState()) private val nodeEventUpdates = MutableSharedFlow() @@ -312,6 +313,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(connectivityRepo.isOnline).thenReturn(connectivityState) whenever(healthRepo.healthState).thenReturn(MutableStateFlow(mock())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + whenever(lightningRepo.isRecoveryMode).thenReturn(isRecoveryMode) whenever(lightningRepo.nodeEventUpdates).thenReturn(nodeEventUpdates) whenever(lightningRepo.nodeEvents).thenReturn(nodeEventUpdates.map { it.event }) whenever(hwWalletRepo.receivedTxs).thenReturn(hwReceivedTxs) @@ -3272,6 +3274,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u, ) + sut.setIsAuthenticated(true) setUnifiedState(amount = 1000u, payMethod = SendMethod.LIGHTNING) sut.setSendEvent(SendEvent.ConfirmAmountWarning(SanityWarning.VALUE_OVER_100_USD)) advanceUntilIdle() @@ -4676,7 +4679,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `QuickPay eligible scan remains deferred until authenticated`() = test { + fun `QuickPay eligible scan remains deferred and confirms after authenticating`() = test { val bolt11 = "lnbcrt1lockedscan" enableQuickPay() settingsData.value = settingsData.value.copy( @@ -4695,8 +4698,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setIsAuthenticated(true) advanceUntilIdle() - assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value?.data) - assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) + assertNull(sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) verify(coreService).decode(bolt11) } @@ -4717,6 +4720,185 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) } + @Test + fun `lockOnBackground requires auth when PIN is enabled`() = test { + settingsData.value = SettingsData(isPinEnabled = true) + advanceUntilIdle() + sut.setIsAuthenticated(true) + + sut.lockOnBackground() + advanceUntilIdle() + + assertFalse(sut.isAuthenticated.value) + } + + @Test + fun `lockOnBackground requires auth without waiting for a suspension`() = test { + settingsData.value = SettingsData(isPinEnabled = true) + advanceUntilIdle() + sut.setIsAuthenticated(true) + + sut.lockOnBackground() + + assertFalse(sut.isAuthenticated.value) + } + + @Test + fun `lockOnBackground keeps auth when PIN is disabled`() = test { + settingsData.value = SettingsData(isPinEnabled = false) + advanceUntilIdle() + assertTrue(sut.isAuthenticated.value) + + sut.lockOnBackground() + advanceUntilIdle() + + assertTrue(sut.isAuthenticated.value) + } + + @Test + fun `lockOnBackground keeps auth when no wallet exists`() = test { + settingsData.value = SettingsData(isPinEnabled = true) + whenever(walletRepo.walletExists()).thenReturn(false) + advanceUntilIdle() + sut.setIsAuthenticated(true) + + sut.lockOnBackground() + advanceUntilIdle() + + assertTrue(sut.isAuthenticated.value) + } + + @Test + fun `lockOnBackground keeps auth in recovery mode`() = test { + settingsData.value = SettingsData(isPinEnabled = true) + isRecoveryMode.value = true + advanceUntilIdle() + sut.setIsAuthenticated(true) + + sut.lockOnBackground() + advanceUntilIdle() + + assertTrue(sut.isAuthenticated.value) + } + + @Test + fun `payment deeplink received after background lock flushes after unlock`() = test { + val bolt11 = "lnbcrt1backgroundlockdeeplink" + settingsData.value = SettingsData(isPinEnabled = true) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + advanceUntilIdle() + sut.setIsAuthenticated(true) + sut.lockOnBackground() + advanceUntilIdle() + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "lightning:$bolt11".toUri())) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(coreService, never()).decode(bolt11) + + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + verify(coreService).decode(bolt11) + } + + @Test + fun `amount warning confirmation is ignored after background lock`() = test { + settingsData.value = SettingsData(isPinEnabled = true) + advanceUntilIdle() + sut.setIsAuthenticated(true) + setUnifiedState(amount = 1000u) + sut.lockOnBackground() + advanceUntilIdle() + + sut.setSendEvent(SendEvent.ConfirmAmountWarning(SanityWarning.VALUE_OVER_100_USD)) + advanceUntilIdle() + + assertTrue(sut.sendUiState.value.confirmedWarnings.isEmpty()) + assertFalse(sut.sendUiState.value.shouldConfirmPay) + + sut.setIsAuthenticated(true) + sut.setSendEvent(SendEvent.ConfirmAmountWarning(SanityWarning.VALUE_OVER_100_USD)) + advanceUntilIdle() + + assertEquals(listOf(SanityWarning.VALUE_OVER_100_USD), sut.sendUiState.value.confirmedWarnings) + } + + @Test + fun `payment deeplink received with a sheet open closes it on unlock`() = test { + val bolt11 = "lnbcrt1backgroundlocksheet" + settingsData.value = SettingsData(isPinEnabled = true) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + advanceUntilIdle() + sut.setIsAuthenticated(true) + sut.showSheet(Sheet.Receive()) + advanceUntilIdle() + assertTrue(sut.currentSheet.value is Sheet.Receive) + + sut.lockOnBackground() + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "lightning:$bolt11".toUri())) + advanceUntilIdle() + + assertTrue(sut.currentSheet.value is Sheet.Receive) + verify(coreService, never()).decode(bolt11) + + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + verify(coreService).decode(bolt11) + } + + @Test + fun `payment deeplink received with a high priority sheet open keeps it on unlock`() = test { + val bolt11 = "lnbcrt1backgroundlockpriority" + settingsData.value = SettingsData(isPinEnabled = true) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + advanceUntilIdle() + sut.setIsAuthenticated(true) + sut.showSheet(Sheet.Pin()) + advanceUntilIdle() + + sut.lockOnBackground() + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "lightning:$bolt11".toUri())) + advanceUntilIdle() + + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(Sheet.Pin(), sut.currentSheet.value) + verify(coreService, never()).decode(bolt11) + + sut.hideSheet() + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + verify(coreService).decode(bolt11) + } + + @Test + fun `payment deeplink deferred by the lock skips QuickPay on unlock`() = test { + val bolt11 = "lnbcrt1backgroundlockquickpay" + enableQuickPay() + settingsData.value = settingsData.value.copy(isPinEnabled = true) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + advanceUntilIdle() + sut.setIsAuthenticated(true) + sut.lockOnBackground() + advanceUntilIdle() + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "lightning:$bolt11".toUri())) + advanceUntilIdle() + + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertNull(sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + @Test fun `latest locked scan replaces earlier input`() = test { val first = "lnbcrt1lockedfirst" @@ -6556,6 +6738,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `amount change clears confirmedWarnings`() = test { + sut.setIsAuthenticated(true) setUnifiedState(amount = 1000u) sut.setSendEvent(SendEvent.ConfirmAmountWarning(SanityWarning.VALUE_OVER_100_USD)) advanceUntilIdle() diff --git a/changelog.d/next/724.changed.md b/changelog.d/next/724.changed.md new file mode 100644 index 0000000000..148ab0594d --- /dev/null +++ b/changelog.d/next/724.changed.md @@ -0,0 +1 @@ +Bitkit now asks for your PIN again whenever you return to the app from the background, and a payment link opened while it was locked now takes you to the send confirmation once you unlock, instead of paying automatically. diff --git a/journeys/README.md b/journeys/README.md index c711711637..fa0ce34746 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -129,6 +129,7 @@ fixtures, push notifications) live in each suite's README. | [receive](receive) | 1 | Receive sheet tab selection; needs a spending channel, no README | | [security](security) | 1 | PIN result sheet layout at a long locale and font scale; no README | | [shop](shop) | 1 | Shop Discover category titles and web view handoff; needs Bitrefill reachable; no README | +| [security](security) | 2 | PIN lock when the app returns from the background; PIN result sheet layout at a long locale and font scale; no README | | [subscriptions](subscriptions) | 4 | Paykit subscription lifecycle across two wallets, plus the Payments tab | | [tags](tags) | 1 | Tag input length cap on an activity; no backend, no README | | [transfers](transfers) | 1 | Transfer to Spending settling after the LSP closes the channel; no README | @@ -161,6 +162,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `shop/gift-card-category-titles.xml` | not ported — iOS still hardcodes the category names, and its route in has no screen deeplink | | `home/pull-to-refresh-rates.xml` | not ported — iOS does not refresh exchange rates on pull to refresh | | `receive/receive-auto-tab-selection.xml` | not ported — the Auto tab override fix is Android-only so far; iOS parity not checked | +| `security/pin-lock-on-resume.xml` | not ported yet — iOS already clears the PIN verification on entering the background, so the journey applies; it lands with the iOS side of synonymdev/bitkit-android#1298 | | `security/pin-result-long-label.xml` | not ported — the toggle exists on the iOS security success screen, but the overlap check is a follow-up | | `tags/activity-tag-length-cap.xml` | not ported — iOS has no 20-character cap on tag input | | — | `hardware-wallet/transfer-to-spending-over-max.xml` exists only on iOS | diff --git a/journeys/security/pin-lock-on-resume.xml b/journeys/security/pin-lock-on-resume.xml new file mode 100644 index 0000000000..b124b62535 --- /dev/null +++ b/journeys/security/pin-lock-on-resume.xml @@ -0,0 +1,48 @@ + + + Verifies that with the app PIN enabled, sending Bitkit to the background locks it: returning + to the app shows the PIN pad before any wallet content, a sheet left open is hidden while + locked and comes back after unlock, a configuration change does not lock, and a payment URI + opened while backgrounded waits for the PIN before the Send flow opens. Matches iOS, which + clears the PIN verification when the scene enters the background, with no grace period. + + Precondition: onboarded dev wallet with the app PIN enabled (Settings > Security) and a known + PIN, biometrics off, unlocked, on the wallet home screen. The wallet needs a savings balance + above 10 000 sats and at least one activity item — with an empty balance the last payment URI + is rejected with an insufficient-savings toast instead of opening Send, and with no activity + there is no row to open for the Add Tag steps. Never enter a wrong PIN more than once; + exhausting attempts leads to the forgot-PIN wallet reset. + + The payment URI is fired with the Receive sheet left open, because that is the case the fix + changes: the deferred scan closes the open sheet on unlock. The Add Tag sheet earlier in the + journey is local state inside ActivityDetailScreen and is not a tracked sheet, so it does not + exercise it. The second fix, suppressing QuickPay for a scan deferred by the lock, is left out + here: it needs a fresh under-threshold lightning invoice per run plus a funded channel and + QuickPay enabled, and it asserts the Send confirm screen rather than an observable payment. + AppViewModelSendFlowTest.kt covers both paths. + + + Verify the wallet home screen is visible (testTag "Send") + Run adb shell input keyevent KEYCODE_HOME and wait 3 seconds + Run adb shell am start -n to.bitkit.dev/to.bitkit.ui.MainActivity + Verify the PIN pad is visible (testTag "PinPad") with the text "Please enter your PIN code" and keys (testTag "N1") + Enter the PIN with the number pad keys (testTags "N0" to "N9") + Verify the wallet home screen is visible again (testTag "Send") + Run adb shell cmd uimode night yes, wait 5 seconds, then run adb shell cmd uimode night no and wait 5 seconds + Verify the wallet home screen is visible and the PIN pad is not shown + Tap the first activity item on the home screen (testTag "ActivityShort-0"), then tap Tag (testTag "ActivityTag") + Verify the Add Tag sheet is visible (testTag "TagInput") + Run adb shell input keyevent KEYCODE_HOME and wait 3 seconds + Run adb shell am start -n to.bitkit.dev/to.bitkit.ui.MainActivity + Take a screenshot and verify only the PIN pad is drawn, with no Add Tag sheet above it + Enter the PIN with the number pad keys + Verify the Add Tag sheet is visible again (testTag "TagInput"), then press back until the wallet home screen is visible + Tap Receive (testTag "Receive") and verify the Receive sheet is visible (testTag "ReceiveScreen") + Run adb shell input keyevent KEYCODE_HOME and wait 3 seconds + Run adb shell am start -a android.intent.action.VIEW -d "bitcoin:bcrt1qh6k3khs7qz04a9p5eam6v48nlug9d38x4rgxrn?amount=0.0001" to.bitkit.dev + Verify the PIN pad is visible and the Send amount screen is not shown + Enter the PIN with the number pad keys + Verify the Send amount screen opens (testTag "SendNumberField") showing 10 000 sats and the Receive sheet (testTag "ReceiveScreen") is gone + Press back to close the Send sheet and verify the wallet home screen is visible + +