Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import to.bitkit.appwidget.AppWidgetRefreshReason
import to.bitkit.appwidget.AppWidgetRefreshScheduler
import to.bitkit.data.CacheStore
import to.bitkit.di.UiDispatcher
import to.bitkit.domain.commands.NotifyChannelReady
import to.bitkit.domain.commands.NotifyChannelReadyHandler
import to.bitkit.domain.commands.NotifyPaymentReceived
import to.bitkit.domain.commands.NotifyPaymentReceivedHandler
import to.bitkit.domain.commands.NotifyPendingPaymentResolved
Expand Down Expand Up @@ -60,6 +62,9 @@ class LightningNodeService : Service() {
@Inject
lateinit var notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler

@Inject
lateinit var notifyChannelReadyHandler: NotifyChannelReadyHandler

@Inject
lateinit var notifyPendingPaymentResolvedHandler: NotifyPendingPaymentResolvedHandler

Expand All @@ -80,6 +85,7 @@ class LightningNodeService : Service() {
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)
}
).onSuccess {
Expand All @@ -101,6 +107,15 @@ class LightningNodeService : Service() {
}
}

private suspend fun handleChannelReady(event: Event.ChannelReady) {
val command = NotifyChannelReady.Command(event = event, includeNotification = true)
notifyChannelReadyHandler(command).onSuccess {
Logger.debug("Channel ready notification result: $it", context = TAG)
if (it !is NotifyChannelReady.Result.ShowNotification) return
showPaymentNotification(it.sheet, it.notification)
}
}

