From 82960068125f92711332fbc02e190752b6d99d3b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 17 Feb 2026 13:40:24 -0300 Subject: [PATCH 1/3] chore: create channel ready handler --- .../domain/commands/NotifyChannelReady.kt | 26 +++ .../commands/NotifyChannelReadyHandler.kt | 99 +++++++++ .../commands/NotifyChannelReadyHandlerTest.kt | 193 ++++++++++++++++++ 3 files changed, 318 insertions(+) create mode 100644 app/src/main/java/to/bitkit/domain/commands/NotifyChannelReady.kt create mode 100644 app/src/main/java/to/bitkit/domain/commands/NotifyChannelReadyHandler.kt create mode 100644 app/src/test/java/to/bitkit/domain/commands/NotifyChannelReadyHandlerTest.kt diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyChannelReady.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyChannelReady.kt new file mode 100644 index 000000000..33adb6532 --- /dev/null +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyChannelReady.kt @@ -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 + } +} diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyChannelReadyHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyChannelReadyHandler.kt new file mode 100644 index 000000000..7980a027f --- /dev/null +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyChannelReadyHandler.kt @@ -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 = 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) + 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) + } +} diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyChannelReadyHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyChannelReadyHandlerTest.kt new file mode 100644 index 000000000..4da54ff94 --- /dev/null +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyChannelReadyHandlerTest.kt @@ -0,0 +1,193 @@ +package to.bitkit.domain.commands + +import android.content.Context +import com.synonym.bitkitcore.IcJitEntry +import kotlinx.coroutines.flow.flowOf +import org.junit.Before +import org.junit.Test +import org.lightningdevkit.ldknode.Event +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.R +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.ext.createChannelDetails +import to.bitkit.ext.mock +import to.bitkit.models.ConvertedAmount +import to.bitkit.models.NewTransactionSheetDirection +import to.bitkit.models.NewTransactionSheetType +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.test.BaseUnitTest +import java.math.BigDecimal +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class NotifyChannelReadyHandlerTest : BaseUnitTest() { + + private val context: Context = mock() + private val lightningRepo: LightningRepo = mock() + private val blocktankRepo: BlocktankRepo = mock() + private val activityRepo: ActivityRepo = mock() + private val currencyRepo: CurrencyRepo = mock() + private val settingsStore: SettingsStore = mock() + + private lateinit var sut: NotifyChannelReadyHandler + + @Before + 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(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenReturn( + Result.success( + ConvertedAmount( + value = BigDecimal("0.10"), + formatted = "0.10", + symbol = "$", + currency = "USD", + flag = "\uD83C\uDDFA\uD83C\uDDF8", + sats = 100L + ) + ) + ) + + sut = NotifyChannelReadyHandler( + context = context, + ioDispatcher = testDispatcher, + lightningRepo = lightningRepo, + blocktankRepo = blocktankRepo, + activityRepo = activityRepo, + currencyRepo = currencyRepo, + settingsStore = settingsStore, + ) + } + + @Test + fun `returns Skip when channel is not found`() = test { + val event = mock { + on { channelId } doReturn "unknown-channel" + } + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + + val result = sut(NotifyChannelReady.Command(event = event)) + + assertTrue(result.isSuccess) + assertTrue(result.getOrThrow() is NotifyChannelReady.Result.Skip) + verify(activityRepo, never()).insertActivityFromCjit(any(), any()) + } + + @Test + fun `returns Skip when channel has no cjit entry`() = test { + val event = mock { + on { channelId } doReturn "channel-1" + } + val channel = createChannelDetails().copy(channelId = "channel-1") + whenever(lightningRepo.getChannels()).thenReturn(listOf(channel)) + whenever(blocktankRepo.getCjitEntry(channel)).thenReturn(null) + + val result = sut(NotifyChannelReady.Command(event = event)) + + assertTrue(result.isSuccess) + assertTrue(result.getOrThrow() is NotifyChannelReady.Result.Skip) + verify(activityRepo, never()).insertActivityFromCjit(any(), any()) + } + + @Test + fun `returns ShowSheet for cjit channel`() = test { + val event = mock { + on { channelId } doReturn "channel-1" + } + val channel = createChannelDetails().copy( + channelId = "channel-1", + outboundCapacityMsat = 3000_000u, + unspendablePunishmentReserve = 263u, + ) + val cjitEntry = IcJitEntry.mock() + whenever(lightningRepo.getChannels()).thenReturn(listOf(channel)) + whenever(blocktankRepo.getCjitEntry(channel)).thenReturn(cjitEntry) + whenever(activityRepo.insertActivityFromCjit(any(), any())).thenReturn(Result.success(Unit)) + + val result = sut(NotifyChannelReady.Command(event = event)) + + assertTrue(result.isSuccess) + val showSheet = result.getOrThrow() + assertTrue(showSheet is NotifyChannelReady.Result.ShowSheet) + assertEquals(NewTransactionSheetType.LIGHTNING, showSheet.sheet.type) + assertEquals(NewTransactionSheetDirection.RECEIVED, showSheet.sheet.direction) + assertEquals(3263L, showSheet.sheet.sats) + verify(activityRepo).insertActivityFromCjit(cjitEntry, channel) + } + + @Test + fun `returns ShowNotification when includeNotification is true`() = test { + val event = mock { + on { channelId } doReturn "channel-1" + } + val channel = createChannelDetails().copy( + channelId = "channel-1", + outboundCapacityMsat = 3000_000u, + unspendablePunishmentReserve = 263u, + ) + val cjitEntry = IcJitEntry.mock() + whenever(lightningRepo.getChannels()).thenReturn(listOf(channel)) + whenever(blocktankRepo.getCjitEntry(channel)).thenReturn(cjitEntry) + whenever(activityRepo.insertActivityFromCjit(any(), any())).thenReturn(Result.success(Unit)) + + val result = sut(NotifyChannelReady.Command(event = event, includeNotification = true)) + + assertTrue(result.isSuccess) + val showNotification = result.getOrThrow() + assertTrue(showNotification is NotifyChannelReady.Result.ShowNotification) + assertEquals(NewTransactionSheetType.LIGHTNING, showNotification.sheet.type) + assertEquals(3263L, showNotification.sheet.sats) + assertNotNull(showNotification.notification) + assertEquals("Payment Received", showNotification.notification.title) + 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(Unit)) + 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 Skip when channels list is null`() = test { + val event = mock { + on { channelId } doReturn "channel-1" + } + whenever(lightningRepo.getChannels()).thenReturn(null) + + val result = sut(NotifyChannelReady.Command(event = event)) + + assertTrue(result.isSuccess) + assertTrue(result.getOrThrow() is NotifyChannelReady.Result.Skip) + } +} From 37fc97d620fe3eec3d4305e5d84c6d4003152693 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 17 Feb 2026 13:41:42 -0300 Subject: [PATCH 2/3] fix: handle channel ready --- .../androidServices/LightningNodeService.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 084093db6..c48804580 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -20,6 +20,8 @@ import to.bitkit.App import to.bitkit.R 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.models.NewTransactionSheetDetails @@ -51,6 +53,9 @@ class LightningNodeService : Service() { @Inject lateinit var notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler + @Inject + lateinit var notifyChannelReadyHandler: NotifyChannelReadyHandler + @Inject lateinit var cacheStore: CacheStore @@ -66,6 +71,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) } ).onSuccess { walletRepo.setWalletExistsState() @@ -86,6 +92,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, From 345fca8ff7682ed7b47be1daa52e5e9fe88e1951 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 17 Feb 2026 13:50:12 -0300 Subject: [PATCH 3/3] refactor: implement notifyChannelReadyHandler on AppViewModel.kt --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f0d371c8c..db005ad4c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -59,12 +59,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.getClipboardText import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.maxSendableSat @@ -147,6 +148,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, @@ -559,18 +561,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(