Skip to content

Commit 8cd39ac

Browse files
committed
feat: toast messages for onchain events
1 parent fb3e685 commit 8cd39ac

File tree

8 files changed

+153
-66
lines changed

8 files changed

+153
-66
lines changed

app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,18 @@ import to.bitkit.models.NotificationState
77
sealed interface NotifyPaymentReceived {
88

99
sealed interface Command : NotifyPaymentReceived {
10-
val sats: Long
10+
val sats: ULong
1111
val paymentId: String
1212
val includeNotification: Boolean
1313

1414
data class Lightning(
15-
override val sats: Long,
15+
override val sats: ULong,
1616
override val paymentId: String,
1717
override val includeNotification: Boolean = false,
1818
) : Command
1919

2020
data class Onchain(
21-
override val sats: Long,
21+
override val sats: ULong,
2222
override val paymentId: String,
2323
override val includeNotification: Boolean = false,
2424
) : Command
@@ -27,16 +27,20 @@ sealed interface NotifyPaymentReceived {
2727
fun from(event: Event, includeNotification: Boolean = false): Command? =
2828
when (event) {
2929
is Event.PaymentReceived -> Lightning(
30-
sats = (event.amountMsat / 1000u).toLong(),
30+
sats = event.amountMsat / 1000u,
3131
paymentId = event.paymentHash,
3232
includeNotification = includeNotification,
3333
)
3434

35-
is Event.OnchainTransactionReceived -> Onchain(
36-
sats = event.details.amountSats,
37-
paymentId = event.txid,
38-
includeNotification = includeNotification,
39-
)
35+
is Event.OnchainTransactionReceived -> {
36+
val amountSats = event.details.amountSats
37+
if (amountSats <= 0) null
38+
else Onchain(
39+
sats = amountSats.toULong(),
40+
paymentId = event.txid,
41+
includeNotification = includeNotification,
42+
)
43+
}
4044

4145
else -> null
4246
}

app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,24 +39,25 @@ class NotifyPaymentReceivedHandler @Inject constructor(
3939
val shouldShow = when (command) {
4040
is NotifyPaymentReceived.Command.Lightning -> true
4141
is NotifyPaymentReceived.Command.Onchain -> {
42-
activityRepo.shouldShowPaymentReceived(command.paymentId, command.sats.toULong())
42+
activityRepo.shouldShowPaymentReceived(command.paymentId, command.sats)
4343
}
4444
}
4545

4646
if (!shouldShow) return@runCatching NotifyPaymentReceived.Result.Skip
4747

48+
val satsLong = command.sats.toLong()
4849
val details = NewTransactionSheetDetails(
4950
type = when (command) {
5051
is NotifyPaymentReceived.Command.Lightning -> NewTransactionSheetType.LIGHTNING
5152
is NotifyPaymentReceived.Command.Onchain -> NewTransactionSheetType.ONCHAIN
5253
},
5354
direction = NewTransactionSheetDirection.RECEIVED,
5455
paymentHashOrTxId = command.paymentId,
55-
sats = command.sats,
56+
sats = satsLong,
5657
)
5758

5859
if (command.includeNotification) {
59-
val notification = buildNotificationContent(command.sats)
60+
val notification = buildNotificationContent(satsLong)
6061
NotifyPaymentReceived.Result.ShowNotification(details, notification)
6162
} else {
6263
NotifyPaymentReceived.Result.ShowSheet(details)

app/src/main/java/to/bitkit/models/Toast.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ data class Toast(
66
val description: String? = null,
77
val autoHide: Boolean,
88
val visibilityTime: Long = VISIBILITY_TIME_DEFAULT,
9+
val testTag: String? = null,
910
) {
1011
enum class ToastType { SUCCESS, INFO, LIGHTNING, WARNING, ERROR }
1112

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,25 @@ class ActivityRepo @Inject constructor(
228228
return@withContext true
229229
}
230230

231+
/**
232+
* Checks if a transaction is inbound (received) by looking up the payment direction.
233+
*/
234+
suspend fun isReceivedTransaction(txid: String): Boolean = withContext(bgDispatcher) {
235+
lightningRepo.getPayments().getOrNull()?.let { payments ->
236+
payments.firstOrNull { payment ->
237+
(payment.kind as? PaymentKind.Onchain)?.txid == txid
238+
}
239+
}?.direction == PaymentDirection.INBOUND
240+
}
241+
242+
/**
243+
* Checks if a transaction was replaced (RBF) by checking if the activity exists but doesExist=false.
244+
*/
245+
suspend fun wasTransactionReplaced(txid: String): Boolean = withContext(bgDispatcher) {
246+
val onchainActivity = getOnchainActivityByTxId(txid) ?: return@withContext false
247+
return@withContext !onchainActivity.doesExist
248+
}
249+
231250
/**
232251
* Gets a specific activity by payment hash or txID with retry logic
233252
*/

app/src/main/java/to/bitkit/ui/components/ToastView.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier
3131
import androidx.compose.ui.graphics.Brush.Companion.verticalGradient
3232
import androidx.compose.ui.graphics.Color
3333
import androidx.compose.ui.res.stringResource
34+
import androidx.compose.ui.platform.testTag
3435
import androidx.compose.ui.tooling.preview.Preview
3536
import androidx.compose.ui.unit.dp
3637
import to.bitkit.R
@@ -68,6 +69,7 @@ fun ToastView(
6869
.background(verticalGradient(listOf(gradientColor, Color.Black), startY = 0f), RoundedCornerShape(8.dp))
6970
.border(1.dp, tintColor, RoundedCornerShape(8.dp))
7071
.padding(16.dp)
72+
.then(toast.testTag?.let { Modifier.testTag(it) } ?: Modifier),
7173
) {
7274
Row(
7375
verticalAlignment = Alignment.CenterVertically,

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

Lines changed: 100 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import androidx.lifecycle.ViewModel
1111
import androidx.lifecycle.viewModelScope
1212
import androidx.navigation.NavOptions
1313
import androidx.navigation.navOptions
14+
import com.synonym.bitkitcore.Activity
1415
import com.synonym.bitkitcore.ActivityFilter
1516
import com.synonym.bitkitcore.FeeRates
1617
import com.synonym.bitkitcore.LightningInvoice
@@ -234,56 +235,22 @@ class AppViewModel @Inject constructor(
234235
NotifyPaymentReceived.Command.from(event)?.let { handlePaymentReceived(it, event) }
235236
}
236237

237-
is Event.ChannelReady -> {
238-
val channel = lightningRepo.getChannels()?.find { it.channelId == event.channelId }
239-
val cjitEntry = channel?.let { blocktankRepo.getCjitEntry(it) }
240-
if (cjitEntry != null) {
241-
val amount = channel.amountOnClose.toLong()
242-
showNewTransactionSheet(
243-
NewTransactionSheetDetails(
244-
type = NewTransactionSheetType.LIGHTNING,
245-
direction = NewTransactionSheetDirection.RECEIVED,
246-
sats = amount,
247-
),
248-
event,
249-
)
250-
activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel)
251-
} else {
252-
toast(
253-
type = Toast.ToastType.LIGHTNING,
254-
title = context.getString(R.string.lightning__channel_opened_title),
255-
description = context.getString(R.string.lightning__channel_opened_msg),
256-
)
257-
}
258-
}
238+
is Event.ChannelReady -> notifyChannelReady(event)
259239

260240
is Event.ChannelPending -> Unit
261241
is Event.ChannelClosed -> Unit
262242

263-
is Event.PaymentSuccessful -> {
264-
val paymentHash = event.paymentHash
265-
// TODO Temporary solution while LDK node doesn't return the sent value in the event
266-
activityRepo.findActivityByPaymentId(
267-
paymentHashOrTxId = paymentHash,
268-
type = ActivityFilter.LIGHTNING,
269-
txType = PaymentType.SENT,
270-
retry = true
271-
).onSuccess { activity ->
272-
handlePaymentSuccess(
273-
NewTransactionSheetDetails(
274-
type = NewTransactionSheetType.LIGHTNING,
275-
direction = NewTransactionSheetDirection.SENT,
276-
paymentHashOrTxId = event.paymentHash,
277-
sats = activity.totalValue().toLong(),
278-
),
279-
)
280-
}.onFailure { e ->
281-
Logger.warn("Failed displaying sheet for event: $event", e)
282-
}
283-
}
243+
is Event.PaymentSuccessful -> notifyPaymentSentOnLightning(event)
284244

285245
is Event.PaymentClaimable -> Unit
286-
is Event.PaymentFailed -> Unit
246+
is Event.PaymentFailed -> {
247+
toast(
248+
type = Toast.ToastType.ERROR,
249+
title = context.getString(R.string.wallet__toast_payment_failed_title),
250+
description = context.getString(R.string.wallet__toast_payment_failed_description),
251+
testTag = "PaymentFailedToast",
252+
)
253+
}
287254
is Event.PaymentForwarded -> Unit
288255

289256
is Event.OnchainTransactionReceived -> {
@@ -295,9 +262,47 @@ class AppViewModel @Inject constructor(
295262
is Event.SyncCompleted -> Unit
296263
is Event.BalanceChanged -> Unit
297264

298-
is Event.OnchainTransactionEvicted -> Unit
299-
is Event.OnchainTransactionReorged -> Unit
300-
is Event.OnchainTransactionReplaced -> Unit
265+
is Event.OnchainTransactionEvicted -> {
266+
viewModelScope.launch(bgDispatcher) {
267+
if (!activityRepo.wasTransactionReplaced(event.txid)) {
268+
toast(
269+
type = Toast.ToastType.WARNING,
270+
title = context.getString(R.string.wallet__toast_transaction_removed_title),
271+
description = context.getString(R.string.wallet__toast_transaction_removed_description),
272+
testTag = "TransactionRemovedToast",
273+
)
274+
}
275+
}
276+
}
277+
278+
is Event.OnchainTransactionReorged -> {
279+
toast(
280+
type = Toast.ToastType.WARNING,
281+
title = context.getString(R.string.wallet__toast_transaction_unconfirmed_title),
282+
description = context.getString(R.string.wallet__toast_transaction_unconfirmed_description),
283+
testTag = "TransactionUnconfirmedToast",
284+
)
285+
}
286+
287+
is Event.OnchainTransactionReplaced -> {
288+
viewModelScope.launch(bgDispatcher) {
289+
if (activityRepo.isReceivedTransaction(event.txid)) {
290+
toast(
291+
type = Toast.ToastType.INFO,
292+
title = context.getString(R.string.wallet__toast_received_transaction_replaced_title),
293+
description = context.getString(R.string.wallet__toast_received_transaction_replaced_description),
294+
testTag = "ReceivedTransactionReplacedToast",
295+
)
296+
} else {
297+
toast(
298+
type = Toast.ToastType.INFO,
299+
title = context.getString(R.string.wallet__toast_transaction_replaced_title),
300+
description = context.getString(R.string.wallet__toast_transaction_replaced_description),
301+
testTag = "TransactionReplacedToast",
302+
)
303+
}
304+
}
305+
}
301306
}
302307
}.onFailure { e ->
303308
Logger.error("LDK event handler error", e, context = TAG)
@@ -306,6 +311,51 @@ class AppViewModel @Inject constructor(
306311
}
307312
}
308313

314+
private suspend fun notifyPaymentSentOnLightning(event: Event.PaymentSuccessful): Result<Activity> {
315+
val paymentHash = event.paymentHash
316+
// TODO Temporary solution while LDK node doesn't return the sent value in the event
317+
return activityRepo.findActivityByPaymentId(
318+
paymentHashOrTxId = paymentHash,
319+
type = ActivityFilter.LIGHTNING,
320+
txType = PaymentType.SENT,
321+
retry = true
322+
).onSuccess { activity ->
323+
handlePaymentSuccess(
324+
NewTransactionSheetDetails(
325+
type = NewTransactionSheetType.LIGHTNING,
326+
direction = NewTransactionSheetDirection.SENT,
327+
paymentHashOrTxId = event.paymentHash,
328+
sats = activity.totalValue().toLong(),
329+
),
330+
)
331+
}.onFailure { e ->
332+
Logger.warn("Failed displaying sheet for event: $event", e)
333+
}
334+
}
335+
336+
private suspend fun notifyChannelReady(event: Event.ChannelReady): Any {
337+
val channel = lightningRepo.getChannels()?.find { it.channelId == event.channelId }
338+
val cjitEntry = channel?.let { blocktankRepo.getCjitEntry(it) }
339+
return if (cjitEntry != null) {
340+
val amount = channel.amountOnClose.toLong()
341+
showNewTransactionSheet(
342+
NewTransactionSheetDetails(
343+
type = NewTransactionSheetType.LIGHTNING,
344+
direction = NewTransactionSheetDirection.RECEIVED,
345+
sats = amount,
346+
),
347+
event,
348+
)
349+
activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel)
350+
} else {
351+
toast(
352+
type = Toast.ToastType.LIGHTNING,
353+
title = context.getString(R.string.lightning__channel_opened_title),
354+
description = context.getString(R.string.lightning__channel_opened_msg),
355+
)
356+
}
357+
}
358+
309359
private fun handlePaymentReceived(
310360
receivedEvent: NotifyPaymentReceived.Command,
311361
originalEvent: Event,
@@ -1441,13 +1491,15 @@ class AppViewModel @Inject constructor(
14411491
description: String? = null,
14421492
autoHide: Boolean = true,
14431493
visibilityTime: Long = Toast.VISIBILITY_TIME_DEFAULT,
1494+
testTag: String? = null,
14441495
) {
14451496
currentToast = Toast(
14461497
type = type,
14471498
title = title,
14481499
description = description,
14491500
autoHide = autoHide,
1450-
visibilityTime = visibilityTime
1501+
visibilityTime = visibilityTime,
1502+
testTag = testTag,
14511503
)
14521504
if (autoHide) {
14531505
viewModelScope.launch {

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -952,6 +952,14 @@
952952
<string name="wallet__toast_payment_success_description">Your instant payment was sent successfully.</string>
953953
<string name="wallet__toast_payment_failed_title">Payment Failed</string>
954954
<string name="wallet__toast_payment_failed_description">Your instant payment failed. Please try again.</string>
955+
<string name="wallet__toast_received_transaction_replaced_title">Received Transaction Replaced</string>
956+
<string name="wallet__toast_received_transaction_replaced_description">Your received transaction was replaced by a fee bump</string>
957+
<string name="wallet__toast_transaction_removed_title">Transaction Removed</string>
958+
<string name="wallet__toast_transaction_removed_description">Transaction was removed from mempool</string>
959+
<string name="wallet__toast_transaction_replaced_title">Transaction Replaced</string>
960+
<string name="wallet__toast_transaction_replaced_description">Your transaction was replaced by a fee bump</string>
961+
<string name="wallet__toast_transaction_unconfirmed_title">Transaction Unconfirmed</string>
962+
<string name="wallet__toast_transaction_unconfirmed_description">Transaction became unconfirmed due to blockchain reorganization</string>
955963
<string name="wallet__selection_title">Coin Selection</string>
956964
<string name="wallet__selection_auto">Auto</string>
957965
<string name="wallet__selection_total_required">Total required</string>

app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() {
6262

6363
@Test
6464
fun `lightning payment returns ShowSheet by default`() = test {
65-
val command = NotifyPaymentReceived.Command.Lightning(sats = 1000L, paymentId = "hash123")
65+
val command = NotifyPaymentReceived.Command.Lightning(sats = 1000uL, paymentId = "hash123")
6666

6767
val result = sut(command)
6868

@@ -79,7 +79,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() {
7979
@Test
8080
fun `lightning payment returns ShowNotification when includeNotification is true`() = test {
8181
val command = NotifyPaymentReceived.Command.Lightning(
82-
sats = 1000L,
82+
sats = 1000uL,
8383
paymentId = "hash123",
8484
includeNotification = true,
8585
)
@@ -99,7 +99,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() {
9999
@Test
100100
fun `onchain payment returns ShowSheet when shouldShowPaymentReceived returns true`() = test {
101101
whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(true)
102-
val command = NotifyPaymentReceived.Command.Onchain(sats = 5000L, paymentId = "txid456")
102+
val command = NotifyPaymentReceived.Command.Onchain(sats = 5000uL, paymentId = "txid456")
103103

104104
val result = sut(command)
105105

@@ -116,7 +116,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() {
116116
@Test
117117
fun `onchain payment returns Skip when shouldShowPaymentReceived is false`() = test {
118118
whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(false)
119-
val command = NotifyPaymentReceived.Command.Onchain(sats = 5000L, paymentId = "txid456")
119+
val command = NotifyPaymentReceived.Command.Onchain(sats = 5000uL, paymentId = "txid456")
120120

121121
val result = sut(command)
122122

@@ -128,7 +128,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() {
128128
@Test
129129
fun `onchain payment calls shouldShowPaymentReceived with correct parameters`() = test {
130130
whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(true)
131-
val command = NotifyPaymentReceived.Command.Onchain(sats = 7500L, paymentId = "txid789")
131+
val command = NotifyPaymentReceived.Command.Onchain(sats = 7500uL, paymentId = "txid789")
132132

133133
sut(command)
134134

@@ -137,7 +137,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() {
137137

138138
@Test
139139
fun `lightning payment does not call shouldShowPaymentReceived`() = test {
140-
val command = NotifyPaymentReceived.Command.Lightning(sats = 1000L, paymentId = "hash123")
140+
val command = NotifyPaymentReceived.Command.Lightning(sats = 1000uL, paymentId = "hash123")
141141

142142
sut(command)
143143

0 commit comments

Comments
 (0)