private fun showPaymentNotification(
sheet: NewTransactionSheetDetails,
notification: NotificationDetails,
Expand Down Expand Up @@ -169,7 +184,10 @@ class LightningNodeService : Service() {
serviceScope.launch { lightningRepo.stop() }
}

ACTION_START_SERVICE -> if (promoteToForeground(startId)) setupService()
ACTION_START_SERVICE -> if (promoteToForeground(startId)) {
isRunning = true
setupService()
}
else -> stop(startId) { Logger.warn("Stopped service for unsupported action '$action'", context = TAG) }
}
return START_NOT_STICKY
Expand Down Expand Up @@ -209,6 +227,7 @@ class LightningNodeService : Service() {

override fun onDestroy() {
Logger.debug("onDestroy", context = TAG)
isRunning = false
// Safe to call even if already stopped — guarded by lifecycleMutex + isStoppedOrStopping()
serviceScope.launch { lightningRepo.stop() }
super.onDestroy()
Expand All @@ -225,6 +244,10 @@ class LightningNodeService : Service() {
override fun onBind(intent: Intent?): IBinder? = null

companion object {
@Volatile
var isRunning = false
internal set

const val CHANNEL_ID_NODE = "bitkit_notification_channel_node"
const val TAG = "LightningNodeService"
const val ACTION_START_SERVICE = "to.bitkit.androidServices.action.START_SERVICE"
Expand Down
26 changes: 26 additions & 0 deletions app/src/main/java/to/bitkit/domain/commands/NotifyChannelReady.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package to.bitkit.domain.commands

import org.lightningdevkit.ldknode.Event
import to.bitkit.models.NewTransactionSheetDetails
import to.bitkit.models.NotificationDetails

sealed interface NotifyChannelReady {

data class Command(
val event: Event.ChannelReady,
val includeNotification: Boolean = false,
) : NotifyChannelReady

sealed interface Result : NotifyChannelReady {
data class ShowSheet(
val sheet: NewTransactionSheetDetails,
) : Result

data class ShowNotification(
val sheet: NewTransactionSheetDetails,
val notification: NotificationDetails,
) : Result

data object Skip : Result
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package to.bitkit.domain.commands

import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import to.bitkit.R
import to.bitkit.data.SettingsData
import to.bitkit.data.SettingsStore
import to.bitkit.di.IoDispatcher
import to.bitkit.ext.amountOnClose
import to.bitkit.models.BITCOIN_SYMBOL
import to.bitkit.models.NewTransactionSheetDetails
import to.bitkit.models.NewTransactionSheetDirection
import to.bitkit.models.NewTransactionSheetType
import to.bitkit.models.NotificationDetails
import to.bitkit.models.PrimaryDisplay
import to.bitkit.models.formatToModernDisplay
import to.bitkit.repositories.ActivityRepo
import to.bitkit.repositories.BlocktankRepo
import to.bitkit.repositories.CurrencyRepo
import to.bitkit.repositories.LightningRepo
import to.bitkit.utils.Logger
import javax.inject.Inject
import javax.inject.Singleton

@Suppress("LongParameterList")
@Singleton
class NotifyChannelReadyHandler @Inject constructor(
@ApplicationContext private val context: Context,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val lightningRepo: LightningRepo,
private val blocktankRepo: BlocktankRepo,
private val activityRepo: ActivityRepo,
private val currencyRepo: CurrencyRepo,
private val settingsStore: SettingsStore,
) {
companion object {
const val TAG = "NotifyChannelReadyHandler"
}

suspend operator fun invoke(
command: NotifyChannelReady.Command,
): Result<NotifyChannelReady.Result> = withContext(ioDispatcher) {
runCatching {
val channel = lightningRepo.getChannels()
?.find { it.channelId == command.event.channelId }
?: return@runCatching NotifyChannelReady.Result.Skip

val cjitEntry = blocktankRepo.getCjitEntry(channel)
?: return@runCatching NotifyChannelReady.Result.Skip

val sats = channel.amountOnClose.toLong()
activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel)

val details = NewTransactionSheetDetails(
type = NewTransactionSheetType.LIGHTNING,
direction = NewTransactionSheetDirection.RECEIVED,
sats = sats,
)

if (command.includeNotification) {
val notification = buildNotificationContent(sats)
NotifyChannelReady.Result.ShowNotification(details, notification)
} else {
NotifyChannelReady.Result.ShowSheet(details)
}
}.onFailure {
Logger.error("Failed to process channel ready notification", it, context = TAG)
}
}

private suspend fun buildNotificationContent(sats: Long): NotificationDetails {
val settings = settingsStore.data.first()
val title = context.getString(R.string.notification__received__title)
val body = if (settings.showNotificationDetails) {
formatNotificationAmount(sats, settings)
} else {
context.getString(R.string.notification__received__body_hidden)
}
return NotificationDetails(title, body)
}

private fun formatNotificationAmount(sats: Long, settings: SettingsData): String {
val converted = currencyRepo.convertSatsToFiat(sats).getOrNull()

val amountText = converted?.let {
val btcDisplay = it.bitcoinDisplay(settings.displayUnit)

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.

nit: we will have to create a model class for AmountUi to encapsulate all requirements related to displaying monetary values so we can easily replicate enforcing the same rules with minimal effort.

There's a concern not yet covered here but also not in scope of this PR, TBH: we don't format currency symbol applying the same formatting rule of whether the symbol is a prefix of a suffix based on the currencies' symbol locale, as recently implemented for the main in-app amount values UI by @piotr-iohk

if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) {
"${btcDisplay.symbol} ${btcDisplay.value} (${it.formattedWithSymbol()})"
} else {
"${it.formattedWithSymbol()} (${btcDisplay.symbol} ${btcDisplay.value})"
}
} ?: "$BITCOIN_SYMBOL ${sats.formatToModernDisplay()}"

return context.getString(R.string.notification__received__body_amount, amountText)
}
}
96 changes: 63 additions & 33 deletions app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import kotlinx.serialization.json.jsonObject
import org.lightningdevkit.ldknode.Event
import to.bitkit.App
import to.bitkit.R
import to.bitkit.androidServices.LightningNodeService
import to.bitkit.data.CacheStore
import to.bitkit.data.SettingsStore
import to.bitkit.di.json
Expand All @@ -35,6 +36,7 @@ import to.bitkit.models.NewTransactionSheetDetails
import to.bitkit.models.NewTransactionSheetDirection
import to.bitkit.models.NewTransactionSheetType
import to.bitkit.models.NotificationDetails
import to.bitkit.models.formatToModernDisplay
import to.bitkit.models.msatCeilOf
import to.bitkit.repositories.ActivityRepo
import to.bitkit.repositories.BlocktankRepo
Expand Down Expand Up @@ -204,11 +206,19 @@ class WakeNodeWorker @AssistedInject constructor(
sats = sats.toLong(),
)
)
val content = if (showDetails) "$BITCOIN_SYMBOL $sats" else hiddenBody
bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification__received__title),
body = content,
)

// The in-app UI or foreground service shows a richer notification for this event; avoid duplicating it
if (isHandledInProcess()) {
Logger.debug("Skipping payment notification: handled in-process", context = TAG)
bestAttemptContent = null
} else {
val content = if (showDetails) "$BITCOIN_SYMBOL ${sats.formatToModernDisplay()}" else hiddenBody
bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification__received__title),
body = content,
)
}

