diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index e054b9b65..bcbf41a2a 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -2,7 +2,11 @@ package to.bitkit.ui +import android.Manifest import android.content.Intent +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.DrawerState @@ -196,6 +200,7 @@ import to.bitkit.ui.utils.AutoReadClipboardHandler import to.bitkit.ui.utils.RequestNotificationPermissions import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.ui.utils.navigationWithDefaultTransitions +import to.bitkit.ui.utils.rememberRequestNotificationPermission import to.bitkit.utils.Logger import to.bitkit.viewmodels.ActivityListViewModel import to.bitkit.viewmodels.AppViewModel @@ -237,6 +242,11 @@ fun ContentView( val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() val walletExists = walletUiState.walletExists + val requestNotificationPermission = rememberRequestNotificationPermission( + onPermissionResult = { granted -> settingsViewModel.setNotificationPreference(granted) }, + onPreTiramisu = { navController.navigateTo(Routes.BackgroundPaymentsSettings) }, + ) + // Effects on app entering fg (ON_START) / bg (ON_STOP) DisposableEffect(lifecycle) { val observer = LifecycleEventObserver { _, event -> @@ -501,8 +511,8 @@ fun ContentView( }, onEnable = { appViewModel.dismissTimedSheet() - navController.navigateTo(Routes.BackgroundPaymentsSettings) settingsViewModel.setBgPaymentsIntroSeen(true) + requestNotificationPermission() }, ) } @@ -896,8 +906,10 @@ private fun NavGraphBuilder.home( val isRecoveryMode by walletViewModel.isRecoveryMode.collectAsStateWithLifecycle() val hazeState = rememberHazeState() + // Only keep notification permission state in sync; the system dialog is requested + // from the background payments intro sheet, not automatically on the home screen. RequestNotificationPermissions( - showPermissionDialog = !isRecoveryMode, + showPermissionDialog = false, onPermissionChange = { granted -> settingsViewModel.setNotificationPreference(granted) } @@ -1362,10 +1374,18 @@ private fun NavGraphBuilder.generalSettingsSubScreens( } composableWithDefaultTransitions { + val notificationPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + settingsViewModel.setNotificationPreference(granted) + } BackgroundPaymentsIntroScreen( onBack = { navController.popBackStack() }, onLater = { navController.popBackStack() }, onEnable = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } navController.navigateTo(Routes.BackgroundPaymentsSettings) }, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt index f5da7dc42..a543c7c37 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt @@ -66,6 +66,7 @@ import to.bitkit.ui.theme.AppSwitchDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.RequestNotificationPermissions +import to.bitkit.ui.utils.rememberNotificationToggleClick import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.SettingsViewModel import to.bitkit.viewmodels.TransferViewModel @@ -100,6 +101,12 @@ fun SpendingConfirmScreen( showPermissionDialog = false, ) + val onNotificationSwitchClick = rememberNotificationToggleClick( + isGranted = notificationsGranted, + onPermissionResult = { granted -> settingsViewModel.setNotificationPreference(granted) }, + onOpenSystemSettings = { context.openNotificationSettings() }, + ) + Box { Content( onBackClick = onBackClick, @@ -110,7 +117,7 @@ fun SpendingConfirmScreen( onTransferToSpendingConfirm = viewModel::onTransferToSpendingConfirm, order = order, hasNotificationPermission = notificationsGranted, - onSwitchClick = { context.openNotificationSettings() }, + onSwitchClick = onNotificationSwitchClick, isAdvanced = isAdvanced, ) AnimatedVisibility( @@ -224,6 +231,7 @@ private fun Content( isChecked = hasNotificationPermission, colors = AppSwitchDefaults.colorsPurple, onClick = onSwitchClick, + switchTestTag = "SpendingConfirmNotificationSwitch", modifier = Modifier.fillMaxWidth() ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt index 9722288f3..c1f10c1d2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt @@ -43,6 +43,7 @@ import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppSwitchDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.rememberNotificationToggleClick import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.SettingsViewModel @@ -89,6 +90,12 @@ fun ReceiveConfirmScreen( } ?: sats.toString() } + val onNotificationSwitchClick = rememberNotificationToggleClick( + isGranted = notificationsGranted, + onPermissionResult = { granted -> settingsViewModel.setNotificationPreference(granted) }, + onOpenSystemSettings = { context.openNotificationSettings() }, + ) + Content( receiveSats = entry.receiveAmountSats, networkFeeFormatted = networkFeeFormatted, @@ -96,7 +103,7 @@ fun ReceiveConfirmScreen( receiveAmountFormatted = receiveAmountFormatted, onLearnMoreClick = onLearnMore, isAdditional = isAdditional, - onSystemSettingsClick = { context.openNotificationSettings() }, + onSystemSettingsClick = onNotificationSwitchClick, hasNotificationPermission = notificationsGranted, onContinueClick = { onContinue(entry.invoice) }, onBackClick = onBack, @@ -162,6 +169,7 @@ private fun Content( isChecked = hasNotificationPermission, colors = AppSwitchDefaults.colorsPurple, onClick = onSystemSettingsClick, + switchTestTag = "ReceiveConfirmNotificationSwitch", modifier = Modifier.fillMaxWidth() ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt index 40b7895e3..07861ca9d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt @@ -154,6 +154,7 @@ private fun Content( isChecked = hasNotificationPermission, colors = AppSwitchDefaults.colorsPurple, onClick = onSwitchClick, + switchTestTag = "ReceiveLiquidityNotificationSwitch", modifier = Modifier.fillMaxWidth() ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index 3151c4189..75a330420 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -31,6 +31,7 @@ import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.screens.wallets.send.AddTagScreen import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.utils.composableWithDefaultTransitions +import to.bitkit.ui.utils.rememberNotificationToggleClick import to.bitkit.ui.walletViewModel import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.SettingsViewModel @@ -150,13 +151,18 @@ fun ReceiveSheet( cjitEntryDetails.value?.let { entryDetails -> val context = LocalContext.current val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() + val onNotificationSwitchClick = rememberNotificationToggleClick( + isGranted = notificationsGranted, + onPermissionResult = { granted -> settingsViewModel.setNotificationPreference(granted) }, + onOpenSystemSettings = { context.openNotificationSettings() }, + ) ReceiveLiquidityScreen( entry = entryDetails, onContinue = { navController.popBackStack() }, onBack = { navController.popBackStack() }, hasNotificationPermission = notificationsGranted, - onSwitchClick = { context.openNotificationSettings() }, + onSwitchClick = onNotificationSwitchClick, ) } } @@ -164,6 +170,11 @@ fun ReceiveSheet( cjitEntryDetails.value?.let { entryDetails -> val context = LocalContext.current val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() + val onNotificationSwitchClick = rememberNotificationToggleClick( + isGranted = notificationsGranted, + onPermissionResult = { granted -> settingsViewModel.setNotificationPreference(granted) }, + onOpenSystemSettings = { context.openNotificationSettings() }, + ) ReceiveLiquidityScreen( entry = entryDetails, @@ -171,7 +182,7 @@ fun ReceiveSheet( isAdditional = true, onBack = { navController.popBackStack() }, hasNotificationPermission = notificationsGranted, - onSwitchClick = { context.openNotificationSettings() }, + onSwitchClick = onNotificationSwitchClick, ) } } diff --git a/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt b/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt index 978554396..fe43ccd1f 100644 --- a/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt +++ b/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt @@ -72,3 +72,51 @@ fun RequestNotificationPermissions( } } } + +@Composable +fun rememberRequestNotificationPermission( + onPermissionResult: (Boolean) -> Unit, + onPreTiramisu: () -> Unit, +): () -> Unit { + val currentOnPermissionResult by rememberUpdatedState(onPermissionResult) + val currentOnPreTiramisu by rememberUpdatedState(onPreTiramisu) + + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + currentOnPermissionResult(granted) + } + + return remember(launcher) { + { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + // Pre-13 has no runtime permission dialog; defer to the caller-provided fallback. + currentOnPreTiramisu() + } + } + } +} + +@Composable +fun rememberNotificationToggleClick( + isGranted: Boolean, + onPermissionResult: (Boolean) -> Unit, + onOpenSystemSettings: () -> Unit, +): () -> Unit { + val requestPermission = rememberRequestNotificationPermission( + onPermissionResult = onPermissionResult, + onPreTiramisu = onOpenSystemSettings, + ) + val currentIsGranted by rememberUpdatedState(isGranted) + val currentOnOpenSystemSettings by rememberUpdatedState(onOpenSystemSettings) + + return remember(requestPermission) { + { + // Already granted: the runtime request is a no-op, so send the user to system + // settings where they can actually turn notifications off. + if (currentIsGranted) currentOnOpenSystemSettings() else requestPermission() + } + } +} diff --git a/changelog.d/next/1004.fixed.md b/changelog.d/next/1004.fixed.md new file mode 100644 index 000000000..e533520d5 --- /dev/null +++ b/changelog.d/next/1004.fixed.md @@ -0,0 +1 @@ +The system notification permission dialog is now only requested when you tap Enable on the background payments prompt, instead of appearing automatically on every app open. diff --git a/journeys/notification-permission/README.md b/journeys/notification-permission/README.md new file mode 100644 index 000000000..4b2faa413 --- /dev/null +++ b/journeys/notification-permission/README.md @@ -0,0 +1,62 @@ +# Notification-permission journeys + +These journeys exercise the notification-permission request triggered by the +"Set up in background" toggle that backs Bitkit's background-payments setup. + +The behaviour was changed on `fix/limit-system-notification-permission`: tapping the +toggle now goes through the shared `rememberRequestNotificationPermission` helper +(`ui/utils/RequestNotificationPermissions.kt`) instead of jumping straight to the +system notification settings. + +## What the fix does +- **Toggle OFF (permission already granted)**: re-requesting a granted permission is a + no-op, so tapping opens the **system notification settings** (`openNotificationSettings`) + where the user can actually turn notifications off — the behaviour these toggles had + before the refactor. +- **Toggle ON, Android 13+ (API 33, TIRAMISU)**: tapping launches the OS + `POST_NOTIFICATIONS` runtime permission dialog. Granting it flips the toggle to + checked; the result is persisted via `SettingsViewModel.setNotificationPreference`. +- **Toggle ON, pre-13 (API < 33)**: there is no runtime dialog, so it falls back to the + system notification settings. + +The same helper backs four entry points; these journeys cover the three the user can +reach directly: +- **Transfer → Spending confirm** (`SpendingConfirmScreen`) +- **Receive → CJIT confirm** (`ReceiveConfirmScreen`) +- **Receive → CJIT liquidity** (`ReceiveLiquidityScreen`, via "Learn more") + +## Mandatory setup +1. **Use an API 33+ device** to verify the runtime-dialog path. On API < 33 the dialog + never appears — only the system-settings fallback is exercised. +2. **Start from a fresh notification-permission state.** The OS only shows the + `POST_NOTIFICATIONS` dialog while the permission is in the "ask" state. Once granted + or denied it will not show again, and the journey will silently pass for the wrong + reason. Reset before each run: + `adb shell pm revoke to.bitkit.dev android.permission.POST_NOTIFICATIONS` + (or reinstall / clear app data). +3. **Node must be connected to the LSP (Blocktank).** Both the Transfer→Spending and + Receive→CJIT confirm screens need a real order quoted by Blocktank before the toggle + screen renders. With the hosted staging backend this is `api.stag0.blocktank.to`. +4. **Transfer→Spending also needs a positive on-chain Savings balance** so a real max can + be quoted. Fund + mine via the `blocktank-api:lsp` skill, then wait for the balance to + sync. + +## Gotchas +- **The permission dialog is one-shot** — see setup #2. Always revoke/reset first. +- **Blocktank must be reachable.** If `api.stag0.blocktank.to:443` is down, CJIT/order + creation hangs on a spinner at "Continue" and the confirm screen never appears, so the + toggle is unreachable. Verify the host first: + `curl -s -m 8 -o /dev/null -w '%{http_code}\n' https://api.stag0.blocktank.to/blocktank/api/v2/info` + (`000` = down). This is infra, not the toggle. +- The system permission dialog is **OS UI**, not Compose — locate its buttons with + `android screen --annotate` (text "Allow" / "Don't allow"), not `android layout` tags. +- On grant, the toggle reflects `notificationsGranted`; it only flips to checked once the + `ON_RESUME` re-check or the launcher callback fires. + +## Test tags +- Transfer→Spending toggle switch: `SpendingConfirmNotificationSwitch` +- Receive→CJIT confirm toggle switch: `ReceiveConfirmNotificationSwitch` +- Receive→CJIT liquidity toggle switch: `ReceiveLiquidityNotificationSwitch` +- Spending amount screen: `SpendingAmount`, continue `SpendingAmountContinue`, + available/max `SpendingAmountAvailable` / `SpendingAmountMax`. +- The toggle label on all screens is "Set up in background". diff --git a/journeys/notification-permission/receive-cjit-confirm-notification-toggle.xml b/journeys/notification-permission/receive-cjit-confirm-notification-toggle.xml new file mode 100644 index 000000000..46144c174 --- /dev/null +++ b/journeys/notification-permission/receive-cjit-confirm-notification-toggle.xml @@ -0,0 +1,23 @@ + + + Verifies that the "Set up in background" toggle on the Receive → CJIT confirm screen + (ReceiveConfirmScreen) launches the Android POST_NOTIFICATIONS runtime permission + dialog on API 33+, and that granting it checks the toggle. + + Precondition: API 33+ onboarded dev wallet with the node connected to the LSP so a + CJIT order can be quoted, and POST_NOTIFICATIONS in the "ask" state (revoke or + reinstall first — the dialog is one-shot). Start on the wallet home screen. + + + Tap the "Receive" button on the home screen + Tap the "Spending" tab in the Receive sheet + Tap "Receive Lightning funds" + On the amount screen, enter an amount above the CJIT minimum (e.g. 100 000 sats) using the number pad + Tap "Continue" and wait for the CJIT order to be created and the confirm screen ("To set up your spending balance...") to appear + Verify the "Set up in background" toggle (testTag "ReceiveConfirmNotificationSwitch") is visible and unchecked + Tap the "Set up in background" toggle + Verify the Android system notification permission dialog appears (text like "Allow Bitkit to send you notifications?") + Tap "Allow" + Verify the "Set up in background" toggle is now checked + + diff --git a/journeys/notification-permission/receive-cjit-liquidity-notification-toggle.xml b/journeys/notification-permission/receive-cjit-liquidity-notification-toggle.xml new file mode 100644 index 000000000..1486c08a0 --- /dev/null +++ b/journeys/notification-permission/receive-cjit-liquidity-notification-toggle.xml @@ -0,0 +1,25 @@ + + + Verifies that the "Set up in background" toggle on the Receive → CJIT liquidity screen + (ReceiveLiquidityScreen, reached via "Learn more" from the confirm screen) launches the + Android POST_NOTIFICATIONS runtime permission dialog on API 33+, and that granting it + checks the toggle. + + Precondition: API 33+ onboarded dev wallet with the node connected to the LSP so a CJIT + order can be quoted, and POST_NOTIFICATIONS in the "ask" state (revoke or reinstall + first — the dialog is one-shot). Start on the wallet home screen. + + + Tap the "Receive" button on the home screen + Tap the "Spending" tab in the Receive sheet + Tap "Receive Lightning funds" + On the amount screen, enter an amount above the CJIT minimum (e.g. 100 000 sats) using the number pad + Tap "Continue" and wait for the confirm screen to appear + Tap "Learn more" to open the liquidity screen + Verify the liquidity screen with the lightning channel and the "Set up in background" toggle (testTag "ReceiveLiquidityNotificationSwitch") is visible and unchecked + Tap the "Set up in background" toggle + Verify the Android system notification permission dialog appears (text like "Allow Bitkit to send you notifications?") + Tap "Allow" + Verify the "Set up in background" toggle is now checked + + diff --git a/journeys/notification-permission/toggle-off-opens-system-settings.xml b/journeys/notification-permission/toggle-off-opens-system-settings.xml new file mode 100644 index 000000000..c90ca54c4 --- /dev/null +++ b/journeys/notification-permission/toggle-off-opens-system-settings.xml @@ -0,0 +1,24 @@ + + + Verifies that when the "Set up in background" toggle is already ON (POST_NOTIFICATIONS + granted), tapping it opens the system notification settings instead of re-requesting the + permission (which would be a no-op). Uses the Receive → CJIT confirm screen, but the + behaviour is shared by the transfer and liquidity toggles. + + Precondition: API 33+ onboarded dev wallet with the node connected to the LSP so a CJIT + order can be quoted, and POST_NOTIFICATIONS ALREADY GRANTED so the toggle starts checked + (grant via: adb shell pm grant to.bitkit.dev android.permission.POST_NOTIFICATIONS). + Start on the wallet home screen. + + + Tap the "Receive" button on the home screen + Tap the "Spending" tab in the Receive sheet + Tap "Receive Lightning funds" + On the amount screen, enter an amount above the CJIT minimum (e.g. 100 000 sats) using the number pad + Tap "Continue" and wait for the confirm screen to appear + Verify the "Set up in background" toggle (testTag "ReceiveConfirmNotificationSwitch") is visible and checked + Tap the "Set up in background" toggle + Verify the Android system notification settings screen for Bitkit opens (no in-app navigation, no permission dialog) + Press back to return to Bitkit and verify the confirm screen is still shown + + diff --git a/journeys/notification-permission/transfer-spending-confirm-notification-toggle.xml b/journeys/notification-permission/transfer-spending-confirm-notification-toggle.xml new file mode 100644 index 000000000..71fde7c48 --- /dev/null +++ b/journeys/notification-permission/transfer-spending-confirm-notification-toggle.xml @@ -0,0 +1,25 @@ + + + Verifies that the "Set up in background" toggle on the Transfer → Spending confirm + screen (SpendingConfirmScreen) launches the Android POST_NOTIFICATIONS runtime + permission dialog on API 33+, and that granting it checks the toggle. + + Precondition: API 33+ onboarded dev wallet with a POSITIVE on-chain Savings balance and + the node connected to the LSP (so a real max can be quoted), and POST_NOTIFICATIONS in + the "ask" state (revoke or reinstall first — the dialog is one-shot). The spending max + starts at 0 behind a spinner — wait for it to populate. Start on the wallet home screen. + + + Tap the Spending balance card on the home screen + Tap "Transfer from Savings" + If a transfer intro screen appears, tap "Get Started" + On the spending amount screen (testTag "SpendingAmount"), wait until the available/maximum amount (testTag "SpendingAmountAvailable") finishes loading and shows a positive value + Enter an amount within the maximum using the number pad + Tap continue (testTag "SpendingAmountContinue") and wait for the spending confirm screen to appear + Verify the "Set up in background" toggle (testTag "SpendingConfirmNotificationSwitch") is visible and unchecked + Tap the "Set up in background" toggle + Verify the Android system notification permission dialog appears (text like "Allow Bitkit to send you notifications?") + Tap "Allow" + Verify the "Set up in background" toggle is now checked + +