diff --git a/app/schemas/to.bitkit.data.AppDb/5.json b/app/schemas/to.bitkit.data.AppDb/5.json new file mode 100644 index 000000000..2c02bf775 --- /dev/null +++ b/app/schemas/to.bitkit.data.AppDb/5.json @@ -0,0 +1,102 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "eb40e0b1c9efc8f3cbf698d75fc4d4b6", + "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": "transfers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `amountSats` INTEGER NOT NULL, `channelId` TEXT, `fundingTxId` TEXT, `lspOrderId` TEXT, `isSettled` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `settledAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountSats", + "columnName": "amountSats", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelId", + "columnName": "channelId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fundingTxId", + "columnName": "fundingTxId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lspOrderId", + "columnName": "lspOrderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSettled", + "columnName": "isSettled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "settledAt", + "columnName": "settledAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "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, 'eb40e0b1c9efc8f3cbf698d75fc4d4b6')" + ] + } +} \ 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 38492bd4d..1763a3818 100644 --- a/app/src/main/java/to/bitkit/data/AppDb.kt +++ b/app/src/main/java/to/bitkit/data/AppDb.kt @@ -19,25 +19,21 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import to.bitkit.BuildConfig -import to.bitkit.data.dao.TagMetadataDao import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.ConfigEntity -import to.bitkit.data.entities.TagMetadataEntity import to.bitkit.data.entities.TransferEntity import to.bitkit.data.typeConverters.StringListConverter @Database( entities = [ ConfigEntity::class, - TagMetadataEntity::class, TransferEntity::class, ], - version = 4, + version = 5, ) @TypeConverters(StringListConverter::class) abstract class AppDb : RoomDatabase() { abstract fun configDao(): ConfigDao - abstract fun tagMetadataDao(): TagMetadataDao abstract fun transferDao(): TransferDao companion object { diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 00eb1fc15..d3be38bae 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -14,7 +14,6 @@ import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus import to.bitkit.models.BalanceState import to.bitkit.models.FxRate -import to.bitkit.models.TransactionMetadata import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -114,22 +113,6 @@ class CacheStore @Inject constructor( } } - suspend fun addTransactionMetadata(item: TransactionMetadata) { - if (item.txId in store.data.first().transactionsMetadata.map { it.txId }) return - - store.updateData { - it.copy(transactionsMetadata = it.transactionsMetadata + item) - } - } - - suspend fun removeTransactionMetadata(item: TransactionMetadata) { - if (item.txId !in store.data.first().transactionsMetadata.map { it.txId }) return - - store.updateData { - it.copy(transactionsMetadata = it.transactionsMetadata - item) - } - } - suspend fun reset() { store.updateData { AppCacheData() } Logger.info("Deleted all app cached data.") @@ -152,5 +135,4 @@ data class AppCacheData( val deletedActivities: List = listOf(), val activitiesPendingDelete: List = listOf(), val pendingBoostActivities: List = listOf(), - val transactionsMetadata: List = listOf(), ) diff --git a/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt b/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt deleted file mode 100644 index 8e03451f6..000000000 --- a/app/src/main/java/to/bitkit/data/dao/TagMetadataDao.kt +++ /dev/null @@ -1,71 +0,0 @@ -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 androidx.room.Upsert -import kotlinx.coroutines.flow.Flow -import to.bitkit.data.entities.TagMetadataEntity - -@Dao -interface TagMetadataDao { - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(tagMetadata: TagMetadataEntity) - - @Upsert - suspend fun upsert(entity: TagMetadataEntity) - - @Upsert - suspend fun upsert(entities: List) - - @Query("SELECT * FROM tag_metadata") - fun observeAll(): Flow> - - @Query("SELECT * FROM tag_metadata") - suspend fun getAll(): List - - // Search by payment hash (for invoices) - @Query("SELECT * FROM tag_metadata WHERE paymentHash = :paymentHash LIMIT 1") - suspend fun searchByPaymentHash(paymentHash: String): TagMetadataEntity? - - // Search by transaction ID - @Query("SELECT * FROM tag_metadata WHERE txId = :txId LIMIT 1") - suspend fun searchByTxId(txId: String): TagMetadataEntity? - - // Search by address - @Query("SELECT * FROM tag_metadata WHERE address = :address ORDER BY createdAt DESC LIMIT 1") - suspend fun searchByAddress(address: String): TagMetadataEntity? - - // Search by primary key (id) - @Query("SELECT * FROM tag_metadata WHERE id = :id LIMIT 1") - suspend fun searchById(id: String): TagMetadataEntity? - - // Get all receive transactions - @Query("SELECT * FROM tag_metadata WHERE isReceive = 1") - suspend fun getAllReceiveTransactions(): List - - // Get all send transactions - @Query("SELECT * FROM tag_metadata WHERE isReceive = 0") - suspend fun getAllSendTransactions(): List - - @Delete - suspend fun deleteTagMetadata(tagMetadata: TagMetadataEntity) - - @Query("DELETE FROM tag_metadata WHERE paymentHash = :paymentHash") - suspend fun deleteByPaymentHash(paymentHash: String) - - @Query("DELETE FROM tag_metadata WHERE txId = :txId") - suspend fun deleteByTxId(txId: String) - - @Query("DELETE FROM tag_metadata WHERE id = :id") - suspend fun deleteById(id: String) - - @Query("DELETE FROM tag_metadata") - suspend fun deleteAll() - - @Query("DELETE FROM tag_metadata WHERE createdAt < :expirationTimeStamp") - suspend fun deleteExpired(expirationTimeStamp: Long) -} diff --git a/app/src/main/java/to/bitkit/data/entities/TagMetadataEntity.kt b/app/src/main/java/to/bitkit/data/entities/TagMetadataEntity.kt deleted file mode 100644 index e4f8acb7d..000000000 --- a/app/src/main/java/to/bitkit/data/entities/TagMetadataEntity.kt +++ /dev/null @@ -1,23 +0,0 @@ -package to.bitkit.data.entities - -import androidx.room.Entity -import androidx.room.PrimaryKey -import kotlinx.serialization.Serializable - -@Serializable -@Entity(tableName = "tag_metadata") -/** - * @param id This will be paymentHash, txId, or address depending on context - * @param txId on-chain transaction id - * @param address on-chain address - * @param isReceive true for receive, false for send - * */ -data class TagMetadataEntity( - @PrimaryKey val id: String, - val paymentHash: String? = null, - val txId: String? = null, - val address: String, - val isReceive: Boolean, - val tags: List, - val createdAt: Long, -) diff --git a/app/src/main/java/to/bitkit/ext/TagMetadataEntity.kt b/app/src/main/java/to/bitkit/ext/TagMetadataEntity.kt deleted file mode 100644 index b586d9d8d..000000000 --- a/app/src/main/java/to/bitkit/ext/TagMetadataEntity.kt +++ /dev/null @@ -1,31 +0,0 @@ -package to.bitkit.ext - -import com.synonym.bitkitcore.PreActivityMetadata -import to.bitkit.data.entities.TagMetadataEntity - -// TODO use PreActivityMetadata -fun TagMetadataEntity.toActivityTagsMetadata() = PreActivityMetadata( - paymentId = id, - createdAt = createdAt.toULong(), - tags = tags, - paymentHash = paymentHash, - txId = txId, - address = address, - isReceive = isReceive, - feeRate = 0u, // TODO: update room db entity or drop it in favour of bitkit-core - isTransfer = false, // TODO: update room db entity or drop it in favour of bitkit-core - channelId = "", // TODO: update room db entity or drop it in favour of bitkit-core -) - -fun PreActivityMetadata.toTagMetadataEntity() = TagMetadataEntity( - id = paymentId, - createdAt = createdAt.toLong(), - tags = tags, - paymentHash = paymentHash, - txId = txId, - address = address.orEmpty(), - isReceive = isReceive, - // feeRate = 0u, - // isTransfer = false, - // channelId = "", -) diff --git a/app/src/main/java/to/bitkit/models/TransactionMetadata.kt b/app/src/main/java/to/bitkit/models/TransactionMetadata.kt deleted file mode 100644 index 0111791c0..000000000 --- a/app/src/main/java/to/bitkit/models/TransactionMetadata.kt +++ /dev/null @@ -1,14 +0,0 @@ -package to.bitkit.models - -import kotlinx.serialization.Serializable - -@Serializable -data class TransactionMetadata( - val txId: String, - val feeRate: UInt, - val address: String, - val isTransfer: Boolean, - val channelId: String?, -) { - fun transferTxId(): String? = txId.takeIf { isTransfer } -} diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 06955b746..96ebe79a8 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -9,7 +9,6 @@ import com.synonym.bitkitcore.IcJitEntry import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType -import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.SortDirection import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.TimeoutCancellationException @@ -24,10 +23,10 @@ import kotlinx.coroutines.withTimeout import kotlinx.datetime.Clock import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.PaymentDetails -import to.bitkit.data.AppDb +import org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentKind import to.bitkit.data.CacheStore import to.bitkit.data.dto.PendingBoostActivity -import to.bitkit.data.entities.TagMetadataEntity import to.bitkit.di.BgDispatcher import to.bitkit.ext.amountOnClose import to.bitkit.ext.matchesPaymentId @@ -49,9 +48,9 @@ class ActivityRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val coreService: CoreService, private val lightningRepo: LightningRepo, - private val cacheStore: CacheStore, - private val db: AppDb, + private val blocktankRepo: BlocktankRepo, private val addressChecker: AddressChecker, + private val cacheStore: CacheStore, private val transferRepo: TransferRepo, private val clock: Clock, ) { @@ -88,8 +87,6 @@ class ActivityRepo @Inject constructor( lightningRepo.getPayments().mapCatching { payments -> Logger.debug("Got payments with success, syncing activities", context = TAG) syncLdkNodePayments(payments).getOrThrow() - updateActivitiesMetadata() - syncTagsMetadata() boostPendingActivities() transferRepo.syncTransferStates().getOrThrow() }.onSuccess { @@ -113,11 +110,87 @@ class ActivityRepo @Inject constructor( * Syncs `ldk-node` [PaymentDetails] list to `bitkit-core` [Activity] items. */ private suspend fun syncLdkNodePayments(payments: List): Result = runCatching { - coreService.activity.syncLdkNodePaymentsToActivities(payments) + val channelIdsByTxId = findChannelsForPayments(payments) + coreService.activity.syncLdkNodePaymentsToActivities(payments, channelIdsByTxId = channelIdsByTxId) }.onFailure { e -> Logger.error("Error syncing LDK payments:", e, context = TAG) } + private suspend fun findChannelsForPayments( + payments: List, + ): Map = withContext(bgDispatcher) { + val channelIdsByTxId = mutableMapOf() + + payments.filter { it.kind is PaymentKind.Onchain }.forEach { payment -> + val kind = payment.kind as? PaymentKind.Onchain ?: return@forEach + val channelId = findChannelForTransaction(kind.txid, payment.direction) + if (channelId != null) { + channelIdsByTxId[kind.txid] = channelId + } + } + + return@withContext channelIdsByTxId + } + + private suspend fun findChannelForTransaction(txid: String, direction: PaymentDirection): String? { + return if (direction == PaymentDirection.OUTBOUND) { + findOpenChannelForTransaction(txid) + } else { + findClosedChannelForTransaction(txid) + } + } + + private suspend fun findOpenChannelForTransaction(txid: String): String? { + return try { + val channels = lightningRepo.lightningState.value.channels + if (channels.isEmpty()) return null + + channels.firstOrNull { channel -> + channel.fundingTxo?.txid == txid + }?.channelId + ?: run { + val orders = blocktankRepo.blocktankState.value.orders + val matchingOrder = orders.firstOrNull { order -> + order.payment?.onchain?.transactions?.any { it.txId == txid } == true + } ?: return null + + val orderChannel = matchingOrder.channel ?: return null + channels.firstOrNull { channel -> + channel.fundingTxo?.txid == orderChannel.fundingTx.id + }?.channelId + } + } catch (e: Exception) { + Logger.warn("Failed to find open channel for transaction: $txid", e, context = TAG) + null + } + } + + private suspend fun findClosedChannelForTransaction(txid: String): String? { + return try { + val closedChannelsResult = getClosedChannels(SortDirection.DESC) + val closedChannels = closedChannelsResult.getOrNull() ?: return null + if (closedChannels.isEmpty()) return null + + val txDetails = addressChecker.getTransaction(txid) + + txDetails.vin.firstNotNullOfOrNull { input -> + val inputTxid = input.txid ?: return@firstNotNullOfOrNull null + val inputVout = input.vout ?: return@firstNotNullOfOrNull null + + closedChannels.firstOrNull { channel -> + channel.fundingTxoTxid == inputTxid && channel.fundingTxoIndex == inputVout.toUInt() + }?.channelId + } + } catch (e: Exception) { + Logger.warn( + "Failed to check if transaction $txid spends closed channel funding UTXO", + e, + context = TAG + ) + null + } + } + /** * Gets a specific activity by payment hash or txID with retry logic */ @@ -215,7 +288,7 @@ class ActivityRepo @Inject constructor( suspend fun getClosedChannels( sortDirection: SortDirection = SortDirection.ASC, ): Result> = withContext(bgDispatcher) { - runCatching { + return@withContext runCatching { coreService.activity.closedChannels(sortDirection) }.onFailure { e -> Logger.error("Error getting closed channels (sortDirection=$sortDirection)", e, context = TAG) @@ -303,123 +376,6 @@ class ActivityRepo @Inject constructor( }.awaitAll() } - private suspend fun updateActivitiesMetadata() = withContext(bgDispatcher) { - cacheStore.data.first().transactionsMetadata.map { metadata -> - async { - findActivityByPaymentId( - paymentHashOrTxId = metadata.txId, - type = ActivityFilter.ALL, - txType = PaymentType.SENT - ).onSuccess { activityToUpdate -> - Logger.debug( - "updateActivitiesMetaData - Activity found: ${activityToUpdate.rawId()}", - context = TAG - ) - - if (activityToUpdate is Activity.Onchain) { - val onChainActivity = activityToUpdate.v1.copy( - feeRate = metadata.feeRate.toULong(), - address = metadata.address.ifEmpty { activityToUpdate.v1.address }, - isTransfer = metadata.isTransfer, - channelId = metadata.channelId, - transferTxId = metadata.transferTxId(), - updatedAt = nowTimestamp().toEpochMilli().toULong(), - ) - val updatedActivity = Activity.Onchain(v1 = onChainActivity) - - updateActivity(id = updatedActivity.v1.id, activity = updatedActivity).onSuccess { - cacheStore.removeTransactionMetadata(metadata) - } - } - } - } - }.awaitAll() - } - - @Suppress("LongMethod") - private suspend fun syncTagsMetadata(): Result = withContext(context = bgDispatcher) { - runCatching { - if (db.tagMetadataDao().getAll().isEmpty()) return@runCatching - val lastActivities = getActivities(limit = 10u).getOrNull() ?: return@runCatching - Logger.debug("syncTagsMetadata called") - - lastActivities.map { activity -> - async { - when (activity) { - is Activity.Lightning -> { - val paymentHash = activity.rawId() - db.tagMetadataDao().searchByPaymentHash(paymentHash = paymentHash)?.let { tagMetadata -> - Logger.debug("Tags metadata found! $tagMetadata", context = TAG) - addTagsToTransaction( - paymentHashOrTxId = paymentHash, - type = ActivityFilter.LIGHTNING, - txType = if (tagMetadata.isReceive) PaymentType.RECEIVED else PaymentType.SENT, - tags = tagMetadata.tags - ).onSuccess { - Logger.debug("Tags synced with success!", context = TAG) - db.tagMetadataDao().deleteByPaymentHash(paymentHash = paymentHash) - } - } - } - - is Activity.Onchain -> { - when (activity.v1.txType) { - PaymentType.RECEIVED -> { - // TODO Temporary solution while whe ldk-node doesn't return the address directly - Logger.verbose("Fetching data for txId: ${activity.v1.txId}", context = TAG) - runCatching { - addressChecker.getTransaction(activity.v1.txId) - }.onSuccess { txDetails -> - Logger.verbose("Tx detail fetched with success: $txDetails", context = TAG) - txDetails.vout.map { vOut -> - async { - vOut.scriptpubkey_address?.let { - Logger.verbose("Extracted address: $it", context = TAG) - db.tagMetadataDao().searchByAddress(it) - }?.let { tagMetadata -> - Logger.debug("Tags metadata found! $tagMetadata", context = TAG) - addTagsToTransaction( - paymentHashOrTxId = txDetails.txid, - type = ActivityFilter.ONCHAIN, - txType = PaymentType.RECEIVED, - tags = tagMetadata.tags - ).onSuccess { - Logger.debug( - "Tags synced with success! $tagMetadata", - context = TAG - ) - db.tagMetadataDao().deleteByTxId(activity.v1.txId) - } - } - } - }.awaitAll() - }.onFailure { - Logger.warn("Failed getting transaction detail", context = TAG) - } - } - - PaymentType.SENT -> { - db.tagMetadataDao().searchByTxId(activity.v1.txId)?.let { tagMetadata -> - addTagsToTransaction( - paymentHashOrTxId = activity.v1.txId, - type = ActivityFilter.ONCHAIN, - txType = PaymentType.SENT, - tags = tagMetadata.tags - ).onSuccess { - Logger.debug("Tags synced with success! $tagMetadata", context = TAG) - db.tagMetadataDao().deleteByTxId(activity.v1.txId) - } - } - } - } - } - } - } - }.awaitAll() - Result.success(Unit) - } - } - private suspend fun boostPendingActivities() = withContext(bgDispatcher) { cacheStore.data.first().pendingBoostActivities.map { pendingBoostActivity -> async { @@ -676,55 +632,6 @@ class ActivityRepo @Inject constructor( } } - /** - * Get all [PreActivityMetadata] for backup - */ - suspend fun getAllPreActivityMetadata(): Result> = withContext(bgDispatcher) { - return@withContext runCatching { - coreService.activity.getAllPreActivityMetadata() - }.onFailure { e -> - Logger.error("getAllPreActivityMetadata error", e, context = TAG) - } - } - - /** - * Upsert all [PreActivityMetadata] - */ - suspend fun upsertPreActivityMetadata(list: List): Result = withContext(bgDispatcher) { - return@withContext runCatching { - coreService.activity.upsertPreActivityMetadata(list) - }.onFailure { e -> - Logger.error("upsertPreActivityMetadata error", e, context = TAG) - } - } - - suspend fun saveTagsMetadata( - id: String, - paymentHash: String? = null, - txId: String? = null, - address: String, - isReceive: Boolean, - tags: List, - ): Result = withContext(bgDispatcher) { - return@withContext runCatching { - require(tags.isNotEmpty()) - - val entity = TagMetadataEntity( - id = id, - paymentHash = paymentHash, - txId = txId, - address = address, - isReceive = isReceive, - tags = tags, - createdAt = nowTimestamp().toEpochMilli() - ) - db.tagMetadataDao().insert(tagMetadata = entity) - Logger.debug("Tag metadata saved: $entity", context = TAG) - }.onFailure { e -> - Logger.error("getAllAvailableTags error", e, context = TAG) - } - } - suspend fun restoreFromBackup(payload: ActivityBackupV1): Result = withContext(bgDispatcher) { return@withContext runCatching { coreService.activity.upsertList(payload.activities) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 142a0f4b2..bae630283 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -31,8 +31,6 @@ import to.bitkit.di.IoDispatcher import to.bitkit.di.json import to.bitkit.ext.formatPlural import to.bitkit.ext.nowMillis -import to.bitkit.ext.toActivityTagsMetadata -import to.bitkit.ext.toTagMetadataEntity import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus @@ -75,6 +73,7 @@ class BackupRepo @Inject constructor( private val widgetsStore: WidgetsStore, private val blocktankRepo: BlocktankRepo, private val activityRepo: ActivityRepo, + private val preActivityMetadataRepo: PreActivityMetadataRepo, private val lightningService: LightningService, private val clock: Clock, private val db: AppDb, @@ -216,9 +215,10 @@ class BackupRepo @Inject constructor( } dataListenerJobs.add(transfersJob) - // METADATA - Observe tag metadata - val tagMetadataJob = scope.launch { - db.tagMetadataDao().observeAll() + // METADATA - Observe entire CacheStore excluding backup statuses + val cacheMetadataJob = scope.launch { + cacheStore.data + .map { it.copy(backupStatuses = mapOf()) } .distinctUntilChanged() .drop(1) .collect { @@ -226,21 +226,18 @@ class BackupRepo @Inject constructor( markBackupRequired(BackupCategory.METADATA) } } - dataListenerJobs.add(tagMetadataJob) + dataListenerJobs.add(cacheMetadataJob) - // METADATA - Observe entire CacheStore excluding backup statuses - // TODO use PreActivityMetadata - val cacheMetadataJob = scope.launch { - cacheStore.data - .map { it.copy(backupStatuses = mapOf()) } - .distinctUntilChanged() + // METADATA - Observe pre-activity metadata changes + val preActivityMetadataJob = scope.launch { + preActivityMetadataRepo.preActivityMetadataChanged .drop(1) .collect { if (shouldSkipBackup()) return@collect markBackupRequired(BackupCategory.METADATA) } } - dataListenerJobs.add(cacheMetadataJob) + dataListenerJobs.add(preActivityMetadataJob) // BLOCKTANK - Observe blocktank state changes (orders, cjitEntries, info) val blocktankJob = scope.launch { @@ -434,14 +431,12 @@ class BackupRepo @Inject constructor( } BackupCategory.METADATA -> { - val tagMetadata = db.tagMetadataDao().getAll().map { it.toActivityTagsMetadata() } + val preActivityMetadata = preActivityMetadataRepo.getAllPreActivityMetadata().getOrDefault(emptyList()) val cacheData = cacheStore.data.first() - // TODO use PreActivityMetadata - // val preActivityMetadata = activityRepo.getAllPreActivityMetadata().getOrDefault(emptyList()) val payload = MetadataBackupV1( createdAt = currentTimeMillis(), - tagMetadata = tagMetadata, + tagMetadata = preActivityMetadata, cache = cacheData, ) @@ -493,11 +488,8 @@ class BackupRepo @Inject constructor( cacheStore.update { cleanedUp } Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG) onCacheRestored() - // TODO use PreActivityMetadata - // activityRepo.upsertPreActivityMetadata(parsed.tagMetadata) - val tagMetadata = parsed.tagMetadata.map { it.toTagMetadataEntity() } - db.tagMetadataDao().upsert(tagMetadata) - Logger.debug("Restored ${tagMetadata.size} pre-activity metadata", TAG) + preActivityMetadataRepo.upsertPreActivityMetadata(parsed.tagMetadata).getOrNull() + Logger.debug("Restored ${parsed.tagMetadata.size} pre-activity metadata", TAG) parsed.createdAt } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 4c83360d9..8d32de835 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -4,6 +4,7 @@ import com.google.firebase.messaging.FirebaseMessaging import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.FeeRates import com.synonym.bitkitcore.LightningInvoice +import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.createChannelRequestUrl import com.synonym.bitkitcore.createWithdrawCallbackUrl @@ -41,10 +42,10 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.ext.getSatsPerVByteFor +import to.bitkit.ext.nowTimestamp import to.bitkit.models.CoinSelectionPreference import to.bitkit.models.NodeLifecycleState import to.bitkit.models.OpenChannelResult -import to.bitkit.models.TransactionMetadata import to.bitkit.models.TransactionSpeed import to.bitkit.models.toCoinSelectAlgorithm import to.bitkit.models.toCoreNetwork @@ -67,6 +68,7 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @Singleton +@Suppress("LongParameterList") class LightningRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningService: LightningService, @@ -78,6 +80,7 @@ class LightningRepo @Inject constructor( private val keychain: Keychain, private val lnurlService: LnurlService, private val cacheStore: CacheStore, + private val preActivityMetadataRepo: PreActivityMetadataRepo, ) { private val _lightningState = MutableStateFlow(LightningState()) val lightningState = _lightningState.asStateFlow() @@ -629,6 +632,7 @@ class LightningRepo @Inject constructor( isTransfer: Boolean = false, channelId: String? = null, isMaxAmount: Boolean = false, + tags: List = emptyList(), ): Result = executeWhenNodeRunning("sendOnChain") { require(address.isNotEmpty()) { "Send address cannot be empty" } @@ -651,15 +655,22 @@ class LightningRepo @Inject constructor( utxosToSpend = finalUtxosToSpend, isMaxAmount = isMaxAmount ) - cacheStore.addTransactionMetadata( - TransactionMetadata( - txId = txId, - feeRate = satsPerVByte, - address = address, - isTransfer = isTransfer, - channelId = channelId, - ) + + val addressString = address.toString() + val preActivityMetadata = PreActivityMetadata( + paymentId = txId, + createdAt = nowTimestamp().toEpochMilli().toULong(), + tags = tags, + paymentHash = null, + txId = txId, + address = addressString, + isReceive = false, + feeRate = satsPerVByte.toULong(), + isTransfer = isTransfer, + channelId = channelId ?: "", ) + preActivityMetadataRepo.addPreActivityMetadata(preActivityMetadata) + syncState() Result.success(txId) } diff --git a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt new file mode 100644 index 000000000..5b3ca2bb1 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt @@ -0,0 +1,155 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.PreActivityMetadata +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.nowMillis +import to.bitkit.ext.nowTimestamp +import to.bitkit.services.CoreService +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PreActivityMetadataRepo @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val coreService: CoreService, + private val clock: Clock, +) { + private val _preActivityMetadataChanged = MutableStateFlow(0L) + val preActivityMetadataChanged: StateFlow = _preActivityMetadataChanged.asStateFlow() + + private fun notifyChanged() = _preActivityMetadataChanged.update { nowMillis(clock) } + + suspend fun getAllPreActivityMetadata(): Result> = withContext(ioDispatcher) { + return@withContext runCatching { + coreService.activity.getAllPreActivityMetadata() + }.onFailure { e -> + Logger.error("getAllPreActivityMetadata error", e, context = TAG) + } + } + + suspend fun upsertPreActivityMetadata(list: List): Result = withContext(ioDispatcher) { + return@withContext runCatching { + coreService.activity.upsertPreActivityMetadata(list) + notifyChanged() + }.onFailure { e -> + Logger.error("upsertPreActivityMetadata error", e, context = TAG) + } + } + + suspend fun addPreActivityMetadata(metadata: PreActivityMetadata): Result = withContext(ioDispatcher) { + return@withContext runCatching { + coreService.activity.addPreActivityMetadata(metadata) + notifyChanged() + }.onFailure { e -> + Logger.error("addPreActivityMetadata error", e, context = TAG) + } + } + + suspend fun addPreActivityMetadataTags( + paymentId: String, + tags: List, + ): Result = withContext(ioDispatcher) { + return@withContext runCatching { + coreService.activity.addPreActivityMetadataTags(paymentId, tags) + notifyChanged() + Logger.debug("Added tags to pre-activity metadata: paymentId=$paymentId, tags=$tags", context = TAG) + }.onFailure { e -> + Logger.error("addPreActivityMetadataTags error for paymentId: $paymentId", e, context = TAG) + } + } + + suspend fun removePreActivityMetadataTags( + paymentId: String, + tags: List, + ): Result = withContext(ioDispatcher) { + return@withContext runCatching { + coreService.activity.removePreActivityMetadataTags(paymentId, tags) + notifyChanged() + Logger.debug("Removed tags from pre-activity metadata: paymentId=$paymentId, tags=$tags", context = TAG) + }.onFailure { e -> + Logger.error("removePreActivityMetadataTags error for paymentId: $paymentId", e, context = TAG) + } + } + + suspend fun resetPreActivityMetadataTags(paymentId: String): Result = withContext(ioDispatcher) { + return@withContext runCatching { + coreService.activity.resetPreActivityMetadataTags(paymentId) + notifyChanged() + Logger.debug("Reset tags for pre-activity metadata: paymentId=$paymentId", context = TAG) + }.onFailure { e -> + Logger.error("resetPreActivityMetadataTags error for paymentId: $paymentId", e, context = TAG) + } + } + + suspend fun getPreActivityMetadata( + searchKey: String, + searchByAddress: Boolean = false, + ): Result = withContext(ioDispatcher) { + return@withContext runCatching { + coreService.activity.getPreActivityMetadata(searchKey, searchByAddress) + }.onFailure { e -> + Logger.error( + "getPreActivityMetadata error for searchKey: $searchKey, searchByAddress: $searchByAddress", + e, + context = TAG + ) + } + } + + suspend fun deletePreActivityMetadata(paymentId: String): Result = withContext(ioDispatcher) { + return@withContext runCatching { + coreService.activity.deletePreActivityMetadata(paymentId) + notifyChanged() + Logger.debug("Deleted pre-activity metadata: paymentId=$paymentId", context = TAG) + }.onFailure { e -> + Logger.error("deletePreActivityMetadata error for paymentId: $paymentId", e, context = TAG) + } + } + + @Suppress("LongParameterList") + suspend fun savePreActivityMetadata( + id: String, + paymentHash: String? = null, + txId: String? = null, + address: String, + isReceive: Boolean, + tags: List, + feeRate: ULong? = null, + isTransfer: Boolean = false, + channelId: String? = null, + ): Result = withContext(ioDispatcher) { + return@withContext runCatching { + require(tags.isNotEmpty() || isTransfer) + + val preActivityMetadata = PreActivityMetadata( + paymentId = id, + createdAt = nowTimestamp().toEpochMilli().toULong(), + tags = tags, + paymentHash = paymentHash, + txId = txId, + address = address, + isReceive = isReceive, + feeRate = feeRate ?: 0u, + isTransfer = isTransfer, + channelId = channelId ?: "", + ) + coreService.activity.upsertPreActivityMetadata(listOf(preActivityMetadata)) + notifyChanged() + Logger.debug("Pre-activity metadata saved: $preActivityMetadata", context = TAG) + }.onFailure { e -> + Logger.error("savePreActivityMetadata error", e, context = TAG) + } + } + + private companion object { + const val TAG = "PreActivityMetadataRepo" + } +} diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 9157ffb68..ec281e9f1 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -1,6 +1,7 @@ package to.bitkit.repositories import com.synonym.bitkitcore.AddressType +import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.decode import kotlinx.coroutines.CoroutineDispatcher @@ -13,12 +14,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.datetime.Clock import org.lightningdevkit.ldknode.Event -import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore -import to.bitkit.data.entities.TagMetadataEntity import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Env @@ -37,19 +35,18 @@ import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError import javax.inject.Inject import javax.inject.Singleton -import kotlin.time.Duration.Companion.days @Suppress("LongParameterList") @Singleton class WalletRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val db: AppDb, private val keychain: Keychain, private val coreService: CoreService, private val settingsStore: SettingsStore, private val addressChecker: AddressChecker, private val lightningRepo: LightningRepo, private val cacheStore: CacheStore, + private val preActivityMetadataRepo: PreActivityMetadataRepo, private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase, private val wipeWalletUseCase: WipeWalletUseCase, ) { @@ -98,16 +95,64 @@ class WalletRepo @Inject constructor( suspend fun refreshBip21(): Result = withContext(bgDispatcher) { Logger.debug("Refreshing bip21", context = TAG) + // Get old payment ID and tags before refreshing (which may change payment ID) + val oldPaymentId = paymentId() + val tagsToMigrate = if (oldPaymentId != null && oldPaymentId.isNotEmpty()) { + preActivityMetadataRepo + .getPreActivityMetadata(oldPaymentId, searchByAddress = false) + .getOrNull() + ?.tags ?: emptyList() + } else { + emptyList() + } + val (_, shouldBlockLightningReceive) = coreService.checkGeoBlock() _walletState.update { it.copy(receiveOnSpendingBalance = !shouldBlockLightningReceive) } - clearBip21State() + clearBip21State(clearTags = false) refreshAddressIfNeeded() updateBip21Invoice() + + val newPaymentId = paymentId() + val newBip21Url = _walletState.value.bip21 + if (newPaymentId != null && newPaymentId.isNotEmpty() && newBip21Url.isNotEmpty()) { + persistPreActivityMetadata(newPaymentId, tagsToMigrate, newBip21Url) + } + return@withContext Result.success(Unit) } + private suspend fun persistPreActivityMetadata( + paymentId: String, + tags: List, + bip21Url: String, + ) { + val onChainAddress = getOnchainAddress() + val paymentHash = runCatching { + when (val decoded = decode(bip21Url)) { + is Scanner.Lightning -> decoded.invoice.paymentHash.toHex() + is Scanner.OnChain -> decoded.extractLightningHash() + else -> null + } + }.getOrNull() + + val preActivityMetadata = PreActivityMetadata( + paymentId = paymentId, + createdAt = nowTimestamp().toEpochMilli().toULong(), + tags = tags, + paymentHash = paymentHash, + txId = null, + address = onChainAddress, + isReceive = true, + feeRate = 0u, + isTransfer = false, + channelId = "", + ) + + preActivityMetadataRepo.addPreActivityMetadata(preActivityMetadata) + } + suspend fun observeLdkWallet() = withContext(bgDispatcher) { lightningRepo.getSyncFlow() .collect { @@ -336,11 +381,11 @@ class WalletRepo @Inject constructor( fun setBip21Description(description: String) = _walletState.update { it.copy(bip21Description = description) } - fun clearBip21State() { + fun clearBip21State(clearTags: Boolean = true) { _walletState.update { it.copy( bip21 = "", - selectedTags = emptyList(), + selectedTags = if (clearTags) emptyList() else it.selectedTags, bip21AmountSats = null, bip21Description = "", ) @@ -357,30 +402,116 @@ class WalletRepo @Inject constructor( return@withContext Result.success(Unit) } - // Tags - suspend fun addTagToSelected(newTag: String) { - _walletState.update { - it.copy( - selectedTags = (it.selectedTags + newTag).distinct() + // Payment ID management + private suspend fun paymentHash(): String? = withContext(bgDispatcher) { + val bolt11 = getBolt11() + if (bolt11.isEmpty()) return@withContext null + return@withContext runCatching { + when (val decoded = decode(bolt11)) { + is Scanner.Lightning -> decoded.invoice.paymentHash.toHex() + else -> null + } + }.onFailure { e -> + Logger.error("Error extracting payment hash from bolt11", e, context = TAG) + }.getOrNull() + } + + suspend fun paymentId(): String? = withContext(bgDispatcher) { + val hash = paymentHash() + if (hash != null) return@withContext hash + val address = getOnchainAddress() + return@withContext if (address.isEmpty()) null else address + } + + // Pre-activity metadata tag management + suspend fun addTagToSelected(newTag: String): Result = withContext(bgDispatcher) { + val paymentId = paymentId() + if (paymentId == null || paymentId.isEmpty()) { + Logger.warn("Cannot add tag: payment ID not available", context = TAG) + return@withContext Result.failure( + IllegalStateException("Cannot add tag: payment ID not available") ) } - settingsStore.addLastUsedTag(newTag) + + return@withContext preActivityMetadataRepo.addPreActivityMetadataTags(paymentId, listOf(newTag)) + .onSuccess { + _walletState.update { + it.copy( + selectedTags = (it.selectedTags + newTag).distinct() + ) + } + settingsStore.addLastUsedTag(newTag) + }.onFailure { e -> + Logger.error("Failed to add tag to pre-activity metadata", e, context = TAG) + } } - fun removeTag(tag: String) { - _walletState.update { - it.copy( - selectedTags = it.selectedTags.filterNot { tagItem -> tagItem == tag } + suspend fun removeTag(tag: String): Result = withContext(bgDispatcher) { + val paymentId = paymentId() + if (paymentId == null || paymentId.isEmpty()) { + Logger.warn("Cannot remove tag: payment ID not available", context = TAG) + return@withContext Result.failure( + IllegalStateException("Cannot remove tag: payment ID not available") ) } + + return@withContext preActivityMetadataRepo.removePreActivityMetadataTags(paymentId, listOf(tag)) + .onSuccess { + _walletState.update { + it.copy( + selectedTags = it.selectedTags.filterNot { tagItem -> tagItem == tag } + ) + } + }.onFailure { e -> + Logger.error("Failed to remove tag from pre-activity metadata", e, context = TAG) + } } - // BIP21 invoice creation + suspend fun resetPreActivityMetadataTagsForCurrentInvoice() = withContext(bgDispatcher) { + val paymentId = paymentId() + if (paymentId == null || paymentId.isEmpty()) return@withContext + + preActivityMetadataRepo.resetPreActivityMetadataTags(paymentId).onSuccess { + _walletState.update { it.copy(selectedTags = emptyList()) } + }.onFailure { e -> + Logger.error("Failed to reset tags for pre-activity metadata", e, context = TAG) + } + } + + suspend fun loadTagsForCurrentInvoice() { + val paymentId = paymentId() + if (paymentId == null || paymentId.isEmpty()) { + _walletState.update { it.copy(selectedTags = emptyList()) } + return + } + + preActivityMetadataRepo.getPreActivityMetadata(paymentId, searchByAddress = false) + .onSuccess { metadata -> + _walletState.update { + it.copy(selectedTags = metadata?.tags ?: emptyList()) + } + } + .onFailure { e -> + Logger.error("Failed to load tags for current invoice", e, context = TAG) + } + } + + // BIP21 invoice creation and persistence suspend fun updateBip21Invoice( amountSats: ULong? = walletState.value.bip21AmountSats, description: String = walletState.value.bip21Description, ): Result = withContext(bgDispatcher) { - try { + return@withContext runCatching { + val oldPaymentId = paymentId() + val tagsToMigrate = if (oldPaymentId != null && oldPaymentId.isNotEmpty()) { + preActivityMetadataRepo + .getPreActivityMetadata(oldPaymentId, searchByAddress = false) + .getOrNull() + ?.tags ?: emptyList() + } else { + emptyList() + } + setBip21AmountSats(amountSats) setBip21Description(description) @@ -393,11 +524,15 @@ class WalletRepo @Inject constructor( setBolt11("") } val newBip21Url = updateBip21Url(amountSats, description) - persistTagsMetadata(newBip21Url) - Result.success(Unit) - } catch (e: Throwable) { + setBip21(newBip21Url) + + // Persist metadata with migrated tags + val newPaymentId = paymentId() + if (newPaymentId != null && newPaymentId.isNotEmpty() && newBip21Url.isNotEmpty()) { + persistPreActivityMetadata(newPaymentId, tagsToMigrate, newBip21Url) + } + }.onFailure { e -> Logger.error("Update BIP21 invoice error", e, context = TAG) - Result.failure(e) } } @@ -419,45 +554,6 @@ class WalletRepo @Inject constructor( } } - private suspend fun persistTagsMetadata(bip21Url: String) = - withContext(bgDispatcher) { - val tags = _walletState.value.selectedTags - if (tags.isEmpty()) return@withContext - - val onChainAddress = getOnchainAddress() - - try { - deleteExpiredTagMetadata() - val paymentHash = when (val decoded = decode(bip21Url)) { - is Scanner.Lightning -> decoded.invoice.paymentHash.toHex() - is Scanner.OnChain -> decoded.extractLightningHash() - else -> null - } - - val entity = TagMetadataEntity( - id = paymentHash ?: onChainAddress, - paymentHash = paymentHash, - tags = tags, - address = onChainAddress, - isReceive = true, - createdAt = nowTimestamp().toEpochMilli() - ) - db.tagMetadataDao().insert(tagMetadata = entity) - Logger.debug("Tag metadata saved: $entity", context = TAG) - } catch (e: Throwable) { - Logger.error("Error persisting tag metadata", e, context = TAG) - } - } - - private suspend fun deleteExpiredTagMetadata() = withContext(bgDispatcher) { - try { - val twoDaysAgoMillis = Clock.System.now().minus(2.days).toEpochMilliseconds() - db.tagMetadataDao().deleteExpired(expirationTimeStamp = twoDaysAgoMillis) - } catch (e: Throwable) { - Logger.error("Error deleting expired tag metadata records", e, context = TAG) - } - } - private suspend fun Scanner.OnChain.extractLightningHash(): String? { val lightningInvoice: String = this.invoice.params?.get("lightning") ?: return null diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 3968a749a..b1e7ca73c 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -67,9 +67,11 @@ import to.bitkit.data.CacheStore import to.bitkit.env.Env import to.bitkit.ext.amountSats import to.bitkit.models.toCoreNetwork +import to.bitkit.utils.AddressChecker import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError +import to.bitkit.utils.TxDetails import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random @@ -81,10 +83,18 @@ class CoreService @Inject constructor( private val lightningService: LightningService, private val httpClient: HttpClient, private val cacheStore: CacheStore, + private val addressChecker: AddressChecker, ) { private var walletIndex: Int = 0 - val activity: ActivityService by lazy { ActivityService(coreService = this, cacheStore = cacheStore) } + val activity: ActivityService by lazy { + ActivityService( + coreService = this, + cacheStore = cacheStore, + addressChecker = addressChecker, + lightningService = lightningService + ) + } val blocktank: BlocktankService by lazy { BlocktankService( coreService = this, @@ -192,6 +202,8 @@ private const val CHUNK_SIZE = 50 class ActivityService( @Suppress("unused") private val coreService: CoreService, // used to ensure CoreService inits first private val cacheStore: CacheStore, + private val addressChecker: AddressChecker, + private val lightningService: LightningService, ) { suspend fun removeAll() { ServiceQueue.CORE.background { @@ -308,6 +320,33 @@ class ActivityService( com.synonym.bitkitcore.upsertPreActivityMetadata(list) } + suspend fun addPreActivityMetadata(preActivityMetadata: PreActivityMetadata) = ServiceQueue.CORE.background { + com.synonym.bitkitcore.addPreActivityMetadata(preActivityMetadata = preActivityMetadata) + } + + suspend fun addPreActivityMetadataTags(paymentId: String, tags: List) = ServiceQueue.CORE.background { + com.synonym.bitkitcore.addPreActivityMetadataTags(paymentId = paymentId, tags = tags) + } + + suspend fun removePreActivityMetadataTags(paymentId: String, tags: List) = ServiceQueue.CORE.background { + com.synonym.bitkitcore.removePreActivityMetadataTags(paymentId = paymentId, tags = tags) + } + + suspend fun resetPreActivityMetadataTags(paymentId: String) = ServiceQueue.CORE.background { + com.synonym.bitkitcore.resetPreActivityMetadataTags(paymentId = paymentId) + } + + suspend fun getPreActivityMetadata( + searchKey: String, + searchByAddress: Boolean = false, + ): PreActivityMetadata? = ServiceQueue.CORE.background { + com.synonym.bitkitcore.getPreActivityMetadata(searchKey = searchKey, searchByAddress = searchByAddress) + } + + suspend fun deletePreActivityMetadata(paymentId: String) = ServiceQueue.CORE.background { + com.synonym.bitkitcore.deletePreActivityMetadata(paymentId = paymentId) + } + suspend fun upsertClosedChannelList(closedChannels: List) = ServiceQueue.CORE.background { upsertClosedChannels(closedChannels) } @@ -330,8 +369,13 @@ class ActivityService( * * @param payments The list of `PaymentDetails` from the LDK node to be processed. * @param forceUpdate If true, it will also update activities previously marked as deleted. + * @param channelIdsByTxId Map of transaction IDs to channel IDs for identifying transfer activities. */ - suspend fun syncLdkNodePaymentsToActivities(payments: List, forceUpdate: Boolean = false) { + suspend fun syncLdkNodePaymentsToActivities( + payments: List, + forceUpdate: Boolean = false, + channelIdsByTxId: Map = emptyMap(), + ) { ServiceQueue.CORE.background { val allResults = mutableListOf>() @@ -339,7 +383,7 @@ class ActivityService( val results = chunk.map { payment -> async { runCatching { - processSinglePayment(payment, forceUpdate) + processSinglePayment(payment, forceUpdate, channelIdsByTxId) payment.id }.onFailure { e -> Logger.error("Error syncing payment with id: ${payment.id}:", e, context = TAG) @@ -356,7 +400,11 @@ class ActivityService( } } - private suspend fun processSinglePayment(payment: PaymentDetails, forceUpdate: Boolean) { + private suspend fun processSinglePayment( + payment: PaymentDetails, + forceUpdate: Boolean, + channelIdsByTxId: Map, + ) { val state = when (payment.status) { PaymentStatus.FAILED -> PaymentState.FAILED PaymentStatus.PENDING -> PaymentState.PENDING @@ -365,7 +413,13 @@ class ActivityService( when (val kind = payment.kind) { is PaymentKind.Onchain -> { - processOnchainPayment(kind = kind, payment = payment, forceUpdate = forceUpdate) + val channelId = channelIdsByTxId[kind.txid] + processOnchainPayment( + kind = kind, + payment = payment, + forceUpdate = forceUpdate, + channelId = channelId, + ) } is PaymentKind.Bolt11 -> { @@ -425,11 +479,53 @@ class ActivityService( } } - private suspend fun processOnchainPayment( + /** + * Check pre-activity metadata for addresses in the transaction + * Returns the first address found in pre-activity metadata that matches a transaction output + */ + private suspend fun findAddressInPreActivityMetadata(txDetails: TxDetails): String? { + for (output in txDetails.vout) { + val address = output.scriptpubkey_address ?: continue + val metadata = coreService.activity.getPreActivityMetadata(searchKey = address, searchByAddress = true) + if (metadata != null && metadata.isReceive) { + return address + } + } + return null + } + + private suspend fun resolveAddressForInboundPayment( kind: PaymentKind.Onchain, + existingActivity: Activity?, payment: PaymentDetails, - forceUpdate: Boolean, - ) { + ): String? { + if (existingActivity != null || payment.direction != PaymentDirection.INBOUND) { + return null + } + + return try { + val txDetails = addressChecker.getTransaction(kind.txid) + findAddressInPreActivityMetadata(txDetails) + } catch (e: Exception) { + Logger.verbose( + "Failed to get transaction details for address lookup: ${kind.txid}", + e, + context = TAG + ) + null + } + } + + private data class ConfirmationData( + val isConfirmed: Boolean, + val confirmedTimestamp: ULong?, + val timestamp: ULong, + ) + + private suspend fun getConfirmationStatus( + kind: PaymentKind.Onchain, + timestamp: ULong, + ): ConfirmationData { var isConfirmed = false var confirmedTimestamp: ULong? = null @@ -439,13 +535,86 @@ class ActivityService( confirmedTimestamp = status.timestamp } - // Ensure confirmTimestamp is at least equal to timestamp when confirmed - val timestamp = payment.latestUpdateTimestamp - if (isConfirmed && confirmedTimestamp != null && confirmedTimestamp < timestamp) { confirmedTimestamp = timestamp } + return ConfirmationData(isConfirmed, confirmedTimestamp, timestamp) + } + + private suspend fun buildUpdatedOnchainActivity( + existingActivity: Activity.Onchain, + confirmationData: ConfirmationData, + txid: String, + channelId: String? = null, + ): OnchainActivity { + val wasRemoved = !existingActivity.v1.doesExist + val shouldRestore = wasRemoved && confirmationData.isConfirmed + + var preservedIsTransfer = existingActivity.v1.isTransfer + var preservedChannelId = existingActivity.v1.channelId + + if ((preservedChannelId == null || !preservedIsTransfer) && channelId != null) { + preservedChannelId = channelId + preservedIsTransfer = true + } + + val updatedOnChain = existingActivity.v1.copy( + confirmed = confirmationData.isConfirmed, + confirmTimestamp = confirmationData.confirmedTimestamp, + doesExist = if (shouldRestore) true else existingActivity.v1.doesExist, + updatedAt = confirmationData.timestamp, + isTransfer = preservedIsTransfer, + channelId = preservedChannelId, + ) + + if (wasRemoved && confirmationData.isConfirmed) { + markReplacementTransactionsAsRemoved(originalTxId = txid) + } + + return updatedOnChain + } + + private suspend fun buildNewOnchainActivity( + payment: PaymentDetails, + kind: PaymentKind.Onchain, + confirmationData: ConfirmationData, + resolvedAddress: String?, + channelId: String? = null, + ): OnchainActivity { + val isTransfer = channelId != null + + return OnchainActivity( + id = payment.id, + txType = payment.direction.toPaymentType(), + txId = kind.txid, + value = payment.amountSats ?: 0u, + fee = (payment.feePaidMsat ?: 0u) / 1000u, + feeRate = 1u, + address = resolvedAddress ?: "Loading...", + confirmed = confirmationData.isConfirmed, + timestamp = confirmationData.timestamp, + isBoosted = false, + boostTxIds = emptyList(), + isTransfer = isTransfer, + doesExist = true, + confirmTimestamp = confirmationData.confirmedTimestamp, + channelId = channelId, + transferTxId = null, + createdAt = confirmationData.timestamp, + updatedAt = confirmationData.timestamp, + ) + } + + private suspend fun processOnchainPayment( + kind: PaymentKind.Onchain, + payment: PaymentDetails, + forceUpdate: Boolean, + channelId: String? = null, + ) { + val timestamp = payment.latestUpdateTimestamp + val confirmationData = getConfirmationStatus(kind, timestamp) + val existingActivity = getActivityById(payment.id) if (existingActivity != null && existingActivity is Activity.Onchain && @@ -454,42 +623,22 @@ class ActivityService( return } + val resolvedAddress = resolveAddressForInboundPayment(kind, existingActivity, payment) + val onChain = if (existingActivity is Activity.Onchain) { - val wasRemoved = !existingActivity.v1.doesExist - val shouldRestore = wasRemoved && isConfirmed - val updatedOnChain = existingActivity.v1.copy( - confirmed = isConfirmed, - confirmTimestamp = confirmedTimestamp, - doesExist = if (shouldRestore) true else existingActivity.v1.doesExist, - updatedAt = timestamp, + buildUpdatedOnchainActivity( + existingActivity = existingActivity, + confirmationData = confirmationData, + txid = kind.txid, + channelId = channelId, ) - - // If a removed transaction confirms, mark its replacement transactions as removed - if (wasRemoved && isConfirmed) { - markReplacementTransactionsAsRemoved(originalTxId = kind.txid) - } - - updatedOnChain } else { - OnchainActivity( - id = payment.id, - txType = payment.direction.toPaymentType(), - txId = kind.txid, - value = payment.amountSats ?: 0u, - fee = (payment.feePaidMsat ?: 0u) / 1000u, - feeRate = 1u, - address = "Loading...", - confirmed = isConfirmed, - timestamp = timestamp, - isBoosted = false, - boostTxIds = emptyList(), - isTransfer = false, - doesExist = true, - confirmTimestamp = confirmedTimestamp, - channelId = null, - transferTxId = null, - createdAt = timestamp, - updatedAt = timestamp, + buildNewOnchainActivity( + payment = payment, + kind = kind, + confirmationData = confirmationData, + resolvedAddress = resolvedAddress, + channelId = channelId, ) } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index f7d234cc6..4402f8978 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1025,6 +1025,12 @@ private fun NavGraphBuilder.activityItem( listViewModel = activityListViewModel, route = it.toRoute(), onExploreClick = { id -> navController.navigateToActivityExplore(id) }, + onChannelClick = { channelId -> + navController.currentBackStackEntry?.savedStateHandle?.set("selectedChannelId", channelId) + navController.navigate(Routes.ConnectionsNav) { + launchSingleTop = true + } + }, onBackClick = { navController.popBackStack() }, onCloseClick = { navController.navigateToHome() }, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt index 80f2aad4e..a8ea25a6b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt @@ -16,17 +16,16 @@ import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.UserChannelId import to.bitkit.R -import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.ext.WatchResult import to.bitkit.ext.parse import to.bitkit.ext.watchUntil import to.bitkit.models.Toast -import to.bitkit.models.TransactionMetadata import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.LdkNodeEventBus import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.SideEffect @@ -44,8 +43,8 @@ class ExternalNodeViewModel @Inject constructor( private val walletRepo: WalletRepo, private val lightningRepo: LightningRepo, private val settingsStore: SettingsStore, - private val cacheStore: CacheStore, private val transferRepo: to.bitkit.repositories.TransferRepo, + private val preActivityMetadataRepo: to.bitkit.repositories.PreActivityMetadataRepo, private val addressChecker: AddressChecker, ) : ViewModel() { private val _uiState = MutableStateFlow(UiState()) @@ -172,21 +171,26 @@ class ExternalNodeViewModel @Inject constructor( channelAmountSats = _uiState.value.amount.sats.toULong(), ).mapCatching { result -> awaitChannelPendingEvent(result.userChannelId).mapCatching { event -> + val txId = event.fundingTxo.txid val address = addressChecker.getOutputAddress(event.fundingTxo).getOrDefault("") - cacheStore.addTransactionMetadata( - TransactionMetadata( - txId = event.fundingTxo.txid, - feeRate = _uiState.value.customFeeRate ?: 0u, - isTransfer = true, - channelId = event.channelId, - address = address, - ) + val feeRate = _uiState.value.customFeeRate ?: 0u + + preActivityMetadataRepo.savePreActivityMetadata( + id = txId, + txId = txId, + address = address, + isReceive = false, + tags = emptyList(), + feeRate = feeRate.toULong(), + isTransfer = true, + channelId = event.channelId, ) + transferRepo.createTransfer( type = TransferType.MANUAL_SETUP, amountSats = result.channelAmountSats.toLong(), channelId = event.channelId, - fundingTxId = event.fundingTxo.txid, + fundingTxId = txId, ) }.getOrThrow() }.onSuccess { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 3c1f502ac..f4d68b8cf 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -88,6 +88,7 @@ fun ActivityDetailScreen( onExploreClick: (String) -> Unit, onBackClick: () -> Unit, onCloseClick: () -> Unit, + onChannelClick: ((String) -> Unit)? = null, ) { val activities by listViewModel.filteredActivities.collectAsStateWithLifecycle() val item = activities?.find { it.rawId() == route.id } @@ -125,15 +126,17 @@ fun ActivityDetailScreen( tags = tags, onRemoveTag = { detailViewModel.removeTag(it) }, onAddTagClick = { showAddTagSheet = true }, + onClickBoost = detailViewModel::onClickBoost, onExploreClick = onExploreClick, + onChannelClick = onChannelClick, + detailViewModel = detailViewModel, onCopy = { text -> app.toast( type = Toast.ToastType.SUCCESS, title = copyToastTitle, description = text.ellipsisMiddle(40) ) - }, - onClickBoost = detailViewModel::onClickBoost + } ) if (showAddTagSheet) { ActivityAddTagSheet( @@ -194,11 +197,22 @@ private fun ActivityDetailContent( onAddTagClick: () -> Unit, onClickBoost: () -> Unit, onExploreClick: (String) -> Unit, + onChannelClick: ((String) -> Unit)?, + detailViewModel: ActivityDetailViewModel? = null, onCopy: (String) -> Unit, ) { val isLightning = item is Activity.Lightning - val accentColor = if (isLightning) Colors.Purple else Colors.Brand val isSent = item.isSent() + val isTransfer = item.isTransfer() + val isTransferFromSpending = isTransfer && !isSent + val isTransferToSpending = isTransfer && isSent + + val accentColor = when { + isTransferFromSpending -> Colors.Purple + isLightning -> Colors.Purple + else -> Colors.Brand + } + val amountPrefix = if (isSent) "-" else "+" val timestamp = when (item) { is Activity.Lightning -> item.v1.timestamp @@ -211,12 +225,33 @@ private fun ActivityDetailContent( is Activity.Lightning -> item.v1.value is Activity.Onchain -> item.v1.value } - val fee = when (item) { + val baseFee = when (item) { is Activity.Lightning -> item.v1.fee is Activity.Onchain -> item.v1.fee } val isSelfSend = isSent && paymentValue == 0uL - val isTransfer = item.isTransfer() + val channelId = (item as? Activity.Onchain)?.v1?.channelId + val txId = (item as? Activity.Onchain)?.v1?.txId + + var order by remember { mutableStateOf(null) } + + LaunchedEffect(item, isTransferToSpending, detailViewModel) { + order = if (isTransferToSpending && detailViewModel != null) { + detailViewModel.findOrderForTransfer(channelId, txId) + } else { + null + } + } + + val orderServiceFee: ULong? = order?.let { it.feeSat - it.clientBalanceSat } + val transferAmount: ULong? = order?.clientBalanceSat + + val fee: ULong? = when { + isTransferToSpending && orderServiceFee != null && baseFee != null -> baseFee + orderServiceFee + else -> baseFee + } + + val displayAmount: ULong = transferAmount?.takeIf { isTransferToSpending } ?: paymentValue Column( modifier = Modifier @@ -241,7 +276,7 @@ private fun ActivityDetailContent( } Spacer(modifier = Modifier.height(16.dp)) - StatusSection(item) + StatusSection(item, accentColor) HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) // Timestamp section: date and time @@ -291,7 +326,7 @@ private fun ActivityDetailContent( HorizontalDivider() } } - if (isSent) { + if (isSent || isTransfer) { Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() @@ -299,7 +334,8 @@ private fun ActivityDetailContent( Column(modifier = Modifier.weight(1f)) { Caption13Up( text = when { - isTransfer -> stringResource(R.string.wallet__activity_transfer_to_spending) + isTransferToSpending -> stringResource(R.string.wallet__activity_transfer_to_spending) + isTransferFromSpending -> stringResource(R.string.wallet__activity_transfer_to_savings) isSelfSend -> "Sent to myself" // TODO add missing localized text else -> stringResource(R.string.wallet__activity_payment) }, @@ -312,7 +348,8 @@ private fun ActivityDetailContent( ) { Icon( painter = when { - isTransfer -> painterResource(R.drawable.ic_lightning) + isTransferToSpending -> painterResource(R.drawable.ic_lightning) + isTransferFromSpending -> painterResource(R.drawable.ic_bitcoin) else -> painterResource(R.drawable.ic_user) }, contentDescription = null, @@ -320,7 +357,7 @@ private fun ActivityDetailContent( modifier = Modifier.size(16.dp) ) Spacer(modifier = Modifier.width(4.dp)) - MoneySSB(sats = paymentValue.toLong()) + MoneySSB(sats = displayAmount.toLong()) } Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider() @@ -328,7 +365,11 @@ private fun ActivityDetailContent( if (fee != null) { Column(modifier = Modifier.weight(1f)) { Caption13Up( - text = stringResource(R.string.wallet__activity_fee), + text = if (isTransferFromSpending) { + stringResource(R.string.wallet__activity_fee_prepaid) + } else { + stringResource(R.string.wallet__activity_fee) + }, color = Colors.White64, modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) ) @@ -337,7 +378,7 @@ private fun ActivityDetailContent( modifier = Modifier.testTag("ActivityFee") ) { Icon( - painter = painterResource(R.drawable.ic_speed_normal), + painter = painterResource(R.drawable.ic_timer), contentDescription = null, tint = accentColor, modifier = Modifier.size(16.dp) @@ -492,29 +533,48 @@ private fun ActivityDetailContent( } ) ) - PrimaryButton( - text = stringResource(R.string.wallet__activity_explore), - size = ButtonSize.Small, - onClick = { onExploreClick(item.rawId()) }, - icon = { - Icon( - painter = painterResource(R.drawable.ic_git_branch), - contentDescription = null, - tint = accentColor, - modifier = Modifier.size(16.dp) - ) - }, - modifier = Modifier - .weight(1f) - .testTag("ActivityTxDetails") - ) + if (isTransfer && channelId != null && onChannelClick != null) { + PrimaryButton( + text = stringResource(R.string.lightning__connection), + size = ButtonSize.Small, + onClick = { onChannelClick(channelId) }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_lightning), + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier + .weight(1f) + .testTag("ChannelButton") + ) + } else { + PrimaryButton( + text = stringResource(R.string.wallet__activity_explore), + size = ButtonSize.Small, + onClick = { onExploreClick(item.rawId()) }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_git_branch), + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier + .weight(1f) + .testTag("ActivityTxDetails") + ) + } } } } } @Composable -private fun StatusSection(item: Activity) { +private fun StatusSection(item: Activity, accentColor: Color) { Column(modifier = Modifier.fillMaxWidth()) { Caption13Up( text = stringResource(R.string.wallet__activity_status), @@ -554,7 +614,7 @@ private fun StatusSection(item: Activity) { is Activity.Onchain -> { // Default status is confirming var statusIcon = painterResource(R.drawable.ic_hourglass_simple) - var statusColor = Colors.Brand + var statusColor = accentColor // Use accent color for transfers var statusText = stringResource(R.string.wallet__activity_confirming) var statusTestTag: String? = null @@ -671,6 +731,7 @@ private fun PreviewLightningSent() { onRemoveTag = {}, onAddTagClick = {}, onExploreClick = {}, + onChannelClick = null, onCopy = {}, onClickBoost = {} ) @@ -708,6 +769,7 @@ private fun PreviewOnchain() { onRemoveTag = {}, onAddTagClick = {}, onExploreClick = {}, + onChannelClick = null, onCopy = {}, onClickBoost = {}, ) @@ -741,6 +803,7 @@ private fun PreviewSheetSmallScreen() { onRemoveTag = {}, onAddTagClick = {}, onExploreClick = {}, + onChannelClick = null, onCopy = {}, onClickBoost = {}, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt index c38aba4f0..60a1bb980 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt @@ -93,16 +93,21 @@ fun ActivityIcon( // onchain else -> { + val isTransfer = activity.isTransfer() + val isTransferFromSpending = isTransfer && activity.txType() == PaymentType.RECEIVED + val transferIconColor = if (isTransferFromSpending) Colors.Purple else Colors.Brand + val transferBackgroundColor = if (isTransferFromSpending) Colors.Purple16 else Colors.Brand16 + CircularIcon( icon = when { !activity.doesExist() -> painterResource(R.drawable.ic_x) - activity.isTransfer() -> painterResource(R.drawable.ic_transfer) + isTransfer -> painterResource(R.drawable.ic_transfer) else -> arrowIcon }, - iconColor = Colors.Brand, - backgroundColor = Colors.Brand16, + iconColor = if (isTransfer) transferIconColor else Colors.Brand, + backgroundColor = if (isTransfer) transferBackgroundColor else Colors.Brand16, size = size, - modifier = modifier.testTag(if (activity.isTransfer()) "TransferIcon" else "ActivityIcon"), + modifier = modifier.testTag(if (isTransfer) "TransferIcon" else "ActivityIcon"), ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index 16cde5a53..c2dccc46c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -44,6 +44,7 @@ fun ReceiveSheet( val lightningState: LightningState by wallet.lightningState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { + wallet.resetPreActivityMetadataTagsForCurrentInvoice() wallet.refreshReceiveState() } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt index afe0ad9e8..026876255 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt @@ -14,8 +14,8 @@ import org.lightningdevkit.ldknode.SpendableUtxo import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.ext.rawId +import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.LightningRepo -import to.bitkit.services.CoreService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject @@ -24,7 +24,7 @@ import javax.inject.Inject class SendCoinSelectionViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningRepo: LightningRepo, - private val coreService: CoreService, + private val activityRepo: ActivityRepo, ) : ViewModel() { private val _uiState = MutableStateFlow(CoinSelectionUiState()) @@ -74,17 +74,19 @@ class SendCoinSelectionViewModel @Inject constructor( if (_tagsByTxId.value.containsKey(txId)) return viewModelScope.launch(bgDispatcher) { - runCatching { - // find activity by txId - onchainActivities.firstOrNull { (it as? Onchain)?.v1?.txId == txId }?.let { activity -> - // get tags by activity id - coreService.activity.tags(forActivityId = activity.rawId()) - .takeIf { it.isNotEmpty() } - ?.let { tags -> + // find activity by txId + onchainActivities.firstOrNull { (it as? Onchain)?.v1?.txId == txId }?.let { activity -> + // get tags by activity id + activityRepo.getActivityTags(activity.rawId()) + .onSuccess { tags -> + if (tags.isNotEmpty()) { // add map entry linking tags to utxo.outpoint.txid _tagsByTxId.update { currentMap -> currentMap + (txId to tags) } } - } + } + .onFailure { e -> + Logger.error("Failed to load tags for utxo $txId", e) + } } } } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt index 832c484f7..32618dc1a 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController +import kotlinx.coroutines.delay import to.bitkit.R import to.bitkit.ext.amountOnClose import to.bitkit.ext.createChannelDetails @@ -65,6 +66,7 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors private const val CLOSED_CHANNEL_ALPHA = 0.64f +private const val CHANNEL_SELECTION_DELAY_MS = 200L object LightningConnectionsTestTags { const val SCREEN = "lightning_connections_screen" @@ -88,6 +90,20 @@ fun LightningConnectionsScreen( viewModel.clearTransactionDetails() } + LaunchedEffect(navController.currentBackStackEntry) { + val selectedChannelId = navController.previousBackStackEntry?.savedStateHandle?.get("selectedChannelId") + if (selectedChannelId == null) return@LaunchedEffect + + navController.previousBackStackEntry?.savedStateHandle?.remove("selectedChannelId") + delay(CHANNEL_SELECTION_DELAY_MS) + if (viewModel.findAndSelectChannel(selectedChannelId)) { + navController.navigate(Routes.ChannelDetail) { + launchSingleTop = true + popUpTo(Routes.ConnectionsNav) { inclusive = false } + } + } + } + Content( uiState = uiState, onBack = { navController.popBackStack() }, diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index 092c05fc1..408c9570a 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -355,6 +355,42 @@ class LightningConnectionsViewModel @Inject constructor( fun clearSelectedChannel() = _selectedChannel.update { null } + fun findAndSelectChannel(channelId: String): Boolean { + val channels = lightningRepo.lightningState.value.channels + val blocktankState = blocktankRepo.blocktankState.value + + val channelUi = findChannelUi(channelId, channels, blocktankState) + if (channelUi != null) { + setSelectedChannel(channelUi) + return true + } + + return false + } + + private fun findChannelUi( + channelId: String, + channels: List, + blocktankState: to.bitkit.repositories.BlocktankState, + ): ChannelUi? { + return channels.find { it.channelId == channelId }?.mapToUiModel() + ?: getPendingOrdersAsChannels(channels, blocktankState.paidOrders) + .find { it.channelId == channelId }?.mapToUiModel() + ?: getFailedOrdersAsChannels(blocktankState.paidOrders) + .find { it.channelId == channelId }?.mapToUiModel() + ?: _uiState.value.closedChannels.find { it.details.channelId == channelId } + ?: blocktankState.orders.find { it.id == channelId }?.let { order -> + createChannelDetails().copy( + channelId = order.id, + counterpartyNodeId = order.lspNode?.pubkey.orEmpty(), + fundingTxo = order.channel?.fundingTx?.let { OutPoint(txid = it.id, vout = it.vout.toUInt()) }, + channelValueSats = order.clientBalanceSat + order.lspBalanceSat, + outboundCapacityMsat = order.clientBalanceSat * 1000u, + inboundCapacityMsat = order.lspBalanceSat * 1000u, + ).mapToUiModel() + } + } + fun fetchTransactionDetails(txid: String) { viewModelScope.launch(bgDispatcher) { try { diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index d7fb9f6fc..509a7a820 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -3,16 +3,19 @@ package to.bitkit.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.IBtOrder import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.ext.rawId -import to.bitkit.services.CoreService +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.BlocktankRepo import to.bitkit.utils.AddressChecker import to.bitkit.utils.Logger import to.bitkit.utils.TxDetails @@ -22,8 +25,9 @@ import javax.inject.Inject class ActivityDetailViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val addressChecker: AddressChecker, - private val coreService: CoreService, + private val activityRepo: ActivityRepo, private val settingsStore: SettingsStore, + private val blocktankRepo: BlocktankRepo, ) : ViewModel() { private val _txDetails = MutableStateFlow(null) val txDetails = _txDetails.asStateFlow() @@ -44,40 +48,41 @@ class ActivityDetailViewModel @Inject constructor( fun loadTags() { val id = activity?.rawId() ?: return viewModelScope.launch(bgDispatcher) { - try { - val activityTags = coreService.activity.tags(forActivityId = id) - _tags.value = activityTags - } catch (e: Exception) { - Logger.error("Failed to load tags for activity $id", e, TAG) - _tags.value = emptyList() - } + activityRepo.getActivityTags(id) + .onSuccess { activityTags -> + _tags.value = activityTags + } + .onFailure { e -> + Logger.error("Failed to load tags for activity $id", e, TAG) + _tags.value = emptyList() + } } } fun removeTag(tag: String) { val id = activity?.rawId() ?: return viewModelScope.launch(bgDispatcher) { - try { - coreService.activity.dropTags(fromActivityId = id, tags = listOf(tag)) - loadTags() - } catch (e: Exception) { - Logger.error("Failed to remove tag $tag from activity $id", e, TAG) - } + activityRepo.removeTagsFromActivity(id, listOf(tag)) + .onSuccess { + loadTags() + } + .onFailure { e -> + Logger.error("Failed to remove tag $tag from activity $id", e, TAG) + } } } fun addTag(tag: String) { val id = activity?.rawId() ?: return viewModelScope.launch(bgDispatcher) { - try { - val result = coreService.activity.appendTags(toActivityId = id, tags = listOf(tag)) - if (result.isSuccess) { + activityRepo.addTagsToActivity(id, listOf(tag)) + .onSuccess { settingsStore.addLastUsedTag(tag) loadTags() } - } catch (e: Exception) { - Logger.error("Failed to add tag $tag to activity $id", e, TAG) - } + .onFailure { e -> + Logger.error("Failed to add tag $tag to activity $id", e, TAG) + } } } @@ -105,6 +110,30 @@ class ActivityDetailViewModel @Inject constructor( _boostSheetVisible.update { false } } + suspend fun findOrderForTransfer( + channelId: String?, + txId: String?, + ): IBtOrder? = withContext(bgDispatcher) { + try { + val orders = blocktankRepo.blocktankState.value.orders + + if (channelId != null) { + orders.find { it.id == channelId }?.let { return@withContext it } + } + + if (txId != null) { + orders.firstOrNull { order -> + order.payment?.onchain?.transactions?.any { it.txId == txId } == true + }?.let { return@withContext it } + } + + null + } catch (e: Exception) { + Logger.warn("Failed to find order for transfer: channelId=$channelId, txId=$txId", e, context = TAG) + null + } + } + private companion object { const val TAG = "ActivityDetailViewModel" } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 5cd9f303c..1a3a0ffb6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -68,6 +68,7 @@ import to.bitkit.ext.minWithdrawableSat import to.bitkit.ext.rawId import to.bitkit.ext.removeSpaces import to.bitkit.ext.setClipboardText +import to.bitkit.ext.toHex import to.bitkit.ext.totalValue import to.bitkit.ext.watchUntil import to.bitkit.models.FeeRate @@ -87,6 +88,7 @@ import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.AppUpdaterService import to.bitkit.services.LdkNodeEventBus @@ -113,6 +115,7 @@ class AppViewModel @Inject constructor( private val settingsStore: SettingsStore, private val currencyRepo: CurrencyRepo, private val activityRepo: ActivityRepo, + private val preActivityMetadataRepo: PreActivityMetadataRepo, private val blocktankRepo: BlocktankRepo, private val connectivityRepo: ConnectivityRepo, private val healthRepo: HealthRepo, @@ -968,16 +971,10 @@ class AppViewModel @Inject constructor( return } - sendOnchain(validatedAddress.address, amount) + val tags = _sendUiState.value.selectedTags + + sendOnchain(validatedAddress.address, amount, tags = tags) .onSuccess { txId -> - val tags = _sendUiState.value.selectedTags - activityRepo.saveTagsMetadata( - id = txId, - txId = txId, - address = validatedAddress.address, - isReceive = false, - tags = tags - ) Logger.info("Onchain send result txid: $txId", context = TAG) handlePaymentSuccess( NewTransactionSheetDetails( @@ -1006,25 +1003,40 @@ class AppViewModel @Inject constructor( // Determine if we should override amount val paymentAmount = decodedInvoice.amountSatoshis.takeIf { it > 0uL } ?: amount - sendLightning(bolt11, paymentAmount).onSuccess { paymentHash -> - Logger.info("Lightning send result payment hash: $paymentHash", context = TAG) - val tags = _sendUiState.value.selectedTags - activityRepo.saveTagsMetadata( + val tags = _sendUiState.value.selectedTags + var createdMetadataPaymentId: String? = null + + // Extract payment hash from invoice for pre-activity metadata + val paymentHash = decodedInvoice.paymentHash.toHex() + + // Create pre-activity metadata before sending + if (tags.isNotEmpty()) { + preActivityMetadataRepo.savePreActivityMetadata( id = paymentHash, paymentHash = paymentHash, address = _sendUiState.value.address, isReceive = false, - tags = tags - ) + tags = tags, + ).onSuccess { + createdMetadataPaymentId = paymentHash + } + } + + sendLightning(bolt11, paymentAmount).onSuccess { actualPaymentHash -> + Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG) handlePaymentSuccess( NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.SENT, - paymentHashOrTxId = paymentHash, + paymentHashOrTxId = actualPaymentHash, sats = paymentAmount.toLong(), // TODO Add fee when available ), ) }.onFailure { e -> + // Delete pre-activity metadata on failure + if (createdMetadataPaymentId != null) { + preActivityMetadataRepo.deletePreActivityMetadata(createdMetadataPaymentId) + } Logger.error("Error sending lightning payment", e, context = TAG) toast(e) hideSheet() @@ -1130,14 +1142,19 @@ class AppViewModel @Inject constructor( } } - private suspend fun sendOnchain(address: String, amount: ULong): Result { + private suspend fun sendOnchain( + address: String, + amount: ULong, + tags: List = emptyList(), + ): Result { return lightningRepo.sendOnChain( address = address, sats = amount, speed = _sendUiState.value.speed, utxosToSpend = _sendUiState.value.selectedUtxos, isMaxAmount = _sendUiState.value.payMethod == SendMethod.ONCHAIN && - amount == walletRepo.balanceState.value.maxSendOnchainSats + amount == walletRepo.balanceState.value.maxSendOnchainSats, + tags = tags, ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index ef6834b5f..5aa8fd1b3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -204,9 +204,11 @@ class TransferViewModel @Inject constructor( /** Pays for the order and start watching it for state updates */ fun onTransferToSpendingConfirm(order: IBtOrder, speed: TransactionSpeed? = null) { viewModelScope.launch { + val address = order.payment?.onchain?.address.orEmpty() + lightningRepo .sendOnChain( - address = order.payment?.onchain?.address.orEmpty(), + address = address, sats = order.feeSat, speed = speed, isTransfer = true, diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 1f5a3e03d..968fb83bd 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -263,18 +263,26 @@ class WalletViewModel @Inject constructor( // region debug methods - fun addTagToSelected(newTag: String) { - viewModelScope.launch(bgDispatcher) { - walletRepo.addTagToSelected(newTag) + fun addTagToSelected(newTag: String) = viewModelScope.launch { + walletRepo.addTagToSelected(newTag).onFailure { e -> + ToastEventBus.send(e) } } - fun removeTag(tag: String) { - viewModelScope.launch(bgDispatcher) { - walletRepo.removeTag(tag) + fun removeTag(tag: String) = viewModelScope.launch { + walletRepo.removeTag(tag).onFailure { e -> + ToastEventBus.send(e) } } + fun resetPreActivityMetadataTagsForCurrentInvoice() = viewModelScope.launch { + walletRepo.resetPreActivityMetadataTagsForCurrentInvoice() + } + + fun loadTagsForCurrentInvoice() = viewModelScope.launch { + walletRepo.loadTagsForCurrentInvoice() + } + fun updateBip21Description(newText: String) { if (newText.isEmpty()) { Logger.warn("Empty") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f1f09c921..8417fe519 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -991,6 +991,7 @@ From Savings (±{duration}) From Savings To Spending + To Savings Transfer (±{duration}) Confirms in {feeRateDescription} Boosting. Confirms in {feeRateDescription} @@ -1007,6 +1008,7 @@ Please check your activity list. The {count} impacted transaction(s) will be highlighted in red. Boosting Fee + Fee (prepaid) Payment Status Date diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt new file mode 100644 index 000000000..d958a40d3 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -0,0 +1,88 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.IBtOrder +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsStore +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AddressChecker +import to.bitkit.viewmodels.ActivityDetailViewModel +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ActivityDetailViewModelTest : BaseUnitTest() { + + private val activityRepo = mock() + private val blocktankRepo = mock() + private val settingsStore = mock() + private val addressChecker = mock() + + private lateinit var sut: ActivityDetailViewModel + + @Before + fun setUp() { + whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) + + sut = ActivityDetailViewModel( + bgDispatcher = testDispatcher, + activityRepo = activityRepo, + blocktankRepo = blocktankRepo, + settingsStore = settingsStore, + addressChecker = addressChecker, + ) + } + + @Test + fun `findOrderForTransfer returns null when both channelId and txId are null`() = test { + val result = sut.findOrderForTransfer(null, null) + + assertNull(result) + } + + @Test + fun `findOrderForTransfer finds order by channelId`() = test { + val orderId = "test-order-id" + val mockOrder = mock { + on { id } doReturn orderId + } + + whenever(blocktankRepo.blocktankState).thenReturn( + MutableStateFlow(BlocktankState(orders = listOf(mockOrder))) + ) + + val result = sut.findOrderForTransfer(orderId, null) + + assertEquals(mockOrder, result) + } + + @Test + fun `findOrderForTransfer finds order by channelId matching order id`() = test { + val orderId = "order-123" + val mockOrder = mock { + on { id } doReturn orderId + } + + whenever(blocktankRepo.blocktankState).thenReturn( + MutableStateFlow(BlocktankState(orders = listOf(mockOrder))) + ) + + val result = sut.findOrderForTransfer(orderId, null) + + assertEquals(mockOrder, result) + } + + @Test + fun `findOrderForTransfer returns null when order not found`() = test { + whenever(blocktankRepo.blocktankState).thenReturn( + MutableStateFlow(BlocktankState(orders = emptyList())) + ) + + val result = sut.findOrderForTransfer("non-existent-id", null) + + assertNull(result) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 974ae6b1c..6e27059d1 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -6,6 +6,7 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.datetime.Clock import org.junit.Before @@ -22,9 +23,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.AppCacheData -import to.bitkit.data.AppDb import to.bitkit.data.CacheStore -import to.bitkit.data.dao.TagMetadataDao import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest @@ -39,10 +38,10 @@ class ActivityRepoTest : BaseUnitTest() { private val coreService = mock() private val lightningRepo = mock() + private val blocktankRepo = mock() private val transferRepo = mock() private val cacheStore = mock() private val addressChecker = mock() - private val db = mock() private val clock = mock() private lateinit var sut: ActivityRepo @@ -128,14 +127,16 @@ class ActivityRepoTest : BaseUnitTest() { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) whenever(coreService.activity).thenReturn(mock()) whenever(clock.now()).thenReturn(Clock.System.now()) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) sut = ActivityRepo( bgDispatcher = testDispatcher, coreService = coreService, lightningRepo = lightningRepo, - cacheStore = cacheStore, + blocktankRepo = blocktankRepo, addressChecker = addressChecker, - db = db, + cacheStore = cacheStore, transferRepo = transferRepo, clock = clock, ) @@ -144,13 +145,15 @@ class ActivityRepoTest : BaseUnitTest() { private fun setupSyncActivitiesMocks( cacheData: AppCacheData ) { - val tagMetadataDao = mock() - whenever(db.tagMetadataDao()).thenReturn(tagMetadataDao) - wheneverBlocking { tagMetadataDao.getAll() }.thenReturn(emptyList()) - whenever(cacheStore.data).thenReturn(flowOf(cacheData)) wheneverBlocking { lightningRepo.getPayments() }.thenReturn(Result.success(emptyList())) - wheneverBlocking { coreService.activity.syncLdkNodePaymentsToActivities(any(), eq(false)) }.thenReturn(Unit) + wheneverBlocking { + coreService.activity.syncLdkNodePaymentsToActivities( + any(), + eq(false), + any() + ) + }.thenReturn(Unit) wheneverBlocking { transferRepo.syncTransferStates() }.thenReturn(Result.success(Unit)) wheneverBlocking { coreService.activity.allPossibleTags() }.thenReturn(emptyList()) } @@ -159,8 +162,14 @@ class ActivityRepoTest : BaseUnitTest() { fun `syncActivities success flow`() = test { val payments = listOf(testPaymentDetails) wheneverBlocking { lightningRepo.getPayments() }.thenReturn(Result.success(payments)) - wheneverBlocking { coreService.activity.getActivity(any()) }.thenReturn(null) - wheneverBlocking { coreService.activity.syncLdkNodePaymentsToActivities(payments) }.thenReturn(Unit) + wheneverBlocking { coreService.activity.getActivity(any()) }.thenReturn(null) + wheneverBlocking { + coreService.activity.syncLdkNodePaymentsToActivities( + any>(), + any(), + any>() + ) + }.thenReturn(Unit) wheneverBlocking { transferRepo.syncTransferStates() }.thenReturn(Result.success(Unit)) wheneverBlocking { coreService.activity.allPossibleTags() }.thenReturn(emptyList()) @@ -168,7 +177,7 @@ class ActivityRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) verify(lightningRepo).getPayments() - verify(coreService.activity).syncLdkNodePaymentsToActivities(payments) + verify(coreService.activity).syncLdkNodePaymentsToActivities(any(), any(), any()) assertFalse(sut.isSyncingLdkNodePayments.value) } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 4d31d2e00..cd2b314aa 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -13,7 +13,6 @@ import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.SpendableUtxo import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder @@ -34,7 +33,6 @@ import to.bitkit.models.BalanceState import to.bitkit.models.CoinSelectionPreference import to.bitkit.models.NodeLifecycleState import to.bitkit.models.OpenChannelResult -import to.bitkit.models.TransactionMetadata import to.bitkit.models.TransactionSpeed import to.bitkit.services.BlocktankService import to.bitkit.services.CoreService @@ -61,6 +59,7 @@ class LightningRepoTest : BaseUnitTest() { private val firebaseMessaging: FirebaseMessaging = mock() private val keychain: Keychain = mock() private val cacheStore: CacheStore = mock() + private val preActivityMetadataRepo: PreActivityMetadataRepo = mock() private val lnurlService: LnurlService = mock() @@ -78,6 +77,7 @@ class LightningRepoTest : BaseUnitTest() { keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, + preActivityMetadataRepo = preActivityMetadataRepo, ) } @@ -374,7 +374,9 @@ class LightningRepoTest : BaseUnitTest() { ) whenever(settingsStore.data).thenReturn(flowOf(mockSettingsData)) - wheneverBlocking { cacheStore.addTransactionMetadata(any()) }.thenReturn(Unit) + wheneverBlocking { + preActivityMetadataRepo.addPreActivityMetadata(any()) + }.thenReturn(Result.success(Unit)) whenever( lightningService.send( @@ -406,18 +408,10 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals("testPaymentId", result.getOrNull()) - // Verify the cache call - val captor = argumentCaptor() - verifyBlocking(cacheStore) { - addTransactionMetadata(captor.capture()) + // Verify pre-activity metadata was saved + verifyBlocking(preActivityMetadataRepo) { + addPreActivityMetadata(any()) } - - val capturedActivity = captor.firstValue - assertEquals("testPaymentId", capturedActivity.txId) - assertEquals("test_address", capturedActivity.address) - assertEquals(true, capturedActivity.isTransfer) - assertEquals("test_channel_id", capturedActivity.channelId) - assertEquals(10u, capturedActivity.feeRate) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt new file mode 100644 index 000000000..c18750972 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt @@ -0,0 +1,623 @@ +package to.bitkit.repositories + +import app.cash.turbine.test +import com.synonym.bitkitcore.PreActivityMetadata +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.kotlin.wheneverBlocking +import to.bitkit.services.ActivityService +import to.bitkit.services.CoreService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class PreActivityMetadataRepoTest : BaseUnitTest() { + + private val coreService = mock() + private val activityService = mock() + private val clock = mock() + + private lateinit var sut: PreActivityMetadataRepo + + private val testTimestamp = Instant.parse("2025-01-01T00:00:00Z") + private var timestampCounter = 0L + + private val testMetadata = PreActivityMetadata( + paymentId = "payment-123", + createdAt = 1234567890uL, + tags = listOf("tag1", "tag2"), + paymentHash = "hash-123", + txId = "tx-123", + address = "bc1qtest", + isReceive = false, + feeRate = 10u, + isTransfer = false, + channelId = "channel-123" + ) + + @Before + fun setUp() { + timestampCounter = 0L + whenever(coreService.activity).thenReturn(activityService) + // Return incrementing timestamps to ensure StateFlow emits new values + whenever(clock.now()).thenAnswer { + Instant.fromEpochMilliseconds(testTimestamp.toEpochMilliseconds() + (++timestampCounter)) + } + + sut = PreActivityMetadataRepo( + ioDispatcher = testDispatcher, + coreService = coreService, + clock = clock, + ) + } + + // region getAllPreActivityMetadata + + @Test + fun `getAllPreActivityMetadata returns success with metadata list`() = test { + val metadataList = listOf(testMetadata) + wheneverBlocking { activityService.getAllPreActivityMetadata() }.thenReturn(metadataList) + + val result = sut.getAllPreActivityMetadata() + + assertTrue(result.isSuccess) + assertEquals(metadataList, result.getOrNull()) + verify(activityService).getAllPreActivityMetadata() + } + + @Test + fun `getAllPreActivityMetadata returns empty list when no metadata exists`() = test { + wheneverBlocking { activityService.getAllPreActivityMetadata() }.thenReturn(emptyList()) + + val result = sut.getAllPreActivityMetadata() + + assertTrue(result.isSuccess) + assertEquals(emptyList(), result.getOrNull()) + } + + @Test + fun `getAllPreActivityMetadata returns failure on exception`() = test { + val exception = RuntimeException("Database error") + wheneverBlocking { activityService.getAllPreActivityMetadata() } doThrow exception + + val result = sut.getAllPreActivityMetadata() + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } + + // endregion + + // region upsertPreActivityMetadata + + @Test + fun `upsertPreActivityMetadata succeeds and notifies changed`() = test { + val metadataList = listOf(testMetadata) + wheneverBlocking { activityService.upsertPreActivityMetadata(metadataList) }.thenReturn(Unit) + + sut.preActivityMetadataChanged.test { + val initialValue = awaitItem() + + val result = sut.upsertPreActivityMetadata(metadataList) + + assertTrue(result.isSuccess) + verify(activityService).upsertPreActivityMetadata(metadataList) + + val updatedValue = awaitItem() + assertTrue(updatedValue > initialValue, "Changed timestamp should be updated") + } + } + + @Test + fun `upsertPreActivityMetadata with multiple items succeeds`() = test { + val metadata1 = testMetadata.copy(paymentId = "payment-1") + val metadata2 = testMetadata.copy(paymentId = "payment-2") + val metadataList = listOf(metadata1, metadata2) + wheneverBlocking { activityService.upsertPreActivityMetadata(metadataList) }.thenReturn(Unit) + + val result = sut.upsertPreActivityMetadata(metadataList) + + assertTrue(result.isSuccess) + verify(activityService).upsertPreActivityMetadata(metadataList) + } + + @Test + fun `upsertPreActivityMetadata returns failure on exception`() = test { + val metadataList = listOf(testMetadata) + val exception = RuntimeException("Upsert failed") + wheneverBlocking { activityService.upsertPreActivityMetadata(metadataList) } doThrow exception + + val result = sut.upsertPreActivityMetadata(metadataList) + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } + + // endregion + + // region addPreActivityMetadataTags + + @Test + fun `addPreActivityMetadataTags succeeds and notifies changed`() = test { + val paymentId = "payment-123" + val tags = listOf("shopping", "groceries") + wheneverBlocking { activityService.addPreActivityMetadataTags(paymentId, tags) }.thenReturn(Unit) + + sut.preActivityMetadataChanged.test { + val initialValue = awaitItem() + + val result = sut.addPreActivityMetadataTags(paymentId, tags) + + assertTrue(result.isSuccess) + verify(activityService).addPreActivityMetadataTags(paymentId, tags) + + val updatedValue = awaitItem() + assertTrue(updatedValue > initialValue, "Changed timestamp should be updated") + } + } + + @Test + fun `addPreActivityMetadataTags with single tag succeeds`() = test { + val paymentId = "payment-123" + val tags = listOf("important") + wheneverBlocking { activityService.addPreActivityMetadataTags(paymentId, tags) }.thenReturn(Unit) + + val result = sut.addPreActivityMetadataTags(paymentId, tags) + + assertTrue(result.isSuccess) + } + + @Test + fun `addPreActivityMetadataTags returns failure on exception`() = test { + val paymentId = "payment-123" + val tags = listOf("tag1") + val exception = RuntimeException("Add tags failed") + wheneverBlocking { activityService.addPreActivityMetadataTags(paymentId, tags) } doThrow exception + + val result = sut.addPreActivityMetadataTags(paymentId, tags) + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } + + // endregion + + // region removePreActivityMetadataTags + + @Test + fun `removePreActivityMetadataTags succeeds and notifies changed`() = test { + val paymentId = "payment-123" + val tags = listOf("tag1") + wheneverBlocking { activityService.removePreActivityMetadataTags(paymentId, tags) }.thenReturn(Unit) + + sut.preActivityMetadataChanged.test { + val initialValue = awaitItem() + + val result = sut.removePreActivityMetadataTags(paymentId, tags) + + assertTrue(result.isSuccess) + verify(activityService).removePreActivityMetadataTags(paymentId, tags) + + val updatedValue = awaitItem() + assertTrue(updatedValue > initialValue, "Changed timestamp should be updated") + } + } + + @Test + fun `removePreActivityMetadataTags with multiple tags succeeds`() = test { + val paymentId = "payment-123" + val tags = listOf("tag1", "tag2", "tag3") + wheneverBlocking { activityService.removePreActivityMetadataTags(paymentId, tags) }.thenReturn(Unit) + + val result = sut.removePreActivityMetadataTags(paymentId, tags) + + assertTrue(result.isSuccess) + verify(activityService).removePreActivityMetadataTags(paymentId, tags) + } + + @Test + fun `removePreActivityMetadataTags returns failure on exception`() = test { + val paymentId = "payment-123" + val tags = listOf("tag1") + val exception = RuntimeException("Remove tags failed") + wheneverBlocking { activityService.removePreActivityMetadataTags(paymentId, tags) } doThrow exception + + val result = sut.removePreActivityMetadataTags(paymentId, tags) + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } + + // endregion + + // region resetPreActivityMetadataTags + + @Test + fun `resetPreActivityMetadataTags succeeds and notifies changed`() = test { + val paymentId = "payment-123" + wheneverBlocking { activityService.resetPreActivityMetadataTags(paymentId) }.thenReturn(Unit) + + sut.preActivityMetadataChanged.test { + val initialValue = awaitItem() + + val result = sut.resetPreActivityMetadataTags(paymentId) + + assertTrue(result.isSuccess) + verify(activityService).resetPreActivityMetadataTags(paymentId) + + val updatedValue = awaitItem() + assertTrue(updatedValue > initialValue, "Changed timestamp should be updated") + } + } + + @Test + fun `resetPreActivityMetadataTags returns failure on exception`() = test { + val paymentId = "payment-123" + val exception = RuntimeException("Reset failed") + wheneverBlocking { activityService.resetPreActivityMetadataTags(paymentId) } doThrow exception + + val result = sut.resetPreActivityMetadataTags(paymentId) + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } + + // endregion + + // region getPreActivityMetadata + + @Test + fun `getPreActivityMetadata by payment id returns metadata`() = test { + val searchKey = "payment-123" + wheneverBlocking { activityService.getPreActivityMetadata(searchKey, false) }.thenReturn(testMetadata) + + val result = sut.getPreActivityMetadata(searchKey, searchByAddress = false) + + assertTrue(result.isSuccess) + assertEquals(testMetadata, result.getOrNull()) + verify(activityService).getPreActivityMetadata(searchKey, false) + } + + @Test + fun `getPreActivityMetadata by address returns metadata`() = test { + val address = "bc1qtest" + wheneverBlocking { activityService.getPreActivityMetadata(address, true) }.thenReturn(testMetadata) + + val result = sut.getPreActivityMetadata(address, searchByAddress = true) + + assertTrue(result.isSuccess) + assertEquals(testMetadata, result.getOrNull()) + verify(activityService).getPreActivityMetadata(address, true) + } + + @Test + fun `getPreActivityMetadata returns null when not found`() = test { + val searchKey = "non-existent" + wheneverBlocking { activityService.getPreActivityMetadata(searchKey, false) }.thenReturn(null) + + val result = sut.getPreActivityMetadata(searchKey, searchByAddress = false) + + assertTrue(result.isSuccess) + assertNull(result.getOrNull()) + } + + @Test + fun `getPreActivityMetadata returns failure on exception`() = test { + val searchKey = "payment-123" + val exception = RuntimeException("Query failed") + wheneverBlocking { activityService.getPreActivityMetadata(searchKey, false) } doThrow exception + + val result = sut.getPreActivityMetadata(searchKey, searchByAddress = false) + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } + + // endregion + + // region deletePreActivityMetadata + + @Test + fun `deletePreActivityMetadata succeeds and notifies changed`() = test { + val paymentId = "payment-123" + wheneverBlocking { activityService.deletePreActivityMetadata(paymentId) }.thenReturn(Unit) + + sut.preActivityMetadataChanged.test { + val initialValue = awaitItem() + + val result = sut.deletePreActivityMetadata(paymentId) + + assertTrue(result.isSuccess) + verify(activityService).deletePreActivityMetadata(paymentId) + + val updatedValue = awaitItem() + assertTrue(updatedValue > initialValue, "Changed timestamp should be updated") + } + } + + @Test + fun `deletePreActivityMetadata returns failure on exception`() = test { + val paymentId = "payment-123" + val exception = RuntimeException("Delete failed") + wheneverBlocking { activityService.deletePreActivityMetadata(paymentId) } doThrow exception + + val result = sut.deletePreActivityMetadata(paymentId) + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } + + // endregion + + // region savePreActivityMetadata + + @Test + fun `savePreActivityMetadata with tags succeeds`() = test { + val id = "payment-123" + val address = "bc1qtest" + val tags = listOf("tag1", "tag2") + wheneverBlocking { activityService.upsertPreActivityMetadata(any()) }.thenReturn(Unit) + + val result = sut.savePreActivityMetadata( + id = id, + address = address, + isReceive = true, + tags = tags + ) + + assertTrue(result.isSuccess) + verify(activityService).upsertPreActivityMetadata(any()) + } + + @Test + fun `savePreActivityMetadata with transfer flag succeeds even without tags`() = test { + val id = "payment-123" + val address = "bc1qtest" + wheneverBlocking { activityService.upsertPreActivityMetadata(any()) }.thenReturn(Unit) + + val result = sut.savePreActivityMetadata( + id = id, + address = address, + isReceive = false, + tags = emptyList(), + isTransfer = true + ) + + assertTrue(result.isSuccess) + } + + @Test + fun `savePreActivityMetadata with all optional parameters succeeds`() = test { + val id = "payment-123" + wheneverBlocking { activityService.upsertPreActivityMetadata(any()) }.thenReturn(Unit) + + val result = sut.savePreActivityMetadata( + id = id, + paymentHash = "hash-123", + txId = "tx-123", + address = "bc1qtest", + isReceive = false, + tags = listOf("important"), + feeRate = 10u, + isTransfer = true, + channelId = "channel-123" + ) + + assertTrue(result.isSuccess) + verify(activityService).upsertPreActivityMetadata(any()) + } + + @Test + fun `savePreActivityMetadata fails when no tags and not transfer`() = test { + val id = "payment-123" + val address = "bc1qtest" + + val result = sut.savePreActivityMetadata( + id = id, + address = address, + isReceive = true, + tags = emptyList(), + isTransfer = false + ) + + assertTrue(result.isFailure) + assertNotNull(result.exceptionOrNull()) + assertTrue(result.exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun `savePreActivityMetadata uses default feeRate when not provided`() = test { + val id = "payment-123" + val address = "bc1qtest" + val tags = listOf("tag1") + + wheneverBlocking { activityService.upsertPreActivityMetadata(any()) }.thenAnswer { invocation -> + val metadataList = invocation.getArgument>(0) + assertEquals(0u, metadataList.first().feeRate, "Default feeRate should be 0") + Unit + } + + val result = sut.savePreActivityMetadata( + id = id, + address = address, + isReceive = true, + tags = tags, + feeRate = null + ) + + assertTrue(result.isSuccess) + } + + @Test + fun `savePreActivityMetadata uses empty string for null channelId`() = test { + val id = "payment-123" + val address = "bc1qtest" + val tags = listOf("tag1") + + wheneverBlocking { activityService.upsertPreActivityMetadata(any()) }.thenAnswer { invocation -> + val metadataList = invocation.getArgument>(0) + assertEquals("", metadataList.first().channelId, "Null channelId should be empty string") + Unit + } + + val result = sut.savePreActivityMetadata( + id = id, + address = address, + isReceive = true, + tags = tags, + channelId = null + ) + + assertTrue(result.isSuccess) + } + + @Test + fun `savePreActivityMetadata returns failure on exception`() = test { + val id = "payment-123" + val address = "bc1qtest" + val tags = listOf("tag1") + val exception = RuntimeException("Save failed") + wheneverBlocking { activityService.upsertPreActivityMetadata(any()) } doThrow exception + + val result = sut.savePreActivityMetadata( + id = id, + address = address, + isReceive = true, + tags = tags + ) + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } + + // endregion + + // region Change Notification Tests + + @Test + fun `multiple operations trigger change notifications`() = test { + val paymentId = "payment-123" + val tags = listOf("tag1") + + wheneverBlocking { activityService.addPreActivityMetadataTags(any(), any()) }.thenReturn(Unit) + wheneverBlocking { activityService.removePreActivityMetadataTags(any(), any()) }.thenReturn(Unit) + + // Test that add operation triggers notification + val initialValue = sut.preActivityMetadataChanged.value + + sut.addPreActivityMetadataTags(paymentId, tags) + val afterAdd = sut.preActivityMetadataChanged.value + assertTrue(afterAdd > initialValue, "After add should update timestamp") + + // Test that remove operation also triggers notification + sut.removePreActivityMetadataTags(paymentId, tags) + val afterRemove = sut.preActivityMetadataChanged.value + assertTrue(afterRemove > afterAdd, "After remove should update timestamp") + } + + @Test + fun `failed operations do not trigger change notifications`() = test { + val paymentId = "payment-123" + val exception = RuntimeException("Operation failed") + + wheneverBlocking { activityService.deletePreActivityMetadata(paymentId) } doThrow exception + + sut.preActivityMetadataChanged.test { + awaitItem() + + sut.deletePreActivityMetadata(paymentId) + advanceUntilIdle() + + // Should not emit a new value on failure + expectNoEvents() + } + } + + @Test + fun `getAllPreActivityMetadata does not trigger change notification`() = test { + wheneverBlocking { activityService.getAllPreActivityMetadata() }.thenReturn(emptyList()) + + sut.preActivityMetadataChanged.test { + awaitItem() + + sut.getAllPreActivityMetadata() + advanceUntilIdle() + + // Read operations should not trigger change notifications + expectNoEvents() + } + } + + @Test + fun `getPreActivityMetadata does not trigger change notification`() = test { + val searchKey = "payment-123" + wheneverBlocking { activityService.getPreActivityMetadata(searchKey, false) }.thenReturn(null) + + sut.preActivityMetadataChanged.test { + awaitItem() + + sut.getPreActivityMetadata(searchKey, searchByAddress = false) + advanceUntilIdle() + + // Read operations should not trigger change notifications + expectNoEvents() + } + } + + // endregion + + // region Edge Cases + + @Test + fun `savePreActivityMetadata handles empty strings correctly`() = test { + val id = "" + val address = "" + val tags = listOf("tag1") + wheneverBlocking { activityService.upsertPreActivityMetadata(any()) }.thenReturn(Unit) + + val result = sut.savePreActivityMetadata( + id = id, + address = address, + isReceive = true, + tags = tags + ) + + assertTrue(result.isSuccess) + } + + @Test + fun `addPreActivityMetadataTags with empty tag list succeeds`() = test { + val paymentId = "payment-123" + val tags = emptyList() + wheneverBlocking { activityService.addPreActivityMetadataTags(paymentId, tags) }.thenReturn(Unit) + + val result = sut.addPreActivityMetadataTags(paymentId, tags) + + assertTrue(result.isSuccess) + verify(activityService).addPreActivityMetadataTags(paymentId, tags) + } + + @Test + fun `upsertPreActivityMetadata with empty list succeeds`() = test { + val emptyList = emptyList() + wheneverBlocking { activityService.upsertPreActivityMetadata(emptyList) }.thenReturn(Unit) + + val result = sut.upsertPreActivityMetadata(emptyList) + + assertTrue(result.isSuccess) + } + + // endregion +} diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 654987146..9ba73cfac 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -16,7 +16,6 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.AppCacheData -import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore @@ -32,13 +31,13 @@ import to.bitkit.utils.AddressInfo import to.bitkit.utils.AddressStats import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertTrue class WalletRepoTest : BaseUnitTest() { private lateinit var sut: WalletRepo - private val db = mock() private val keychain = mock() private val coreService = mock() private val onchainService = mock() @@ -46,17 +45,19 @@ class WalletRepoTest : BaseUnitTest() { private val addressChecker = mock() private val lightningRepo = mock() private val cacheStore = mock() + private val preActivityMetadataRepo = mock() private val deriveBalanceStateUseCase = mock() private val wipeWalletUseCase = mock() @Before fun setUp() { wheneverBlocking { coreService.checkGeoBlock() }.thenReturn(Pair(false, false)) - whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(bolt11 = "", onchainAddress = "testAddress"))) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) wheneverBlocking { lightningRepo.listSpendableOutputs() }.thenReturn(Result.success(emptyList())) wheneverBlocking { lightningRepo.calculateTotalFee(any(), any(), any(), any(), anyOrNull()) } .thenReturn(Result.success(1000uL)) + wheneverBlocking { lightningRepo.canReceive() }.thenReturn(false) whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) wheneverBlocking { deriveBalanceStateUseCase.invoke() }.thenReturn(Result.success(BalanceState())) @@ -64,18 +65,32 @@ class WalletRepoTest : BaseUnitTest() { whenever(keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)).thenReturn(null) whenever(coreService.onchain).thenReturn(onchainService) + wheneverBlocking { preActivityMetadataRepo.addPreActivityMetadataTags(any(), any()) } + .thenReturn(Result.success(Unit)) + wheneverBlocking { preActivityMetadataRepo.removePreActivityMetadataTags(any(), any()) } + .thenReturn(Result.success(Unit)) + wheneverBlocking { preActivityMetadataRepo.getPreActivityMetadata(any(), any()) } + .thenReturn(Result.success(null)) + wheneverBlocking { preActivityMetadataRepo.upsertPreActivityMetadata(any()) } + .thenReturn(Result.success(Unit)) + wheneverBlocking { preActivityMetadataRepo.addPreActivityMetadata(any()) } + .thenReturn(Result.success(Unit)) + wheneverBlocking { preActivityMetadataRepo.resetPreActivityMetadataTags(any()) } + .thenReturn(Result.success(Unit)) + wheneverBlocking { preActivityMetadataRepo.deletePreActivityMetadata(any()) } + .thenReturn(Result.success(Unit)) sut = createSut() } private fun createSut() = WalletRepo( bgDispatcher = testDispatcher, - db = db, keychain = keychain, coreService = coreService, settingsStore = settingsStore, addressChecker = addressChecker, lightningRepo = lightningRepo, cacheStore = cacheStore, + preActivityMetadataRepo = preActivityMetadataRepo, deriveBalanceStateUseCase = deriveBalanceStateUseCase, wipeWalletUseCase = wipeWalletUseCase, ) @@ -347,23 +362,91 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `addTagToSelected should add tag and update lastUsedTags`() = test { val testTag = "testTag" + val testAddress = "bc1qtest" - sut.addTagToSelected(testTag) + // Set address in wallet state so paymentId() returns it + sut.setOnchainAddress(testAddress) + + // Now add the tag + val result = sut.addTagToSelected(testTag) + assertTrue(result.isSuccess) assertEquals(listOf(testTag), sut.walletState.value.selectedTags) verify(settingsStore).addLastUsedTag(testTag) + verify(preActivityMetadataRepo).addPreActivityMetadataTags(testAddress, listOf(testTag)) } @Test fun `removeTag should remove tag`() = test { val testTag = "testTag" - sut.addTagToSelected(testTag) + val testAddress = "bc1qtest" + + // Set address in wallet state so paymentId() returns it + sut.setOnchainAddress(testAddress) + + val addResult = sut.addTagToSelected(testTag) + assertTrue(addResult.isSuccess) + + val removeResult = sut.removeTag(testTag) + assertTrue(removeResult.isSuccess) + + assertTrue(sut.walletState.value.selectedTags.isEmpty()) + } + + @Test + fun `addTagToSelected should fail when payment ID is not available`() = test { + // Don't set address, so paymentId() returns null + val result = sut.addTagToSelected("testTag") + + assertTrue(result.isFailure) + assertNotNull(result.exceptionOrNull()) + assertTrue(result.exceptionOrNull() is IllegalStateException) + assertTrue(sut.walletState.value.selectedTags.isEmpty()) + } + + @Test + fun `removeTag should fail when payment ID is not available`() = test { + // Don't set address, so paymentId() returns null + val result = sut.removeTag("testTag") + + assertTrue(result.isFailure) + assertNotNull(result.exceptionOrNull()) + assertTrue(result.exceptionOrNull() is IllegalStateException) + } + + @Test + fun `addTagToSelected should fail when metadata repo fails`() = test { + val testTag = "testTag" + val testAddress = "bc1qtest" + val error = RuntimeException("Repo error") + + sut.setOnchainAddress(testAddress) + wheneverBlocking { preActivityMetadataRepo.addPreActivityMetadataTags(testAddress, listOf(testTag)) } + .thenReturn(Result.failure(error)) - sut.removeTag(testTag) + val result = sut.addTagToSelected(testTag) + assertTrue(result.isFailure) + assertEquals(error, result.exceptionOrNull()) assertTrue(sut.walletState.value.selectedTags.isEmpty()) } + @Test + fun `removeTag should fail when metadata repo fails`() = test { + val testTag = "testTag" + val testAddress = "bc1qtest" + val error = RuntimeException("Repo error") + + sut.setOnchainAddress(testAddress) + wheneverBlocking { preActivityMetadataRepo.removePreActivityMetadataTags(testAddress, listOf(testTag)) } + .thenReturn(Result.failure(error)) + + val result = sut.removeTag(testTag) + + assertTrue(result.isFailure) + assertEquals(error, result.exceptionOrNull()) + } + @Test fun `shouldRequestAdditionalLiquidity should return false when geoBlocked is true`() = test { // Given @@ -453,7 +536,9 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `clearBip21State should clear all bip21 related state`() = test { - sut.addTagToSelected("tag1") + sut.setOnchainAddress("bc1qtest") + val addResult = sut.addTagToSelected("tag1") + assertTrue(addResult.isSuccess) sut.updateBip21Invoice(amountSats = 1000uL, description = "test") sut.clearBip21State()