Skip to content

Commit 77a9411

Browse files
authored
Merge pull request #787 from synonymdev/fix/channelready-cjit
fix: handle cjit channel ready notification
2 parents 335038c + 10f3322 commit 77a9411

21 files changed

Lines changed: 1206 additions & 112 deletions

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import to.bitkit.appwidget.AppWidgetRefreshReason
2424
import to.bitkit.appwidget.AppWidgetRefreshScheduler
2525
import to.bitkit.data.CacheStore
2626
import to.bitkit.di.UiDispatcher
27+
import to.bitkit.domain.commands.NotifyChannelReady
28+
import to.bitkit.domain.commands.NotifyChannelReadyHandler
2729
import to.bitkit.domain.commands.NotifyPaymentReceived
2830
import to.bitkit.domain.commands.NotifyPaymentReceivedHandler
2931
import to.bitkit.domain.commands.NotifyPendingPaymentResolved
@@ -33,6 +35,7 @@ import to.bitkit.models.NewTransactionSheetDetails
3335
import to.bitkit.models.NotificationDetails
3436
import to.bitkit.repositories.LightningRepo
3537
import to.bitkit.repositories.WalletRepo
38+
import to.bitkit.services.NodeServiceFgState
3639
import to.bitkit.ui.ID_NOTIFICATION_NODE
3740
import to.bitkit.ui.MainActivity
3841
import to.bitkit.ui.pushNotification
@@ -60,6 +63,9 @@ class LightningNodeService : Service() {
6063
@Inject
6164
lateinit var notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler
6265

66+
@Inject
67+
lateinit var notifyChannelReadyHandler: NotifyChannelReadyHandler
68+
6369
@Inject
6470
lateinit var notifyPendingPaymentResolvedHandler: NotifyPendingPaymentResolvedHandler
6571

@@ -69,6 +75,9 @@ class LightningNodeService : Service() {
6975
@Inject
7076
lateinit var appWidgetRefreshScheduler: AppWidgetRefreshScheduler
7177

78+
@Inject
79+
lateinit var nodeServiceFgState: NodeServiceFgState
80+
7281
private var hasStartedNode = false
7382

7483
private fun setupService() {
@@ -80,6 +89,7 @@ class LightningNodeService : Service() {
8089
eventHandler = { event ->
8190
Logger.debug("LDK-node event received in $TAG: ${jsonLogOf(event)}", context = TAG)
8291
handlePaymentReceived(event)
92+
if (event is Event.ChannelReady) handleChannelReady(event)
8393
handlePendingPaymentResolved(event)
8494
}
8595
).onSuccess {
@@ -101,6 +111,21 @@ class LightningNodeService : Service() {
101111
}
102112
}
103113

114+
private suspend fun handleChannelReady(event: Event.ChannelReady) {
115+
// When the app is in the foreground, AppViewModel handles this event and shows the receive
116+
// sheet. Running here would consume the CJIT dedup gate (insertActivityFromCjit) and then
117+
// skip the notification (foreground), leaving the in-app handler to see Duplicate and show
118+
// nothing — so defer entirely.
119+
if (App.currentActivity?.value != null) return
120+
121+
val command = NotifyChannelReady.Command(event = event, includeNotification = true)
122+
notifyChannelReadyHandler(command).onSuccess {
123+
Logger.debug("Channel ready notification result: $it", context = TAG)
124+
if (it !is NotifyChannelReady.Result.ShowNotification) return
125+
showPaymentNotification(it.sheet, it.notification)
126+
}
127+
}
128+
104129
private fun showPaymentNotification(
105130
sheet: NewTransactionSheetDetails,
106131
notification: NotificationDetails,
@@ -169,7 +194,10 @@ class LightningNodeService : Service() {
169194
serviceScope.launch { lightningRepo.stop() }
170195
}
171196

172-
ACTION_START_SERVICE -> if (promoteToForeground(startId)) setupService()
197+
ACTION_START_SERVICE -> if (promoteToForeground(startId)) {
198+
nodeServiceFgState.setForegroundServiceRunning(true)
199+
setupService()
200+
}
173201
else -> stop(startId) { Logger.warn("Stopped service for unsupported action '$action'", context = TAG) }
174202
}
175203
return START_NOT_STICKY
@@ -209,6 +237,7 @@ class LightningNodeService : Service() {
209237

210238
override fun onDestroy() {
211239
Logger.debug("onDestroy", context = TAG)
240+
nodeServiceFgState.setForegroundServiceRunning(false)
212241
// Safe to call even if already stopped — guarded by lifecycleMutex + isStoppedOrStopping()
213242
serviceScope.launch { lightningRepo.stop() }
214243
super.onDestroy()
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package to.bitkit.domain.commands
2+
3+
import org.lightningdevkit.ldknode.Event
4+
import to.bitkit.models.NewTransactionSheetDetails
5+
import to.bitkit.models.NotificationDetails
6+
7+
sealed interface NotifyChannelReady {
8+
9+
data class Command(
10+
val event: Event.ChannelReady,
11+
val includeNotification: Boolean = false,
12+
) : NotifyChannelReady
13+
14+
sealed interface Result : NotifyChannelReady {
15+
data class ShowSheet(
16+
val sheet: NewTransactionSheetDetails,
17+
) : Result
18+
19+
data class ShowNotification(
20+
val sheet: NewTransactionSheetDetails,
21+
val notification: NotificationDetails,
22+
) : Result
23+
24+
data object Skip : Result
25+
26+
data object Duplicate : Result
27+
}
28+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package to.bitkit.domain.commands
2+
3+
import kotlinx.coroutines.CoroutineDispatcher
4+
import kotlinx.coroutines.withContext
5+
import to.bitkit.di.IoDispatcher
6+
import to.bitkit.ext.amountOnClose
7+
import to.bitkit.models.NewTransactionSheetDetails
8+
import to.bitkit.models.NewTransactionSheetDirection
9+
import to.bitkit.models.NewTransactionSheetType
10+
import to.bitkit.repositories.ActivityRepo
11+
import to.bitkit.repositories.BlocktankRepo
12+
import to.bitkit.repositories.LightningRepo
13+
import to.bitkit.utils.Logger
14+
import javax.inject.Inject
15+
import javax.inject.Singleton
16+
17+
@Suppress("LongParameterList")
18+
@Singleton
19+
class NotifyChannelReadyHandler @Inject constructor(
20+
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
21+
private val lightningRepo: LightningRepo,
22+
private val blocktankRepo: BlocktankRepo,
23+
private val activityRepo: ActivityRepo,
24+
private val receivedNotificationContent: ReceivedNotificationContent,
25+
) {
26+
companion object {
27+
const val TAG = "NotifyChannelReadyHandler"
28+
}
29+
30+
suspend operator fun invoke(
31+
command: NotifyChannelReady.Command,
32+
): Result<NotifyChannelReady.Result> = withContext(ioDispatcher) {
33+
runCatching {
34+
val channel = lightningRepo.getChannels()
35+
?.find { it.channelId == command.event.channelId }
36+
?: return@runCatching NotifyChannelReady.Result.Skip
37+
38+
val cjitEntry = blocktankRepo.getCjitEntry(channel)
39+
?: return@runCatching NotifyChannelReady.Result.Skip
40+
41+
val inserted = activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel)
42+
.getOrDefault(false)
43+
if (!inserted) return@runCatching NotifyChannelReady.Result.Duplicate
44+
45+
val sats = channel.amountOnClose.toLong()
46+
47+
val details = NewTransactionSheetDetails(
48+
type = NewTransactionSheetType.LIGHTNING,
49+
direction = NewTransactionSheetDirection.RECEIVED,
50+
sats = sats,
51+
)
52+
53+
if (command.includeNotification) {
54+
val notification = receivedNotificationContent.build(sats)
55+
NotifyChannelReady.Result.ShowNotification(details, notification)
56+
} else {
57+
NotifyChannelReady.Result.ShowSheet(details)
58+
}
59+
}.onFailure {
60+
Logger.error("Failed to process channel ready notification", it, context = TAG)
61+
}
62+
}
63+
}

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

Lines changed: 2 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,23 @@
11
package to.bitkit.domain.commands
22

3-
import android.content.Context
4-
import dagger.hilt.android.qualifiers.ApplicationContext
53
import kotlinx.coroutines.CoroutineDispatcher
64
import kotlinx.coroutines.delay
7-
import kotlinx.coroutines.flow.first
85
import kotlinx.coroutines.withContext
9-
import to.bitkit.R
10-
import to.bitkit.data.SettingsData
11-
import to.bitkit.data.SettingsStore
126
import to.bitkit.di.IoDispatcher
13-
import to.bitkit.models.BITCOIN_SYMBOL
147
import to.bitkit.models.NewTransactionSheetDetails
158
import to.bitkit.models.NewTransactionSheetDirection
169
import to.bitkit.models.NewTransactionSheetType
17-
import to.bitkit.models.NotificationDetails
18-
import to.bitkit.models.PrimaryDisplay
19-
import to.bitkit.models.formatToModernDisplay
2010
import to.bitkit.models.msatCeilOf
2111
import to.bitkit.repositories.ActivityRepo
22-
import to.bitkit.repositories.CurrencyRepo
2312
import to.bitkit.utils.Logger
2413
import javax.inject.Inject
2514
import javax.inject.Singleton
2615

2716
@Singleton
2817
class NotifyPaymentReceivedHandler @Inject constructor(
29-
@ApplicationContext private val context: Context,
3018
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
3119
private val activityRepo: ActivityRepo,
32-
private val currencyRepo: CurrencyRepo,
33-
private val settingsStore: SettingsStore,
20+
private val receivedNotificationContent: ReceivedNotificationContent,
3421
) {
3522
suspend operator fun invoke(
3623
command: NotifyPaymentReceived.Command,
@@ -46,7 +33,7 @@ class NotifyPaymentReceivedHandler @Inject constructor(
4633
val details = buildSheetDetails(command)
4734

4835
if (command.includeNotification) {
49-
val notification = buildNotificationContent(details.sats)
36+
val notification = receivedNotificationContent.build(details.sats)
5037
NotifyPaymentReceived.Result.ShowNotification(details, notification)
5138
} else {
5239
NotifyPaymentReceived.Result.ShowSheet(details)
@@ -103,32 +90,6 @@ class NotifyPaymentReceivedHandler @Inject constructor(
10390
},
10491
)
10592

106-
private suspend fun buildNotificationContent(sats: Long): NotificationDetails {
107-
val settings = settingsStore.data.first()
108-
val title = context.getString(R.string.notification__received__title)
109-
val body = if (settings.showNotificationDetails) {
110-
formatNotificationAmount(sats, settings)
111-
} else {
112-
context.getString(R.string.notification__received__body_hidden)
113-
}
114-
return NotificationDetails(title, body)
115-
}
116-
117-
private fun formatNotificationAmount(sats: Long, settings: SettingsData): String {
118-
val converted = currencyRepo.convertSatsToFiat(sats).getOrNull()
119-
120-
val amountText = converted?.let {
121-
val btcDisplay = it.bitcoinDisplay(settings.displayUnit)
122-
if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) {
123-
"${btcDisplay.symbol} ${btcDisplay.value} (${it.formattedWithSymbol()})"
124-
} else {
125-
"${it.formattedWithSymbol()} (${btcDisplay.symbol} ${btcDisplay.value})"
126-
}
127-
} ?: "$BITCOIN_SYMBOL ${sats.formatToModernDisplay()}"
128-
129-
return context.getString(R.string.notification__received__body_amount, amountText)
130-
}
131-
13293
companion object {
13394
const val TAG = "NotifyPaymentReceivedHandler"
13495

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package to.bitkit.domain.commands
2+
3+
import android.content.Context
4+
import dagger.hilt.android.qualifiers.ApplicationContext
5+
import kotlinx.coroutines.flow.first
6+
import to.bitkit.R
7+
import to.bitkit.data.SettingsData
8+
import to.bitkit.data.SettingsStore
9+
import to.bitkit.models.BITCOIN_SYMBOL
10+
import to.bitkit.models.NotificationDetails
11+
import to.bitkit.models.PrimaryDisplay
12+
import to.bitkit.models.formatToModernDisplay
13+
import to.bitkit.repositories.CurrencyRepo
14+
import javax.inject.Inject
15+
import javax.inject.Singleton
16+
17+
/**
18+
* Builds the user-facing "Payment Received" notification content, applying the same currency
19+
* formatting rules everywhere a received payment is surfaced (foreground service, in-app handler,
20+
* and the background [to.bitkit.fcm.WakeNodeWorker] push path).
21+
*/
22+
@Singleton
23+
class ReceivedNotificationContent @Inject constructor(
24+
@ApplicationContext private val context: Context,
25+
private val currencyRepo: CurrencyRepo,
26+
private val settingsStore: SettingsStore,
27+
) {
28+
suspend fun build(sats: Long): NotificationDetails {
29+
val settings = settingsStore.data.first()
30+
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+
}
36+
return NotificationDetails(title, body)
37+
}
38+
39+
private fun formatAmount(sats: Long, settings: SettingsData): String {
40+
val converted = currencyRepo.convertSatsToFiat(sats).getOrNull()
41+
42+
val amountText = converted?.let {
43+
val btcDisplay = it.bitcoinDisplay(settings.displayUnit)
44+
if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) {
45+
"${btcDisplay.symbol} ${btcDisplay.value} (${it.formattedWithSymbol()})"
46+
} else {
47+
"${it.formattedWithSymbol()} (${btcDisplay.symbol} ${btcDisplay.value})"
48+
}
49+
} ?: "$BITCOIN_SYMBOL ${sats.formatToModernDisplay()}"
50+
51+
return context.getString(R.string.notification__received__body_amount, amountText)
52+
}
53+
}

0 commit comments

Comments
 (0)