Skip to content

Commit 53aa345

Browse files
committed
feat: LN payment received push notification
1 parent 3649737 commit 53aa345

File tree

3 files changed

+241
-1
lines changed

3 files changed

+241
-1
lines changed

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

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,28 @@ import android.app.Service
66
import android.content.Intent
77
import android.os.IBinder
88
import androidx.core.app.NotificationCompat
9+
import androidx.lifecycle.Lifecycle
10+
import androidx.lifecycle.ProcessLifecycleOwner
911
import dagger.hilt.android.AndroidEntryPoint
1012
import kotlinx.coroutines.CoroutineScope
1113
import kotlinx.coroutines.Dispatchers
1214
import kotlinx.coroutines.SupervisorJob
1315
import kotlinx.coroutines.cancel
16+
import kotlinx.coroutines.flow.first
1417
import kotlinx.coroutines.launch
18+
import org.lightningdevkit.ldknode.Event
1519
import to.bitkit.App
1620
import to.bitkit.R
21+
import to.bitkit.data.SettingsStore
22+
import to.bitkit.models.BITCOIN_SYMBOL
23+
import to.bitkit.models.NewTransactionSheetDetails
24+
import to.bitkit.models.NewTransactionSheetDirection
25+
import to.bitkit.models.NewTransactionSheetType
1726
import to.bitkit.repositories.LightningRepo
1827
import to.bitkit.repositories.WalletRepo
28+
import to.bitkit.services.LdkNodeEventBus
1929
import to.bitkit.ui.MainActivity
30+
import to.bitkit.ui.pushNotification
2031
import to.bitkit.utils.Logger
2132
import javax.inject.Inject
2233

@@ -31,6 +42,12 @@ class LightningNodeService : Service() {
3142
@Inject
3243
lateinit var walletRepo: WalletRepo
3344

45+
@Inject
46+
lateinit var ldkNodeEventBus: LdkNodeEventBus
47+
48+
@Inject
49+
lateinit var settingsStore: SettingsStore
50+
3451
override fun onCreate() {
3552
super.onCreate()
3653
startForeground(NOTIFICATION_ID, createNotification())
@@ -53,9 +70,49 @@ class LightningNodeService : Service() {
5370
walletRepo.syncBalances()
5471
}
5572
}
73+
74+
launch {
75+
ldkNodeEventBus.events.collect { event ->
76+
handleBackgroundEvent(event)
77+
}
78+
}
5679
}
5780
}
5881

