From b9abef850145036c75dea796ecee9e1ada88bf2a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 22:38:14 +0200 Subject: [PATCH 1/8] feat: scope core activities by wallet id --- app/src/main/java/to/bitkit/ext/Activities.kt | 10 ++ .../to/bitkit/repositories/ActivityRepo.kt | 79 ++++++++----- .../to/bitkit/repositories/LightningRepo.kt | 2 + .../repositories/PreActivityMetadataRepo.kt | 2 + .../java/to/bitkit/repositories/WalletRepo.kt | 2 + .../java/to/bitkit/services/CoreService.kt | 105 ++++++++++++----- .../to/bitkit/services/MigrationService.kt | 7 +- .../bitkit/services/TrezorBridgeTransport.kt | 48 ++++++-- .../to/bitkit/services/TrezorTransport.kt | 106 ++++++++++-------- .../ui/screens/trezor/TrezorPreviewData.kt | 7 +- .../ui/screens/trezor/TrezorViewModel.kt | 8 +- .../ui/screens/trezor/WatcherSection.kt | 48 -------- gradle/libs.versions.toml | 2 +- 13 files changed, 255 insertions(+), 171 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index 8b99d5ff79..c914e7061b 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -5,12 +5,18 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.getDefaultWalletId 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.txType(): PaymentType = when (this) { is Activity.Lightning -> v1.txType is Activity.Onchain -> v1.txType @@ -107,7 +113,9 @@ fun LightningActivity.Companion.create( createdAt: ULong? = timestamp, updatedAt: ULong? = createdAt, seenAt: ULong? = null, + walletId: String = getDefaultWalletId(), ) = LightningActivity( + walletId = walletId, id = id, txType = txType, status = status, @@ -145,7 +153,9 @@ fun OnchainActivity.Companion.create( createdAt: ULong? = timestamp, updatedAt: ULong? = createdAt, seenAt: ULong? = null, + walletId: String = getDefaultWalletId(), ) = OnchainActivity( + walletId = walletId, id = id, txType = txType, txId = txId, diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 7dfb8f4d10..5c9f061f1d 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -12,6 +12,7 @@ import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection +import com.synonym.bitkitcore.getDefaultWalletId import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -213,23 +214,31 @@ class ActivityRepo @Inject constructor( notifyActivitiesChanged() } - suspend fun syncHardwareOnchainActivity(activity: OnchainActivity): Result = withContext(bgDispatcher) { + /** + * Persists the wallet-scoped activities and transaction details a hardware-wallet watcher + * emits, so hardware transactions become first-class Bitkit Core activities (tags, inputs/outputs). + */ + 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) + } + } + + /** Removes all activity, details and tag metadata scoped to a hardware wallet's id. */ + 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) } } @@ -271,8 +280,11 @@ class ActivityRepo @Inject constructor( 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 +339,7 @@ class ActivityRepo @Inject constructor( } suspend fun getActivities( + walletId: String? = null, filter: ActivityFilter? = null, txType: PaymentType? = null, tags: List? = null, @@ -337,7 +350,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:" + @@ -357,7 +370,10 @@ class ActivityRepo @Inject constructor( suspend fun getActivity(id: String): Result = withContext(bgDispatcher) { runCatching { + // Resolve the local wallet first (indexed), then fall back to scanning all wallets so + // hardware-wallet activities (scoped to their own walletId) also resolve by id. coreService.activity.getActivity(id) + ?: coreService.activity.get(walletId = null).firstOrNull { it.rawId() == id } }.onFailure { Logger.error("getActivity error for ID: $id", it, context = TAG) } @@ -654,6 +670,7 @@ class ActivityRepo @Inject constructor( insertActivity( Activity.Lightning( LightningActivity( + walletId = getDefaultWalletId(), id = id, txType = PaymentType.RECEIVED, status = PaymentState.SUCCEEDED, @@ -684,15 +701,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 +746,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 +768,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/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index dc4f99a7e4..80d906877a 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -10,6 +10,7 @@ import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.createChannelRequestUrl import com.synonym.bitkitcore.createWithdrawCallbackUrl +import com.synonym.bitkitcore.getDefaultWalletId import com.synonym.bitkitcore.lnurlAuth import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -1172,6 +1173,7 @@ class LightningRepo @Inject constructor( val txId = lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount) val preActivityMetadata = PreActivityMetadata( + walletId = getDefaultWalletId(), 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..f7b112d539 100644 --- a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt @@ -1,6 +1,7 @@ package to.bitkit.repositories import com.synonym.bitkitcore.PreActivityMetadata +import com.synonym.bitkitcore.getDefaultWalletId import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -132,6 +133,7 @@ class PreActivityMetadataRepo @Inject constructor( require(tags.isNotEmpty() || isTransfer) val preActivityMetadata = PreActivityMetadata( + walletId = getDefaultWalletId(), paymentId = id, createdAt = nowTimestamp().toEpochMilli().toULong(), tags = tags, diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 6c53356f5f..e1389b2e17 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -6,6 +6,7 @@ import com.synonym.bitkitcore.LegacyRnCloseRecoveryScanResult import com.synonym.bitkitcore.LegacyRnCloseRecoverySweepPreview import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.Scanner +import com.synonym.bitkitcore.getDefaultWalletId import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -236,6 +237,7 @@ class WalletRepo @Inject constructor( }.getOrNull() val preActivityMetadata = PreActivityMetadata( + walletId = getDefaultWalletId(), 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 6333f14803..dc6bb4ea2f 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -30,6 +30,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 @@ -39,6 +40,7 @@ import com.synonym.bitkitcore.getActivityByTxId import com.synonym.bitkitcore.getAllClosedChannels import com.synonym.bitkitcore.getAllUniqueTags import com.synonym.bitkitcore.getCjitEntries +import com.synonym.bitkitcore.getDefaultWalletId import com.synonym.bitkitcore.getInfo import com.synonym.bitkitcore.getOrders import com.synonym.bitkitcore.getTags @@ -84,6 +86,7 @@ import to.bitkit.ext.amountSats import to.bitkit.ext.channelId import to.bitkit.ext.create import to.bitkit.ext.latestSpendingTxid +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.ALL_ADDRESS_TYPES import to.bitkit.models.DEFAULT_ADDRESS_TYPE import to.bitkit.models.addressTypeFromAddress @@ -232,10 +235,14 @@ class ActivityService( private val settingsStore: SettingsStore, private val privatePaykitContactResolver: Provider, ) { + /** Wallet id for the local Bitkit wallet; hardware wallets pass their own derived id. */ + private val defaultWalletId: String by lazy { getDefaultWalletId() } + 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, @@ -246,15 +253,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) } @@ -267,9 +277,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( @@ -290,6 +305,7 @@ class ActivityService( ) } return BitkitCoreTransactionDetails( + walletId = walletId, txId = txid, amountSats = details.amountSats, inputs = inputs, @@ -297,16 +313,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 { @@ -319,6 +341,7 @@ class ActivityService( @Suppress("LongParameterList") suspend fun get( + walletId: String? = null, filter: ActivityFilter? = null, txType: PaymentType? = null, tags: List? = null, @@ -328,30 +351,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() @@ -378,26 +410,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 { @@ -500,7 +544,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 && @@ -540,7 +584,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)) @@ -880,7 +924,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) @@ -1380,7 +1424,7 @@ class ActivityService( } suspend fun isActivitySeen(activityId: String): Boolean = ServiceQueue.CORE.background { - val activity = getActivityById(activityId) ?: return@background false + val activity = getActivityById(defaultWalletId, activityId) ?: return@background false return@background when (activity) { is Activity.Lightning -> activity.v1.seenAt != null is Activity.Onchain -> activity.v1.seenAt != null @@ -1388,7 +1432,7 @@ class ActivityService( } suspend fun markActivityAsSeen(activityId: String, seenAt: ULong? = null) = ServiceQueue.CORE.background { - val activity = getActivityById(activityId) ?: run { + val activity = getActivityById(defaultWalletId, activityId) ?: run { Logger.warn("Cannot mark activity as seen - activity not found: $activityId", context = TAG) return@background } @@ -1416,6 +1460,7 @@ class ActivityService( suspend fun markAllUnseenActivitiesAsSeen() = ServiceQueue.CORE.background { val timestamp = (System.currentTimeMillis() / 1000).toULong() val activities = getActivities( + walletId = null, filter = ActivityFilter.ALL, txType = null, tags = null, diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index ba2b1ae635..d293cd6abc 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -14,6 +14,7 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.getDefaultWalletId import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -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 = getDefaultWalletId(), activityId = onchain.id, tags = tagList) } else { val activity = activityRepo.getActivity(activityId).getOrNull() if (activity != null) { applied++ - ActivityTags(activityId = activityId, tags = tagList) + ActivityTags(walletId = getDefaultWalletId(), 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 = getDefaultWalletId(), 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 = getDefaultWalletId(), 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/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/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..492fc7e47f 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 @@ -68,7 +67,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 +712,7 @@ class TrezorViewModel @Inject constructor( } val result = trezorRepo.startWatcher( watcherId = watcherId, + walletId = watcherId, extendedKey = key, network = state.selectedNetwork, gapLimit = gapLimit, @@ -776,7 +775,6 @@ class TrezorViewModel @Inject constructor( activeWatcherId = null, connectionStatus = WatcherConnectionStatus.IDLE, balance = null, - transactions = persistentListOf(), transactionCount = 0u, blockHeight = 0u, accountType = null, @@ -956,9 +954,6 @@ data class TrezorUiState( val watcherBalance: WalletBalance? get() = watcher.balance - val watcherTransactions: ImmutableList - get() = watcher.transactions - val watcherTransactionCount: UInt get() = watcher.transactionCount @@ -1035,7 +1030,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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 40ca00380e..89cd1164aa 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.73" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.3.4" } 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" } From 5069736f01ee15bfc6362c1203a888198794370b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 22:38:20 +0200 Subject: [PATCH 2/8] feat: derive hardware wallet id from xpubs --- .../java/to/bitkit/repositories/TrezorRepo.kt | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 1bb77cf30b..82314219af 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -24,6 +24,8 @@ import com.synonym.bitkitcore.WalletParams import com.synonym.bitkitcore.WalletSelection import com.synonym.bitkitcore.WatcherEvent import com.synonym.bitkitcore.WatcherParams +import com.synonym.bitkitcore.deriveWalletId +import com.synonym.bitkitcore.getDefaultGapLimit import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -69,7 +71,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 +738,10 @@ class TrezorRepo @Inject constructor( suspend fun startWatcher( watcherId: String, + walletId: String, extendedKey: String, network: BitkitCoreNetwork, - gapLimit: UInt = 20u, + gapLimit: UInt = getDefaultGapLimit(), accountType: AccountType? = null, electrumUrl: String = electrumUrlForNetwork(network), ): Result = withContext(ioDispatcher) { @@ -747,6 +749,7 @@ class TrezorRepo @Inject constructor( awaitSetup() val params = WatcherParams( watcherId = watcherId, + walletId = walletId, extendedKey = extendedKey, electrumUrl = electrumUrl, network = network, @@ -1090,7 +1093,7 @@ private fun List.findHardwareWalletId(deviceId: String, xpubs: Map< val walletKey = walletKey(xpubs, deviceId) return firstOrNull { it.id == deviceId }?.walletId?.takeIf { it.isNotBlank() } ?: firstOrNull { it.walletKey == walletKey }?.walletId?.takeIf { it.isNotBlank() } - ?: newHardwareWalletId() + ?: deriveHardwareWalletId(xpubs) } private fun List.withHardwareWalletIds(): List { @@ -1100,12 +1103,22 @@ private fun List.withHardwareWalletIds(): List { return map { val walletId = existingByWallet[it.walletKey] - ?: generatedByWallet.getOrPut(it.walletKey) { newHardwareWalletId() } + ?: generatedByWallet.getOrPut(it.walletKey) { deriveHardwareWalletId(it.xpubs) } if (it.walletId == walletId) it else it.copy(walletId = walletId) } } -private fun newHardwareWalletId(): String = UUID.randomUUID().toString() +/** + * Stable, cross-platform wallet id derived from the device's account xpubs via Bitkit Core, so the + * same physical device produces the same id on every platform without a backup. Blank until xpubs + * are captured (Core rejects empty xpubs); the id is filled in once they are. + */ +private fun deriveHardwareWalletId(xpubs: Map): String { + val keys = xpubs.values.filter { it.isNotBlank() } + return if (keys.isEmpty()) "" else deriveWalletId(HW_WALLET_DEVICE_TYPE, keys) +} + +private const val HW_WALLET_DEVICE_TYPE = "trezor" private fun KnownDevice.toDeviceInfo() = TrezorDeviceInfo( id = id, From ffa293a05c433742c814dca5632ac1e477826858 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 22:38:20 +0200 Subject: [PATCH 3/8] feat: persist hardware activities to core --- .../to/bitkit/repositories/HwWalletRepo.kt | 117 ++++-------------- .../wallets/activity/ActivityDetailScreen.kt | 10 +- .../viewmodels/ActivityDetailViewModel.kt | 61 +++------ .../viewmodels/ActivityListViewModel.kt | 81 +++--------- 4 files changed, 61 insertions(+), 208 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index f4992921c0..f525c29af9 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,7 +34,6 @@ 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.models.HwFundingAccount @@ -48,7 +44,6 @@ import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType -import to.bitkit.models.safe import to.bitkit.models.toAccountType import to.bitkit.models.toAddressType import to.bitkit.models.toCoreNetwork @@ -58,9 +53,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 +64,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 { @@ -278,6 +269,11 @@ 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" } + + // Drop the removed wallet's hardware activity/details/tags from Bitkit Core so the + // activity database does not grow for unpaired devices; re-pairing rebuilds from the watcher. + val walletIdToPurge = target?.walletId?.takeIf { it.isNotBlank() } + if (walletIdToPurge != null) activityRepo.deleteActivitiesForWallet(walletIdToPurge) }.onFailure { watcherSyncRequests.tryEmit(Unit) } @@ -327,21 +323,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() @@ -352,21 +333,17 @@ class HwWalletRepo @Inject constructor( trezorRepo.watcherEvents.collect { (watcherId, event) -> if (event !is WatcherEvent.TransactionsChanged) 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 = event.activities.toImmutableList(), ) val updatedWatcherData = _watcherData.value + (watcherId to watcher) _watcherData.update { updatedWatcherData } - activities.filterIsInstance().forEach { - activityRepo.syncHardwareOnchainActivity(it.v1) - } + // The watcher emits persistence-ready, wallet-scoped activities + details; store them so + // hardware transactions become first-class Bitkit Core activities (tags, inputs/outputs). + activityRepo.persistHardwareActivities(event.activities, event.transactionDetails) emitReceivedTxs(previous, event, updatedWatcherData) } } @@ -384,15 +361,16 @@ class HwWalletRepo @Inject constructor( if (previous == null) return val knownTxIds = previous.activities.map { it.rawId() }.toSet() val mergedActivities = watcherData.values.toList().toMergedActivities() - event.transactions + event.activities + .filterIsInstance() .filter { - it.direction == TxDirection.RECEIVED && - it.txid !in knownTxIds && - emittedReceivedTxIds.add(it.txid) + it.v1.txType == PaymentType.RECEIVED && + it.v1.id !in knownTxIds && + emittedReceivedTxIds.add(it.v1.txId) } .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)?.v1?.value ?: it.v1.value + _receivedTxs.emit(HwWalletReceivedTx(txid = it.v1.txId, sats = sats)) } } @@ -421,7 +399,7 @@ class HwWalletRepo @Inject constructor( device.xpubs .filterKeys { it in watcherSettings.monitoredTypes } .map { (addressType, xpub) -> - WatcherSpec(device.id, addressType, xpub, watcherSettings.electrumUrl) + WatcherSpec(device.id, device.walletId, addressType, xpub, watcherSettings.electrumUrl) } }.distinctBy { it.addressType to it.xpub } val filteredIds = filtered.map { it.watcherId }.toSet() @@ -433,6 +411,7 @@ class HwWalletRepo @Inject constructor( trezorRepo.startWatcher( watcherId = spec.watcherId, + walletId = spec.walletId, extendedKey = spec.xpub, network = Env.network.toCoreNetwork(), accountType = spec.addressType.toAddressType()?.toAccountType(), @@ -473,66 +452,17 @@ 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 } - .values - .map { transactions -> - val timestamp = transactions.mapNotNull { it.timestamp }.minOrNull() - ?: sourceActivities.findOnchain(transactions.first().txid)?.v1?.timestamp - ?: 0uL - transactions.toOnchainActivity(timestamp, sourceActivities) - } - } - - 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 { - received > sent -> PaymentType.RECEIVED - else -> PaymentType.SENT - } - val value = when (type) { - 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, - value = value, - fee = fee, - address = "", - timestamp = timestamp, - confirmed = confirmations > 0u, - confirmTimestamp = sourceActivity?.v1?.confirmTimestamp, - ) - ) - } + // The watcher already emits persistence-ready activities scoped to the device's walletId; the same + // txid seen under two address-type watchers collapses to one entry (keyed on tx_id), matching Core. + private fun List.toMergedActivities(): List = + flatMap { it.activities }.distinctBy { it.rawId() } private fun List.findOnchain(txid: String) = filterIsInstance() .firstOrNull { it.v1.txId == txid } private data class WatcherSpec( val deviceId: String, + val walletId: String, val addressType: String, val xpub: String, val electrumUrl: String, @@ -577,6 +507,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/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 4ce821aa56..f44a7ee605 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 @@ -50,6 +50,7 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.getDefaultWalletId import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf @@ -65,6 +66,7 @@ import to.bitkit.ext.timestamp import to.bitkit.ext.toActivityItemDate import to.bitkit.ext.toActivityItemTime import to.bitkit.ext.totalValue +import to.bitkit.ext.walletId import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat @@ -178,6 +180,7 @@ fun ActivityDetailScreen( is ActivityDetailViewModel.ActivityLoadState.Success -> { val item = loadState.activity + val isHardware = remember(item) { item.walletId() != getDefaultWalletId() } val app = appViewModel ?: return@Box val settings = settingsViewModel ?: return@Box val hideBalance by settings.hideBalance.collectAsStateWithLifecycle() @@ -246,8 +249,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( @@ -598,7 +601,8 @@ private fun ActivityDetailContent( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { - val showTagAction = !isHardware + // Hardware-wallet activities are first-class Bitkit Core activities, so they support tags too. + val showTagAction = true if (showContactActions || showTagAction) { Row( horizontalArrangement = Arrangement.spacedBy(16.dp), diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index ec8c49ea6f..d2aa9d6e01 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -25,9 +25,9 @@ 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.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 +40,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) @@ -70,7 +69,13 @@ class ActivityDetailViewModel @Inject constructor( loadTags() observeActivityChanges(activityId) } else { - loadHwWalletActivity(activityId) + _uiState.update { + it.copy( + activityLoadState = ActivityLoadState.Error( + context.getString(R.string.wallet__activity_error_not_found) + ) + ) + } } } .onFailure { e -> @@ -89,46 +94,11 @@ 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) { observeJob?.cancel() observeJob = viewModelScope.launch(bgDispatcher) { @@ -157,8 +127,9 @@ class ActivityDetailViewModel @Inject constructor( 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() } } @@ -171,8 +142,9 @@ 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() } @@ -184,8 +156,9 @@ class ActivityDetailViewModel @Inject constructor( 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() @@ -211,8 +184,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 } } @@ -286,6 +260,5 @@ class ActivityDetailViewModel @Inject constructor( data class ActivityDetailUiState( val activityLoadState: ActivityLoadState = ActivityLoadState.Initial, - val isHardwareActivity: Boolean = false, ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index df932631a0..bb6ad22f82 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.getDefaultWalletId import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet @@ -32,10 +33,10 @@ import to.bitkit.ext.isTransfer import to.bitkit.ext.rawId import to.bitkit.ext.timestamp import to.bitkit.ext.txType +import to.bitkit.ext.walletId 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 +47,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() { @@ -59,26 +59,12 @@ class ActivityListViewModel @Inject constructor( private val _onchainActivities = MutableStateFlow?>(null) val onchainActivities = _onchainActivities.asStateFlow() + // Hardware-wallet activities are persisted into Bitkit Core scoped by their walletId, so the + // unified list already includes them; no separate in-memory merge is needed. 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 +77,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 +121,21 @@ 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 localWalletId = getDefaultWalletId() 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.walletId() != localWalletId }.map { it.rawId() }.toImmutableSet() + } _latestActivities.update { filtered.take(SIZE_LATEST).toImmutableList() } _lightningActivities.update { filtered.filterIsInstance().toImmutableList() } _onchainActivities.update { filtered.filterIsInstance().toImmutableList() } From 46bd68a50044dfede80c13f9825569482fb41e46 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 22:42:33 +0200 Subject: [PATCH 4/8] test: add hardware activity tags journey --- journeys/hardware-wallet/README.md | 3 +- .../hardware-wallet/activity-blue-icons.xml | 14 +++---- .../activity-detail-hw-tags.xml | 40 +++++++++++++++++++ 3 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 journeys/hardware-wallet/activity-detail-hw-tags.xml diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index b823c0b83e..252d43138c 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 + + + From 2f097d8a110f54e24ec0e3462d928c37fbc558e0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 22:42:38 +0200 Subject: [PATCH 5/8] docs: add changelog fragment for hw activities --- changelog.d/next/1029.changed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1029.changed.md 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. From f13bfea91a26dd32277391801533cef8a5274465 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 25 Jun 2026 00:05:42 +0200 Subject: [PATCH 6/8] refactor: resolve wallet ids without jni calls --- app/src/main/java/to/bitkit/ext/Activities.kt | 12 +++++++--- .../to/bitkit/repositories/ActivityRepo.kt | 4 ++-- .../to/bitkit/repositories/LightningRepo.kt | 4 ++-- .../repositories/PreActivityMetadataRepo.kt | 4 ++-- .../java/to/bitkit/repositories/TrezorRepo.kt | 22 +++++++++++++------ .../java/to/bitkit/repositories/WalletRepo.kt | 4 ++-- .../java/to/bitkit/services/CoreService.kt | 4 ++-- .../to/bitkit/services/MigrationService.kt | 10 ++++----- .../wallets/activity/ActivityDetailScreen.kt | 4 ++-- .../viewmodels/ActivityListViewModel.kt | 4 ++-- 10 files changed, 43 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index c914e7061b..3e6608e9d1 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -5,7 +5,13 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType -import com.synonym.bitkitcore.getDefaultWalletId + +/** + * Wallet id of the local Bitkit wallet. Mirrors Bitkit Core's `getDefaultWalletId()` (Rust + * `DEFAULT_WALLET_ID`); kept as a plain constant so the value is available without a JNI call. + * Hardware wallets use their own derived id instead. + */ +const val DEFAULT_WALLET_ID = "bitkit" fun Activity.rawId(): String = when (this) { is Activity.Lightning -> v1.id @@ -113,7 +119,7 @@ fun LightningActivity.Companion.create( createdAt: ULong? = timestamp, updatedAt: ULong? = createdAt, seenAt: ULong? = null, - walletId: String = getDefaultWalletId(), + walletId: String = DEFAULT_WALLET_ID, ) = LightningActivity( walletId = walletId, id = id, @@ -153,7 +159,7 @@ fun OnchainActivity.Companion.create( createdAt: ULong? = timestamp, updatedAt: ULong? = createdAt, seenAt: ULong? = null, - walletId: String = getDefaultWalletId(), + walletId: String = DEFAULT_WALLET_ID, ) = OnchainActivity( walletId = walletId, id = id, diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 5c9f061f1d..3a7ec3c333 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -12,7 +12,6 @@ import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection -import com.synonym.bitkitcore.getDefaultWalletId import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -35,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 @@ -670,7 +670,7 @@ class ActivityRepo @Inject constructor( insertActivity( Activity.Lightning( LightningActivity( - walletId = getDefaultWalletId(), + walletId = DEFAULT_WALLET_ID, id = id, txType = PaymentType.RECEIVED, status = PaymentState.SUCCEEDED, diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 80d906877a..4081ea14c5 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -10,7 +10,6 @@ import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.createChannelRequestUrl import com.synonym.bitkitcore.createWithdrawCallbackUrl -import com.synonym.bitkitcore.getDefaultWalletId import com.synonym.bitkitcore.lnurlAuth import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -61,6 +60,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 @@ -1173,7 +1173,7 @@ class LightningRepo @Inject constructor( val txId = lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount) val preActivityMetadata = PreActivityMetadata( - walletId = getDefaultWalletId(), + 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 f7b112d539..e8cbb994c0 100644 --- a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt @@ -1,7 +1,6 @@ package to.bitkit.repositories import com.synonym.bitkitcore.PreActivityMetadata -import com.synonym.bitkitcore.getDefaultWalletId import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -9,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 @@ -133,7 +133,7 @@ class PreActivityMetadataRepo @Inject constructor( require(tags.isNotEmpty() || isTransfer) val preActivityMetadata = PreActivityMetadata( - walletId = getDefaultWalletId(), + 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 82314219af..2ca0a14129 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -24,8 +24,6 @@ import com.synonym.bitkitcore.WalletParams import com.synonym.bitkitcore.WalletSelection import com.synonym.bitkitcore.WatcherEvent import com.synonym.bitkitcore.WatcherParams -import com.synonym.bitkitcore.deriveWalletId -import com.synonym.bitkitcore.getDefaultGapLimit import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -71,6 +69,7 @@ import to.bitkit.services.TrezorWalletMode import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.File +import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock @@ -741,7 +740,7 @@ class TrezorRepo @Inject constructor( walletId: String, extendedKey: String, network: BitkitCoreNetwork, - gapLimit: UInt = getDefaultGapLimit(), + gapLimit: UInt = DEFAULT_GAP_LIMIT, accountType: AccountType? = null, electrumUrl: String = electrumUrlForNetwork(network), ): Result = withContext(ioDispatcher) { @@ -1109,17 +1108,26 @@ private fun List.withHardwareWalletIds(): List { } /** - * Stable, cross-platform wallet id derived from the device's account xpubs via Bitkit Core, so the - * same physical device produces the same id on every platform without a backup. Blank until xpubs - * are captured (Core rejects empty xpubs); the id is filled in once they are. + * Stable, cross-platform wallet id derived from the device's account xpubs, so the same physical + * device produces the same id on every platform without a backup. Blank until xpubs are captured. + * + * Mirrors Bitkit Core's `deriveWalletId` (and iOS): sha256 of the account xpubs sorted and joined + * by "\n", lower-hex, prefixed with the device type. Implemented in pure Kotlin so the deterministic + * id is available without a JNI call and stays unit-testable on the JVM. */ private fun deriveHardwareWalletId(xpubs: Map): String { val keys = xpubs.values.filter { it.isNotBlank() } - return if (keys.isEmpty()) "" else deriveWalletId(HW_WALLET_DEVICE_TYPE, keys) + if (keys.isEmpty()) return "" + val hash = MessageDigest.getInstance("SHA-256") + .digest(keys.sorted().joinToString("\n").toByteArray(Charsets.UTF_8)) + return "$HW_WALLET_DEVICE_TYPE:" + hash.joinToString("") { "%02x".format(it) } } private const val HW_WALLET_DEVICE_TYPE = "trezor" +/** Unused-address scan gap limit for watch-only watchers; mirrors Bitkit Core's `DEFAULT_GAP_LIMIT`. */ +private const val DEFAULT_GAP_LIMIT = 20u + private fun KnownDevice.toDeviceInfo() = TrezorDeviceInfo( id = id, transportType = transportType.toCoreTransportType(), diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index e1389b2e17..74495c89ee 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -6,7 +6,6 @@ import com.synonym.bitkitcore.LegacyRnCloseRecoveryScanResult import com.synonym.bitkitcore.LegacyRnCloseRecoverySweepPreview import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.Scanner -import com.synonym.bitkitcore.getDefaultWalletId import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -29,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 @@ -237,7 +237,7 @@ class WalletRepo @Inject constructor( }.getOrNull() val preActivityMetadata = PreActivityMetadata( - walletId = getDefaultWalletId(), + 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 dc6bb4ea2f..9806d89ec7 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -40,7 +40,6 @@ import com.synonym.bitkitcore.getActivityByTxId import com.synonym.bitkitcore.getAllClosedChannels import com.synonym.bitkitcore.getAllUniqueTags import com.synonym.bitkitcore.getCjitEntries -import com.synonym.bitkitcore.getDefaultWalletId import com.synonym.bitkitcore.getInfo import com.synonym.bitkitcore.getOrders import com.synonym.bitkitcore.getTags @@ -82,6 +81,7 @@ 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 @@ -236,7 +236,7 @@ class ActivityService( private val privatePaykitContactResolver: Provider, ) { /** Wallet id for the local Bitkit wallet; hardware wallets pass their own derived id. */ - private val defaultWalletId: String by lazy { getDefaultWalletId() } + private val defaultWalletId: String = DEFAULT_WALLET_ID suspend fun removeAll() { ServiceQueue.CORE.background { diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index d293cd6abc..6fe30200e4 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -14,7 +14,6 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType -import com.synonym.bitkitcore.getDefaultWalletId import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -47,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 @@ -949,12 +949,12 @@ class MigrationService @Inject constructor( val onchain = activityRepo.getOnchainActivityByTxId(activityId) if (onchain != null) { applied++ - ActivityTags(walletId = getDefaultWalletId(), 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(walletId = getDefaultWalletId(), 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 @@ -1006,7 +1006,7 @@ class MigrationService @Inject constructor( Activity.Lightning( LightningActivity( - walletId = getDefaultWalletId(), + walletId = DEFAULT_WALLET_ID, id = item.id, txType = txType, status = status, @@ -1962,7 +1962,7 @@ class MigrationService @Inject constructor( val activityTimestamp = if (timestampSecs > 0u) timestampSecs else now val newOnchain = OnchainActivity( - walletId = getDefaultWalletId(), + 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/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index f44a7ee605..db0d17145c 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 @@ -50,12 +50,12 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType -import com.synonym.bitkitcore.getDefaultWalletId import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import to.bitkit.R +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.contact import to.bitkit.ext.create import to.bitkit.ext.ellipsisMiddle @@ -180,7 +180,7 @@ fun ActivityDetailScreen( is ActivityDetailViewModel.ActivityLoadState.Success -> { val item = loadState.activity - val isHardware = remember(item) { item.walletId() != getDefaultWalletId() } + val isHardware = remember(item) { item.walletId() != DEFAULT_WALLET_ID } val app = appViewModel ?: return@Box val settings = settingsViewModel ?: return@Box val hideBalance by settings.hideBalance.collectAsStateWithLifecycle() diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index bb6ad22f82..ab8a511c8e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.PaymentType -import com.synonym.bitkitcore.getDefaultWalletId import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet @@ -28,6 +27,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.isReplacedSentTransaction import to.bitkit.ext.isTransfer import to.bitkit.ext.rawId @@ -130,7 +130,7 @@ class ActivityListViewModel @Inject constructor( } private suspend fun refreshActivityState() { - val localWalletId = getDefaultWalletId() + val localWalletId = DEFAULT_WALLET_ID val all = activityRepo.getActivities(filter = ActivityFilter.ALL).getOrNull() ?: emptyList() val filtered = filterOutReplacedSentTransactions(all) _hardwareIds.update { From a60c931a977d92988ad9c7e227173c189c666672 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 25 Jun 2026 00:05:43 +0200 Subject: [PATCH 7/8] test: cover hardware activity core integration --- .../ActivityDetailViewModelTest.kt | 57 +-- .../bitkit/repositories/ActivityRepoTest.kt | 118 +++--- .../bitkit/repositories/HwWalletRepoTest.kt | 367 +++++++----------- .../PreActivityMetadataRepoTest.kt | 1 + .../bitkit/repositories/TransferRepoTest.kt | 1 + .../to/bitkit/repositories/TrezorRepoTest.kt | 63 ++- .../ui/screens/trezor/TrezorViewModelTest.kt | 18 +- .../viewmodels/ActivityListViewModelTest.kt | 96 +++-- 8 files changed, 336 insertions(+), 385 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index d65386de54..4ce0452dda 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -12,10 +12,13 @@ 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 @@ -23,7 +26,6 @@ import to.bitkit.ext.create 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,7 +36,6 @@ class ActivityDetailViewModelTest : BaseUnitTest() { private val activityRepo = mock() private val blocktankRepo = mock() private val settingsStore = mock() - private val hwWalletRepo = mock() private val transferRepo = mock() companion object Fixtures { @@ -48,7 +49,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 +59,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 +75,35 @@ class ActivityDetailViewModelTest : BaseUnitTest() { address = "", timestamp = 1_700_000_000uL, confirmed = true, + walletId = "trezor:dev1", ) ) - whenever { activityRepo.getActivity(ACTIVITY_ID) }.thenReturn(Result.success(null)) - whenever(hwWalletRepo.activities).thenReturn(MutableStateFlow(persistentListOf(hwActivity))) + whenever { activityRepo.getActivity(ACTIVITY_ID) }.thenReturn(Result.success(hwActivity)) + whenever { activityRepo.getActivityTags(ACTIVITY_ID, "trezor:dev1") }.thenReturn(Result.success(emptyList())) + whenever { + activityRepo.addTagsToActivity(ACTIVITY_ID, listOf("tag1"), "trezor:dev1") + }.thenReturn(Result.success(Unit)) + whenever { settingsStore.addLastUsedTag("tag1") }.thenReturn(Unit) sut.loadActivity(ACTIVITY_ID) - - val state = sut.uiState.value - val loadState = state.activityLoadState as ActivityDetailViewModel.ActivityLoadState.Success + val loadState = sut.uiState.value.activityLoadState + assertTrue(loadState is 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) + sut.addTag("tag1") - 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) + verify(activityRepo).addTagsToActivity(ACTIVITY_ID, listOf("tag1"), "trezor:dev1") + verify(activityRepo, atLeastOnce()).getActivityTags(ACTIVITY_ID, "trezor:dev1") } @Test - fun `loadActivity reports not found when missing from database and hardware wallets`() = test { + fun `loadActivity reports not found when missing from the database`() = test { whenever { activityRepo.getActivity(ACTIVITY_ID) }.thenReturn(Result.success(null)) sut.loadActivity(ACTIVITY_ID) val state = sut.uiState.value assertTrue(state.activityLoadState is ActivityDetailViewModel.ActivityLoadState.Error) - assertFalse(state.isHardwareActivity) } @Test @@ -182,7 +165,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { 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.getActivityTags(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(emptyList())) // Load activity sut.loadActivity(ACTIVITY_ID) @@ -209,7 +192,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { 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.getActivityTags(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(emptyList())) // Load activity sut.loadActivity(ACTIVITY_ID) @@ -233,7 +216,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { 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.getActivityTags(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(emptyList())) // Load activity 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..ce7c5fa4d0 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 @@ -37,6 +36,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) @@ -159,7 +159,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 +196,7 @@ class ActivityRepoTest : BaseUnitTest() { wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = any(), txType = any(), tags = any(), @@ -263,80 +264,62 @@ 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, + 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 = "trezor:dev1", + ) + ) + val details = BitkitCoreTransactionDetails( + walletId = "trezor:dev1", + txId = "hw-txid", + amountSats = 10_000L, + inputs = emptyList(), + outputs = emptyList(), ) - whenever(coreService.activity.getOnchainActivityByTxId("transfer-txid")).thenReturn(existing) + 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) - 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, - ) - whenever(coreService.activity.getOnchainActivityByTxId("hardware-only-txid")).thenReturn(null) + 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()) + } - val result = sut.syncHardwareOnchainActivity(watcher) + @Test + fun `deleteActivitiesForWallet delegates to core delete by wallet id`() = test { + wheneverBlocking { coreService.activity.deleteByWalletId("trezor:dev1") }.thenReturn(3u) + + val result = sut.deleteActivitiesForWallet("trezor:dev1") 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).deleteByWalletId("trezor:dev1") } @Test fun `getActivity returns null when not found`() = test { val activityId = "activity123" wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(null) + // getActivity now falls back to scanning all wallets when the indexed lookup misses. + wheneverBlocking { coreService.activity.get(walletId = null) }.thenReturn(emptyList()) val result = sut.getActivity(activityId) @@ -496,7 +479,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 +612,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 +754,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -823,6 +807,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -875,6 +860,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -926,6 +912,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -978,6 +965,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..af48900788 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 @@ -27,11 +25,13 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.HwWalletData 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.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,7 +54,6 @@ 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 @@ -71,6 +69,7 @@ class HwWalletRepoTest : BaseUnitTest() { model = "Safe 5", lastConnectedAt = 0L, xpubs = mapOf("nativeSegwit" to "zpubNS"), + walletId = "trezor:dev1", ) @Before @@ -83,10 +82,8 @@ 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)) + wheneverBlocking { activityRepo.persistHardwareActivities(any(), any()) }.thenReturn(Result.success(Unit)) + wheneverBlocking { activityRepo.deleteActivitiesForWallet(any()) }.thenReturn(Result.success(Unit)) } private fun createRepo() = HwWalletRepo( @@ -94,7 +91,6 @@ class HwWalletRepoTest : BaseUnitTest() { activityRepo = activityRepo, hwWalletStore = hwWalletStore, settingsStore = settingsStore, - clock = clock, ioDispatcher = testDispatcher, ) @@ -139,16 +135,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,9 +151,8 @@ 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 @@ -166,22 +160,10 @@ 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, accountType = AccountType.NATIVE_SEGWIT) ) watcherEvents.emit( - "dev1|taproot" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 50uL), - transactions = emptyList(), - txCount = 0u, - blockHeight = 1u, - accountType = AccountType.TAPROOT, - ) + "dev1|taproot" to transactionsChanged(balanceTotal = 50uL, accountType = AccountType.TAPROOT) ) val wallet = sut.wallets.value.single() @@ -193,108 +175,29 @@ class HwWalletRepoTest : BaseUnitTest() { @Test fun `merges duplicate tx activities from multiple address-type watchers`() = test { val sut = createRepo() + val shared = onchainActivity(txid = "shared", amount = 150uL) watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = listOf(receivedTransaction(amount = 100uL).copy(txid = "shared")), - txCount = 1u, - blockHeight = 1u, + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(shared), + 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 = 1u, + "dev1|taproot" to transactionsChanged( + activities = listOf(shared), + balanceTotal = 50uL, 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("shared", activity.v1.txId) assertEquals(150uL, sut.wallets.value.single().balanceSats) } - @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"), - ) - storeData.value = HwWalletData(knownDevices = listOf(device, secondDevice)) - wheneverStartWatcher().thenReturn(Result.success(Unit)) - 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( - "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, - ) - ) - - 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) - } - - @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, - ) - - watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), - transactions = listOf(pendingTx), - txCount = 1u, - blockHeight = 1u, - 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, - ) - ) - val refreshedTimestamp = (sut.wallets.value.single().activities.single() as Activity.Onchain).v1.timestamp - - assertEquals(1_800_000_000uL, firstTimestamp) - assertEquals(firstTimestamp, refreshedTimestamp) - } - @Test fun `starts watchers only for the address types the user monitors`() = test { storeData.value = HwWalletData( @@ -313,9 +216,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 +234,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 +260,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 +275,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 +299,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) // 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) @@ -433,39 +343,23 @@ class HwWalletRepoTest : BaseUnitTest() { 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, ) ) @@ -481,23 +375,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 +398,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 +455,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 +478,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 +489,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 +502,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isSuccess) verify(trezorRepo).stopWatcher("dev1|nativeSegwit") verify(trezorRepo).forgetDevice("dev1") + verify(activityRepo).deleteActivitiesForWallet("trezor:dev1") } @Test @@ -698,9 +577,10 @@ 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(), ) @@ -858,36 +738,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 +825,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 = "trezor:dev1", + ): 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 +877,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..3db616581d 100644 --- a/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt @@ -36,6 +36,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { private var timestampCounter = 0L private val testMetadata = PreActivityMetadata( + walletId = "bitkit", 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 68104ccb9c..1e1cd845d0 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -623,6 +623,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..a614999267 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -43,7 +43,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,7 +94,7 @@ 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) @@ -195,8 +194,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 +206,27 @@ class TrezorRepoTest : BaseUnitTest() { verify(hwWalletStore).saveKnownDevices(savedCaptor.capture()) val saved = savedCaptor.firstValue.single() assertEquals(knownDevice.id, saved.id) - assertNotNull(UUID.fromString(saved.walletId)) + // Derived deterministically from the device xpubs, matching Bitkit Core's deriveWalletId + // scheme: "trezor:" + sha256(sorted xpubs joined by "\n"). sha256("zpubNS") below. + assertTrue(saved.walletId.startsWith("trezor:")) + assertEquals("trezor:5fc5940538054e483780b5ee3e44eb74e35323a34bb37ddca4c7f51e1759b9b6", saved.walletId) 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) + // No xpubs means Core cannot derive an id yet, so it stays blank and nothing is re-saved. + assertEquals("", sut.state.value.knownDevices.single().walletId) + verify(hwWalletStore, never()).saveKnownDevices(any()) + } + @Test fun `initialize should reuse completed setup`() = test { sut = createSut() @@ -578,7 +594,42 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(TransportType.USB, saved.transportType) assertEquals("Savings", saved.label) assertEquals("Safe 5", saved.model) - assertNotNull(UUID.fromString(saved.walletId)) + // No account xpubs were read in this flow, so Core cannot derive an id yet. + 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() + // sha256("captured-native-xpub"), matching Bitkit Core's deriveWalletId scheme. + assertEquals("trezor:1cdbd51b9a263f26c98ca762d74a160ad2f2cbe352addc95c9a92351ac6ad4cc", saved.walletId) } @Test 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/viewmodels/ActivityListViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt index 526b3825ae..9a1100ac77 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,6 +10,7 @@ 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 @@ -19,7 +18,6 @@ import to.bitkit.ext.create import to.bitkit.ext.rawId 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 +27,38 @@ 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> + + // Hardware-wallet activity scoped to its own walletId; the repo now returns it merged with local ones. + private val hwActivity = onchainActivity( + id = "hw1", + txType = PaymentType.RECEIVED, + timestamp = 100uL, + walletId = "trezor: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 +66,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 +80,20 @@ class ActivityListViewModelTest : BaseUnitTest() { @Test fun `filtered activities exclude hardware activities not matching the tab`() = test { + // Core filters by txType, so the SENT tab query returns only the SENT db activity. + 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,44 @@ 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 { + // A tagged hardware activity must still appear: it now lives in Core and is returned by the query. + 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() }) - } - - @Test - fun `hardware ids expose the hardware activity ids`() = test { - val sut = createViewModel() - val job = launch { sut.hardwareIds.collect {} } - advanceUntilIdle() - - assertEquals(setOf("hw1"), sut.hardwareIds.value) - job.cancel() + assertEquals(listOf("hw1"), sut.filteredActivities.value?.map { it.rawId() }) } @Test - fun `hardware duplicates of local activities are excluded`() = test { - hardwareActivities.value = persistentListOf( - hwActivity, - onchainActivity(id = "db1", txType = PaymentType.RECEIVED, timestamp = 300uL), - ) + 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(listOf("db1", "hw1"), sut.filteredActivities.value?.map { it.rawId() }) assertEquals(setOf("hw1"), 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 = "bitkit", + ) = Activity.Onchain( OnchainActivity.create( id = id, txType = txType, @@ -130,6 +149,7 @@ class ActivityListViewModelTest : BaseUnitTest() { address = "bc1", timestamp = timestamp, confirmed = true, + walletId = walletId, ) ) } From a896d9f059f863fe40a40cf1a18b1113b0045663 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 25 Jun 2026 01:11:34 +0200 Subject: [PATCH 8/8] fix: scope hardware wallet activities --- app/src/main/java/to/bitkit/ext/Activities.kt | 11 +- .../to/bitkit/models/ActivityWalletType.kt | 29 +++ .../java/to/bitkit/models/HardwareWallet.kt | 1 + .../models/NewTransactionSheetDetails.kt | 1 + .../to/bitkit/repositories/ActivityRepo.kt | 21 +-- .../to/bitkit/repositories/HwWalletRepo.kt | 178 ++++++++++++------ .../java/to/bitkit/repositories/TrezorRepo.kt | 17 +- .../java/to/bitkit/services/CoreService.kt | 41 ++-- app/src/main/java/to/bitkit/ui/ContentView.kt | 25 ++- .../screens/contacts/ContactActivityScreen.kt | 6 +- .../ui/screens/trezor/TrezorViewModel.kt | 3 +- .../screens/wallets/HardwareWalletScreen.kt | 8 +- .../bitkit/ui/screens/wallets/HomeScreen.kt | 4 +- .../ui/screens/wallets/SavingsWalletScreen.kt | 2 +- .../screens/wallets/SpendingWalletScreen.kt | 2 +- .../wallets/activity/ActivityDetailScreen.kt | 14 +- .../wallets/activity/ActivityExploreScreen.kt | 4 +- .../wallets/activity/AllActivityScreen.kt | 4 +- .../components/ActivityListGrouped.kt | 18 +- .../activity/components/ActivityListSimple.kt | 6 +- .../activity/components/ActivityRow.kt | 5 +- .../viewmodels/ActivityDetailViewModel.kt | 27 +-- .../viewmodels/ActivityListViewModel.kt | 8 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 9 +- .../NotifyPaymentReceivedHandlerTest.kt | 20 +- .../ActivityDetailViewModelTest.kt | 33 ++-- .../bitkit/repositories/ActivityRepoTest.kt | 31 ++- .../bitkit/repositories/HwWalletRepoTest.kt | 110 ++++++++++- .../PreActivityMetadataRepoTest.kt | 3 +- .../to/bitkit/repositories/TrezorRepoTest.kt | 51 ++++- .../viewmodels/ActivityListViewModelTest.kt | 8 +- .../viewmodels/AppViewModelSendFlowTest.kt | 7 +- 32 files changed, 493 insertions(+), 214 deletions(-) create mode 100644 app/src/main/java/to/bitkit/models/ActivityWalletType.kt diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index 3e6608e9d1..fba9c05e0c 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -5,13 +5,14 @@ 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 /** * Wallet id of the local Bitkit wallet. Mirrors Bitkit Core's `getDefaultWalletId()` (Rust - * `DEFAULT_WALLET_ID`); kept as a plain constant so the value is available without a JNI call. - * Hardware wallets use their own derived id instead. + * `DEFAULT_WALLET_ID`); kept local so the value is available without a JNI call. Hardware wallets + * use their own derived id instead. */ -const val DEFAULT_WALLET_ID = "bitkit" +val DEFAULT_WALLET_ID: String get() = ActivityWalletType.BITKIT.id fun Activity.rawId(): String = when (this) { is Activity.Lightning -> v1.id @@ -23,6 +24,10 @@ fun Activity.walletId(): String = when (this) { is Activity.Onchain -> v1.walletId } +fun Activity.scopedId(): String = "${walletId()}:${rawId()}" + +fun Activity.isHardwareWalletActivity(): Boolean = ActivityWalletType.TREZOR.owns(walletId()) + fun Activity.txType(): PaymentType = when (this) { is Activity.Lightning -> v1.txType is Activity.Onchain -> v1.txType 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..8293f048ea --- /dev/null +++ b/app/src/main/java/to/bitkit/models/ActivityWalletType.kt @@ -0,0 +1,29 @@ +package to.bitkit.models + +import java.security.MessageDigest +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" + + fun deriveId(keys: Collection): String { + val normalizedKeys = keys.filter { it.isNotBlank() } + if (normalizedKeys.isEmpty()) return "" + + val hash = MessageDigest.getInstance("SHA-256") + .digest(normalizedKeys.sorted().joinToString("\n").toByteArray(Charsets.UTF_8)) + return scopedId(hash.joinToString("") { "%02x".format(it) }) + } + + 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 3a7ec3c333..1c340df6b5 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -266,17 +266,17 @@ 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() } @@ -368,14 +368,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 { - // Resolve the local wallet first (indexed), then fall back to scanning all wallets so - // hardware-wallet activities (scoped to their own walletId) also resolve by id. - coreService.activity.getActivity(id) - ?: coreService.activity.get(walletId = null).firstOrNull { it.rawId() == 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) } } diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index f525c29af9..2043391fef 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -36,6 +36,9 @@ import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.rawId import to.bitkit.ext.runSuspendCatching +import to.bitkit.ext.scopedId +import to.bitkit.ext.walletId +import to.bitkit.models.ActivityWalletType import to.bitkit.models.HwFundingAccount import to.bitkit.models.HwFundingAddressType import to.bitkit.models.HwFundingBroadcastResult @@ -44,6 +47,7 @@ import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType +import to.bitkit.models.safe import to.bitkit.models.toAccountType import to.bitkit.models.toAddressType import to.bitkit.models.toCoreNetwork @@ -83,6 +87,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()) @@ -105,6 +110,7 @@ class HwWalletRepo @Inject constructor( } activeWatchers.clear() activeWatcherElectrumUrls.clear() + activeWatcherWalletIds.clear() retryingWatcherStarts.clear() emittedReceivedTxIds.clear() _watcherData.update { emptyMap() } @@ -273,7 +279,7 @@ class HwWalletRepo @Inject constructor( // Drop the removed wallet's hardware activity/details/tags from Bitkit Core so the // activity database does not grow for unpaired devices; re-pairing rebuilds from the watcher. val walletIdToPurge = target?.walletId?.takeIf { it.isNotBlank() } - if (walletIdToPurge != null) activityRepo.deleteActivitiesForWallet(walletIdToPurge) + if (walletIdToPurge != null) activityRepo.deleteActivitiesForWallet(walletIdToPurge).getOrThrow() }.onFailure { watcherSyncRequests.tryEmit(Unit) } @@ -332,19 +338,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 watcher = HwWatcherData( deviceId = watcherId.toDeviceId(), addressType = watcherId.toAddressTypeKey(), balanceSats = event.balance.total, - activities = event.activities.toImmutableList(), + activities = activities.toImmutableList(), ) val updatedWatcherData = _watcherData.value + (watcherId to watcher) _watcherData.update { updatedWatcherData } - // The watcher emits persistence-ready, wallet-scoped activities + details; store them so - // hardware transactions become first-class Bitkit Core activities (tags, inputs/outputs). - activityRepo.persistHardwareActivities(event.activities, event.transactionDetails) - emitReceivedTxs(previous, event, updatedWatcherData) + emitReceivedTxs(previous, activities, updatedWatcherData) } } } @@ -355,22 +366,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.activities + activities .filterIsInstance() .filter { it.v1.txType == PaymentType.RECEIVED && - it.v1.id !in knownTxIds && - emittedReceivedTxIds.add(it.v1.txId) + it.scopedId() !in knownActivityIds && + emittedReceivedTxIds.add(it.scopedId()) } .forEach { - val sats = mergedActivities.findOnchain(it.v1.txId)?.v1?.value ?: it.v1.value - _receivedTxs.emit(HwWalletReceivedTx(txid = it.v1.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)) } } @@ -391,54 +402,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, device.walletId, 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, - walletId = spec.walletId, - 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() } + ?: ActivityWalletType.TREZOR.deriveId(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 @@ -452,13 +477,60 @@ class HwWalletRepo @Inject constructor( } } - // The watcher already emits persistence-ready activities scoped to the device's walletId; the same - // txid seen under two address-type watchers collapses to one entry (keyed on tx_id), matching Core. + // The watcher emits one row per address-type account. Merge rows for the same logical transaction + // so wallet tiles and receive sheets show the wallet-level net amount instead of whichever row won. private fun List.toMergedActivities(): List = - flatMap { it.activities }.distinctBy { it.rawId() } + flatMap { it.activities } + .groupBy { it.rawId() } + .values + .map { it.mergedActivity() } + + 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 + sent > received -> PaymentType.SENT + else -> base.v1.txType + } + val value = when (txType) { + PaymentType.RECEIVED -> received.safe() - sent.safe() + PaymentType.SENT -> (sent.safe() - received.safe()).safe() - fee.safe() + } + + return Activity.Onchain( + base.v1.copy( + txType = txType, + value = value, + fee = fee, + 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, diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 2ca0a14129..532521a47b 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.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock @@ -1090,9 +1090,10 @@ private fun walletKey(xpubs: Map, fallback: String): String = private fun List.findHardwareWalletId(deviceId: String, xpubs: Map): String { val walletKey = walletKey(xpubs, deviceId) - return firstOrNull { it.id == deviceId }?.walletId?.takeIf { it.isNotBlank() } - ?: firstOrNull { it.walletKey == walletKey }?.walletId?.takeIf { it.isNotBlank() } - ?: deriveHardwareWalletId(xpubs) + firstOrNull { it.walletKey == walletKey }?.walletId?.takeIf { it.isNotBlank() }?.let { return it } + if (xpubs.values.any { it.isNotBlank() }) return deriveHardwareWalletId(xpubs) + + return firstOrNull { it.id == deviceId }?.walletId?.takeIf { it.isNotBlank() }.orEmpty() } private fun List.withHardwareWalletIds(): List { @@ -1116,15 +1117,9 @@ private fun List.withHardwareWalletIds(): List { * id is available without a JNI call and stays unit-testable on the JVM. */ private fun deriveHardwareWalletId(xpubs: Map): String { - val keys = xpubs.values.filter { it.isNotBlank() } - if (keys.isEmpty()) return "" - val hash = MessageDigest.getInstance("SHA-256") - .digest(keys.sorted().joinToString("\n").toByteArray(Charsets.UTF_8)) - return "$HW_WALLET_DEVICE_TYPE:" + hash.joinToString("") { "%02x".format(it) } + return ActivityWalletType.TREZOR.deriveId(xpubs.values) } -private const val HW_WALLET_DEVICE_TYPE = "trezor" - /** Unused-address scan gap limit for watch-only watchers; mirrors Bitkit Core's `DEFAULT_GAP_LIMIT`. */ private const val DEFAULT_GAP_LIMIT = 20u diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 9806d89ec7..ccaf6b3f46 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -86,7 +86,10 @@ 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 @@ -1423,42 +1426,50 @@ class ActivityService( } } - suspend fun isActivitySeen(activityId: String): Boolean = ServiceQueue.CORE.background { - val activity = getActivityById(defaultWalletId, 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(defaultWalletId, 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, @@ -1478,11 +1489,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/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d98a6e7734..75be73f0e2 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -42,6 +42,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute +import com.synonym.bitkitcore.Activity import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState @@ -52,6 +53,8 @@ import kotlinx.serialization.Serializable import to.bitkit.appwidget.AppWidgetRefreshReason import to.bitkit.appwidget.appWidgetRefreshScheduler import to.bitkit.env.Env +import to.bitkit.ext.rawId +import to.bitkit.ext.walletId import to.bitkit.models.NodeLifecycleState import to.bitkit.models.Toast import to.bitkit.repositories.ConnectivityState @@ -1033,7 +1036,7 @@ private fun NavGraphBuilder.home( val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() HardwareWalletScreen( deviceId = deviceId, - onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, + onActivityItemClick = { navController.navigateToActivityItem(it) }, onTransferToSpendingClick = { selectedDeviceId -> navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) }, @@ -1050,7 +1053,7 @@ private fun NavGraphBuilder.allActivity( AllActivityScreen( viewModel = activityListViewModel, onBack = { navController.popBackStack() }, - onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, + onActivityItemClick = { navController.navigateToActivityItem(it) }, ) } } @@ -1592,7 +1595,7 @@ private fun NavGraphBuilder.activityItem( ActivityDetailScreen( listViewModel = activityListViewModel, route = it.toRoute(), - onExploreClick = { id -> navController.navigateToActivityExplore(id) }, + onExploreClick = { activity -> navController.navigateToActivityExplore(activity) }, onAssignContactClick = { id -> navController.navigateTo(Routes.ActivityAssignContact(id)) }, onChannelClick = { channelId -> navController.navigateTo(Routes.ChannelDetail(channelId)) @@ -1849,9 +1852,17 @@ fun NavController.navigateToTransferIntro() = navigateTo(Routes.TransferIntro) fun NavController.navigateToTransferFunding() = navigateTo(Routes.Funding) -fun NavController.navigateToActivityItem(id: String) = navigateTo(Routes.ActivityDetail(id)) +fun NavController.navigateToActivityItem(activity: Activity) = + navigateTo(Routes.ActivityDetail(activity.rawId(), activity.walletId())) + +fun NavController.navigateToActivityItem(id: String, walletId: String? = null) = + navigateTo(Routes.ActivityDetail(id, walletId)) + +fun NavController.navigateToActivityExplore(activity: Activity) = + navigateTo(Routes.ActivityExplore(activity.rawId(), activity.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)) @@ -2072,13 +2083,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..a130d4eebb 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: (Activity) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -54,7 +54,7 @@ private fun Content( uiState: ContactActivityUiState, onBackClick: () -> Unit, onRetryClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, ) { ScreenColumn { AppTopBar( @@ -118,7 +118,7 @@ private fun ErrorState( private fun ContactActivityList( profile: PubkyProfile?, activities: ImmutableList?, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, modifier: Modifier = Modifier, ) { val name = profile?.name 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 492fc7e47f..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 @@ -30,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 @@ -712,7 +713,7 @@ class TrezorViewModel @Inject constructor( } val result = trezorRepo.startWatcher( watcherId = watcherId, - walletId = watcherId, + walletId = ActivityWalletType.TREZOR.scopedId(watcherId), extendedKey = key, network = state.selectedNetwork, gapLimit = gapLimit, 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..159bafd790 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: (Activity) -> 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: (Activity) -> Unit, onTransferToSpendingClick: (String) -> Unit, onRemoveClick: () -> Unit, onConfirmRemove: () -> Unit, @@ -109,7 +109,7 @@ 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 hardwareIds = remember(wallet.activities) { wallet.activities.map { it.scopedId() }.toImmutableSet() } val hazeState = rememberHazeState() 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..7c8e4d1fc5 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 @@ -417,7 +417,7 @@ private fun Content( onNavigateToAppStatus: () -> Unit = {}, onNavigateToSettingUp: () -> Unit = {}, onNavigateToAllActivity: () -> Unit = {}, - onNavigateToActivityItem: (String) -> Unit = {}, + onNavigateToActivityItem: (Activity) -> 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: (Activity) -> 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..dd9d5cd21e 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: (Activity) -> Unit, onTransferToSpendingClick: () -> Unit, onBackClick: () -> Unit, forceCloseRemainingDuration: String? = null, 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..9432a30df7 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: (Activity) -> Unit, onEmptyActivityRowClick: () -> Unit, onTransferToSavingsClick: () -> Unit, onTransferFromSavingsClick: () -> Unit, 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 db0d17145c..bef09564c3 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,10 +55,10 @@ import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import to.bitkit.R -import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.contact import to.bitkit.ext.create import to.bitkit.ext.ellipsisMiddle +import to.bitkit.ext.isHardwareWalletActivity import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer import to.bitkit.ext.rawId @@ -108,7 +108,7 @@ fun ActivityDetailScreen( listViewModel: ActivityListViewModel, detailViewModel: ActivityDetailViewModel = hiltViewModel(), route: Routes.ActivityDetail, - onExploreClick: (String) -> Unit, + onExploreClick: (Activity) -> Unit, onAssignContactClick: (String) -> Unit, onBackClick: () -> Unit, onCloseClick: () -> Unit, @@ -117,8 +117,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 @@ -180,7 +180,7 @@ fun ActivityDetailScreen( is ActivityDetailViewModel.ActivityLoadState.Success -> { val item = loadState.activity - val isHardware = remember(item) { item.walletId() != DEFAULT_WALLET_ID } + val isHardware = remember(item) { item.isHardwareWalletActivity() } val app = appViewModel ?: return@Box val settings = settingsViewModel ?: return@Box val hideBalance by settings.hideBalance.collectAsStateWithLifecycle() @@ -328,7 +328,7 @@ private fun ActivityDetailContent( onAssignClick: () -> Unit, onDetachClick: () -> Unit, onClickBoost: () -> Unit, - onExploreClick: (String) -> Unit, + onExploreClick: (Activity) -> Unit, onChannelClick: ((String) -> Unit)?, detailViewModel: ActivityDetailViewModel? = null, isCpfpChild: Boolean = false, @@ -727,7 +727,7 @@ private fun ActivityDetailContent( PrimaryButton( text = stringResource(R.string.wallet__activity_explore), size = ButtonSize.Small, - onClick = { onExploreClick(item.rawId()) }, + onClick = { onExploreClick(item) }, icon = { Icon( painter = painterResource(R.drawable.ic_git_branch), 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..7420158680 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 @@ -75,8 +75,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 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..b8828d7318 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: (Activity) -> 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: (Activity) -> Unit, onEmptyActivityRowClick: () -> Unit, ) { val listState = rememberLazyListState() 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..9ff6deeb7b 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 @@ -29,6 +29,8 @@ 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.ext.walletId import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption13Up @@ -47,7 +49,7 @@ import java.util.Locale @Composable fun ActivityListGrouped( items: ImmutableList?, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, onEmptyActivityRowClick: () -> Unit, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), @@ -82,8 +84,8 @@ fun ActivityListGrouped( when (item) { is String -> "header_$item" is Activity -> when (item) { - is Activity.Lightning -> "lightning_${item.rawId()}" - is Activity.Onchain -> "onchain_${item.rawId()}" + is Activity.Lightning -> "lightning_${item.walletId()}_${item.rawId()}" + is Activity.Onchain -> "onchain_${item.walletId()}_${item.rawId()}" } else -> "item_$index" @@ -120,7 +122,7 @@ fun ActivityListGrouped( onClick = onActivityItemClick, testTag = "$activityTestTagPrefix-$index", title = titleProvider(item) ?: contactActivityTitle(item, contacts), - isHardware = item.rawId() in hardwareIds, + isHardware = item.scopedId() in hardwareIds, contact = if (showContactAvatar) contactForActivity(item, contacts) else null, ) VerticalSpacer(16.dp) @@ -165,7 +167,7 @@ fun ActivityListGrouped( @Suppress("LongMethod", "LongParameterList") fun LazyListScope.activityListGroupedItems( items: ImmutableList?, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (Activity) -> Unit, onEmptyActivityRowClick: () -> Unit, showFooter: Boolean = false, onAllActivityButtonClick: () -> Unit = {}, @@ -180,8 +182,8 @@ fun LazyListScope.activityListGroupedItems( when (item) { is String -> "header_$item" is Activity -> when (item) { - is Activity.Lightning -> "lightning_${item.rawId()}" - is Activity.Onchain -> "onchain_${item.rawId()}" + is Activity.Lightning -> "lightning_${item.walletId()}_${item.rawId()}" + is Activity.Onchain -> "onchain_${item.walletId()}_${item.rawId()}" } else -> "item_$index" @@ -217,7 +219,7 @@ fun LazyListScope.activityListGroupedItems( item = item, onClick = onActivityItemClick, testTag = "Activity-$index", - isHardware = item.rawId() in hardwareIds, + isHardware = item.scopedId() in hardwareIds, ) VerticalSpacer(16.dp) } 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..2a220d64cc 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: (Activity) -> 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) { 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..3ca8c0f154 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 @@ -32,7 +32,6 @@ import to.bitkit.ext.DatePattern import to.bitkit.ext.formatted import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer -import to.bitkit.ext.rawId import to.bitkit.ext.timestamp import to.bitkit.ext.totalValue import to.bitkit.ext.txType @@ -65,7 +64,7 @@ import java.time.ZoneId @Composable fun ActivityRow( item: Activity, - onClick: (String) -> Unit, + onClick: (Activity) -> Unit, testTag: String, title: String? = null, isHardware: Boolean = false, @@ -111,7 +110,7 @@ fun ActivityRow( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickableAlpha { onClick(item.rawId()) } + .clickableAlpha { onClick(item) } .background(color = Colors.Gray6, shape = Shapes.medium) .padding(16.dp) .testTag(testTag) diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index d2aa9d6e01..9735df1108 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -57,17 +57,17 @@ 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)) } loadTags() - observeActivityChanges(activityId) + observeActivityChanges(activityId, walletId) } else { _uiState.update { it.copy( @@ -79,7 +79,7 @@ class ActivityDetailViewModel @Inject constructor( } } .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( @@ -99,17 +99,17 @@ class ActivityDetailViewModel @Inject constructor( _tags.update { persistentListOf() } } - 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 @@ -120,7 +120,7 @@ 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 } } @@ -134,7 +134,7 @@ class ActivityDetailViewModel @Inject constructor( _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() } } } @@ -149,7 +149,7 @@ class ActivityDetailViewModel @Inject constructor( 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) } } } @@ -164,19 +164,20 @@ class ActivityDetailViewModel @Inject constructor( 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) } diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index ab8a511c8e..476b2ddd1d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -27,13 +27,12 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher -import to.bitkit.ext.DEFAULT_WALLET_ID +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.ext.walletId import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.PubkyProfile import to.bitkit.repositories.ActivityRepo @@ -130,11 +129,10 @@ class ActivityListViewModel @Inject constructor( } private suspend fun refreshActivityState() { - val localWalletId = DEFAULT_WALLET_ID val all = activityRepo.getActivities(filter = ActivityFilter.ALL).getOrNull() ?: emptyList() val filtered = filterOutReplacedSentTransactions(all) _hardwareIds.update { - filtered.filter { it.walletId() != localWalletId }.map { it.rawId() }.toImmutableSet() + filtered.filter { it.isHardwareWalletActivity() }.map { it.scopedId() }.toImmutableSet() } _latestActivities.update { filtered.take(SIZE_LATEST).toImmutableList() } _lightningActivities.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 915f9f8b86..f3298f0692 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 @@ -331,6 +332,7 @@ class AppViewModel @Inject constructor( direction = NewTransactionSheetDirection.RECEIVED, paymentHashOrTxId = tx.txid, activityId = tx.txid, + activityWalletId = tx.walletId, sats = tx.sats.toLong(), ), ) @@ -2433,8 +2435,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 } @@ -2451,7 +2454,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) @@ -2475,7 +2478,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 007a5b4688..679745586d 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 4ce0452dda..92240f41ec 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -23,6 +23,7 @@ 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 @@ -37,6 +38,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { private val blocktankRepo = mock() private val settingsStore = mock() private val transferRepo = mock() + private val hardwareWalletId = ActivityWalletType.TREZOR.scopedId("dev1") companion object Fixtures { const val ACTIVITY_ID = "test-activity-1" @@ -75,30 +77,31 @@ class ActivityDetailViewModelTest : BaseUnitTest() { address = "", timestamp = 1_700_000_000uL, confirmed = true, - walletId = "trezor:dev1", + walletId = hardwareWalletId, ) ) - whenever { activityRepo.getActivity(ACTIVITY_ID) }.thenReturn(Result.success(hwActivity)) - whenever { activityRepo.getActivityTags(ACTIVITY_ID, "trezor:dev1") }.thenReturn(Result.success(emptyList())) + 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"), "trezor:dev1") + activityRepo.addTagsToActivity(ACTIVITY_ID, listOf("tag1"), hardwareWalletId) }.thenReturn(Result.success(Unit)) whenever { settingsStore.addLastUsedTag("tag1") }.thenReturn(Unit) - sut.loadActivity(ACTIVITY_ID) + sut.loadActivity(ACTIVITY_ID, hardwareWalletId) val loadState = sut.uiState.value.activityLoadState assertTrue(loadState is ActivityDetailViewModel.ActivityLoadState.Success) assertEquals(hwActivity, loadState.activity) sut.addTag("tag1") - verify(activityRepo).addTagsToActivity(ACTIVITY_ID, listOf("tag1"), "trezor:dev1") - verify(activityRepo, atLeastOnce()).getActivityTags(ACTIVITY_ID, "trezor:dev1") + 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 the database`() = test { - whenever { activityRepo.getActivity(ACTIVITY_ID) }.thenReturn(Result.success(null)) + whenever { activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull()) }.thenReturn(Result.success(null)) sut.loadActivity(ACTIVITY_ID) @@ -164,7 +167,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { val activitiesChangedFlow = MutableStateFlow(System.currentTimeMillis()) whenever(activityRepo.activitiesChanged).thenReturn(activitiesChangedFlow) - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(initialActivity)) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(initialActivity)) whenever(activityRepo.getActivityTags(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(emptyList())) // Load activity @@ -176,7 +179,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { assertEquals(initialActivity, initialState.activity) // 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 @@ -191,7 +194,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { val activitiesChangedFlow = MutableStateFlow(System.currentTimeMillis()) whenever(activityRepo.activitiesChanged).thenReturn(activitiesChangedFlow) - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(activity)) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(activity)) whenever(activityRepo.getActivityTags(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(emptyList())) // Load activity @@ -215,14 +218,15 @@ class ActivityDetailViewModelTest : BaseUnitTest() { val activitiesChangedFlow = MutableStateFlow(System.currentTimeMillis()) whenever(activityRepo.activitiesChanged).thenReturn(activitiesChangedFlow) - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(activity)) + 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 @@ -233,7 +237,8 @@ class ActivityDetailViewModelTest : BaseUnitTest() { @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 ce7c5fa4d0..2f110d03c7 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -28,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 @@ -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", @@ -255,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,6 +265,18 @@ class ActivityRepoTest : BaseUnitTest() { assertEquals(testActivity, result.getOrThrow()) } + @Test + fun `getActivity passes wallet id to core lookup`() = test { + val activityId = "activity123" + wheneverBlocking { coreService.activity.getActivity(activityId, hardwareWalletId) }.thenReturn(testActivity) + + val result = sut.getActivity(activityId, hardwareWalletId) + + assertTrue(result.isSuccess) + assertEquals(testActivity, result.getOrThrow()) + verify(coreService.activity).getActivity(activityId, hardwareWalletId) + } + @Test fun `persistHardwareActivities upserts activities and transaction details`() = test { val activity = Activity.Onchain( @@ -275,11 +289,11 @@ class ActivityRepoTest : BaseUnitTest() { address = "", timestamp = 2_000uL, confirmed = true, - walletId = "trezor:dev1", + walletId = hardwareWalletId, ) ) val details = BitkitCoreTransactionDetails( - walletId = "trezor:dev1", + walletId = hardwareWalletId, txId = "hw-txid", amountSats = 10_000L, inputs = emptyList(), @@ -306,25 +320,24 @@ class ActivityRepoTest : BaseUnitTest() { @Test fun `deleteActivitiesForWallet delegates to core delete by wallet id`() = test { - wheneverBlocking { coreService.activity.deleteByWalletId("trezor:dev1") }.thenReturn(3u) + wheneverBlocking { coreService.activity.deleteByWalletId(hardwareWalletId) }.thenReturn(3u) - val result = sut.deleteActivitiesForWallet("trezor:dev1") + val result = sut.deleteActivitiesForWallet(hardwareWalletId) assertTrue(result.isSuccess) - verify(coreService.activity).deleteByWalletId("trezor:dev1") + verify(coreService.activity).deleteByWalletId(hardwareWalletId) } @Test fun `getActivity returns null when not found`() = test { val activityId = "activity123" - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(null) - // getActivity now falls back to scanning all wallets when the indexed lookup misses. - wheneverBlocking { coreService.activity.get(walletId = null) }.thenReturn(emptyList()) + 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 diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index af48900788..55f3793e9f 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -32,6 +32,7 @@ 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 @@ -59,6 +60,7 @@ class HwWalletRepoTest : BaseUnitTest() { 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", @@ -69,7 +71,7 @@ class HwWalletRepoTest : BaseUnitTest() { model = "Safe 5", lastConnectedAt = 0L, xpubs = mapOf("nativeSegwit" to "zpubNS"), - walletId = "trezor:dev1", + walletId = trezorWalletId, ) @Before @@ -82,6 +84,9 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(settingsStore.data).thenReturn(settingsData) whenever(trezorRepo.state).thenReturn(trezorState) whenever(trezorRepo.watcherEvents).thenReturn(watcherEvents) + wheneverBlocking { + trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), anyOrNull(), any()) + }.thenReturn(Result.success(Unit)) wheneverBlocking { activityRepo.persistHardwareActivities(any(), any()) }.thenReturn(Result.success(Unit)) wheneverBlocking { activityRepo.deleteActivitiesForWallet(any()) }.thenReturn(Result.success(Unit)) } @@ -155,8 +160,48 @@ class HwWalletRepoTest : BaseUnitTest() { verify(activityRepo).persistHardwareActivities(listOf(activity), emptyList()) } + @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( + "random|nativeSegwit" to transactionsChanged( + activities = listOf(activity), + balanceTotal = 10_562_411uL, + txCount = 1u, + ) + ) + + assertEquals(0uL, sut.totalSats.value) + verify(activityRepo, never()).persistHardwareActivities(listOf(activity), emptyList()) + } + + @Test + fun `transactions changed event is not exposed when persistence fails`() = test { + val activity = onchainActivity(txid = "t1", amount = 10_562_411uL) + wheneverBlocking { activityRepo.persistHardwareActivities(listOf(activity), emptyList()) } + .thenReturn(Result.failure(AppError("persist failed"))) + val sut = createRepo() + + watcherEvents.emit( + "dev1|nativeSegwit" to transactionsChanged( + activities = listOf(activity), + balanceTotal = 10_562_411uL, + txCount = 1u, + ) + ) + + assertEquals(0uL, sut.totalSats.value) + assertEquals(emptyList(), sut.wallets.value.single().activities) + } + @Test 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"))) + ) + settingsData.value = SettingsData(addressTypesToMonitor = listOf("nativeSegwit", "taproot")) val sut = createRepo() watcherEvents.emit( @@ -174,19 +219,24 @@ class HwWalletRepoTest : BaseUnitTest() { @Test 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 shared = onchainActivity(txid = "shared", amount = 150uL) + val native = onchainActivity(txid = "shared", amount = 100uL) + val taproot = onchainActivity(txid = "shared", amount = 50uL) watcherEvents.emit( "dev1|nativeSegwit" to transactionsChanged( - activities = listOf(shared), + activities = listOf(native), balanceTotal = 100uL, accountType = AccountType.NATIVE_SEGWIT, ) ) watcherEvents.emit( "dev1|taproot" to transactionsChanged( - activities = listOf(shared), + activities = listOf(taproot), balanceTotal = 50uL, accountType = AccountType.TAPROOT, ) @@ -195,6 +245,7 @@ class HwWalletRepoTest : BaseUnitTest() { 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) } @@ -318,7 +369,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 2u, ) ) - 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( @@ -338,6 +389,10 @@ 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 } } @@ -364,7 +419,7 @@ class HwWalletRepoTest : BaseUnitTest() { ) ) - assertEquals(listOf(HwWalletReceivedTx(txid = "shared", sats = 100uL)), received) + assertEquals(listOf(HwWalletReceivedTx(txid = "shared", sats = 100uL, walletId = trezorWalletId)), received) job.cancel() } @@ -502,7 +557,23 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isSuccess) verify(trezorRepo).stopWatcher("dev1|nativeSegwit") verify(trezorRepo).forgetDevice("dev1") - verify(activityRepo).deleteActivitiesForWallet("trezor: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 @@ -586,6 +657,29 @@ class HwWalletRepoTest : BaseUnitTest() { ) } + @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(), + ) + } + @Test fun `forwards transport restored to the trezor repo`() = test { val sut = createRepo() @@ -838,7 +932,7 @@ class HwWalletRepoTest : BaseUnitTest() { txid: String, amount: ULong, txType: PaymentType = PaymentType.RECEIVED, - walletId: String = "trezor:dev1", + walletId: String = trezorWalletId, ): Activity = Activity.Onchain( OnchainActivity.create( id = txid, diff --git a/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt index 3db616581d..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,7 +37,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { private var timestampCounter = 0L private val testMetadata = PreActivityMetadata( - walletId = "bitkit", + walletId = ActivityWalletType.BITKIT.id, paymentId = "payment-123", createdAt = 1234567890uL, tags = listOf("tag1", "tag2"), diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index a614999267..03eb3c355d 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 @@ -206,10 +207,8 @@ class TrezorRepoTest : BaseUnitTest() { verify(hwWalletStore).saveKnownDevices(savedCaptor.capture()) val saved = savedCaptor.firstValue.single() assertEquals(knownDevice.id, saved.id) - // Derived deterministically from the device xpubs, matching Bitkit Core's deriveWalletId - // scheme: "trezor:" + sha256(sorted xpubs joined by "\n"). sha256("zpubNS") below. - assertTrue(saved.walletId.startsWith("trezor:")) - assertEquals("trezor:5fc5940538054e483780b5ee3e44eb74e35323a34bb37ddca4c7f51e1759b9b6", saved.walletId) + assertTrue(ActivityWalletType.TREZOR.owns(saved.walletId)) + assertEquals(ActivityWalletType.TREZOR.deriveId(listOf("zpubNS")), saved.walletId) assertEquals(listOf(saved), sut.state.value.knownDevices) } @@ -628,8 +627,7 @@ class TrezorRepoTest : BaseUnitTest() { val captor = argumentCaptor>() verify(hwWalletStore).saveKnownDevices(captor.capture()) val saved = captor.firstValue.single() - // sha256("captured-native-xpub"), matching Bitkit Core's deriveWalletId scheme. - assertEquals("trezor:1cdbd51b9a263f26c98ca762d74a160ad2f2cbe352addc95c9a92351ac6ad4cc", saved.walletId) + assertEquals(ActivityWalletType.TREZOR.deriveId(listOf("captured-native-xpub")), saved.walletId) } @Test @@ -673,6 +671,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 = ActivityWalletType.TREZOR.deriveId(listOf("old-native-xpub")) + val newWalletId = ActivityWalletType.TREZOR.deriveId(listOf("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/viewmodels/ActivityListViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt index 9a1100ac77..9544600c14 100644 --- a/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt @@ -16,6 +16,8 @@ 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.PubkyRepo @@ -37,7 +39,7 @@ class ActivityListViewModelTest : BaseUnitTest() { id = "hw1", txType = PaymentType.RECEIVED, timestamp = 100uL, - walletId = "trezor:dev1", + walletId = ActivityWalletType.TREZOR.scopedId("dev1"), ) @Before @@ -130,7 +132,7 @@ class ActivityListViewModelTest : BaseUnitTest() { val job = launch { sut.hardwareIds.collect {} } advanceUntilIdle() - assertEquals(setOf("hw1"), sut.hardwareIds.value) + assertEquals(setOf(hwActivity.scopedId()), sut.hardwareIds.value) job.cancel() } @@ -138,7 +140,7 @@ class ActivityListViewModelTest : BaseUnitTest() { id: String, txType: PaymentType, timestamp: ULong, - walletId: String = "bitkit", + walletId: String = ActivityWalletType.BITKIT.id, ) = Activity.Onchain( OnchainActivity.create( id = id, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 8561752f91..f185838161 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 @@ -276,16 +277,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()) }