diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 447ef2228..a7c17fcca 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,8 @@ + + + + + diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index 636154623..ce40645dd 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -24,7 +24,6 @@ internal open class App : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) } - 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 new file mode 100644 index 000000000..6efaf0dbd --- /dev/null +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -0,0 +1,125 @@ +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.App +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 +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() + startForeground(NOTIFICATION_ID, createNotification()) + setupService() + } + + private fun setupService() { + serviceScope.launch { + launch { + lightningRepo.start( + eventHandler = { event -> + walletRepo.refreshBip21ForEvent(event) + } + ).onSuccess { + val notification = createNotification() + startForeground(NOTIFICATION_ID, notification) + + walletRepo.setWalletExistsState() + walletRepo.refreshBip21() + walletRepo.syncBalances() + } + } + } + } + + // Update the createNotification method in LightningNodeService.kt + private fun createNotification( + contentText: String = "Bitkit is running in background so you can receive Lightning payments" + ): Notification { + 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) + .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 + } + + override fun onDestroy() { + Logger.debug("onDestroy", context = TAG) + 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 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" + } +} 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/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index eaf857902..95b4a12d2 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1,10 +1,14 @@ package to.bitkit.repositories +import com.google.firebase.messaging.FirebaseMessaging 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.tasks.await import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.Address @@ -17,33 +21,38 @@ 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.AddressChecker import to.bitkit.utils.Logger +import uniffi.bitkitcore.IBtInfo 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( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningService: LightningService, private val ldkNodeEventBus: LdkNodeEventBus, - private val addressChecker: AddressChecker, private val settingsStore: SettingsStore, private val coreService: CoreService, + private val blocktankNotificationsService: BlocktankNotificationsService, + private val firebaseMessaging: FirebaseMessaging, + private val keychain: Keychain, ) { - 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. @@ -61,25 +70,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 @@ -116,56 +125,94 @@ class LightningRepo @Inject constructor( } suspend fun start( - walletIndex: Int, + walletIndex: Int = 0, timeout: Duration? = null, + shouldRetry: Boolean = true, 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) - } + 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) + } - _nodeLifecycleState.value = NodeLifecycleState.Running - Result.success(Unit) - } catch (e: Throwable) { + // Start the node service + lightningService.start(timeout) { event -> + eventHandler?.invoke(event) + ldkNodeEventBus.emit(event) + } + + _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) + } + sync() + registerForNotifications() + + Result.success(Unit) + } 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) - _nodeLifecycleState.value = NodeLifecycleState.ErrorStarting(e) + _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 - lightningService.stop() - _nodeLifecycleState.value = 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) @@ -173,14 +220,27 @@ 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) + } + + _lightningState.update { it.copy(isSyncingWallet = true) } lightningService.sync() + syncState() + _lightningState.update { it.copy(isSyncingWallet = false) } + Result.success(Unit) } 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", context = TAG) 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) @@ -198,6 +258,7 @@ class LightningRepo @Inject constructor( suspend fun disconnectPeer(peer: LnPeer): Result = executeWhenNodeRunning("Disconnect peer") { lightningService.disconnectPeer(peer) + syncState() Result.success(Unit) } @@ -206,12 +267,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, @@ -224,6 +279,7 @@ 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) } @@ -244,6 +300,7 @@ class LightningRepo @Inject constructor( var satsPerVByte = fees.getSatsPerVByteFor(transactionSpeed) val txId = lightningService.send(address = address, sats = sats, satsPerVByte = satsPerVByte) + syncState() Result.success(txId) } @@ -258,34 +315,110 @@ 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 syncState() { + _lightningState.update { + it.copy( + nodeId = getNodeId().orEmpty(), + nodeStatus = getStatus(), + peers = getPeers().orEmpty(), + channels = getChannels().orEmpty(), + ) + } + } + 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 = nodeLifecycleState.value.isRunning() && lightningService.channels?.isNotEmpty() == true + 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" } } + +data class LightningState( + val nodeId: String = "", + val nodeStatus: NodeStatus? = null, + val nodeLifecycleState: NodeLifecycleState = NodeLifecycleState.Stopped, + val peers: List = emptyList(), + val channels: List = emptyList(), + 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 07d822999..66f8d29d7 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -1,19 +1,18 @@ 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.filter +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.BalanceDetails +import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.Txid import to.bitkit.data.AppDb @@ -26,11 +25,10 @@ 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.models.NodeLifecycleState 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 @@ -47,17 +45,128 @@ 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, private val coreService: CoreService, - private val blocktankNotificationsService: BlocktankNotificationsService, - private val firebaseMessaging: FirebaseMessaging, private val settingsStore: SettingsStore, + private val addressChecker: AddressChecker, + private val lightningRepo: LightningRepo, + private val network: Network ) { + + 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()) } + } + + 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 = 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()) { + 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) + } + + suspend fun observeLdkWallet() = withContext(bgDispatcher) { + lightningRepo.getSyncFlow() + .filter { lightningRepo.lightningState.value.nodeLifecycleState == NodeLifecycleState.Running } + .collect { + runCatching { + syncNodeAndWallet() + } + } + } + + suspend fun syncNodeAndWallet() : Result = withContext(bgDispatcher) { + syncBalances() + lightningRepo.sync().onSuccess { + syncBalances() + return@withContext Result.success(Unit) + }.onFailure { e -> + return@withContext Result.failure(e) + } + } + + 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 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) } + } + suspend fun createWallet(bip39Passphrase: String?): Result = withContext(bgDispatcher) { try { val mnemonic = generateEntropyMnemonic() @@ -65,9 +174,10 @@ 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) + Logger.error("Create wallet error", e, context = TAG) Result.failure(e) } } @@ -78,6 +188,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) @@ -85,8 +196,8 @@ class WalletRepo @Inject constructor( } } - suspend fun wipeWallet(): Result = withContext(bgDispatcher) { - if (Env.network != Network.REGTEST) { + suspend fun wipeWallet(walletIndex: Int = 0): Result = withContext(bgDispatcher) { + if (network != Network.REGTEST) { return@withContext Result.failure(Exception("Can only wipe on regtest.")) } @@ -96,7 +207,11 @@ class WalletRepo @Inject constructor( settingsStore.wipe() coreService.activity.removeAll() deleteAllInvoices() - Result.success(Unit) + _walletState.update { WalletState() } + _balanceState.update { BalanceState() } + setWalletExistsState() + + return@withContext lightningRepo.wipeStorage(walletIndex = walletIndex) } catch (e: Throwable) { Logger.error("Wipe wallet error", e) Result.failure(e) @@ -108,20 +223,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,73 +259,88 @@ 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) } } - // Notification handling - suspend fun registerForNotifications(): Result = withContext(bgDispatcher) { - try { - val token = firebaseMessaging.token.await() - val cachedToken = keychain.loadString(Keychain.Key.PUSH_NOTIFICATION_TOKEN.name) + // BIP21 state management + fun updateBip21AmountSats(amount: ULong?) { + _walletState.update { it.copy(bip21AmountSats = amount) } + } - if (cachedToken == token) { - Logger.debug("Skipped registering for notifications, current device token already registered") - return@withContext Result.success(Unit) - } + fun updateBip21Description(description: String) { + _walletState.update { it.copy(bip21Description = description) } + } - blocktankNotificationsService.registerDevice(token) - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Register for notifications error", e) - Result.failure(e) - } + fun updateBalanceInput(newText: String) { + _walletState.update { it.copy(balanceInput = newText) } } - 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) + fun toggleReceiveOnSpendingBalance() { + _walletState.update { it.copy(receiveOnSpendingBalance = !it.receiveOnSpendingBalance) } + } + + fun addTagToSelected(newTag: String) { + _walletState.update { + it.copy( + selectedTags = (it.selectedTags + newTag).distinct() + ) } } - 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) + fun removeTag(tag: String) { + _walletState.update { + it.copy( + selectedTags = it.selectedTags.filterNot { tagItem -> tagItem == tag } + ) } } - suspend fun createTransactionSheet( - type: NewTransactionSheetType, - direction: NewTransactionSheetDirection, - sats: Long + // BIP21 invoice creation + suspend fun updateBip21Invoice( + amountSats: ULong? = null, + description: String = "", + generateBolt11IfAvailable: Boolean = true, ): Result = withContext(bgDispatcher) { try { - NewTransactionSheetDetails.save( - appContext, - NewTransactionSheetDetails( - type = type, - direction = direction, - sats = sats, - ) + 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) Result.success(Unit) } catch (e: Throwable) { - Logger.error("Create transaction sheet error", e) + Logger.error("Update BIP21 invoice error", e, context = TAG) Result.failure(e) } } @@ -241,16 +374,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() } @@ -419,3 +542,18 @@ class WalletRepo @Inject constructor( const val TAG = "WalletRepo" } } + +data class WalletState( + val onchainAddress: String = "", + val balanceInput: 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, + val balanceDetails: BalanceDetails? = null, //TODO KEEP ONLY BalanceState IF POSSIBLE +) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index e4012fbb0..4f779174a 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -392,7 +392,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()) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 2d44d41ed..56772f076 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -110,7 +110,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 -> { @@ -130,10 +130,6 @@ fun ContentView( blocktankViewModel.triggerRefreshOrders() } - Lifecycle.Event.ON_STOP -> { - walletViewModel.stopIfNeeded() - } - else -> Unit } } @@ -182,14 +178,13 @@ 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 { try { walletViewModel.setInitNodeLifecycleState() walletViewModel.start() - walletViewModel.setWalletExistsState() } catch (e: Exception) { Logger.error("Failed to start wallet on retry", e) } @@ -209,7 +204,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/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 diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 1d0e0fe3e..7ff38580d 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.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -19,6 +20,8 @@ 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 import to.bitkit.ui.components.InactivityTracker @@ -58,6 +61,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", + ) + startForegroundService(Intent(this, LightningNodeService::class.java)) installSplashScreen() enableAppEdgeToEdge() setContent { @@ -107,7 +116,6 @@ class MainActivity : FragmentActivity() { appViewModel.resetIsAuthenticatedState() walletViewModel.setInitNodeLifecycleState() walletViewModel.createWallet(bip39Passphrase = null) - walletViewModel.setWalletExistsState() appViewModel.setShowEmptyState(true) } catch (e: Throwable) { appViewModel.toast(e) @@ -145,9 +153,8 @@ class MainActivity : FragmentActivity() { try { appViewModel.resetIsAuthenticatedState() walletViewModel.setInitNodeLifecycleState() - walletViewModel.isRestoringWallet = true + walletViewModel.setRestoringWalletState(isRestoringWallet = true) walletViewModel.restoreWallet(mnemonic, passphrase) - walletViewModel.setWalletExistsState() appViewModel.setShowEmptyState(false) } catch (e: Throwable) { appViewModel.toast(e) @@ -170,7 +177,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/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 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..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 @@ -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,6 +19,7 @@ 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.KeyboardOptions import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -25,17 +28,21 @@ 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 +import androidx.compose.ui.layout.ContentScale 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 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 @@ -53,48 +60,50 @@ 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 -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, onDescriptionUpdate: (String) -> Unit, onBack: () -> Unit, ) { val currencyVM = currencyViewModel ?: return - var input: String by remember { mutableStateOf("") } - var satsString by remember { mutableStateOf("") } + var satsString by rememberSaveable { mutableStateOf("") } var keyboardVisible by remember { mutableStateOf(false) } + var isSoftKeyboardVisible by keyboardAsState() 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, tags = walletUiState.selectedTags, onBack = onBack, onTextChanged = onDescriptionUpdate, - keyboardVisible = keyboardVisible, + numericKeyboardVisible = keyboardVisible, onClickBalance = { keyboardVisible = true }, - onInputChanged = { newText -> input = newText }, + onInputChanged = onInputUpdated, onContinueKeyboard = { keyboardVisible = false }, - onContinueGeneral = { updateInvoice(satsString.toULongOrNull(), walletUiState.bip21Description) }, + onContinueGeneral = { updateInvoice(satsString.toULongOrNull()) }, onClickAddTag = onClickAddTag, - onClickTag = onClickTag + onClickTag = onClickTag, + isSoftKeyboardVisible = isSoftKeyboardVisible ) } @@ -103,7 +112,8 @@ fun EditInvoiceScreen( fun EditInvoiceContent( input: String, noteText: String, - keyboardVisible: Boolean, + isSoftKeyboardVisible: Boolean, + numericKeyboardVisible: Boolean, primaryDisplay: PrimaryDisplay, displayUnit: BitcoinDisplayUnit, tags: List, @@ -116,162 +126,189 @@ fun EditInvoiceContent( onClickTag: (String) -> Unit, onInputChanged: (String) -> Unit, ) { - Column( + Box( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() .gradientBackground() - .navigationBarsPadding() - .testTag("edit_invoice_screen") ) { - SheetTopBar(stringResource(R.string.wallet__receive_specify)) { - onBack() - } - Column( + AnimatedVisibility( + visible = !numericKeyboardVisible && !isSoftKeyboardVisible, + enter = fadeIn(), + exit = fadeOut(), modifier = Modifier - .padding(horizontal = 16.dp) - .testTag("edit_invoice_content") + .fillMaxWidth() + .align(Alignment.BottomEnd) ) { - Spacer(Modifier.height(32.dp)) - - NumberPadTextField( - input = input, - displayUnit = displayUnit, - primaryDisplay = primaryDisplay, + Image( + painter = painterResource(R.drawable.coin_stack), + contentDescription = null, + contentScale = ContentScale.FillWidth, modifier = Modifier .fillMaxWidth() - .clickableAlpha(onClick = onClickBalance) - .testTag("amount_input_field") + .padding(32.dp) ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .testTag("edit_invoice_screen") + ) { + SheetTopBar(stringResource(R.string.wallet__receive_specify)) { + onBack() + } - // 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 + .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 = numericKeyboardVisible, + 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.continue_button), - 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 = !numericKeyboardVisible, + 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, - 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.continue_button), - 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)) + } } } } @@ -289,14 +326,15 @@ private fun Preview() { displayUnit = BitcoinDisplayUnit.MODERN, onBack = {}, onTextChanged = {}, - keyboardVisible = false, + numericKeyboardVisible = false, onClickBalance = {}, onInputChanged = {}, onContinueGeneral = {}, onContinueKeyboard = {}, tags = listOf(), onClickAddTag = {}, - onClickTag = {} + onClickTag = {}, + isSoftKeyboardVisible = false, ) } } @@ -312,14 +350,15 @@ private fun Preview2() { displayUnit = BitcoinDisplayUnit.MODERN, onBack = {}, onTextChanged = {}, - keyboardVisible = false, + numericKeyboardVisible = false, onClickBalance = {}, onInputChanged = {}, onContinueGeneral = {}, onContinueKeyboard = {}, tags = listOf("Team", "Dinner", "Home", "Work"), onClickAddTag = {}, - onClickTag = {} + onClickTag = {}, + isSoftKeyboardVisible = false, ) } } @@ -335,14 +374,15 @@ private fun Preview3() { displayUnit = BitcoinDisplayUnit.MODERN, onBack = {}, onTextChanged = {}, - keyboardVisible = true, + numericKeyboardVisible = true, onClickBalance = {}, onInputChanged = {}, onContinueGeneral = {}, onContinueKeyboard = {}, - tags = listOf("Team", "Dinner"), + tags = listOf("Team", "Dinner","Home"), onClickAddTag = {}, - onClickTag = {} + onClickTag = {}, + isSoftKeyboardVisible = false, ) } } 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..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 = { @@ -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/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 +} diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 2443f3e13..fc83ac0e1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -11,16 +11,13 @@ 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.launch import org.lightningdevkit.ldknode.BalanceDetails 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 @@ -40,204 +37,113 @@ 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() + val lightningState = lightningRepo.lightningState + val walletState = walletRepo.walletState + val balanceState = walletRepo.balanceState + // Local UI state var walletExists by mutableStateOf(walletRepo.walletExists()) private set - init { - collectNodeLifecycleState() - } - var isRestoringWallet by mutableStateOf(false) + private set - fun setWalletExistsState() { - walletExists = walletRepo.walletExists() - } + private val _uiState = MutableStateFlow(MainUiState()) + @Deprecated("Prioritize get the wallet and lightning states from LightningRepo or WalletRepo") + val uiState = _uiState.asStateFlow() - fun setInitNodeLifecycleState() { - _uiState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Initializing) } + init { + collectStates() } - fun start(walletIndex: Int = 0) { - if (!walletExists) return - if (_uiState.value.nodeLifecycleState.isRunningOrStarting()) return - + private fun collectStates() { //This is necessary to avoid a bigger refactor in all application 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) + walletState.collect { state -> + walletExists = state.walletExists + isRestoringWallet = state.isRestoringWallet + _uiState.update { + it.copy( + onchainAddress = state.onchainAddress, + balanceInput = state.balanceInput, + bolt11 = state.bolt11, + bip21 = state.bip21, + bip21AmountSats = state.bip21AmountSats, + bip21Description = state.bip21Description, + selectedTags = state.selectedTags, + receiveOnSpendingBalance = state.receiveOnSpendingBalance, + balanceDetails = state.balanceDetails + ) } - - // 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 - } - } - suspend fun observeLdkWallet() { - lightningRepo.getSyncFlow() - .filter { _uiState.value.nodeLifecycleState == NodeLifecycleState.Running } - .collect { - runCatching { sync() } - } - } - - private fun collectNodeLifecycleState() { viewModelScope.launch(bgDispatcher) { - lightningRepo.nodeLifecycleState.collect { currentState -> - _uiState.update { it.copy(nodeLifecycleState = currentState) } + lightningState.collect { state -> + _uiState.update { + it.copy( + nodeId = state.nodeId, + nodeStatus = state.nodeStatus, + nodeLifecycleState = state.nodeLifecycleState, + peers = state.peers, + channels = state.channels, + ) + } } } } - private suspend fun observeDbConfig() { - walletRepo.getDbConfig().collect { - Logger.info("Database config sync: $it") - } + fun setRestoringWalletState(isRestoringWallet: Boolean) { + walletRepo.setRestoringWalletState(isRestoring = isRestoringWallet) } - 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 - } - } - - 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) + .onSuccess { + walletRepo.setWalletExistsState() + walletRepo.syncBalances() + walletRepo.refreshBip21() + } + .onFailure { error -> + Logger.error("Node startup error", error) + ToastEventBus.send(error) } - } } } + suspend fun observeLdkWallet() { + walletRepo.observeLdkWallet() + } + 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() - } 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) } - } + walletRepo.syncNodeAndWallet() + .onSuccess { + _uiState.update { it.copy(isRefreshing = false) } + } + .onFailure { error -> + ToastEventBus.send(error) + _uiState.update { it.copy(isRefreshing = false) } } } - - updateBip21Invoice() } fun disconnectPeer(peer: LnPeer) { @@ -249,9 +155,6 @@ class WalletViewModel @Inject constructor( title = "Success", description = "Peer disconnected." ) - _uiState.update { - it.copy(peers = lightningRepo.getPeers().orEmpty()) - } } .onFailure { error -> ToastEventBus.send( @@ -266,7 +169,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, @@ -279,47 +181,37 @@ class WalletViewModel @Inject constructor( fun updateBip21Invoice( amountSats: ULong? = null, - description: String = "", 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("") + walletRepo.updateBip21Invoice( + amountSats = amountSats, + description = walletState.value.bip21Description, + generateBolt11IfAvailable = generateBolt11IfAvailable, + ).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) } + walletRepo.toggleReceiveOnSpendingBalance() updateBip21Invoice( - amountSats = _uiState.value.bip21AmountSats, - description = _uiState.value.bip21Description, - generateBolt11IfAvailable = _uiState.value.receiveOnSpendingBalance + amountSats = walletState.value.bip21AmountSats, + generateBolt11IfAvailable = walletState.value.receiveOnSpendingBalance ) } + fun refreshBip21() { + viewModelScope.launch { + walletRepo.refreshBip21() + } + } + suspend fun createInvoice( amountSats: ULong? = null, description: String, @@ -363,9 +255,7 @@ class WalletViewModel @Inject constructor( lightningRepo.closeChannel( channel.userChannelId, channel.counterpartyNodeId - ).onSuccess { - syncState() - }.onFailure { + ).onFailure { ToastEventBus.send(it) } } @@ -373,19 +263,15 @@ class WalletViewModel @Inject constructor( fun wipeStorage() { viewModelScope.launch(bgDispatcher) { - walletRepo.wipeWallet() - .onSuccess { - lightningRepo.wipeStorage(walletIndex = 0) - setWalletExistsState() - }.onFailure { - ToastEventBus.send(it) - } + walletRepo.wipeWallet().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,15 +279,15 @@ 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() + lightningRepo.registerForNotifications() .onSuccess { ToastEventBus.send( type = Toast.ToastType.INFO, @@ -417,7 +303,6 @@ class WalletViewModel @Inject constructor( viewModelScope.launch { lightningRepo.newAddress().onSuccess { address -> walletRepo.setOnchainAddress(address) - syncState() }.onFailure { ToastEventBus.send(it) } } } @@ -432,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") } } @@ -458,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) } } @@ -466,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) @@ -498,41 +383,33 @@ 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) } + if (newText.isEmpty()) { + Logger.warn("Empty") + } + walletRepo.updateBip21Description(newText) } - private fun clearTagsAndBip21DescriptionState() { - _uiState.update { it.copy(selectedTags = listOf(), bip21Description = "") } + 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 = "", 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..572f05e47 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -0,0 +1,352 @@ +package to.bitkit.repositories + +import app.cash.turbine.test +import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import org.junit.Before +import org.junit.Test +import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.NodeStatus +import org.lightningdevkit.ldknode.PaymentDetails +import org.lightningdevkit.ldknode.UserChannelId +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsStore +import to.bitkit.data.keychain.Keychain +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.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +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() + 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, + settingsStore = settingsStore, + coreService = coreService, + blocktankNotificationsService = blocktankNotificationsService, + firebaseMessaging = firebaseMessaging, + keychain = keychain + ) + } + + private suspend fun startNodeForTesting() { + sut.setInitNodeLifecycleState() + whenever(lightningService.node).thenReturn(mock()) + whenever(lightningService.setup(any())).thenReturn(Unit) + whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) + whenever(settingsStore.defaultTransactionSpeed).thenReturn(flowOf(TransactionSpeed.Medium)) + 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 `createInvoice should succeed when node is running`() = test { + startNodeForTesting() + 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()) + } + + @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 = "testPaymentId" + whenever(lightningService.send("bolt11", 1000uL)).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") + val result = sut.openChannel(testPeer, 100000uL) + assertTrue(result.isFailure) + } + + @Test + fun `openChannel should succeed when node is running`() = test { + startNodeForTesting() + val testPeer = LnPeer("nodeId", "host", "9735") + val testChannelId = "testChannelId" + val channelAmountSats = 100000uL + whenever(lightningService.openChannel(peer = testPeer, channelAmountSats, null)).thenReturn( + Result.success( + testChannelId + ) + ) + + val result = sut.openChannel(testPeer, channelAmountSats, null) + 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()) + } + + @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 `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() + 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 `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") + val result = sut.disconnectPeer(testPeer) + 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 `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) + } +} 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..bcfc315dc --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -0,0 +1,419 @@ +package to.bitkit.repositories + +import app.cash.turbine.test +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.Event +import org.lightningdevkit.ldknode.Network +import org.mockito.kotlin.any +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.keychain.Keychain +import to.bitkit.services.CoreService +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AddressChecker +import to.bitkit.utils.AddressInfo +import to.bitkit.utils.AddressStats +import uniffi.bitkitcore.ActivityFilter +import uniffi.bitkitcore.PaymentType +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +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 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(appStorage.loadBalance()).thenReturn(null) + whenever(lightningRepo.getSyncFlow()).thenReturn(flowOf(Unit)) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + + sut = WalletRepo( + bgDispatcher = testDispatcher, + appStorage = appStorage, + db = db, + keychain = keychain, + coreService = coreService, + settingsStore = settingsStore, + addressChecker = addressChecker, + lightningRepo = lightningRepo, + network = Network.REGTEST + ) + } + + @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 `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 `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) + } + + @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 = nonRegtestRepo.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")) + 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("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq") + whenever(lightningRepo.newAddress()).thenReturn(Result.success("newAddress")) + 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() + + assertTrue(result.isSuccess) + verify(lightningRepo).newAddress() + } + + @Test + fun `refreshBip21 should keep address when current has no transactions`() = test { + val existingAddress = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + whenever(sut.getOnchainAddress()).thenReturn(existingAddress) + 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() + + assertTrue(result.isSuccess) + verify(lightningRepo, never()).newAddress() + } + + @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(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 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 = "testInvoice" + whenever(lightningRepo.hasChannels()).thenReturn(true) + whenever(lightningRepo.createInvoice(1000uL, description = "test")).thenReturn(Result.success(testInvoice)) + + sut.updateBip21Invoice(amountSats = 1000uL, description = "test", generateBolt11IfAvailable = true).let { result -> + assertTrue(result.isSuccess) + assertEquals(testInvoice, 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 = "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.00001")) + 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 `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 = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + 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.00001")) + 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()) + } +} diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index a68245e24..a12768e39 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -1,115 +1,276 @@ package to.bitkit.ui +import android.content.Context import androidx.test.ext.junit.runners.AndroidJUnit4 -import app.cash.turbine.test +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.ArgumentMatchers.anyString +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.MainUiState import to.bitkit.viewmodels.WalletViewModel import kotlin.test.assertEquals @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 `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, - ) + 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 { + sut.onPullToRefresh() + + verify(walletRepo).syncNodeAndWallet() + } + + @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) - sut.start() + 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())).thenReturn(Result.failure(testError)) + + sut.updateBip21Invoice() + + verify(walletRepo).updateBip21Invoice(anyOrNull(), 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(walletIndex = 0)).thenReturn(Result.success(Unit)) + sut.wipeStorage() - sut.uiState.test { - val content = awaitItem() - assertEquals(expectedUiState, content) - cancelAndIgnoreRemainingEvents() + verify(walletRepo).wipeWallet(walletIndex = 0) } + + @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 `start should register for notifications if token is not cached`() = test { - setupExistingWalletMocks() - whenever(lightningRepo.start(walletIndex = 0)).thenReturn(Result.success(Unit)) + 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.start() + sut.restoreWallet("test_mnemonic", null) - verify(walletRepo).registerForNotifications() + verify(walletRepo).restoreWallet(any(), anyOrNull()) + // Add verification for ToastEventBus.send } @Test - fun `manualRegisterForNotifications should register device with FCM token`() = test { - sut.manualRegisterForNotifications() + fun `manualRegisterForNotifications should call lightningRepo registerForNotifications and send appropriate toasts`() = + test { + whenever(lightningRepo.registerForNotifications()).thenReturn(Result.success(Unit)) + + sut.manualRegisterForNotifications() + + verify(lightningRepo).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 lightningRepo getFcmToken`() = test { + whenever(lightningRepo.getFcmToken()).thenReturn(Result.success("test_token")) + + sut.debugFcmToken() + + verify(lightningRepo).getFcmToken() + } + + @Test + fun `debugKeychain should call walletRepo debugKeychain`() = test { + whenever(walletRepo.debugKeychain(any(), any())).thenReturn(Result.success(null)) + + sut.debugKeychain() - verify(walletRepo).registerForNotifications() + verify(walletRepo).debugKeychain(any(), any()) } - 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.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()) - whenever(lightningRepo.getStatus()).thenReturn(null) + @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 lightningRepo testNotification`() = test { + whenever(lightningRepo.testNotification()).thenReturn(Result.success(Unit)) + + sut.debugLspNotifications() + + verify(lightningRepo).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") } }