diff --git a/app/src/main/java/to/bitkit/di/RepositoryModule.kt b/app/src/main/java/to/bitkit/di/RepositoryModule.kt new file mode 100644 index 000000000..011f629c8 --- /dev/null +++ b/app/src/main/java/to/bitkit/di/RepositoryModule.kt @@ -0,0 +1,68 @@ +@file:Suppress("unused") + +package to.bitkit.di + +import android.content.Context +import com.google.firebase.messaging.FirebaseMessaging +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import to.bitkit.data.AppDb +import to.bitkit.data.AppStorage +import to.bitkit.data.SettingsStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.WalletRepo +import to.bitkit.services.BlocktankNotificationsService +import to.bitkit.services.CoreService +import to.bitkit.services.LdkNodeEventBus +import to.bitkit.services.LightningService +import to.bitkit.utils.AddressChecker + +@Module +@InstallIn(SingletonComponent::class) +object RepositoryModule { + + @Provides + fun provideLightningRepository( + @BgDispatcher bgDispatcher: CoroutineDispatcher, + lightningService: LightningService, + ldkNodeEventBus: LdkNodeEventBus, + addressChecker: AddressChecker + ): LightningRepo { + return LightningRepo( + bgDispatcher = bgDispatcher, + lightningService = lightningService, + ldkNodeEventBus = ldkNodeEventBus, + addressChecker = addressChecker + ) + } + + @Provides + fun provideWalletRepository( + @BgDispatcher bgDispatcher: CoroutineDispatcher, + @ApplicationContext appContext: Context, + appStorage: AppStorage, + db: AppDb, + keychain: Keychain, + coreService: CoreService, + blocktankNotificationsService: BlocktankNotificationsService, + firebaseMessaging: FirebaseMessaging, + settingsStore: SettingsStore, + ): WalletRepo { + return WalletRepo( + bgDispatcher = bgDispatcher, + appContext = appContext, + appStorage = appStorage, + db = db, + keychain = keychain, + coreService = coreService, + blocktankNotificationsService = blocktankNotificationsService, + firebaseMessaging = firebaseMessaging, + settingsStore = settingsStore + ) + } +} diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index ff2f5e1a7..af71e2bcb 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -105,4 +105,5 @@ internal object Env { const val PIN_LENGTH = 4 const val PIN_ATTEMPTS = 8 + const val DEFAULT_INVOICE_MESSAGE = "Bitkit" } diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index 6897606f3..25fb13bd0 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -24,8 +24,8 @@ import to.bitkit.models.blocktank.BlocktankNotificationType.incomingHtlc import to.bitkit.models.blocktank.BlocktankNotificationType.mutualClose import to.bitkit.models.blocktank.BlocktankNotificationType.orderPaymentConfirmed import to.bitkit.models.blocktank.BlocktankNotificationType.wakeToTimeout +import to.bitkit.repositories.LightningRepo import to.bitkit.services.CoreService -import to.bitkit.services.LightningService import to.bitkit.ui.pushNotification import to.bitkit.utils.Logger import to.bitkit.utils.withPerformanceLogging @@ -36,7 +36,7 @@ class WakeNodeWorker @AssistedInject constructor( @Assisted private val appContext: Context, @Assisted private val workerParams: WorkerParameters, private val coreService: CoreService, - private val lightningService: LightningService, + private val lightningRepo: LightningRepo, ) : CoroutineWorker(appContext, workerParams) { private val self = this @@ -63,10 +63,8 @@ class WakeNodeWorker @AssistedInject constructor( try { withPerformanceLogging { - // TODO: Only start node if it's not running or implement & use StateLocker - lightningService.setup(walletIndex = 0) - lightningService.start(timeout) { event -> handleLdkEvent(event) } - lightningService.connectToTrustedPeers() + lightningRepo.start(walletIndex = 0, timeout= timeout) { event -> handleLdkEvent(event) } + lightningRepo.connectToTrustedPeers() // Once node is started, handle the manual channel opening if needed if (self.notificationType == orderPaymentConfirmed) { @@ -136,7 +134,7 @@ class WakeNodeWorker @AssistedInject constructor( self.bestAttemptContent?.title = "Payment received" self.bestAttemptContent?.body = "Via new channel" - lightningService.channels?.find { it.channelId == event.channelId }?.let { channel -> + lightningRepo.getChannels()?.find { it.channelId == event.channelId }?.let { channel -> val sats = channel.outboundCapacityMsat / 1000u self.bestAttemptContent?.title = "Received ⚡ $sats sats" // Save for UI to pick up @@ -185,7 +183,7 @@ class WakeNodeWorker @AssistedInject constructor( } private suspend fun deliver() { - lightningService.stop() + lightningRepo.stop() bestAttemptContent?.run { pushNotification(title, body, context = appContext) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt new file mode 100644 index 000000000..c28d8fded --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -0,0 +1,267 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import org.lightningdevkit.ldknode.Address +import org.lightningdevkit.ldknode.BalanceDetails +import org.lightningdevkit.ldknode.Bolt11Invoice +import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.NodeStatus +import org.lightningdevkit.ldknode.PaymentDetails +import org.lightningdevkit.ldknode.PaymentId +import org.lightningdevkit.ldknode.UserChannelId +import org.lightningdevkit.ldknode.generateEntropyMnemonic +import to.bitkit.di.BgDispatcher +import to.bitkit.models.LnPeer +import to.bitkit.models.NodeLifecycleState +import to.bitkit.services.LdkNodeEventBus +import to.bitkit.services.LightningService +import to.bitkit.services.NodeEventHandler +import to.bitkit.utils.AddressChecker +import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration + +@Singleton +class LightningRepo @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val lightningService: LightningService, + private val ldkNodeEventBus: LdkNodeEventBus, + private val addressChecker: AddressChecker +) { + private val _nodeLifecycleState: MutableStateFlow = MutableStateFlow(NodeLifecycleState.Stopped) + val nodeLifecycleState = _nodeLifecycleState.asStateFlow() + + suspend fun setup(walletIndex: Int): Result = withContext(bgDispatcher) { + return@withContext try { + lightningService.setup(walletIndex) + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Node setup error", e) + Result.failure(e) + } + } + + suspend fun start( + walletIndex: Int, + timeout: Duration? = null, + eventHandler: NodeEventHandler? = null + ): Result = + withContext(bgDispatcher) { + if (nodeLifecycleState.value.isRunningOrStarting()) { + return@withContext Result.success(Unit) + } + + try { + _nodeLifecycleState.value = NodeLifecycleState.Starting + + // 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") + ) + return@withContext setupResult + } + } + + // Start the node service + lightningService.start(timeout) { event -> + eventHandler?.invoke(event) + ldkNodeEventBus.emit(event) + } + + _nodeLifecycleState.value = NodeLifecycleState.Running + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Node start error", e) + _nodeLifecycleState.value = NodeLifecycleState.ErrorStarting(e) + Result.failure(e) + } + } + + suspend fun waitForNodeStart(timeout: Long = 30000): Result = withContext(bgDispatcher) { + val startTime = System.currentTimeMillis() + + while (System.currentTimeMillis() - startTime < timeout) { + when (nodeLifecycleState.value) { + NodeLifecycleState.Running -> return@withContext Result.success(Unit) + is NodeLifecycleState.ErrorStarting -> { + val error = (nodeLifecycleState.value as NodeLifecycleState.ErrorStarting).cause + return@withContext Result.failure(error) + } + + else -> delay(100) // Wait a bit before checking again + } + } + + Result.failure(ServiceError.NodeStartTimeout) + } + + suspend fun stop(): Result = withContext(bgDispatcher) { + if (nodeLifecycleState.value.isStoppedOrStopping()) { + return@withContext Result.success(Unit) + } + + try { + _nodeLifecycleState.value = NodeLifecycleState.Stopping + lightningService.stop() + _nodeLifecycleState.value = NodeLifecycleState.Stopped + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Node stop error", e) + Result.failure(e) + } + } + + suspend fun sync(): Result = withContext(bgDispatcher) { + try { + lightningService.sync() + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Sync error", e) + Result.failure(e) + } + } + + suspend fun wipeStorage(walletIndex: Int): Result = withContext(bgDispatcher) { + try { + lightningService.wipeStorage(walletIndex) + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Wipe storage error", e) + Result.failure(e) + } + } + + suspend fun connectToTrustedPeers(): Result = withContext(bgDispatcher) { + try { + lightningService.connectToTrustedPeers() + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Connect to trusted peers error", e) + Result.failure(e) + } + } + + suspend fun disconnectPeer(peer: LnPeer): Result = withContext(bgDispatcher) { + try { + lightningService.disconnectPeer(peer) + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Disconnect peer error", e) + Result.failure(e) + } + } + + suspend fun newAddress(): Result = withContext(bgDispatcher) { + try { + val address = lightningService.newAddress() + Result.success(address) + } catch (e: Throwable) { + Logger.error("New address error", e) + Result.failure(e) + } + } + + suspend fun checkAddressUsage(address: String): Result = withContext(bgDispatcher) { + 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: Throwable) { + Logger.error("Check address usage error", e) + Result.failure(e) + } + } + + suspend fun createInvoice( + amountSats: ULong? = null, + description: String, + expirySeconds: UInt = 86_400u + ): Result = withContext(bgDispatcher) { + try { + val invoice = lightningService.receive(amountSats, description, expirySeconds) + Result.success(invoice) + } catch (e: Throwable) { + Logger.error("Create invoice error", e) + Result.failure(e) + } + } + + suspend fun payInvoice(bolt11: String, sats: ULong? = null): Result = withContext(bgDispatcher) { + try { + val paymentId = lightningService.send(bolt11 = bolt11, sats = sats) + Result.success(paymentId) + } catch (e: Throwable) { + Logger.error("Pay invoice error", e) + Result.failure(e) + } + } + + suspend fun sendOnChain(address: Address, sats: ULong): Result = withContext(bgDispatcher) { + try { + val paymentId = lightningService.send(address = address, sats = sats) + Result.success(paymentId) + } catch (e: Throwable) { + Logger.error("sendOnChain error", e) + Result.failure(e) + } + } + + suspend fun getPayments(): Result< List> = withContext(bgDispatcher) { + try { + val payments = lightningService.payments ?: return@withContext Result.failure(Exception("It wan't possible get the payments")) + Result.success(payments) + } catch (e: Throwable) { + Logger.error("getPayments error", e) + Result.failure(e) + } + } + + suspend fun openChannel( + peer: LnPeer, + channelAmountSats: ULong, + pushToCounterpartySats: ULong? = null + ): Result = withContext(bgDispatcher) { + try { + val result = lightningService.openChannel(peer, channelAmountSats, pushToCounterpartySats) + result + } catch (e: Throwable) { + Logger.error("Open channel error", e) + Result.failure(e) + } + } + + suspend fun closeChannel(userChannelId: String, counterpartyNodeId: String): Result = + withContext(bgDispatcher) { + try { + lightningService.closeChannel(userChannelId, counterpartyNodeId) + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Close channel error", e) + Result.failure(e) + } + } + + fun canSend(amountSats: ULong): Boolean = lightningService.canSend(amountSats) + + fun getSyncFlow(): Flow = lightningService.syncFlow() + + fun getNodeId(): String? = lightningService.nodeId + fun getBalances(): BalanceDetails? = lightningService.balances + fun getStatus(): NodeStatus? = lightningService.status + fun getPeers(): List? = lightningService.peers + fun getChannels(): List? = lightningService.channels + + fun hasChannels(): Boolean = lightningService.channels?.isNotEmpty() == true + + fun generateMnemonic(): String = generateEntropyMnemonic() +} diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt new file mode 100644 index 000000000..b4a260a71 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -0,0 +1,245 @@ +package to.bitkit.repositories + +import android.content.Context +import com.google.firebase.messaging.FirebaseMessaging +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import org.lightningdevkit.ldknode.Network +import to.bitkit.data.AppDb +import to.bitkit.data.AppStorage +import to.bitkit.data.SettingsStore +import to.bitkit.data.entities.ConfigEntity +import to.bitkit.data.keychain.Keychain +import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env +import to.bitkit.models.BalanceState +import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.models.NewTransactionSheetDirection +import to.bitkit.models.NewTransactionSheetType +import to.bitkit.models.Toast +import to.bitkit.services.BlocktankNotificationsService +import to.bitkit.services.CoreService +import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.Bip21Utils +import to.bitkit.utils.Logger +import uniffi.bitkitcore.IBtInfo +import javax.inject.Inject +import javax.inject.Singleton + +@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, +) { + fun walletExists(): Boolean = keychain.exists(Keychain.Key.BIP39_MNEMONIC.name) + + suspend fun createWallet(bip39Passphrase: String?): Result = withContext(bgDispatcher) { + try { + val mnemonic = generateEntropyMnemonic() + keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) + if (bip39Passphrase != null) { + keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) + } + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Create wallet error", e) + Result.failure(e) + } + } + + suspend fun restoreWallet(mnemonic: String, bip39Passphrase: String?): Result = withContext(bgDispatcher) { + try { + keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) + if (bip39Passphrase != null) { + keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) + } + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Restore wallet error", e) + Result.failure(e) + } + } + + suspend fun wipeWallet(): Result = withContext(bgDispatcher) { + if (Env.network != Network.REGTEST) { + return@withContext Result.failure(Exception("Can only wipe on regtest.")) + } + + try { + keychain.wipe() + appStorage.clear() + settingsStore.wipe() + coreService.activity.removeAll() + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Wipe wallet error", e) + Result.failure(e) + } + } + + // Blockchain address management + fun getOnchainAddress(): String = appStorage.onchainAddress + + fun setOnchainAddress(address: String) { + appStorage.onchainAddress = address + } + + // Bolt11 management + fun getBolt11(): String = appStorage.bolt11 + + fun setBolt11(bolt11: String) { + appStorage.bolt11 = bolt11 + } + + // BIP21 management + fun getBip21(): String = appStorage.bip21 + + fun setBip21(bip21: String) { + appStorage.bip21 = bip21 + } + + fun buildBip21Url( + bitcoinAddress: String, + amountSats: ULong? = null, + message: String = "Bitkit", //TODO GET ENV VARIABLE + lightningInvoice: String = "" + ): String { + return Bip21Utils.buildBip21Url( + bitcoinAddress = bitcoinAddress, + amountSats = amountSats, + message = message, + lightningInvoice = lightningInvoice + ) + } + + // Balance management + fun getBalanceState(): BalanceState = appStorage.loadBalance() ?: BalanceState() + + fun saveBalanceState(balanceState: BalanceState) { + appStorage.cacheBalance(balanceState) + } + + // Settings + suspend fun setShowEmptyState(show: Boolean) { + settingsStore.setShowEmptyState(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) + + if (cachedToken == token) { + Logger.debug("Skipped registering for notifications, current device token already registered") + return@withContext Result.success(Unit) + } + + blocktankNotificationsService.registerDevice(token) + keychain.saveString(Keychain.Key.PUSH_NOTIFICATION_TOKEN.name, token) + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Register for notifications error", e) + Result.failure(e) + } + } + + suspend fun testNotification(): Result = withContext(bgDispatcher) { + try { + val token = firebaseMessaging.token.await() + blocktankNotificationsService.testNotification(token) + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Test notification error", e) + Result.failure(e) + } + } + + suspend fun getBlocktankInfo(): Result = withContext(bgDispatcher) { + try { + val info = coreService.blocktank.info(refresh = true) ?: return@withContext Result.failure(Exception("Couldn't get info")) + Result.success(info) + } catch (e: Throwable) { + Logger.error("Blocktank info error", e) + Result.failure(e) + } + } + + suspend fun createTransactionSheet( + type: NewTransactionSheetType, + direction: NewTransactionSheetDirection, + sats: Long + ): Result = withContext(bgDispatcher) { + try { + NewTransactionSheetDetails.save( + appContext, + NewTransactionSheetDetails( + type = type, + direction = direction, + sats = sats, + ) + ) + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Create transaction sheet error", e) + Result.failure(e) + } + } + + // Debug methods + suspend fun debugKeychain(key: String, value: String): Result = withContext(bgDispatcher) { + try { + if (keychain.exists(key)) { + val existingValue = keychain.loadString(key) + keychain.delete(key) + keychain.saveString(key, value) + Result.success(existingValue) + } else { + keychain.saveString(key, value) + Result.success(null) + } + } catch (e: Throwable) { + Logger.error("Debug keychain error", e) + Result.failure(e) + } + } + + suspend fun getMnemonic(): Result = withContext(bgDispatcher) { + try { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: return@withContext Result.failure(Exception("Mnemonic not found")) + Result.success(mnemonic) + } catch (e: Throwable) { + Logger.error("Get mnemonic error", e) + Result.failure(e) + } + } + + 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() + } + + private fun generateEntropyMnemonic(): String { + return org.lightningdevkit.ldknode.generateEntropyMnemonic() + } +} diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 479534c32..7676e9405 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -311,10 +311,10 @@ class LightningService @Inject constructor( return ServiceQueue.LDK.background { if (sat != null) { Logger.debug("Creating bolt11 for $sat sats") - node.bolt11Payment().receive(sat.millis, description.ifBlank { "Bitkit" }, expirySecs) + node.bolt11Payment().receive(sat.millis, description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE }, expirySecs) } else { Logger.debug("Creating bolt11 for variable amount") - node.bolt11Payment().receiveVariableAmount(description.ifBlank { "Bitkit" }, expirySecs) + node.bolt11Payment().receiveVariableAmount(description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE }, expirySecs) } } } 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 9ce0e7abb..041bd62e5 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 @@ -8,6 +8,8 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -16,6 +18,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TextField import androidx.compose.runtime.Composable @@ -26,6 +29,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -35,10 +39,12 @@ import to.bitkit.models.PrimaryDisplay import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.AmountInputHandler import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Keyboard import to.bitkit.ui.components.NumberPadTextField import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.UnitButton import to.bitkit.ui.currencyViewModel import to.bitkit.ui.scaffold.SheetTopBar @@ -53,12 +59,14 @@ import to.bitkit.viewmodels.CurrencyUiState fun EditInvoiceScreen( currencyUiState: CurrencyUiState = LocalCurrencies.current, updateInvoice: (ULong?, String) -> Unit, + onClickAddTag: () -> Unit, onBack: () -> Unit, ) { val currencyVM = currencyViewModel ?: return var input: String by remember { mutableStateOf("") } var noteText by remember { mutableStateOf("") } var satsString by remember { mutableStateOf("") } + var tags by remember { mutableStateOf(listOf("")) } var keyboardVisible by remember { mutableStateOf(false) } AmountInputHandler( @@ -75,16 +83,20 @@ fun EditInvoiceScreen( noteText = noteText, primaryDisplay = currencyUiState.primaryDisplay, displayUnit = currencyUiState.displayUnit, + tags = tags, onBack = onBack, onTextChanged = { newNote -> noteText = newNote }, keyboardVisible = keyboardVisible, onClickBalance = { keyboardVisible = true }, onInputChanged = { newText -> input = newText }, onContinueKeyboard = { keyboardVisible = false }, - onContinueGeneral = { updateInvoice(satsString.toULongOrNull(), noteText) } + onContinueGeneral = { updateInvoice(satsString.toULongOrNull(), noteText) }, + onClickAddTag = onClickAddTag, + onClickTag = { tagToRemove -> tags = tags.filterNot { it == tagToRemove } } ) } +@OptIn(ExperimentalLayoutApi::class) @Composable fun EditInvoiceContent( input: String, @@ -92,11 +104,14 @@ fun EditInvoiceContent( keyboardVisible: Boolean, primaryDisplay: PrimaryDisplay, displayUnit: BitcoinDisplayUnit, + tags: List, onBack: () -> Unit, onContinueKeyboard: () -> Unit, onClickBalance: () -> Unit, onContinueGeneral: () -> Unit, + onClickAddTag: () -> Unit, onTextChanged: (String) -> Unit, + onClickTag: (String) -> Unit, onInputChanged: (String) -> Unit, ) { Column( @@ -214,6 +229,38 @@ fun EditInvoiceContent( 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) }, + ) + } + } + 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)) PrimaryButton( @@ -244,7 +291,10 @@ private fun Preview() { onClickBalance = {}, onInputChanged = {}, onContinueGeneral = {}, - onContinueKeyboard = {} + onContinueKeyboard = {}, + tags = listOf(), + onClickAddTag = {}, + onClickTag = {} ) } } @@ -264,7 +314,10 @@ private fun Preview2() { onClickBalance = {}, onInputChanged = {}, onContinueGeneral = {}, - onContinueKeyboard = {} + onContinueKeyboard = {}, + tags = listOf("Team", "Dinner", "Home", "Work"), + onClickAddTag = {}, + onClickTag = {} ) } } @@ -284,7 +337,10 @@ private fun Preview3() { onClickBalance = {}, onInputChanged = {}, onContinueGeneral = {}, - onContinueKeyboard = {} + onContinueKeyboard = {}, + tags = listOf("Team", "Dinner"), + onClickAddTag = {}, + onClickTag = {} ) } } 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 5ba8ddd3b..07dec4dbd 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 @@ -55,6 +55,7 @@ import to.bitkit.ui.components.Headline import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.QrCodeImage import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.wallets.send.AddTagScreen import to.bitkit.ui.shared.PagerWithIndicator import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.shared.util.shareText @@ -72,6 +73,7 @@ private object ReceiveRoutes { const val CONFIRM = "confirm" const val LIQUIDITY = "liquidity" const val EDIT_INVOICE = "edit_invoice" + const val ADD_TAG = "add_tag" } @Composable @@ -167,8 +169,22 @@ fun ReceiveQrSheet( updateInvoice = { sats, description -> wallet.updateBip21Invoice(amountSats = sats, description = description) navController.popBackStack() + }, + onClickAddTag = { + navController.navigate(ReceiveRoutes.ADD_TAG) + } + ) + } + composable(ReceiveRoutes.ADD_TAG) { + AddTagScreen( + onBack = { + navController.popBackStack() + }, + onTagSelected = { + navController.popBackStack() } ) + } } } diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index 34ddd59dc..e42caca31 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -23,6 +23,7 @@ open class AppError(override val message: String? = null) : Exception(message) { sealed class ServiceError(message: String) : AppError(message) { data object NodeNotSetup : ServiceError("Node is not setup") data object NodeNotStarted : ServiceError("Node is not started") + data object NodeStartTimeout : ServiceError("Node took too long to start") class LdkNodeSqliteAlreadyExists(path: String) : ServiceError("LDK-node SQLite file already exists at $path") data object LdkToLdkNodeMigration : ServiceError("LDK to LDK-node migration issue") data object MnemonicNotFound : ServiceError("Mnemonic not found") diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityViewModel.kt index ee0a96bc9..f517aa789 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityViewModel.kt @@ -9,9 +9,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch +import to.bitkit.repositories.LightningRepo import to.bitkit.services.CoreService import to.bitkit.services.LdkNodeEventBus -import to.bitkit.services.LightningService import to.bitkit.utils.Logger import uniffi.bitkitcore.Activity import uniffi.bitkitcore.ActivityFilter @@ -20,7 +20,7 @@ import javax.inject.Inject @HiltViewModel class ActivityListViewModel @Inject constructor( private val coreService: CoreService, - private val lightningService: LightningService, + private val lightningRepo: LightningRepo, private val ldkNodeEventBus: LdkNodeEventBus, ) : ViewModel() { private val _filteredActivities = MutableStateFlow?>(null) @@ -166,12 +166,10 @@ class ActivityListViewModel @Inject constructor( fun syncLdkNodePayments() { viewModelScope.launch { - try { - lightningService.payments?.let { - coreService.activity.syncLdkNodePayments(it) - syncState() - } - } catch (e: Exception) { + lightningRepo.getPayments().onSuccess { + coreService.activity.syncLdkNodePayments(it) + syncState() + }.onFailure { e -> Logger.error("Failed to sync ldk-node payments", e) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 9520ba2af..1548c5ef5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -34,9 +34,9 @@ import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.Toast import to.bitkit.models.toActivityFilter import to.bitkit.models.toTxType +import to.bitkit.repositories.LightningRepo import to.bitkit.services.CoreService import to.bitkit.services.LdkNodeEventBus -import to.bitkit.services.LightningService import to.bitkit.services.ScannerService import to.bitkit.services.hasLightingParam import to.bitkit.services.lightningParam @@ -56,7 +56,7 @@ import javax.inject.Inject class AppViewModel @Inject constructor( private val keychain: Keychain, private val scannerService: ScannerService, - private val lightningService: LightningService, + private val lightningService: LightningRepo, private val coreService: CoreService, private val ldkNodeEventBus: LdkNodeEventBus, private val settingsStore: SettingsStore, @@ -209,7 +209,7 @@ class AppViewModel @Inject constructor( is Event.ChannelReady -> { // TODO: handle ONLY cjit as payment received. This makes it look like any channel confirmed is a received payment. - val channel = lightningService.channels?.find { it.channelId == event.channelId } + val channel = lightningService.getChannels()?.find { it.channelId == event.channelId } if (channel != null) { showNewTransactionSheet( NewTransactionSheetDetails( @@ -634,14 +634,13 @@ class AppViewModel @Inject constructor( } private suspend fun sendOnchain(address: String, amount: ULong): Result { - return runCatching { lightningService.send(address = address, amount) } - .onFailure { - toast( - type = Toast.ToastType.ERROR, - title = "Error Sending", - description = it.message ?: "Unknown error" - ) - } + return lightningService.sendOnChain(address = address, amount).onFailure { + toast( + type = Toast.ToastType.ERROR, + title = "Error Sending", + description = it.message ?: "Unknown error" + ) + } } private suspend fun sendLightning( @@ -649,7 +648,7 @@ class AppViewModel @Inject constructor( amount: ULong? = null, ): Result { return try { - val hash = lightningService.send(bolt11 = bolt11, amount) + val hash = lightningService.payInvoice(bolt11 = bolt11, sats = amount).getOrNull() // Wait until matching payment event is received val result = ldkNodeEventBus.events.watchUntil { event -> diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index a61f42603..edafeb20b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -18,9 +18,9 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.data.SettingsStore +import to.bitkit.repositories.LightningRepo import to.bitkit.services.CoreService import to.bitkit.services.CurrencyService -import to.bitkit.services.LightningService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import uniffi.bitkitcore.BtOrderState2 @@ -37,7 +37,7 @@ const val GIVE_UP_MS = 30 * 60 * 1000L // 30 minutes in ms @HiltViewModel class TransferViewModel @Inject constructor( - private val lightningService: LightningService, + private val lightningRepo: LightningRepo, private val coreService: CoreService, private val currencyService: CurrencyService, private val settingsStore: SettingsStore, @@ -69,7 +69,7 @@ class TransferViewModel @Inject constructor( fun onTransferToSpendingConfirm(order: IBtOrder) { viewModelScope.launch { try { - lightningService.send(address = order.payment.onchain.address, sats = order.feeSat) + lightningRepo.sendOnChain(address = order.payment.onchain.address, sats = order.feeSat) settingsStore.setLightningSetupStep(0) watchOrder(order.id) } catch (e: Throwable) { @@ -248,7 +248,7 @@ class TransferViewModel @Inject constructor( /** Calculates the total value of channels connected to Blocktank nodes */ private fun totalBtChannelsValueSats(info: IBtInfo?): ULong { - val channels = lightningService.channels ?: return 0u + val channels = lightningRepo.getChannels() ?: return 0u val btNodeIds = info?.nodes?.map { it.pubkey } ?: return 0u val btChannels = channels.filter { btNodeIds.contains(it.counterpartyNodeId) } @@ -284,7 +284,7 @@ class TransferViewModel @Inject constructor( async { try { Logger.info("Closing channel: ${channel.channelId}") - lightningService.closeChannel(channel.userChannelId, channel.counterpartyNodeId) + lightningRepo.closeChannel(channel.userChannelId, channel.counterpartyNodeId) null } catch (e: Throwable) { Logger.error("Error closing channel: ${channel.channelId}", e) diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 1d4fa503e..7f68ef6cd 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -6,28 +6,20 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.firebase.messaging.FirebaseMessaging import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.NodeStatus -import org.lightningdevkit.ldknode.generateEntropyMnemonic -import to.bitkit.data.AppDb -import to.bitkit.data.AppStorage -import to.bitkit.data.SettingsStore -import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.models.BalanceState @@ -37,13 +29,9 @@ import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NodeLifecycleState import to.bitkit.models.Toast -import to.bitkit.services.BlocktankNotificationsService -import to.bitkit.services.CoreService -import to.bitkit.services.LdkNodeEventBus -import to.bitkit.services.LightningService +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.WalletRepo import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.utils.AddressChecker -import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger import javax.inject.Inject @@ -51,92 +39,63 @@ import javax.inject.Inject class WalletViewModel @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 lightningService: LightningService, - private val firebaseMessaging: FirebaseMessaging, - private val ldkNodeEventBus: LdkNodeEventBus, - private val settingsStore: SettingsStore, - private val addressChecker: AddressChecker, + private val walletRepo: WalletRepo, + private val lightningRepo: LightningRepo, ) : ViewModel() { private val _uiState = MutableStateFlow(MainUiState()) val uiState = _uiState.asStateFlow() - private val _balanceState = MutableStateFlow(appStorage.loadBalance() ?: BalanceState()) + private val _balanceState = MutableStateFlow(walletRepo.getBalanceState()) val balanceState = _balanceState.asStateFlow() - private var _nodeLifecycleState: NodeLifecycleState = NodeLifecycleState.Stopped - - var walletExists by mutableStateOf(keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)) + var walletExists by mutableStateOf(walletRepo.walletExists()) private set + init { + collectNodeLifecycleState() + } + var isRestoringWallet by mutableStateOf(false) - fun setWalletExistsState() { - walletExists = keychain.exists(Keychain.Key.BIP39_MNEMONIC.name) + fun setWalletExistsState() { //TODO CHECK IF THIS IS NECESSARY + walletExists = walletRepo.walletExists() } fun setInitNodeLifecycleState() { - _nodeLifecycleState = NodeLifecycleState.Initializing - _uiState.update { it.copy(nodeLifecycleState = _nodeLifecycleState) } + _uiState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Initializing) } } - private var _onchainAddress: String - get() = appStorage.onchainAddress - set(value) = let { appStorage.onchainAddress = value } - - private var _bolt11: String - get() = appStorage.bolt11 - set(value) = let { appStorage.bolt11 = value } - - private var _bip21: String - get() = appStorage.bip21 - set(value) = let { appStorage.bip21 = value } - fun start(walletIndex: Int = 0) { if (!walletExists) return - if (_nodeLifecycleState.isRunningOrStarting()) return + if (_uiState.value.nodeLifecycleState.isRunningOrStarting()) return viewModelScope.launch { - if (_nodeLifecycleState != NodeLifecycleState.Initializing) { + if (_uiState.value.nodeLifecycleState != NodeLifecycleState.Initializing) { // Initializing means it's a wallet restore or create so we need to show the loading view - _nodeLifecycleState = NodeLifecycleState.Starting + _uiState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Starting) } } syncState() - try { - lightningService.setup(walletIndex) - lightningService.start { event -> - syncState() - ldkNodeEventBus.emit(event) - refreshBip21ForEvent(event) - } - } catch (error: Throwable) { - _uiState.update { it.copy(nodeLifecycleState = NodeLifecycleState.ErrorStarting(error)) } + lightningRepo.start(walletIndex) { event -> + syncState() + refreshBip21ForEvent(event) + }.onFailure { error -> Logger.error("Node startup error", error) throw error - } + }.onSuccess { + syncState() - _nodeLifecycleState = NodeLifecycleState.Running - syncState() + lightningRepo.connectToTrustedPeers().onFailure { e -> + Logger.error("Failed to connect to trusted peers", e) + } - try { - lightningService.connectToTrustedPeers() - } catch (e: Throwable) { - Logger.error("Failed to connect to trusted peers", e) + // Refresh BIP21 and sync + launch(bgDispatcher) { refreshBip21() } + launch(bgDispatcher) { sync() } + launch(bgDispatcher) { registerForNotificationsIfNeeded() } + launch(bgDispatcher) { observeDbConfig() } } - - launch(bgDispatcher) { refreshBip21() } - - // Always sync on start but don't need to wait for this - launch(bgDispatcher) { sync() } - - launch(bgDispatcher) { registerForNotificationsIfNeeded() } - launch(bgDispatcher) { observeDbConfig() } } } @@ -148,16 +107,26 @@ class WalletViewModel @Inject constructor( } suspend fun observeLdkWallet() { - lightningService.syncFlow() - .filter { _nodeLifecycleState == NodeLifecycleState.Running } + lightningRepo.getSyncFlow() + .filter { _uiState.value.nodeLifecycleState == NodeLifecycleState.Running } .collect { runCatching { sync() } Logger.verbose("App state synced with ldk-node.") } } + private fun collectNodeLifecycleState() { + viewModelScope.launch(Dispatchers.IO) { + lightningRepo.nodeLifecycleState.collect { currentState -> + _uiState.update { it.copy(nodeLifecycleState = currentState) } + } + } + } + private suspend fun observeDbConfig() { - db.configDao().getAll().collect { Logger.info("Database config sync: $it") } + walletRepo.getDbConfig().collect { + Logger.info("Database config sync: $it") + } } private var isSyncingWallet = false @@ -167,37 +136,33 @@ class WalletViewModel @Inject constructor( if (isSyncingWallet) { Logger.warn("Sync already in progress, waiting for existing sync.") - while (isSyncingWallet) { - delay(500) - } return } isSyncingWallet = true syncState() - try { - lightningService.sync() - } catch (e: Exception) { - isSyncingWallet = false - throw e - } - - isSyncingWallet = false - syncState() + lightningRepo.sync() + .onSuccess { + isSyncingWallet = false + syncState() + } + .onFailure { e -> + isSyncingWallet = false + throw e + } } private fun syncState() { _uiState.update { it.copy( - nodeId = lightningService.nodeId.orEmpty(), - onchainAddress = _onchainAddress, - bolt11 = _bolt11, - bip21 = _bip21, - nodeStatus = lightningService.status, - nodeLifecycleState = _nodeLifecycleState, - peers = lightningService.peers.orEmpty(), - channels = lightningService.channels.orEmpty(), + nodeId = lightningRepo.getNodeId().orEmpty(), + onchainAddress = walletRepo.getOnchainAddress(), + bolt11 = walletRepo.getBolt11(), + bip21 = walletRepo.getBip21(), + nodeStatus = lightningRepo.getStatus(), + peers = lightningRepo.getPeers().orEmpty(), + channels = lightningRepo.getChannels().orEmpty(), ) } @@ -205,7 +170,7 @@ class WalletViewModel @Inject constructor( } private fun syncBalances() { - lightningService.balances?.let { balance -> + lightningRepo.getBalances()?.let { balance -> _uiState.update { it.copy(balanceDetails = balance) } val totalSats = balance.totalLightningBalanceSats + balance.totalOnchainBalanceSats @@ -215,11 +180,11 @@ class WalletViewModel @Inject constructor( totalSats = totalSats, ) _balanceState.update { newBalance } - appStorage.cacheBalance(newBalance) + walletRepo.saveBalanceState(newBalance) if (totalSats > 0u) { viewModelScope.launch { - settingsStore.setShowEmptyState(false) + walletRepo.setShowEmptyState(false) } } } @@ -245,37 +210,34 @@ class WalletViewModel @Inject constructor( } private suspend fun registerForNotificationsIfNeeded() { - 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 - } - - try { - blocktankNotificationsService.registerDevice(token) - } catch (e: Throwable) { - Logger.error("Failed to register device for notifications", e) - } + walletRepo.registerForNotifications() + .onFailure { e -> + Logger.error("Failed to register device for notifications", e) + } } private val incomingLightningCapacitySats: ULong? - get() = lightningService.channels?.sumOf { it.inboundCapacityMsat / 1000u } + get() = lightningRepo.getChannels()?.sumOf { it.inboundCapacityMsat / 1000u } suspend fun refreshBip21() { Logger.debug("Refreshing bip21", context = "WalletViewModel") - if (_onchainAddress.isEmpty()) { - _onchainAddress = lightningService.newAddress() + + // 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 - val addressInfo = addressChecker.getAddressInfo(_onchainAddress) - val hasTransactions = addressInfo.chain_stats.tx_count > 0 || addressInfo.mempool_stats.tx_count > 0 - - if (hasTransactions) { - // Address has been used, generate a new one - _onchainAddress = lightningService.newAddress() - } + lightningRepo.checkAddressUsage(currentAddress) + .onSuccess { hasTransactions -> + if (hasTransactions) { + // Address has been used, generate a new one + lightningRepo.newAddress() + .onSuccess { address -> walletRepo.setOnchainAddress(address) } + } + } } updateBip21Invoice() @@ -283,23 +245,36 @@ class WalletViewModel @Inject constructor( fun disconnectPeer(peer: LnPeer) { viewModelScope.launch { - lightningService.disconnectPeer(peer) - ToastEventBus.send(type = Toast.ToastType.INFO, title = "Success", description = "Peer disconnected.") - _uiState.update { - it.copy(peers = lightningService.peers.orEmpty()) - } + lightningRepo.disconnectPeer(peer) + .onSuccess { + ToastEventBus.send( + type = Toast.ToastType.INFO, + title = "Success", + description = "Peer disconnected." + ) + _uiState.update { + it.copy(peers = lightningRepo.getPeers().orEmpty()) + } + } + .onFailure { error -> + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = "Error", + description = error.message ?: "Unknown error" + ) + } } } fun send(bolt11: String) { viewModelScope.launch(bgDispatcher) { - runCatching { lightningService.send(bolt11) } + lightningRepo.payInvoice(bolt11) .onSuccess { syncState() } - .onFailure { + .onFailure { error -> ToastEventBus.send( type = Toast.ToastType.ERROR, title = "Error sending", - description = it.message ?: "Unknown error" + description = error.message ?: "Unknown error" ) } } @@ -310,24 +285,29 @@ class WalletViewModel @Inject constructor( description: String = "", generateBolt11IfAvailable: Boolean = true ) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch { _uiState.update { it.copy(bip21AmountSats = amountSats, bip21Description = description) } - val hasChannels = lightningService.channels.hasChannels() + val hasChannels = lightningRepo.hasChannels() - _bolt11 = if (hasChannels && generateBolt11IfAvailable) { - createInvoice(amountSats = _uiState.value.bip21AmountSats, description = _uiState.value.bip21Description) + if (hasChannels && generateBolt11IfAvailable) { + lightningRepo.createInvoice( + amountSats = _uiState.value.bip21AmountSats, + description = _uiState.value.bip21Description + ).onSuccess { bolt11 -> + walletRepo.setBolt11(bolt11) + } } else { - "" + walletRepo.setBolt11("") } - val newBip21 = Bip21Utils.buildBip21Url( - bitcoinAddress = _onchainAddress, + val newBip21 = walletRepo.buildBip21Url( + bitcoinAddress = walletRepo.getOnchainAddress(), amountSats = _uiState.value.bip21AmountSats, - message = description.ifBlank { DEFAULT_INVOICE_MESSAGE }, - lightningInvoice = _bolt11 + message = description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE }, + lightningInvoice = walletRepo.getBolt11() ) - _bip21 = newBip21 + walletRepo.setBip21(newBip21) syncState() } @@ -347,13 +327,28 @@ class WalletViewModel @Inject constructor( description: String, expirySeconds: UInt = 86_400u, // 1 day ): String { - return lightningService.receive(amountSats, description.ifBlank { DEFAULT_INVOICE_MESSAGE }, expirySeconds) + val result = lightningRepo.createInvoice( + amountSats, + description.ifBlank { Env.DEFAULT_INVOICE_MESSAGE }, + expirySeconds + ) + return result.getOrThrow() } fun openChannel() { viewModelScope.launch(bgDispatcher) { - val peer = lightningService.peers?.firstOrNull() ?: error("No peer connected to open channel.") - runCatching { lightningService.openChannel(peer, 50000u, 10000u) } + val peer = lightningRepo.getPeers()?.firstOrNull() + + if (peer == null) { + ToastEventBus.send( + type = Toast.ToastType.INFO, + title = "Channel Pending", + description = "No peer connected to open channel" + ) + return@launch + } + + lightningRepo.openChannel(peer, 50000u, 10000u) .onSuccess { ToastEventBus.send( type = Toast.ToastType.INFO, @@ -367,58 +362,51 @@ class WalletViewModel @Inject constructor( fun closeChannel(channel: ChannelDetails) { viewModelScope.launch(bgDispatcher) { - runCatching { lightningService.closeChannel(channel.userChannelId, channel.counterpartyNodeId) } - .onFailure { ToastEventBus.send(it) } - syncState() + lightningRepo.closeChannel( + channel.userChannelId, + channel.counterpartyNodeId + ).onSuccess { + syncState() + }.onFailure { + ToastEventBus.send(it) + } } } fun wipeStorage() { viewModelScope.launch { - if (Env.network != Network.REGTEST) { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Error", - description = "Can only wipe on regtest." - ) - return@launch + if (lightningRepo.nodeLifecycleState.value.isRunningOrStarting()) { + stopLightningNode() } - runCatching { - if (_nodeLifecycleState.isRunningOrStarting()) { - stopLightningNode() + walletRepo.wipeWallet() + .onSuccess { + lightningRepo.wipeStorage(walletIndex = 0) + setWalletExistsState() + }.onFailure { + ToastEventBus.send(it) } - lightningService.wipeStorage(walletIndex = 0) - appStorage.clear() - settingsStore.wipe() - keychain.wipe() - coreService.activity.removeAll() // todo: extract to repo & syncState after, like in removeAllActivities - setWalletExistsState() - }.onFailure { - ToastEventBus.send(it) - } } } suspend fun createWallet(bip39Passphrase: String?) { - val mnemonic = generateEntropyMnemonic() - keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) - if (bip39Passphrase != null) { - keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) + walletRepo.createWallet(bip39Passphrase).onFailure { + ToastEventBus.send(it) } } suspend fun restoreWallet(mnemonic: String, bip39Passphrase: String?) { - keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) - if (bip39Passphrase != null) { - keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) + walletRepo.restoreWallet( + mnemonic = mnemonic, + bip39Passphrase = bip39Passphrase + ).onFailure { + ToastEventBus.send(it) } } // region debug fun manualRegisterForNotifications() { viewModelScope.launch(bgDispatcher) { - val token = firebaseMessaging.token.await() - runCatching { blocktankNotificationsService.registerDevice(token) } + walletRepo.registerForNotifications() .onSuccess { ToastEventBus.send( type = Toast.ToastType.INFO, @@ -432,14 +420,16 @@ class WalletViewModel @Inject constructor( fun manualNewAddress() { viewModelScope.launch { - _onchainAddress = lightningService.newAddress() - syncState() + lightningRepo.newAddress().onSuccess { address -> + walletRepo.setOnchainAddress(address) + syncState() + }.onFailure { ToastEventBus.send(it) } } } fun debugDb() { viewModelScope.launch { - db.configDao().getAll().collect { + walletRepo.getDbConfig().collect { Logger.debug("${it.count()} entities in DB: $it") } } @@ -447,48 +437,43 @@ class WalletViewModel @Inject constructor( fun debugFcmToken() { viewModelScope.launch(bgDispatcher) { - val token = firebaseMessaging.token.await() - Logger.debug("FCM registration token: $token") + walletRepo.getFcmToken().onSuccess { token -> + Logger.debug("FCM registration token: $token") + } } } fun debugKeychain() { viewModelScope.launch { val key = "test" - if (keychain.exists(key)) { - val value = keychain.loadString(key) - Logger.debug("Keychain entry: $key = $value") - keychain.delete(key) + val value = "testValue" + walletRepo.debugKeychain(key, value).onSuccess { existingValue -> + Logger.debug("Keychain entry: $key = $existingValue") } - keychain.saveString(key, "testValue") } } fun debugMnemonic() { viewModelScope.launch { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - Logger.debug(mnemonic) + walletRepo.getMnemonic().onSuccess { mnemonic -> + Logger.debug(mnemonic) + } } } fun debugLspNotifications() { viewModelScope.launch(bgDispatcher) { - try { - val token = FirebaseMessaging.getInstance().token.await() - blocktankNotificationsService.testNotification(token) - } catch (e: Throwable) { + walletRepo.testNotification().onFailure { e -> Logger.error("Error in LSP notification test:", e) - ToastEventBus.send(e) } } } fun debugBlocktankInfo() { viewModelScope.launch(bgDispatcher) { - try { - val info = coreService.blocktank.info(refresh = true) + walletRepo.getBlocktankInfo().onSuccess { info -> Logger.debug("Blocktank info: $info") - } catch (e: Throwable) { + }.onFailure { e -> Logger.error("Error getting Blocktank info:", e) } } @@ -511,31 +496,22 @@ class WalletViewModel @Inject constructor( ) } } - // endregion fun stopIfNeeded() { - if (_nodeLifecycleState.isStoppedOrStopping()) return - - viewModelScope.launch { - stopLightningNode() + viewModelScope.launch(bgDispatcher) { + lightningRepo.stop() } } private suspend fun stopLightningNode() { - _nodeLifecycleState = NodeLifecycleState.Stopping - lightningService.stop() - _nodeLifecycleState = NodeLifecycleState.Stopped - syncState() - } - - private fun List?.hasChannels() = this?.isNotEmpty() == true - - private companion object { - const val DEFAULT_INVOICE_MESSAGE = "Bitkit" + viewModelScope.launch(bgDispatcher) { + lightningRepo.stop().onSuccess { + syncState() + } + } } } -// region state data class MainUiState( val nodeId: String = "", val balanceDetails: BalanceDetails? = null, @@ -551,5 +527,3 @@ data class MainUiState( val bip21AmountSats: ULong? = null, val bip21Description: String = "" ) - -// endregion diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index c7f8bb12a..a68245e24 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -2,84 +2,58 @@ package to.bitkit.ui import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test -import com.google.android.gms.tasks.Task -import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.lightningdevkit.ldknode.BalanceDetails import org.mockito.ArgumentMatchers.anyString 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.data.AppDb -import to.bitkit.data.AppStorage -import to.bitkit.data.SettingsStore -import to.bitkit.data.keychain.Keychain -import to.bitkit.services.BlocktankNotificationsService -import to.bitkit.services.LightningService +import to.bitkit.models.NodeLifecycleState +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.WalletRepo import to.bitkit.test.BaseUnitTest import to.bitkit.test.TestApp import to.bitkit.viewmodels.MainUiState -import to.bitkit.models.NodeLifecycleState -import to.bitkit.services.CoreService -import to.bitkit.services.LdkNodeEventBus -import to.bitkit.utils.AddressChecker -import to.bitkit.utils.AddressInfo -import to.bitkit.utils.AddressStats import to.bitkit.viewmodels.WalletViewModel import kotlin.test.assertEquals @RunWith(AndroidJUnit4::class) @Config(application = TestApp::class) class WalletViewModelTest : BaseUnitTest() { - private var db: AppDb = mock() - private var keychain: Keychain = mock() - private var firebaseMessaging: FirebaseMessaging = mock() - private var coreService: CoreService = mock() - private var blocktankNotificationsService: BlocktankNotificationsService = mock() - private var lightningService: LightningService = mock() - private var appStorage: AppStorage = mock() - private val ldkNodeEventBus: LdkNodeEventBus = mock() - private val settingsStore: SettingsStore = mock() - private val addressChecker: AddressChecker = mock() + 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) @Before fun setUp() { - whenever(lightningService.nodeId).thenReturn("nodeId") - whenever(lightningService.balances).thenReturn(balanceDetails) - whenever(lightningService.balances?.totalLightningBalanceSats).thenReturn(1000u) - whenever(lightningService.balances?.totalOnchainBalanceSats).thenReturn(10_000u) - wheneverBlocking { lightningService.newAddress() }.thenReturn("onchainAddress") - whenever(db.configDao()).thenReturn(mock()) - whenever(db.configDao().getAll()).thenReturn(mock()) - - val task = mock> { - on(it.isComplete).thenReturn(true) - on(it.result).thenReturn("cachedToken") - } - whenever(firebaseMessaging.token).thenReturn(task) + 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())) sut = WalletViewModel( bgDispatcher = testDispatcher, appContext = mock(), - appStorage = appStorage, - db = db, - keychain = keychain, - coreService = coreService, - blocktankNotificationsService = blocktankNotificationsService, - lightningService = lightningService, - firebaseMessaging = firebaseMessaging, - ldkNodeEventBus = ldkNodeEventBus, - settingsStore = settingsStore, - addressChecker = addressChecker, + walletRepo = walletRepo, + lightningRepo = lightningRepo ) } @@ -94,7 +68,7 @@ class WalletViewModelTest : BaseUnitTest() { balanceDetails = balanceDetails, bolt11 = "bolt11", bip21 = "bitcoin:onchainAddress", - nodeLifecycleState = NodeLifecycleState.Running, + nodeLifecycleState = NodeLifecycleState.Starting, nodeStatus = null, ) @@ -110,60 +84,32 @@ class WalletViewModelTest : BaseUnitTest() { @Test fun `start should register for notifications if token is not cached`() = test { setupExistingWalletMocks() - val task = mock> { - on(it.isComplete).thenReturn(true) - on(it.result).thenReturn("newToken") - } - whenever(firebaseMessaging.token).thenReturn(task) - whenever(keychain.loadString(Keychain.Key.PUSH_NOTIFICATION_TOKEN.name)).thenReturn("cachedToken") + whenever(lightningRepo.start(walletIndex = 0)).thenReturn(Result.success(Unit)) sut.start() - verify(blocktankNotificationsService).registerDevice("newToken") - } - - @Test - fun `start should skip register for notifications if token is cached`() = test { - setupExistingWalletMocks() - whenever(keychain.loadString(Keychain.Key.PUSH_NOTIFICATION_TOKEN.name)).thenReturn("cachedToken") - - sut.start() - - verify(blocktankNotificationsService, never()).registerDevice(anyString()) + verify(walletRepo).registerForNotifications() } @Test fun `manualRegisterForNotifications should register device with FCM token`() = test { sut.manualRegisterForNotifications() - verify(blocktankNotificationsService).registerDevice("cachedToken") + verify(walletRepo).registerForNotifications() } - private fun setupExistingWalletMocks() { - whenever(keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(true) + private fun setupExistingWalletMocks() = test { + whenever(walletRepo.walletExists()).thenReturn(true) sut.setWalletExistsState() - whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("mnemonic") - whenever(appStorage.onchainAddress).thenReturn("onchainAddress") - whenever(appStorage.bolt11).thenReturn("bolt11") - whenever(appStorage.bip21).thenReturn("bitcoin:onchainAddress") - wheneverBlocking { addressChecker.getAddressInfo(anyString()) }.thenReturn(mockAddressInfo) + 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) } } - -val mockAddressInfo = AddressInfo( - address = "bc1qar...", - chain_stats = AddressStats( - funded_txo_count = 15, - funded_txo_sum = 0, - spent_txo_count = 10, - spent_txo_sum = 0, - tx_count = 25 - ), - mempool_stats = AddressStats( - funded_txo_count = 1, - funded_txo_sum = 100000, - spent_txo_count = 0, - spent_txo_sum = 0, - tx_count = 1 - ) -)