Skip to content

Commit 435d9b0

Browse files
committed
chore: merge master
2 parents db4045f + a3aeae6 commit 435d9b0

34 files changed

Lines changed: 744 additions & 232 deletions

app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import to.bitkit.models.NewTransactionSheetDetails
3535
import to.bitkit.models.NotificationDetails
3636
import to.bitkit.repositories.LightningRepo
3737
import to.bitkit.repositories.WalletRepo
38+
import to.bitkit.services.NodeEventHandler
3839
import to.bitkit.services.NodeServiceFgState
3940
import to.bitkit.ui.ID_NOTIFICATION_NODE
4041
import to.bitkit.ui.MainActivity
@@ -80,18 +81,20 @@ class LightningNodeService : Service() {
8081

8182
private var hasStartedNode = false
8283

84+
private val nodeEventHandler: NodeEventHandler = { event ->
85+
Logger.debug("LDK-node event received in $TAG: ${jsonLogOf(event)}", context = TAG)
86+
handlePaymentReceived(event)
87+
if (event is Event.ChannelReady) handleChannelReady(event)
88+
handlePendingPaymentResolved(event)
89+
}
90+
8391
private fun setupService() {
8492
if (hasStartedNode) return
8593
hasStartedNode = true
8694

8795
serviceScope.launch {
8896
lightningRepo.start(
89-
eventHandler = { event ->
90-
Logger.debug("LDK-node event received in $TAG: ${jsonLogOf(event)}", context = TAG)
91-
handlePaymentReceived(event)
92-
if (event is Event.ChannelReady) handleChannelReady(event)
93-
handlePendingPaymentResolved(event)
94-
}
97+
eventHandler = nodeEventHandler,
9598
).onSuccess {
9699
walletRepo.setWalletExistsState()
97100
walletRepo.refreshBip21()
@@ -238,19 +241,28 @@ class LightningNodeService : Service() {
238241
override fun onDestroy() {
239242
Logger.debug("onDestroy", context = TAG)
240243
nodeServiceFgState.setForegroundServiceRunning(false)
241-
// Safe to call even if already stopped — guarded by lifecycleMutex + isStoppedOrStopping()
242-
serviceScope.launch { lightningRepo.stop() }
244+
// Drop our event handler so it isn't retained by the repo singleton across service restarts.
245+
lightningRepo.removeEventHandler(nodeEventHandler)
246+
stopNodeIfBackgrounded()
243247
super.onDestroy()
244248
}
245249

246250
@RequiresApi(VERSION_CODES.VANILLA_ICE_CREAM)
247251
override fun onTimeout(startId: Int, fgsType: Int) {
248252
Logger.warn("Reached foreground service timeout for type '$fgsType'", context = TAG)
249253
stopForegroundService(startId)
250-
serviceScope.launch { lightningRepo.stop() }
254+
stopNodeIfBackgrounded()
251255
super.onTimeout(startId, fgsType)
252256
}
253257

258+
private fun stopNodeIfBackgrounded() {
259+
if (App.currentActivity?.value == null) {
260+
serviceScope.launch { lightningRepo.stop() }
261+
} else {
262+
Logger.debug("Skipping node stop: activity is active", context = TAG)
263+
}
264+
}
265+
254266
override fun onBind(intent: Intent?): IBinder? = null
255267

256268
companion object {

app/src/main/java/to/bitkit/data/SettingsStore.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ data class SettingsData(
142142
val enableSendAmountWarning: Boolean = false,
143143
val backupVerified: Boolean = false,
144144
val notificationsGranted: Boolean = false,
145-
val showNotificationDetails: Boolean = true,
145+
val keepBitkitActiveInBackground: Boolean = false,
146146
val dismissedSuggestions: List<String> = emptyList(),
147147
val balanceWarningIgnoredMillis: Long = 0,
148148
val backupWarningIgnoredMillis: Long = 0,

app/src/main/java/to/bitkit/domain/commands/ReceivedNotificationContent.kt

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,7 @@ class ReceivedNotificationContent @Inject constructor(
2828
suspend fun build(sats: Long): NotificationDetails {
2929
val settings = settingsStore.data.first()
3030
val title = context.getString(R.string.notification__received__title)
31-
val body = if (settings.showNotificationDetails) {
32-
formatAmount(sats, settings)
33-
} else {
34-
context.getString(R.string.notification__received__body_hidden)
35-
}
31+
val body = formatAmount(sats, settings)
3632
return NotificationDetails(title, body)
3733
}
3834

app/src/main/java/to/bitkit/repositories/LightningRepo.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,10 @@ class LightningRepo @Inject constructor(
432432
result
433433
}
434434

435+
fun removeEventHandler(handler: NodeEventHandler) {
436+
_eventHandlers.remove(handler)
437+
}
438+
435439
private suspend fun onEvent(event: Event) {
436440
handleLdkEvent(event)
437441
recordProbeOutcome(event)

app/src/main/java/to/bitkit/repositories/TransferRepo.kt

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.Flow
1010
import kotlinx.coroutines.flow.combine
1111
import kotlinx.coroutines.flow.first
1212
import kotlinx.coroutines.withContext
13+
import org.lightningdevkit.ldknode.BalanceDetails
1314
import org.lightningdevkit.ldknode.ChannelDetails
1415
import org.lightningdevkit.ldknode.PendingSweepBalance
1516
import to.bitkit.data.dao.TransferDao
@@ -161,33 +162,47 @@ class TransferRepo @Inject constructor(
161162
}
162163
}
163164

164-
val toSavings = activeTransfers.filter { it.type.isToSavings() }
165-
166-
for (transfer in toSavings) {
167-
val channelId = resolveChannelIdForTransfer(transfer, channels)
168-
val hasBalance = balances?.lightningBalances?.any {
169-
it.channelId() == channelId
170-
} ?: false
171-
172-
if (!hasBalance) {
173-
if (transfer.type == TransferType.FORCE_CLOSE) {
174-
settleForceClose(transfer, channelId, balances?.pendingBalancesFromChannelClosures)
175-
} else {
176-
markSettled(transfer.id)
177-
Logger.debug(
178-
"Channel $channelId balance swept, settled transfer: ${transfer.id}",
179-
context = TAG
180-
)
181-
}
182-
}
183-
}
165+
settleToSavingsTransfers(activeTransfers, channels, balances)
184166
}.onSuccess {
185167
Logger.verbose("syncTransferStates completed", context = TAG)
186168
}.onFailure { e ->
187169
Logger.error("syncTransferStates error", e, context = TAG)
188170
}
189171
}
190172

173+
private suspend fun settleToSavingsTransfers(
174+
activeTransfers: List<TransferEntity>,
175+
channels: List<ChannelDetails>,
176+
balances: BalanceDetails?,
177+
) {
178+
val toSavings = activeTransfers.filter { it.type.isToSavings() }
179+
if (toSavings.isEmpty()) return
180+
181+
val balanceDetails = balances ?: run {
182+
Logger.debug("Skipped settling to-savings transfers because balances are unavailable", context = TAG)
183+
return
184+
}
185+
186+
for (transfer in toSavings) {
187+
val channelId = resolveChannelIdForTransfer(transfer, channels)
188+
val hasBalance = balanceDetails.lightningBalances.any {
189+
it.channelId() == channelId
190+
}
191+
192+
if (!hasBalance) {
193+
if (transfer.type == TransferType.FORCE_CLOSE) {
194+
settleForceClose(transfer, channelId, balanceDetails.pendingBalancesFromChannelClosures)
195+
} else {
196+
markSettled(transfer.id)
197+
Logger.debug(
198+
"Channel $channelId balance swept, settled transfer: ${transfer.id}",
199+
context = TAG
200+
)
201+
}
202+
}
203+
}
204+
}
205+
191206
private suspend fun settleForceClose(
192207
transfer: TransferEntity,
193208
channelId: String?,

app/src/main/java/to/bitkit/ui/ContentView.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ fun ContentView(
246246

247247
val isRecoveryMode by walletViewModel.isRecoveryMode.collectAsStateWithLifecycle()
248248
val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle()
249+
val keepActiveInBackground by settingsViewModel.keepBitkitActiveInBackground.collectAsStateWithLifecycle()
249250
val walletExists = walletUiState.walletExists
250251

251252
val requestNotificationPermission = rememberRequestNotificationPermission(
@@ -273,11 +274,12 @@ fun ContentView(
273274
}
274275

275276
Lifecycle.Event.ON_STOP -> {
276-
if (walletExists && !isRecoveryMode && !notificationsGranted) {
277-
// App backgrounded without notification permission - stop node
277+
val keptAliveByService = notificationsGranted &&
278+
keepActiveInBackground &&
279+
appViewModel.isForegroundServiceRunning()
280+
if (walletExists && !isRecoveryMode && !keptAliveByService) {
278281
walletViewModel.stop()
279282
}
280-
// If notificationsGranted=true, service keeps node running
281283
}
282284

283285
else -> Unit

app/src/main/java/to/bitkit/ui/MainActivity.kt

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import dagger.hilt.android.AndroidEntryPoint
3131
import dev.chrisbanes.haze.hazeSource
3232
import dev.chrisbanes.haze.rememberHazeState
3333
import kotlinx.coroutines.CoroutineScope
34-
import kotlinx.coroutines.flow.map
3534
import kotlinx.coroutines.launch
3635
import kotlinx.serialization.Serializable
3736
import to.bitkit.R
@@ -117,9 +116,8 @@ class MainActivity : FragmentActivity() {
117116
val scope = rememberCoroutineScope()
118117
val isRecoveryMode by walletViewModel.isRecoveryMode.collectAsStateWithLifecycle()
119118
val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle()
120-
val walletExists by walletViewModel.walletState
121-
.map { it.walletExists }
122-
.collectAsStateWithLifecycle(initialValue = walletViewModel.walletExists)
119+
val keepActive by settingsViewModel.keepBitkitActiveInBackground.collectAsStateWithLifecycle()
120+
val walletExists = walletViewModel.walletExists
123121
val isShowingMigrationLoading by walletViewModel.isShowingMigrationLoading.collectAsStateWithLifecycle()
124122
val restoreState by walletViewModel.restoreState.collectAsStateWithLifecycle()
125123
val hazeState = rememberHazeState(blurEnabled = true)
@@ -128,11 +126,14 @@ class MainActivity : FragmentActivity() {
128126
walletExists,
129127
isRecoveryMode,
130128
notificationsGranted,
129+
keepActive,
131130
restoreState,
132131
) {
133-
val canStartService = walletExists && notificationsGranted && restoreState.isIdle()
132+
val canStartService = walletExists && notificationsGranted && keepActive && restoreState.isIdle()
134133
if (canStartService && !isRecoveryMode) {
135134
tryStartForegroundService()
135+
} else {
136+
stopForegroundService()
136137
}
137138
}
138139

@@ -264,9 +265,7 @@ class MainActivity : FragmentActivity() {
264265
override fun onDestroy() {
265266
super.onDestroy()
266267
if (!settingsViewModel.notificationsGranted.value) {
267-
runCatching {
268-
stopService(Intent(this, LightningNodeService::class.java))
269-
}
268+
stopForegroundService()
270269
}
271270
}
272271

@@ -285,6 +284,14 @@ class MainActivity : FragmentActivity() {
285284
Logger.error("Failed to start LightningNodeService", error, context = "MainActivity")
286285
}
287286
}
287+
288+
private fun stopForegroundService() {
289+
runCatching {
290+
stopService(Intent(this, LightningNodeService::class.java))
291+
}.onFailure { error ->
292+
Logger.error("Failed to stop LightningNodeService", error, context = "MainActivity")
293+
}
294+
}
288295
}
289296

290297
internal fun Intent?.launchKey(): String? {

app/src/main/java/to/bitkit/ui/components/NotificationPreview.kt

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package to.bitkit.ui.components
22

3-
import androidx.compose.animation.AnimatedContent
43
import androidx.compose.foundation.Image
54
import androidx.compose.foundation.background
65
import androidx.compose.foundation.layout.Arrangement
@@ -11,63 +10,77 @@ import androidx.compose.foundation.layout.fillMaxSize
1110
import androidx.compose.foundation.layout.fillMaxWidth
1211
import androidx.compose.foundation.layout.padding
1312
import androidx.compose.foundation.layout.size
13+
import androidx.compose.material3.Icon
1414
import androidx.compose.runtime.Composable
15+
import androidx.compose.ui.Alignment
1516
import androidx.compose.ui.Modifier
1617
import androidx.compose.ui.draw.clip
18+
import androidx.compose.ui.draw.rotate
1719
import androidx.compose.ui.res.painterResource
18-
import androidx.compose.ui.res.stringResource
1920
import androidx.compose.ui.tooling.preview.Preview
2021
import androidx.compose.ui.unit.dp
2122
import to.bitkit.R
2223
import to.bitkit.ui.theme.AppThemeSurface
2324
import to.bitkit.ui.theme.Colors
2425
import to.bitkit.ui.theme.Shapes
2526

27+
/** Degrees to rotate the right chevron so it points downward (expand-more) in the preview. */
28+
private const val CHEVRON_EXPAND_ROTATION_DEGREES = 90f
29+
2630
@Composable
2731
fun NotificationPreview(
2832
enabled: Boolean,
2933
title: String,
3034
description: String,
31-
showDetails: Boolean,
3235
modifier: Modifier = Modifier,
36+
time: String = "5m",
3337
) {
3438
Box(modifier = modifier) {
3539
Row(
36-
horizontalArrangement = Arrangement.spacedBy(8.dp),
40+
horizontalArrangement = Arrangement.spacedBy(14.dp),
41+
verticalAlignment = Alignment.CenterVertically,
3742
modifier = Modifier
38-
.clip(Shapes.medium)
39-
.background(Colors.White80)
40-
.padding(9.dp)
43+
.clip(Shapes.extraSmall)
44+
.background(Colors.White16)
45+
.padding(16.dp)
4146
) {
4247
Image(
4348
painter = painterResource(R.drawable.ic_notification),
4449
contentDescription = null,
45-
modifier = Modifier
46-
.size(38.dp)
50+
modifier = Modifier.size(24.dp)
4751
)
4852

4953
Column(
50-
modifier = Modifier.weight(1f),
51-
verticalArrangement = Arrangement.SpaceBetween
54+
verticalArrangement = Arrangement.spacedBy(4.dp),
55+
modifier = Modifier.weight(1f)
5256
) {
53-
BodySSB(text = title, color = Colors.Black)
54-
val textDescription = when (showDetails) {
55-
true -> description
56-
else -> stringResource(R.string.notification__received__body_hidden)
57-
}
58-
AnimatedContent(targetState = textDescription) { text ->
59-
Footnote(text = text, color = Colors.Gray3)
57+
Row(
58+
horizontalArrangement = Arrangement.spacedBy(4.dp),
59+
verticalAlignment = Alignment.CenterVertically,
60+
) {
61+
BodySSB(text = title, color = Colors.White)
62+
Caption(text = "", color = Colors.White64)
63+
Caption(text = time, color = Colors.White64)
6064
}
65+
66+
BodyS(text = description, color = Colors.White80)
6167
}
6268

63-
Caption("3m ago", color = Colors.Gray2)
69+
Icon(
70+
painter = painterResource(R.drawable.ic_chevron_right),
71+
contentDescription = null,
72+
tint = Colors.White64,
73+
modifier = Modifier
74+
.size(16.dp)
75+
.rotate(CHEVRON_EXPAND_ROTATION_DEGREES)
76+
)
6477
}
6578

6679
if (!enabled) {
6780
Box(
6881
modifier = Modifier
6982
.matchParentSize()
70-
.clip(Shapes.medium)
83+
.clip(Shapes.extraSmall)
7184
.background(Colors.Black70)
7285
)
7386
}
@@ -79,24 +92,21 @@ fun NotificationPreview(
7992
private fun Preview() {
8093
AppThemeSurface {
8194
Column(
95+
verticalArrangement = Arrangement.spacedBy(16.dp),
8296
modifier = Modifier
8397
.fillMaxSize()
84-
.padding(8.dp),
85-
verticalArrangement = Arrangement.Center
98+
.padding(16.dp)
8699
) {
87100
NotificationPreview(
88101
enabled = true,
89102
title = "Payment Received",
90-
description = "₿ 21 000",
91-
showDetails = true,
103+
description = "₿ 21 000 ($21.00)",
92104
modifier = Modifier.fillMaxWidth()
93105
)
94-
VerticalSpacer(16.dp)
95106
NotificationPreview(
96107
enabled = false,
97108
title = "Payment Received",
98-
description = "₿ 21 000",
99-
showDetails = false,
109+
description = "₿ 21 000 ($21.00)",
100110
modifier = Modifier.fillMaxWidth()
101111
)
102112
}

0 commit comments

Comments
 (0)