From 4bea4f22c818d4e8d8238b80679ff984b6133d05 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 21:01:03 -0300 Subject: [PATCH 1/7] fix: require pin when app resumes Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/to/bitkit/ui/MainActivity.kt | 13 ++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 11 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 75 +++++++++++++++++++ changelog.d/next/724.changed.md | 1 + 4 files changed, 100 insertions(+) create mode 100644 changelog.d/next/724.changed.md diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 41d3bd79df..bad93fa40b 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -13,6 +13,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -23,6 +24,9 @@ import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.core.content.IntentCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -172,6 +176,15 @@ class MainActivity : FragmentActivity() { modifier = Modifier.hazeSource(hazeState, zIndex = 0f), ) + DisposableEffect(appViewModel) { + val processLifecycle = ProcessLifecycleOwner.get().lifecycle + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP) appViewModel.lockOnBackground() + } + processLifecycle.addObserver(observer) + onDispose { processLifecycle.removeObserver(observer) } + } + AnimatedVisibility( visible = !isAuthenticated, enter = fadeIn(), diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c912066536..296c9edfc3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4837,6 +4837,17 @@ class AppViewModel @Inject constructor( } } + /** Requires the PIN again after the app process moves to the background. */ + fun lockOnBackground() { + viewModelScope.launch { + if (!settingsStore.data.first().isPinEnabled) return@launch + if (!walletRepo.walletExists()) return@launch + if (lightningRepo.isRecoveryMode.value) return@launch + _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 diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 829cd2bf29..d11808acb0 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) @@ -4695,6 +4697,79 @@ 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 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 `latest locked scan replaces earlier input`() = test { val first = "lnbcrt1lockedfirst" diff --git a/changelog.d/next/724.changed.md b/changelog.d/next/724.changed.md new file mode 100644 index 0000000000..fb505418ab --- /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. From 6101ee5af973c714eca23c2da8715a3256b57a37 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 21:12:57 -0300 Subject: [PATCH 2/7] fix: hide dialogs and sheets while pin screen is shown Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/java/to/bitkit/ui/Locals.kt | 3 ++ .../main/java/to/bitkit/ui/MainActivity.kt | 33 ++++++++++--------- .../to/bitkit/ui/components/BottomSheet.kt | 2 ++ .../to/bitkit/ui/scaffold/AppAlertDialog.kt | 2 ++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 1 + .../viewmodels/AppViewModelSendFlowTest.kt | 24 ++++++++++++++ 6 files changed, 49 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/Locals.kt b/app/src/main/java/to/bitkit/ui/Locals.kt index 3ed862eab5..a324482e64 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; window-based dialogs and sheets 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 bad93fa40b..87fe811bfa 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -133,6 +133,7 @@ class MainActivity : FragmentActivity() { val hazeState = rememberHazeState(blurEnabled = true) val bottomSheetOverlayState = remember { BottomSheetOverlayState() } val authSheetOverlayState = remember { BottomSheetOverlayState() } + val isAuthenticated by appViewModel.isAuthenticated.collectAsStateWithLifecycle() LaunchedEffect( walletExists, @@ -159,22 +160,22 @@ 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), - ) + 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), + ) + } DisposableEffect(appViewModel) { val processLifecycle = ProcessLifecycleOwner.get().lifecycle @@ -221,7 +222,7 @@ class MainActivity : FragmentActivity() { } val transactionSheetDetails by appViewModel.transactionSheet.collectAsStateWithLifecycle() - if (transactionSheetDetails != NewTransactionSheetDetails.EMPTY) { + if (isAuthenticated && transactionSheetDetails != NewTransactionSheetDetails.EMPTY) { NewTransactionSheet( appViewModel = appViewModel, bottomSheetOverlayState = bottomSheetOverlayState, diff --git a/app/src/main/java/to/bitkit/ui/components/BottomSheet.kt b/app/src/main/java/to/bitkit/ui/components/BottomSheet.kt index b6d2e2365d..8face20a32 100644 --- a/app/src/main/java/to/bitkit/ui/components/BottomSheet.kt +++ b/app/src/main/java/to/bitkit/ui/components/BottomSheet.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.ui.LocalBottomSheetOverlayState +import to.bitkit.ui.LocalIsAppLocked import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground @@ -72,6 +73,7 @@ fun BottomSheet( sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), content: @Composable ColumnScope.() -> Unit, ) { + if (LocalIsAppLocked.current) return val overlayState = checkNotNull(LocalBottomSheetOverlayState.current) { "BottomSheet must be composed inside BottomSheetOverlayHost" } 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 296c9edfc3..705d193c81 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4913,6 +4913,7 @@ class AppViewModel @Inject constructor( } private fun onConfirmAmountWarning(warning: SanityWarning) { + if (!_isAuthenticated.value) return viewModelScope.launch { _sendUiState.update { it.copy( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index d11808acb0..fc66b20be6 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -3274,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() @@ -4770,6 +4771,28 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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 `latest locked scan replaces earlier input`() = test { val first = "lnbcrt1lockedfirst" @@ -6609,6 +6632,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() From 7c10bd27d1b0d1af359d5dea4017e5f9c0344d09 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 17 Sep 2026 06:41:57 -0300 Subject: [PATCH 3/7] fix: lock on activity stop and show pin overlay instantly Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/to/bitkit/ui/MainActivity.kt | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 87fe811bfa..0ba8ea5c12 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -9,12 +9,12 @@ 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.DisposableEffect 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 @@ -24,9 +24,6 @@ import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.core.content.IntentCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -133,7 +130,7 @@ class MainActivity : FragmentActivity() { val hazeState = rememberHazeState(blurEnabled = true) val bottomSheetOverlayState = remember { BottomSheetOverlayState() } val authSheetOverlayState = remember { BottomSheetOverlayState() } - val isAuthenticated by appViewModel.isAuthenticated.collectAsStateWithLifecycle() + val isAuthenticated by appViewModel.isAuthenticated.collectAsState() LaunchedEffect( walletExists, @@ -177,18 +174,9 @@ class MainActivity : FragmentActivity() { ) } - DisposableEffect(appViewModel) { - val processLifecycle = ProcessLifecycleOwner.get().lifecycle - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_STOP) appViewModel.lockOnBackground() - } - processLifecycle.addObserver(observer) - onDispose { processLifecycle.removeObserver(observer) } - } - AnimatedVisibility( visible = !isAuthenticated, - enter = fadeIn(), + enter = EnterTransition.None, exit = fadeOut(), ) { AuthCheckView( @@ -313,6 +301,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) { From 096175462e830f70d9c51b2395b05ed47bf8c90c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 17 Sep 2026 18:16:29 -0300 Subject: [PATCH 4/7] fix: lock synchronously on activity stop Co-Authored-By: Claude Opus 5 (1M context) --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 23 ++++++++++++------- .../viewmodels/AppViewModelSendFlowTest.kt | 11 +++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 705d193c81..707e79faf0 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() @@ -4837,15 +4842,17 @@ class AppViewModel @Inject constructor( } } - /** Requires the PIN again after the app process moves to the background. */ + /** + * 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() { - viewModelScope.launch { - if (!settingsStore.data.first().isPinEnabled) return@launch - if (!walletRepo.walletExists()) return@launch - if (lightningRepo.isRecoveryMode.value) return@launch - _isAuthenticated.update { false } - Logger.debug("Locked app on background", context = TAG) - } + 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 { diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index fc66b20be6..1b8a36f312 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -4710,6 +4710,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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) From 6a1c0c8254deb2a14872e82666be06f3d0ac9133 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 08:43:17 -0300 Subject: [PATCH 5/7] fix: flush scans deferred by the lock without auto-paying Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/java/to/bitkit/ui/Locals.kt | 2 +- .../main/java/to/bitkit/ui/MainActivity.kt | 10 ++ .../to/bitkit/ui/components/BottomSheet.kt | 2 - .../java/to/bitkit/viewmodels/AppViewModel.kt | 131 ++++++++++-------- .../viewmodels/AppViewModelSendFlowTest.kt | 79 ++++++++++- changelog.d/next/724.changed.md | 2 +- 6 files changed, 160 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/Locals.kt b/app/src/main/java/to/bitkit/ui/Locals.kt index a324482e64..2c02e1bc1c 100644 --- a/app/src/main/java/to/bitkit/ui/Locals.kt +++ b/app/src/main/java/to/bitkit/ui/Locals.kt @@ -21,7 +21,7 @@ val LocalBalances = compositionLocalOf { BalanceState() } val LocalCurrencies = compositionLocalOf { CurrencyState() } val LocalIs24HourFormat = compositionLocalOf { false } -/** True while the PIN screen covers the wallet; window-based dialogs and sheets must not render. */ +/** True while the PIN screen covers the wallet; dialogs owning their own window must not render. */ val LocalIsAppLocked = compositionLocalOf { false } // Statics diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 0ba8ea5c12..458b947eba 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -19,6 +19,8 @@ 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 @@ -131,6 +133,8 @@ class MainActivity : FragmentActivity() { val bottomSheetOverlayState = remember { BottomSheetOverlayState() } val authSheetOverlayState = remember { BottomSheetOverlayState() } val isAuthenticated by appViewModel.isAuthenticated.collectAsState() + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current LaunchedEffect( walletExists, @@ -158,6 +162,12 @@ class MainActivity : FragmentActivity() { ) } else { IsOnlineTracker(appViewModel) + LaunchedEffect(isAuthenticated) { + if (!isAuthenticated) { + focusManager.clearFocus(force = true) + keyboardController?.hide() + } + } CompositionLocalProvider(LocalIsAppLocked provides !isAuthenticated) { ContentView( appViewModel = appViewModel, diff --git a/app/src/main/java/to/bitkit/ui/components/BottomSheet.kt b/app/src/main/java/to/bitkit/ui/components/BottomSheet.kt index 8face20a32..b6d2e2365d 100644 --- a/app/src/main/java/to/bitkit/ui/components/BottomSheet.kt +++ b/app/src/main/java/to/bitkit/ui/components/BottomSheet.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.ui.LocalBottomSheetOverlayState -import to.bitkit.ui.LocalIsAppLocked import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground @@ -73,7 +72,6 @@ fun BottomSheet( sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), content: @Composable ColumnScope.() -> Unit, ) { - if (LocalIsAppLocked.current) return val overlayState = checkNotNull(LocalBottomSheetOverlayState.current) { "BottomSheet must be composed inside BottomSheetOverlayHost" } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 707e79faf0..95f039d7ba 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -408,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() @@ -2219,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 } @@ -2244,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 } @@ -2252,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( @@ -2312,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, ) } @@ -2345,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) { @@ -2379,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 || @@ -2425,6 +2424,7 @@ class AppViewModel @Inject constructor( contactPaymentContext = pending.contactPaymentContext, preserveUntilComplete = true, allowPubkyAuth = pending.allowPubkyAuth, + suppressQuickPay = pending.suppressQuickPay, ) } @@ -2827,6 +2827,7 @@ class AppViewModel @Inject constructor( routePubkyKeys: Boolean, contactPaymentContext: ContactPaymentContext?, allowPubkyAuth: Boolean, + suppressQuickPay: Boolean, ) = withContext(bgDispatcher) { if (rejectPubkyAuthScan(result, allowPubkyAuth, contactPaymentContext)) return@withContext @@ -2924,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) { @@ -2940,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) { @@ -2960,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) } @@ -3165,6 +3167,7 @@ class AppViewModel @Inject constructor( invoice: OnChainInvoice, scanResult: String, fromMainScanner: Boolean, + suppressQuickPay: Boolean, ) { val validatedAddress = runCatching { coreService.validateBitcoinAddress(invoice.address) } .getOrElse { @@ -3289,6 +3292,7 @@ class AppViewModel @Inject constructor( amountSats = lnAmountSats, invoice = lnInvoice, fromMainScanner = fromMainScanner, + suppressQuickPay = suppressQuickPay, ) if (quickPayHandled) return @@ -3403,6 +3407,7 @@ class AppViewModel @Inject constructor( invoice: LightningInvoice, scanResult: String, fromMainScanner: Boolean, + suppressQuickPay: Boolean, ) { if (invoice.isExpired) { if (clearIncomingPaymentRequestTarget()) return @@ -3426,6 +3431,7 @@ class AppViewModel @Inject constructor( amountSats = amount, invoice = invoice, fromMainScanner = fromMainScanner, + suppressQuickPay = suppressQuickPay, ) if (quickPayHandled) return @@ -3467,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() @@ -3517,6 +3523,7 @@ class AppViewModel @Inject constructor( amountSats = initialAmount, lnurlPay = data, fromMainScanner = fromMainScanner, + suppressQuickPay = suppressQuickPay, ) if (quickPayHandled) return @@ -3658,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) @@ -5692,6 +5704,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 1b8a36f312..2816dce2f6 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -4657,7 +4657,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( @@ -4676,8 +4676,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) } @@ -4804,6 +4804,79 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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" diff --git a/changelog.d/next/724.changed.md b/changelog.d/next/724.changed.md index fb505418ab..148ab0594d 100644 --- a/changelog.d/next/724.changed.md +++ b/changelog.d/next/724.changed.md @@ -1 +1 @@ -Bitkit now asks for your PIN again whenever you return to the app from the background. +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. From 5fb363325690f5f20f0fe7d03b7164d5c458c27d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 10:18:33 -0300 Subject: [PATCH 6/7] docs: add pin lock on resume journey Co-Authored-By: Claude Opus 5 (1M context) --- journeys/README.md | 2 ++ journeys/security/pin-lock-on-resume.xml | 39 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 journeys/security/pin-lock-on-resume.xml diff --git a/journeys/README.md b/journeys/README.md index 365f2ce75c..60011dba17 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -121,6 +121,7 @@ fixtures, push notifications) live in each suite's README. | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [payment-requests](payment-requests) | 2 | Requires a linked fixture issuer; rejected shapes are unit fixtures | | [pubky-marketplace](pubky-marketplace) | 1 | Two-wallet Paykit marketplace payment; integration fixture required | +| [security](security) | 1 | PIN lock when the app returns from the background; no README | | [subscriptions](subscriptions) | 4 | Paykit subscription lifecycle across two wallets, plus the Payments tab | | [widgets](widgets) | 2 | Needs no backend — the quickest way to see the loop work; no README | @@ -142,6 +143,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `hardware-wallet/usb-reconnect.xml` | `reconnect.xml` — over Bridge, since iOS cannot do WebUSB | | `hardware-wallet/receive-onchain.xml`, `hardware-wallet/send-onchain.xml` | not ported | | `payment-requests/requested-resolution-failure.xml` | not ported | +| `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 | | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | | — | `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..924308ea56 --- /dev/null +++ b/journeys/security/pin-lock-on-resume.xml @@ -0,0 +1,39 @@ + + + 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. + + + 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 + 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 + Press back to close the Send sheet and verify the wallet home screen is visible + + From 510f4b0981955620f99affa563d9255854f9aba0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 11:05:33 -0300 Subject: [PATCH 7/7] docs: leave a sheet open in pin lock journey Co-Authored-By: Claude Opus 5 (1M context) --- journeys/security/pin-lock-on-resume.xml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/journeys/security/pin-lock-on-resume.xml b/journeys/security/pin-lock-on-resume.xml index 924308ea56..b124b62535 100644 --- a/journeys/security/pin-lock-on-resume.xml +++ b/journeys/security/pin-lock-on-resume.xml @@ -12,6 +12,14 @@ 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") @@ -29,11 +37,12 @@ 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 + 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