From 4333203d3c2b8152ac6f92608693b03a7d654bd0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 29 Jun 2026 13:36:01 -0300 Subject: [PATCH 1/8] feat: make background fg service opt-in --- .../androidServices/LightningNodeService.kt | 8 ++++++- .../main/java/to/bitkit/data/SettingsStore.kt | 1 + .../main/java/to/bitkit/ui/MainActivity.kt | 18 +++++++++++---- .../BackgroundPaymentsSettings.kt | 22 +++++++++++++++++++ .../to/bitkit/viewmodels/SettingsViewModel.kt | 9 ++++++++ app/src/main/res/values/strings.xml | 2 ++ .../viewmodels/SettingsViewModelTest.kt | 18 +++++++++++++++ changelog.d/next/fg-service-optional.added.md | 1 + 8 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 changelog.d/next/fg-service-optional.added.md diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 6bb2e2b027..95ad1b22eb 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -238,8 +238,14 @@ class LightningNodeService : Service() { override fun onDestroy() { Logger.debug("onDestroy", context = TAG) nodeServiceFgState.setForegroundServiceRunning(false) + // Only stop the node when no activity is active; in the foreground WalletViewModel owns the + // node lifecycle, so toggling off the foreground service must leave the node running. // Safe to call even if already stopped — guarded by lifecycleMutex + isStoppedOrStopping() - serviceScope.launch { lightningRepo.stop() } + if (App.currentActivity?.value == null) { + serviceScope.launch { lightningRepo.stop() } + } else { + Logger.debug("Skipping node stop on foreground service destroy: activity is active", context = TAG) + } super.onDestroy() } diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index eba55786fd..d76f9e0b76 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -143,6 +143,7 @@ data class SettingsData( val backupVerified: Boolean = false, val notificationsGranted: Boolean = false, val showNotificationDetails: Boolean = true, + val keepBitkitActiveInBackground: Boolean = false, val dismissedSuggestions: List = emptyList(), val balanceWarningIgnoredMillis: Long = 0, val backupWarningIgnoredMillis: Long = 0, diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 7693d14467..5686349d27 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -117,6 +117,7 @@ class MainActivity : FragmentActivity() { val scope = rememberCoroutineScope() val isRecoveryMode by walletViewModel.isRecoveryMode.collectAsStateWithLifecycle() val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() + val keepActive by settingsViewModel.keepBitkitActiveInBackground.collectAsStateWithLifecycle() val walletExists by walletViewModel.walletState .map { it.walletExists } .collectAsStateWithLifecycle(initialValue = walletViewModel.walletExists) @@ -128,11 +129,14 @@ class MainActivity : FragmentActivity() { walletExists, isRecoveryMode, notificationsGranted, + keepActive, restoreState, ) { - val canStartService = walletExists && notificationsGranted && restoreState.isIdle() + val canStartService = walletExists && notificationsGranted && keepActive && restoreState.isIdle() if (canStartService && !isRecoveryMode) { tryStartForegroundService() + } else if (!keepActive) { + stopForegroundService() } } @@ -264,9 +268,7 @@ class MainActivity : FragmentActivity() { override fun onDestroy() { super.onDestroy() if (!settingsViewModel.notificationsGranted.value) { - runCatching { - stopService(Intent(this, LightningNodeService::class.java)) - } + stopForegroundService() } } @@ -285,6 +287,14 @@ class MainActivity : FragmentActivity() { Logger.error("Failed to start LightningNodeService", error, context = "MainActivity") } } + + private fun stopForegroundService() { + runCatching { + stopService(Intent(this, LightningNodeService::class.java)) + }.onFailure { error -> + Logger.error("Failed to stop LightningNodeService", error, context = "MainActivity") + } + } } internal fun Intent?.launchKey(): String? { diff --git a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt index 9242f686f3..d06eb44497 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt @@ -42,6 +42,7 @@ fun BackgroundPaymentsSettings( val context = LocalContext.current val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() val showNotificationDetails by settingsViewModel.showNotificationDetails.collectAsStateWithLifecycle() + val keepActive by settingsViewModel.keepBitkitActiveInBackground.collectAsStateWithLifecycle() RequestNotificationPermissions( onPermissionChange = settingsViewModel::setNotificationPreference, @@ -51,9 +52,11 @@ fun BackgroundPaymentsSettings( Content( hasPermission = notificationsGranted, showDetails = showNotificationDetails, + keepActive = keepActive, onBack = onBack, onSystemSettingsClick = context::openNotificationSettings, toggleNotificationDetails = settingsViewModel::toggleNotificationDetails, + onKeepActiveClick = { settingsViewModel.setKeepBitkitActiveInBackground(!keepActive) }, ) } @@ -61,9 +64,11 @@ fun BackgroundPaymentsSettings( private fun Content( hasPermission: Boolean, showDetails: Boolean, + keepActive: Boolean, onBack: () -> Unit, onSystemSettingsClick: () -> Unit, toggleNotificationDetails: () -> Unit, + onKeepActiveClick: () -> Unit, ) { Column( modifier = Modifier.screen() @@ -105,6 +110,19 @@ private fun Content( ) } + SettingsSwitchRow( + title = stringResource(R.string.settings__bg__keep_active_title), + isChecked = keepActive, + onClick = onKeepActiveClick, + enabled = hasPermission, + ) + + BodyM( + text = stringResource(R.string.settings__bg__keep_active_desc), + color = Colors.White64, + modifier = Modifier.padding(vertical = 16.dp), + ) + NotificationPreview( enabled = hasPermission, title = stringResource(R.string.notification__received__title), @@ -151,9 +169,11 @@ private fun Preview1() { Content( hasPermission = true, showDetails = true, + keepActive = true, onBack = {}, onSystemSettingsClick = {}, toggleNotificationDetails = {}, + onKeepActiveClick = {}, ) } } @@ -165,9 +185,11 @@ private fun Preview2() { Content( hasPermission = false, showDetails = false, + keepActive = false, onBack = {}, onSystemSettingsClick = {}, toggleNotificationDetails = {}, + onKeepActiveClick = {}, ) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index c2c92a0473..aa9627e2ff 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -73,6 +73,15 @@ class SettingsViewModel @Inject constructor( } } + val keepBitkitActiveInBackground = settingsStore.data.map { it.keepBitkitActiveInBackground } + .asStateFlow(initialValue = false) + + fun setKeepBitkitActiveInBackground(value: Boolean) { + viewModelScope.launch { + settingsStore.update { it.copy(keepBitkitActiveInBackground = value) } + } + } + fun setHasSeenSpendingIntro(value: Boolean) { viewModelScope.launch { settingsStore.update { it.copy(hasSeenSpendingIntro = value) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7a3a3a97e..1d9ff7c5ee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -817,6 +817,8 @@ Enable Turn on notifications to get paid, even when your Bitkit app is closed. GET PAID\n<accent>PASSIVELY</accent> + Keeping Bitkit active results in more reliable payments. Android may show a persistent notification while this is enabled. + Keep Bitkit active in background Notifications Off On diff --git a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt index 350e4d9081..f494a0aa5a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt @@ -159,6 +159,24 @@ class SettingsViewModelTest : BaseUnitTest() { verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(contacts.value.map { it.publicKey }) } + @Test + fun `keepBitkitActiveInBackground defaults to false`() = test { + assertFalse(sut.keepBitkitActiveInBackground.value) + } + + @Test + fun `setKeepBitkitActiveInBackground persists the new value`() = test { + sut.setKeepBitkitActiveInBackground(true) + advanceUntilIdle() + + assertTrue(settingsData.value.keepBitkitActiveInBackground) + + sut.setKeepBitkitActiveInBackground(false) + advanceUntilIdle() + + assertFalse(settingsData.value.keepBitkitActiveInBackground) + } + private fun createViewModel() = SettingsViewModel( settingsStore = settingsStore, pubkyRepo = pubkyRepo, diff --git a/changelog.d/next/fg-service-optional.added.md b/changelog.d/next/fg-service-optional.added.md new file mode 100644 index 0000000000..e35b274d0d --- /dev/null +++ b/changelog.d/next/fg-service-optional.added.md @@ -0,0 +1 @@ +Background payments now include a "Keep Bitkit active in background" option for more reliable payments. From 602fc59508df930bd9876fad363ad6c923aac14f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 29 Jun 2026 13:42:44 -0300 Subject: [PATCH 2/8] feat: implement remove event handler logic to avoid leak --- .../androidServices/LightningNodeService.kt | 17 +++++++++++------ .../to/bitkit/repositories/LightningRepo.kt | 2 ++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 95ad1b22eb..121a90992b 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -35,6 +35,7 @@ import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NotificationDetails import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo +import to.bitkit.services.NodeEventHandler import to.bitkit.services.NodeServiceFgState import to.bitkit.ui.ID_NOTIFICATION_NODE import to.bitkit.ui.MainActivity @@ -80,18 +81,20 @@ class LightningNodeService : Service() { private var hasStartedNode = false + private val nodeEventHandler: NodeEventHandler = { event -> + Logger.debug("LDK-node event received in $TAG: ${jsonLogOf(event)}", context = TAG) + handlePaymentReceived(event) + if (event is Event.ChannelReady) handleChannelReady(event) + handlePendingPaymentResolved(event) + } + private fun setupService() { if (hasStartedNode) return hasStartedNode = true serviceScope.launch { lightningRepo.start( - eventHandler = { event -> - Logger.debug("LDK-node event received in $TAG: ${jsonLogOf(event)}", context = TAG) - handlePaymentReceived(event) - if (event is Event.ChannelReady) handleChannelReady(event) - handlePendingPaymentResolved(event) - } + eventHandler = nodeEventHandler, ).onSuccess { walletRepo.setWalletExistsState() walletRepo.refreshBip21() @@ -238,6 +241,8 @@ class LightningNodeService : Service() { override fun onDestroy() { Logger.debug("onDestroy", context = TAG) nodeServiceFgState.setForegroundServiceRunning(false) + // Drop our event handler so it isn't retained by the repo singleton across service restarts. + lightningRepo.removeEventHandler(nodeEventHandler) // Only stop the node when no activity is active; in the foreground WalletViewModel owns the // node lifecycle, so toggling off the foreground service must leave the node running. // Safe to call even if already stopped — guarded by lifecycleMutex + isStoppedOrStopping() diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index dc4f99a7e4..6dc78920ec 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -429,6 +429,8 @@ class LightningRepo @Inject constructor( result } + fun removeEventHandler(handler: NodeEventHandler) = run { _eventHandlers.remove(handler) } + private suspend fun onEvent(event: Event) { handleLdkEvent(event) recordProbeOutcome(event) From 3ab09d0d90eb44206bc15875c69923deba8763ad Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 29 Jun 2026 13:57:12 -0300 Subject: [PATCH 3/8] feat: feat: match figma on background payments screen --- .../ui/components/NotificationPreview.kt | 59 +++++++++++++------ .../BackgroundPaymentsSettings.kt | 38 +++++------- 2 files changed, 54 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt b/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt index 2169aa50b6..46ee930cd4 100644 --- a/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt +++ b/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt @@ -11,9 +11,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -23,6 +26,9 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.Shapes +/** Degrees to rotate the right chevron so it points downward (expand-more) in the preview. */ +private const val CHEVRON_EXPAND_ROTATION_DEGREES = 90f + @Composable fun NotificationPreview( enabled: Boolean, @@ -30,44 +36,60 @@ fun NotificationPreview( description: String, showDetails: Boolean, modifier: Modifier = Modifier, + time: String = "5m", ) { Box(modifier = modifier) { Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .clip(Shapes.medium) - .background(Colors.White80) - .padding(9.dp) + .clip(Shapes.extraSmall) + .background(Colors.White16) + .padding(16.dp) ) { Image( painter = painterResource(R.drawable.ic_notification), contentDescription = null, - modifier = Modifier - .size(38.dp) + modifier = Modifier.size(24.dp) ) Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.SpaceBetween + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f) ) { - BodySSB(text = title, color = Colors.Black) - val textDescription = when (showDetails) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + BodySSB(text = title, color = Colors.White) + Caption(text = "•", color = Colors.White64) + Caption(text = time, color = Colors.White64) + } + + val bodyText = when (showDetails) { true -> description else -> stringResource(R.string.notification__received__body_hidden) } - AnimatedContent(targetState = textDescription) { text -> - Footnote(text = text, color = Colors.Gray3) + AnimatedContent(targetState = bodyText) { text -> + BodyS(text = text, color = Colors.White80) } } - Caption("3m ago", color = Colors.Gray2) + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = Colors.White64, + modifier = Modifier + .size(16.dp) + .rotate(CHEVRON_EXPAND_ROTATION_DEGREES) + ) } if (!enabled) { Box( modifier = Modifier .matchParentSize() - .clip(Shapes.medium) + .clip(Shapes.extraSmall) .background(Colors.Black70) ) } @@ -79,23 +101,22 @@ fun NotificationPreview( private fun Preview() { AppThemeSurface { Column( + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .fillMaxSize() - .padding(8.dp), - verticalArrangement = Arrangement.Center + .padding(16.dp) ) { NotificationPreview( enabled = true, title = "Payment Received", - description = "₿ 21 000", + description = "₿ 21 000 ($21.00)", showDetails = true, modifier = Modifier.fillMaxWidth() ) - VerticalSpacer(16.dp) NotificationPreview( enabled = false, title = "Payment Received", - description = "₿ 21 000", + description = "₿ 21 000 ($21.00)", showDetails = false, modifier = Modifier.fillMaxWidth() ) diff --git a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt index d06eb44497..23ec22739e 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt @@ -22,8 +22,6 @@ import to.bitkit.ui.components.NotificationPreview import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.components.settings.SettingsButtonRow -import to.bitkit.ui.components.settings.SettingsButtonValue import to.bitkit.ui.components.settings.SettingsSwitchRow import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.scaffold.AppTopBar @@ -55,7 +53,6 @@ fun BackgroundPaymentsSettings( keepActive = keepActive, onBack = onBack, onSystemSettingsClick = context::openNotificationSettings, - toggleNotificationDetails = settingsViewModel::toggleNotificationDetails, onKeepActiveClick = { settingsViewModel.setKeepBitkitActiveInBackground(!keepActive) }, ) } @@ -67,7 +64,6 @@ private fun Content( keepActive: Boolean, onBack: () -> Unit, onSystemSettingsClick: () -> Unit, - toggleNotificationDetails: () -> Unit, onKeepActiveClick: () -> Unit, ) { Column( @@ -123,40 +119,36 @@ private fun Content( modifier = Modifier.padding(vertical = 16.dp), ) - NotificationPreview( - enabled = hasPermission, - title = stringResource(R.string.notification__received__title), - description = "₿ 21 000", - showDetails = showDetails, - modifier = Modifier.fillMaxWidth() - ) - VerticalSpacer(32.dp) Text13Up( - text = stringResource(R.string.settings__bg__privacy_header), + text = stringResource(R.string.settings__bg__notifications_header), color = Colors.White64 ) - SettingsButtonRow( - stringResource(R.string.settings__bg__include_amount), - value = SettingsButtonValue.BooleanValue(showDetails), - onClick = toggleNotificationDetails, + VerticalSpacer(16.dp) + + SecondaryButton( + stringResource(R.string.settings__bg__customize), + icon = { Image(painter = painterResource(R.drawable.ic_bell), contentDescription = null) }, + onClick = onSystemSettingsClick, ) VerticalSpacer(32.dp) Text13Up( - text = stringResource(R.string.settings__bg__notifications_header), + text = stringResource(R.string.common__preview), color = Colors.White64 ) VerticalSpacer(16.dp) - SecondaryButton( - stringResource(R.string.settings__bg__customize), - icon = { Image(painter = painterResource(R.drawable.ic_bell), contentDescription = null) }, - onClick = onSystemSettingsClick, + NotificationPreview( + enabled = hasPermission, + title = stringResource(R.string.notification__received__title), + description = "₿ 21 000 ($21.00)", + showDetails = showDetails, + modifier = Modifier.fillMaxWidth() ) } } @@ -172,7 +164,6 @@ private fun Preview1() { keepActive = true, onBack = {}, onSystemSettingsClick = {}, - toggleNotificationDetails = {}, onKeepActiveClick = {}, ) } @@ -188,7 +179,6 @@ private fun Preview2() { keepActive = false, onBack = {}, onSystemSettingsClick = {}, - toggleNotificationDetails = {}, onKeepActiveClick = {}, ) } From b9a98728be6e03c6159e84e64f53494f617f64ea Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 29 Jun 2026 14:05:19 -0300 Subject: [PATCH 4/8] refactor: always show amount in payment notifications --- .../main/java/to/bitkit/data/SettingsStore.kt | 1 - .../commands/ReceivedNotificationContent.kt | 6 +---- .../ui/components/NotificationPreview.kt | 13 +--------- .../BackgroundPaymentsSettings.kt | 6 ----- .../to/bitkit/viewmodels/SettingsViewModel.kt | 9 ------- .../commands/NotifyChannelReadyHandlerTest.kt | 26 +------------------ .../NotifyPaymentReceivedHandlerTest.kt | 2 +- .../ReceivedNotificationContentTest.kt | 11 +------- 8 files changed, 5 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index d76f9e0b76..ab21f8f496 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -142,7 +142,6 @@ data class SettingsData( val enableSendAmountWarning: Boolean = false, val backupVerified: Boolean = false, val notificationsGranted: Boolean = false, - val showNotificationDetails: Boolean = true, val keepBitkitActiveInBackground: Boolean = false, val dismissedSuggestions: List = emptyList(), val balanceWarningIgnoredMillis: Long = 0, diff --git a/app/src/main/java/to/bitkit/domain/commands/ReceivedNotificationContent.kt b/app/src/main/java/to/bitkit/domain/commands/ReceivedNotificationContent.kt index dd7309f761..822d6a54a5 100644 --- a/app/src/main/java/to/bitkit/domain/commands/ReceivedNotificationContent.kt +++ b/app/src/main/java/to/bitkit/domain/commands/ReceivedNotificationContent.kt @@ -28,11 +28,7 @@ class ReceivedNotificationContent @Inject constructor( suspend fun build(sats: Long): NotificationDetails { val settings = settingsStore.data.first() val title = context.getString(R.string.notification__received__title) - val body = if (settings.showNotificationDetails) { - formatAmount(sats, settings) - } else { - context.getString(R.string.notification__received__body_hidden) - } + val body = formatAmount(sats, settings) return NotificationDetails(title, body) } diff --git a/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt b/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt index 46ee930cd4..10d0f9f634 100644 --- a/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt +++ b/app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt @@ -1,6 +1,5 @@ package to.bitkit.ui.components -import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -18,7 +17,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R @@ -34,7 +32,6 @@ fun NotificationPreview( enabled: Boolean, title: String, description: String, - showDetails: Boolean, modifier: Modifier = Modifier, time: String = "5m", ) { @@ -66,13 +63,7 @@ fun NotificationPreview( Caption(text = time, color = Colors.White64) } - val bodyText = when (showDetails) { - true -> description - else -> stringResource(R.string.notification__received__body_hidden) - } - AnimatedContent(targetState = bodyText) { text -> - BodyS(text = text, color = Colors.White80) - } + BodyS(text = description, color = Colors.White80) } Icon( @@ -110,14 +101,12 @@ private fun Preview() { enabled = true, title = "Payment Received", description = "₿ 21 000 ($21.00)", - showDetails = true, modifier = Modifier.fillMaxWidth() ) NotificationPreview( enabled = false, title = "Payment Received", description = "₿ 21 000 ($21.00)", - showDetails = false, modifier = Modifier.fillMaxWidth() ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt index 23ec22739e..db90c67454 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt @@ -39,7 +39,6 @@ fun BackgroundPaymentsSettings( ) { val context = LocalContext.current val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() - val showNotificationDetails by settingsViewModel.showNotificationDetails.collectAsStateWithLifecycle() val keepActive by settingsViewModel.keepBitkitActiveInBackground.collectAsStateWithLifecycle() RequestNotificationPermissions( @@ -49,7 +48,6 @@ fun BackgroundPaymentsSettings( Content( hasPermission = notificationsGranted, - showDetails = showNotificationDetails, keepActive = keepActive, onBack = onBack, onSystemSettingsClick = context::openNotificationSettings, @@ -60,7 +58,6 @@ fun BackgroundPaymentsSettings( @Composable private fun Content( hasPermission: Boolean, - showDetails: Boolean, keepActive: Boolean, onBack: () -> Unit, onSystemSettingsClick: () -> Unit, @@ -147,7 +144,6 @@ private fun Content( enabled = hasPermission, title = stringResource(R.string.notification__received__title), description = "₿ 21 000 ($21.00)", - showDetails = showDetails, modifier = Modifier.fillMaxWidth() ) } @@ -160,7 +156,6 @@ private fun Preview1() { AppThemeSurface { Content( hasPermission = true, - showDetails = true, keepActive = true, onBack = {}, onSystemSettingsClick = {}, @@ -175,7 +170,6 @@ private fun Preview2() { AppThemeSurface { Content( hasPermission = false, - showDetails = false, keepActive = false, onBack = {}, onSystemSettingsClick = {}, diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index aa9627e2ff..6af5924f53 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -64,15 +64,6 @@ class SettingsViewModel @Inject constructor( } } - val showNotificationDetails = settingsStore.data.map { it.showNotificationDetails } - .asStateFlow(initialValue = false) - - fun toggleNotificationDetails() { - viewModelScope.launch { - settingsStore.update { it.copy(showNotificationDetails = !it.showNotificationDetails) } - } - } - val keepBitkitActiveInBackground = settingsStore.data.map { it.keepBitkitActiveInBackground } .asStateFlow(initialValue = false) diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyChannelReadyHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyChannelReadyHandlerTest.kt index 69f5fcdfef..4dfb328b98 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyChannelReadyHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyChannelReadyHandlerTest.kt @@ -46,7 +46,7 @@ class NotifyChannelReadyHandlerTest : BaseUnitTest() { fun setUp() { whenever(context.getString(R.string.notification__received__title)).thenReturn("Payment Received") whenever(context.getString(any(), any())).thenReturn("Received amount") - whenever(settingsStore.data).thenReturn(flowOf(SettingsData(showNotificationDetails = true))) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenReturn( Result.success( ConvertedAmount( @@ -156,30 +156,6 @@ class NotifyChannelReadyHandlerTest : BaseUnitTest() { verify(activityRepo).insertActivityFromCjit(cjitEntry, channel) } - @Test - fun `notification hides details when showNotificationDetails is false`() = test { - val event = mock { - on { channelId } doReturn "channel-1" - } - val channel = createChannelDetails().copy( - channelId = "channel-1", - outboundCapacityMsat = 3000_000u, - ) - val cjitEntry = IcJitEntry.mock() - whenever(lightningRepo.getChannels()).thenReturn(listOf(channel)) - whenever(blocktankRepo.getCjitEntry(channel)).thenReturn(cjitEntry) - whenever(activityRepo.insertActivityFromCjit(any(), any())).thenReturn(Result.success(true)) - whenever(settingsStore.data).thenReturn(flowOf(SettingsData(showNotificationDetails = false))) - whenever(context.getString(R.string.notification__received__body_hidden)).thenReturn("Hidden") - - val result = sut(NotifyChannelReady.Command(event = event, includeNotification = true)) - - assertTrue(result.isSuccess) - val showNotification = result.getOrThrow() - assertTrue(showNotification is NotifyChannelReady.Result.ShowNotification) - assertEquals("Hidden", showNotification.notification.body) - } - @Test fun `returns Duplicate when activity already exists`() = test { val event = mock { diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt index 007a5b4688..1c935765f6 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -41,7 +41,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { fun setUp() { whenever(context.getString(R.string.notification__received__title)).thenReturn("Payment Received") whenever(context.getString(any(), any())).thenReturn("Received amount") - whenever(settingsStore.data).thenReturn(flowOf(SettingsData(showNotificationDetails = true))) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenReturn( Result.success( ConvertedAmount( diff --git a/app/src/test/java/to/bitkit/domain/commands/ReceivedNotificationContentTest.kt b/app/src/test/java/to/bitkit/domain/commands/ReceivedNotificationContentTest.kt index 7452ef6d3c..d93231094d 100644 --- a/app/src/test/java/to/bitkit/domain/commands/ReceivedNotificationContentTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/ReceivedNotificationContentTest.kt @@ -89,18 +89,9 @@ class ReceivedNotificationContentTest : BaseUnitTest() { assertEquals(expected, result.body) } - @Test - fun `hides the amount when notification details are disabled`() = test { - whenever(settingsStore.data).thenReturn(flowOf(SettingsData(showNotificationDetails = false))) - - val result = sut.build(48_064L) - - assertEquals(context.getString(R.string.notification__received__body_hidden), result.body) - } - private fun stubSettings(primaryDisplay: PrimaryDisplay) { whenever(settingsStore.data).thenReturn( - flowOf(SettingsData(showNotificationDetails = true, primaryDisplay = primaryDisplay)), + flowOf(SettingsData(primaryDisplay = primaryDisplay)), ) } } From 9db59533b1465d244ba424270acea989f18c7b9a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 30 Jun 2026 07:03:23 -0300 Subject: [PATCH 5/8] refactor: drop redundant run --- app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 6dc78920ec..94a8fbc7ef 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -429,7 +429,9 @@ class LightningRepo @Inject constructor( result } - fun removeEventHandler(handler: NodeEventHandler) = run { _eventHandlers.remove(handler) } + fun removeEventHandler(handler: NodeEventHandler) { + _eventHandlers.remove(handler) + } private suspend fun onEvent(event: Event) { handleLdkEvent(event) From ea1f74a169f3ea5ebdefed05f0dd577e7754c199 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 30 Jun 2026 07:09:53 -0300 Subject: [PATCH 6/8] fix: stop service when notification is disabled and keepActive is still true --- app/src/main/java/to/bitkit/ui/ContentView.kt | 6 +++--- app/src/main/java/to/bitkit/ui/MainActivity.kt | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d98a6e7734..bc848aff16 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -246,6 +246,7 @@ fun ContentView( val isRecoveryMode by walletViewModel.isRecoveryMode.collectAsStateWithLifecycle() val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() + val keepActiveInBackground by settingsViewModel.keepBitkitActiveInBackground.collectAsStateWithLifecycle() val walletExists = walletUiState.walletExists val requestNotificationPermission = rememberRequestNotificationPermission( @@ -273,11 +274,10 @@ fun ContentView( } Lifecycle.Event.ON_STOP -> { - if (walletExists && !isRecoveryMode && !notificationsGranted) { - // App backgrounded without notification permission - stop node + val keptAliveByService = notificationsGranted && keepActiveInBackground + if (walletExists && !isRecoveryMode && !keptAliveByService) { walletViewModel.stop() } - // If notificationsGranted=true, service keeps node running } else -> Unit diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 5686349d27..ab7dc370ad 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -135,7 +135,7 @@ class MainActivity : FragmentActivity() { val canStartService = walletExists && notificationsGranted && keepActive && restoreState.isIdle() if (canStartService && !isRecoveryMode) { tryStartForegroundService() - } else if (!keepActive) { + } else { stopForegroundService() } } From c075bca1bb47d6d5e425a1e13863248c30490806 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 30 Jun 2026 07:18:13 -0300 Subject: [PATCH 7/8] fix: stop node onTimeOut only when is in BG --- .../androidServices/LightningNodeService.kt | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 121a90992b..fd70ca71e8 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -243,14 +243,7 @@ class LightningNodeService : Service() { nodeServiceFgState.setForegroundServiceRunning(false) // Drop our event handler so it isn't retained by the repo singleton across service restarts. lightningRepo.removeEventHandler(nodeEventHandler) - // Only stop the node when no activity is active; in the foreground WalletViewModel owns the - // node lifecycle, so toggling off the foreground service must leave the node running. - // Safe to call even if already stopped — guarded by lifecycleMutex + isStoppedOrStopping() - if (App.currentActivity?.value == null) { - serviceScope.launch { lightningRepo.stop() } - } else { - Logger.debug("Skipping node stop on foreground service destroy: activity is active", context = TAG) - } + stopNodeIfBackgrounded() super.onDestroy() } @@ -258,10 +251,18 @@ class LightningNodeService : Service() { override fun onTimeout(startId: Int, fgsType: Int) { Logger.warn("Reached foreground service timeout for type '$fgsType'", context = TAG) stopForegroundService(startId) - serviceScope.launch { lightningRepo.stop() } + stopNodeIfBackgrounded() super.onTimeout(startId, fgsType) } + private fun stopNodeIfBackgrounded() { + if (App.currentActivity?.value == null) { + serviceScope.launch { lightningRepo.stop() } + } else { + Logger.debug("Skipping node stop: activity is active", context = TAG) + } + } + override fun onBind(intent: Intent?): IBinder? = null companion object { From 80fd77505ad918beb46950da10699c08de83d93e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 30 Jun 2026 07:28:20 -0300 Subject: [PATCH 8/8] refactor: remove unnecessary map --- app/src/main/java/to/bitkit/ui/MainActivity.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index ab7dc370ad..8336b7aded 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -31,7 +31,6 @@ import dagger.hilt.android.AndroidEntryPoint import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import to.bitkit.R @@ -118,9 +117,7 @@ class MainActivity : FragmentActivity() { val isRecoveryMode by walletViewModel.isRecoveryMode.collectAsStateWithLifecycle() val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() val keepActive by settingsViewModel.keepBitkitActiveInBackground.collectAsStateWithLifecycle() - val walletExists by walletViewModel.walletState - .map { it.walletExists } - .collectAsStateWithLifecycle(initialValue = walletViewModel.walletExists) + val walletExists = walletViewModel.walletExists val isShowingMigrationLoading by walletViewModel.isShowingMigrationLoading.collectAsStateWithLifecycle() val restoreState by walletViewModel.restoreState.collectAsStateWithLifecycle() val hazeState = rememberHazeState(blurEnabled = true)