From 649cd17cf3b7cad1ec827bd911fd9a2e672da02c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Nov 2025 21:30:08 +0100 Subject: [PATCH 01/32] chore: update ldk-node to `0.6.2-rc.7` chore: update ldk-node to `0.6.2-rc.6` --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 72a6b7408..ec9408126 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -82,8 +82,8 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } #ldk-node-android = { module = "org.lightningdevkit:ldk-node-android", version = "0.6.2" } # upstream -#ldk-node-android = { module = "org.lightningdevkit:ldk-node-android", version = "0.6.2-rc.4" } # local -ldk-node-android = { module = "com.github.synonymdev:ldk-node", version = "v0.6.2-rc.4" } # fork +#ldk-node-android = { module = "org.lightningdevkit:ldk-node-android", version = "0.6.2-rc.7" } # local +ldk-node-android = { module = "com.github.synonymdev:ldk-node", version = "0.6.2-rc.7" } # fork lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } From 91d11de87af9419b8d7702db6a06e0e18ce44174 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 21 Nov 2025 10:37:34 +0100 Subject: [PATCH 02/32] feat: onchain received sheet --- .../main/java/to/bitkit/fcm/WakeNodeWorker.kt | 25 +++++- .../models/NewTransactionSheetDetails.kt | 4 +- .../to/bitkit/repositories/LightningRepo.kt | 5 +- .../to/bitkit/services/LightningService.kt | 79 +------------------ app/src/main/java/to/bitkit/ui/ContentView.kt | 2 +- .../java/to/bitkit/ui/components/Money.kt | 2 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 56 ++++++++----- 7 files changed, 67 insertions(+), 106 deletions(-) diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index fd30c92f0..c28a779f0 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -132,10 +132,6 @@ class WakeNodeWorker @AssistedInject constructor( is Event.ChannelReady -> onChannelReady(event, showDetails, openBitkitMessage) is Event.ChannelClosed -> onChannelClosed(event) - is Event.PaymentSuccessful -> Unit - is Event.PaymentClaimable -> Unit - is Event.PaymentForwarded -> Unit - is Event.PaymentFailed -> { self.bestAttemptContent?.title = "Payment failed" self.bestAttemptContent?.body = "⚡ ${event.reason}" @@ -144,6 +140,27 @@ class WakeNodeWorker @AssistedInject constructor( self.deliver() } } + + is Event.OnchainTransactionReceived -> { + // TODO handle like onPaymentReceived but for onchain + } + + is Event.OnchainTransactionConfirmed -> Unit + + is Event.PaymentSuccessful -> Unit + is Event.PaymentClaimable -> Unit + is Event.PaymentForwarded -> Unit + + is Event.SyncProgress -> Unit + is Event.SyncCompleted -> Unit + is Event.BalanceChanged -> Unit + + is Event.OnchainTransactionEvicted, + is Event.OnchainTransactionReorged, + is Event.OnchainTransactionReplaced, + -> { + // TODO handle activity removed from mempool UI & toast + } } } diff --git a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt index 3279570be..ee86821c9 100644 --- a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt +++ b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt @@ -16,8 +16,8 @@ data class NewTransactionSheetDetails( val type: NewTransactionSheetType, val direction: NewTransactionSheetDirection, val paymentHashOrTxId: String? = null, - val sats: Long, - val isLoadingDetails: Boolean = false + val sats: Long = 0, + val isLoadingDetails: Boolean = false, ) { companion object { private const val BACKGROUND_TRANSACTION_KEY = "backgroundTransaction" diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index af03fdfd2..e0b32776d 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -368,7 +368,7 @@ class LightningRepo @Inject constructor( } val channelName = channel.inboundScidAlias?.toString() - ?: channel.channelId.take(CHANNEL_ID_PREVIEW_LENGTH) + "…" + ?: (channel.channelId.take(CHANNEL_ID_PREVIEW_LENGTH) + "…") val closedAt = (System.currentTimeMillis() / 1000L).toULong() @@ -656,14 +656,13 @@ class LightningRepo @Inject constructor( isMaxAmount = isMaxAmount ) - val addressString = address.toString() val preActivityMetadata = PreActivityMetadata( paymentId = txId, createdAt = nowTimestamp().toEpochMilli().toULong(), tags = tags, paymentHash = null, txId = txId, - address = addressString, + address = address, isReceive = false, feeRate = satsPerVByte.toULong(), isTransfer = isTransfer, diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 716c5a512..fadf46786 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -49,6 +49,7 @@ import to.bitkit.utils.LdkError import to.bitkit.utils.LdkLogWriter import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError +import to.bitkit.utils.jsonLogOf import javax.inject.Inject import javax.inject.Singleton import kotlin.io.path.Path @@ -680,88 +681,16 @@ class LightningService @Inject constructor( return } val event = node.nextEventAsync() - + Logger.debug("LDK-node event fired: ${jsonLogOf(event)}") try { node.eventHandled() - Logger.debug("LDK eventHandled: $event") + Logger.verbose("LDK-node eventHandled: $event") } catch (e: NodeException) { - Logger.error("LDK eventHandled error", LdkError(e)) + Logger.verbose("LDK eventHandled error: $event", LdkError(e)) } - - logEvent(event) onEvent?.invoke(event) } } - - private fun logEvent(event: Event) { - when (event) { - is Event.PaymentSuccessful -> { - val paymentId = event.paymentId ?: "?" - val paymentHash = event.paymentHash - val feePaidMsat = event.feePaidMsat ?: 0 - Logger.info( - "✅ Payment successful: paymentId: $paymentId paymentHash: $paymentHash feePaidMsat: $feePaidMsat" - ) - } - - is Event.PaymentFailed -> { - val paymentId = event.paymentId ?: "?" - val paymentHash = event.paymentHash - val reason = event.reason - Logger.info("❌ Payment failed: paymentId: $paymentId paymentHash: $paymentHash reason: $reason") - } - - is Event.PaymentReceived -> { - val paymentId = event.paymentId ?: "?" - val paymentHash = event.paymentHash - val amountMsat = event.amountMsat - Logger.info( - "🤑 Payment received: paymentId: $paymentId paymentHash: $paymentHash amountMsat: $amountMsat" - ) - } - - is Event.PaymentClaimable -> { - val paymentId = event.paymentId - val paymentHash = event.paymentHash - val claimableAmountMsat = event.claimableAmountMsat - Logger.info( - "🫰 Payment claimable: paymentId: $paymentId paymentHash: $paymentHash claimableAmountMsat: $claimableAmountMsat" - ) - } - - is Event.PaymentForwarded -> Unit - - is Event.ChannelPending -> { - val channelId = event.channelId - val userChannelId = event.userChannelId - val formerTemporaryChannelId = event.formerTemporaryChannelId - val counterpartyNodeId = event.counterpartyNodeId - val fundingTxo = event.fundingTxo - Logger.info( - "⏳ Channel pending: channelId: $channelId userChannelId: $userChannelId formerTemporaryChannelId: $formerTemporaryChannelId counterpartyNodeId: $counterpartyNodeId fundingTxo: $fundingTxo" - ) - } - - is Event.ChannelReady -> { - val channelId = event.channelId - val userChannelId = event.userChannelId - val counterpartyNodeId = event.counterpartyNodeId ?: "?" - Logger.info( - "👐 Channel ready: channelId: $channelId userChannelId: $userChannelId counterpartyNodeId: $counterpartyNodeId" - ) - } - - is Event.ChannelClosed -> { - val channelId = event.channelId - val userChannelId = event.userChannelId - val counterpartyNodeId = event.counterpartyNodeId ?: "?" - val reason = event.reason?.toString() ?: "" - Logger.info( - "⛔ Channel closed: channelId: $channelId userChannelId: $userChannelId counterpartyNodeId: $counterpartyNodeId reason: $reason" - ) - } - } - } // endregion // region state diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 873ef5a77..0df333178 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -220,7 +220,7 @@ fun ContentView( val pendingTransaction = NewTransactionSheetDetails.load(context) if (pendingTransaction != null) { - appViewModel.showNewTransactionSheet(details = pendingTransaction, event = null) + appViewModel.showNewTransactionSheet(details = pendingTransaction) NewTransactionSheetDetails.clear(context) } diff --git a/app/src/main/java/to/bitkit/ui/components/Money.kt b/app/src/main/java/to/bitkit/ui/components/Money.kt index d0d012e98..899e377b8 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -74,10 +74,10 @@ fun MoneyMSB( @Composable fun MoneyCaptionB( sats: Long, + modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.primary, symbol: Boolean = false, symbolColor: Color = Colors.White64, - modifier: Modifier = Modifier, ) { val isPreview = LocalInspectionMode.current if (isPreview) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c9546614e..e574beb49 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -226,7 +226,7 @@ class AppViewModel @Inject constructor( launch(bgDispatcher) { walletRepo.syncNodeAndWallet() } runCatching { - when (event) { // TODO Create individual sheet for each type of event + when (event) { is Event.PaymentReceived -> { showNewTransactionSheet( NewTransactionSheetDetails( @@ -235,7 +235,7 @@ class AppViewModel @Inject constructor( paymentHashOrTxId = event.paymentHash, sats = (event.amountMsat / 1000u).toLong(), ), - event = event + event, ) } @@ -250,7 +250,7 @@ class AppViewModel @Inject constructor( direction = NewTransactionSheetDirection.RECEIVED, sats = amount, ), - event = event + event, ) activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel) } else { @@ -267,7 +267,7 @@ class AppViewModel @Inject constructor( is Event.PaymentSuccessful -> { val paymentHash = event.paymentHash - // TODO Temporary solution while LDK node don't returns the sent value in the event + // TODO Temporary solution while LDK node doesn't return the sent value in the event activityRepo.findActivityByPaymentId( paymentHashOrTxId = paymentHash, type = ActivityFilter.LIGHTNING, @@ -283,13 +283,37 @@ class AppViewModel @Inject constructor( ), ) }.onFailure { e -> - Logger.warn("Failed displaying sheet for event: $Event", e) + Logger.warn("Failed displaying sheet for event: $event", e) } } is Event.PaymentClaimable -> Unit is Event.PaymentFailed -> Unit is Event.PaymentForwarded -> Unit + + is Event.OnchainTransactionReceived -> { + showNewTransactionSheet( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = event.txid, + sats = event.details.amountSats.toLong(), + ), + event, + ) + } + + is Event.OnchainTransactionConfirmed -> Unit + is Event.SyncProgress -> Unit + is Event.SyncCompleted -> Unit + is Event.BalanceChanged -> Unit + + is Event.OnchainTransactionEvicted, + is Event.OnchainTransactionReorged, + is Event.OnchainTransactionReplaced, + -> { + // TODO handle activity removed from mempool UI & toast + } } }.onFailure { e -> Logger.error("LDK event handler error", e, context = TAG) @@ -1324,9 +1348,7 @@ class AppViewModel @Inject constructor( // endregion // region TxSheet - var isNewTransactionSheetEnabled = true - private set - + private var _isNewTransactionSheetEnabled = true private val _showNewTransaction = MutableStateFlow(false) val showNewTransaction: StateFlow = _showNewTransaction.asStateFlow() @@ -1334,8 +1356,6 @@ class AppViewModel @Inject constructor( NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.RECEIVED, - paymentHashOrTxId = null, - sats = 0 ) ) @@ -1345,25 +1365,23 @@ class AppViewModel @Inject constructor( NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.SENT, - paymentHashOrTxId = null, - sats = 0 ) ) val successSendUiState = _successSendUiState.asStateFlow() fun setNewTransactionSheetEnabled(enabled: Boolean) { - isNewTransactionSheetEnabled = enabled + _isNewTransactionSheetEnabled = enabled } fun showNewTransactionSheet( details: NewTransactionSheetDetails, - event: Event?, + event: Event? = null, ) = viewModelScope.launch { if (backupRepo.isRestoring.value) return@launch - if (!isNewTransactionSheetEnabled) { - Logger.debug("NewTransactionSheet display blocked by isNewTransactionSheetEnabled=false", context = TAG) + if (!_isNewTransactionSheetEnabled) { + Logger.verbose("NewTransactionSheet blocked by isNewTransactionSheetEnabled=false", context = TAG) return@launch } @@ -1385,13 +1403,11 @@ class AppViewModel @Inject constructor( hideSheet() + _showNewTransaction.update { true } _newTransaction.update { details } - _showNewTransaction.value = true } - fun hideNewTransactionSheet() { - _showNewTransaction.value = false - } + fun hideNewTransactionSheet() = _showNewTransaction.update { false } // endregion // region Sheets From 60f0a2883877178dd23acf71ca0483dddb8c3b5b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 26 Nov 2025 15:16:05 +0100 Subject: [PATCH 03/32] feat: payment received push notification --- app/build.gradle.kts | 2 +- app/src/main/java/to/bitkit/App.kt | 15 +- .../androidServices/LightningNodeService.kt | 98 +++++++- .../main/java/to/bitkit/fcm/WakeNodeWorker.kt | 21 +- .../to/bitkit/repositories/ActivityRepo.kt | 37 +++ .../java/to/bitkit/services/CoreService.kt | 5 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 34 +-- app/src/main/res/values/strings.xml | 5 + .../LightningNodeServiceTest.kt | 226 ++++++++++++++++++ gradle/libs.versions.toml | 2 +- 10 files changed, 400 insertions(+), 45 deletions(-) create mode 100644 app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 21208b3d6..1ef22e2c9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -195,7 +195,7 @@ dependencies { // Crypto implementation(libs.bouncycastle.provider.jdk) implementation(libs.ldk.node.android) { exclude(group = "net.java.dev.jna", module = "jna") } - implementation(libs.bitkitcore) + implementation(libs.bitkit.core) implementation(libs.vss) // Firebase implementation(platform(libs.firebase.bom)) diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index 52ffa4053..68f925680 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -38,9 +38,7 @@ class CurrentActivity : ActivityLifecycleCallbacks { var value: Activity? = null private set - override fun onActivityCreated(activity: Activity, bundle: Bundle?) { - this.value = activity - } + override fun onActivityCreated(activity: Activity, bundle: Bundle?) = Unit override fun onActivityStarted(activity: Activity) { this.value = activity @@ -51,8 +49,15 @@ class CurrentActivity : ActivityLifecycleCallbacks { } override fun onActivityPaused(activity: Activity) = Unit - override fun onActivityStopped(activity: Activity) = Unit + + override fun onActivityStopped(activity: Activity) { + if (this.value == activity) this.value = null + } + override fun onActivitySaveInstanceState(activity: Activity, bundle: Bundle) = Unit - override fun onActivityDestroyed(activity: Activity) = Unit + + override fun onActivityDestroyed(activity: Activity) { + if (this.value == activity) this.value = null + } } // endregion diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index c54a9a9db..da011d4d4 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -11,14 +11,30 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import org.lightningdevkit.ldknode.Event import to.bitkit.App import to.bitkit.R +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +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.PrimaryDisplay +import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo +import to.bitkit.services.LdkNodeEventBus import to.bitkit.ui.MainActivity +import to.bitkit.ui.pushNotification import to.bitkit.utils.Logger import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds @AndroidEntryPoint class LightningNodeService : Service() { @@ -31,6 +47,18 @@ class LightningNodeService : Service() { @Inject lateinit var walletRepo: WalletRepo + @Inject + lateinit var ldkNodeEventBus: LdkNodeEventBus + + @Inject + lateinit var settingsStore: SettingsStore + + @Inject + lateinit var activityRepo: ActivityRepo + + @Inject + lateinit var currencyRepo: CurrencyRepo + override fun onCreate() { super.onCreate() startForeground(NOTIFICATION_ID, createNotification()) @@ -53,12 +81,76 @@ class LightningNodeService : Service() { walletRepo.syncBalances() } } + + launch { + ldkNodeEventBus.events.collect { event -> + handleBackgroundEvent(event) + } + } } } - // Update the createNotification method in LightningNodeService.kt + private suspend fun handleBackgroundEvent(event: Event) { + delay(0.5.seconds) // Small delay to allow lifecycle callbacks to settle after app backgrounding + if (App.currentActivity?.value != null) return + + when (event) { + is Event.PaymentReceived -> { + val sats = event.amountMsat / 1000u + showPaymentNotification(sats.toLong(), event.paymentHash, isOnchain = false) + } + + is Event.OnchainTransactionReceived -> { + val sats = event.details.amountSats + val shouldShow = activityRepo.shouldShowPaymentReceived(event.txid, sats.toULong()) + if (!shouldShow) return + + showPaymentNotification(sats, event.txid, isOnchain = true) + } + + else -> Unit + } + } + + private suspend fun showPaymentNotification(sats: Long, paymentHashOrTxId: String?, isOnchain: Boolean) { + if (App.currentActivity?.value != null) return + + val settings = settingsStore.data.first() + val type = if (isOnchain) NewTransactionSheetType.ONCHAIN else NewTransactionSheetType.LIGHTNING + val direction = NewTransactionSheetDirection.RECEIVED + + NewTransactionSheetDetails.save( + this, + NewTransactionSheetDetails(type, direction, paymentHashOrTxId, sats) + ) + + val title = getString(R.string.notification_received_title) + val body = if (settings.showNotificationDetails) { + formatNotificationAmount(sats, settings) + } else { + getString(R.string.notification_received_body_hidden) + } + + pushNotification(title, body, context = this) + } + + 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.symbol}${it.formatted})" + } else { + "${it.symbol}${it.formatted} (${btcDisplay.symbol} ${btcDisplay.value})" + } + } ?: "$BITCOIN_SYMBOL ${sats.formatToModernDisplay()}" + + return getString(R.string.notification_received_body_amount, amountText) + } + private fun createNotification( - contentText: String = "Bitkit is running in background so you can receive Lightning payments" + contentText: String = getString(R.string.notification_running_in_background), ): Notification { val notificationIntent = Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP @@ -88,7 +180,7 @@ class LightningNodeService : Service() { .setContentIntent(pendingIntent) .addAction( R.drawable.ic_x, - "Stop App", // TODO: Get from resources + getString(R.string.notification_stop_app), stopPendingIntent ) .build() diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index c28a779f0..9a2341b89 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -141,26 +141,7 @@ class WakeNodeWorker @AssistedInject constructor( } } - is Event.OnchainTransactionReceived -> { - // TODO handle like onPaymentReceived but for onchain - } - - is Event.OnchainTransactionConfirmed -> Unit - - is Event.PaymentSuccessful -> Unit - is Event.PaymentClaimable -> Unit - is Event.PaymentForwarded -> Unit - - is Event.SyncProgress -> Unit - is Event.SyncCompleted -> Unit - is Event.BalanceChanged -> Unit - - is Event.OnchainTransactionEvicted, - is Event.OnchainTransactionReorged, - is Event.OnchainTransactionReplaced, - -> { - // TODO handle activity removed from mempool UI & toast - } + else -> Unit } } diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 96ebe79a8..8b37d0985 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -7,6 +7,7 @@ import com.synonym.bitkitcore.ActivityTags import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.IcJitEntry import com.synonym.bitkitcore.LightningActivity +import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection @@ -191,6 +192,42 @@ class ActivityRepo @Inject constructor( } } + private suspend fun getOnchainActivityByTxId(txid: String): OnchainActivity? { + return coreService.activity.getOnchainActivityByTxId(txid) + } + + /** + * Determines whether to show the payment received UI for an onchain transaction. + * Returns false for: + * - Zero value transactions + * - Channel closure transactions (transfers to savings) + * - RBF replacement transactions with the same value as the original + */ + suspend fun shouldShowPaymentReceived(txid: String, value: ULong): Boolean = withContext(bgDispatcher) { + if (value == 0uL) return@withContext false + + if (findClosedChannelForTransaction(txid) != null) { + Logger.debug("Skipping payment received UI for channel closure tx: $txid", context = TAG) + return@withContext false + } + + val onchainActivity = getOnchainActivityByTxId(txid) + if (onchainActivity != null && onchainActivity.boostTxIds.isNotEmpty()) { + for (replacedTxid in onchainActivity.boostTxIds) { + val replacedActivity = getOnchainActivityByTxId(replacedTxid) + if (replacedActivity != null && replacedActivity.value == value) { + Logger.info( + "Skipping payment received UI for RBF replacement $txid with same value as $replacedTxid", + context = TAG + ) + return@withContext false + } + } + } + + return@withContext true + } + /** * Gets a specific activity by payment hash or txID with retry logic */ diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index b1e7ca73c..f19f7f1f2 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -29,6 +29,7 @@ import com.synonym.bitkitcore.deleteActivityById import com.synonym.bitkitcore.estimateOrderFeeFull import com.synonym.bitkitcore.getActivities import com.synonym.bitkitcore.getActivityById +import com.synonym.bitkitcore.getActivityByTxId import com.synonym.bitkitcore.getAllClosedChannels import com.synonym.bitkitcore.getAllUniqueTags import com.synonym.bitkitcore.getCjitEntries @@ -248,6 +249,10 @@ class ActivityService( } } + suspend fun getOnchainActivityByTxId(txId: String): OnchainActivity? = ServiceQueue.CORE.background { + getActivityByTxId(txId = txId) + } + suspend fun get( filter: ActivityFilter? = null, txType: PaymentType? = null, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e574beb49..1cada96c2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -292,15 +292,22 @@ class AppViewModel @Inject constructor( is Event.PaymentForwarded -> Unit is Event.OnchainTransactionReceived -> { - showNewTransactionSheet( - NewTransactionSheetDetails( - type = NewTransactionSheetType.ONCHAIN, - direction = NewTransactionSheetDirection.RECEIVED, - paymentHashOrTxId = event.txid, - sats = event.details.amountSats.toLong(), - ), - event, - ) + val sats = event.details.amountSats + launch(bgDispatcher) { + delay(500) + val shouldShow = activityRepo.shouldShowPaymentReceived(event.txid, sats.toULong()) + if (!shouldShow) return@launch + + showNewTransactionSheet( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = event.txid, + sats = sats, + ), + event, + ) + } } is Event.OnchainTransactionConfirmed -> Unit @@ -308,12 +315,9 @@ class AppViewModel @Inject constructor( is Event.SyncCompleted -> Unit is Event.BalanceChanged -> Unit - is Event.OnchainTransactionEvicted, - is Event.OnchainTransactionReorged, - is Event.OnchainTransactionReplaced, - -> { - // TODO handle activity removed from mempool UI & toast - } + is Event.OnchainTransactionEvicted -> Unit + is Event.OnchainTransactionReorged -> Unit + is Event.OnchainTransactionReplaced -> Unit } }.onFailure { e -> Logger.error("LDK event handler error", e, context = TAG) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 171d3437e..118831559 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1129,4 +1129,9 @@ Current average fee Next block inclusion Couldn\'t get current fee weather + Stop App + Bitkit is running in background so you can receive Lightning payments + Payment Received + Open Bitkit to see details + Received %s diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt new file mode 100644 index 000000000..fdb1fd11f --- /dev/null +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -0,0 +1,226 @@ +package to.bitkit.androidServices + +import android.Manifest +import android.app.Activity +import android.app.Application +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.lightningdevkit.ldknode.Event +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +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.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.ConvertedAmount +import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.WalletRepo +import to.bitkit.services.CoreService +import to.bitkit.services.LdkNodeEventBus +import to.bitkit.test.BaseUnitTest +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@Config(application = HiltTestApplication::class) +@RunWith(RobolectricTestRunner::class) +class LightningNodeServiceTest : BaseUnitTest() { + + @get:Rule(order = 0) + val mainDispatcherRule = coroutinesTestRule + + @get:Rule(order = 1) + var hiltRule = HiltAndroidRule(this) + + @BindValue + @JvmField + val lightningRepo: LightningRepo = mock() + + @BindValue + @JvmField + val walletRepo: WalletRepo = mock() + + @BindValue + @JvmField + val ldkNodeEventBus: LdkNodeEventBus = mock() + + @BindValue + @JvmField + val settingsStore: SettingsStore = mock() + + @BindValue + @JvmField + val coreService: CoreService = mock() + + @BindValue + @JvmField + val activityRepo: ActivityRepo = mock() + + @BindValue + @JvmField + val currencyRepo: CurrencyRepo = mock() + + private val eventsFlow = MutableSharedFlow() + private val context = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + runBlocking { + hiltRule.inject() + whenever(ldkNodeEventBus.events).thenReturn(eventsFlow) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(showNotificationDetails = true))) + whenever(lightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn( + Result.success(Unit) + ) + whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) + + // Mock CurrencyRepo to return a ConvertedAmount + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenReturn( + Result.success( + ConvertedAmount( + value = BigDecimal("0.10"), + formatted = "0.10", + symbol = "$", + currency = "USD", + flag = "🇺🇸", + sats = 100L + ) + ) + ) + + // Mock ActivityRepo for onchain + whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(true) + + // Grant permissions for notifications + val app = context as Application + Shadows.shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + + // Reset App.currentActivity to simulate background state + App.currentActivity = CurrentActivity() + } + } + + @After + fun tearDown() { + NewTransactionSheetDetails.clear(context) + App.currentActivity = null + } + + @Test + fun `payment received in background shows notification`() = test { + val controller = Robolectric.buildService(LightningNodeService::class.java) + controller.create().startCommand(0, 0) + + val event = Event.PaymentReceived( + paymentId = "payment_id", + paymentHash = "test_hash", + amountMsat = 100000u, + customRecords = emptyList() + ) + + eventsFlow.emit(event) + testScheduler.advanceUntilIdle() + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val shadows = Shadows.shadowOf(notificationManager) + + val paymentNotification = shadows.allNotifications.find { + it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification_received_title) + } + assertNotNull("Payment notification should be present", paymentNotification) + + val details = NewTransactionSheetDetails.load(context) + assertNotNull(details) + assertEquals("test_hash", details?.paymentHashOrTxId) + assertEquals(100L, details?.sats) + } + + @Test + fun `payment received in foreground does nothing`() = test { + // Simulate foreground by setting App.currentActivity.value via lifecycle callback + val mockActivity: Activity = mock() + App.currentActivity?.onActivityStarted(mockActivity) + + val controller = Robolectric.buildService(LightningNodeService::class.java) + controller.create().startCommand(0, 0) + + val event = Event.PaymentReceived( + paymentId = "payment_id_fg", + paymentHash = "test_hash_fg", + amountMsat = 200000u, + customRecords = emptyList() + ) + + eventsFlow.emit(event) + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val shadows = Shadows.shadowOf(notificationManager) + + val paymentNotification = shadows.allNotifications.find { + it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification_received_title) + } + + assertNull("Payment notification should NOT be present in foreground", paymentNotification) + + val details = NewTransactionSheetDetails.load(context) + assertNull(details) + } + + @Test + fun `notification body contains formatted amount with fiat`() = test { + val controller = Robolectric.buildService(LightningNodeService::class.java) + controller.create().startCommand(0, 0) + + val event = Event.PaymentReceived( + paymentId = "payment_id", + paymentHash = "test_hash", + amountMsat = 100000u, + customRecords = emptyList() + ) + + eventsFlow.emit(event) + testScheduler.advanceUntilIdle() + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val shadows = Shadows.shadowOf(notificationManager) + + val paymentNotification = shadows.allNotifications.find { + it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification_received_title) + } + assertNotNull("Payment notification should be present", paymentNotification) + + val body = paymentNotification?.extras?.getString(Notification.EXTRA_TEXT) + assertNotNull("Notification body should not be null", body) + assertTrue("Notification body should contain fiat amount", body!!.contains("$")) + assertTrue("Notification body should contain bitcoin symbol", body.contains("₿")) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec9408126..073dbd5ff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,7 +44,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "barcodeScanning" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } -bitkitcore = { module = "com.synonym:bitkit-core-android", version = "0.1.27" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.30" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncyCastle" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From 158b11eeb0c8867843bd9cdd2f234c63cd7f7b4f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 27 Nov 2025 14:22:36 +0100 Subject: [PATCH 04/32] refactor: use CQRS command + handler --- .../androidServices/LightningNodeService.kt | 82 ++-------- .../domain/commands/NotifyPaymentReceived.kt | 58 +++++++ .../commands/NotifyPaymentReceivedHandler.kt | 97 ++++++++++++ .../main/java/to/bitkit/fcm/WakeNodeWorker.kt | 1 + .../to/bitkit/models/NotificationState.kt | 7 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 51 +++--- .../LightningNodeServiceTest.kt | 65 +++----- .../NotifyPaymentReceivedHandlerTest.kt | 146 ++++++++++++++++++ 8 files changed, 369 insertions(+), 138 deletions(-) create mode 100644 app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt create mode 100644 app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt create mode 100644 app/src/main/java/to/bitkit/models/NotificationState.kt create mode 100644 app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index da011d4d4..a0b69c158 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -11,22 +11,14 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Event import to.bitkit.App import to.bitkit.R -import to.bitkit.data.SettingsData -import to.bitkit.data.SettingsStore -import to.bitkit.models.BITCOIN_SYMBOL +import to.bitkit.domain.commands.NotifyPaymentReceived +import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.models.NewTransactionSheetDetails -import to.bitkit.models.NewTransactionSheetDirection -import to.bitkit.models.NewTransactionSheetType -import to.bitkit.models.PrimaryDisplay -import to.bitkit.models.formatToModernDisplay -import to.bitkit.repositories.ActivityRepo -import to.bitkit.repositories.CurrencyRepo +import to.bitkit.models.NotificationState import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.LdkNodeEventBus @@ -34,7 +26,6 @@ import to.bitkit.ui.MainActivity import to.bitkit.ui.pushNotification import to.bitkit.utils.Logger import javax.inject.Inject -import kotlin.time.Duration.Companion.seconds @AndroidEntryPoint class LightningNodeService : Service() { @@ -51,13 +42,7 @@ class LightningNodeService : Service() { lateinit var ldkNodeEventBus: LdkNodeEventBus @Inject - lateinit var settingsStore: SettingsStore - - @Inject - lateinit var activityRepo: ActivityRepo - - @Inject - lateinit var currencyRepo: CurrencyRepo + lateinit var notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler override fun onCreate() { super.onCreate() @@ -91,62 +76,25 @@ class LightningNodeService : Service() { } private suspend fun handleBackgroundEvent(event: Event) { - delay(0.5.seconds) // Small delay to allow lifecycle callbacks to settle after app backgrounding if (App.currentActivity?.value != null) return - when (event) { - is Event.PaymentReceived -> { - val sats = event.amountMsat / 1000u - showPaymentNotification(sats.toLong(), event.paymentHash, isOnchain = false) - } - - is Event.OnchainTransactionReceived -> { - val sats = event.details.amountSats - val shouldShow = activityRepo.shouldShowPaymentReceived(event.txid, sats.toULong()) - if (!shouldShow) return + val command = NotifyPaymentReceived.Command.from(event, includeNotification = true) ?: return - showPaymentNotification(sats, event.txid, isOnchain = true) + notifyPaymentReceivedHandler(command).onSuccess { result -> + if (result is NotifyPaymentReceived.Result.ShowNotification) { + if (App.currentActivity?.value != null) return@onSuccess + showPaymentNotification(result.details, result.notification) } - - else -> Unit } } - private suspend fun showPaymentNotification(sats: Long, paymentHashOrTxId: String?, isOnchain: Boolean) { + private fun showPaymentNotification( + details: NewTransactionSheetDetails, + notification: NotificationState, + ) { if (App.currentActivity?.value != null) return - - val settings = settingsStore.data.first() - val type = if (isOnchain) NewTransactionSheetType.ONCHAIN else NewTransactionSheetType.LIGHTNING - val direction = NewTransactionSheetDirection.RECEIVED - - NewTransactionSheetDetails.save( - this, - NewTransactionSheetDetails(type, direction, paymentHashOrTxId, sats) - ) - - val title = getString(R.string.notification_received_title) - val body = if (settings.showNotificationDetails) { - formatNotificationAmount(sats, settings) - } else { - getString(R.string.notification_received_body_hidden) - } - - pushNotification(title, body, context = this) - } - - 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.symbol}${it.formatted})" - } else { - "${it.symbol}${it.formatted} (${btcDisplay.symbol} ${btcDisplay.value})" - } - } ?: "$BITCOIN_SYMBOL ${sats.formatToModernDisplay()}" - - return getString(R.string.notification_received_body_amount, amountText) + NewTransactionSheetDetails.save(this, details) + pushNotification(notification.title, notification.body, context = this) } private fun createNotification( diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt new file mode 100644 index 000000000..b591f9b6d --- /dev/null +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt @@ -0,0 +1,58 @@ +package to.bitkit.domain.commands + +import org.lightningdevkit.ldknode.Event +import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.models.NotificationState + +sealed interface NotifyPaymentReceived { + + sealed interface Command : NotifyPaymentReceived { + val sats: Long + val paymentId: String + val includeNotification: Boolean + + data class Lightning( + override val sats: Long, + override val paymentId: String, + override val includeNotification: Boolean = false, + ) : Command + + data class Onchain( + override val sats: Long, + override val paymentId: String, + override val includeNotification: Boolean = false, + ) : Command + + companion object { + fun from(event: Event, includeNotification: Boolean = false): Command? = + when (event) { + is Event.PaymentReceived -> Lightning( + sats = (event.amountMsat / 1000u).toLong(), + paymentId = event.paymentHash, + includeNotification = includeNotification, + ) + + is Event.OnchainTransactionReceived -> Onchain( + sats = event.details.amountSats, + paymentId = event.txid, + includeNotification = includeNotification, + ) + + else -> null + } + } + } + + sealed interface Result : NotifyPaymentReceived { + data class ShowSheet( + val details: NewTransactionSheetDetails, + ) : Result + + data class ShowNotification( + val details: NewTransactionSheetDetails, + val notification: NotificationState, + ) : Result + + data object Skip : Result + } +} diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt new file mode 100644 index 000000000..5ee42351b --- /dev/null +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -0,0 +1,97 @@ +package to.bitkit.domain.commands + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +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.models.BITCOIN_SYMBOL +import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.models.NewTransactionSheetDirection +import to.bitkit.models.NewTransactionSheetType +import to.bitkit.models.NotificationState +import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.CurrencyRepo +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NotifyPaymentReceivedHandler @Inject constructor( + @param:ApplicationContext private val context: Context, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val activityRepo: ActivityRepo, + private val currencyRepo: CurrencyRepo, + private val settingsStore: SettingsStore, +) { + suspend operator fun invoke( + command: NotifyPaymentReceived.Command, + ): Result = withContext(ioDispatcher) { + runCatching { + delay(DELAY_MS) + + val shouldShow = when (command) { + is NotifyPaymentReceived.Command.Lightning -> true + is NotifyPaymentReceived.Command.Onchain -> { + activityRepo.shouldShowPaymentReceived(command.paymentId, command.sats.toULong()) + } + } + + if (!shouldShow) return@runCatching NotifyPaymentReceived.Result.Skip + + val details = NewTransactionSheetDetails( + type = when (command) { + is NotifyPaymentReceived.Command.Lightning -> NewTransactionSheetType.LIGHTNING + is NotifyPaymentReceived.Command.Onchain -> NewTransactionSheetType.ONCHAIN + }, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = command.paymentId, + sats = command.sats, + ) + + if (command.includeNotification) { + val notification = buildNotificationContent(command.sats) + NotifyPaymentReceived.Result.ShowNotification(details, notification) + } else { + NotifyPaymentReceived.Result.ShowSheet(details) + } + } + } + + private suspend fun buildNotificationContent(sats: Long): NotificationState { + 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 NotificationState(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.symbol}${it.formatted})" + } else { + "${it.symbol}${it.formatted} (${btcDisplay.symbol} ${btcDisplay.value})" + } + } ?: "$BITCOIN_SYMBOL ${sats.formatToModernDisplay()}" + + return context.getString(R.string.notification_received_body_amount, amountText) + } + + companion object { + const val TAG = "NotifyPaymentReceivedHandler" + private const val DELAY_MS = 500L + } +} diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index 9a2341b89..d49e01503 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -50,6 +50,7 @@ class WakeNodeWorker @AssistedInject constructor( ) : CoroutineWorker(appContext, workerParams) { private val self = this + // TODO extract as global model and turn into data class. class VisibleNotification(var title: String = "", var body: String = "") private var bestAttemptContent: VisibleNotification? = VisibleNotification() diff --git a/app/src/main/java/to/bitkit/models/NotificationState.kt b/app/src/main/java/to/bitkit/models/NotificationState.kt new file mode 100644 index 000000000..3f39edfcf --- /dev/null +++ b/app/src/main/java/to/bitkit/models/NotificationState.kt @@ -0,0 +1,7 @@ +package to.bitkit.models + +// TODO should replace WakeNodeWorker.VisibleNotification +data class NotificationState( + val title: String, + val body: String, +) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1cada96c2..8c7d8691d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -56,6 +56,8 @@ 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.NotifyPaymentReceived +import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.env.Env import to.bitkit.ext.WatchResult import to.bitkit.ext.amountOnClose @@ -105,8 +107,10 @@ import javax.inject.Inject @Suppress("LongParameterList") @HiltViewModel class AppViewModel @Inject constructor( - @ApplicationContext private val context: Context, - @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + connectivityRepo: ConnectivityRepo, + healthRepo: HealthRepo, + @param:ApplicationContext private val context: Context, + @param:BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val keychain: Keychain, private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, @@ -117,9 +121,8 @@ class AppViewModel @Inject constructor( private val activityRepo: ActivityRepo, private val preActivityMetadataRepo: PreActivityMetadataRepo, private val blocktankRepo: BlocktankRepo, - private val connectivityRepo: ConnectivityRepo, - private val healthRepo: HealthRepo, private val appUpdaterService: AppUpdaterService, + private val notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler, ) : ViewModel() { val healthState = healthRepo.healthState @@ -228,15 +231,7 @@ class AppViewModel @Inject constructor( runCatching { when (event) { is Event.PaymentReceived -> { - showNewTransactionSheet( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.RECEIVED, - paymentHashOrTxId = event.paymentHash, - sats = (event.amountMsat / 1000u).toLong(), - ), - event, - ) + NotifyPaymentReceived.Command.from(event)?.let { handlePaymentReceived(it, event) } } is Event.ChannelReady -> { @@ -292,22 +287,7 @@ class AppViewModel @Inject constructor( is Event.PaymentForwarded -> Unit is Event.OnchainTransactionReceived -> { - val sats = event.details.amountSats - launch(bgDispatcher) { - delay(500) - val shouldShow = activityRepo.shouldShowPaymentReceived(event.txid, sats.toULong()) - if (!shouldShow) return@launch - - showNewTransactionSheet( - NewTransactionSheetDetails( - type = NewTransactionSheetType.ONCHAIN, - direction = NewTransactionSheetDirection.RECEIVED, - paymentHashOrTxId = event.txid, - sats = sats, - ), - event, - ) - } + NotifyPaymentReceived.Command.from(event)?.let { handlePaymentReceived(it, event) } } is Event.OnchainTransactionConfirmed -> Unit @@ -326,6 +306,19 @@ class AppViewModel @Inject constructor( } } + private fun handlePaymentReceived( + receivedEvent: NotifyPaymentReceived.Command, + originalEvent: Event, + ) { + viewModelScope.launch(bgDispatcher) { + notifyPaymentReceivedHandler(receivedEvent).onSuccess { result -> + if (result is NotifyPaymentReceived.Result.ShowSheet) { + showNewTransactionSheet(result.details, originalEvent) + } + } + } + } + // region send private fun observeSendEvents() { diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index fdb1fd11f..454e3cf89 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -13,13 +13,11 @@ import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltTestApplication import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -29,6 +27,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import org.mockito.kotlin.wheneverBlocking import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows @@ -36,18 +35,16 @@ import org.robolectric.annotation.Config import to.bitkit.App import to.bitkit.CurrentActivity import to.bitkit.R -import to.bitkit.data.SettingsData -import to.bitkit.data.SettingsStore -import to.bitkit.models.ConvertedAmount +import to.bitkit.domain.commands.NotifyPaymentReceived +import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.models.NewTransactionSheetDetails -import to.bitkit.repositories.ActivityRepo -import to.bitkit.repositories.CurrencyRepo +import to.bitkit.models.NewTransactionSheetDirection +import to.bitkit.models.NewTransactionSheetType +import to.bitkit.models.NotificationState import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo -import to.bitkit.services.CoreService import to.bitkit.services.LdkNodeEventBus import to.bitkit.test.BaseUnitTest -import java.math.BigDecimal @OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest @@ -75,19 +72,7 @@ class LightningNodeServiceTest : BaseUnitTest() { @BindValue @JvmField - val settingsStore: SettingsStore = mock() - - @BindValue - @JvmField - val coreService: CoreService = mock() - - @BindValue - @JvmField - val activityRepo: ActivityRepo = mock() - - @BindValue - @JvmField - val currencyRepo: CurrencyRepo = mock() + val notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler = mock() private val eventsFlow = MutableSharedFlow() private val context = ApplicationProvider.getApplicationContext() @@ -97,28 +82,26 @@ class LightningNodeServiceTest : BaseUnitTest() { runBlocking { hiltRule.inject() whenever(ldkNodeEventBus.events).thenReturn(eventsFlow) - whenever(settingsStore.data).thenReturn(flowOf(SettingsData(showNotificationDetails = true))) whenever(lightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn( Result.success(Unit) ) whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) - // Mock CurrencyRepo to return a ConvertedAmount - whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenReturn( - Result.success( - ConvertedAmount( - value = BigDecimal("0.10"), - formatted = "0.10", - symbol = "$", - currency = "USD", - flag = "🇺🇸", - sats = 100L - ) - ) + // Mock NotifyPaymentReceivedHandler to return ShowNotification result + val defaultDetails = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = "test_hash", + sats = 100L, ) - - // Mock ActivityRepo for onchain - whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(true) + val defaultNotification = NotificationState( + title = context.getString(R.string.notification_received_title), + body = "Received ₿ 100 ($0.10)", + ) + wheneverBlocking { notifyPaymentReceivedHandler.invoke(any()) } + .thenReturn( + Result.success(NotifyPaymentReceived.Result.ShowNotification(defaultDetails, defaultNotification)) + ) // Grant permissions for notifications val app = context as Application @@ -196,7 +179,7 @@ class LightningNodeServiceTest : BaseUnitTest() { } @Test - fun `notification body contains formatted amount with fiat`() = test { + fun `notification uses content from use case result`() = test { val controller = Robolectric.buildService(LightningNodeService::class.java) controller.create().startCommand(0, 0) @@ -219,8 +202,6 @@ class LightningNodeServiceTest : BaseUnitTest() { assertNotNull("Payment notification should be present", paymentNotification) val body = paymentNotification?.extras?.getString(Notification.EXTRA_TEXT) - assertNotNull("Notification body should not be null", body) - assertTrue("Notification body should contain fiat amount", body!!.contains("$")) - assertTrue("Notification body should contain bitcoin symbol", body.contains("₿")) + assertEquals("Received ₿ 100 (\$0.10)", body) } } diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt new file mode 100644 index 000000000..5bf71c970 --- /dev/null +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -0,0 +1,146 @@ +package to.bitkit.domain.commands + +import android.content.Context +import kotlinx.coroutines.flow.flowOf +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +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.models.ConvertedAmount +import to.bitkit.models.NewTransactionSheetDirection +import to.bitkit.models.NewTransactionSheetType +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.test.BaseUnitTest +import java.math.BigDecimal +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { + + private val context: Context = mock() + private val activityRepo: ActivityRepo = mock() + private val currencyRepo: CurrencyRepo = mock() + private val settingsStore: SettingsStore = mock() + + private lateinit var sut: NotifyPaymentReceivedHandler + + @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 = NotifyPaymentReceivedHandler( + context = context, + ioDispatcher = testDispatcher, + activityRepo = activityRepo, + currencyRepo = currencyRepo, + settingsStore = settingsStore, + ) + } + + @Test + fun `lightning payment returns ShowSheet by default`() = test { + val command = NotifyPaymentReceived.Command.Lightning(sats = 1000L, paymentId = "hash123") + + val result = sut(command) + + assertTrue(result.isSuccess) + val paymentResult = result.getOrThrow() + assertTrue(paymentResult is NotifyPaymentReceived.Result.ShowSheet) + val showResult = paymentResult as NotifyPaymentReceived.Result.ShowSheet + assertEquals(NewTransactionSheetType.LIGHTNING, showResult.details.type) + assertEquals(NewTransactionSheetDirection.RECEIVED, showResult.details.direction) + assertEquals("hash123", showResult.details.paymentHashOrTxId) + assertEquals(1000L, showResult.details.sats) + } + + @Test + fun `lightning payment returns ShowNotification when includeNotification is true`() = test { + val command = NotifyPaymentReceived.Command.Lightning( + sats = 1000L, + paymentId = "hash123", + includeNotification = true, + ) + + val result = sut(command) + + assertTrue(result.isSuccess) + val paymentResult = result.getOrThrow() + assertTrue(paymentResult is NotifyPaymentReceived.Result.ShowNotification) + val showResult = paymentResult as NotifyPaymentReceived.Result.ShowNotification + assertEquals(NewTransactionSheetType.LIGHTNING, showResult.details.type) + assertEquals("hash123", showResult.details.paymentHashOrTxId) + assertNotNull(showResult.notification) + assertEquals("Payment Received", showResult.notification.title) + } + + @Test + fun `onchain payment returns ShowSheet when shouldShowPaymentReceived returns true`() = test { + whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(true) + val command = NotifyPaymentReceived.Command.Onchain(sats = 5000L, paymentId = "txid456") + + val result = sut(command) + + assertTrue(result.isSuccess) + val paymentResult = result.getOrThrow() + assertTrue(paymentResult is NotifyPaymentReceived.Result.ShowSheet) + val showResult = paymentResult as NotifyPaymentReceived.Result.ShowSheet + assertEquals(NewTransactionSheetType.ONCHAIN, showResult.details.type) + assertEquals(NewTransactionSheetDirection.RECEIVED, showResult.details.direction) + assertEquals("txid456", showResult.details.paymentHashOrTxId) + assertEquals(5000L, showResult.details.sats) + } + + @Test + fun `onchain payment returns Skip when shouldShowPaymentReceived is false`() = test { + whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(false) + val command = NotifyPaymentReceived.Command.Onchain(sats = 5000L, paymentId = "txid456") + + val result = sut(command) + + assertTrue(result.isSuccess) + val paymentResult = result.getOrThrow() + assertTrue(paymentResult is NotifyPaymentReceived.Result.Skip) + } + + @Test + fun `onchain payment calls shouldShowPaymentReceived with correct parameters`() = test { + whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(true) + val command = NotifyPaymentReceived.Command.Onchain(sats = 7500L, paymentId = "txid789") + + sut(command) + + verify(activityRepo).shouldShowPaymentReceived("txid789", 7500uL) + } + + @Test + fun `lightning payment does not call shouldShowPaymentReceived`() = test { + val command = NotifyPaymentReceived.Command.Lightning(sats = 1000L, paymentId = "hash123") + + sut(command) + + verify(activityRepo, never()).shouldShowPaymentReceived(any(), any()) + } +} From 3d1a578e41c84ab87968e43797335cda341c6fac Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 27 Nov 2025 15:18:30 +0100 Subject: [PATCH 05/32] feat: toast messages for onchain events --- .../domain/commands/NotifyPaymentReceived.kt | 22 +-- .../commands/NotifyPaymentReceivedHandler.kt | 7 +- app/src/main/java/to/bitkit/models/Toast.kt | 1 + .../to/bitkit/repositories/ActivityRepo.kt | 19 +++ .../java/to/bitkit/ui/components/ToastView.kt | 2 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 148 ++++++++++++------ app/src/main/res/values/strings.xml | 8 + .../NotifyPaymentReceivedHandlerTest.kt | 12 +- 8 files changed, 153 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt index b591f9b6d..c8f864c05 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt @@ -7,18 +7,18 @@ import to.bitkit.models.NotificationState sealed interface NotifyPaymentReceived { sealed interface Command : NotifyPaymentReceived { - val sats: Long + val sats: ULong val paymentId: String val includeNotification: Boolean data class Lightning( - override val sats: Long, + override val sats: ULong, override val paymentId: String, override val includeNotification: Boolean = false, ) : Command data class Onchain( - override val sats: Long, + override val sats: ULong, override val paymentId: String, override val includeNotification: Boolean = false, ) : Command @@ -27,16 +27,20 @@ sealed interface NotifyPaymentReceived { fun from(event: Event, includeNotification: Boolean = false): Command? = when (event) { is Event.PaymentReceived -> Lightning( - sats = (event.amountMsat / 1000u).toLong(), + sats = event.amountMsat / 1000u, paymentId = event.paymentHash, includeNotification = includeNotification, ) - is Event.OnchainTransactionReceived -> Onchain( - sats = event.details.amountSats, - paymentId = event.txid, - includeNotification = includeNotification, - ) + is Event.OnchainTransactionReceived -> { + val amountSats = event.details.amountSats + if (amountSats <= 0) null + else Onchain( + sats = amountSats.toULong(), + paymentId = event.txid, + includeNotification = includeNotification, + ) + } else -> null } diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index 5ee42351b..2ef4750f2 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -39,12 +39,13 @@ class NotifyPaymentReceivedHandler @Inject constructor( val shouldShow = when (command) { is NotifyPaymentReceived.Command.Lightning -> true is NotifyPaymentReceived.Command.Onchain -> { - activityRepo.shouldShowPaymentReceived(command.paymentId, command.sats.toULong()) + activityRepo.shouldShowPaymentReceived(command.paymentId, command.sats) } } if (!shouldShow) return@runCatching NotifyPaymentReceived.Result.Skip + val satsLong = command.sats.toLong() val details = NewTransactionSheetDetails( type = when (command) { is NotifyPaymentReceived.Command.Lightning -> NewTransactionSheetType.LIGHTNING @@ -52,11 +53,11 @@ class NotifyPaymentReceivedHandler @Inject constructor( }, direction = NewTransactionSheetDirection.RECEIVED, paymentHashOrTxId = command.paymentId, - sats = command.sats, + sats = satsLong, ) if (command.includeNotification) { - val notification = buildNotificationContent(command.sats) + val notification = buildNotificationContent(satsLong) NotifyPaymentReceived.Result.ShowNotification(details, notification) } else { NotifyPaymentReceived.Result.ShowSheet(details) diff --git a/app/src/main/java/to/bitkit/models/Toast.kt b/app/src/main/java/to/bitkit/models/Toast.kt index f0aa42a38..a4dc00d99 100644 --- a/app/src/main/java/to/bitkit/models/Toast.kt +++ b/app/src/main/java/to/bitkit/models/Toast.kt @@ -6,6 +6,7 @@ data class Toast( val description: String? = null, val autoHide: Boolean, val visibilityTime: Long = VISIBILITY_TIME_DEFAULT, + val testTag: String? = null, ) { enum class ToastType { SUCCESS, INFO, LIGHTNING, WARNING, ERROR } diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 8b37d0985..0369d9e3a 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -228,6 +228,25 @@ class ActivityRepo @Inject constructor( return@withContext true } + /** + * Checks if a transaction is inbound (received) by looking up the payment direction. + */ + suspend fun isReceivedTransaction(txid: String): Boolean = withContext(bgDispatcher) { + lightningRepo.getPayments().getOrNull()?.let { payments -> + payments.firstOrNull { payment -> + (payment.kind as? PaymentKind.Onchain)?.txid == txid + } + }?.direction == PaymentDirection.INBOUND + } + + /** + * Checks if a transaction was replaced (RBF) by checking if the activity exists but doesExist=false. + */ + suspend fun wasTransactionReplaced(txid: String): Boolean = withContext(bgDispatcher) { + val onchainActivity = getOnchainActivityByTxId(txid) ?: return@withContext false + return@withContext !onchainActivity.doesExist + } + /** * Gets a specific activity by payment hash or txID with retry logic */ diff --git a/app/src/main/java/to/bitkit/ui/components/ToastView.kt b/app/src/main/java/to/bitkit/ui/components/ToastView.kt index dd79dbd19..7cf69423d 100644 --- a/app/src/main/java/to/bitkit/ui/components/ToastView.kt +++ b/app/src/main/java/to/bitkit/ui/components/ToastView.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush.Companion.verticalGradient import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R @@ -68,6 +69,7 @@ fun ToastView( .background(verticalGradient(listOf(gradientColor, Color.Black), startY = 0f), RoundedCornerShape(8.dp)) .border(1.dp, tintColor, RoundedCornerShape(8.dp)) .padding(16.dp) + .then(toast.testTag?.let { Modifier.testTag(it) } ?: Modifier), ) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 8c7d8691d..90356dbb2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavOptions import androidx.navigation.navOptions +import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.FeeRates import com.synonym.bitkitcore.LightningInvoice @@ -234,56 +235,22 @@ class AppViewModel @Inject constructor( NotifyPaymentReceived.Command.from(event)?.let { handlePaymentReceived(it, event) } } - is 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() - showNewTransactionSheet( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.RECEIVED, - sats = amount, - ), - event, - ) - activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel) - } else { - toast( - type = Toast.ToastType.LIGHTNING, - title = context.getString(R.string.lightning__channel_opened_title), - description = context.getString(R.string.lightning__channel_opened_msg), - ) - } - } + is Event.ChannelReady -> notifyChannelReady(event) is Event.ChannelPending -> Unit is Event.ChannelClosed -> Unit - is Event.PaymentSuccessful -> { - val paymentHash = event.paymentHash - // TODO Temporary solution while LDK node doesn't return the sent value in the event - activityRepo.findActivityByPaymentId( - paymentHashOrTxId = paymentHash, - type = ActivityFilter.LIGHTNING, - txType = PaymentType.SENT, - retry = true - ).onSuccess { activity -> - handlePaymentSuccess( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.SENT, - paymentHashOrTxId = event.paymentHash, - sats = activity.totalValue().toLong(), - ), - ) - }.onFailure { e -> - Logger.warn("Failed displaying sheet for event: $event", e) - } - } + is Event.PaymentSuccessful -> notifyPaymentSentOnLightning(event) is Event.PaymentClaimable -> Unit - is Event.PaymentFailed -> Unit + is Event.PaymentFailed -> { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__toast_payment_failed_title), + description = context.getString(R.string.wallet__toast_payment_failed_description), + testTag = "PaymentFailedToast", + ) + } is Event.PaymentForwarded -> Unit is Event.OnchainTransactionReceived -> { @@ -295,9 +262,47 @@ class AppViewModel @Inject constructor( is Event.SyncCompleted -> Unit is Event.BalanceChanged -> Unit - is Event.OnchainTransactionEvicted -> Unit - is Event.OnchainTransactionReorged -> Unit - is Event.OnchainTransactionReplaced -> Unit + is Event.OnchainTransactionEvicted -> { + viewModelScope.launch(bgDispatcher) { + if (!activityRepo.wasTransactionReplaced(event.txid)) { + toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.wallet__toast_transaction_removed_title), + description = context.getString(R.string.wallet__toast_transaction_removed_description), + testTag = "TransactionRemovedToast", + ) + } + } + } + + is Event.OnchainTransactionReorged -> { + toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.wallet__toast_transaction_unconfirmed_title), + description = context.getString(R.string.wallet__toast_transaction_unconfirmed_description), + testTag = "TransactionUnconfirmedToast", + ) + } + + is Event.OnchainTransactionReplaced -> { + viewModelScope.launch(bgDispatcher) { + if (activityRepo.isReceivedTransaction(event.txid)) { + toast( + type = Toast.ToastType.INFO, + title = context.getString(R.string.wallet__toast_received_transaction_replaced_title), + description = context.getString(R.string.wallet__toast_received_transaction_replaced_description), + testTag = "ReceivedTransactionReplacedToast", + ) + } else { + toast( + type = Toast.ToastType.INFO, + title = context.getString(R.string.wallet__toast_transaction_replaced_title), + description = context.getString(R.string.wallet__toast_transaction_replaced_description), + testTag = "TransactionReplacedToast", + ) + } + } + } } }.onFailure { e -> Logger.error("LDK event handler error", e, context = TAG) @@ -306,6 +311,51 @@ class AppViewModel @Inject constructor( } } + private suspend fun notifyPaymentSentOnLightning(event: Event.PaymentSuccessful): Result { + val paymentHash = event.paymentHash + // TODO Temporary solution while LDK node doesn't return the sent value in the event + return activityRepo.findActivityByPaymentId( + paymentHashOrTxId = paymentHash, + type = ActivityFilter.LIGHTNING, + txType = PaymentType.SENT, + retry = true + ).onSuccess { activity -> + handlePaymentSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = event.paymentHash, + sats = activity.totalValue().toLong(), + ), + ) + }.onFailure { e -> + Logger.warn("Failed displaying sheet for event: $event", e) + } + } + + private suspend fun notifyChannelReady(event: Event.ChannelReady): Any { + val channel = lightningRepo.getChannels()?.find { it.channelId == event.channelId } + val cjitEntry = channel?.let { blocktankRepo.getCjitEntry(it) } + return if (cjitEntry != null) { + val amount = channel.amountOnClose.toLong() + showNewTransactionSheet( + NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + sats = amount, + ), + event, + ) + activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel) + } else { + toast( + type = Toast.ToastType.LIGHTNING, + title = context.getString(R.string.lightning__channel_opened_title), + description = context.getString(R.string.lightning__channel_opened_msg), + ) + } + } + private fun handlePaymentReceived( receivedEvent: NotifyPaymentReceived.Command, originalEvent: Event, @@ -1441,13 +1491,15 @@ class AppViewModel @Inject constructor( description: String? = null, autoHide: Boolean = true, visibilityTime: Long = Toast.VISIBILITY_TIME_DEFAULT, + testTag: String? = null, ) { currentToast = Toast( type = type, title = title, description = description, autoHide = autoHide, - visibilityTime = visibilityTime + visibilityTime = visibilityTime, + testTag = testTag, ) if (autoHide) { viewModelScope.launch { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 118831559..d23f4208c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -952,6 +952,14 @@ Your instant payment was sent successfully. Payment Failed Your instant payment failed. Please try again. + Received Transaction Replaced + Your received transaction was replaced by a fee bump + Transaction Removed + Transaction was removed from mempool + Transaction Replaced + Your transaction was replaced by a fee bump + Transaction Unconfirmed + Transaction became unconfirmed due to blockchain reorganization Coin Selection Auto Total required diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt index 5bf71c970..d0a879706 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -62,7 +62,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { @Test fun `lightning payment returns ShowSheet by default`() = test { - val command = NotifyPaymentReceived.Command.Lightning(sats = 1000L, paymentId = "hash123") + val command = NotifyPaymentReceived.Command.Lightning(sats = 1000uL, paymentId = "hash123") val result = sut(command) @@ -79,7 +79,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { @Test fun `lightning payment returns ShowNotification when includeNotification is true`() = test { val command = NotifyPaymentReceived.Command.Lightning( - sats = 1000L, + sats = 1000uL, paymentId = "hash123", includeNotification = true, ) @@ -99,7 +99,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { @Test fun `onchain payment returns ShowSheet when shouldShowPaymentReceived returns true`() = test { whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(true) - val command = NotifyPaymentReceived.Command.Onchain(sats = 5000L, paymentId = "txid456") + val command = NotifyPaymentReceived.Command.Onchain(sats = 5000uL, paymentId = "txid456") val result = sut(command) @@ -116,7 +116,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { @Test fun `onchain payment returns Skip when shouldShowPaymentReceived is false`() = test { whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(false) - val command = NotifyPaymentReceived.Command.Onchain(sats = 5000L, paymentId = "txid456") + val command = NotifyPaymentReceived.Command.Onchain(sats = 5000uL, paymentId = "txid456") val result = sut(command) @@ -128,7 +128,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { @Test fun `onchain payment calls shouldShowPaymentReceived with correct parameters`() = test { whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(true) - val command = NotifyPaymentReceived.Command.Onchain(sats = 7500L, paymentId = "txid789") + val command = NotifyPaymentReceived.Command.Onchain(sats = 7500uL, paymentId = "txid789") sut(command) @@ -137,7 +137,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { @Test fun `lightning payment does not call shouldShowPaymentReceived`() = test { - val command = NotifyPaymentReceived.Command.Lightning(sats = 1000L, paymentId = "hash123") + val command = NotifyPaymentReceived.Command.Lightning(sats = 1000uL, paymentId = "hash123") sut(command) From fd04d112bc1f70e040d170aad6035b816643c704 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 27 Nov 2025 19:50:44 +0100 Subject: [PATCH 06/32] chore: lint --- .../domain/commands/NotifyPaymentReceived.kt | 7 +- app/src/main/java/to/bitkit/ext/Activities.kt | 7 + .../java/to/bitkit/ui/components/ToastView.kt | 44 +++--- .../wallets/activity/ActivityDetailScreen.kt | 1 + .../activity/components/ActivityIcon.kt | 118 ++++++++------- .../activity/components/EmptyActivityRow.kt | 2 +- .../wallets/send/SendRecipientScreen.kt | 2 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 134 ++++++++---------- config/detekt/detekt.yml | 2 +- 9 files changed, 167 insertions(+), 150 deletions(-) diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt index c8f864c05..06bec0838 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt @@ -34,12 +34,13 @@ sealed interface NotifyPaymentReceived { is Event.OnchainTransactionReceived -> { val amountSats = event.details.amountSats - if (amountSats <= 0) null - else Onchain( + Onchain( sats = amountSats.toULong(), paymentId = event.txid, includeNotification = includeNotification, - ) + ).takeIf { + amountSats > 0 + } } else -> null diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index ae7b48527..c7c1e60b2 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -48,6 +48,8 @@ fun Activity.isFinished() = when (this) { is Activity.Lightning -> v1.status != PaymentState.PENDING } +fun Activity.isBoosting(): Boolean = isBoosted() && !isFinished() && doesExist() + fun Activity.isSent() = when (this) { is Activity.Lightning -> v1.txType == PaymentType.SENT is Activity.Onchain -> v1.txType == PaymentType.SENT @@ -62,6 +64,11 @@ fun Activity.isTransfer() = this is Activity.Onchain && this.v1.isTransfer fun Activity.doesExist() = this is Activity.Onchain && this.v1.doesExist +fun Activity.paymentState(): PaymentState? = when (this) { + is Activity.Lightning -> this.v1.status + is Activity.Onchain -> null +} + fun Activity.Onchain.boostType() = when (this.v1.txType) { PaymentType.SENT -> BoostType.RBF PaymentType.RECEIVED -> BoostType.CPFP diff --git a/app/src/main/java/to/bitkit/ui/components/ToastView.kt b/app/src/main/java/to/bitkit/ui/components/ToastView.kt index 7cf69423d..d9144e303 100644 --- a/app/src/main/java/to/bitkit/ui/components/ToastView.kt +++ b/app/src/main/java/to/bitkit/ui/components/ToastView.kt @@ -26,16 +26,18 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush.Companion.verticalGradient import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R import to.bitkit.models.Toast +import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -44,21 +46,8 @@ fun ToastView( toast: Toast, onDismiss: () -> Unit, ) { - val tintColor = when (toast.type) { - Toast.ToastType.SUCCESS -> Colors.Green - Toast.ToastType.INFO -> Colors.Blue - Toast.ToastType.LIGHTNING -> Colors.Purple - Toast.ToastType.WARNING -> Colors.Brand - Toast.ToastType.ERROR -> Colors.Red - } - - val gradientColor = when (toast.type) { - Toast.ToastType.SUCCESS -> Color(0XFF1D2F1C) - Toast.ToastType.INFO -> Color(0XFF032E56) - Toast.ToastType.LIGHTNING -> Color(0XFF2B1637) - Toast.ToastType.WARNING -> Color(0XFF3C1001) - Toast.ToastType.ERROR -> Color(0XFF491F25) - } + val tintColor = toast.tintColor() + val gradientColor = toast.gradientColor() Box( contentAlignment = Alignment.CenterStart, @@ -140,7 +129,7 @@ fun ToastOverlay( @Composable private fun ToastViewPreview() { AppThemeSurface { - Column( + ScreenColumn( verticalArrangement = Arrangement.spacedBy(16.dp), ) { ToastView( @@ -189,3 +178,24 @@ private fun ToastViewPreview() { } } } + +@Suppress("MagicNumber") +@ReadOnlyComposable +@Composable +private fun Toast.gradientColor(): Color = when (type) { + Toast.ToastType.SUCCESS -> Color(0XFF1D2F1C) + Toast.ToastType.INFO -> Color(0XFF032E56) + Toast.ToastType.LIGHTNING -> Color(0XFF2B1637) + Toast.ToastType.WARNING -> Color(0XFF3C1001) + Toast.ToastType.ERROR -> Color(0XFF491F25) +} + +@ReadOnlyComposable +@Composable +private fun Toast.tintColor(): Color = when (type) { + Toast.ToastType.SUCCESS -> Colors.Green + Toast.ToastType.INFO -> Colors.Blue + Toast.ToastType.LIGHTNING -> Colors.Purple + Toast.ToastType.WARNING -> Colors.Brand + Toast.ToastType.ERROR -> Colors.Red +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 5c39eab86..b2a2c369e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -189,6 +189,7 @@ fun ActivityDetailScreen( } } +@Suppress("CyclomaticComplexMethod") @Composable private fun ActivityDetailContent( item: Activity, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt index 46424ae60..965b82245 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt @@ -25,9 +25,9 @@ import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import to.bitkit.R import to.bitkit.ext.doesExist -import to.bitkit.ext.isBoosted -import to.bitkit.ext.isFinished +import to.bitkit.ext.isBoosting import to.bitkit.ext.isTransfer +import to.bitkit.ext.paymentState import to.bitkit.ext.txType import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -39,15 +39,13 @@ fun ActivityIcon( size: Dp = 32.dp, ) { val isLightning = activity is Activity.Lightning - val status: PaymentState? = when (activity) { - is Activity.Lightning -> activity.v1.status - is Activity.Onchain -> null - } - val txType: PaymentType = activity.txType() + val isBoosting = activity.isBoosting() + val status = activity.paymentState() + val txType = activity.txType() val arrowIcon = painterResource(if (txType == PaymentType.SENT) R.drawable.ic_sent else R.drawable.ic_received) when { - activity.isBoosted() && !activity.isFinished() && activity.doesExist() -> { + isBoosting -> { CircularIcon( icon = painterResource(R.drawable.ic_timer_alt), iconColor = Colors.Yellow, @@ -57,57 +55,71 @@ fun ActivityIcon( ) } - isLightning -> { - when (status) { - PaymentState.FAILED -> { - CircularIcon( - icon = painterResource(R.drawable.ic_x), - iconColor = Colors.Purple, - backgroundColor = Colors.Purple16, - size = size, - modifier = modifier, - ) - } + isLightning -> ActivityIconLightning(status, size, arrowIcon, modifier) + else -> ActivityIconOnchain(activity, arrowIcon, size, modifier) + } +} - PaymentState.PENDING -> { - CircularIcon( - icon = painterResource(R.drawable.ic_hourglass_simple), - iconColor = Colors.Purple, - backgroundColor = Colors.Purple16, - size = size, - modifier = modifier, - ) - } +@Composable +private fun ActivityIconOnchain( + activity: Activity, + arrowIcon: Painter, + size: Dp, + modifier: Modifier = Modifier, +) { + val isTransfer = activity.isTransfer() + val isTransferFromSpending = isTransfer && activity.txType() == PaymentType.RECEIVED + val transferIconColor = if (isTransferFromSpending) Colors.Purple else Colors.Brand + val transferBackgroundColor = if (isTransferFromSpending) Colors.Purple16 else Colors.Brand16 - else -> { - CircularIcon( - icon = arrowIcon, - iconColor = Colors.Purple, - backgroundColor = Colors.Purple16, - size = size, - modifier = modifier, - ) - } - } + CircularIcon( + icon = when { + !activity.doesExist() -> painterResource(R.drawable.ic_x) + isTransfer -> painterResource(R.drawable.ic_transfer) + else -> arrowIcon + }, + iconColor = if (isTransfer) transferIconColor else Colors.Brand, + backgroundColor = if (isTransfer) transferBackgroundColor else Colors.Brand16, + size = size, + modifier = modifier.testTag(if (isTransfer) "TransferIcon" else "ActivityIcon"), + ) +} + +@Composable +private fun ActivityIconLightning( + status: PaymentState?, + size: Dp, + arrowIcon: Painter, + modifier: Modifier = Modifier, +) { + when (status) { + PaymentState.FAILED -> { + CircularIcon( + icon = painterResource(R.drawable.ic_x), + iconColor = Colors.Purple, + backgroundColor = Colors.Purple16, + size = size, + modifier = modifier, + ) } - // onchain - else -> { - val isTransfer = activity.isTransfer() - val isTransferFromSpending = isTransfer && activity.txType() == PaymentType.RECEIVED - val transferIconColor = if (isTransferFromSpending) Colors.Purple else Colors.Brand - val transferBackgroundColor = if (isTransferFromSpending) Colors.Purple16 else Colors.Brand16 + PaymentState.PENDING -> { + CircularIcon( + icon = painterResource(R.drawable.ic_hourglass_simple), + iconColor = Colors.Purple, + backgroundColor = Colors.Purple16, + size = size, + modifier = modifier, + ) + } + else -> { CircularIcon( - icon = when { - !activity.doesExist() -> painterResource(R.drawable.ic_x) - isTransfer -> painterResource(R.drawable.ic_transfer) - else -> arrowIcon - }, - iconColor = if (isTransfer) transferIconColor else Colors.Brand, - backgroundColor = if (isTransfer) transferBackgroundColor else Colors.Brand16, + icon = arrowIcon, + iconColor = Colors.Purple, + backgroundColor = Colors.Purple16, size = size, - modifier = modifier.testTag(if (isTransfer) "TransferIcon" else "ActivityIcon"), + modifier = modifier, ) } } @@ -122,7 +134,7 @@ fun CircularIcon( modifier: Modifier = Modifier, ) { Box( - contentAlignment = Alignment.Companion.Center, + contentAlignment = Alignment.Center, modifier = modifier .size(size) .background(backgroundColor, CircleShape) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/EmptyActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/EmptyActivityRow.kt index 9ee9493f5..3d8c95643 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/EmptyActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/EmptyActivityRow.kt @@ -27,7 +27,7 @@ fun EmptyActivityRow( modifier: Modifier = Modifier, ) { Row( - verticalAlignment = Alignment.Companion.CenterVertically, + verticalAlignment = Alignment.CenterVertically, modifier = modifier .fillMaxWidth() .padding(vertical = 16.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt index a964df902..7bb7797ad 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt @@ -119,7 +119,7 @@ fun SendRecipientScreen( Image( painter = painterResource(R.drawable.coin_stack_logo), contentDescription = null, - contentScale = ContentScale.Companion.FillWidth, + contentScale = ContentScale.FillWidth, modifier = Modifier.fillMaxWidth() ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 90356dbb2..31306fb03 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -223,6 +223,7 @@ class AppViewModel @Inject constructor( } } + @Suppress("CyclomaticComplexMethod") private fun observeLdkNodeEvents() { viewModelScope.launch { ldkNodeEventBus.events.collect { event -> @@ -231,78 +232,22 @@ class AppViewModel @Inject constructor( launch(bgDispatcher) { walletRepo.syncNodeAndWallet() } runCatching { when (event) { - is Event.PaymentReceived -> { - NotifyPaymentReceived.Command.from(event)?.let { handlePaymentReceived(it, event) } - } - - is Event.ChannelReady -> notifyChannelReady(event) - - is Event.ChannelPending -> Unit + is Event.BalanceChanged -> Unit is Event.ChannelClosed -> Unit - - is Event.PaymentSuccessful -> notifyPaymentSentOnLightning(event) - + is Event.ChannelPending -> Unit + is Event.ChannelReady -> notifyChannelReady(event) + is Event.OnchainTransactionConfirmed -> Unit + is Event.OnchainTransactionEvicted -> notifyTransactionRemoved(event) + is Event.OnchainTransactionReceived -> notifyPaymentReceived(event) + is Event.OnchainTransactionReorged -> notifyTransactionUnconfirmed() + is Event.OnchainTransactionReplaced -> notifyTransactionReplaced(event) is Event.PaymentClaimable -> Unit - is Event.PaymentFailed -> { - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.wallet__toast_payment_failed_title), - description = context.getString(R.string.wallet__toast_payment_failed_description), - testTag = "PaymentFailedToast", - ) - } + is Event.PaymentFailed -> notifyPaymentFailed() is Event.PaymentForwarded -> Unit - - is Event.OnchainTransactionReceived -> { - NotifyPaymentReceived.Command.from(event)?.let { handlePaymentReceived(it, event) } - } - - is Event.OnchainTransactionConfirmed -> Unit - is Event.SyncProgress -> Unit + is Event.PaymentReceived -> notifyPaymentReceived(event) + is Event.PaymentSuccessful -> notifyPaymentSentOnLightning(event) is Event.SyncCompleted -> Unit - is Event.BalanceChanged -> Unit - - is Event.OnchainTransactionEvicted -> { - viewModelScope.launch(bgDispatcher) { - if (!activityRepo.wasTransactionReplaced(event.txid)) { - toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.wallet__toast_transaction_removed_title), - description = context.getString(R.string.wallet__toast_transaction_removed_description), - testTag = "TransactionRemovedToast", - ) - } - } - } - - is Event.OnchainTransactionReorged -> { - toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.wallet__toast_transaction_unconfirmed_title), - description = context.getString(R.string.wallet__toast_transaction_unconfirmed_description), - testTag = "TransactionUnconfirmedToast", - ) - } - - is Event.OnchainTransactionReplaced -> { - viewModelScope.launch(bgDispatcher) { - if (activityRepo.isReceivedTransaction(event.txid)) { - toast( - type = Toast.ToastType.INFO, - title = context.getString(R.string.wallet__toast_received_transaction_replaced_title), - description = context.getString(R.string.wallet__toast_received_transaction_replaced_description), - testTag = "ReceivedTransactionReplacedToast", - ) - } else { - toast( - type = Toast.ToastType.INFO, - title = context.getString(R.string.wallet__toast_transaction_replaced_title), - description = context.getString(R.string.wallet__toast_transaction_replaced_description), - testTag = "TransactionReplacedToast", - ) - } - } - } + is Event.SyncProgress -> Unit } }.onFailure { e -> Logger.error("LDK event handler error", e, context = TAG) @@ -311,6 +256,49 @@ class AppViewModel @Inject constructor( } } + private fun notifyPaymentFailed() = toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__toast_payment_failed_title), + description = context.getString(R.string.wallet__toast_payment_failed_description), + testTag = "PaymentFailedToast", + ) + + private fun notifyTransactionUnconfirmed() = toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.wallet__toast_transaction_unconfirmed_title), + description = context.getString(R.string.wallet__toast_transaction_unconfirmed_description), + testTag = "TransactionUnconfirmedToast", + ) + + private fun notifyTransactionRemoved(event: Event.OnchainTransactionEvicted) = viewModelScope.launch { + if (!activityRepo.wasTransactionReplaced(event.txid)) { + toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.wallet__toast_transaction_removed_title), + description = context.getString(R.string.wallet__toast_transaction_removed_description), + testTag = "TransactionRemovedToast", + ) + } + } + + private fun notifyTransactionReplaced(event: Event.OnchainTransactionReplaced) = viewModelScope.launch { + if (activityRepo.isReceivedTransaction(event.txid)) { + toast( + type = Toast.ToastType.INFO, + title = context.getString(R.string.wallet__toast_received_transaction_replaced_title), + description = context.getString(R.string.wallet__toast_received_transaction_replaced_description), + testTag = "ReceivedTransactionReplacedToast", + ) + } else { + toast( + type = Toast.ToastType.INFO, + title = context.getString(R.string.wallet__toast_transaction_replaced_title), + description = context.getString(R.string.wallet__toast_transaction_replaced_description), + testTag = "TransactionReplacedToast", + ) + } + } + private suspend fun notifyPaymentSentOnLightning(event: Event.PaymentSuccessful): Result { val paymentHash = event.paymentHash // TODO Temporary solution while LDK node doesn't return the sent value in the event @@ -356,14 +344,12 @@ class AppViewModel @Inject constructor( } } - private fun handlePaymentReceived( - receivedEvent: NotifyPaymentReceived.Command, - originalEvent: Event, - ) { + private fun notifyPaymentReceived(event: Event) { + val command = NotifyPaymentReceived.Command.from(event) ?: return viewModelScope.launch(bgDispatcher) { - notifyPaymentReceivedHandler(receivedEvent).onSuccess { result -> + notifyPaymentReceivedHandler(command).onSuccess { result -> if (result is NotifyPaymentReceived.Result.ShowSheet) { - showNewTransactionSheet(result.details, originalEvent) + showNewTransactionSheet(result.details, event) } } } diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 0450146ab..746b40004 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -171,7 +171,7 @@ complexity: ignoreStringsRegex: '$^' TooManyFunctions: active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/ext/**'] thresholdInFiles: 11 thresholdInClasses: 11 thresholdInInterfaces: 11 From 80c2b150fa8de1b7da2404e37a7679fe44d7da67 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 27 Nov 2025 20:33:50 +0100 Subject: [PATCH 07/32] chore: review --- .../to/bitkit/androidServices/LightningNodeService.kt | 10 ++++------ .../domain/commands/NotifyPaymentReceivedHandler.kt | 7 +++++-- .../bitkit/androidServices/LightningNodeServiceTest.kt | 10 +++------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index a0b69c158..e04d88319 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -76,15 +76,13 @@ class LightningNodeService : Service() { } private suspend fun handleBackgroundEvent(event: Event) { - if (App.currentActivity?.value != null) return - val command = NotifyPaymentReceived.Command.from(event, includeNotification = true) ?: return notifyPaymentReceivedHandler(command).onSuccess { result -> - if (result is NotifyPaymentReceived.Result.ShowNotification) { - if (App.currentActivity?.value != null) return@onSuccess - showPaymentNotification(result.details, result.notification) - } + if (result !is NotifyPaymentReceived.Result.ShowNotification) return@onSuccess + if (App.currentActivity?.value != null) return@onSuccess + + showPaymentNotification(result.details, result.notification) } } diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index 2ef4750f2..6b139d2d2 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -19,6 +19,7 @@ import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.CurrencyRepo +import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -34,7 +35,7 @@ class NotifyPaymentReceivedHandler @Inject constructor( command: NotifyPaymentReceived.Command, ): Result = withContext(ioDispatcher) { runCatching { - delay(DELAY_MS) + delay(DELAY_FOR_ACTIVITY_SYNC_MS) val shouldShow = when (command) { is NotifyPaymentReceived.Command.Lightning -> true @@ -62,6 +63,8 @@ class NotifyPaymentReceivedHandler @Inject constructor( } else { NotifyPaymentReceived.Result.ShowSheet(details) } + }.onFailure { e -> + Logger.error("Failed to process payment notification", e, context = TAG) } } @@ -93,6 +96,6 @@ class NotifyPaymentReceivedHandler @Inject constructor( companion object { const val TAG = "NotifyPaymentReceivedHandler" - private const val DELAY_MS = 500L + private const val DELAY_FOR_ACTIVITY_SYNC_MS = 500L } } diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index 454e3cf89..eaf85d639 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -11,7 +11,6 @@ import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltTestApplication -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.runBlocking import org.junit.After @@ -27,7 +26,6 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -import org.mockito.kotlin.wheneverBlocking import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows @@ -46,7 +44,6 @@ import to.bitkit.repositories.WalletRepo import to.bitkit.services.LdkNodeEventBus import to.bitkit.test.BaseUnitTest -@OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest @Config(application = HiltTestApplication::class) @RunWith(RobolectricTestRunner::class) @@ -98,10 +95,9 @@ class LightningNodeServiceTest : BaseUnitTest() { title = context.getString(R.string.notification_received_title), body = "Received ₿ 100 ($0.10)", ) - wheneverBlocking { notifyPaymentReceivedHandler.invoke(any()) } - .thenReturn( - Result.success(NotifyPaymentReceived.Result.ShowNotification(defaultDetails, defaultNotification)) - ) + whenever(notifyPaymentReceivedHandler.invoke(any())).thenReturn( + Result.success(NotifyPaymentReceived.Result.ShowNotification(defaultDetails, defaultNotification)) + ) // Grant permissions for notifications val app = context as Application From ab4334a22fab79ee72ae9f7035789229dad2062c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 28 Nov 2025 02:50:35 +0100 Subject: [PATCH 08/32] refactor: skip ldk-node replayed event using cache --- .../main/java/to/bitkit/data/CacheStore.kt | 5 +++ .../domain/commands/NotifyPaymentReceived.kt | 10 +++--- .../commands/NotifyPaymentReceivedHandler.kt | 7 ++-- .../to/bitkit/repositories/LightningRepo.kt | 32 +++++++++--------- .../to/bitkit/services/LightningService.kt | 7 ++-- .../java/to/bitkit/ui/sheets/GiftSheet.kt | 14 +++----- .../java/to/bitkit/viewmodels/AppViewModel.kt | 33 ++++++++----------- .../NotifyPaymentReceivedHandlerTest.kt | 14 ++++---- 8 files changed, 60 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index d3be38bae..114784b05 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -113,6 +113,10 @@ class CacheStore @Inject constructor( } } + suspend fun setLastLightningPayment(paymentId: String) { + store.updateData { it.copy(lastLightningPaymentId = paymentId) } + } + suspend fun reset() { store.updateData { AppCacheData() } Logger.info("Deleted all app cached data.") @@ -134,5 +138,6 @@ data class AppCacheData( val backupStatuses: Map = mapOf(), val deletedActivities: List = listOf(), val activitiesPendingDelete: List = listOf(), + val lastLightningPaymentId: String? = null, val pendingBoostActivities: List = listOf(), ) diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt index 06bec0838..5a6f5a399 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt @@ -8,18 +8,18 @@ sealed interface NotifyPaymentReceived { sealed interface Command : NotifyPaymentReceived { val sats: ULong - val paymentId: String + val paymentHashOrTxId: String val includeNotification: Boolean data class Lightning( override val sats: ULong, - override val paymentId: String, + override val paymentHashOrTxId: String, override val includeNotification: Boolean = false, ) : Command data class Onchain( override val sats: ULong, - override val paymentId: String, + override val paymentHashOrTxId: String, override val includeNotification: Boolean = false, ) : Command @@ -28,7 +28,7 @@ sealed interface NotifyPaymentReceived { when (event) { is Event.PaymentReceived -> Lightning( sats = event.amountMsat / 1000u, - paymentId = event.paymentHash, + paymentHashOrTxId = event.paymentHash, includeNotification = includeNotification, ) @@ -36,7 +36,7 @@ sealed interface NotifyPaymentReceived { val amountSats = event.details.amountSats Onchain( sats = amountSats.toULong(), - paymentId = event.txid, + paymentHashOrTxId = event.txid, includeNotification = includeNotification, ).takeIf { amountSats > 0 diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index 6b139d2d2..206e3276d 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -35,12 +35,11 @@ class NotifyPaymentReceivedHandler @Inject constructor( command: NotifyPaymentReceived.Command, ): Result = withContext(ioDispatcher) { runCatching { - delay(DELAY_FOR_ACTIVITY_SYNC_MS) - val shouldShow = when (command) { is NotifyPaymentReceived.Command.Lightning -> true is NotifyPaymentReceived.Command.Onchain -> { - activityRepo.shouldShowPaymentReceived(command.paymentId, command.sats) + delay(DELAY_FOR_ACTIVITY_SYNC_MS) + activityRepo.shouldShowPaymentReceived(command.paymentHashOrTxId, command.sats) } } @@ -53,7 +52,7 @@ class NotifyPaymentReceivedHandler @Inject constructor( is NotifyPaymentReceived.Command.Onchain -> NewTransactionSheetType.ONCHAIN }, direction = NewTransactionSheetDirection.RECEIVED, - paymentHashOrTxId = command.paymentId, + paymentHashOrTxId = command.paymentHashOrTxId, sats = satsLong, ) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index e0b32776d..2e5ff6586 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -87,7 +87,7 @@ class LightningRepo @Inject constructor( private val scope = CoroutineScope(bgDispatcher + SupervisorJob()) - private var cachedEventHandler: NodeEventHandler? = null + private var _eventHandler: NodeEventHandler? = null private val _isRecoveryMode = MutableStateFlow(false) val isRecoveryMode = _isRecoveryMode.asStateFlow() @@ -193,6 +193,8 @@ class LightningRepo @Inject constructor( try { _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Starting) } + this@LightningRepo._eventHandler = eventHandler + // Setup if not already setup if (lightningService.node == null) { val setupResult = setup(walletIndex, customServerUrl, customRgsServerUrl) @@ -211,21 +213,12 @@ class LightningRepo @Inject constructor( if (getStatus()?.isRunning == true) { Logger.info("LDK node already running", context = TAG) _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } - lightningService.listenForEvents(onEvent = { event -> - handleLdkEvent(event) - eventHandler?.invoke(event) - }) + lightningService.listenForEvents(::onEvent) return@withContext Result.success(Unit) } // Start the node service - lightningService.start(timeout) { event -> - handleLdkEvent(event) - eventHandler?.invoke(event) - ldkNodeEventBus.emit(event) - } - - this@LightningRepo.cachedEventHandler = eventHandler + lightningService.start(timeout, ::onEvent) _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } @@ -266,6 +259,12 @@ class LightningRepo @Inject constructor( } } + private suspend fun onEvent(event: Event) { + handleLdkEvent(event) + _eventHandler?.invoke(event) + ldkNodeEventBus.emit(event) + } + fun setRecoveryMode(enabled: Boolean) { _isRecoveryMode.value = enabled } @@ -330,11 +329,13 @@ class LightningRepo @Inject constructor( refreshChannelCache() } } + is Event.ChannelReady -> { scope.launch { refreshChannelCache() } } + is Event.ChannelClosed -> { val channelId = event.channelId val reason = event.reason?.toString() ?: "" @@ -342,6 +343,7 @@ class LightningRepo @Inject constructor( registerClosedChannel(channelId, reason) } } + else -> { // Other events don't need special handling } @@ -436,7 +438,7 @@ class LightningRepo @Inject constructor( start( shouldRetry = false, customServerUrl = newServerUrl, - eventHandler = cachedEventHandler, + eventHandler = _eventHandler, ).onFailure { startError -> Logger.warn("Failed ldk-node config change, attempting recovery…") restartWithPreviousConfig() @@ -463,7 +465,7 @@ class LightningRepo @Inject constructor( start( shouldRetry = false, customRgsServerUrl = newRgsUrl, - eventHandler = cachedEventHandler, + eventHandler = _eventHandler, ).onFailure { startError -> Logger.warn("Failed ldk-node config change, attempting recovery…") restartWithPreviousConfig() @@ -488,7 +490,7 @@ class LightningRepo @Inject constructor( start( shouldRetry = false, - eventHandler = cachedEventHandler, + eventHandler = _eventHandler, ).onSuccess { Logger.debug("Successfully started node with previous config") }.onFailure { e -> diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index fadf46786..cca66765b 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.lightningdevkit.ldknode.Address import org.lightningdevkit.ldknode.AnchorChannelsConfig @@ -674,11 +675,11 @@ class LightningService @Inject constructor( // region events private var shouldListenForEvents = true - suspend fun listenForEvents(onEvent: NodeEventHandler? = null) { + suspend fun listenForEvents(onEvent: NodeEventHandler? = null) = withContext(bgDispatcher) { while (shouldListenForEvents) { - val node = this.node ?: let { + val node = this@LightningService.node ?: let { Logger.error(ServiceError.NodeNotStarted.message.orEmpty()) - return + return@withContext } val event = node.nextEventAsync() Logger.debug("LDK-node event fired: ${jsonLogOf(event)}") diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftSheet.kt index 614678b18..2384625db 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/GiftSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/GiftSheet.kt @@ -33,25 +33,21 @@ fun GiftSheet( val onSuccessState = rememberUpdatedState { details: NewTransactionSheetDetails -> appViewModel.hideSheet() - appViewModel.showNewTransactionSheet(details = details, event = null) + appViewModel.showNewTransactionSheet(details) } LaunchedEffect(Unit) { viewModel.successEvent.collect { details -> - onSuccessState.value(details) + onSuccessState.value.invoke(details) } } LaunchedEffect(Unit) { viewModel.navigationEvent.collect { route -> when (route) { - is GiftRoute.Success -> { - appViewModel.hideSheet() - } - else -> { - navController.navigate(route) { - popUpTo(GiftRoute.Loading) { inclusive = false } - } + is GiftRoute.Success -> appViewModel.hideSheet() + else -> navController.navigate(route) { + popUpTo(GiftRoute.Loading) { inclusive = false } } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 31306fb03..19af300d4 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -53,6 +53,7 @@ import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid import to.bitkit.BuildConfig import to.bitkit.R +import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin @@ -124,6 +125,7 @@ class AppViewModel @Inject constructor( private val blocktankRepo: BlocktankRepo, private val appUpdaterService: AppUpdaterService, private val notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler, + private val cacheStore: CacheStore, ) : ViewModel() { val healthState = healthRepo.healthState @@ -332,7 +334,6 @@ class AppViewModel @Inject constructor( direction = NewTransactionSheetDirection.RECEIVED, sats = amount, ), - event, ) activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel) } else { @@ -346,10 +347,21 @@ class AppViewModel @Inject constructor( private fun notifyPaymentReceived(event: Event) { val command = NotifyPaymentReceived.Command.from(event) ?: return + viewModelScope.launch(bgDispatcher) { + // Skip lightning payment events replayed by ldk-node on startup + if (command is NotifyPaymentReceived.Command.Lightning) { + val cachedId = cacheStore.data.first().lastLightningPaymentId + if (command.paymentHashOrTxId == cachedId) { + Logger.debug("Skipping replayed Lightning payment: ${command.paymentHashOrTxId}", context = TAG) + return@launch + } + cacheStore.setLastLightningPayment(command.paymentHashOrTxId) + } + notifyPaymentReceivedHandler(command).onSuccess { result -> if (result is NotifyPaymentReceived.Result.ShowSheet) { - showNewTransactionSheet(result.details, event) + showNewTransactionSheet(result.details) } } } @@ -1409,7 +1421,6 @@ class AppViewModel @Inject constructor( fun showNewTransactionSheet( details: NewTransactionSheetDetails, - event: Event? = null, ) = viewModelScope.launch { if (backupRepo.isRestoring.value) return@launch @@ -1418,22 +1429,6 @@ class AppViewModel @Inject constructor( return@launch } - if (event is Event.PaymentReceived) { - val activity = activityRepo.findActivityByPaymentId( - paymentHashOrTxId = event.paymentHash, - type = ActivityFilter.ALL, - txType = PaymentType.RECEIVED, - retry = false, - ).getOrNull() - - // TODO check if this is still needed now that we're disabling the sheet during restore - // TODO Temporary fix while ldk-node bug is not fixed https://github.com/synonymdev/bitkit-android/pull/297 - if (activity != null) { - Logger.warn("Activity ${activity.rawId()} already exists, skipping sheet", context = TAG) - return@launch - } - } - hideSheet() _showNewTransaction.update { true } diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt index d0a879706..334577b0f 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -61,8 +61,8 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { } @Test - fun `lightning payment returns ShowSheet by default`() = test { - val command = NotifyPaymentReceived.Command.Lightning(sats = 1000uL, paymentId = "hash123") + fun `lightning payment returns ShowSheet`() = test { + val command = NotifyPaymentReceived.Command.Lightning(sats = 1000uL, paymentHashOrTxId = "hash123") val result = sut(command) @@ -80,7 +80,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { fun `lightning payment returns ShowNotification when includeNotification is true`() = test { val command = NotifyPaymentReceived.Command.Lightning( sats = 1000uL, - paymentId = "hash123", + paymentHashOrTxId = "hash123", includeNotification = true, ) @@ -99,7 +99,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { @Test fun `onchain payment returns ShowSheet when shouldShowPaymentReceived returns true`() = test { whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(true) - val command = NotifyPaymentReceived.Command.Onchain(sats = 5000uL, paymentId = "txid456") + val command = NotifyPaymentReceived.Command.Onchain(sats = 5000uL, paymentHashOrTxId = "txid456") val result = sut(command) @@ -116,7 +116,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { @Test fun `onchain payment returns Skip when shouldShowPaymentReceived is false`() = test { whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(false) - val command = NotifyPaymentReceived.Command.Onchain(sats = 5000uL, paymentId = "txid456") + val command = NotifyPaymentReceived.Command.Onchain(sats = 5000uL, paymentHashOrTxId = "txid456") val result = sut(command) @@ -128,7 +128,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { @Test fun `onchain payment calls shouldShowPaymentReceived with correct parameters`() = test { whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(true) - val command = NotifyPaymentReceived.Command.Onchain(sats = 7500uL, paymentId = "txid789") + val command = NotifyPaymentReceived.Command.Onchain(sats = 7500uL, paymentHashOrTxId = "txid789") sut(command) @@ -137,7 +137,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { @Test fun `lightning payment does not call shouldShowPaymentReceived`() = test { - val command = NotifyPaymentReceived.Command.Lightning(sats = 1000uL, paymentId = "hash123") + val command = NotifyPaymentReceived.Command.Lightning(sats = 1000uL, paymentHashOrTxId = "hash123") sut(command) From 55751a7dd94013130ca0572b0c123a868ba2df90 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 27 Nov 2025 21:05:40 -0500 Subject: [PATCH 09/32] Integrate LDK events --- .../main/java/to/bitkit/data/CacheStore.kt | 17 - .../commands/NotifyPaymentReceivedHandler.kt | 2 +- app/src/main/java/to/bitkit/ext/Activities.kt | 5 - .../to/bitkit/repositories/ActivityRepo.kt | 162 ++---- .../to/bitkit/repositories/LightningRepo.kt | 13 + .../java/to/bitkit/repositories/WalletRepo.kt | 7 +- .../java/to/bitkit/services/CoreService.kt | 545 +++++++++++++++--- .../to/bitkit/services/LightningService.kt | 27 + app/src/main/java/to/bitkit/ui/ContentView.kt | 5 - .../external/ExternalNodeViewModel.kt | 7 +- .../wallets/activity/ActivityDetailScreen.kt | 109 +++- .../wallets/activity/ActivityExploreScreen.kt | 34 +- .../activity/components/ActivityIcon.kt | 3 +- .../activity/components/ActivityRow.kt | 25 +- .../advanced/AddressViewerViewModel.kt | 15 +- .../settings/lightning/ChannelDetailScreen.kt | 26 +- .../LightningConnectionsViewModel.kt | 63 +- .../ui/sheets/BoostTransactionViewModel.kt | 118 +--- .../java/to/bitkit/utils/AddressChecker.kt | 131 ----- .../viewmodels/ActivityDetailViewModel.kt | 28 +- .../viewmodels/ActivityListViewModel.kt | 42 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 108 +++- app/src/main/res/values/strings.xml | 2 + .../NotifyPaymentReceivedHandlerTest.kt | 18 +- .../ActivityDetailViewModelTest.kt | 5 +- .../bitkit/repositories/ActivityRepoTest.kt | 148 +---- .../to/bitkit/repositories/WalletRepoTest.kt | 47 +- 27 files changed, 962 insertions(+), 750 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/utils/AddressChecker.kt diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 114784b05..e9e209645 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -83,22 +83,6 @@ class CacheStore @Inject constructor( } } - suspend fun addActivityToPendingDelete(activityId: String) { - if (activityId.isBlank()) return - if (activityId in store.data.first().activitiesPendingDelete) return - store.updateData { - it.copy(activitiesPendingDelete = it.activitiesPendingDelete + activityId) - } - } - - suspend fun removeActivityFromPendingDelete(activityId: String) { - if (activityId.isBlank()) return - if (activityId !in store.data.first().activitiesPendingDelete) return - store.updateData { - it.copy(activitiesPendingDelete = it.activitiesPendingDelete - activityId) - } - } - suspend fun addActivityToPendingBoost(pendingBoostActivity: PendingBoostActivity) { if (pendingBoostActivity in store.data.first().pendingBoostActivities) return store.updateData { @@ -137,7 +121,6 @@ data class AppCacheData( val balance: BalanceState? = null, val backupStatuses: Map = mapOf(), val deletedActivities: List = listOf(), - val activitiesPendingDelete: List = listOf(), val lastLightningPaymentId: String? = null, val pendingBoostActivities: List = listOf(), ) diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index 206e3276d..c27001d83 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -39,7 +39,7 @@ class NotifyPaymentReceivedHandler @Inject constructor( is NotifyPaymentReceived.Command.Lightning -> true is NotifyPaymentReceived.Command.Onchain -> { delay(DELAY_FOR_ACTIVITY_SYNC_MS) - activityRepo.shouldShowPaymentReceived(command.paymentHashOrTxId, command.sats) + activityRepo.shouldShowReceivedSheet(command.paymentHashOrTxId, command.sats) } } diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index c7c1e60b2..73153cf40 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -33,11 +33,6 @@ fun Activity.totalValue() = when (this) { } } -fun Activity.canBeBoosted() = when (this) { - is Activity.Onchain -> !v1.confirmed && v1.doesExist && !v1.isBoosted && !v1.isTransfer && v1.value > 0uL - else -> false -} - fun Activity.isBoosted() = when (this) { is Activity.Onchain -> v1.isBoosted else -> false diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 0369d9e3a..0f7b6382c 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -26,6 +26,7 @@ import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind +import org.lightningdevkit.ldknode.TransactionDetails import to.bitkit.data.CacheStore import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.di.BgDispatcher @@ -36,7 +37,6 @@ import to.bitkit.ext.nowTimestamp import to.bitkit.ext.rawId import to.bitkit.models.ActivityBackupV1 import to.bitkit.services.CoreService -import to.bitkit.utils.AddressChecker import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -50,7 +50,6 @@ class ActivityRepo @Inject constructor( private val coreService: CoreService, private val lightningRepo: LightningRepo, private val blocktankRepo: BlocktankRepo, - private val addressChecker: AddressChecker, private val cacheStore: CacheStore, private val transferRepo: TransferRepo, private val clock: Clock, @@ -83,8 +82,6 @@ class ActivityRepo @Inject constructor( isSyncingLdkNodePayments.update { true } - deletePendingActivities() - lightningRepo.getPayments().mapCatching { payments -> Logger.debug("Got payments with success, syncing activities", context = TAG) syncLdkNodePayments(payments).getOrThrow() @@ -167,67 +164,13 @@ class ActivityRepo @Inject constructor( } private suspend fun findClosedChannelForTransaction(txid: String): String? { - return try { - val closedChannelsResult = getClosedChannels(SortDirection.DESC) - val closedChannels = closedChannelsResult.getOrNull() ?: return null - if (closedChannels.isEmpty()) return null - - val txDetails = addressChecker.getTransaction(txid) - - txDetails.vin.firstNotNullOfOrNull { input -> - val inputTxid = input.txid ?: return@firstNotNullOfOrNull null - val inputVout = input.vout ?: return@firstNotNullOfOrNull null - - closedChannels.firstOrNull { channel -> - channel.fundingTxoTxid == inputTxid && channel.fundingTxoIndex == inputVout.toUInt() - }?.channelId - } - } catch (e: Exception) { - Logger.warn( - "Failed to check if transaction $txid spends closed channel funding UTXO", - e, - context = TAG - ) - null - } + return coreService.activity.findClosedChannelForTransaction(txid, null) } - private suspend fun getOnchainActivityByTxId(txid: String): OnchainActivity? { + suspend fun getOnchainActivityByTxId(txid: String): OnchainActivity? { return coreService.activity.getOnchainActivityByTxId(txid) } - /** - * Determines whether to show the payment received UI for an onchain transaction. - * Returns false for: - * - Zero value transactions - * - Channel closure transactions (transfers to savings) - * - RBF replacement transactions with the same value as the original - */ - suspend fun shouldShowPaymentReceived(txid: String, value: ULong): Boolean = withContext(bgDispatcher) { - if (value == 0uL) return@withContext false - - if (findClosedChannelForTransaction(txid) != null) { - Logger.debug("Skipping payment received UI for channel closure tx: $txid", context = TAG) - return@withContext false - } - - val onchainActivity = getOnchainActivityByTxId(txid) - if (onchainActivity != null && onchainActivity.boostTxIds.isNotEmpty()) { - for (replacedTxid in onchainActivity.boostTxIds) { - val replacedActivity = getOnchainActivityByTxId(replacedTxid) - if (replacedActivity != null && replacedActivity.value == value) { - Logger.info( - "Skipping payment received UI for RBF replacement $txid with same value as $replacedTxid", - context = TAG - ) - return@withContext false - } - } - } - - return@withContext true - } - /** * Checks if a transaction is inbound (received) by looking up the payment direction. */ @@ -247,6 +190,58 @@ class ActivityRepo @Inject constructor( return@withContext !onchainActivity.doesExist } + suspend fun handleOnchainTransactionReceived( + txid: String, + details: TransactionDetails, + ) { + coreService.activity.handleOnchainTransactionReceived(txid, details) + notifyActivitiesChanged() + } + + suspend fun handleOnchainTransactionConfirmed( + txid: String, + details: TransactionDetails, + ) { + coreService.activity.handleOnchainTransactionConfirmed(txid, details) + notifyActivitiesChanged() + } + + suspend fun handleOnchainTransactionReplaced(txid: String, conflicts: List) { + coreService.activity.handleOnchainTransactionReplaced(txid, conflicts) + notifyActivitiesChanged() + } + + suspend fun handleOnchainTransactionReorged(txid: String) { + coreService.activity.handleOnchainTransactionReorged(txid) + notifyActivitiesChanged() + } + + suspend fun handleOnchainTransactionEvicted(txid: String) { + coreService.activity.handleOnchainTransactionEvicted(txid) + notifyActivitiesChanged() + } + + suspend fun handlePaymentEvent(paymentHash: String) { + coreService.activity.handlePaymentEvent(paymentHash) + notifyActivitiesChanged() + } + + suspend fun shouldShowReceivedSheet(txid: String, value: ULong): Boolean { + return coreService.activity.shouldShowReceivedSheet(txid, value) + } + + suspend fun getBoostTxDoesExist(boostTxIds: List): Map { + return coreService.activity.getBoostTxDoesExist(boostTxIds) + } + + suspend fun isCpfpChildTransaction(txId: String): Boolean { + return coreService.activity.isCpfpChildTransaction(txId) + } + + suspend fun getTxIdsInBoostTxIds(): Set { + return coreService.activity.getTxIdsInBoostTxIds() + } + /** * Gets a specific activity by payment hash or txID with retry logic */ @@ -392,22 +387,13 @@ class ActivityRepo @Inject constructor( ).fold( onSuccess = { Logger.debug( - "Activity $id updated with success. new data: $activity. " + - "Marking activity $activityIdToDelete as removed from mempool", + "Activity $id updated with success. new data: $activity", context = TAG ) val tags = coreService.activity.tags(activityIdToDelete) addTagsToActivity(activityId = id, tags = tags) - markActivityAsRemovedFromMempool(activityIdToDelete).onFailure { e -> - Logger.warn( - "Failed to mark $activityIdToDelete as removed from mempool, caching to retry on next sync", - e = e, - context = TAG - ) - cacheStore.addActivityToPendingDelete(activityId = activityIdToDelete) - } Result.success(Unit) }, onFailure = { e -> @@ -422,16 +408,6 @@ class ActivityRepo @Inject constructor( ) } - private suspend fun deletePendingActivities() = withContext(bgDispatcher) { - cacheStore.data.first().activitiesPendingDelete.map { activityId -> - async { - markActivityAsRemovedFromMempool(activityId).onSuccess { - cacheStore.removeActivityFromPendingDelete(activityId) - } - } - }.awaitAll() - } - private suspend fun boostPendingActivities() = withContext(bgDispatcher) { cacheStore.data.first().pendingBoostActivities.map { pendingBoostActivity -> async { @@ -484,32 +460,6 @@ class ActivityRepo @Inject constructor( }.awaitAll() } - /** - * Marks an activity as removed from mempool (sets doesExist = false). - * Used for RBFed transactions that are replaced. - */ - private suspend fun markActivityAsRemovedFromMempool(activityId: String): Result = withContext(bgDispatcher) { - return@withContext runCatching { - val existingActivity = getActivity(activityId).getOrNull() - ?: return@withContext Result.failure(Exception("Activity $activityId not found")) - - if (existingActivity is Activity.Onchain) { - val updatedActivity = Activity.Onchain( - v1 = existingActivity.v1.copy( - doesExist = false, - updatedAt = nowTimestamp().toEpochMilli().toULong() - ) - ) - updateActivity(id = activityId, activity = updatedActivity, forceUpdate = true).getOrThrow() - notifyActivitiesChanged() - } else { - return@withContext Result.failure(Exception("Activity $activityId is not an onchain activity")) - } - }.onFailure { e -> - Logger.error("markActivityAsRemovedFromMempool error for ID: $activityId", e, context = TAG) - } - } - /** * Deletes an activity */ diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 2e5ff6586..44badaab7 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -35,6 +35,7 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.SpendableUtxo +import org.lightningdevkit.ldknode.TransactionDetails import org.lightningdevkit.ldknode.Txid import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore @@ -717,6 +718,18 @@ class LightningRepo @Inject constructor( Result.success(payments) } + suspend fun getTransactionDetails(txid: Txid): Result = executeWhenNodeRunning( + "Get transaction details by txid" + ) { + Result.success(lightningService.getTransactionDetails(txid)) + } + + suspend fun getAddressBalance(address: String): Result = executeWhenNodeRunning("Get address balance") { + runCatching { + lightningService.getAddressBalance(address) + } + } + suspend fun listSpendableOutputs(): Result> = executeWhenNodeRunning("List spendable outputs") { lightningService.listSpendableOutputs() } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index ec281e9f1..5f0386aef 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -29,7 +29,6 @@ import to.bitkit.models.toDerivationPath import to.bitkit.services.CoreService import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.usecases.WipeWalletUseCase -import to.bitkit.utils.AddressChecker import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError @@ -43,7 +42,6 @@ class WalletRepo @Inject constructor( private val keychain: Keychain, private val coreService: CoreService, private val settingsStore: SettingsStore, - private val addressChecker: AddressChecker, private val lightningRepo: LightningRepo, private val cacheStore: CacheStore, private val preActivityMetadataRepo: PreActivityMetadataRepo, @@ -83,9 +81,8 @@ class WalletRepo @Inject constructor( suspend fun checkAddressUsage(address: String): Result = withContext(bgDispatcher) { return@withContext try { - val addressInfo = addressChecker.getAddressInfo(address) - val hasTransactions = addressInfo.chain_stats.tx_count > 0 || addressInfo.mempool_stats.tx_count > 0 - Result.success(hasTransactions) + val result = coreService.isAddressUsed(address) + Result.success(result) } catch (e: Exception) { Logger.error("checkAddressUsage error", e, context = TAG) Result.failure(e) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index f19f7f1f2..0d41d69b1 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -38,6 +38,7 @@ import com.synonym.bitkitcore.getOrders import com.synonym.bitkitcore.getTags import com.synonym.bitkitcore.initDb import com.synonym.bitkitcore.insertActivity +import com.synonym.bitkitcore.isAddressUsed import com.synonym.bitkitcore.openChannel import com.synonym.bitkitcore.refreshActiveCjitEntries import com.synonym.bitkitcore.refreshActiveOrders @@ -57,22 +58,22 @@ import io.ktor.http.HttpStatusCode import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.first +import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.ConfirmationStatus import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PaymentStatus +import org.lightningdevkit.ldknode.TransactionDetails import to.bitkit.async.ServiceQueue import to.bitkit.data.CacheStore import to.bitkit.env.Env import to.bitkit.ext.amountSats import to.bitkit.models.toCoreNetwork -import to.bitkit.utils.AddressChecker import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError -import to.bitkit.utils.TxDetails import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random @@ -84,7 +85,6 @@ class CoreService @Inject constructor( private val lightningService: LightningService, private val httpClient: HttpClient, private val cacheStore: CacheStore, - private val addressChecker: AddressChecker, ) { private var walletIndex: Int = 0 @@ -92,7 +92,6 @@ class CoreService @Inject constructor( ActivityService( coreService = this, cacheStore = cacheStore, - addressChecker = addressChecker, lightningService = lightningService ) } @@ -190,6 +189,12 @@ class CoreService @Inject constructor( } } + suspend fun isAddressUsed(address: String): Boolean { + return ServiceQueue.CORE.background { + com.synonym.bitkitcore.isAddressUsed(address = address) + } + } + companion object { private const val TAG = "CoreService" } @@ -203,7 +208,6 @@ private const val CHUNK_SIZE = 50 class ActivityService( @Suppress("unused") private val coreService: CoreService, // used to ensure CoreService inits first private val cacheStore: CacheStore, - private val addressChecker: AddressChecker, private val lightningService: LightningService, ) { suspend fun removeAll() { @@ -376,6 +380,27 @@ class ActivityService( * @param forceUpdate If true, it will also update activities previously marked as deleted. * @param channelIdsByTxId Map of transaction IDs to channel IDs for identifying transfer activities. */ + suspend fun handlePaymentEvent(paymentHash: String) { + ServiceQueue.CORE.background { + val payments = lightningService.payments ?: run { + Logger.warn("No payments available for hash $paymentHash", context = TAG) + return@background + } + + val payment = payments.firstOrNull { it.id == paymentHash } + if (payment != null) { + // Lightning payments don't need channel IDs, only onchain payments do + val channelIdsByTxId = emptyMap() + processSinglePayment(payment, forceUpdate = false, channelIdsByTxId = channelIdsByTxId) + } else { + Logger.info("Payment not found for hash $paymentHash - syncing all payments", context = TAG) + // For full sync, we need channel IDs for onchain payments + // This will be handled by ActivityRepo.syncLdkNodePayments which calls findChannelsForPayments + syncLdkNodePaymentsToActivities(payments, channelIdsByTxId = emptyMap()) + } + } + } + suspend fun syncLdkNodePaymentsToActivities( payments: List, forceUpdate: Boolean = false, @@ -488,10 +513,15 @@ class ActivityService( * Check pre-activity metadata for addresses in the transaction * Returns the first address found in pre-activity metadata that matches a transaction output */ - private suspend fun findAddressInPreActivityMetadata(txDetails: TxDetails): String? { - for (output in txDetails.vout) { - val address = output.scriptpubkey_address ?: continue - val metadata = coreService.activity.getPreActivityMetadata(searchKey = address, searchByAddress = true) + private suspend fun findAddressInPreActivityMetadata( + details: TransactionDetails + ): String? { + for (output in details.outputs) { + val address = output.scriptpubkeyAddress ?: continue + val metadata = coreService.activity.getPreActivityMetadata( + searchKey = address, + searchByAddress = true + ) if (metadata != null && metadata.isReceive) { return address } @@ -503,22 +533,20 @@ class ActivityService( kind: PaymentKind.Onchain, existingActivity: Activity?, payment: PaymentDetails, + transactionDetails: TransactionDetails? = null, ): String? { if (existingActivity != null || payment.direction != PaymentDirection.INBOUND) { return null } - return try { - val txDetails = addressChecker.getTransaction(kind.txid) - findAddressInPreActivityMetadata(txDetails) - } catch (e: Exception) { - Logger.verbose( - "Failed to get transaction details for address lookup: ${kind.txid}", - e, - context = TAG - ) - null + // Get transaction details if not provided + val details = transactionDetails ?: lightningService.getTransactionDetails(kind.txid) + if (details == null) { + Logger.verbose("Transaction details not available for txid: ${kind.txid}", context = TAG) + return null } + + return findAddressInPreActivityMetadata(details) } private data class ConfirmationData( @@ -550,12 +578,8 @@ class ActivityService( private suspend fun buildUpdatedOnchainActivity( existingActivity: Activity.Onchain, confirmationData: ConfirmationData, - txid: String, channelId: String? = null, ): OnchainActivity { - val wasRemoved = !existingActivity.v1.doesExist - val shouldRestore = wasRemoved && confirmationData.isConfirmed - var preservedIsTransfer = existingActivity.v1.isTransfer var preservedChannelId = existingActivity.v1.channelId @@ -564,19 +588,17 @@ class ActivityService( preservedIsTransfer = true } + val finalDoesExist = if (confirmationData.isConfirmed) true else existingActivity.v1.doesExist + val updatedOnChain = existingActivity.v1.copy( confirmed = confirmationData.isConfirmed, confirmTimestamp = confirmationData.confirmedTimestamp, - doesExist = if (shouldRestore) true else existingActivity.v1.doesExist, + doesExist = finalDoesExist, updatedAt = confirmationData.timestamp, isTransfer = preservedIsTransfer, channelId = preservedChannelId, ) - if (wasRemoved && confirmationData.isConfirmed) { - markReplacementTransactionsAsRemoved(originalTxId = txid) - } - return updatedOnChain } @@ -616,6 +638,7 @@ class ActivityService( payment: PaymentDetails, forceUpdate: Boolean, channelId: String? = null, + transactionDetails: TransactionDetails? = null, ) { val timestamp = payment.latestUpdateTimestamp val confirmationData = getConfirmationStatus(kind, timestamp) @@ -628,14 +651,36 @@ class ActivityService( return } - val resolvedAddress = resolveAddressForInboundPayment(kind, existingActivity, payment) + // Extract existing activity data + val existingOnchain = if (existingActivity is Activity.Onchain) { + existingActivity.v1 + } else { + null + } + + var resolvedChannelId = channelId + var isTransfer = existingOnchain?.isTransfer ?: false + + // Check if this transaction is a channel transfer + if (resolvedChannelId == null || !isTransfer) { + val foundChannelId = findChannelForTransaction( + txid = kind.txid, + direction = payment.direction, + transactionDetails = transactionDetails + ) + if (foundChannelId != null) { + resolvedChannelId = foundChannelId + isTransfer = true + } + } + + val resolvedAddress = resolveAddressForInboundPayment(kind, existingActivity, payment, transactionDetails) val onChain = if (existingActivity is Activity.Onchain) { buildUpdatedOnchainActivity( existingActivity = existingActivity, confirmationData = confirmationData, - txid = kind.txid, - channelId = channelId, + channelId = resolvedChannelId, ) } else { buildNewOnchainActivity( @@ -643,7 +688,7 @@ class ActivityService( kind = kind, confirmationData = confirmationData, resolvedAddress = resolvedAddress, - channelId = channelId, + channelId = resolvedChannelId, ) } @@ -659,54 +704,6 @@ class ActivityService( } } - /** - * Marks replacement transactions (with originalTxId in boostTxIds) as doesExist = false when original confirms. - * This is called when a removed RBFed transaction gets confirmed. - */ - private suspend fun markReplacementTransactionsAsRemoved(originalTxId: String) { - try { - val allActivities = getActivities( - filter = ActivityFilter.ONCHAIN, - txType = null, - tags = null, - search = null, - minDate = null, - maxDate = null, - limit = null, - sortDirection = null - ) - - for (activity in allActivities) { - if (activity !is Activity.Onchain) continue - - val onchainActivity = activity.v1 - val isReplacement = onchainActivity.boostTxIds.contains(originalTxId) && - onchainActivity.doesExist && - !onchainActivity.confirmed - - if (isReplacement) { - Logger.debug( - "Marking replacement transaction ${onchainActivity.txId} as doesExist = false " + - "(original $originalTxId confirmed)", - context = TAG - ) - - val updatedActivity = onchainActivity.copy( - doesExist = false, - updatedAt = System.currentTimeMillis().toULong() / 1000u - ) - updateActivity(activityId = onchainActivity.id, activity = Activity.Onchain(updatedActivity)) - } - } - } catch (e: Exception) { - Logger.error( - "Error marking replacement transactions as removed for originalTxId: $originalTxId", - e, - context = TAG - ) - } - } - private fun PaymentDirection.toPaymentType(): PaymentType = if (this == PaymentDirection.OUTBOUND) PaymentType.SENT else PaymentType.RECEIVED @@ -805,6 +802,394 @@ class ActivityService( } } + suspend fun handleOnchainTransactionReceived(txid: String, details: TransactionDetails) { + ServiceQueue.CORE.background { + runCatching { + val payments = lightningService.payments ?: run { + Logger.warn("No payments available for transaction $txid", context = TAG) + return@background + } + + val payment = payments.firstOrNull { payment -> + (payment.kind as? PaymentKind.Onchain)?.txid == txid + } ?: run { + Logger.warn("Payment not found for transaction $txid", context = TAG) + return@background + } + + processOnchainPayment( + kind = payment.kind as PaymentKind.Onchain, + payment = payment, + forceUpdate = false, + channelId = null, + transactionDetails = details, + ) + }.onFailure { e -> + Logger.error("Error handling onchain transaction received for $txid", e, context = TAG) + } + } + } + + suspend fun handleOnchainTransactionConfirmed(txid: String, details: TransactionDetails) { + ServiceQueue.CORE.background { + runCatching { + val payments = lightningService.payments ?: run { + Logger.warn("No payments available for transaction $txid", context = TAG) + return@background + } + + val payment = payments.firstOrNull { payment -> + (payment.kind as? PaymentKind.Onchain)?.txid == txid + } ?: run { + Logger.warn("Payment not found for transaction $txid", context = TAG) + return@background + } + + processOnchainPayment( + kind = payment.kind as PaymentKind.Onchain, + payment = payment, + forceUpdate = false, + channelId = null, + transactionDetails = details, + ) + }.onFailure { e -> + Logger.error("Error handling onchain transaction confirmed for $txid", e, context = TAG) + } + } + } + + suspend fun handleOnchainTransactionReplaced(txid: String, conflicts: List) { + ServiceQueue.CORE.background { + runCatching { + val replacedActivity = getOnchainActivityByTxId(txid) + markReplacedActivity(txid, replacedActivity, conflicts) + + for (conflictTxid in conflicts) { + val replacementActivity = getOrCreateReplacementActivity(conflictTxid) + if (replacementActivity != null && !replacementActivity.boostTxIds.contains(txid)) { + updateReplacementActivity(txid, conflictTxid, replacementActivity, replacedActivity) + } + } + }.onFailure { e -> + Logger.error("Error handling onchain transaction replaced for $txid", e, context = TAG) + } + } + } + + private suspend fun markReplacedActivity( + txid: String, + replacedActivity: OnchainActivity?, + conflicts: List, + ) { + if (replacedActivity != null) { + Logger.info( + "Transaction $txid replaced by ${conflicts.size} conflict(s): " + + conflicts.joinToString(", "), + context = TAG + ) + + val updatedActivity = replacedActivity.copy( + doesExist = false, + isBoosted = false, + updatedAt = System.currentTimeMillis().toULong() / 1000u + ) + updateActivity(replacedActivity.id, Activity.Onchain(updatedActivity)) + Logger.info("Marked transaction $txid as replaced", context = TAG) + } else { + Logger.info( + "Activity not found for replaced transaction $txid - " + + "will be created when transaction is processed", + context = TAG + ) + } + } + + private suspend fun getOrCreateReplacementActivity(conflictTxid: String): OnchainActivity? { + var replacementActivity = getOnchainActivityByTxId(conflictTxid) + + if (replacementActivity == null) { + val payments = lightningService.payments + val replacementPayment = payments?.firstOrNull { payment -> + (payment.kind as? PaymentKind.Onchain)?.txid == conflictTxid + } + + if (replacementPayment != null) { + Logger.info( + "Processing replacement transaction $conflictTxid that was already in payments list", + context = TAG + ) + val processResult = runCatching { + processOnchainPayment( + kind = replacementPayment.kind as PaymentKind.Onchain, + payment = replacementPayment, + forceUpdate = false, + channelId = null, + ) + getOnchainActivityByTxId(conflictTxid) + } + processResult.onFailure { e -> + Logger.error( + "Failed to process replacement transaction $conflictTxid", + e, + context = TAG + ) + } + replacementActivity = processResult.getOrNull() + } + } + + return replacementActivity + } + + private suspend fun updateReplacementActivity( + txid: String, + conflictTxid: String, + replacementActivity: OnchainActivity, + replacedActivity: OnchainActivity?, + ) { + val updatedActivity = replacementActivity.copy( + boostTxIds = replacementActivity.boostTxIds + txid, + isBoosted = true, + updatedAt = System.currentTimeMillis().toULong() / 1000u + ) + updateActivity(replacementActivity.id, Activity.Onchain(updatedActivity)) + + if (replacedActivity != null) { + copyTagsFromReplacedActivity(txid, conflictTxid, replacedActivity.id, replacementActivity.id) + } + + Logger.info("Updated replacement transaction $conflictTxid with boostTxId $txid", context = TAG) + } + + private suspend fun copyTagsFromReplacedActivity( + txid: String, + conflictTxid: String, + replacedActivityId: String, + replacementActivityId: String, + ) { + runCatching { + val replacedTags = tags(replacedActivityId) + if (replacedTags.isNotEmpty()) { + appendTags(replacementActivityId, replacedTags) + } + }.onFailure { e -> + Logger.error( + "Failed to copy tags from replaced transaction $txid " + + "to replacement transaction $conflictTxid", + e, + context = TAG + ) + } + } + + suspend fun handleOnchainTransactionReorged(txid: String) { + ServiceQueue.CORE.background { + runCatching { + val onchain = getOnchainActivityByTxId(txid) ?: run { + Logger.warn("Activity not found for reorged transaction $txid", context = TAG) + return@background + } + + val updatedActivity = onchain.copy( + confirmed = false, + confirmTimestamp = null, + updatedAt = System.currentTimeMillis().toULong() / 1000u + ) + + updateActivity(onchain.id, Activity.Onchain(updatedActivity)) + }.onFailure { e -> + Logger.error("Error handling onchain transaction reorged for $txid", e, context = TAG) + } + } + } + + suspend fun handleOnchainTransactionEvicted(txid: String) { + ServiceQueue.CORE.background { + runCatching { + val onchain = getOnchainActivityByTxId(txid) ?: run { + Logger.warn("Activity not found for evicted transaction $txid", context = TAG) + return@background + } + + val updatedActivity = onchain.copy( + doesExist = false, + updatedAt = System.currentTimeMillis().toULong() / 1000u + ) + + updateActivity(onchain.id, Activity.Onchain(updatedActivity)) + }.onFailure { e -> + Logger.error("Error handling onchain transaction evicted for $txid", e, context = TAG) + } + } + } + + suspend fun shouldShowReceivedSheet(txid: String, value: ULong): Boolean { + return ServiceQueue.CORE.background { + if (value == 0uL) { + return@background false + } + + if (findClosedChannelForTransaction(txid, null) != null) { + return@background false + } + + runCatching { + val onchain = getOnchainActivityByTxId(txid) ?: return@background true + + if (onchain.boostTxIds.isEmpty()) { + return@background true + } + + for (replacedTxid in onchain.boostTxIds) { + val replaced = getOnchainActivityByTxId(replacedTxid) + if (replaced != null && replaced.value == value) { + Logger.info( + "Skipping received sheet for replacement transaction $txid " + + "with same value as replaced transaction $replacedTxid", + context = TAG + ) + return@background false + } + } + }.onFailure { e -> + Logger.error("Failed to check existing activities for replacement", e, context = TAG) + } + + return@background true + } + } + + suspend fun getBoostTxDoesExist(boostTxIds: List): Map { + return ServiceQueue.CORE.background { + val doesExistMap = mutableMapOf() + for (boostTxId in boostTxIds) { + val boostActivity = getOnchainActivityByTxId(boostTxId) + if (boostActivity != null) { + doesExistMap[boostTxId] = boostActivity.doesExist + } + } + return@background doesExistMap + } + } + + suspend fun isCpfpChildTransaction(txId: String): Boolean { + return ServiceQueue.CORE.background { + val txIdsInBoostTxIds = getTxIdsInBoostTxIds() + if (!txIdsInBoostTxIds.contains(txId)) { + return@background false + } + + val activity = getOnchainActivityByTxId(txId) ?: return@background false + return@background activity.doesExist + } + } + + suspend fun getTxIdsInBoostTxIds(): Set { + return ServiceQueue.CORE.background { + val allOnchainActivities = get( + filter = ActivityFilter.ONCHAIN, + txType = null, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null + ) + + allOnchainActivities + .filterIsInstance() + .flatMap { it.v1.boostTxIds } + .toSet() + } + } + + private suspend fun findChannelForTransaction( + txid: String, + direction: PaymentDirection, + transactionDetails: TransactionDetails? = null, + ): String? { + return when (direction) { + PaymentDirection.INBOUND -> { + // Check if this transaction is a channel close by checking if it spends + // a closed channel's funding UTXO + findClosedChannelForTransaction(txid, transactionDetails) + } + PaymentDirection.OUTBOUND -> { + // Check if this transaction is a channel open by checking if it's + // the funding transaction for an open channel + findOpenChannelForTransaction(txid) + } + } + } + + private suspend fun findOpenChannelForTransaction(txid: String): String? { + val channels = lightningService.channels ?: return null + if (channels.isEmpty()) return null + + // First, check if the transaction matches any channel's funding transaction directly + val directMatch = channels.firstOrNull { channel -> + channel.fundingTxo?.txid == txid + } + if (directMatch != null) { + return directMatch.channelId + } + + // If no direct match, check Blocktank orders for payment transactions + return findChannelFromBlocktankOrders(txid, channels) + } + + private suspend fun findChannelFromBlocktankOrders( + txid: String, + channels: List, + ): String? { + return runCatching { + val blocktank = coreService.blocktank ?: return null + val orders = blocktank.orders(orderIds = null, filter = null, refresh = false) + val matchingOrder = orders.firstOrNull { order -> + order.payment?.onchain?.transactions?.any { transaction -> transaction.txId == txid } == true + } ?: return null + + val orderChannel = matchingOrder.channel ?: return null + channels.firstOrNull { channel -> + channel.fundingTxo?.txid == orderChannel.fundingTx.id + }?.channelId + }.onFailure { e -> + Logger.warn("Failed to fetch Blocktank orders: $e", context = TAG) + }.getOrNull() + } + + suspend fun findClosedChannelForTransaction(txid: String, transactionDetails: TransactionDetails? = null): String? { + return runCatching { + val closedChannelsList = closedChannels(SortDirection.DESC) + if (closedChannelsList.isEmpty()) { + return null + } + + // Use provided transaction details if available, otherwise try node + val details = transactionDetails ?: lightningService.getTransactionDetails(txid) ?: run { + Logger.warn("Transaction details not available for $txid", context = TAG) + return null + } + + for (input in details.inputs) { + val inputTxid = input.txid + val inputVout = input.vout.toInt() + + val matchingChannel = closedChannelsList.firstOrNull { channel -> + channel.fundingTxoTxid == inputTxid && channel.fundingTxoIndex == inputVout.toUInt() + } + + if (matchingChannel != null) { + return matchingChannel.channelId + } + } + null + }.onFailure { e -> + Logger.warn("Failed to check if transaction $txid spends closed channel funding UTXO", e, context = TAG) + }.getOrNull() + } + companion object { private const val TAG = "ActivityService" } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index cca66765b..870dfc8ba 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -694,6 +694,33 @@ class LightningService @Inject constructor( } // endregion + // region transaction details + suspend fun getTransactionDetails(txid: Txid): org.lightningdevkit.ldknode.TransactionDetails? { + val node = this.node ?: return null + return ServiceQueue.LDK.background { + try { + node.getTransactionDetails(txid) + } catch (e: Exception) { + Logger.error("Error getting transaction details by txid: $txid", e, context = TAG) + null + } + } + } + + suspend fun getAddressBalance(address: String): ULong { + val node = this.node ?: throw ServiceError.NodeNotSetup + return ServiceQueue.LDK.background { + try { + node.getAddressBalance(addressStr = address) + } catch (e: Exception) { + Logger.error("Error getting address balance for address: $address", e, context = TAG) + throw e + } + } + } + + // endregion + // region state val nodeId: String? get() = node?.nodeId() val balances: BalanceDetails? get() = node?.listBalances() diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 0df333178..d35438fdc 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -335,11 +335,6 @@ fun ContentView( val balance by walletViewModel.balanceState.collectAsStateWithLifecycle() val currencies by currencyViewModel.uiState.collectAsState() - LaunchedEffect(balance) { - // Anytime we receive a balance update, we should sync the payments to activity list - activityListViewModel.resync() - } - // Keep backups in sync LaunchedEffect(backupsViewModel) { backupsViewModel.observeAndSyncBackups() } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt index a8ea25a6b..064c0f6aa 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt @@ -31,7 +31,6 @@ import to.bitkit.services.LdkNodeEventBus import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.SideEffect import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.UiState import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.utils.AddressChecker import to.bitkit.utils.Logger import javax.inject.Inject @@ -45,7 +44,6 @@ class ExternalNodeViewModel @Inject constructor( private val settingsStore: SettingsStore, private val transferRepo: to.bitkit.repositories.TransferRepo, private val preActivityMetadataRepo: to.bitkit.repositories.PreActivityMetadataRepo, - private val addressChecker: AddressChecker, ) : ViewModel() { private val _uiState = MutableStateFlow(UiState()) val uiState = _uiState.asStateFlow() @@ -171,8 +169,9 @@ class ExternalNodeViewModel @Inject constructor( channelAmountSats = _uiState.value.amount.sats.toULong(), ).mapCatching { result -> awaitChannelPendingEvent(result.userChannelId).mapCatching { event -> - val txId = event.fundingTxo.txid - val address = addressChecker.getOutputAddress(event.fundingTxo).getOrDefault("") + val (txId, vout) = event.fundingTxo + val transactionDetails = lightningRepo.getTransactionDetails(txId).getOrNull() + val address = transactionDetails?.outputs?.getOrNull(vout.toInt())?.scriptpubkeyAddress ?: "" val feeRate = _uiState.value.customFeeRate ?: 0u preActivityMetadataRepo.savePreActivityMetadata( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index b2a2c369e..b3dbe153d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -44,7 +44,6 @@ import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import to.bitkit.R -import to.bitkit.ext.canBeBoosted import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.isBoosted import to.bitkit.ext.isSent @@ -103,9 +102,29 @@ fun ActivityDetailScreen( val tags by detailViewModel.tags.collectAsStateWithLifecycle() val boostSheetVisible by detailViewModel.boostSheetVisible.collectAsStateWithLifecycle() var showAddTagSheet by remember { mutableStateOf(false) } + var isCpfpChild by remember { mutableStateOf(false) } + var boostTxDoesExist by remember { mutableStateOf>(emptyMap()) } LaunchedEffect(item) { detailViewModel.setActivity(item) + if (item is Activity.Onchain) { + isCpfpChild = detailViewModel.isCpfpChildTransaction(item.v1.txId) + if (item.v1.boostTxIds.isNotEmpty()) { + boostTxDoesExist = detailViewModel.getBoostTxDoesExist(item.v1.boostTxIds) + } else { + boostTxDoesExist = emptyMap() + } + } else { + isCpfpChild = false + boostTxDoesExist = emptyMap() + } + } + + // Update boostTxDoesExist when boostTxIds change + LaunchedEffect(if (item is Activity.Onchain) item.v1.boostTxIds else emptyList()) { + if (item is Activity.Onchain && item.v1.boostTxIds.isNotEmpty()) { + boostTxDoesExist = detailViewModel.getBoostTxDoesExist(item.v1.boostTxIds) + } } val context = LocalContext.current @@ -117,7 +136,13 @@ fun ActivityDetailScreen( modifier = Modifier.background(Colors.Black) ) { AppTopBar( - titleText = stringResource(item.getScreenTitleRes()), + titleText = stringResource( + if (isCpfpChild) { + R.string.wallet__activity_boost_fee + } else { + item.getScreenTitleRes() + } + ), onBackClick = onBackClick, actions = { DrawerNavIcon() }, ) @@ -130,6 +155,8 @@ fun ActivityDetailScreen( onExploreClick = onExploreClick, onChannelClick = onChannelClick, detailViewModel = detailViewModel, + isCpfpChild = isCpfpChild, + boostTxDoesExist = boostTxDoesExist, onCopy = { text -> app.toast( type = Toast.ToastType.SUCCESS, @@ -200,6 +227,8 @@ private fun ActivityDetailContent( onExploreClick: (String) -> Unit, onChannelClick: ((String) -> Unit)?, detailViewModel: ActivityDetailViewModel? = null, + isCpfpChild: Boolean = false, + boostTxDoesExist: Map = emptyMap(), onCopy: (String) -> Unit, ) { val isLightning = item is Activity.Lightning @@ -273,7 +302,11 @@ private fun ActivityDetailContent( useSwipeToHide = false, modifier = Modifier.weight(1f) ) - ActivityIcon(activity = item, size = 48.dp) // TODO Display the user avatar when selfSend + ActivityIcon( + activity = item, + size = 48.dp, + isCpfpChild = isCpfpChild + ) // TODO Display the user avatar when selfSend } Spacer(modifier = Modifier.height(16.dp)) @@ -505,9 +538,28 @@ private fun ActivityDetailContent( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { + val hasCompletedBoost = when (item) { + is Activity.Lightning -> false + is Activity.Onchain -> { + val activity = item.v1 + if (activity.isBoosted && activity.boostTxIds.isNotEmpty()) { + val hasCPFP = activity.boostTxIds.any { boostTxDoesExist[it] == true } + if (hasCPFP) { + true + } else if (activity.txType == PaymentType.SENT) { + activity.boostTxIds.any { boostTxDoesExist[it] == false } + } else { + false + } + } else { + false + } + } + } + val shouldEnable = shouldEnableBoostButton(item, isCpfpChild, boostTxDoesExist) PrimaryButton( text = stringResource( - if (item.isBoosted()) { + if (hasCompletedBoost) { R.string.wallet__activity_boosted } else { R.string.wallet__activity_boost @@ -515,7 +567,7 @@ private fun ActivityDetailContent( ), size = ButtonSize.Small, onClick = onClickBoost, - enabled = item.canBeBoosted(), + enabled = shouldEnable, icon = { Icon( painter = painterResource(R.drawable.ic_timer_alt), @@ -528,8 +580,8 @@ private fun ActivityDetailContent( .weight(1f) .testTag( when { - item.isBoosted() -> "BoostedButton" - item.canBeBoosted() -> "BoostButton" + hasCompletedBoost -> "BoostedButton" + shouldEnable -> "BoostButton" else -> "BoostDisabled" } ) @@ -811,3 +863,46 @@ private fun PreviewSheetSmallScreen() { } } } + +@Composable +private fun shouldEnableBoostButton( + item: Activity, + isCpfpChild: Boolean, + boostTxDoesExist: Map +): Boolean { + if (item !is Activity.Onchain) return false + + val activity = item.v1 + + // Check all disable conditions + val shouldDisable = isCpfpChild || + !activity.doesExist || + activity.confirmed == true || + (activity.isBoosted && isBoostCompleted(activity, boostTxDoesExist)) + + if (shouldDisable) return false + + // Enable if not a transfer and has value + return !activity.isTransfer && activity.value > 0uL +} + +@Composable +private fun isBoostCompleted( + activity: OnchainActivity, + boostTxDoesExist: Map +): Boolean { + // If boostTxIds is empty, boost is in progress (RBF case) + if (activity.boostTxIds.isEmpty()) return true + + // Check if CPFP boost is completed + val hasCPFP = activity.boostTxIds.any { boostTxDoesExist[it] == true } + if (hasCPFP) return true + + // For sent transactions, check if RBF boost is completed + if (activity.txType == PaymentType.SENT) { + val hasRBF = activity.boostTxIds.any { boostTxDoesExist[it] == false } + if (hasRBF) return true + } + + return false +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt index 9f9e03e70..46639fc86 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt @@ -19,6 +19,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -35,9 +38,8 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType +import org.lightningdevkit.ldknode.TransactionDetails import to.bitkit.R -import to.bitkit.ext.BoostType -import to.bitkit.ext.boostType import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.isBoosted import to.bitkit.ext.isSent @@ -61,7 +63,6 @@ import to.bitkit.ui.utils.copyToClipboard import to.bitkit.ui.utils.getBlockExplorerUrl import to.bitkit.ui.utils.getScreenTitleRes import to.bitkit.ui.utils.localizedPlural -import to.bitkit.utils.TxDetails import to.bitkit.viewmodels.ActivityDetailViewModel import to.bitkit.viewmodels.ActivityListViewModel @@ -79,10 +80,14 @@ fun ActivityExploreScreen( val context = LocalContext.current val txDetails by detailViewModel.txDetails.collectAsStateWithLifecycle() + var boostTxDoesExist by remember { mutableStateOf>(emptyMap()) } LaunchedEffect(item) { if (item is Activity.Onchain) { detailViewModel.fetchTransactionDetails(item.v1.txId) + if (item.v1.boostTxIds.isNotEmpty()) { + boostTxDoesExist = detailViewModel.getBoostTxDoesExist(item.v1.boostTxIds) + } } else { detailViewModel.clearTransactionDetails() } @@ -103,6 +108,7 @@ fun ActivityExploreScreen( ActivityExploreContent( item = item, txDetails = txDetails, + boostTxDoesExist = boostTxDoesExist, onCopy = { text -> app.toast( type = Toast.ToastType.SUCCESS, @@ -122,7 +128,8 @@ fun ActivityExploreScreen( @Composable private fun ActivityExploreContent( item: Activity, - txDetails: TxDetails? = null, + txDetails: TransactionDetails? = null, + boostTxDoesExist: Map = emptyMap(), onCopy: (String) -> Unit = {}, onClickExplore: (String) -> Unit = {}, ) { @@ -155,6 +162,7 @@ private fun ActivityExploreContent( onchain = item, onCopy = onCopy, txDetails = txDetails, + boostTxDoesExist = boostTxDoesExist, ) Spacer(modifier = Modifier.weight(1f)) PrimaryButton( @@ -215,7 +223,8 @@ private fun LightningDetails( private fun ColumnScope.OnchainDetails( onchain: Activity.Onchain, onCopy: (String) -> Unit, - txDetails: TxDetails?, + txDetails: TransactionDetails?, + boostTxDoesExist: Map = emptyMap(), ) { val txId = onchain.v1.txId Section( @@ -231,22 +240,22 @@ private fun ColumnScope.OnchainDetails( ) if (txDetails != null) { Section( - title = localizedPlural(R.string.wallet__activity_input, mapOf("count" to txDetails.vin.size)), + title = localizedPlural(R.string.wallet__activity_input, mapOf("count" to txDetails.inputs.size)), valueContent = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - txDetails.vin.forEach { input -> + txDetails.inputs.forEach { input -> val text = "${input.txid}:${input.vout}" - BodySSB(text = text) + BodySSB(text = text, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) } } }, ) Section( - title = localizedPlural(R.string.wallet__activity_output, mapOf("count" to txDetails.vout.size)), + title = localizedPlural(R.string.wallet__activity_output, mapOf("count" to txDetails.outputs.size)), valueContent = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - txDetails.vout.forEach { output -> - val address = output.scriptpubkey_address.orEmpty() + txDetails.outputs.forEach { output -> + val address = output.scriptpubkeyAddress ?: "" BodySSB(text = address, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) } } @@ -267,8 +276,9 @@ private fun ColumnScope.OnchainDetails( // For RBF (SENT): shows parent transaction IDs that this replacement replaced val boostTxIds = onchain.v1.boostTxIds if (boostTxIds.isNotEmpty()) { - val isRbf = onchain.boostType() == BoostType.RBF boostTxIds.forEachIndexed { index, boostedTxId -> + val boostTxDoesExistValue = boostTxDoesExist[boostedTxId] ?: true + val isRbf = !boostTxDoesExistValue Section( title = stringResource( if (isRbf) R.string.wallet__activity_boosted_rbf else R.string.wallet__activity_boosted_cpfp diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt index 965b82245..dc677fc01 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt @@ -37,6 +37,7 @@ fun ActivityIcon( activity: Activity, modifier: Modifier = Modifier, size: Dp = 32.dp, + isCpfpChild: Boolean = false, ) { val isLightning = activity is Activity.Lightning val isBoosting = activity.isBoosting() @@ -45,7 +46,7 @@ fun ActivityIcon( val arrowIcon = painterResource(if (txType == PaymentType.SENT) R.drawable.ic_sent else R.drawable.ic_received) when { - isBoosting -> { + isCpfpChild || isBoosting -> { CircularIcon( icon = painterResource(R.drawable.ic_timer_alt), iconColor = Colors.Yellow, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index 7f0a7911e..0a4dc6a76 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -10,7 +10,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalInspectionMode @@ -35,6 +39,7 @@ import to.bitkit.ext.txType import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.LocalCurrencies +import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.CaptionB import to.bitkit.ui.currencyViewModel @@ -74,6 +79,17 @@ fun ActivityRow( } val isTransfer = item.isTransfer() + val activityListViewModel = activityListViewModel + var isCpfpChild by remember { mutableStateOf(false) } + + LaunchedEffect(item) { + if (item is Activity.Onchain && activityListViewModel != null) { + isCpfpChild = activityListViewModel.isCpfpChildTransaction(item.v1.txId) + } else { + isCpfpChild = false + } + } + Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -83,7 +99,7 @@ fun ActivityRow( .padding(16.dp) .testTag(testTag) ) { - ActivityIcon(activity = item, size = 40.dp) + ActivityIcon(activity = item, size = 40.dp, isCpfpChild = isCpfpChild) Spacer(modifier = Modifier.width(16.dp)) Column( verticalArrangement = Arrangement.spacedBy(4.dp), @@ -93,7 +109,8 @@ fun ActivityRow( txType = txType, isLightning = isLightning, status = status, - isTransfer = isTransfer + isTransfer = isTransfer, + isCpfpChild = isCpfpChild ) val subtitleText = when (item) { is Activity.Lightning -> item.v1.message.ifEmpty { formattedTime(timestamp) } @@ -101,6 +118,8 @@ fun ActivityRow( when { !item.v1.doesExist -> stringResource(R.string.wallet__activity_removed) + isCpfpChild -> stringResource(R.string.wallet__activity_boost_fee_description) + isTransfer && isSent -> if (item.v1.confirmed) { stringResource(R.string.wallet__activity_transfer_spending_done) } else { @@ -147,9 +166,11 @@ private fun TransactionStatusText( isLightning: Boolean, status: PaymentState?, isTransfer: Boolean, + isCpfpChild: Boolean = false, ) { when { isTransfer -> BodyMSB(text = stringResource(R.string.wallet__activity_transfer)) + isCpfpChild -> BodyMSB(text = stringResource(R.string.wallet__activity_boost_fee)) isLightning -> { when (txType) { PaymentType.SENT -> when (status) { diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt index 3a4aa77b4..c34f4d4dd 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt @@ -16,15 +16,15 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import to.bitkit.di.BgDispatcher import to.bitkit.models.AddressModel +import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo -import to.bitkit.utils.AddressChecker import to.bitkit.utils.Logger import javax.inject.Inject @HiltViewModel class AddressViewerViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val addressChecker: AddressChecker, + private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, ) : ViewModel() { @@ -167,13 +167,10 @@ class AddressViewerViewModel @Inject constructor( } suspend fun getBalanceForAddress(address: String): Result = withContext(bgDispatcher) { - return@withContext runCatching { - val utxos = addressChecker.getUtxosForAddress(address) - val balance = utxos.sumOf { it.value } - return@runCatching balance - }.onFailure { e -> - Logger.error("Error getting balance for address $address", e) - } + return@withContext lightningRepo.getAddressBalance(address).map { it.toLong() } + .onFailure { e -> + Logger.error("Error getting balance for address $address", e) + } } } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index e033af786..992f70a0a 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -76,7 +76,6 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.getBlockExplorerUrl import to.bitkit.ui.walletViewModel -import to.bitkit.utils.TxDetails import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -108,11 +107,20 @@ fun ChannelDetailScreen( } } + // Fetch activity timestamp for transfer activity with matching channel ID + LaunchedEffect(channel.details.channelId) { + channel.details.channelId?.let { channelId -> + viewModel.fetchActivityTimestamp(channelId) + } + } + + val txTime by viewModel.txTime.collectAsStateWithLifecycle() + Content( channel = channel, blocktankOrders = paidOrders.paidOrders, cjitEntries = paidOrders.cjitEntries, - txDetails = txDetails, + txTime = txTime, isRefreshing = uiState.isRefreshing, isClosedChannel = isClosedChannel, onBack = { navController.popBackStack() }, @@ -144,7 +152,7 @@ private fun Content( channel: ChannelUi, blocktankOrders: List = emptyList(), cjitEntries: List = emptyList(), - txDetails: TxDetails? = null, + txTime: ULong? = null, isRefreshing: Boolean = false, isClosedChannel: Boolean = false, onBack: () -> Unit = {}, @@ -375,17 +383,12 @@ private fun Content( ) val fundingTxId = channel.details.fundingTxo?.txid - val txTime = if (fundingTxId != null && txDetails?.txid == fundingTxId) { - txDetails.status.block_time - } else { - null - } txTime?.let { SectionRow( name = stringResource(R.string.lightning__opened_on), valueContent = { - CaptionB(text = formatUnixTimestamp(txTime)) + CaptionB(text = formatUnixTimestamp(txTime.toLong())) } ) } @@ -642,7 +645,6 @@ private fun PreviewOpenChannel() { isUsable = true, ), ), - txDetails = null, ) } } @@ -729,7 +731,6 @@ private fun PreviewChannelWithOrder() { createdAt = "2024-01-15T10:30:00.000Z" ) ), - txDetails = null, ) } } @@ -817,7 +818,6 @@ private fun PreviewPendingOrder() { createdAt = "2024-01-15T14:20:00.000Z" ) ), - txDetails = null, ) } } @@ -888,7 +888,6 @@ private fun PreviewExpiredOrder() { createdAt = "2024-01-14T11:45:00.000Z" ) ), - txDetails = null, ) } } @@ -962,7 +961,6 @@ private fun PreviewChannelWithCjit() { createdAt = "2024-01-16T11:30:00.000Z" ) ), - txDetails = null, ) } } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index e484e3174..43da56db3 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -4,9 +4,12 @@ import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.IBtOrder +import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -20,6 +23,7 @@ import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.OutPoint +import org.lightningdevkit.ldknode.TransactionDetails import to.bitkit.R import to.bitkit.di.BgDispatcher import to.bitkit.ext.amountOnClose @@ -35,9 +39,7 @@ import to.bitkit.repositories.LogsRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.LdkNodeEventBus import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.utils.AddressChecker import to.bitkit.utils.Logger -import to.bitkit.utils.TxDetails import javax.inject.Inject @Suppress("LongParameterList") @@ -48,7 +50,6 @@ class LightningConnectionsViewModel @Inject constructor( private val lightningRepo: LightningRepo, internal val blocktankRepo: BlocktankRepo, private val logsRepo: LogsRepo, - private val addressChecker: AddressChecker, private val ldkNodeEventBus: LdkNodeEventBus, private val walletRepo: WalletRepo, private val activityRepo: ActivityRepo, @@ -60,9 +61,12 @@ class LightningConnectionsViewModel @Inject constructor( private val _selectedChannel = MutableStateFlow(null) val selectedChannel = _selectedChannel.asStateFlow() - private val _txDetails = MutableStateFlow(null) + private val _txDetails = MutableStateFlow(null) val txDetails = _txDetails.asStateFlow() + private val _txTime = MutableStateFlow(null) + val txTime = _txTime.asStateFlow() + private val _closeConnectionUiState = MutableStateFlow(CloseConnectionUiState()) val closeConnectionUiState = _closeConnectionUiState.asStateFlow() @@ -388,18 +392,53 @@ class LightningConnectionsViewModel @Inject constructor( fun fetchTransactionDetails(txid: String) { viewModelScope.launch(bgDispatcher) { - try { - // TODO replace with bitkit-core method when available - _txDetails.value = addressChecker.getTransaction(txid) - Logger.debug("fetchTransactionDetails success for: '$txid'") - } catch (e: Exception) { - Logger.warn("fetchTransactionDetails error for: '$txid'", e) - _txDetails.value = null + runCatching { + val transactionDetails = lightningRepo.getTransactionDetails(txid).getOrNull() + _txDetails.update { transactionDetails } + if (transactionDetails != null) { + Logger.debug("fetchTransactionDetails success for: '$txid'", context = TAG) + } else { + Logger.warn("Transaction details not found for: '$txid'", context = TAG) + } + }.onFailure { e -> + Logger.warn("fetchTransactionDetails error for: '$txid'", e, context = TAG) + _txDetails.update { null } } } } - fun clearTransactionDetails() = _txDetails.update { null } + fun clearTransactionDetails() { + _txDetails.update { null } + _txTime.update { null } + } + + fun fetchActivityTimestamp(channelId: String) { + viewModelScope.launch(bgDispatcher) { + runCatching { + val activities = activityRepo.getActivities( + filter = ActivityFilter.ONCHAIN, + txType = PaymentType.SENT, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null + ).getOrNull() ?: emptyList() + + val transferActivity = activities.firstOrNull { activity -> + activity is Activity.Onchain && + activity.v1.isTransfer && + activity.v1.channelId == channelId + } as? Activity.Onchain + + _txTime.update { transferActivity?.v1?.confirmTimestamp ?: transferActivity?.v1?.timestamp } + }.onFailure { e -> + Logger.warn("fetchActivityTimestamp error for channelId: '$channelId'", e, context = TAG) + _txTime.update { null } + } + } + } fun clearCloseConnectionState() { _closeConnectionUiState.update { CloseConnectionUiState() } diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt index f5f78da26..50f226f42 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt @@ -3,7 +3,6 @@ package to.bitkit.ui.sheets import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.Activity -import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import dagger.hilt.android.lifecycle.HiltViewModel @@ -14,7 +13,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Txid -import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.ext.BoostType import to.bitkit.ext.boostType import to.bitkit.ext.nowTimestamp @@ -39,7 +37,6 @@ class BoostTransactionViewModel @Inject constructor( private val _boostTransactionEffect = MutableSharedFlow(extraBufferCapacity = 1) val boostTransactionEffect = _boostTransactionEffect.asSharedFlow() - // Configuration constants private companion object { const val TAG = "BoostTransactionViewModel" const val MAX_FEE_PERCENTAGE = 0.5 @@ -48,7 +45,6 @@ class BoostTransactionViewModel @Inject constructor( const val RBF_MIN_INCREASE = 2UL } - // State variables private var totalFeeSatsRecommended: ULong = 0U private var maxTotalFee: ULong = 0U private var feeRateRecommended: ULong = 0U @@ -281,9 +277,9 @@ class BoostTransactionViewModel @Inject constructor( } /** - * Updates activity based on boost type: - * - RBF: Updates current activity with boost data, then replaces with new transaction - * - CPFP: Updates the current activity and appends child txId to parent's boostTxIds + * Updates activity based on boost type. + * RBF: Updates current activity with boost data. Event handler will handle replacement. + * CPFP: Updates current activity and appends child txId to parent's boostTxIds. */ private suspend fun updateActivity(newTxId: Txid, isRBF: Boolean): Result { Logger.debug("Updating activity for txId: $newTxId. isRBF: $isRBF", context = TAG) @@ -292,7 +288,7 @@ class BoostTransactionViewModel @Inject constructor( ?: return Result.failure(Exception("Activity required")) return if (isRBF) { - handleRBFUpdate(newTxId, currentActivity) + handleRBFUpdate(currentActivity) } else { handleCPFPUpdate(currentActivity, newTxId) } @@ -300,7 +296,7 @@ class BoostTransactionViewModel @Inject constructor( /** * Handles CPFP (Child Pays For Parent) update by updating the current activity - * and appending the child transaction ID to the parent's boostTxIds + * and appending the child transaction ID to the parent's boostTxIds. */ private suspend fun handleCPFPUpdate(currentActivity: OnchainActivity, childTxId: Txid): Result { val updatedBoostTxIds = currentActivity.boostTxIds + childTxId @@ -319,15 +315,13 @@ class BoostTransactionViewModel @Inject constructor( } /** - * Handles RBF (Replace By Fee) update by updating current activity and replacing with new one - * For RBF, we need to store the parent txId (currentActivity.txId) so it can be added to - * the replacement activity's boostTxIds when it syncs + * Handles RBF (Replace By Fee) update by updating current activity to show boost status. + * The event handler (handleOnchainTransactionReplaced) will handle the replacement + * when the OnchainTransactionReplaced event fires. */ private suspend fun handleRBFUpdate( - newTxId: Txid, currentActivity: OnchainActivity, ): Result { - // First update the current activity to show boost status val updatedCurrentActivity = Activity.Onchain( v1 = currentActivity.copy( isBoosted = true, @@ -342,101 +336,7 @@ class BoostTransactionViewModel @Inject constructor( activity = updatedCurrentActivity ) - // Then find and replace with the new activity - return findAndReplaceWithNewActivity(newTxId, currentActivity.id, currentActivity.txId) - } - - /** - * Finds the new activity and replaces the old one, handling failures gracefully - */ - private suspend fun findAndReplaceWithNewActivity( - newTxId: Txid, - oldActivityId: String, - parentTxId: String, - ): Result { - return activityRepo.findActivityByPaymentId( - paymentHashOrTxId = newTxId, - type = ActivityFilter.ONCHAIN, - txType = PaymentType.SENT - ).fold( - onSuccess = { newActivity -> - replaceActivityWithNewOne(newActivity, oldActivityId, newTxId, parentTxId) - }, - onFailure = { error -> - handleActivityNotFound(error, newTxId, oldActivityId, parentTxId) - } - ) - } - - /** - * Replaces the old activity with the new boosted one - * For RBF, adds the parent txId to the new activity's boostTxIds - */ - private suspend fun replaceActivityWithNewOne( - newActivity: Activity, - oldActivityId: String, - newTxId: Txid, - parentTxId: String, - ): Result { - Logger.debug("Activity found: $newActivity", context = TAG) - - val newOnChainActivity = newActivity as? Activity.Onchain - ?: return Result.failure(Exception("Activity is not onchain type")) - - val updatedBoostTxIds = newOnChainActivity.v1.boostTxIds + parentTxId - val updatedNewActivity = Activity.Onchain( - v1 = newOnChainActivity.v1.copy( - isBoosted = true, - boostTxIds = updatedBoostTxIds, - feeRate = _uiState.value.feeRate, - updatedAt = nowTimestamp().toEpochMilli().toULong() - ) - ) - - return activityRepo.replaceActivity( - id = updatedNewActivity.v1.id, - activityIdToDelete = oldActivityId, - activity = updatedNewActivity, - ).onFailure { - cachePendingBoostActivity(newTxId, oldActivityId, parentTxId) - } - } - - /** - * Handles the case when new activity is not found by caching for later retry - */ - private suspend fun handleActivityNotFound( - error: Throwable, - newTxId: Txid, - oldActivityId: String?, - parentTxId: String, - ): Result { - Logger.error( - "Activity $newTxId not found. Caching data to try again on next sync", - e = error, - context = TAG - ) - - cachePendingBoostActivity(newTxId, oldActivityId, parentTxId) - return Result.failure(error) - } - - /** - * Caches activity data for pending boost operation - */ - private suspend fun cachePendingBoostActivity( - newTxId: Txid, - activityToDelete: String?, - parentTxId: String? = null - ) { - activityRepo.addActivityToPendingBoost( - PendingBoostActivity( - txId = newTxId, - updatedAt = nowTimestamp().toEpochMilli().toULong(), - activityToDelete = activityToDelete, - parentTxId = parentTxId - ) - ) + return Result.success(Unit) } private fun handleError(message: String, error: Throwable? = null) { diff --git a/app/src/main/java/to/bitkit/utils/AddressChecker.kt b/app/src/main/java/to/bitkit/utils/AddressChecker.kt deleted file mode 100644 index 9ec130630..000000000 --- a/app/src/main/java/to/bitkit/utils/AddressChecker.kt +++ /dev/null @@ -1,131 +0,0 @@ -package to.bitkit.utils - -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.get -import kotlinx.serialization.Serializable -import org.lightningdevkit.ldknode.OutPoint -import to.bitkit.env.Env -import javax.inject.Inject -import javax.inject.Singleton - -/** - * TEMPORARY IMPLEMENTATION - * This is a short-term solution for getting address information using electrs. - * Eventually, this will be replaced by similar features in bitkit-core or ldk-node - * when they support native address lookup. - */ -@Singleton -class AddressChecker @Inject constructor( - private val client: HttpClient, -) { - suspend fun getAddressInfo(address: String): AddressInfo { - try { - val response = client.get("${Env.esploraServerUrl}/address/$address") - - return response.body() - } catch (e: Exception) { - throw AddressCheckerError.NetworkError(e) - } - } - - suspend fun getTransaction(txid: String): TxDetails { - try { - val response = client.get("${Env.esploraServerUrl}/tx/$txid") - - return response.body() - } catch (e: Exception) { - throw AddressCheckerError.NetworkError(e) - } - } - - suspend fun getUtxosForAddress(address: String): List { - try { - val response = client.get("${Env.esploraServerUrl}/address/$address/utxo") - - return response.body>() - } catch (e: Exception) { - throw AddressCheckerError.NetworkError(e) - } - } - - suspend fun getOutputAddress(outPoint: OutPoint): Result = runCatching { - val (txid, vout) = outPoint - getTransaction(txid).vout.find { it.n == vout.toInt() }?.scriptpubkey_address ?: let { - throw AddressCheckerError.OutputNotFound("$txid:$vout").also { - Logger.warn("Failed to fetch funding address: ${it.message}", e = it) - } - } - } -} - -@Suppress("PropertyName") -@Serializable -data class AddressStats( - val funded_txo_count: Int, - val funded_txo_sum: Int, - val spent_txo_count: Int, - val spent_txo_sum: Int, - val tx_count: Int, -) - -@Suppress("PropertyName") -@Serializable -data class AddressInfo( - val address: String, - val chain_stats: AddressStats, - val mempool_stats: AddressStats, -) - -@Suppress("SpellCheckingInspection", "PropertyName") -@Serializable -data class TxInput( - val txid: String? = null, - val vout: Int? = null, - val prevout: TxOutput? = null, - val scriptsig: String? = null, - val scriptsig_asm: String? = null, - val witness: List? = null, - val is_coinbase: Boolean? = null, - val sequence: Long? = null, -) - -@Suppress("SpellCheckingInspection", "PropertyName") -@Serializable -data class TxOutput( - val scriptpubkey: String, - val scriptpubkey_asm: String? = null, - val scriptpubkey_type: String? = null, - val scriptpubkey_address: String? = null, - val value: Long, - val n: Int? = null, -) - -@Suppress("PropertyName") -@Serializable -data class TxStatus( - val confirmed: Boolean, - val block_height: Int? = null, - val block_hash: String? = null, - val block_time: Long? = null, -) - -@Serializable -data class TxDetails( - val txid: String, - val vin: List, - val vout: List, - val status: TxStatus, -) - -@Serializable -data class EsploraUtxo( - val txid: String, - val vout: Int, - val value: Long, -) - -sealed class AddressCheckerError(message: String? = null) : AppError(message) { - data class NetworkError(val error: Throwable) : AddressCheckerError(error.message) - data class OutputNotFound(val outpoint: String) : AddressCheckerError("Output not found: $outpoint") -} diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index 509a7a820..edef66bbc 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -11,25 +11,25 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.lightningdevkit.ldknode.TransactionDetails import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.ext.rawId import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo -import to.bitkit.utils.AddressChecker +import to.bitkit.repositories.LightningRepo import to.bitkit.utils.Logger -import to.bitkit.utils.TxDetails import javax.inject.Inject @HiltViewModel class ActivityDetailViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val addressChecker: AddressChecker, private val activityRepo: ActivityRepo, private val settingsStore: SettingsStore, private val blocktankRepo: BlocktankRepo, + private val lightningRepo: LightningRepo, ) : ViewModel() { - private val _txDetails = MutableStateFlow(null) + private val _txDetails = MutableStateFlow(null) val txDetails = _txDetails.asStateFlow() private val _tags = MutableStateFlow>(emptyList()) @@ -88,18 +88,18 @@ class ActivityDetailViewModel @Inject constructor( fun fetchTransactionDetails(txid: String) { viewModelScope.launch(bgDispatcher) { - try { - // TODO replace with bitkit-core method when available - _txDetails.value = addressChecker.getTransaction(txid) - } catch (e: Throwable) { + runCatching { + val transactionDetails = lightningRepo.getTransactionDetails(txid).getOrNull() + _txDetails.update { transactionDetails } + }.onFailure { e -> Logger.error("fetchTransactionDetails error", e, context = TAG) - _txDetails.value = null + _txDetails.update { null } } } } fun clearTransactionDetails() { - _txDetails.value = null + _txDetails.update { null } } fun onClickBoost() { @@ -110,6 +110,14 @@ class ActivityDetailViewModel @Inject constructor( _boostSheetVisible.update { false } } + suspend fun getBoostTxDoesExist(boostTxIds: List): Map { + return activityRepo.getBoostTxDoesExist(boostTxIds) + } + + suspend fun isCpfpChildTransaction(txId: String): Boolean { + return activityRepo.isCpfpChildTransaction(txId) + } + suspend fun findOrderForTransfer( channelId: String?, txId: String?, diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index bebd66a76..60615598c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -22,7 +22,6 @@ import kotlinx.coroutines.launch import to.bitkit.di.BgDispatcher import to.bitkit.ext.isTransfer import to.bitkit.repositories.ActivityRepo -import to.bitkit.services.LdkNodeEventBus import to.bitkit.ui.screens.wallets.activity.components.ActivityTab import to.bitkit.utils.Logger import javax.inject.Inject @@ -30,7 +29,6 @@ import javax.inject.Inject @HiltViewModel class ActivityListViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val ldkNodeEventBus: LdkNodeEventBus, private val activityRepo: ActivityRepo, ) : ViewModel() { private val _filteredActivities = MutableStateFlow?>(null) @@ -68,7 +66,6 @@ class ActivityListViewModel @Inject constructor( init { observeActivities() observeFilters() - observerNodeEvents() resync() } @@ -93,18 +90,12 @@ class ActivityListViewModel @Inject constructor( }.collect { _filteredActivities.value = it } } - private fun observerNodeEvents() = viewModelScope.launch { - ldkNodeEventBus.events.collect { - // TODO: resync only on specific events for better performance - resync() - } - } - private suspend fun refreshActivityState() { val all = activityRepo.getActivities(filter = ActivityFilter.ALL).getOrNull() ?: emptyList() - _latestActivities.value = all.take(SIZE_LATEST) - _lightningActivities.value = all.filter { it is Activity.Lightning } - _onchainActivities.value = all.filter { it is Activity.Onchain } + val filtered = filterOutReplacedSentTransactions(all) + _latestActivities.update { filtered.take(SIZE_LATEST) } + _lightningActivities.update { filtered.filter { it is Activity.Lightning } } + _onchainActivities.update { filtered.filter { it is Activity.Onchain } } } private suspend fun fetchFilteredActivities(filters: ActivityFilters): List? { @@ -126,10 +117,29 @@ class ActivityListViewModel @Inject constructor( return null } - return when (filters.tab) { + val filteredByTab = when (filters.tab) { ActivityTab.OTHER -> activities.filter { it.isTransfer() } else -> activities } + + return filterOutReplacedSentTransactions(filteredByTab) + } + + private suspend fun filterOutReplacedSentTransactions(activities: List): List { + val txIdsInBoostTxIds = activityRepo.getTxIdsInBoostTxIds() + + return activities.filter { activity -> + if (activity is Activity.Onchain) { + val onchain = activity.v1 + if (!onchain.doesExist && + onchain.txType == PaymentType.SENT && + txIdsInBoostTxIds.contains(onchain.txId) + ) { + return@filter false + } + } + true + } } fun updateAvailableTags() { @@ -156,6 +166,10 @@ class ActivityListViewModel @Inject constructor( activityRepo.removeAllActivities() } + suspend fun isCpfpChildTransaction(txId: String): Boolean { + return activityRepo.isCpfpChildTransaction(txId) + } + private fun Flow.stateInScope( initialValue: T, started: SharingStarted = SharingStarted.WhileSubscribed(MS_TIMEOUT_SUB), diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 19af300d4..b76035cdc 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -231,24 +231,23 @@ class AppViewModel @Inject constructor( ldkNodeEventBus.events.collect { event -> if (!walletRepo.walletExists()) return@collect - launch(bgDispatcher) { walletRepo.syncNodeAndWallet() } runCatching { when (event) { - is Event.BalanceChanged -> Unit + is Event.BalanceChanged -> handleBalanceChanged() + is Event.SyncCompleted -> handleSyncCompleted() is Event.ChannelClosed -> Unit is Event.ChannelPending -> Unit is Event.ChannelReady -> notifyChannelReady(event) - is Event.OnchainTransactionConfirmed -> Unit - is Event.OnchainTransactionEvicted -> notifyTransactionRemoved(event) - is Event.OnchainTransactionReceived -> notifyPaymentReceived(event) - is Event.OnchainTransactionReorged -> notifyTransactionUnconfirmed() - is Event.OnchainTransactionReplaced -> notifyTransactionReplaced(event) + is Event.OnchainTransactionConfirmed -> handleOnchainTransactionConfirmed(event) + is Event.OnchainTransactionEvicted -> handleOnchainTransactionEvicted(event) + is Event.OnchainTransactionReceived -> handleOnchainTransactionReceived(event) + is Event.OnchainTransactionReorged -> handleOnchainTransactionReorged(event) + is Event.OnchainTransactionReplaced -> handleOnchainTransactionReplaced(event) is Event.PaymentClaimable -> Unit - is Event.PaymentFailed -> notifyPaymentFailed() + is Event.PaymentFailed -> handlePaymentFailed(event) is Event.PaymentForwarded -> Unit - is Event.PaymentReceived -> notifyPaymentReceived(event) - is Event.PaymentSuccessful -> notifyPaymentSentOnLightning(event) - is Event.SyncCompleted -> Unit + is Event.PaymentReceived -> handlePaymentReceived(event) + is Event.PaymentSuccessful -> handlePaymentSuccessful(event) is Event.SyncProgress -> Unit } }.onFailure { e -> @@ -258,6 +257,90 @@ class AppViewModel @Inject constructor( } } + private fun handleBalanceChanged() { + viewModelScope.launch(bgDispatcher) { + walletRepo.syncBalances() + } + } + + private fun handleSyncCompleted() { + viewModelScope.launch(bgDispatcher) { + walletRepo.syncNodeAndWallet() + } + } + + private fun handleOnchainTransactionConfirmed(event: Event.OnchainTransactionConfirmed) { + viewModelScope.launch(bgDispatcher) { + activityRepo.handleOnchainTransactionConfirmed(event.txid, event.details) + } + } + + private fun handleOnchainTransactionEvicted(event: Event.OnchainTransactionEvicted) { + viewModelScope.launch(bgDispatcher) { + activityRepo.handleOnchainTransactionEvicted(event.txid) + } + notifyTransactionRemoved(event) + } + + private fun handleOnchainTransactionReceived(event: Event.OnchainTransactionReceived) { + viewModelScope.launch(bgDispatcher) { + activityRepo.handleOnchainTransactionReceived(event.txid, event.details) + } + if (event.details.amountSats > 0) { + val sats = event.details.amountSats.toULong() + viewModelScope.launch { + delay(DELAY_FOR_ACTIVITY_SYNC_MS) + val shouldShow = activityRepo.shouldShowReceivedSheet(event.txid, sats) + if (shouldShow) { + notifyPaymentReceived(event) + } + } + } + } + + private fun handleOnchainTransactionReorged(event: Event.OnchainTransactionReorged) { + viewModelScope.launch(bgDispatcher) { + activityRepo.handleOnchainTransactionReorged(event.txid) + } + notifyTransactionUnconfirmed() + } + + private fun handleOnchainTransactionReplaced(event: Event.OnchainTransactionReplaced) { + viewModelScope.launch(bgDispatcher) { + activityRepo.handleOnchainTransactionReplaced(event.txid, event.conflicts) + } + notifyTransactionReplaced(event) + } + + private fun handlePaymentFailed(event: Event.PaymentFailed) { + event.paymentHash?.let { paymentHash -> + viewModelScope.launch(bgDispatcher) { + activityRepo.handlePaymentEvent(paymentHash) + } + } + notifyPaymentFailed() + } + + private fun handlePaymentReceived(event: Event.PaymentReceived) { + event.paymentHash?.let { paymentHash -> + viewModelScope.launch(bgDispatcher) { + activityRepo.handlePaymentEvent(paymentHash) + } + } + notifyPaymentReceived(event) + } + + private fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { + event.paymentHash?.let { paymentHash -> + viewModelScope.launch(bgDispatcher) { + activityRepo.handlePaymentEvent(paymentHash) + } + } + viewModelScope.launch { + notifyPaymentSentOnLightning(event) + } + } + private fun notifyPaymentFailed() = toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.wallet__toast_payment_failed_title), @@ -1879,6 +1962,9 @@ class AppViewModel @Inject constructor( /**How long user needs to stay on the home screen before he see this prompt*/ private const val CHECK_DELAY_MILLIS = 2000L + + /** Delay to allow activity sync before checking if received sheet should be shown */ + private const val DELAY_FOR_ACTIVITY_SYNC_MS = 500L } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d23f4208c..63723f3fb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -995,6 +995,8 @@ Received Pending Failed + Boost Fee + Boosted incoming transaction Transfer From Spending (±{duration}) From Spending diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt index 334577b0f..ce119b840 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -97,8 +97,8 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { } @Test - fun `onchain payment returns ShowSheet when shouldShowPaymentReceived returns true`() = test { - whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(true) + fun `onchain payment returns ShowSheet when shouldShowReceivedSheet returns true`() = test { + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) val command = NotifyPaymentReceived.Command.Onchain(sats = 5000uL, paymentHashOrTxId = "txid456") val result = sut(command) @@ -114,8 +114,8 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { } @Test - fun `onchain payment returns Skip when shouldShowPaymentReceived is false`() = test { - whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(false) + fun `onchain payment returns Skip when shouldShowReceivedSheet is false`() = test { + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(false) val command = NotifyPaymentReceived.Command.Onchain(sats = 5000uL, paymentHashOrTxId = "txid456") val result = sut(command) @@ -126,21 +126,21 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { } @Test - fun `onchain payment calls shouldShowPaymentReceived with correct parameters`() = test { - whenever(activityRepo.shouldShowPaymentReceived(any(), any())).thenReturn(true) + fun `onchain payment calls shouldShowReceivedSheet with correct parameters`() = test { + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) val command = NotifyPaymentReceived.Command.Onchain(sats = 7500uL, paymentHashOrTxId = "txid789") sut(command) - verify(activityRepo).shouldShowPaymentReceived("txid789", 7500uL) + verify(activityRepo).shouldShowReceivedSheet("txid789", 7500uL) } @Test - fun `lightning payment does not call shouldShowPaymentReceived`() = test { + fun `lightning payment does not call shouldShowReceivedSheet`() = test { val command = NotifyPaymentReceived.Command.Lightning(sats = 1000uL, paymentHashOrTxId = "hash123") sut(command) - verify(activityRepo, never()).shouldShowPaymentReceived(any(), any()) + verify(activityRepo, never()).shouldShowReceivedSheet(any(), any()) } } diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index d958a40d3..02afdb72b 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -9,7 +9,6 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import to.bitkit.data.SettingsStore import to.bitkit.test.BaseUnitTest -import to.bitkit.utils.AddressChecker import to.bitkit.viewmodels.ActivityDetailViewModel import kotlin.test.assertEquals import kotlin.test.assertNull @@ -19,7 +18,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { private val activityRepo = mock() private val blocktankRepo = mock() private val settingsStore = mock() - private val addressChecker = mock() + private val lightningRepo = mock() private lateinit var sut: ActivityDetailViewModel @@ -32,7 +31,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { activityRepo = activityRepo, blocktankRepo = blocktankRepo, settingsStore = settingsStore, - addressChecker = addressChecker, + lightningRepo = lightningRepo, ) } diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 6e27059d1..87fb93a98 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -27,7 +27,6 @@ import to.bitkit.data.CacheStore import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest -import to.bitkit.utils.AddressChecker import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull @@ -41,7 +40,6 @@ class ActivityRepoTest : BaseUnitTest() { private val blocktankRepo = mock() private val transferRepo = mock() private val cacheStore = mock() - private val addressChecker = mock() private val clock = mock() private lateinit var sut: ActivityRepo @@ -135,7 +133,6 @@ class ActivityRepoTest : BaseUnitTest() { coreService = coreService, lightningRepo = lightningRepo, blocktankRepo = blocktankRepo, - addressChecker = addressChecker, cacheStore = cacheStore, transferRepo = transferRepo, clock = clock, @@ -315,24 +312,17 @@ class ActivityRepoTest : BaseUnitTest() { } @Test - fun `replaceActivity updates and marks old activity as removed from mempool`() = test { + fun `replaceActivity updates activity and copies tags`() = test { val activityId = "activity123" val activityToDeleteId = "activity456" val tagsMock = listOf("tag1", "tag2") val cacheData = AppCacheData(deletedActivities = emptyList()) whenever(cacheStore.data).thenReturn(flowOf(cacheData)) - // Mock the activity to be marked as removed (must be Onchain) - val onchainActivityToDelete = createOnchainActivity(id = activityToDeleteId, txId = "tx123") - // Mock update for the new activity wheneverBlocking { coreService.activity.update(activityId, testActivity) }.thenReturn(Unit) // Mock getActivity to return the new activity (for addTagsToActivity check) wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) - // Mock getActivity to return the onchain activity to be marked as removed - wheneverBlocking { coreService.activity.getActivity(activityToDeleteId) }.thenReturn(onchainActivityToDelete) - // Mock update for the old activity (with doesExist=false) - wheneverBlocking { coreService.activity.update(eq(activityToDeleteId), any()) }.thenReturn(Unit) // Mock tags retrieval from the old activity wheneverBlocking { coreService.activity.tags(activityToDeleteId) }.thenReturn(tagsMock) // Mock tags retrieval from the new activity (should be empty so all tags are considered new) @@ -345,19 +335,10 @@ class ActivityRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) // Verify the new activity is updated verify(coreService.activity).update(activityId, testActivity) - // Verify the old activity is retrieved - verify(coreService.activity).getActivity(activityToDeleteId) // Verify tags are retrieved from the old activity verify(coreService.activity).tags(activityToDeleteId) // Verify tags are added to the new activity verify(coreService.activity).appendTags(activityId, tagsMock) - // Verify the old activity is updated (marked as removed from mempool with doesExist=false) - verify(coreService.activity).update( - eq(activityToDeleteId), - argThat { activity -> - activity is Activity.Onchain && !activity.v1.doesExist - } - ) // Verify delete is NOT called verify(coreService.activity, never()).delete(any()) // Verify addActivityToDeletedList is NOT called @@ -573,50 +554,6 @@ class ActivityRepoTest : BaseUnitTest() { verify(cacheStore).addActivityToPendingBoost(pendingBoost) } - @Test - fun `markActivityAsRemovedFromMempool successfully marks onchain activity as removed`() = test { - val activityId = "activity456" - val onchainActivity = createOnchainActivity( - id = activityId, - txId = "tx123", - doesExist = true // Initially exists - ) - - val cacheData = AppCacheData(activitiesPendingDelete = listOf(activityId)) - setupSyncActivitiesMocks(cacheData) - wheneverBlocking { - coreService.activity.get( - filter = anyOrNull(), - txType = anyOrNull(), - tags = anyOrNull(), - search = anyOrNull(), - minDate = anyOrNull(), - maxDate = anyOrNull(), - limit = anyOrNull(), - sortDirection = anyOrNull() - ) - }.thenReturn(emptyList()) - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(onchainActivity) - wheneverBlocking { coreService.activity.update(eq(activityId), any()) }.thenReturn(Unit) - wheneverBlocking { cacheStore.removeActivityFromPendingDelete(activityId) }.thenReturn(Unit) - - val result = sut.syncActivities() - - assertTrue(result.isSuccess) - // Verify the activity was marked as removed (doesExist = false) - verify(coreService.activity).update( - eq(activityId), - argThat { activity -> - activity is Activity.Onchain && - !activity.v1.doesExist && - activity.v1.id == activityId && - activity.v1.txId == "tx123" - } - ) - // Verify it was removed from pending delete after successful marking - verify(cacheStore).removeActivityFromPendingDelete(activityId) - } - @Test fun `boostPendingActivities adds parentTxId to boostTxIds when parentTxId is provided`() = test { val txId = "tx123" @@ -785,16 +722,6 @@ class ActivityRepoTest : BaseUnitTest() { updatedAt = 1000uL ) - val onchainActivityToDelete = createOnchainActivity( - id = activityToDeleteId, - txId = "oldTx123", - value = 500uL, - fee = 50uL, - feeRate = 5uL, - address = "bc1old", - timestamp = 1234560000uL - ) - val pendingBoost = PendingBoostActivity( txId = txId, updatedAt = updatedAt, @@ -816,26 +743,22 @@ class ActivityRepoTest : BaseUnitTest() { sortDirection = anyOrNull() ) }.thenReturn(listOf(existingActivity)) + val tagsToCopy = listOf("tag1", "tag2") wheneverBlocking { coreService.activity.update(eq(activityId), any()) }.thenReturn(Unit) wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(existingActivity) - wheneverBlocking { coreService.activity.getActivity(activityToDeleteId) }.thenReturn(onchainActivityToDelete) - wheneverBlocking { coreService.activity.update(eq(activityToDeleteId), any()) }.thenReturn(Unit) - wheneverBlocking { coreService.activity.tags(activityToDeleteId) }.thenReturn(emptyList()) + wheneverBlocking { coreService.activity.tags(activityToDeleteId) }.thenReturn(tagsToCopy) wheneverBlocking { coreService.activity.tags(activityId) }.thenReturn(emptyList()) + wheneverBlocking { coreService.activity.appendTags(activityId, tagsToCopy) }.thenReturn(Result.success(Unit)) wheneverBlocking { cacheStore.removeActivityFromPendingBoost(pendingBoost) }.thenReturn(Unit) val result = sut.syncActivities() assertTrue(result.isSuccess) - // Verify replaceActivity was called (indirectly by checking both activities were updated) + // Verify replaceActivity was called (indirectly by checking the new activity was updated) verify(coreService.activity).update(eq(activityId), any()) - // Verify the old activity was marked as removed (doesExist = false) - verify(coreService.activity).update( - eq(activityToDeleteId), - argThat { activity -> - activity is Activity.Onchain && !activity.v1.doesExist - } - ) + // Verify tags were copied from old activity to new activity + verify(coreService.activity).tags(activityToDeleteId) + verify(coreService.activity).appendTags(activityId, tagsToCopy) verify(cacheStore).removeActivityFromPendingBoost(pendingBoost) } @@ -882,59 +805,4 @@ class ActivityRepoTest : BaseUnitTest() { // Verify pending boost was removed (skipped) verify(cacheStore).removeActivityFromPendingBoost(pendingBoost) } - - @Test - fun `markActivityAsRemovedFromMempool fails when activity not found`() = test { - val activityId = "activity456" - val cacheData = AppCacheData(activitiesPendingDelete = listOf(activityId)) - setupSyncActivitiesMocks(cacheData) - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(null) - - val result = sut.syncActivities() - - assertTrue(result.isSuccess) - // Verify update was NOT called (activity not found) - verify(coreService.activity, never()).update(eq(activityId), any()) - // Verify it was NOT removed from pending delete (operation failed, will retry next sync) - verify(cacheStore, never()).removeActivityFromPendingDelete(activityId) - } - - @Test - fun `markActivityAsRemovedFromMempool fails when activity is not Onchain`() = test { - val activityId = "activity456" - val lightningActivity = testActivity - val cacheData = AppCacheData(activitiesPendingDelete = listOf(activityId)) - setupSyncActivitiesMocks(cacheData) - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(lightningActivity) - - val result = sut.syncActivities() - - assertTrue(result.isSuccess) - // Verify update was NOT called (Lightning activities can't be marked as removed) - verify(coreService.activity, never()).update(eq(activityId), any()) - // Verify it was NOT removed from pending delete (operation failed, will retry next sync) - verify(cacheStore, never()).removeActivityFromPendingDelete(activityId) - } - - @Test - fun `replaceActivity caches to pending delete when markActivityAsRemovedFromMempool fails`() = test { - val activityId = "activity123" - val activityToDeleteId = "activity456" - val cacheData = AppCacheData(deletedActivities = emptyList()) - whenever(cacheStore.data).thenReturn(flowOf(cacheData)) - - // Activity to delete doesn't exist (will cause markActivityAsRemovedFromMempool to fail) - wheneverBlocking { coreService.activity.update(activityId, testActivity) }.thenReturn(Unit) - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) - wheneverBlocking { coreService.activity.getActivity(activityToDeleteId) }.thenReturn(null) - wheneverBlocking { coreService.activity.tags(activityToDeleteId) }.thenReturn(emptyList()) - wheneverBlocking { coreService.activity.tags(activityId) }.thenReturn(emptyList()) - wheneverBlocking { cacheStore.addActivityToPendingDelete(activityToDeleteId) }.thenReturn(Unit) - - val result = sut.replaceActivity(activityId, activityToDeleteId, testActivity) - - assertTrue(result.isSuccess) - // Verify it was added to pending delete when marking failed - verify(cacheStore).addActivityToPendingDelete(activityToDeleteId) - } } diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 9ba73cfac..3aec48ba8 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -26,9 +26,6 @@ import to.bitkit.services.OnchainService import to.bitkit.test.BaseUnitTest import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.usecases.WipeWalletUseCase -import to.bitkit.utils.AddressChecker -import to.bitkit.utils.AddressInfo -import to.bitkit.utils.AddressStats import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -42,7 +39,6 @@ class WalletRepoTest : BaseUnitTest() { private val coreService = mock() private val onchainService = mock() private val settingsStore = mock() - private val addressChecker = mock() private val lightningRepo = mock() private val cacheStore = mock() private val preActivityMetadataRepo = mock() @@ -87,7 +83,6 @@ class WalletRepoTest : BaseUnitTest() { keychain = keychain, coreService = coreService, settingsStore = settingsStore, - addressChecker = addressChecker, lightningRepo = lightningRepo, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, @@ -153,7 +148,6 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `refreshBip21 should generate new address when current is empty`() = test { whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) - whenever(addressChecker.getAddressInfo(any())).thenReturn(mock()) val result = sut.refreshBip21() @@ -165,7 +159,6 @@ class WalletRepoTest : BaseUnitTest() { fun `refreshBip21 should set receiveOnSpendingBalance false when shouldBlockLightning is true`() = test { wheneverBlocking { coreService.checkGeoBlock() }.thenReturn(Pair(true, true)) whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) - whenever(addressChecker.getAddressInfo(any())).thenReturn(mock()) val result = sut.refreshBip21() @@ -177,7 +170,6 @@ class WalletRepoTest : BaseUnitTest() { fun `refreshBip21 should set receiveOnSpendingBalance true when shouldBlockLightning is false`() = test { wheneverBlocking { coreService.checkGeoBlock() }.thenReturn(Pair(true, false)) whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) - whenever(addressChecker.getAddressInfo(any())).thenReturn(mock()) val result = sut.refreshBip21() @@ -190,14 +182,7 @@ class WalletRepoTest : BaseUnitTest() { val testAddress = "testAddress" whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = testAddress))) whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) - whenever(addressChecker.getAddressInfo(any())).thenReturn( - mockAddressInfo().let { addressInfo -> - addressInfo.copy( - chain_stats = addressInfo.chain_stats.copy(tx_count = 5), - mempool_stats = addressInfo.mempool_stats.copy(tx_count = 5) - ) - } - ) + wheneverBlocking { coreService.isAddressUsed(any()) }.thenReturn(true) val result = sut.refreshBip21() @@ -209,7 +194,7 @@ class WalletRepoTest : BaseUnitTest() { fun `refreshBip21 should keep address when current has no transactions`() = test { val existingAddress = "existingAddress" whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = existingAddress))) - whenever(addressChecker.getAddressInfo(any())).thenReturn(mockAddressInfo()) + wheneverBlocking { coreService.isAddressUsed(any()) }.thenReturn(false) sut = createSut() sut.loadFromCache() @@ -649,13 +634,7 @@ class WalletRepoTest : BaseUnitTest() { fun `refreshBip21ForEvent PaymentReceived should refresh address if used`() = test { val testAddress = "testAddress" whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = testAddress))) - whenever(addressChecker.getAddressInfo(any())).thenReturn( - mockAddressInfo().let { addressInfo -> - addressInfo.copy( - chain_stats = addressInfo.chain_stats.copy(tx_count = 1) - ) - } - ) + wheneverBlocking { coreService.isAddressUsed(any()) }.thenReturn(true) whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) sut = createSut() sut.loadFromCache() @@ -676,7 +655,7 @@ class WalletRepoTest : BaseUnitTest() { fun `refreshBip21ForEvent PaymentReceived should not refresh address if not used`() = test { val testAddress = "testAddress" whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = testAddress))) - whenever(addressChecker.getAddressInfo(any())).thenReturn(mockAddressInfo()) + wheneverBlocking { coreService.isAddressUsed(any()) }.thenReturn(false) sut = createSut() sut.loadFromCache() @@ -712,21 +691,3 @@ class WalletRepoTest : BaseUnitTest() { assertTrue(result.isFailure) } } - -private fun mockAddressInfo() = AddressInfo( - address = "testAddress", - chain_stats = AddressStats( - funded_txo_count = 1, - funded_txo_sum = 2, - spent_txo_count = 1, - spent_txo_sum = 1, - tx_count = 0 - ), - mempool_stats = AddressStats( - funded_txo_count = 1, - funded_txo_sum = 2, - spent_txo_count = 1, - spent_txo_sum = 1, - tx_count = 0 - ) -) From e90e413b7c35fa80fa9dfd30ad4b248d5b822e4e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 28 Nov 2025 16:40:51 +0100 Subject: [PATCH 10/32] chore: cleanup --- .../java/to/bitkit/services/CoreService.kt | 25 ++++--------------- .../to/bitkit/services/LightningService.kt | 3 ++- .../wallets/activity/ActivityDetailScreen.kt | 18 ++++++------- .../activity/components/ActivityRow.kt | 6 ++--- 4 files changed, 19 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 0d41d69b1..cb47f0e27 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -38,7 +38,6 @@ import com.synonym.bitkitcore.getOrders import com.synonym.bitkitcore.getTags import com.synonym.bitkitcore.initDb import com.synonym.bitkitcore.insertActivity -import com.synonym.bitkitcore.isAddressUsed import com.synonym.bitkitcore.openChannel import com.synonym.bitkitcore.refreshActiveCjitEntries import com.synonym.bitkitcore.refreshActiveOrders @@ -366,20 +365,6 @@ class ActivityService( getAllClosedChannels(sortDirection) } - /** - * Maps all `PaymentDetails` from LDK Node to bitkit-core [Activity] records. - * - * Payments are parallelly processed in chunks, handling both on-chain and Lightning payments - * to create new activity records or updating existing ones based on the payment's status and details. - * - * It's designed to be idempotent, meaning it can be called multiple times with the same payment - * list without creating duplicate entries. It checks the `updatedAt` timestamp to avoid overwriting - * newer local data with older data from LDK. - * - * @param payments The list of `PaymentDetails` from the LDK node to be processed. - * @param forceUpdate If true, it will also update activities previously marked as deleted. - * @param channelIdsByTxId Map of transaction IDs to channel IDs for identifying transfer activities. - */ suspend fun handlePaymentEvent(paymentHash: String) { ServiceQueue.CORE.background { val payments = lightningService.payments ?: run { @@ -555,7 +540,7 @@ class ActivityService( val timestamp: ULong, ) - private suspend fun getConfirmationStatus( + private fun getConfirmationStatus( kind: PaymentKind.Onchain, timestamp: ULong, ): ConfirmationData { @@ -575,7 +560,7 @@ class ActivityService( return ConfirmationData(isConfirmed, confirmedTimestamp, timestamp) } - private suspend fun buildUpdatedOnchainActivity( + private fun buildUpdatedOnchainActivity( existingActivity: Activity.Onchain, confirmationData: ConfirmationData, channelId: String? = null, @@ -602,7 +587,7 @@ class ActivityService( return updatedOnChain } - private suspend fun buildNewOnchainActivity( + private fun buildNewOnchainActivity( payment: PaymentDetails, kind: PaymentKind.Onchain, confirmationData: ConfirmationData, @@ -876,7 +861,7 @@ class ActivityService( } } - private suspend fun markReplacedActivity( + private fun markReplacedActivity( txid: String, replacedActivity: OnchainActivity?, conflicts: List, @@ -1144,7 +1129,7 @@ class ActivityService( channels: List, ): String? { return runCatching { - val blocktank = coreService.blocktank ?: return null + val blocktank = coreService.blocktank val orders = blocktank.orders(orderIds = null, filter = null, refresh = false) val matchingOrder = orders.firstOrNull { order -> order.payment?.onchain?.transactions?.any { transaction -> transaction.txId == txid } == true diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 870dfc8ba..ea0d559ef 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -33,6 +33,7 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.SpendableUtxo +import org.lightningdevkit.ldknode.TransactionDetails import org.lightningdevkit.ldknode.Txid import org.lightningdevkit.ldknode.defaultConfig import to.bitkit.async.BaseCoroutineScope @@ -695,7 +696,7 @@ class LightningService @Inject constructor( // endregion // region transaction details - suspend fun getTransactionDetails(txid: Txid): org.lightningdevkit.ldknode.TransactionDetails? { + suspend fun getTransactionDetails(txid: Txid): TransactionDetails? { val node = this.node ?: return null return ServiceQueue.LDK.background { try { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index b3dbe153d..4d312a7df 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -45,7 +46,6 @@ import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import to.bitkit.R import to.bitkit.ext.ellipsisMiddle -import to.bitkit.ext.isBoosted import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer import to.bitkit.ext.rawId @@ -109,10 +109,10 @@ fun ActivityDetailScreen( detailViewModel.setActivity(item) if (item is Activity.Onchain) { isCpfpChild = detailViewModel.isCpfpChildTransaction(item.v1.txId) - if (item.v1.boostTxIds.isNotEmpty()) { - boostTxDoesExist = detailViewModel.getBoostTxDoesExist(item.v1.boostTxIds) + boostTxDoesExist = if (item.v1.boostTxIds.isNotEmpty()) { + detailViewModel.getBoostTxDoesExist(item.v1.boostTxIds) } else { - boostTxDoesExist = emptyMap() + emptyMap() } } else { isCpfpChild = false @@ -864,20 +864,19 @@ private fun PreviewSheetSmallScreen() { } } +@ReadOnlyComposable @Composable private fun shouldEnableBoostButton( item: Activity, isCpfpChild: Boolean, - boostTxDoesExist: Map + boostTxDoesExist: Map, ): Boolean { if (item !is Activity.Onchain) return false val activity = item.v1 // Check all disable conditions - val shouldDisable = isCpfpChild || - !activity.doesExist || - activity.confirmed == true || + val shouldDisable = isCpfpChild || !activity.doesExist || activity.confirmed || (activity.isBoosted && isBoostCompleted(activity, boostTxDoesExist)) if (shouldDisable) return false @@ -886,10 +885,11 @@ private fun shouldEnableBoostButton( return !activity.isTransfer && activity.value > 0uL } +@ReadOnlyComposable @Composable private fun isBoostCompleted( activity: OnchainActivity, - boostTxDoesExist: Map + boostTxDoesExist: Map, ): Boolean { // If boostTxIds is empty, boost is in progress (RBF case) if (activity.boostTxIds.isEmpty()) return true diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index 0a4dc6a76..a71673f4d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -83,10 +83,10 @@ fun ActivityRow( var isCpfpChild by remember { mutableStateOf(false) } LaunchedEffect(item) { - if (item is Activity.Onchain && activityListViewModel != null) { - isCpfpChild = activityListViewModel.isCpfpChildTransaction(item.v1.txId) + isCpfpChild = if (item is Activity.Onchain && activityListViewModel != null) { + activityListViewModel.isCpfpChildTransaction(item.v1.txId) } else { - isCpfpChild = false + false } } From b25ad35d57715b7b701cc7d13c5f72dfdacf4857 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 28 Nov 2025 16:44:48 +0100 Subject: [PATCH 11/32] chore: lint --- app/src/main/java/to/bitkit/services/CoreService.kt | 1 + .../ui/screens/wallets/activity/components/ActivityRow.kt | 1 + .../ui/settings/lightning/LightningConnectionsViewModel.kt | 2 +- .../main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt | 1 + app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt | 1 + 5 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index cb47f0e27..a225a5395 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -204,6 +204,7 @@ class CoreService @Inject constructor( // region Activity private const val CHUNK_SIZE = 50 +@Suppress("LargeClass") class ActivityService( @Suppress("unused") private val coreService: CoreService, // used to ensure CoreService inits first private val cacheStore: CacheStore, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index a71673f4d..a7e330f1f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -160,6 +160,7 @@ fun ActivityRow( } } +@Suppress("CyclomaticComplexMethod") @Composable private fun TransactionStatusText( txType: PaymentType, diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index 43da56db3..723b28a25 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -42,7 +42,7 @@ import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel class LightningConnectionsViewModel @Inject constructor( @ApplicationContext private val context: Context, diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index edef66bbc..d7bfc3797 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -21,6 +21,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.utils.Logger import javax.inject.Inject +@Suppress("TooManyFunctions") @HiltViewModel class ActivityDetailViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index 60615598c..34ab68295 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -26,6 +26,7 @@ import to.bitkit.ui.screens.wallets.activity.components.ActivityTab import to.bitkit.utils.Logger import javax.inject.Inject +@Suppress("TooManyFunctions") @HiltViewModel class ActivityListViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, From 815af81f05f4c73aa6eb6ecab68dfa0f734a5b63 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 28 Nov 2025 10:45:44 -0500 Subject: [PATCH 12/32] Remove unused istransfer --- app/src/main/java/to/bitkit/services/CoreService.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index a225a5395..cd7801e07 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -645,10 +645,9 @@ class ActivityService( } var resolvedChannelId = channelId - var isTransfer = existingOnchain?.isTransfer ?: false // Check if this transaction is a channel transfer - if (resolvedChannelId == null || !isTransfer) { + if (resolvedChannelId == null) { val foundChannelId = findChannelForTransaction( txid = kind.txid, direction = payment.direction, @@ -656,7 +655,6 @@ class ActivityService( ) if (foundChannelId != null) { resolvedChannelId = foundChannelId - isTransfer = true } } From eb60e68833cbea6a91039a67d83c028b5a8d8d6b Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 28 Nov 2025 16:44:20 +0100 Subject: [PATCH 13/32] fix: wait for updated bolt11 before closing receive sheet --- .../wallets/receive/EditInvoiceScreen.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt index 660d311a1..6d470769a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt @@ -27,7 +27,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -40,6 +42,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull import to.bitkit.R import to.bitkit.repositories.CurrencyState import to.bitkit.repositories.WalletState @@ -66,6 +72,7 @@ import to.bitkit.ui.utils.keyboardAsState import to.bitkit.utils.Logger import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.previewAmountInputViewModel +import kotlin.time.Duration.Companion.seconds @Suppress("ViewModelForwarding") @Composable @@ -85,6 +92,7 @@ fun EditInvoiceScreen( var keyboardVisible by remember { mutableStateOf(false) } var isSoftKeyboardVisible by keyboardAsState() val amountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() + val latestWalletState = rememberUpdatedState(walletUiState) LaunchedEffect(Unit) { editInvoiceVM.editInvoiceEffect.collect { effect -> @@ -116,7 +124,17 @@ fun EditInvoiceScreen( } EditInvoiceVM.EditInvoiceScreenEffects.UpdateInvoice -> { + val previousBolt11 = latestWalletState.value.bolt11 updateInvoice(receiveSats) + val updated = withTimeoutOrNull(5.seconds) { + snapshotFlow { latestWalletState.value.bolt11 } + .distinctUntilChanged() + .filter { it.isNotEmpty() && it != previousBolt11 } + .first() + } + if (updated == null) { + Logger.warn("Timed out waiting for invoice update", context = "EditInvoiceScreen") + } onBack() } } From 0dc033fad4cad1cbb5e2817c8a10abb929ba1a93 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 28 Nov 2025 10:50:41 -0500 Subject: [PATCH 14/32] Remove unused variable --- app/src/main/java/to/bitkit/services/CoreService.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index cd7801e07..76c621cf2 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -637,13 +637,6 @@ class ActivityService( return } - // Extract existing activity data - val existingOnchain = if (existingActivity is Activity.Onchain) { - existingActivity.v1 - } else { - null - } - var resolvedChannelId = channelId // Check if this transaction is a channel transfer From 8d5521afae0f65b9793d356d92e5d1180db3313f Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 28 Nov 2025 12:53:57 -0500 Subject: [PATCH 15/32] Fix comments --- .../advanced/AddressViewerViewModel.kt | 9 +--- .../LightningConnectionsViewModel.kt | 43 ++++++++----------- 2 files changed, 21 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt index c34f4d4dd..657821d71 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt @@ -18,7 +18,6 @@ import to.bitkit.di.BgDispatcher import to.bitkit.models.AddressModel import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo -import to.bitkit.utils.Logger import javax.inject.Inject @HiltViewModel @@ -166,12 +165,8 @@ class AddressViewerViewModel @Inject constructor( } } - suspend fun getBalanceForAddress(address: String): Result = withContext(bgDispatcher) { - return@withContext lightningRepo.getAddressBalance(address).map { it.toLong() } - .onFailure { e -> - Logger.error("Error getting balance for address $address", e) - } - } + suspend fun getBalanceForAddress(address: String): Result = + lightningRepo.getAddressBalance(address).map { it.toLong() } } data class UiState( diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index 723b28a25..752cc01e0 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -413,30 +413,25 @@ class LightningConnectionsViewModel @Inject constructor( } fun fetchActivityTimestamp(channelId: String) { - viewModelScope.launch(bgDispatcher) { - runCatching { - val activities = activityRepo.getActivities( - filter = ActivityFilter.ONCHAIN, - txType = PaymentType.SENT, - tags = null, - search = null, - minDate = null, - maxDate = null, - limit = null, - sortDirection = null - ).getOrNull() ?: emptyList() - - val transferActivity = activities.firstOrNull { activity -> - activity is Activity.Onchain && - activity.v1.isTransfer && - activity.v1.channelId == channelId - } as? Activity.Onchain - - _txTime.update { transferActivity?.v1?.confirmTimestamp ?: transferActivity?.v1?.timestamp } - }.onFailure { e -> - Logger.warn("fetchActivityTimestamp error for channelId: '$channelId'", e, context = TAG) - _txTime.update { null } - } + viewModelScope.launch { + val activities = activityRepo.getActivities( + filter = ActivityFilter.ONCHAIN, + txType = PaymentType.SENT, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null + ).getOrNull() ?: emptyList() + + val transferActivity = activities.firstOrNull { activity -> + activity is Activity.Onchain && + activity.v1.isTransfer && + activity.v1.channelId == channelId + } as? Activity.Onchain + + _txTime.update { transferActivity?.v1?.confirmTimestamp ?: transferActivity?.v1?.timestamp } } } From 06d271d59902c89e7f81875f7bf674701ec9d2f9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 28 Nov 2025 15:09:53 +0100 Subject: [PATCH 16/32] feat: move cached receive details to cache store --- .../androidServices/LightningNodeService.kt | 20 +- .../main/java/to/bitkit/data/CacheStore.kt | 10 + .../domain/commands/NotifyPaymentReceived.kt | 8 +- .../commands/NotifyPaymentReceivedHandler.kt | 11 +- app/src/main/java/to/bitkit/fcm/FcmService.kt | 2 +- .../main/java/to/bitkit/fcm/WakeNodeWorker.kt | 102 ++++---- .../models/NewTransactionSheetDetails.kt | 46 +--- .../to/bitkit/models/NotificationDetails.kt | 9 + .../to/bitkit/models/NotificationState.kt | 7 - app/src/main/java/to/bitkit/ui/ContentView.kt | 7 +- .../main/java/to/bitkit/ui/Notifications.kt | 9 +- .../ui/screens/settings/DevSettingsScreen.kt | 6 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 226 ++++++++---------- .../bitkit/viewmodels/DevSettingsViewModel.kt | 7 +- app/src/main/res/values/strings.xml | 21 +- .../LightningNodeServiceTest.kt | 36 ++- .../NotifyPaymentReceivedHandlerTest.kt | 27 +-- 17 files changed, 277 insertions(+), 277 deletions(-) create mode 100644 app/src/main/java/to/bitkit/models/NotificationDetails.kt delete mode 100644 app/src/main/java/to/bitkit/models/NotificationState.kt diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index e04d88319..46ea9bb45 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -15,10 +15,11 @@ import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Event import to.bitkit.App import to.bitkit.R +import to.bitkit.data.CacheStore import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.models.NewTransactionSheetDetails -import to.bitkit.models.NotificationState +import to.bitkit.models.NotificationDetails import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.LdkNodeEventBus @@ -44,6 +45,9 @@ class LightningNodeService : Service() { @Inject lateinit var notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler + @Inject + lateinit var cacheStore: CacheStore + override fun onCreate() { super.onCreate() startForeground(NOTIFICATION_ID, createNotification()) @@ -79,20 +83,18 @@ class LightningNodeService : Service() { val command = NotifyPaymentReceived.Command.from(event, includeNotification = true) ?: return notifyPaymentReceivedHandler(command).onSuccess { result -> - if (result !is NotifyPaymentReceived.Result.ShowNotification) return@onSuccess - if (App.currentActivity?.value != null) return@onSuccess - - showPaymentNotification(result.details, result.notification) + if (result !is NotifyPaymentReceived.Result.ShowNotification) return + showPaymentNotification(result.sheet, result.notification) } } private fun showPaymentNotification( - details: NewTransactionSheetDetails, - notification: NotificationState, + sheet: NewTransactionSheetDetails, + notification: NotificationDetails, ) { if (App.currentActivity?.value != null) return - NewTransactionSheetDetails.save(this, details) - pushNotification(notification.title, notification.body, context = this) + serviceScope.launch { cacheStore.setBackgroundReceive(sheet) } + pushNotification(notification.title, notification.body) } private fun createNotification( diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index e9e209645..70f24631b 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -14,6 +14,7 @@ import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus import to.bitkit.models.BalanceState import to.bitkit.models.FxRate +import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -101,6 +102,14 @@ class CacheStore @Inject constructor( store.updateData { it.copy(lastLightningPaymentId = paymentId) } } + suspend fun setBackgroundReceive(details: NewTransactionSheetDetails) = store.updateData { + it.copy(backgroundReceive = details) + } + + suspend fun clearBackgroundReceive() { + store.updateData { it.copy(backgroundReceive = null) } + } + suspend fun reset() { store.updateData { AppCacheData() } Logger.info("Deleted all app cached data.") @@ -123,4 +132,5 @@ data class AppCacheData( val deletedActivities: List = listOf(), val lastLightningPaymentId: String? = null, val pendingBoostActivities: List = listOf(), + val backgroundReceive: NewTransactionSheetDetails? = null, ) diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt index 5a6f5a399..1d3c5e35e 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt @@ -2,7 +2,7 @@ package to.bitkit.domain.commands import org.lightningdevkit.ldknode.Event import to.bitkit.models.NewTransactionSheetDetails -import to.bitkit.models.NotificationState +import to.bitkit.models.NotificationDetails sealed interface NotifyPaymentReceived { @@ -50,12 +50,12 @@ sealed interface NotifyPaymentReceived { sealed interface Result : NotifyPaymentReceived { data class ShowSheet( - val details: NewTransactionSheetDetails, + val sheet: NewTransactionSheetDetails, ) : Result data class ShowNotification( - val details: NewTransactionSheetDetails, - val notification: NotificationState, + val sheet: NewTransactionSheetDetails, + val notification: NotificationDetails, ) : Result data object Skip : Result diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index c27001d83..65ac759dd 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -14,7 +14,7 @@ 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.NotificationState +import to.bitkit.models.NotificationDetails import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.ActivityRepo @@ -67,7 +67,7 @@ class NotifyPaymentReceivedHandler @Inject constructor( } } - private suspend fun buildNotificationContent(sats: Long): NotificationState { + 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) { @@ -75,7 +75,7 @@ class NotifyPaymentReceivedHandler @Inject constructor( } else { context.getString(R.string.notification_received_body_hidden) } - return NotificationState(title, body) + return NotificationDetails(title, body) } private fun formatNotificationAmount(sats: Long, settings: SettingsData): String { @@ -95,6 +95,11 @@ class NotifyPaymentReceivedHandler @Inject constructor( companion object { const val TAG = "NotifyPaymentReceivedHandler" + + /** + * Delay before calling `shouldShowPaymentReceived` for onchain transactions to allow ActivityRepo + * to sync payments before we check for RBF replacement or channel closure. + */ private const val DELAY_FOR_ACTIVITY_SYNC_MS = 500L } } diff --git a/app/src/main/java/to/bitkit/fcm/FcmService.kt b/app/src/main/java/to/bitkit/fcm/FcmService.kt index df103c71e..4992e1758 100644 --- a/app/src/main/java/to/bitkit/fcm/FcmService.kt +++ b/app/src/main/java/to/bitkit/fcm/FcmService.kt @@ -136,7 +136,7 @@ class FcmService : FirebaseMessagingService() { } private fun sendNotification(title: String?, body: String?, extras: Bundle? = null) { - pushNotification(title, body, extras, context = applicationContext) + applicationContext.pushNotification(title, body, extras) } private inline fun Map.tryAs(block: (T) -> Unit): Boolean { diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index d49e01503..77f032326 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -15,6 +15,7 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import org.lightningdevkit.ldknode.Event +import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.di.json import to.bitkit.ext.amountOnClose @@ -28,10 +29,12 @@ import to.bitkit.models.BlocktankNotificationType.wakeToTimeout import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType +import to.bitkit.models.NotificationDetails import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo import to.bitkit.services.CoreService +import to.bitkit.R import to.bitkit.ui.pushNotification import to.bitkit.utils.Logger import to.bitkit.utils.withPerformanceLogging @@ -47,13 +50,11 @@ class WakeNodeWorker @AssistedInject constructor( private val blocktankRepo: BlocktankRepo, private val activityRepo: ActivityRepo, private val settingsStore: SettingsStore, + private val cacheStore: CacheStore, ) : CoroutineWorker(appContext, workerParams) { private val self = this - // TODO extract as global model and turn into data class. - class VisibleNotification(var title: String = "", var body: String = "") - - private var bestAttemptContent: VisibleNotification? = VisibleNotification() + private var bestAttemptContent: NotificationDetails? = null private var notificationType: BlocktankNotificationType? = null private var notificationPayload: JsonObject? = null @@ -93,8 +94,10 @@ class WakeNodeWorker @AssistedInject constructor( coreService.blocktank.open(orderId = orderId) } catch (e: Exception) { Logger.error("failed to open channel", e) - self.bestAttemptContent?.title = "Channel open failed" - self.bestAttemptContent?.body = e.message ?: "Unknown error" + self.bestAttemptContent = NotificationDetails( + title = appContext.getString(R.string.notification_channel_open_failed_title), + body = e.message ?: appContext.getString(R.string.notification_unknown_error), + ) self.deliver() } } @@ -103,10 +106,12 @@ class WakeNodeWorker @AssistedInject constructor( withTimeout(timeout) { deliverSignal.await() } // Stops node on timeout & avoids notification replay by OS return Result.success() } catch (e: Exception) { - val reason = e.message ?: "Unknown error" + val reason = e.message ?: appContext.getString(R.string.notification_unknown_error) - self.bestAttemptContent?.title = "Lightning error" - self.bestAttemptContent?.body = reason + self.bestAttemptContent = NotificationDetails( + title = appContext.getString(R.string.notification_lightning_error_title), + body = reason, + ) Logger.error("Lightning error", e) self.deliver() @@ -120,22 +125,26 @@ class WakeNodeWorker @AssistedInject constructor( */ private suspend fun handleLdkEvent(event: Event) { val showDetails = settingsStore.data.first().showNotificationDetails - val openBitkitMessage = "Open Bitkit to see details" + val hiddenBody = appContext.getString(R.string.notification_received_body_hidden) when (event) { - is Event.PaymentReceived -> onPaymentReceived(event, showDetails, openBitkitMessage) + is Event.PaymentReceived -> onPaymentReceived(event, showDetails, hiddenBody) is Event.ChannelPending -> { - self.bestAttemptContent?.title = "Channel Opened" - self.bestAttemptContent?.body = "Pending" + self.bestAttemptContent = NotificationDetails( + title = appContext.getString(R.string.notification_channel_opened_title), + body = appContext.getString(R.string.notification_channel_pending_body), + ) // Don't deliver, give a chance for channelReady event to update the content if it's a turbo channel } - is Event.ChannelReady -> onChannelReady(event, showDetails, openBitkitMessage) + is Event.ChannelReady -> onChannelReady(event, showDetails, hiddenBody) is Event.ChannelClosed -> onChannelClosed(event) is Event.PaymentFailed -> { - self.bestAttemptContent?.title = "Payment failed" - self.bestAttemptContent?.body = "⚡ ${event.reason}" + self.bestAttemptContent = NotificationDetails( + title = appContext.getString(R.string.notification_payment_failed_title), + body = "⚡ ${event.reason}", + ) if (self.notificationType == wakeToTimeout) { self.deliver() @@ -147,14 +156,19 @@ class WakeNodeWorker @AssistedInject constructor( } private suspend fun onChannelClosed(event: Event.ChannelClosed) { - self.bestAttemptContent?.title = "Channel closed" - self.bestAttemptContent?.body = "Reason: ${event.reason}" - - if (self.notificationType == mutualClose) { - self.bestAttemptContent?.body = "Balance moved from spending to savings" - } else if (self.notificationType == orderPaymentConfirmed) { - self.bestAttemptContent?.title = "Channel failed to open in the background" - self.bestAttemptContent?.body = "Please try again" + self.bestAttemptContent = when (self.notificationType) { + mutualClose -> NotificationDetails( + title = appContext.getString(R.string.notification_channel_closed_title), + body = appContext.getString(R.string.notification_channel_closed_mutual_body), + ) + orderPaymentConfirmed -> NotificationDetails( + title = appContext.getString(R.string.notification_channel_open_bg_failed_title), + body = appContext.getString(R.string.notification_please_try_again_body), + ) + else -> NotificationDetails( + title = appContext.getString(R.string.notification_channel_closed_title), + body = appContext.getString(R.string.notification_channel_closed_reason_body, event.reason), + ) } self.deliver() @@ -163,13 +177,11 @@ class WakeNodeWorker @AssistedInject constructor( private suspend fun onPaymentReceived( event: Event.PaymentReceived, showDetails: Boolean, - openBitkitMessage: String, + hiddenBody: String, ) { - bestAttemptContent?.title = "Payment Received" val sats = event.amountMsat / 1000u // Save for UI to pick up - NewTransactionSheetDetails.save( - appContext, + cacheStore.setBackgroundReceive( NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.RECEIVED, @@ -177,8 +189,11 @@ class WakeNodeWorker @AssistedInject constructor( sats = sats.toLong(), ) ) - val content = if (showDetails) "$BITCOIN_SYMBOL $sats" else openBitkitMessage - bestAttemptContent?.body = content + val content = if (showDetails) "$BITCOIN_SYMBOL $sats" else hiddenBody + bestAttemptContent = NotificationDetails( + title = appContext.getString(R.string.notification_received_title), + body = content, + ) if (self.notificationType == incomingHtlc) { self.deliver() } @@ -187,21 +202,26 @@ class WakeNodeWorker @AssistedInject constructor( private suspend fun onChannelReady( event: Event.ChannelReady, showDetails: Boolean, - openBitkitMessage: String, + hiddenBody: String, ) { + val viaNewChannel = appContext.getString(R.string.notification_via_new_channel_body) if (self.notificationType == cjitPaymentArrived) { - self.bestAttemptContent?.title = "Payment received" - self.bestAttemptContent?.body = "Via new channel" + self.bestAttemptContent = NotificationDetails( + title = appContext.getString(R.string.notification_received_title), + body = viaNewChannel, + ) lightningRepo.getChannels()?.find { it.channelId == event.channelId }?.let { channel -> val sats = channel.amountOnClose - val content = if (showDetails) "$BITCOIN_SYMBOL $sats" else openBitkitMessage - self.bestAttemptContent?.title = content + val content = if (showDetails) "$BITCOIN_SYMBOL $sats" else hiddenBody + self.bestAttemptContent = NotificationDetails( + title = content, + body = viaNewChannel, + ) val cjitEntry = channel.let { blocktankRepo.getCjitEntry(it) } if (cjitEntry != null) { // Save for UI to pick up - NewTransactionSheetDetails.save( - appContext, + cacheStore.setBackgroundReceive( NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.RECEIVED, @@ -212,8 +232,10 @@ class WakeNodeWorker @AssistedInject constructor( } } } else if (self.notificationType == orderPaymentConfirmed) { - self.bestAttemptContent?.title = "Channel opened" - self.bestAttemptContent?.body = "Ready to send" + self.bestAttemptContent = NotificationDetails( + title = appContext.getString(R.string.notification_channel_opened_title), + body = appContext.getString(R.string.notification_channel_ready_body), + ) } self.deliver() } @@ -222,7 +244,7 @@ class WakeNodeWorker @AssistedInject constructor( lightningRepo.stop() bestAttemptContent?.run { - pushNotification(title, body, context = appContext) + appContext.pushNotification(title, body) Logger.info("Delivered notification") } diff --git a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt index ee86821c9..71b43495a 100644 --- a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt +++ b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt @@ -1,15 +1,8 @@ package to.bitkit.models -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.PaymentType import kotlinx.serialization.Serializable -import to.bitkit.di.json -import to.bitkit.utils.Logger - -private const val APP_PREFS = "bitkit_prefs" @Serializable data class NewTransactionSheetDetails( @@ -18,44 +11,7 @@ data class NewTransactionSheetDetails( val paymentHashOrTxId: String? = null, val sats: Long = 0, val isLoadingDetails: Boolean = false, -) { - companion object { - private const val BACKGROUND_TRANSACTION_KEY = "backgroundTransaction" - - fun save(context: Context, details: NewTransactionSheetDetails) { - val sharedPreferences = getSharedPreferences(context) - val editor = sharedPreferences.edit() - try { - val jsonData = json.encodeToString(details) - editor.putString(BACKGROUND_TRANSACTION_KEY, jsonData) - editor.apply() - } catch (e: Exception) { - Logger.error("Failed to cache transaction", e) - } - } - - fun load(context: Context): NewTransactionSheetDetails? { - val sharedPreferences = getSharedPreferences(context) - val jsonData = sharedPreferences.getString(BACKGROUND_TRANSACTION_KEY, null) ?: return null - - return try { - json.decodeFromString(jsonData) - } catch (e: Exception) { - Logger.error("Failed to load cached transaction", e) - null - } - } - - fun clear(context: Context) { - val sharedPreferences = getSharedPreferences(context) - sharedPreferences.edit { remove(BACKGROUND_TRANSACTION_KEY) } - } - - private fun getSharedPreferences(context: Context): SharedPreferences { - return context.getSharedPreferences(APP_PREFS, Context.MODE_PRIVATE) - } - } -} +) @Serializable enum class NewTransactionSheetType { diff --git a/app/src/main/java/to/bitkit/models/NotificationDetails.kt b/app/src/main/java/to/bitkit/models/NotificationDetails.kt new file mode 100644 index 000000000..290c3ab2c --- /dev/null +++ b/app/src/main/java/to/bitkit/models/NotificationDetails.kt @@ -0,0 +1,9 @@ +package to.bitkit.models + +import kotlinx.serialization.Serializable + +@Serializable +data class NotificationDetails( + val title: String = "", + val body: String = "", +) diff --git a/app/src/main/java/to/bitkit/models/NotificationState.kt b/app/src/main/java/to/bitkit/models/NotificationState.kt deleted file mode 100644 index 3f39edfcf..000000000 --- a/app/src/main/java/to/bitkit/models/NotificationState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package to.bitkit.models - -// TODO should replace WakeNodeWorker.VisibleNotification -data class NotificationState( - val title: String, - val body: String, -) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d35438fdc..c418cfe73 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -43,7 +43,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import to.bitkit.env.Env -import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NodeLifecycleState import to.bitkit.models.Toast import to.bitkit.models.WidgetType @@ -218,11 +217,7 @@ fun ContentView( walletViewModel.start() } - val pendingTransaction = NewTransactionSheetDetails.load(context) - if (pendingTransaction != null) { - appViewModel.showNewTransactionSheet(details = pendingTransaction) - NewTransactionSheetDetails.clear(context) - } + appViewModel.consumePaymentReceivedInBackground() currencyViewModel.triggerRefresh() blocktankViewModel.refreshOrders() diff --git a/app/src/main/java/to/bitkit/ui/Notifications.kt b/app/src/main/java/to/bitkit/ui/Notifications.kt index d0da028bf..97e463afc 100644 --- a/app/src/main/java/to/bitkit/ui/Notifications.kt +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -52,22 +52,21 @@ internal fun Context.notificationBuilder( .setAutoCancel(true) // remove on tap } -internal fun pushNotification( +internal fun Context.pushNotification( title: String?, text: String?, extras: Bundle? = null, bigText: String? = null, id: Int = Random.nextInt(), - context: Context, ): Int { Logger.debug("Push notification: $title, $text") // Only check permission if running on Android 13+ (SDK 33+) val requiresPermission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - context.requiresPermission(Manifest.permission.POST_NOTIFICATIONS) + requiresPermission(Manifest.permission.POST_NOTIFICATIONS) if (!requiresPermission) { - val builder = context.notificationBuilder(extras) + val builder = notificationBuilder(extras) .setContentTitle(title) .setContentText(text) .apply { @@ -75,7 +74,7 @@ internal fun pushNotification( setStyle(NotificationCompat.BigTextStyle().bigText(bigText)) } } - context.notificationManagerCompat.notify(id, builder.build()) + notificationManagerCompat.notify(id, builder.build()) } return id diff --git a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt index 20fc09b2d..41aed839a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt @@ -144,10 +144,10 @@ fun DevSettingsScreen( } ) SettingsTextButtonRow( - "Fake New BG Transaction", + "Fake New BG Receive", onClick = { - viewModel.fakeBgTransaction() - app.toast(type = Toast.ToastType.INFO, title = "Restart to see the transaction sheet") + viewModel.fakeBgReceive() + app.toast(type = Toast.ToastType.INFO, title = "Restart app to see the payment received sheet") } ) SettingsTextButtonRow( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b76035cdc..96e55f99d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -234,7 +234,6 @@ class AppViewModel @Inject constructor( runCatching { when (event) { is Event.BalanceChanged -> handleBalanceChanged() - is Event.SyncCompleted -> handleSyncCompleted() is Event.ChannelClosed -> Unit is Event.ChannelPending -> Unit is Event.ChannelReady -> notifyChannelReady(event) @@ -248,6 +247,7 @@ class AppViewModel @Inject constructor( is Event.PaymentForwarded -> Unit is Event.PaymentReceived -> handlePaymentReceived(event) is Event.PaymentSuccessful -> handlePaymentSuccessful(event) + is Event.SyncCompleted -> handleSyncCompleted() is Event.SyncProgress -> Unit } }.onFailure { e -> @@ -257,35 +257,25 @@ class AppViewModel @Inject constructor( } } - private fun handleBalanceChanged() { - viewModelScope.launch(bgDispatcher) { - walletRepo.syncBalances() - } + private suspend fun handleBalanceChanged() { + walletRepo.syncBalances() } - private fun handleSyncCompleted() { - viewModelScope.launch(bgDispatcher) { - walletRepo.syncNodeAndWallet() - } + private suspend fun handleSyncCompleted() { + walletRepo.syncNodeAndWallet() } - private fun handleOnchainTransactionConfirmed(event: Event.OnchainTransactionConfirmed) { - viewModelScope.launch(bgDispatcher) { - activityRepo.handleOnchainTransactionConfirmed(event.txid, event.details) - } + private suspend fun handleOnchainTransactionConfirmed(event: Event.OnchainTransactionConfirmed) { + activityRepo.handleOnchainTransactionConfirmed(event.txid, event.details) } - private fun handleOnchainTransactionEvicted(event: Event.OnchainTransactionEvicted) { - viewModelScope.launch(bgDispatcher) { - activityRepo.handleOnchainTransactionEvicted(event.txid) - } + private suspend fun handleOnchainTransactionEvicted(event: Event.OnchainTransactionEvicted) { + activityRepo.handleOnchainTransactionEvicted(event.txid) notifyTransactionRemoved(event) } - private fun handleOnchainTransactionReceived(event: Event.OnchainTransactionReceived) { - viewModelScope.launch(bgDispatcher) { - activityRepo.handleOnchainTransactionReceived(event.txid, event.details) - } + private suspend fun handleOnchainTransactionReceived(event: Event.OnchainTransactionReceived) { + activityRepo.handleOnchainTransactionReceived(event.txid, event.details) if (event.details.amountSats > 0) { val sats = event.details.amountSats.toULong() viewModelScope.launch { @@ -298,55 +288,89 @@ class AppViewModel @Inject constructor( } } - private fun handleOnchainTransactionReorged(event: Event.OnchainTransactionReorged) { - viewModelScope.launch(bgDispatcher) { - activityRepo.handleOnchainTransactionReorged(event.txid) - } + private suspend fun handleOnchainTransactionReorged(event: Event.OnchainTransactionReorged) { + activityRepo.handleOnchainTransactionReorged(event.txid) notifyTransactionUnconfirmed() } - private fun handleOnchainTransactionReplaced(event: Event.OnchainTransactionReplaced) { - viewModelScope.launch(bgDispatcher) { - activityRepo.handleOnchainTransactionReplaced(event.txid, event.conflicts) - } + private suspend fun handleOnchainTransactionReplaced(event: Event.OnchainTransactionReplaced) { + activityRepo.handleOnchainTransactionReplaced(event.txid, event.conflicts) notifyTransactionReplaced(event) } - private fun handlePaymentFailed(event: Event.PaymentFailed) { + private suspend fun handlePaymentFailed(event: Event.PaymentFailed) { event.paymentHash?.let { paymentHash -> - viewModelScope.launch(bgDispatcher) { - activityRepo.handlePaymentEvent(paymentHash) - } + activityRepo.handlePaymentEvent(paymentHash) } notifyPaymentFailed() } - private fun handlePaymentReceived(event: Event.PaymentReceived) { + private suspend fun handlePaymentReceived(event: Event.PaymentReceived) { event.paymentHash?.let { paymentHash -> - viewModelScope.launch(bgDispatcher) { - activityRepo.handlePaymentEvent(paymentHash) - } + activityRepo.handlePaymentEvent(paymentHash) } notifyPaymentReceived(event) } - private fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { + private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { event.paymentHash?.let { paymentHash -> - viewModelScope.launch(bgDispatcher) { - activityRepo.handlePaymentEvent(paymentHash) - } + activityRepo.handlePaymentEvent(paymentHash) } - viewModelScope.launch { - notifyPaymentSentOnLightning(event) + notifyPaymentSentOnLightning(event) + } + + // 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() + showNewTransactionSheet( + NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + sats = amount, + ), + ) + activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel) + return } + toast( + type = Toast.ToastType.LIGHTNING, + title = context.getString(R.string.lightning__channel_opened_title), + description = context.getString(R.string.lightning__channel_opened_msg), + ) } - private fun notifyPaymentFailed() = toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.wallet__toast_payment_failed_title), - description = context.getString(R.string.wallet__toast_payment_failed_description), - testTag = "PaymentFailedToast", - ) + private suspend fun notifyTransactionRemoved(event: Event.OnchainTransactionEvicted) { + if (activityRepo.wasTransactionReplaced(event.txid)) return + toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.wallet__toast_transaction_removed_title), + description = context.getString(R.string.wallet__toast_transaction_removed_description), + testTag = "TransactionRemovedToast", + ) + } + + private suspend fun notifyPaymentReceived(event: Event) { + val command = NotifyPaymentReceived.Command.from(event) ?: return + if (command is NotifyPaymentReceived.Command.Lightning) { + val cachedId = cacheStore.data.first().lastLightningPaymentId + // Skip if this is a replay by ldk-node on startup + if (command.paymentHashOrTxId == cachedId) { + Logger.debug("Skipping notification for replayed event: $event", context = TAG) + return + } + // Cache to skip later as needed + cacheStore.setLastLightningPayment(command.paymentHashOrTxId) + } + + val result = notifyPaymentReceivedHandler(command).getOrNull() + if (result !is NotifyPaymentReceived.Result.ShowSheet) return + + showNewTransactionSheet(result.sheet) + } private fun notifyTransactionUnconfirmed() = toast( type = Toast.ToastType.WARNING, @@ -355,34 +379,31 @@ class AppViewModel @Inject constructor( testTag = "TransactionUnconfirmedToast", ) - private fun notifyTransactionRemoved(event: Event.OnchainTransactionEvicted) = viewModelScope.launch { - if (!activityRepo.wasTransactionReplaced(event.txid)) { - toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.wallet__toast_transaction_removed_title), - description = context.getString(R.string.wallet__toast_transaction_removed_description), - testTag = "TransactionRemovedToast", - ) - } + private suspend fun notifyTransactionReplaced(event: Event.OnchainTransactionReplaced) { + val isReceive = activityRepo.isReceivedTransaction(event.txid) + toast( + type = Toast.ToastType.INFO, + title = when (isReceive) { + true -> R.string.wallet__toast_received_transaction_replaced_title + else -> R.string.wallet__toast_transaction_replaced_title + }.let { context.getString(it) }, + description = when (isReceive) { + true -> R.string.wallet__toast_received_transaction_replaced_description + else -> R.string.wallet__toast_transaction_replaced_description + }.let { context.getString(it) }, + testTag = when (isReceive) { + true -> "ReceivedTransactionReplacedToast" + else -> "TransactionReplacedToast" + }, + ) } - private fun notifyTransactionReplaced(event: Event.OnchainTransactionReplaced) = viewModelScope.launch { - if (activityRepo.isReceivedTransaction(event.txid)) { - toast( - type = Toast.ToastType.INFO, - title = context.getString(R.string.wallet__toast_received_transaction_replaced_title), - description = context.getString(R.string.wallet__toast_received_transaction_replaced_description), - testTag = "ReceivedTransactionReplacedToast", - ) - } else { - toast( - type = Toast.ToastType.INFO, - title = context.getString(R.string.wallet__toast_transaction_replaced_title), - description = context.getString(R.string.wallet__toast_transaction_replaced_description), - testTag = "TransactionReplacedToast", - ) - } - } + private fun notifyPaymentFailed() = toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__toast_payment_failed_title), + description = context.getString(R.string.wallet__toast_payment_failed_description), + testTag = "PaymentFailedToast", + ) private suspend fun notifyPaymentSentOnLightning(event: Event.PaymentSuccessful): Result { val paymentHash = event.paymentHash @@ -406,49 +427,7 @@ class AppViewModel @Inject constructor( } } - private suspend fun notifyChannelReady(event: Event.ChannelReady): Any { - val channel = lightningRepo.getChannels()?.find { it.channelId == event.channelId } - val cjitEntry = channel?.let { blocktankRepo.getCjitEntry(it) } - return if (cjitEntry != null) { - val amount = channel.amountOnClose.toLong() - showNewTransactionSheet( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.RECEIVED, - sats = amount, - ), - ) - activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel) - } else { - toast( - type = Toast.ToastType.LIGHTNING, - title = context.getString(R.string.lightning__channel_opened_title), - description = context.getString(R.string.lightning__channel_opened_msg), - ) - } - } - - private fun notifyPaymentReceived(event: Event) { - val command = NotifyPaymentReceived.Command.from(event) ?: return - - viewModelScope.launch(bgDispatcher) { - // Skip lightning payment events replayed by ldk-node on startup - if (command is NotifyPaymentReceived.Command.Lightning) { - val cachedId = cacheStore.data.first().lastLightningPaymentId - if (command.paymentHashOrTxId == cachedId) { - Logger.debug("Skipping replayed Lightning payment: ${command.paymentHashOrTxId}", context = TAG) - return@launch - } - cacheStore.setLastLightningPayment(command.paymentHashOrTxId) - } - - notifyPaymentReceivedHandler(command).onSuccess { result -> - if (result is NotifyPaymentReceived.Result.ShowSheet) { - showNewTransactionSheet(result.details) - } - } - } - } + // endregion // region send @@ -675,6 +654,7 @@ class AppViewModel @Inject constructor( resetSendState() resetQuickPayData() + // TODO: wrap the bindings `decode` fn in a `CoreService` method and call it from here val scan = runCatching { decode(result) } .onFailure { Logger.error("Failed to decode scan data: '$result'", it, context = TAG) } .onSuccess { Logger.info("Handling decoded scan data: $it", context = TAG) } @@ -1257,7 +1237,7 @@ class AppViewModel @Inject constructor( val txType = _newTransaction.value.direction.toTxType() val paymentHashOrTxId = _newTransaction.value.paymentHashOrTxId ?: return _newTransaction.update { it.copy(isLoadingDetails = true) } - viewModelScope.launch(bgDispatcher) { + viewModelScope.launch { activityRepo.findActivityByPaymentId( paymentHashOrTxId = paymentHashOrTxId, type = activityType, @@ -1281,7 +1261,7 @@ class AppViewModel @Inject constructor( val txType = _successSendUiState.value.direction.toTxType() val paymentHashOrTxId = _successSendUiState.value.paymentHashOrTxId ?: return _successSendUiState.update { it.copy(isLoadingDetails = true) } - viewModelScope.launch(bgDispatcher) { + viewModelScope.launch { activityRepo.findActivityByPaymentId( paymentHashOrTxId = paymentHashOrTxId, type = activityType, @@ -1519,6 +1499,12 @@ class AppViewModel @Inject constructor( } fun hideNewTransactionSheet() = _showNewTransaction.update { false } + + fun consumePaymentReceivedInBackground() = viewModelScope.launch(bgDispatcher) { + val details = cacheStore.data.first().backgroundReceive ?: return@launch + cacheStore.clearBackgroundReceive() + showNewTransactionSheet(details) + } // endregion // region Sheets diff --git a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt index c6b47ce47..e8d10c8f6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt @@ -88,14 +88,13 @@ class DevSettingsViewModel @Inject constructor( } } - fun fakeBgTransaction() { + fun fakeBgReceive() { viewModelScope.launch { - NewTransactionSheetDetails.save( - context, + cacheStore.setBackgroundReceive( NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.RECEIVED, - sats = 123456789, + sats = 21_000_000, ) ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 63723f3fb..eba2763e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1139,9 +1139,22 @@ Current average fee Next block inclusion Couldn\'t get current fee weather - Stop App - Bitkit is running in background so you can receive Lightning payments - Payment Received - Open Bitkit to see details + Balance moved from spending to savings + Reason: %s + Channel closed + Channel failed to open in the background + Channel open failed + Channel opened + Pending + Ready to send + Lightning error + Payment failed + Please try again Received %s + Open Bitkit to see details + Payment Received + Bitkit is running in background so you can receive Lightning payments + Stop App + Unknown error + Via new channel diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index eaf85d639..d670081d5 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -25,6 +25,8 @@ import org.lightningdevkit.ldknode.Event import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner @@ -33,12 +35,14 @@ import org.robolectric.annotation.Config import to.bitkit.App import to.bitkit.CurrentActivity import to.bitkit.R +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType -import to.bitkit.models.NotificationState +import to.bitkit.models.NotificationDetails import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.LdkNodeEventBus @@ -71,7 +75,12 @@ class LightningNodeServiceTest : BaseUnitTest() { @JvmField val notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler = mock() + @BindValue + @JvmField + val cacheStore: CacheStore = mock() + private val eventsFlow = MutableSharedFlow() + private val cacheDataFlow = MutableSharedFlow(replay = 1) private val context = ApplicationProvider.getApplicationContext() @Before @@ -84,19 +93,23 @@ class LightningNodeServiceTest : BaseUnitTest() { ) whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) + // Set up CacheStore mock + cacheDataFlow.emit(AppCacheData()) + whenever(cacheStore.data).thenReturn(cacheDataFlow) + // Mock NotifyPaymentReceivedHandler to return ShowNotification result - val defaultDetails = NewTransactionSheetDetails( + val sheet = NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.RECEIVED, paymentHashOrTxId = "test_hash", sats = 100L, ) - val defaultNotification = NotificationState( + val notification = NotificationDetails( title = context.getString(R.string.notification_received_title), body = "Received ₿ 100 ($0.10)", ) whenever(notifyPaymentReceivedHandler.invoke(any())).thenReturn( - Result.success(NotifyPaymentReceived.Result.ShowNotification(defaultDetails, defaultNotification)) + Result.success(NotifyPaymentReceived.Result.ShowNotification(sheet, notification)) ) // Grant permissions for notifications @@ -110,7 +123,6 @@ class LightningNodeServiceTest : BaseUnitTest() { @After fun tearDown() { - NewTransactionSheetDetails.clear(context) App.currentActivity = null } @@ -137,10 +149,13 @@ class LightningNodeServiceTest : BaseUnitTest() { } assertNotNull("Payment notification should be present", paymentNotification) - val details = NewTransactionSheetDetails.load(context) - assertNotNull(details) - assertEquals("test_hash", details?.paymentHashOrTxId) - assertEquals(100L, details?.sats) + val expected = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = "test_hash", + sats = 100L, + ) + verify(cacheStore).setBackgroundReceive(expected) } @Test @@ -170,8 +185,7 @@ class LightningNodeServiceTest : BaseUnitTest() { assertNull("Payment notification should NOT be present in foreground", paymentNotification) - val details = NewTransactionSheetDetails.load(context) - assertNull(details) + verify(cacheStore, never()).setBackgroundReceive(any()) } @Test diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt index ce119b840..b49c97650 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -69,11 +69,10 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertTrue(result.isSuccess) val paymentResult = result.getOrThrow() assertTrue(paymentResult is NotifyPaymentReceived.Result.ShowSheet) - val showResult = paymentResult as NotifyPaymentReceived.Result.ShowSheet - assertEquals(NewTransactionSheetType.LIGHTNING, showResult.details.type) - assertEquals(NewTransactionSheetDirection.RECEIVED, showResult.details.direction) - assertEquals("hash123", showResult.details.paymentHashOrTxId) - assertEquals(1000L, showResult.details.sats) + assertEquals(NewTransactionSheetType.LIGHTNING, paymentResult.sheet.type) + assertEquals(NewTransactionSheetDirection.RECEIVED, paymentResult.sheet.direction) + assertEquals("hash123", paymentResult.sheet.paymentHashOrTxId) + assertEquals(1000L, paymentResult.sheet.sats) } @Test @@ -89,11 +88,10 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertTrue(result.isSuccess) val paymentResult = result.getOrThrow() assertTrue(paymentResult is NotifyPaymentReceived.Result.ShowNotification) - val showResult = paymentResult as NotifyPaymentReceived.Result.ShowNotification - assertEquals(NewTransactionSheetType.LIGHTNING, showResult.details.type) - assertEquals("hash123", showResult.details.paymentHashOrTxId) - assertNotNull(showResult.notification) - assertEquals("Payment Received", showResult.notification.title) + assertEquals(NewTransactionSheetType.LIGHTNING, paymentResult.sheet.type) + assertEquals("hash123", paymentResult.sheet.paymentHashOrTxId) + assertNotNull(paymentResult.notification) + assertEquals("Payment Received", paymentResult.notification.title) } @Test @@ -106,11 +104,10 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertTrue(result.isSuccess) val paymentResult = result.getOrThrow() assertTrue(paymentResult is NotifyPaymentReceived.Result.ShowSheet) - val showResult = paymentResult as NotifyPaymentReceived.Result.ShowSheet - assertEquals(NewTransactionSheetType.ONCHAIN, showResult.details.type) - assertEquals(NewTransactionSheetDirection.RECEIVED, showResult.details.direction) - assertEquals("txid456", showResult.details.paymentHashOrTxId) - assertEquals(5000L, showResult.details.sats) + assertEquals(NewTransactionSheetType.ONCHAIN, paymentResult.sheet.type) + assertEquals(NewTransactionSheetDirection.RECEIVED, paymentResult.sheet.direction) + assertEquals("txid456", paymentResult.sheet.paymentHashOrTxId) + assertEquals(5000L, paymentResult.sheet.sats) } @Test From 0f7527accc9ffb4ff024a2ae79593c3a0d59a797 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 29 Nov 2025 01:44:44 +0100 Subject: [PATCH 17/32] chore: lint --- app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt | 4 +++- app/src/main/java/to/bitkit/ui/ContentView.kt | 1 + .../java/to/bitkit/ui/settings/appStatus/AppStatusScreen.kt | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index 77f032326..53a517296 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -15,6 +15,7 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import org.lightningdevkit.ldknode.Event +import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.di.json @@ -34,7 +35,6 @@ import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo import to.bitkit.services.CoreService -import to.bitkit.R import to.bitkit.ui.pushNotification import to.bitkit.utils.Logger import to.bitkit.utils.withPerformanceLogging @@ -161,10 +161,12 @@ class WakeNodeWorker @AssistedInject constructor( title = appContext.getString(R.string.notification_channel_closed_title), body = appContext.getString(R.string.notification_channel_closed_mutual_body), ) + orderPaymentConfirmed -> NotificationDetails( title = appContext.getString(R.string.notification_channel_open_bg_failed_title), body = appContext.getString(R.string.notification_please_try_again_body), ) + else -> NotificationDetails( title = appContext.getString(R.string.notification_channel_closed_title), body = appContext.getString(R.string.notification_channel_closed_reason_body, event.reason), diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index c418cfe73..a57d8083d 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -744,6 +744,7 @@ private fun RootNavHost( } // region destinations +@Suppress("LongParameterList") private fun NavGraphBuilder.home( walletViewModel: WalletViewModel, appViewModel: AppViewModel, diff --git a/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusScreen.kt b/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusScreen.kt index f0a404409..b756c3ac9 100644 --- a/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/appStatus/AppStatusScreen.kt @@ -66,6 +66,7 @@ fun AppStatusScreen( ) } +@Suppress("CyclomaticComplexMethod") @Composable private fun Content( uiState: AppStatusUiState = AppStatusUiState(), From ee50c0a07e32aab1e9efa3b5ab117e030e8fc627 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 1 Dec 2025 13:07:20 +0100 Subject: [PATCH 18/32] fix: reset bip21 on restore --- app/src/main/java/to/bitkit/data/CacheStore.kt | 4 +++- app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 70f24631b..51df348d2 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -133,4 +133,6 @@ data class AppCacheData( val lastLightningPaymentId: String? = null, val pendingBoostActivities: List = listOf(), val backgroundReceive: NewTransactionSheetDetails? = null, -) +) { + fun resetBip21() = copy(bip21 = "", bolt11 = "", onchainAddress = "") +} diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index bae630283..b3a91223a 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -484,8 +484,8 @@ class BackupRepo @Inject constructor( return@withContext try { performRestore(BackupCategory.METADATA) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) - val cleanedUp = parsed.cache.copy(onchainAddress = "") // Force address rotation - cacheStore.update { cleanedUp } + val cleanCache = parsed.cache.resetBip21() // Force address rotation + cacheStore.update { cleanCache } Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG) onCacheRestored() preActivityMetadataRepo.upsertPreActivityMetadata(parsed.tagMetadata).getOrNull() From ff2417846de860a73716c21bc6f7f79c4823fa96 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Mon, 1 Dec 2025 17:57:11 +0100 Subject: [PATCH 19/32] RGS and Electrum toast ids --- .../java/to/bitkit/ui/settings/advanced/ElectrumConfigScreen.kt | 2 ++ .../main/java/to/bitkit/ui/settings/advanced/RgsServerScreen.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigScreen.kt index d37a6ea8e..00322eb29 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigScreen.kt @@ -80,12 +80,14 @@ fun ElectrumConfigScreen( description = context.getString(R.string.settings__es__server_updated_message) .replace("{host}", uiState.host) .replace("{port}", uiState.port), + testTag = "ElectrumUpdatedToast", ) } else { app.toast( type = Toast.ToastType.WARNING, title = context.getString(R.string.settings__es__server_error), description = context.getString(R.string.settings__es__server_error_description), + testTag = "ElectrumErrorToast", ) } viewModel.clearConnectionResult() diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerScreen.kt index b53bab3ac..36d0b52c6 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerScreen.kt @@ -71,12 +71,14 @@ fun RgsServerScreen( type = Toast.ToastType.SUCCESS, title = context.getString(R.string.settings__rgs__update_success_title), description = context.getString(R.string.settings__rgs__update_success_description), + testTag = "RgsUpdatedToast", ) } else { app.toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.wallet__ldk_start_error_title), description = result.exceptionOrNull()?.message ?: "Unknown error", + testTag = "RgsErrorToast", ) } viewModel.clearConnectionResult() From 4a6f460d424373ca36c238efe6c69b9b1135a51f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 2 Dec 2025 03:49:08 +0100 Subject: [PATCH 20/32] fix: e2e onboarding_2 test logic --- app/src/main/java/to/bitkit/ui/ContentView.kt | 4 +- .../to/bitkit/viewmodels/WalletViewModel.kt | 33 ++++--- .../java/to/bitkit/ui/WalletViewModelTest.kt | 98 +++++++++++++++++-- 3 files changed, 110 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index a57d8083d..c47947ed6 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -319,10 +319,10 @@ fun ContentView( walletIsInitializing = false } }, - isRestoring = restoreState.isRestoring(), + isRestoring = restoreState.isOngoing(), ) } - } else if (restoreState is RestoreState.BackupRestoreCompleted) { + } else if (restoreState is RestoreState.Completed) { WalletRestoreSuccessView( onContinue = { walletViewModel.onRestoreContinue() }, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 2b0de8324..2676b0395 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -56,7 +56,7 @@ class WalletViewModel @Inject constructor( val isRecoveryMode = lightningRepo.isRecoveryMode - var restoreState by mutableStateOf(RestoreState.NotRestoring) + var restoreState by mutableStateOf(RestoreState.Initial) private set private val _uiState = MutableStateFlow(MainUiState()) @@ -90,7 +90,7 @@ class WalletViewModel @Inject constructor( receiveOnSpendingBalance = state.receiveOnSpendingBalance, ) } - if (state.walletExists && restoreState == RestoreState.RestoringWallet) { + if (state.walletExists && restoreState == RestoreState.InProgress.Wallet) { restoreFromBackup() } } @@ -112,14 +112,14 @@ class WalletViewModel @Inject constructor( } private suspend fun restoreFromBackup() { - restoreState = RestoreState.RestoringBackups + restoreState = RestoreState.InProgress.Metadata backupRepo.performFullRestoreFromLatestBackup(onCacheRestored = walletRepo::loadFromCache) // data backup is not critical and mostly for user convenience so there is no reason to propagate errors up - restoreState = RestoreState.BackupRestoreCompleted + restoreState = RestoreState.Completed } fun onRestoreContinue() { - restoreState = RestoreState.NotRestoring + restoreState = RestoreState.Settled } fun proceedWithoutRestore(onDone: () -> Unit) { @@ -127,7 +127,7 @@ class WalletViewModel @Inject constructor( // TODO start LDK without trying to restore backup state from VSS if possible lightningRepo.stop() delay(LOADING_MS.milliseconds) - restoreState = RestoreState.NotRestoring + restoreState = RestoreState.Settled onDone() } } @@ -142,7 +142,10 @@ class WalletViewModel @Inject constructor( .onSuccess { walletRepo.setWalletExistsState() walletRepo.syncBalances() - walletRepo.refreshBip21() + // Skip refreshing during restore, it will be called when it completes + if (restoreState.isIdle()) { + walletRepo.refreshBip21() + } } .onFailure { error -> Logger.error("Node startup error", error) @@ -263,7 +266,7 @@ class WalletViewModel @Inject constructor( suspend fun restoreWallet(mnemonic: String, bip39Passphrase: String?) { setInitNodeLifecycleState() - restoreState = RestoreState.RestoringWallet + restoreState = RestoreState.InProgress.Wallet walletRepo.restoreWallet( mnemonic = mnemonic, @@ -332,10 +335,14 @@ sealed interface WalletViewModelEffects { } sealed interface RestoreState { - data object NotRestoring : RestoreState - data object RestoringWallet : RestoreState - data object RestoringBackups : RestoreState - data object BackupRestoreCompleted : RestoreState + data object Initial : RestoreState + sealed interface InProgress : RestoreState { + object Wallet : InProgress + object Metadata : InProgress + } + data object Completed : RestoreState + data object Settled : RestoreState - fun isRestoring() = this is RestoringWallet || this is RestoringBackups + fun isOngoing() = this is InProgress + fun isIdle() = this is Initial || this is Settled } diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 068f3bb3d..c065efc86 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -21,10 +21,12 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WalletState +import to.bitkit.models.BalanceState import to.bitkit.test.BaseUnitTest import to.bitkit.viewmodels.RestoreState import to.bitkit.viewmodels.WalletViewModel +@OptIn(ExperimentalCoroutinesApi::class) class WalletViewModelTest : BaseUnitTest() { private lateinit var sut: WalletViewModel @@ -36,6 +38,8 @@ class WalletViewModelTest : BaseUnitTest() { private val blocktankRepo: BlocktankRepo = mock() private val mockLightningState = MutableStateFlow(LightningState()) private val mockWalletState = MutableStateFlow(WalletState()) + private val mockBalanceState = MutableStateFlow(BalanceState()) + private val mockIsRecoveryMode = MutableStateFlow(false) @Before fun setUp() { @@ -159,7 +163,7 @@ class WalletViewModelTest : BaseUnitTest() { @Test fun `backup restore should not be triggered when wallet exists while not restoring`() = test { - assertEquals(RestoreState.NotRestoring, sut.restoreState) + assertEquals(RestoreState.Initial, sut.restoreState) mockWalletState.value = mockWalletState.value.copy(walletExists = true) @@ -171,39 +175,113 @@ class WalletViewModelTest : BaseUnitTest() { whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) mockWalletState.value = mockWalletState.value.copy(walletExists = true) sut.restoreWallet("mnemonic", "passphrase") - assertEquals(RestoreState.RestoringWallet, sut.restoreState) + assertEquals(RestoreState.InProgress.Wallet, sut.restoreState) sut.onRestoreContinue() - assertEquals(RestoreState.NotRestoring, sut.restoreState) + assertEquals(RestoreState.Settled, sut.restoreState) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun `proceedWithoutRestore should exit restore flow`() = test { val testError = Exception("Test error") whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.failure(testError)) sut.restoreWallet("mnemonic", "passphrase") mockWalletState.value = mockWalletState.value.copy(walletExists = true) - assertEquals(RestoreState.BackupRestoreCompleted, sut.restoreState) + assertEquals(RestoreState.Completed, sut.restoreState) sut.proceedWithoutRestore(onDone = {}) advanceUntilIdle() - assertEquals(RestoreState.NotRestoring, sut.restoreState) + assertEquals(RestoreState.Settled, sut.restoreState) } @Test fun `restore state should transition as expected`() = test { whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) - assertEquals(RestoreState.NotRestoring, sut.restoreState) + assertEquals(RestoreState.Initial, sut.restoreState) sut.restoreWallet("mnemonic", "passphrase") - assertEquals(RestoreState.RestoringWallet, sut.restoreState) + assertEquals(RestoreState.InProgress.Wallet, sut.restoreState) mockWalletState.value = mockWalletState.value.copy(walletExists = true) - assertEquals(RestoreState.BackupRestoreCompleted, sut.restoreState) + assertEquals(RestoreState.Completed, sut.restoreState) sut.onRestoreContinue() - assertEquals(RestoreState.NotRestoring, sut.restoreState) + assertEquals(RestoreState.Settled, sut.restoreState) + } + + @Test + fun `start should call refreshBip21 when restore state is idle`() = test { + // Create fresh mocks for this test + val testWalletRepo: WalletRepo = mock() + val testLightningRepo: LightningRepo = mock() + + // Create a wallet state with walletExists = true + val testWalletState = MutableStateFlow(WalletState(walletExists = true)) + + // Set up mocks BEFORE creating SUT + whenever(testWalletRepo.walletState).thenReturn(testWalletState) + whenever(testWalletRepo.balanceState).thenReturn(mockBalanceState) + whenever(testWalletRepo.walletExists()).thenReturn(true) + whenever(testLightningRepo.lightningState).thenReturn(mockLightningState) + whenever(testLightningRepo.isRecoveryMode).thenReturn(mockIsRecoveryMode) + whenever(testLightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(Unit)) + + val testSut = WalletViewModel( + bgDispatcher = testDispatcher, + walletRepo = testWalletRepo, + lightningRepo = testLightningRepo, + settingsStore = settingsStore, + backupRepo = backupRepo, + blocktankRepo = blocktankRepo, + ) + + assertEquals(RestoreState.Initial, testSut.restoreState) + assertEquals(true, testSut.walletExists) + + testSut.start() + advanceUntilIdle() + + verify(testLightningRepo).start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull()) + verify(testWalletRepo).refreshBip21() + } + + @Test + fun `start should skip refreshBip21 when restore is in progress`() = test { + // Create fresh mocks for this test + val testWalletRepo: WalletRepo = mock() + val testLightningRepo: LightningRepo = mock() + + // Create wallet state with walletExists = true so start() doesn't return early + val testWalletState = MutableStateFlow(WalletState(walletExists = true)) + + // Set up mocks BEFORE creating SUT + whenever(testWalletRepo.walletState).thenReturn(testWalletState) + whenever(testWalletRepo.balanceState).thenReturn(mockBalanceState) + whenever(testWalletRepo.walletExists()).thenReturn(true) + whenever(testWalletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.success(Unit)) + whenever(testLightningRepo.lightningState).thenReturn(mockLightningState) + whenever(testLightningRepo.isRecoveryMode).thenReturn(mockIsRecoveryMode) + whenever(testLightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(Unit)) + + val testSut = WalletViewModel( + bgDispatcher = testDispatcher, + walletRepo = testWalletRepo, + lightningRepo = testLightningRepo, + settingsStore = settingsStore, + backupRepo = backupRepo, + blocktankRepo = blocktankRepo, + ) + + // Trigger restore to put state in non-idle + testSut.restoreWallet("mnemonic", null) + assertEquals(RestoreState.InProgress.Wallet, testSut.restoreState) + + testSut.start() + advanceUntilIdle() + + verify(testWalletRepo, never()).refreshBip21() } } From 37c19294ab7e7c72e26966e3a135a9694708d6a6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 2 Dec 2025 05:48:27 +0100 Subject: [PATCH 21/32] refactor: move tx sync to handler --- .../domain/commands/NotifyPaymentReceived.kt | 25 +++----- .../commands/NotifyPaymentReceivedHandler.kt | 20 ++++--- .../java/to/bitkit/viewmodels/AppViewModel.kt | 19 +----- .../NotifyPaymentReceivedHandlerTest.kt | 60 +++++++++++++++---- 4 files changed, 74 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt index 1d3c5e35e..a832b2c23 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt @@ -7,19 +7,15 @@ import to.bitkit.models.NotificationDetails sealed interface NotifyPaymentReceived { sealed interface Command : NotifyPaymentReceived { - val sats: ULong - val paymentHashOrTxId: String val includeNotification: Boolean data class Lightning( - override val sats: ULong, - override val paymentHashOrTxId: String, + val event: Event.PaymentReceived, override val includeNotification: Boolean = false, ) : Command data class Onchain( - override val sats: ULong, - override val paymentHashOrTxId: String, + val event: Event.OnchainTransactionReceived, override val includeNotification: Boolean = false, ) : Command @@ -27,20 +23,15 @@ sealed interface NotifyPaymentReceived { fun from(event: Event, includeNotification: Boolean = false): Command? = when (event) { is Event.PaymentReceived -> Lightning( - sats = event.amountMsat / 1000u, - paymentHashOrTxId = event.paymentHash, + event = event, includeNotification = includeNotification, ) - is Event.OnchainTransactionReceived -> { - val amountSats = event.details.amountSats - Onchain( - sats = amountSats.toULong(), - paymentHashOrTxId = event.txid, - includeNotification = includeNotification, - ).takeIf { - amountSats > 0 - } + is Event.OnchainTransactionReceived -> Onchain( + event = event, + includeNotification = includeNotification, + ).takeIf { + event.details.amountSats > 0 } else -> null diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index 65ac759dd..6563e9178 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -38,26 +38,32 @@ class NotifyPaymentReceivedHandler @Inject constructor( val shouldShow = when (command) { is NotifyPaymentReceived.Command.Lightning -> true is NotifyPaymentReceived.Command.Onchain -> { + activityRepo.handleOnchainTransactionReceived(command.event.txid, command.event.details) delay(DELAY_FOR_ACTIVITY_SYNC_MS) - activityRepo.shouldShowReceivedSheet(command.paymentHashOrTxId, command.sats) + activityRepo.shouldShowReceivedSheet(command.event.txid, command.event.details.amountSats.toULong()) } } if (!shouldShow) return@runCatching NotifyPaymentReceived.Result.Skip - val satsLong = command.sats.toLong() val details = NewTransactionSheetDetails( type = when (command) { is NotifyPaymentReceived.Command.Lightning -> NewTransactionSheetType.LIGHTNING is NotifyPaymentReceived.Command.Onchain -> NewTransactionSheetType.ONCHAIN }, direction = NewTransactionSheetDirection.RECEIVED, - paymentHashOrTxId = command.paymentHashOrTxId, - sats = satsLong, + paymentHashOrTxId = when (command) { + is NotifyPaymentReceived.Command.Lightning -> command.event.paymentHash + is NotifyPaymentReceived.Command.Onchain -> command.event.txid + }, + sats = when (command) { + is NotifyPaymentReceived.Command.Lightning -> (command.event.amountMsat / 1000u).toLong() + is NotifyPaymentReceived.Command.Onchain -> command.event.details.amountSats + }, ) if (command.includeNotification) { - val notification = buildNotificationContent(satsLong) + val notification = buildNotificationContent(details.sats) NotifyPaymentReceived.Result.ShowNotification(details, notification) } else { NotifyPaymentReceived.Result.ShowSheet(details) @@ -97,8 +103,8 @@ class NotifyPaymentReceivedHandler @Inject constructor( const val TAG = "NotifyPaymentReceivedHandler" /** - * Delay before calling `shouldShowPaymentReceived` for onchain transactions to allow ActivityRepo - * to sync payments before we check for RBF replacement or channel closure. + * Delay after syncing onchain transaction to allow the database to fully process + * the transaction before checking for RBF replacement or channel closure. */ private const val DELAY_FOR_ACTIVITY_SYNC_MS = 500L } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 96e55f99d..27f73b0cc 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -275,17 +275,7 @@ class AppViewModel @Inject constructor( } private suspend fun handleOnchainTransactionReceived(event: Event.OnchainTransactionReceived) { - activityRepo.handleOnchainTransactionReceived(event.txid, event.details) - if (event.details.amountSats > 0) { - val sats = event.details.amountSats.toULong() - viewModelScope.launch { - delay(DELAY_FOR_ACTIVITY_SYNC_MS) - val shouldShow = activityRepo.shouldShowReceivedSheet(event.txid, sats) - if (shouldShow) { - notifyPaymentReceived(event) - } - } - } + notifyPaymentReceived(event) } private suspend fun handleOnchainTransactionReorged(event: Event.OnchainTransactionReorged) { @@ -358,12 +348,12 @@ class AppViewModel @Inject constructor( if (command is NotifyPaymentReceived.Command.Lightning) { val cachedId = cacheStore.data.first().lastLightningPaymentId // Skip if this is a replay by ldk-node on startup - if (command.paymentHashOrTxId == cachedId) { + if (command.event.paymentHash == cachedId) { Logger.debug("Skipping notification for replayed event: $event", context = TAG) return } // Cache to skip later as needed - cacheStore.setLastLightningPayment(command.paymentHashOrTxId) + cacheStore.setLastLightningPayment(command.event.paymentHash) } val result = notifyPaymentReceivedHandler(command).getOrNull() @@ -1948,9 +1938,6 @@ class AppViewModel @Inject constructor( /**How long user needs to stay on the home screen before he see this prompt*/ private const val CHECK_DELAY_MILLIS = 2000L - - /** Delay to allow activity sync before checking if received sheet should be shown */ - private const val DELAY_FOR_ACTIVITY_SYNC_MS = 500L } } diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt index b49c97650..a101f9d39 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -4,8 +4,12 @@ import android.content.Context import kotlinx.coroutines.flow.flowOf import org.junit.Before import org.junit.Test +import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.TransactionDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -62,7 +66,11 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { @Test fun `lightning payment returns ShowSheet`() = test { - val command = NotifyPaymentReceived.Command.Lightning(sats = 1000uL, paymentHashOrTxId = "hash123") + val event = mock { + on { amountMsat } doReturn 1000000uL + on { paymentHash } doReturn "hash123" + } + val command = NotifyPaymentReceived.Command.Lightning(event = event) val result = sut(command) @@ -77,9 +85,12 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { @Test fun `lightning payment returns ShowNotification when includeNotification is true`() = test { + val event = mock { + on { amountMsat } doReturn 1000000uL + on { paymentHash } doReturn "hash123" + } val command = NotifyPaymentReceived.Command.Lightning( - sats = 1000uL, - paymentHashOrTxId = "hash123", + event = event, includeNotification = true, ) @@ -96,8 +107,15 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { @Test fun `onchain payment returns ShowSheet when shouldShowReceivedSheet returns true`() = test { + val details = mock { + on { amountSats } doReturn 5000L + } + val event = mock { + on { txid } doReturn "txid456" + on { this.details } doReturn details + } whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) - val command = NotifyPaymentReceived.Command.Onchain(sats = 5000uL, paymentHashOrTxId = "txid456") + val command = NotifyPaymentReceived.Command.Onchain(event = event) val result = sut(command) @@ -112,8 +130,15 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { @Test fun `onchain payment returns Skip when shouldShowReceivedSheet is false`() = test { + val details = mock { + on { amountSats } doReturn 5000L + } + val event = mock { + on { txid } doReturn "txid456" + on { this.details } doReturn details + } whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(false) - val command = NotifyPaymentReceived.Command.Onchain(sats = 5000uL, paymentHashOrTxId = "txid456") + val command = NotifyPaymentReceived.Command.Onchain(event = event) val result = sut(command) @@ -123,21 +148,36 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { } @Test - fun `onchain payment calls shouldShowReceivedSheet with correct parameters`() = test { + fun `onchain payment calls handleOnchainTransactionReceived before shouldShowReceivedSheet`() = test { + val details = mock { + on { amountSats } doReturn 7500L + } + val event = mock { + on { txid } doReturn "txid789" + on { this.details } doReturn details + } whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) - val command = NotifyPaymentReceived.Command.Onchain(sats = 7500uL, paymentHashOrTxId = "txid789") + val command = NotifyPaymentReceived.Command.Onchain(event = event) sut(command) - verify(activityRepo).shouldShowReceivedSheet("txid789", 7500uL) + inOrder(activityRepo) { + verify(activityRepo).handleOnchainTransactionReceived("txid789", details) + verify(activityRepo).shouldShowReceivedSheet("txid789", 7500uL) + } } @Test - fun `lightning payment does not call shouldShowReceivedSheet`() = test { - val command = NotifyPaymentReceived.Command.Lightning(sats = 1000uL, paymentHashOrTxId = "hash123") + fun `lightning payment does not call onchain-specific methods`() = test { + val event = mock { + on { amountMsat } doReturn 1000000uL + on { paymentHash } doReturn "hash123" + } + val command = NotifyPaymentReceived.Command.Lightning(event = event) sut(command) + verify(activityRepo, never()).handleOnchainTransactionReceived(any(), any()) verify(activityRepo, never()).shouldShowReceivedSheet(any(), any()) } } From 59f492f92486c65bbd67620f6616c7793f550b1f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 2 Dec 2025 06:24:30 +0100 Subject: [PATCH 22/32] revert: remove unnecessary bolt11 update timeout This reverts the workaround added in eb60e688 which waited up to 5s for bolt11 state updates before closing the receive sheet. The timeout was unnecessary because: - updateBip21Invoice() already updates state synchronously - StateFlow updates propagate immediately via .update {} - By the time the suspend function returns, state is already updated The 5-second timeout was a code smell suggesting a race condition that doesn't actually exist in the architecture. --- .../wallets/receive/EditInvoiceScreen.kt | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt index 6d470769a..660d311a1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt @@ -27,9 +27,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -42,10 +40,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.withTimeoutOrNull import to.bitkit.R import to.bitkit.repositories.CurrencyState import to.bitkit.repositories.WalletState @@ -72,7 +66,6 @@ import to.bitkit.ui.utils.keyboardAsState import to.bitkit.utils.Logger import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.previewAmountInputViewModel -import kotlin.time.Duration.Companion.seconds @Suppress("ViewModelForwarding") @Composable @@ -92,7 +85,6 @@ fun EditInvoiceScreen( var keyboardVisible by remember { mutableStateOf(false) } var isSoftKeyboardVisible by keyboardAsState() val amountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() - val latestWalletState = rememberUpdatedState(walletUiState) LaunchedEffect(Unit) { editInvoiceVM.editInvoiceEffect.collect { effect -> @@ -124,17 +116,7 @@ fun EditInvoiceScreen( } EditInvoiceVM.EditInvoiceScreenEffects.UpdateInvoice -> { - val previousBolt11 = latestWalletState.value.bolt11 updateInvoice(receiveSats) - val updated = withTimeoutOrNull(5.seconds) { - snapshotFlow { latestWalletState.value.bolt11 } - .distinctUntilChanged() - .filter { it.isNotEmpty() && it != previousBolt11 } - .first() - } - if (updated == null) { - Logger.warn("Timed out waiting for invoice update", context = "EditInvoiceScreen") - } onBack() } } From d048c0086205a1e8b0dc95be166244846eed3118 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 2 Dec 2025 06:39:02 +0100 Subject: [PATCH 23/32] chore: lint --- app/src/main/java/to/bitkit/ui/ContentView.kt | 278 +++++++++--------- app/src/main/java/to/bitkit/ui/Locals.kt | 2 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../java/to/bitkit/ui/WalletViewModelTest.kt | 2 +- 4 files changed, 145 insertions(+), 139 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index c47947ed6..ac9102185 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -184,6 +184,7 @@ import to.bitkit.viewmodels.SettingsViewModel import to.bitkit.viewmodels.TransferViewModel import to.bitkit.viewmodels.WalletViewModel +@Suppress("CyclomaticComplexMethod") @Composable fun ContentView( appViewModel: AppViewModel, @@ -194,6 +195,7 @@ fun ContentView( transferViewModel: TransferViewModel, settingsViewModel: SettingsViewModel, backupsViewModel: BackupsViewModel, + modifier: Modifier = Modifier, ) { val navController = rememberNavController() val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) @@ -322,164 +324,166 @@ fun ContentView( isRestoring = restoreState.isOngoing(), ) } + return } else if (restoreState is RestoreState.Completed) { WalletRestoreSuccessView( onContinue = { walletViewModel.onRestoreContinue() }, ) - } else { - val balance by walletViewModel.balanceState.collectAsStateWithLifecycle() - val currencies by currencyViewModel.uiState.collectAsState() - - // Keep backups in sync - LaunchedEffect(backupsViewModel) { backupsViewModel.observeAndSyncBackups() } - - CompositionLocalProvider( - LocalAppViewModel provides appViewModel, - LocalWalletViewModel provides walletViewModel, - LocalBlocktankViewModel provides blocktankViewModel, - LocalCurrencyViewModel provides currencyViewModel, - LocalActivityListViewModel provides activityListViewModel, - LocalTransferViewModel provides transferViewModel, - LocalSettingsViewModel provides settingsViewModel, - LocalBackupsViewModel provides backupsViewModel, - LocalDrawerState provides drawerState, - LocalBalances provides balance, - LocalCurrencies provides currencies, + return + } + + val balance by walletViewModel.balanceState.collectAsStateWithLifecycle() + val currencies by currencyViewModel.uiState.collectAsState() + + // Keep backups in sync + LaunchedEffect(backupsViewModel) { backupsViewModel.observeAndSyncBackups() } + + CompositionLocalProvider( + LocalAppViewModel provides appViewModel, + LocalWalletViewModel provides walletViewModel, + LocalBlocktankViewModel provides blocktankViewModel, + LocalCurrencyViewModel provides currencyViewModel, + LocalActivityListViewModel provides activityListViewModel, + LocalTransferViewModel provides transferViewModel, + LocalSettingsViewModel provides settingsViewModel, + LocalBackupsViewModel provides backupsViewModel, + LocalDrawerState provides drawerState, + LocalBalances provides balance, + LocalCurrencies provides currencies, + ) { + AutoReadClipboardHandler() + + val hasSeenWidgetsIntro by settingsViewModel.hasSeenWidgetsIntro.collectAsStateWithLifecycle() + val hasSeenShopIntro by settingsViewModel.hasSeenShopIntro.collectAsStateWithLifecycle() + + val currentSheet by appViewModel.currentSheet.collectAsStateWithLifecycle() + val hazeState = rememberHazeState() + + Box( + modifier = modifier.fillMaxSize() ) { - AutoReadClipboardHandler() + SheetHost( + shouldExpand = currentSheet != null, + onDismiss = { appViewModel.hideSheet() }, + sheets = { + when (val sheet = currentSheet) { + null -> Unit + is Sheet.Send -> { + SendSheet( + appViewModel = appViewModel, + walletViewModel = walletViewModel, + startDestination = sheet.route, + ) + } - val hasSeenWidgetsIntro by settingsViewModel.hasSeenWidgetsIntro.collectAsStateWithLifecycle() - val hasSeenShopIntro by settingsViewModel.hasSeenShopIntro.collectAsStateWithLifecycle() + is Sheet.Receive -> { + val walletUiState by walletViewModel.uiState.collectAsState() + ReceiveSheet( + walletState = walletUiState, + navigateToExternalConnection = { + navController.navigate(ExternalConnection()) + appViewModel.hideSheet() + } + ) + } - val currentSheet by appViewModel.currentSheet.collectAsStateWithLifecycle() - val hazeState = rememberHazeState() + is Sheet.ActivityDateRangeSelector -> DateRangeSelectorSheet() + is Sheet.ActivityTagSelector -> TagSelectorSheet() + is Sheet.Pin -> PinSheet(sheet, appViewModel) + is Sheet.Backup -> BackupSheet(sheet, onDismiss = { appViewModel.hideSheet() }) + is Sheet.LnurlAuth -> LnurlAuthSheet(sheet, appViewModel) + Sheet.ForceTransfer -> ForceTransferSheet(appViewModel, transferViewModel) + is Sheet.Gift -> GiftSheet(sheet, appViewModel) + is Sheet.TimedSheet -> { + when (sheet.type) { + TimedSheetType.APP_UPDATE -> { + UpdateSheet(onCancel = { appViewModel.dismissTimedSheet() }) + } - Box( - modifier = Modifier.fillMaxSize() - ) { - SheetHost( - shouldExpand = currentSheet != null, - onDismiss = { appViewModel.hideSheet() }, - sheets = { - when (val sheet = currentSheet) { - null -> Unit - is Sheet.Send -> { - SendSheet( - appViewModel = appViewModel, - walletViewModel = walletViewModel, - startDestination = sheet.route, - ) - } + TimedSheetType.BACKUP -> { + BackupSheet( + sheet = Sheet.Backup(BackupRoute.Intro), + onDismiss = { appViewModel.dismissTimedSheet() } + ) + } - is Sheet.Receive -> { - val walletUiState by walletViewModel.uiState.collectAsState() - ReceiveSheet( - walletState = walletUiState, - navigateToExternalConnection = { - navController.navigate(ExternalConnection()) - appViewModel.hideSheet() - } - ) - } + TimedSheetType.NOTIFICATIONS -> { + BackgroundPaymentsIntroSheet( + onContinue = { + appViewModel.dismissTimedSheet(skipQueue = true) + navController.navigate(Routes.BackgroundPaymentsSettings) + settingsViewModel.setBgPaymentsIntroSeen(true) + }, + ) + } + + TimedSheetType.QUICK_PAY -> { + QuickPayIntroSheet( + onContinue = { + appViewModel.dismissTimedSheet(skipQueue = true) + navController.navigate(Routes.QuickPaySettings) + }, + ) + } - is Sheet.ActivityDateRangeSelector -> DateRangeSelectorSheet() - is Sheet.ActivityTagSelector -> TagSelectorSheet() - is Sheet.Pin -> PinSheet(sheet, appViewModel) - is Sheet.Backup -> BackupSheet(sheet, onDismiss = { appViewModel.hideSheet() }) - is Sheet.LnurlAuth -> LnurlAuthSheet(sheet, appViewModel) - Sheet.ForceTransfer -> ForceTransferSheet(appViewModel, transferViewModel) - is Sheet.Gift -> GiftSheet(sheet, appViewModel) - is Sheet.TimedSheet -> { - when (sheet.type) { - TimedSheetType.APP_UPDATE -> { - UpdateSheet(onCancel = { appViewModel.dismissTimedSheet() }) - } - - TimedSheetType.BACKUP -> { - BackupSheet( - sheet = Sheet.Backup(BackupRoute.Intro), - onDismiss = { appViewModel.dismissTimedSheet() } - ) - } - - TimedSheetType.NOTIFICATIONS -> { - BackgroundPaymentsIntroSheet( - onContinue = { - appViewModel.dismissTimedSheet(skipQueue = true) - navController.navigate(Routes.BackgroundPaymentsSettings) - settingsViewModel.setBgPaymentsIntroSeen(true) - }, - ) - } - - TimedSheetType.QUICK_PAY -> { - QuickPayIntroSheet( - onContinue = { - appViewModel.dismissTimedSheet(skipQueue = true) - navController.navigate(Routes.QuickPaySettings) - }, - ) - } - - TimedSheetType.HIGH_BALANCE -> { - HighBalanceWarningSheet( - understoodClick = { appViewModel.dismissTimedSheet() }, - learnMoreClick = { - val intent = - Intent(Intent.ACTION_VIEW, Env.STORING_BITCOINS_URL.toUri()) - context.startActivity(intent) - appViewModel.dismissTimedSheet(skipQueue = true) - } - ) - } + TimedSheetType.HIGH_BALANCE -> { + HighBalanceWarningSheet( + understoodClick = { appViewModel.dismissTimedSheet() }, + learnMoreClick = { + val intent = + Intent(Intent.ACTION_VIEW, Env.STORING_BITCOINS_URL.toUri()) + context.startActivity(intent) + appViewModel.dismissTimedSheet(skipQueue = true) + } + ) } } } } - ) { - RootNavHost( - navController = navController, - drawerState = drawerState, - walletViewModel = walletViewModel, - appViewModel = appViewModel, - activityListViewModel = activityListViewModel, - settingsViewModel = settingsViewModel, - currencyViewModel = currencyViewModel, - transferViewModel = transferViewModel, - ) } + ) { + RootNavHost( + navController = navController, + drawerState = drawerState, + walletViewModel = walletViewModel, + appViewModel = appViewModel, + activityListViewModel = activityListViewModel, + settingsViewModel = settingsViewModel, + currencyViewModel = currencyViewModel, + transferViewModel = transferViewModel, + ) + } - val navBackStackEntry by navController.currentBackStackEntryAsState() - - val currentRoute = navBackStackEntry?.destination?.route + val navBackStackEntry by navController.currentBackStackEntryAsState() - val showTabBar = currentRoute in listOf( - Routes.Home::class.qualifiedName, - Routes.AllActivity::class.qualifiedName, - ) + val currentRoute = navBackStackEntry?.destination?.route - AnimatedVisibility( - visible = showTabBar && currentSheet == null, - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - ) { - TabBar( - hazeState = hazeState, - onSendClick = { appViewModel.showSheet(Sheet.Send()) }, - onReceiveClick = { appViewModel.showSheet(Sheet.Receive) }, - onScanClick = { navController.navigateToScanner() }, - ) - } + val showTabBar = currentRoute in listOf( + Routes.Home::class.qualifiedName, + Routes.AllActivity::class.qualifiedName, + ) - DrawerMenu( - drawerState = drawerState, - rootNavController = navController, - hasSeenWidgetsIntro = hasSeenWidgetsIntro, - hasSeenShopIntro = hasSeenShopIntro, - modifier = Modifier.align(Alignment.TopEnd), + AnimatedVisibility( + visible = showTabBar && currentSheet == null, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + ) { + TabBar( + hazeState = hazeState, + onSendClick = { appViewModel.showSheet(Sheet.Send()) }, + onReceiveClick = { appViewModel.showSheet(Sheet.Receive) }, + onScanClick = { navController.navigateToScanner() }, ) } + + DrawerMenu( + drawerState = drawerState, + rootNavController = navController, + hasSeenWidgetsIntro = hasSeenWidgetsIntro, + hasSeenShopIntro = hasSeenShopIntro, + modifier = Modifier.align(Alignment.TopEnd), + ) } } } diff --git a/app/src/main/java/to/bitkit/ui/Locals.kt b/app/src/main/java/to/bitkit/ui/Locals.kt index 2843e38c4..0d8e7397f 100644 --- a/app/src/main/java/to/bitkit/ui/Locals.kt +++ b/app/src/main/java/to/bitkit/ui/Locals.kt @@ -1,3 +1,5 @@ +@file:Suppress("CompositionLocalAllowlist") + package to.bitkit.ui import androidx.compose.material3.DrawerState diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 27f73b0cc..7a32e8e0f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -644,7 +644,7 @@ class AppViewModel @Inject constructor( resetSendState() resetQuickPayData() - // TODO: wrap the bindings `decode` fn in a `CoreService` method and call it from here + @Suppress("ForbiddenComment") // TODO: wrap `decode` from bindings in a `CoreService` method and call that one val scan = runCatching { decode(result) } .onFailure { Logger.error("Failed to decode scan data: '$result'", it, context = TAG) } .onSuccess { Logger.info("Handling decoded scan data: $it", context = TAG) } diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index c065efc86..dc83fc88a 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -15,13 +15,13 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.SettingsStore import to.bitkit.ext.from +import to.bitkit.models.BalanceState import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WalletState -import to.bitkit.models.BalanceState import to.bitkit.test.BaseUnitTest import to.bitkit.viewmodels.RestoreState import to.bitkit.viewmodels.WalletViewModel From 22ea407546f89fd7b69064d53d8e221c0b027ff0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 2 Dec 2025 16:19:27 +0100 Subject: [PATCH 24/32] fix: keep timed sheets behind transaction sheet --- .../models/NewTransactionSheetDetails.kt | 9 +++- .../main/java/to/bitkit/ui/MainActivity.kt | 5 +- .../ui/screens/transfer/SettingUpScreen.kt | 4 +- .../java/to/bitkit/ui/sheets/GiftSheet.kt | 2 +- .../bitkit/ui/sheets/NewTransactionSheet.kt | 4 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 52 +++++++------------ 6 files changed, 36 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt index 71b43495a..c99d94bd3 100644 --- a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt +++ b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt @@ -11,7 +11,14 @@ data class NewTransactionSheetDetails( val paymentHashOrTxId: String? = null, val sats: Long = 0, val isLoadingDetails: Boolean = false, -) +) { + companion object { + val EMPTY = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + ) + } +} @Serializable enum class NewTransactionSheetType { diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 90b8e9eef..9760415a2 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import to.bitkit.androidServices.LightningNodeService import to.bitkit.androidServices.LightningNodeService.Companion.CHANNEL_ID_NODE +import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.ui.components.AuthCheckView import to.bitkit.ui.components.InactivityTracker import to.bitkit.ui.components.IsOnlineTracker @@ -168,8 +169,8 @@ class MainActivity : FragmentActivity() { } ) - val showNewTransaction by appViewModel.showNewTransaction.collectAsStateWithLifecycle() - if (showNewTransaction) { + val transactionSheetDetails by appViewModel.transactionSheet.collectAsStateWithLifecycle() + if (transactionSheetDetails != NewTransactionSheetDetails.EMPTY) { NewTransactionSheet( appViewModel = appViewModel, currencyViewModel = currencyViewModel, diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SettingUpScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SettingUpScreen.kt index 983d08a02..9ccb4a8ee 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SettingUpScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SettingUpScreen.kt @@ -69,9 +69,9 @@ fun SettingUpScreen( // Effect to disable new transaction sheet for channel purchase DisposableEffect(Unit) { - app.setNewTransactionSheetEnabled(false) + app.enabledTransactionSheet(false) onDispose { - app.setNewTransactionSheetEnabled(true) + app.enabledTransactionSheet(true) } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftSheet.kt index 2384625db..380d9d8f4 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/GiftSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/GiftSheet.kt @@ -33,7 +33,7 @@ fun GiftSheet( val onSuccessState = rememberUpdatedState { details: NewTransactionSheetDetails -> appViewModel.hideSheet() - appViewModel.showNewTransactionSheet(details) + appViewModel.showTransactionSheet(details) } LaunchedEffect(Unit) { diff --git a/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt index 52963fa70..c68b93754 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt @@ -57,7 +57,7 @@ fun NewTransactionSheet( modifier: Modifier = Modifier, ) { val currencies by currencyViewModel.uiState.collectAsState() - val newTransaction by appViewModel.newTransaction.collectAsState() + val details by appViewModel.transactionSheet.collectAsState() CompositionLocalProvider( LocalCurrencyViewModel provides currencyViewModel, @@ -68,7 +68,7 @@ fun NewTransactionSheet( onDismissRequest = { appViewModel.hideNewTransactionSheet() }, ) { NewTransactionSheetView( - details = newTransaction, + details = details, onCloseClick = { appViewModel.hideNewTransactionSheet() }, onDetailClick = { appViewModel.onClickActivityDetail() diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 7a32e8e0f..9725866a2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -37,7 +37,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first @@ -316,7 +315,7 @@ class AppViewModel @Inject constructor( val cjitEntry = channel?.let { blocktankRepo.getCjitEntry(it) } if (cjitEntry != null) { val amount = channel.amountOnClose.toLong() - showNewTransactionSheet( + showTransactionSheet( NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.RECEIVED, @@ -359,7 +358,7 @@ class AppViewModel @Inject constructor( val result = notifyPaymentReceivedHandler(command).getOrNull() if (result !is NotifyPaymentReceived.Result.ShowSheet) return - showNewTransactionSheet(result.sheet) + showTransactionSheet(result.sheet) } private fun notifyTransactionUnconfirmed() = toast( @@ -1223,10 +1222,10 @@ class AppViewModel @Inject constructor( } fun onClickActivityDetail() { - val activityType = _newTransaction.value.type.toActivityFilter() - val txType = _newTransaction.value.direction.toTxType() - val paymentHashOrTxId = _newTransaction.value.paymentHashOrTxId ?: return - _newTransaction.update { it.copy(isLoadingDetails = true) } + val activityType = _transactionSheet.value.type.toActivityFilter() + val txType = _transactionSheet.value.direction.toTxType() + val paymentHashOrTxId = _transactionSheet.value.paymentHashOrTxId ?: return + _transactionSheet.update { it.copy(isLoadingDetails = true) } viewModelScope.launch { activityRepo.findActivityByPaymentId( paymentHashOrTxId = paymentHashOrTxId, @@ -1235,13 +1234,13 @@ class AppViewModel @Inject constructor( retry = true ).onSuccess { activity -> hideNewTransactionSheet() - _newTransaction.update { it.copy(isLoadingDetails = false) } + _transactionSheet.update { it.copy(isLoadingDetails = false) } val nextRoute = Routes.ActivityDetail(activity.rawId()) mainScreenEffect(MainScreenEffect.Navigate(nextRoute)) }.onFailure { e -> Logger.error(msg = "Activity not found", context = TAG) toast(e) - _newTransaction.update { it.copy(isLoadingDetails = false) } + _transactionSheet.update { it.copy(isLoadingDetails = false) } } } } @@ -1446,18 +1445,10 @@ class AppViewModel @Inject constructor( // endregion // region TxSheet - private var _isNewTransactionSheetEnabled = true - private val _showNewTransaction = MutableStateFlow(false) - val showNewTransaction: StateFlow = _showNewTransaction.asStateFlow() + private var _isTransactionSheetEnabled = true - private val _newTransaction = MutableStateFlow( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.RECEIVED, - ) - ) - - val newTransaction = _newTransaction.asStateFlow() + private val _transactionSheet = MutableStateFlow(NewTransactionSheetDetails.EMPTY) + val transactionSheet = _transactionSheet.asStateFlow() private val _successSendUiState = MutableStateFlow( NewTransactionSheetDetails( @@ -1468,32 +1459,31 @@ class AppViewModel @Inject constructor( val successSendUiState = _successSendUiState.asStateFlow() - fun setNewTransactionSheetEnabled(enabled: Boolean) { - _isNewTransactionSheetEnabled = enabled + fun enabledTransactionSheet(enabled: Boolean) { + _isTransactionSheetEnabled = enabled } - fun showNewTransactionSheet( + fun showTransactionSheet( details: NewTransactionSheetDetails, ) = viewModelScope.launch { if (backupRepo.isRestoring.value) return@launch - if (!_isNewTransactionSheetEnabled) { + if (!_isTransactionSheetEnabled) { Logger.verbose("NewTransactionSheet blocked by isNewTransactionSheetEnabled=false", context = TAG) return@launch } - hideSheet() - - _showNewTransaction.update { true } - _newTransaction.update { details } + _transactionSheet.update { details } } - fun hideNewTransactionSheet() = _showNewTransaction.update { false } + fun hideNewTransactionSheet() { + _transactionSheet.update { NewTransactionSheetDetails.EMPTY } + } fun consumePaymentReceivedInBackground() = viewModelScope.launch(bgDispatcher) { val details = cacheStore.data.first().backgroundReceive ?: return@launch cacheStore.clearBackgroundReceive() - showNewTransactionSheet(details) + showTransactionSheet(details) } // endregion @@ -1714,8 +1704,6 @@ class AppViewModel @Inject constructor( return } - if (backupRepo.isRestoring.value) return - timedSheetsScope?.cancel() timedSheetsScope = CoroutineScope(bgDispatcher + SupervisorJob()) timedSheetsScope?.launch { From 610a24b2e5cb94553a9b8b3104e3972ed7efdda1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 2 Dec 2025 17:13:55 +0100 Subject: [PATCH 25/32] fix: always handle OnchainTransactionReceived --- .../to/bitkit/domain/commands/NotifyPaymentReceived.kt | 10 ++++++---- .../domain/commands/NotifyPaymentReceivedHandler.kt | 8 ++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt index a832b2c23..7736a8d07 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt @@ -3,6 +3,7 @@ package to.bitkit.domain.commands import org.lightningdevkit.ldknode.Event import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NotificationDetails +import to.bitkit.utils.Logger sealed interface NotifyPaymentReceived { @@ -30,11 +31,12 @@ sealed interface NotifyPaymentReceived { is Event.OnchainTransactionReceived -> Onchain( event = event, includeNotification = includeNotification, - ).takeIf { - event.details.amountSats > 0 - } + ) - else -> null + else -> { + Logger.warn("Unknown event type: ${event::class.simpleName}") + null + } } } } diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index 6563e9178..c382f87f5 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -39,8 +39,12 @@ class NotifyPaymentReceivedHandler @Inject constructor( is NotifyPaymentReceived.Command.Lightning -> true is NotifyPaymentReceived.Command.Onchain -> { activityRepo.handleOnchainTransactionReceived(command.event.txid, command.event.details) - delay(DELAY_FOR_ACTIVITY_SYNC_MS) - activityRepo.shouldShowReceivedSheet(command.event.txid, command.event.details.amountSats.toULong()) + if (command.event.details.amountSats > 0) { + delay(DELAY_FOR_ACTIVITY_SYNC_MS) + activityRepo.shouldShowReceivedSheet(command.event.txid, command.event.details.amountSats.toULong()) + } else { + false + } } } From 85c1e5ae105099b2728bef97fc89f14913230c25 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 2 Dec 2025 17:47:26 +0100 Subject: [PATCH 26/32] refactor: unify bg receive handler --- .../to/bitkit/androidServices/LightningNodeService.kt | 10 +++------- .../to/bitkit/domain/commands/NotifyPaymentReceived.kt | 5 +---- app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 2 +- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 46ea9bb45..ba9355716 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -60,6 +60,7 @@ class LightningNodeService : Service() { lightningRepo.start( eventHandler = { event -> walletRepo.refreshBip21ForEvent(event) + handlePaymentReceived(event) } ).onSuccess { val notification = createNotification() @@ -70,16 +71,11 @@ class LightningNodeService : Service() { walletRepo.syncBalances() } } - - launch { - ldkNodeEventBus.events.collect { event -> - handleBackgroundEvent(event) - } - } } } - private suspend fun handleBackgroundEvent(event: Event) { + private suspend fun handlePaymentReceived(event: Event) { + if (event !in listOf(Event.PaymentReceived, Event.OnchainTransactionReceived)) return val command = NotifyPaymentReceived.Command.from(event, includeNotification = true) ?: return notifyPaymentReceivedHandler(command).onSuccess { result -> diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt index 7736a8d07..54458a16a 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt @@ -33,10 +33,7 @@ sealed interface NotifyPaymentReceived { includeNotification = includeNotification, ) - else -> { - Logger.warn("Unknown event type: ${event::class.simpleName}") - null - } + else -> null } } } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 5f0386aef..f16e05c89 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -185,7 +185,7 @@ class WalletRepo @Inject constructor( } } - suspend fun refreshBip21ForEvent(event: Event) { + suspend fun refreshBip21ForEvent(event: Event) = withContext(bgDispatcher) { when (event) { is Event.ChannelReady -> { // Only refresh bolt11 if we can now receive on lightning From a48f42e4d98eecaf82f02a29f4395f9d8aebad26 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 2 Dec 2025 19:17:30 +0100 Subject: [PATCH 27/32] refactor: expose node events from LightningRepo --- .../androidServices/LightningNodeService.kt | 39 +++---- .../domain/commands/NotifyPaymentReceived.kt | 1 - .../commands/NotifyPaymentReceivedHandler.kt | 5 +- .../to/bitkit/repositories/LightningRepo.kt | 9 +- .../java/to/bitkit/repositories/WalletRepo.kt | 16 ++- .../to/bitkit/services/LdkNodeEventBus.kt | 17 --- .../external/ExternalNodeViewModel.kt | 4 +- .../LightningConnectionsViewModel.kt | 4 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 10 +- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 4 +- .../LightningNodeServiceTest.kt | 109 +++++++++++------- .../bitkit/repositories/LightningRepoTest.kt | 3 - .../to/bitkit/repositories/WalletRepoTest.kt | 2 + 13 files changed, 120 insertions(+), 103 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/services/LdkNodeEventBus.kt diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index ba9355716..b534e7582 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -7,8 +7,8 @@ import android.content.Intent import android.os.IBinder import androidx.core.app.NotificationCompat import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch @@ -16,13 +16,13 @@ import org.lightningdevkit.ldknode.Event import to.bitkit.App import to.bitkit.R import to.bitkit.data.CacheStore +import to.bitkit.di.UiDispatcher import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NotificationDetails import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo -import to.bitkit.services.LdkNodeEventBus import to.bitkit.ui.MainActivity import to.bitkit.ui.pushNotification import to.bitkit.utils.Logger @@ -31,7 +31,11 @@ import javax.inject.Inject @AndroidEntryPoint class LightningNodeService : Service() { - private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + @Inject + @UiDispatcher + lateinit var uiDispatcher: CoroutineDispatcher + + private val serviceScope by lazy { CoroutineScope(SupervisorJob() + uiDispatcher) } @Inject lateinit var lightningRepo: LightningRepo @@ -39,9 +43,6 @@ class LightningNodeService : Service() { @Inject lateinit var walletRepo: WalletRepo - @Inject - lateinit var ldkNodeEventBus: LdkNodeEventBus - @Inject lateinit var notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler @@ -56,26 +57,24 @@ class LightningNodeService : Service() { private fun setupService() { serviceScope.launch { - launch { - lightningRepo.start( - eventHandler = { event -> - walletRepo.refreshBip21ForEvent(event) - handlePaymentReceived(event) - } - ).onSuccess { - val notification = createNotification() - startForeground(NOTIFICATION_ID, notification) - - walletRepo.setWalletExistsState() - walletRepo.refreshBip21() - walletRepo.syncBalances() + lightningRepo.start( + eventHandler = { event -> + Logger.debug("LDK-node event received in $TAG: $event", context = TAG) + handlePaymentReceived(event) } + ).onSuccess { + val notification = createNotification() + startForeground(NOTIFICATION_ID, notification) + + walletRepo.setWalletExistsState() + walletRepo.refreshBip21() + walletRepo.syncBalances() } } } private suspend fun handlePaymentReceived(event: Event) { - if (event !in listOf(Event.PaymentReceived, Event.OnchainTransactionReceived)) return + if (event !is Event.PaymentReceived && event !is Event.OnchainTransactionReceived) return val command = NotifyPaymentReceived.Command.from(event, includeNotification = true) ?: return notifyPaymentReceivedHandler(command).onSuccess { result -> diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt index 54458a16a..1e9c92a93 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceived.kt @@ -3,7 +3,6 @@ package to.bitkit.domain.commands import org.lightningdevkit.ldknode.Event import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NotificationDetails -import to.bitkit.utils.Logger sealed interface NotifyPaymentReceived { diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index c382f87f5..576422178 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -41,7 +41,10 @@ class NotifyPaymentReceivedHandler @Inject constructor( activityRepo.handleOnchainTransactionReceived(command.event.txid, command.event.details) if (command.event.details.amountSats > 0) { delay(DELAY_FOR_ACTIVITY_SYNC_MS) - activityRepo.shouldShowReceivedSheet(command.event.txid, command.event.details.amountSats.toULong()) + activityRepo.shouldShowReceivedSheet( + command.event.txid, + command.event.details.amountSats.toULong() + ) } else { false } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 44badaab7..586a44efe 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -14,7 +14,9 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first @@ -51,7 +53,6 @@ import to.bitkit.models.TransactionSpeed import to.bitkit.models.toCoinSelectAlgorithm import to.bitkit.models.toCoreNetwork import to.bitkit.services.CoreService -import to.bitkit.services.LdkNodeEventBus import to.bitkit.services.LightningService import to.bitkit.services.LnurlChannelResponse import to.bitkit.services.LnurlService @@ -73,7 +74,6 @@ import kotlin.time.Duration.Companion.seconds class LightningRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningService: LightningService, - private val ldkNodeEventBus: LdkNodeEventBus, private val settingsStore: SettingsStore, private val coreService: CoreService, private val lspNotificationsService: LspNotificationsService, @@ -86,6 +86,9 @@ class LightningRepo @Inject constructor( private val _lightningState = MutableStateFlow(LightningState()) val lightningState = _lightningState.asStateFlow() + private val _nodeEvents = MutableSharedFlow(extraBufferCapacity = 64) + val nodeEvents = _nodeEvents.asSharedFlow() + private val scope = CoroutineScope(bgDispatcher + SupervisorJob()) private var _eventHandler: NodeEventHandler? = null @@ -263,7 +266,7 @@ class LightningRepo @Inject constructor( private suspend fun onEvent(event: Event) { handleLdkEvent(event) _eventHandler?.invoke(event) - ldkNodeEventBus.emit(event) + _nodeEvents.emit(event) } fun setRecoveryMode(enabled: Boolean) { diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index f16e05c89..26e7fe0d2 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -56,6 +56,15 @@ class WalletRepo @Inject constructor( private val _balanceState = MutableStateFlow(BalanceState()) val balanceState = _balanceState.asStateFlow() + init { + repoScope.launch { + lightningRepo.nodeEvents.collect { event -> + if (!walletExists()) return@collect + refreshBip21ForEvent(event) + } + } + } + fun loadFromCache() { // TODO try keeping in sync with cache if performant and reliable repoScope.launch { @@ -151,6 +160,8 @@ class WalletRepo @Inject constructor( } suspend fun observeLdkWallet() = withContext(bgDispatcher) { + // TODO:Refactor: when a sync event is emitted by ldk-node, do the sync, and + // get rid of the entire polling mechanism. lightningRepo.getSyncFlow() .collect { runCatching { @@ -189,6 +200,7 @@ class WalletRepo @Inject constructor( when (event) { is Event.ChannelReady -> { // Only refresh bolt11 if we can now receive on lightning + Logger.debug("refreshBip21ForEvent: $event", context = TAG) if (lightningRepo.canReceive()) { lightningRepo.createInvoice( amountSats = _walletState.value.bip21AmountSats, @@ -202,14 +214,16 @@ class WalletRepo @Inject constructor( is Event.ChannelClosed -> { // Clear bolt11 if we can no longer receive on lightning + Logger.debug("refreshBip21ForEvent: $event", context = TAG) if (!lightningRepo.canReceive()) { setBolt11("") updateBip21Url() } } - is Event.PaymentReceived -> { + is Event.PaymentReceived, is Event.OnchainTransactionReceived -> { // Check if onchain address was used, generate new one if needed + Logger.debug("refreshBip21ForEvent: $event", context = TAG) refreshAddressIfNeeded() updateBip21Url() } diff --git a/app/src/main/java/to/bitkit/services/LdkNodeEventBus.kt b/app/src/main/java/to/bitkit/services/LdkNodeEventBus.kt deleted file mode 100644 index eb6900848..000000000 --- a/app/src/main/java/to/bitkit/services/LdkNodeEventBus.kt +++ /dev/null @@ -1,17 +0,0 @@ -package to.bitkit.services - -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import org.lightningdevkit.ldknode.Event -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class LdkNodeEventBus @Inject constructor() { - private val _events = MutableSharedFlow(extraBufferCapacity = 1) - val events = _events.asSharedFlow() - - suspend fun emit(event: Event) { - _events.emit(event) - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt index 064c0f6aa..d881d46b0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt @@ -27,7 +27,6 @@ import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.WalletRepo -import to.bitkit.services.LdkNodeEventBus import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.SideEffect import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.UiState import to.bitkit.ui.shared.toast.ToastEventBus @@ -38,7 +37,6 @@ import javax.inject.Inject @HiltViewModel class ExternalNodeViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val ldkNodeEventBus: LdkNodeEventBus, private val walletRepo: WalletRepo, private val lightningRepo: LightningRepo, private val settingsStore: SettingsStore, @@ -210,7 +208,7 @@ class ExternalNodeViewModel @Inject constructor( } private suspend fun awaitChannelPendingEvent(userChannelId: UserChannelId): Result { - return ldkNodeEventBus.events.watchUntil { event -> + return lightningRepo.nodeEvents.watchUntil { event -> when (event) { is Event.ChannelClosed -> if (event.userChannelId == userChannelId) { WatchResult.Complete(Result.failure(Exception("${event.reason}"))) diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index 752cc01e0..1ef0d20a8 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -37,7 +37,6 @@ import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LogsRepo import to.bitkit.repositories.WalletRepo -import to.bitkit.services.LdkNodeEventBus import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject @@ -50,7 +49,6 @@ class LightningConnectionsViewModel @Inject constructor( private val lightningRepo: LightningRepo, internal val blocktankRepo: BlocktankRepo, private val logsRepo: LogsRepo, - private val ldkNodeEventBus: LdkNodeEventBus, private val walletRepo: WalletRepo, private val activityRepo: ActivityRepo, ) : ViewModel() { @@ -129,7 +127,7 @@ class LightningConnectionsViewModel @Inject constructor( private fun observeLdkEvents() { viewModelScope.launch { - ldkNodeEventBus.events.collect { event -> + lightningRepo.nodeEvents.collect { event -> if (event is Event.ChannelPending || event is Event.ChannelReady || event is Event.ChannelClosed) { Logger.debug("Channel event received: ${event::class.simpleName}, triggering refresh") refreshObservedState() diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 9725866a2..1c88fdcc6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -94,7 +94,6 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.AppUpdaterService -import to.bitkit.services.LdkNodeEventBus import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.TimedSheetType @@ -116,7 +115,6 @@ class AppViewModel @Inject constructor( private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, private val backupRepo: BackupRepo, - private val ldkNodeEventBus: LdkNodeEventBus, private val settingsStore: SettingsStore, private val currencyRepo: CurrencyRepo, private val activityRepo: ActivityRepo, @@ -227,9 +225,10 @@ class AppViewModel @Inject constructor( @Suppress("CyclomaticComplexMethod") private fun observeLdkNodeEvents() { viewModelScope.launch { - ldkNodeEventBus.events.collect { event -> + lightningRepo.nodeEvents.collect { event -> if (!walletRepo.walletExists()) return@collect - + Logger.debug("LDK-node event received in $TAG: $event", context = TAG) + // TODO maybe use launch runCatching { when (event) { is Event.BalanceChanged -> handleBalanceChanged() @@ -1112,6 +1111,7 @@ class AppViewModel @Inject constructor( ) ) lightningRepo.sync() + activityRepo.syncActivities() }.onFailure { e -> Logger.error(msg = "Error sending onchain payment", e = e, context = TAG) toast( @@ -1291,7 +1291,7 @@ class AppViewModel @Inject constructor( ): Result { return lightningRepo.payInvoice(bolt11 = bolt11, sats = amount).onSuccess { hash -> // Wait until matching payment event is received - val result = ldkNodeEventBus.events.watchUntil { event -> + val result = lightningRepo.nodeEvents.watchUntil { event -> when (event) { is Event.PaymentSuccessful -> { if (event.paymentHash == hash) { diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index cad84d55a..dd0b72457 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -12,14 +12,12 @@ import org.lightningdevkit.ldknode.PaymentId import to.bitkit.ext.WatchResult import to.bitkit.ext.watchUntil import to.bitkit.repositories.LightningRepo -import to.bitkit.services.LdkNodeEventBus import to.bitkit.utils.Logger import javax.inject.Inject @HiltViewModel class QuickPayViewModel @Inject constructor( private val lightningRepo: LightningRepo, - private val ldkNodeEventBus: LdkNodeEventBus, ) : ViewModel() { private val _uiState = MutableStateFlow(QuickPayUiState()) @@ -80,7 +78,7 @@ class QuickPayViewModel @Inject constructor( .getOrDefault("") // Wait until matching payment event is received - val result = ldkNodeEventBus.events.watchUntil { event -> + val result = lightningRepo.nodeEvents.watchUntil { event -> when (event) { is Event.PaymentSuccessful -> { if (event.paymentHash == hash) { diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index d670081d5..3f2169582 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -11,6 +11,8 @@ import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltTestApplication +import dagger.hilt.android.testing.UninstallModules +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.runBlocking import org.junit.After @@ -22,8 +24,10 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.lightningdevkit.ldknode.Event +import org.mockito.kotlin.KArgumentCaptor import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -37,6 +41,10 @@ import to.bitkit.CurrentActivity import to.bitkit.R import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore +import to.bitkit.di.BgDispatcher +import to.bitkit.di.DispatchersModule +import to.bitkit.di.IoDispatcher +import to.bitkit.di.UiDispatcher import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.models.NewTransactionSheetDetails @@ -45,10 +53,11 @@ import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NotificationDetails import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo -import to.bitkit.services.LdkNodeEventBus +import to.bitkit.services.NodeEventHandler import to.bitkit.test.BaseUnitTest @HiltAndroidTest +@UninstallModules(DispatchersModule::class) @Config(application = HiltTestApplication::class) @RunWith(RobolectricTestRunner::class) class LightningNodeServiceTest : BaseUnitTest() { @@ -69,56 +78,63 @@ class LightningNodeServiceTest : BaseUnitTest() { @BindValue @JvmField - val ldkNodeEventBus: LdkNodeEventBus = mock() + val notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler = mock() @BindValue @JvmField - val notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler = mock() + val cacheStore: CacheStore = mock() @BindValue + @UiDispatcher @JvmField - val cacheStore: CacheStore = mock() + val uiDispatcher: CoroutineDispatcher = testDispatcher + + @BindValue + @BgDispatcher + @JvmField + val bgDispatcher: CoroutineDispatcher = testDispatcher - private val eventsFlow = MutableSharedFlow() + @BindValue + @IoDispatcher + @JvmField + val ioDispatcher: CoroutineDispatcher = testDispatcher + + private val eventHandlerCaptor: KArgumentCaptor = argumentCaptor() private val cacheDataFlow = MutableSharedFlow(replay = 1) private val context = ApplicationProvider.getApplicationContext() @Before - fun setUp() { - runBlocking { - hiltRule.inject() - whenever(ldkNodeEventBus.events).thenReturn(eventsFlow) - whenever(lightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn( - Result.success(Unit) - ) - whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) - - // Set up CacheStore mock - cacheDataFlow.emit(AppCacheData()) - whenever(cacheStore.data).thenReturn(cacheDataFlow) - - // Mock NotifyPaymentReceivedHandler to return ShowNotification result - val sheet = NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.RECEIVED, - paymentHashOrTxId = "test_hash", - sats = 100L, - ) - val notification = NotificationDetails( - title = context.getString(R.string.notification_received_title), - body = "Received ₿ 100 ($0.10)", - ) - whenever(notifyPaymentReceivedHandler.invoke(any())).thenReturn( - Result.success(NotifyPaymentReceived.Result.ShowNotification(sheet, notification)) - ) - - // Grant permissions for notifications - val app = context as Application - Shadows.shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) - - // Reset App.currentActivity to simulate background state - App.currentActivity = CurrentActivity() - } + fun setUp() = runBlocking { + hiltRule.inject() + whenever( + lightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), eventHandlerCaptor.capture()) + ).thenReturn(Result.success(Unit)) + whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) + + // Set up CacheStore mock + whenever(cacheStore.data).thenReturn(cacheDataFlow) + + // Mock NotifyPaymentReceivedHandler to return ShowNotification result + val sheet = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = "test_hash", + sats = 100L, + ) + val notification = NotificationDetails( + title = context.getString(R.string.notification_received_title), + body = "Received ₿ 100 ($0.10)", + ) + whenever(notifyPaymentReceivedHandler.invoke(any())).thenReturn( + Result.success(NotifyPaymentReceived.Result.ShowNotification(sheet, notification)) + ) + + // Grant permissions for notifications + val app = context as Application + Shadows.shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + + // Reset App.currentActivity to simulate background state + App.currentActivity = CurrentActivity() } @After @@ -130,6 +146,10 @@ class LightningNodeServiceTest : BaseUnitTest() { fun `payment received in background shows notification`() = test { val controller = Robolectric.buildService(LightningNodeService::class.java) controller.create().startCommand(0, 0) + testScheduler.advanceUntilIdle() + + val capturedHandler = eventHandlerCaptor.lastValue + assertNotNull("Event handler should be captured", capturedHandler) val event = Event.PaymentReceived( paymentId = "payment_id", @@ -138,7 +158,7 @@ class LightningNodeServiceTest : BaseUnitTest() { customRecords = emptyList() ) - eventsFlow.emit(event) + capturedHandler?.invoke(event) testScheduler.advanceUntilIdle() val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -166,6 +186,7 @@ class LightningNodeServiceTest : BaseUnitTest() { val controller = Robolectric.buildService(LightningNodeService::class.java) controller.create().startCommand(0, 0) + testScheduler.advanceUntilIdle() val event = Event.PaymentReceived( paymentId = "payment_id_fg", @@ -174,7 +195,8 @@ class LightningNodeServiceTest : BaseUnitTest() { customRecords = emptyList() ) - eventsFlow.emit(event) + eventHandlerCaptor.lastValue?.invoke(event) + testScheduler.advanceUntilIdle() val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val shadows = Shadows.shadowOf(notificationManager) @@ -192,6 +214,7 @@ class LightningNodeServiceTest : BaseUnitTest() { fun `notification uses content from use case result`() = test { val controller = Robolectric.buildService(LightningNodeService::class.java) controller.create().startCommand(0, 0) + testScheduler.advanceUntilIdle() val event = Event.PaymentReceived( paymentId = "payment_id", @@ -200,7 +223,7 @@ class LightningNodeServiceTest : BaseUnitTest() { customRecords = emptyList() ) - eventsFlow.emit(event) + eventHandlerCaptor.lastValue?.invoke(event) testScheduler.advanceUntilIdle() val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index cd2b314aa..62870750f 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -36,7 +36,6 @@ import to.bitkit.models.OpenChannelResult import to.bitkit.models.TransactionSpeed import to.bitkit.services.BlocktankService import to.bitkit.services.CoreService -import to.bitkit.services.LdkNodeEventBus import to.bitkit.services.LightningService import to.bitkit.services.LnurlService import to.bitkit.services.LspNotificationsService @@ -52,7 +51,6 @@ class LightningRepoTest : BaseUnitTest() { private lateinit var sut: LightningRepo private val lightningService: LightningService = mock() - private val ldkNodeEventBus: LdkNodeEventBus = mock() private val settingsStore: SettingsStore = mock() private val coreService: CoreService = mock() private val lspNotificationsService: LspNotificationsService = mock() @@ -69,7 +67,6 @@ class LightningRepoTest : BaseUnitTest() { sut = LightningRepo( bgDispatcher = testDispatcher, lightningService = lightningService, - ldkNodeEventBus = ldkNodeEventBus, settingsStore = settingsStore, coreService = coreService, lspNotificationsService = lspNotificationsService, diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 3aec48ba8..71455c5f6 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -1,6 +1,7 @@ package to.bitkit.repositories import app.cash.turbine.test +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import org.junit.Before @@ -50,6 +51,7 @@ class WalletRepoTest : BaseUnitTest() { wheneverBlocking { coreService.checkGeoBlock() }.thenReturn(Pair(false, false)) whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(bolt11 = "", onchainAddress = "testAddress"))) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + whenever(lightningRepo.nodeEvents).thenReturn(MutableSharedFlow()) wheneverBlocking { lightningRepo.listSpendableOutputs() }.thenReturn(Result.success(emptyList())) wheneverBlocking { lightningRepo.calculateTotalFee(any(), any(), any(), any(), anyOrNull()) } .thenReturn(Result.success(1000uL)) From fc54b2b0cffefbc75890a96f998ff433b38877f6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 2 Dec 2025 19:30:06 +0100 Subject: [PATCH 28/32] chore: use json in log --- .../java/to/bitkit/androidServices/LightningNodeService.kt | 3 ++- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index b534e7582..1e51625d5 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -26,6 +26,7 @@ import to.bitkit.repositories.WalletRepo import to.bitkit.ui.MainActivity import to.bitkit.ui.pushNotification import to.bitkit.utils.Logger +import to.bitkit.utils.jsonLogOf import javax.inject.Inject @AndroidEntryPoint @@ -59,7 +60,7 @@ class LightningNodeService : Service() { serviceScope.launch { lightningRepo.start( eventHandler = { event -> - Logger.debug("LDK-node event received in $TAG: $event", context = TAG) + Logger.debug("LDK-node event received in $TAG: ${jsonLogOf(event)}", context = TAG) handlePaymentReceived(event) } ).onSuccess { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1c88fdcc6..3e92e74af 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -101,6 +101,7 @@ import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.utils.Logger +import to.bitkit.utils.jsonLogOf import java.math.BigDecimal import javax.inject.Inject @@ -227,7 +228,7 @@ class AppViewModel @Inject constructor( viewModelScope.launch { lightningRepo.nodeEvents.collect { event -> if (!walletRepo.walletExists()) return@collect - Logger.debug("LDK-node event received in $TAG: $event", context = TAG) + Logger.debug("LDK-node event received in $TAG: ${jsonLogOf(event)}", context = TAG) // TODO maybe use launch runCatching { when (event) { From c27c0f11378edfa1a4fb8313fb051875d2a852f3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 2 Dec 2025 19:35:49 +0100 Subject: [PATCH 29/32] refactor: remove periodic sync in favor of events --- .../java/to/bitkit/repositories/BackupRepo.kt | 7 ++- .../to/bitkit/repositories/LightningRepo.kt | 3 - .../java/to/bitkit/repositories/WalletRepo.kt | 11 ---- .../to/bitkit/services/LightningService.kt | 22 +++---- app/src/main/java/to/bitkit/ui/ContentView.kt | 4 -- .../java/to/bitkit/viewmodels/AppViewModel.kt | 59 ++++++++++--------- .../to/bitkit/viewmodels/WalletViewModel.kt | 5 +- 7 files changed, 46 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index b3a91223a..2e98b443c 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -4,6 +4,7 @@ import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.currentCoroutineContext @@ -11,6 +12,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first @@ -262,8 +264,10 @@ class BackupRepo @Inject constructor( dataListenerJobs.add(activityChangesJob) // LIGHTNING_CONNECTIONS - Only display sync timestamp, ldk-node manages its own backups + @OptIn(FlowPreview::class) val lightningConnectionsJob = scope.launch { - lightningService.syncFlow() + lightningService.syncStatusChanged + .debounce(SYNC_STATUS_DEBOUNCE) .collect { val lastSync = lightningService.status?.latestLightningWalletSyncTimestamp?.toLong() ?.let { it * 1000 } // Convert seconds to millis @@ -571,5 +575,6 @@ class BackupRepo @Inject constructor( private const val BACKUP_CHECK_INTERVAL = 60 * 1000L // 1 minute private const val FAILED_BACKUP_CHECK_TIME = 30 * 60 * 1000L // 30 minutes private const val FAILED_BACKUP_NOTIFICATION_INTERVAL = 10 * 60 * 1000L // 10 minutes + private const val SYNC_STATUS_DEBOUNCE = 500L // 500ms debounce for sync status updates } } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 586a44efe..2d9c35dfd 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -828,8 +827,6 @@ class LightningRepo @Inject constructor( } } - fun getSyncFlow() = lightningService.syncFlow().filter { lightningState.value.nodeLifecycleState.isRunning() } - fun getNodeId(): String? = if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.nodeId else null diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 26e7fe0d2..fa1323d52 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -159,17 +159,6 @@ class WalletRepo @Inject constructor( preActivityMetadataRepo.addPreActivityMetadata(preActivityMetadata) } - suspend fun observeLdkWallet() = withContext(bgDispatcher) { - // TODO:Refactor: when a sync event is emitted by ldk-node, do the sync, and - // get rid of the entire polling mechanism. - lightningRepo.getSyncFlow() - .collect { - runCatching { - syncNodeAndWallet() - } - } - } - suspend fun syncNodeAndWallet(): Result = withContext(bgDispatcher) { val startHeight = lightningRepo.lightningState.value.block()?.height Logger.verbose("syncNodeAndWallet started at block height=$startHeight", context = TAG) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index ea0d559ef..8e38da702 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -1,13 +1,10 @@ package to.bitkit.services import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout @@ -56,7 +53,6 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.io.path.Path import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds typealias NodeEventHandler = suspend (Event) -> Unit @@ -72,6 +68,9 @@ class LightningService @Inject constructor( @Volatile var node: Node? = null + private val _syncStatusChanged = MutableSharedFlow(extraBufferCapacity = 1) + val syncStatusChanged: SharedFlow = _syncStatusChanged.asSharedFlow() + private lateinit var trustedPeers: List suspend fun setup( @@ -230,6 +229,8 @@ class LightningService @Inject constructor( // launch { setMaxDustHtlcExposureForCurrentChannels() } } + _syncStatusChanged.tryEmit(Unit) + Logger.debug("LDK synced") } @@ -730,13 +731,6 @@ class LightningService @Inject constructor( val peers: List? get() = node?.listPeers() val channels: List? get() = node?.listChannels() val payments: List? get() = node?.listPayments() - - fun syncFlow(): Flow = flow { - while (currentCoroutineContext().isActive) { - emit(Unit) - delay(Env.walletSyncIntervalSecs.toLong().seconds) - } - }.flowOn(bgDispatcher) // endregion companion object { diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index ac9102185..a4119a370 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -243,10 +243,6 @@ fun ContentView( } } - LaunchedEffect(Unit) { - walletViewModel.observeLdkWallet() - } - LaunchedEffect(Unit) { walletViewModel.handleHideBalanceOnOpen() } LaunchedEffect(appViewModel) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 3e92e74af..4264b7c90 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -226,32 +226,35 @@ class AppViewModel @Inject constructor( @Suppress("CyclomaticComplexMethod") private fun observeLdkNodeEvents() { viewModelScope.launch { - lightningRepo.nodeEvents.collect { event -> - if (!walletRepo.walletExists()) return@collect - Logger.debug("LDK-node event received in $TAG: ${jsonLogOf(event)}", context = TAG) - // TODO maybe use launch - runCatching { - when (event) { - is Event.BalanceChanged -> handleBalanceChanged() - is Event.ChannelClosed -> Unit - is Event.ChannelPending -> Unit - is Event.ChannelReady -> notifyChannelReady(event) - is Event.OnchainTransactionConfirmed -> handleOnchainTransactionConfirmed(event) - is Event.OnchainTransactionEvicted -> handleOnchainTransactionEvicted(event) - is Event.OnchainTransactionReceived -> handleOnchainTransactionReceived(event) - is Event.OnchainTransactionReorged -> handleOnchainTransactionReorged(event) - is Event.OnchainTransactionReplaced -> handleOnchainTransactionReplaced(event) - is Event.PaymentClaimable -> Unit - is Event.PaymentFailed -> handlePaymentFailed(event) - is Event.PaymentForwarded -> Unit - is Event.PaymentReceived -> handlePaymentReceived(event) - is Event.PaymentSuccessful -> handlePaymentSuccessful(event) - is Event.SyncCompleted -> handleSyncCompleted() - is Event.SyncProgress -> Unit - } - }.onFailure { e -> - Logger.error("LDK event handler error", e, context = TAG) + lightningRepo.nodeEvents.collect { handleLdkEvent(it) } + } + } + + private fun handleLdkEvent(event: Event) { + if (!walletRepo.walletExists()) return + Logger.debug("LDK-node event received in $TAG: ${jsonLogOf(event)}", context = TAG) + viewModelScope.launch { + runCatching { + when (event) { + is Event.BalanceChanged -> handleBalanceChanged() + is Event.ChannelClosed -> Unit + is Event.ChannelPending -> Unit + is Event.ChannelReady -> notifyChannelReady(event) + is Event.OnchainTransactionConfirmed -> handleOnchainTransactionConfirmed(event) + is Event.OnchainTransactionEvicted -> handleOnchainTransactionEvicted(event) + is Event.OnchainTransactionReceived -> handleOnchainTransactionReceived(event) + is Event.OnchainTransactionReorged -> handleOnchainTransactionReorged(event) + is Event.OnchainTransactionReplaced -> handleOnchainTransactionReplaced(event) + is Event.PaymentClaimable -> Unit + is Event.PaymentFailed -> handlePaymentFailed(event) + is Event.PaymentForwarded -> Unit + is Event.PaymentReceived -> handlePaymentReceived(event) + is Event.PaymentSuccessful -> handlePaymentSuccessful(event) + is Event.SyncCompleted -> handleSyncCompleted() + is Event.SyncProgress -> Unit } + }.onFailure { e -> + Logger.error("LDK event handler error", e, context = TAG) } } } @@ -295,14 +298,14 @@ class AppViewModel @Inject constructor( } private suspend fun handlePaymentReceived(event: Event.PaymentReceived) { - event.paymentHash?.let { paymentHash -> + event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) } notifyPaymentReceived(event) } private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { - event.paymentHash?.let { paymentHash -> + event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) } notifyPaymentSentOnLightning(event) @@ -446,7 +449,7 @@ class AppViewModel @Inject constructor( is SendEvent.ConfirmAmountWarning -> onConfirmAmountWarning(it.warning) SendEvent.DismissAmountWarning -> onDismissAmountWarning() SendEvent.PayConfirmed -> onConfirmPay() - SendEvent.ClearPayConfirmation -> _sendUiState.update { it.copy(shouldConfirmPay = false) } + SendEvent.ClearPayConfirmation -> _sendUiState.update { s -> s.copy(shouldConfirmPay = false) } SendEvent.BackToAmount -> setSendEffect(SendEffect.PopBack(SendRoute.Amount)) SendEvent.NavToAddress -> setSendEffect(SendEffect.NavigateToAddress) } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 2676b0395..ff03f0084 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -168,10 +168,6 @@ class WalletViewModel @Inject constructor( } } - suspend fun observeLdkWallet() { - walletRepo.observeLdkWallet() - } - fun refreshState() = viewModelScope.launch { walletRepo.syncNodeAndWallet() .onFailure { error -> @@ -340,6 +336,7 @@ sealed interface RestoreState { object Wallet : InProgress object Metadata : InProgress } + data object Completed : RestoreState data object Settled : RestoreState From 8d90afa9888a4c0e1d03e649f69244cf41220d40 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 2 Dec 2025 19:55:31 +0100 Subject: [PATCH 30/32] chore: lint --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 4264b7c90..45afb8e06 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -223,16 +223,17 @@ class AppViewModel @Inject constructor( } } - @Suppress("CyclomaticComplexMethod") private fun observeLdkNodeEvents() { viewModelScope.launch { lightningRepo.nodeEvents.collect { handleLdkEvent(it) } } } + @Suppress("CyclomaticComplexMethod") private fun handleLdkEvent(event: Event) { if (!walletRepo.walletExists()) return Logger.debug("LDK-node event received in $TAG: ${jsonLogOf(event)}", context = TAG) + viewModelScope.launch { runCatching { when (event) { From 2d7bb9f726f7c9e8dd3aa7add043c4c705db6a27 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 4 Dec 2025 12:56:52 +0100 Subject: [PATCH 31/32] fix: reload tags on filter sheet open --- .../bitkit/ui/screens/wallets/activity/TagSelectorSheet.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/TagSelectorSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/TagSelectorSheet.kt index fa7e8a074..3d178f85a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/TagSelectorSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/TagSelectorSheet.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -37,6 +38,10 @@ fun TagSelectorSheet() { val availableTags by activity.availableTags.collectAsStateWithLifecycle() val selectedTags by activity.selectedTags.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + activity.updateAvailableTags() + } + Content( availableTags = availableTags, selectedTags = selectedTags, From 8a9594bf36b61249e08e29b4be4caa0f04985764 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 4 Dec 2025 20:16:59 +0100 Subject: [PATCH 32/32] chore: lint --- app/src/main/java/to/bitkit/ui/ContentView.kt | 7 ++----- .../components/CustomTabRowWithSpacing.kt | 10 +++++----- .../wallets/receive/ReceiveInvoiceUtils.kt | 1 + .../wallets/receive/ReceiveQrScreen.kt | 20 ++++++++++--------- .../screens/wallets/receive/ReceiveSheet.kt | 1 + 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 4dd5cd651..1375eb400 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -448,11 +448,8 @@ fun ContentView( transferViewModel = transferViewModel, ) - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = navBackStackEntry?.destination?.route - val showTabBar = currentRoute in listOf( Routes.Home::class.qualifiedName, Routes.AllActivity::class.qualifiedName, @@ -463,8 +460,8 @@ fun ContentView( hazeState = hazeState, onSendClick = { appViewModel.showSheet(Sheet.Send()) }, onReceiveClick = { appViewModel.showSheet(Sheet.Receive) }, - onScanClick = { navController.navigateToScanner() }, modifier = Modifier - .align(Alignment.BottomCenter) + onScanClick = { navController.navigateToScanner() }, + modifier = Modifier.align(Alignment.BottomCenter) ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt index 9f90a8739..c4d62599b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt @@ -36,8 +36,8 @@ fun CustomTabRowWithSpacing( ) { Column(modifier = modifier) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth() ) { tabs.forEachIndexed { index, tab -> val isSelected = tabs[currentTabIndex] == tab @@ -51,7 +51,7 @@ fun CustomTabRowWithSpacing( .fillMaxWidth() .clickableAlpha { onTabChange(tab) } .padding(vertical = 8.dp) - .testTag("Tab-${tab.name.lowercase()}"), + .testTag("Tab-${tab.name.lowercase()}") ) { CaptionB( tab.uiText, @@ -67,7 +67,7 @@ fun CustomTabRowWithSpacing( durationMillis = 250, easing = FastOutSlowInEasing ), - label = "indicatorAlpha" + label = "indicatorAlpha", ) val animatedColor by animateColorAsState( @@ -76,7 +76,7 @@ fun CustomTabRowWithSpacing( durationMillis = 250, easing = FastOutSlowInEasing ), - label = "indicatorColor" + label = "indicatorColor", ) Box( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt index e5ac5e324..1c37d0c87 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt @@ -12,6 +12,7 @@ import to.bitkit.R * @param onchainAddress Pure Bitcoin address (fallback) * @return The invoice string to display/encode in QR */ +@Suppress("LongParameterList") fun getInvoiceForTab( tab: ReceiveTab, bip21: String, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 36c04c85f..db350a194 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -69,9 +69,6 @@ import to.bitkit.ui.components.Tooltip import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.screens.wallets.activity.components.CustomTabRowWithSpacing -import to.bitkit.ui.screens.wallets.receive.ReceiveTab.AUTO -import to.bitkit.ui.screens.wallets.receive.ReceiveTab.SAVINGS -import to.bitkit.ui.screens.wallets.receive.ReceiveTab.SPENDING import to.bitkit.ui.shared.effects.SetMaxBrightness import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground @@ -83,6 +80,7 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.MainUiState +@Suppress("CyclomaticComplexMethod") @OptIn(FlowPreview::class) @Composable fun ReceiveQrScreen( @@ -188,9 +186,9 @@ fun ReceiveQrScreen( tabs = visibleTabs, currentTabIndex = visibleTabs.indexOf(selectedTab), selectedColor = when (selectedTab) { - SAVINGS -> Colors.Brand - AUTO -> Colors.White - SPENDING -> Colors.Purple + ReceiveTab.SAVINGS -> Colors.Brand + ReceiveTab.AUTO -> Colors.White + ReceiveTab.SPENDING -> Colors.Purple }, onTabChange = { tab -> haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) @@ -673,8 +671,11 @@ private fun PreviewAutoMode() { nodeLifecycleState = NodeLifecycleState.Running, channels = listOf(mockChannel), onchainAddress = "bcrt1qfserxgtuesul4m9zva56wzk849yf9l8rk4qy0l", - bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79vhx9n2ps8q6tcdehhxapqd9h8vmmfvdjjqen0wgsyqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxq", - bip21 = "bitcoin:bcrt1qfserxgtuesul4m9zva56wzk849yf9l8rk4qy0l?lightning=lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79..." + bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79vhx9n2ps8q6tcdehhxapqd9h8vmmfv" + + "djjqen0wgsyqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxq", + bip21 = "bitcoin:bcrt1qfserxgtuesul4m9zva56wzk849yf9l8rk4qy0l?lightning=" + + "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79vhx9n2ps8q6tcdehhxapqd9h8vmmfv" + + "djjqen0wgsyqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxq", ), onClickEditInvoice = {}, modifier = Modifier.sheetHeight(), @@ -737,7 +738,8 @@ private fun PreviewSpendingMode() { walletState = MainUiState( nodeLifecycleState = NodeLifecycleState.Running, channels = listOf(mockChannel), - bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79vhx9n2ps8q6tcdehhxapqd9h8vmmfvdjjqen0wgsyqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxq" + bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79vhx9n2ps8q6tcdehhxapqd9h8vmmfv" + + "djjqen0wgsyqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxq" ), onClickEditInvoice = {}, modifier = Modifier.sheetHeight(), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index 2027c58de..0da519f81 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -36,6 +36,7 @@ fun ReceiveSheet( ) { val wallet = requireNotNull(walletViewModel) val navController = rememberNavController() + LaunchedEffect(Unit) { editInvoiceAmountViewModel.clearInput() } val cjitInvoice = remember { mutableStateOf(null) }