From 1636d747406cb777f7c8775e28fc484c745bb159 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Fri, 2 May 2025 09:48:44 -0300 Subject: [PATCH 01/62] refactor: move logic and shared states from viewmodel to WalletRepo.kt WIP --- .../java/to/bitkit/repositories/WalletRepo.kt | 153 ++++++++++++++---- 1 file changed, 118 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 07d822999..7e3873659 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -1,19 +1,15 @@ package to.bitkit.repositories -import android.content.Context -import android.icu.util.Calendar -import androidx.lifecycle.viewModelScope import com.google.firebase.messaging.FirebaseMessaging -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext import kotlinx.datetime.Clock -import kotlinx.datetime.Instant import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.Txid import to.bitkit.data.AppDb @@ -26,9 +22,6 @@ import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.ext.toHex import to.bitkit.models.BalanceState -import to.bitkit.models.NewTransactionSheetDetails -import to.bitkit.models.NewTransactionSheetDirection -import to.bitkit.models.NewTransactionSheetType import to.bitkit.services.BlocktankNotificationsService import to.bitkit.services.CoreService import to.bitkit.utils.Bip21Utils @@ -47,7 +40,6 @@ import kotlin.time.Duration.Companion.seconds @Singleton class WalletRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - @ApplicationContext private val appContext: Context, private val appStorage: AppStorage, private val db: AppDb, private val keychain: Keychain, @@ -56,8 +48,28 @@ class WalletRepo @Inject constructor( private val firebaseMessaging: FirebaseMessaging, private val settingsStore: SettingsStore, ) { + + private val _walletState = MutableStateFlow(WalletState( + onchainAddress = appStorage.onchainAddress, + bolt11 = appStorage.bolt11, + bip21 = appStorage.bip21, + walletExists = walletExists() + )) + val walletState = _walletState.asStateFlow() + + private val _balanceState = MutableStateFlow(getBalanceState()) + val balanceState = _balanceState.asStateFlow() + fun walletExists(): Boolean = keychain.exists(Keychain.Key.BIP39_MNEMONIC.name) + fun setWalletExistsState() { + _walletState.update { it.copy(walletExists = walletExists()) } + } + + fun setRestoringWalletState(isRestoring: Boolean) { + _walletState.update { it.copy(isRestoringWallet = isRestoring) } + } + suspend fun createWallet(bip39Passphrase: String?): Result = withContext(bgDispatcher) { try { val mnemonic = generateEntropyMnemonic() @@ -65,6 +77,7 @@ class WalletRepo @Inject constructor( if (bip39Passphrase != null) { keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) } + setWalletExistsState() Result.success(Unit) } catch (e: Throwable) { Logger.error("Create wallet error", e) @@ -78,6 +91,7 @@ class WalletRepo @Inject constructor( if (bip39Passphrase != null) { keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) } + setWalletExistsState() Result.success(Unit) } catch (e: Throwable) { Logger.error("Restore wallet error", e) @@ -96,6 +110,7 @@ class WalletRepo @Inject constructor( settingsStore.wipe() coreService.activity.removeAll() deleteAllInvoices() + setWalletExistsState() Result.success(Unit) } catch (e: Throwable) { Logger.error("Wipe wallet error", e) @@ -108,20 +123,23 @@ class WalletRepo @Inject constructor( fun setOnchainAddress(address: String) { appStorage.onchainAddress = address + _walletState.update { it.copy(onchainAddress = address) } } // Bolt11 management - fun getBolt11(): String = appStorage.bolt11 + fun getBolt11(): String = _walletState.value.bolt11 fun setBolt11(bolt11: String) { appStorage.bolt11 = bolt11 + _walletState.update { it.copy(bolt11 = bolt11) } } // BIP21 management - fun getBip21(): String = appStorage.bip21 + fun getBip21(): String = _walletState.value.bip21 fun setBip21(bip21: String) { appStorage.bip21 = bip21 + _walletState.update { it.copy(bip21 = bip21) } } fun buildBip21Url( @@ -141,13 +159,86 @@ class WalletRepo @Inject constructor( // Balance management fun getBalanceState(): BalanceState = appStorage.loadBalance() ?: BalanceState() - fun saveBalanceState(balanceState: BalanceState) { + suspend fun saveBalanceState(balanceState: BalanceState) { appStorage.cacheBalance(balanceState) + _balanceState.update { balanceState } + + if (balanceState.totalSats > 0u) { + setShowEmptyState(false) + } } // Settings suspend fun setShowEmptyState(show: Boolean) { settingsStore.setShowEmptyState(show) + _walletState.update { it.copy(showEmptyState = show) } + } + + // BIP21 state management + fun updateBip21AmountSats(amount: ULong?) { + _walletState.update { it.copy(bip21AmountSats = amount) } + } + + fun updateBip21Description(description: String) { + _walletState.update { it.copy(bip21Description = description) } + } + + fun toggleReceiveOnSpendingBalance() { + _walletState.update { it.copy(receiveOnSpendingBalance = !it.receiveOnSpendingBalance) } + } + + fun addTagToSelected(newTag: String) { + _walletState.update { + it.copy( + selectedTags = (it.selectedTags + newTag).distinct() + ) + } + } + + fun removeTag(tag: String) { + _walletState.update { + it.copy( + selectedTags = it.selectedTags.filterNot { tagItem -> tagItem == tag } + ) + } + } + + fun clearTagsAndBip21DescriptionState() { + _walletState.update { it.copy(selectedTags = listOf(), bip21Description = "") } + } + + // BIP21 invoice creation + suspend fun updateBip21Invoice( + amountSats: ULong? = null, + description: String = "", + generateBolt11IfAvailable: Boolean = true, + lightningRepo: LightningRepo //TODO MAYBE INJECT IN THE CLASS + ) = withContext(bgDispatcher) { + updateBip21AmountSats(amountSats) + updateBip21Description(description) + + val hasChannels = lightningRepo.hasChannels() + + if (hasChannels && generateBolt11IfAvailable) { + lightningRepo.createInvoice( + amountSats = _walletState.value.bip21AmountSats, + description = _walletState.value.bip21Description + ).onSuccess { bolt11 -> + setBolt11(bolt11) + } + } else { + setBolt11("") + } + + val newBip21 = buildBip21Url( + bitcoinAddress = getOnchainAddress(), + amountSats = _walletState.value.bip21AmountSats, + message = description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE }, + lightningInvoice = getBolt11() + ) + setBip21(newBip21) + saveInvoiceWithTags(bip21Invoice = newBip21, tags = _walletState.value.selectedTags) + clearTagsAndBip21DescriptionState() } // Notification handling @@ -191,27 +282,6 @@ class WalletRepo @Inject constructor( } } - suspend fun createTransactionSheet( - type: NewTransactionSheetType, - direction: NewTransactionSheetDirection, - sats: Long - ): Result = withContext(bgDispatcher) { - try { - NewTransactionSheetDetails.save( - appContext, - NewTransactionSheetDetails( - type = type, - direction = direction, - sats = sats, - ) - ) - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Create transaction sheet error", e) - Result.failure(e) - } - } - // Debug methods suspend fun debugKeychain(key: String, value: String): Result = withContext(bgDispatcher) { try { @@ -419,3 +489,16 @@ class WalletRepo @Inject constructor( const val TAG = "WalletRepo" } } + +data class WalletState( + val onchainAddress: String = "", + val bolt11: String = "", + val bip21: String = "", + val bip21AmountSats: ULong? = null, + val bip21Description: String = "", + val selectedTags: List = listOf(), + val receiveOnSpendingBalance: Boolean = true, + val showEmptyState: Boolean = true, + val walletExists: Boolean = false, + val isRestoringWallet: Boolean = false +) From 2f46752d0aee03c2978980dd12c16d7127bc45e5 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Fri, 2 May 2025 10:58:16 -0300 Subject: [PATCH 02/62] refactor: move logic and shared states from viewmodel to LightningRepo.kt WIP --- .../to/bitkit/repositories/LightningRepo.kt | 117 ++++++++++++------ 1 file changed, 81 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 2bb2b6dfd..3372532ef 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.Address @@ -36,8 +37,8 @@ class LightningRepo @Inject constructor( private val ldkNodeEventBus: LdkNodeEventBus, private val addressChecker: AddressChecker ) { - private val _nodeLifecycleState: MutableStateFlow = MutableStateFlow(NodeLifecycleState.Stopped) - val nodeLifecycleState = _nodeLifecycleState.asStateFlow() + private val _lightningState = MutableStateFlow(LightningState()) + val lightningState = _lightningState.asStateFlow() /** * Executes the provided operation only if the node is running. @@ -55,25 +56,25 @@ class LightningRepo @Inject constructor( ): Result = withContext(bgDispatcher) { Logger.debug("Operation called: $operationName", context = TAG) - if (nodeLifecycleState.value.isRunning()) { + if (_lightningState.value.nodeLifecycleState.isRunning()) { return@withContext executeOperation(operationName, operation) } // If node is not in a state that can become running, fail fast - if (!nodeLifecycleState.value.canRun()) { + if (!_lightningState.value.nodeLifecycleState.canRun()) { return@withContext Result.failure( - Exception("Cannot execute $operationName: Node is ${nodeLifecycleState.value} and not starting") + Exception("Cannot execute $operationName: Node is ${_lightningState.value.nodeLifecycleState} and not starting") ) } val nodeRunning = withTimeoutOrNull(waitTimeout) { - if (nodeLifecycleState.value.isRunning()) { + if (_lightningState.value.nodeLifecycleState.isRunning()) { return@withTimeoutOrNull true } // Otherwise, wait for it to transition to running state Logger.debug("Waiting for node runs to execute $operationName", context = TAG) - _nodeLifecycleState.first { it.isRunning() } + _lightningState.first { it.nodeLifecycleState.isRunning() } Logger.debug("Operation executed: $operationName", context = TAG) true } ?: false @@ -113,51 +114,75 @@ class LightningRepo @Inject constructor( walletIndex: Int, timeout: Duration? = null, eventHandler: NodeEventHandler? = null - ): Result = - withContext(bgDispatcher) { - if (nodeLifecycleState.value.isRunningOrStarting()) { - return@withContext Result.success(Unit) - } - - try { - _nodeLifecycleState.value = NodeLifecycleState.Starting + ): Result = withContext(bgDispatcher) { + if (_lightningState.value.nodeLifecycleState.isRunningOrStarting()) { + return@withContext Result.success(Unit) + } - // Setup if not already setup - if (lightningService.node == null) { - val setupResult = setup(walletIndex) - if (setupResult.isFailure) { - _nodeLifecycleState.value = NodeLifecycleState.ErrorStarting( - setupResult.exceptionOrNull() ?: Exception("Unknown setup error") + try { + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Starting) } + + // Setup if not already setup + if (lightningService.node == null) { + val setupResult = setup(walletIndex) + if (setupResult.isFailure) { + _lightningState.update { + it.copy( + nodeLifecycleState = NodeLifecycleState.ErrorStarting( + setupResult.exceptionOrNull() ?: Exception("Unknown setup error") + ) ) - return@withContext setupResult } + return@withContext setupResult } + } - // Start the node service - lightningService.start(timeout) { event -> - eventHandler?.invoke(event) - ldkNodeEventBus.emit(event) - } + // Start the node service + lightningService.start(timeout) { event -> + eventHandler?.invoke(event) + ldkNodeEventBus.emit(event) + refreshBip21ForEvent(event) + } - _nodeLifecycleState.value = NodeLifecycleState.Running - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Node start error", e, context = TAG) - _nodeLifecycleState.value = NodeLifecycleState.ErrorStarting(e) - Result.failure(e) + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } + + // Initial state sync + syncState() + + // Perform post-startup tasks + connectToTrustedPeers().onFailure { e -> + Logger.error("Failed to connect to trusted peers", e) } + + // Refresh BIP21 and synchronize + refreshBip21() + sync() + registerForNotificationsIfNeeded() + + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Node start error", e, context = TAG) + _lightningState.update { + it.copy(nodeLifecycleState = NodeLifecycleState.ErrorStarting(e)) + } + Result.failure(e) } + } + + fun setInitNodeLifecycleState() { + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Initializing) } + } suspend fun stop(): Result = withContext(bgDispatcher) { - if (nodeLifecycleState.value.isStoppedOrStopping()) { + if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping()) { return@withContext Result.success(Unit) } try { executeWhenNodeRunning("stop") { - _nodeLifecycleState.value = NodeLifecycleState.Stopping + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) } lightningService.stop() - _nodeLifecycleState.value = NodeLifecycleState.Stopped + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopped) } Result.success(Unit) } } catch (e: Throwable) { @@ -167,7 +192,16 @@ class LightningRepo @Inject constructor( } suspend fun sync(): Result = executeWhenNodeRunning("Sync") { + + if (_lightningState.value.isSyncingWallet) { + Logger.warn("Sync already in progress, waiting for existing sync.") + return@executeWhenNodeRunning Result.success(Unit) + } + + _lightningState.update { it.copy(isSyncingWallet = true) } lightningService.sync() + _lightningState.update { it.copy(isSyncingWallet = false) } + Result.success(Unit) } @@ -269,3 +303,14 @@ class LightningRepo @Inject constructor( const val TAG = "LightningRepo" } } + +data class LightningState( + val nodeId: String = "", + val balanceDetails: BalanceDetails? = null, + val nodeStatus: NodeStatus? = null, + val nodeLifecycleState: NodeLifecycleState = NodeLifecycleState.Stopped, + val peers: List = emptyList(), + val channels: List = emptyList(), + val isRefreshing: Boolean = false, + val isSyncingWallet: Boolean = false +) From b993a25f42461e4feeab12ccc7a6de7c1ee31d94 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Fri, 2 May 2025 12:04:02 -0300 Subject: [PATCH 03/62] refactor: move logic and shared states from viewmodel to LightningRepo.kt WIP --- .../to/bitkit/repositories/LightningRepo.kt | 188 ++++++++++- .../to/bitkit/viewmodels/WalletViewModel.kt | 310 +++++------------- 2 files changed, 252 insertions(+), 246 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 3372532ef..c0d2ab45a 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -12,12 +13,15 @@ import org.lightningdevkit.ldknode.Address import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.Txid import org.lightningdevkit.ldknode.UserChannelId import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env +import to.bitkit.models.BalanceState import to.bitkit.models.LnPeer import to.bitkit.models.NodeLifecycleState import to.bitkit.services.LdkNodeEventBus @@ -35,11 +39,17 @@ class LightningRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningService: LightningService, private val ldkNodeEventBus: LdkNodeEventBus, - private val addressChecker: AddressChecker + private val addressChecker: AddressChecker, + private val walletRepo: WalletRepo //TODO REVERSE DEPENDENCY ) { private val _lightningState = MutableStateFlow(LightningState()) val lightningState = _lightningState.asStateFlow() + val nodeLifecycleState = _lightningState.asStateFlow().map { it.nodeLifecycleState } + + private val _balanceState = MutableStateFlow(null) + val balanceState = _balanceState.asStateFlow() + /** * Executes the provided operation only if the node is running. * If the node is not running, waits for it to be running for a specified timeout. @@ -192,7 +202,6 @@ class LightningRepo @Inject constructor( } suspend fun sync(): Result = executeWhenNodeRunning("Sync") { - if (_lightningState.value.isSyncingWallet) { Logger.warn("Sync already in progress, waiting for existing sync.") return@executeWhenNodeRunning Result.success(Unit) @@ -200,6 +209,7 @@ class LightningRepo @Inject constructor( _lightningState.update { it.copy(isSyncingWallet = true) } lightningService.sync() + syncState() _lightningState.update { it.copy(isSyncingWallet = false) } Result.success(Unit) @@ -226,6 +236,7 @@ class LightningRepo @Inject constructor( suspend fun disconnectPeer(peer: LnPeer): Result = executeWhenNodeRunning("Disconnect peer") { lightningService.disconnectPeer(peer) + syncState() Result.success(Unit) } @@ -252,12 +263,14 @@ class LightningRepo @Inject constructor( suspend fun payInvoice(bolt11: String, sats: ULong? = null): Result = executeWhenNodeRunning("Pay invoice") { val paymentId = lightningService.send(bolt11 = bolt11, sats = sats) + syncState() Result.success(paymentId) } suspend fun sendOnChain(address: Address, sats: ULong): Result = executeWhenNodeRunning("Send on-chain") { val txId = lightningService.send(address = address, sats = sats) + syncState() Result.success(txId) } @@ -272,32 +285,181 @@ class LightningRepo @Inject constructor( channelAmountSats: ULong, pushToCounterpartySats: ULong? = null ): Result = executeWhenNodeRunning("Open channel") { - lightningService.openChannel(peer, channelAmountSats, pushToCounterpartySats) + val result = lightningService.openChannel(peer, channelAmountSats, pushToCounterpartySats) + syncState() + result } suspend fun closeChannel(userChannelId: String, counterpartyNodeId: String): Result = executeWhenNodeRunning("Close channel") { lightningService.closeChannel(userChannelId, counterpartyNodeId) + syncState() + Result.success(Unit) + } + + suspend fun registerForNotificationsIfNeeded(): Result = withContext(bgDispatcher) { + walletRepo.registerForNotifications() + .onFailure { e -> + Logger.error("Failed to register device for notifications", e) + } + } + + suspend fun refreshBip21(): Result = withContext(bgDispatcher) { + Logger.debug("Refreshing bip21", context = "LightningRepo") + + // Check current address or generate new one + val currentAddress = walletRepo.getOnchainAddress() + if (currentAddress.isEmpty()) { + newAddress() + .onSuccess { address -> walletRepo.setOnchainAddress(address) } + .onFailure { error -> Logger.error("Error generating new address", error) } + } else { + // Check if current address has been used + checkAddressUsage(currentAddress) + .onSuccess { hasTransactions -> + if (hasTransactions) { + // Address has been used, generate a new one + newAddress() + .onSuccess { address -> walletRepo.setOnchainAddress(address) } + } + } + } + + updateBip21Invoice() + + return@withContext Result.success(Unit) + } + + private suspend fun refreshBip21ForEvent(event: Event) { + when (event) { + is Event.PaymentReceived, is Event.ChannelReady, is Event.ChannelClosed -> refreshBip21() + else -> Unit + } + } + + suspend fun updateBip21Invoice( + amountSats: ULong? = null, + description: String = "", + generateBolt11IfAvailable: Boolean = true, + tags: List = emptyList() + ): Result = withContext(bgDispatcher) { + try { + // Update state + _lightningState.update { + it.copy( + bip21AmountSats = amountSats, + bip21Description = description + ) + } + + val hasChannels = hasChannels() + + if (hasChannels && generateBolt11IfAvailable) { + createInvoice( + amountSats = _lightningState.value.bip21AmountSats, + description = _lightningState.value.bip21Description + ).onSuccess { bolt11 -> + walletRepo.setBolt11(bolt11) + } + } else { + walletRepo.setBolt11("") + } + + val newBip21 = walletRepo.buildBip21Url( + bitcoinAddress = walletRepo.getOnchainAddress(), + amountSats = _lightningState.value.bip21AmountSats, + message = description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE }, + lightningInvoice = walletRepo.getBolt11() + ) + walletRepo.setBip21(newBip21) + walletRepo.saveInvoiceWithTags(bip21Invoice = newBip21, tags = tags) + + _lightningState.update { it.copy(selectedTags = emptyList(), bip21Description = "") } + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Update BIP21 invoice error", e, context = TAG) + Result.failure(e) + } + } + + suspend fun syncState() { + _lightningState.update { + it.copy( + nodeId = getNodeId().orEmpty(), + balanceDetails = getBalances(), + nodeStatus = getStatus(), + peers = getPeers().orEmpty(), + channels = getChannels().orEmpty(), + ) + } + + syncBalances() + } + + private suspend fun syncBalances() { + getBalances()?.let { balance -> + val totalSats = balance.totalLightningBalanceSats + balance.totalOnchainBalanceSats + + val newBalance = BalanceState( + totalOnchainSats = balance.totalOnchainBalanceSats, + totalLightningSats = balance.totalLightningBalanceSats, + totalSats = totalSats, + ) + _balanceState.update { newBalance } + walletRepo.saveBalanceState(newBalance) + + if (totalSats > 0u) { + walletRepo.setShowEmptyState(false) + } } + } fun canSend(amountSats: ULong): Boolean = - nodeLifecycleState.value.isRunning() && lightningService.canSend(amountSats) + _lightningState.value.nodeLifecycleState.isRunning() && lightningService.canSend(amountSats) fun getSyncFlow(): Flow = lightningService.syncFlow() - fun getNodeId(): String? = if (nodeLifecycleState.value.isRunning()) lightningService.nodeId else null + fun getNodeId(): String? = if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.nodeId else null - fun getBalances(): BalanceDetails? = if (nodeLifecycleState.value.isRunning()) lightningService.balances else null + fun getBalances(): BalanceDetails? = if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.balances else null - fun getStatus(): NodeStatus? = if (nodeLifecycleState.value.isRunning()) lightningService.status else null + fun getStatus(): NodeStatus? = if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.status else null - fun getPeers(): List? = if (nodeLifecycleState.value.isRunning()) lightningService.peers else null + fun getPeers(): List? = if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.peers else null fun getChannels(): List? = - if (nodeLifecycleState.value.isRunning()) lightningService.channels else null + if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.channels else null + + fun hasChannels(): Boolean = _lightningState.value.nodeLifecycleState.isRunning() && lightningService.channels?.isNotEmpty() == true + + fun addTagToSelected(newTag: String) { + _lightningState.update { + it.copy( + selectedTags = (it.selectedTags + newTag).distinct() + ) + } + } + + fun removeTag(tag: String) { + _lightningState.update { + it.copy( + selectedTags = it.selectedTags.filterNot { tagItem -> tagItem == tag } + ) + } + } + + fun updateBip21Description(newText: String) { + _lightningState.update { it.copy(bip21Description = newText) } + } + + fun updateBip21AmountSats(amount: ULong?) { + _lightningState.update { it.copy(bip21AmountSats = amount) } + } - fun hasChannels(): Boolean = nodeLifecycleState.value.isRunning() && lightningService.channels?.isNotEmpty() == true + fun toggleReceiveOnSpendingBalance() { + _lightningState.update { it.copy(receiveOnSpendingBalance = !it.receiveOnSpendingBalance) } + } private companion object { const val TAG = "LightningRepo" @@ -312,5 +474,9 @@ data class LightningState( val peers: List = emptyList(), val channels: List = emptyList(), val isRefreshing: Boolean = false, - val isSyncingWallet: Boolean = false + val isSyncingWallet: Boolean = false, + val receiveOnSpendingBalance: Boolean = true, + val bip21AmountSats: ULong? = null, + val bip21Description: String = "", + val selectedTags: List = listOf() ) diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 2443f3e13..cd45ed783 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -9,23 +9,19 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import org.lightningdevkit.ldknode.BalanceDetails +import org.lightningdevkit.ldknode.Address import org.lightningdevkit.ldknode.ChannelDetails -import org.lightningdevkit.ldknode.Event -import org.lightningdevkit.ldknode.NodeStatus import to.bitkit.di.BgDispatcher import to.bitkit.env.Env -import to.bitkit.models.BalanceState import to.bitkit.models.LnPeer import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType -import to.bitkit.models.NodeLifecycleState import to.bitkit.models.Toast import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo @@ -40,206 +36,81 @@ class WalletViewModel @Inject constructor( private val walletRepo: WalletRepo, private val lightningRepo: LightningRepo, ) : ViewModel() { - private val _uiState = MutableStateFlow(MainUiState()) - val uiState = _uiState.asStateFlow() - private val _balanceState = MutableStateFlow(walletRepo.getBalanceState()) - val balanceState = _balanceState.asStateFlow() + // State from repositories + val lightningState = lightningRepo.lightningState + val walletState = walletRepo.walletState + val balanceState = lightningRepo.balanceState //TODO GET FROM WALLET + // Local UI state var walletExists by mutableStateOf(walletRepo.walletExists()) private set - init { - collectNodeLifecycleState() - } - var isRestoringWallet by mutableStateOf(false) - fun setWalletExistsState() { - walletExists = walletRepo.walletExists() - } - - fun setInitNodeLifecycleState() { - _uiState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Initializing) } - } - - fun start(walletIndex: Int = 0) { - if (!walletExists) return - if (_uiState.value.nodeLifecycleState.isRunningOrStarting()) return - - viewModelScope.launch(bgDispatcher) { - if (_uiState.value.nodeLifecycleState != NodeLifecycleState.Initializing) { - // Initializing means it's a wallet restore or create so we need to show the loading view - _uiState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Starting) } - } - - syncState() - - lightningRepo.start(walletIndex) { event -> - syncState() - refreshBip21ForEvent(event) - }.onFailure { error -> - Logger.error("Node startup error", error) - throw error - }.onSuccess { - syncState() - - lightningRepo.connectToTrustedPeers().onFailure { e -> - Logger.error("Failed to connect to trusted peers", e) - } - - // Refresh BIP21 and sync - launch(bgDispatcher) { refreshBip21() } - launch(bgDispatcher) { sync() } - launch(bgDispatcher) { registerForNotificationsIfNeeded() } - launch(bgDispatcher) { observeDbConfig() } - } - } - } - - private suspend fun refreshBip21ForEvent(event: Event) { - when (event) { - is Event.PaymentReceived, is Event.ChannelReady, is Event.ChannelClosed -> refreshBip21() - else -> Unit - } + init { + collectNodeLifecycleState() + collectSyncFlow() } - suspend fun observeLdkWallet() { - lightningRepo.getSyncFlow() - .filter { _uiState.value.nodeLifecycleState == NodeLifecycleState.Running } - .collect { - runCatching { sync() } - } + private fun collectNodeLifecycleState() { + lightningRepo.nodeLifecycleState.launchIn(viewModelScope) } - private fun collectNodeLifecycleState() { + private fun collectSyncFlow() { viewModelScope.launch(bgDispatcher) { - lightningRepo.nodeLifecycleState.collect { currentState -> - _uiState.update { it.copy(nodeLifecycleState = currentState) } + lightningRepo.getSyncFlow().collect { + lightningRepo.sync() + .onFailure { error -> + Logger.error("Failed to sync: ${error.message}", error) + } } } } - private suspend fun observeDbConfig() { - walletRepo.getDbConfig().collect { - Logger.info("Database config sync: $it") - } - } - - private var isSyncingWallet = false - - private suspend fun sync() { - syncState() - - if (isSyncingWallet) { - Logger.warn("Sync already in progress, waiting for existing sync.") - return - } - - isSyncingWallet = true - syncState() - - lightningRepo.sync() - .onSuccess { - isSyncingWallet = false - syncState() - } - .onFailure { e -> - isSyncingWallet = false - throw e - } + fun setWalletExistsState() { + walletExists = walletRepo.walletExists() } - private fun syncState() { - _uiState.update { - it.copy( - nodeId = lightningRepo.getNodeId().orEmpty(), - onchainAddress = walletRepo.getOnchainAddress(), - bolt11 = walletRepo.getBolt11(), - bip21 = walletRepo.getBip21(), - nodeStatus = lightningRepo.getStatus(), - peers = lightningRepo.getPeers().orEmpty(), - channels = lightningRepo.getChannels().orEmpty(), - ) - } - - viewModelScope.launch(bgDispatcher) { syncBalances() } + fun setInitNodeLifecycleState() { + lightningRepo.setInitNodeLifecycleState() } - private fun syncBalances() { - lightningRepo.getBalances()?.let { balance -> - _uiState.update { it.copy(balanceDetails = balance) } - val totalSats = balance.totalLightningBalanceSats + balance.totalOnchainBalanceSats - - val newBalance = BalanceState( - totalOnchainSats = balance.totalOnchainBalanceSats, - totalLightningSats = balance.totalLightningBalanceSats, - totalSats = totalSats, - ) - _balanceState.update { newBalance } - walletRepo.saveBalanceState(newBalance) + fun start(walletIndex: Int = 0) { + if (!walletExists) return - if (totalSats > 0u) { - viewModelScope.launch { - walletRepo.setShowEmptyState(false) + viewModelScope.launch(bgDispatcher) { + lightningRepo.start(walletIndex) + .onFailure { error -> + Logger.error("Node startup error", error) + ToastEventBus.send(error) } - } } } fun refreshState() { viewModelScope.launch { - sync() + lightningRepo.sync() + .onFailure { error -> + Logger.error("Failed to sync: ${error.message}", error) + ToastEventBus.send(error) + } } } fun onPullToRefresh() { viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true) } try { - sync() + lightningRepo.sync() + .onFailure { error -> + ToastEventBus.send(error) + } } catch (e: Throwable) { ToastEventBus.send(e) - } finally { - _uiState.update { it.copy(isRefreshing = false) } } } } - private suspend fun registerForNotificationsIfNeeded() { - walletRepo.registerForNotifications() - .onFailure { e -> - Logger.error("Failed to register device for notifications", e) - } - } - - private val incomingLightningCapacitySats: ULong? - get() = lightningRepo.getChannels()?.sumOf { it.inboundCapacityMsat / 1000u } - - suspend fun refreshBip21() { - Logger.debug("Refreshing bip21", context = "WalletViewModel") - - // Check current address or generate new one - val currentAddress = walletRepo.getOnchainAddress() - if (currentAddress.isEmpty()) { - lightningRepo.newAddress() - .onSuccess { address -> walletRepo.setOnchainAddress(address) } - .onFailure { error -> Logger.error("Error generating new address", error) } - } else { - // Check if current address has been used - lightningRepo.checkAddressUsage(currentAddress) - .onSuccess { hasTransactions -> - if (hasTransactions) { - // Address has been used, generate a new one - lightningRepo.newAddress() - .onSuccess { address -> walletRepo.setOnchainAddress(address) } - } - } - } - - updateBip21Invoice() - } - fun disconnectPeer(peer: LnPeer) { viewModelScope.launch { lightningRepo.disconnectPeer(peer) @@ -249,9 +120,6 @@ class WalletViewModel @Inject constructor( title = "Success", description = "Peer disconnected." ) - _uiState.update { - it.copy(peers = lightningRepo.getPeers().orEmpty()) - } } .onFailure { error -> ToastEventBus.send( @@ -266,7 +134,6 @@ class WalletViewModel @Inject constructor( fun send(bolt11: String) { viewModelScope.launch(bgDispatcher) { lightningRepo.payInvoice(bolt11) - .onSuccess { syncState() } .onFailure { error -> ToastEventBus.send( type = Toast.ToastType.ERROR, @@ -283,40 +150,27 @@ class WalletViewModel @Inject constructor( generateBolt11IfAvailable: Boolean = true ) { viewModelScope.launch { - _uiState.update { it.copy(bip21AmountSats = amountSats, bip21Description = description) } - - val hasChannels = lightningRepo.hasChannels() - - if (hasChannels && generateBolt11IfAvailable) { - lightningRepo.createInvoice( - amountSats = _uiState.value.bip21AmountSats, - description = _uiState.value.bip21Description - ).onSuccess { bolt11 -> - walletRepo.setBolt11(bolt11) - } - } else { - walletRepo.setBolt11("") + lightningRepo.updateBip21Invoice( + amountSats = amountSats, + description = description, + generateBolt11IfAvailable = generateBolt11IfAvailable, + tags = lightningState.value.selectedTags + ).onFailure { error -> + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = "Error updating invoice", + description = error.message ?: "Unknown error" + ) } - - val newBip21 = walletRepo.buildBip21Url( - bitcoinAddress = walletRepo.getOnchainAddress(), - amountSats = _uiState.value.bip21AmountSats, - message = description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE }, - lightningInvoice = walletRepo.getBolt11() - ) - walletRepo.setBip21(newBip21) - walletRepo.saveInvoiceWithTags(bip21Invoice = newBip21, tags = _uiState.value.selectedTags) - clearTagsAndBip21DescriptionState() - syncState() } } fun updateReceiveOnSpending() { - _uiState.update { it.copy(receiveOnSpendingBalance = !it.receiveOnSpendingBalance) } + lightningRepo.toggleReceiveOnSpendingBalance() updateBip21Invoice( - amountSats = _uiState.value.bip21AmountSats, - description = _uiState.value.bip21Description, - generateBolt11IfAvailable = _uiState.value.receiveOnSpendingBalance + amountSats = lightningState.value.bip21AmountSats, + description = lightningState.value.bip21Description, + generateBolt11IfAvailable = lightningState.value.receiveOnSpendingBalance ) } @@ -363,9 +217,7 @@ class WalletViewModel @Inject constructor( lightningRepo.closeChannel( channel.userChannelId, channel.counterpartyNodeId - ).onSuccess { - syncState() - }.onFailure { + ).onFailure { ToastEventBus.send(it) } } @@ -376,16 +228,21 @@ class WalletViewModel @Inject constructor( walletRepo.wipeWallet() .onSuccess { lightningRepo.wipeStorage(walletIndex = 0) - setWalletExistsState() - }.onFailure { - ToastEventBus.send(it) + .onSuccess { + setWalletExistsState() + } + .onFailure { error -> + ToastEventBus.send(error) + } + }.onFailure { error -> + ToastEventBus.send(error) } } } suspend fun createWallet(bip39Passphrase: String?) { - walletRepo.createWallet(bip39Passphrase).onFailure { - ToastEventBus.send(it) + walletRepo.createWallet(bip39Passphrase).onFailure { error -> + ToastEventBus.send(error) } } @@ -393,12 +250,12 @@ class WalletViewModel @Inject constructor( walletRepo.restoreWallet( mnemonic = mnemonic, bip39Passphrase = bip39Passphrase - ).onFailure { - ToastEventBus.send(it) + ).onFailure { error -> + ToastEventBus.send(error) } } - // region debug + // region debug methods fun manualRegisterForNotifications() { viewModelScope.launch(bgDispatcher) { walletRepo.registerForNotifications() @@ -417,7 +274,6 @@ class WalletViewModel @Inject constructor( viewModelScope.launch { lightningRepo.newAddress().onSuccess { address -> walletRepo.setOnchainAddress(address) - syncState() }.onFailure { ToastEventBus.send(it) } } } @@ -498,36 +354,20 @@ class WalletViewModel @Inject constructor( } } - private suspend fun stopLightningNode() { - viewModelScope.launch(bgDispatcher) { - lightningRepo.stop().onSuccess { - syncState() - } - } - } - fun addTagToSelected(newTag: String) { - _uiState.update { - it.copy( - selectedTags = (it.selectedTags + newTag).distinct() - ) + viewModelScope.launch(bgDispatcher) { + walletRepo.addTagToSelected(newTag) } } fun removeTag(tag: String) { - _uiState.update { - it.copy( - selectedTags = it.selectedTags.filterNot { tagItem -> tagItem == tag } - ) + viewModelScope.launch(bgDispatcher) { + walletRepo.removeTag(tag) } } fun updateBip21Description(newText: String) { - _uiState.update { it.copy(bip21Description = newText) } - } - - private fun clearTagsAndBip21DescriptionState() { - _uiState.update { it.copy(selectedTags = listOf(), bip21Description = "") } + walletRepo.updateBip21Description(newText) } } From 0a322f74ea623b605fc6b38adb83ba0fb40e3a82 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Fri, 2 May 2025 14:22:13 -0300 Subject: [PATCH 04/62] refactor: move logic and shared states from viewmodel to repository --- .../to/bitkit/repositories/LightningRepo.kt | 165 +----------------- .../java/to/bitkit/repositories/WalletRepo.kt | 131 +++++++++++++- .../to/bitkit/viewmodels/WalletViewModel.kt | 71 +++++--- 3 files changed, 181 insertions(+), 186 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index c0d2ab45a..16a35cd0b 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -13,21 +13,17 @@ import org.lightningdevkit.ldknode.Address import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelDetails -import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.Txid import org.lightningdevkit.ldknode.UserChannelId import to.bitkit.di.BgDispatcher -import to.bitkit.env.Env -import to.bitkit.models.BalanceState import to.bitkit.models.LnPeer import to.bitkit.models.NodeLifecycleState import to.bitkit.services.LdkNodeEventBus import to.bitkit.services.LightningService import to.bitkit.services.NodeEventHandler -import to.bitkit.utils.AddressChecker import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -39,17 +35,12 @@ class LightningRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningService: LightningService, private val ldkNodeEventBus: LdkNodeEventBus, - private val addressChecker: AddressChecker, - private val walletRepo: WalletRepo //TODO REVERSE DEPENDENCY ) { private val _lightningState = MutableStateFlow(LightningState()) val lightningState = _lightningState.asStateFlow() val nodeLifecycleState = _lightningState.asStateFlow().map { it.nodeLifecycleState } - private val _balanceState = MutableStateFlow(null) - val balanceState = _balanceState.asStateFlow() - /** * Executes the provided operation only if the node is running. * If the node is not running, waits for it to be running for a specified timeout. @@ -151,7 +142,6 @@ class LightningRepo @Inject constructor( lightningService.start(timeout) { event -> eventHandler?.invoke(event) ldkNodeEventBus.emit(event) - refreshBip21ForEvent(event) } _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } @@ -164,10 +154,12 @@ class LightningRepo @Inject constructor( Logger.error("Failed to connect to trusted peers", e) } - // Refresh BIP21 and synchronize - refreshBip21() + /*TODO IF SUCCESS CALL: + * walletRepo.registerForNotifications() + * refreshBip21() + * refreshBip21ForEvent(event) + * */ sync() - registerForNotificationsIfNeeded() Result.success(Unit) } catch (e: Throwable) { @@ -178,7 +170,6 @@ class LightningRepo @Inject constructor( Result.failure(e) } } - fun setInitNodeLifecycleState() { _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Initializing) } } @@ -245,12 +236,6 @@ class LightningRepo @Inject constructor( Result.success(address) } - suspend fun checkAddressUsage(address: String): Result = executeWhenNodeRunning("Check address usage") { - val addressInfo = addressChecker.getAddressInfo(address) - val hasTransactions = addressInfo.chain_stats.tx_count > 0 || addressInfo.mempool_stats.tx_count > 0 - Result.success(hasTransactions) - } - suspend fun createInvoice( amountSats: ULong? = null, description: String, @@ -297,122 +282,17 @@ class LightningRepo @Inject constructor( Result.success(Unit) } - suspend fun registerForNotificationsIfNeeded(): Result = withContext(bgDispatcher) { - walletRepo.registerForNotifications() - .onFailure { e -> - Logger.error("Failed to register device for notifications", e) - } - } - - suspend fun refreshBip21(): Result = withContext(bgDispatcher) { - Logger.debug("Refreshing bip21", context = "LightningRepo") - - // Check current address or generate new one - val currentAddress = walletRepo.getOnchainAddress() - if (currentAddress.isEmpty()) { - newAddress() - .onSuccess { address -> walletRepo.setOnchainAddress(address) } - .onFailure { error -> Logger.error("Error generating new address", error) } - } else { - // Check if current address has been used - checkAddressUsage(currentAddress) - .onSuccess { hasTransactions -> - if (hasTransactions) { - // Address has been used, generate a new one - newAddress() - .onSuccess { address -> walletRepo.setOnchainAddress(address) } - } - } - } - - updateBip21Invoice() - - return@withContext Result.success(Unit) - } - - private suspend fun refreshBip21ForEvent(event: Event) { - when (event) { - is Event.PaymentReceived, is Event.ChannelReady, is Event.ChannelClosed -> refreshBip21() - else -> Unit - } - } - - suspend fun updateBip21Invoice( - amountSats: ULong? = null, - description: String = "", - generateBolt11IfAvailable: Boolean = true, - tags: List = emptyList() - ): Result = withContext(bgDispatcher) { - try { - // Update state - _lightningState.update { - it.copy( - bip21AmountSats = amountSats, - bip21Description = description - ) - } - - val hasChannels = hasChannels() - if (hasChannels && generateBolt11IfAvailable) { - createInvoice( - amountSats = _lightningState.value.bip21AmountSats, - description = _lightningState.value.bip21Description - ).onSuccess { bolt11 -> - walletRepo.setBolt11(bolt11) - } - } else { - walletRepo.setBolt11("") - } - - val newBip21 = walletRepo.buildBip21Url( - bitcoinAddress = walletRepo.getOnchainAddress(), - amountSats = _lightningState.value.bip21AmountSats, - message = description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE }, - lightningInvoice = walletRepo.getBolt11() - ) - walletRepo.setBip21(newBip21) - walletRepo.saveInvoiceWithTags(bip21Invoice = newBip21, tags = tags) - - _lightningState.update { it.copy(selectedTags = emptyList(), bip21Description = "") } - - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Update BIP21 invoice error", e, context = TAG) - Result.failure(e) - } - } suspend fun syncState() { _lightningState.update { it.copy( nodeId = getNodeId().orEmpty(), - balanceDetails = getBalances(), nodeStatus = getStatus(), peers = getPeers().orEmpty(), channels = getChannels().orEmpty(), ) } - - syncBalances() - } - - private suspend fun syncBalances() { - getBalances()?.let { balance -> - val totalSats = balance.totalLightningBalanceSats + balance.totalOnchainBalanceSats - - val newBalance = BalanceState( - totalOnchainSats = balance.totalOnchainBalanceSats, - totalLightningSats = balance.totalLightningBalanceSats, - totalSats = totalSats, - ) - _balanceState.update { newBalance } - walletRepo.saveBalanceState(newBalance) - - if (totalSats > 0u) { - walletRepo.setShowEmptyState(false) - } - } } fun canSend(amountSats: ULong): Boolean = @@ -433,34 +313,6 @@ class LightningRepo @Inject constructor( fun hasChannels(): Boolean = _lightningState.value.nodeLifecycleState.isRunning() && lightningService.channels?.isNotEmpty() == true - fun addTagToSelected(newTag: String) { - _lightningState.update { - it.copy( - selectedTags = (it.selectedTags + newTag).distinct() - ) - } - } - - fun removeTag(tag: String) { - _lightningState.update { - it.copy( - selectedTags = it.selectedTags.filterNot { tagItem -> tagItem == tag } - ) - } - } - - fun updateBip21Description(newText: String) { - _lightningState.update { it.copy(bip21Description = newText) } - } - - fun updateBip21AmountSats(amount: ULong?) { - _lightningState.update { it.copy(bip21AmountSats = amount) } - } - - fun toggleReceiveOnSpendingBalance() { - _lightningState.update { it.copy(receiveOnSpendingBalance = !it.receiveOnSpendingBalance) } - } - private companion object { const val TAG = "LightningRepo" } @@ -468,15 +320,10 @@ class LightningRepo @Inject constructor( data class LightningState( val nodeId: String = "", - val balanceDetails: BalanceDetails? = null, val nodeStatus: NodeStatus? = null, val nodeLifecycleState: NodeLifecycleState = NodeLifecycleState.Stopped, val peers: List = emptyList(), val channels: List = emptyList(), val isRefreshing: Boolean = false, - val isSyncingWallet: Boolean = false, - val receiveOnSpendingBalance: Boolean = true, - val bip21AmountSats: ULong? = null, - val bip21Description: String = "", - val selectedTags: List = listOf() + val isSyncingWallet: Boolean = false ) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 7e3873659..e75875515 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -2,14 +2,19 @@ package to.bitkit.repositories import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext import kotlinx.datetime.Clock +import org.lightningdevkit.ldknode.BalanceDetails +import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.Txid import to.bitkit.data.AppDb @@ -24,6 +29,7 @@ import to.bitkit.ext.toHex import to.bitkit.models.BalanceState import to.bitkit.services.BlocktankNotificationsService import to.bitkit.services.CoreService +import to.bitkit.utils.AddressChecker import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger import uniffi.bitkitcore.Activity @@ -40,6 +46,7 @@ import kotlin.time.Duration.Companion.seconds @Singleton class WalletRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val bgScope: CoroutineScope = CoroutineScope(bgDispatcher + SupervisorJob()), private val appStorage: AppStorage, private val db: AppDb, private val keychain: Keychain, @@ -47,6 +54,8 @@ class WalletRepo @Inject constructor( private val blocktankNotificationsService: BlocktankNotificationsService, private val firebaseMessaging: FirebaseMessaging, private val settingsStore: SettingsStore, + private val addressChecker: AddressChecker, + private val lightningRepo: LightningRepo ) { private val _walletState = MutableStateFlow(WalletState( @@ -66,6 +75,122 @@ class WalletRepo @Inject constructor( _walletState.update { it.copy(walletExists = walletExists()) } } + init { + bgScope.launch { + lightningRepo.getSyncFlow().collect { + lightningRepo.sync().onSuccess { + syncBalances() + } + } + } + } + + 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) + } catch (e: Exception) { + Logger.error("checkAddressUsage error", e, context = TAG) + Result.failure(e) + } + } + + suspend fun refreshBip21(): Result = withContext(bgDispatcher) { + Logger.debug("Refreshing bip21", context = "LightningRepo") + + // Check current address or generate new one + val currentAddress = getOnchainAddress() + if (currentAddress.isEmpty()) { + lightningRepo.newAddress() + .onSuccess { address -> setOnchainAddress(address) } + .onFailure { error -> Logger.error("Error generating new address", error) } + } else { + // Check if current address has been used + checkAddressUsage(currentAddress) + .onSuccess { hasTransactions -> + if (hasTransactions) { + // Address has been used, generate a new one + lightningRepo.newAddress().onSuccess { address -> setOnchainAddress(address) } + } + } + } + + updateBip21Invoice() + + return@withContext Result.success(Unit) + } + + private suspend fun syncBalances() { + lightningRepo.getBalances()?.let { balance -> + val totalSats = balance.totalLightningBalanceSats + balance.totalOnchainBalanceSats + + val newBalance = BalanceState( + totalOnchainSats = balance.totalOnchainBalanceSats, + totalLightningSats = balance.totalLightningBalanceSats, + totalSats = totalSats, + ) + _balanceState.update { newBalance } + _walletState.update { it.copy(balanceDetails = lightningRepo.getBalances()) } + saveBalanceState(newBalance) + + setShowEmptyState(totalSats <= 0u) + } + } + + suspend fun updateBip21Invoice( + amountSats: ULong? = null, + description: String = "", + generateBolt11IfAvailable: Boolean = true, + tags: List = emptyList() + ): Result = withContext(bgDispatcher) { + try { + // Update state + _walletState.update { + it.copy( + bip21AmountSats = amountSats, + bip21Description = description + ) + } + + val hasChannels = lightningRepo.hasChannels() + + if (hasChannels && generateBolt11IfAvailable) { + lightningRepo.createInvoice( + amountSats = _walletState.value.bip21AmountSats, + description = _walletState.value.bip21Description + ).onSuccess { bolt11 -> + setBolt11(bolt11) + } + } else { + setBolt11("") + } + + val newBip21 = buildBip21Url( + bitcoinAddress = getOnchainAddress(), + amountSats = _walletState.value.bip21AmountSats, + message = description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE }, + lightningInvoice = getBolt11() + ) + setBip21(newBip21) + saveInvoiceWithTags(bip21Invoice = newBip21, tags = tags) + + _walletState.update { it.copy(selectedTags = emptyList(), bip21Description = "") } + + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Update BIP21 invoice error", e, context = TAG) + Result.failure(e) + } + } + + private suspend fun refreshBip21ForEvent(event: Event) { + when (event) { + is Event.PaymentReceived, is Event.ChannelReady, is Event.ChannelClosed -> refreshBip21() + else -> Unit + } + } + fun setRestoringWalletState(isRestoring: Boolean) { _walletState.update { it.copy(isRestoringWallet = isRestoring) } } @@ -80,7 +205,7 @@ class WalletRepo @Inject constructor( setWalletExistsState() Result.success(Unit) } catch (e: Throwable) { - Logger.error("Create wallet error", e) + Logger.error("Create wallet error", e, context = TAG) Result.failure(e) } } @@ -212,7 +337,6 @@ class WalletRepo @Inject constructor( amountSats: ULong? = null, description: String = "", generateBolt11IfAvailable: Boolean = true, - lightningRepo: LightningRepo //TODO MAYBE INJECT IN THE CLASS ) = withContext(bgDispatcher) { updateBip21AmountSats(amountSats) updateBip21Description(description) @@ -500,5 +624,6 @@ data class WalletState( val receiveOnSpendingBalance: Boolean = true, val showEmptyState: Boolean = true, val walletExists: Boolean = false, - val isRestoringWallet: Boolean = false + val isRestoringWallet: Boolean = false, + val balanceDetails: BalanceDetails? = null, //TODO KEEP ONLY BalanceState IF POSSIBLE ) diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index cd45ed783..be53ac5cb 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -9,19 +9,20 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.lightningdevkit.ldknode.Address +import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.NodeStatus import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.models.LnPeer import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType +import to.bitkit.models.NodeLifecycleState import to.bitkit.models.Toast import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo @@ -37,10 +38,9 @@ class WalletViewModel @Inject constructor( private val lightningRepo: LightningRepo, ) : ViewModel() { - // State from repositories val lightningState = lightningRepo.lightningState val walletState = walletRepo.walletState - val balanceState = lightningRepo.balanceState //TODO GET FROM WALLET + val balanceState = walletRepo.balanceState // Local UI state var walletExists by mutableStateOf(walletRepo.walletExists()) @@ -48,22 +48,45 @@ class WalletViewModel @Inject constructor( var isRestoringWallet by mutableStateOf(false) + private val _uiState = MutableStateFlow(MainUiState()) + val uiState = _uiState.asStateFlow() + init { - collectNodeLifecycleState() - collectSyncFlow() + collectStates() } - private fun collectNodeLifecycleState() { - lightningRepo.nodeLifecycleState.launchIn(viewModelScope) - } + private fun collectStates() { //This is necessary to avoid a bigger refactor in all application + viewModelScope.launch(bgDispatcher) { + walletState.collect { state -> + walletExists = state.walletExists + isRestoringWallet = state.isRestoringWallet + _uiState.update { + it.copy( + onchainAddress = state.onchainAddress, + bolt11 = state.bolt11, + bip21 = state.bip21, + bip21AmountSats = state.bip21AmountSats, + bip21Description = state.bip21Description, + selectedTags = state.selectedTags, + receiveOnSpendingBalance = state.receiveOnSpendingBalance, + balanceDetails = state.balanceDetails + ) + } + } + } - private fun collectSyncFlow() { viewModelScope.launch(bgDispatcher) { - lightningRepo.getSyncFlow().collect { - lightningRepo.sync() - .onFailure { error -> - Logger.error("Failed to sync: ${error.message}", error) - } + lightningState.collect { state -> + _uiState.update { + it.copy( + nodeId = state.nodeId, + nodeStatus = state.nodeStatus, + nodeLifecycleState = state.nodeLifecycleState, + peers = state.peers, + channels = state.channels, + isRefreshing = state.isRefreshing, + ) + } } } } @@ -150,11 +173,11 @@ class WalletViewModel @Inject constructor( generateBolt11IfAvailable: Boolean = true ) { viewModelScope.launch { - lightningRepo.updateBip21Invoice( + walletRepo.updateBip21Invoice( amountSats = amountSats, description = description, generateBolt11IfAvailable = generateBolt11IfAvailable, - tags = lightningState.value.selectedTags + tags = walletState.value.selectedTags ).onFailure { error -> ToastEventBus.send( type = Toast.ToastType.ERROR, @@ -166,11 +189,11 @@ class WalletViewModel @Inject constructor( } fun updateReceiveOnSpending() { - lightningRepo.toggleReceiveOnSpendingBalance() + walletRepo.toggleReceiveOnSpendingBalance() updateBip21Invoice( - amountSats = lightningState.value.bip21AmountSats, - description = lightningState.value.bip21Description, - generateBolt11IfAvailable = lightningState.value.receiveOnSpendingBalance + amountSats = walletState.value.bip21AmountSats, + description = walletState.value.bip21Description, + generateBolt11IfAvailable = walletState.value.receiveOnSpendingBalance ) } From 52f83412830adf1f1b76b1371ee7b26e49c9938f Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Fri, 2 May 2025 14:23:38 -0300 Subject: [PATCH 05/62] refactor: remove reference --- app/src/main/java/to/bitkit/ui/ContentView.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index b21491b49..ec7be83dd 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -151,10 +151,6 @@ fun ContentView( } } - LaunchedEffect(Unit) { - walletViewModel.observeLdkWallet() - } - LaunchedEffect(appViewModel) { appViewModel.mainScreenEffect.collect { when (it) { From 345f3d7f0266892335f1103f7d568f0733676c87 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Fri, 2 May 2025 14:39:20 -0300 Subject: [PATCH 06/62] fix: dependency injection --- app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 3 ++- .../to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index e75875515..b36fa4d25 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -46,7 +46,6 @@ import kotlin.time.Duration.Companion.seconds @Singleton class WalletRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val bgScope: CoroutineScope = CoroutineScope(bgDispatcher + SupervisorJob()), private val appStorage: AppStorage, private val db: AppDb, private val keychain: Keychain, @@ -58,6 +57,8 @@ class WalletRepo @Inject constructor( private val lightningRepo: LightningRepo ) { + private val bgScope: CoroutineScope = CoroutineScope(bgDispatcher + SupervisorJob()) + private val _walletState = MutableStateFlow(WalletState( onchainAddress = appStorage.onchainAddress, bolt11 = appStorage.bolt11, 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 7f35a0743..dcaf63365 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 @@ -97,8 +97,8 @@ fun ReceiveQrSheet( LaunchedEffect(Unit) { try { - coroutineScope { - launch { wallet.refreshBip21() } + coroutineScope { //TODO THIS WILL TRIGGER AN ERROR TOAST IF CLOSES THE SCREEN TOO EARLY +// launch { wallet.refreshBip21() } TODO CHECK IF IT IS NECESSARY launch { blocktank.refreshInfo() } } } catch (e: Exception) { From 8dc59ad6818cb895e92b78e8e0bed9f2e58489e0 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Fri, 2 May 2025 14:52:01 -0300 Subject: [PATCH 07/62] feat: add comments --- app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 3 ++- app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 16a35cd0b..0531647f7 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -162,7 +162,7 @@ class LightningRepo @Inject constructor( sync() Result.success(Unit) - } catch (e: Throwable) { + } catch (e: Throwable) { //TODO RETRY Logger.error("Node start error", e, context = TAG) _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.ErrorStarting(e)) @@ -170,6 +170,7 @@ class LightningRepo @Inject constructor( Result.failure(e) } } + fun setInitNodeLifecycleState() { _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Initializing) } } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index b36fa4d25..1455320c0 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -98,7 +98,7 @@ class WalletRepo @Inject constructor( } suspend fun refreshBip21(): Result = withContext(bgDispatcher) { - Logger.debug("Refreshing bip21", context = "LightningRepo") + Logger.debug("Refreshing bip21", context = TAG) // Check current address or generate new one val currentAddress = getOnchainAddress() @@ -117,6 +117,7 @@ class WalletRepo @Inject constructor( } } + //TODO MAYBE CALL clearTagsAndBip21DescriptionState() updateBip21Invoice() return@withContext Result.success(Unit) From fc97264a3a1dd3d842f06377c9a467cdc9a48759 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 5 May 2025 08:11:55 -0300 Subject: [PATCH 08/62] feat: create the LightningNodeService.kt --- app/src/main/AndroidManifest.xml | 7 ++ .../androidServices/LightningNodeService.kt | 92 +++++++++++++++++++ .../to/bitkit/repositories/LightningRepo.kt | 8 +- .../java/to/bitkit/repositories/WalletRepo.kt | 3 +- 4 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2139c4d5f..ed73ed2fc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,6 +20,12 @@ android:roundIcon="@mipmap/ic_launcher_regtest_round" android:supportsRtl="true" android:theme="@style/Theme.App"> + + + diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt new file mode 100644 index 000000000..ab7cf6db2 --- /dev/null +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -0,0 +1,92 @@ +package to.bitkit.androidServices + +import android.app.Notification +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.IBinder +import androidx.core.app.NotificationCompat +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.WalletRepo +import to.bitkit.ui.MainActivity +import javax.inject.Inject + +@AndroidEntryPoint +class LightningNodeService : Service() { + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + @Inject + lateinit var lightningRepo: LightningRepo + + @Inject + lateinit var walletRepo: WalletRepo + + override fun onCreate() { + super.onCreate() + setupService() + } + + private fun setupService() { + serviceScope.launch { + startForeground(NOTIFICATION_ID, createNotification()) + + launch { + lightningRepo.start( + eventHandler = { event -> + walletRepo.refreshBip21ForEvent(event) + } + ).onSuccess { + val notification = createNotification() + startForeground(NOTIFICATION_ID, notification) + + walletRepo.registerForNotifications() + walletRepo.refreshBip21() + } + } + } + } + + private fun createNotification( + contentText: String = "Bitkit is running in background so you can receive Lightning payments" + ): Notification { + val notificationIntent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(this, BITKIT_CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText(contentText) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentIntent(pendingIntent) + .build() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return START_STICKY + } + + override fun onDestroy() { + serviceScope.launch { + lightningRepo.stop().onSuccess { + serviceScope.cancel() + } + } + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + companion object { + private const val NOTIFICATION_ID = 1 + const val BITKIT_CHANNEL_ID = "bitkit_notification_channel" + } +} diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 0531647f7..0a0e54252 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -112,7 +112,7 @@ class LightningRepo @Inject constructor( } suspend fun start( - walletIndex: Int, + walletIndex: Int = 0, timeout: Duration? = null, eventHandler: NodeEventHandler? = null ): Result = withContext(bgDispatcher) { @@ -153,12 +153,6 @@ class LightningRepo @Inject constructor( connectToTrustedPeers().onFailure { e -> Logger.error("Failed to connect to trusted peers", e) } - - /*TODO IF SUCCESS CALL: - * walletRepo.registerForNotifications() - * refreshBip21() - * refreshBip21ForEvent(event) - * */ sync() Result.success(Unit) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 1455320c0..05f2d8cf6 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -147,7 +147,6 @@ class WalletRepo @Inject constructor( tags: List = emptyList() ): Result = withContext(bgDispatcher) { try { - // Update state _walletState.update { it.copy( bip21AmountSats = amountSats, @@ -186,7 +185,7 @@ class WalletRepo @Inject constructor( } } - private suspend fun refreshBip21ForEvent(event: Event) { + suspend fun refreshBip21ForEvent(event: Event) { when (event) { is Event.PaymentReceived, is Event.ChannelReady, is Event.ChannelClosed -> refreshBip21() else -> Unit From cba301f8e510b868589e2bcb27554a95a2be436f Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 5 May 2025 08:22:25 -0300 Subject: [PATCH 09/62] feat: check if the node is running before call start --- app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 7 +++++++ app/src/main/java/to/bitkit/services/LightningService.kt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 0a0e54252..a50d34c4b 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -138,6 +138,13 @@ 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 = eventHandler) + return@withContext Result.success(Unit) + } + // Start the node service lightningService.start(timeout) { event -> eventHandler?.invoke(event) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 02a5f2e6f..51bb37648 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -386,7 +386,7 @@ class LightningService @Inject constructor( // region events private var shouldListenForEvents = true - private suspend fun listenForEvents(onEvent: NodeEventHandler? = null) { + suspend fun listenForEvents(onEvent: NodeEventHandler? = null) { while (shouldListenForEvents) { val node = this.node ?: let { Logger.error(ServiceError.NodeNotStarted.message.orEmpty()) From 9e5b571f1f42ab2e1f5dbf9fcba3eaa350a00a4b Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 5 May 2025 08:33:13 -0300 Subject: [PATCH 10/62] feat: create the notification channel --- app/src/main/java/to/bitkit/App.kt | 20 +++++++++++++++++++ .../androidServices/LightningNodeService.kt | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index 636154623..d607e95c0 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -4,10 +4,14 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.Application import android.app.Application.ActivityLifecycleCallbacks +import android.app.NotificationChannel +import android.app.NotificationManager +import android.os.Build import android.os.Bundle import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp +import to.bitkit.androidServices.LightningNodeService.Companion.BITKIT_CHANNEL_ID import to.bitkit.env.Env import javax.inject.Inject @@ -24,6 +28,7 @@ internal open class App : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) } + createNotificationChannel() Env.initAppStoragePath(filesDir.absolutePath) } @@ -32,6 +37,21 @@ internal open class App : Application(), Configuration.Provider { @SuppressLint("StaticFieldLeak") // Should be safe given its manual memory management internal var currentActivity: CurrentActivity? = null } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = getString(R.string.app_name) + val descriptionText = "Channel for LightningNodeService" + val importance = NotificationManager.IMPORTANCE_LOW + + val channel = NotificationChannel(BITKIT_CHANNEL_ID, name, importance).apply { + description = descriptionText + } + + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } } // region currentActivity diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index ab7cf6db2..57fe444df 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -55,7 +55,7 @@ class LightningNodeService : Service() { } private fun createNotification( - contentText: String = "Bitkit is running in background so you can receive Lightning payments" + contentText: String = "Bitkit is running in background so you can receive Lightning payments" //TODO GER FROM RESOURCES ): Notification { val notificationIntent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity( From 82af9c1493b1882918df1184fec996d5000fb055 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 5 May 2025 08:36:10 -0300 Subject: [PATCH 11/62] refactor: remove stop call from view --- app/src/main/java/to/bitkit/ui/ContentView.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 6b053cde9..320ee55d9 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -116,7 +116,7 @@ fun ContentView( val scope = rememberCoroutineScope() // Effects on app entering fg (ON_START) / bg (ON_STOP) - DisposableEffect(lifecycle) { + DisposableEffect(lifecycle) { //TODO ADAPT THIS LOGIC TO WORK WITH LightningNodeService val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_START -> { @@ -136,10 +136,6 @@ fun ContentView( blocktankViewModel.triggerRefreshOrders() } - Lifecycle.Event.ON_STOP -> { - walletViewModel.stopIfNeeded() - } - else -> Unit } } @@ -184,7 +180,7 @@ fun ContentView( } } - if (walletIsInitializing) { + if (walletIsInitializing) { //TODO ADAPT THIS LOGIC TO WORK WITH LightningNodeService if (nodeLifecycleState is NodeLifecycleState.ErrorStarting) { WalletInitResultView(result = WalletInitResult.Failed(nodeLifecycleState.cause)) { scope.launch { From 5866cd83557b9e5bc00c948eae781cedc6836530 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 5 May 2025 09:21:28 -0300 Subject: [PATCH 12/62] feat: start service --- app/src/main/java/to/bitkit/App.kt | 4 ++++ .../java/to/bitkit/androidServices/LightningNodeService.kt | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index d607e95c0..c30ea1a96 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -6,11 +6,13 @@ import android.app.Application import android.app.Application.ActivityLifecycleCallbacks import android.app.NotificationChannel import android.app.NotificationManager +import android.content.Intent import android.os.Build import android.os.Bundle import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp +import to.bitkit.androidServices.LightningNodeService import to.bitkit.androidServices.LightningNodeService.Companion.BITKIT_CHANNEL_ID import to.bitkit.env.Env import javax.inject.Inject @@ -30,6 +32,8 @@ internal open class App : Application(), Configuration.Provider { currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) } createNotificationChannel() + startService(Intent(this, LightningNodeService::class.java)) + Env.initAppStoragePath(filesDir.absolutePath) } diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 57fe444df..9f0b253e0 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -16,6 +16,7 @@ import to.bitkit.R import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.ui.MainActivity +import to.bitkit.utils.Logger import javax.inject.Inject @AndroidEntryPoint @@ -32,6 +33,7 @@ class LightningNodeService : Service() { override fun onCreate() { super.onCreate() setupService() + Logger.debug("onCreate", context = TAG) } private fun setupService() { @@ -71,10 +73,12 @@ class LightningNodeService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Logger.debug("onStartCommand", context = TAG) return START_STICKY } override fun onDestroy() { + Logger.debug("onDestroy", context = TAG) serviceScope.launch { lightningRepo.stop().onSuccess { serviceScope.cancel() @@ -88,5 +92,6 @@ class LightningNodeService : Service() { companion object { private const val NOTIFICATION_ID = 1 const val BITKIT_CHANNEL_ID = "bitkit_notification_channel" + const val TAG = "LightningNodeService" } } From 490b1924c1169f5bbe31955015266d2c49f07245 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 5 May 2025 10:47:41 -0300 Subject: [PATCH 13/62] feat: refresh bip 21 --- .../java/to/bitkit/androidServices/LightningNodeService.kt | 2 +- app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 1 - .../to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt | 2 +- app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt | 6 ++++++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 9f0b253e0..5e1b64e73 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -57,7 +57,7 @@ class LightningNodeService : Service() { } private fun createNotification( - contentText: String = "Bitkit is running in background so you can receive Lightning payments" //TODO GER FROM RESOURCES + contentText: String = "Bitkit is running in background so you can receive Lightning payments" //TODO GET FROM RESOURCES ): Notification { val notificationIntent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity( diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 05f2d8cf6..85a2c4e27 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -117,7 +117,6 @@ class WalletRepo @Inject constructor( } } - //TODO MAYBE CALL clearTagsAndBip21DescriptionState() updateBip21Invoice() return@withContext Result.success(Unit) 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 dcaf63365..7ccdc35d5 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 @@ -98,7 +98,7 @@ fun ReceiveQrSheet( LaunchedEffect(Unit) { try { coroutineScope { //TODO THIS WILL TRIGGER AN ERROR TOAST IF CLOSES THE SCREEN TOO EARLY -// launch { wallet.refreshBip21() } TODO CHECK IF IT IS NECESSARY + launch { wallet.refreshBip21() } launch { blocktank.refreshInfo() } } } catch (e: Exception) { diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index be53ac5cb..75902813d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -197,6 +197,12 @@ class WalletViewModel @Inject constructor( ) } + fun refreshBip21() { + viewModelScope.launch { + walletRepo.refreshBip21() + } + } + suspend fun createInvoice( amountSats: ULong? = null, description: String, From b62b4a7dd3d8efa6316fd386310f7bd2ef595a7f Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 5 May 2025 11:09:43 -0300 Subject: [PATCH 14/62] feat: add retry --- .../to/bitkit/repositories/LightningRepo.kt | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index a50d34c4b..1a960c6a3 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1,6 +1,7 @@ package to.bitkit.repositories import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -29,6 +30,7 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds @Singleton class LightningRepo @Inject constructor( @@ -114,6 +116,7 @@ class LightningRepo @Inject constructor( suspend fun start( walletIndex: Int = 0, timeout: Duration? = null, + shouldRetry: Boolean = true, eventHandler: NodeEventHandler? = null ): Result = withContext(bgDispatcher) { if (_lightningState.value.nodeLifecycleState.isRunningOrStarting()) { @@ -163,12 +166,23 @@ class LightningRepo @Inject constructor( sync() Result.success(Unit) - } catch (e: Throwable) { //TODO RETRY - Logger.error("Node start error", e, context = TAG) - _lightningState.update { - it.copy(nodeLifecycleState = NodeLifecycleState.ErrorStarting(e)) + } catch (e: Throwable) { + if (shouldRetry) { + Logger.warn("Start error, retrying after two seconds...", e = e, context = TAG) + delay(2.seconds) + return@withContext start( + walletIndex = walletIndex, + timeout = timeout, + shouldRetry = false, + eventHandler = eventHandler + ) + } else { + Logger.error("Node start error", e, context = TAG) + _lightningState.update { + it.copy(nodeLifecycleState = NodeLifecycleState.ErrorStarting(e)) + } + Result.failure(e) } - Result.failure(e) } } From 611b185f8114be700a7ba665ecd51c164df30357 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 5 May 2025 11:31:43 -0300 Subject: [PATCH 15/62] refactor: remove comment --- .../to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7ccdc35d5..7f35a0743 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 @@ -97,7 +97,7 @@ fun ReceiveQrSheet( LaunchedEffect(Unit) { try { - coroutineScope { //TODO THIS WILL TRIGGER AN ERROR TOAST IF CLOSES THE SCREEN TOO EARLY + coroutineScope { launch { wallet.refreshBip21() } launch { blocktank.refreshInfo() } } From 05534365d4f14330e7a7b956c1abb76db0977cea Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 5 May 2025 11:40:17 -0300 Subject: [PATCH 16/62] refactor: deprecate walletViewModel --- app/src/main/java/to/bitkit/ui/Locals.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/Locals.kt b/app/src/main/java/to/bitkit/ui/Locals.kt index ad2849e05..b8a4f4663 100644 --- a/app/src/main/java/to/bitkit/ui/Locals.kt +++ b/app/src/main/java/to/bitkit/ui/Locals.kt @@ -26,7 +26,7 @@ val LocalTransferViewModel = staticCompositionLocalOf { null val appViewModel: AppViewModel? @Composable get() = LocalAppViewModel.current - +@Deprecated("Prefer inject the repositories in a specific viewmodel so you don't need to handle a nullable viewmodel") val walletViewModel: WalletViewModel? @Composable get() = LocalWalletViewModel.current From f229fb444be128c6d8e8882b91b170cf9d9512c0 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 5 May 2025 14:10:39 -0300 Subject: [PATCH 17/62] test: WalletRepoTest.kt WIP --- .../java/to/bitkit/repositories/WalletRepo.kt | 2 +- .../to/bitkit/repositories/WalletRepoTest.kt | 318 ++++++++++++++++++ 2 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 85a2c4e27..daf6a13de 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -122,7 +122,7 @@ class WalletRepo @Inject constructor( return@withContext Result.success(Unit) } - private suspend fun syncBalances() { + suspend fun syncBalances() { lightningRepo.getBalances()?.let { balance -> val totalSats = balance.totalLightningBalanceSats + balance.totalOnchainBalanceSats diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt new file mode 100644 index 000000000..d401b1e23 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -0,0 +1,318 @@ +package to.bitkit.repositories + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.lightningdevkit.ldknode.BalanceDetails +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import to.bitkit.data.AppDb +import to.bitkit.data.AppStorage +import to.bitkit.data.SettingsStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.ext.toHex +import to.bitkit.services.BlocktankNotificationsService +import to.bitkit.services.CoreService +import to.bitkit.test.BaseUnitTest +import to.bitkit.test.TestApp +import to.bitkit.utils.AddressChecker +import uniffi.bitkitcore.Activity +import uniffi.bitkitcore.ActivityFilter +import uniffi.bitkitcore.PaymentType +import uniffi.bitkitcore.Scanner +import uniffi.bitkitcore.decode +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.days + +@RunWith(AndroidJUnit4::class) +@Config(application = TestApp::class) +class WalletRepoTest : BaseUnitTest() { + + private lateinit var sut: WalletRepo + + private val appStorage: AppStorage = mock() + private val db: AppDb = mock() + private val keychain: Keychain = mock() + private val coreService: CoreService = mock() + private val blocktankNotificationsService: BlocktankNotificationsService = mock() + private val firebaseMessaging: FirebaseMessaging = mock() + private val settingsStore: SettingsStore = mock() + private val addressChecker: AddressChecker = mock() + private val lightningRepo: LightningRepo = mock() + + @Before + fun setUp() { + whenever(appStorage.onchainAddress).thenReturn("") + whenever(appStorage.bolt11).thenReturn("") + whenever(appStorage.bip21).thenReturn("") + + whenever(lightningRepo.getSyncFlow()).thenReturn(flowOf(Unit)) + + sut = WalletRepo( + bgDispatcher = testDispatcher, + appStorage = appStorage, + db = db, + keychain = keychain, + coreService = coreService, + blocktankNotificationsService = blocktankNotificationsService, + firebaseMessaging = firebaseMessaging, + settingsStore = settingsStore, + addressChecker = addressChecker, + lightningRepo = lightningRepo + ) + } + + @Test + fun `init should collect from sync flow and sync balances`() = test { + val lightningRepo: LightningRepo = mock() + + val testFlow = MutableStateFlow(Unit) + whenever(lightningRepo.getSyncFlow()).thenReturn(testFlow) + whenever(lightningRepo.sync()).thenReturn(Result.success(Unit)) + + // Recreate sut with the new mock + val testSut = WalletRepo( + bgDispatcher = testDispatcher, + appStorage = appStorage, + db = db, + keychain = keychain, + coreService = coreService, + blocktankNotificationsService = blocktankNotificationsService, + firebaseMessaging = firebaseMessaging, + settingsStore = settingsStore, + addressChecker = addressChecker, + lightningRepo = lightningRepo + ) + + verify(lightningRepo).sync() + } + + @Test + fun `walletExists should return true when mnemonic exists in keychain`() = test { + whenever(keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(true) + + val result = sut.walletExists() + + assertTrue(result) + } + + @Test + fun `walletExists should return false when mnemonic does not exist in keychain`() = test { + whenever(keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(false) + + val result = sut.walletExists() + + assertFalse(result) + } + + @Test + fun `setWalletExistsState should update walletState with current existence status`() = test { + whenever(keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(true) + + sut.setWalletExistsState() + + sut.walletState.test { + val state = awaitItem() + assertTrue(state.walletExists) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `createWallet should save mnemonic and passphrase to keychain`() = test { + val mnemonic = "test mnemonic" + val passphrase = "test passphrase" + whenever(keychain.saveString(any(), any())).thenReturn(Unit) + + val result = sut.createWallet(passphrase) + + assertTrue(result.isSuccess) + verify(keychain).saveString(Keychain.Key.BIP39_MNEMONIC.name, any()) + verify(keychain).saveString(Keychain.Key.BIP39_PASSPHRASE.name, passphrase) + } + + @Test + fun `restoreWallet should save provided mnemonic and passphrase to keychain`() = test { + val mnemonic = "restore mnemonic" + val passphrase = "restore passphrase" + whenever(keychain.saveString(any(), any())).thenReturn(Unit) + + val result = sut.restoreWallet(mnemonic, passphrase) + + assertTrue(result.isSuccess) + verify(keychain).saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) + verify(keychain).saveString(Keychain.Key.BIP39_PASSPHRASE.name, passphrase) + } + + @Test + fun `wipeWallet should clear all data when on regtest`() = test { +// Env.network = Network.REGTEST + whenever(keychain.wipe()).thenReturn(Unit) + whenever(appStorage.clear()).thenReturn(Unit) + whenever(settingsStore.wipe()).thenReturn(Unit) + + val result = sut.wipeWallet() + + assertTrue(result.isSuccess) + verify(keychain).wipe() + verify(appStorage).clear() + verify(settingsStore).wipe() + verify(coreService.activity).removeAll() + } + + @Test + fun `wipeWallet should fail when not on regtest`() = test { +// Env.network = Network.TESTNET + + val result = sut.wipeWallet() + + assertTrue(result.isFailure) + } + + @Test + fun `refreshBip21 should generate new address when current is empty`() = test { + whenever(sut.getOnchainAddress()).thenReturn("") + whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) + + val result = sut.refreshBip21() + + assertTrue(result.isSuccess) + verify(lightningRepo).newAddress() + } + +// @Test +// fun `refreshBip21 should generate new address when current has transactions`() = test { +// whenever(sut.getOnchainAddress()).thenReturn("usedAddress") +// whenever(lightningRepo.checkAddressUsage("usedAddress")).thenReturn(Result.success(true)) +// whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) +// +// val result = sut.refreshBip21() +// +// assertTrue(result.isSuccess) +// verify(lightningRepo).newAddress() +// } + +// @Test +// fun `refreshBip21 should not generate new address when current has no transactions`() = test { +// whenever(sut.getOnchainAddress()).thenReturn("unusedAddress") +// whenever(lightningRepo.checkAddressUsage("unusedAddress")).thenReturn(Result.success(false)) +// +// val result = sut.refreshBip21() +// +// assertTrue(result.isSuccess) +// verify(lightningRepo, never()).newAddress() +// } + + @Test + fun `updateBip21Invoice should build correct BIP21 URL`() = test { + val address = "testAddress" + val amount = 1000uL + val description = "test description" + val bolt11 = "testBolt11" + + whenever(sut.getOnchainAddress()).thenReturn(address) + whenever(lightningRepo.hasChannels()).thenReturn(true) + whenever(lightningRepo.createInvoice(anyOrNull(), anyOrNull())).thenReturn(Result.success(bolt11)) + + sut.updateBip21Invoice(amount, description) + verify(appStorage).bip21 = any() // Verify BIP21 was saved + } + + @Test + fun `syncBalances should update balance state`() = test { + val balanceDetails = mock { + on { totalLightningBalanceSats } doReturn 500uL + on { totalOnchainBalanceSats } doReturn 1000uL + } + whenever(lightningRepo.getBalances()).thenReturn(balanceDetails) + + sut.syncBalances() + + sut.balanceState.test { + val state = awaitItem() + assertEquals(expected = 1500uL, state.totalSats) + cancelAndIgnoreRemainingEvents() + } + } + +// @Test +// fun `registerForNotifications should save token when different from cached`() = test { +// val token = "testToken" +// whenever(firebaseMessaging.token).thenReturn(mock { +// on { await() } doReturn token +// }) +// whenever(keychain.loadString(Keychain.Key.PUSH_NOTIFICATION_TOKEN.name)).thenReturn("oldToken") +// +// val result = sut.registerForNotifications() +// +// assertThat(result.isSuccess).isTrue() +// verify(blocktankNotificationsService).registerDevice(token) +// } + +// @Test +// fun `registerForNotifications should skip when token matches cached`() = test { +// val token = "testToken" +// whenever(firebaseMessaging.token).thenReturn(mock { +// on { await() } doReturn token +// }) +// whenever(keychain.loadString(Keychain.Key.PUSH_NOTIFICATION_TOKEN.name)).thenReturn(token) +// +// val result = sut.registerForNotifications() +// +// assertThat(result.isSuccess).isTrue() +// verify(blocktankNotificationsService, never()).registerDevice(any()) +// } + + @Test + fun `saveInvoiceWithTags should save invoice with tags`() = test { + val bip21 = "bitcoin:address?lightning=lnbc123" + val tags = listOf("tag1", "tag2") + val decoded = mock() + whenever(decode(bip21)).thenReturn(decoded) + whenever(decoded.invoice.paymentHash.toHex()).thenReturn("paymentHash") + + sut.saveInvoiceWithTags(bip21, tags) + + verify(db.invoiceTagDao()).saveInvoice(any()) + } + + @Test + fun `deleteExpiredInvoices should delete invoices older than 2 days`() = test { + val twoDaysAgo = System.currentTimeMillis() - 2.days.inWholeMilliseconds + sut.deleteExpiredInvoices() + + verify(db.invoiceTagDao()).deleteExpiredInvoices(twoDaysAgo) + } + + @Test + fun `attachTagsToActivity should attach tags to matching activity`() = test { + val paymentHash = "testHash" + val tags = listOf("tag1", "tag2") + val activity = mock() + whenever(activity.v1.id).thenReturn(paymentHash) + whenever(coreService.activity.get(any(), any(), any())).thenReturn(listOf(activity)) + whenever(coreService.activity.appendTags(any(), any())).thenReturn(Result.success(Unit)) + + val result = sut.attachTagsToActivity( + paymentHashOrTxId = paymentHash, + type = ActivityFilter.ALL, + txType = PaymentType.RECEIVED, + tags = tags + ) + + assertTrue(result.isSuccess) + verify(coreService.activity).appendTags(paymentHash, tags) + } +} From 8a01b5d1c2cdde17506ec9ef72eacfe495ad863d Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 5 May 2025 14:46:06 -0300 Subject: [PATCH 18/62] test: WalletRepoTest.kt --- app/src/main/java/to/bitkit/di/EnvModule.kt | 20 ++ .../java/to/bitkit/repositories/WalletRepo.kt | 5 +- .../to/bitkit/repositories/WalletRepoTest.kt | 190 +++++------------- .../java/to/bitkit/ui/WalletViewModelTest.kt | 2 - 4 files changed, 70 insertions(+), 147 deletions(-) create mode 100644 app/src/main/java/to/bitkit/di/EnvModule.kt diff --git a/app/src/main/java/to/bitkit/di/EnvModule.kt b/app/src/main/java/to/bitkit/di/EnvModule.kt new file mode 100644 index 000000000..eff7b6de4 --- /dev/null +++ b/app/src/main/java/to/bitkit/di/EnvModule.kt @@ -0,0 +1,20 @@ +@file:Suppress("unused") + +package to.bitkit.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.lightningdevkit.ldknode.Network +import to.bitkit.env.Env + +@Module +@InstallIn(SingletonComponent::class) +object EnvModule { + + @Provides + fun provideNetwork(): Network { + return Env.network + } +} diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index daf6a13de..5e2ec1ad1 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -54,7 +54,8 @@ class WalletRepo @Inject constructor( private val firebaseMessaging: FirebaseMessaging, private val settingsStore: SettingsStore, private val addressChecker: AddressChecker, - private val lightningRepo: LightningRepo + private val lightningRepo: LightningRepo, + private val network: Network ) { private val bgScope: CoroutineScope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -225,7 +226,7 @@ class WalletRepo @Inject constructor( } suspend fun wipeWallet(): Result = withContext(bgDispatcher) { - if (Env.network != Network.REGTEST) { + if (network != Network.REGTEST) { return@withContext Result.failure(Exception("Can only wipe on regtest.")) } diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index d401b1e23..1b80e78de 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -9,8 +9,8 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.lightningdevkit.ldknode.BalanceDetails +import org.lightningdevkit.ldknode.Network import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -20,21 +20,19 @@ import to.bitkit.data.AppDb import to.bitkit.data.AppStorage import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain -import to.bitkit.ext.toHex +import to.bitkit.env.Env import to.bitkit.services.BlocktankNotificationsService import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import to.bitkit.test.TestApp import to.bitkit.utils.AddressChecker -import uniffi.bitkitcore.Activity -import uniffi.bitkitcore.ActivityFilter +import uniffi.bitkitcore.OnchainActivity import uniffi.bitkitcore.PaymentType -import uniffi.bitkitcore.Scanner -import uniffi.bitkitcore.decode +import kotlin.random.Random +import kotlin.random.nextULong import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.days @RunWith(AndroidJUnit4::class) @Config(application = TestApp::class) @@ -70,7 +68,8 @@ class WalletRepoTest : BaseUnitTest() { firebaseMessaging = firebaseMessaging, settingsStore = settingsStore, addressChecker = addressChecker, - lightningRepo = lightningRepo + lightningRepo = lightningRepo, + network = Network.REGTEST ) } @@ -93,7 +92,8 @@ class WalletRepoTest : BaseUnitTest() { firebaseMessaging = firebaseMessaging, settingsStore = settingsStore, addressChecker = addressChecker, - lightningRepo = lightningRepo + lightningRepo = lightningRepo, + network = Env.network ) verify(lightningRepo).sync() @@ -130,19 +130,6 @@ class WalletRepoTest : BaseUnitTest() { } } - @Test - fun `createWallet should save mnemonic and passphrase to keychain`() = test { - val mnemonic = "test mnemonic" - val passphrase = "test passphrase" - whenever(keychain.saveString(any(), any())).thenReturn(Unit) - - val result = sut.createWallet(passphrase) - - assertTrue(result.isSuccess) - verify(keychain).saveString(Keychain.Key.BIP39_MNEMONIC.name, any()) - verify(keychain).saveString(Keychain.Key.BIP39_PASSPHRASE.name, passphrase) - } - @Test fun `restoreWallet should save provided mnemonic and passphrase to keychain`() = test { val mnemonic = "restore mnemonic" @@ -156,25 +143,8 @@ class WalletRepoTest : BaseUnitTest() { verify(keychain).saveString(Keychain.Key.BIP39_PASSPHRASE.name, passphrase) } - @Test - fun `wipeWallet should clear all data when on regtest`() = test { -// Env.network = Network.REGTEST - whenever(keychain.wipe()).thenReturn(Unit) - whenever(appStorage.clear()).thenReturn(Unit) - whenever(settingsStore.wipe()).thenReturn(Unit) - - val result = sut.wipeWallet() - - assertTrue(result.isSuccess) - verify(keychain).wipe() - verify(appStorage).clear() - verify(settingsStore).wipe() - verify(coreService.activity).removeAll() - } - @Test fun `wipeWallet should fail when not on regtest`() = test { -// Env.network = Network.TESTNET val result = sut.wipeWallet() @@ -192,44 +162,6 @@ class WalletRepoTest : BaseUnitTest() { verify(lightningRepo).newAddress() } -// @Test -// fun `refreshBip21 should generate new address when current has transactions`() = test { -// whenever(sut.getOnchainAddress()).thenReturn("usedAddress") -// whenever(lightningRepo.checkAddressUsage("usedAddress")).thenReturn(Result.success(true)) -// whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) -// -// val result = sut.refreshBip21() -// -// assertTrue(result.isSuccess) -// verify(lightningRepo).newAddress() -// } - -// @Test -// fun `refreshBip21 should not generate new address when current has no transactions`() = test { -// whenever(sut.getOnchainAddress()).thenReturn("unusedAddress") -// whenever(lightningRepo.checkAddressUsage("unusedAddress")).thenReturn(Result.success(false)) -// -// val result = sut.refreshBip21() -// -// assertTrue(result.isSuccess) -// verify(lightningRepo, never()).newAddress() -// } - - @Test - fun `updateBip21Invoice should build correct BIP21 URL`() = test { - val address = "testAddress" - val amount = 1000uL - val description = "test description" - val bolt11 = "testBolt11" - - whenever(sut.getOnchainAddress()).thenReturn(address) - whenever(lightningRepo.hasChannels()).thenReturn(true) - whenever(lightningRepo.createInvoice(anyOrNull(), anyOrNull())).thenReturn(Result.success(bolt11)) - - sut.updateBip21Invoice(amount, description) - verify(appStorage).bip21 = any() // Verify BIP21 was saved - } - @Test fun `syncBalances should update balance state`() = test { val balanceDetails = mock { @@ -247,72 +179,44 @@ class WalletRepoTest : BaseUnitTest() { } } -// @Test -// fun `registerForNotifications should save token when different from cached`() = test { -// val token = "testToken" -// whenever(firebaseMessaging.token).thenReturn(mock { -// on { await() } doReturn token -// }) -// whenever(keychain.loadString(Keychain.Key.PUSH_NOTIFICATION_TOKEN.name)).thenReturn("oldToken") -// -// val result = sut.registerForNotifications() -// -// assertThat(result.isSuccess).isTrue() -// verify(blocktankNotificationsService).registerDevice(token) -// } - -// @Test -// fun `registerForNotifications should skip when token matches cached`() = test { -// val token = "testToken" -// whenever(firebaseMessaging.token).thenReturn(mock { -// on { await() } doReturn token -// }) -// whenever(keychain.loadString(Keychain.Key.PUSH_NOTIFICATION_TOKEN.name)).thenReturn(token) -// -// val result = sut.registerForNotifications() -// -// assertThat(result.isSuccess).isTrue() -// verify(blocktankNotificationsService, never()).registerDevice(any()) -// } - - @Test - fun `saveInvoiceWithTags should save invoice with tags`() = test { - val bip21 = "bitcoin:address?lightning=lnbc123" - val tags = listOf("tag1", "tag2") - val decoded = mock() - whenever(decode(bip21)).thenReturn(decoded) - whenever(decoded.invoice.paymentHash.toHex()).thenReturn("paymentHash") - - sut.saveInvoiceWithTags(bip21, tags) - - verify(db.invoiceTagDao()).saveInvoice(any()) - } - - @Test - fun `deleteExpiredInvoices should delete invoices older than 2 days`() = test { - val twoDaysAgo = System.currentTimeMillis() - 2.days.inWholeMilliseconds - sut.deleteExpiredInvoices() - - verify(db.invoiceTagDao()).deleteExpiredInvoices(twoDaysAgo) - } - - @Test - fun `attachTagsToActivity should attach tags to matching activity`() = test { - val paymentHash = "testHash" - val tags = listOf("tag1", "tag2") - val activity = mock() - whenever(activity.v1.id).thenReturn(paymentHash) - whenever(coreService.activity.get(any(), any(), any())).thenReturn(listOf(activity)) - whenever(coreService.activity.appendTags(any(), any())).thenReturn(Result.success(Unit)) - - val result = sut.attachTagsToActivity( - paymentHashOrTxId = paymentHash, - type = ActivityFilter.ALL, - txType = PaymentType.RECEIVED, - tags = tags + fun mockOnchainActivity( + id: String = java.util.UUID.randomUUID().toString(), + txType: PaymentType = PaymentType.values().random(), + txId: String = java.util.UUID.randomUUID().toString(), + value: ULong = Random.nextULong(), + fee: ULong = Random.nextULong(), + feeRate: ULong = Random.nextULong(), + address: String = "mockAddress_${Random.nextInt(100)}", + confirmed: Boolean = Random.nextBoolean(), + timestamp: ULong = System.currentTimeMillis().toULong(), + isBoosted: Boolean = Random.nextBoolean(), + isTransfer: Boolean = Random.nextBoolean(), + doesExist: Boolean = true, + confirmTimestamp: ULong? = if (confirmed) System.currentTimeMillis().toULong() else null, + channelId: String? = "mockChannel_${Random.nextInt(10)}", + transferTxId: String? = if (isTransfer) java.util.UUID.randomUUID().toString() else null, + createdAt: ULong? = System.currentTimeMillis().toULong(), + updatedAt: ULong? = System.currentTimeMillis().toULong() + ): OnchainActivity { + return OnchainActivity( + id, + txType, + txId, + value, + fee, + feeRate, + address, + confirmed, + timestamp, + isBoosted, + isTransfer, + doesExist, + confirmTimestamp, + channelId, + transferTxId, + createdAt, + updatedAt ) - - assertTrue(result.isSuccess) - verify(coreService.activity).appendTags(paymentHash, tags) } + } diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index a68245e24..fd8299b47 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -8,7 +8,6 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.lightningdevkit.ldknode.BalanceDetails -import org.mockito.ArgumentMatchers.anyString import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -106,7 +105,6 @@ class WalletViewModelTest : BaseUnitTest() { whenever(walletRepo.getBip21()).thenReturn("bitcoin:onchainAddress") whenever(walletRepo.getMnemonic()).thenReturn(Result.success("mnemonic")) whenever(walletRepo.getBolt11()).thenReturn("bolt11") - whenever(lightningRepo.checkAddressUsage(anyString())).thenReturn(Result.success(true)) whenever(lightningRepo.start(walletIndex = 0)).thenReturn(Result.success(Unit)) whenever(lightningRepo.getPeers()).thenReturn(emptyList()) whenever(lightningRepo.getChannels()).thenReturn(emptyList()) From ae511c422c0ab29245947188088d51bdf6f4a970 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 5 May 2025 14:47:27 -0300 Subject: [PATCH 19/62] test: update tests --- .../java/to/bitkit/ui/WalletViewModelTest.kt | 39 +------------------ 1 file changed, 1 insertion(+), 38 deletions(-) diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index fd8299b47..af64002b4 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -1,7 +1,6 @@ package to.bitkit.ui import androidx.test.ext.junit.runners.AndroidJUnit4 -import app.cash.turbine.test import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import org.junit.Before @@ -18,9 +17,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.test.BaseUnitTest import to.bitkit.test.TestApp -import to.bitkit.viewmodels.MainUiState import to.bitkit.viewmodels.WalletViewModel -import kotlin.test.assertEquals @RunWith(AndroidJUnit4::class) @Config(application = TestApp::class) @@ -46,7 +43,7 @@ class WalletViewModelTest : BaseUnitTest() { whenever(lightningRepo.nodeLifecycleState).thenReturn(nodeLifecycleStateFlow) // Database config flow - wheneverBlocking{walletRepo.getDbConfig()}.thenReturn(flowOf(emptyList())) + wheneverBlocking { walletRepo.getDbConfig() }.thenReturn(flowOf(emptyList())) sut = WalletViewModel( bgDispatcher = testDispatcher, @@ -56,40 +53,6 @@ class WalletViewModelTest : BaseUnitTest() { ) } - @Test - fun `start should emit Content uiState`() = test { - setupExistingWalletMocks() - val expectedUiState = MainUiState( - nodeId = "nodeId", - onchainAddress = "onchainAddress", - peers = emptyList(), - channels = emptyList(), - balanceDetails = balanceDetails, - bolt11 = "bolt11", - bip21 = "bitcoin:onchainAddress", - nodeLifecycleState = NodeLifecycleState.Starting, - nodeStatus = null, - ) - - sut.start() - - sut.uiState.test { - val content = awaitItem() - assertEquals(expectedUiState, content) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `start should register for notifications if token is not cached`() = test { - setupExistingWalletMocks() - whenever(lightningRepo.start(walletIndex = 0)).thenReturn(Result.success(Unit)) - - sut.start() - - verify(walletRepo).registerForNotifications() - } - @Test fun `manualRegisterForNotifications should register device with FCM token`() = test { sut.manualRegisterForNotifications() From 6a45eb115b696a33ecde80888a269d1b54ccdae2 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Tue, 6 May 2025 07:49:12 -0300 Subject: [PATCH 20/62] test: LightningRepoTest.kt --- .../to/bitkit/repositories/LightningRepo.kt | 2 - .../bitkit/repositories/LightningRepoTest.kt | 234 ++++++++++++++++++ 2 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 1a960c6a3..93c9ac79e 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -41,8 +41,6 @@ class LightningRepo @Inject constructor( private val _lightningState = MutableStateFlow(LightningState()) val lightningState = _lightningState.asStateFlow() - val nodeLifecycleState = _lightningState.asStateFlow().map { it.nodeLifecycleState } - /** * Executes the provided operation only if the node is running. * If the node is not running, waits for it to be running for a specified timeout. diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt new file mode 100644 index 000000000..fc2eab4d3 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -0,0 +1,234 @@ +package to.bitkit.repositories + +import app.cash.turbine.test +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.lightningdevkit.ldknode.BalanceDetails +import org.lightningdevkit.ldknode.Bolt11Invoice +import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.NodeStatus +import org.lightningdevkit.ldknode.PaymentDetails +import org.lightningdevkit.ldknode.PaymentId +import org.lightningdevkit.ldknode.Txid +import org.lightningdevkit.ldknode.UserChannelId +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.models.LnPeer +import to.bitkit.models.NodeLifecycleState +import to.bitkit.services.LdkNodeEventBus +import to.bitkit.services.LightningService +import to.bitkit.services.NodeEventHandler +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LightningRepoTest : BaseUnitTest() { + + private lateinit var sut: LightningRepo + + private val lightningService: LightningService = mock() + private val ldkNodeEventBus: LdkNodeEventBus = mock() + + @Before + fun setUp() { + sut = LightningRepo( + bgDispatcher = testDispatcher, + lightningService = lightningService, + ldkNodeEventBus = ldkNodeEventBus + ) + } + + private suspend fun startNodeForTesting() { + sut.setInitNodeLifecycleState() + whenever(lightningService.node).thenReturn(mock()) + whenever(lightningService.setup(any())).thenReturn(Unit) + whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) + sut.start().let { assertTrue(it.isSuccess) } + } + + @Test + fun `setup should call service setup and return success`() = test { + whenever(lightningService.setup(any())).thenReturn(Unit) + + val result = sut.setup(0) + + assertTrue(result.isSuccess) + verify(lightningService).setup(0) + } + + @Test + fun `start should transition through correct states`() = test { + sut.setInitNodeLifecycleState() + whenever(lightningService.node).thenReturn(mock()) + whenever(lightningService.setup(any())).thenReturn(Unit) + whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) + + sut.lightningState.test { + assertEquals(NodeLifecycleState.Initializing, awaitItem().nodeLifecycleState) + + sut.start() + + assertEquals(NodeLifecycleState.Starting, awaitItem().nodeLifecycleState) + assertEquals(NodeLifecycleState.Running, awaitItem().nodeLifecycleState) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `stop should transition to stopped state`() = test { + startNodeForTesting() + + sut.lightningState.test { + // Verify initial state is Running (from startNodeForTesting) + assertEquals(NodeLifecycleState.Running, awaitItem().nodeLifecycleState) + + sut.stop() + + assertEquals(NodeLifecycleState.Stopping, awaitItem().nodeLifecycleState) + assertEquals(NodeLifecycleState.Stopped, awaitItem().nodeLifecycleState) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `newAddress should fail when node is not running`() = test { + val result = sut.newAddress() + assertTrue(result.isFailure) + } + + @Test + fun `newAddress should succeed when node is running`() = test { + startNodeForTesting() + val testAddress = "test_address" + whenever(lightningService.newAddress()).thenReturn(testAddress) + + val result = sut.newAddress() + assertTrue(result.isSuccess) + assertEquals(testAddress, result.getOrNull()) + } + + @Test + fun `createInvoice should fail when node is not running`() = test { + val result = sut.createInvoice(description = "test") + assertTrue(result.isFailure) + } + + @Test + fun `payInvoice should fail when node is not running`() = test { + val result = sut.payInvoice("bolt11", 1000uL) + assertTrue(result.isFailure) + } + + @Test + fun `getPayments should fail when node is not running`() = test { + val result = sut.getPayments() + assertTrue(result.isFailure) + } + + @Test + fun `openChannel should fail when node is not running`() = test { + val testPeer = LnPeer("nodeId", "host", "9735") + val result = sut.openChannel(testPeer, 100000uL) + assertTrue(result.isFailure) + } + + @Test + fun `getNodeId should return null when node is not running`() = test { + assertNull(sut.getNodeId()) + } + + @Test + fun `getNodeId should return value when node is running`() = test { + startNodeForTesting() + val testNodeId = "test_node_id" + whenever(lightningService.nodeId).thenReturn(testNodeId) + + assertEquals(testNodeId, sut.getNodeId()) + } + + @Test + fun `getBalances should return null when node is not running`() = test { + assertNull(sut.getBalances()) + } + + @Test + fun `hasChannels should return false when node is not running`() = test { + assertFalse(sut.hasChannels()) + } + + @Test + fun `getSyncFlow should return flow from service`() = test { + val testFlow = flowOf(Unit) + whenever(lightningService.syncFlow()).thenReturn(testFlow) + + assertEquals(testFlow, sut.getSyncFlow()) + } + + @Test + fun `syncState should update state with current values`() = test { + startNodeForTesting() + val testNodeId = "test_node_id" + val testStatus = mock() + val testPeers = listOf(mock()) + val testChannels = listOf(mock()) + + whenever(lightningService.nodeId).thenReturn(testNodeId) + whenever(lightningService.status).thenReturn(testStatus) + whenever(lightningService.peers).thenReturn(testPeers) + whenever(lightningService.channels).thenReturn(testChannels) + + sut.syncState() + + assertEquals(testNodeId, sut.lightningState.value.nodeId) + assertEquals(testStatus, sut.lightningState.value.nodeStatus) + assertEquals(testPeers, sut.lightningState.value.peers) + assertEquals(testChannels, sut.lightningState.value.channels) + } + + @Test + fun `canSend should return false when node is not running`() = test { + assertFalse(sut.canSend(1000uL)) + } + + @Test + fun `wipeStorage should stop node and call service wipe`() = test { + startNodeForTesting() + whenever(lightningService.stop()).thenReturn(Unit) + + val result = sut.wipeStorage(0) + + assertTrue(result.isSuccess) + verify(lightningService).stop() + verify(lightningService).wipeStorage(0) + } + + @Test + fun `connectToTrustedPeers should fail when node is not running`() = test { + val result = sut.connectToTrustedPeers() + assertTrue(result.isFailure) + } + + @Test + fun `disconnectPeer should fail when node is not running`() = test { + val testPeer = LnPeer("nodeId", "host", "9735") + val result = sut.disconnectPeer(testPeer) + assertTrue(result.isFailure) + } + + @Test + fun `sendOnChain should fail when node is not running`() = test { + val result = sut.sendOnChain("address", 1000uL) + assertTrue(result.isFailure) + } +} From 108130746109e69a34c7a4abf8a865583c70701e Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Tue, 6 May 2025 08:09:11 -0300 Subject: [PATCH 21/62] test: update WalletViewModelTest.kt --- .../java/to/bitkit/ui/WalletViewModelTest.kt | 292 +++++++++++++++--- 1 file changed, 256 insertions(+), 36 deletions(-) diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index af64002b4..a287f0691 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -1,76 +1,296 @@ package to.bitkit.ui +import android.content.Context import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.lightningdevkit.ldknode.BalanceDetails +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.mockito.kotlin.wheneverBlocking import org.robolectric.annotation.Config -import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.LnPeer import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.LightningState import to.bitkit.repositories.WalletRepo +import to.bitkit.repositories.WalletState import to.bitkit.test.BaseUnitTest import to.bitkit.test.TestApp import to.bitkit.viewmodels.WalletViewModel +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @Config(application = TestApp::class) class WalletViewModelTest : BaseUnitTest() { - private var lightningRepo: LightningRepo = mock() - private var walletRepo: WalletRepo = mock() private lateinit var sut: WalletViewModel - private val balanceDetails = mock() - private val nodeLifecycleStateFlow = MutableStateFlow(NodeLifecycleState.Stopped) + private val walletRepo: WalletRepo = mock() + private val lightningRepo: LightningRepo = mock() + private val context: Context = mock() + private val mockLightningState = MutableStateFlow(LightningState()) + private val mockWalletState = MutableStateFlow(WalletState()) + @Before fun setUp() { - whenever(lightningRepo.getNodeId()).thenReturn("nodeId") - whenever(lightningRepo.getBalances()).thenReturn(balanceDetails) - whenever(lightningRepo.getBalances()?.totalLightningBalanceSats).thenReturn(1000u) - whenever(lightningRepo.getBalances()?.totalOnchainBalanceSats).thenReturn(10_000u) - wheneverBlocking { lightningRepo.newAddress() }.thenReturn(Result.success("onchainAddress")) - - - // Node lifecycle state flow - whenever(lightningRepo.nodeLifecycleState).thenReturn(nodeLifecycleStateFlow) - - // Database config flow - wheneverBlocking { walletRepo.getDbConfig() }.thenReturn(flowOf(emptyList())) + whenever(walletRepo.walletState).thenReturn(mockWalletState) + whenever(lightningRepo.lightningState).thenReturn(mockLightningState) sut = WalletViewModel( - bgDispatcher = testDispatcher, - appContext = mock(), + bgDispatcher = Dispatchers.Unconfined, + appContext = context, walletRepo = walletRepo, lightningRepo = lightningRepo ) } @Test - fun `manualRegisterForNotifications should register device with FCM token`() = test { - sut.manualRegisterForNotifications() + fun `setWalletExistsState should update walletExists`() = test { + whenever(walletRepo.walletExists()).thenReturn(true) + sut.setWalletExistsState() + assertTrue(sut.walletExists) - verify(walletRepo).registerForNotifications() + whenever(walletRepo.walletExists()).thenReturn(false) + sut.setWalletExistsState() + assertFalse(sut.walletExists) } - private fun setupExistingWalletMocks() = test { - whenever(walletRepo.walletExists()).thenReturn(true) - sut.setWalletExistsState() - whenever(walletRepo.walletExists()).thenReturn(true) - whenever(walletRepo.getOnchainAddress()).thenReturn("onchainAddress") - whenever(walletRepo.getBip21()).thenReturn("bitcoin:onchainAddress") - whenever(walletRepo.getMnemonic()).thenReturn(Result.success("mnemonic")) - whenever(walletRepo.getBolt11()).thenReturn("bolt11") - whenever(lightningRepo.start(walletIndex = 0)).thenReturn(Result.success(Unit)) - whenever(lightningRepo.getPeers()).thenReturn(emptyList()) - whenever(lightningRepo.getChannels()).thenReturn(emptyList()) - whenever(lightningRepo.getStatus()).thenReturn(null) + @Test + fun `setInitNodeLifecycleState should call lightningRepo`() = test { + sut.setInitNodeLifecycleState() + verify(lightningRepo).setInitNodeLifecycleState() + } + + + @Test + fun `refreshState should call lightningRepo sync`() = test { + whenever(lightningRepo.sync()).thenReturn(Result.success(Unit)) + + sut.refreshState() + + verify(lightningRepo).sync() + } + + @Test + fun `onPullToRefresh should call lightningRepo sync`() = test { + whenever(lightningRepo.sync()).thenReturn(Result.success(Unit)) + + sut.onPullToRefresh() + + verify(lightningRepo).sync() + } + + @Test + fun `disconnectPeer should call lightningRepo disconnectPeer and send success toast`() = test { + val testPeer = LnPeer("nodeId", "host", "9735") + whenever(lightningRepo.disconnectPeer(testPeer)).thenReturn(Result.success(Unit)) + + sut.disconnectPeer(testPeer) + + verify(lightningRepo).disconnectPeer(testPeer) + // Add verification for ToastEventBus.send if you have a way to capture those events + } + + @Test + fun `disconnectPeer should call lightningRepo disconnectPeer and send failure toast`() = test { + val testPeer = LnPeer("nodeId", "host", "9735") + val testError = Exception("Test error") + whenever(lightningRepo.disconnectPeer(testPeer)).thenReturn(Result.failure(testError)) + + sut.disconnectPeer(testPeer) + + verify(lightningRepo).disconnectPeer(testPeer) + // Add verification for ToastEventBus.send if you have a way to capture those events + } + + @Test + fun `send should call lightningRepo payInvoice and send failure toast`() = test { + val testBolt11 = "test_bolt11" + val testError = Exception("Test error") + whenever(lightningRepo.payInvoice(testBolt11)).thenReturn(Result.failure(testError)) + + sut.send(testBolt11) + + verify(lightningRepo).payInvoice(testBolt11) + // Add verification for ToastEventBus.send + } + + @Test + fun `updateBip21Invoice should call walletRepo updateBip21Invoice and send failure toast`() = test { + val testError = Exception("Test error") + whenever(walletRepo.updateBip21Invoice(anyOrNull(), any(), any(), any())).thenReturn(Result.failure(testError)) + + sut.updateBip21Invoice() + + verify(walletRepo).updateBip21Invoice(anyOrNull(), any(), any(), any()) + // Add verification for ToastEventBus.send + } + + @Test + fun `refreshBip21 should call walletRepo refreshBip21`() = test { + sut.refreshBip21() + verify(walletRepo).refreshBip21() + } + + @Test + fun `createInvoice should call lightningRepo createInvoice`() = test { + val testInvoice = "test_invoice" + whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success(testInvoice)) + + val result = sut.createInvoice(description = "test") + + verify(lightningRepo).createInvoice(anyOrNull(), any(), any()) + assertEquals("test_invoice", result) + } + + @Test + fun `openChannel should send a toast if there are no peers`() = test { + val peersFlow = MutableStateFlow(emptyList()) + whenever(lightningRepo.getPeers()).thenReturn(peersFlow.value) + + sut.openChannel() + + verify(lightningRepo, never()).openChannel(any(), any(), any()) + // Add verification for ToastEventBus.send + } + + @Test + fun `wipeStorage should call walletRepo wipeWallet and lightningRepo wipeStorage, and set walletExists`() = + test { + whenever(walletRepo.wipeWallet()).thenReturn(Result.success(Unit)) + whenever(lightningRepo.wipeStorage(any())).thenReturn(Result.success(Unit)) + whenever(walletRepo.walletExists()).thenReturn(false) + + sut.wipeStorage() + + verify(walletRepo).wipeWallet() + verify(lightningRepo).wipeStorage(any()) + assertFalse(sut.walletExists) + } + + @Test + fun `createWallet should call walletRepo createWallet and send failure toast`() = test { + val testError = Exception("Test error") + whenever(walletRepo.createWallet(anyOrNull())).thenReturn(Result.failure(testError)) + + sut.createWallet(null) + + verify(walletRepo).createWallet(anyOrNull()) + // Add verification for ToastEventBus.send + } + + @Test + fun `restoreWallet should call walletRepo restoreWallet and send failure toast`() = test { + val testError = Exception("Test error") + whenever(walletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.failure(testError)) + + sut.restoreWallet("test_mnemonic", null) + + verify(walletRepo).restoreWallet(any(), anyOrNull()) + // Add verification for ToastEventBus.send + } + + @Test + fun `manualRegisterForNotifications should call walletRepo registerForNotifications and send appropriate toasts`() = + test { + whenever(walletRepo.registerForNotifications()).thenReturn(Result.success(Unit)) + + sut.manualRegisterForNotifications() + + verify(walletRepo).registerForNotifications() + // Add verification for ToastEventBus.send + } + + @Test + fun `manualNewAddress should call lightningRepo newAddress and walletRepo setOnchainAddress, and send failure toast`() = + test { + whenever(lightningRepo.newAddress()).thenReturn(Result.success("test_address")) + + sut.manualNewAddress() + + verify(lightningRepo).newAddress() + verify(walletRepo).setOnchainAddress("test_address") + } + + @Test + fun `debugDb should call walletRepo getDbConfig`() = test { + whenever(walletRepo.getDbConfig()).thenReturn(flowOf(emptyList())) + + sut.debugDb() + + verify(walletRepo).getDbConfig() + } + + @Test + fun `debugFcmToken should call walletRepo getFcmToken`() = test { + whenever(walletRepo.getFcmToken()).thenReturn(Result.success("test_token")) + + sut.debugFcmToken() + + verify(walletRepo).getFcmToken() + } + + @Test + fun `debugKeychain should call walletRepo debugKeychain`() = test { + whenever(walletRepo.debugKeychain(any(), any())).thenReturn(Result.success(null)) + + sut.debugKeychain() + + verify(walletRepo).debugKeychain(any(), any()) + } + + @Test + fun `debugMnemonic should call walletRepo getMnemonic`() = test { + whenever(walletRepo.getMnemonic()).thenReturn(Result.success("test_mnemonic")) + + sut.debugMnemonic() + + verify(walletRepo).getMnemonic() + } + + @Test + fun `debugLspNotifications should call walletRepo testNotification`() = test { + whenever(walletRepo.testNotification()).thenReturn(Result.success(Unit)) + + sut.debugLspNotifications() + + verify(walletRepo).testNotification() + } + + @Test + fun `stopIfNeeded should call lightningRepo stop`() = test { + sut.stopIfNeeded() + + verify(lightningRepo).stop() + } + + @Test + fun `addTagToSelected should call walletRepo addTagToSelected`() = test { + sut.addTagToSelected("test_tag") + + verify(walletRepo).addTagToSelected("test_tag") + } + + @Test + fun `removeTag should call walletRepo removeTag`() = test { + sut.removeTag("test_tag") + + verify(walletRepo).removeTag("test_tag") + } + + @Test + fun `updateBip21Description should call walletRepo updateBip21Description`() = test { + sut.updateBip21Description("test_description") + + verify(walletRepo).updateBip21Description("test_description") } } From 0e63f351e9b1476de3be3dd12c86710fa0625636 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Tue, 6 May 2025 08:16:19 -0300 Subject: [PATCH 22/62] refactor: restoring wallet state access --- app/src/main/java/to/bitkit/ui/ContentView.kt | 2 +- app/src/main/java/to/bitkit/ui/MainActivity.kt | 2 +- app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 320ee55d9..31f10745d 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -207,7 +207,7 @@ fun ContentView( } } else if (walletViewModel.isRestoringWallet) { WalletInitResultView(result = WalletInitResult.Restored) { - walletViewModel.isRestoringWallet = false + walletViewModel.setRestoringWalletState(false) } } else { val balance by walletViewModel.balanceState.collectAsStateWithLifecycle() diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 1d0e0fe3e..ce1cd8a45 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -145,7 +145,7 @@ class MainActivity : FragmentActivity() { try { appViewModel.resetIsAuthenticatedState() walletViewModel.setInitNodeLifecycleState() - walletViewModel.isRestoringWallet = true + walletViewModel.setRestoringWalletState(isRestoringWallet = true) walletViewModel.restoreWallet(mnemonic, passphrase) walletViewModel.setWalletExistsState() appViewModel.setShowEmptyState(false) diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 75902813d..092620c43 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -47,6 +47,7 @@ class WalletViewModel @Inject constructor( private set var isRestoringWallet by mutableStateOf(false) + private set private val _uiState = MutableStateFlow(MainUiState()) val uiState = _uiState.asStateFlow() @@ -91,6 +92,10 @@ class WalletViewModel @Inject constructor( } } + fun setRestoringWalletState(isRestoringWallet: Boolean) { + walletRepo.setRestoringWalletState(isRestoring = isRestoringWallet) + } + fun setWalletExistsState() { walletExists = walletRepo.walletExists() } From 15b1c1f3f25d0123e2f85a1362856136e7039d56 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Tue, 6 May 2025 08:17:16 -0300 Subject: [PATCH 23/62] refactor: remove imports --- app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 93c9ac79e..2c812fcb6 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull From 273ef07aa93a8f67994ae64d34fc173f43c0e8b8 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Tue, 6 May 2025 08:37:55 -0300 Subject: [PATCH 24/62] fix: clear states when wipe wallet --- app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 1 + app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 2 ++ 2 files changed, 3 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 2c812fcb6..35fc1f551 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -223,6 +223,7 @@ class LightningRepo @Inject constructor( stop().onSuccess { return@withContext try { lightningService.wipeStorage(walletIndex) + _lightningState.update { LightningState(nodeStatus = it.nodeStatus, nodeLifecycleState = it.nodeLifecycleState) } Result.success(Unit) } catch (e: Throwable) { Logger.error("Wipe storage error", e, context = TAG) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 5e2ec1ad1..536bf74d2 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -236,6 +236,8 @@ class WalletRepo @Inject constructor( settingsStore.wipe() coreService.activity.removeAll() deleteAllInvoices() + _walletState.update { WalletState() } + _balanceState.update { BalanceState() } setWalletExistsState() Result.success(Unit) } catch (e: Throwable) { From 4f5932dba61791ea972603bb6d60d9d7e8443134 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Tue, 6 May 2025 10:07:02 -0300 Subject: [PATCH 25/62] fix: only trigger onInputChanged if the primary display has changed --- .../main/java/to/bitkit/ui/components/NumberPadTextField.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt index ca40d75be..f5c49cc08 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt @@ -10,6 +10,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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -145,7 +146,10 @@ fun AmountInputHandler( onAmountCalculated: (String) -> Unit, currencyVM: CurrencyViewModel ) { + var lastDisplay by rememberSaveable { mutableStateOf(primaryDisplay) } LaunchedEffect(primaryDisplay) { + if (primaryDisplay == lastDisplay) return@LaunchedEffect + lastDisplay = primaryDisplay val newInput = when (primaryDisplay) { PrimaryDisplay.BITCOIN -> { //Convert fiat to sats val amountLong = currencyVM.convertFiatToSats(input.replace(",", "").toDoubleOrNull() ?: 0.0) ?: 0 From 931c68fa6026719f2967a2d9f371ffca34154df2 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Tue, 6 May 2025 10:07:26 -0300 Subject: [PATCH 26/62] fix: keep states --- .../bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 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 708d6b62d..1ecb2beb3 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 @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.wallets.receive +import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -25,6 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -67,8 +69,8 @@ fun EditInvoiceScreen( onBack: () -> Unit, ) { val currencyVM = currencyViewModel ?: return - var input: String by remember { mutableStateOf("") } - var satsString by remember { mutableStateOf("") } + var input: String by rememberSaveable { mutableStateOf("") } + var satsString by rememberSaveable { mutableStateOf("") } var keyboardVisible by remember { mutableStateOf(false) } AmountInputHandler( From c129e4cbd4687a97a186b43723b28f6911a492ed Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Tue, 6 May 2025 10:34:39 -0300 Subject: [PATCH 27/62] fix: continue button label --- .../bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 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 1ecb2beb3..d3dbb10de 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 @@ -1,6 +1,5 @@ package to.bitkit.ui.screens.wallets.receive -import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -189,7 +188,7 @@ fun EditInvoiceContent( Spacer(modifier = Modifier.height(41.dp)) PrimaryButton( - text = stringResource(R.string.continue_button), + text = stringResource(R.string.common__continue), onClick = onContinueKeyboard, modifier = Modifier.testTag("keyboard_continue_button") ) @@ -268,7 +267,7 @@ fun EditInvoiceContent( Spacer(modifier = Modifier.weight(1f)) PrimaryButton( - text = stringResource(R.string.continue_button), + text = stringResource(R.string.wallet__receive_show_qr), onClick = onContinueGeneral, modifier = Modifier.testTag("general_continue_button") ) From ac631f6dfbb9a144c3d09ffa01dcbe36decc0813 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Tue, 6 May 2025 11:08:59 -0300 Subject: [PATCH 28/62] fix: keep the balance input state while the qr code is displayed and reset then the sheet is dismissed --- app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 8 ++++++++ .../ui/screens/wallets/receive/EditInvoiceScreen.kt | 10 +++++----- .../ui/screens/wallets/receive/ReceiveQrScreen.kt | 3 +++ .../main/java/to/bitkit/viewmodels/WalletViewModel.kt | 6 ++++++ 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 536bf74d2..922056461 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -118,6 +118,9 @@ class WalletRepo @Inject constructor( } } + //Reset invoice state + _walletState.update { it.copy(balanceInput = "") } + updateBip21Invoice() return@withContext Result.success(Unit) @@ -311,6 +314,10 @@ class WalletRepo @Inject constructor( _walletState.update { it.copy(bip21Description = description) } } + fun updateBalanceInput(newText: String) { + _walletState.update { it.copy(balanceInput = newText) } + } + fun toggleReceiveOnSpendingBalance() { _walletState.update { it.copy(receiveOnSpendingBalance = !it.receiveOnSpendingBalance) } } @@ -619,6 +626,7 @@ class WalletRepo @Inject constructor( data class WalletState( val onchainAddress: String = "", + val balanceInput: String = "", val bolt11: String = "", val bip21: String = "", val bip21AmountSats: ULong? = null, 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 d3dbb10de..b586fb5a2 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 @@ -64,25 +64,25 @@ fun EditInvoiceScreen( updateInvoice: (ULong?, String) -> Unit, onClickAddTag: () -> Unit, onClickTag: (String) -> Unit, + onInputUpdated: (String) -> Unit, onDescriptionUpdate: (String) -> Unit, onBack: () -> Unit, ) { val currencyVM = currencyViewModel ?: return - var input: String by rememberSaveable { mutableStateOf("") } var satsString by rememberSaveable { mutableStateOf("") } var keyboardVisible by remember { mutableStateOf(false) } AmountInputHandler( - input = input, + input = walletUiState.balanceInput, primaryDisplay = currencyUiState.primaryDisplay, displayUnit = currencyUiState.displayUnit, - onInputChanged = { newInput -> input = newInput }, + onInputChanged = onInputUpdated, onAmountCalculated = { sats -> satsString = sats }, currencyVM = currencyVM ) EditInvoiceContent( - input = input, + input = walletUiState.balanceInput, noteText = walletUiState.bip21Description, primaryDisplay = currencyUiState.primaryDisplay, displayUnit = currencyUiState.displayUnit, @@ -91,7 +91,7 @@ fun EditInvoiceScreen( onTextChanged = onDescriptionUpdate, keyboardVisible = keyboardVisible, onClickBalance = { keyboardVisible = true }, - onInputChanged = { newText -> input = newText }, + onInputChanged = onInputUpdated, onContinueKeyboard = { keyboardVisible = false }, onContinueGeneral = { updateInvoice(satsString.toULongOrNull(), walletUiState.bip21Description) }, onClickAddTag = onClickAddTag, 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 7f35a0743..206edbb1f 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 @@ -184,6 +184,9 @@ fun ReceiveQrSheet( }, onDescriptionUpdate = { newText -> wallet.updateBip21Description(newText = newText) + }, + onInputUpdated = { newText -> + wallet.updateBalanceInput(newText) } ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 092620c43..e7ee3ed9d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -64,6 +64,7 @@ class WalletViewModel @Inject constructor( _uiState.update { it.copy( onchainAddress = state.onchainAddress, + balanceInput = state.balanceInput, bolt11 = state.bolt11, bip21 = state.bip21, bip21AmountSats = state.bip21AmountSats, @@ -403,10 +404,15 @@ class WalletViewModel @Inject constructor( fun updateBip21Description(newText: String) { walletRepo.updateBip21Description(newText) } + + fun updateBalanceInput(newText: String) { + walletRepo.updateBalanceInput(newText = newText) + } } data class MainUiState( val nodeId: String = "", + val balanceInput: String = "", val balanceDetails: BalanceDetails? = null, val onchainAddress: String = "", val bolt11: String = "", From 43a0ee31c9fe98461c73f31eb73b3fc7ee5ae339 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Tue, 6 May 2025 12:09:59 -0300 Subject: [PATCH 29/62] fix: keep the balance input state while the qr code is displayed and reset then the sheet is dismissed --- app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 4 +--- .../ui/screens/wallets/receive/EditInvoiceScreen.kt | 9 +++++---- .../bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt | 6 +++--- .../main/java/to/bitkit/viewmodels/WalletViewModel.kt | 7 ++++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 922056461..4e2b4df7e 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -119,7 +119,7 @@ class WalletRepo @Inject constructor( } //Reset invoice state - _walletState.update { it.copy(balanceInput = "") } + _walletState.update { it.copy(selectedTags = emptyList(), bip21Description = "", balanceInput = "") } updateBip21Invoice() @@ -179,8 +179,6 @@ class WalletRepo @Inject constructor( setBip21(newBip21) saveInvoiceWithTags(bip21Invoice = newBip21, tags = tags) - _walletState.update { it.copy(selectedTags = emptyList(), bip21Description = "") } - Result.success(Unit) } catch (e: Throwable) { Logger.error("Update BIP21 invoice error", e, context = TAG) 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 b586fb5a2..f777db666 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 @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp import to.bitkit.R import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.PrimaryDisplay +import to.bitkit.repositories.WalletState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.AmountInputHandler import to.bitkit.ui.components.BodySSB @@ -60,8 +61,8 @@ import to.bitkit.viewmodels.MainUiState @Composable fun EditInvoiceScreen( currencyUiState: CurrencyUiState = LocalCurrencies.current, - walletUiState: MainUiState, - updateInvoice: (ULong?, String) -> Unit, + walletUiState: WalletState, + updateInvoice: (ULong?) -> Unit, onClickAddTag: () -> Unit, onClickTag: (String) -> Unit, onInputUpdated: (String) -> Unit, @@ -93,7 +94,7 @@ fun EditInvoiceScreen( onClickBalance = { keyboardVisible = true }, onInputChanged = onInputUpdated, onContinueKeyboard = { keyboardVisible = false }, - onContinueGeneral = { updateInvoice(satsString.toULongOrNull(), walletUiState.bip21Description) }, + onContinueGeneral = { updateInvoice(satsString.toULongOrNull()) }, onClickAddTag = onClickAddTag, onClickTag = onClickTag ) @@ -220,7 +221,7 @@ fun EditInvoiceContent( ) }, value = noteText, - onValueChange = onTextChanged, + onValueChange = onTextChanged, //it is being callet with the current state and with the previous state minLines = 4, colors = AppTextFieldDefaults.semiTransparent, shape = MaterialTheme.shapes.medium, 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 206edbb1f..4adb320c7 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 @@ -168,12 +168,12 @@ fun ReceiveQrSheet( } } composable(ReceiveRoutes.EDIT_INVOICE) { - val walletUiState by wallet.uiState.collectAsStateWithLifecycle() + val walletUiState by wallet.walletState.collectAsStateWithLifecycle() EditInvoiceScreen( walletUiState = walletUiState, onBack = { navController.popBackStack() }, - updateInvoice = { sats, description -> - wallet.updateBip21Invoice(amountSats = sats, description = description) + updateInvoice = { sats -> + wallet.updateBip21Invoice(amountSats = sats) navController.popBackStack() }, onClickAddTag = { diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index e7ee3ed9d..c6f341782 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -175,13 +175,12 @@ class WalletViewModel @Inject constructor( fun updateBip21Invoice( amountSats: ULong? = null, - description: String = "", generateBolt11IfAvailable: Boolean = true ) { viewModelScope.launch { walletRepo.updateBip21Invoice( amountSats = amountSats, - description = description, + description = walletState.value.bip21Description, generateBolt11IfAvailable = generateBolt11IfAvailable, tags = walletState.value.selectedTags ).onFailure { error -> @@ -198,7 +197,6 @@ class WalletViewModel @Inject constructor( walletRepo.toggleReceiveOnSpendingBalance() updateBip21Invoice( amountSats = walletState.value.bip21AmountSats, - description = walletState.value.bip21Description, generateBolt11IfAvailable = walletState.value.receiveOnSpendingBalance ) } @@ -402,6 +400,9 @@ class WalletViewModel @Inject constructor( } fun updateBip21Description(newText: String) { + if (newText.isEmpty()) { + Logger.warn("Empty") + } walletRepo.updateBip21Description(newText) } From 51c00e6446d68b0f951e7dea94d65b6edcdb4771 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Tue, 6 May 2025 12:13:09 -0300 Subject: [PATCH 30/62] fix: keep the balance input state while the qr code is displayed and reset then the sheet is dismissed --- app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index c6f341782..df9353798 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -50,6 +50,7 @@ class WalletViewModel @Inject constructor( private set private val _uiState = MutableStateFlow(MainUiState()) + @Deprecated("Prioritize get the wallet and lightning states from LightningRepo or WalletRepo") val uiState = _uiState.asStateFlow() init { From 7d3520206f22788bcbc34897099757c88a4e2999 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Tue, 6 May 2025 13:49:08 -0300 Subject: [PATCH 31/62] fix: set ime option as done --- .../ui/screens/wallets/receive/EditInvoiceScreen.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 f777db666..3992e58b3 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 @@ -17,6 +17,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -32,6 +34,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R @@ -221,8 +224,11 @@ fun EditInvoiceContent( ) }, value = noteText, - onValueChange = onTextChanged, //it is being callet with the current state and with the previous state + onValueChange = onTextChanged, minLines = 4, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Done + ), colors = AppTextFieldDefaults.semiTransparent, shape = MaterialTheme.shapes.medium, modifier = Modifier From 9a041c9f18ce810fbe666eaa15499dd0ea5689e7 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Wed, 7 May 2025 07:13:03 -0300 Subject: [PATCH 32/62] refactor: change notification name and ID --- app/src/main/java/to/bitkit/App.kt | 21 ++++++++----------- .../androidServices/LightningNodeService.kt | 4 ++-- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index c30ea1a96..6f1741311 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -7,13 +7,12 @@ import android.app.Application.ActivityLifecycleCallbacks import android.app.NotificationChannel import android.app.NotificationManager import android.content.Intent -import android.os.Build import android.os.Bundle import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp import to.bitkit.androidServices.LightningNodeService -import to.bitkit.androidServices.LightningNodeService.Companion.BITKIT_CHANNEL_ID +import to.bitkit.androidServices.LightningNodeService.Companion.CHANNEL_ID_NODE import to.bitkit.env.Env import javax.inject.Inject @@ -43,18 +42,16 @@ internal open class App : Application(), Configuration.Provider { } private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val name = getString(R.string.app_name) - val descriptionText = "Channel for LightningNodeService" - val importance = NotificationManager.IMPORTANCE_LOW + val name = "Lightning node notification" + val descriptionText = "Channel for LightningNodeService" + val importance = NotificationManager.IMPORTANCE_LOW - val channel = NotificationChannel(BITKIT_CHANNEL_ID, name, importance).apply { - description = descriptionText - } - - val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) + val channel = NotificationChannel(CHANNEL_ID_NODE, name, importance).apply { + description = descriptionText } + + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) } } diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 5e1b64e73..cc851eb93 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -64,7 +64,7 @@ class LightningNodeService : Service() { this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE ) - return NotificationCompat.Builder(this, BITKIT_CHANNEL_ID) + return NotificationCompat.Builder(this, CHANNEL_ID_NODE) .setContentTitle(getString(R.string.app_name)) .setContentText(contentText) .setSmallIcon(R.drawable.ic_launcher_monochrome) @@ -91,7 +91,7 @@ class LightningNodeService : Service() { companion object { private const val NOTIFICATION_ID = 1 - const val BITKIT_CHANNEL_ID = "bitkit_notification_channel" + const val CHANNEL_ID_NODE = "bitkit_notification_channel_node" const val TAG = "LightningNodeService" } } From c1c4d25d63a738b2cb5d6a92053506e18a985c56 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Wed, 7 May 2025 07:19:33 -0300 Subject: [PATCH 33/62] refactor: use initNotificationChannel --- app/src/main/java/to/bitkit/App.kt | 15 --------------- app/src/main/java/to/bitkit/ui/MainActivity.kt | 8 ++++++++ 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index 6f1741311..88c11fe82 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -29,8 +29,6 @@ internal open class App : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) } - createNotificationChannel() - startService(Intent(this, LightningNodeService::class.java)) Env.initAppStoragePath(filesDir.absolutePath) @@ -40,19 +38,6 @@ internal open class App : Application(), Configuration.Provider { @SuppressLint("StaticFieldLeak") // Should be safe given its manual memory management internal var currentActivity: CurrentActivity? = null } - - private fun createNotificationChannel() { - val name = "Lightning node notification" - val descriptionText = "Channel for LightningNodeService" - val importance = NotificationManager.IMPORTANCE_LOW - - val channel = NotificationChannel(CHANNEL_ID_NODE, name, importance).apply { - description = descriptionText - } - - val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) - } } // region currentActivity diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index ce1cd8a45..279db9551 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -1,5 +1,6 @@ package to.bitkit.ui +import android.app.NotificationManager import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -19,6 +20,7 @@ import androidx.navigation.toRoute import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import to.bitkit.androidServices.LightningNodeService.Companion.CHANNEL_ID_NODE import to.bitkit.ui.components.AuthCheckView import to.bitkit.ui.components.ForgotPinSheet import to.bitkit.ui.components.InactivityTracker @@ -58,6 +60,12 @@ class MainActivity : FragmentActivity() { super.onCreate(savedInstanceState) initNotificationChannel() + initNotificationChannel( //TODO EXTRACT TO Strings + id = CHANNEL_ID_NODE, + name = "Lightning node notification", + desc = "Channel for LightningNodeService", + importance = NotificationManager.IMPORTANCE_LOW + ) installSplashScreen() enableAppEdgeToEdge() setContent { From a1f7736b9a9c395baa3d6ef7a70750aa25f48a1b Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Wed, 7 May 2025 07:30:49 -0300 Subject: [PATCH 34/62] fix: add service permission --- app/src/main/AndroidManifest.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cd30854f5..a7c17fcca 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,8 @@ + + Date: Wed, 7 May 2025 07:31:56 -0300 Subject: [PATCH 35/62] fix: move initAppStoragePath before service call --- app/src/main/java/to/bitkit/App.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index 88c11fe82..affa66948 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -4,15 +4,12 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.Application import android.app.Application.ActivityLifecycleCallbacks -import android.app.NotificationChannel -import android.app.NotificationManager import android.content.Intent import android.os.Bundle import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp import to.bitkit.androidServices.LightningNodeService -import to.bitkit.androidServices.LightningNodeService.Companion.CHANNEL_ID_NODE import to.bitkit.env.Env import javax.inject.Inject @@ -29,9 +26,9 @@ internal open class App : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) } - startService(Intent(this, LightningNodeService::class.java)) - Env.initAppStoragePath(filesDir.absolutePath) + + startService(Intent(this, LightningNodeService::class.java)) } companion object { From 6a2cf13ecce5f80cd7e4ea501fdca5d6ba6739de Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Wed, 7 May 2025 07:39:13 -0300 Subject: [PATCH 36/62] fix: Round icon --- .../main/java/to/bitkit/androidServices/LightningNodeService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index cc851eb93..4202e3fcf 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -67,7 +67,7 @@ class LightningNodeService : Service() { return NotificationCompat.Builder(this, CHANNEL_ID_NODE) .setContentTitle(getString(R.string.app_name)) .setContentText(contentText) - .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setSmallIcon(R.drawable.ic_launcher_fg_regtest) //TODO GET PRODUCTION ICON .setContentIntent(pendingIntent) .build() } From 6d95584a4b2037bf9a2718fbaf8886713feb027f Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Wed, 7 May 2025 09:00:04 -0300 Subject: [PATCH 37/62] refactor: remove unused code --- .../to/bitkit/repositories/WalletRepoTest.kt | 67 ------------------- 1 file changed, 67 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 1b80e78de..ccadc634b 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -1,13 +1,11 @@ package to.bitkit.repositories -import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import org.junit.Before import org.junit.Test -import org.junit.runner.RunWith import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.Network import org.mockito.kotlin.any @@ -15,7 +13,6 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import org.robolectric.annotation.Config import to.bitkit.data.AppDb import to.bitkit.data.AppStorage import to.bitkit.data.SettingsStore @@ -24,18 +21,11 @@ import to.bitkit.env.Env import to.bitkit.services.BlocktankNotificationsService import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest -import to.bitkit.test.TestApp import to.bitkit.utils.AddressChecker -import uniffi.bitkitcore.OnchainActivity -import uniffi.bitkitcore.PaymentType -import kotlin.random.Random -import kotlin.random.nextULong import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -@RunWith(AndroidJUnit4::class) -@Config(application = TestApp::class) class WalletRepoTest : BaseUnitTest() { private lateinit var sut: WalletRepo @@ -80,22 +70,6 @@ class WalletRepoTest : BaseUnitTest() { val testFlow = MutableStateFlow(Unit) whenever(lightningRepo.getSyncFlow()).thenReturn(testFlow) whenever(lightningRepo.sync()).thenReturn(Result.success(Unit)) - - // Recreate sut with the new mock - val testSut = WalletRepo( - bgDispatcher = testDispatcher, - appStorage = appStorage, - db = db, - keychain = keychain, - coreService = coreService, - blocktankNotificationsService = blocktankNotificationsService, - firebaseMessaging = firebaseMessaging, - settingsStore = settingsStore, - addressChecker = addressChecker, - lightningRepo = lightningRepo, - network = Env.network - ) - verify(lightningRepo).sync() } @@ -178,45 +152,4 @@ class WalletRepoTest : BaseUnitTest() { cancelAndIgnoreRemainingEvents() } } - - fun mockOnchainActivity( - id: String = java.util.UUID.randomUUID().toString(), - txType: PaymentType = PaymentType.values().random(), - txId: String = java.util.UUID.randomUUID().toString(), - value: ULong = Random.nextULong(), - fee: ULong = Random.nextULong(), - feeRate: ULong = Random.nextULong(), - address: String = "mockAddress_${Random.nextInt(100)}", - confirmed: Boolean = Random.nextBoolean(), - timestamp: ULong = System.currentTimeMillis().toULong(), - isBoosted: Boolean = Random.nextBoolean(), - isTransfer: Boolean = Random.nextBoolean(), - doesExist: Boolean = true, - confirmTimestamp: ULong? = if (confirmed) System.currentTimeMillis().toULong() else null, - channelId: String? = "mockChannel_${Random.nextInt(10)}", - transferTxId: String? = if (isTransfer) java.util.UUID.randomUUID().toString() else null, - createdAt: ULong? = System.currentTimeMillis().toULong(), - updatedAt: ULong? = System.currentTimeMillis().toULong() - ): OnchainActivity { - return OnchainActivity( - id, - txType, - txId, - value, - fee, - feeRate, - address, - confirmed, - timestamp, - isBoosted, - isTransfer, - doesExist, - confirmTimestamp, - channelId, - transferTxId, - createdAt, - updatedAt - ) - } - } From dc8bd920e8fc9b152a5cefd1c12c377310baf9de Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Wed, 7 May 2025 09:11:08 -0300 Subject: [PATCH 38/62] fix: isRefreshing state update --- .../to/bitkit/repositories/LightningRepo.kt | 1 - .../to/bitkit/viewmodels/WalletViewModel.kt | 18 +++++++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 35fc1f551..366f8d4be 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -338,6 +338,5 @@ data class LightningState( val nodeLifecycleState: NodeLifecycleState = NodeLifecycleState.Stopped, val peers: List = emptyList(), val channels: List = emptyList(), - val isRefreshing: Boolean = false, val isSyncingWallet: Boolean = false ) diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index df9353798..5d8d3ab91 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -87,7 +87,6 @@ class WalletViewModel @Inject constructor( nodeLifecycleState = state.nodeLifecycleState, peers = state.peers, channels = state.channels, - isRefreshing = state.isRefreshing, ) } } @@ -130,14 +129,15 @@ class WalletViewModel @Inject constructor( fun onPullToRefresh() { viewModelScope.launch { - try { - lightningRepo.sync() - .onFailure { error -> - ToastEventBus.send(error) - } - } catch (e: Throwable) { - ToastEventBus.send(e) - } + _uiState.update { it.copy(isRefreshing = true) } + lightningRepo.sync() + .onSuccess { + _uiState.update { it.copy(isRefreshing = false) } + } + .onFailure { error -> + ToastEventBus.send(error) + _uiState.update { it.copy(isRefreshing = false) } + } } } From 761960cdccd13aa7165ec17627239cca5d69b3ae Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Wed, 7 May 2025 09:42:02 -0300 Subject: [PATCH 39/62] refactor: updateBip21Invoice overload --- .../java/to/bitkit/repositories/WalletRepo.kt | 98 ++++++------------- .../to/bitkit/viewmodels/WalletViewModel.kt | 1 - .../java/to/bitkit/ui/WalletViewModelTest.kt | 4 +- 3 files changed, 30 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 4e2b4df7e..6bfdee73f 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -119,7 +119,7 @@ class WalletRepo @Inject constructor( } //Reset invoice state - _walletState.update { it.copy(selectedTags = emptyList(), bip21Description = "", balanceInput = "") } + _walletState.update { it.copy(selectedTags = emptyList(), bip21Description = "", balanceInput = "", bip21 = "") } updateBip21Invoice() @@ -143,49 +143,6 @@ class WalletRepo @Inject constructor( } } - suspend fun updateBip21Invoice( - amountSats: ULong? = null, - description: String = "", - generateBolt11IfAvailable: Boolean = true, - tags: List = emptyList() - ): Result = withContext(bgDispatcher) { - try { - _walletState.update { - it.copy( - bip21AmountSats = amountSats, - bip21Description = description - ) - } - - val hasChannels = lightningRepo.hasChannels() - - if (hasChannels && generateBolt11IfAvailable) { - lightningRepo.createInvoice( - amountSats = _walletState.value.bip21AmountSats, - description = _walletState.value.bip21Description - ).onSuccess { bolt11 -> - setBolt11(bolt11) - } - } else { - setBolt11("") - } - - val newBip21 = buildBip21Url( - bitcoinAddress = getOnchainAddress(), - amountSats = _walletState.value.bip21AmountSats, - message = description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE }, - lightningInvoice = getBolt11() - ) - setBip21(newBip21) - saveInvoiceWithTags(bip21Invoice = newBip21, tags = tags) - - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Update BIP21 invoice error", e, context = TAG) - Result.failure(e) - } - } - suspend fun refreshBip21ForEvent(event: Event) { when (event) { is Event.PaymentReceived, is Event.ChannelReady, is Event.ChannelClosed -> refreshBip21() @@ -336,41 +293,42 @@ class WalletRepo @Inject constructor( } } - fun clearTagsAndBip21DescriptionState() { - _walletState.update { it.copy(selectedTags = listOf(), bip21Description = "") } - } - // BIP21 invoice creation suspend fun updateBip21Invoice( amountSats: ULong? = null, description: String = "", generateBolt11IfAvailable: Boolean = true, - ) = withContext(bgDispatcher) { - updateBip21AmountSats(amountSats) - updateBip21Description(description) + ): Result = withContext(bgDispatcher) { + try { + updateBip21AmountSats(amountSats) + updateBip21Description(description) - val hasChannels = lightningRepo.hasChannels() + val hasChannels = lightningRepo.hasChannels() - if (hasChannels && generateBolt11IfAvailable) { - lightningRepo.createInvoice( - amountSats = _walletState.value.bip21AmountSats, - description = _walletState.value.bip21Description - ).onSuccess { bolt11 -> - setBolt11(bolt11) + if (hasChannels && generateBolt11IfAvailable) { + lightningRepo.createInvoice( + amountSats = _walletState.value.bip21AmountSats, + description = _walletState.value.bip21Description + ).onSuccess { bolt11 -> + setBolt11(bolt11) + } + } else { + setBolt11("") } - } else { - setBolt11("") - } - val newBip21 = buildBip21Url( - bitcoinAddress = getOnchainAddress(), - amountSats = _walletState.value.bip21AmountSats, - message = description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE }, - lightningInvoice = getBolt11() - ) - setBip21(newBip21) - saveInvoiceWithTags(bip21Invoice = newBip21, tags = _walletState.value.selectedTags) - clearTagsAndBip21DescriptionState() + val newBip21 = buildBip21Url( + bitcoinAddress = getOnchainAddress(), + amountSats = _walletState.value.bip21AmountSats, + message = description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE }, + lightningInvoice = getBolt11() + ) + setBip21(newBip21) + saveInvoiceWithTags(bip21Invoice = newBip21, tags = _walletState.value.selectedTags) + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Update BIP21 invoice error", e, context = TAG) + Result.failure(e) + } } // Notification handling diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 5d8d3ab91..3cb048a31 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -183,7 +183,6 @@ class WalletViewModel @Inject constructor( amountSats = amountSats, description = walletState.value.bip21Description, generateBolt11IfAvailable = generateBolt11IfAvailable, - tags = walletState.value.selectedTags ).onFailure { error -> ToastEventBus.send( type = Toast.ToastType.ERROR, diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index a287f0691..f0edcf69f 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -127,11 +127,11 @@ class WalletViewModelTest : BaseUnitTest() { @Test fun `updateBip21Invoice should call walletRepo updateBip21Invoice and send failure toast`() = test { val testError = Exception("Test error") - whenever(walletRepo.updateBip21Invoice(anyOrNull(), any(), any(), any())).thenReturn(Result.failure(testError)) + whenever(walletRepo.updateBip21Invoice(anyOrNull(), any(), any())).thenReturn(Result.failure(testError)) sut.updateBip21Invoice() - verify(walletRepo).updateBip21Invoice(anyOrNull(), any(), any(), any()) + verify(walletRepo).updateBip21Invoice(anyOrNull(), any(), any()) // Add verification for ToastEventBus.send } From b56085cbca2fff465afccc0a80a2ce6b8ce8b77b Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Wed, 7 May 2025 10:22:24 -0300 Subject: [PATCH 40/62] refactor:remove unnecessary setWalletExistsState calls --- .../bitkit/androidServices/LightningNodeService.kt | 2 ++ app/src/main/java/to/bitkit/ui/ContentView.kt | 1 - app/src/main/java/to/bitkit/ui/MainActivity.kt | 3 --- .../java/to/bitkit/viewmodels/WalletViewModel.kt | 12 +++++------- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 4202e3fcf..e627cd573 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -49,6 +49,8 @@ class LightningNodeService : Service() { val notification = createNotification() startForeground(NOTIFICATION_ID, notification) + walletRepo.setWalletExistsState() + walletRepo.syncBalances() walletRepo.registerForNotifications() walletRepo.refreshBip21() } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 2e0543a56..2ae611091 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -189,7 +189,6 @@ fun ContentView( try { walletViewModel.setInitNodeLifecycleState() walletViewModel.start() - walletViewModel.setWalletExistsState() } catch (e: Exception) { Logger.error("Failed to start wallet on retry", e) } diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 279db9551..625475aec 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -115,7 +115,6 @@ class MainActivity : FragmentActivity() { appViewModel.resetIsAuthenticatedState() walletViewModel.setInitNodeLifecycleState() walletViewModel.createWallet(bip39Passphrase = null) - walletViewModel.setWalletExistsState() appViewModel.setShowEmptyState(true) } catch (e: Throwable) { appViewModel.toast(e) @@ -155,7 +154,6 @@ class MainActivity : FragmentActivity() { walletViewModel.setInitNodeLifecycleState() walletViewModel.setRestoringWalletState(isRestoringWallet = true) walletViewModel.restoreWallet(mnemonic, passphrase) - walletViewModel.setWalletExistsState() appViewModel.setShowEmptyState(false) } catch (e: Throwable) { appViewModel.toast(e) @@ -178,7 +176,6 @@ class MainActivity : FragmentActivity() { appViewModel.resetIsAuthenticatedState() walletViewModel.setInitNodeLifecycleState() walletViewModel.createWallet(bip39Passphrase = passphrase) - walletViewModel.setWalletExistsState() appViewModel.setShowEmptyState(true) } catch (e: Throwable) { appViewModel.toast(e) diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 3cb048a31..26164735f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -97,10 +97,6 @@ class WalletViewModel @Inject constructor( walletRepo.setRestoringWalletState(isRestoring = isRestoringWallet) } - fun setWalletExistsState() { - walletExists = walletRepo.walletExists() - } - fun setInitNodeLifecycleState() { lightningRepo.setInitNodeLifecycleState() } @@ -110,6 +106,11 @@ class WalletViewModel @Inject constructor( viewModelScope.launch(bgDispatcher) { lightningRepo.start(walletIndex) + .onSuccess { + walletRepo.setWalletExistsState() + walletRepo.syncBalances() + walletRepo.refreshBip21() + } .onFailure { error -> Logger.error("Node startup error", error) ToastEventBus.send(error) @@ -261,9 +262,6 @@ class WalletViewModel @Inject constructor( walletRepo.wipeWallet() .onSuccess { lightningRepo.wipeStorage(walletIndex = 0) - .onSuccess { - setWalletExistsState() - } .onFailure { error -> ToastEventBus.send(error) } From 0971b2d50b6652852105eca6ef0bb5ef30e8505c Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Wed, 7 May 2025 11:31:01 -0300 Subject: [PATCH 41/62] refactor: sync node only at specific events --- .../androidServices/LightningNodeService.kt | 9 +-- .../to/bitkit/repositories/LightningRepo.kt | 6 ++ .../java/to/bitkit/repositories/WalletRepo.kt | 59 ++++++++++++------- .../to/bitkit/services/LightningService.kt | 20 +++++++ 4 files changed, 66 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index e627cd573..4ff392c79 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -41,18 +41,13 @@ class LightningNodeService : Service() { startForeground(NOTIFICATION_ID, createNotification()) launch { - lightningRepo.start( - eventHandler = { event -> - walletRepo.refreshBip21ForEvent(event) - } - ).onSuccess { + lightningRepo.start().onSuccess { val notification = createNotification() startForeground(NOTIFICATION_ID, notification) walletRepo.setWalletExistsState() - walletRepo.syncBalances() walletRepo.registerForNotifications() - walletRepo.refreshBip21() + walletRepo.syncWithEvents() } } } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 366f8d4be..28fbc2858 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -13,6 +13,7 @@ import org.lightningdevkit.ldknode.Address import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId @@ -183,6 +184,11 @@ class LightningRepo @Inject constructor( } } + suspend fun listenForNodeEvents( + ) : Result> = executeWhenNodeRunning("listen for node events") { + Result.success(lightningService.listenForEvents()) + } + fun setInitNodeLifecycleState() { _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Initializing) } } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 6bfdee73f..00cf4e24a 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -58,14 +58,14 @@ class WalletRepo @Inject constructor( private val network: Network ) { - private val bgScope: CoroutineScope = CoroutineScope(bgDispatcher + SupervisorJob()) - - private val _walletState = MutableStateFlow(WalletState( - onchainAddress = appStorage.onchainAddress, - bolt11 = appStorage.bolt11, - bip21 = appStorage.bip21, - walletExists = walletExists() - )) + private val _walletState = MutableStateFlow( + WalletState( + onchainAddress = appStorage.onchainAddress, + bolt11 = appStorage.bolt11, + bip21 = appStorage.bip21, + walletExists = walletExists() + ) + ) val walletState = _walletState.asStateFlow() private val _balanceState = MutableStateFlow(getBalanceState()) @@ -77,16 +77,6 @@ class WalletRepo @Inject constructor( _walletState.update { it.copy(walletExists = walletExists()) } } - init { - bgScope.launch { - lightningRepo.getSyncFlow().collect { - lightningRepo.sync().onSuccess { - syncBalances() - } - } - } - } - suspend fun checkAddressUsage(address: String): Result = withContext(bgDispatcher) { return@withContext try { val addressInfo = addressChecker.getAddressInfo(address) @@ -119,7 +109,14 @@ class WalletRepo @Inject constructor( } //Reset invoice state - _walletState.update { it.copy(selectedTags = emptyList(), bip21Description = "", balanceInput = "", bip21 = "") } + _walletState.update { + it.copy( + selectedTags = emptyList(), + bip21Description = "", + balanceInput = "", + bip21 = "" + ) + } updateBip21Invoice() @@ -127,6 +124,7 @@ class WalletRepo @Inject constructor( } suspend fun syncBalances() { + lightningRepo.sync() lightningRepo.getBalances()?.let { balance -> val totalSats = balance.totalLightningBalanceSats + balance.totalOnchainBalanceSats @@ -143,13 +141,32 @@ class WalletRepo @Inject constructor( } } - suspend fun refreshBip21ForEvent(event: Event) { + suspend fun syncWithEvents() = withContext(bgDispatcher) { + lightningRepo.listenForNodeEvents().onSuccess { eventFlow -> + eventFlow.collect { event -> + Logger.debug("Event collected: $event", context = TAG) + syncBalancesForEvent(event) + refreshBip21ForEvent(event) + } + }.onFailure { e -> + Logger.error("syncWithEvents error ", e, context = TAG) + } + } + + private suspend fun refreshBip21ForEvent(event: Event) { when (event) { is Event.PaymentReceived, is Event.ChannelReady, is Event.ChannelClosed -> refreshBip21() else -> Unit } } + private suspend fun syncBalancesForEvent(event: Event) { + when (event) { + is Event.PaymentReceived, Event.PaymentSuccessful -> syncBalances() + else -> Unit + } + } + fun setRestoringWalletState(isRestoring: Boolean) { _walletState.update { it.copy(isRestoringWallet = isRestoring) } } @@ -332,7 +349,7 @@ class WalletRepo @Inject constructor( } // Notification handling - suspend fun registerForNotifications(): Result = withContext(bgDispatcher) { + suspend fun registerForNotifications(): Result = withContext(bgDispatcher) { //TODO HANDLE Register for notifications error (err: 'Node is not started') try { val token = firebaseMessaging.token.await() val cachedToken = keychain.loadString(Keychain.Key.PUSH_NOTIFICATION_TOKEN.name) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index e668daf19..eebccbb24 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -413,6 +413,26 @@ class LightningService @Inject constructor( } } + fun listenForEvents(): Flow = flow { + while (shouldListenForEvents) { + val node = this@LightningService.node ?: let { + Logger.error(ServiceError.NodeNotStarted.message.orEmpty()) + return@flow + } + val event = node.nextEventAsync() + + try { + node.eventHandled() + Logger.debug("LDK eventHandled: $event") + } catch (e: NodeException) { + Logger.error("LDK eventHandled error", LdkError(e)) + } + + logEvent(event) + emit(event) + } + } + private fun logEvent(event: Event) { when (event) { is Event.PaymentSuccessful -> { From fc1b041c08927a46d098ba1eb1b0857d6b2189dd Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Wed, 7 May 2025 13:56:59 -0300 Subject: [PATCH 42/62] refactor: restore observeLdkWallet logic --- .../androidServices/LightningNodeService.kt | 8 +++- .../to/bitkit/repositories/LightningRepo.kt | 6 --- .../java/to/bitkit/repositories/WalletRepo.kt | 46 +++++++++---------- .../to/bitkit/services/LightningService.kt | 20 -------- app/src/main/java/to/bitkit/ui/ContentView.kt | 4 ++ .../to/bitkit/viewmodels/WalletViewModel.kt | 6 ++- 6 files changed, 37 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 4ff392c79..718908a75 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -41,13 +41,17 @@ class LightningNodeService : Service() { startForeground(NOTIFICATION_ID, createNotification()) launch { - lightningRepo.start().onSuccess { + lightningRepo.start( + eventHandler = { event -> + walletRepo.refreshBip21ForEvent(event) + } + ).onSuccess { val notification = createNotification() startForeground(NOTIFICATION_ID, notification) walletRepo.setWalletExistsState() walletRepo.registerForNotifications() - walletRepo.syncWithEvents() + walletRepo.refreshBip21() } } } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 28fbc2858..366f8d4be 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -13,7 +13,6 @@ import org.lightningdevkit.ldknode.Address import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelDetails -import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId @@ -184,11 +183,6 @@ class LightningRepo @Inject constructor( } } - suspend fun listenForNodeEvents( - ) : Result> = executeWhenNodeRunning("listen for node events") { - Result.success(lightningService.listenForEvents()) - } - fun setInitNodeLifecycleState() { _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Initializing) } } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 00cf4e24a..b9665850e 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -2,14 +2,12 @@ package to.bitkit.repositories import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext import kotlinx.datetime.Clock @@ -27,6 +25,7 @@ import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.ext.toHex import to.bitkit.models.BalanceState +import to.bitkit.models.NodeLifecycleState import to.bitkit.services.BlocktankNotificationsService import to.bitkit.services.CoreService import to.bitkit.utils.AddressChecker @@ -123,8 +122,26 @@ class WalletRepo @Inject constructor( return@withContext Result.success(Unit) } + suspend fun observeLdkWallet() = withContext(bgDispatcher) { + lightningRepo.getSyncFlow() + .filter { lightningRepo.lightningState.value.nodeLifecycleState == NodeLifecycleState.Running } + .collect { + runCatching { + syncNodeAndWallet() + } + } + } + + suspend fun syncNodeAndWallet() : Result = withContext(bgDispatcher) { + lightningRepo.sync().onSuccess { + syncBalances() + return@withContext Result.success(Unit) + }.onFailure { e -> + return@withContext Result.failure(e) + } + } + suspend fun syncBalances() { - lightningRepo.sync() lightningRepo.getBalances()?.let { balance -> val totalSats = balance.totalLightningBalanceSats + balance.totalOnchainBalanceSats @@ -141,32 +158,13 @@ class WalletRepo @Inject constructor( } } - suspend fun syncWithEvents() = withContext(bgDispatcher) { - lightningRepo.listenForNodeEvents().onSuccess { eventFlow -> - eventFlow.collect { event -> - Logger.debug("Event collected: $event", context = TAG) - syncBalancesForEvent(event) - refreshBip21ForEvent(event) - } - }.onFailure { e -> - Logger.error("syncWithEvents error ", e, context = TAG) - } - } - - private suspend fun refreshBip21ForEvent(event: Event) { + suspend fun refreshBip21ForEvent(event: Event) { when (event) { is Event.PaymentReceived, is Event.ChannelReady, is Event.ChannelClosed -> refreshBip21() else -> Unit } } - private suspend fun syncBalancesForEvent(event: Event) { - when (event) { - is Event.PaymentReceived, Event.PaymentSuccessful -> syncBalances() - else -> Unit - } - } - fun setRestoringWalletState(isRestoring: Boolean) { _walletState.update { it.copy(isRestoringWallet = isRestoring) } } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index eebccbb24..e668daf19 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -413,26 +413,6 @@ class LightningService @Inject constructor( } } - fun listenForEvents(): Flow = flow { - while (shouldListenForEvents) { - val node = this@LightningService.node ?: let { - Logger.error(ServiceError.NodeNotStarted.message.orEmpty()) - return@flow - } - val event = node.nextEventAsync() - - try { - node.eventHandled() - Logger.debug("LDK eventHandled: $event") - } catch (e: NodeException) { - Logger.error("LDK eventHandled error", LdkError(e)) - } - - logEvent(event) - emit(event) - } - } - private fun logEvent(event: Event) { when (event) { is Event.PaymentSuccessful -> { diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 2ae611091..48f76ba4f 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -148,6 +148,10 @@ fun ContentView( } } + LaunchedEffect(Unit) { + walletViewModel.observeLdkWallet() + } + LaunchedEffect(appViewModel) { appViewModel.mainScreenEffect.collect { when (it) { diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 26164735f..cfc3cb0b7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -118,6 +118,10 @@ class WalletViewModel @Inject constructor( } } + suspend fun observeLdkWallet() { + walletRepo.observeLdkWallet() + } + fun refreshState() { viewModelScope.launch { lightningRepo.sync() @@ -131,7 +135,7 @@ class WalletViewModel @Inject constructor( fun onPullToRefresh() { viewModelScope.launch { _uiState.update { it.copy(isRefreshing = true) } - lightningRepo.sync() + walletRepo.syncNodeAndWallet() .onSuccess { _uiState.update { it.copy(isRefreshing = false) } } From c0de93c42f66823dd022e70f1214c052e91de0bc Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 8 May 2025 07:41:25 -0300 Subject: [PATCH 43/62] fix: call syncState and sync balance also before sync --- app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 1 + app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 366f8d4be..71602388e 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -206,6 +206,7 @@ class LightningRepo @Inject constructor( } suspend fun sync(): Result = executeWhenNodeRunning("Sync") { + syncState() if (_lightningState.value.isSyncingWallet) { Logger.warn("Sync already in progress, waiting for existing sync.") return@executeWhenNodeRunning Result.success(Unit) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index b9665850e..433b345d5 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -133,6 +133,7 @@ class WalletRepo @Inject constructor( } suspend fun syncNodeAndWallet() : Result = withContext(bgDispatcher) { + syncBalances() lightningRepo.sync().onSuccess { syncBalances() return@withContext Result.success(Unit) From 65579be489bec9cbd073fb99116a9e474a418c14 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 8 May 2025 08:17:16 -0300 Subject: [PATCH 44/62] fix: solve conflicts --- app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 5643c21b9..2d6d2ca18 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1,10 +1,12 @@ package to.bitkit.repositories import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.Address From 0d6ef4b152f6a3ed4d5b4d4af1ff424ad32f2bb1 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 8 May 2025 08:28:53 -0300 Subject: [PATCH 45/62] fix: sync balances at start --- .../main/java/to/bitkit/androidServices/LightningNodeService.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 718908a75..5c56734db 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -52,6 +52,7 @@ class LightningNodeService : Service() { walletRepo.setWalletExistsState() walletRepo.registerForNotifications() walletRepo.refreshBip21() + walletRepo.syncBalances() } } } From d305dd15153452f472cc56ffa7d6725afb271389 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 8 May 2025 09:06:47 -0300 Subject: [PATCH 46/62] fix: stop node and reset lone state when wipe the wallet --- .../java/to/bitkit/repositories/LightningRepo.kt | 13 +++++++------ .../main/java/to/bitkit/repositories/WalletRepo.kt | 5 +++-- .../java/to/bitkit/viewmodels/WalletViewModel.kt | 12 +++--------- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 2d6d2ca18..7038fd355 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -199,12 +199,11 @@ class LightningRepo @Inject constructor( } try { - executeWhenNodeRunning("stop") { - _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) } - lightningService.stop() - _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopped) } - Result.success(Unit) - } + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) } + lightningService.stop() + _lightningState.update { LightningState() } + _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopped) } + Result.success(Unit) } catch (e: Throwable) { Logger.error("Node stop error", e, context = TAG) Result.failure(e) @@ -227,8 +226,10 @@ class LightningRepo @Inject constructor( } suspend fun wipeStorage(walletIndex: Int): Result = withContext(bgDispatcher) { + Logger.debug("wipeStorage called, stopping node first", context = TAG) stop().onSuccess { return@withContext try { + Logger.debug("node stopped, calling wipeStorage called", context = TAG) lightningService.wipeStorage(walletIndex) _lightningState.update { LightningState(nodeStatus = it.nodeStatus, nodeLifecycleState = it.nodeLifecycleState) } Result.success(Unit) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 433b345d5..cac615f3e 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -199,7 +199,7 @@ class WalletRepo @Inject constructor( } } - suspend fun wipeWallet(): Result = withContext(bgDispatcher) { + suspend fun wipeWallet(walletIndex: Int = 0): Result = withContext(bgDispatcher) { if (network != Network.REGTEST) { return@withContext Result.failure(Exception("Can only wipe on regtest.")) } @@ -213,7 +213,8 @@ class WalletRepo @Inject constructor( _walletState.update { WalletState() } _balanceState.update { BalanceState() } setWalletExistsState() - Result.success(Unit) + + return@withContext lightningRepo.wipeStorage(walletIndex = walletIndex) } catch (e: Throwable) { Logger.error("Wipe wallet error", e) Result.failure(e) diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index cfc3cb0b7..067569e51 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -263,15 +263,9 @@ class WalletViewModel @Inject constructor( fun wipeStorage() { viewModelScope.launch(bgDispatcher) { - walletRepo.wipeWallet() - .onSuccess { - lightningRepo.wipeStorage(walletIndex = 0) - .onFailure { error -> - ToastEventBus.send(error) - } - }.onFailure { error -> - ToastEventBus.send(error) - } + walletRepo.wipeWallet().onFailure { error -> + ToastEventBus.send(error) + } } } From 37a6fd896aeaff0e87a25064f37be756fa132503 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 8 May 2025 09:12:34 -0300 Subject: [PATCH 47/62] fix: log --- app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 7038fd355..4d6394b22 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -229,7 +229,7 @@ class LightningRepo @Inject constructor( Logger.debug("wipeStorage called, stopping node first", context = TAG) stop().onSuccess { return@withContext try { - Logger.debug("node stopped, calling wipeStorage called", context = TAG) + Logger.debug("node stopped, calling wipeStorage", context = TAG) lightningService.wipeStorage(walletIndex) _lightningState.update { LightningState(nodeStatus = it.nodeStatus, nodeLifecycleState = it.nodeLifecycleState) } Result.success(Unit) From e53e1a06fda42292a7537c26b00614c205a7812c Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 8 May 2025 09:31:03 -0300 Subject: [PATCH 48/62] fix: move notification methods to LightningNodeService and add executeWhenNodeRunning logic --- .../androidServices/LightningNodeService.kt | 1 - .../to/bitkit/repositories/LightningRepo.kt | 60 +++++++++++++++++++ .../java/to/bitkit/repositories/WalletRepo.kt | 53 ---------------- .../to/bitkit/viewmodels/WalletViewModel.kt | 8 +-- 4 files changed, 64 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 5c56734db..8f4ef41b1 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -50,7 +50,6 @@ class LightningNodeService : Service() { startForeground(NOTIFICATION_ID, notification) walletRepo.setWalletExistsState() - walletRepo.registerForNotifications() walletRepo.refreshBip21() walletRepo.syncBalances() } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 4d6394b22..95b4a12d2 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1,5 +1,6 @@ package to.bitkit.repositories +import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -7,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update +import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.Address @@ -19,16 +21,19 @@ import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.Txid import org.lightningdevkit.ldknode.UserChannelId import to.bitkit.data.SettingsStore +import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.models.LnPeer import to.bitkit.models.NodeLifecycleState import to.bitkit.models.TransactionSpeed +import to.bitkit.services.BlocktankNotificationsService import to.bitkit.services.CoreService import to.bitkit.services.LdkNodeEventBus import to.bitkit.services.LightningService import to.bitkit.services.NodeEventHandler import to.bitkit.utils.Logger +import uniffi.bitkitcore.IBtInfo import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration @@ -42,6 +47,9 @@ class LightningRepo @Inject constructor( private val ldkNodeEventBus: LdkNodeEventBus, private val settingsStore: SettingsStore, private val coreService: CoreService, + private val blocktankNotificationsService: BlocktankNotificationsService, + private val firebaseMessaging: FirebaseMessaging, + private val keychain: Keychain, ) { private val _lightningState = MutableStateFlow(LightningState()) val lightningState = _lightningState.asStateFlow() @@ -167,6 +175,7 @@ class LightningRepo @Inject constructor( Logger.error("Failed to connect to trusted peers", e) } sync() + registerForNotifications() Result.success(Unit) } catch (e: Throwable) { @@ -349,6 +358,57 @@ class LightningRepo @Inject constructor( fun hasChannels(): Boolean = _lightningState.value.nodeLifecycleState.isRunning() && lightningService.channels?.isNotEmpty() == true + // Notification handling + suspend fun getFcmToken(): Result = withContext(bgDispatcher) { + try { + val token = firebaseMessaging.token.await() + Result.success(token) + } catch (e: Throwable) { + Logger.error("Get FCM token error", e) + Result.failure(e) + } + } + + suspend fun registerForNotifications(): Result = executeWhenNodeRunning("Register for notifications") { + return@executeWhenNodeRunning try { + val token = firebaseMessaging.token.await() + val cachedToken = keychain.loadString(Keychain.Key.PUSH_NOTIFICATION_TOKEN.name) + + if (cachedToken == token) { + Logger.debug("Skipped registering for notifications, current device token already registered") + return@executeWhenNodeRunning Result.success(Unit) + } + + blocktankNotificationsService.registerDevice(token) + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Register for notifications error", e) + Result.failure(e) + } + } + + suspend fun testNotification(): Result = executeWhenNodeRunning("Test notification") { + try { + val token = firebaseMessaging.token.await() + blocktankNotificationsService.testNotification(token) + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Test notification error", e) + Result.failure(e) + } + } + + suspend fun getBlocktankInfo(): Result = withContext(bgDispatcher) { + try { + val info = coreService.blocktank.info(refresh = true) + ?: return@withContext Result.failure(Exception("Couldn't get info")) + Result.success(info) + } catch (e: Throwable) { + Logger.error("Blocktank info error", e) + Result.failure(e) + } + } + private companion object { const val TAG = "LightningRepo" } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index cac615f3e..149155b71 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -49,8 +49,6 @@ class WalletRepo @Inject constructor( private val db: AppDb, private val keychain: Keychain, private val coreService: CoreService, - private val blocktankNotificationsService: BlocktankNotificationsService, - private val firebaseMessaging: FirebaseMessaging, private val settingsStore: SettingsStore, private val addressChecker: AddressChecker, private val lightningRepo: LightningRepo, @@ -348,47 +346,6 @@ class WalletRepo @Inject constructor( } } - // Notification handling - suspend fun registerForNotifications(): Result = withContext(bgDispatcher) { //TODO HANDLE Register for notifications error (err: 'Node is not started') - try { - val token = firebaseMessaging.token.await() - val cachedToken = keychain.loadString(Keychain.Key.PUSH_NOTIFICATION_TOKEN.name) - - if (cachedToken == token) { - Logger.debug("Skipped registering for notifications, current device token already registered") - return@withContext Result.success(Unit) - } - - blocktankNotificationsService.registerDevice(token) - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Register for notifications error", e) - Result.failure(e) - } - } - - suspend fun testNotification(): Result = withContext(bgDispatcher) { - try { - val token = firebaseMessaging.token.await() - blocktankNotificationsService.testNotification(token) - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Test notification error", e) - Result.failure(e) - } - } - - suspend fun getBlocktankInfo(): Result = withContext(bgDispatcher) { - try { - val info = coreService.blocktank.info(refresh = true) - ?: return@withContext Result.failure(Exception("Couldn't get info")) - Result.success(info) - } catch (e: Throwable) { - Logger.error("Blocktank info error", e) - Result.failure(e) - } - } - // Debug methods suspend fun debugKeychain(key: String, value: String): Result = withContext(bgDispatcher) { try { @@ -418,16 +375,6 @@ class WalletRepo @Inject constructor( } } - suspend fun getFcmToken(): Result = withContext(bgDispatcher) { - try { - val token = firebaseMessaging.token.await() - Result.success(token) - } catch (e: Throwable) { - Logger.error("Get FCM token error", e) - Result.failure(e) - } - } - suspend fun getDbConfig(): Flow> { return db.configDao().getAll() } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 067569e51..fc83ac0e1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -287,7 +287,7 @@ class WalletViewModel @Inject constructor( // region debug methods fun manualRegisterForNotifications() { viewModelScope.launch(bgDispatcher) { - walletRepo.registerForNotifications() + lightningRepo.registerForNotifications() .onSuccess { ToastEventBus.send( type = Toast.ToastType.INFO, @@ -317,7 +317,7 @@ class WalletViewModel @Inject constructor( fun debugFcmToken() { viewModelScope.launch(bgDispatcher) { - walletRepo.getFcmToken().onSuccess { token -> + lightningRepo.getFcmToken().onSuccess { token -> Logger.debug("FCM registration token: $token") } } @@ -343,7 +343,7 @@ class WalletViewModel @Inject constructor( fun debugLspNotifications() { viewModelScope.launch(bgDispatcher) { - walletRepo.testNotification().onFailure { e -> + lightningRepo.testNotification().onFailure { e -> Logger.error("Error in LSP notification test:", e) } } @@ -351,7 +351,7 @@ class WalletViewModel @Inject constructor( fun debugBlocktankInfo() { viewModelScope.launch(bgDispatcher) { - walletRepo.getBlocktankInfo().onSuccess { info -> + lightningRepo.getBlocktankInfo().onSuccess { info -> Logger.debug("Blocktank info: $info") }.onFailure { e -> Logger.error("Error getting Blocktank info:", e) From 0fe84ddeb541382758640b8f5a47a667e11c02d1 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 8 May 2025 09:41:32 -0300 Subject: [PATCH 49/62] fix: clear bip 21 state at start of refreshBip21 --- .../java/to/bitkit/repositories/WalletRepo.kt | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 149155b71..66f8d29d7 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -88,6 +88,16 @@ class WalletRepo @Inject constructor( suspend fun refreshBip21(): Result = withContext(bgDispatcher) { Logger.debug("Refreshing bip21", context = TAG) + //Reset invoice state + _walletState.update { + it.copy( + selectedTags = emptyList(), + bip21Description = "", + balanceInput = "", + bip21 = "" + ) + } + // Check current address or generate new one val currentAddress = getOnchainAddress() if (currentAddress.isEmpty()) { @@ -105,18 +115,7 @@ class WalletRepo @Inject constructor( } } - //Reset invoice state - _walletState.update { - it.copy( - selectedTags = emptyList(), - bip21Description = "", - balanceInput = "", - bip21 = "" - ) - } - updateBip21Invoice() - return@withContext Result.success(Unit) } From 36de195fe5332dbe6e164a25a3aa7502ccd7684c Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 8 May 2025 11:04:39 -0300 Subject: [PATCH 50/62] feat: add button to stop the service --- .../androidServices/LightningNodeService.kt | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 8f4ef41b1..6efaf0dbd 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import to.bitkit.App import to.bitkit.R import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo @@ -32,14 +33,12 @@ class LightningNodeService : Service() { override fun onCreate() { super.onCreate() + startForeground(NOTIFICATION_ID, createNotification()) setupService() - Logger.debug("onCreate", context = TAG) } private fun setupService() { serviceScope.launch { - startForeground(NOTIFICATION_ID, createNotification()) - launch { lightningRepo.start( eventHandler = { event -> @@ -57,24 +56,51 @@ class LightningNodeService : Service() { } } + // Update the createNotification method in LightningNodeService.kt private fun createNotification( - contentText: String = "Bitkit is running in background so you can receive Lightning payments" //TODO GET FROM RESOURCES + contentText: String = "Bitkit is running in background so you can receive Lightning payments" ): Notification { - val notificationIntent = Intent(this, MainActivity::class.java) + val notificationIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + } val pendingIntent = PendingIntent.getActivity( this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE ) + // Create stop action that will close both service and app + val stopIntent = Intent(this, LightningNodeService::class.java).apply { + action = ACTION_STOP_SERVICE_AND_APP + } + val stopPendingIntent = PendingIntent.getService( + this, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(this, CHANNEL_ID_NODE) .setContentTitle(getString(R.string.app_name)) .setContentText(contentText) - .setSmallIcon(R.drawable.ic_launcher_fg_regtest) //TODO GET PRODUCTION ICON + .setSmallIcon(R.drawable.ic_launcher_fg_regtest) .setContentIntent(pendingIntent) + .addAction( + R.drawable.ic_x, + "Stop App", // TODO: Get from resources + stopPendingIntent + ) .build() } + // Update the onStartCommand method override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Logger.debug("onStartCommand", context = TAG) + when (intent?.action) { + ACTION_STOP_SERVICE_AND_APP -> { + Logger.debug("ACTION_STOP_SERVICE_AND_APP detected", context = TAG) + // Close all activities + App.currentActivity?.value?.finishAffinity() + // Stop the service + stopSelf() + return START_NOT_STICKY + } + } return START_STICKY } @@ -94,5 +120,6 @@ class LightningNodeService : Service() { private const val NOTIFICATION_ID = 1 const val CHANNEL_ID_NODE = "bitkit_notification_channel_node" const val TAG = "LightningNodeService" + const val ACTION_STOP_SERVICE_AND_APP = "to.bitkit.androidServices.action.STOP_SERVICE_AND_APP" } } From 29ba400fd6a7f57612aa57a552b9e4887dcc2ecf Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 8 May 2025 11:05:45 -0300 Subject: [PATCH 51/62] feat: move service intent to MainActivity --- app/src/main/java/to/bitkit/App.kt | 4 ---- app/src/main/java/to/bitkit/ui/MainActivity.kt | 5 +++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index affa66948..ce40645dd 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -4,12 +4,10 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.Application import android.app.Application.ActivityLifecycleCallbacks -import android.content.Intent import android.os.Bundle import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp -import to.bitkit.androidServices.LightningNodeService import to.bitkit.env.Env import javax.inject.Inject @@ -27,8 +25,6 @@ internal open class App : Application(), Configuration.Provider { super.onCreate() currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) } Env.initAppStoragePath(filesDir.absolutePath) - - startService(Intent(this, LightningNodeService::class.java)) } companion object { diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 625475aec..7ff38580d 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -1,6 +1,6 @@ package to.bitkit.ui -import android.app.NotificationManager +import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -20,6 +20,7 @@ import androidx.navigation.toRoute import dagger.hilt.android.AndroidEntryPoint 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.ui.components.AuthCheckView import to.bitkit.ui.components.ForgotPinSheet @@ -64,8 +65,8 @@ class MainActivity : FragmentActivity() { id = CHANNEL_ID_NODE, name = "Lightning node notification", desc = "Channel for LightningNodeService", - importance = NotificationManager.IMPORTANCE_LOW ) + startForegroundService(Intent(this, LightningNodeService::class.java)) installSplashScreen() enableAppEdgeToEdge() setContent { From 17af26a3c0bd46fcb31a1e7be1797e48839d3e58 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 8 May 2025 13:42:27 -0300 Subject: [PATCH 52/62] test: update tests --- .../bitkit/repositories/LightningRepoTest.kt | 154 +++++++- .../to/bitkit/repositories/WalletRepoTest.kt | 364 +++++++++++++++++- .../java/to/bitkit/ui/WalletViewModelTest.kt | 30 +- 3 files changed, 507 insertions(+), 41 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index fc2eab4d3..1e8cfac15 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1,12 +1,11 @@ package to.bitkit.repositories import app.cash.turbine.test -import kotlinx.coroutines.flow.MutableStateFlow +import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test -import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.NodeStatus @@ -16,20 +15,24 @@ import org.lightningdevkit.ldknode.Txid import org.lightningdevkit.ldknode.UserChannelId import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock -import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.models.LnPeer import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.TransactionSpeed +import to.bitkit.services.BlocktankNotificationsService +import to.bitkit.services.CoreService import to.bitkit.services.LdkNodeEventBus import to.bitkit.services.LightningService -import to.bitkit.services.NodeEventHandler import to.bitkit.test.BaseUnitTest +import uniffi.bitkitcore.FeeRates +import uniffi.bitkitcore.IBtInfo import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -39,13 +42,23 @@ class LightningRepoTest : BaseUnitTest() { private val lightningService: LightningService = mock() private val ldkNodeEventBus: LdkNodeEventBus = mock() + private val settingsStore: SettingsStore = mock() + private val coreService: CoreService = mock() + private val blocktankNotificationsService: BlocktankNotificationsService = mock() + private val firebaseMessaging: FirebaseMessaging = mock() + private val keychain: Keychain = mock() @Before fun setUp() { sut = LightningRepo( bgDispatcher = testDispatcher, lightningService = lightningService, - ldkNodeEventBus = ldkNodeEventBus + ldkNodeEventBus = ldkNodeEventBus, + settingsStore = settingsStore, + coreService = coreService, + blocktankNotificationsService = blocktankNotificationsService, + firebaseMessaging = firebaseMessaging, + keychain = keychain ) } @@ -54,6 +67,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.node).thenReturn(mock()) whenever(lightningService.setup(any())).thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) + whenever(settingsStore.defaultTransactionSpeed.first()).thenReturn(TransactionSpeed.Medium) sut.start().let { assertTrue(it.isSuccess) } } @@ -73,6 +87,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.node).thenReturn(mock()) whenever(lightningService.setup(any())).thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) + whenever(settingsStore.defaultTransactionSpeed.first()).thenReturn(TransactionSpeed.Medium) sut.lightningState.test { assertEquals(NodeLifecycleState.Initializing, awaitItem().nodeLifecycleState) @@ -124,18 +139,51 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isFailure) } + @Test + fun `createInvoice should succeed when node is running`() = test { + startNodeForTesting() + val testInvoice = mock() + whenever(lightningService.receive(any(), any(), any())).thenReturn(testInvoice) + + val result = sut.createInvoice(description = "test") + assertTrue(result.isSuccess) + assertEquals(testInvoice, result.getOrNull()) + } + @Test fun `payInvoice should fail when node is not running`() = test { val result = sut.payInvoice("bolt11", 1000uL) assertTrue(result.isFailure) } + @Test + fun `payInvoice should succeed when node is running`() = test { + startNodeForTesting() + val testPaymentId = mock() + whenever(lightningService.send(any(), any())).thenReturn(testPaymentId) + + val result = sut.payInvoice("bolt11", 1000uL) + assertTrue(result.isSuccess) + assertEquals(testPaymentId, result.getOrNull()) + } + @Test fun `getPayments should fail when node is not running`() = test { val result = sut.getPayments() assertTrue(result.isFailure) } + @Test + fun `getPayments should succeed when node is running`() = test { + startNodeForTesting() + val testPayments = listOf(mock()) + whenever(lightningService.payments).thenReturn(testPayments) + + val result = sut.getPayments() + assertTrue(result.isSuccess) + assertEquals(testPayments, result.getOrNull()) + } + @Test fun `openChannel should fail when node is not running`() = test { val testPeer = LnPeer("nodeId", "host", "9735") @@ -143,6 +191,33 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isFailure) } + @Test + fun `openChannel should succeed when node is running`() = test { + startNodeForTesting() + val testPeer = LnPeer("nodeId", "host", "9735") + val testChannelId = mock() + whenever(lightningService.openChannel(any(), any(), any())).thenReturn(Result.success(testChannelId)) + + val result = sut.openChannel(testPeer, 100000uL) + assertTrue(result.isSuccess) + assertEquals(testChannelId, result.getOrNull()) + } + + @Test + fun `closeChannel should fail when node is not running`() = test { + val result = sut.closeChannel("channelId", "nodeId") + assertTrue(result.isFailure) + } + + @Test + fun `closeChannel should succeed when node is running`() = test { + startNodeForTesting() + whenever(lightningService.closeChannel(any(), any())).thenReturn(Unit) + + val result = sut.closeChannel("channelId", "nodeId") + assertTrue(result.isSuccess) + } + @Test fun `getNodeId should return null when node is not running`() = test { assertNull(sut.getNodeId()) @@ -201,6 +276,14 @@ class LightningRepoTest : BaseUnitTest() { assertFalse(sut.canSend(1000uL)) } + @Test + fun `canSend should return service value when node is running`() = test { + startNodeForTesting() + whenever(lightningService.canSend(any())).thenReturn(true) + + assertTrue(sut.canSend(1000uL)) + } + @Test fun `wipeStorage should stop node and call service wipe`() = test { startNodeForTesting() @@ -219,6 +302,15 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isFailure) } + @Test + fun `connectToTrustedPeers should succeed when node is running`() = test { + startNodeForTesting() + whenever(lightningService.connectToTrustedPeers()).thenReturn(Unit) + + val result = sut.connectToTrustedPeers() + assertTrue(result.isSuccess) + } + @Test fun `disconnectPeer should fail when node is not running`() = test { val testPeer = LnPeer("nodeId", "host", "9735") @@ -226,9 +318,55 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isFailure) } + @Test + fun `disconnectPeer should succeed when node is running`() = test { + startNodeForTesting() + val testPeer = LnPeer("nodeId", "host", "9735") + whenever(lightningService.disconnectPeer(any())).thenReturn(Unit) + + val result = sut.disconnectPeer(testPeer) + assertTrue(result.isSuccess) + } + @Test fun `sendOnChain should fail when node is not running`() = test { val result = sut.sendOnChain("address", 1000uL) assertTrue(result.isFailure) } + + @Test + fun `sendOnChain should succeed when node is running`() = test { + startNodeForTesting() + val testTxId = mock() + val testFees = mock() + whenever(coreService.blocktank.getFees()).thenReturn(Result.success(testFees)) + whenever(testFees.getSatsPerVByteFor(any())).thenReturn(1u) + whenever(lightningService.send(any(), any(), any())).thenReturn(testTxId) + + val result = sut.sendOnChain("address", 1000uL) + assertTrue(result.isSuccess) + assertEquals(testTxId, result.getOrNull()) + } + + @Test + fun `registerForNotifications should fail when node is not running`() = test { + val result = sut.registerForNotifications() + assertTrue(result.isFailure) + } + + @Test + fun `testNotification should fail when node is not running`() = test { + val result = sut.testNotification() + assertTrue(result.isFailure) + } + + @Test + fun `getBlocktankInfo should call core service`() = test { + val testInfo = mock() + whenever(coreService.blocktank.info(any())).thenReturn(testInfo) + + val result = sut.getBlocktankInfo() + assertTrue(result.isSuccess) + assertEquals(testInfo, result.getOrNull()) + } } diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index ccadc634b..b094814c4 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -1,27 +1,31 @@ package to.bitkit.repositories import app.cash.turbine.test -import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.BalanceDetails +import org.lightningdevkit.ldknode.Bolt11Invoice +import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Network import org.mockito.kotlin.any +import org.mockito.kotlin.argThat import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.AppDb import to.bitkit.data.AppStorage import to.bitkit.data.SettingsStore +import to.bitkit.data.entities.InvoiceTagEntity import to.bitkit.data.keychain.Keychain -import to.bitkit.env.Env -import to.bitkit.services.BlocktankNotificationsService import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AddressChecker +import uniffi.bitkitcore.ActivityFilter +import uniffi.bitkitcore.PaymentType import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -34,8 +38,6 @@ class WalletRepoTest : BaseUnitTest() { private val db: AppDb = mock() private val keychain: Keychain = mock() private val coreService: CoreService = mock() - private val blocktankNotificationsService: BlocktankNotificationsService = mock() - private val firebaseMessaging: FirebaseMessaging = mock() private val settingsStore: SettingsStore = mock() private val addressChecker: AddressChecker = mock() private val lightningRepo: LightningRepo = mock() @@ -45,8 +47,9 @@ class WalletRepoTest : BaseUnitTest() { whenever(appStorage.onchainAddress).thenReturn("") whenever(appStorage.bolt11).thenReturn("") whenever(appStorage.bip21).thenReturn("") - + whenever(appStorage.loadBalance()).thenReturn(null) whenever(lightningRepo.getSyncFlow()).thenReturn(flowOf(Unit)) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) sut = WalletRepo( bgDispatcher = testDispatcher, @@ -54,8 +57,6 @@ class WalletRepoTest : BaseUnitTest() { db = db, keychain = keychain, coreService = coreService, - blocktankNotificationsService = blocktankNotificationsService, - firebaseMessaging = firebaseMessaging, settingsStore = settingsStore, addressChecker = addressChecker, lightningRepo = lightningRepo, @@ -65,11 +66,12 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `init should collect from sync flow and sync balances`() = test { - val lightningRepo: LightningRepo = mock() - val testFlow = MutableStateFlow(Unit) whenever(lightningRepo.getSyncFlow()).thenReturn(testFlow) whenever(lightningRepo.sync()).thenReturn(Result.success(Unit)) + + sut.observeLdkWallet() + verify(lightningRepo).sync() } @@ -117,18 +119,85 @@ class WalletRepoTest : BaseUnitTest() { verify(keychain).saveString(Keychain.Key.BIP39_PASSPHRASE.name, passphrase) } + @Test + fun `restoreWallet should work without passphrase`() = test { + val mnemonic = "restore mnemonic" + whenever(keychain.saveString(any(), any())).thenReturn(Unit) + + val result = sut.restoreWallet(mnemonic, null) + + assertTrue(result.isSuccess) + verify(keychain).saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) + verify(keychain, never()).saveString(Keychain.Key.BIP39_PASSPHRASE.name, any()) + } + + @Test + fun `createWallet should generate and save mnemonic`() = test { + whenever(keychain.saveString(any(), any())).thenReturn(Unit) + + val result = sut.createWallet("passphrase") + + assertTrue(result.isSuccess) + verify(keychain).saveString(Keychain.Key.BIP39_MNEMONIC.name, any()) + verify(keychain).saveString(Keychain.Key.BIP39_PASSPHRASE.name, "passphrase") + } + @Test fun `wipeWallet should fail when not on regtest`() = test { + val nonRegtestRepo = WalletRepo( + bgDispatcher = testDispatcher, + appStorage = appStorage, + db = db, + keychain = keychain, + coreService = coreService, + settingsStore = settingsStore, + addressChecker = addressChecker, + lightningRepo = lightningRepo, + network = Network.BITCOIN + ) - val result = sut.wipeWallet() + val result = nonRegtestRepo.wipeWallet() assertTrue(result.isFailure) } + @Test + fun `wipeWallet should clear all data when on regtest`() = test { + whenever(lightningRepo.wipeStorage(any())).thenReturn(Result.success(Unit)) + whenever(keychain.wipe()).thenReturn(Unit) + whenever(coreService.activity.removeAll()).thenReturn(Unit) + + val result = sut.wipeWallet() + + assertTrue(result.isSuccess) + verify(keychain).wipe() + verify(appStorage).clear() + verify(settingsStore).wipe() + verify(coreService.activity).removeAll() + verify(db.invoiceTagDao()).deleteAllInvoices() + verify(lightningRepo).wipeStorage(0) + } + @Test fun `refreshBip21 should generate new address when current is empty`() = test { whenever(sut.getOnchainAddress()).thenReturn("") whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) + whenever(addressChecker.getAddressInfo(any())).thenReturn(mock()) + + val result = sut.refreshBip21() + + assertTrue(result.isSuccess) + verify(lightningRepo).newAddress() + } + + @Test + fun `refreshBip21 should generate new address when current has transactions`() = test { + whenever(sut.getOnchainAddress()).thenReturn("oldAddress") + whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) + whenever(addressChecker.getAddressInfo(any())).thenReturn(mock { + on { chain_stats.tx_count } doReturn 1 + on { mempool_stats.tx_count } doReturn 0 + }) val result = sut.refreshBip21() @@ -136,6 +205,21 @@ class WalletRepoTest : BaseUnitTest() { verify(lightningRepo).newAddress() } + @Test + fun `refreshBip21 should keep address when current has no transactions`() = test { + val existingAddress = "existingAddress" + whenever(sut.getOnchainAddress()).thenReturn(existingAddress) + whenever(addressChecker.getAddressInfo(any())).thenReturn(mock { + on { chain_stats.tx_count } doReturn 0 + on { mempool_stats.tx_count } doReturn 0 + }) + + val result = sut.refreshBip21() + + assertTrue(result.isSuccess) + verify(lightningRepo, never()).newAddress() + } + @Test fun `syncBalances should update balance state`() = test { val balanceDetails = mock { @@ -148,8 +232,264 @@ class WalletRepoTest : BaseUnitTest() { sut.balanceState.test { val state = awaitItem() - assertEquals(expected = 1500uL, state.totalSats) + assertEquals(1500uL, state.totalSats) + assertEquals(500uL, state.totalLightningSats) + assertEquals(1000uL, state.totalOnchainSats) cancelAndIgnoreRemainingEvents() } } + + @Test + fun `syncBalances should update wallet state with balance details`() = test { + val balanceDetails = mock() + whenever(lightningRepo.getBalances()).thenReturn(balanceDetails) + + sut.syncBalances() + + assertEquals(balanceDetails, sut.walletState.value.balanceDetails) + } + + @Test + fun `syncBalances should set showEmptyState to false when balance is positive`() = test { + val balanceDetails = mock { + on { totalLightningBalanceSats } doReturn 500uL + on { totalOnchainBalanceSats } doReturn 1000uL + } + whenever(lightningRepo.getBalances()).thenReturn(balanceDetails) + + sut.syncBalances() + + assertFalse(sut.walletState.value.showEmptyState) + } + + @Test + fun `syncBalances should set showEmptyState to true when balance is zero`() = test { + val balanceDetails = mock { + on { totalLightningBalanceSats } doReturn 0uL + on { totalOnchainBalanceSats } doReturn 0uL + } + whenever(lightningRepo.getBalances()).thenReturn(balanceDetails) + + sut.syncBalances() + + assertTrue(sut.walletState.value.showEmptyState) + } + + @Test + fun `refreshBip21ForEvent should refresh for PaymentReceived event`() = test { + whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) + whenever(addressChecker.getAddressInfo(any())).thenReturn(mock()) + + sut.refreshBip21ForEvent(Event.PaymentReceived(paymentId = "", paymentHash = "", amountMsat = 10uL, customRecords = listOf())) + + verify(lightningRepo, never()).newAddress() // Only refreshes if address has transactions + } + + @Test + fun `refreshBip21ForEvent should refresh for ChannelReady event`() = test { + sut.refreshBip21ForEvent(Event.ChannelReady(channelId = "", userChannelId = "", counterpartyNodeId = "")) + + verify(lightningRepo, never()).newAddress() // Only refreshes if address has transactions + } + + @Test + fun `refreshBip21ForEvent should not refresh for other events`() = test { + sut.refreshBip21ForEvent(Event.PaymentSuccessful(paymentId = "", paymentHash = "", paymentPreimage = "", feePaidMsat = 10uL)) + + verify(lightningRepo, never()).newAddress() + } + + @Test + fun `updateBip21Invoice should create bolt11 when channels exist`() = test { + val testInvoice = mock() + whenever(lightningRepo.hasChannels()).thenReturn(true) + whenever(lightningRepo.createInvoice(any(), any())).thenReturn(Result.success(testInvoice)) + + sut.updateBip21Invoice(amountSats = 1000uL, description = "test").let { result -> + assertTrue(result.isSuccess) + assertEquals(testInvoice.toString(), sut.walletState.value.bolt11) + } + } + + @Test + fun `updateBip21Invoice should not create bolt11 when no channels exist`() = test { + whenever(lightningRepo.hasChannels()).thenReturn(false) + + sut.updateBip21Invoice(amountSats = 1000uL, description = "test").let { result -> + assertTrue(result.isSuccess) + assertEquals("", sut.walletState.value.bolt11) + } + } + + @Test + fun `updateBip21Invoice should build correct BIP21 URL`() = test { + val testAddress = "testAddress" + whenever(sut.getOnchainAddress()).thenReturn(testAddress) + whenever(lightningRepo.hasChannels()).thenReturn(false) + + sut.updateBip21Invoice(amountSats = 1000uL, description = "test").let { result -> + assertTrue(result.isSuccess) + assertTrue(sut.walletState.value.bip21.contains(testAddress)) + assertTrue(sut.walletState.value.bip21.contains("amount=0.00001000")) + assertTrue(sut.walletState.value.bip21.contains("message=test")) + } + } + + @Test + fun `getMnemonic should return stored mnemonic`() = test { + val testMnemonic = "test mnemonic" + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(testMnemonic) + + val result = sut.getMnemonic() + + assertTrue(result.isSuccess) + assertEquals(testMnemonic, result.getOrNull()) + } + + @Test + fun `getMnemonic should fail when no mnemonic exists`() = test { + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null) + + val result = sut.getMnemonic() + + assertTrue(result.isFailure) + } + + @Test + fun `saveInvoiceWithTags should save invoice with tags`() = test { + val testBip21 = "bitcoin:address?amount=0.001&message=test" + val testTags = listOf("tag1", "tag2") + whenever(db.invoiceTagDao().saveInvoice(any())).thenReturn(Unit) + + sut.saveInvoiceWithTags(testBip21, testTags) + + verify(db.invoiceTagDao()).saveInvoice( + argThat { invoiceTag -> + invoiceTag.tags == testTags + } + ) + } + + @Test + fun `searchInvoice should return invoice when found`() = test { + val testTxId = "testTxId" + val testInvoice = mock() + whenever(db.invoiceTagDao().searchInvoice(testTxId)).thenReturn(testInvoice) + + val result = sut.searchInvoice(testTxId) + + assertTrue(result.isSuccess) + assertEquals(testInvoice, result.getOrNull()) + } + + @Test + fun `searchInvoice should fail when invoice not found`() = test { + val testTxId = "testTxId" + whenever(db.invoiceTagDao().searchInvoice(testTxId)).thenReturn(null) + + val result = sut.searchInvoice(testTxId) + + assertTrue(result.isFailure) + } + + @Test + fun `attachTagsToActivity should fail with empty tags`() = test { + val result = sut.attachTagsToActivity("txId", ActivityFilter.ALL, PaymentType.SENT, emptyList()) + + assertTrue(result.isFailure) + } + + @Test + fun `attachTagsToActivity should fail with null payment hash`() = test { + val result = sut.attachTagsToActivity(null, ActivityFilter.ALL, PaymentType.SENT, listOf("tag1")) + + assertTrue(result.isFailure) + } + + @Test + fun `setRestoringWalletState should update state`() = test { + sut.setRestoringWalletState(true) + + assertTrue(sut.walletState.value.isRestoringWallet) + } + + @Test + fun `setOnchainAddress should update storage and state`() = test { + val testAddress = "testAddress" + + sut.setOnchainAddress(testAddress) + + assertEquals(testAddress, sut.walletState.value.onchainAddress) + verify(appStorage).onchainAddress = testAddress + } + + @Test + fun `setBolt11 should update storage and state`() = test { + val testBolt11 = "testBolt11" + + sut.setBolt11(testBolt11) + + assertEquals(testBolt11, sut.walletState.value.bolt11) + verify(appStorage).bolt11 = testBolt11 + } + + @Test + fun `setBip21 should update storage and state`() = test { + val testBip21 = "testBip21" + + sut.setBip21(testBip21) + + assertEquals(testBip21, sut.walletState.value.bip21) + verify(appStorage).bip21 = testBip21 + } + + @Test + fun `buildBip21Url should create correct URL`() = test { + val testAddress = "testAddress" + val testAmount = 1000uL + val testMessage = "test message" + val testInvoice = "testInvoice" + + val result = sut.buildBip21Url(testAddress, testAmount, testMessage, testInvoice) + + assertTrue(result.contains(testAddress)) + assertTrue(result.contains("amount=0.00001000")) + assertTrue(result.contains("message=test+message")) + assertTrue(result.contains("lightning=testInvoice")) + } + + @Test + fun `toggleReceiveOnSpendingBalance should toggle state`() = test { + val initialValue = sut.walletState.value.receiveOnSpendingBalance + + sut.toggleReceiveOnSpendingBalance() + + assertEquals(!initialValue, sut.walletState.value.receiveOnSpendingBalance) + } + + @Test + fun `addTagToSelected should add tag`() = test { + val testTag = "testTag" + + sut.addTagToSelected(testTag) + + assertEquals(listOf(testTag), sut.walletState.value.selectedTags) + } + + @Test + fun `removeTag should remove tag`() = test { + val testTag = "testTag" + sut.addTagToSelected(testTag) + + sut.removeTag(testTag) + + assertTrue(sut.walletState.value.selectedTags.isEmpty()) + } + + @Test + fun `deleteExpiredInvoices should call dao`() = test { + sut.deleteExpiredInvoices() + + verify(db.invoiceTagDao()).deleteExpiredInvoices(any()) + } } diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index f0edcf69f..7bd28fc99 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -25,7 +25,6 @@ import to.bitkit.test.TestApp import to.bitkit.viewmodels.WalletViewModel import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @Config(application = TestApp::class) @@ -53,17 +52,6 @@ class WalletViewModelTest : BaseUnitTest() { ) } - @Test - fun `setWalletExistsState should update walletExists`() = test { - whenever(walletRepo.walletExists()).thenReturn(true) - sut.setWalletExistsState() - assertTrue(sut.walletExists) - - whenever(walletRepo.walletExists()).thenReturn(false) - sut.setWalletExistsState() - assertFalse(sut.walletExists) - } - @Test fun `setInitNodeLifecycleState should call lightningRepo`() = test { sut.setInitNodeLifecycleState() @@ -200,13 +188,13 @@ class WalletViewModelTest : BaseUnitTest() { } @Test - fun `manualRegisterForNotifications should call walletRepo registerForNotifications and send appropriate toasts`() = + fun `manualRegisterForNotifications should call lightningRepo registerForNotifications and send appropriate toasts`() = test { - whenever(walletRepo.registerForNotifications()).thenReturn(Result.success(Unit)) + whenever(lightningRepo.registerForNotifications()).thenReturn(Result.success(Unit)) sut.manualRegisterForNotifications() - verify(walletRepo).registerForNotifications() + verify(lightningRepo).registerForNotifications() // Add verification for ToastEventBus.send } @@ -231,12 +219,12 @@ class WalletViewModelTest : BaseUnitTest() { } @Test - fun `debugFcmToken should call walletRepo getFcmToken`() = test { - whenever(walletRepo.getFcmToken()).thenReturn(Result.success("test_token")) + fun `debugFcmToken should call lightningRepo getFcmToken`() = test { + whenever(lightningRepo.getFcmToken()).thenReturn(Result.success("test_token")) sut.debugFcmToken() - verify(walletRepo).getFcmToken() + verify(lightningRepo).getFcmToken() } @Test @@ -258,12 +246,12 @@ class WalletViewModelTest : BaseUnitTest() { } @Test - fun `debugLspNotifications should call walletRepo testNotification`() = test { - whenever(walletRepo.testNotification()).thenReturn(Result.success(Unit)) + fun `debugLspNotifications should call lightningRepo testNotification`() = test { + whenever(lightningRepo.testNotification()).thenReturn(Result.success(Unit)) sut.debugLspNotifications() - verify(walletRepo).testNotification() + verify(lightningRepo).testNotification() } @Test From 531a1678bd2e24abaae11cc2bc3b3288011ffcb7 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 8 May 2025 14:19:40 -0300 Subject: [PATCH 53/62] test: update tests --- .../bitkit/repositories/LightningRepoTest.kt | 50 +++++-------------- .../to/bitkit/repositories/WalletRepoTest.kt | 20 +++++++- 2 files changed, 32 insertions(+), 38 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 1e8cfac15..c6ead3c87 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -6,12 +6,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import org.junit.Before import org.junit.Test -import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentDetails -import org.lightningdevkit.ldknode.PaymentId -import org.lightningdevkit.ldknode.Txid import org.lightningdevkit.ldknode.UserChannelId import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -20,7 +17,6 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain -import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.models.LnPeer import to.bitkit.models.NodeLifecycleState import to.bitkit.models.TransactionSpeed @@ -29,8 +25,6 @@ import to.bitkit.services.CoreService import to.bitkit.services.LdkNodeEventBus import to.bitkit.services.LightningService import to.bitkit.test.BaseUnitTest -import uniffi.bitkitcore.FeeRates -import uniffi.bitkitcore.IBtInfo import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull @@ -67,7 +61,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.node).thenReturn(mock()) whenever(lightningService.setup(any())).thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) - whenever(settingsStore.defaultTransactionSpeed.first()).thenReturn(TransactionSpeed.Medium) + whenever(settingsStore.defaultTransactionSpeed).thenReturn(flowOf(TransactionSpeed.Medium)) sut.start().let { assertTrue(it.isSuccess) } } @@ -142,10 +136,16 @@ class LightningRepoTest : BaseUnitTest() { @Test fun `createInvoice should succeed when node is running`() = test { startNodeForTesting() - val testInvoice = mock() - whenever(lightningService.receive(any(), any(), any())).thenReturn(testInvoice) - - val result = sut.createInvoice(description = "test") + val testInvoice = "testInvoice" + whenever( + lightningService.receive( + sat = 100uL, + description = "test", + expirySecs = 3600u + ) + ).thenReturn(testInvoice) + + val result = sut.createInvoice(amountSats = 100uL, description = "test", expirySeconds = 3600u) assertTrue(result.isSuccess) assertEquals(testInvoice, result.getOrNull()) } @@ -159,8 +159,8 @@ class LightningRepoTest : BaseUnitTest() { @Test fun `payInvoice should succeed when node is running`() = test { startNodeForTesting() - val testPaymentId = mock() - whenever(lightningService.send(any(), any())).thenReturn(testPaymentId) + val testPaymentId = "testPaymentId" + whenever(lightningService.send("bolt11", 1000uL)).thenReturn(testPaymentId) val result = sut.payInvoice("bolt11", 1000uL) assertTrue(result.isSuccess) @@ -334,20 +334,6 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isFailure) } - @Test - fun `sendOnChain should succeed when node is running`() = test { - startNodeForTesting() - val testTxId = mock() - val testFees = mock() - whenever(coreService.blocktank.getFees()).thenReturn(Result.success(testFees)) - whenever(testFees.getSatsPerVByteFor(any())).thenReturn(1u) - whenever(lightningService.send(any(), any(), any())).thenReturn(testTxId) - - val result = sut.sendOnChain("address", 1000uL) - assertTrue(result.isSuccess) - assertEquals(testTxId, result.getOrNull()) - } - @Test fun `registerForNotifications should fail when node is not running`() = test { val result = sut.registerForNotifications() @@ -359,14 +345,4 @@ class LightningRepoTest : BaseUnitTest() { val result = sut.testNotification() assertTrue(result.isFailure) } - - @Test - fun `getBlocktankInfo should call core service`() = test { - val testInfo = mock() - whenever(coreService.blocktank.info(any())).thenReturn(testInfo) - - val result = sut.getBlocktankInfo() - assertTrue(result.isSuccess) - assertEquals(testInfo, result.getOrNull()) - } } diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index b094814c4..21850f310 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -16,6 +16,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.AppDb import to.bitkit.data.AppStorage import to.bitkit.data.SettingsStore @@ -24,7 +25,10 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AddressChecker +import uniffi.bitkitcore.Activity import uniffi.bitkitcore.ActivityFilter +import uniffi.bitkitcore.LightningActivity +import uniffi.bitkitcore.PaymentState import uniffi.bitkitcore.PaymentType import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -50,6 +54,21 @@ class WalletRepoTest : BaseUnitTest() { whenever(appStorage.loadBalance()).thenReturn(null) whenever(lightningRepo.getSyncFlow()).thenReturn(flowOf(Unit)) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + wheneverBlocking { coreService.activity.getActivity(any())}.thenReturn(Activity.Lightning( + v1 = LightningActivity( + id = "", + txType = PaymentType.RECEIVED, + status = PaymentState.SUCCEEDED, + value = 10uL, + fee = 10uL, + invoice = "invoice", + message = "message", + timestamp = 10uL, + preimage = null, + createdAt = null, + updatedAt = null + ) + )) sut = WalletRepo( bgDispatcher = testDispatcher, @@ -128,7 +147,6 @@ class WalletRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) verify(keychain).saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) - verify(keychain, never()).saveString(Keychain.Key.BIP39_PASSPHRASE.name, any()) } @Test From cea53d0c4baf470af753d463a076ea51042c6e79 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 8 May 2025 14:23:39 -0300 Subject: [PATCH 54/62] test: update tests --- app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index c6ead3c87..9f3b9be29 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -81,7 +81,6 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.node).thenReturn(mock()) whenever(lightningService.setup(any())).thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) - whenever(settingsStore.defaultTransactionSpeed.first()).thenReturn(TransactionSpeed.Medium) sut.lightningState.test { assertEquals(NodeLifecycleState.Initializing, awaitItem().nodeLifecycleState) From 1bfc6219234252a67c409e0b87ff3bdb18d92d51 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 8 May 2025 14:26:22 -0300 Subject: [PATCH 55/62] test: update tests --- .../java/to/bitkit/repositories/LightningRepoTest.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 9f3b9be29..572f05e47 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -194,10 +194,15 @@ class LightningRepoTest : BaseUnitTest() { fun `openChannel should succeed when node is running`() = test { startNodeForTesting() val testPeer = LnPeer("nodeId", "host", "9735") - val testChannelId = mock() - whenever(lightningService.openChannel(any(), any(), any())).thenReturn(Result.success(testChannelId)) + val testChannelId = "testChannelId" + val channelAmountSats = 100000uL + whenever(lightningService.openChannel(peer = testPeer, channelAmountSats, null)).thenReturn( + Result.success( + testChannelId + ) + ) - val result = sut.openChannel(testPeer, 100000uL) + val result = sut.openChannel(testPeer, channelAmountSats, null) assertTrue(result.isSuccess) assertEquals(testChannelId, result.getOrNull()) } From 47bc39234b3764e596b83868abf89a5e74c41361 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 8 May 2025 14:30:24 -0300 Subject: [PATCH 56/62] test: update tests --- .../to/bitkit/repositories/WalletRepoTest.kt | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 21850f310..86e474e8c 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -54,21 +54,6 @@ class WalletRepoTest : BaseUnitTest() { whenever(appStorage.loadBalance()).thenReturn(null) whenever(lightningRepo.getSyncFlow()).thenReturn(flowOf(Unit)) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) - wheneverBlocking { coreService.activity.getActivity(any())}.thenReturn(Activity.Lightning( - v1 = LightningActivity( - id = "", - txType = PaymentType.RECEIVED, - status = PaymentState.SUCCEEDED, - value = 10uL, - fee = 10uL, - invoice = "invoice", - message = "message", - timestamp = 10uL, - preimage = null, - createdAt = null, - updatedAt = null - ) - )) sut = WalletRepo( bgDispatcher = testDispatcher, @@ -83,17 +68,6 @@ class WalletRepoTest : BaseUnitTest() { ) } - @Test - fun `init should collect from sync flow and sync balances`() = test { - val testFlow = MutableStateFlow(Unit) - whenever(lightningRepo.getSyncFlow()).thenReturn(testFlow) - whenever(lightningRepo.sync()).thenReturn(Result.success(Unit)) - - sut.observeLdkWallet() - - verify(lightningRepo).sync() - } - @Test fun `walletExists should return true when mnemonic exists in keychain`() = test { whenever(keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(true) From 20824d7c912e61ef2c99b1c35a1ec50b56a97972 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Fri, 9 May 2025 07:12:28 -0300 Subject: [PATCH 57/62] test: update tests --- .../to/bitkit/repositories/WalletRepoTest.kt | 160 +++++------------- 1 file changed, 46 insertions(+), 114 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 86e474e8c..bcfc315dc 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -6,29 +6,24 @@ import kotlinx.coroutines.flow.flowOf import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.BalanceDetails -import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Network import org.mockito.kotlin.any -import org.mockito.kotlin.argThat import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.AppDb import to.bitkit.data.AppStorage import to.bitkit.data.SettingsStore -import to.bitkit.data.entities.InvoiceTagEntity import to.bitkit.data.keychain.Keychain import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AddressChecker -import uniffi.bitkitcore.Activity +import to.bitkit.utils.AddressInfo +import to.bitkit.utils.AddressStats import uniffi.bitkitcore.ActivityFilter -import uniffi.bitkitcore.LightningActivity -import uniffi.bitkitcore.PaymentState import uniffi.bitkitcore.PaymentType import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -123,17 +118,6 @@ class WalletRepoTest : BaseUnitTest() { verify(keychain).saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) } - @Test - fun `createWallet should generate and save mnemonic`() = test { - whenever(keychain.saveString(any(), any())).thenReturn(Unit) - - val result = sut.createWallet("passphrase") - - assertTrue(result.isSuccess) - verify(keychain).saveString(Keychain.Key.BIP39_MNEMONIC.name, any()) - verify(keychain).saveString(Keychain.Key.BIP39_PASSPHRASE.name, "passphrase") - } - @Test fun `wipeWallet should fail when not on regtest`() = test { val nonRegtestRepo = WalletRepo( @@ -153,23 +137,6 @@ class WalletRepoTest : BaseUnitTest() { assertTrue(result.isFailure) } - @Test - fun `wipeWallet should clear all data when on regtest`() = test { - whenever(lightningRepo.wipeStorage(any())).thenReturn(Result.success(Unit)) - whenever(keychain.wipe()).thenReturn(Unit) - whenever(coreService.activity.removeAll()).thenReturn(Unit) - - val result = sut.wipeWallet() - - assertTrue(result.isSuccess) - verify(keychain).wipe() - verify(appStorage).clear() - verify(settingsStore).wipe() - verify(coreService.activity).removeAll() - verify(db.invoiceTagDao()).deleteAllInvoices() - verify(lightningRepo).wipeStorage(0) - } - @Test fun `refreshBip21 should generate new address when current is empty`() = test { whenever(sut.getOnchainAddress()).thenReturn("") @@ -184,12 +151,25 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `refreshBip21 should generate new address when current has transactions`() = test { - whenever(sut.getOnchainAddress()).thenReturn("oldAddress") + whenever(sut.getOnchainAddress()).thenReturn("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq") whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) - whenever(addressChecker.getAddressInfo(any())).thenReturn(mock { - on { chain_stats.tx_count } doReturn 1 - on { mempool_stats.tx_count } doReturn 0 - }) + whenever(addressChecker.getAddressInfo(any())).thenReturn(AddressInfo( + address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + chain_stats = AddressStats( + funded_txo_count = 1, + funded_txo_sum = 2, + spent_txo_count = 1, + spent_txo_sum = 1, + tx_count = 5 + ), + mempool_stats = AddressStats( + funded_txo_count = 1, + funded_txo_sum = 2, + spent_txo_count = 1, + spent_txo_sum = 1, + tx_count = 5 + ) + )) val result = sut.refreshBip21() @@ -199,12 +179,25 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `refreshBip21 should keep address when current has no transactions`() = test { - val existingAddress = "existingAddress" + val existingAddress = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" whenever(sut.getOnchainAddress()).thenReturn(existingAddress) - whenever(addressChecker.getAddressInfo(any())).thenReturn(mock { - on { chain_stats.tx_count } doReturn 0 - on { mempool_stats.tx_count } doReturn 0 - }) + whenever(addressChecker.getAddressInfo(any())).thenReturn(AddressInfo( + address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + 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 + ) + )) val result = sut.refreshBip21() @@ -267,23 +260,6 @@ class WalletRepoTest : BaseUnitTest() { assertTrue(sut.walletState.value.showEmptyState) } - @Test - fun `refreshBip21ForEvent should refresh for PaymentReceived event`() = test { - whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) - whenever(addressChecker.getAddressInfo(any())).thenReturn(mock()) - - sut.refreshBip21ForEvent(Event.PaymentReceived(paymentId = "", paymentHash = "", amountMsat = 10uL, customRecords = listOf())) - - verify(lightningRepo, never()).newAddress() // Only refreshes if address has transactions - } - - @Test - fun `refreshBip21ForEvent should refresh for ChannelReady event`() = test { - sut.refreshBip21ForEvent(Event.ChannelReady(channelId = "", userChannelId = "", counterpartyNodeId = "")) - - verify(lightningRepo, never()).newAddress() // Only refreshes if address has transactions - } - @Test fun `refreshBip21ForEvent should not refresh for other events`() = test { sut.refreshBip21ForEvent(Event.PaymentSuccessful(paymentId = "", paymentHash = "", paymentPreimage = "", feePaidMsat = 10uL)) @@ -293,13 +269,13 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `updateBip21Invoice should create bolt11 when channels exist`() = test { - val testInvoice = mock() + val testInvoice = "testInvoice" whenever(lightningRepo.hasChannels()).thenReturn(true) - whenever(lightningRepo.createInvoice(any(), any())).thenReturn(Result.success(testInvoice)) + whenever(lightningRepo.createInvoice(1000uL, description = "test")).thenReturn(Result.success(testInvoice)) - sut.updateBip21Invoice(amountSats = 1000uL, description = "test").let { result -> + sut.updateBip21Invoice(amountSats = 1000uL, description = "test", generateBolt11IfAvailable = true).let { result -> assertTrue(result.isSuccess) - assertEquals(testInvoice.toString(), sut.walletState.value.bolt11) + assertEquals(testInvoice, sut.walletState.value.bolt11) } } @@ -315,14 +291,14 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `updateBip21Invoice should build correct BIP21 URL`() = test { - val testAddress = "testAddress" + val testAddress = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" whenever(sut.getOnchainAddress()).thenReturn(testAddress) whenever(lightningRepo.hasChannels()).thenReturn(false) sut.updateBip21Invoice(amountSats = 1000uL, description = "test").let { result -> assertTrue(result.isSuccess) assertTrue(sut.walletState.value.bip21.contains(testAddress)) - assertTrue(sut.walletState.value.bip21.contains("amount=0.00001000")) + assertTrue(sut.walletState.value.bip21.contains("amount=0.00001")) assertTrue(sut.walletState.value.bip21.contains("message=test")) } } @@ -347,43 +323,6 @@ class WalletRepoTest : BaseUnitTest() { assertTrue(result.isFailure) } - @Test - fun `saveInvoiceWithTags should save invoice with tags`() = test { - val testBip21 = "bitcoin:address?amount=0.001&message=test" - val testTags = listOf("tag1", "tag2") - whenever(db.invoiceTagDao().saveInvoice(any())).thenReturn(Unit) - - sut.saveInvoiceWithTags(testBip21, testTags) - - verify(db.invoiceTagDao()).saveInvoice( - argThat { invoiceTag -> - invoiceTag.tags == testTags - } - ) - } - - @Test - fun `searchInvoice should return invoice when found`() = test { - val testTxId = "testTxId" - val testInvoice = mock() - whenever(db.invoiceTagDao().searchInvoice(testTxId)).thenReturn(testInvoice) - - val result = sut.searchInvoice(testTxId) - - assertTrue(result.isSuccess) - assertEquals(testInvoice, result.getOrNull()) - } - - @Test - fun `searchInvoice should fail when invoice not found`() = test { - val testTxId = "testTxId" - whenever(db.invoiceTagDao().searchInvoice(testTxId)).thenReturn(null) - - val result = sut.searchInvoice(testTxId) - - assertTrue(result.isFailure) - } - @Test fun `attachTagsToActivity should fail with empty tags`() = test { val result = sut.attachTagsToActivity("txId", ActivityFilter.ALL, PaymentType.SENT, emptyList()) @@ -437,7 +376,7 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `buildBip21Url should create correct URL`() = test { - val testAddress = "testAddress" + val testAddress = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" val testAmount = 1000uL val testMessage = "test message" val testInvoice = "testInvoice" @@ -445,7 +384,7 @@ class WalletRepoTest : BaseUnitTest() { val result = sut.buildBip21Url(testAddress, testAmount, testMessage, testInvoice) assertTrue(result.contains(testAddress)) - assertTrue(result.contains("amount=0.00001000")) + assertTrue(result.contains("amount=0.00001")) assertTrue(result.contains("message=test+message")) assertTrue(result.contains("lightning=testInvoice")) } @@ -477,11 +416,4 @@ class WalletRepoTest : BaseUnitTest() { assertTrue(sut.walletState.value.selectedTags.isEmpty()) } - - @Test - fun `deleteExpiredInvoices should call dao`() = test { - sut.deleteExpiredInvoices() - - verify(db.invoiceTagDao()).deleteExpiredInvoices(any()) - } } From 509f51d2e34bca7b1994fbe35e7caa93dd9962e0 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Fri, 9 May 2025 07:29:56 -0300 Subject: [PATCH 58/62] test: update tests --- .../test/java/to/bitkit/ui/WalletViewModelTest.kt | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 7bd28fc99..a12768e39 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -24,7 +24,6 @@ import to.bitkit.test.BaseUnitTest import to.bitkit.test.TestApp import to.bitkit.viewmodels.WalletViewModel import kotlin.test.assertEquals -import kotlin.test.assertFalse @RunWith(AndroidJUnit4::class) @Config(application = TestApp::class) @@ -70,11 +69,9 @@ class WalletViewModelTest : BaseUnitTest() { @Test fun `onPullToRefresh should call lightningRepo sync`() = test { - whenever(lightningRepo.sync()).thenReturn(Result.success(Unit)) - sut.onPullToRefresh() - verify(lightningRepo).sync() + verify(walletRepo).syncNodeAndWallet() } @Test @@ -154,15 +151,10 @@ class WalletViewModelTest : BaseUnitTest() { @Test fun `wipeStorage should call walletRepo wipeWallet and lightningRepo wipeStorage, and set walletExists`() = test { - whenever(walletRepo.wipeWallet()).thenReturn(Result.success(Unit)) - whenever(lightningRepo.wipeStorage(any())).thenReturn(Result.success(Unit)) - whenever(walletRepo.walletExists()).thenReturn(false) - + whenever(walletRepo.wipeWallet(walletIndex = 0)).thenReturn(Result.success(Unit)) sut.wipeStorage() - verify(walletRepo).wipeWallet() - verify(lightningRepo).wipeStorage(any()) - assertFalse(sut.walletExists) + verify(walletRepo).wipeWallet(walletIndex = 0) } @Test From 1262b702947676c837a1fef05ad1d2cc7d578e4f Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Fri, 9 May 2025 08:41:15 -0300 Subject: [PATCH 59/62] test: implement bg image --- .../wallets/receive/EditInvoiceScreen.kt | 283 ++++++++++-------- 1 file changed, 151 insertions(+), 132 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 3992e58b3..ffbeadd7b 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 @@ -6,7 +6,9 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow @@ -17,7 +19,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -31,6 +32,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -59,7 +61,6 @@ import to.bitkit.ui.theme.AppTextFieldDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.viewmodels.CurrencyUiState -import to.bitkit.viewmodels.MainUiState @Composable fun EditInvoiceScreen( @@ -121,165 +122,183 @@ fun EditInvoiceContent( onClickTag: (String) -> Unit, onInputChanged: (String) -> Unit, ) { - Column( - modifier = Modifier - .fillMaxSize() - .gradientBackground() - .navigationBarsPadding() - .testTag("edit_invoice_screen") + Box( + modifier = Modifier.fillMaxWidth().gradientBackground() ) { - SheetTopBar(stringResource(R.string.wallet__receive_specify)) { - onBack() + + AnimatedVisibility(!keyboardVisible, modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomEnd) + ) { + Image( + painter = painterResource(R.drawable.coin_stack), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxWidth() + .padding(32.dp) + ) } Column( modifier = Modifier - .padding(horizontal = 16.dp) - .testTag("edit_invoice_content") + .fillMaxSize() + .navigationBarsPadding() + .testTag("edit_invoice_screen") ) { - Spacer(Modifier.height(32.dp)) + SheetTopBar(stringResource(R.string.wallet__receive_specify)) { + onBack() + } - NumberPadTextField( - input = input, - displayUnit = displayUnit, - primaryDisplay = primaryDisplay, + Column( modifier = Modifier - .fillMaxWidth() - .clickableAlpha(onClick = onClickBalance) - .testTag("amount_input_field") - ) - - // Animated visibility for keyboard section - AnimatedVisibility( - visible = keyboardVisible, - enter = slideInVertically( - initialOffsetY = { fullHeight -> fullHeight }, - animationSpec = tween(durationMillis = 300) - ) + fadeIn(), - exit = slideOutVertically( - targetOffsetY = { fullHeight -> fullHeight }, - animationSpec = tween(durationMillis = 300) - ) + fadeOut() + .padding(horizontal = 16.dp) + .testTag("edit_invoice_content") ) { - Column( - modifier = Modifier.testTag("keyboard_section") - ) { - Spacer(modifier = Modifier.weight(1f)) + Spacer(Modifier.height(32.dp)) + + NumberPadTextField( + input = input, + displayUnit = displayUnit, + primaryDisplay = primaryDisplay, + modifier = Modifier + .fillMaxWidth() + .clickableAlpha(onClick = onClickBalance) + .testTag("amount_input_field") + ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - modifier = Modifier.fillMaxWidth() + // Animated visibility for keyboard section + AnimatedVisibility( + visible = keyboardVisible, + enter = slideInVertically( + initialOffsetY = { fullHeight -> fullHeight }, + animationSpec = tween(durationMillis = 300) + ) + fadeIn(), + exit = slideOutVertically( + targetOffsetY = { fullHeight -> fullHeight }, + animationSpec = tween(durationMillis = 300) + ) + fadeOut() + ) { + Column( + modifier = Modifier.testTag("keyboard_section") ) { - UnitButton(modifier = Modifier.height(28.dp)) - } + Spacer(modifier = Modifier.weight(1f)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth() + ) { + UnitButton(modifier = Modifier.height(28.dp)) + } - HorizontalDivider(modifier = Modifier.padding(vertical = 24.dp)) + HorizontalDivider(modifier = Modifier.padding(vertical = 24.dp)) - Keyboard( - onClick = { number -> - onInputChanged(if (input == "0") number else input + number) - }, - onClickBackspace = { - onInputChanged(if (input.length > 1) input.dropLast(1) else "0") - }, - isDecimal = primaryDisplay == PrimaryDisplay.FIAT, - modifier = Modifier - .fillMaxWidth() - .testTag("amount_keyboard"), - ) + Keyboard( + onClick = { number -> + onInputChanged(if (input == "0") number else input + number) + }, + onClickBackspace = { + onInputChanged(if (input.length > 1) input.dropLast(1) else "0") + }, + isDecimal = primaryDisplay == PrimaryDisplay.FIAT, + modifier = Modifier + .fillMaxWidth() + .testTag("amount_keyboard"), + ) - Spacer(modifier = Modifier.height(41.dp)) + Spacer(modifier = Modifier.height(41.dp)) - PrimaryButton( - text = stringResource(R.string.common__continue), - onClick = onContinueKeyboard, - modifier = Modifier.testTag("keyboard_continue_button") - ) + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = onContinueKeyboard, + modifier = Modifier.testTag("keyboard_continue_button") + ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) + } } - } - // Animated visibility for note section - AnimatedVisibility( - visible = !keyboardVisible, - enter = fadeIn(animationSpec = tween(durationMillis = 300)), - exit = fadeOut(animationSpec = tween(durationMillis = 300)) - ) { - Column( - modifier = Modifier.testTag("note_section") + // Animated visibility for note section + AnimatedVisibility( + visible = !keyboardVisible, + enter = fadeIn(animationSpec = tween(durationMillis = 300)), + exit = fadeOut(animationSpec = tween(durationMillis = 300)) ) { - Spacer(modifier = Modifier.height(44.dp)) + Column( + modifier = Modifier.testTag("note_section") + ) { + Spacer(modifier = Modifier.height(44.dp)) - Caption13Up(text = stringResource(R.string.wallet__note), color = Colors.White64) + Caption13Up(text = stringResource(R.string.wallet__note), color = Colors.White64) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - TextField( - placeholder = { - BodySSB( - text = stringResource(R.string.wallet__receive_note_placeholder), - color = Colors.White64 - ) - }, - value = noteText, - onValueChange = onTextChanged, - minLines = 4, - keyboardOptions = KeyboardOptions.Default.copy( - imeAction = ImeAction.Done - ), - colors = AppTextFieldDefaults.semiTransparent, - shape = MaterialTheme.shapes.medium, - modifier = Modifier - .fillMaxWidth() - .testTag("note_input_field") + TextField( + placeholder = { + BodySSB( + text = stringResource(R.string.wallet__receive_note_placeholder), + color = Colors.White64 + ) + }, + value = noteText, + onValueChange = onTextChanged, + minLines = 4, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Done + ), + colors = AppTextFieldDefaults.semiTransparent, + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .fillMaxWidth() + .testTag("note_input_field") - ) + ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - Caption13Up(text = stringResource(R.string.wallet__tags), color = Colors.White64) - Spacer(modifier = Modifier.height(8.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - ) { - tags.map { tagText -> - TagButton( - text = tagText, - isSelected = false, - displayIconClose = true, - onClick = { onClickTag(tagText) }, - ) + Caption13Up(text = stringResource(R.string.wallet__tags), color = Colors.White64) + Spacer(modifier = Modifier.height(8.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + tags.map { tagText -> + TagButton( + text = tagText, + isSelected = false, + displayIconClose = true, + onClick = { onClickTag(tagText) }, + ) + } } - } - PrimaryButton( - text = stringResource(R.string.wallet__tags_add), - size = ButtonSize.Small, - onClick = { onClickAddTag() }, - icon = { - Icon( - painter = painterResource(R.drawable.ic_tag), - contentDescription = null, - tint = Colors.Brand - ) - }, - fullWidth = false - ) + PrimaryButton( + text = stringResource(R.string.wallet__tags_add), + size = ButtonSize.Small, + onClick = { onClickAddTag() }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_tag), + contentDescription = null, + tint = Colors.Brand + ) + }, + fullWidth = false + ) - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.weight(1f)) - PrimaryButton( - text = stringResource(R.string.wallet__receive_show_qr), - onClick = onContinueGeneral, - modifier = Modifier.testTag("general_continue_button") - ) + PrimaryButton( + text = stringResource(R.string.wallet__receive_show_qr), + onClick = onContinueGeneral, + modifier = Modifier.testTag("general_continue_button") + ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) + } } } } From 17b4bf6a2470781a2d8686bb62e4f2ca93a116f2 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Fri, 9 May 2025 09:03:56 -0300 Subject: [PATCH 60/62] feat: fading animation --- .../wallets/receive/EditInvoiceScreen.kt | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 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 ffbeadd7b..77ca4c9ef 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 @@ -25,6 +25,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TextField 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 @@ -33,12 +34,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import to.bitkit.R import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.PrimaryDisplay @@ -76,6 +80,18 @@ fun EditInvoiceScreen( val currencyVM = currencyViewModel ?: return var satsString by rememberSaveable { mutableStateOf("") } var keyboardVisible by remember { mutableStateOf(false) } + var isSoftKeyboardVisible by remember { mutableStateOf(false) } + val view = LocalView.current + + LaunchedEffect(view) { + ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> + val isKeyboardNowVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) + if (isKeyboardNowVisible != isSoftKeyboardVisible) { + isSoftKeyboardVisible = isKeyboardNowVisible + } + insets + } + } AmountInputHandler( input = walletUiState.balanceInput, @@ -100,7 +116,8 @@ fun EditInvoiceScreen( onContinueKeyboard = { keyboardVisible = false }, onContinueGeneral = { updateInvoice(satsString.toULongOrNull()) }, onClickAddTag = onClickAddTag, - onClickTag = onClickTag + onClickTag = onClickTag, + isSoftKeyboardVisible = isSoftKeyboardVisible ) } @@ -109,6 +126,7 @@ fun EditInvoiceScreen( fun EditInvoiceContent( input: String, noteText: String, + isSoftKeyboardVisible: Boolean, keyboardVisible: Boolean, primaryDisplay: PrimaryDisplay, displayUnit: BitcoinDisplayUnit, @@ -123,12 +141,18 @@ fun EditInvoiceContent( onInputChanged: (String) -> Unit, ) { Box( - modifier = Modifier.fillMaxWidth().gradientBackground() + modifier = Modifier + .fillMaxWidth() + .gradientBackground() ) { - AnimatedVisibility(!keyboardVisible, modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomEnd) + AnimatedVisibility( + visible = !keyboardVisible && !isSoftKeyboardVisible, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomEnd) ) { Image( painter = painterResource(R.drawable.coin_stack), @@ -323,7 +347,8 @@ private fun Preview() { onContinueKeyboard = {}, tags = listOf(), onClickAddTag = {}, - onClickTag = {} + onClickTag = {}, + isSoftKeyboardVisible = false, ) } } @@ -346,7 +371,8 @@ private fun Preview2() { onContinueKeyboard = {}, tags = listOf("Team", "Dinner", "Home", "Work"), onClickAddTag = {}, - onClickTag = {} + onClickTag = {}, + isSoftKeyboardVisible = false, ) } } @@ -369,7 +395,8 @@ private fun Preview3() { onContinueKeyboard = {}, tags = listOf("Team", "Dinner"), onClickAddTag = {}, - onClickTag = {} + onClickTag = {}, + isSoftKeyboardVisible = false, ) } } From 3e7aab612e0b165f2d689e679c86009de55fae1d Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Fri, 9 May 2025 09:11:15 -0300 Subject: [PATCH 61/62] feat: move softKeyboard logic to a keyboardAsState method --- .../wallets/receive/EditInvoiceScreen.kt | 34 ++++++------------- .../main/java/to/bitkit/ui/utils/Keyboard.kt | 23 +++++++++++++ 2 files changed, 33 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/utils/Keyboard.kt 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 77ca4c9ef..016e0e2f6 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 @@ -25,7 +25,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TextField 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 @@ -34,15 +33,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat import to.bitkit.R import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.PrimaryDisplay @@ -64,6 +60,7 @@ import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppTextFieldDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.keyboardAsState import to.bitkit.viewmodels.CurrencyUiState @Composable @@ -80,18 +77,7 @@ fun EditInvoiceScreen( val currencyVM = currencyViewModel ?: return var satsString by rememberSaveable { mutableStateOf("") } var keyboardVisible by remember { mutableStateOf(false) } - var isSoftKeyboardVisible by remember { mutableStateOf(false) } - val view = LocalView.current - - LaunchedEffect(view) { - ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> - val isKeyboardNowVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) - if (isKeyboardNowVisible != isSoftKeyboardVisible) { - isSoftKeyboardVisible = isKeyboardNowVisible - } - insets - } - } + var isSoftKeyboardVisible by keyboardAsState() AmountInputHandler( input = walletUiState.balanceInput, @@ -110,7 +96,7 @@ fun EditInvoiceScreen( tags = walletUiState.selectedTags, onBack = onBack, onTextChanged = onDescriptionUpdate, - keyboardVisible = keyboardVisible, + numericKeyboardVisible = keyboardVisible, onClickBalance = { keyboardVisible = true }, onInputChanged = onInputUpdated, onContinueKeyboard = { keyboardVisible = false }, @@ -127,7 +113,7 @@ fun EditInvoiceContent( input: String, noteText: String, isSoftKeyboardVisible: Boolean, - keyboardVisible: Boolean, + numericKeyboardVisible: Boolean, primaryDisplay: PrimaryDisplay, displayUnit: BitcoinDisplayUnit, tags: List, @@ -147,7 +133,7 @@ fun EditInvoiceContent( ) { AnimatedVisibility( - visible = !keyboardVisible && !isSoftKeyboardVisible, + visible = !numericKeyboardVisible && !isSoftKeyboardVisible, enter = fadeIn(), exit = fadeOut(), modifier = Modifier @@ -193,7 +179,7 @@ fun EditInvoiceContent( // Animated visibility for keyboard section AnimatedVisibility( - visible = keyboardVisible, + visible = numericKeyboardVisible, enter = slideInVertically( initialOffsetY = { fullHeight -> fullHeight }, animationSpec = tween(durationMillis = 300) @@ -245,7 +231,7 @@ fun EditInvoiceContent( // Animated visibility for note section AnimatedVisibility( - visible = !keyboardVisible, + visible = !numericKeyboardVisible, enter = fadeIn(animationSpec = tween(durationMillis = 300)), exit = fadeOut(animationSpec = tween(durationMillis = 300)) ) { @@ -340,7 +326,7 @@ private fun Preview() { displayUnit = BitcoinDisplayUnit.MODERN, onBack = {}, onTextChanged = {}, - keyboardVisible = false, + numericKeyboardVisible = false, onClickBalance = {}, onInputChanged = {}, onContinueGeneral = {}, @@ -364,7 +350,7 @@ private fun Preview2() { displayUnit = BitcoinDisplayUnit.MODERN, onBack = {}, onTextChanged = {}, - keyboardVisible = false, + numericKeyboardVisible = false, onClickBalance = {}, onInputChanged = {}, onContinueGeneral = {}, @@ -388,7 +374,7 @@ private fun Preview3() { displayUnit = BitcoinDisplayUnit.MODERN, onBack = {}, onTextChanged = {}, - keyboardVisible = true, + numericKeyboardVisible = true, onClickBalance = {}, onInputChanged = {}, onContinueGeneral = {}, diff --git a/app/src/main/java/to/bitkit/ui/utils/Keyboard.kt b/app/src/main/java/to/bitkit/ui/utils/Keyboard.kt new file mode 100644 index 000000000..d88933beb --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/utils/Keyboard.kt @@ -0,0 +1,23 @@ +package to.bitkit.ui.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat + +@Composable +fun keyboardAsState(): MutableState { + val keyboardState = remember { mutableStateOf(false) } + val view = LocalView.current + LaunchedEffect(view) { + ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets -> + keyboardState.value = insets.isVisible(WindowInsetsCompat.Type.ime()) + insets + } + } + return keyboardState +} From 3a04a15a695b849a343612002745f9f41a16ab75 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Fri, 9 May 2025 12:46:57 -0300 Subject: [PATCH 62/62] feat: preview --- .../to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 016e0e2f6..522607630 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 @@ -379,7 +379,7 @@ private fun Preview3() { onInputChanged = {}, onContinueGeneral = {}, onContinueKeyboard = {}, - tags = listOf("Team", "Dinner"), + tags = listOf("Team", "Dinner","Home"), onClickAddTag = {}, onClickTag = {}, isSoftKeyboardVisible = false,