diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 683095cbf7..ebf6256e73 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -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 @@ -60,6 +62,9 @@ class LightningNodeService : Service() { @Inject lateinit var notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler + @Inject + lateinit var notifyChannelReadyHandler: NotifyChannelReadyHandler + @Inject lateinit var notifyPendingPaymentResolvedHandler: NotifyPendingPaymentResolvedHandler @@ -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 { @@ -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, @@ -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 @@ -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() @@ -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" 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 0000000000..33adb6532f --- /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 0000000000..7980a027fd --- /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/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index 555da1bc45..e004e69f92 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -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 @@ -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 @@ -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() } @@ -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 { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 52c10c5235..6bc2752509 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -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 @@ -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, @@ -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( diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index d54e561e81..9e18857a8a 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -50,6 +50,8 @@ import to.bitkit.data.CacheStore import to.bitkit.di.DbModule import to.bitkit.di.DispatchersModule import to.bitkit.di.ViewModelModule +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 @@ -98,6 +100,9 @@ class LightningNodeServiceTest : BaseUnitTest() { @BindValue val notifyPaymentReceivedHandler = mock() + @BindValue + val notifyChannelReadyHandler = mock() + @BindValue val notifyPendingPaymentResolvedHandler = mock() @@ -150,6 +155,10 @@ class LightningNodeServiceTest : BaseUnitTest() { whenever(notifyPaymentReceivedHandler.invoke(any())) .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowNotification(sheet, notification))) + // Default: not a CJIT channel ready unless a test overrides it + whenever(notifyChannelReadyHandler.invoke(any())) + .thenReturn(Result.success(NotifyChannelReady.Result.Skip)) + // Grant permissions for notifications val app = context as Application Shadows.shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) @@ -161,6 +170,7 @@ class LightningNodeServiceTest : BaseUnitTest() { @After fun tearDown() { App.currentActivity = null + LightningNodeService.isRunning = false } @Test @@ -491,6 +501,111 @@ class LightningNodeServiceTest : BaseUnitTest() { assertNull(notification, "Non-pending payment should NOT trigger notification") } + @Test + fun `cjit channel ready in background shows notification`() = test { + val cjitTitle = context.getString(R.string.notification__received__title) + val sheet = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + sats = 48064L, + ) + whenever(notifyChannelReadyHandler.invoke(any())).thenReturn( + Result.success( + NotifyChannelReady.Result.ShowNotification( + sheet = sheet, + notification = NotificationDetails(title = cjitTitle, body = $$"Received ₿ 48 064 ($30.79)"), + ) + ) + ) + + startService() + testScheduler.advanceUntilIdle() + + val event = Event.ChannelReady( + channelId = "channel-1", + userChannelId = "u1", + counterpartyNodeId = null, + fundingTxo = null, + ) + capturedHandler?.invoke(event) + testScheduler.advanceUntilIdle() + + val notificationManager = context.notificationManager + val shadows = Shadows.shadowOf(notificationManager) + val notification = shadows.allNotifications.find { + it.extras.getString(Notification.EXTRA_TITLE) == cjitTitle + } + assertNotNull(notification, "CJIT channel ready notification should be present") + assertEquals($$"Received ₿ 48 064 ($30.79)", notification?.extras?.getString(Notification.EXTRA_TEXT)) + verify(cacheStore).setBackgroundReceive(sheet) + } + + @Test + fun `non-cjit channel ready shows no notification`() = test { + whenever(notifyChannelReadyHandler.invoke(any())) + .thenReturn(Result.success(NotifyChannelReady.Result.Skip)) + + startService() + testScheduler.advanceUntilIdle() + + val event = Event.ChannelReady( + channelId = "channel-1", + userChannelId = "u1", + counterpartyNodeId = null, + fundingTxo = null, + ) + capturedHandler?.invoke(event) + testScheduler.advanceUntilIdle() + + val notificationManager = context.notificationManager + val shadows = Shadows.shadowOf(notificationManager) + val cjitTitle = context.getString(R.string.notification__received__title) + val notification = shadows.allNotifications.find { + it.extras.getString(Notification.EXTRA_TITLE) == cjitTitle + } + assertNull(notification, "Non-CJIT channel ready should NOT trigger a notification") + verify(cacheStore, never()).setBackgroundReceive(any()) + } + + @Test + fun `cjit channel ready in foreground shows no notification`() = test { + val mockActivity: Activity = mock() + App.currentActivity?.onActivityStarted(mockActivity) + + val cjitTitle = context.getString(R.string.notification__received__title) + whenever(notifyChannelReadyHandler.invoke(any())).thenReturn( + Result.success( + NotifyChannelReady.Result.ShowNotification( + sheet = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + sats = 48064L, + ), + notification = NotificationDetails(title = cjitTitle, body = "body"), + ) + ) + ) + + startService() + testScheduler.advanceUntilIdle() + + val event = Event.ChannelReady( + channelId = "channel-1", + userChannelId = "u1", + counterpartyNodeId = null, + fundingTxo = null, + ) + capturedHandler?.invoke(event) + testScheduler.advanceUntilIdle() + + val notificationManager = context.notificationManager + val shadows = Shadows.shadowOf(notificationManager) + val notification = shadows.allNotifications.find { + it.extras.getString(Notification.EXTRA_TITLE) == cjitTitle + } + assertNull(notification, "CJIT notification should NOT be present in foreground") + } + @Test fun `stop service action schedules widget catch-up before shutdown`() = test { val service = createService() 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 0000000000..4da54ff945 --- /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) + } +} diff --git a/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt b/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt new file mode 100644 index 0000000000..643d00f7db --- /dev/null +++ b/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt @@ -0,0 +1,244 @@ +package to.bitkit.fcm + +import android.Manifest +import android.app.Activity +import android.app.Application +import android.app.Notification +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import com.synonym.bitkitcore.IcJitEntry +import kotlinx.coroutines.flow.flowOf +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.Event +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import to.bitkit.App +import to.bitkit.CurrentActivity +import to.bitkit.R +import to.bitkit.androidServices.LightningNodeService +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.ext.createChannelDetails +import to.bitkit.ext.mock +import to.bitkit.ext.notificationManager +import to.bitkit.models.BITCOIN_SYMBOL +import to.bitkit.models.BlocktankNotificationType +import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.services.NodeEventHandler +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class WakeNodeWorkerTest : BaseUnitTest() { + private val context = ApplicationProvider.getApplicationContext() + private val workerParams = mock() + private val lightningRepo = mock() + private val blocktankRepo = mock() + private val activityRepo = mock() + private val settingsStore = mock() + private val cacheStore = mock() + + private val channelId = "channel-1" + private val viaNewChannel by lazy { context.getString(R.string.notification__received__body_channel) } + + @Before + fun setUp() { + whenever(workerParams.inputData).thenReturn( + workDataOf("type" to BlocktankNotificationType.cjitPaymentArrived.name), + ) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(showNotificationDetails = true))) + + val app = context as Application + Shadows.shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + + // Default: app killed (no foreground activity), no foreground service running + App.currentActivity = CurrentActivity() + LightningNodeService.isRunning = false + } + + @After + fun tearDown() { + App.currentActivity = null + LightningNodeService.isRunning = false + } + + @Test + fun `cjit channel ready formats amount with thousands separators`() = test { + val channel = cjitChannel(sats = 48_064) + stubChannel(channel, cjitEntry = IcJitEntry.mock()) + stubStartFiring(channelReadyEvent()) + + val result = worker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + val notification = findNotification(viaNewChannel) + assertNotNull(notification, "CJIT notification should be delivered when app is killed") + assertEquals( + "$BITCOIN_SYMBOL ${48_064L.formatToModernDisplay()}", + notification?.extras?.getString(Notification.EXTRA_TITLE), + ) + // sanity: a thousands separator is actually present + assertTrue(48_064L.formatToModernDisplay().contains(' '), "amount should be grouped") + verify(activityRepo).insertActivityFromCjit(any(), any()) + } + + @Test + fun `non-cjit channel ready delivers no payment notification`() = test { + val channel = cjitChannel(sats = 45_000) + stubChannel(channel, cjitEntry = null) + stubStartFiring(channelReadyEvent()) + + val result = worker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + assertNull(findNotification(viaNewChannel), "A non-CJIT channel must not show a 'via new channel' notification") + verify(activityRepo, never()).insertActivityFromCjit(any(), any()) + verify(cacheStore, never()).setBackgroundReceive(any()) + } + + @Test + fun `cjit channel ready skips notification when foreground service is running`() = test { + LightningNodeService.isRunning = true + val channel = cjitChannel(sats = 48_064) + stubChannel(channel, cjitEntry = IcJitEntry.mock()) + stubStartFiring(channelReadyEvent()) + + val result = worker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + assertNull(findNotification(viaNewChannel), "Notification is deduped when the foreground service handles it") + // Activity is still recorded so the receive is not lost + verify(activityRepo).insertActivityFromCjit(any(), any()) + } + + @Test + fun `cjit channel ready skips notification when app is in foreground`() = test { + App.currentActivity?.onActivityStarted(mock()) + val channel = cjitChannel(sats = 48_064) + stubChannel(channel, cjitEntry = IcJitEntry.mock()) + stubStartFiring(channelReadyEvent()) + + val result = worker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + assertNull(findNotification(viaNewChannel), "Notification is deduped when the in-app UI handles it") + verify(activityRepo).insertActivityFromCjit(any(), any()) + } + + @Test + fun `payment received formats amount with thousands separators`() = test { + whenever(workerParams.inputData).thenReturn( + workDataOf("type" to BlocktankNotificationType.incomingHtlc.name), + ) + whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) + stubStartFiring(paymentReceivedEvent(sats = 48_064)) + + val result = worker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + val notification = findNotificationByTitle(context.getString(R.string.notification__received__title)) + assertNotNull(notification, "Payment notification should be delivered when app is killed") + assertEquals( + "$BITCOIN_SYMBOL ${48_064L.formatToModernDisplay()}", + notification?.extras?.getString(Notification.EXTRA_TEXT), + ) + } + + @Test + fun `payment received skips notification when foreground service is running`() = test { + LightningNodeService.isRunning = true + whenever(workerParams.inputData).thenReturn( + workDataOf("type" to BlocktankNotificationType.incomingHtlc.name), + ) + whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) + stubStartFiring(paymentReceivedEvent(sats = 48_064)) + + val result = worker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + assertNull( + findNotificationByTitle(context.getString(R.string.notification__received__title)), + "Notification is deduped when the foreground service handles it", + ) + // Receive is still cached for the in-app UI to pick up + verify(cacheStore).setBackgroundReceive(any()) + } + + private fun worker() = WakeNodeWorker( + appContext = context, + workerParams = workerParams, + lightningRepo = lightningRepo, + blocktankRepo = blocktankRepo, + activityRepo = activityRepo, + settingsStore = settingsStore, + cacheStore = cacheStore, + ) + + private fun channelReadyEvent() = mock { + on { this.channelId } doReturn this@WakeNodeWorkerTest.channelId + } + + private fun paymentReceivedEvent(sats: Long) = Event.PaymentReceived( + paymentId = "payment-1", + paymentHash = "hash-1", + amountMsat = (sats * 1000).toULong(), + customRecords = emptyList(), + ) + + private fun cjitChannel(sats: Long) = createChannelDetails().copy( + channelId = channelId, + outboundCapacityMsat = (sats * 1000).toULong(), + unspendablePunishmentReserve = 0u, + ) + + private suspend fun stubChannel(channel: ChannelDetails, cjitEntry: IcJitEntry?) { + whenever(lightningRepo.getChannels()).thenReturn(listOf(channel)) + whenever(blocktankRepo.getCjitEntry(channel)).thenReturn(cjitEntry) + whenever(activityRepo.insertActivityFromCjit(any(), any())).thenReturn(Result.success(Unit)) + whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) + } + + private fun stubStartFiring(event: Event) { + whenever { + lightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), any()) + }.doSuspendableAnswer { + val handler = it.getArgument(5) + handler?.invoke(event) + Result.success(Unit) + } + } + + private fun findNotification(body: String): Notification? { + val shadows = Shadows.shadowOf(context.notificationManager) + return shadows.allNotifications.find { it.extras.getString(Notification.EXTRA_TEXT) == body } + } + + private fun findNotificationByTitle(title: String): Notification? { + val shadows = Shadows.shadowOf(context.notificationManager) + return shadows.allNotifications.find { it.extras.getString(Notification.EXTRA_TITLE) == title } + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 10428d9e5a..e4ac0ec531 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -36,6 +36,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.models.BalanceState import to.bitkit.models.PubkyProfile @@ -105,6 +106,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val blocktankRepo = mock() private val appUpdaterService = mock() private val notifyPaymentReceivedHandler = mock() + private val notifyChannelReadyHandler = mock() private val cacheStore = mock() private val transferRepo = mock() private val migrationService = mock() @@ -227,6 +229,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { blocktankRepo = blocktankRepo, appUpdaterService = appUpdaterService, notifyPaymentReceivedHandler = notifyPaymentReceivedHandler, + notifyChannelReadyHandler = notifyChannelReadyHandler, cacheStore = cacheStore, transferRepo = transferRepo, migrationService = migrationService, diff --git a/changelog.d/next/787.fixed.md b/changelog.d/next/787.fixed.md new file mode 100644 index 0000000000..4c953e7015 --- /dev/null +++ b/changelog.d/next/787.fixed.md @@ -0,0 +1 @@ +Instant-channel payment notifications now appear once with a correctly formatted amount, and opening a regular channel no longer shows a misleading "payment received" notification. diff --git a/journeys/cjit-notifications/README.md b/journeys/cjit-notifications/README.md new file mode 100644 index 0000000000..3a91007955 --- /dev/null +++ b/journeys/cjit-notifications/README.md @@ -0,0 +1,59 @@ +# CJIT channel-ready notification journeys + +These journeys reproduce the three notification issues raised in PR #787 (`fix/channelready-cjit`) +and verify the fixes. They exercise the `ChannelReady` event path across the two notification +producers: + +- **`WakeNodeWorker`** — wakes a killed/background node from an FCM push, opens the channel, and is + the *only* producer that should show a user-facing notification when nothing in-process is running. +- **`LightningNodeService`** (foreground service) + **`AppViewModel`** — handle the same + `ChannelReady` event when the app/service is alive, via `NotifyChannelReadyHandler`. + +## The bugs being reproduced + +1. **🟢 Missing thousands separators.** `WakeNodeWorker` built the amount as `"₿ $sats"` (raw), + so a CJIT receive showed `₿ 48064` instead of `₿ 48 064`. +2. **🟠 Double notification.** When the foreground service was running, a CJIT push produced + *two* notifications — one from `LightningNodeService` (`Received ₿ 48 064 ($30.79)`) and one + from `WakeNodeWorker` (`₿ 48064 / Via new channel`). +3. **🔴 Non-CJIT channel shows a payment notification (and multiplies).** Opening a regular + (non-CJIT) channel produced one-or-more `₿ 45000 / Via new channel` notifications even though no + payment was received. + +## The fix (what the journeys assert) + +- `WakeNodeWorker` formats amounts with `formatToModernDisplay()` (space-grouped). [#1] +- `WakeNodeWorker` only emits the "Via new channel" CJIT notification when the channel actually has + a Blocktank CJIT entry; a regular channel opening shows no payment notification. [#3] +- `WakeNodeWorker` skips its own user-facing notification when an in-process handler covers the + event — i.e. when the app is in the foreground OR `LightningNodeService.isRunning` — leaving a + single, richer notification. It still wakes the node and records the activity. [#2] + +## Mandatory setup + +1. **Onboarded dev (regtest) wallet** with the node connected to the staging/regtest LSP. +2. **Create a CJIT entry and fund it** with the `blocktank-api` (`lsp`) skill so a real JIT channel + opens on payment: + - `./lsp POST /cjit '{"channelSizeSat":100000,"invoiceSat":50000,"invoiceDescription":"cjit","nodeId":"","channelExpiryWeeks":1}'` + - Pay the returned bolt11 invoice: `./lsp POST /regtest/lightning/pay '{"invoice":""}'` + - `./lsp POST /regtest/mine '{"count":3}'` if confirmation is needed. +3. **Background notifications** toggle (foreground service) is in Settings → Notifications; enabling + it keeps `LightningNodeService` alive in the background (`LightningNodeService.isRunning == true`). + +## Inspecting notifications (adb) + +Use the notification shade dump to assert what was posted: + +```sh +adb shell dumpsys notification --noredact | grep -A3 -i "to.bitkit.dev" +``` + +Look at the posted notifications' `android.title` / `android.text`. Count how many distinct +notifications Bitkit posted for a single channel opening. + +## Test tags / references + +- Foreground "Spending balance ready" toast: testTag `SpendingBalanceReadyToast`. +- Notification strings: `notification__received__title`, `notification__received__body_channel` + ("Via new channel"), `notification__received__body_amount` ("Received %s"). +- Amount grouping separator: `SATS_GROUPING_SEPARATOR` (a space) via `formatToModernDisplay`. diff --git a/journeys/cjit-notifications/cjit-foreground-service-notification.xml b/journeys/cjit-notifications/cjit-foreground-service-notification.xml new file mode 100644 index 0000000000..a3dd20b2d7 --- /dev/null +++ b/journeys/cjit-notifications/cjit-foreground-service-notification.xml @@ -0,0 +1,25 @@ + + + Reproduces PR #787 issues #1 (missing thousands separators) and #2 (double notification). + + With background notifications (the LightningNodeService foreground service) enabled and the app + in the background, a CJIT payment that opens a JIT channel must produce EXACTLY ONE "Payment + Received" notification, with a space-grouped amount (e.g. "Received ₿ 48 064 (...)"), NOT a raw + "₿ 48064" and NOT a second "Via new channel" notification from WakeNodeWorker. + + Precondition: onboarded regtest wallet connected to the LSP. A funded CJIT entry is ready to pay + (see README.md). Background notifications enabled in Settings → Notifications. No Lightning + channel open yet (this is the first, channel-opening payment). + + + Confirm background notifications are enabled (Settings → Notifications → background/foreground service toggle ON), then go back to the wallet home screen + Send the device to the background (press Home) so no Bitkit activity is in the foreground but the foreground service keeps running + Pay the CJIT invoice via the lsp skill: ./lsp POST /regtest/lightning/pay '{"invoice":"<bolt11>"}' + Wait ~5s for the ChannelReady event and notification delivery + Dump notifications: adb shell dumpsys notification --noredact | grep -A3 -i "to.bitkit" + Verify EXACTLY ONE Bitkit payment notification is posted for this channel opening (not two) + Verify its amount uses a thousands separator space, e.g. "₿ 48 064" — NOT "₿ 48064" + Verify there is NO separate notification whose body is "Via new channel" alongside the payment notification + Tap the notification, bring the app to the foreground, and verify the received-transaction sheet shows the correct amount + + diff --git a/journeys/cjit-notifications/cjit-push-single-notification.xml b/journeys/cjit-notifications/cjit-push-single-notification.xml new file mode 100644 index 0000000000..6b548794f1 --- /dev/null +++ b/journeys/cjit-notifications/cjit-push-single-notification.xml @@ -0,0 +1,23 @@ + + + Reproduces PR #787 issue #2 on the pure push path. With the app fully killed (no foreground + activity and no foreground service), a CJIT payment must still surface a single payment + notification delivered by WakeNodeWorker — this is the wake-the-node-and-open-the-channel path, + so it must keep working. The amount must be space-grouped. + + Precondition: onboarded regtest wallet connected to the LSP. A funded CJIT entry is ready to pay + (see README.md). Background notifications DISABLED (so no foreground service survives the kill), + and the app force-stopped so only the FCM push + WakeNodeWorker run. + + + Ensure background notifications are DISABLED in Settings → Notifications + Force-stop the app: adb shell am force-stop to.bitkit.dev + Pay the CJIT invoice via the lsp skill: ./lsp POST /regtest/lightning/pay '{"invoice":"<bolt11>"}' + Wait up to ~30s for the FCM push to wake the node and deliver the notification + Dump notifications: adb shell dumpsys notification --noredact | grep -A3 -i "to.bitkit" + Verify EXACTLY ONE Bitkit notification is posted for this CJIT payment + Verify the amount is space-grouped, e.g. "₿ 48 064" — NOT "₿ 48064" + Verify the notification body is "Via new channel" + Tap the notification to open the app and verify the received amount in the activity list matches + + diff --git a/journeys/cjit-notifications/non-cjit-channel-no-payment-notification.xml b/journeys/cjit-notifications/non-cjit-channel-no-payment-notification.xml new file mode 100644 index 0000000000..b8b2080ee2 --- /dev/null +++ b/journeys/cjit-notifications/non-cjit-channel-no-payment-notification.xml @@ -0,0 +1,23 @@ + + + Reproduces PR #787 issue #3 (🔴). Opening a regular (non-CJIT) Lightning channel must NOT be + reported as a received payment: no "₿ <amount> / Via new channel" notification, and never + several duplicates. When the app is in the foreground the only feedback is the in-app "Spending + balance ready" toast. + + Precondition: onboarded regtest wallet connected to the LSP, with a positive on-chain balance to + fund the transfer. No CJIT entry is involved here — this is a manual/Transfer channel opening + (orderPaymentConfirmed), not a CJIT receive. + + + Keep the app in the foreground on the wallet home screen + Open a regular spending channel via Transfer → Spending (fund some sats and confirm), OR open one through the lsp skill order flow + Mine confirmations if required: ./lsp POST /regtest/mine '{"count":3}' + Wait for the ChannelReady event (~5-10s) + Verify an in-app "Spending balance ready" toast appears (testTag "SpendingBalanceReadyToast") + Dump notifications: adb shell dumpsys notification --noredact | grep -A3 -i "to.bitkit" + Verify NO Bitkit notification with body "Via new channel" was posted + Verify NO "Payment Received" notification was posted for this channel opening + Verify there are not multiple duplicate channel-ready notifications stacked in the shade + +