if (notificationType == incomingHtlc) {
deliver()
}
Expand All @@ -219,42 +229,62 @@ class WakeNodeWorker @AssistedInject constructor(
showDetails: Boolean,
hiddenBody: String,
) {
val viaNewChannel = appContext.getString(R.string.notification__received__body_channel)
if (notificationType == cjitPaymentArrived) {
bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification__received__title),
body = viaNewChannel,
)
when (notificationType) {
cjitPaymentArrived -> onCjitChannelReady(event, showDetails, hiddenBody)

lightningRepo.getChannels()?.find { it.channelId == event.channelId }?.let { channel ->
val sats = channel.amountOnClose
val content = if (showDetails) "$BITCOIN_SYMBOL $sats" else hiddenBody
bestAttemptContent = NotificationDetails(
title = content,
body = viaNewChannel,
)
val cjitEntry = channel.let { blocktankRepo.getCjitEntry(it) }
if (cjitEntry != null) {
// Save for UI to pick up
cacheStore.setBackgroundReceive(
NewTransactionSheetDetails(
type = NewTransactionSheetType.LIGHTNING,
direction = NewTransactionSheetDirection.RECEIVED,
sats = sats.toLong(),
)
)
activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel)
}
}
} else if (notificationType == orderPaymentConfirmed) {
bestAttemptContent = NotificationDetails(
orderPaymentConfirmed -> bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification__channel_opened_title),
body = appContext.getString(R.string.notification__channel_ready_body),
)

else -> Unit
}
deliver()
}

private suspend fun onCjitChannelReady(
event: Event.ChannelReady,
showDetails: Boolean,
hiddenBody: String,
) {
val channel = lightningRepo.getChannels()?.find { it.channelId == event.channelId }
val cjitEntry = channel?.let { blocktankRepo.getCjitEntry(it) }

// A regular (non-CJIT) channel opening must not be reported as a received payment
if (channel == null || cjitEntry == null) {
Logger.debug("Skipping CJIT notification: no cjit entry for channel '${event.channelId}'", context = TAG)
bestAttemptContent = null
return
}

val sats = channel.amountOnClose
// Save for UI to pick up
cacheStore.setBackgroundReceive(
NewTransactionSheetDetails(
type = NewTransactionSheetType.LIGHTNING,
direction = NewTransactionSheetDirection.RECEIVED,
sats = sats.toLong(),
)
)
activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel)

// The in-app UI or foreground service shows a richer notification for this event; avoid duplicating it
if (isHandledInProcess()) {
Logger.debug("Skipping CJIT notification: handled in-process", context = TAG)
bestAttemptContent = null
return
}

val content = if (showDetails) "$BITCOIN_SYMBOL ${sats.formatToModernDisplay()}" else hiddenBody
bestAttemptContent = NotificationDetails(
title = content,
body = appContext.getString(R.string.notification__received__body_channel),
)
}

private fun isHandledInProcess(): Boolean =
App.currentActivity?.value != null || LightningNodeService.isRunning

private suspend fun deliver() {
// Send notification first
bestAttemptContent?.run {
Expand Down
20 changes: 7 additions & 13 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,13 @@ import to.bitkit.data.SettingsStore
import to.bitkit.data.keychain.Keychain
import to.bitkit.data.resetPin
import to.bitkit.di.BgDispatcher
import to.bitkit.domain.commands.NotifyChannelReady
import to.bitkit.domain.commands.NotifyChannelReadyHandler
import to.bitkit.domain.commands.NotifyPaymentReceived
import to.bitkit.domain.commands.NotifyPaymentReceivedHandler
import to.bitkit.env.Defaults
import to.bitkit.env.Env
import to.bitkit.ext.WatchResult
import to.bitkit.ext.amountOnClose
import to.bitkit.ext.amountSats
import to.bitkit.ext.callbackAmountMsats
import to.bitkit.ext.channelId
Expand Down Expand Up @@ -189,6 +190,7 @@ class AppViewModel @Inject constructor(
private val blocktankRepo: BlocktankRepo,
private val appUpdaterService: AppUpdaterService,
private val notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler,
private val notifyChannelReadyHandler: NotifyChannelReadyHandler,
private val cacheStore: CacheStore,
private val transferRepo: TransferRepo,
private val migrationService: MigrationService,
Expand Down Expand Up @@ -930,18 +932,10 @@ class AppViewModel @Inject constructor(
// region Notifications

private suspend fun notifyChannelReady(event: Event.ChannelReady) {
val channel = lightningRepo.getChannels()?.find { it.channelId == event.channelId }
val cjitEntry = channel?.let { blocktankRepo.getCjitEntry(it) }
if (cjitEntry != null) {
val amount = channel.amountOnClose.toLong()
showTransactionSheet(
NewTransactionSheetDetails(
type = NewTransactionSheetType.LIGHTNING,
direction = NewTransactionSheetDirection.RECEIVED,
sats = amount,
),
)
activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel)
val command = NotifyChannelReady.Command(event = event)
val result = notifyChannelReadyHandler(command).getOrNull()
if (result is NotifyChannelReady.Result.ShowSheet) {
showTransactionSheet(result.sheet)
return
}
toast(
Expand Down
Loading
Loading