diff --git a/app/schemas/to.bitkit.data.AppDb/1.json b/app/schemas/to.bitkit.data.AppDb/1.json index 4b5011545..a02aaecb9 100644 --- a/app/schemas/to.bitkit.data.AppDb/1.json +++ b/app/schemas/to.bitkit.data.AppDb/1.json @@ -31,4 +31,4 @@ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ea0d5b36d92a5a3fb1523c3064686f7d')" ] } -} \ No newline at end of file +} diff --git a/app/schemas/to.bitkit.data.AppDb/2.json b/app/schemas/to.bitkit.data.AppDb/2.json new file mode 100644 index 000000000..d8c69daa6 --- /dev/null +++ b/app/schemas/to.bitkit.data.AppDb/2.json @@ -0,0 +1,66 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "548162ed64d13ae0bed807c23709b850", + "entities": [ + { + "tableName": "config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletIndex` INTEGER NOT NULL, PRIMARY KEY(`walletIndex`))", + "fields": [ + { + "fieldPath": "walletIndex", + "columnName": "walletIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletIndex" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "invoice_tag", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`paymentHash` TEXT NOT NULL, `tags` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`paymentHash`))", + "fields": [ + { + "fieldPath": "paymentHash", + "columnName": "paymentHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "paymentHash" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '548162ed64d13ae0bed807c23709b850')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/to/bitkit/data/AppDb.kt b/app/src/main/java/to/bitkit/data/AppDb.kt index 8792d3fb3..89a7dc2cf 100644 --- a/app/src/main/java/to/bitkit/data/AppDb.kt +++ b/app/src/main/java/to/bitkit/data/AppDb.kt @@ -6,6 +6,7 @@ import androidx.room.Database import androidx.room.Query import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.TypeConverters import androidx.room.Upsert import androidx.sqlite.db.SupportSQLiteDatabase import androidx.work.CoroutineWorker @@ -15,17 +16,24 @@ import androidx.work.WorkerParameters import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import to.bitkit.BuildConfig +import to.bitkit.data.dao.InvoiceTagDao import to.bitkit.data.entities.ConfigEntity +import to.bitkit.data.entities.InvoiceTagEntity +import to.bitkit.data.typeConverters.StringListConverter import to.bitkit.env.Env @Database( entities = [ ConfigEntity::class, + InvoiceTagEntity::class ], - version = 1, + version = 2, ) + +@TypeConverters(StringListConverter::class) abstract class AppDb : RoomDatabase() { abstract fun configDao(): ConfigDao + abstract fun invoiceTagDao(): InvoiceTagDao companion object { private val DB_NAME = "${BuildConfig.APPLICATION_ID}.${Env.network.name.lowercase()}.sqlite" diff --git a/app/src/main/java/to/bitkit/data/dao/InvoiceTagDao.kt b/app/src/main/java/to/bitkit/data/dao/InvoiceTagDao.kt new file mode 100644 index 000000000..0b474b12c --- /dev/null +++ b/app/src/main/java/to/bitkit/data/dao/InvoiceTagDao.kt @@ -0,0 +1,30 @@ +package to.bitkit.data.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import to.bitkit.data.entities.InvoiceTagEntity + +@Dao +interface InvoiceTagDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun saveInvoice(invoiceTag: InvoiceTagEntity) + + @Query("SELECT * FROM invoice_tag WHERE paymentHash = :paymentHash LIMIT 1") + suspend fun searchInvoice(paymentHash: String) : InvoiceTagEntity? + + @Delete + suspend fun deleteInvoice(invoiceTag: InvoiceTagEntity) + + @Query("DELETE FROM invoice_tag WHERE paymentHash = :paymentHash") + suspend fun deleteInvoiceByPaymentHash(paymentHash: String) + + @Query("DELETE FROM invoice_tag") + suspend fun deleteAllInvoices() + + @Query("DELETE FROM invoice_tag WHERE createdAt < :expirationTimeStamp") + suspend fun deleteExpiredInvoices(expirationTimeStamp: Long) +} diff --git a/app/src/main/java/to/bitkit/data/entities/InvoiceTagEntity.kt b/app/src/main/java/to/bitkit/data/entities/InvoiceTagEntity.kt new file mode 100644 index 000000000..168037177 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/entities/InvoiceTagEntity.kt @@ -0,0 +1,11 @@ +package to.bitkit.data.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "invoice_tag") +data class InvoiceTagEntity( + @PrimaryKey val paymentHash: String, + val tags: List, + val createdAt: Long +) diff --git a/app/src/main/java/to/bitkit/data/typeConverters/StringListConverter.kt b/app/src/main/java/to/bitkit/data/typeConverters/StringListConverter.kt new file mode 100644 index 000000000..b41f6bd6f --- /dev/null +++ b/app/src/main/java/to/bitkit/data/typeConverters/StringListConverter.kt @@ -0,0 +1,15 @@ +package to.bitkit.data.typeConverters + +import androidx.room.TypeConverter + +class StringListConverter { + @TypeConverter + fun fromString(value: String): List { + return value.split(",").map { it.trim() } + } + + @TypeConverter + fun fromList(list: List): String { + return list.joinToString(",") + } +} diff --git a/app/src/main/java/to/bitkit/models/NodeLifecycleState.kt b/app/src/main/java/to/bitkit/models/NodeLifecycleState.kt index bd7b13073..5b6c66fc7 100644 --- a/app/src/main/java/to/bitkit/models/NodeLifecycleState.kt +++ b/app/src/main/java/to/bitkit/models/NodeLifecycleState.kt @@ -12,6 +12,7 @@ sealed class NodeLifecycleState { fun isRunningOrStarting() = this is Running || this is Starting fun isStarting() = this is Starting fun isRunning() = this is Running + fun canRun() = this.isRunningOrStarting() || this is Initializing val displayState: String get() = when (this) { diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 5692a401f..2bb2b6dfd 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -4,7 +4,9 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.Address import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.Bolt11Invoice @@ -25,6 +27,7 @@ import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes @Singleton class LightningRepo @Inject constructor( @@ -36,12 +39,72 @@ class LightningRepo @Inject constructor( private val _nodeLifecycleState: MutableStateFlow = MutableStateFlow(NodeLifecycleState.Stopped) val nodeLifecycleState = _nodeLifecycleState.asStateFlow() + /** + * Executes the provided operation only if the node is running. + * If the node is not running, waits for it to be running for a specified timeout. + * + * @param operationName Name of the operation for logging + * @param waitTimeout Duration to wait for the node to be running + * @param operation Lambda to execute when the node is running + * @return Result of the operation, or failure if node isn't running or operation fails + */ + private suspend fun executeWhenNodeRunning( + operationName: String, + waitTimeout: Duration = 1.minutes, + operation: suspend () -> Result + ): Result = withContext(bgDispatcher) { + Logger.debug("Operation called: $operationName", context = TAG) + + if (nodeLifecycleState.value.isRunning()) { + return@withContext executeOperation(operationName, operation) + } + + // If node is not in a state that can become running, fail fast + if (!nodeLifecycleState.value.canRun()) { + return@withContext Result.failure( + Exception("Cannot execute $operationName: Node is ${nodeLifecycleState.value} and not starting") + ) + } + + val nodeRunning = withTimeoutOrNull(waitTimeout) { + if (nodeLifecycleState.value.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() } + Logger.debug("Operation executed: $operationName", context = TAG) + true + } ?: false + + if (!nodeRunning) { + return@withContext Result.failure( + Exception("Timeout waiting for node to be running to execute $operationName") + ) + } + + return@withContext executeOperation(operationName, operation) + } + + private suspend fun executeOperation( + operationName: String, + operation: suspend () -> Result + ): Result { + return try { + operation() + } catch (e: Throwable) { + Logger.error("$operationName error", e, context = TAG) + Result.failure(e) + } + } + suspend fun setup(walletIndex: Int): Result = withContext(bgDispatcher) { return@withContext try { lightningService.setup(walletIndex) Result.success(Unit) } catch (e: Throwable) { - Logger.error("Node setup error", e) + Logger.error("Node setup error", e, context = TAG) Result.failure(e) } } @@ -79,7 +142,7 @@ class LightningRepo @Inject constructor( _nodeLifecycleState.value = NodeLifecycleState.Running Result.success(Unit) } catch (e: Throwable) { - Logger.error("Node start error", e) + Logger.error("Node start error", e, context = TAG) _nodeLifecycleState.value = NodeLifecycleState.ErrorStarting(e) Result.failure(e) } @@ -91,156 +154,118 @@ class LightningRepo @Inject constructor( } try { - _nodeLifecycleState.value = NodeLifecycleState.Stopping - lightningService.stop() - _nodeLifecycleState.value = NodeLifecycleState.Stopped - Result.success(Unit) + executeWhenNodeRunning("stop") { + _nodeLifecycleState.value = NodeLifecycleState.Stopping + lightningService.stop() + _nodeLifecycleState.value = NodeLifecycleState.Stopped + Result.success(Unit) + } } catch (e: Throwable) { - Logger.error("Node stop error", e) + Logger.error("Node stop error", e, context = TAG) Result.failure(e) } } - suspend fun sync(): Result = withContext(bgDispatcher) { - try { - lightningService.sync() - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Sync error", e) - Result.failure(e) - } + suspend fun sync(): Result = executeWhenNodeRunning("Sync") { + lightningService.sync() + Result.success(Unit) } suspend fun wipeStorage(walletIndex: Int): Result = withContext(bgDispatcher) { - try { - lightningService.wipeStorage(walletIndex) - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Wipe storage error", e) - Result.failure(e) + stop().onSuccess { + return@withContext try { + lightningService.wipeStorage(walletIndex) + Result.success(Unit) + } catch (e: Throwable) { + Logger.error("Wipe storage error", e, context = TAG) + Result.failure(e) + } + }.onFailure { e -> + return@withContext Result.failure(e) } } - suspend fun connectToTrustedPeers(): Result = withContext(bgDispatcher) { - try { - lightningService.connectToTrustedPeers() - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Connect to trusted peers error", e) - Result.failure(e) - } + suspend fun connectToTrustedPeers(): Result = executeWhenNodeRunning("Connect to trusted peers") { + lightningService.connectToTrustedPeers() + Result.success(Unit) } - suspend fun disconnectPeer(peer: LnPeer): Result = withContext(bgDispatcher) { - try { - lightningService.disconnectPeer(peer) - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Disconnect peer error", e) - Result.failure(e) - } + suspend fun disconnectPeer(peer: LnPeer): Result = executeWhenNodeRunning("Disconnect peer") { + lightningService.disconnectPeer(peer) + Result.success(Unit) } - suspend fun newAddress(): Result = withContext(bgDispatcher) { - try { - val address = lightningService.newAddress() - Result.success(address) - } catch (e: Throwable) { - Logger.error("New address error", e) - Result.failure(e) - } + suspend fun newAddress(): Result = executeWhenNodeRunning("New address") { + val address = lightningService.newAddress() + Result.success(address) } - suspend fun checkAddressUsage(address: String): Result = withContext(bgDispatcher) { - try { - val addressInfo = addressChecker.getAddressInfo(address) - val hasTransactions = addressInfo.chain_stats.tx_count > 0 || addressInfo.mempool_stats.tx_count > 0 - Result.success(hasTransactions) - } catch (e: Throwable) { - Logger.error("Check address usage error", e) - Result.failure(e) - } + suspend fun 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, expirySeconds: UInt = 86_400u - ): Result = withContext(bgDispatcher) { - try { - val invoice = lightningService.receive(amountSats, description, expirySeconds) - Result.success(invoice) - } catch (e: Throwable) { - Logger.error("Create invoice error", e) - Result.failure(e) - } + ): Result = executeWhenNodeRunning("Create invoice") { + val invoice = lightningService.receive(amountSats, description, expirySeconds) + Result.success(invoice) } - suspend fun payInvoice(bolt11: String, sats: ULong? = null): Result = withContext(bgDispatcher) { - try { + suspend fun payInvoice(bolt11: String, sats: ULong? = null): Result = + executeWhenNodeRunning("Pay invoice") { val paymentId = lightningService.send(bolt11 = bolt11, sats = sats) Result.success(paymentId) - } catch (e: Throwable) { - Logger.error("Pay invoice error", e) - Result.failure(e) } - } - suspend fun sendOnChain(address: Address, sats: ULong): Result = withContext(bgDispatcher) { - try { + suspend fun sendOnChain(address: Address, sats: ULong): Result = + executeWhenNodeRunning("Send on-chain") { val txId = lightningService.send(address = address, sats = sats) Result.success(txId) - } catch (e: Throwable) { - Logger.error("sendOnChain error", e) - Result.failure(e) } - } - suspend fun getPayments(): Result> = withContext(bgDispatcher) { - try { - val payments = lightningService.payments - ?: return@withContext Result.failure(Exception("It wasn't possible get the payments")) - Result.success(payments) - } catch (e: Throwable) { - Logger.error("getPayments error", e) - Result.failure(e) - } + suspend fun getPayments(): Result> = executeWhenNodeRunning("Get payments") { + val payments = lightningService.payments + ?: return@executeWhenNodeRunning Result.failure(Exception("It wasn't possible get the payments")) + Result.success(payments) } suspend fun openChannel( peer: LnPeer, channelAmountSats: ULong, pushToCounterpartySats: ULong? = null - ): Result = withContext(bgDispatcher) { - try { - val result = lightningService.openChannel(peer, channelAmountSats, pushToCounterpartySats) - result - } catch (e: Throwable) { - Logger.error("Open channel error", e) - Result.failure(e) - } + ): Result = executeWhenNodeRunning("Open channel") { + lightningService.openChannel(peer, channelAmountSats, pushToCounterpartySats) } suspend fun closeChannel(userChannelId: String, counterpartyNodeId: String): Result = - withContext(bgDispatcher) { - try { - lightningService.closeChannel(userChannelId, counterpartyNodeId) - Result.success(Unit) - } catch (e: Throwable) { - Logger.error("Close channel error", e) - Result.failure(e) - } + executeWhenNodeRunning("Close channel") { + lightningService.closeChannel(userChannelId, counterpartyNodeId) + Result.success(Unit) } - fun canSend(amountSats: ULong): Boolean = lightningService.canSend(amountSats) + fun canSend(amountSats: ULong): Boolean = + nodeLifecycleState.value.isRunning() && lightningService.canSend(amountSats) fun getSyncFlow(): Flow = lightningService.syncFlow() - fun getNodeId(): String? = lightningService.nodeId - fun getBalances(): BalanceDetails? = lightningService.balances - fun getStatus(): NodeStatus? = lightningService.status - fun getPeers(): List? = lightningService.peers - fun getChannels(): List? = lightningService.channels + fun getNodeId(): String? = if (nodeLifecycleState.value.isRunning()) lightningService.nodeId else null + + fun getBalances(): BalanceDetails? = if (nodeLifecycleState.value.isRunning()) lightningService.balances else null - fun hasChannels(): Boolean = lightningService.channels?.isNotEmpty() == true + fun getStatus(): NodeStatus? = if (nodeLifecycleState.value.isRunning()) lightningService.status else null + + fun getPeers(): List? = if (nodeLifecycleState.value.isRunning()) lightningService.peers else null + + fun getChannels(): List? = + if (nodeLifecycleState.value.isRunning()) lightningService.channels else null + + fun hasChannels(): Boolean = nodeLifecycleState.value.isRunning() && lightningService.channels?.isNotEmpty() == true + + private companion object { + const val TAG = "LightningRepo" + } } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index fd2989a3c..07d822999 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -1,38 +1,53 @@ 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.tasks.await import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import org.lightningdevkit.ldknode.Network +import org.lightningdevkit.ldknode.Txid import to.bitkit.data.AppDb import to.bitkit.data.AppStorage import to.bitkit.data.SettingsStore import to.bitkit.data.entities.ConfigEntity +import to.bitkit.data.entities.InvoiceTagEntity import to.bitkit.data.keychain.Keychain 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.Toast import to.bitkit.services.BlocktankNotificationsService import to.bitkit.services.CoreService -import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger +import uniffi.bitkitcore.Activity +import uniffi.bitkitcore.ActivityFilter import uniffi.bitkitcore.IBtInfo +import uniffi.bitkitcore.PaymentType +import uniffi.bitkitcore.Scanner +import uniffi.bitkitcore.decode import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds @Singleton class WalletRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - @ApplicationContext private val appContext: Context, + @ApplicationContext private val appContext: Context, private val appStorage: AppStorage, private val db: AppDb, private val keychain: Keychain, @@ -80,6 +95,7 @@ class WalletRepo @Inject constructor( appStorage.clear() settingsStore.wipe() coreService.activity.removeAll() + deleteAllInvoices() Result.success(Unit) } catch (e: Throwable) { Logger.error("Wipe wallet error", e) @@ -166,7 +182,8 @@ class WalletRepo @Inject constructor( suspend fun getBlocktankInfo(): Result = withContext(bgDispatcher) { try { - val info = coreService.blocktank.info(refresh = true) ?: return@withContext Result.failure(Exception("Couldn't get info")) + 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) @@ -238,7 +255,167 @@ class WalletRepo @Inject constructor( return db.configDao().getAll() } + suspend fun saveInvoiceWithTags(bip21Invoice: String, tags: List) = withContext(bgDispatcher) { + if (tags.isEmpty()) return@withContext + + try { + deleteExpiredInvoices() + val decoded = decode(bip21Invoice) + val paymentHashOrAddress = when (decoded) { + is Scanner.Lightning -> decoded.invoice.paymentHash.toHex() + is Scanner.OnChain -> decoded.extractLightningHashOrAddress() + else -> null + } + + paymentHashOrAddress?.let { + db.invoiceTagDao().saveInvoice( + invoiceTag = InvoiceTagEntity( + paymentHash = paymentHashOrAddress, + tags = tags, + createdAt = System.currentTimeMillis() + ) + ) + } + } catch (e: Throwable) { + Logger.error("saveInvoice error", e, context = TAG) + } + } + + suspend fun searchInvoice(txId: Txid): Result = withContext(bgDispatcher) { + return@withContext try { + val invoiceTag = db.invoiceTagDao().searchInvoice(paymentHash = txId) ?: return@withContext Result.failure( + Exception("Invoice not found") + ) + Result.success(invoiceTag) + } catch (e: Throwable) { + Logger.error("searchInvoice error", e, context = TAG) + Result.failure(e) + } + } + + suspend fun deleteInvoice(txId: Txid) = withContext(bgDispatcher) { + try { + db.invoiceTagDao().deleteInvoiceByPaymentHash(paymentHash = txId) + } catch (e: Throwable) { + Logger.error("deleteInvoice error", e, context = TAG) + } + } + + suspend fun deleteAllInvoices() = withContext(bgDispatcher) { + try { + db.invoiceTagDao().deleteAllInvoices() + } catch (e: Throwable) { + Logger.error("deleteAllInvoices error", e, context = TAG) + } + } + + suspend fun deleteExpiredInvoices() = withContext(bgDispatcher) { + try { + val twoDaysAgoMillis = Clock.System.now().minus(2.days).toEpochMilliseconds() + db.invoiceTagDao().deleteExpiredInvoices(expirationTimeStamp = twoDaysAgoMillis) + } catch (e: Throwable) { + Logger.error("deleteExpiredInvoices error", e, context = TAG) + } + } + + suspend fun attachTagsToActivity( + paymentHashOrTxId: String?, + type: ActivityFilter, + txType: PaymentType, + tags: List + ): Result = withContext(bgDispatcher) { + Logger.debug("attachTagsToActivity $tags", context = TAG) + + when { + tags.isEmpty() -> { + Logger.debug("selectedTags empty", context = TAG) + return@withContext Result.failure(IllegalArgumentException("selectedTags empty")) + } + + paymentHashOrTxId == null -> { + Logger.error(msg = "null paymentHashOrTxId", context = TAG) + return@withContext Result.failure(IllegalArgumentException("null paymentHashOrTxId")) + } + } + + val activity = findActivityWithRetry( + paymentHashOrTxId = paymentHashOrTxId, + type = type, + txType = txType + ) ?: return@withContext Result.failure(IllegalStateException("Activity not found")) + + if (!activity.matchesId(paymentHashOrTxId)) { + Logger.error( + "ID mismatch. Expected: $paymentHashOrTxId found: ${activity.idValue}", + context = TAG + ) + return@withContext Result.failure(IllegalStateException("Activity ID mismatch")) + } + + coreService.activity.appendTags( + toActivityId = activity.idValue, + tags = tags + ).fold( + onFailure = { error -> + Logger.error("Error attaching tags $tags", error, context = TAG) + Result.failure(Exception("Error attaching tags $tags", error)) + }, + onSuccess = { + Logger.info("Success attaching tags $tags to activity ${activity.idValue}", context = TAG) + deleteInvoice(txId = paymentHashOrTxId) + Result.success(Unit) + } + ) + } + + private suspend fun findActivityWithRetry( + paymentHashOrTxId: String, + type: ActivityFilter, + txType: PaymentType + ): Activity? { + + suspend fun findActivity(): Activity? = coreService.activity.get( + filter = type, + txType = txType, + limit = 10u + ).firstOrNull { it.matchesId(paymentHashOrTxId) } + + var activity = findActivity() + if (activity == null) { + Logger.warn("activity not found, trying again after delay", context = TAG) + delay(5.seconds) + activity = findActivity() + } + return activity + } + + private fun Activity.matchesId(paymentHashOrTxId: String): Boolean = when (this) { + is Activity.Lightning -> paymentHashOrTxId == v1.id + is Activity.Onchain -> paymentHashOrTxId == v1.txId + } + + private val Activity.idValue: String + get() = when (this) { + is Activity.Lightning -> v1.id + is Activity.Onchain -> v1.txId + } + + private suspend fun Scanner.OnChain.extractLightningHashOrAddress(): String { + val address = this.invoice.address + val lightningInvoice: String = this.invoice.params?.get("lightning") ?: address + val decoded = decode(lightningInvoice) + + return when (decoded) { + is Scanner.Lightning -> decoded.invoice.paymentHash.toHex() + else -> address + } + } + private fun generateEntropyMnemonic(): String { return org.lightningdevkit.ldknode.generateEntropyMnemonic() } + + private companion object { + const val TAG = "WalletRepo" + } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt index 9ce0e7abb..708d6b62d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt @@ -8,6 +8,8 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -16,6 +18,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TextField import androidx.compose.runtime.Composable @@ -26,6 +29,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -35,10 +39,12 @@ import to.bitkit.models.PrimaryDisplay import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.AmountInputHandler import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Keyboard import to.bitkit.ui.components.NumberPadTextField import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.UnitButton import to.bitkit.ui.currencyViewModel import to.bitkit.ui.scaffold.SheetTopBar @@ -48,16 +54,20 @@ import to.bitkit.ui.theme.AppTextFieldDefaults import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.viewmodels.CurrencyUiState +import to.bitkit.viewmodels.MainUiState @Composable fun EditInvoiceScreen( currencyUiState: CurrencyUiState = LocalCurrencies.current, + walletUiState: MainUiState, updateInvoice: (ULong?, String) -> Unit, + onClickAddTag: () -> Unit, + onClickTag: (String) -> Unit, + onDescriptionUpdate: (String) -> Unit, onBack: () -> Unit, ) { val currencyVM = currencyViewModel ?: return var input: String by remember { mutableStateOf("") } - var noteText by remember { mutableStateOf("") } var satsString by remember { mutableStateOf("") } var keyboardVisible by remember { mutableStateOf(false) } @@ -72,19 +82,23 @@ fun EditInvoiceScreen( EditInvoiceContent( input = input, - noteText = noteText, + noteText = walletUiState.bip21Description, primaryDisplay = currencyUiState.primaryDisplay, displayUnit = currencyUiState.displayUnit, + tags = walletUiState.selectedTags, onBack = onBack, - onTextChanged = { newNote -> noteText = newNote }, + onTextChanged = onDescriptionUpdate, keyboardVisible = keyboardVisible, onClickBalance = { keyboardVisible = true }, onInputChanged = { newText -> input = newText }, onContinueKeyboard = { keyboardVisible = false }, - onContinueGeneral = { updateInvoice(satsString.toULongOrNull(), noteText) } + onContinueGeneral = { updateInvoice(satsString.toULongOrNull(), walletUiState.bip21Description) }, + onClickAddTag = onClickAddTag, + onClickTag = onClickTag ) } +@OptIn(ExperimentalLayoutApi::class) @Composable fun EditInvoiceContent( input: String, @@ -92,11 +106,14 @@ fun EditInvoiceContent( keyboardVisible: Boolean, primaryDisplay: PrimaryDisplay, displayUnit: BitcoinDisplayUnit, + tags: List, onBack: () -> Unit, onContinueKeyboard: () -> Unit, onClickBalance: () -> Unit, onContinueGeneral: () -> Unit, + onClickAddTag: () -> Unit, onTextChanged: (String) -> Unit, + onClickTag: (String) -> Unit, onInputChanged: (String) -> Unit, ) { Column( @@ -214,6 +231,38 @@ fun EditInvoiceContent( Spacer(modifier = Modifier.height(16.dp)) + Caption13Up(text = stringResource(R.string.wallet__tags), color = Colors.White64) + Spacer(modifier = Modifier.height(8.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + tags.map { tagText -> + TagButton( + text = tagText, + isSelected = false, + displayIconClose = true, + onClick = { onClickTag(tagText) }, + ) + } + } + PrimaryButton( + text = stringResource(R.string.wallet__tags_add), + size = ButtonSize.Small, + onClick = { onClickAddTag() }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_tag), + contentDescription = null, + tint = Colors.Brand + ) + }, + fullWidth = false + ) + Spacer(modifier = Modifier.weight(1f)) PrimaryButton( @@ -244,7 +293,10 @@ private fun Preview() { onClickBalance = {}, onInputChanged = {}, onContinueGeneral = {}, - onContinueKeyboard = {} + onContinueKeyboard = {}, + tags = listOf(), + onClickAddTag = {}, + onClickTag = {} ) } } @@ -264,7 +316,10 @@ private fun Preview2() { onClickBalance = {}, onInputChanged = {}, onContinueGeneral = {}, - onContinueKeyboard = {} + onContinueKeyboard = {}, + tags = listOf("Team", "Dinner", "Home", "Work"), + onClickAddTag = {}, + onClickTag = {} ) } } @@ -284,7 +339,10 @@ private fun Preview3() { onClickBalance = {}, onInputChanged = {}, onContinueGeneral = {}, - onContinueKeyboard = {} + onContinueKeyboard = {}, + tags = listOf("Team", "Dinner"), + onClickAddTag = {}, + onClickTag = {} ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 5ba8ddd3b..7f35a0743 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -1,5 +1,7 @@ package to.bitkit.ui.screens.wallets.receive +import android.app.Activity +import android.view.WindowManager import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -18,6 +20,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.Switch import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue @@ -36,6 +39,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Devices.PIXEL_TABLET import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -55,6 +59,7 @@ import to.bitkit.ui.components.Headline import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.QrCodeImage import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.wallets.send.AddTagScreen import to.bitkit.ui.shared.PagerWithIndicator import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.shared.util.shareText @@ -72,6 +77,7 @@ private object ReceiveRoutes { const val CONFIRM = "confirm" const val LIQUIDITY = "liquidity" const val EDIT_INVOICE = "edit_invoice" + const val ADD_TAG = "add_tag" } @Composable @@ -162,13 +168,36 @@ fun ReceiveQrSheet( } } composable(ReceiveRoutes.EDIT_INVOICE) { + val walletUiState by wallet.uiState.collectAsStateWithLifecycle() EditInvoiceScreen( + walletUiState = walletUiState, onBack = { navController.popBackStack() }, updateInvoice = { sats, description -> wallet.updateBip21Invoice(amountSats = sats, description = description) navController.popBackStack() + }, + onClickAddTag = { + navController.navigate(ReceiveRoutes.ADD_TAG) + }, + onClickTag = { tagToRemove -> + wallet.removeTag(tagToRemove) + }, + onDescriptionUpdate = { newText -> + wallet.updateBip21Description(newText = newText) + } + ) + } + composable(ReceiveRoutes.ADD_TAG) { + AddTagScreen( + onBack = { + navController.popBackStack() + }, + onTagSelected = { tag -> + wallet.addTagToSelected(tag) + navController.popBackStack() } ) + } } } @@ -183,6 +212,27 @@ private fun ReceiveQrScreen( onClickEditInvoice: () -> Unit, onClickReceiveOnSpending: () -> Unit, ) { + val context = LocalContext.current + val window = remember(context) { (context as Activity).window } + + // Keep screen on and set brightness to max while this composable is active + DisposableEffect(Unit) { + val originalBrightness = window.attributes.screenBrightness + val originalFlags = window.attributes.flags + + window.attributes = window.attributes.apply { + screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL + flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + } + + onDispose { + window.attributes = window.attributes.apply { + screenBrightness = originalBrightness + flags = originalFlags + } + } + } + val qrLogoImageRes by remember(walletState, cjitInvoice.value) { val resId = when { cjitInvoice.value?.isNotEmpty() == true -> R.drawable.ic_ln_circle @@ -241,7 +291,7 @@ private fun ReceiveQrScreen( } AnimatedVisibility(walletState.nodeLifecycleState.isRunning() && walletState.channels.isNotEmpty()) { Column { - AnimatedVisibility (!walletState.receiveOnSpendingBalance) { + AnimatedVisibility(!walletState.receiveOnSpendingBalance) { Headline( text = stringResource(R.string.wallet__receive_text_lnfunds).withAccent(accentColor = Colors.Purple) ) @@ -282,7 +332,7 @@ private fun ReceiveLightningFunds( onCjitToggle: (Boolean) -> Unit, ) { Column { - AnimatedVisibility (!cjitActive.value && cjitInvoice.value == null) { + AnimatedVisibility(!cjitActive.value && cjitInvoice.value == null) { Headline( text = stringResource(R.string.wallet__receive_text_lnfunds).withAccent(accentColor = Colors.Purple) ) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b70c5a589..e3be24ff3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -38,6 +38,7 @@ import to.bitkit.models.Toast import to.bitkit.models.toActivityFilter import to.bitkit.models.toTxType import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.WalletRepo import to.bitkit.services.CoreService import to.bitkit.services.LdkNodeEventBus import to.bitkit.services.ScannerService @@ -62,6 +63,7 @@ class AppViewModel @Inject constructor( private val keychain: Keychain, private val scannerService: ScannerService, private val lightningService: LightningRepo, + private val walletRepo: WalletRepo, private val coreService: CoreService, private val ldkNodeEventBus: LdkNodeEventBus, private val settingsStore: SettingsStore, @@ -220,6 +222,7 @@ class AppViewModel @Inject constructor( try { when (event) { is Event.PaymentReceived -> { + handleTags(event) showNewTransactionSheet( NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, @@ -280,6 +283,16 @@ class AppViewModel @Inject constructor( } } + private suspend fun handleTags(event: Event.PaymentReceived) { + val tags = walletRepo.searchInvoice(txId = event.paymentHash).getOrNull()?.tags.orEmpty() + walletRepo.attachTagsToActivity( + paymentHashOrTxId = event.paymentHash, + type = ActivityFilter.LIGHTNING, + txType = PaymentType.RECEIVED, + tags = tags + ) + } + private fun checkGeoStatus() { viewModelScope.launch { try { @@ -547,7 +560,13 @@ class AppViewModel @Inject constructor( val result = sendOnchain(validatedAddress.address, amount) if (result.isSuccess) { val txId = result.getOrNull() - attachTagsToActivity(paymentHashOrTxId = txId, type = ActivityFilter.ONCHAIN) + val tags = _sendUiState.value.selectedTags + walletRepo.attachTagsToActivity( + paymentHashOrTxId = txId, + type = ActivityFilter.ONCHAIN, + txType = PaymentType.SENT, + tags = tags + ) Logger.info("Onchain send result txid: $txId") setSendEffect( SendEffect.PaymentSuccess( @@ -575,7 +594,13 @@ class AppViewModel @Inject constructor( if (result.isSuccess) { val paymentHash = result.getOrNull() Logger.info("Lightning send result payment hash: $paymentHash") - attachTagsToActivity(paymentHashOrTxId = paymentHash, type = ActivityFilter.LIGHTNING) + val tags = _sendUiState.value.selectedTags + walletRepo.attachTagsToActivity( + paymentHashOrTxId = paymentHash, + type = ActivityFilter.LIGHTNING, + txType = PaymentType.SENT, + tags = tags + ) setSendEffect(SendEffect.PaymentSuccess()) resetSendState() } else { @@ -608,55 +633,6 @@ class AppViewModel @Inject constructor( } } - private fun attachTagsToActivity(paymentHashOrTxId: String?, type: ActivityFilter) { - val tags = _sendUiState.value.selectedTags - Logger.debug("attachTagsToActivity $tags") - if (tags.isEmpty()) { - Logger.debug("selectedTags empty") - return - } - - if (paymentHashOrTxId == null) { - Logger.error(msg = "null paymentHashOrTxId") - return - } - - viewModelScope.launch(Dispatchers.IO) { - val activity = coreService.activity.get(filter = type, txType = PaymentType.SENT, limit = 1u).firstOrNull() - - if (activity == null) { - Logger.error(msg = "Activity not found") - return@launch - } - - when (activity) { - is Activity.Lightning -> { - if (paymentHashOrTxId == activity.v1.id) { - coreService.activity.appendTags( - toActivityId = activity.v1.id, - tags = tags - ).onFailure { - Logger.error("Error attaching tags $tags") - } - } else { - Logger.error("Different activity id. Expected: $paymentHashOrTxId found: ${activity.v1.id}") - } - } - - is Activity.Onchain -> { - if (paymentHashOrTxId == activity.v1.txId) { - coreService.activity.appendTags( - toActivityId = activity.v1.id, - tags = tags - ) - } else { - Logger.error("Different txId. Expected: $paymentHashOrTxId found: ${activity.v1.txId}") - } - } - } - } - } - private suspend fun sendOnchain(address: String, amount: ULong): Result { return lightningService.sendOnChain(address = address, amount).onFailure { toast( @@ -672,7 +648,7 @@ class AppViewModel @Inject constructor( amount: ULong? = null, ): Result { return try { - val hash = lightningService.payInvoice(bolt11 = bolt11, sats = amount).getOrNull() + val hash = lightningService.payInvoice(bolt11 = bolt11, sats = amount).getOrNull() //TODO HANDLE FAILURE IN OTHER PR // Wait until matching payment event is received val result = ldkNodeEventBus.events.watchUntil { event -> diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 1eb3f96a7..2443f3e13 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -9,7 +9,6 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter @@ -18,7 +17,6 @@ import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event -import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.NodeStatus import to.bitkit.di.BgDispatcher import to.bitkit.env.Env @@ -307,7 +305,8 @@ class WalletViewModel @Inject constructor( lightningInvoice = walletRepo.getBolt11() ) walletRepo.setBip21(newBip21) - + walletRepo.saveInvoiceWithTags(bip21Invoice = newBip21, tags = _uiState.value.selectedTags) + clearTagsAndBip21DescriptionState() syncState() } } @@ -374,9 +373,6 @@ class WalletViewModel @Inject constructor( fun wipeStorage() { viewModelScope.launch(bgDispatcher) { - if (lightningRepo.nodeLifecycleState.value.isRunningOrStarting()) { - stopLightningNode() - } walletRepo.wipeWallet() .onSuccess { lightningRepo.wipeStorage(walletIndex = 0) @@ -509,6 +505,30 @@ class WalletViewModel @Inject constructor( } } } + + fun addTagToSelected(newTag: String) { + _uiState.update { + it.copy( + selectedTags = (it.selectedTags + newTag).distinct() + ) + } + } + + fun removeTag(tag: String) { + _uiState.update { + it.copy( + selectedTags = it.selectedTags.filterNot { tagItem -> tagItem == tag } + ) + } + } + + fun updateBip21Description(newText: String) { + _uiState.update { it.copy(bip21Description = newText) } + } + + private fun clearTagsAndBip21DescriptionState() { + _uiState.update { it.copy(selectedTags = listOf(), bip21Description = "") } + } } data class MainUiState( @@ -524,5 +544,6 @@ data class MainUiState( val isRefreshing: Boolean = false, val receiveOnSpendingBalance: Boolean = true, val bip21AmountSats: ULong? = null, - val bip21Description: String = "" + val bip21Description: String = "", + val selectedTags: List = listOf(), )