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")
}
}