diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index 8b99d5ff79..e573d162a4 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -5,12 +5,29 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType +import to.bitkit.models.ActivityWalletType + +val DEFAULT_WALLET_ID: String get() = ActivityWalletType.BITKIT.id fun Activity.rawId(): String = when (this) { is Activity.Lightning -> v1.id is Activity.Onchain -> v1.id } +fun Activity.walletId(): String = when (this) { + is Activity.Lightning -> v1.walletId + is Activity.Onchain -> v1.walletId +} + +fun Activity.scopedId(): String = "${walletId()}:${rawId()}" + +fun Activity.activityKey(): String = when (this) { + is Activity.Lightning -> "lightning_${scopedId()}" + is Activity.Onchain -> "onchain_${scopedId()}" +} + +fun Activity.isHardwareWalletActivity(): Boolean = ActivityWalletType.TREZOR.owns(walletId()) + fun Activity.txType(): PaymentType = when (this) { is Activity.Lightning -> v1.txType is Activity.Onchain -> v1.txType @@ -35,6 +52,21 @@ fun Activity.totalValue() = when (this) { } } +fun Activity.value() = when (this) { + is Activity.Lightning -> v1.value + is Activity.Onchain -> v1.value +} + +fun Activity.fee() = when (this) { + is Activity.Lightning -> v1.fee + is Activity.Onchain -> v1.fee +} + +fun Activity.message() = when (this) { + is Activity.Lightning -> v1.message + is Activity.Onchain -> "" +} + fun Activity.isBoosted() = when (this) { is Activity.Onchain -> v1.isBoosted else -> false @@ -77,6 +109,21 @@ fun Activity.paymentState(): PaymentState? = when (this) { is Activity.Onchain -> null } +fun Activity.txId(): String? = when (this) { + is Activity.Lightning -> null + is Activity.Onchain -> v1.txId +} + +fun Activity.confirmed(): Boolean? = when (this) { + is Activity.Lightning -> null + is Activity.Onchain -> v1.confirmed +} + +fun Activity.feeRate(): ULong = when (this) { + is Activity.Lightning -> 0u + is Activity.Onchain -> v1.feeRate +} + fun Activity.Onchain.boostType() = when (this.v1.txType) { PaymentType.SENT -> BoostType.RBF PaymentType.RECEIVED -> BoostType.CPFP @@ -90,6 +137,11 @@ fun Activity.timestamp() = when (this) { } } +fun Activity.groupTimestamp() = when (this) { + is Activity.Lightning -> v1.timestamp + is Activity.Onchain -> v1.timestamp +} + enum class BoostType { RBF, CPFP } @Suppress("LongParameterList") @@ -107,7 +159,9 @@ fun LightningActivity.Companion.create( createdAt: ULong? = timestamp, updatedAt: ULong? = createdAt, seenAt: ULong? = null, + walletId: String = DEFAULT_WALLET_ID, ) = LightningActivity( + walletId = walletId, id = id, txType = txType, status = status, @@ -145,7 +199,9 @@ fun OnchainActivity.Companion.create( createdAt: ULong? = timestamp, updatedAt: ULong? = createdAt, seenAt: ULong? = null, + walletId: String = DEFAULT_WALLET_ID, ) = OnchainActivity( + walletId = walletId, id = id, txType = txType, txId = txId, diff --git a/app/src/main/java/to/bitkit/models/ActivityWalletType.kt b/app/src/main/java/to/bitkit/models/ActivityWalletType.kt new file mode 100644 index 0000000000..b3e70cea86 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/ActivityWalletType.kt @@ -0,0 +1,19 @@ +package to.bitkit.models + +import java.util.Locale + +enum class ActivityWalletType { + BITKIT, + TREZOR, + ; + + val id: String + get() = name.lowercase(Locale.US) + + fun owns(walletId: String): Boolean = walletId == id || walletId.startsWith(prefix) + + fun scopedId(value: String): String = "$prefix$value" + + private val prefix: String + get() = "$id:" +} diff --git a/app/src/main/java/to/bitkit/models/HardwareWallet.kt b/app/src/main/java/to/bitkit/models/HardwareWallet.kt index c0d19e1a01..2c7f3eb48b 100644 --- a/app/src/main/java/to/bitkit/models/HardwareWallet.kt +++ b/app/src/main/java/to/bitkit/models/HardwareWallet.kt @@ -37,6 +37,7 @@ data class HwWalletBalance( data class HwWalletReceivedTx( val txid: String, val sats: ULong, + val walletId: String, ) sealed interface HwFundingAccount { diff --git a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt index 11b4610d71..004c985fd1 100644 --- a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt +++ b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt @@ -10,6 +10,7 @@ data class NewTransactionSheetDetails( val direction: NewTransactionSheetDirection, val paymentHashOrTxId: String? = null, val activityId: String? = null, + val activityWalletId: String? = null, val sats: Long = 0, val isLoadingDetails: Boolean = false, ) { diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 7dfb8f4d10..e4e3eea144 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -34,6 +34,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.di.BgDispatcher import to.bitkit.di.IoDispatcher +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.amountOnClose import to.bitkit.ext.contact import to.bitkit.ext.isReplacedSentTransaction @@ -213,23 +214,26 @@ class ActivityRepo @Inject constructor( notifyActivitiesChanged() } - suspend fun syncHardwareOnchainActivity(activity: OnchainActivity): Result = withContext(bgDispatcher) { + suspend fun persistHardwareActivities( + activities: List, + transactionDetails: List, + ): Result = withContext(bgDispatcher) { runCatching { - val existing = coreService.activity.getOnchainActivityByTxId(activity.txId) ?: return@runCatching - val confirmTimestamp = existing.confirmTimestamp ?: activity.confirmTimestamp ?: activity.timestamp - .takeIf { activity.confirmed } - val updated = existing.copy( - confirmed = existing.confirmed || activity.confirmed, - confirmTimestamp = confirmTimestamp, - doesExist = if (activity.confirmed) true else existing.doesExist, - fee = if (existing.fee == 0uL && activity.fee > 0uL) activity.fee else existing.fee, - updatedAt = maxOf(existing.updatedAt ?: 0uL, activity.updatedAt ?: activity.timestamp), - ) - if (updated == existing) return@runCatching - coreService.activity.update(existing.id, Activity.Onchain(updated)) + if (activities.isNotEmpty()) coreService.activity.upsertList(activities) + if (transactionDetails.isNotEmpty()) coreService.activity.upsertTransactionDetailsList(transactionDetails) + if (activities.isNotEmpty() || transactionDetails.isNotEmpty()) notifyActivitiesChanged() + }.onFailure { + Logger.error("Failed to persist hardware activities", it, context = TAG) + } + } + + suspend fun deleteActivitiesForWallet(walletId: String): Result = withContext(bgDispatcher) { + runCatching { + val deleted = coreService.activity.deleteByWalletId(walletId) notifyActivitiesChanged() + Logger.info("Deleted '$deleted' activities for hardware wallet '$walletId'", context = TAG) }.onFailure { - Logger.error("Failed to sync hardware activity '${activity.txId}'", it, context = TAG) + Logger.error("Failed to delete activities for hardware wallet '$walletId'", it, context = TAG) } } @@ -257,22 +261,25 @@ class ActivityRepo @Inject constructor( return coreService.activity.shouldShowReceivedSheet(txid, value) } - suspend fun isActivitySeen(activityId: String): Boolean { - return coreService.activity.isActivitySeen(activityId) + suspend fun isActivitySeen(activityId: String, walletId: String? = null): Boolean { + return coreService.activity.isActivitySeen(activityId, walletId) } - suspend fun markActivityAsSeen(activityId: String) { - coreService.activity.markActivityAsSeen(activityId) + suspend fun markActivityAsSeen(activityId: String, walletId: String? = null) { + coreService.activity.markActivityAsSeen(activityId, walletId = walletId) notifyActivitiesChanged() } - suspend fun markOnchainActivityAsSeen(txid: String) { - coreService.activity.markOnchainActivityAsSeen(txid) + suspend fun markOnchainActivityAsSeen(txid: String, walletId: String? = null) { + coreService.activity.markOnchainActivityAsSeen(txid, walletId = walletId) notifyActivitiesChanged() } - suspend fun getTransactionDetails(txid: String): Result = runCatching { - coreService.activity.getTransactionDetails(txid) + suspend fun getTransactionDetails( + txid: String, + walletId: String? = null, + ): Result = runCatching { + coreService.activity.getTransactionDetails(txid, walletId) } suspend fun getBoostTxDoesExist(boostTxIds: List): Map { @@ -327,6 +334,7 @@ class ActivityRepo @Inject constructor( } suspend fun getActivities( + walletId: String? = null, filter: ActivityFilter? = null, txType: PaymentType? = null, tags: List? = null, @@ -337,7 +345,7 @@ class ActivityRepo @Inject constructor( sortDirection: SortDirection? = null, ): Result> = withContext(bgDispatcher) { runCatching { - coreService.activity.get(filter, txType, tags, search, minDate, maxDate, limit, sortDirection) + coreService.activity.get(walletId, filter, txType, tags, search, minDate, maxDate, limit, sortDirection) }.onFailure { Logger.error( "getActivities error. Parameters:" + @@ -355,11 +363,11 @@ class ActivityRepo @Inject constructor( } } - suspend fun getActivity(id: String): Result = withContext(bgDispatcher) { + suspend fun getActivity(id: String, walletId: String? = null): Result = withContext(bgDispatcher) { runCatching { - coreService.activity.getActivity(id) + coreService.activity.getActivity(id, walletId) }.onFailure { - Logger.error("getActivity error for ID: $id", it, context = TAG) + Logger.error("Failed to get activity '$id'", it, context = TAG) } } @@ -654,6 +662,7 @@ class ActivityRepo @Inject constructor( insertActivity( Activity.Lightning( LightningActivity( + walletId = DEFAULT_WALLET_ID, id = id, txType = PaymentType.RECEIVED, status = PaymentState.SUCCEEDED, @@ -684,15 +693,18 @@ class ActivityRepo @Inject constructor( suspend fun addTagsToActivity( activityId: String, tags: List, + walletId: String? = null, ): Result = withContext(bgDispatcher) { runCatching { - checkNotNull(coreService.activity.getActivity(activityId)) { "Activity with ID $activityId not found" } + checkNotNull(coreService.activity.getActivity(activityId, walletId)) { + "Activity with ID $activityId not found" + } - val existingTags = coreService.activity.tags(activityId) + val existingTags = coreService.activity.tags(activityId, walletId) val newTags = tags.filter { it.isNotBlank() && it !in existingTags } if (newTags.isNotEmpty()) { - coreService.activity.appendTags(activityId, newTags).getOrThrow() + coreService.activity.appendTags(activityId, newTags, walletId).getOrThrow() notifyActivitiesChanged() Logger.info("Added ${newTags.size} new tags to activity $activityId", context = TAG) } else { @@ -726,12 +738,18 @@ class ActivityRepo @Inject constructor( /** * Removes tags from an activity */ - suspend fun removeTagsFromActivity(activityId: String, tags: List): Result = + suspend fun removeTagsFromActivity( + activityId: String, + tags: List, + walletId: String? = null, + ): Result = withContext(bgDispatcher) { runCatching { - checkNotNull(coreService.activity.getActivity(activityId)) { "Activity with ID $activityId not found" } + checkNotNull(coreService.activity.getActivity(activityId, walletId)) { + "Activity with ID $activityId not found" + } - coreService.activity.dropTags(activityId, tags) + coreService.activity.dropTags(activityId, tags, walletId) notifyActivitiesChanged() Logger.info("Removed ${tags.size} tags from activity $activityId", context = TAG) }.onFailure { @@ -742,9 +760,12 @@ class ActivityRepo @Inject constructor( /** * Gets all tags for an activity */ - suspend fun getActivityTags(activityId: String): Result> = withContext(bgDispatcher) { + suspend fun getActivityTags( + activityId: String, + walletId: String? = null, + ): Result> = withContext(bgDispatcher) { runCatching { - coreService.activity.tags(activityId) + coreService.activity.tags(activityId, walletId) }.onFailure { Logger.error("getActivityTags error for activity $activityId", it, context = TAG) } diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index f4992921c0..a4851ee78a 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -4,12 +4,9 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.CoinSelection import com.synonym.bitkitcore.ComposeOutput import com.synonym.bitkitcore.ComposeResult -import com.synonym.bitkitcore.HistoryTransaction -import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorFeatures -import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WatcherEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -37,9 +34,10 @@ import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env -import to.bitkit.ext.create import to.bitkit.ext.rawId import to.bitkit.ext.runSuspendCatching +import to.bitkit.ext.scopedId +import to.bitkit.ext.walletId import to.bitkit.models.HwFundingAccount import to.bitkit.models.HwFundingAddressType import to.bitkit.models.HwFundingBroadcastResult @@ -58,9 +56,7 @@ import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton import kotlin.math.ceil -import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds -import kotlin.time.ExperimentalTime /** * Production hardware-wallet business layer. Tracks paired Trezor devices as @@ -71,14 +67,12 @@ import kotlin.time.ExperimentalTime * and the underlying watcher transport. */ @Suppress("TooManyFunctions") -@OptIn(ExperimentalTime::class) @Singleton class HwWalletRepo @Inject constructor( private val trezorRepo: TrezorRepo, private val activityRepo: ActivityRepo, private val hwWalletStore: HwWalletStore, private val settingsStore: SettingsStore, - private val clock: Clock, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { companion object { @@ -92,6 +86,7 @@ class HwWalletRepo @Inject constructor( private val activeWatchers = mutableSetOf() private val activeWatcherElectrumUrls = mutableMapOf() + private val activeWatcherWalletIds = mutableMapOf() private val retryingWatcherStarts = mutableSetOf() private val watcherSyncRequests = MutableSharedFlow(extraBufferCapacity = 1) private val _watcherData = MutableStateFlow>(emptyMap()) @@ -114,6 +109,7 @@ class HwWalletRepo @Inject constructor( } activeWatchers.clear() activeWatcherElectrumUrls.clear() + activeWatcherWalletIds.clear() retryingWatcherStarts.clear() emittedReceivedTxIds.clear() _watcherData.update { emptyMap() } @@ -278,6 +274,9 @@ class HwWalletRepo @Inject constructor( val remaining = hwWalletStore.loadKnownDevices().map { it.id }.toSet() failures.firstOrNull()?.let { throw it } check(ids.none { it in remaining }) { "Hardware wallet '$deviceId' still present after removal" } + + val walletIdToPurge = target?.walletId?.takeIf { it.isNotBlank() } + if (walletIdToPurge != null) activityRepo.deleteActivitiesForWallet(walletIdToPurge).getOrThrow() }.onFailure { watcherSyncRequests.tryEmit(Unit) } @@ -327,21 +326,6 @@ class HwWalletRepo @Inject constructor( .map { wallets -> wallets.fold(0uL) { acc, wallet -> acc + wallet.balanceSats } } .stateIn(scope, SharingStarted.Eagerly, 0uL) - val activities: StateFlow> = combine( - hwWalletStore.data, - _watcherData, - ) { data, watcherData -> - val knownDeviceIds = data.knownDevices - .filter { it.xpubs.isNotEmpty() } - .map { it.id } - .toSet() - watcherData.values - .filter { it.deviceId in knownDeviceIds } - .toMergedActivities() - .toImmutableList() - } - .stateIn(scope, SharingStarted.Eagerly, persistentListOf()) - init { observeWatcherEvents() syncWatchers() @@ -351,23 +335,24 @@ class HwWalletRepo @Inject constructor( scope.launch { trezorRepo.watcherEvents.collect { (watcherId, event) -> if (event !is WatcherEvent.TransactionsChanged) return@collect + val walletId = activeWatcherWalletIds[watcherId] ?: return@collect + val activities = event.activities.filter { it.walletId() == walletId } + val transactionDetails = event.transactionDetails.filter { it.walletId == walletId } + + activityRepo.persistHardwareActivities(activities, transactionDetails).getOrElse { + return@collect + } + val previous = _watcherData.value[watcherId] - val activities = event.transactions - .map { it.toOnchainActivity(clock, previous?.activities.orEmpty()) } - .toImmutableList() val watcher = HwWatcherData( deviceId = watcherId.toDeviceId(), addressType = watcherId.toAddressTypeKey(), balanceSats = event.balance.total, - transactions = event.transactions.toImmutableList(), - activities = activities, + activities = activities.toImmutableList(), ) val updatedWatcherData = _watcherData.value + (watcherId to watcher) _watcherData.update { updatedWatcherData } - activities.filterIsInstance().forEach { - activityRepo.syncHardwareOnchainActivity(it.v1) - } - emitReceivedTxs(previous, event, updatedWatcherData) + emitReceivedTxs(previous, activities, updatedWatcherData) } } } @@ -378,21 +363,22 @@ class HwWalletRepo @Inject constructor( */ private suspend fun emitReceivedTxs( previous: HwWatcherData?, - event: WatcherEvent.TransactionsChanged, + activities: List, watcherData: Map, ) { if (previous == null) return - val knownTxIds = previous.activities.map { it.rawId() }.toSet() + val knownActivityIds = previous.activities.map { it.scopedId() }.toSet() val mergedActivities = watcherData.values.toList().toMergedActivities() - event.transactions + activities + .filterIsInstance() .filter { - it.direction == TxDirection.RECEIVED && - it.txid !in knownTxIds && - emittedReceivedTxIds.add(it.txid) + it.v1.txType == PaymentType.RECEIVED && + it.scopedId() !in knownActivityIds && + emittedReceivedTxIds.add(it.scopedId()) } .forEach { - val sats = mergedActivities.findOnchain(it.txid)?.v1?.value ?: it.amount - _receivedTxs.emit(HwWalletReceivedTx(txid = it.txid, sats = sats)) + val sats = mergedActivities.findOnchain(it.v1.txId, it.v1.walletId)?.v1?.value ?: it.v1.value + _receivedTxs.emit(HwWalletReceivedTx(txid = it.v1.txId, sats = sats, walletId = it.v1.walletId)) } } @@ -413,53 +399,68 @@ class HwWalletRepo @Inject constructor( ) { desired, _ -> desired }.collect { (knownDevices, watcherSettings) -> - // Only watch the address types the user monitors (Settings > Advanced > Address Type), - // mirroring the on-chain wallet. Xpubs for all types are still captured on connect, so - // toggling a type on later starts its watcher without reconnecting the device. - // Device entries sharing an xpub (same device on bluetooth and usb) watch it only once. - val filtered = knownDevices.flatMap { device -> - device.xpubs - .filterKeys { it in watcherSettings.monitoredTypes } - .map { (addressType, xpub) -> - WatcherSpec(device.id, addressType, xpub, watcherSettings.electrumUrl) - } - }.distinctBy { it.addressType to it.xpub } - val filteredIds = filtered.map { it.watcherId }.toSet() - - filtered.forEach { spec -> - val isActive = spec.watcherId in activeWatchers - if (isActive && activeWatcherElectrumUrls[spec.watcherId] == spec.electrumUrl) return@forEach - if (isActive && !stopActiveWatcher(spec.watcherId)) return@forEach - - trezorRepo.startWatcher( - watcherId = spec.watcherId, - extendedKey = spec.xpub, - network = Env.network.toCoreNetwork(), - accountType = spec.addressType.toAddressType()?.toAccountType(), - electrumUrl = spec.electrumUrl, - ).onSuccess { - activeWatchers += spec.watcherId - activeWatcherElectrumUrls[spec.watcherId] = spec.electrumUrl - retryingWatcherStarts -= spec.watcherId - }.onFailure { - Logger.warn("Retrying watcher '${spec.watcherId}' after start failure", it, context = TAG) - scheduleWatcherStartRetry(spec.watcherId) - } - } + val filtered = knownDevices.toWatcherSpecs(watcherSettings) + filtered.forEach { syncWatcher(it) } // A failed stop stays active so the next sync retries it; dropping it here // would leave the orphaned watcher feeding _watcherData as a ghost balance. - (activeWatchers - filteredIds).forEach { staleId -> + (activeWatchers - filtered.map { it.watcherId }.toSet()).forEach { staleId -> stopActiveWatcher(staleId) } } } } + private fun List.toWatcherSpecs(watcherSettings: WatcherSettings): List = + flatMap { device -> + val walletId = device.walletId.takeIf { it.isNotBlank() } + ?: trezorRepo.deriveWalletId(device.xpubs.values) + if (walletId.isBlank()) return@flatMap emptyList() + + device.xpubs + .filterKeys { it in watcherSettings.monitoredTypes } + .map { (addressType, xpub) -> + WatcherSpec(device.id, walletId, addressType, xpub, watcherSettings.electrumUrl) + } + }.distinctBy { it.addressType to it.xpub } + + private suspend fun syncWatcher(spec: WatcherSpec) { + val isActive = spec.watcherId in activeWatchers + if ( + isActive && + activeWatcherElectrumUrls[spec.watcherId] == spec.electrumUrl && + activeWatcherWalletIds[spec.watcherId] == spec.walletId + ) { + return + } + if (isActive && !stopActiveWatcher(spec.watcherId)) return + + activeWatchers += spec.watcherId + activeWatcherElectrumUrls[spec.watcherId] = spec.electrumUrl + activeWatcherWalletIds[spec.watcherId] = spec.walletId + trezorRepo.startWatcher( + watcherId = spec.watcherId, + walletId = spec.walletId, + extendedKey = spec.xpub, + network = Env.network.toCoreNetwork(), + accountType = spec.addressType.toAddressType()?.toAccountType(), + electrumUrl = spec.electrumUrl, + ).onSuccess { + retryingWatcherStarts -= spec.watcherId + }.onFailure { + activeWatchers -= spec.watcherId + activeWatcherElectrumUrls -= spec.watcherId + activeWatcherWalletIds -= spec.watcherId + Logger.warn("Retrying watcher '${spec.watcherId}' after start failure", it, context = TAG) + scheduleWatcherStartRetry(spec.watcherId) + } + } + private suspend fun stopActiveWatcher(watcherId: String): Boolean = trezorRepo.stopWatcher(watcherId).onSuccess { activeWatchers -= watcherId activeWatcherElectrumUrls -= watcherId + activeWatcherWalletIds -= watcherId _watcherData.update { it - watcherId } }.isSuccess @@ -473,66 +474,62 @@ class HwWalletRepo @Inject constructor( } } - private fun HistoryTransaction.toOnchainActivity(clock: Clock, previousActivities: List): Activity { - val activityTimestamp = timestamp ?: previousActivities.findOnchain(txid)?.v1?.timestamp - ?: clock.now().epochSeconds.toULong() - return listOf(this).toOnchainActivity( - timestamp = activityTimestamp, - sourceActivities = previousActivities, - ) - } - - private fun List.toMergedActivities(): List { - val sourceActivities = flatMap { it.activities } - return flatMap { it.transactions } - .groupBy { it.txid } + private fun List.toMergedActivities(): List = + flatMap { it.activities } + .groupBy { it.rawId() } .values - .map { transactions -> - val timestamp = transactions.mapNotNull { it.timestamp }.minOrNull() - ?: sourceActivities.findOnchain(transactions.first().txid)?.v1?.timestamp - ?: 0uL - transactions.toOnchainActivity(timestamp, sourceActivities) - } - } + .map { it.mergedActivity() } - private fun List.toOnchainActivity( - timestamp: ULong, - sourceActivities: List, - ): Activity { - val first = first() - val received = fold(0uL) { acc, tx -> acc.safe() + tx.received.safe() } - val sent = fold(0uL) { acc, tx -> acc.safe() + tx.sent.safe() } - val fee = mapNotNull { it.fee }.maxOrNull() ?: 0uL - val type = when { + private fun List.mergedActivity(): Activity { + if (size == 1) return first() + + val onchainActivities = filterIsInstance() + if (onchainActivities.size != size) return first() + + val base = onchainActivities.minBy { it.v1.timestamp } + val received = onchainActivities.filter { it.v1.txType == PaymentType.RECEIVED } + .fold(0uL) { acc, activity -> acc.safe() + activity.v1.value.safe() } + val sent = onchainActivities.filter { it.v1.txType == PaymentType.SENT } + .fold(0uL) { acc, activity -> acc.safe() + activity.v1.value.safe() } + val fee = onchainActivities.maxOf { it.v1.fee } + val txType = when { received > sent -> PaymentType.RECEIVED - else -> PaymentType.SENT + sent > received -> PaymentType.SENT + else -> base.v1.txType } - val value = when (type) { + val value = when (txType) { PaymentType.RECEIVED -> received.safe() - sent.safe() PaymentType.SENT -> (sent.safe() - received.safe()).safe() - fee.safe() } - val confirmations = maxOf { it.confirmations } - val sourceActivity = sourceActivities.findOnchain(first.txid) + return Activity.Onchain( - OnchainActivity.create( - id = first.txid, - txType = type, - txId = first.txid, + base.v1.copy( + txType = txType, value = value, fee = fee, - address = "", - timestamp = timestamp, - confirmed = confirmations > 0u, - confirmTimestamp = sourceActivity?.v1?.confirmTimestamp, + address = onchainActivities.firstOrNull { it.v1.address.isNotBlank() }?.v1?.address.orEmpty(), + confirmed = onchainActivities.any { it.v1.confirmed }, + isBoosted = onchainActivities.any { it.v1.isBoosted }, + boostTxIds = onchainActivities.flatMap { it.v1.boostTxIds }.distinct(), + isTransfer = onchainActivities.any { it.v1.isTransfer }, + doesExist = onchainActivities.any { it.v1.doesExist }, + confirmTimestamp = onchainActivities.mapNotNull { it.v1.confirmTimestamp }.maxOrNull(), + channelId = onchainActivities.firstNotNullOfOrNull { it.v1.channelId }, + transferTxId = onchainActivities.firstNotNullOfOrNull { it.v1.transferTxId }, + contact = onchainActivities.firstNotNullOfOrNull { it.v1.contact }, + createdAt = onchainActivities.mapNotNull { it.v1.createdAt }.minOrNull(), + updatedAt = onchainActivities.mapNotNull { it.v1.updatedAt }.maxOrNull(), + seenAt = onchainActivities.mapNotNull { it.v1.seenAt }.minOrNull(), ) ) } - private fun List.findOnchain(txid: String) = filterIsInstance() - .firstOrNull { it.v1.txId == txid } + private fun List.findOnchain(txid: String, walletId: String) = filterIsInstance() + .firstOrNull { it.v1.txId == txid && it.v1.walletId == walletId } private data class WatcherSpec( val deviceId: String, + val walletId: String, val addressType: String, val xpub: String, val electrumUrl: String, @@ -577,6 +574,5 @@ private data class HwWatcherData( val deviceId: String, val addressType: String, val balanceSats: ULong, - val transactions: ImmutableList, val activities: ImmutableList, ) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 127a992b06..f073dea152 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -62,6 +62,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Defaults import to.bitkit.env.Env +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp @@ -1178,6 +1179,7 @@ class LightningRepo @Inject constructor( val txId = lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount) val preActivityMetadata = PreActivityMetadata( + walletId = DEFAULT_WALLET_ID, paymentId = txId, createdAt = nowTimestamp().toEpochMilli().toULong(), tags = tags, diff --git a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt index d0fa79b790..e8cbb994c0 100644 --- a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import to.bitkit.di.IoDispatcher +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp import to.bitkit.services.CoreService @@ -132,6 +133,7 @@ class PreActivityMetadataRepo @Inject constructor( require(tags.isNotEmpty() || isTransfer) val preActivityMetadata = PreActivityMetadata( + walletId = DEFAULT_WALLET_ID, paymentId = id, createdAt = nowTimestamp().toEpochMilli().toULong(), tags = tags, diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 1bb77cf30b..bc2e7f38da 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -55,6 +55,7 @@ import to.bitkit.ext.nowMs import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toTransportType import to.bitkit.models.ALL_ADDRESS_TYPES +import to.bitkit.models.ActivityWalletType import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.toAccountDerivationPath @@ -69,7 +70,6 @@ import to.bitkit.services.TrezorWalletMode import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.File -import java.util.UUID import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock @@ -737,9 +737,10 @@ class TrezorRepo @Inject constructor( suspend fun startWatcher( watcherId: String, + walletId: String, extendedKey: String, network: BitkitCoreNetwork, - gapLimit: UInt = 20u, + gapLimit: UInt = DEFAULT_GAP_LIMIT, accountType: AccountType? = null, electrumUrl: String = electrumUrlForNetwork(network), ): Result = withContext(ioDispatcher) { @@ -747,6 +748,7 @@ class TrezorRepo @Inject constructor( awaitSetup() val params = WatcherParams( watcherId = watcherId, + walletId = walletId, extendedKey = extendedKey, electrumUrl = electrumUrl, network = network, @@ -762,6 +764,12 @@ class TrezorRepo @Inject constructor( } } + fun deriveWalletId(xpubs: Collection): String { + val xpubList = xpubs.toList() + if (xpubList.isEmpty() || xpubList.any { it.isBlank() }) return "" + return trezorService.deriveWalletId(ActivityWalletType.TREZOR.id, xpubList) + } + suspend fun stopWatcher(watcherId: String): Result = withContext(ioDispatcher) { runCatching { awaitSetup() @@ -886,7 +894,7 @@ class TrezorRepo @Inject constructor( lastConnectedAt = clock.nowMs(), xpubs = xpubs, customLabel = previous?.customLabel, - walletId = knownDevices.findHardwareWalletId(deviceInfo.id, xpubs), + walletId = knownDevices.findHardwareWalletId(deviceInfo.id, xpubs, ::deriveWalletId), ) val updated = knownDevices.filter { it.id != known.id } + known saveKnownDevices(updated) @@ -916,7 +924,7 @@ class TrezorRepo @Inject constructor( private suspend fun loadKnownDevices(): List = runCatching { val devices = hwWalletStore.loadKnownDevices() - val migrated = devices.withHardwareWalletIds() + val migrated = devices.withHardwareWalletIds(::deriveWalletId) if (migrated != devices) { hwWalletStore.saveKnownDevices(migrated) } @@ -1086,26 +1094,33 @@ private val KnownDevice.walletKey: String private fun walletKey(xpubs: Map, fallback: String): String = xpubs.values.sorted().joinToString().ifEmpty { fallback } -private fun List.findHardwareWalletId(deviceId: String, xpubs: Map): String { +private fun List.findHardwareWalletId( + deviceId: String, + xpubs: Map, + deriveWalletId: (Collection) -> String, +): String { val walletKey = walletKey(xpubs, deviceId) - return firstOrNull { it.id == deviceId }?.walletId?.takeIf { it.isNotBlank() } - ?: firstOrNull { it.walletKey == walletKey }?.walletId?.takeIf { it.isNotBlank() } - ?: newHardwareWalletId() + firstOrNull { it.walletKey == walletKey }?.walletId?.takeIf { it.isNotBlank() }?.let { return it } + if (xpubs.values.any { it.isNotBlank() }) return deriveWalletId(xpubs.values) + + return firstOrNull { it.id == deviceId }?.walletId?.takeIf { it.isNotBlank() }.orEmpty() } -private fun List.withHardwareWalletIds(): List { +private fun List.withHardwareWalletIds( + deriveWalletId: (Collection) -> String, +): List { val existingByWallet = filter { it.walletId.isNotBlank() } .associate { it.walletKey to it.walletId } val generatedByWallet = mutableMapOf() return map { val walletId = existingByWallet[it.walletKey] - ?: generatedByWallet.getOrPut(it.walletKey) { newHardwareWalletId() } + ?: generatedByWallet.getOrPut(it.walletKey) { deriveWalletId(it.xpubs.values) } if (it.walletId == walletId) it else it.copy(walletId = walletId) } } -private fun newHardwareWalletId(): String = UUID.randomUUID().toString() +private const val DEFAULT_GAP_LIMIT = 20u private fun KnownDevice.toDeviceInfo() = TrezorDeviceInfo( id = id, diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 6c53356f5f..74495c89ee 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -28,6 +28,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Env +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.filterOpen import to.bitkit.ext.nowTimestamp import to.bitkit.ext.toHex @@ -236,6 +237,7 @@ class WalletRepo @Inject constructor( }.getOrNull() val preActivityMetadata = PreActivityMetadata( + walletId = DEFAULT_WALLET_ID, paymentId = paymentId, createdAt = nowTimestamp().toEpochMilli().toULong(), tags = tags, diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index b11bddae8d..d7182803cd 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -31,6 +31,7 @@ import com.synonym.bitkitcore.WordCount import com.synonym.bitkitcore.addTags import com.synonym.bitkitcore.createCjitEntry import com.synonym.bitkitcore.createOrder +import com.synonym.bitkitcore.deleteActivitiesByWalletId import com.synonym.bitkitcore.deleteActivityById import com.synonym.bitkitcore.deriveOnchainDescriptor import com.synonym.bitkitcore.estimateOrderFeeFull @@ -81,10 +82,15 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.env.Defaults import to.bitkit.env.Env +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.amountSats import to.bitkit.ext.channelId import to.bitkit.ext.create import to.bitkit.ext.latestSpendingTxid +import to.bitkit.ext.nowTimestamp +import to.bitkit.ext.rawId +import to.bitkit.ext.runSuspendCatching +import to.bitkit.ext.walletId import to.bitkit.models.ALL_ADDRESS_TYPES import to.bitkit.models.DEFAULT_ADDRESS_TYPE import to.bitkit.models.addressTypeFromAddress @@ -242,10 +248,13 @@ class ActivityService( private val settingsStore: SettingsStore, private val privatePaykitContactResolver: Provider, ) { + private val defaultWalletId: String = DEFAULT_WALLET_ID + suspend fun removeAll() { ServiceQueue.CORE.background { // Get all activities and delete them one by one val activities = getActivities( + walletId = null, filter = ActivityFilter.ALL, txType = null, tags = null, @@ -256,15 +265,18 @@ class ActivityService( sortDirection = null ) for (activity in activities) { - val id = when (activity) { - is Activity.Lightning -> activity.v1.id - is Activity.Onchain -> activity.v1.id + when (activity) { + is Activity.Lightning -> deleteActivityById(activity.v1.walletId, activity.v1.id) + is Activity.Onchain -> deleteActivityById(activity.v1.walletId, activity.v1.id) } - deleteActivityById(activityId = id) } } } + suspend fun deleteByWalletId(walletId: String): UInt = ServiceQueue.CORE.background { + deleteActivitiesByWalletId(walletId) + } + suspend fun insert(activity: Activity) = ServiceQueue.CORE.background { insertActivity(activity) } @@ -277,9 +289,14 @@ class ActivityService( upsertActivities(activities) } + suspend fun upsertTransactionDetailsList(list: List) = ServiceQueue.CORE.background { + upsertTransactionDetails(list) + } + private fun mapToCoreTransactionDetails( txid: String, details: TransactionDetails, + walletId: String = defaultWalletId, ): BitkitCoreTransactionDetails { val inputs = details.inputs.map { input -> BitkitCoreTxInput( @@ -300,6 +317,7 @@ class ActivityService( ) } return BitkitCoreTransactionDetails( + walletId = walletId, txId = txid, amountSats = details.amountSats, inputs = inputs, @@ -307,16 +325,22 @@ class ActivityService( ) } - suspend fun getTransactionDetails(txid: String): BitkitCoreTransactionDetails? = ServiceQueue.CORE.background { - getBitkitCoreTransactionDetails(txid) + suspend fun getTransactionDetails( + txid: String, + walletId: String? = null, + ): BitkitCoreTransactionDetails? = ServiceQueue.CORE.background { + getBitkitCoreTransactionDetails(walletId ?: defaultWalletId, txid) } - suspend fun getActivity(id: String): Activity? = ServiceQueue.CORE.background { - getActivityById(id) + suspend fun getActivity(id: String, walletId: String? = null): Activity? = ServiceQueue.CORE.background { + getActivityById(walletId ?: defaultWalletId, id) } - suspend fun getOnchainActivityByTxId(txId: String): OnchainActivity? = ServiceQueue.CORE.background { - getActivityByTxId(txId = txId) + suspend fun getOnchainActivityByTxId( + txId: String, + walletId: String? = null, + ): OnchainActivity? = ServiceQueue.CORE.background { + getActivityByTxId(walletId = walletId ?: defaultWalletId, txId = txId) } suspend fun hasOnchainActivityForChannel(channelId: String): Boolean { @@ -329,6 +353,7 @@ class ActivityService( @Suppress("LongParameterList") suspend fun get( + walletId: String? = null, filter: ActivityFilter? = null, txType: PaymentType? = null, tags: List? = null, @@ -338,30 +363,39 @@ class ActivityService( limit: UInt? = null, sortDirection: SortDirection? = null, ): List = ServiceQueue.CORE.background { - getActivities(filter, txType, tags, search, minDate, maxDate, limit, sortDirection) + getActivities(walletId, filter, txType, tags, search, minDate, maxDate, limit, sortDirection) } suspend fun update(id: String, activity: Activity) = ServiceQueue.CORE.background { updateActivity(id, activity) } - suspend fun delete(id: String): Boolean = ServiceQueue.CORE.background { - deleteActivityById(id) + suspend fun delete(id: String, walletId: String? = null): Boolean = ServiceQueue.CORE.background { + deleteActivityById(walletId ?: defaultWalletId, id) } - suspend fun appendTags(toActivityId: String, tags: List): Result = runCatching { + suspend fun appendTags( + toActivityId: String, + tags: List, + walletId: String? = null, + ): Result = runSuspendCatching { ServiceQueue.CORE.background { - addTags(toActivityId, tags) + addTags(walletId ?: defaultWalletId, toActivityId, tags) } } - suspend fun dropTags(fromActivityId: String, tags: List) = ServiceQueue.CORE.background { - removeTags(fromActivityId, tags) + suspend fun dropTags( + fromActivityId: String, + tags: List, + walletId: String? = null, + ) = ServiceQueue.CORE.background { + removeTags(walletId ?: defaultWalletId, fromActivityId, tags) } - suspend fun tags(forActivityId: String): List = ServiceQueue.CORE.background { - getTags(forActivityId) - } + suspend fun tags(forActivityId: String, walletId: String? = null): List = + ServiceQueue.CORE.background { + getTags(walletId ?: defaultWalletId, forActivityId) + } suspend fun allPossibleTags(): List = ServiceQueue.CORE.background { getAllUniqueTags() @@ -388,26 +422,38 @@ class ActivityService( } suspend fun addPreActivityMetadataTags(paymentId: String, tags: List) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.addPreActivityMetadataTags(paymentId = paymentId, tags = tags) + com.synonym.bitkitcore.addPreActivityMetadataTags( + walletId = defaultWalletId, + paymentId = paymentId, + tags = tags + ) } suspend fun removePreActivityMetadataTags(paymentId: String, tags: List) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.removePreActivityMetadataTags(paymentId = paymentId, tags = tags) + com.synonym.bitkitcore.removePreActivityMetadataTags( + walletId = defaultWalletId, + paymentId = paymentId, + tags = tags, + ) } suspend fun resetPreActivityMetadataTags(paymentId: String) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.resetPreActivityMetadataTags(paymentId = paymentId) + com.synonym.bitkitcore.resetPreActivityMetadataTags(walletId = defaultWalletId, paymentId = paymentId) } suspend fun getPreActivityMetadata( searchKey: String, searchByAddress: Boolean = false, ): PreActivityMetadata? = ServiceQueue.CORE.background { - com.synonym.bitkitcore.getPreActivityMetadata(searchKey = searchKey, searchByAddress = searchByAddress) + com.synonym.bitkitcore.getPreActivityMetadata( + walletId = defaultWalletId, + searchKey = searchKey, + searchByAddress = searchByAddress, + ) } suspend fun deletePreActivityMetadata(paymentId: String) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.deletePreActivityMetadata(paymentId = paymentId) + com.synonym.bitkitcore.deletePreActivityMetadata(walletId = defaultWalletId, paymentId = paymentId) } suspend fun upsertClosedChannelList(closedChannels: List) = ServiceQueue.CORE.background { @@ -510,7 +556,7 @@ class ActivityService( return } - val existingActivity = getActivityById(payment.id) + val existingActivity = getActivityById(defaultWalletId, payment.id) if (existingActivity is Activity.Lightning) { val statusChanging = existingActivity.v1.status != state val needsPrivateContactAttribution = existingActivity.v1.contact == null && @@ -550,7 +596,7 @@ class ActivityService( ) } - if (getActivityById(payment.id) != null) { + if (getActivityById(defaultWalletId, payment.id) != null) { updateActivity(payment.id, Activity.Lightning(ln)) } else { upsertActivity(Activity.Lightning(ln)) @@ -890,7 +936,7 @@ class ActivityService( val timestamp = payment.latestUpdateTimestamp val confirmationData = getConfirmationStatus(kind, timestamp) - var existingActivity = getActivityById(payment.id) + var existingActivity = getActivityById(defaultWalletId, payment.id) if (existingActivity == null) { getOnchainActivityByTxId(kind.txid)?.let { existingActivity = Activity.Onchain(it) @@ -1389,43 +1435,52 @@ class ActivityService( } } - suspend fun isActivitySeen(activityId: String): Boolean = ServiceQueue.CORE.background { - val activity = getActivityById(activityId) ?: return@background false + suspend fun isActivitySeen(activityId: String, walletId: String? = null): Boolean = ServiceQueue.CORE.background { + val activity = getActivityById(walletId ?: defaultWalletId, activityId) ?: return@background false return@background when (activity) { is Activity.Lightning -> activity.v1.seenAt != null is Activity.Onchain -> activity.v1.seenAt != null } } - suspend fun markActivityAsSeen(activityId: String, seenAt: ULong? = null) = ServiceQueue.CORE.background { - val activity = getActivityById(activityId) ?: run { - Logger.warn("Cannot mark activity as seen - activity not found: $activityId", context = TAG) + suspend fun markActivityAsSeen( + activityId: String, + walletId: String? = null, + seenAt: ULong? = null, + ) = ServiceQueue.CORE.background { + val activity = getActivityById(walletId ?: defaultWalletId, activityId) ?: run { + Logger.warn("Skipped marking activity '$activityId' as seen because it was not found", context = TAG) return@background } - val timestamp = seenAt ?: (System.currentTimeMillis().toULong() / 1000u) + val timestamp = seenAt ?: nowTimestamp().epochSecond.toULong() val updatedActivity = when (activity) { is Activity.Lightning -> Activity.Lightning(activity.v1.copy(seenAt = timestamp)) is Activity.Onchain -> Activity.Onchain(activity.v1.copy(seenAt = timestamp)) } updateActivity(activityId, updatedActivity) - Logger.info("Marked activity $activityId as seen at $timestamp", context = TAG) + Logger.info("Marked activity '$activityId' as seen at '$timestamp'", context = TAG) } - suspend fun markOnchainActivityAsSeen(txid: String, seenAt: ULong? = null) { + suspend fun markOnchainActivityAsSeen( + txid: String, + walletId: String? = null, + seenAt: ULong? = null, + ) { val activity = ServiceQueue.CORE.background { - getOnchainActivityByTxId(txid) + getOnchainActivityByTxId(txid, walletId) } ?: run { - Logger.warn("Cannot mark onchain activity as seen - activity not found for txid: $txid", context = TAG) + Logger.warn("Skipped marking onchain activity '$txid' as seen because it was not found", context = TAG) return } - markActivityAsSeen(activity.id, seenAt) + markActivityAsSeen(activity.id, walletId = activity.walletId, seenAt = seenAt) } suspend fun markAllUnseenActivitiesAsSeen() = ServiceQueue.CORE.background { - val timestamp = (System.currentTimeMillis() / 1000).toULong() + val timestamp = nowTimestamp().epochSecond.toULong() val activities = getActivities( + walletId = null, filter = ActivityFilter.ALL, txType = null, tags = null, @@ -1443,11 +1498,7 @@ class ActivityService( } if (!isSeen) { - val activityId = when (activity) { - is Activity.Onchain -> activity.v1.id - is Activity.Lightning -> activity.v1.id - } - markActivityAsSeen(activityId, timestamp) + markActivityAsSeen(activity.rawId(), walletId = activity.walletId(), seenAt = timestamp) } } } diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index ba2b1ae635..6fe30200e4 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -46,6 +46,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin import to.bitkit.di.json import to.bitkit.env.Env +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.CoinSelectionPreference import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING @@ -948,12 +949,12 @@ class MigrationService @Inject constructor( val onchain = activityRepo.getOnchainActivityByTxId(activityId) if (onchain != null) { applied++ - ActivityTags(activityId = onchain.id, tags = tagList) + ActivityTags(walletId = DEFAULT_WALLET_ID, activityId = onchain.id, tags = tagList) } else { val activity = activityRepo.getActivity(activityId).getOrNull() if (activity != null) { applied++ - ActivityTags(activityId = activityId, tags = tagList) + ActivityTags(walletId = DEFAULT_WALLET_ID, activityId = activityId, tags = tagList) } else { Logger.warn("Activity not found for tags: id=$activityId", context = TAG) null @@ -1005,6 +1006,7 @@ class MigrationService @Inject constructor( Activity.Lightning( LightningActivity( + walletId = DEFAULT_WALLET_ID, id = item.id, txType = txType, status = status, @@ -1960,6 +1962,7 @@ class MigrationService @Inject constructor( val activityTimestamp = if (timestampSecs > 0u) timestampSecs else now val newOnchain = OnchainActivity( + walletId = DEFAULT_WALLET_ID, id = item.id, txType = if (item.txType == "sent") PaymentType.SENT else PaymentType.RECEIVED, txId = txId, diff --git a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt index 023045080d..c4233acd48 100644 --- a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt @@ -2,6 +2,7 @@ package to.bitkit.services import com.synonym.bitkitcore.NativeDeviceInfo import com.synonym.bitkitcore.TrezorCallMessageResult +import com.synonym.bitkitcore.TrezorTransportErrorCode import com.synonym.bitkitcore.TrezorTransportReadResult import com.synonym.bitkitcore.TrezorTransportWriteResult import kotlinx.serialization.Serializable @@ -97,29 +98,29 @@ class TrezorBridgeTransport( val session = json.decodeFromString(response).session openSessions[path] = session Logger.info("Opened Trezor Bridge device '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + transportWriteResult(success = true, error = "") }.getOrElse { Logger.warn("Failed to open Trezor Bridge device '$path'", it, context = TAG) - TrezorTransportWriteResult(success = false, error = it.message ?: "Bridge open failed") + transportWriteResult(success = false, error = it.message ?: "Bridge open failed") } } fun closeDevice(path: String): TrezorTransportWriteResult { val session = openSessions.remove(path) - ?: return TrezorTransportWriteResult(success = true, error = "") + ?: return transportWriteResult(success = true, error = "") return runCatching { post("/release/${encode(session)}") Logger.info("Closed Trezor Bridge device '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + transportWriteResult(success = true, error = "") }.getOrElse { Logger.warn("Failed to close Trezor Bridge device '$path'", it, context = TAG) - TrezorTransportWriteResult(success = false, error = it.message ?: "Bridge close failed") + transportWriteResult(success = false, error = it.message ?: "Bridge close failed") } } fun readChunk(path: String): TrezorTransportReadResult { - return TrezorTransportReadResult( + return transportReadResult( success = false, data = byteArrayOf(), error = "Trezor Bridge uses callMessage for '$path'", @@ -127,7 +128,7 @@ class TrezorBridgeTransport( } fun writeChunk(path: String, data: ByteArray): TrezorTransportWriteResult { - return TrezorTransportWriteResult( + return transportWriteResult( success = false, error = "Trezor Bridge uses callMessage for '$path' and ignored '${data.size}' bytes", ) @@ -139,7 +140,7 @@ class TrezorBridgeTransport( data: ByteArray, ): TrezorCallMessageResult { val session = openSessions[path] - ?: return TrezorCallMessageResult( + ?: return callMessageResult( success = false, messageType = 0u.toUShort(), data = byteArrayOf(), @@ -153,7 +154,7 @@ class TrezorBridgeTransport( decodeFrame(response) }.getOrElse { Logger.warn("Failed to call Trezor Bridge message for '$path'", it, context = TAG) - TrezorCallMessageResult( + callMessageResult( success = false, messageType = 0u.toUShort(), data = byteArrayOf(), @@ -184,7 +185,7 @@ class TrezorBridgeTransport( "Bridge response payload length '$length' exceeds '${bytes.size - HEADER_SIZE}' bytes" } - return TrezorCallMessageResult( + return callMessageResult( success = true, messageType = messageType, data = bytes.copyOfRange(HEADER_SIZE, HEADER_SIZE + length), @@ -236,3 +237,30 @@ class TrezorBridgeTransport( val session: String, ) } + +private fun transportWriteResult( + success: Boolean, + error: String, + errorCode: TrezorTransportErrorCode? = null, +) = TrezorTransportWriteResult(success = success, error = error, errorCode = errorCode) + +private fun transportReadResult( + success: Boolean, + data: ByteArray, + error: String, + errorCode: TrezorTransportErrorCode? = null, +) = TrezorTransportReadResult(success = success, data = data, error = error, errorCode = errorCode) + +private fun callMessageResult( + success: Boolean, + messageType: UShort, + data: ByteArray, + error: String, + errorCode: TrezorTransportErrorCode? = null, +) = TrezorCallMessageResult( + success = success, + messageType = messageType, + data = data, + error = error, + errorCode = errorCode, +) diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index 8d9e97fe9c..535b316b33 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -50,6 +50,7 @@ import to.bitkit.async.ServiceQueue import javax.inject.Inject import javax.inject.Singleton import com.synonym.bitkitcore.Network as BitkitCoreNetwork +import com.synonym.bitkitcore.deriveWalletId as deriveCoreWalletId @Suppress("TooManyFunctions") @Singleton @@ -301,4 +302,8 @@ class TrezorService @Inject constructor( onchainStopAllWatchers() } } + + fun deriveWalletId(deviceType: String, xpubs: Collection): String { + return deriveCoreWalletId(deviceType = deviceType, xpubs = xpubs.toList()) + } } diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index cba4965b59..4ba662725d 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -32,6 +32,7 @@ import androidx.core.content.edit import com.synonym.bitkitcore.NativeDeviceInfo import com.synonym.bitkitcore.TrezorCallMessageResult import com.synonym.bitkitcore.TrezorTransportCallback +import com.synonym.bitkitcore.TrezorTransportErrorCode import com.synonym.bitkitcore.TrezorTransportReadResult import com.synonym.bitkitcore.TrezorTransportWriteResult import dagger.hilt.android.qualifiers.ApplicationContext @@ -680,18 +681,18 @@ class TrezorTransport @Inject constructor( closeUsbDevice(path) val device = usbManager.deviceList[path] - ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path") + ?: return transportWriteResult(success = false, error = "Device not found: $path") if (!usbManager.hasPermission(device)) { if (!requestUsbPermissionEnabled) { Logger.info("Skipped USB permission request for '$path'", context = TAG) - return TrezorTransportWriteResult( + return transportWriteResult( success = false, error = "USB permission missing for '$path'", ) } if (!requestUsbPermission(device)) { - return TrezorTransportWriteResult( + return transportWriteResult( success = false, error = "USB permission denied for '$path'", ) @@ -699,19 +700,19 @@ class TrezorTransport @Inject constructor( } val connection = usbManager.openDevice(device) - ?: return TrezorTransportWriteResult(success = false, error = "Failed to open device: $path") + ?: return transportWriteResult(success = false, error = "Failed to open device: $path") val usbInterface = device.getInterface(0) if (!connection.claimInterface(usbInterface, true)) { connection.close() - return TrezorTransportWriteResult(success = false, error = "Failed to claim interface") + return transportWriteResult(success = false, error = "Failed to claim interface") } val endpoints = findUsbEndpoints(usbInterface) if (endpoints == null) { connection.releaseInterface(usbInterface) connection.close() - return TrezorTransportWriteResult( + return transportWriteResult( success = false, error = "Could not find required endpoints", ) @@ -724,10 +725,10 @@ class TrezorTransport @Inject constructor( endpoints.write, ) Logger.info("USB device opened: '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + transportWriteResult(success = true, error = "") } catch (e: Exception) { Logger.error("USB open failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error") + transportWriteResult(success = false, error = e.message ?: "Unknown error") } } @@ -735,15 +736,15 @@ class TrezorTransport @Inject constructor( private fun closeUsbDevice(path: String): TrezorTransportWriteResult { return try { val openDevice = usbConnections.remove(path) - ?: return TrezorTransportWriteResult(success = true, error = "") + ?: return transportWriteResult(success = true, error = "") openDevice.connection.releaseInterface(openDevice.usbInterface) openDevice.connection.close() Logger.info("USB device closed: '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + transportWriteResult(success = true, error = "") } catch (e: Exception) { Logger.error("USB close failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error") + transportWriteResult(success = false, error = e.message ?: "Unknown error") } } @@ -751,7 +752,7 @@ class TrezorTransport @Inject constructor( private fun readUsbChunk(path: String): TrezorTransportReadResult { return try { val openDevice = usbConnections[path] - ?: return TrezorTransportReadResult( + ?: return transportReadResult( success = false, data = byteArrayOf(), error = "Device not open: $path", @@ -769,7 +770,7 @@ class TrezorTransport @Inject constructor( READ_TIMEOUT_MS, ) if (bytesRead < 0) { - return TrezorTransportReadResult( + return transportReadResult( success = false, data = byteArrayOf(), error = "USB read timed out", @@ -777,10 +778,10 @@ class TrezorTransport @Inject constructor( } Logger.debug("USB read '$bytesRead' bytes from '$path'", context = TAG) - TrezorTransportReadResult(success = true, data = buffer.copyOf(bytesRead), error = "") + transportReadResult(success = true, data = buffer.copyOf(bytesRead), error = "") } catch (e: Exception) { Logger.error("USB read failed", e, context = TAG) - TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Unknown error") + transportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Unknown error") } } @@ -788,7 +789,7 @@ class TrezorTransport @Inject constructor( private fun writeUsbChunk(path: String, data: ByteArray): TrezorTransportWriteResult { return try { val openDevice = usbConnections[path] - ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path") + ?: return transportWriteResult(success = false, error = "Device not open: $path") val bytesWritten = openDevice.connection.bulkTransfer( openDevice.writeEndpoint, @@ -797,14 +798,14 @@ class TrezorTransport @Inject constructor( WRITE_TIMEOUT_MS, ) if (bytesWritten != data.size) { - return TrezorTransportWriteResult(success = false, error = "USB write timed out") + return transportWriteResult(success = false, error = "USB write timed out") } Logger.debug("USB wrote '${data.size}' bytes to '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + transportWriteResult(success = true, error = "") } catch (e: Exception) { Logger.error("USB write failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error") + transportWriteResult(success = false, error = e.message ?: "Unknown error") } } @@ -870,18 +871,18 @@ class TrezorTransport @Inject constructor( if (device.bondState == BluetoothDevice.BOND_NONE) { Logger.info("Device not bonded, initiating bonding: '$address'", context = TAG) if (!device.createBond()) { - return TrezorTransportWriteResult(success = false, error = "Failed to initiate bonding") + return transportWriteResult(success = false, error = "Failed to initiate bonding") } var bondAttempts = 0 while (device.bondState != BluetoothDevice.BOND_BONDED && bondAttempts < MAX_BOND_POLL_ATTEMPTS) { Thread.sleep(BOND_POLL_INTERVAL_MS) bondAttempts++ if (device.bondState == BluetoothDevice.BOND_NONE) { - return TrezorTransportWriteResult(success = false, error = "Bonding failed or rejected") + return transportWriteResult(success = false, error = "Bonding failed or rejected") } } if (device.bondState != BluetoothDevice.BOND_BONDED) { - return TrezorTransportWriteResult(success = false, error = "Bonding timeout") + return transportWriteResult(success = false, error = "Bonding timeout") } Logger.info("Device bonded successfully: '$address'", context = TAG) } else if (device.bondState == BluetoothDevice.BOND_BONDING) { @@ -892,7 +893,7 @@ class TrezorTransport @Inject constructor( bondAttempts++ } if (device.bondState != BluetoothDevice.BOND_BONDED) { - return TrezorTransportWriteResult(success = false, error = "Bonding failed") + return transportWriteResult(success = false, error = "Bonding failed") } } else { Logger.info("Device already bonded: '$address'", context = TAG) @@ -910,7 +911,7 @@ class TrezorTransport @Inject constructor( TrezorDebugLog.log("OPEN", "Drained $staleCount stale notifications from read queue") } Logger.info("Reused open BLE device '$path'", context = TAG) - return TrezorTransportWriteResult(success = true, error = "") + return transportWriteResult(success = true, error = "") } val address = path.removePrefix("ble:") @@ -919,7 +920,7 @@ class TrezorTransport @Inject constructor( // fresh scan — a scan right after a disconnect often finds nothing yet. val device = discoveredBleDevices[address] ?: runCatching { bluetoothAdapter?.getRemoteDevice(address) }.getOrNull() - ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path") + ?: return transportWriteResult(success = false, error = "Device not found: $path") bleConnections[path]?.takeIf { !it.isConnected }?.let { disconnectBleDevice(path) } @@ -940,13 +941,13 @@ class TrezorTransport @Inject constructor( if (!connectionLatch.await(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { disconnectBleDevice(path) - return TrezorTransportWriteResult(success = false, error = "Connection timeout") + return transportWriteResult(success = false, error = "Connection timeout") } val updatedConnection = bleConnections[path] if (updatedConnection == null || !updatedConnection.isConnected) { disconnectBleDevice(path) - return TrezorTransportWriteResult(success = false, error = "Failed to connect") + return transportWriteResult(success = false, error = "Failed to connect") } gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) @@ -961,27 +962,27 @@ class TrezorTransport @Inject constructor( Thread.sleep(BLE_CONNECTION_STABILIZATION_MS) Logger.info("BLE device opened: '$path'", context = TAG) - return TrezorTransportWriteResult(success = true, error = "") + return transportWriteResult(success = true, error = "") } @Suppress("TooGenericExceptionCaught") @SuppressLint("MissingPermission") private fun closeBleDevice(path: String): TrezorTransportWriteResult { val connection = bleConnections[path] - ?: return TrezorTransportWriteResult(success = true, error = "") + ?: return transportWriteResult(success = true, error = "") connection.readQueue.clear() connection.writeLatch?.countDown() connection.connectionLatch?.countDown() Logger.info("Closed BLE device session '$path'", context = TAG) - return TrezorTransportWriteResult(success = true, error = "") + return transportWriteResult(success = true, error = "") } @Suppress("TooGenericExceptionCaught") @SuppressLint("MissingPermission") private fun disconnectBleDevice(path: String): TrezorTransportWriteResult { val connection = bleConnections[path] - ?: return TrezorTransportWriteResult(success = true, error = "") + ?: return transportWriteResult(success = true, error = "") userInitiatedCloseSet.add(path) return try { @@ -1000,10 +1001,10 @@ class TrezorTransport @Inject constructor( connection.gatt.close() Thread.sleep(100) Logger.info("BLE device closed: '$path'", context = TAG) - TrezorTransportWriteResult(success = timeoutError == null, error = timeoutError.orEmpty()) + transportWriteResult(success = timeoutError == null, error = timeoutError.orEmpty()) } catch (e: Exception) { Logger.error("BLE close failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "BLE close failed") + transportWriteResult(success = false, error = e.message ?: "BLE close failed") } finally { userInitiatedCloseSet.remove(path) } @@ -1012,7 +1013,7 @@ class TrezorTransport @Inject constructor( @Suppress("TooGenericExceptionCaught") private fun readBleChunk(path: String): TrezorTransportReadResult { val connection = bleConnections[path] - ?: return TrezorTransportReadResult( + ?: return transportReadResult( success = false, data = byteArrayOf(), error = "Device not open: $path" @@ -1020,17 +1021,17 @@ class TrezorTransport @Inject constructor( return try { val data = connection.readQueue.poll(BLE_READ_TIMEOUT_MS, TimeUnit.MILLISECONDS) - ?: return TrezorTransportReadResult( + ?: return transportReadResult( success = false, data = byteArrayOf(), error = "Read timeout" ) Logger.debug("BLE read ${data.size} bytes from '$path'", context = TAG) - TrezorTransportReadResult(success = true, data = data, error = "") + transportReadResult(success = true, data = data, error = "") } catch (e: Exception) { Logger.error("BLE read failed", e, context = TAG) - TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Read failed") + transportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Read failed") } } @@ -1045,14 +1046,14 @@ class TrezorTransport @Inject constructor( @SuppressLint("MissingPermission") private fun writeBleChunk(path: String, data: ByteArray): TrezorTransportWriteResult { val connection = bleConnections[path] - ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path") + ?: return transportWriteResult(success = false, error = "Device not open: $path") val writeChar = connection.writeCharacteristic - ?: return TrezorTransportWriteResult(success = false, error = "Write characteristic not available") + ?: return transportWriteResult(success = false, error = "Write characteristic not available") if (!connection.isConnected) { Logger.warn("BLE write attempted on disconnected device: '$path'", context = TAG) - return TrezorTransportWriteResult(success = false, error = "Device disconnected") + return transportWriteResult(success = false, error = "Device disconnected") } return try { @@ -1079,7 +1080,7 @@ class TrezorTransport @Inject constructor( Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) continue } - return TrezorTransportWriteResult(success = false, error = lastError) + return transportWriteResult(success = false, error = lastError) } if (!writeLatch.await(WRITE_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS)) { @@ -1092,7 +1093,7 @@ class TrezorTransport @Inject constructor( Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) continue } - return TrezorTransportWriteResult(success = false, error = lastError) + return transportWriteResult(success = false, error = lastError) } if (connection.writeStatus != BluetoothGatt.GATT_SUCCESS) { @@ -1106,7 +1107,7 @@ class TrezorTransport @Inject constructor( Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) continue } - return TrezorTransportWriteResult(success = false, error = lastError) + return transportWriteResult(success = false, error = lastError) } Logger.debug("BLE wrote '${data.size}' bytes to '$path' (attempt '$attempt')", context = TAG) @@ -1114,13 +1115,13 @@ class TrezorTransport @Inject constructor( // Small delay between writes to avoid overwhelming the GATT Thread.sleep(BLE_WRITE_INTER_DELAY_MS) - return TrezorTransportWriteResult(success = true, error = "") + return transportWriteResult(success = true, error = "") } - TrezorTransportWriteResult(success = false, error = lastError) + transportWriteResult(success = false, error = lastError) } catch (e: Exception) { Logger.error("BLE write failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "Write failed") + transportWriteResult(success = false, error = e.message ?: "Write failed") } } @@ -1354,3 +1355,16 @@ class TrezorTransport @Inject constructor( bleConnections.keys.toList().forEach { path -> disconnectBleDevice(path) } } } + +private fun transportWriteResult( + success: Boolean, + error: String, + errorCode: TrezorTransportErrorCode? = null, +) = TrezorTransportWriteResult(success = success, error = error, errorCode = errorCode) + +private fun transportReadResult( + success: Boolean, + data: ByteArray, + error: String, + errorCode: TrezorTransportErrorCode? = null, +) = TrezorTransportReadResult(success = success, data = data, error = error, errorCode = errorCode) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 2b1642a37a..a5765c87a6 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -996,7 +996,7 @@ private fun NavGraphBuilder.home( isGeoBlocked = isGeoBlocked, onchainActivities = onchainActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSpendingClick = { navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) @@ -1015,7 +1015,7 @@ private fun NavGraphBuilder.home( channels = lightningState.channels, lightningActivities = lightningActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSavingsClick = { if (!hasSeenSavingsIntro) { @@ -1035,7 +1035,7 @@ private fun NavGraphBuilder.home( val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() HardwareWalletScreen( deviceId = deviceId, - onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, onTransferToSpendingClick = { selectedDeviceId -> navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) }, @@ -1052,7 +1052,7 @@ private fun NavGraphBuilder.allActivity( AllActivityScreen( viewModel = activityListViewModel, onBack = { navController.popBackStack() }, - onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, ) } } @@ -1210,7 +1210,7 @@ private fun NavGraphBuilder.contacts( ContactActivityScreen( viewModel = viewModel, onBackClick = { navController.popBackStack() }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, ) } } @@ -1594,7 +1594,7 @@ private fun NavGraphBuilder.activityItem( ActivityDetailScreen( listViewModel = activityListViewModel, route = it.toRoute(), - onExploreClick = { id -> navController.navigateToActivityExplore(id) }, + onExploreClick = { id, walletId -> navController.navigateToActivityExplore(id, walletId) }, onAssignContactClick = { id -> navController.navigateTo(Routes.ActivityAssignContact(id)) }, onChannelClick = { channelId -> navController.navigateTo(Routes.ChannelDetail(channelId)) @@ -1851,9 +1851,11 @@ fun NavController.navigateToTransferIntro() = navigateTo(Routes.TransferIntro) fun NavController.navigateToTransferFunding() = navigateTo(Routes.Funding) -fun NavController.navigateToActivityItem(id: String) = navigateTo(Routes.ActivityDetail(id)) +fun NavController.navigateToActivityItem(id: String, walletId: String? = null) = + navigateTo(Routes.ActivityDetail(id, walletId)) -fun NavController.navigateToActivityExplore(id: String) = navigateTo(Routes.ActivityExplore(id)) +fun NavController.navigateToActivityExplore(id: String, walletId: String? = null) = + navigateTo(Routes.ActivityExplore(id, walletId)) fun NavController.navigateToLogDetail(fileName: String) = navigateTo(Routes.LogDetail(fileName)) @@ -2074,13 +2076,13 @@ sealed interface Routes { data class LnurlChannel(val uri: String, val callback: String, val k1: String) : Routes @Serializable - data class ActivityDetail(val id: String) : Routes + data class ActivityDetail(val id: String, val walletId: String? = null) : Routes @Serializable data class ActivityAssignContact(val id: String) : Routes @Serializable - data class ActivityExplore(val id: String) : Routes + data class ActivityExplore(val id: String, val walletId: String? = null) : Routes @Serializable data object BuyIntro : Routes diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt index 80d8c507f5..8cc2dead5d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt @@ -37,7 +37,7 @@ import to.bitkit.ui.theme.Colors fun ContactActivityScreen( viewModel: ContactActivityViewModel, onBackClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -54,7 +54,7 @@ private fun Content( uiState: ContactActivityUiState, onBackClick: () -> Unit, onRetryClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, ) { ScreenColumn { AppTopBar( @@ -118,7 +118,7 @@ private fun ErrorState( private fun ContactActivityList( profile: PubkyProfile?, activities: ImmutableList?, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, modifier: Modifier = Modifier, ) { val name = profile?.name @@ -154,7 +154,7 @@ private fun Preview() { ), onBackClick = {}, onRetryClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } @@ -170,7 +170,7 @@ private fun PreviewEmpty() { ), onBackClick = {}, onRetryClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } @@ -186,7 +186,7 @@ private fun PreviewError() { ), onBackClick = {}, onRetryClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt index 6a0bdac66b..92c368fe6f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt @@ -18,7 +18,6 @@ import com.synonym.bitkitcore.TrezorTransportType import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WalletBalance import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.repositories.ConnectedTrezorDevice @@ -40,6 +39,7 @@ internal object TrezorPreviewData { initialized = true, needsBackup = false, passphraseEntryCapable = true, + unlocked = true, ) val sampleFeaturesMinimal = TrezorFeatures( @@ -55,6 +55,7 @@ internal object TrezorPreviewData { initialized = null, needsBackup = null, passphraseEntryCapable = null, + unlocked = null, ) val sampleKnownDevice = KnownDevice( @@ -257,6 +258,7 @@ internal object TrezorPreviewData { sent = 0uL, net = 100_000L, fee = null, + feeRate = null, amount = 100_000uL, direction = TxDirection.RECEIVED, blockHeight = 849_990u, @@ -269,6 +271,7 @@ internal object TrezorPreviewData { sent = 50_000uL, net = -50_000L, fee = 1_200uL, + feeRate = 8.0, amount = 48_800uL, direction = TxDirection.SENT, blockHeight = 849_995u, @@ -281,6 +284,7 @@ internal object TrezorPreviewData { sent = 5_000uL, net = 0L, fee = 500uL, + feeRate = 2.5, amount = 500uL, direction = TxDirection.SELF_TRANSFER, blockHeight = null, @@ -312,7 +316,6 @@ internal object TrezorPreviewData { activeWatcherId = "watcher-abc-123", connectionStatus = WatcherConnectionStatus.CONNECTED, balance = sampleWalletBalance, - transactions = sampleHistoryTransactions.toImmutableList(), transactionCount = 2u, blockHeight = 850_000u, accountType = AccountType.NATIVE_SEGWIT, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index b4772d9785..6037c7ca78 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt @@ -9,7 +9,6 @@ import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.CoinSelection import com.synonym.bitkitcore.ComposeOutput import com.synonym.bitkitcore.ComposeResult -import com.synonym.bitkitcore.HistoryTransaction import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TransactionHistoryResult import com.synonym.bitkitcore.TrezorScriptType @@ -31,6 +30,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.di.BgDispatcher import to.bitkit.env.Env +import to.bitkit.models.ActivityWalletType import to.bitkit.models.KnownDevice import to.bitkit.models.Toast import to.bitkit.models.toCoreNetwork @@ -68,7 +68,6 @@ class TrezorViewModel @Inject constructor( it.copy( watcher = it.watcher.copy( balance = event.balance, - transactions = event.transactions.toImmutableList(), transactionCount = event.txCount, blockHeight = event.blockHeight, accountType = event.accountType, @@ -714,6 +713,7 @@ class TrezorViewModel @Inject constructor( } val result = trezorRepo.startWatcher( watcherId = watcherId, + walletId = ActivityWalletType.TREZOR.scopedId(watcherId), extendedKey = key, network = state.selectedNetwork, gapLimit = gapLimit, @@ -776,7 +776,6 @@ class TrezorViewModel @Inject constructor( activeWatcherId = null, connectionStatus = WatcherConnectionStatus.IDLE, balance = null, - transactions = persistentListOf(), transactionCount = 0u, blockHeight = 0u, accountType = null, @@ -956,9 +955,6 @@ data class TrezorUiState( val watcherBalance: WalletBalance? get() = watcher.balance - val watcherTransactions: ImmutableList - get() = watcher.transactions - val watcherTransactionCount: UInt get() = watcher.transactionCount @@ -1035,7 +1031,6 @@ data class TrezorWatcherState( val activeWatcherId: String? = null, val connectionStatus: WatcherConnectionStatus = WatcherConnectionStatus.IDLE, val balance: WalletBalance? = null, - val transactions: ImmutableList = persistentListOf(), val transactionCount: UInt = 0u, val blockHeight: UInt = 0u, val accountType: AccountType? = null, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt index aeaabafc89..2ef0e12ce8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt @@ -24,14 +24,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.AccountType -import com.synonym.bitkitcore.TxDirection import to.bitkit.models.safe import to.bitkit.repositories.TrezorState import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Footnote -import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer @@ -171,52 +169,6 @@ private fun WatcherStatusContent(uiState: TrezorUiState) { } } - if (uiState.watcherTransactions.isNotEmpty()) { - VerticalSpacer(12.dp) - Caption13Up( - text = "Transactions (${uiState.watcherTransactions.size})", - color = Colors.White64, - ) - VerticalSpacer(4.dp) - LazyColumn( - modifier = Modifier.heightIn(max = 200.dp), - ) { - items(uiState.watcherTransactions) { tx -> - val directionLabel = when (tx.direction) { - TxDirection.SENT -> "Sent" - TxDirection.RECEIVED -> "Recv" - TxDirection.SELF_TRANSFER -> "Self" - } - val directionColor = when (tx.direction) { - TxDirection.SENT -> Colors.Red - TxDirection.RECEIVED -> Colors.Green - TxDirection.SELF_TRANSFER -> Colors.White64 - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Caption( - text = "$directionLabel ${tx.amount} sats", - color = directionColor, - ) - HorizontalSpacer(8.dp) - Caption( - text = "${tx.txid.take(8)}...${tx.txid.takeLast(8)}", - color = Colors.White50, - ) - HorizontalSpacer(8.dp) - Caption( - text = "${tx.confirmations} conf", - color = Colors.White50, - ) - } - } - } - } - if (uiState.watcherEvents.isNotEmpty()) { VerticalSpacer(12.dp) Caption13Up( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt index 9b1d45116f..9f3c8de043 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt @@ -39,7 +39,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableSet import to.bitkit.R -import to.bitkit.ext.rawId +import to.bitkit.ext.scopedId import to.bitkit.models.HwWallet import to.bitkit.models.TransportType import to.bitkit.ui.components.BalanceHeaderView @@ -62,7 +62,7 @@ import to.bitkit.ui.theme.TopBarGradient @Composable fun HardwareWalletScreen( deviceId: String, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, onTransferToSpendingClick: (String) -> Unit, onBackClick: () -> Unit, viewModel: HwWalletViewModel = hiltViewModel(), @@ -95,7 +95,7 @@ fun HardwareWalletScreen( private fun HardwareWalletContent( wallet: HwWallet, showRemoveDialog: Boolean, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, onTransferToSpendingClick: (String) -> Unit, onRemoveClick: () -> Unit, onConfirmRemove: () -> Unit, @@ -109,7 +109,8 @@ private fun HardwareWalletContent( // Every activity here belongs to the watch-only device, so render them all with the blue // hardware icon, matching the home list. - val hardwareIds = remember(wallet.activities) { wallet.activities.map { it.rawId() }.toImmutableSet() } + val activityItems = remember(wallet.activities) { wallet.activities } + val hardwareIds = remember(activityItems) { activityItems.map { it.scopedId() }.toImmutableSet() } val hazeState = rememberHazeState() @@ -189,7 +190,7 @@ private fun HardwareWalletContent( } activityListGroupedItems( - items = wallet.activities, + items = activityItems, onActivityItemClick = onActivityItemClick, onEmptyActivityRowClick = {}, showFooter = false, @@ -264,7 +265,7 @@ private fun Preview() { HardwareWalletContent( wallet = previewWallet(), showRemoveDialog = false, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, @@ -284,7 +285,7 @@ private fun PreviewNoActivity() { HardwareWalletContent( wallet = previewWallet(activities = persistentListOf()), showRemoveDialog = false, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, @@ -304,7 +305,7 @@ private fun PreviewEmpty() { HardwareWalletContent( wallet = previewWallet(balanceSats = 0uL, activities = persistentListOf()), showRemoveDialog = false, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, @@ -324,7 +325,7 @@ private fun PreviewRemoveDialog() { HardwareWalletContent( wallet = previewWallet(), showRemoveDialog = true, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index cc3ea5f20d..083121d0c0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -379,7 +379,7 @@ fun HomeScreen( onNavigateToAppStatus = { rootNavController.navigate(Routes.AppStatus) }, onNavigateToSettingUp = { rootNavController.navigate(Routes.SettingUp) }, onNavigateToAllActivity = { rootNavController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onNavigateToActivityItem = { rootNavController.navigateToActivityItem(it) }, + onNavigateToActivityItem = { id, walletId -> rootNavController.navigateToActivityItem(id, walletId) }, onNavigateToSavings = { walletNavController.navigate(Routes.Savings) }, onNavigateToSpending = { walletNavController.navigate(Routes.Spending) }, onClickHardwareWallet = { walletNavController.navigateTo(Routes.HardwareWallet(it)) }, @@ -417,7 +417,7 @@ private fun Content( onNavigateToAppStatus: () -> Unit = {}, onNavigateToSettingUp: () -> Unit = {}, onNavigateToAllActivity: () -> Unit = {}, - onNavigateToActivityItem: (String) -> Unit = {}, + onNavigateToActivityItem: (String, String) -> Unit = { _, _ -> }, onNavigateToSavings: () -> Unit = {}, onNavigateToSpending: () -> Unit = {}, onClickHardwareWallet: (String) -> Unit = {}, @@ -588,7 +588,7 @@ private fun WalletPage( onRefresh: () -> Unit, onNavigateToSettingUp: () -> Unit, onNavigateToAllActivity: () -> Unit, - onNavigateToActivityItem: (String) -> Unit, + onNavigateToActivityItem: (String, String) -> Unit, onNavigateToSavings: () -> Unit, onNavigateToSpending: () -> Unit, onClickHardwareWallet: (String) -> Unit, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt index 45efea812a..368aabc417 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt @@ -62,7 +62,7 @@ fun SavingsWalletScreen( onchainActivities: ImmutableList, onAllActivityButtonClick: () -> Unit, onEmptyActivityRowClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, onTransferToSpendingClick: () -> Unit, onBackClick: () -> Unit, forceCloseRemainingDuration: String? = null, @@ -200,7 +200,7 @@ private fun Preview() { isGeoBlocked = false, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, @@ -220,7 +220,7 @@ private fun PreviewTransfer() { isGeoBlocked = false, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, @@ -243,7 +243,7 @@ private fun PreviewNoActivity() { isGeoBlocked = false, onchainActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, @@ -263,7 +263,7 @@ private fun PreviewGeoBlocked() { isGeoBlocked = true, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, @@ -283,7 +283,7 @@ private fun PreviewEmpty() { isGeoBlocked = false, onchainActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index 2cedaeafb5..dbb8f3aaac 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt @@ -63,7 +63,7 @@ fun SpendingWalletScreen( channels: ImmutableList, lightningActivities: ImmutableList, onAllActivityButtonClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, onEmptyActivityRowClick: () -> Unit, onTransferToSavingsClick: () -> Unit, onTransferFromSavingsClick: () -> Unit, @@ -223,7 +223,7 @@ private fun Preview() { channels = persistentListOf(createChannelDetails()), lightningActivities = previewLightningActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, @@ -244,7 +244,7 @@ private fun PreviewTransfer() { channels = persistentListOf(createChannelDetails()), lightningActivities = previewLightningActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, @@ -268,7 +268,7 @@ private fun PreviewNoActivity() { channels = persistentListOf(createChannelDetails()), lightningActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, @@ -289,7 +289,7 @@ private fun PreviewEmpty() { channels = persistentListOf(), lightningActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, @@ -309,7 +309,7 @@ private fun PreviewEmptyWithSavings() { channels = persistentListOf(), lightningActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, 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 2a295347c0..b044bcfb02 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 @@ -55,16 +55,27 @@ import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import to.bitkit.R +import to.bitkit.ext.confirmed import to.bitkit.ext.contact import to.bitkit.ext.create +import to.bitkit.ext.doesExist import to.bitkit.ext.ellipsisMiddle +import to.bitkit.ext.fee +import to.bitkit.ext.feeRate +import to.bitkit.ext.isBoosted import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer +import to.bitkit.ext.message +import to.bitkit.ext.paymentState import to.bitkit.ext.rawId import to.bitkit.ext.timestamp import to.bitkit.ext.toActivityItemDate import to.bitkit.ext.toActivityItemTime import to.bitkit.ext.totalValue +import to.bitkit.ext.txType +import to.bitkit.ext.value +import to.bitkit.ext.walletId +import to.bitkit.models.ActivityWalletType import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat @@ -99,6 +110,7 @@ import to.bitkit.ui.utils.copyToClipboard import to.bitkit.ui.utils.getScreenTitleRes import to.bitkit.viewmodels.ActivityDetailViewModel import to.bitkit.viewmodels.ActivityListViewModel +import to.bitkit.viewmodels.TransferOrderAmounts @Suppress("CyclomaticComplexMethod") @Composable @@ -106,7 +118,7 @@ fun ActivityDetailScreen( listViewModel: ActivityListViewModel, detailViewModel: ActivityDetailViewModel = hiltViewModel(), route: Routes.ActivityDetail, - onExploreClick: (String) -> Unit, + onExploreClick: (String, String) -> Unit, onAssignContactClick: (String) -> Unit, onBackClick: () -> Unit, onCloseClick: () -> Unit, @@ -115,8 +127,8 @@ fun ActivityDetailScreen( val uiState by detailViewModel.uiState.collectAsStateWithLifecycle() // Load activity on composition - LaunchedEffect(route.id) { - detailViewModel.loadActivity(route.id) + LaunchedEffect(route.id, route.walletId) { + detailViewModel.loadActivity(route.id, route.walletId) } // Clear state on disposal @@ -178,6 +190,7 @@ fun ActivityDetailScreen( is ActivityDetailViewModel.ActivityLoadState.Success -> { val item = loadState.activity + val isHardware = remember(item.walletId()) { ActivityWalletType.TREZOR.owns(item.walletId()) } val app = appViewModel ?: return@Box val settings = settingsViewModel ?: return@Box val hideBalance by settings.hideBalance.collectAsStateWithLifecycle() @@ -206,7 +219,7 @@ fun ActivityDetailScreen( } // Update boostTxDoesExist when boostTxIds change - LaunchedEffect(if (item is Activity.Onchain) item.v1.boostTxIds else emptyList()) { + LaunchedEffect(if (item is Activity.Onchain) item.v1.boostTxIds else persistentListOf()) { if (item is Activity.Onchain && item.v1.boostTxIds.isNotEmpty()) { boostTxDoesExist = detailViewModel.getBoostTxDoesExist(item.v1.boostTxIds) } @@ -246,8 +259,8 @@ fun ActivityDetailScreen( onChannelClick = onChannelClick, detailViewModel = detailViewModel, isCpfpChild = isCpfpChild, - isHardware = uiState.isHardwareActivity, - showContactActions = isPaykitEnabled && !uiState.isHardwareActivity, + isHardware = isHardware, + showContactActions = isPaykitEnabled && !isHardware, boostTxDoesExist = boostTxDoesExist, onCopy = { text -> app.toast( @@ -272,7 +285,8 @@ fun ActivityDetailScreen( (item as? Activity.Onchain)?.let { BoostTransactionSheet( onDismiss = detailViewModel::onDismissBoostSheet, - item = it, + activityId = it.rawId(), + walletId = it.walletId(), onSuccess = { app.toast( type = Toast.ToastType.SUCCESS, @@ -325,7 +339,7 @@ private fun ActivityDetailContent( onAssignClick: () -> Unit, onDetachClick: () -> Unit, onClickBoost: () -> Unit, - onExploreClick: (String) -> Unit, + onExploreClick: (String, String) -> Unit, onChannelClick: ((String) -> Unit)?, detailViewModel: ActivityDetailViewModel? = null, isCpfpChild: Boolean = false, @@ -350,30 +364,24 @@ private fun ActivityDetailContent( val amountPrefix = if (isSent) "-" else "+" val timestamp = item.timestamp() - val paymentValue = when (item) { - is Activity.Lightning -> item.v1.value - is Activity.Onchain -> item.v1.value - } - val baseFee = when (item) { - is Activity.Lightning -> item.v1.fee - is Activity.Onchain -> item.v1.fee - } + val paymentValue = item.value() + val baseFee = item.fee() val isSelfSend = isSent && paymentValue == 0uL val channelId = (item as? Activity.Onchain)?.v1?.channelId val txId = (item as? Activity.Onchain)?.v1?.txId - var order by remember { mutableStateOf(null) } + var orderAmounts by remember { mutableStateOf(null) } LaunchedEffect(item, isTransferToSpending, detailViewModel) { - order = if (isTransferToSpending && detailViewModel != null) { - detailViewModel.findOrderForTransfer(channelId, txId) + orderAmounts = if (isTransferToSpending && detailViewModel != null) { + detailViewModel.findTransferOrderAmounts(channelId, txId) } else { null } } - val orderServiceFee: ULong? = order?.let { it.feeSat - it.clientBalanceSat } - val transferAmount: ULong? = order?.clientBalanceSat + val orderServiceFee: ULong? = orderAmounts?.serviceFee + val transferAmount: ULong? = orderAmounts?.transferAmount val fee: ULong? = when { isTransferToSpending && orderServiceFee != null && baseFee != null -> baseFee + orderServiceFee @@ -554,8 +562,8 @@ private fun ActivityDetailContent( ) // Note section for Lightning payments with message - if (item is Activity.Lightning && item.v1.message.isNotEmpty()) { - val message = item.v1.message + if (item is Activity.Lightning && item.message().isNotEmpty()) { + val message = item.message() Column( modifier = Modifier .fillMaxWidth() @@ -598,7 +606,7 @@ private fun ActivityDetailContent( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { - val showTagAction = !isHardware + val showTagAction = true if (showContactActions || showTagAction) { Row( horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -660,12 +668,11 @@ private fun ActivityDetailContent( val hasCompletedBoost = when (item) { is Activity.Lightning -> false is Activity.Onchain -> { - val activity = item.v1 - if (activity.isBoosted && activity.boostTxIds.isNotEmpty()) { - if (activity.txType == PaymentType.SENT) { + if (item.isBoosted() && item.v1.boostTxIds.isNotEmpty()) { + if (item.txType() == PaymentType.SENT) { true } else { - activity.boostTxIds.any { boostTxDoesExist[it] == true } + item.v1.boostTxIds.any { boostTxDoesExist[it] == true } } } else { false @@ -705,7 +712,7 @@ private fun ActivityDetailContent( PrimaryButton( text = stringResource(R.string.wallet__activity_explore), size = ButtonSize.Small, - onClick = { onExploreClick(item.rawId()) }, + onClick = { onExploreClick(item.rawId(), item.walletId()) }, icon = { Icon( painter = painterResource(R.drawable.ic_git_branch), @@ -851,7 +858,7 @@ private fun StatusSection( Row(verticalAlignment = Alignment.CenterVertically) { when (item) { is Activity.Lightning -> { - when (item.v1.status) { + when (item.paymentState()) { PaymentState.PENDING -> { StatusRow( painterResource(R.drawable.ic_hourglass_simple), @@ -875,6 +882,8 @@ private fun StatusSection( Colors.Purple, ) } + + null -> Unit } } @@ -885,29 +894,29 @@ private fun StatusSection( var statusText = stringResource(R.string.wallet__activity_confirming) var statusTestTag: String? = null - if (item.v1.isTransfer) { + if (item.isTransfer()) { val context = LocalContext.current - val duration = context.getFeeShortDescription(item.v1.feeRate, feeRates) + val duration = context.getFeeShortDescription(item.feeRate(), feeRates) statusText = stringResource(R.string.wallet__activity_transfer_pending) .replace("{duration}", duration) statusTestTag = "StatusTransfer" } - if (item.v1.isBoosted) { + if (item.isBoosted()) { statusIcon = painterResource(R.drawable.ic_timer_alt) statusColor = Colors.Yellow statusText = stringResource(R.string.wallet__activity_boosting) statusTestTag = "StatusBoosting" } - if (item.v1.confirmed) { + if (item.confirmed() == true) { statusIcon = painterResource(R.drawable.ic_check_circle) statusColor = Colors.Green statusText = stringResource(R.string.wallet__activity_confirmed) statusTestTag = "StatusConfirmed" } - if (!item.v1.doesExist) { + if (!item.doesExist()) { statusIcon = painterResource(R.drawable.ic_x) statusColor = Colors.Red statusText = stringResource(R.string.wallet__activity_removed) @@ -980,25 +989,14 @@ private fun ZigzagDivider() { private fun PreviewLightningSent() { AppThemeSurface { ActivityDetailContent( - item = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-1", - txType = PaymentType.SENT, - status = PaymentState.SUCCEEDED, - value = 50000UL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1UL, - message = "Thanks for paying at the bar. Here's my share.", - ) - ), + item = previewLightningDetailItem(), assignedContact = null, tags = persistentListOf("Lunch", "Drinks"), onRemoveTag = {}, onAddTagClick = {}, onAssignClick = {}, onDetachClick = {}, - onExploreClick = {}, + onExploreClick = { _, _ -> }, onChannelClick = null, onCopy = {}, onClickBoost = {} @@ -1011,27 +1009,14 @@ private fun PreviewLightningSent() { private fun PreviewOnchain() { AppThemeSurface { ActivityDetailContent( - item = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-1", - txType = PaymentType.RECEIVED, - txId = "abc123", - value = 100000UL, - fee = 500UL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000 - 3600).toULong(), - confirmed = true, - feeRate = 8UL, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - ) - ), + item = previewOnchainDetailItem(), assignedContact = null, tags = persistentListOf(), onRemoveTag = {}, onAddTagClick = {}, onAssignClick = {}, onDetachClick = {}, - onExploreClick = {}, + onExploreClick = { _, _ -> }, onChannelClick = null, onCopy = {}, onClickBoost = {}, @@ -1047,25 +1032,14 @@ private fun PreviewSheetSmallScreen() { modifier = Modifier.sheetHeight(), ) { ActivityDetailContent( - item = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-1", - txType = PaymentType.SENT, - status = PaymentState.SUCCEEDED, - value = 50000UL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1UL, - message = "Thanks for paying at the bar. Here's my share.", - ) - ), + item = previewLightningDetailItem(), assignedContact = null, tags = persistentListOf("Lunch", "Drinks"), onRemoveTag = {}, onAddTagClick = {}, onAssignClick = {}, onDetachClick = {}, - onExploreClick = {}, + onExploreClick = { _, _ -> }, onChannelClick = null, onCopy = {}, onClickBoost = {}, @@ -1085,29 +1059,61 @@ private fun shouldEnableBoostButton( if (isHardware) return false if (item !is Activity.Onchain) return false - val activity = item.v1 - // Check all disable conditions - val shouldDisable = isCpfpChild || !activity.doesExist || activity.confirmed || - (activity.isBoosted && isBoostCompleted(activity, boostTxDoesExist)) + val shouldDisable = isCpfpChild || !item.doesExist() || item.confirmed() == true || + (item.isBoosted() && isBoostCompleted(item, boostTxDoesExist)) if (shouldDisable) return false // Enable if not a transfer and has value - return !activity.isTransfer && activity.value > 0uL + return !item.isTransfer() && item.value() > 0uL } @ReadOnlyComposable @Composable private fun isBoostCompleted( - activity: OnchainActivity, + activity: Activity.Onchain, boostTxDoesExist: ImmutableMap, ): Boolean { - if (activity.boostTxIds.isEmpty()) return true + if (activity.v1.boostTxIds.isEmpty()) return true - if (activity.txType == PaymentType.SENT) { + if (activity.txType() == PaymentType.SENT) { return true } else { - return activity.boostTxIds.any { boostTxDoesExist[it] == true } + return activity.v1.boostTxIds.any { boostTxDoesExist[it] == true } } } + +private fun previewLightningDetailItem(): Activity.Lightning { + val timestamp = 1_700_000_000uL + return Activity.Lightning( + v1 = LightningActivity.create( + id = "test-lightning-1", + txType = PaymentType.SENT, + status = PaymentState.SUCCEEDED, + value = 50_000UL, + invoice = "lnbc...", + timestamp = timestamp, + fee = 1UL, + message = "Thanks for paying at the bar. Here's my share.", + ), + ) +} + +private fun previewOnchainDetailItem(): Activity.Onchain { + val timestamp = 1_699_996_400uL + return Activity.Onchain( + v1 = OnchainActivity.create( + id = "test-onchain-1", + txType = PaymentType.RECEIVED, + value = 100_000UL, + fee = 500UL, + timestamp = timestamp, + txId = "abc123", + address = "bc1...", + feeRate = 8UL, + confirmed = true, + confirmTimestamp = 1_700_000_000uL, + ), + ) +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt index 286d4b3a96..8985407117 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt @@ -46,6 +46,7 @@ import to.bitkit.ext.create import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.isSent import to.bitkit.ext.totalValue +import to.bitkit.ext.txType import to.bitkit.models.Toast import to.bitkit.ui.Routes import to.bitkit.ui.appViewModel @@ -75,8 +76,8 @@ fun ActivityExploreScreen( val uiState by detailViewModel.uiState.collectAsStateWithLifecycle() // Load activity on composition - LaunchedEffect(route.id) { - detailViewModel.loadActivity(route.id) + LaunchedEffect(route.id, route.walletId) { + detailViewModel.loadActivity(route.id, route.walletId) } // Clear state on disposal @@ -303,8 +304,11 @@ private fun ColumnScope.OnchainDetails( valueContent = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { txDetails.inputs.forEach { input -> - val text = "${input.txid}:${input.vout}" - BodySSB(text = text, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) + BodySSB( + text = "${input.txid}:${input.vout}", + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) } } }, @@ -317,8 +321,11 @@ private fun ColumnScope.OnchainDetails( valueContent = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { txDetails.outputs.forEach { output -> - val address = output.scriptpubkeyAddress ?: "" - BodySSB(text = address, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) + BodySSB( + text = output.scriptpubkeyAddress ?: "", + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) } } }, @@ -339,7 +346,7 @@ private fun ColumnScope.OnchainDetails( val boostTxIds = onchain.v1.boostTxIds if (boostTxIds.isNotEmpty()) { boostTxIds.forEachIndexed { index, boostedTxId -> - val isRbf = onchain.v1.txType == PaymentType.SENT || !(boostTxDoesExist[boostedTxId] ?: true) + val isRbf = onchain.txType() == PaymentType.SENT || !(boostTxDoesExist[boostedTxId] ?: true) Section( title = stringResource( if (isRbf) R.string.wallet__activity_boosted_rbf else R.string.wallet__activity_boosted_cpfp @@ -389,19 +396,7 @@ private fun Section( private fun PreviewLightning() { AppThemeSurface { ActivityExploreContent( - item = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-1", - txType = PaymentType.SENT, - status = PaymentState.SUCCEEDED, - value = 50000UL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1UL, - message = "Thanks for paying at the bar. Here's my share.", - preimage = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - ), - ), + item = previewLightningDetailItem(), ) } } @@ -411,20 +406,42 @@ private fun PreviewLightning() { private fun PreviewOnchain() { AppThemeSurface { ActivityExploreContent( - item = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-1", - txType = PaymentType.RECEIVED, - txId = "abc123", - value = 100000UL, - fee = 500UL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000 - 3600).toULong(), - confirmed = true, - feeRate = 8UL, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - ), - ), + item = previewOnchainDetailItem(), ) } } + +private fun previewLightningDetailItem(): Activity.Lightning { + val timestamp = 1_700_000_000uL + return Activity.Lightning( + v1 = LightningActivity.create( + id = "test-lightning-1", + txType = PaymentType.SENT, + status = PaymentState.SUCCEEDED, + value = 50_000UL, + invoice = "lnbc...", + timestamp = timestamp, + fee = 1UL, + message = "Thanks for paying at the bar. Here's my share.", + preimage = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ), + ) +} + +private fun previewOnchainDetailItem(): Activity.Onchain { + val timestamp = 1_699_996_400uL + return Activity.Onchain( + v1 = OnchainActivity.create( + id = "test-onchain-1", + txType = PaymentType.RECEIVED, + value = 100_000UL, + fee = 500UL, + timestamp = timestamp, + txId = "abc123", + address = "bc1...", + feeRate = 8UL, + confirmed = true, + confirmTimestamp = 1_700_000_000uL, + ), + ) +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt index e05492d000..a596ab12a0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt @@ -40,7 +40,7 @@ import to.bitkit.viewmodels.ActivityListViewModel fun AllActivityScreen( viewModel: ActivityListViewModel, onBack: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, ) { val app = appViewModel ?: return val filteredActivities by viewModel.filteredActivities.collectAsStateWithLifecycle() @@ -90,7 +90,7 @@ private fun AllActivityScreenContent( onBackClick: () -> Unit, onTagClick: () -> Unit, onDateRangeClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, onEmptyActivityRowClick: () -> Unit, ) { val listState = rememberLazyListState() @@ -167,7 +167,7 @@ private fun Preview() { onBackClick = {}, onTagClick = {}, onDateRangeClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onRemoveTag = {}, onEmptyActivityRowClick = {}, ) @@ -192,7 +192,7 @@ private fun PreviewEmpty() { onTagClick = {}, onDateRangeClick = {}, onRemoveTag = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, ) } 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 1127c8c768..f0f1af3495 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 @@ -19,12 +19,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.Activity -import com.synonym.bitkitcore.LightningActivity -import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import to.bitkit.R -import to.bitkit.ext.create import to.bitkit.ext.doesExist import to.bitkit.ext.isBoosting import to.bitkit.ext.isTransfer @@ -32,6 +29,7 @@ import to.bitkit.ext.paymentState import to.bitkit.ext.txType import to.bitkit.models.PubkyProfile import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -44,14 +42,17 @@ fun ActivityIcon( isHardware: Boolean = false, contact: PubkyProfile? = null, ) { - val isLightning = activity is Activity.Lightning - val isBoosting = activity.isBoosting() - val status = activity.paymentState() val txType = activity.txType() - val arrowIcon = painterResource(if (txType == PaymentType.SENT) R.drawable.ic_sent else R.drawable.ic_received) + val arrowIcon = painterResource( + id = if (txType == PaymentType.SENT) { + R.drawable.ic_sent + } else { + R.drawable.ic_received + }, + ) when { - isCpfpChild || isBoosting -> { + isCpfpChild || activity.isBoosting() -> { CircularIcon( icon = painterResource(R.drawable.ic_timer_alt), iconColor = Colors.Yellow, @@ -67,21 +68,30 @@ fun ActivityIcon( testTag = "ActivityContactAvatar", modifier = modifier ) - isLightning -> ActivityIconLightning(status, size, arrowIcon, modifier) - else -> ActivityIconOnchain(activity, arrowIcon, size, isHardware, modifier) + activity is Activity.Lightning -> ActivityIconLightning(activity.paymentState(), size, arrowIcon, modifier) + else -> ActivityIconOnchain( + txType = txType, + isTransfer = activity.isTransfer(), + doesExist = activity.doesExist(), + arrowIcon = arrowIcon, + size = size, + isHardware = isHardware, + modifier = modifier, + ) } } @Composable private fun ActivityIconOnchain( - activity: Activity, + txType: PaymentType, + isTransfer: Boolean, + doesExist: Boolean, arrowIcon: Painter, size: Dp, isHardware: Boolean, modifier: Modifier = Modifier, ) { - val isTransfer = activity.isTransfer() - val isTransferFromSpending = isTransfer && activity.txType() == PaymentType.RECEIVED + val isTransferFromSpending = isTransfer && txType == PaymentType.RECEIVED val (iconColor, backgroundColor) = when { isHardware -> Colors.Blue to Colors.Blue16 isTransferFromSpending -> Colors.Purple to Colors.Purple16 @@ -95,7 +105,7 @@ private fun ActivityIconOnchain( CircularIcon( icon = when { - !activity.doesExist() -> painterResource(R.drawable.ic_x) + !doesExist -> painterResource(R.drawable.ic_x) isTransfer -> painterResource(R.drawable.ic_transfer) else -> arrowIcon }, @@ -177,145 +187,7 @@ private fun Preview() { verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(16.dp), ) { - // Lightning Sent Succeeded - ActivityIcon( - activity = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-1", - txType = PaymentType.SENT, - status = PaymentState.SUCCEEDED, - value = 50000uL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1uL, - ) - ) - ) - - // Lightning Received Failed - ActivityIcon( - activity = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-2", - txType = PaymentType.RECEIVED, - status = PaymentState.FAILED, - value = 50000uL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1uL, - ) - ) - ) - - // Lightning Pending - ActivityIcon( - activity = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-3", - txType = PaymentType.SENT, - status = PaymentState.PENDING, - value = 50000uL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1uL, - ) - ) - ) - - // Onchain Received - ActivityIcon( - activity = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-1", - txType = PaymentType.RECEIVED, - txId = "abc123", - value = 100000uL, - fee = 500uL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - confirmed = true, - feeRate = 8uL, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - ) - ) - ) - - // Onchain BOOST CPFP - ActivityIcon( - activity = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-1", - txType = PaymentType.RECEIVED, - txId = "abc123", - value = 100000uL, - fee = 500uL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - feeRate = 8uL, - isBoosted = true, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - ) - ) - ) - - // Onchain BOOST RBF - ActivityIcon( - activity = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-1", - txType = PaymentType.SENT, - txId = "abc123", - value = 100000uL, - fee = 500uL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - feeRate = 8uL, - isBoosted = true, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - ) - ) - ) - - // Onchain Transfer - ActivityIcon( - activity = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-2", - txType = PaymentType.SENT, - txId = "abc123", - value = 100000uL, - fee = 500uL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - confirmed = true, - feeRate = 8uL, - isTransfer = true, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - transferTxId = "transferTxId", - ) - ) - ) - - // Onchain Removed - ActivityIcon( - activity = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-2", - txType = PaymentType.SENT, - txId = "abc123", - value = 100000uL, - fee = 500uL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - confirmed = true, - feeRate = 8uL, - isBoosted = true, - doesExist = false, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - transferTxId = "transferTxId", - ) - ) - ) + previewActivityItems.forEach { ActivityIcon(activity = it) } } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt index 6c4394e81b..707eed602a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -28,7 +29,9 @@ import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R -import to.bitkit.ext.rawId +import to.bitkit.ext.activityKey +import to.bitkit.ext.groupTimestamp +import to.bitkit.ext.scopedId import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption13Up @@ -47,7 +50,7 @@ import java.util.Locale @Composable fun ActivityListGrouped( items: ImmutableList?, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, onEmptyActivityRowClick: () -> Unit, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), @@ -78,22 +81,17 @@ fun ActivityListGrouped( ) { itemsIndexed( items = groupedItems, - key = { index, item -> + key = { _, item -> when (item) { - is String -> "header_$item" - is Activity -> when (item) { - is Activity.Lightning -> "lightning_${item.rawId()}" - is Activity.Onchain -> "onchain_${item.rawId()}" - } - - else -> "item_$index" + is ActivityGroupHeader -> "header_${item.title}" + is ActivityGroupEntry -> item.item.activityKey() } } ) { index, item -> when (item) { - is String -> { + is ActivityGroupHeader -> { Caption13Up( - text = item, + text = item.title, color = Colors.White64, modifier = Modifier .fillMaxWidth() @@ -106,7 +104,8 @@ fun ActivityListGrouped( ) } - is Activity -> { + is ActivityGroupEntry -> { + val activity = item.item Column( modifier = Modifier .animateItem( @@ -116,12 +115,12 @@ fun ActivityListGrouped( ) ) { ActivityRow( - item = item, + item = activity, onClick = onActivityItemClick, testTag = "$activityTestTagPrefix-$index", - title = titleProvider(item) ?: contactActivityTitle(item, contacts), - isHardware = item.rawId() in hardwareIds, - contact = if (showContactAvatar) contactForActivity(item, contacts) else null, + title = titleProvider(activity) ?: contactActivityTitle(activity, contacts), + isHardware = activity.scopedId() in hardwareIds, + contact = if (showContactAvatar) contactForActivity(activity, contacts) else null, ) VerticalSpacer(16.dp) } @@ -165,7 +164,7 @@ fun ActivityListGrouped( @Suppress("LongMethod", "LongParameterList") fun LazyListScope.activityListGroupedItems( items: ImmutableList?, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, onEmptyActivityRowClick: () -> Unit, showFooter: Boolean = false, onAllActivityButtonClick: () -> Unit = {}, @@ -176,22 +175,17 @@ fun LazyListScope.activityListGroupedItems( val groupedItems = groupActivityItems(items) itemsIndexed( items = groupedItems, - key = { index, item -> + key = { _, item -> when (item) { - is String -> "header_$item" - is Activity -> when (item) { - is Activity.Lightning -> "lightning_${item.rawId()}" - is Activity.Onchain -> "onchain_${item.rawId()}" - } - - else -> "item_$index" + is ActivityGroupHeader -> "header_${item.title}" + is ActivityGroupEntry -> item.item.activityKey() } }, ) { index, item -> when (item) { - is String -> { + is ActivityGroupHeader -> { Caption13Up( - text = item, + text = item.title, color = Colors.White64, modifier = Modifier .fillMaxWidth() @@ -204,7 +198,8 @@ fun LazyListScope.activityListGroupedItems( ) } - is Activity -> { + is ActivityGroupEntry -> { + val activity = item.item Column( modifier = Modifier .animateItem( @@ -214,10 +209,10 @@ fun LazyListScope.activityListGroupedItems( ) ) { ActivityRow( - item = item, + item = activity, onClick = onActivityItemClick, testTag = "Activity-$index", - isHardware = item.rawId() in hardwareIds, + isHardware = activity.scopedId() in hardwareIds, ) VerticalSpacer(16.dp) } @@ -264,7 +259,7 @@ fun LazyListScope.activityListGroupedItems( // region utils @Suppress("CyclomaticComplexMethod") -private fun groupActivityItems(activityItems: List): List { +private fun groupActivityItems(activityItems: List): List { val now = Instant.now() val zoneId = ZoneId.systemDefault() val today = now.atZone(zoneId).truncatedTo(ChronoUnit.DAYS) @@ -284,10 +279,7 @@ private fun groupActivityItems(activityItems: List): List { val earlierItems = mutableListOf() for (item in activityItems) { - val timestamp = when (item) { - is Activity.Lightning -> item.v1.timestamp.toLong() - is Activity.Onchain -> item.v1.timestamp.toLong() - } + val timestamp = item.groupTimestamp().toLong() when { timestamp >= startOfDay -> todayItems.add(item) timestamp >= startOfYesterday -> yesterdayItems.add(item) @@ -300,33 +292,42 @@ private fun groupActivityItems(activityItems: List): List { return buildList { if (todayItems.isNotEmpty()) { - add("TODAY") - addAll(todayItems) + add(ActivityGroupHeader("TODAY")) + addAll(todayItems.map { ActivityGroupEntry(it) }) } if (yesterdayItems.isNotEmpty()) { - add("YESTERDAY") - addAll(yesterdayItems) + add(ActivityGroupHeader("YESTERDAY")) + addAll(yesterdayItems.map { ActivityGroupEntry(it) }) } if (weekItems.isNotEmpty()) { - add("THIS WEEK") - addAll(weekItems) + add(ActivityGroupHeader("THIS WEEK")) + addAll(weekItems.map { ActivityGroupEntry(it) }) } if (monthItems.isNotEmpty()) { - add("THIS MONTH") - addAll(monthItems) + add(ActivityGroupHeader("THIS MONTH")) + addAll(monthItems.map { ActivityGroupEntry(it) }) } if (yearItems.isNotEmpty()) { - add("THIS YEAR") - addAll(yearItems) + add(ActivityGroupHeader("THIS YEAR")) + addAll(yearItems.map { ActivityGroupEntry(it) }) } if (earlierItems.isNotEmpty()) { - add("EARLIER") - addAll(earlierItems) + add(ActivityGroupHeader("EARLIER")) + addAll(earlierItems.map { ActivityGroupEntry(it) }) } } } // endregion +@Immutable +private sealed interface GroupedActivityItem + +@Immutable +private data class ActivityGroupHeader(val title: String) : GroupedActivityItem + +@Immutable +private data class ActivityGroupEntry(val item: Activity) : GroupedActivityItem + @Preview @Composable private fun Preview() { @@ -334,7 +335,7 @@ private fun Preview() { Column(modifier = Modifier.padding(horizontal = 16.dp)) { ActivityListGrouped( items = previewActivityItems, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, ) } @@ -347,7 +348,7 @@ private fun PreviewEmpty() { AppThemeSurface { ActivityListGrouped( items = persistentListOf(), - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, ) } @@ -359,7 +360,7 @@ private fun PreviewEmptyWithFooter() { AppThemeSurface { ActivityListGrouped( items = persistentListOf(), - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, showFooter = true, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt index 5af0ba3173..b20bb4c06b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt @@ -21,7 +21,7 @@ import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R -import to.bitkit.ext.rawId +import to.bitkit.ext.scopedId import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.VerticalSpacer @@ -32,7 +32,7 @@ import to.bitkit.ui.theme.AppThemeSurface fun ActivityListSimple( items: ImmutableList?, onAllActivityClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, hardwareIds: ImmutableSet = persistentSetOf(), ) { if (items.isNullOrEmpty()) return @@ -51,7 +51,7 @@ fun ActivityListSimple( onClick = onActivityItemClick, testTag = "ActivityShort-$index", title = contactActivityTitle(item, contacts), - isHardware = item.rawId() in hardwareIds, + isHardware = item.scopedId() in hardwareIds, contact = contactForActivity(item, contacts), ) if (index < items.lastIndex) { @@ -76,7 +76,7 @@ private fun Preview() { ActivityListSimple( items = previewActivityItems, onAllActivityClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } @@ -88,7 +88,7 @@ private fun PreviewEmpty() { ActivityListSimple( items = persistentListOf(), onAllActivityClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index a26a324811..501732239d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -29,13 +29,20 @@ import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import to.bitkit.R import to.bitkit.ext.DatePattern +import to.bitkit.ext.confirmed +import to.bitkit.ext.doesExist +import to.bitkit.ext.feeRate import to.bitkit.ext.formatted import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer +import to.bitkit.ext.message +import to.bitkit.ext.paymentState import to.bitkit.ext.rawId import to.bitkit.ext.timestamp import to.bitkit.ext.totalValue +import to.bitkit.ext.txId import to.bitkit.ext.txType +import to.bitkit.ext.walletId import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PrimaryDisplay import to.bitkit.models.PubkyProfile @@ -65,7 +72,7 @@ import java.time.ZoneId @Composable fun ActivityRow( item: Activity, - onClick: (String) -> Unit, + onClick: (String, String) -> Unit, testTag: String, title: String? = null, isHardware: Boolean = false, @@ -76,20 +83,15 @@ fun ActivityRow( } val feeRates = blocktankInfo?.onchain?.feeRates - val status: PaymentState? = when (item) { - is Activity.Lightning -> item.v1.status - is Activity.Onchain -> null - } + val status = item.paymentState() val isLightning = item is Activity.Lightning val timestamp = item.timestamp() - val txType: PaymentType = item.txType() + val txType = item.txType() val isSent = item.isSent() val amountPrefix = if (isSent) "-" else "+" - val confirmed: Boolean? = when (item) { - is Activity.Lightning -> null - is Activity.Onchain -> item.v1.confirmed - } + val confirmed = item.confirmed() val isTransfer = item.isTransfer() + val txId = item.txId() val activityListViewModel = activityListViewModel var isCpfpChild by remember { mutableStateOf(false) } val resolvedTitle = title.takeIf { @@ -99,9 +101,9 @@ fun ActivityRow( shouldUseContactActivityTitle(item, status, isTransfer, isCpfpChild) } - LaunchedEffect(item) { - isCpfpChild = if (item is Activity.Onchain && activityListViewModel != null) { - activityListViewModel.isCpfpChildTransaction(item.v1.txId) + LaunchedEffect(txId) { + isCpfpChild = if (txId != null && activityListViewModel != null) { + activityListViewModel.isCpfpChildTransaction(txId) } else { false } @@ -111,7 +113,7 @@ fun ActivityRow( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickableAlpha { onClick(item.rawId()) } + .clickableAlpha { onClick(item.rawId(), item.walletId()) } .background(color = Colors.Gray6, shape = Shapes.medium) .padding(16.dp) .testTag(testTag) @@ -137,37 +139,36 @@ fun ActivityRow( title = resolvedTitle, ) val context = LocalContext.current - val subtitleText = when (item) { - is Activity.Lightning -> item.v1.message.ifEmpty { formattedTime(timestamp) } - is Activity.Onchain -> { - when { - !item.v1.doesExist -> stringResource(R.string.wallet__activity_removed) + val subtitleText = if (isLightning) { + item.message().ifEmpty { formattedTime(timestamp) } + } else { + when { + !item.doesExist() -> stringResource(R.string.wallet__activity_removed) - isCpfpChild -> stringResource(R.string.wallet__activity_boost_fee_description) + isCpfpChild -> stringResource(R.string.wallet__activity_boost_fee_description) - isTransfer && isSent -> if (item.v1.confirmed) { - stringResource(R.string.wallet__activity_transfer_spending_done) - } else { - val duration = context.getFeeShortDescription(item.v1.feeRate, feeRates) - stringResource(R.string.wallet__activity_transfer_spending_pending) - .replace("{duration}", duration) - } + isTransfer && isSent -> if (confirmed == true) { + stringResource(R.string.wallet__activity_transfer_spending_done) + } else { + val duration = context.getFeeShortDescription(item.feeRate(), feeRates) + stringResource(R.string.wallet__activity_transfer_spending_pending) + .replace("{duration}", duration) + } - isTransfer && !isSent -> if (item.v1.confirmed) { - stringResource(R.string.wallet__activity_transfer_savings_done) - } else { - val duration = context.getFeeShortDescription(item.v1.feeRate, feeRates) - stringResource(R.string.wallet__activity_transfer_savings_pending) - .replace("{duration}", duration) - } + isTransfer && !isSent -> if (confirmed == true) { + stringResource(R.string.wallet__activity_transfer_savings_done) + } else { + val duration = context.getFeeShortDescription(item.feeRate(), feeRates) + stringResource(R.string.wallet__activity_transfer_savings_pending) + .replace("{duration}", duration) + } - confirmed == true -> formattedTime(timestamp) + confirmed == true -> formattedTime(timestamp) - else -> { - val feeDescription = context.getFeeShortDescription(item.v1.feeRate, feeRates) - stringResource(R.string.wallet__activity_confirms_in) - .replace("{feeRateDescription}", feeDescription) - } + else -> { + val feeDescription = context.getFeeShortDescription(item.feeRate(), feeRates) + stringResource(R.string.wallet__activity_confirms_in) + .replace("{feeRateDescription}", feeDescription) } } } @@ -193,9 +194,9 @@ private fun shouldUseContactActivityTitle( ): Boolean { if (isTransfer || isCpfpChild) return false - return when (activity) { - is Activity.Lightning -> status == PaymentState.SUCCEEDED - is Activity.Onchain -> activity.v1.doesExist + return when { + activity is Activity.Lightning -> status == PaymentState.SUCCEEDED + else -> activity.doesExist() } } @@ -387,7 +388,7 @@ private fun Preview(@PreviewParameter(ActivityItemsPreviewProvider::class) item: AppThemeSurface { ActivityRow( item = item, - onClick = {}, + onClick = { _, _ -> }, testTag = "Activity-", ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/utils/PreviewItems.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/utils/PreviewItems.kt index c74fb3c9a0..3fce59762f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/utils/PreviewItems.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/utils/PreviewItems.kt @@ -8,6 +8,7 @@ import com.synonym.bitkitcore.PaymentType import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import to.bitkit.ext.create +import to.bitkit.models.ActivityWalletType import java.util.Calendar val previewActivityItems: ImmutableList = buildList { @@ -19,97 +20,119 @@ val previewActivityItems: ImmutableList = buildList { fun Calendar.epochSecond() = (timeInMillis / 1000).toULong() - // Today add( - Activity.Onchain( - OnchainActivity.create( - id = "1", - txType = PaymentType.RECEIVED, - txId = "01", - value = 42_000_u, - fee = 200_u, - address = "bc1", - confirmed = true, - timestamp = today.epochSecond(), - isBoosted = true, - boostTxIds = listOf("02", "03"), - doesExist = false, - confirmTimestamp = today.epochSecond(), - channelId = "channelId", - transferTxId = "transferTxId", - createdAt = today.epochSecond() - 30_000u, - updatedAt = today.epochSecond(), - ) + onchainPreviewItem( + id = "1", + txType = PaymentType.RECEIVED, + value = 42_000uL, + fee = 200uL, + timestamp = today.epochSecond(), + txId = "01", + confirmed = true, + isBoosted = true, + doesExist = false, ) ) - // Yesterday add( - Activity.Lightning( - LightningActivity.create( - id = "2", - txType = PaymentType.SENT, - status = PaymentState.PENDING, - value = 30_000_u, - invoice = "lnbc2", - timestamp = yesterday.epochSecond(), - fee = 15_u, - message = "Custom very long lightning activity message to test truncation", - preimage = "preimage1", - ) + lightningPreviewItem( + id = "2", + txType = PaymentType.SENT, + status = PaymentState.PENDING, + value = 30_000uL, + fee = 15uL, + timestamp = yesterday.epochSecond(), + message = "Custom very long lightning activity message to test truncation", ) ) - // This Week add( - Activity.Lightning( - LightningActivity.create( - id = "3", - txType = PaymentType.RECEIVED, - status = PaymentState.FAILED, - value = 217_000_u, - invoice = "lnbc3", - timestamp = thisWeek.epochSecond(), - fee = 17_u, - preimage = "preimage2", - ) + lightningPreviewItem( + id = "3", + txType = PaymentType.RECEIVED, + status = PaymentState.FAILED, + value = 217_000uL, + fee = 17uL, + timestamp = thisWeek.epochSecond(), ) ) - // This Month add( - Activity.Onchain( - OnchainActivity.create( - id = "4", - txType = PaymentType.SENT, - txId = "04", - value = 950_000_u, - fee = 110_u, - address = "bc1", - timestamp = thisMonth.epochSecond(), - isTransfer = true, - confirmTimestamp = today.epochSecond() + 3600u, - channelId = "channelId", - transferTxId = "transferTxId", - ) + onchainPreviewItem( + id = "4", + txType = PaymentType.SENT, + value = 950_000uL, + fee = 110uL, + timestamp = thisMonth.epochSecond(), + txId = "04", + isTransfer = true, ) ) - // Last Year add( - Activity.Lightning( - LightningActivity.create( - id = "5", - txType = PaymentType.SENT, - status = PaymentState.SUCCEEDED, - value = 200_000_u, - invoice = "lnbc…", - timestamp = lastYear.epochSecond(), - fee = 1_u, - ) + lightningPreviewItem( + id = "5", + txType = PaymentType.SENT, + status = PaymentState.SUCCEEDED, + value = 200_000uL, + fee = 1uL, + timestamp = lastYear.epochSecond(), ) ) }.toImmutableList() fun previewOnchainActivityItems() = previewActivityItems.filterIsInstance().toImmutableList() + fun previewLightningActivityItems() = previewActivityItems.filterIsInstance().toImmutableList() + +@Suppress("LongParameterList") +private fun lightningPreviewItem( + id: String, + txType: PaymentType, + status: PaymentState, + value: ULong, + fee: ULong, + timestamp: ULong, + message: String = "", +) = Activity.Lightning( + LightningActivity.create( + id = id, + walletId = ActivityWalletType.BITKIT.id, + txType = txType, + status = status, + value = value, + fee = fee, + invoice = "lnbc$id", + message = message, + timestamp = timestamp, + ) +) + +@Suppress("LongParameterList") +private fun onchainPreviewItem( + id: String, + txType: PaymentType, + value: ULong, + fee: ULong, + timestamp: ULong, + txId: String, + confirmed: Boolean = false, + isBoosted: Boolean = false, + isTransfer: Boolean = false, + doesExist: Boolean = true, +) = Activity.Onchain( + OnchainActivity.create( + id = id, + walletId = ActivityWalletType.BITKIT.id, + txType = txType, + value = value, + fee = fee, + txId = txId, + address = "bc1qpreview$id", + timestamp = timestamp, + confirmed = confirmed, + isBoosted = isBoosted, + isTransfer = isTransfer, + doesExist = doesExist, + ) +) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt index ffd725a7fe..b39498f401 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt @@ -37,7 +37,6 @@ import to.bitkit.R import to.bitkit.ext.uniqueUtxoKey import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.LocalCurrencies -import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.BottomSheetPreview @@ -67,11 +66,7 @@ fun SendCoinSelectionScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val tagsByTxId by viewModel.tagsByTxId.collectAsStateWithLifecycle() - val activity = activityListViewModel ?: return - val onchainActivities by activity.onchainActivities.collectAsStateWithLifecycle() - - LaunchedEffect(requiredAmount, onchainActivities) { - viewModel.setOnchainActivities(onchainActivities.orEmpty()) + LaunchedEffect(requiredAmount, address) { viewModel.loadUtxos(requiredAmount, address) } 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 0d771fb139..0fbebd8249 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 @@ -3,8 +3,6 @@ package to.bitkit.ui.screens.wallets.send import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.synonym.bitkitcore.Activity -import com.synonym.bitkitcore.Activity.Onchain import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -20,7 +18,6 @@ import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.SpendableUtxo import to.bitkit.di.BgDispatcher import to.bitkit.env.Defaults -import to.bitkit.ext.rawId import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.LightningRepo import to.bitkit.ui.shared.toast.ToastEventBus @@ -43,12 +40,6 @@ class SendCoinSelectionViewModel @Inject constructor( private val _tagsByTxId = MutableStateFlow>>(persistentMapOf()) val tagsByTxId = _tagsByTxId.asStateFlow() - private var onchainActivities: List = emptyList() - - fun setOnchainActivities(onchainActivities: List) { - this.onchainActivities = onchainActivities - } - fun loadUtxos(requiredAmount: ULong, address: String) = viewModelScope.launch { runCatching { val sortedUtxos = lightningRepo.listSpendableOutputs().getOrThrow() @@ -82,13 +73,10 @@ class SendCoinSelectionViewModel @Inject constructor( if (_tagsByTxId.value.containsKey(txId)) return viewModelScope.launch(bgDispatcher) { - // find activity by txId - onchainActivities.firstOrNull { (it as? Onchain)?.v1?.txId == txId }?.let { activity -> - // get tags by activity id - activityRepo.getActivityTags(activity.rawId()) + activityRepo.getOnchainActivityByTxId(txId)?.let { activity -> + activityRepo.getActivityTags(activity.id) .onSuccess { tags -> if (tags.isNotEmpty()) { - // add map entry linking tags to utxo.outpoint.txid _tagsByTxId.update { (it + (txId to tags.toImmutableList())).toImmutableMap() } diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt index c9fe07801d..199c6e6ecf 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.synonym.bitkitcore.Activity import to.bitkit.R import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.ui.components.BodyMSB @@ -64,13 +63,14 @@ fun BoostTransactionSheet( onMaxFee: () -> Unit, onMinFee: () -> Unit, onDismiss: () -> Unit, - item: Activity.Onchain, + activityId: String, + walletId: String, ) { val haptic = LocalHapticFeedback.current // Setup activity when component is first created - LaunchedEffect(Unit) { - viewModel.setupActivity(item) + LaunchedEffect(activityId, walletId) { + viewModel.setupActivity(activityId, walletId) } // Handle effects diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt index e814bb10fa..fe22c138c2 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt @@ -57,7 +57,27 @@ class BoostTransactionViewModel @Inject constructor( private var minFeeRate: ULong = 2U private var activity: Activity.Onchain? = null - fun setupActivity(activity: Activity.Onchain) { + fun setupActivity(activityId: String, walletId: String?) { + _uiState.update { it.copy(loading = true) } + + viewModelScope.launch { + activityRepo.getActivity(activityId, walletId) + .onSuccess { + val activity = it as? Activity.Onchain + if (activity == null) { + handleError("Activity '$activityId' is not boostable") + return@onSuccess + } + + setupActivity(activity) + } + .onFailure { + handleError("Failed to load activity '$activityId'", it) + } + } + } + + private fun setupActivity(activity: Activity.Onchain) { Logger.debug("Setup activity $activity", context = TAG) this.activity = activity diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index ec8c49ea6f..f199dce0e6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -25,9 +25,10 @@ import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.ext.rawId +import to.bitkit.ext.walletId +import to.bitkit.models.USat import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo -import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.TransferRepo import to.bitkit.utils.Logger import javax.inject.Inject @@ -40,7 +41,6 @@ class ActivityDetailViewModel @Inject constructor( private val activityRepo: ActivityRepo, private val settingsStore: SettingsStore, private val blocktankRepo: BlocktankRepo, - private val hwWalletRepo: HwWalletRepo, private val transferRepo: TransferRepo, ) : ViewModel() { private val _txDetails = MutableStateFlow(null) @@ -58,23 +58,31 @@ class ActivityDetailViewModel @Inject constructor( private val _uiState = MutableStateFlow(ActivityDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun loadActivity(activityId: String) { + fun loadActivity(activityId: String, walletId: String? = null) { viewModelScope.launch(bgDispatcher) { _uiState.update { it.copy(activityLoadState = ActivityLoadState.Loading) } - activityRepo.getActivity(activityId) + activityRepo.getActivity(activityId, walletId) .onSuccess { activity -> if (activity != null) { this@ActivityDetailViewModel.activity = activity - _uiState.update { it.copy(activityLoadState = ActivityLoadState.Success(activity)) } + _uiState.update { + it.copy(activityLoadState = ActivityLoadState.Success(activity)) + } loadTags() - observeActivityChanges(activityId) + observeActivityChanges(activityId, walletId) } else { - loadHwWalletActivity(activityId) + _uiState.update { + it.copy( + activityLoadState = ActivityLoadState.Error( + context.getString(R.string.wallet__activity_error_not_found) + ) + ) + } } } .onFailure { e -> - Logger.error("Failed to load activity $activityId", e, TAG) + Logger.error("Failed to load activity '$activityId'", e, context = TAG) _uiState.update { it.copy( activityLoadState = ActivityLoadState.Error( @@ -89,57 +97,22 @@ class ActivityDetailViewModel @Inject constructor( fun clearActivityState() { observeJob?.cancel() observeJob = null - _uiState.update { it.copy(activityLoadState = ActivityLoadState.Initial, isHardwareActivity = false) } + _uiState.update { it.copy(activityLoadState = ActivityLoadState.Initial) } activity = null _tags.update { persistentListOf() } } - private fun loadHwWalletActivity(activityId: String) { - val hwActivity = hwWalletRepo.activities.value.find { it.rawId() == activityId } - if (hwActivity != null) { - activity = hwActivity - _uiState.update { - it.copy(activityLoadState = ActivityLoadState.Success(hwActivity), isHardwareActivity = true) - } - observeHwWalletActivityChanges(activityId) - } else { - _uiState.update { - it.copy( - activityLoadState = ActivityLoadState.Error( - context.getString(R.string.wallet__activity_error_not_found) - ) - ) - } - } - } - - private fun observeHwWalletActivityChanges(activityId: String) { - observeJob?.cancel() - observeJob = viewModelScope.launch(bgDispatcher) { - hwWalletRepo.activities.collect { activities -> - val updatedActivity = activities.find { it.rawId() == activityId } ?: return@collect - activity = updatedActivity - _uiState.update { - it.copy( - activityLoadState = ActivityLoadState.Success(updatedActivity), - isHardwareActivity = true, - ) - } - } - } - } - - private fun observeActivityChanges(activityId: String) { + private fun observeActivityChanges(activityId: String, walletId: String?) { observeJob?.cancel() observeJob = viewModelScope.launch(bgDispatcher) { activityRepo.activitiesChanged.collect { - reloadActivity(activityId) + reloadActivity(activityId, walletId) } } } - private suspend fun reloadActivity(activityId: String) { - activityRepo.getActivity(activityId) + private suspend fun reloadActivity(activityId: String, walletId: String?) { + activityRepo.getActivity(activityId, walletId) .onSuccess { updatedActivity -> if (updatedActivity != null) { activity = updatedActivity @@ -150,20 +123,21 @@ class ActivityDetailViewModel @Inject constructor( } } .onFailure { error -> - Logger.warn("Failed to reload activity $activityId", error, context = TAG) + Logger.warn("Failed to reload activity '$activityId'", error, context = TAG) // Keep showing the last known state on reload failure } } fun loadTags() { val id = activity?.rawId() ?: return + val walletId = activity?.walletId() viewModelScope.launch(bgDispatcher) { - activityRepo.getActivityTags(id) + activityRepo.getActivityTags(id, walletId) .onSuccess { activityTags -> _tags.update { activityTags.toImmutableList() } } .onFailure { - Logger.error("Failed to load tags for activity $id", it, TAG) + Logger.error("Failed to load tags for activity '$id'", it, context = TAG) _tags.update { persistentListOf() } } } @@ -171,39 +145,42 @@ class ActivityDetailViewModel @Inject constructor( fun removeTag(tag: String) { val id = activity?.rawId() ?: return + val walletId = activity?.walletId() viewModelScope.launch(bgDispatcher) { - activityRepo.removeTagsFromActivity(id, listOf(tag)) + activityRepo.removeTagsFromActivity(id, listOf(tag), walletId) .onSuccess { loadTags() } .onFailure { - Logger.error("Failed to remove tag $tag from activity $id", it, TAG) + Logger.error("Failed to remove tag '$tag' from activity '$id'", it, context = TAG) } } } fun addTag(tag: String) { val id = activity?.rawId() ?: return + val walletId = activity?.walletId() viewModelScope.launch(bgDispatcher) { - activityRepo.addTagsToActivity(id, listOf(tag)) + activityRepo.addTagsToActivity(id, listOf(tag), walletId) .onSuccess { settingsStore.addLastUsedTag(tag) loadTags() } .onFailure { - Logger.error("Failed to add tag $tag to activity $id", it, TAG) + Logger.error("Failed to add tag '$tag' to activity '$id'", it, context = TAG) } } } fun detachContact() { val id = activity?.rawId() ?: return + val walletId = activity?.walletId() viewModelScope.launch(bgDispatcher) { activityRepo.clearContact( forPaymentId = id, syncLdkPayments = false, ).onSuccess { - reloadActivity(id) + reloadActivity(id, walletId) }.onFailure { Logger.error("Failed to detach contact for activity '$id'", it, context = TAG) } @@ -211,8 +188,9 @@ class ActivityDetailViewModel @Inject constructor( } fun fetchTransactionDetails(txid: String) { + val walletId = activity?.walletId() viewModelScope.launch(bgDispatcher) { - activityRepo.getTransactionDetails(txid) + activityRepo.getTransactionDetails(txid, walletId) .onSuccess { transactionDetails -> _txDetails.update { transactionDetails } } @@ -273,6 +251,17 @@ class ActivityDetailViewModel @Inject constructor( }.getOrNull() } + suspend fun findTransferOrderAmounts( + channelId: String?, + txId: String?, + ): TransferOrderAmounts? { + val order = findOrderForTransfer(channelId, txId) ?: return null + return TransferOrderAmounts( + serviceFee = USat(order.feeSat) - USat(order.clientBalanceSat), + transferAmount = order.clientBalanceSat, + ) + } + private companion object { const val TAG = "ActivityDetailViewModel" } @@ -286,6 +275,10 @@ class ActivityDetailViewModel @Inject constructor( data class ActivityDetailUiState( val activityLoadState: ActivityLoadState = ActivityLoadState.Initial, - val isHardwareActivity: Boolean = false, ) } + +data class TransferOrderAmounts( + val serviceFee: ULong, + val transferAmount: ULong, +) diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index df932631a0..e5239801e8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -27,15 +27,15 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher +import to.bitkit.ext.isHardwareWalletActivity import to.bitkit.ext.isReplacedSentTransaction import to.bitkit.ext.isTransfer -import to.bitkit.ext.rawId +import to.bitkit.ext.scopedId import to.bitkit.ext.timestamp import to.bitkit.ext.txType import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.PubkyProfile import to.bitkit.repositories.ActivityRepo -import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.screens.wallets.activity.components.ActivityTab import to.bitkit.utils.Logger @@ -46,7 +46,6 @@ import javax.inject.Inject class ActivityListViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val activityRepo: ActivityRepo, - private val hwWalletRepo: HwWalletRepo, pubkyRepo: PubkyRepo, settingsStore: SettingsStore, ) : ViewModel() { @@ -60,25 +59,9 @@ class ActivityListViewModel @Inject constructor( val onchainActivities = _onchainActivities.asStateFlow() private val _latestActivities = MutableStateFlow?>(null) - private val _localActivityIds = MutableStateFlow>(emptySet()) - - // Merge the device's watch-only hardware-wallet activity into the home list, - // newest first, capped at the same limit as the on-chain/lightning list. - val latestActivities: StateFlow?> = combine( - _latestActivities, - hwWalletRepo.activities, - _localActivityIds, - ) { localActivities, hardwareActivities, localActivityIds -> - val visibleHardwareActivities = hardwareActivities.withoutLocalDuplicates(localActivityIds) - if (localActivities == null && visibleHardwareActivities.isEmpty()) { - null - } else { - (localActivities.orEmpty() + visibleHardwareActivities) - .sortedByDescending { it.timestamp() } - .take(SIZE_LATEST) - .toImmutableList() - } - }.stateInScope(null) + val latestActivities: StateFlow?> = _latestActivities.asStateFlow() + + private val _hardwareIds = MutableStateFlow>(persistentSetOf()) val contacts: StateFlow> = combine( @@ -91,15 +74,7 @@ class ActivityListViewModel @Inject constructor( val availableTags: StateFlow> = activityRepo.state.map { it.tags }.stateInScope(persistentListOf()) - val hardwareIds: StateFlow> = combine( - hwWalletRepo.activities, - _localActivityIds, - ) { activities, localActivityIds -> - activities.withoutLocalDuplicates(localActivityIds) - .map { it.rawId() } - .toImmutableSet() - } - .stateInScope(persistentSetOf()) + val hardwareIds: StateFlow> = _hardwareIds.asStateFlow() private val _filters = MutableStateFlow(ActivityFilters()) @@ -143,52 +118,20 @@ class ActivityListViewModel @Inject constructor( _filters.map { it.searchText }.debounce(300), _filters.map { it.copy(searchText = "") }, activityRepo.activitiesChanged, - hwWalletRepo.activities, - _localActivityIds, - ) { debouncedSearch, filtersWithoutSearch, _, hardwareActivities, localActivityIds -> + ) { debouncedSearch, filtersWithoutSearch, _ -> val filters = filtersWithoutSearch.copy(searchText = debouncedSearch) - fetchFilteredActivities(filters)?.let { activities -> - (activities + hardwareActivities.withoutLocalDuplicates(localActivityIds).filteredWith(filters)) - .sortedByDescending { it.timestamp() } - } + fetchFilteredActivities(filters)?.sortedByDescending { it.timestamp() } }.collect { activities -> _filteredActivities.update { activities?.toImmutableList() } } } - /** - * Watch-only hardware-wallet activities live outside the activity database, so the - * list filters are applied to them here. They carry no tags and are never transfers. - */ - private fun List.filteredWith(filters: ActivityFilters): List { - if (filters.tags.isNotEmpty() || filters.tab == ActivityTab.OTHER) return emptyList() - - val minTimestamp = filters.startDate?.let { (it / 1000).toULong() } - val maxTimestamp = filters.endDate?.let { (it / 1000).toULong() } - - return filter { activity -> - val matchesTab = when (filters.tab) { - ActivityTab.SENT -> activity.txType() == PaymentType.SENT - ActivityTab.RECEIVED -> activity.txType() == PaymentType.RECEIVED - else -> true - } - val matchesSearch = filters.searchText.isEmpty() || - activity.rawId().contains(filters.searchText, ignoreCase = true) - val timestamp = activity.timestamp() - val matchesDate = (minTimestamp == null || timestamp >= minTimestamp) && - (maxTimestamp == null || timestamp <= maxTimestamp) - matchesTab && matchesSearch && matchesDate - } - } - - private fun List.withoutLocalDuplicates(localActivityIds: Set) = filterNot { - it.rawId() in localActivityIds - } - private suspend fun refreshActivityState() { val all = activityRepo.getActivities(filter = ActivityFilter.ALL).getOrNull() ?: emptyList() val filtered = filterOutReplacedSentTransactions(all) - _localActivityIds.update { filtered.map { it.rawId() }.toSet() } + _hardwareIds.update { + filtered.filter { it.isHardwareWalletActivity() }.map { it.scopedId() }.toImmutableSet() + } _latestActivities.update { filtered.take(SIZE_LATEST).toImmutableList() } _lightningActivities.update { filtered.filterIsInstance().toImmutableList() } _onchainActivities.update { filtered.filterIsInstance().toImmutableList() } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index aa0d1ba2bf..ba38aafbb7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -95,6 +95,7 @@ import to.bitkit.ext.setClipboardText import to.bitkit.ext.toHex import to.bitkit.ext.toUserMessage import to.bitkit.ext.totalValue +import to.bitkit.ext.walletId import to.bitkit.ext.watchUntil import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.FeeRate @@ -334,6 +335,7 @@ class AppViewModel @Inject constructor( direction = NewTransactionSheetDirection.RECEIVED, paymentHashOrTxId = tx.txid, activityId = tx.txid, + activityWalletId = tx.walletId, sats = tx.sats.toLong(), ), ) @@ -2441,8 +2443,9 @@ class AppViewModel @Inject constructor( fun onClickActivityDetail() { _transactionSheet.value.activityId?.let { + val walletId = _transactionSheet.value.activityWalletId hideNewTransactionSheet() - mainScreenEffect(MainScreenEffect.Navigate(Routes.ActivityDetail(it))) + mainScreenEffect(MainScreenEffect.Navigate(Routes.ActivityDetail(it, walletId))) return } @@ -2459,7 +2462,7 @@ class AppViewModel @Inject constructor( ).onSuccess { activity -> hideNewTransactionSheet() _transactionSheet.update { it.copy(isLoadingDetails = false) } - val nextRoute = Routes.ActivityDetail(activity.rawId()) + val nextRoute = Routes.ActivityDetail(activity.rawId(), activity.walletId()) mainScreenEffect(MainScreenEffect.Navigate(nextRoute)) }.onFailure { e -> Logger.error(msg = "Activity not found", context = TAG) @@ -2483,7 +2486,7 @@ class AppViewModel @Inject constructor( ).onSuccess { activity -> hideSheet() _successSendUiState.update { it.copy(isLoadingDetails = false) } - val nextRoute = Routes.ActivityDetail(activity.rawId()) + val nextRoute = Routes.ActivityDetail(activity.rawId(), activity.walletId()) mainScreenEffect(MainScreenEffect.Navigate(nextRoute)) }.onFailure { e -> Logger.error(msg = "Activity not found", context = TAG) diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt index 1c935765f6..2402f8bd3a 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -73,7 +73,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { on { paymentHash } doReturn "hash123" on { paymentId } doReturn "paymentId123" } - whenever(activityRepo.isActivitySeen(any())).thenReturn(false) + whenever(activityRepo.isActivitySeen(any(), anyOrNull())).thenReturn(false) val command = NotifyPaymentReceived.Command.Lightning(event = event) val result = sut(command) @@ -85,7 +85,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals(NewTransactionSheetDirection.RECEIVED, paymentResult.sheet.direction) assertEquals("hash123", paymentResult.sheet.paymentHashOrTxId) assertEquals(1000L, paymentResult.sheet.sats) - verify(activityRepo).markActivityAsSeen("paymentId123") + verify(activityRepo).markActivityAsSeen("paymentId123", null) } @Test @@ -95,7 +95,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { on { paymentHash } doReturn "hash123" on { paymentId } doReturn "paymentId123" } - whenever(activityRepo.isActivitySeen(any())).thenReturn(false) + whenever(activityRepo.isActivitySeen(any(), anyOrNull())).thenReturn(false) val command = NotifyPaymentReceived.Command.Lightning( event = event, includeNotification = true, @@ -133,7 +133,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals(NewTransactionSheetDirection.RECEIVED, paymentResult.sheet.direction) assertEquals("txid456", paymentResult.sheet.paymentHashOrTxId) assertEquals(5000L, paymentResult.sheet.sats) - verify(activityRepo).markOnchainActivityAsSeen("txid456") + verify(activityRepo).markOnchainActivityAsSeen("txid456", null) } @Test @@ -172,7 +172,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { inOrder(activityRepo) { verify(activityRepo).handleOnchainTransactionReceived("txid789", details) verify(activityRepo).shouldShowReceivedSheet("txid789", 7500uL) - verify(activityRepo).markOnchainActivityAsSeen("txid789") + verify(activityRepo).markOnchainActivityAsSeen("txid789", null) } } @@ -190,7 +190,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { sut(command) - verify(activityRepo, never()).markOnchainActivityAsSeen(any()) + verify(activityRepo, never()).markOnchainActivityAsSeen(any(), anyOrNull()) } @Test @@ -200,14 +200,14 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { on { paymentHash } doReturn "hash123" on { paymentId } doReturn "paymentId123" } - whenever(activityRepo.isActivitySeen(any())).thenReturn(false) + whenever(activityRepo.isActivitySeen(any(), anyOrNull())).thenReturn(false) val command = NotifyPaymentReceived.Command.Lightning(event = event) sut(command) verify(activityRepo, never()).handleOnchainTransactionReceived(any(), any()) verify(activityRepo, never()).shouldShowReceivedSheet(any(), any()) - verify(activityRepo, never()).markOnchainActivityAsSeen(any()) + verify(activityRepo, never()).markOnchainActivityAsSeen(any(), anyOrNull()) } @Test @@ -217,7 +217,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { on { paymentHash } doReturn "hash123" on { paymentId } doReturn "paymentId123" } - whenever(activityRepo.isActivitySeen("paymentId123")).thenReturn(true) + whenever(activityRepo.isActivitySeen("paymentId123", null)).thenReturn(true) val command = NotifyPaymentReceived.Command.Lightning(event = event) val result = sut(command) @@ -225,7 +225,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertTrue(result.isSuccess) val paymentResult = result.getOrThrow() assertTrue(paymentResult is NotifyPaymentReceived.Result.Skip) - verify(activityRepo, never()).markActivityAsSeen(any()) + verify(activityRepo, never()).markActivityAsSeen(any(), anyOrNull()) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index d65386de54..7c27455b6f 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -12,18 +12,21 @@ import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.mockingDetails +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.ext.create +import to.bitkit.models.ActivityWalletType import to.bitkit.test.BaseUnitTest import to.bitkit.viewmodels.ActivityDetailViewModel import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -34,8 +37,8 @@ class ActivityDetailViewModelTest : BaseUnitTest() { private val activityRepo = mock() private val blocktankRepo = mock() private val settingsStore = mock() - private val hwWalletRepo = mock() private val transferRepo = mock() + private val hardwareWalletId = ActivityWalletType.TREZOR.scopedId("dev1") companion object Fixtures { const val ACTIVITY_ID = "test-activity-1" @@ -48,7 +51,6 @@ class ActivityDetailViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.wallet__activity_error_load_failed)).thenReturn("Failed to load activity") whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(System.currentTimeMillis())) - whenever(hwWalletRepo.activities).thenReturn(MutableStateFlow(persistentListOf())) runBlocking { whenever(transferRepo.findLspOrderIdByFundingTxId(any())).thenReturn(Result.success(null)) } @@ -59,13 +61,12 @@ class ActivityDetailViewModelTest : BaseUnitTest() { activityRepo = activityRepo, blocktankRepo = blocktankRepo, settingsStore = settingsStore, - hwWalletRepo = hwWalletRepo, transferRepo = transferRepo, ) } @Test - fun `loadActivity falls back to hardware wallet activity when missing from the database`() = test { + fun `loadActivity resolves a hardware wallet activity and tags it via its wallet id`() = test { val hwActivity = Activity.Onchain( OnchainActivity.create( id = ACTIVITY_ID, @@ -76,51 +77,39 @@ class ActivityDetailViewModelTest : BaseUnitTest() { address = "", timestamp = 1_700_000_000uL, confirmed = true, + walletId = hardwareWalletId, ) ) - whenever { activityRepo.getActivity(ACTIVITY_ID) }.thenReturn(Result.success(null)) - whenever(hwWalletRepo.activities).thenReturn(MutableStateFlow(persistentListOf(hwActivity))) - - sut.loadActivity(ACTIVITY_ID) - - val state = sut.uiState.value - val loadState = state.activityLoadState as ActivityDetailViewModel.ActivityLoadState.Success - assertEquals(hwActivity, loadState.activity) - assertTrue(state.isHardwareActivity) - } - - @Test - fun `hardware wallet activity updates while loaded`() = test { - val initialActivity = createTestActivity(ACTIVITY_ID, confirmed = false) - val updatedActivity = createTestActivity(ACTIVITY_ID, confirmed = true) - val hardwareActivities = MutableStateFlow(persistentListOf(initialActivity)) - - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(null)) - whenever(hwWalletRepo.activities).thenReturn(hardwareActivities) - - sut.loadActivity(ACTIVITY_ID) - - val initialState = sut.uiState.value.activityLoadState - assertTrue(initialState is ActivityDetailViewModel.ActivityLoadState.Success) - assertEquals(initialActivity, initialState.activity) - - hardwareActivities.value = persistentListOf(updatedActivity) - - val updatedState = sut.uiState.value.activityLoadState - assertTrue(updatedState is ActivityDetailViewModel.ActivityLoadState.Success) - assertEquals(updatedActivity, updatedState.activity) - assertTrue(sut.uiState.value.isHardwareActivity) + whenever { activityRepo.getActivity(ACTIVITY_ID, hardwareWalletId) }.thenReturn(Result.success(hwActivity)) + whenever { activityRepo.getActivityTags(ACTIVITY_ID, hardwareWalletId) }.thenReturn(Result.success(emptyList())) + whenever { + activityRepo.addTagsToActivity(ACTIVITY_ID, listOf("tag1"), hardwareWalletId) + }.thenReturn(Result.success(Unit)) + whenever { settingsStore.addLastUsedTag("tag1") }.thenReturn(Unit) + + sut.loadActivity(ACTIVITY_ID, hardwareWalletId) + val loadState = sut.uiState.value.activityLoadState + assertTrue(loadState is ActivityDetailViewModel.ActivityLoadState.Success) + val activity = loadState.activity + assertTrue(activity is Activity.Onchain) + assertEquals(ACTIVITY_ID, activity.v1.id) + assertEquals(hardwareWalletId, activity.v1.walletId) + + sut.addTag("tag1") + + verify(activityRepo, atLeastOnce()).getActivity(ACTIVITY_ID, hardwareWalletId) + verify(activityRepo).addTagsToActivity(ACTIVITY_ID, listOf("tag1"), hardwareWalletId) + verify(activityRepo, atLeastOnce()).getActivityTags(ACTIVITY_ID, hardwareWalletId) } @Test - fun `loadActivity reports not found when missing from database and hardware wallets`() = test { - whenever { activityRepo.getActivity(ACTIVITY_ID) }.thenReturn(Result.success(null)) + fun `loadActivity reports not found when missing from the database`() = test { + whenever { activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull()) }.thenReturn(Result.success(null)) sut.loadActivity(ACTIVITY_ID) val state = sut.uiState.value assertTrue(state.activityLoadState is ActivityDetailViewModel.ActivityLoadState.Error) - assertFalse(state.isHardwareActivity) } @Test @@ -181,8 +170,8 @@ class ActivityDetailViewModelTest : BaseUnitTest() { val activitiesChangedFlow = MutableStateFlow(System.currentTimeMillis()) whenever(activityRepo.activitiesChanged).thenReturn(activitiesChangedFlow) - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(initialActivity)) - whenever(activityRepo.getActivityTags(ACTIVITY_ID)).thenReturn(Result.success(emptyList())) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(initialActivity)) + whenever(activityRepo.getActivityTags(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(emptyList())) // Load activity sut.loadActivity(ACTIVITY_ID) @@ -190,16 +179,20 @@ class ActivityDetailViewModelTest : BaseUnitTest() { // Verify initial state loaded val initialState = sut.uiState.value.activityLoadState assertTrue(initialState is ActivityDetailViewModel.ActivityLoadState.Success) - assertEquals(initialActivity, initialState.activity) + assertTrue(initialState.activity is Activity.Onchain) + assertEquals(initialActivity.v1.id, initialState.activity.v1.id) + assertEquals(initialActivity.v1.confirmed, initialState.activity.v1.confirmed) // Simulate activity update - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(updatedActivity)) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(updatedActivity)) activitiesChangedFlow.value += 1 // Verify ViewModel reflects updated activity val updatedState = sut.uiState.value.activityLoadState assertTrue(updatedState is ActivityDetailViewModel.ActivityLoadState.Success) - assertEquals(updatedActivity, updatedState.activity) + assertTrue(updatedState.activity is Activity.Onchain) + assertEquals(updatedActivity.v1.id, updatedState.activity.v1.id) + assertEquals(updatedActivity.v1.confirmed, updatedState.activity.v1.confirmed) } @Test @@ -208,8 +201,8 @@ class ActivityDetailViewModelTest : BaseUnitTest() { val activitiesChangedFlow = MutableStateFlow(System.currentTimeMillis()) whenever(activityRepo.activitiesChanged).thenReturn(activitiesChangedFlow) - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(activity)) - whenever(activityRepo.getActivityTags(ACTIVITY_ID)).thenReturn(Result.success(emptyList())) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(activity)) + whenever(activityRepo.getActivityTags(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(emptyList())) // Load activity sut.loadActivity(ACTIVITY_ID) @@ -232,25 +225,29 @@ class ActivityDetailViewModelTest : BaseUnitTest() { val activitiesChangedFlow = MutableStateFlow(System.currentTimeMillis()) whenever(activityRepo.activitiesChanged).thenReturn(activitiesChangedFlow) - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(activity)) - whenever(activityRepo.getActivityTags(ACTIVITY_ID)).thenReturn(Result.success(emptyList())) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(activity)) + whenever(activityRepo.getActivityTags(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(emptyList())) // Load activity sut.loadActivity(ACTIVITY_ID) // Simulate reload failure - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.failure(Exception("Network error"))) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())) + .thenReturn(Result.failure(Exception("Network error"))) activitiesChangedFlow.value += 1 // Verify last known state is preserved val state = sut.uiState.value.activityLoadState assertTrue(state is ActivityDetailViewModel.ActivityLoadState.Success) - assertEquals(activity, state.activity) + assertTrue(state.activity is Activity.Onchain) + assertEquals(activity.v1.id, state.activity.v1.id) + assertEquals(activity.v1.confirmed, state.activity.v1.confirmed) } @Test fun `loadActivity handles error gracefully`() = test { - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.failure(Exception("Database error"))) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())) + .thenReturn(Result.failure(Exception("Database error"))) sut.loadActivity(ACTIVITY_ID) diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 286fcdd677..2f110d03c7 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -15,7 +15,6 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat -import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -29,6 +28,7 @@ import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.ext.create import to.bitkit.ext.createChannelDetails import to.bitkit.ext.mock +import to.bitkit.models.ActivityWalletType import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -37,6 +37,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime +import com.synonym.bitkitcore.TransactionDetails as BitkitCoreTransactionDetails @Suppress("LargeClass") @OptIn(ExperimentalTime::class) @@ -62,6 +63,7 @@ class ActivityRepoTest : BaseUnitTest() { private val testActivity = mock { on { v1 } doReturn testActivityV1 } + private val hardwareWalletId = ActivityWalletType.TREZOR.scopedId("dev1") private val baseOnchainActivity = OnchainActivity.create( id = "base_activity_id", @@ -159,7 +161,7 @@ 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.getActivity(any(), anyOrNull()) }.thenReturn(null) wheneverBlocking { coreService.activity.syncLdkNodePaymentsToActivities( any>(), @@ -196,6 +198,7 @@ class ActivityRepoTest : BaseUnitTest() { wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = any(), txType = any(), tags = any(), @@ -254,7 +257,7 @@ class ActivityRepoTest : BaseUnitTest() { @Test fun `getActivity returns activity when found`() = test { val activityId = "activity123" - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) + wheneverBlocking { coreService.activity.getActivity(activityId, null) }.thenReturn(testActivity) val result = sut.getActivity(activityId) @@ -263,85 +266,78 @@ class ActivityRepoTest : BaseUnitTest() { } @Test - fun `syncHardwareOnchainActivity confirms existing transfer and preserves metadata`() = test { - val existing = createOnchainActivity( - id = "transfer-txid", - txId = "transfer-txid", - value = 50_000uL, - fee = 0uL, - feeRate = 2uL, - address = "bc1qlsp", - confirmed = false, - timestamp = 1_000uL, - isTransfer = true, - channelId = "channel-1", - isBoosted = true, - boostTxIds = listOf("boost-txid"), - contact = "contact", - ).v1 - val watcher = OnchainActivity.create( - id = "transfer-txid", - txType = PaymentType.SENT, - txId = "transfer-txid", - value = 49_000uL, - fee = 1_250uL, - address = "", - timestamp = 2_000uL, - confirmed = true, - ) - whenever(coreService.activity.getOnchainActivityByTxId("transfer-txid")).thenReturn(existing) + fun `getActivity passes wallet id to core lookup`() = test { + val activityId = "activity123" + wheneverBlocking { coreService.activity.getActivity(activityId, hardwareWalletId) }.thenReturn(testActivity) - val result = sut.syncHardwareOnchainActivity(watcher) + val result = sut.getActivity(activityId, hardwareWalletId) assertTrue(result.isSuccess) - val captor = argumentCaptor() - verify(coreService.activity).update(eq("transfer-txid"), captor.capture()) - val updated = (captor.firstValue as Activity.Onchain).v1 - assertTrue(updated.confirmed) - assertEquals(2_000uL, updated.confirmTimestamp) - assertEquals(true, updated.doesExist) - assertEquals(50_000uL, updated.value) - assertEquals(1_250uL, updated.fee) - assertEquals(2uL, updated.feeRate) - assertEquals("bc1qlsp", updated.address) - assertEquals(true, updated.isTransfer) - assertEquals("channel-1", updated.channelId) - assertEquals(true, updated.isBoosted) - assertEquals(listOf("boost-txid"), updated.boostTxIds) - assertEquals("contact", updated.contact) - } - - @Test - fun `syncHardwareOnchainActivity ignores hardware tx that is not in main activities`() = test { - val watcher = OnchainActivity.create( - id = "hardware-only-txid", - txType = PaymentType.RECEIVED, - txId = "hardware-only-txid", - value = 10_000uL, - fee = 0uL, - address = "", - timestamp = 2_000uL, - confirmed = true, + assertEquals(testActivity, result.getOrThrow()) + verify(coreService.activity).getActivity(activityId, hardwareWalletId) + } + + @Test + fun `persistHardwareActivities upserts activities and transaction details`() = test { + val activity = Activity.Onchain( + OnchainActivity.create( + id = "hw-txid", + txType = PaymentType.RECEIVED, + txId = "hw-txid", + value = 10_000uL, + fee = 0uL, + address = "", + timestamp = 2_000uL, + confirmed = true, + walletId = hardwareWalletId, + ) + ) + val details = BitkitCoreTransactionDetails( + walletId = hardwareWalletId, + txId = "hw-txid", + amountSats = 10_000L, + inputs = emptyList(), + outputs = emptyList(), ) - whenever(coreService.activity.getOnchainActivityByTxId("hardware-only-txid")).thenReturn(null) + wheneverBlocking { coreService.activity.upsertList(listOf(activity)) }.thenReturn(Unit) + wheneverBlocking { coreService.activity.upsertTransactionDetailsList(listOf(details)) }.thenReturn(Unit) - val result = sut.syncHardwareOnchainActivity(watcher) + val result = sut.persistHardwareActivities(listOf(activity), listOf(details)) assertTrue(result.isSuccess) - verify(coreService.activity, never()).update(any(), any()) - verify(coreService.activity, never()).insert(any()) - verify(coreService.activity, never()).upsert(any()) + verify(coreService.activity).upsertList(listOf(activity)) + verify(coreService.activity).upsertTransactionDetailsList(listOf(details)) + } + + @Test + fun `persistHardwareActivities does nothing when both lists are empty`() = test { + val result = sut.persistHardwareActivities(emptyList(), emptyList()) + + assertTrue(result.isSuccess) + verify(coreService.activity, never()).upsertList(any()) + verify(coreService.activity, never()).upsertTransactionDetailsList(any()) + } + + @Test + fun `deleteActivitiesForWallet delegates to core delete by wallet id`() = test { + wheneverBlocking { coreService.activity.deleteByWalletId(hardwareWalletId) }.thenReturn(3u) + + val result = sut.deleteActivitiesForWallet(hardwareWalletId) + + assertTrue(result.isSuccess) + verify(coreService.activity).deleteByWalletId(hardwareWalletId) } @Test fun `getActivity returns null when not found`() = test { val activityId = "activity123" - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(null) + wheneverBlocking { coreService.activity.getActivity(activityId, null) }.thenReturn(null) val result = sut.getActivity(activityId) assertTrue(result.isSuccess) assertNull(result.getOrThrow()) + verify(coreService.activity, never()).get(walletId = null) } @Test @@ -496,7 +492,7 @@ class ActivityRepoTest : BaseUnitTest() { // Verify tags are added to the new activity verify(coreService.activity).appendTags(activityId, tagsMock) // Verify delete is NOT called - verify(coreService.activity, never()).delete(any()) + verify(coreService.activity, never()).delete(any(), anyOrNull()) // Verify addActivityToDeletedList is NOT called verify(cacheStore, never()).addActivityToDeletedList(any()) } @@ -629,7 +625,7 @@ class ActivityRepoTest : BaseUnitTest() { val result = sut.addTagsToActivity(activityId, duplicateTags) assertTrue(result.isSuccess) - verify(coreService.activity, never()).appendTags(any(), any()) + verify(coreService.activity, never()).appendTags(any(), any(), anyOrNull()) } @Test @@ -771,6 +767,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -823,6 +820,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -875,6 +873,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -926,6 +925,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -978,6 +978,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 50cf626b1d..ec285d83c9 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -3,18 +3,16 @@ package to.bitkit.repositories import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ComposeResult -import com.synonym.bitkitcore.HistoryTransaction +import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorSignedTx -import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WalletBalance import com.synonym.bitkitcore.WatcherEvent import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import org.junit.Before @@ -32,6 +30,8 @@ import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.env.Env +import to.bitkit.ext.create +import to.bitkit.models.ActivityWalletType import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.KnownDevice @@ -42,10 +42,9 @@ import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertTrue -import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime -import kotlin.time.Instant +import com.synonym.bitkitcore.TransactionDetails as BitkitCoreTransactionDetails @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) @Suppress("LargeClass") @@ -55,12 +54,12 @@ class HwWalletRepoTest : BaseUnitTest() { private val activityRepo = mock() private val hwWalletStore = mock() private val settingsStore = mock() - private val clock = mock() private lateinit var storeData: MutableStateFlow private lateinit var settingsData: MutableStateFlow private lateinit var trezorState: MutableStateFlow private lateinit var watcherEvents: MutableSharedFlow> + private val trezorWalletId = ActivityWalletType.TREZOR.scopedId("dev1") private val device = KnownDevice( id = "dev1", @@ -71,6 +70,7 @@ class HwWalletRepoTest : BaseUnitTest() { model = "Safe 5", lastConnectedAt = 0L, xpubs = mapOf("nativeSegwit" to "zpubNS"), + walletId = trezorWalletId, ) @Before @@ -83,10 +83,12 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(settingsStore.data).thenReturn(settingsData) whenever(trezorRepo.state).thenReturn(trezorState) whenever(trezorRepo.watcherEvents).thenReturn(watcherEvents) - runBlocking { - whenever(activityRepo.syncHardwareOnchainActivity(any())).thenReturn(Result.success(Unit)) - } - whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(1_700_000_000)) + whenever(trezorRepo.deriveWalletId(any())).thenReturn(trezorWalletId) + whenever { + trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), anyOrNull(), any()) + }.thenReturn(Result.success(Unit)) + whenever { activityRepo.persistHardwareActivities(any(), any()) }.thenReturn(Result.success(Unit)) + whenever { activityRepo.deleteActivitiesForWallet(any()) }.thenReturn(Result.success(Unit)) } private fun createRepo() = HwWalletRepo( @@ -94,7 +96,6 @@ class HwWalletRepoTest : BaseUnitTest() { activityRepo = activityRepo, hwWalletStore = hwWalletStore, settingsStore = settingsStore, - clock = clock, ioDispatcher = testDispatcher, ) @@ -139,16 +140,15 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `transactions changed event sets device balance and maps activity`() = test { + fun `transactions changed event sets balance, exposes activities and persists them`() = test { val sut = createRepo() + val activity = onchainActivity(txid = "t1", amount = 10_562_411uL) watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 10_562_411uL), - transactions = listOf(receivedTransaction(amount = 10_562_411uL)), + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(activity), + balanceTotal = 10_562_411uL, txCount = 1u, - blockHeight = 850_000u, - accountType = AccountType.NATIVE_SEGWIT, ) ) @@ -156,143 +156,97 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(10_562_411uL, wallet.balanceSats) assertEquals(10_562_411uL, sut.totalSats.value) assertEquals(1, wallet.activities.size) - assertEquals(1, sut.activities.value.size) - assertEquals(Activity.Onchain::class, wallet.activities.single()::class) - verify(activityRepo).syncHardwareOnchainActivity((wallet.activities.single() as Activity.Onchain).v1) + assertEquals("t1", (wallet.activities.single() as Activity.Onchain).v1.txId) + verify(activityRepo).persistHardwareActivities(listOf(activity), emptyList()) } @Test - fun `balances from multiple address-type watchers are summed per device`() = test { + fun `transactions changed event from inactive watcher is ignored`() = test { val sut = createRepo() + val activity = onchainActivity(txid = "t1", amount = 10_562_411uL) watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) - ) - watcherEvents.emit( - "dev1|taproot" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 50uL), - transactions = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.TAPROOT, + "random|nativeSegwit" to transactionsChanged( + activities = listOf(activity), + balanceTotal = 10_562_411uL, + txCount = 1u, ) ) - val wallet = sut.wallets.value.single() - assertEquals(150uL, wallet.balanceSats) - assertEquals(100uL, wallet.fundingBalanceSats) - assertEquals(150uL, sut.totalSats.value) + assertEquals(0uL, sut.totalSats.value) + verify(activityRepo, never()).persistHardwareActivities(listOf(activity), emptyList()) } @Test - fun `merges duplicate tx activities from multiple address-type watchers`() = test { + fun `transactions changed event is not exposed when persistence fails`() = test { + val activity = onchainActivity(txid = "t1", amount = 10_562_411uL) + whenever { activityRepo.persistHardwareActivities(listOf(activity), emptyList()) } + .thenReturn(Result.failure(AppError("persist failed"))) val sut = createRepo() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = listOf(receivedTransaction(amount = 100uL).copy(txid = "shared")), - txCount = 1u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) - ) - watcherEvents.emit( - "dev1|taproot" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 50uL), - transactions = listOf(receivedTransaction(amount = 50uL).copy(txid = "shared")), + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(activity), + balanceTotal = 10_562_411uL, txCount = 1u, - blockHeight = 1u, - accountType = AccountType.TAPROOT, ) ) - val activity = sut.wallets.value.single().activities.single() as Activity.Onchain - assertEquals(PaymentType.RECEIVED, activity.v1.txType) - assertEquals(150uL, activity.v1.value) - assertEquals(150uL, sut.wallets.value.single().balanceSats) + assertEquals(0uL, sut.totalSats.value) + assertEquals(emptyList(), sut.wallets.value.single().activities) } @Test - fun `merges duplicate tx activities across hardware wallets`() = test { - val secondDevice = device.copy( - id = "dev2", - path = "ble:CC:DD", - lastConnectedAt = 1L, - xpubs = mapOf("nativeSegwit" to "zpubNS2"), + fun `balances from multiple address-type watchers are summed per device`() = test { + storeData.value = HwWalletData( + knownDevices = listOf(device.copy(xpubs = mapOf("nativeSegwit" to "zpubNS", "taproot" to "zpubTR"))) ) - storeData.value = HwWalletData(knownDevices = listOf(device, secondDevice)) - wheneverStartWatcher().thenReturn(Result.success(Unit)) + settingsData.value = SettingsData(addressTypesToMonitor = listOf("nativeSegwit", "taproot")) val sut = createRepo() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = listOf(receivedTransaction(amount = 100uL).copy(txid = "shared")), - txCount = 1u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) + "dev1|nativeSegwit" to transactionsChanged(balanceTotal = 100uL, accountType = AccountType.NATIVE_SEGWIT) ) watcherEvents.emit( - "dev2|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 50uL), - transactions = listOf(receivedTransaction(amount = 50uL).copy(txid = "shared")), - txCount = 1u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) + "dev1|taproot" to transactionsChanged(balanceTotal = 50uL, accountType = AccountType.TAPROOT) ) - val activity = sut.activities.value.single() as Activity.Onchain - assertEquals(2, sut.wallets.value.size) - assertEquals(PaymentType.RECEIVED, activity.v1.txType) - assertEquals(150uL, activity.v1.value) + val wallet = sut.wallets.value.single() + assertEquals(150uL, wallet.balanceSats) + assertEquals(100uL, wallet.fundingBalanceSats) + assertEquals(150uL, sut.totalSats.value) } @Test - fun `preserves generated timestamp for pending tx refreshes`() = test { - whenever(clock.now()) - .thenReturn(Instant.fromEpochSeconds(1_800_000_000)) - .thenReturn(Instant.fromEpochSeconds(1_800_000_060)) - val sut = createRepo() - val pendingTx = receivedTransaction(amount = 100uL).copy( - txid = "pending", - blockHeight = null, - timestamp = null, - confirmations = 0u, + fun `merges duplicate tx activities from multiple address-type watchers`() = test { + storeData.value = HwWalletData( + knownDevices = listOf(device.copy(xpubs = mapOf("nativeSegwit" to "zpubNS", "taproot" to "zpubTR"))) ) + settingsData.value = SettingsData(addressTypesToMonitor = listOf("nativeSegwit", "taproot")) + val sut = createRepo() + val native = onchainActivity(txid = "shared", amount = 100uL) + val taproot = onchainActivity(txid = "shared", amount = 50uL) watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = listOf(pendingTx), - txCount = 1u, - blockHeight = 1u, + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(native), + balanceTotal = 100uL, accountType = AccountType.NATIVE_SEGWIT, ) ) - val firstTimestamp = (sut.wallets.value.single().activities.single() as Activity.Onchain).v1.timestamp - watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = listOf(pendingTx), - txCount = 1u, - blockHeight = 2u, - accountType = AccountType.NATIVE_SEGWIT, + "dev1|taproot" to transactionsChanged( + activities = listOf(taproot), + balanceTotal = 50uL, + accountType = AccountType.TAPROOT, ) ) - val refreshedTimestamp = (sut.wallets.value.single().activities.single() as Activity.Onchain).v1.timestamp - assertEquals(1_800_000_000uL, firstTimestamp) - assertEquals(firstTimestamp, refreshedTimestamp) + val activity = sut.wallets.value.single().activities.single() as Activity.Onchain + assertEquals(PaymentType.RECEIVED, activity.v1.txType) + assertEquals("shared", activity.v1.txId) + assertEquals(150uL, activity.v1.value) + assertEquals(150uL, sut.wallets.value.single().balanceSats) } @Test @@ -313,9 +267,12 @@ class HwWalletRepoTest : BaseUnitTest() { createRepo() - verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) - verify(trezorRepo).startWatcher(eq("dev1|taproot"), any(), any(), any(), anyOrNull(), any()) - verify(trezorRepo, never()).startWatcher(eq("dev1|legacy"), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), anyOrNull(), any()) + verify(trezorRepo).startWatcher(eq("dev1|taproot"), any(), any(), any(), anyOrNull(), anyOrNull(), any()) + verify( + trezorRepo, + never() + ).startWatcher(eq("dev1|legacy"), any(), any(), any(), anyOrNull(), anyOrNull(), any()) } @Test @@ -328,9 +285,10 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo).startWatcher( watcherId = eq("dev1|nativeSegwit"), + walletId = any(), extendedKey = eq("zpubNS"), network = eq(Env.network.toCoreNetwork()), - gapLimit = any(), + gapLimit = anyOrNull(), accountType = anyOrNull(), electrumUrl = eq(electrumServer), ) @@ -353,9 +311,10 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo).stopWatcher("dev1|nativeSegwit") verify(trezorRepo).startWatcher( watcherId = eq("dev1|nativeSegwit"), + walletId = any(), extendedKey = eq("zpubNS"), network = eq(Env.network.toCoreNetwork()), - gapLimit = any(), + gapLimit = anyOrNull(), accountType = anyOrNull(), electrumUrl = eq(secondServer), ) @@ -367,12 +326,20 @@ class HwWalletRepoTest : BaseUnitTest() { createRepo() - verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), anyOrNull(), any()) advanceTimeBy(30.seconds) runCurrent() - verify(trezorRepo, times(2)).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo, times(2)).startWatcher( + watcherId = eq("dev1|nativeSegwit"), + walletId = any(), + extendedKey = any(), + network = any(), + gapLimit = anyOrNull(), + accountType = anyOrNull(), + electrumUrl = any(), + ) } @Test @@ -383,42 +350,36 @@ class HwWalletRepoTest : BaseUnitTest() { // Baseline: full history delivered on watcher start must not emit. watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = listOf(receivedTransaction(amount = 100uL)), + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(onchainActivity(txid = "t1", amount = 100uL)), + balanceTotal = 100uL, txCount = 1u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, ) ) assertEquals(0, received.size) // New inbound tx after the baseline emits once. watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 150uL), - transactions = listOf( - receivedTransaction(amount = 100uL), - receivedTransaction(amount = 50uL).copy(txid = "t2"), + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf( + onchainActivity(txid = "t1", amount = 100uL), + onchainActivity(txid = "t2", amount = 50uL), ), + balanceTotal = 150uL, txCount = 2u, - blockHeight = 2u, - accountType = AccountType.NATIVE_SEGWIT, ) ) - assertEquals(listOf(HwWalletReceivedTx(txid = "t2", sats = 50uL)), received) + assertEquals(listOf(HwWalletReceivedTx(txid = "t2", sats = 50uL, walletId = trezorWalletId)), received) // Re-delivering the same set (e.g. confirmation update) must not emit again. watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 150uL), - transactions = listOf( - receivedTransaction(amount = 100uL), - receivedTransaction(amount = 50uL).copy(txid = "t2"), + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf( + onchainActivity(txid = "t1", amount = 100uL), + onchainActivity(txid = "t2", amount = 50uL), ), + balanceTotal = 150uL, txCount = 2u, - blockHeight = 3u, - accountType = AccountType.NATIVE_SEGWIT, ) ) assertEquals(1, received.size) @@ -428,49 +389,37 @@ class HwWalletRepoTest : BaseUnitTest() { @Test fun `emits received tx once when multiple watchers report the same new tx`() = test { + storeData.value = HwWalletData( + knownDevices = listOf(device.copy(xpubs = mapOf("nativeSegwit" to "zpubNS", "taproot" to "zpubTR"))) + ) + settingsData.value = SettingsData(addressTypesToMonitor = listOf("nativeSegwit", "taproot")) val sut = createRepo() val received = mutableListOf() val job = launch { sut.receivedTxs.collect { received += it } } watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 0uL), - transactions = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) + "dev1|nativeSegwit" to transactionsChanged(balanceTotal = 0uL, accountType = AccountType.NATIVE_SEGWIT) ) watcherEvents.emit( - "dev1|taproot" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 0uL), - transactions = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.TAPROOT, - ) + "dev1|taproot" to transactionsChanged(balanceTotal = 0uL, accountType = AccountType.TAPROOT) ) watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = listOf(receivedTransaction(amount = 100uL).copy(txid = "shared")), - txCount = 1u, - blockHeight = 2u, + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(onchainActivity(txid = "shared", amount = 100uL)), + balanceTotal = 100uL, accountType = AccountType.NATIVE_SEGWIT, ) ) watcherEvents.emit( - "dev1|taproot" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 50uL), - transactions = listOf(receivedTransaction(amount = 50uL).copy(txid = "shared")), - txCount = 1u, - blockHeight = 2u, + "dev1|taproot" to transactionsChanged( + activities = listOf(onchainActivity(txid = "shared", amount = 100uL)), + balanceTotal = 50uL, accountType = AccountType.TAPROOT, ) ) - assertEquals(listOf(HwWalletReceivedTx(txid = "shared", sats = 100uL)), received) + assertEquals(listOf(HwWalletReceivedTx(txid = "shared", sats = 100uL, walletId = trezorWalletId)), received) job.cancel() } @@ -481,23 +430,13 @@ class HwWalletRepoTest : BaseUnitTest() { val job = launch { sut.receivedTxs.collect { received += it } } watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) + "dev1|nativeSegwit" to transactionsChanged(balanceTotal = 100uL) ) watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 40uL), - transactions = listOf( - receivedTransaction(amount = 60uL).copy(txid = "t3", direction = TxDirection.SENT), - ), + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(onchainActivity(txid = "t3", amount = 60uL, txType = PaymentType.SENT)), + balanceTotal = 40uL, txCount = 1u, - blockHeight = 2u, - accountType = AccountType.NATIVE_SEGWIT, ) ) @@ -514,16 +453,22 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() - verify(trezorRepo).startWatcher(eq("ble1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) - verify(trezorRepo, never()).startWatcher(eq("usb1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo).startWatcher(eq("ble1|nativeSegwit"), any(), any(), any(), anyOrNull(), anyOrNull(), any()) + verify(trezorRepo, never()).startWatcher( + watcherId = eq("usb1|nativeSegwit"), + walletId = any(), + extendedKey = any(), + network = any(), + gapLimit = anyOrNull(), + accountType = anyOrNull(), + electrumUrl = any(), + ) watcherEvents.emit( - "ble1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 421_900uL), - transactions = listOf(receivedTransaction(amount = 421_900uL)), + "ble1|nativeSegwit" to transactionsChanged( + activities = listOf(onchainActivity(txid = "t1", amount = 421_900uL)), + balanceTotal = 421_900uL, txCount = 1u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, ) ) @@ -565,13 +510,7 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) + "dev1|nativeSegwit" to transactionsChanged(balanceTotal = 100uL) ) // Stop fails: the watcher data must survive so the balance is not silently wrong. @@ -594,13 +533,7 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) + "dev1|nativeSegwit" to transactionsChanged(balanceTotal = 100uL) ) sut.resetState() @@ -611,7 +544,7 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `removeDevice stops the device watchers and forgets it`() = test { + fun `removeDevice stops the device watchers, forgets it and purges its activities`() = test { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device), emptyList()) wheneverStartWatcher().thenReturn(Result.success(Unit)) whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) @@ -624,6 +557,23 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isSuccess) verify(trezorRepo).stopWatcher("dev1|nativeSegwit") verify(trezorRepo).forgetDevice("dev1") + verify(activityRepo).deleteActivitiesForWallet(trezorWalletId) + } + + @Test + fun `removeDevice fails when activity purge fails`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device), emptyList()) + whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) + whenever { trezorRepo.forgetDevice(any()) }.thenReturn(Result.success(Unit)) + whenever { activityRepo.deleteActivitiesForWallet(trezorWalletId) } + .thenReturn(Result.failure(AppError("purge failed"))) + val sut = createRepo() + runCurrent() + + val result = sut.removeDevice("dev1") + + assertEquals(true, result.isFailure) + verify(activityRepo).deleteActivitiesForWallet(trezorWalletId) } @Test @@ -698,9 +648,33 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isFailure) verify(trezorRepo, times(2)).startWatcher( watcherId = eq("dev1|nativeSegwit"), + walletId = any(), extendedKey = any(), network = any(), - gapLimit = any(), + gapLimit = anyOrNull(), + accountType = anyOrNull(), + electrumUrl = any(), + ) + } + + @Test + fun `restarts active watchers when wallet id changes`() = test { + val newWalletId = ActivityWalletType.TREZOR.scopedId("new-wallet") + whenever(trezorRepo.stopWatcher(any())).thenReturn(Result.success(Unit)) + val sut = createRepo() + runCurrent() + + storeData.value = HwWalletData(knownDevices = listOf(device.copy(walletId = newWalletId))) + runCurrent() + + assertEquals(0uL, sut.totalSats.value) + verify(trezorRepo).stopWatcher("dev1|nativeSegwit") + verify(trezorRepo).startWatcher( + watcherId = eq("dev1|nativeSegwit"), + walletId = eq(newWalletId), + extendedKey = eq("zpubNS"), + network = eq(Env.network.toCoreNetwork()), + gapLimit = anyOrNull(), accountType = anyOrNull(), electrumUrl = any(), ) @@ -858,36 +832,15 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo).startWatcher( watcherId = any(), + walletId = any(), extendedKey = any(), network = eq(Env.network.toCoreNetwork()), - gapLimit = any(), + gapLimit = anyOrNull(), accountType = anyOrNull(), electrumUrl = any(), ) } - private fun walletBalance(total: ULong) = WalletBalance( - confirmed = total, - immature = 0uL, - trustedPending = 0uL, - untrustedPending = 0uL, - spendable = total, - total = total, - ) - - private fun receivedTransaction(amount: ULong) = HistoryTransaction( - txid = "t1", - received = amount, - sent = 0uL, - net = amount.toLong(), - fee = null, - amount = amount, - direction = TxDirection.RECEIVED, - blockHeight = 850_000u, - timestamp = 1_700_000_000uL, - confirmations = 3u, - ) - @Test fun `scan delegates to trezorRepo`() = test { whenever(trezorRepo.scan(includeBluetooth = false)).thenReturn(Result.success(emptyList())) @@ -966,6 +919,51 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals("My Cold Wallet", sut.wallets.value.single().name) } + private fun walletBalance(total: ULong) = WalletBalance( + confirmed = total, + immature = 0uL, + trustedPending = 0uL, + untrustedPending = 0uL, + spendable = total, + total = total, + ) + + private fun onchainActivity( + txid: String, + amount: ULong, + txType: PaymentType = PaymentType.RECEIVED, + walletId: String = trezorWalletId, + ): Activity = Activity.Onchain( + OnchainActivity.create( + id = txid, + txType = txType, + txId = txid, + value = amount, + fee = 0uL, + address = "", + timestamp = 1_700_000_000uL, + confirmed = true, + walletId = walletId, + ) + ) + + @Suppress("LongParameterList") + private fun transactionsChanged( + activities: List = emptyList(), + transactionDetails: List = emptyList(), + balanceTotal: ULong = 0uL, + txCount: UInt = activities.size.toUInt(), + blockHeight: UInt = 1u, + accountType: AccountType = AccountType.NATIVE_SEGWIT, + ) = WatcherEvent.TransactionsChanged( + activities = activities, + transactionDetails = transactionDetails, + balance = walletBalance(balanceTotal), + txCount = txCount, + blockHeight = blockHeight, + accountType = accountType, + ) + private suspend fun wheneverStartWatcher() = whenever( trezorRepo.startWatcher( any(), @@ -973,6 +971,7 @@ class HwWalletRepoTest : BaseUnitTest() { any(), any(), anyOrNull(), + anyOrNull(), any(), ) ) diff --git a/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt index 139405fab3..286a249bf9 100644 --- a/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt @@ -12,6 +12,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.kotlin.wheneverBlocking +import to.bitkit.models.ActivityWalletType import to.bitkit.services.ActivityService import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest @@ -36,6 +37,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { private var timestampCounter = 0L private val testMetadata = PreActivityMetadata( + walletId = ActivityWalletType.BITKIT.id, paymentId = "payment-123", createdAt = 1234567890uL, tags = listOf("tag1", "tag2"), diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index 039f52c708..3c0bc8427a 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -643,6 +643,7 @@ class TransferRepoTest : BaseUnitTest() { anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), anyOrNull() ) ) diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 9c0dc0f612..6aa63c12a2 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -35,6 +35,7 @@ import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.env.Env +import to.bitkit.models.ActivityWalletType import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.toCoreNetwork @@ -43,7 +44,6 @@ import to.bitkit.services.TrezorTransport import to.bitkit.services.TrezorUiHandler import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError -import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse @@ -95,13 +95,16 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorTransport.transportRestored).thenReturn(MutableSharedFlow()) whenever(trezorTransport.hasUsbPermission(any())).thenReturn(true) whenever(trezorTransport.disconnectDevice(any())).thenReturn( - TrezorTransportWriteResult(success = true, error = "") + TrezorTransportWriteResult(success = true, error = "", errorCode = null) ) whenever(trezorUiHandler.needsPinEntry).thenReturn(MutableStateFlow(false)) whenever(trezorUiHandler.currentSelection()).thenReturn(WalletSelection.Standard) whenever(settingsStore.data).thenReturn(settingsData) whenever(context.filesDir).thenReturn(tempFolder.root) whenever { hwWalletStore.loadKnownDevices() }.thenReturn(emptyList()) + whenever(trezorService.deriveWalletId(any(), any())).thenAnswer { + walletIdFor(*it.getArgument>(1).toTypedArray()) + } } private fun createSut(): TrezorRepo = TrezorRepo( @@ -115,6 +118,9 @@ class TrezorRepoTest : BaseUnitTest() { ioDispatcher = testDispatcher, ) + private fun walletIdFor(vararg xpubs: String): String = + ActivityWalletType.TREZOR.scopedId(xpubs.sorted().joinToString("|")) + @Suppress("LongParameterList") private fun mockDeviceInfo( id: String = DEVICE_ID, @@ -195,8 +201,8 @@ class TrezorRepoTest : BaseUnitTest() { } @Test - fun `initialize assigns wallet ids to restored devices missing them`() = test { - val knownDevice = mockKnownDevice(walletId = "") + fun `initialize derives wallet ids from xpubs for restored devices missing them`() = test { + val knownDevice = mockKnownDevice(walletId = "", xpubs = mapOf("nativeSegwit" to "zpubNS")) whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) sut = createSut() @@ -207,10 +213,25 @@ class TrezorRepoTest : BaseUnitTest() { verify(hwWalletStore).saveKnownDevices(savedCaptor.capture()) val saved = savedCaptor.firstValue.single() assertEquals(knownDevice.id, saved.id) - assertNotNull(UUID.fromString(saved.walletId)) + assertTrue(ActivityWalletType.TREZOR.owns(saved.walletId)) + assertEquals(walletIdFor("zpubNS"), saved.walletId) + verify(trezorService).deriveWalletId(ActivityWalletType.TREZOR.id, listOf("zpubNS")) assertEquals(listOf(saved), sut.state.value.knownDevices) } + @Test + fun `initialize leaves wallet id blank for restored devices without xpubs`() = test { + val knownDevice = mockKnownDevice(walletId = "", xpubs = emptyMap()) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + sut = createSut() + + val result = sut.initialize() + + assertTrue(result.isSuccess) + assertEquals("", sut.state.value.knownDevices.single().walletId) + verify(hwWalletStore, never()).saveKnownDevices(any()) + } + @Test fun `initialize should reuse completed setup`() = test { sut = createSut() @@ -578,7 +599,41 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(TransportType.USB, saved.transportType) assertEquals("Savings", saved.label) assertEquals("Safe 5", saved.model) - assertNotNull(UUID.fromString(saved.walletId)) + assertEquals("", saved.walletId) + } + + @Test + fun `connect derives a deterministic wallet id from captured xpubs`() = test { + val nativeSegwitPath = "m/84'/1'/0'" + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever( + trezorService.getPublicKey( + path = any(), + coin = anyOrNull(), + showOnTrezor = eq(false), + ) + ).thenAnswer { + val path = it.getArgument(0) + if (path == nativeSegwitPath) { + mockPublicKeyResponse(xpub = "captured-native-xpub", path = nativeSegwitPath) + } else { + throw AppError("xpub failed") + } + } + sut = createSut() + + sut.scan() + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture()) + val saved = captor.firstValue.single() + assertEquals(walletIdFor("captured-native-xpub"), saved.walletId) + verify(trezorService).deriveWalletId(ActivityWalletType.TREZOR.id, listOf("captured-native-xpub")) } @Test @@ -622,6 +677,47 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(setOf(walletId), captor.firstValue.map { it.walletId }.toSet()) } + @Test + fun `connect derives new wallet id when same device id has different xpub identity`() = test { + val oldWalletId = walletIdFor("old-native-xpub") + val newWalletId = walletIdFor("new-native-xpub") + val nativeSegwitPath = "m/84'/1'/0'" + val previousDevice = mockKnownDevice( + id = DEVICE_ID, + path = DEVICE_PATH, + xpubs = mapOf("nativeSegwit" to "old-native-xpub"), + walletId = oldWalletId, + ) + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(previousDevice)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever( + trezorService.getPublicKey( + path = any(), + coin = anyOrNull(), + showOnTrezor = eq(false), + ) + ).thenAnswer { + val path = it.getArgument(0) + if (path == nativeSegwitPath) { + mockPublicKeyResponse(xpub = "new-native-xpub", path = nativeSegwitPath) + } else { + throw AppError("xpub failed") + } + } + sut = createSut() + + sut.scan() + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture()) + assertEquals(newWalletId, captor.firstValue.single().walletId) + } + @Test fun `connect preserves stored xpubs when account xpub refresh is partial`() = test { val previousXpubs = mapOf( diff --git a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt index 66d1a9d944..0d06a5de27 100644 --- a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt @@ -361,7 +361,7 @@ class TrezorViewModelTest : BaseUnitTest() { @Test fun `startWatcher should not expose active watcher until start completes`() = test { val startResult = CompletableDeferred>() - whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), any(), anyOrNull(), any())) .doSuspendableAnswer { startResult.await() } sut.setWatcherExtendedKey("xpub6test123") @@ -391,13 +391,13 @@ class TrezorViewModelTest : BaseUnitTest() { sut.startWatcher() advanceUntilIdle() - verify(trezorRepo, never()).startWatcher(any(), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo, never()).startWatcher(any(), any(), any(), any(), any(), anyOrNull(), any()) assertNull(sut.uiState.value.activeWatcherId) } @Test fun `watcher transaction event should mark watcher connected`() = test { - whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), any(), anyOrNull(), any())) .thenReturn(Result.success(Unit)) sut.setWatcherExtendedKey("xpub6test123") sut.startWatcher() @@ -406,7 +406,8 @@ class TrezorViewModelTest : BaseUnitTest() { watcherEventsFlow.emit( watcherId to WatcherEvent.TransactionsChanged( - transactions = TrezorPreviewData.sampleHistoryTransactions, + activities = emptyList(), + transactionDetails = emptyList(), balance = TrezorPreviewData.sampleWalletBalance, txCount = 3u, blockHeight = 850_000u, @@ -424,7 +425,7 @@ class TrezorViewModelTest : BaseUnitTest() { @Test fun `watcher event should be handled while start is in flight`() = test { val startResult = CompletableDeferred>() - whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), any(), anyOrNull(), any())) .doSuspendableAnswer { startResult.await() } sut.setWatcherExtendedKey("xpub6test123") sut.startWatcher() @@ -433,7 +434,8 @@ class TrezorViewModelTest : BaseUnitTest() { watcherEventsFlow.emit( watcherId to WatcherEvent.TransactionsChanged( - transactions = TrezorPreviewData.sampleHistoryTransactions, + activities = emptyList(), + transactionDetails = emptyList(), balance = TrezorPreviewData.sampleWalletBalance, txCount = 3u, blockHeight = 850_000u, @@ -458,7 +460,7 @@ class TrezorViewModelTest : BaseUnitTest() { @Test fun `stopWatcher should stop repo watcher and clear watcher state`() = test { - whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), any(), anyOrNull(), any())) .thenReturn(Result.success(Unit)) whenever(trezorRepo.stopWatcher(any())).thenReturn(Result.success(Unit)) sut.setWatcherExtendedKey("xpub6test123") @@ -474,7 +476,7 @@ class TrezorViewModelTest : BaseUnitTest() { assertNull(state.activeWatcherId) assertEquals(WatcherConnectionStatus.IDLE, state.watcherConnectionStatus) assertNull(state.watcherBalance) - assertTrue(state.watcherTransactions.isEmpty()) + assertEquals(0u, state.watcherTransactionCount) } @Test diff --git a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt index 8b9e64ff12..f460860934 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.sheets import android.content.Context +import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.FeeRates @@ -110,6 +111,19 @@ class BoostTransactionViewModelTest : BaseUnitTest() { } } + private suspend fun setupActivity(activity: Activity.Onchain) { + whenever(activityRepo.getActivity(activity.v1.id, activity.v1.walletId)).thenReturn(Result.success(activity)) + sut.setupActivity(activity.v1.id, activity.v1.walletId) + } + + private suspend fun ReceiveTurbine.awaitLoadedState(): BoostTransactionUiState { + var state = awaitItem() + while (state.loading) { + state = awaitItem() + } + return state + } + @Test fun `setupActivity should set loading state initially`() = runTest { whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(feeRate)) @@ -118,7 +132,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { sut.uiState.test { awaitItem() // initial state - sut.setupActivity(activitySent) + setupActivity(activitySent) val loadingState = awaitItem() assertTrue(loadingState.loading) @@ -132,7 +146,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(totalFee)) - sut.setupActivity(activitySent) + setupActivity(activitySent) verify(lightningRepo).getFeeRateForSpeed(eq(TransactionSpeed.Fast), anyOrNull()) verify(lightningRepo).calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) @@ -145,7 +159,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(lightningRepo.calculateCpfpFeeRate(eq(mockTxId))) .thenReturn(Result.success(feeRate)) - sut.setupActivity(receivedActivity) + setupActivity(receivedActivity) verify(lightningRepo).calculateCpfpFeeRate(eq(mockTxId)) verify(lightningRepo, never()).getFeeRateForSpeed(any(), anyOrNull()) @@ -176,7 +190,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(totalFee)) - sut.setupActivity(activitySent) + setupActivity(activitySent) sut.boostTransactionEffect.test { sut.onChangeAmount(increase = true) @@ -190,7 +204,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(totalFee)) - sut.setupActivity(activitySent) + setupActivity(activitySent) sut.boostTransactionEffect.test { sut.onChangeAmount(increase = false) @@ -203,7 +217,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.failure(Exception("error"))) sut.boostTransactionEffect.test { - sut.setupActivity(activitySent) + setupActivity(activitySent) assertEquals(BoostTransactionEffects.OnBoostFailed, awaitItem()) } } @@ -225,7 +239,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(activityRepo.updateActivity(any(), any(), any())).thenReturn(Result.success(Unit)) - sut.setupActivity(receivedActivity) + setupActivity(receivedActivity) sut.boostTransactionEffect.test { sut.onConfirmBoost() @@ -248,9 +262,8 @@ class BoostTransactionViewModelTest : BaseUnitTest() { sut.uiState.test { awaitItem() - sut.setupActivity(activitySent) - awaitItem() - val state = awaitItem() + setupActivity(activitySent) + val state = awaitLoadedState() assertEquals(fastFeeTime, state.estimateTime) } } @@ -263,9 +276,8 @@ class BoostTransactionViewModelTest : BaseUnitTest() { sut.uiState.test { awaitItem() - sut.setupActivity(activitySent) - awaitItem() - val state = awaitItem() + setupActivity(activitySent) + val state = awaitLoadedState() assertEquals(normalFeeTime, state.estimateTime) } } @@ -279,9 +291,8 @@ class BoostTransactionViewModelTest : BaseUnitTest() { sut.uiState.test { awaitItem() // initial state - sut.setupActivity(lowFeeActivity) - awaitItem() // loading state - val state = awaitItem() + setupActivity(lowFeeActivity) + val state = awaitLoadedState() assertEquals(flowFeeTime, state.estimateTime) } } @@ -295,9 +306,8 @@ class BoostTransactionViewModelTest : BaseUnitTest() { sut.uiState.test { awaitItem() // initial state - sut.setupActivity(lowFeeActivity) - awaitItem() // loading state - val state = awaitItem() + setupActivity(lowFeeActivity) + val state = awaitLoadedState() assertEquals(minFeeTime, state.estimateTime) } } diff --git a/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt index 526b3825ae..170718c8ae 100644 --- a/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt @@ -3,8 +3,6 @@ package to.bitkit.viewmodels import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -12,14 +10,16 @@ import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import to.bitkit.data.SettingsStore import to.bitkit.ext.create import to.bitkit.ext.rawId +import to.bitkit.ext.scopedId +import to.bitkit.models.ActivityWalletType import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.ActivityState -import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest import to.bitkit.ui.screens.wallets.activity.components.ActivityTab @@ -29,34 +29,37 @@ import kotlin.test.assertEquals class ActivityListViewModelTest : BaseUnitTest() { private val activityRepo = mock() - private val hwWalletRepo = mock() private val pubkyRepo = mock() private val settingsStore = mock() private val dbActivity = onchainActivity(id = "db1", txType = PaymentType.SENT, timestamp = 200uL) - private val hwActivity = onchainActivity(id = "hw1", txType = PaymentType.RECEIVED, timestamp = 100uL) - private lateinit var hardwareActivities: MutableStateFlow> + + private val hwActivity = onchainActivity( + id = "hw1", + txType = PaymentType.RECEIVED, + timestamp = 100uL, + walletId = ActivityWalletType.TREZOR.scopedId("dev1"), + ) @Before fun setUp() { - hardwareActivities = MutableStateFlow(persistentListOf(hwActivity)) whenever(activityRepo.state).thenReturn(MutableStateFlow(ActivityState())) whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(0L)) whenever { activityRepo.syncActivities() }.thenReturn(Result.success(Unit)) whenever { activityRepo.getTxIdsInBoostTxIds() }.thenReturn(emptySet()) whenever { activityRepo.getActivities( - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), + walletId = anyOrNull(), + filter = anyOrNull(), + txType = anyOrNull(), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = anyOrNull(), + sortDirection = anyOrNull(), ) - }.thenReturn(Result.success(listOf(dbActivity))) - whenever(hwWalletRepo.activities).thenReturn(hardwareActivities) + }.thenReturn(Result.success(listOf(dbActivity, hwActivity))) whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(emptyList())) whenever(settingsStore.isPaykitEnabled).thenReturn(MutableStateFlow(false)) } @@ -64,13 +67,12 @@ class ActivityListViewModelTest : BaseUnitTest() { private fun createViewModel() = ActivityListViewModel( bgDispatcher = testDispatcher, activityRepo = activityRepo, - hwWalletRepo = hwWalletRepo, pubkyRepo = pubkyRepo, settingsStore = settingsStore, ) @Test - fun `filtered activities merge hardware activities newest first`() = test { + fun `filtered activities include hardware activities newest first`() = test { val sut = createViewModel() advanceUntilIdle() @@ -79,6 +81,19 @@ class ActivityListViewModelTest : BaseUnitTest() { @Test fun `filtered activities exclude hardware activities not matching the tab`() = test { + whenever { + activityRepo.getActivities( + walletId = anyOrNull(), + filter = anyOrNull(), + txType = eq(PaymentType.SENT), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = anyOrNull(), + sortDirection = anyOrNull(), + ) + }.thenReturn(Result.success(listOf(dbActivity))) val sut = createViewModel() sut.setTab(ActivityTab.SENT) advanceUntilIdle() @@ -87,40 +102,43 @@ class ActivityListViewModelTest : BaseUnitTest() { } @Test - fun `filtered activities exclude hardware activities when a tag filter is active`() = test { + fun `hardware activity is included under an active tag filter`() = test { + whenever { + activityRepo.getActivities( + walletId = anyOrNull(), + filter = anyOrNull(), + txType = anyOrNull(), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = anyOrNull(), + sortDirection = anyOrNull(), + ) + }.thenReturn(Result.success(listOf(hwActivity))) val sut = createViewModel() sut.toggleTag("tag1") advanceUntilIdle() - assertEquals(listOf("db1"), sut.filteredActivities.value?.map { it.rawId() }) + assertEquals(listOf("hw1"), sut.filteredActivities.value?.map { it.rawId() }) } @Test - fun `hardware ids expose the hardware activity ids`() = test { + fun `hardware ids expose the ids of activities scoped to a hardware wallet`() = test { val sut = createViewModel() val job = launch { sut.hardwareIds.collect {} } advanceUntilIdle() - assertEquals(setOf("hw1"), sut.hardwareIds.value) - job.cancel() - } - - @Test - fun `hardware duplicates of local activities are excluded`() = test { - hardwareActivities.value = persistentListOf( - hwActivity, - onchainActivity(id = "db1", txType = PaymentType.RECEIVED, timestamp = 300uL), - ) - val sut = createViewModel() - val job = launch { sut.hardwareIds.collect {} } - advanceUntilIdle() - - assertEquals(listOf("db1", "hw1"), sut.filteredActivities.value?.map { it.rawId() }) - assertEquals(setOf("hw1"), sut.hardwareIds.value) + assertEquals(setOf(hwActivity.scopedId()), sut.hardwareIds.value) job.cancel() } - private fun onchainActivity(id: String, txType: PaymentType, timestamp: ULong) = Activity.Onchain( + private fun onchainActivity( + id: String, + txType: PaymentType, + timestamp: ULong, + walletId: String = ActivityWalletType.BITKIT.id, + ) = Activity.Onchain( OnchainActivity.create( id = id, txType = txType, @@ -130,6 +148,7 @@ class ActivityListViewModelTest : BaseUnitTest() { address = "bc1", timestamp = timestamp, confirmed = true, + walletId = walletId, ) ) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index c4df1976d1..ce88009be7 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -38,6 +38,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceivedHandler +import to.bitkit.models.ActivityWalletType import to.bitkit.models.BalanceState import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.PubkyProfile @@ -279,16 +280,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `hardware received tx details navigate directly to hardware activity`() = test { val txId = "hardware-tx" + val walletId = ActivityWalletType.TREZOR.scopedId("dev1") sut.mainScreenEffect.test { advanceUntilIdle() - hwReceivedTxs.emit(HwWalletReceivedTx(txid = txId, sats = 21uL)) + hwReceivedTxs.emit(HwWalletReceivedTx(txid = txId, sats = 21uL, walletId = walletId)) advanceUntilIdle() assertEquals(txId, sut.transactionSheet.value.activityId) + assertEquals(walletId, sut.transactionSheet.value.activityWalletId) sut.onClickActivityDetail() - assertEquals(MainScreenEffect.Navigate(Routes.ActivityDetail(txId)), awaitItem()) + assertEquals(MainScreenEffect.Navigate(Routes.ActivityDetail(txId, walletId)), awaitItem()) } verify(activityRepo, never()).findActivityByPaymentId(any(), any(), any(), any()) } diff --git a/changelog.d/next/1029.changed.md b/changelog.d/next/1029.changed.md new file mode 100644 index 0000000000..fcadac60aa --- /dev/null +++ b/changelog.d/next/1029.changed.md @@ -0,0 +1 @@ +Hardware wallet transactions are now first-class activity entries: they can be tagged, show their input and output details, and appear in the activity list under tag and tab filters alongside your normal Bitkit transactions. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a80d35b4d6..e939233749 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.75" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.3.9" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc8" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index 12d6411d6e..1580198963 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -60,7 +60,8 @@ Remove step forgets the device. | Journey | Covers | | - | - | | `connect-home-tile.xml` | Dev-screen connect, home tile, indicator, balance, detail screen opens | -| `activity-blue-icons.xml` | Hardware activity merge, blue icons, All Activity filters, current watch-only detail fallback | +| `activity-blue-icons.xml` | Hardware activity in the unified list, blue icons, All Activity tab filters | +| `activity-detail-hw-tags.xml` | Hardware activity detail tags (persist + survive tag filter) and Explore inputs/outputs | | `usb-reconnect.xml` | Disconnect indicator, injected USB attach intent → silent auto-reconnect; physical-device chooser path noted separately | | `suggestion-intro-sheet.xml` | Forget device, Hardware suggestion card, full connect flow (Intro → Searching → Found → Paired → Finish) re-pairs | | `connect-flow.xml` | Settings Add button → connect flow with an edited Label Funds → paired device count + name | diff --git a/journeys/hardware-wallet/activity-blue-icons.xml b/journeys/hardware-wallet/activity-blue-icons.xml index 1b91a4d0c0..34b0032ab8 100644 --- a/journeys/hardware-wallet/activity-blue-icons.xml +++ b/journeys/hardware-wallet/activity-blue-icons.xml @@ -1,11 +1,11 @@ - Verifies hardware wallet on-chain activity merged into the home list and the All - Activity screen with blue icon variants, filter behavior, and the current watch-only - activity detail fallback until Core-backed hardware activity support lands. Requires a - paired Bridge emulator whose wallet has at least one on-chain transaction (run - connect-home-tile.xml first; fund per README.md if the - deterministic wallet has no history). + Verifies hardware wallet on-chain activity in the home list and the All Activity screen + with blue icon variants and tab filter behavior. Hardware activities are now first-class + Bitkit Core activities (persisted by the watcher), so they appear in the unified list and + survive tab and tag filters like normal transactions. Requires a paired Bridge emulator + whose wallet has at least one on-chain transaction (run connect-home-tile.xml first; fund + per README.md if the deterministic wallet has no history). @@ -33,7 +33,7 @@ Tap the "Received" tab and verify blue-icon items with received arrows are listed, assuming the hardware wallet has incoming transactions - Apply any tag filter if a tag exists, and verify blue-icon hardware items disappear from the filtered list; skip this step if no tags exist + Tap back to the "All" tab and verify the blue-icon hardware items are listed again diff --git a/journeys/hardware-wallet/activity-detail-hw-tags.xml b/journeys/hardware-wallet/activity-detail-hw-tags.xml new file mode 100644 index 0000000000..c457a124cf --- /dev/null +++ b/journeys/hardware-wallet/activity-detail-hw-tags.xml @@ -0,0 +1,40 @@ + + + Verifies that a hardware-wallet transaction behaves as a first-class Bitkit Core activity: + its detail screen supports tags (which persist and keep the item visible under a tag + filter) and its Explore screen shows the transaction inputs and outputs fetched from the + configured Electrum backend. Requires a paired Bridge emulator whose wallet has at least + one on-chain transaction (run connect-home-tile.xml first; fund per README.md if the + deterministic wallet has no history). Use a hardware seed distinct from the Bitkit wallet + seed so the transaction resolves as a hardware (blue-icon) activity, not a local one. + + + + Launch the Bitkit app and go to the wallet home screen + + + Tap the first activity item with a blue (hardware) circular icon + + + Verify an activity detail screen opens showing a blue icon and an on-chain amount + + + Tap "Add Tag", enter the tag "hwtest" and confirm it + + + Verify a tag chip labelled "hwtest" is shown on the activity detail screen + + + Navigate back to the home screen, then tap the same blue-icon activity again and verify the "hwtest" tag is still shown (it persisted to Bitkit Core) + + + Tap "Explore", verify the Activity Explorer screen opens and shows an "Inputs" section and an "Outputs" section each listing at least one entry + + + Navigate back to the home screen, then tap "Show All" beneath the activity list + + + Open the tag filter, select the "hwtest" tag, and verify the blue-icon hardware activity remains listed in the filtered results + + + diff --git a/journeys/hardware-wallet/detail-overview.xml b/journeys/hardware-wallet/detail-overview.xml index 8d13bda3c6..3b14b0c90b 100644 --- a/journeys/hardware-wallet/detail-overview.xml +++ b/journeys/hardware-wallet/detail-overview.xml @@ -17,7 +17,7 @@ Verify the hardware wallet detail screen opens (testTag "HardwareWalletScreen"), showing the device name with a blue bitcoin icon in the top bar and a balance header - If a "Transfer To Spending" button is shown (testTag "HardwareTransferToSpending"), tap it, verify the transfer amount screen opens (testTag "HardwareTransferAmount") titled "TRANSFER TO SPENDING", then navigate back to the hardware wallet detail screen; otherwise skip this step + If a "Transfer To Spending" button is shown (testTag "HardwareTransferToSpending"), tap it; if the first-run Transfer To Spending intro appears, tap "Get Started"; verify the transfer amount screen opens (testTag "HardwareTransferAmount") titled "TRANSFER TO SPENDING", then navigate back to the hardware wallet detail screen; otherwise skip this step If the activity list shows transactions, verify their circular icons are blue, then tap the first one, verify an activity detail screen opens, and navigate back diff --git a/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml b/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml index a36e8485d6..05fbaedebf 100644 --- a/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml +++ b/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml @@ -19,6 +19,9 @@ Tap the "Transfer To Spending" button (testTag "HardwareTransferToSpending") + + If the first-run Transfer To Spending intro appears, tap "Get Started" + Verify the transfer amount screen opens (testTag "HardwareTransferAmount"), titled "TRANSFER TO SPENDING", and the AVAILABLE amount is lower than the hardware wallet balance because it is capped by LSP headroom diff --git a/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml b/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml index 518131da38..8466f6640d 100644 --- a/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml +++ b/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml @@ -21,6 +21,9 @@ Verify the transfer amount screen or loading/progress UI appears, and no reconnect, node-not-ready, or generic failure toast is shown while the node warms up + + If the first-run Transfer To Spending intro appears, tap "Get Started" + Tap the "25%" quick button (testTag "HardwareTransferAmountQuarter") if the amount screen is shown diff --git a/journeys/hardware-wallet/transfer-to-spending.xml b/journeys/hardware-wallet/transfer-to-spending.xml index 4c628a7b4b..3d28b2567b 100644 --- a/journeys/hardware-wallet/transfer-to-spending.xml +++ b/journeys/hardware-wallet/transfer-to-spending.xml @@ -18,6 +18,9 @@ Tap the "Transfer To Spending" button (testTag "HardwareTransferToSpending") + + If the first-run Transfer To Spending intro appears, tap "Get Started" + Verify the transfer amount screen opens (testTag "HardwareTransferAmount"), titled "TRANSFER TO SPENDING", showing an AVAILABLE row, the 25% and MAX quick buttons, and a number pad