Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -501,8 +511,8 @@ fun ContentView(
},
onEnable = {
appViewModel.dismissTimedSheet()
navController.navigateTo(Routes.BackgroundPaymentsSettings)
settingsViewModel.setBgPaymentsIntroSeen(true)
requestNotificationPermission()
},
)
}
Expand Down Expand Up @@ -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,
Comment thread
jvsena42 marked this conversation as resolved.
onPermissionChange = { granted ->
settingsViewModel.setNotificationPreference(granted)
}
Expand Down Expand Up @@ -1362,10 +1374,18 @@ private fun NavGraphBuilder.generalSettingsSubScreens(
}

composableWithDefaultTransitions<Routes.BackgroundPaymentsIntro> {
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)
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -110,7 +117,7 @@ fun SpendingConfirmScreen(
onTransferToSpendingConfirm = viewModel::onTransferToSpendingConfirm,
order = order,
hasNotificationPermission = notificationsGranted,
onSwitchClick = { context.openNotificationSettings() },
onSwitchClick = onNotificationSwitchClick,
isAdvanced = isAdvanced,
)
AnimatedVisibility(
Expand Down Expand Up @@ -224,6 +231,7 @@ private fun Content(
isChecked = hasNotificationPermission,
colors = AppSwitchDefaults.colorsPurple,
onClick = onSwitchClick,
switchTestTag = "SpendingConfirmNotificationSwitch",
modifier = Modifier.fillMaxWidth()
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -89,14 +90,20 @@ fun ReceiveConfirmScreen(
} ?: sats.toString()
}

val onNotificationSwitchClick = rememberNotificationToggleClick(
isGranted = notificationsGranted,
onPermissionResult = { granted -> settingsViewModel.setNotificationPreference(granted) },
onOpenSystemSettings = { context.openNotificationSettings() },
)

Content(
receiveSats = entry.receiveAmountSats,
networkFeeFormatted = networkFeeFormatted,
serviceFeeFormatted = serviceFeeFormatted,
receiveAmountFormatted = receiveAmountFormatted,
onLearnMoreClick = onLearnMore,
isAdditional = isAdditional,
onSystemSettingsClick = { context.openNotificationSettings() },
onSystemSettingsClick = onNotificationSwitchClick,
hasNotificationPermission = notificationsGranted,
onContinueClick = { onContinue(entry.invoice) },
onBackClick = onBack,
Expand Down Expand Up @@ -162,6 +169,7 @@ private fun Content(
isChecked = hasNotificationPermission,
colors = AppSwitchDefaults.colorsPurple,
onClick = onSystemSettingsClick,
switchTestTag = "ReceiveConfirmNotificationSwitch",
modifier = Modifier.fillMaxWidth()
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ private fun Content(
isChecked = hasNotificationPermission,
colors = AppSwitchDefaults.colorsPurple,
onClick = onSwitchClick,
switchTestTag = "ReceiveLiquidityNotificationSwitch",
modifier = Modifier.fillMaxWidth()
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -150,28 +151,38 @@ 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,
)
}
}
composableWithDefaultTransitions<ReceiveRoute.LiquidityAdditional> {
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() },
isAdditional = true,
onBack = { navController.popBackStack() },
hasNotificationPermission = notificationsGranted,
onSwitchClick = { context.openNotificationSettings() },
onSwitchClick = onNotificationSwitchClick,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
1 change: 1 addition & 0 deletions changelog.d/next/1004.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 62 additions & 0 deletions journeys/notification-permission/README.md
Original file line number Diff line number Diff line change
@@ -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".
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<journey name="receive cjit confirm toggle requests notification permission">
<description>
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.
</description>
<actions>
<action>Tap the "Receive" button on the home screen</action>
<action>Tap the "Spending" tab in the Receive sheet</action>
<action>Tap "Receive Lightning funds"</action>
<action>On the amount screen, enter an amount above the CJIT minimum (e.g. 100 000 sats) using the number pad</action>
<action>Tap "Continue" and wait for the CJIT order to be created and the confirm screen ("To set up your spending balance...") to appear</action>
<action>Verify the "Set up in background" toggle (testTag "ReceiveConfirmNotificationSwitch") is visible and unchecked</action>
<action>Tap the "Set up in background" toggle</action>
<action>Verify the Android system notification permission dialog appears (text like "Allow Bitkit to send you notifications?")</action>
<action>Tap "Allow"</action>
<action>Verify the "Set up in background" toggle is now checked</action>
</actions>
</journey>
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<journey name="receive cjit liquidity toggle requests notification permission">
<description>
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.
</description>
<actions>
<action>Tap the "Receive" button on the home screen</action>
<action>Tap the "Spending" tab in the Receive sheet</action>
<action>Tap "Receive Lightning funds"</action>
<action>On the amount screen, enter an amount above the CJIT minimum (e.g. 100 000 sats) using the number pad</action>
<action>Tap "Continue" and wait for the confirm screen to appear</action>
<action>Tap "Learn more" to open the liquidity screen</action>
<action>Verify the liquidity screen with the lightning channel and the "Set up in background" toggle (testTag "ReceiveLiquidityNotificationSwitch") is visible and unchecked</action>
<action>Tap the "Set up in background" toggle</action>
<action>Verify the Android system notification permission dialog appears (text like "Allow Bitkit to send you notifications?")</action>
<action>Tap "Allow"</action>
<action>Verify the "Set up in background" toggle is now checked</action>
</actions>
</journey>
Loading
Loading