82+
private suspend fun handleBackgroundEvent(event: Event) {
83+
val isForeground = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
84+
if (isForeground) return
85+
86+
if (event is Event.PaymentReceived) {
87+
val sats = event.amountMsat / 1000u
88+
showPaymentNotification(sats.toLong(), event.paymentHash)
89+
}
90+
}
91+
92+
private suspend fun showPaymentNotification(sats: Long, paymentHash: String?) {
93+
val settings = settingsStore.data.first()
94+
val showDetails = settings.showNotificationDetails
95+
96+
NewTransactionSheetDetails.save(
97+
this,
98+
NewTransactionSheetDetails(
99+
type = NewTransactionSheetType.LIGHTNING,
100+
direction = NewTransactionSheetDirection.RECEIVED,
101+
paymentHashOrTxId = paymentHash,
102+
sats = sats
103+
)
104+
)
105+
106+
val title = getString(R.string.notification_received_title)
107+
val body = if (showDetails) {
108+
getString(R.string.notification_received_body_amount, "$BITCOIN_SYMBOL $sats")
109+
} else {
110+
getString(R.string.notification_received_body_hidden)
111+
}
112+
113+
pushNotification(title, body, context = this)
114+
}
115+
59116
// Update the createNotification method in LightningNodeService.kt
60117
private fun createNotification(
61118
contentText: String = "Bitkit is running in background so you can receive Lightning payments"
@@ -88,7 +145,7 @@ class LightningNodeService : Service() {
88145
.setContentIntent(pendingIntent)
89146
.addAction(
90147
R.drawable.ic_x,
91-
"Stop App", // TODO: Get from resources
148+
getString(R.string.notification_stop_app),
92149
stopPendingIntent
93150
)
94151
.build()

app/src/main/res/values/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,4 +1129,8 @@
11291129
<string name="widgets__weather__current_fee">Current average fee</string>
11301130
<string name="widgets__weather__next_block">Next block inclusion</string>
11311131
<string name="widgets__weather__error">Couldn\'t get current fee weather</string>
1132+
<string name="notification_received_title">Payment Received</string>
1133+
<string name="notification_received_body_hidden">Open Bitkit to see details</string>
1134+
<string name="notification_received_body_amount">Received %s</string>
1135+
<string name="notification_stop_app">Stop App</string>
11321136
</resources>
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package to.bitkit.androidServices
2+
3+
import android.Manifest
4+
import android.app.Application
5+
import android.app.Notification
6+
import android.app.NotificationManager
7+
import android.content.Context
8+
import androidx.lifecycle.Lifecycle
9+
import androidx.lifecycle.LifecycleRegistry
10+
import androidx.lifecycle.ProcessLifecycleOwner
11+
import androidx.test.core.app.ApplicationProvider
12+
import dagger.hilt.android.testing.BindValue
13+
import dagger.hilt.android.testing.HiltAndroidRule
14+
import dagger.hilt.android.testing.HiltAndroidTest
15+
import dagger.hilt.android.testing.HiltTestApplication
16+
import kotlinx.coroutines.Dispatchers
17+
import kotlinx.coroutines.ExperimentalCoroutinesApi
18+
import kotlinx.coroutines.flow.MutableSharedFlow
19+
import kotlinx.coroutines.flow.flowOf
20+
import kotlinx.coroutines.runBlocking
21+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
22+
import kotlinx.coroutines.test.resetMain
23+
import kotlinx.coroutines.test.runTest
24+
import kotlinx.coroutines.test.setMain
25+
import org.junit.After
26+
import org.junit.Assert.assertEquals
27+
import org.junit.Assert.assertNotNull
28+
import org.junit.Assert.assertNull
29+
import org.junit.Before
30+
import org.junit.Rule
31+
import org.junit.Test
32+
import org.junit.runner.RunWith
33+
import org.lightningdevkit.ldknode.Event
34+
import org.mockito.kotlin.any
35+
import org.mockito.kotlin.anyOrNull
36+
import org.mockito.kotlin.mock
37+
import org.mockito.kotlin.whenever
38+
import org.robolectric.Robolectric
39+
import org.robolectric.RobolectricTestRunner
40+
import org.robolectric.Shadows
41+
import org.robolectric.annotation.Config
42+
import to.bitkit.R
43+
import to.bitkit.data.SettingsData
44+
import to.bitkit.data.SettingsStore
45+
import to.bitkit.models.NewTransactionSheetDetails
46+
import to.bitkit.repositories.LightningRepo
47+
import to.bitkit.repositories.WalletRepo
48+
import to.bitkit.services.CoreService
49+
import to.bitkit.services.LdkNodeEventBus
50+
51+
@OptIn(ExperimentalCoroutinesApi::class)
52+
@HiltAndroidTest
53+
@Config(application = HiltTestApplication::class)
54+
@RunWith(RobolectricTestRunner::class)
55+
class LightningNodeServiceTest {
56+
57+
@get:Rule
58+
var hiltRule = HiltAndroidRule(this)
59+
60+
@BindValue
61+
@JvmField
62+
val lightningRepo: LightningRepo = mock()
63+
64+
@BindValue
65+
@JvmField
66+
val walletRepo: WalletRepo = mock()
67+
68+
@BindValue
69+
@JvmField
70+
val ldkNodeEventBus: LdkNodeEventBus = mock()
71+
72+
@BindValue
73+
@JvmField
74+
val settingsStore: SettingsStore = mock()
75+
76+
@BindValue
77+
@JvmField
78+
val coreService: CoreService = mock()
79+
80+
private val eventsFlow = MutableSharedFlow<Event>()
81+
private val context = ApplicationProvider.getApplicationContext<Context>()
82+
83+
@Before
84+
fun setUp() {
85+
runBlocking {
86+
hiltRule.inject()
87+
Dispatchers.setMain(UnconfinedTestDispatcher())
88+
whenever(ldkNodeEventBus.events).thenReturn(eventsFlow)
89+
whenever(settingsStore.data).thenReturn(flowOf(SettingsData(showNotificationDetails = true)))
90+
whenever(lightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(
91+
Result.success(Unit)
92+
)
93+
whenever(lightningRepo.stop()).thenReturn(Result.success(Unit))
94+
95+
// Grant permissions for notifications
96+
val app = context as Application
97+
Shadows.shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS)
98+
99+
// Reset ProcessLifecycleOwner to Background (CREATED)
100+
runBlocking(Dispatchers.Main) {
101+
val owner = ProcessLifecycleOwner.get()
102+
val registry = owner.lifecycle as? LifecycleRegistry
103+
if (registry?.currentState?.isAtLeast(Lifecycle.State.STARTED) == true) {
104+
registry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
105+
registry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
106+
}
107+
}
108+
}
109+
}
110+
111+
@After
112+
fun tearDown() {
113+
Dispatchers.resetMain()
114+
NewTransactionSheetDetails.clear(context)
115+
}
116+
117+
@Test
118+
fun `payment received in background shows notification`() = runTest {
119+
val controller = Robolectric.buildService(LightningNodeService::class.java)
120+
controller.create().startCommand(0, 0)
121+
122+
val event = Event.PaymentReceived(
123+
paymentId = "payment_id",
124+
paymentHash = "test_hash",
125+
amountMsat = 100000u,
126+
customRecords = emptyList()
127+
)
128+
129+
eventsFlow.emit(event)
130+
131+
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
132+
val shadows = Shadows.shadowOf(notificationManager)
133+
134+
val paymentNotification = shadows.allNotifications.find {
135+
it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification_received_title)
136+
}
137+
assertNotNull("Payment notification should be present", paymentNotification)
138+
139+
val details = NewTransactionSheetDetails.load(context)
140+
assertNotNull(details)
141+
assertEquals("test_hash", details?.paymentHashOrTxId)
142+
assertEquals(100L, details?.sats)
143+
}
144+
145+
@Test
146+
fun `payment received in foreground does nothing`() = runTest {
147+
// Force Foreground state
148+
runBlocking(Dispatchers.Main) {
149+
val owner = ProcessLifecycleOwner.get()
150+
val registry = owner.lifecycle as? LifecycleRegistry
151+
registry?.handleLifecycleEvent(Lifecycle.Event.ON_START)
152+
registry?.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
153+
}
154+
155+
val controller = Robolectric.buildService(LightningNodeService::class.java)
156+
controller.create().startCommand(0, 0)
157+
158+
val event = Event.PaymentReceived(
159+
paymentId = "payment_id_fg",
160+
paymentHash = "test_hash_fg",
161+
amountMsat = 200000u,
162+
customRecords = emptyList()
163+
)
164+
165+
eventsFlow.emit(event)
166+
167+
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
168+
val shadows = Shadows.shadowOf(notificationManager)
169+
170+
val paymentNotification = shadows.allNotifications.find {
171+
it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification_received_title)
172+
}
173+
174+
assertNull("Payment notification should NOT be present in foreground", paymentNotification)
175+
176+
val details = NewTransactionSheetDetails.load(context)
177+
assertNull(details)
178+
}
179+
}

0 commit comments

Comments
 (0)