Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -238,19 +241,28 @@ class LightningNodeService : Service() {
override fun onDestroy() {
Logger.debug("onDestroy", context = TAG)
nodeServiceFgState.setForegroundServiceRunning(false)
// Safe to call even if already stopped — guarded by lifecycleMutex + isStoppedOrStopping()
serviceScope.launch { lightningRepo.stop() }
// Drop our event handler so it isn't retained by the repo singleton across service restarts.
lightningRepo.removeEventHandler(nodeEventHandler)
stopNodeIfBackgrounded()
super.onDestroy()
}

@RequiresApi(VERSION_CODES.VANILLA_ICE_CREAM)
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 {
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ 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<String> = emptyList(),
val balanceWarningIgnoredMillis: Long = 0,
val backupWarningIgnoredMillis: Long = 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,10 @@ class LightningRepo @Inject constructor(
result
}

fun removeEventHandler(handler: NodeEventHandler) {
_eventHandlers.remove(handler)
}

private suspend fun onEvent(event: Event) {
handleLdkEvent(event)
recordProbeOutcome(event)
Expand Down
6 changes: 3 additions & 3 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this observer can use stale notificationsGranted and keepActiveInBackground values because the effect is keyed only by lifecycle. If the user toggles Keep Bitkit active during the same activity session, the next ON_STOP can still follow the old setting and either stop the node despite the service being enabled or leave it running after the service was disabled. Could we keep these values current inside the observer, for example with rememberUpdatedState or by recreating the effect when they change?

if (walletExists && !isRecoveryMode && !keptAliveByService) {
walletViewModel.stop()
}
// If notificationsGranted=true, service keeps node running
}

else -> Unit
Expand Down
23 changes: 15 additions & 8 deletions app/src/main/java/to/bitkit/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -117,9 +116,8 @@ class MainActivity : FragmentActivity() {
val scope = rememberCoroutineScope()
val isRecoveryMode by walletViewModel.isRecoveryMode.collectAsStateWithLifecycle()
val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle()
val walletExists by walletViewModel.walletState
.map { it.walletExists }
.collectAsStateWithLifecycle(initialValue = walletViewModel.walletExists)
val keepActive by settingsViewModel.keepBitkitActiveInBackground.collectAsStateWithLifecycle()
val walletExists = walletViewModel.walletExists
val isShowingMigrationLoading by walletViewModel.isShowingMigrationLoading.collectAsStateWithLifecycle()
val restoreState by walletViewModel.restoreState.collectAsStateWithLifecycle()
val hazeState = rememberHazeState(blurEnabled = true)
Expand All @@ -128,11 +126,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 {
stopForegroundService()
Comment thread
jvsena42 marked this conversation as resolved.
}
}
Comment thread
jvsena42 marked this conversation as resolved.

Expand Down Expand Up @@ -264,9 +265,7 @@ class MainActivity : FragmentActivity() {
override fun onDestroy() {
super.onDestroy()
if (!settingsViewModel.notificationsGranted.value) {
runCatching {
stopService(Intent(this, LightningNodeService::class.java))
}
stopForegroundService()
}
}

Expand All @@ -285,6 +284,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? {
Expand Down
64 changes: 37 additions & 27 deletions app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,63 +10,77 @@ 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
import androidx.compose.ui.unit.dp
import to.bitkit.R
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,
title: String,
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) {
true -> description
else -> stringResource(R.string.notification__received__body_hidden)
}
AnimatedContent(targetState = textDescription) { text ->
Footnote(text = text, color = Colors.Gray3)
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)
}

BodyS(text = description, 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)
)
}
Expand All @@ -79,24 +92,21 @@ 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",
showDetails = true,
description = "₿ 21 000 ($21.00)",
modifier = Modifier.fillMaxWidth()
)
VerticalSpacer(16.dp)
NotificationPreview(
enabled = false,
title = "Payment Received",
description = "₿ 21 000",
showDetails = false,
description = "₿ 21 000 ($21.00)",
modifier = Modifier.fillMaxWidth()
)
}
Expand Down
Loading
Loading