Skip to content

Commit abd1ffe

Browse files
committed
feat: payment received push notification
1 parent 3649737 commit abd1ffe

File tree

9 files changed

+354
-19
lines changed

9 files changed

+354
-19
lines changed

app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ dependencies {
195195
// Crypto
196196
implementation(libs.bouncycastle.provider.jdk)
197197
implementation(libs.ldk.node.android) { exclude(group = "net.java.dev.jna", module = "jna") }
198-
implementation(libs.bitkitcore)
198+
implementation(libs.bitkit.core)
199199
implementation(libs.vss)
200200
// Firebase
201201
implementation(platform(libs.firebase.bom))

app/src/main/java/to/bitkit/App.kt

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,7 @@ class CurrentActivity : ActivityLifecycleCallbacks {
3838
var value: Activity? = null
3939
private set
4040

41-
override fun onActivityCreated(activity: Activity, bundle: Bundle?) {
42-
this.value = activity
43-
}
41+
override fun onActivityCreated(activity: Activity, bundle: Bundle?) = Unit
4442

4543
override fun onActivityStarted(activity: Activity) {
4644
this.value = activity
@@ -51,8 +49,15 @@ class CurrentActivity : ActivityLifecycleCallbacks {
5149
}
5250

5351
override fun onActivityPaused(activity: Activity) = Unit
54-
override fun onActivityStopped(activity: Activity) = Unit
52+
53+
override fun onActivityStopped(activity: Activity) {
54+
if (this.value == activity) this.value = null
55+
}
56+
5557
override fun onActivitySaveInstanceState(activity: Activity, bundle: Bundle) = Unit
56-
override fun onActivityDestroyed(activity: Activity) = Unit
58+
59+
override fun onActivityDestroyed(activity: Activity) {
60+
if (this.value == activity) this.value = null
61+
}
5762
}
5863
// endregion

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

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,30 @@ import kotlinx.coroutines.CoroutineScope
1111
import kotlinx.coroutines.Dispatchers
1212
import kotlinx.coroutines.SupervisorJob
1313
import kotlinx.coroutines.cancel
14+
import kotlinx.coroutines.delay
15+
import kotlinx.coroutines.flow.first
1416
import kotlinx.coroutines.launch
17+
import org.lightningdevkit.ldknode.Event
1518
import to.bitkit.App
1619
import to.bitkit.R
20+
import to.bitkit.data.SettingsData
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
26+
import to.bitkit.models.PrimaryDisplay
27+
import to.bitkit.models.formatToModernDisplay
28+
import to.bitkit.repositories.ActivityRepo
29+
import to.bitkit.repositories.CurrencyRepo
1730
import to.bitkit.repositories.LightningRepo
1831
import to.bitkit.repositories.WalletRepo
32+
import to.bitkit.services.LdkNodeEventBus
1933
import to.bitkit.ui.MainActivity
34+
import to.bitkit.ui.pushNotification
2035
import to.bitkit.utils.Logger
2136
import javax.inject.Inject
37+
import kotlin.time.Duration.Companion.seconds
2238

2339
@AndroidEntryPoint
2440
class LightningNodeService : Service() {
@@ -31,6 +47,18 @@ class LightningNodeService : Service() {
3147
@Inject
3248
lateinit var walletRepo: WalletRepo
3349

50+
@Inject
51+
lateinit var ldkNodeEventBus: LdkNodeEventBus
52+
53+
@Inject
54+
lateinit var settingsStore: SettingsStore
55+
56+
@Inject
57+
lateinit var activityRepo: ActivityRepo
58+
59+
@Inject
60+
lateinit var currencyRepo: CurrencyRepo
61+
3462
override fun onCreate() {
3563
super.onCreate()
3664
startForeground(NOTIFICATION_ID, createNotification())
@@ -53,12 +81,76 @@ class LightningNodeService : Service() {
5381
walletRepo.syncBalances()
5482
}
5583
}
84+
85+
launch {
86+
ldkNodeEventBus.events.collect { event ->
87+
handleBackgroundEvent(event)
88+
}
89+
}
5690
}
5791
}
5892

59-
// Update the createNotification method in LightningNodeService.kt
93+
private suspend fun handleBackgroundEvent(event: Event) {
94+
delay(0.5.seconds) // Small delay to allow lifecycle callbacks to settle after app backgrounding
95+
if (App.currentActivity?.value != null) return
96+
97+
when (event) {
98+
is Event.PaymentReceived -> {
99+
val sats = event.amountMsat / 1000u
100+
showPaymentNotification(sats.toLong(), event.paymentHash, isOnchain = false)
101+
}
102+
103+
is Event.OnchainTransactionReceived -> {
104+
val sats = event.details.amountSats
105+
val shouldShow = activityRepo.shouldShowReceivedSheet(event.txid, sats.toULong())
106+
if (shouldShow) {
107+
showPaymentNotification(sats, event.txid, isOnchain = true)
108+
}
109+
}
110+
111+
else -> Unit
112+
}
113+
}
114+
115+
private suspend fun showPaymentNotification(sats: Long, paymentHashOrTxId: String?, isOnchain: Boolean) {
116+
if (App.currentActivity?.value != null) return
117+
118+
val settings = settingsStore.data.first()
119+
val type = if (isOnchain) NewTransactionSheetType.ONCHAIN else NewTransactionSheetType.LIGHTNING
120+
val direction = NewTransactionSheetDirection.RECEIVED
121+
122+
NewTransactionSheetDetails.save(
123+
this,
124+
NewTransactionSheetDetails(type, direction, paymentHashOrTxId, sats)
125+
)
126+
127+
val title = getString(R.string.notification_received_title)
128+
val body = if (settings.showNotificationDetails) {
129+
formatNotificationAmount(sats, settings)
130+
} else {
131+
getString(R.string.notification_received_body_hidden)
132+
}
133+
134+
pushNotification(title, body, context = this)
135+
}
136+
137+
private fun formatNotificationAmount(sats: Long, settings: SettingsData): String {
138+
val converted = currencyRepo.convertSatsToFiat(sats).getOrNull()
139+
140+
val amountText = converted?.let {
141+
val btcDisplay = it.bitcoinDisplay(settings.displayUnit)
142+
if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) {
143+
"${btcDisplay.symbol} ${btcDisplay.value} (${it.symbol}${it.formatted})"
144+
} else {
145+
"${it.symbol}${it.formatted} (${btcDisplay.symbol} ${btcDisplay.value})"
146+
}
147+
} ?: "$BITCOIN_SYMBOL ${sats.formatToModernDisplay()}"
148+
149+
return getString(R.string.notification_received_body_amount, amountText)
150+
}
151+
60152
private fun createNotification(
61-
contentText: String = "Bitkit is running in background so you can receive Lightning payments"
153+
contentText: String = getString(R.string.notification_running_in_background),
62154
): Notification {
63155
val notificationIntent = Intent(this, MainActivity::class.java).apply {
64156
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
@@ -88,7 +180,7 @@ class LightningNodeService : Service() {
88180
.setContentIntent(pendingIntent)
89181
.addAction(
90182
R.drawable.ic_x,
91-
"Stop App", // TODO: Get from resources
183+
getString(R.string.notification_stop_app),
92184
stopPendingIntent
93185
)
94186
.build()

app/src/main/java/to/bitkit/repositories/ActivityRepo.kt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.synonym.bitkitcore.ActivityTags
77
import com.synonym.bitkitcore.ClosedChannelDetails
88
import com.synonym.bitkitcore.IcJitEntry
99
import com.synonym.bitkitcore.LightningActivity
10+
import com.synonym.bitkitcore.OnchainActivity
1011
import com.synonym.bitkitcore.PaymentState
1112
import com.synonym.bitkitcore.PaymentType
1213
import com.synonym.bitkitcore.SortDirection
@@ -191,6 +192,42 @@ class ActivityRepo @Inject constructor(
191192
}
192193
}
193194

195+
private suspend fun getOnchainActivityByTxId(txid: String): OnchainActivity? {
196+
return coreService.activity.getOnchainActivityByTxId(txid)
197+
}
198+
199+
/**
200+
* Determines whether to show the payment received sheet for an onchain transaction.
201+
* Returns false for:
202+
* - Zero value transactions
203+
* - Channel closure transactions (transfers to savings)
204+
* - RBF replacement transactions with the same value as the original
205+
*/
206+
suspend fun shouldShowReceivedSheet(txid: String, value: ULong): Boolean = withContext(bgDispatcher) {
207+
if (value == 0uL) return@withContext false
208+
209+
if (findClosedChannelForTransaction(txid) != null) {
210+
Logger.debug("Skipping received sheet for channel closure tx: $txid", context = TAG)
211+
return@withContext false
212+
}
213+
214+
val onchainActivity = getOnchainActivityByTxId(txid)
215+
if (onchainActivity != null && onchainActivity.boostTxIds.isNotEmpty()) {
216+
for (replacedTxid in onchainActivity.boostTxIds) {
217+
val replacedActivity = getOnchainActivityByTxId(replacedTxid)
218+
if (replacedActivity != null && replacedActivity.value == value) {
219+
Logger.info(
220+
"Skipping received sheet for RBF replacement $txid with same value as $replacedTxid",
221+
context = TAG
222+
)
223+
return@withContext false
224+
}
225+
}
226+
}
227+
228+
return@withContext true
229+
}
230+
194231
/**
195232
* Gets a specific activity by payment hash or txID with retry logic
196233
*/

app/src/main/java/to/bitkit/services/CoreService.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import com.synonym.bitkitcore.deleteActivityById
2929
import com.synonym.bitkitcore.estimateOrderFeeFull
3030
import com.synonym.bitkitcore.getActivities
3131
import com.synonym.bitkitcore.getActivityById
32+
import com.synonym.bitkitcore.getActivityByTxId
3233
import com.synonym.bitkitcore.getAllClosedChannels
3334
import com.synonym.bitkitcore.getAllUniqueTags
3435
import com.synonym.bitkitcore.getCjitEntries
@@ -248,6 +249,10 @@ class ActivityService(
248249
}
249250
}
250251

252+
suspend fun getOnchainActivityByTxId(txId: String): OnchainActivity? = ServiceQueue.CORE.background {
253+
getActivityByTxId(txId = txId)
254+
}
255+
251256
suspend fun get(
252257
filter: ActivityFilter? = null,
253258
txType: PaymentType? = null,

app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -292,15 +292,27 @@ class AppViewModel @Inject constructor(
292292
is Event.PaymentForwarded -> Unit
293293

294294
is Event.OnchainTransactionReceived -> {
295-
showNewTransactionSheet(
296-
NewTransactionSheetDetails(
297-
type = NewTransactionSheetType.ONCHAIN,
298-
direction = NewTransactionSheetDirection.RECEIVED,
299-
paymentHashOrTxId = event.txid,
300-
sats = event.details.amountSats.toLong(),
301-
),
302-
event,
303-
)
295+
val sats = event.details.amountSats
296+
launch(bgDispatcher) {
297+
delay(500)
298+
val shouldShow = activityRepo.shouldShowReceivedSheet(
299+
txid = event.txid,
300+
value = sats.toULong()
301+
)
302+
if (!shouldShow) {
303+
Logger.info("Skipping received sheet for tx: ${event.txid}", context = TAG)
304+
return@launch
305+
}
306+
showNewTransactionSheet(
307+
NewTransactionSheetDetails(
308+
type = NewTransactionSheetType.ONCHAIN,
309+
direction = NewTransactionSheetDirection.RECEIVED,
310+
paymentHashOrTxId = event.txid,
311+
sats = sats,
312+
),
313+
event,
314+
)
315+
}
304316
}
305317

306318
is Event.OnchainTransactionConfirmed -> Unit

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,4 +1129,9 @@
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_stop_app">Stop App</string>
1133+
<string name="notification_running_in_background">Bitkit is running in background so you can receive Lightning payments</string>
1134+
<string name="notification_received_title">Payment Received</string>
1135+
<string name="notification_received_body_hidden">Open Bitkit to see details</string>
1136+
<string name="notification_received_body_amount">Received %s</string>
11321137
</resources>

0 commit comments

Comments
 (0)