From b9abef850145036c75dea796ecee9e1ada88bf2a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 22:38:14 +0200 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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()) } From db4045f8f89c101a5675f6ecd43cd52505fbb04c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 1 Jul 2026 14:57:58 +0200 Subject: [PATCH 9/9] fix: update core activity integration --- app/src/main/java/to/bitkit/ext/Activities.kt | 40 ++++ .../to/bitkit/repositories/LightningRepo.kt | 3 +- app/src/main/java/to/bitkit/ui/ContentView.kt | 21 +-- .../screens/contacts/ContactActivityScreen.kt | 12 +- .../screens/wallets/HardwareWalletScreen.kt | 24 +-- .../bitkit/ui/screens/wallets/HomeScreen.kt | 20 +- .../bitkit/ui/screens/wallets/HomeUiState.kt | 3 +- .../ui/screens/wallets/HomeViewModel.kt | 4 +- .../ui/screens/wallets/HwWalletViewModel.kt | 54 +++++- .../ui/screens/wallets/SavingsWalletScreen.kt | 12 +- .../screens/wallets/SpendingWalletScreen.kt | 12 +- .../wallets/activity/ActivityDetailScreen.kt | 167 ++++++++-------- .../wallets/activity/ActivityExploreScreen.kt | 81 ++++---- .../wallets/activity/AllActivityScreen.kt | 8 +- .../activity/components/ActivityIcon.kt | 178 +++--------------- .../components/ActivityListGrouped.kt | 103 +++++----- .../activity/components/ActivityListSimple.kt | 6 +- .../activity/components/ActivityRow.kt | 90 ++++----- .../wallets/activity/utils/PreviewItems.kt | 165 +++++++++------- .../wallets/send/SendCoinSelectionScreen.kt | 7 +- .../send/SendCoinSelectionViewModel.kt | 16 +- .../general/HardwareWalletsSettingsScreen.kt | 30 +-- .../bitkit/ui/sheets/BoostTransactionSheet.kt | 8 +- .../ui/sheets/BoostTransactionViewModel.kt | 22 ++- .../viewmodels/ActivityDetailViewModel.kt | 21 ++- .../ActivityDetailViewModelTest.kt | 17 +- .../ui/screens/wallets/HomeViewModelTest.kt | 2 +- .../screens/wallets/HwWalletViewModelTest.kt | 16 +- .../sheets/BoostTransactionViewModelTest.kt | 48 +++-- gradle/libs.versions.toml | 2 +- 30 files changed, 611 insertions(+), 581 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index fba9c05e0c..597512076e 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -26,6 +26,11 @@ fun Activity.walletId(): String = when (this) { fun Activity.scopedId(): String = "${walletId()}:${rawId()}" +fun Activity.activityKey(): String = when (this) { + is Activity.Lightning -> "lightning_${scopedId()}" + is Activity.Onchain -> "onchain_${scopedId()}" +} + fun Activity.isHardwareWalletActivity(): Boolean = ActivityWalletType.TREZOR.owns(walletId()) fun Activity.txType(): PaymentType = when (this) { @@ -52,6 +57,21 @@ fun Activity.totalValue() = when (this) { } } +fun Activity.value() = when (this) { + is Activity.Lightning -> v1.value + is Activity.Onchain -> v1.value +} + +fun Activity.fee() = when (this) { + is Activity.Lightning -> v1.fee + is Activity.Onchain -> v1.fee +} + +fun Activity.message() = when (this) { + is Activity.Lightning -> v1.message + is Activity.Onchain -> "" +} + fun Activity.isBoosted() = when (this) { is Activity.Onchain -> v1.isBoosted else -> false @@ -94,6 +114,21 @@ fun Activity.paymentState(): PaymentState? = when (this) { is Activity.Onchain -> null } +fun Activity.txId(): String? = when (this) { + is Activity.Lightning -> null + is Activity.Onchain -> v1.txId +} + +fun Activity.confirmed(): Boolean? = when (this) { + is Activity.Lightning -> null + is Activity.Onchain -> v1.confirmed +} + +fun Activity.feeRate(): ULong = when (this) { + is Activity.Lightning -> 0u + is Activity.Onchain -> v1.feeRate +} + fun Activity.Onchain.boostType() = when (this.v1.txType) { PaymentType.SENT -> BoostType.RBF PaymentType.RECEIVED -> BoostType.CPFP @@ -107,6 +142,11 @@ fun Activity.timestamp() = when (this) { } } +fun Activity.groupTimestamp() = when (this) { + is Activity.Lightning -> v1.timestamp + is Activity.Onchain -> v1.timestamp +} + enum class BoostType { RBF, CPFP } @Suppress("LongParameterList") diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 74f2a2ba74..2d254cdd2d 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1734,8 +1734,7 @@ private fun Throwable.toLnurlPayInvoiceError(): Throwable { private fun Throwable.isLnurlPayValidationError(): Boolean = when (this) { is LnurlException.InvalidAmount, - is LnurlException.AmountMismatch, - is LnurlException.MetadataMismatch -> true + is LnurlException.AmountMismatch -> true else -> false } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 75be73f0e2..09cace884a 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -42,7 +42,6 @@ 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 @@ -53,8 +52,6 @@ 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 @@ -997,7 +994,7 @@ private fun NavGraphBuilder.home( isGeoBlocked = isGeoBlocked, onchainActivities = onchainActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSpendingClick = { navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) @@ -1016,7 +1013,7 @@ private fun NavGraphBuilder.home( channels = lightningState.channels, lightningActivities = lightningActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSavingsClick = { if (!hasSeenSavingsIntro) { @@ -1036,7 +1033,7 @@ private fun NavGraphBuilder.home( val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() HardwareWalletScreen( deviceId = deviceId, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, onTransferToSpendingClick = { selectedDeviceId -> navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) }, @@ -1053,7 +1050,7 @@ private fun NavGraphBuilder.allActivity( AllActivityScreen( viewModel = activityListViewModel, onBack = { navController.popBackStack() }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, ) } } @@ -1211,7 +1208,7 @@ private fun NavGraphBuilder.contacts( ContactActivityScreen( viewModel = viewModel, onBackClick = { navController.popBackStack() }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, ) } } @@ -1595,7 +1592,7 @@ private fun NavGraphBuilder.activityItem( ActivityDetailScreen( listViewModel = activityListViewModel, route = it.toRoute(), - onExploreClick = { activity -> navController.navigateToActivityExplore(activity) }, + onExploreClick = { id, walletId -> navController.navigateToActivityExplore(id, walletId) }, onAssignContactClick = { id -> navController.navigateTo(Routes.ActivityAssignContact(id)) }, onChannelClick = { channelId -> navController.navigateTo(Routes.ChannelDetail(channelId)) @@ -1852,15 +1849,9 @@ fun NavController.navigateToTransferIntro() = navigateTo(Routes.TransferIntro) fun NavController.navigateToTransferFunding() = navigateTo(Routes.Funding) -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, walletId: String? = null) = navigateTo(Routes.ActivityExplore(id, walletId)) 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 a130d4eebb..8cc2dead5d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt @@ -37,7 +37,7 @@ import to.bitkit.ui.theme.Colors fun ContactActivityScreen( viewModel: ContactActivityViewModel, onBackClick: () -> Unit, - onActivityItemClick: (Activity) -> Unit, + onActivityItemClick: (String, String) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -54,7 +54,7 @@ private fun Content( uiState: ContactActivityUiState, onBackClick: () -> Unit, onRetryClick: () -> Unit, - onActivityItemClick: (Activity) -> Unit, + onActivityItemClick: (String, String) -> Unit, ) { ScreenColumn { AppTopBar( @@ -118,7 +118,7 @@ private fun ErrorState( private fun ContactActivityList( profile: PubkyProfile?, activities: ImmutableList?, - onActivityItemClick: (Activity) -> Unit, + onActivityItemClick: (String, String) -> Unit, modifier: Modifier = Modifier, ) { val name = profile?.name @@ -154,7 +154,7 @@ private fun Preview() { ), onBackClick = {}, onRetryClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } @@ -170,7 +170,7 @@ private fun PreviewEmpty() { ), onBackClick = {}, onRetryClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } @@ -186,7 +186,7 @@ private fun PreviewError() { ), onBackClick = {}, onRetryClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt index 159bafd790..3e93c07906 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 @@ -40,7 +40,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableSet import to.bitkit.R import to.bitkit.ext.scopedId -import to.bitkit.models.HwWallet import to.bitkit.models.TransportType import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.SecondaryButton @@ -62,7 +61,7 @@ import to.bitkit.ui.theme.TopBarGradient @Composable fun HardwareWalletScreen( deviceId: String, - onActivityItemClick: (Activity) -> Unit, + onActivityItemClick: (String, String) -> Unit, onTransferToSpendingClick: (String) -> Unit, onBackClick: () -> Unit, viewModel: HwWalletViewModel = hiltViewModel(), @@ -80,7 +79,7 @@ fun HardwareWalletScreen( wallet?.let { device -> HardwareWalletContent( wallet = device, - showRemoveDialog = uiState.isPendingRemoval != null, + showRemoveDialog = uiState.pendingRemovalId != null, onActivityItemClick = onActivityItemClick, onTransferToSpendingClick = onTransferToSpendingClick, onRemoveClick = { viewModel.onRemoveClick(device) }, @@ -93,9 +92,9 @@ fun HardwareWalletScreen( @Composable private fun HardwareWalletContent( - wallet: HwWallet, + wallet: HwWalletUi, showRemoveDialog: Boolean, - onActivityItemClick: (Activity) -> Unit, + onActivityItemClick: (String, String) -> Unit, onTransferToSpendingClick: (String) -> Unit, onRemoveClick: () -> Unit, onConfirmRemove: () -> Unit, @@ -109,7 +108,8 @@ private fun HardwareWalletContent( // Every activity here belongs to the watch-only device, so render them all with the blue // hardware icon, matching the home list. - val hardwareIds = remember(wallet.activities) { wallet.activities.map { it.scopedId() }.toImmutableSet() } + val activityItems = remember(wallet.activities) { wallet.activities } + val hardwareIds = remember(activityItems) { activityItems.map { it.scopedId() }.toImmutableSet() } val hazeState = rememberHazeState() @@ -189,7 +189,7 @@ private fun HardwareWalletContent( } activityListGroupedItems( - items = wallet.activities, + items = activityItems, onActivityItemClick = onActivityItemClick, onEmptyActivityRowClick = {}, showFooter = false, @@ -246,7 +246,7 @@ private fun RemoveHardwareWalletButton( private fun previewWallet( balanceSats: ULong = 10_562_411uL, activities: ImmutableList = previewOnchainActivityItems(), -) = HwWallet( +) = HwWalletUi( id = "dev1", name = "Trezor Safe 3", model = "Safe 3", @@ -264,7 +264,7 @@ private fun Preview() { HardwareWalletContent( wallet = previewWallet(), showRemoveDialog = false, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, @@ -284,7 +284,7 @@ private fun PreviewNoActivity() { HardwareWalletContent( wallet = previewWallet(activities = persistentListOf()), showRemoveDialog = false, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, @@ -304,7 +304,7 @@ private fun PreviewEmpty() { HardwareWalletContent( wallet = previewWallet(balanceSats = 0uL, activities = persistentListOf()), showRemoveDialog = false, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, @@ -324,7 +324,7 @@ private fun PreviewRemoveDialog() { HardwareWalletContent( wallet = previewWallet(), showRemoveDialog = true, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 7c8e4d1fc5..4cb7137332 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 @@ -116,7 +116,6 @@ import to.bitkit.env.Env import to.bitkit.models.ActivityBannerType import to.bitkit.models.BalanceState import to.bitkit.models.BannerItem -import to.bitkit.models.HwWallet import to.bitkit.models.MoneyType import to.bitkit.models.Suggestion import to.bitkit.models.TransportType @@ -124,7 +123,6 @@ import to.bitkit.models.WidgetSize import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition import to.bitkit.models.effectiveSize -import to.bitkit.models.toBalance import to.bitkit.models.widget.ArticleModel import to.bitkit.models.widget.BlockModel import to.bitkit.ui.LocalBalances @@ -379,7 +377,7 @@ fun HomeScreen( onNavigateToAppStatus = { rootNavController.navigate(Routes.AppStatus) }, onNavigateToSettingUp = { rootNavController.navigate(Routes.SettingUp) }, onNavigateToAllActivity = { rootNavController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onNavigateToActivityItem = { rootNavController.navigateToActivityItem(it) }, + onNavigateToActivityItem = { id, walletId -> rootNavController.navigateToActivityItem(id, walletId) }, onNavigateToSavings = { walletNavController.navigate(Routes.Savings) }, onNavigateToSpending = { walletNavController.navigate(Routes.Spending) }, onClickHardwareWallet = { walletNavController.navigateTo(Routes.HardwareWallet(it)) }, @@ -417,7 +415,7 @@ private fun Content( onNavigateToAppStatus: () -> Unit = {}, onNavigateToSettingUp: () -> Unit = {}, onNavigateToAllActivity: () -> Unit = {}, - onNavigateToActivityItem: (Activity) -> Unit = {}, + onNavigateToActivityItem: (String, String) -> Unit = { _, _ -> }, onNavigateToSavings: () -> Unit = {}, onNavigateToSpending: () -> Unit = {}, onClickHardwareWallet: (String) -> Unit = {}, @@ -588,7 +586,7 @@ private fun WalletPage( onRefresh: () -> Unit, onNavigateToSettingUp: () -> Unit, onNavigateToAllActivity: () -> Unit, - onNavigateToActivityItem: (Activity) -> Unit, + onNavigateToActivityItem: (String, String) -> Unit, onNavigateToSavings: () -> Unit, onNavigateToSpending: () -> Unit, onClickHardwareWallet: (String) -> Unit, @@ -699,7 +697,7 @@ private fun WalletPage( @Composable private fun BalancesSection( balances: BalanceState, - hardwareWallets: ImmutableList, + hardwareWallets: ImmutableList, onNavigateToSavings: () -> Unit, onNavigateToSpending: () -> Unit, onClickHardwareWallet: (String) -> Unit, @@ -742,7 +740,7 @@ private fun BalancesSection( */ @Composable private fun HwDevices( - wallets: ImmutableList, + wallets: ImmutableList, onClick: (String) -> Unit, ) { wallets.chunked(2).forEach { rowWallets -> @@ -767,7 +765,7 @@ private fun HwDevices( @Composable private fun RowScope.HwDeviceCell( - wallet: HwWallet, + wallet: HwWalletUi, onClick: (String) -> Unit, ) { WalletBalanceView( @@ -1480,7 +1478,7 @@ private val previewWeather = WeatherModel( private val previewLatestActivities = previewActivityItems.take(3).toImmutableList() private val previewBanners = ActivityBannerType.entries.map { BannerItem(type = it, title = "") }.toImmutableList() private val previewSuggestions = Suggestion.entries.take(4).toImmutableList() -private val previewHardwareWalletBt = HwWallet( +private val previewHardwareWalletBt = HwWalletUi( id = "trezor-1", name = "Trezor Safe 5", model = "Safe 5", @@ -1489,7 +1487,7 @@ private val previewHardwareWalletBt = HwWallet( balanceSats = 10_562_411uL, activities = persistentListOf(), ) -private val previewHardwareWalletUsb = HwWallet( +private val previewHardwareWalletUsb = HwWalletUi( id = "trezor-2", name = "Trezor Model T", model = "Model T", @@ -1498,7 +1496,7 @@ private val previewHardwareWalletUsb = HwWallet( balanceSats = 2_735_180uL, activities = persistentListOf(), ) -private val previewHardwareWalletThird = HwWallet( +private val previewHardwareWalletThird = HwWalletUi( id = "trezor-3", name = "Trezor Safe 3", model = "Safe 3", diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt index 0156048dbd..5a8f0b43aa 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt @@ -5,7 +5,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.data.dto.price.PriceDTO import to.bitkit.models.BannerItem -import to.bitkit.models.HwWallet import to.bitkit.models.Suggestion import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition @@ -20,7 +19,7 @@ import to.bitkit.ui.screens.widgets.blocks.WeatherModel @Stable data class HomeUiState( val suggestions: ImmutableList = persistentListOf(), - val hardwareWallets: ImmutableList = persistentListOf(), + val hardwareWallets: ImmutableList = persistentListOf(), val banners: ImmutableList = persistentListOf(), val showWidgets: Boolean = false, val widgetsWithPosition: ImmutableList = persistentListOf(), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt index 1e6f6fe92e..e763007f95 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt @@ -118,7 +118,9 @@ class HomeViewModel @Inject constructor( viewModelScope.launch { hwWalletRepo.wallets.collect { wallets -> - _uiState.update { it.copy(hardwareWallets = wallets) } + _uiState.update { + it.copy(hardwareWallets = wallets.map { wallet -> wallet.toUi() }.toImmutableList()) + } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HwWalletViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HwWalletViewModel.kt index ed8c2bf239..5684f20ada 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HwWalletViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HwWalletViewModel.kt @@ -4,9 +4,14 @@ import android.content.Context import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.Activity import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -14,7 +19,9 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.models.HwWallet +import to.bitkit.models.HwWalletBalance import to.bitkit.models.Toast +import to.bitkit.models.TransportType import to.bitkit.repositories.HwWalletRepo import to.bitkit.ui.shared.toast.ToastEventBus import javax.inject.Inject @@ -25,19 +32,28 @@ class HwWalletViewModel @Inject constructor( private val hwWalletRepo: HwWalletRepo, ) : ViewModel() { - val wallets: StateFlow> = hwWalletRepo.wallets + private val _wallets = MutableStateFlow>(persistentListOf()) + val wallets: StateFlow> = _wallets.asStateFlow() val walletsLoaded: StateFlow = hwWalletRepo.walletsLoaded private val _uiState = MutableStateFlow(HwWalletDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun onRemoveClick(wallet: HwWallet) = _uiState.update { it.copy(isPendingRemoval = wallet) } + init { + viewModelScope.launch { + hwWalletRepo.wallets.collect { wallets -> + _wallets.update { wallets.map { it.toUi() }.toImmutableList() } + } + } + } + + fun onRemoveClick(wallet: HwWalletUi) = _uiState.update { it.copy(pendingRemovalId = wallet.id) } - fun onDismissRemoveDialog() = _uiState.update { it.copy(isPendingRemoval = null) } + fun onDismissRemoveDialog() = _uiState.update { it.copy(pendingRemovalId = null) } fun removeDevice(deviceId: String) { viewModelScope.launch { - _uiState.update { it.copy(isPendingRemoval = null) } + _uiState.update { it.copy(pendingRemovalId = null) } hwWalletRepo.removeDevice(deviceId).onFailure { ToastEventBus.send( type = Toast.ToastType.ERROR, @@ -51,5 +67,33 @@ class HwWalletViewModel @Inject constructor( @Immutable data class HwWalletDetailUiState( - val isPendingRemoval: HwWallet? = null, + val pendingRemovalId: String? = null, +) + +/** Stable hardware wallet projection for Compose screens. */ +@Immutable +data class HwWalletUi( + val id: String, + val name: String, + val model: String?, + val transportType: TransportType, + val isConnected: Boolean, + val balanceSats: ULong, + val activities: ImmutableList, + val fundingBalanceSats: ULong = balanceSats, + val deviceIds: ImmutableSet = persistentSetOf(id), +) + +fun HwWalletUi.toBalance() = HwWalletBalance(id = id, sats = balanceSats) + +fun HwWallet.toUi() = HwWalletUi( + id = id, + name = name, + model = model, + transportType = transportType, + isConnected = isConnected, + balanceSats = balanceSats, + activities = activities, + fundingBalanceSats = fundingBalanceSats, + deviceIds = deviceIds, ) 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 dd9d5cd21e..368aabc417 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt @@ -62,7 +62,7 @@ fun SavingsWalletScreen( onchainActivities: ImmutableList, onAllActivityButtonClick: () -> Unit, onEmptyActivityRowClick: () -> Unit, - onActivityItemClick: (Activity) -> Unit, + onActivityItemClick: (String, String) -> Unit, onTransferToSpendingClick: () -> Unit, onBackClick: () -> Unit, forceCloseRemainingDuration: String? = null, @@ -200,7 +200,7 @@ private fun Preview() { isGeoBlocked = false, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, @@ -220,7 +220,7 @@ private fun PreviewTransfer() { isGeoBlocked = false, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, @@ -243,7 +243,7 @@ private fun PreviewNoActivity() { isGeoBlocked = false, onchainActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, @@ -263,7 +263,7 @@ private fun PreviewGeoBlocked() { isGeoBlocked = true, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, @@ -283,7 +283,7 @@ private fun PreviewEmpty() { isGeoBlocked = false, onchainActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index 9432a30df7..dbb8f3aaac 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt @@ -63,7 +63,7 @@ fun SpendingWalletScreen( channels: ImmutableList, lightningActivities: ImmutableList, onAllActivityButtonClick: () -> Unit, - onActivityItemClick: (Activity) -> Unit, + onActivityItemClick: (String, String) -> Unit, onEmptyActivityRowClick: () -> Unit, onTransferToSavingsClick: () -> Unit, onTransferFromSavingsClick: () -> Unit, @@ -223,7 +223,7 @@ private fun Preview() { channels = persistentListOf(createChannelDetails()), lightningActivities = previewLightningActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, @@ -244,7 +244,7 @@ private fun PreviewTransfer() { channels = persistentListOf(createChannelDetails()), lightningActivities = previewLightningActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, @@ -268,7 +268,7 @@ private fun PreviewNoActivity() { channels = persistentListOf(createChannelDetails()), lightningActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, @@ -289,7 +289,7 @@ private fun PreviewEmpty() { channels = persistentListOf(), lightningActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, @@ -309,7 +309,7 @@ private fun PreviewEmptyWithSavings() { channels = persistentListOf(), lightningActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 1dca81d3af..0b9e72030e 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,18 +55,27 @@ import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import to.bitkit.R +import to.bitkit.ext.confirmed import to.bitkit.ext.contact import to.bitkit.ext.create +import to.bitkit.ext.doesExist import to.bitkit.ext.ellipsisMiddle -import to.bitkit.ext.isHardwareWalletActivity +import to.bitkit.ext.fee +import to.bitkit.ext.feeRate +import to.bitkit.ext.isBoosted import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer +import to.bitkit.ext.message +import to.bitkit.ext.paymentState import to.bitkit.ext.rawId import to.bitkit.ext.timestamp import to.bitkit.ext.toActivityItemDate import to.bitkit.ext.toActivityItemTime import to.bitkit.ext.totalValue +import to.bitkit.ext.txType +import to.bitkit.ext.value import to.bitkit.ext.walletId +import to.bitkit.models.ActivityWalletType import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat @@ -101,6 +110,7 @@ import to.bitkit.ui.utils.copyToClipboard import to.bitkit.ui.utils.getScreenTitleRes import to.bitkit.viewmodels.ActivityDetailViewModel import to.bitkit.viewmodels.ActivityListViewModel +import to.bitkit.viewmodels.TransferOrderAmounts @Suppress("CyclomaticComplexMethod") @Composable @@ -108,7 +118,7 @@ fun ActivityDetailScreen( listViewModel: ActivityListViewModel, detailViewModel: ActivityDetailViewModel = hiltViewModel(), route: Routes.ActivityDetail, - onExploreClick: (Activity) -> Unit, + onExploreClick: (String, String) -> Unit, onAssignContactClick: (String) -> Unit, onBackClick: () -> Unit, onCloseClick: () -> Unit, @@ -180,7 +190,7 @@ fun ActivityDetailScreen( is ActivityDetailViewModel.ActivityLoadState.Success -> { val item = loadState.activity - val isHardware = remember(item) { item.isHardwareWalletActivity() } + val isHardware = remember(item.walletId()) { ActivityWalletType.TREZOR.owns(item.walletId()) } val app = appViewModel ?: return@Box val settings = settingsViewModel ?: return@Box val hideBalance by settings.hideBalance.collectAsStateWithLifecycle() @@ -209,7 +219,7 @@ fun ActivityDetailScreen( } // Update boostTxDoesExist when boostTxIds change - LaunchedEffect(if (item is Activity.Onchain) item.v1.boostTxIds else emptyList()) { + LaunchedEffect(if (item is Activity.Onchain) item.v1.boostTxIds else persistentListOf()) { if (item is Activity.Onchain && item.v1.boostTxIds.isNotEmpty()) { boostTxDoesExist = detailViewModel.getBoostTxDoesExist(item.v1.boostTxIds) } @@ -275,7 +285,8 @@ fun ActivityDetailScreen( (item as? Activity.Onchain)?.let { BoostTransactionSheet( onDismiss = detailViewModel::onDismissBoostSheet, - item = it, + activityId = it.rawId(), + walletId = it.walletId(), onSuccess = { app.toast( type = Toast.ToastType.SUCCESS, @@ -328,7 +339,7 @@ private fun ActivityDetailContent( onAssignClick: () -> Unit, onDetachClick: () -> Unit, onClickBoost: () -> Unit, - onExploreClick: (Activity) -> Unit, + onExploreClick: (String, String) -> Unit, onChannelClick: ((String) -> Unit)?, detailViewModel: ActivityDetailViewModel? = null, isCpfpChild: Boolean = false, @@ -353,30 +364,24 @@ private fun ActivityDetailContent( val amountPrefix = if (isSent) "-" else "+" val timestamp = item.timestamp() - val paymentValue = when (item) { - is Activity.Lightning -> item.v1.value - is Activity.Onchain -> item.v1.value - } - val baseFee = when (item) { - is Activity.Lightning -> item.v1.fee - is Activity.Onchain -> item.v1.fee - } + val paymentValue = item.value() + val baseFee = item.fee() val isSelfSend = isSent && paymentValue == 0uL val channelId = (item as? Activity.Onchain)?.v1?.channelId val txId = (item as? Activity.Onchain)?.v1?.txId - var order by remember { mutableStateOf(null) } + var orderAmounts by remember { mutableStateOf(null) } LaunchedEffect(item, isTransferToSpending, detailViewModel) { - order = if (isTransferToSpending && detailViewModel != null) { - detailViewModel.findOrderForTransfer(channelId, txId) + orderAmounts = if (isTransferToSpending && detailViewModel != null) { + detailViewModel.findTransferOrderAmounts(channelId, txId) } else { null } } - val orderServiceFee: ULong? = order?.let { it.feeSat - it.clientBalanceSat } - val transferAmount: ULong? = order?.clientBalanceSat + val orderServiceFee: ULong? = orderAmounts?.serviceFee + val transferAmount: ULong? = orderAmounts?.transferAmount val fee: ULong? = when { isTransferToSpending && orderServiceFee != null && baseFee != null -> baseFee + orderServiceFee @@ -557,8 +562,8 @@ private fun ActivityDetailContent( ) // Note section for Lightning payments with message - if (item is Activity.Lightning && item.v1.message.isNotEmpty()) { - val message = item.v1.message + if (item is Activity.Lightning && item.message().isNotEmpty()) { + val message = item.message() Column( modifier = Modifier .fillMaxWidth() @@ -664,12 +669,11 @@ private fun ActivityDetailContent( val hasCompletedBoost = when (item) { is Activity.Lightning -> false is Activity.Onchain -> { - val activity = item.v1 - if (activity.isBoosted && activity.boostTxIds.isNotEmpty()) { - if (activity.txType == PaymentType.SENT) { + if (item.isBoosted() && item.v1.boostTxIds.isNotEmpty()) { + if (item.txType() == PaymentType.SENT) { true } else { - activity.boostTxIds.any { boostTxDoesExist[it] == true } + item.v1.boostTxIds.any { boostTxDoesExist[it] == true } } } else { false @@ -709,7 +713,7 @@ private fun ActivityDetailContent( PrimaryButton( text = stringResource(R.string.wallet__activity_explore), size = ButtonSize.Small, - onClick = { onExploreClick(item) }, + onClick = { onExploreClick(item.rawId(), item.walletId()) }, icon = { Icon( painter = painterResource(R.drawable.ic_git_branch), @@ -855,7 +859,7 @@ private fun StatusSection( Row(verticalAlignment = Alignment.CenterVertically) { when (item) { is Activity.Lightning -> { - when (item.v1.status) { + when (item.paymentState()) { PaymentState.PENDING -> { StatusRow( painterResource(R.drawable.ic_hourglass_simple), @@ -879,6 +883,8 @@ private fun StatusSection( Colors.Purple, ) } + + null -> Unit } } @@ -889,29 +895,29 @@ private fun StatusSection( var statusText = stringResource(R.string.wallet__activity_confirming) var statusTestTag: String? = null - if (item.v1.isTransfer) { + if (item.isTransfer()) { val context = LocalContext.current - val duration = context.getFeeShortDescription(item.v1.feeRate, feeRates) + val duration = context.getFeeShortDescription(item.feeRate(), feeRates) statusText = stringResource(R.string.wallet__activity_transfer_pending) .replace("{duration}", duration) statusTestTag = "StatusTransfer" } - if (item.v1.isBoosted) { + if (item.isBoosted()) { statusIcon = painterResource(R.drawable.ic_timer_alt) statusColor = Colors.Yellow statusText = stringResource(R.string.wallet__activity_boosting) statusTestTag = "StatusBoosting" } - if (item.v1.confirmed) { + if (item.confirmed() == true) { statusIcon = painterResource(R.drawable.ic_check_circle) statusColor = Colors.Green statusText = stringResource(R.string.wallet__activity_confirmed) statusTestTag = "StatusConfirmed" } - if (!item.v1.doesExist) { + if (!item.doesExist()) { statusIcon = painterResource(R.drawable.ic_x) statusColor = Colors.Red statusText = stringResource(R.string.wallet__activity_removed) @@ -984,25 +990,14 @@ private fun ZigzagDivider() { private fun PreviewLightningSent() { AppThemeSurface { ActivityDetailContent( - item = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-1", - txType = PaymentType.SENT, - status = PaymentState.SUCCEEDED, - value = 50000UL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1UL, - message = "Thanks for paying at the bar. Here's my share.", - ) - ), + item = previewLightningDetailItem(), assignedContact = null, tags = persistentListOf("Lunch", "Drinks"), onRemoveTag = {}, onAddTagClick = {}, onAssignClick = {}, onDetachClick = {}, - onExploreClick = {}, + onExploreClick = { _, _ -> }, onChannelClick = null, onCopy = {}, onClickBoost = {} @@ -1015,27 +1010,14 @@ private fun PreviewLightningSent() { private fun PreviewOnchain() { AppThemeSurface { ActivityDetailContent( - item = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-1", - txType = PaymentType.RECEIVED, - txId = "abc123", - value = 100000UL, - fee = 500UL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000 - 3600).toULong(), - confirmed = true, - feeRate = 8UL, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - ) - ), + item = previewOnchainDetailItem(), assignedContact = null, tags = persistentListOf(), onRemoveTag = {}, onAddTagClick = {}, onAssignClick = {}, onDetachClick = {}, - onExploreClick = {}, + onExploreClick = { _, _ -> }, onChannelClick = null, onCopy = {}, onClickBoost = {}, @@ -1051,25 +1033,14 @@ private fun PreviewSheetSmallScreen() { modifier = Modifier.sheetHeight(), ) { ActivityDetailContent( - item = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-1", - txType = PaymentType.SENT, - status = PaymentState.SUCCEEDED, - value = 50000UL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1UL, - message = "Thanks for paying at the bar. Here's my share.", - ) - ), + item = previewLightningDetailItem(), assignedContact = null, tags = persistentListOf("Lunch", "Drinks"), onRemoveTag = {}, onAddTagClick = {}, onAssignClick = {}, onDetachClick = {}, - onExploreClick = {}, + onExploreClick = { _, _ -> }, onChannelClick = null, onCopy = {}, onClickBoost = {}, @@ -1089,29 +1060,61 @@ private fun shouldEnableBoostButton( if (isHardware) return false if (item !is Activity.Onchain) return false - val activity = item.v1 - // Check all disable conditions - val shouldDisable = isCpfpChild || !activity.doesExist || activity.confirmed || - (activity.isBoosted && isBoostCompleted(activity, boostTxDoesExist)) + val shouldDisable = isCpfpChild || !item.doesExist() || item.confirmed() == true || + (item.isBoosted() && isBoostCompleted(item, boostTxDoesExist)) if (shouldDisable) return false // Enable if not a transfer and has value - return !activity.isTransfer && activity.value > 0uL + return !item.isTransfer() && item.value() > 0uL } @ReadOnlyComposable @Composable private fun isBoostCompleted( - activity: OnchainActivity, + activity: Activity.Onchain, boostTxDoesExist: ImmutableMap, ): Boolean { - if (activity.boostTxIds.isEmpty()) return true + if (activity.v1.boostTxIds.isEmpty()) return true - if (activity.txType == PaymentType.SENT) { + if (activity.txType() == PaymentType.SENT) { return true } else { - return activity.boostTxIds.any { boostTxDoesExist[it] == true } + return activity.v1.boostTxIds.any { boostTxDoesExist[it] == true } } } + +private fun previewLightningDetailItem(): Activity.Lightning { + val timestamp = 1_700_000_000uL + return Activity.Lightning( + v1 = LightningActivity.create( + id = "test-lightning-1", + txType = PaymentType.SENT, + status = PaymentState.SUCCEEDED, + value = 50_000UL, + invoice = "lnbc...", + timestamp = timestamp, + fee = 1UL, + message = "Thanks for paying at the bar. Here's my share.", + ), + ) +} + +private fun previewOnchainDetailItem(): Activity.Onchain { + val timestamp = 1_699_996_400uL + return Activity.Onchain( + v1 = OnchainActivity.create( + id = "test-onchain-1", + txType = PaymentType.RECEIVED, + value = 100_000UL, + fee = 500UL, + timestamp = timestamp, + txId = "abc123", + address = "bc1...", + feeRate = 8UL, + confirmed = true, + confirmTimestamp = 1_700_000_000uL, + ), + ) +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt index 7420158680..8985407117 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt @@ -46,6 +46,7 @@ import to.bitkit.ext.create import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.isSent import to.bitkit.ext.totalValue +import to.bitkit.ext.txType import to.bitkit.models.Toast import to.bitkit.ui.Routes import to.bitkit.ui.appViewModel @@ -303,8 +304,11 @@ private fun ColumnScope.OnchainDetails( valueContent = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { txDetails.inputs.forEach { input -> - val text = "${input.txid}:${input.vout}" - BodySSB(text = text, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) + BodySSB( + text = "${input.txid}:${input.vout}", + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) } } }, @@ -317,8 +321,11 @@ private fun ColumnScope.OnchainDetails( valueContent = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { txDetails.outputs.forEach { output -> - val address = output.scriptpubkeyAddress ?: "" - BodySSB(text = address, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) + BodySSB( + text = output.scriptpubkeyAddress ?: "", + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) } } }, @@ -339,7 +346,7 @@ private fun ColumnScope.OnchainDetails( val boostTxIds = onchain.v1.boostTxIds if (boostTxIds.isNotEmpty()) { boostTxIds.forEachIndexed { index, boostedTxId -> - val isRbf = onchain.v1.txType == PaymentType.SENT || !(boostTxDoesExist[boostedTxId] ?: true) + val isRbf = onchain.txType() == PaymentType.SENT || !(boostTxDoesExist[boostedTxId] ?: true) Section( title = stringResource( if (isRbf) R.string.wallet__activity_boosted_rbf else R.string.wallet__activity_boosted_cpfp @@ -389,19 +396,7 @@ private fun Section( private fun PreviewLightning() { AppThemeSurface { ActivityExploreContent( - item = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-1", - txType = PaymentType.SENT, - status = PaymentState.SUCCEEDED, - value = 50000UL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1UL, - message = "Thanks for paying at the bar. Here's my share.", - preimage = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - ), - ), + item = previewLightningDetailItem(), ) } } @@ -411,20 +406,42 @@ private fun PreviewLightning() { private fun PreviewOnchain() { AppThemeSurface { ActivityExploreContent( - item = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-1", - txType = PaymentType.RECEIVED, - txId = "abc123", - value = 100000UL, - fee = 500UL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000 - 3600).toULong(), - confirmed = true, - feeRate = 8UL, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - ), - ), + item = previewOnchainDetailItem(), ) } } + +private fun previewLightningDetailItem(): Activity.Lightning { + val timestamp = 1_700_000_000uL + return Activity.Lightning( + v1 = LightningActivity.create( + id = "test-lightning-1", + txType = PaymentType.SENT, + status = PaymentState.SUCCEEDED, + value = 50_000UL, + invoice = "lnbc...", + timestamp = timestamp, + fee = 1UL, + message = "Thanks for paying at the bar. Here's my share.", + preimage = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ), + ) +} + +private fun previewOnchainDetailItem(): Activity.Onchain { + val timestamp = 1_699_996_400uL + return Activity.Onchain( + v1 = OnchainActivity.create( + id = "test-onchain-1", + txType = PaymentType.RECEIVED, + value = 100_000UL, + fee = 500UL, + timestamp = timestamp, + txId = "abc123", + address = "bc1...", + feeRate = 8UL, + confirmed = true, + confirmTimestamp = 1_700_000_000uL, + ), + ) +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt index b8828d7318..a596ab12a0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt @@ -40,7 +40,7 @@ import to.bitkit.viewmodels.ActivityListViewModel fun AllActivityScreen( viewModel: ActivityListViewModel, onBack: () -> Unit, - onActivityItemClick: (Activity) -> Unit, + onActivityItemClick: (String, String) -> Unit, ) { val app = appViewModel ?: return val filteredActivities by viewModel.filteredActivities.collectAsStateWithLifecycle() @@ -90,7 +90,7 @@ private fun AllActivityScreenContent( onBackClick: () -> Unit, onTagClick: () -> Unit, onDateRangeClick: () -> Unit, - onActivityItemClick: (Activity) -> Unit, + onActivityItemClick: (String, String) -> Unit, onEmptyActivityRowClick: () -> Unit, ) { val listState = rememberLazyListState() @@ -167,7 +167,7 @@ private fun Preview() { onBackClick = {}, onTagClick = {}, onDateRangeClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onRemoveTag = {}, onEmptyActivityRowClick = {}, ) @@ -192,7 +192,7 @@ private fun PreviewEmpty() { onTagClick = {}, onDateRangeClick = {}, onRemoveTag = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt index 1127c8c768..f0f1af3495 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt @@ -19,12 +19,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.Activity -import com.synonym.bitkitcore.LightningActivity -import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import to.bitkit.R -import to.bitkit.ext.create import to.bitkit.ext.doesExist import to.bitkit.ext.isBoosting import to.bitkit.ext.isTransfer @@ -32,6 +29,7 @@ import to.bitkit.ext.paymentState import to.bitkit.ext.txType import to.bitkit.models.PubkyProfile import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -44,14 +42,17 @@ fun ActivityIcon( isHardware: Boolean = false, contact: PubkyProfile? = null, ) { - val isLightning = activity is Activity.Lightning - val isBoosting = activity.isBoosting() - val status = activity.paymentState() val txType = activity.txType() - val arrowIcon = painterResource(if (txType == PaymentType.SENT) R.drawable.ic_sent else R.drawable.ic_received) + val arrowIcon = painterResource( + id = if (txType == PaymentType.SENT) { + R.drawable.ic_sent + } else { + R.drawable.ic_received + }, + ) when { - isCpfpChild || isBoosting -> { + isCpfpChild || activity.isBoosting() -> { CircularIcon( icon = painterResource(R.drawable.ic_timer_alt), iconColor = Colors.Yellow, @@ -67,21 +68,30 @@ fun ActivityIcon( testTag = "ActivityContactAvatar", modifier = modifier ) - isLightning -> ActivityIconLightning(status, size, arrowIcon, modifier) - else -> ActivityIconOnchain(activity, arrowIcon, size, isHardware, modifier) + activity is Activity.Lightning -> ActivityIconLightning(activity.paymentState(), size, arrowIcon, modifier) + else -> ActivityIconOnchain( + txType = txType, + isTransfer = activity.isTransfer(), + doesExist = activity.doesExist(), + arrowIcon = arrowIcon, + size = size, + isHardware = isHardware, + modifier = modifier, + ) } } @Composable private fun ActivityIconOnchain( - activity: Activity, + txType: PaymentType, + isTransfer: Boolean, + doesExist: Boolean, arrowIcon: Painter, size: Dp, isHardware: Boolean, modifier: Modifier = Modifier, ) { - val isTransfer = activity.isTransfer() - val isTransferFromSpending = isTransfer && activity.txType() == PaymentType.RECEIVED + val isTransferFromSpending = isTransfer && txType == PaymentType.RECEIVED val (iconColor, backgroundColor) = when { isHardware -> Colors.Blue to Colors.Blue16 isTransferFromSpending -> Colors.Purple to Colors.Purple16 @@ -95,7 +105,7 @@ private fun ActivityIconOnchain( CircularIcon( icon = when { - !activity.doesExist() -> painterResource(R.drawable.ic_x) + !doesExist -> painterResource(R.drawable.ic_x) isTransfer -> painterResource(R.drawable.ic_transfer) else -> arrowIcon }, @@ -177,145 +187,7 @@ private fun Preview() { verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(16.dp), ) { - // Lightning Sent Succeeded - ActivityIcon( - activity = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-1", - txType = PaymentType.SENT, - status = PaymentState.SUCCEEDED, - value = 50000uL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1uL, - ) - ) - ) - - // Lightning Received Failed - ActivityIcon( - activity = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-2", - txType = PaymentType.RECEIVED, - status = PaymentState.FAILED, - value = 50000uL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1uL, - ) - ) - ) - - // Lightning Pending - ActivityIcon( - activity = Activity.Lightning( - v1 = LightningActivity.create( - id = "test-lightning-3", - txType = PaymentType.SENT, - status = PaymentState.PENDING, - value = 50000uL, - invoice = "lnbc...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - fee = 1uL, - ) - ) - ) - - // Onchain Received - ActivityIcon( - activity = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-1", - txType = PaymentType.RECEIVED, - txId = "abc123", - value = 100000uL, - fee = 500uL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - confirmed = true, - feeRate = 8uL, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - ) - ) - ) - - // Onchain BOOST CPFP - ActivityIcon( - activity = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-1", - txType = PaymentType.RECEIVED, - txId = "abc123", - value = 100000uL, - fee = 500uL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - feeRate = 8uL, - isBoosted = true, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - ) - ) - ) - - // Onchain BOOST RBF - ActivityIcon( - activity = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-1", - txType = PaymentType.SENT, - txId = "abc123", - value = 100000uL, - fee = 500uL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - feeRate = 8uL, - isBoosted = true, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - ) - ) - ) - - // Onchain Transfer - ActivityIcon( - activity = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-2", - txType = PaymentType.SENT, - txId = "abc123", - value = 100000uL, - fee = 500uL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - confirmed = true, - feeRate = 8uL, - isTransfer = true, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - transferTxId = "transferTxId", - ) - ) - ) - - // Onchain Removed - ActivityIcon( - activity = Activity.Onchain( - v1 = OnchainActivity.create( - id = "test-onchain-2", - txType = PaymentType.SENT, - txId = "abc123", - value = 100000uL, - fee = 500uL, - address = "bc1...", - timestamp = (System.currentTimeMillis() / 1000).toULong(), - confirmed = true, - feeRate = 8uL, - isBoosted = true, - doesExist = false, - confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), - transferTxId = "transferTxId", - ) - ) - ) + previewActivityItems.forEach { ActivityIcon(activity = it) } } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt index 9ff6deeb7b..707eed602a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -28,9 +29,9 @@ import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R -import to.bitkit.ext.rawId +import to.bitkit.ext.activityKey +import to.bitkit.ext.groupTimestamp import to.bitkit.ext.scopedId -import to.bitkit.ext.walletId import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption13Up @@ -49,7 +50,7 @@ import java.util.Locale @Composable fun ActivityListGrouped( items: ImmutableList?, - onActivityItemClick: (Activity) -> Unit, + onActivityItemClick: (String, String) -> Unit, onEmptyActivityRowClick: () -> Unit, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), @@ -80,22 +81,17 @@ fun ActivityListGrouped( ) { itemsIndexed( items = groupedItems, - key = { index, item -> + key = { _, item -> when (item) { - is String -> "header_$item" - is Activity -> when (item) { - is Activity.Lightning -> "lightning_${item.walletId()}_${item.rawId()}" - is Activity.Onchain -> "onchain_${item.walletId()}_${item.rawId()}" - } - - else -> "item_$index" + is ActivityGroupHeader -> "header_${item.title}" + is ActivityGroupEntry -> item.item.activityKey() } } ) { index, item -> when (item) { - is String -> { + is ActivityGroupHeader -> { Caption13Up( - text = item, + text = item.title, color = Colors.White64, modifier = Modifier .fillMaxWidth() @@ -108,7 +104,8 @@ fun ActivityListGrouped( ) } - is Activity -> { + is ActivityGroupEntry -> { + val activity = item.item Column( modifier = Modifier .animateItem( @@ -118,12 +115,12 @@ fun ActivityListGrouped( ) ) { ActivityRow( - item = item, + item = activity, onClick = onActivityItemClick, testTag = "$activityTestTagPrefix-$index", - title = titleProvider(item) ?: contactActivityTitle(item, contacts), - isHardware = item.scopedId() in hardwareIds, - contact = if (showContactAvatar) contactForActivity(item, contacts) else null, + title = titleProvider(activity) ?: contactActivityTitle(activity, contacts), + isHardware = activity.scopedId() in hardwareIds, + contact = if (showContactAvatar) contactForActivity(activity, contacts) else null, ) VerticalSpacer(16.dp) } @@ -167,7 +164,7 @@ fun ActivityListGrouped( @Suppress("LongMethod", "LongParameterList") fun LazyListScope.activityListGroupedItems( items: ImmutableList?, - onActivityItemClick: (Activity) -> Unit, + onActivityItemClick: (String, String) -> Unit, onEmptyActivityRowClick: () -> Unit, showFooter: Boolean = false, onAllActivityButtonClick: () -> Unit = {}, @@ -178,22 +175,17 @@ fun LazyListScope.activityListGroupedItems( val groupedItems = groupActivityItems(items) itemsIndexed( items = groupedItems, - key = { index, item -> + key = { _, item -> when (item) { - is String -> "header_$item" - is Activity -> when (item) { - is Activity.Lightning -> "lightning_${item.walletId()}_${item.rawId()}" - is Activity.Onchain -> "onchain_${item.walletId()}_${item.rawId()}" - } - - else -> "item_$index" + is ActivityGroupHeader -> "header_${item.title}" + is ActivityGroupEntry -> item.item.activityKey() } }, ) { index, item -> when (item) { - is String -> { + is ActivityGroupHeader -> { Caption13Up( - text = item, + text = item.title, color = Colors.White64, modifier = Modifier .fillMaxWidth() @@ -206,7 +198,8 @@ fun LazyListScope.activityListGroupedItems( ) } - is Activity -> { + is ActivityGroupEntry -> { + val activity = item.item Column( modifier = Modifier .animateItem( @@ -216,10 +209,10 @@ fun LazyListScope.activityListGroupedItems( ) ) { ActivityRow( - item = item, + item = activity, onClick = onActivityItemClick, testTag = "Activity-$index", - isHardware = item.scopedId() in hardwareIds, + isHardware = activity.scopedId() in hardwareIds, ) VerticalSpacer(16.dp) } @@ -266,7 +259,7 @@ fun LazyListScope.activityListGroupedItems( // region utils @Suppress("CyclomaticComplexMethod") -private fun groupActivityItems(activityItems: List): List { +private fun groupActivityItems(activityItems: List): List { val now = Instant.now() val zoneId = ZoneId.systemDefault() val today = now.atZone(zoneId).truncatedTo(ChronoUnit.DAYS) @@ -286,10 +279,7 @@ private fun groupActivityItems(activityItems: List): List { val earlierItems = mutableListOf() for (item in activityItems) { - val timestamp = when (item) { - is Activity.Lightning -> item.v1.timestamp.toLong() - is Activity.Onchain -> item.v1.timestamp.toLong() - } + val timestamp = item.groupTimestamp().toLong() when { timestamp >= startOfDay -> todayItems.add(item) timestamp >= startOfYesterday -> yesterdayItems.add(item) @@ -302,33 +292,42 @@ private fun groupActivityItems(activityItems: List): List { return buildList { if (todayItems.isNotEmpty()) { - add("TODAY") - addAll(todayItems) + add(ActivityGroupHeader("TODAY")) + addAll(todayItems.map { ActivityGroupEntry(it) }) } if (yesterdayItems.isNotEmpty()) { - add("YESTERDAY") - addAll(yesterdayItems) + add(ActivityGroupHeader("YESTERDAY")) + addAll(yesterdayItems.map { ActivityGroupEntry(it) }) } if (weekItems.isNotEmpty()) { - add("THIS WEEK") - addAll(weekItems) + add(ActivityGroupHeader("THIS WEEK")) + addAll(weekItems.map { ActivityGroupEntry(it) }) } if (monthItems.isNotEmpty()) { - add("THIS MONTH") - addAll(monthItems) + add(ActivityGroupHeader("THIS MONTH")) + addAll(monthItems.map { ActivityGroupEntry(it) }) } if (yearItems.isNotEmpty()) { - add("THIS YEAR") - addAll(yearItems) + add(ActivityGroupHeader("THIS YEAR")) + addAll(yearItems.map { ActivityGroupEntry(it) }) } if (earlierItems.isNotEmpty()) { - add("EARLIER") - addAll(earlierItems) + add(ActivityGroupHeader("EARLIER")) + addAll(earlierItems.map { ActivityGroupEntry(it) }) } } } // endregion +@Immutable +private sealed interface GroupedActivityItem + +@Immutable +private data class ActivityGroupHeader(val title: String) : GroupedActivityItem + +@Immutable +private data class ActivityGroupEntry(val item: Activity) : GroupedActivityItem + @Preview @Composable private fun Preview() { @@ -336,7 +335,7 @@ private fun Preview() { Column(modifier = Modifier.padding(horizontal = 16.dp)) { ActivityListGrouped( items = previewActivityItems, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, ) } @@ -349,7 +348,7 @@ private fun PreviewEmpty() { AppThemeSurface { ActivityListGrouped( items = persistentListOf(), - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, ) } @@ -361,7 +360,7 @@ private fun PreviewEmptyWithFooter() { AppThemeSurface { ActivityListGrouped( items = persistentListOf(), - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, showFooter = true, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt index 2a220d64cc..b20bb4c06b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt @@ -32,7 +32,7 @@ import to.bitkit.ui.theme.AppThemeSurface fun ActivityListSimple( items: ImmutableList?, onAllActivityClick: () -> Unit, - onActivityItemClick: (Activity) -> Unit, + onActivityItemClick: (String, String) -> Unit, hardwareIds: ImmutableSet = persistentSetOf(), ) { if (items.isNullOrEmpty()) return @@ -76,7 +76,7 @@ private fun Preview() { ActivityListSimple( items = previewActivityItems, onAllActivityClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } @@ -88,7 +88,7 @@ private fun PreviewEmpty() { ActivityListSimple( items = persistentListOf(), onAllActivityClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index 3ca8c0f154..501732239d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -29,12 +29,20 @@ import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import to.bitkit.R import to.bitkit.ext.DatePattern +import to.bitkit.ext.confirmed +import to.bitkit.ext.doesExist +import to.bitkit.ext.feeRate import to.bitkit.ext.formatted import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer +import to.bitkit.ext.message +import to.bitkit.ext.paymentState +import to.bitkit.ext.rawId import to.bitkit.ext.timestamp import to.bitkit.ext.totalValue +import to.bitkit.ext.txId import to.bitkit.ext.txType +import to.bitkit.ext.walletId import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PrimaryDisplay import to.bitkit.models.PubkyProfile @@ -64,7 +72,7 @@ import java.time.ZoneId @Composable fun ActivityRow( item: Activity, - onClick: (Activity) -> Unit, + onClick: (String, String) -> Unit, testTag: String, title: String? = null, isHardware: Boolean = false, @@ -75,20 +83,15 @@ fun ActivityRow( } val feeRates = blocktankInfo?.onchain?.feeRates - val status: PaymentState? = when (item) { - is Activity.Lightning -> item.v1.status - is Activity.Onchain -> null - } + val status = item.paymentState() val isLightning = item is Activity.Lightning val timestamp = item.timestamp() - val txType: PaymentType = item.txType() + val txType = item.txType() val isSent = item.isSent() val amountPrefix = if (isSent) "-" else "+" - val confirmed: Boolean? = when (item) { - is Activity.Lightning -> null - is Activity.Onchain -> item.v1.confirmed - } + val confirmed = item.confirmed() val isTransfer = item.isTransfer() + val txId = item.txId() val activityListViewModel = activityListViewModel var isCpfpChild by remember { mutableStateOf(false) } val resolvedTitle = title.takeIf { @@ -98,9 +101,9 @@ fun ActivityRow( shouldUseContactActivityTitle(item, status, isTransfer, isCpfpChild) } - LaunchedEffect(item) { - isCpfpChild = if (item is Activity.Onchain && activityListViewModel != null) { - activityListViewModel.isCpfpChildTransaction(item.v1.txId) + LaunchedEffect(txId) { + isCpfpChild = if (txId != null && activityListViewModel != null) { + activityListViewModel.isCpfpChildTransaction(txId) } else { false } @@ -110,7 +113,7 @@ fun ActivityRow( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickableAlpha { onClick(item) } + .clickableAlpha { onClick(item.rawId(), item.walletId()) } .background(color = Colors.Gray6, shape = Shapes.medium) .padding(16.dp) .testTag(testTag) @@ -136,37 +139,36 @@ fun ActivityRow( title = resolvedTitle, ) val context = LocalContext.current - val subtitleText = when (item) { - is Activity.Lightning -> item.v1.message.ifEmpty { formattedTime(timestamp) } - is Activity.Onchain -> { - when { - !item.v1.doesExist -> stringResource(R.string.wallet__activity_removed) + val subtitleText = if (isLightning) { + item.message().ifEmpty { formattedTime(timestamp) } + } else { + when { + !item.doesExist() -> stringResource(R.string.wallet__activity_removed) - isCpfpChild -> stringResource(R.string.wallet__activity_boost_fee_description) + isCpfpChild -> stringResource(R.string.wallet__activity_boost_fee_description) - isTransfer && isSent -> if (item.v1.confirmed) { - stringResource(R.string.wallet__activity_transfer_spending_done) - } else { - val duration = context.getFeeShortDescription(item.v1.feeRate, feeRates) - stringResource(R.string.wallet__activity_transfer_spending_pending) - .replace("{duration}", duration) - } + isTransfer && isSent -> if (confirmed == true) { + stringResource(R.string.wallet__activity_transfer_spending_done) + } else { + val duration = context.getFeeShortDescription(item.feeRate(), feeRates) + stringResource(R.string.wallet__activity_transfer_spending_pending) + .replace("{duration}", duration) + } - isTransfer && !isSent -> if (item.v1.confirmed) { - stringResource(R.string.wallet__activity_transfer_savings_done) - } else { - val duration = context.getFeeShortDescription(item.v1.feeRate, feeRates) - stringResource(R.string.wallet__activity_transfer_savings_pending) - .replace("{duration}", duration) - } + isTransfer && !isSent -> if (confirmed == true) { + stringResource(R.string.wallet__activity_transfer_savings_done) + } else { + val duration = context.getFeeShortDescription(item.feeRate(), feeRates) + stringResource(R.string.wallet__activity_transfer_savings_pending) + .replace("{duration}", duration) + } - confirmed == true -> formattedTime(timestamp) + confirmed == true -> formattedTime(timestamp) - else -> { - val feeDescription = context.getFeeShortDescription(item.v1.feeRate, feeRates) - stringResource(R.string.wallet__activity_confirms_in) - .replace("{feeRateDescription}", feeDescription) - } + else -> { + val feeDescription = context.getFeeShortDescription(item.feeRate(), feeRates) + stringResource(R.string.wallet__activity_confirms_in) + .replace("{feeRateDescription}", feeDescription) } } } @@ -192,9 +194,9 @@ private fun shouldUseContactActivityTitle( ): Boolean { if (isTransfer || isCpfpChild) return false - return when (activity) { - is Activity.Lightning -> status == PaymentState.SUCCEEDED - is Activity.Onchain -> activity.v1.doesExist + return when { + activity is Activity.Lightning -> status == PaymentState.SUCCEEDED + else -> activity.doesExist() } } @@ -386,7 +388,7 @@ private fun Preview(@PreviewParameter(ActivityItemsPreviewProvider::class) item: AppThemeSurface { ActivityRow( item = item, - onClick = {}, + onClick = { _, _ -> }, testTag = "Activity-", ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/utils/PreviewItems.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/utils/PreviewItems.kt index c74fb3c9a0..3fce59762f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/utils/PreviewItems.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/utils/PreviewItems.kt @@ -8,6 +8,7 @@ import com.synonym.bitkitcore.PaymentType import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import to.bitkit.ext.create +import to.bitkit.models.ActivityWalletType import java.util.Calendar val previewActivityItems: ImmutableList = buildList { @@ -19,97 +20,119 @@ val previewActivityItems: ImmutableList = buildList { fun Calendar.epochSecond() = (timeInMillis / 1000).toULong() - // Today add( - Activity.Onchain( - OnchainActivity.create( - id = "1", - txType = PaymentType.RECEIVED, - txId = "01", - value = 42_000_u, - fee = 200_u, - address = "bc1", - confirmed = true, - timestamp = today.epochSecond(), - isBoosted = true, - boostTxIds = listOf("02", "03"), - doesExist = false, - confirmTimestamp = today.epochSecond(), - channelId = "channelId", - transferTxId = "transferTxId", - createdAt = today.epochSecond() - 30_000u, - updatedAt = today.epochSecond(), - ) + onchainPreviewItem( + id = "1", + txType = PaymentType.RECEIVED, + value = 42_000uL, + fee = 200uL, + timestamp = today.epochSecond(), + txId = "01", + confirmed = true, + isBoosted = true, + doesExist = false, ) ) - // Yesterday add( - Activity.Lightning( - LightningActivity.create( - id = "2", - txType = PaymentType.SENT, - status = PaymentState.PENDING, - value = 30_000_u, - invoice = "lnbc2", - timestamp = yesterday.epochSecond(), - fee = 15_u, - message = "Custom very long lightning activity message to test truncation", - preimage = "preimage1", - ) + lightningPreviewItem( + id = "2", + txType = PaymentType.SENT, + status = PaymentState.PENDING, + value = 30_000uL, + fee = 15uL, + timestamp = yesterday.epochSecond(), + message = "Custom very long lightning activity message to test truncation", ) ) - // This Week add( - Activity.Lightning( - LightningActivity.create( - id = "3", - txType = PaymentType.RECEIVED, - status = PaymentState.FAILED, - value = 217_000_u, - invoice = "lnbc3", - timestamp = thisWeek.epochSecond(), - fee = 17_u, - preimage = "preimage2", - ) + lightningPreviewItem( + id = "3", + txType = PaymentType.RECEIVED, + status = PaymentState.FAILED, + value = 217_000uL, + fee = 17uL, + timestamp = thisWeek.epochSecond(), ) ) - // This Month add( - Activity.Onchain( - OnchainActivity.create( - id = "4", - txType = PaymentType.SENT, - txId = "04", - value = 950_000_u, - fee = 110_u, - address = "bc1", - timestamp = thisMonth.epochSecond(), - isTransfer = true, - confirmTimestamp = today.epochSecond() + 3600u, - channelId = "channelId", - transferTxId = "transferTxId", - ) + onchainPreviewItem( + id = "4", + txType = PaymentType.SENT, + value = 950_000uL, + fee = 110uL, + timestamp = thisMonth.epochSecond(), + txId = "04", + isTransfer = true, ) ) - // Last Year add( - Activity.Lightning( - LightningActivity.create( - id = "5", - txType = PaymentType.SENT, - status = PaymentState.SUCCEEDED, - value = 200_000_u, - invoice = "lnbc…", - timestamp = lastYear.epochSecond(), - fee = 1_u, - ) + lightningPreviewItem( + id = "5", + txType = PaymentType.SENT, + status = PaymentState.SUCCEEDED, + value = 200_000uL, + fee = 1uL, + timestamp = lastYear.epochSecond(), ) ) }.toImmutableList() fun previewOnchainActivityItems() = previewActivityItems.filterIsInstance().toImmutableList() + fun previewLightningActivityItems() = previewActivityItems.filterIsInstance().toImmutableList() + +@Suppress("LongParameterList") +private fun lightningPreviewItem( + id: String, + txType: PaymentType, + status: PaymentState, + value: ULong, + fee: ULong, + timestamp: ULong, + message: String = "", +) = Activity.Lightning( + LightningActivity.create( + id = id, + walletId = ActivityWalletType.BITKIT.id, + txType = txType, + status = status, + value = value, + fee = fee, + invoice = "lnbc$id", + message = message, + timestamp = timestamp, + ) +) + +@Suppress("LongParameterList") +private fun onchainPreviewItem( + id: String, + txType: PaymentType, + value: ULong, + fee: ULong, + timestamp: ULong, + txId: String, + confirmed: Boolean = false, + isBoosted: Boolean = false, + isTransfer: Boolean = false, + doesExist: Boolean = true, +) = Activity.Onchain( + OnchainActivity.create( + id = id, + walletId = ActivityWalletType.BITKIT.id, + txType = txType, + value = value, + fee = fee, + txId = txId, + address = "bc1qpreview$id", + timestamp = timestamp, + confirmed = confirmed, + isBoosted = isBoosted, + isTransfer = isTransfer, + doesExist = doesExist, + ) +) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt index ffd725a7fe..b39498f401 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionScreen.kt @@ -37,7 +37,6 @@ import to.bitkit.R import to.bitkit.ext.uniqueUtxoKey import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.LocalCurrencies -import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.BottomSheetPreview @@ -67,11 +66,7 @@ fun SendCoinSelectionScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val tagsByTxId by viewModel.tagsByTxId.collectAsStateWithLifecycle() - val activity = activityListViewModel ?: return - val onchainActivities by activity.onchainActivities.collectAsStateWithLifecycle() - - LaunchedEffect(requiredAmount, onchainActivities) { - viewModel.setOnchainActivities(onchainActivities.orEmpty()) + LaunchedEffect(requiredAmount, address) { viewModel.loadUtxos(requiredAmount, address) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt index 0d771fb139..0fbebd8249 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt @@ -3,8 +3,6 @@ package to.bitkit.ui.screens.wallets.send import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.synonym.bitkitcore.Activity -import com.synonym.bitkitcore.Activity.Onchain import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -20,7 +18,6 @@ import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.SpendableUtxo import to.bitkit.di.BgDispatcher import to.bitkit.env.Defaults -import to.bitkit.ext.rawId import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.LightningRepo import to.bitkit.ui.shared.toast.ToastEventBus @@ -43,12 +40,6 @@ class SendCoinSelectionViewModel @Inject constructor( private val _tagsByTxId = MutableStateFlow>>(persistentMapOf()) val tagsByTxId = _tagsByTxId.asStateFlow() - private var onchainActivities: List = emptyList() - - fun setOnchainActivities(onchainActivities: List) { - this.onchainActivities = onchainActivities - } - fun loadUtxos(requiredAmount: ULong, address: String) = viewModelScope.launch { runCatching { val sortedUtxos = lightningRepo.listSpendableOutputs().getOrThrow() @@ -82,13 +73,10 @@ class SendCoinSelectionViewModel @Inject constructor( if (_tagsByTxId.value.containsKey(txId)) return viewModelScope.launch(bgDispatcher) { - // find activity by txId - onchainActivities.firstOrNull { (it as? Onchain)?.v1?.txId == txId }?.let { activity -> - // get tags by activity id - activityRepo.getActivityTags(activity.rawId()) + activityRepo.getOnchainActivityByTxId(txId)?.let { activity -> + activityRepo.getActivityTags(activity.id) .onSuccess { tags -> if (tags.isNotEmpty()) { - // add map entry linking tags to utxo.outpoint.txid _tagsByTxId.update { (it + (txId to tags.toImmutableList())).toImmutableMap() } diff --git a/app/src/main/java/to/bitkit/ui/settings/general/HardwareWalletsSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/general/HardwareWalletsSettingsScreen.kt index 7aa93153e7..d93fb4bce1 100644 --- a/app/src/main/java/to/bitkit/ui/settings/general/HardwareWalletsSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/general/HardwareWalletsSettingsScreen.kt @@ -34,7 +34,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import to.bitkit.R -import to.bitkit.models.HwWallet import to.bitkit.models.TransportType import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Display @@ -48,6 +47,7 @@ import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.screens.wallets.HwWalletUi import to.bitkit.ui.screens.wallets.HwWalletViewModel import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -65,23 +65,23 @@ fun HardwareWalletsSettingsScreen( Content( wallets = wallets, - isPendingRemoval = uiState.isPendingRemoval, + pendingRemovalId = uiState.pendingRemovalId, onBack = { navController.popBackStack() }, onClickAdd = onClickAdd, onRemoveClick = viewModel::onRemoveClick, - onConfirmRemove = { viewModel.removeDevice(it.id) }, + onConfirmRemove = viewModel::removeDevice, onDismissRemoveDialog = viewModel::onDismissRemoveDialog, ) } @Composable private fun Content( - wallets: ImmutableList, - isPendingRemoval: HwWallet?, + wallets: ImmutableList, + pendingRemovalId: String?, onBack: () -> Unit = {}, onClickAdd: () -> Unit = {}, - onRemoveClick: (HwWallet) -> Unit = {}, - onConfirmRemove: (HwWallet) -> Unit = {}, + onRemoveClick: (HwWalletUi) -> Unit = {}, + onConfirmRemove: (String) -> Unit = {}, onDismissRemoveDialog: () -> Unit = {}, ) { ScreenColumn( @@ -140,13 +140,13 @@ private fun Content( } } - isPendingRemoval?.let { wallet -> + wallets.firstOrNull { it.id == pendingRemovalId }?.let { wallet -> AppAlertDialog( title = stringResource(R.string.hardware__remove_dialog_title, wallet.name), text = stringResource(R.string.hardware__remove_dialog_text), confirmText = stringResource(R.string.common__remove), dismissText = stringResource(R.string.common__cancel), - onConfirm = { onConfirmRemove(wallet) }, + onConfirm = { onConfirmRemove(wallet.id) }, onDismiss = onDismissRemoveDialog, ) } @@ -169,8 +169,8 @@ private fun EmptyState(modifier: Modifier = Modifier) { @Composable private fun HwWalletRow( - wallet: HwWallet, - onRemoveClick: (HwWallet) -> Unit, + wallet: HwWalletUi, + onRemoveClick: (HwWalletUi) -> Unit, modifier: Modifier = Modifier, ) { Row( @@ -235,7 +235,7 @@ private fun previewWallet( transportType: TransportType = TransportType.BLUETOOTH, isConnected: Boolean = true, balanceSats: ULong = 10_562_411uL, -) = HwWallet( +) = HwWalletUi( id = id, name = name, model = name.removePrefix("Trezor ").ifEmpty { null }, @@ -260,7 +260,7 @@ private fun Preview() { balanceSats = 2_735_180uL, ), ).toImmutableList(), - isPendingRemoval = null, + pendingRemovalId = null, ) } } @@ -271,7 +271,7 @@ private fun PreviewEmpty() { AppThemeSurface { Content( wallets = persistentListOf(), - isPendingRemoval = null, + pendingRemovalId = null, ) } } @@ -282,7 +282,7 @@ private fun PreviewRemoveDialog() { AppThemeSurface { Content( wallets = persistentListOf(previewWallet()), - isPendingRemoval = previewWallet(), + pendingRemovalId = "dev1", ) } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt index c9fe07801d..199c6e6ecf 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionSheet.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.synonym.bitkitcore.Activity import to.bitkit.R import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.ui.components.BodyMSB @@ -64,13 +63,14 @@ fun BoostTransactionSheet( onMaxFee: () -> Unit, onMinFee: () -> Unit, onDismiss: () -> Unit, - item: Activity.Onchain, + activityId: String, + walletId: String, ) { val haptic = LocalHapticFeedback.current // Setup activity when component is first created - LaunchedEffect(Unit) { - viewModel.setupActivity(item) + LaunchedEffect(activityId, walletId) { + viewModel.setupActivity(activityId, walletId) } // Handle effects diff --git a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt index e814bb10fa..fe22c138c2 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt @@ -57,7 +57,27 @@ class BoostTransactionViewModel @Inject constructor( private var minFeeRate: ULong = 2U private var activity: Activity.Onchain? = null - fun setupActivity(activity: Activity.Onchain) { + fun setupActivity(activityId: String, walletId: String?) { + _uiState.update { it.copy(loading = true) } + + viewModelScope.launch { + activityRepo.getActivity(activityId, walletId) + .onSuccess { + val activity = it as? Activity.Onchain + if (activity == null) { + handleError("Activity '$activityId' is not boostable") + return@onSuccess + } + + setupActivity(activity) + } + .onFailure { + handleError("Failed to load activity '$activityId'", it) + } + } + } + + private fun setupActivity(activity: Activity.Onchain) { Logger.debug("Setup activity $activity", context = TAG) this.activity = activity diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index 9735df1108..f199dce0e6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -26,6 +26,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.ext.rawId import to.bitkit.ext.walletId +import to.bitkit.models.USat import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.TransferRepo @@ -65,7 +66,9 @@ class ActivityDetailViewModel @Inject constructor( .onSuccess { activity -> if (activity != null) { this@ActivityDetailViewModel.activity = activity - _uiState.update { it.copy(activityLoadState = ActivityLoadState.Success(activity)) } + _uiState.update { + it.copy(activityLoadState = ActivityLoadState.Success(activity)) + } loadTags() observeActivityChanges(activityId, walletId) } else { @@ -248,6 +251,17 @@ class ActivityDetailViewModel @Inject constructor( }.getOrNull() } + suspend fun findTransferOrderAmounts( + channelId: String?, + txId: String?, + ): TransferOrderAmounts? { + val order = findOrderForTransfer(channelId, txId) ?: return null + return TransferOrderAmounts( + serviceFee = USat(order.feeSat) - USat(order.clientBalanceSat), + transferAmount = order.clientBalanceSat, + ) + } + private companion object { const val TAG = "ActivityDetailViewModel" } @@ -263,3 +277,8 @@ class ActivityDetailViewModel @Inject constructor( val activityLoadState: ActivityLoadState = ActivityLoadState.Initial, ) } + +data class TransferOrderAmounts( + val serviceFee: ULong, + val transferAmount: ULong, +) diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index 92240f41ec..7c27455b6f 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -90,7 +90,10 @@ class ActivityDetailViewModelTest : BaseUnitTest() { sut.loadActivity(ACTIVITY_ID, hardwareWalletId) val loadState = sut.uiState.value.activityLoadState assertTrue(loadState is ActivityDetailViewModel.ActivityLoadState.Success) - assertEquals(hwActivity, loadState.activity) + val activity = loadState.activity + assertTrue(activity is Activity.Onchain) + assertEquals(ACTIVITY_ID, activity.v1.id) + assertEquals(hardwareWalletId, activity.v1.walletId) sut.addTag("tag1") @@ -176,7 +179,9 @@ class ActivityDetailViewModelTest : BaseUnitTest() { // Verify initial state loaded val initialState = sut.uiState.value.activityLoadState assertTrue(initialState is ActivityDetailViewModel.ActivityLoadState.Success) - assertEquals(initialActivity, initialState.activity) + assertTrue(initialState.activity is Activity.Onchain) + assertEquals(initialActivity.v1.id, initialState.activity.v1.id) + assertEquals(initialActivity.v1.confirmed, initialState.activity.v1.confirmed) // Simulate activity update whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(updatedActivity)) @@ -185,7 +190,9 @@ class ActivityDetailViewModelTest : BaseUnitTest() { // Verify ViewModel reflects updated activity val updatedState = sut.uiState.value.activityLoadState assertTrue(updatedState is ActivityDetailViewModel.ActivityLoadState.Success) - assertEquals(updatedActivity, updatedState.activity) + assertTrue(updatedState.activity is Activity.Onchain) + assertEquals(updatedActivity.v1.id, updatedState.activity.v1.id) + assertEquals(updatedActivity.v1.confirmed, updatedState.activity.v1.confirmed) } @Test @@ -232,7 +239,9 @@ class ActivityDetailViewModelTest : BaseUnitTest() { // Verify last known state is preserved val state = sut.uiState.value.activityLoadState assertTrue(state is ActivityDetailViewModel.ActivityLoadState.Success) - assertEquals(activity, state.activity) + assertTrue(state.activity is Activity.Onchain) + assertEquals(activity.v1.id, state.activity.v1.id) + assertEquals(activity.v1.confirmed, state.activity.v1.confirmed) } @Test diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt index 3134f5d484..3f92506efa 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt @@ -89,7 +89,7 @@ class HomeViewModelTest : BaseUnitTest() { advanceUntilIdle() - assertTrue(hardwareWallet in sut.uiState.value.hardwareWallets) + assertTrue(hardwareWallet.toUi() in sut.uiState.value.hardwareWallets) } @Test diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/HwWalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/HwWalletViewModelTest.kt index d3e8e6102c..7d988c7f9b 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/HwWalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/HwWalletViewModelTest.kt @@ -67,7 +67,7 @@ class HwWalletViewModelTest : BaseUnitTest() { fun `exposes the wallets from the repo`() = test { val sut = createSut() - assertEquals(listOf(wallet), sut.wallets.value) + assertEquals(listOf(wallet.toUi()), sut.wallets.value) assertEquals(true, sut.walletsLoaded.value) } @@ -75,11 +75,11 @@ class HwWalletViewModelTest : BaseUnitTest() { fun `onRemoveClick sets the pending device and onDismiss clears it`() = test { val sut = createSut() - sut.onRemoveClick(wallet) - assertEquals(wallet, sut.uiState.value.isPendingRemoval) + sut.onRemoveClick(wallet.toUi()) + assertEquals(wallet.id, sut.uiState.value.pendingRemovalId) sut.onDismissRemoveDialog() - assertNull(sut.uiState.value.isPendingRemoval) + assertNull(sut.uiState.value.pendingRemovalId) } @Test @@ -87,22 +87,22 @@ class HwWalletViewModelTest : BaseUnitTest() { wallets.value = listOf(wallet, otherWallet).toImmutableList() val sut = createSut() - sut.onRemoveClick(otherWallet) + sut.onRemoveClick(otherWallet.toUi()) - assertEquals(otherWallet, sut.uiState.value.isPendingRemoval) + assertEquals(otherWallet.id, sut.uiState.value.pendingRemovalId) } @Test fun `removeDevice delegates to the repo and clears the pending device`() = test { whenever { hwWalletRepo.removeDevice("dev1") }.thenReturn(Result.success(Unit)) val sut = createSut() - sut.onRemoveClick(wallet) + sut.onRemoveClick(wallet.toUi()) sut.removeDevice("dev1") advanceUntilIdle() verify(hwWalletRepo).removeDevice("dev1") - assertNull(sut.uiState.value.isPendingRemoval) + assertNull(sut.uiState.value.pendingRemovalId) } @Test diff --git a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt index 8b9e64ff12..f460860934 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.sheets import android.content.Context +import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.FeeRates @@ -110,6 +111,19 @@ class BoostTransactionViewModelTest : BaseUnitTest() { } } + private suspend fun setupActivity(activity: Activity.Onchain) { + whenever(activityRepo.getActivity(activity.v1.id, activity.v1.walletId)).thenReturn(Result.success(activity)) + sut.setupActivity(activity.v1.id, activity.v1.walletId) + } + + private suspend fun ReceiveTurbine.awaitLoadedState(): BoostTransactionUiState { + var state = awaitItem() + while (state.loading) { + state = awaitItem() + } + return state + } + @Test fun `setupActivity should set loading state initially`() = runTest { whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(feeRate)) @@ -118,7 +132,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { sut.uiState.test { awaitItem() // initial state - sut.setupActivity(activitySent) + setupActivity(activitySent) val loadingState = awaitItem() assertTrue(loadingState.loading) @@ -132,7 +146,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(totalFee)) - sut.setupActivity(activitySent) + setupActivity(activitySent) verify(lightningRepo).getFeeRateForSpeed(eq(TransactionSpeed.Fast), anyOrNull()) verify(lightningRepo).calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) @@ -145,7 +159,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(lightningRepo.calculateCpfpFeeRate(eq(mockTxId))) .thenReturn(Result.success(feeRate)) - sut.setupActivity(receivedActivity) + setupActivity(receivedActivity) verify(lightningRepo).calculateCpfpFeeRate(eq(mockTxId)) verify(lightningRepo, never()).getFeeRateForSpeed(any(), anyOrNull()) @@ -176,7 +190,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(totalFee)) - sut.setupActivity(activitySent) + setupActivity(activitySent) sut.boostTransactionEffect.test { sut.onChangeAmount(increase = true) @@ -190,7 +204,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(totalFee)) - sut.setupActivity(activitySent) + setupActivity(activitySent) sut.boostTransactionEffect.test { sut.onChangeAmount(increase = false) @@ -203,7 +217,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.failure(Exception("error"))) sut.boostTransactionEffect.test { - sut.setupActivity(activitySent) + setupActivity(activitySent) assertEquals(BoostTransactionEffects.OnBoostFailed, awaitItem()) } } @@ -225,7 +239,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { whenever(activityRepo.updateActivity(any(), any(), any())).thenReturn(Result.success(Unit)) - sut.setupActivity(receivedActivity) + setupActivity(receivedActivity) sut.boostTransactionEffect.test { sut.onConfirmBoost() @@ -248,9 +262,8 @@ class BoostTransactionViewModelTest : BaseUnitTest() { sut.uiState.test { awaitItem() - sut.setupActivity(activitySent) - awaitItem() - val state = awaitItem() + setupActivity(activitySent) + val state = awaitLoadedState() assertEquals(fastFeeTime, state.estimateTime) } } @@ -263,9 +276,8 @@ class BoostTransactionViewModelTest : BaseUnitTest() { sut.uiState.test { awaitItem() - sut.setupActivity(activitySent) - awaitItem() - val state = awaitItem() + setupActivity(activitySent) + val state = awaitLoadedState() assertEquals(normalFeeTime, state.estimateTime) } } @@ -279,9 +291,8 @@ class BoostTransactionViewModelTest : BaseUnitTest() { sut.uiState.test { awaitItem() // initial state - sut.setupActivity(lowFeeActivity) - awaitItem() // loading state - val state = awaitItem() + setupActivity(lowFeeActivity) + val state = awaitLoadedState() assertEquals(flowFeeTime, state.estimateTime) } } @@ -295,9 +306,8 @@ class BoostTransactionViewModelTest : BaseUnitTest() { sut.uiState.test { awaitItem() // initial state - sut.setupActivity(lowFeeActivity) - awaitItem() // loading state - val state = awaitItem() + setupActivity(lowFeeActivity) + val state = awaitLoadedState() assertEquals(minFeeTime, state.estimateTime) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 89cd1164aa..c078ea9d15 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.3.4" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.3.8" } 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" }