From 062363f0842d7d8406eaa8c5630a75dca9e95b62 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 17 Nov 2025 18:17:16 -0500 Subject: [PATCH 1/5] Save boosted txs --- .../bitkit/data/dto/PendingBoostActivity.kt | 3 +- .../to/bitkit/repositories/ActivityRepo.kt | 45 ++++++++++++-- .../java/to/bitkit/services/CoreService.kt | 60 ++++++++++++++++++- .../wallets/activity/ActivityDetailScreen.kt | 1 + .../wallets/activity/ActivityExploreScreen.kt | 56 ++++++++--------- .../activity/components/ActivityRow.kt | 4 ++ .../ui/sheets/BoostTransactionViewModel.kt | 38 ++++++++---- 7 files changed, 158 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/dto/PendingBoostActivity.kt b/app/src/main/java/to/bitkit/data/dto/PendingBoostActivity.kt index bec9e9c60..8d210ea3e 100644 --- a/app/src/main/java/to/bitkit/data/dto/PendingBoostActivity.kt +++ b/app/src/main/java/to/bitkit/data/dto/PendingBoostActivity.kt @@ -6,5 +6,6 @@ import kotlinx.serialization.Serializable data class PendingBoostActivity( val txId: String, val updatedAt: ULong, - val activityToDelete: String? + val activityToDelete: String?, + val parentTxId: String? = null ) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 0d476281c..06e9f40af 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -241,7 +241,8 @@ class ActivityRepo @Inject constructor( } /** - * Updates an activity and delete other one. In case of failure in the update or deletion, the data will be cached + * Updates an activity and marks the old one as removed from mempool (for RBF). + * In case of failure in the update or marking as removed, the data will be cached * to try again on the next sync */ suspend fun replaceActivity( @@ -255,16 +256,17 @@ class ActivityRepo @Inject constructor( ).fold( onSuccess = { Logger.debug( - "Activity $id updated with success. new data: $activity. Deleting activity $activityIdToDelete", + "Activity $id updated with success. new data: $activity. " + + "Marking activity $activityIdToDelete as removed from mempool", context = TAG ) val tags = coreService.activity.tags(activityIdToDelete) addTagsToActivity(activityId = id, tags = tags) - deleteActivity(activityIdToDelete).onFailure { e -> + markActivityAsRemovedFromMempool(activityIdToDelete).onFailure { e -> Logger.warn( - "Failed to delete $activityIdToDelete caching to retry on next sync", + "Failed to mark $activityIdToDelete as removed from mempool, caching to retry on next sync", e = e, context = TAG ) @@ -286,7 +288,7 @@ class ActivityRepo @Inject constructor( private suspend fun deletePendingActivities() = withContext(bgDispatcher) { cacheStore.data.first().activitiesPendingDelete.map { activityId -> async { - deleteActivity(id = activityId).onSuccess { + markActivityAsRemovedFromMempool(activityId).onSuccess { cacheStore.removeActivityFromPendingDelete(activityId) } } @@ -425,9 +427,16 @@ class ActivityRepo @Inject constructor( return@onSuccess } + val updatedBoostTxIds = if (pendingBoostActivity.parentTxId != null) { + newOnChainActivity.v1.boostTxIds + pendingBoostActivity.parentTxId + } else { + newOnChainActivity.v1.boostTxIds + } + val updatedActivity = Activity.Onchain( v1 = newOnChainActivity.v1.copy( isBoosted = true, + boostTxIds = updatedBoostTxIds, updatedAt = pendingBoostActivity.updatedAt ) ) @@ -453,6 +462,32 @@ class ActivityRepo @Inject constructor( }.awaitAll() } + /** + * Marks an activity as removed from mempool (sets doesExist = false). + * Used for RBFed transactions that are replaced. + */ + private suspend fun markActivityAsRemovedFromMempool(activityId: String): Result = withContext(bgDispatcher) { + return@withContext runCatching { + val existingActivity = getActivity(activityId).getOrNull() + ?: return@withContext Result.failure(Exception("Activity $activityId not found")) + + if (existingActivity is Activity.Onchain) { + val updatedActivity = Activity.Onchain( + v1 = existingActivity.v1.copy( + doesExist = false, + updatedAt = nowTimestamp().toEpochMilli().toULong() + ) + ) + updateActivity(id = activityId, activity = updatedActivity, forceUpdate = true).getOrThrow() + notifyActivitiesChanged() + } else { + return@withContext Result.failure(Exception("Activity $activityId is not an onchain activity")) + } + }.onFailure { e -> + Logger.error("markActivityAsRemovedFromMempool error for ID: $activityId", e, context = TAG) + } + } + /** * Deletes an activity */ diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 62c7c7153..38a97cb52 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -428,11 +428,21 @@ class ActivityService( } val onChain = if (existingActivity is Activity.Onchain) { - existingActivity.v1.copy( + val wasRemoved = !existingActivity.v1.doesExist + val shouldRestore = wasRemoved && isConfirmed + val updatedOnChain = existingActivity.v1.copy( confirmed = isConfirmed, confirmTimestamp = confirmedTimestamp, + doesExist = if (shouldRestore) true else existingActivity.v1.doesExist, updatedAt = timestamp, ) + + // If a removed transaction confirms, mark its replacement transactions as removed + if (wasRemoved && isConfirmed) { + markReplacementTransactionsAsRemoved(originalTxId = kind.txid) + } + + updatedOnChain } else { OnchainActivity( id = payment.id, @@ -468,6 +478,54 @@ class ActivityService( } } + /** + * Marks replacement transactions (with originalTxId in boostTxIds) as doesExist = false when original confirms. + * This is called when a removed RBFed transaction gets confirmed. + */ + private suspend fun markReplacementTransactionsAsRemoved(originalTxId: String) { + try { + val allActivities = getActivities( + filter = ActivityFilter.ONCHAIN, + txType = null, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null + ) + + for (activity in allActivities) { + if (activity !is Activity.Onchain) continue + + val onchainActivity = activity.v1 + val isReplacement = onchainActivity.boostTxIds.contains(originalTxId) && + onchainActivity.doesExist && + !onchainActivity.confirmed + + if (isReplacement) { + Logger.debug( + "Marking replacement transaction ${onchainActivity.txId} as doesExist = false " + + "(original $originalTxId confirmed)", + context = TAG + ) + + val updatedActivity = onchainActivity.copy( + doesExist = false, + updatedAt = System.currentTimeMillis().toULong() / 1000u + ) + updateActivity(activityId = onchainActivity.id, activity = Activity.Onchain(updatedActivity)) + } + } + } catch (e: Exception) { + Logger.error( + "Error marking replacement transactions as removed for originalTxId: $originalTxId", + e, + context = TAG + ) + } + } + private fun PaymentDirection.toPaymentType(): PaymentType = if (this == PaymentDirection.OUTBOUND) PaymentType.SENT else PaymentType.RECEIVED 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 318ea6f72..693561e19 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 @@ -583,6 +583,7 @@ private fun StatusSection(item: Activity) { statusIcon = painterResource(R.drawable.ic_x) statusColor = Colors.Red statusText = stringResource(R.string.wallet__activity_removed) + statusTestTag = "StatusRemoved" } StatusRow(statusIcon, statusText, statusColor, statusTestTag) 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 d6a611ea5..c93303fbf 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 @@ -116,13 +116,6 @@ fun ActivityExploreScreen( val intent = Intent(Intent.ACTION_VIEW, url.toUri()) context.startActivity(intent) }, - onClickParent = { id -> - app.toast( - type = Toast.ToastType.WARNING, - title = "TODO", - description = "Navigate to Activity Detail for: $id", - ) - }, ) } } @@ -133,7 +126,6 @@ private fun ActivityExploreContent( txDetails: TxDetails? = null, onCopy: (String) -> Unit = {}, onClickExplore: (String) -> Unit = {}, - onClickParent: (String) -> Unit = {}, ) { Column( modifier = Modifier @@ -164,7 +156,6 @@ private fun ActivityExploreContent( onchain = item, onCopy = onCopy, txDetails = txDetails, - onClickParent = onClickParent, ) Spacer(modifier = Modifier.weight(1f)) PrimaryButton( @@ -226,7 +217,6 @@ private fun ColumnScope.OnchainDetails( onchain: Activity.Onchain, onCopy: (String) -> Unit, txDetails: TxDetails?, - onClickParent: (String) -> Unit, ) { val txId = onchain.v1.txId Section( @@ -271,29 +261,33 @@ private fun ColumnScope.OnchainDetails( .size(16.dp) .align(Alignment.CenterHorizontally) ) - } // TODO use real boosted parents from bitkit-core/ldk-node when available - val boostedParents = listOfNotNull( - "todo_first_parent_txid".takeIf { onchain.isBoosted() && !onchain.v1.confirmed }, - "todo_second_parent_txid".takeIf { onchain.isBoosted() && onchain.v1.confirmed }, - ) + } - boostedParents.forEachIndexed { index, parent -> + // Display boosted transaction IDs from boostTxIds + // For CPFP (RECEIVED): shows child transaction IDs that boosted this parent + // For RBF (SENT): shows parent transaction IDs that this replacement replaced + val boostTxIds = onchain.v1.boostTxIds + if (boostTxIds.isNotEmpty()) { val isRbf = onchain.boostType() == BoostType.RBF - Section( - title = stringResource( - if (isRbf) R.string.wallet__activity_boosted_rbf else R.string.wallet__activity_boosted_cpfp - ).replace("{num}", "${index + 1}"), - valueContent = { - Column { - BodySSB(text = parent, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) - } - }, - modifier = Modifier - .clickableAlpha { - onClickParent(parent) - } - .testTag(if (isRbf) "RBFBoosted" else "CPFPBoosted") - ) + boostTxIds.forEachIndexed { index, boostedTxId -> + Section( + title = stringResource( + if (isRbf) R.string.wallet__activity_boosted_rbf else R.string.wallet__activity_boosted_cpfp + ).replace("{num}", "${index + 1}"), + valueContent = { + Column { + BodySSB(text = boostedTxId, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) + } + }, + modifier = Modifier + .clickableAlpha( + onClick = copyToClipboard(boostedTxId) { + onCopy(it) + } + ) + .testTag(if (isRbf) "RBFBoosted" else "CPFPBoosted") + ) + } } } 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 84a331fb7..21e5c5a8f 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 @@ -101,6 +101,10 @@ fun ActivityRow( is Activity.Lightning -> item.v1.message.ifEmpty { formattedTime(timestamp) } is Activity.Onchain -> { when { + !item.v1.doesExist -> { + stringResource(R.string.wallet__activity_removed) + } + isTransfer && isSent -> { if (item.v1.confirmed) { stringResource(R.string.wallet__activity_transfer_spending_done) 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 66debe45a..f5f78da26 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BoostTransactionViewModel.kt @@ -283,7 +283,7 @@ class BoostTransactionViewModel @Inject constructor( /** * Updates activity based on boost type: * - RBF: Updates current activity with boost data, then replaces with new transaction - * - CPFP: Simply updates the current activity + * - CPFP: Updates the current activity and appends child txId to parent's boostTxIds */ private suspend fun updateActivity(newTxId: Txid, isRBF: Boolean): Result { Logger.debug("Updating activity for txId: $newTxId. isRBF: $isRBF", context = TAG) @@ -294,17 +294,20 @@ class BoostTransactionViewModel @Inject constructor( return if (isRBF) { handleRBFUpdate(newTxId, currentActivity) } else { - handleCPFPUpdate(currentActivity) + handleCPFPUpdate(currentActivity, newTxId) } } /** - * Handles CPFP (Child Pays For Parent) update by simply updating the current activity + * Handles CPFP (Child Pays For Parent) update by updating the current activity + * and appending the child transaction ID to the parent's boostTxIds */ - private suspend fun handleCPFPUpdate(currentActivity: OnchainActivity): Result { + private suspend fun handleCPFPUpdate(currentActivity: OnchainActivity, childTxId: Txid): Result { + val updatedBoostTxIds = currentActivity.boostTxIds + childTxId val updatedActivity = Activity.Onchain( v1 = currentActivity.copy( isBoosted = true, + boostTxIds = updatedBoostTxIds, updatedAt = nowTimestamp().toEpochMilli().toULong() ) ) @@ -317,6 +320,8 @@ class BoostTransactionViewModel @Inject constructor( /** * Handles RBF (Replace By Fee) update by updating current activity and replacing with new one + * For RBF, we need to store the parent txId (currentActivity.txId) so it can be added to + * the replacement activity's boostTxIds when it syncs */ private suspend fun handleRBFUpdate( newTxId: Txid, @@ -338,7 +343,7 @@ class BoostTransactionViewModel @Inject constructor( ) // Then find and replace with the new activity - return findAndReplaceWithNewActivity(newTxId, currentActivity.id) + return findAndReplaceWithNewActivity(newTxId, currentActivity.id, currentActivity.txId) } /** @@ -347,6 +352,7 @@ class BoostTransactionViewModel @Inject constructor( private suspend fun findAndReplaceWithNewActivity( newTxId: Txid, oldActivityId: String, + parentTxId: String, ): Result { return activityRepo.findActivityByPaymentId( paymentHashOrTxId = newTxId, @@ -354,30 +360,34 @@ class BoostTransactionViewModel @Inject constructor( txType = PaymentType.SENT ).fold( onSuccess = { newActivity -> - replaceActivityWithNewOne(newActivity, oldActivityId, newTxId) + replaceActivityWithNewOne(newActivity, oldActivityId, newTxId, parentTxId) }, onFailure = { error -> - handleActivityNotFound(error, newTxId, oldActivityId) + handleActivityNotFound(error, newTxId, oldActivityId, parentTxId) } ) } /** * Replaces the old activity with the new boosted one + * For RBF, adds the parent txId to the new activity's boostTxIds */ private suspend fun replaceActivityWithNewOne( newActivity: Activity, oldActivityId: String, newTxId: Txid, + parentTxId: String, ): Result { Logger.debug("Activity found: $newActivity", context = TAG) val newOnChainActivity = newActivity as? Activity.Onchain ?: return Result.failure(Exception("Activity is not onchain type")) + val updatedBoostTxIds = newOnChainActivity.v1.boostTxIds + parentTxId val updatedNewActivity = Activity.Onchain( v1 = newOnChainActivity.v1.copy( isBoosted = true, + boostTxIds = updatedBoostTxIds, feeRate = _uiState.value.feeRate, updatedAt = nowTimestamp().toEpochMilli().toULong() ) @@ -388,7 +398,7 @@ class BoostTransactionViewModel @Inject constructor( activityIdToDelete = oldActivityId, activity = updatedNewActivity, ).onFailure { - cachePendingBoostActivity(newTxId, oldActivityId) + cachePendingBoostActivity(newTxId, oldActivityId, parentTxId) } } @@ -399,6 +409,7 @@ class BoostTransactionViewModel @Inject constructor( error: Throwable, newTxId: Txid, oldActivityId: String?, + parentTxId: String, ): Result { Logger.error( "Activity $newTxId not found. Caching data to try again on next sync", @@ -406,19 +417,24 @@ class BoostTransactionViewModel @Inject constructor( context = TAG ) - cachePendingBoostActivity(newTxId, oldActivityId) + cachePendingBoostActivity(newTxId, oldActivityId, parentTxId) return Result.failure(error) } /** * Caches activity data for pending boost operation */ - private suspend fun cachePendingBoostActivity(newTxId: Txid, activityToDelete: String?) { + private suspend fun cachePendingBoostActivity( + newTxId: Txid, + activityToDelete: String?, + parentTxId: String? = null + ) { activityRepo.addActivityToPendingBoost( PendingBoostActivity( txId = newTxId, updatedAt = nowTimestamp().toEpochMilli().toULong(), - activityToDelete = activityToDelete + activityToDelete = activityToDelete, + parentTxId = parentTxId ) ) } From 8756657e72cfda0b9c7d3d3206fb8090dc49a650 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 19 Nov 2025 09:07:34 -0500 Subject: [PATCH 2/5] Update unit tests --- .../to/bitkit/repositories/ActivityRepo.kt | 1 + .../bitkit/repositories/ActivityRepoTest.kt | 423 +++++++++++++++++- 2 files changed, 418 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 06e9f40af..e51cd6086 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -272,6 +272,7 @@ class ActivityRepo @Inject constructor( ) cacheStore.addActivityToPendingDelete(activityId = activityIdToDelete) } + Result.success(Unit) }, onFailure = { e -> Logger.error( diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index c1612aad4..6e88e0dd9 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -3,6 +3,7 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.LightningActivity +import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection import kotlinx.coroutines.flow.flowOf @@ -11,6 +12,7 @@ import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.PaymentDetails import org.mockito.kotlin.any +import org.mockito.kotlin.argThat import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -53,6 +55,69 @@ class ActivityRepoTest : BaseUnitTest() { on { v1 } doReturn testActivityV1 } + private val baseOnchainActivity = OnchainActivity( + id = "base_activity_id", + txType = PaymentType.SENT, + txId = "base_tx_id", + value = 1000uL, + fee = 100uL, + feeRate = 10uL, + address = "bc1test", + confirmed = false, + timestamp = 1234567890uL, + isBoosted = false, + boostTxIds = emptyList(), + isTransfer = false, + doesExist = true, + confirmTimestamp = null, + channelId = null, + transferTxId = null, + createdAt = null, + updatedAt = null + ) + + private fun createOnchainActivity( + id: String = baseOnchainActivity.id, + txId: String = baseOnchainActivity.txId, + value: ULong = baseOnchainActivity.value, + fee: ULong = baseOnchainActivity.fee, + feeRate: ULong = baseOnchainActivity.feeRate, + address: String = baseOnchainActivity.address, + confirmed: Boolean = baseOnchainActivity.confirmed, + timestamp: ULong = baseOnchainActivity.timestamp, + isBoosted: Boolean = baseOnchainActivity.isBoosted, + boostTxIds: List = baseOnchainActivity.boostTxIds, + isTransfer: Boolean = baseOnchainActivity.isTransfer, + doesExist: Boolean = baseOnchainActivity.doesExist, + confirmTimestamp: ULong? = baseOnchainActivity.confirmTimestamp, + channelId: String? = baseOnchainActivity.channelId, + transferTxId: String? = baseOnchainActivity.transferTxId, + createdAt: ULong? = baseOnchainActivity.createdAt, + updatedAt: ULong? = baseOnchainActivity.updatedAt, + ): Activity.Onchain { + return Activity.Onchain( + v1 = baseOnchainActivity.copy( + id = id, + txId = txId, + value = value, + fee = fee, + feeRate = feeRate, + address = address, + confirmed = confirmed, + timestamp = timestamp, + isBoosted = isBoosted, + boostTxIds = boostTxIds, + isTransfer = isTransfer, + doesExist = doesExist, + confirmTimestamp = confirmTimestamp, + channelId = channelId, + transferTxId = transferTxId, + createdAt = createdAt, + updatedAt = updatedAt + ) + ) + } + @Before fun setUp() { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) @@ -71,6 +136,29 @@ class ActivityRepoTest : BaseUnitTest() { ) } + private fun setupSyncActivitiesMocks( + cacheData: AppCacheData, + activities: List = emptyList() + ) { + whenever(cacheStore.data).thenReturn(flowOf(cacheData)) + wheneverBlocking { lightningRepo.getPayments() }.thenReturn(Result.success(emptyList())) + wheneverBlocking { coreService.activity.syncLdkNodePaymentsToActivities(any(), eq(false)) }.thenReturn(Unit) + if (activities.isNotEmpty()) { + wheneverBlocking { + coreService.activity.get( + filter = ActivityFilter.ONCHAIN, + txType = PaymentType.SENT, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = 10u, + sortDirection = null + ) + }.thenReturn(activities) + } + } + @Test fun `syncActivities success flow`() = test { val payments = listOf(testPaymentDetails) @@ -220,24 +308,53 @@ class ActivityRepoTest : BaseUnitTest() { } @Test - fun `replaceActivity updates and deletes successfully`() = test { + fun `replaceActivity updates and marks old activity as removed from mempool`() = test { val activityId = "activity123" val activityToDeleteId = "activity456" val tagsMock = listOf("tag1", "tag2") val cacheData = AppCacheData(deletedActivities = emptyList()) whenever(cacheStore.data).thenReturn(flowOf(cacheData)) - wheneverBlocking { coreService.activity.update(activityId, testActivity) }.thenReturn(Unit) - wheneverBlocking { coreService.activity.delete(activityToDeleteId) }.thenReturn(true) - wheneverBlocking { cacheStore.addActivityToDeletedList(activityToDeleteId) }.thenReturn(Unit) + // Mock the activity to be marked as removed (must be Onchain) + val onchainActivityToDelete = createOnchainActivity(id = activityToDeleteId, txId = "tx123") - whenever(coreService.activity.tags(activityId)).thenAnswer { tagsMock } + // Mock update for the new activity + wheneverBlocking { coreService.activity.update(activityId, testActivity) }.thenReturn(Unit) + // Mock getActivity to return the new activity (for addTagsToActivity check) + wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) + // Mock getActivity to return the onchain activity to be marked as removed + wheneverBlocking { coreService.activity.getActivity(activityToDeleteId) }.thenReturn(onchainActivityToDelete) + // Mock update for the old activity (with doesExist=false) + wheneverBlocking { coreService.activity.update(eq(activityToDeleteId), any()) }.thenReturn(Unit) + // Mock tags retrieval from the old activity + wheneverBlocking { coreService.activity.tags(activityToDeleteId) }.thenReturn(tagsMock) + // Mock tags retrieval from the new activity (should be empty so all tags are considered new) + wheneverBlocking { coreService.activity.tags(activityId) }.thenReturn(emptyList()) + // Mock appendTags to add tags to the new activity + wheneverBlocking { coreService.activity.appendTags(activityId, tagsMock) }.thenReturn(Result.success(Unit)) val result = sut.replaceActivity(activityId, activityToDeleteId, testActivity) assertTrue(result.isSuccess) + // Verify the new activity is updated verify(coreService.activity).update(activityId, testActivity) - verify(coreService.activity).delete(activityToDeleteId) + // Verify the old activity is retrieved + verify(coreService.activity).getActivity(activityToDeleteId) + // Verify tags are retrieved from the old activity + verify(coreService.activity).tags(activityToDeleteId) + // Verify tags are added to the new activity + verify(coreService.activity).appendTags(activityId, tagsMock) + // Verify the old activity is updated (marked as removed from mempool with doesExist=false) + verify(coreService.activity).update( + eq(activityToDeleteId), + argThat { activity -> + activity is Activity.Onchain && !activity.v1.doesExist + } + ) + // Verify delete is NOT called + verify(coreService.activity, never()).delete(any()) + // Verify addActivityToDeletedList is NOT called + verify(cacheStore, never()).addActivityToDeletedList(any()) } @Test @@ -448,4 +565,298 @@ class ActivityRepoTest : BaseUnitTest() { verify(cacheStore).addActivityToPendingBoost(pendingBoost) } + + @Test + fun `markActivityAsRemovedFromMempool successfully marks onchain activity as removed`() = test { + val activityId = "activity456" + val onchainActivity = createOnchainActivity( + id = activityId, + txId = "tx123", + doesExist = true // Initially exists + ) + + val cacheData = AppCacheData(activitiesPendingDelete = listOf(activityId)) + setupSyncActivitiesMocks(cacheData) + wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(onchainActivity) + wheneverBlocking { coreService.activity.update(eq(activityId), any()) }.thenReturn(Unit) + wheneverBlocking { cacheStore.removeActivityFromPendingDelete(activityId) }.thenReturn(Unit) + + val result = sut.syncActivities() + + assertTrue(result.isSuccess) + // Verify the activity was marked as removed (doesExist = false) + verify(coreService.activity).update( + eq(activityId), + argThat { activity -> + activity is Activity.Onchain && + !activity.v1.doesExist && + activity.v1.id == activityId && + activity.v1.txId == "tx123" + } + ) + // Verify it was removed from pending delete after successful marking + verify(cacheStore).removeActivityFromPendingDelete(activityId) + } + + @Test + fun `boostPendingActivities adds parentTxId to boostTxIds when parentTxId is provided`() = test { + val txId = "tx123" + val parentTxId = "parentTx456" + val activityId = "activity123" + val updatedAt = 2000uL + + val existingActivity = createOnchainActivity( + id = activityId, + txId = txId, + updatedAt = 1000uL + ) + + val pendingBoost = PendingBoostActivity( + txId = txId, + updatedAt = updatedAt, + activityToDelete = null, + parentTxId = parentTxId + ) + + val cacheData = AppCacheData(pendingBoostActivities = listOf(pendingBoost)) + setupSyncActivitiesMocks(cacheData, listOf(existingActivity)) + wheneverBlocking { coreService.activity.update(eq(activityId), any()) }.thenReturn(Unit) + wheneverBlocking { cacheStore.removeActivityFromPendingBoost(pendingBoost) }.thenReturn(Unit) + + val result = sut.syncActivities() + + assertTrue(result.isSuccess) + // Verify update was called with parentTxId added to empty boostTxIds + verify(coreService.activity).update( + eq(activityId), + argThat { activity -> + activity is Activity.Onchain && activity.v1.boostTxIds == listOf(parentTxId) + } + ) + verify(cacheStore).removeActivityFromPendingBoost(pendingBoost) + } + + @Test + fun `boostPendingActivities preserves existing boostTxIds when adding parentTxId`() = test { + val txId = "tx123" + val parentTxId = "parentTx456" + val existingBoostTxId = "existingBoost123" + val activityId = "activity123" + val updatedAt = 2000uL + + val existingActivity = createOnchainActivity( + id = activityId, + txId = txId, + boostTxIds = listOf(existingBoostTxId), + updatedAt = 1000uL + ) + + val pendingBoost = PendingBoostActivity( + txId = txId, + updatedAt = updatedAt, + activityToDelete = null, + parentTxId = parentTxId + ) + + val cacheData = AppCacheData(pendingBoostActivities = listOf(pendingBoost)) + setupSyncActivitiesMocks(cacheData, listOf(existingActivity)) + wheneverBlocking { coreService.activity.update(eq(activityId), any()) }.thenReturn(Unit) + wheneverBlocking { cacheStore.removeActivityFromPendingBoost(pendingBoost) }.thenReturn(Unit) + + val result = sut.syncActivities() + + assertTrue(result.isSuccess) + // Verify update was called with both existing and new parentTxId in boostTxIds + verify(coreService.activity).update( + eq(activityId), + argThat { activity -> + activity is Activity.Onchain && + activity.v1.boostTxIds.contains(existingBoostTxId) && + activity.v1.boostTxIds.contains(parentTxId) + } + ) + } + + @Test + fun `boostPendingActivities does not add parentTxId when parentTxId is null`() = test { + val txId = "tx123" + val existingBoostTxId = "existingBoost123" + val activityId = "activity123" + val updatedAt = 2000uL + + val existingActivity = createOnchainActivity( + id = activityId, + txId = txId, + boostTxIds = listOf(existingBoostTxId), + updatedAt = 1000uL + ) + + val pendingBoost = PendingBoostActivity( + txId = txId, + updatedAt = updatedAt, + activityToDelete = null, + parentTxId = null + ) + + val cacheData = AppCacheData(pendingBoostActivities = listOf(pendingBoost)) + setupSyncActivitiesMocks(cacheData, listOf(existingActivity)) + wheneverBlocking { coreService.activity.update(eq(activityId), any()) }.thenReturn(Unit) + wheneverBlocking { cacheStore.removeActivityFromPendingBoost(pendingBoost) }.thenReturn(Unit) + + val result = sut.syncActivities() + + assertTrue(result.isSuccess) + // Verify update was called with only existing boostTxIds (no new parentTxId added) + verify(coreService.activity).update( + eq(activityId), + argThat { activity -> + activity is Activity.Onchain && + activity.v1.boostTxIds == listOf(existingBoostTxId) + } + ) + } + + @Test + fun `boostPendingActivities calls replaceActivity when activityToDelete is provided`() = test { + val txId = "tx123" + val parentTxId = "parentTx456" + val activityId = "activity123" + val activityToDeleteId = "activity456" + val updatedAt = 2000uL + + val existingActivity = createOnchainActivity( + id = activityId, + txId = txId, + updatedAt = 1000uL + ) + + val onchainActivityToDelete = createOnchainActivity( + id = activityToDeleteId, + txId = "oldTx123", + value = 500uL, + fee = 50uL, + feeRate = 5uL, + address = "bc1old", + timestamp = 1234560000uL + ) + + val pendingBoost = PendingBoostActivity( + txId = txId, + updatedAt = updatedAt, + activityToDelete = activityToDeleteId, + parentTxId = parentTxId + ) + + val cacheData = AppCacheData(pendingBoostActivities = listOf(pendingBoost)) + setupSyncActivitiesMocks(cacheData, listOf(existingActivity)) + wheneverBlocking { coreService.activity.update(eq(activityId), any()) }.thenReturn(Unit) + wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(existingActivity) + wheneverBlocking { coreService.activity.getActivity(activityToDeleteId) }.thenReturn(onchainActivityToDelete) + wheneverBlocking { coreService.activity.update(eq(activityToDeleteId), any()) }.thenReturn(Unit) + wheneverBlocking { coreService.activity.tags(activityToDeleteId) }.thenReturn(emptyList()) + wheneverBlocking { coreService.activity.tags(activityId) }.thenReturn(emptyList()) + wheneverBlocking { cacheStore.removeActivityFromPendingBoost(pendingBoost) }.thenReturn(Unit) + + val result = sut.syncActivities() + + assertTrue(result.isSuccess) + // Verify replaceActivity was called (indirectly by checking both activities were updated) + verify(coreService.activity).update(eq(activityId), any()) + // Verify the old activity was marked as removed (doesExist = false) + verify(coreService.activity).update( + eq(activityToDeleteId), + argThat { activity -> + activity is Activity.Onchain && !activity.v1.doesExist + } + ) + verify(cacheStore).removeActivityFromPendingBoost(pendingBoost) + } + + @Test + fun `boostPendingActivities skips when activity updatedAt is newer than pendingBoost updatedAt`() = test { + val txId = "tx123" + val activityId = "activity123" + val updatedAt = 2000uL + + val existingActivity = createOnchainActivity( + id = activityId, + txId = txId, + updatedAt = 3000uL // Newer than pendingBoost.updatedAt + ) + + val pendingBoost = PendingBoostActivity( + txId = txId, + updatedAt = updatedAt, + activityToDelete = null, + parentTxId = null + ) + + val cacheData = AppCacheData(pendingBoostActivities = listOf(pendingBoost)) + setupSyncActivitiesMocks(cacheData, listOf(existingActivity)) + wheneverBlocking { cacheStore.removeActivityFromPendingBoost(pendingBoost) }.thenReturn(Unit) + + val result = sut.syncActivities() + + assertTrue(result.isSuccess) + // Verify update was NOT called (activity is newer) + verify(coreService.activity, never()).update(eq(activityId), any()) + // Verify pending boost was removed (skipped) + verify(cacheStore).removeActivityFromPendingBoost(pendingBoost) + } + + @Test + fun `markActivityAsRemovedFromMempool fails when activity not found`() = test { + val activityId = "activity456" + val cacheData = AppCacheData(activitiesPendingDelete = listOf(activityId)) + setupSyncActivitiesMocks(cacheData) + wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(null) + + val result = sut.syncActivities() + + assertTrue(result.isSuccess) + // Verify update was NOT called (activity not found) + verify(coreService.activity, never()).update(eq(activityId), any()) + // Verify it was NOT removed from pending delete (operation failed, will retry next sync) + verify(cacheStore, never()).removeActivityFromPendingDelete(activityId) + } + + @Test + fun `markActivityAsRemovedFromMempool fails when activity is not Onchain`() = test { + val activityId = "activity456" + val lightningActivity = testActivity + val cacheData = AppCacheData(activitiesPendingDelete = listOf(activityId)) + setupSyncActivitiesMocks(cacheData) + wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(lightningActivity) + + val result = sut.syncActivities() + + assertTrue(result.isSuccess) + // Verify update was NOT called (Lightning activities can't be marked as removed) + verify(coreService.activity, never()).update(eq(activityId), any()) + // Verify it was NOT removed from pending delete (operation failed, will retry next sync) + verify(cacheStore, never()).removeActivityFromPendingDelete(activityId) + } + + @Test + fun `replaceActivity caches to pending delete when markActivityAsRemovedFromMempool fails`() = test { + val activityId = "activity123" + val activityToDeleteId = "activity456" + val cacheData = AppCacheData(deletedActivities = emptyList()) + whenever(cacheStore.data).thenReturn(flowOf(cacheData)) + + // Activity to delete doesn't exist (will cause markActivityAsRemovedFromMempool to fail) + wheneverBlocking { coreService.activity.update(activityId, testActivity) }.thenReturn(Unit) + wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) + wheneverBlocking { coreService.activity.getActivity(activityToDeleteId) }.thenReturn(null) + wheneverBlocking { coreService.activity.tags(activityToDeleteId) }.thenReturn(emptyList()) + wheneverBlocking { coreService.activity.tags(activityId) }.thenReturn(emptyList()) + wheneverBlocking { cacheStore.addActivityToPendingDelete(activityToDeleteId) }.thenReturn(Unit) + + val result = sut.replaceActivity(activityId, activityToDeleteId, testActivity) + + assertTrue(result.isSuccess) + // Verify it was added to pending delete when marking failed + verify(cacheStore).addActivityToPendingDelete(activityToDeleteId) + } + } From 1c88564f053c6ea9f0396b6bfb99d46794edd2f2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 19 Nov 2025 10:00:51 -0500 Subject: [PATCH 3/5] Fix lint --- app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index a474cdec0..431b3a93d 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -31,6 +31,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +@Suppress("LargeClass") class ActivityRepoTest : BaseUnitTest() { private val coreService = mock() @@ -76,6 +77,7 @@ class ActivityRepoTest : BaseUnitTest() { updatedAt = null ) + @Suppress("LongParameterList") private fun createOnchainActivity( id: String = baseOnchainActivity.id, txId: String = baseOnchainActivity.txId, @@ -860,5 +862,4 @@ class ActivityRepoTest : BaseUnitTest() { // Verify it was added to pending delete when marking failed verify(cacheStore).addActivityToPendingDelete(activityToDeleteId) } - } From 7dbed4169156f6bddb3a437b48d7f035c33f3c44 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 19 Nov 2025 10:25:01 -0500 Subject: [PATCH 4/5] Fix tests --- .../to/bitkit/repositories/ActivityRepo.kt | 2 +- .../bitkit/repositories/ActivityRepoTest.kt | 108 ++++++++++++++---- 2 files changed, 88 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 5f832c5e5..06955b746 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -43,8 +43,8 @@ import javax.inject.Singleton private const val SYNC_TIMEOUT_MS = 40_000L -@Suppress("LongParameterList") @Singleton +@Suppress("LargeClass", "LongParameterList") class ActivityRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val coreService: CoreService, diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 431b3a93d..974ae6b1c 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -12,8 +12,10 @@ import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.PaymentDetails import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -22,6 +24,7 @@ import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.AppCacheData import to.bitkit.data.AppDb import to.bitkit.data.CacheStore +import to.bitkit.data.dao.TagMetadataDao import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest @@ -139,26 +142,17 @@ class ActivityRepoTest : BaseUnitTest() { } private fun setupSyncActivitiesMocks( - cacheData: AppCacheData, - activities: List = emptyList() + cacheData: AppCacheData ) { + val tagMetadataDao = mock() + whenever(db.tagMetadataDao()).thenReturn(tagMetadataDao) + wheneverBlocking { tagMetadataDao.getAll() }.thenReturn(emptyList()) + whenever(cacheStore.data).thenReturn(flowOf(cacheData)) wheneverBlocking { lightningRepo.getPayments() }.thenReturn(Result.success(emptyList())) wheneverBlocking { coreService.activity.syncLdkNodePaymentsToActivities(any(), eq(false)) }.thenReturn(Unit) - if (activities.isNotEmpty()) { - wheneverBlocking { - coreService.activity.get( - filter = ActivityFilter.ONCHAIN, - txType = PaymentType.SENT, - tags = null, - search = null, - minDate = null, - maxDate = null, - limit = 10u, - sortDirection = null - ) - }.thenReturn(activities) - } + wheneverBlocking { transferRepo.syncTransferStates() }.thenReturn(Result.success(Unit)) + wheneverBlocking { coreService.activity.allPossibleTags() }.thenReturn(emptyList()) } @Test @@ -581,6 +575,18 @@ class ActivityRepoTest : BaseUnitTest() { val cacheData = AppCacheData(activitiesPendingDelete = listOf(activityId)) setupSyncActivitiesMocks(cacheData) + wheneverBlocking { + coreService.activity.get( + filter = anyOrNull(), + txType = anyOrNull(), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = anyOrNull(), + sortDirection = anyOrNull() + ) + }.thenReturn(emptyList()) wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(onchainActivity) wheneverBlocking { coreService.activity.update(eq(activityId), any()) }.thenReturn(Unit) wheneverBlocking { cacheStore.removeActivityFromPendingDelete(activityId) }.thenReturn(Unit) @@ -623,7 +629,19 @@ class ActivityRepoTest : BaseUnitTest() { ) val cacheData = AppCacheData(pendingBoostActivities = listOf(pendingBoost)) - setupSyncActivitiesMocks(cacheData, listOf(existingActivity)) + setupSyncActivitiesMocks(cacheData) + wheneverBlocking { + coreService.activity.get( + filter = eq(ActivityFilter.ONCHAIN), + txType = eq(PaymentType.SENT), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = eq(10u), + sortDirection = anyOrNull() + ) + }.thenReturn(listOf(existingActivity)) wheneverBlocking { coreService.activity.update(eq(activityId), any()) }.thenReturn(Unit) wheneverBlocking { cacheStore.removeActivityFromPendingBoost(pendingBoost) }.thenReturn(Unit) @@ -663,7 +681,19 @@ class ActivityRepoTest : BaseUnitTest() { ) val cacheData = AppCacheData(pendingBoostActivities = listOf(pendingBoost)) - setupSyncActivitiesMocks(cacheData, listOf(existingActivity)) + setupSyncActivitiesMocks(cacheData) + wheneverBlocking { + coreService.activity.get( + filter = eq(ActivityFilter.ONCHAIN), + txType = eq(PaymentType.SENT), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = eq(10u), + sortDirection = anyOrNull() + ) + }.thenReturn(listOf(existingActivity)) wheneverBlocking { coreService.activity.update(eq(activityId), any()) }.thenReturn(Unit) wheneverBlocking { cacheStore.removeActivityFromPendingBoost(pendingBoost) }.thenReturn(Unit) @@ -703,7 +733,19 @@ class ActivityRepoTest : BaseUnitTest() { ) val cacheData = AppCacheData(pendingBoostActivities = listOf(pendingBoost)) - setupSyncActivitiesMocks(cacheData, listOf(existingActivity)) + setupSyncActivitiesMocks(cacheData) + wheneverBlocking { + coreService.activity.get( + filter = eq(ActivityFilter.ONCHAIN), + txType = eq(PaymentType.SENT), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = eq(10u), + sortDirection = anyOrNull() + ) + }.thenReturn(listOf(existingActivity)) wheneverBlocking { coreService.activity.update(eq(activityId), any()) }.thenReturn(Unit) wheneverBlocking { cacheStore.removeActivityFromPendingBoost(pendingBoost) }.thenReturn(Unit) @@ -752,7 +794,19 @@ class ActivityRepoTest : BaseUnitTest() { ) val cacheData = AppCacheData(pendingBoostActivities = listOf(pendingBoost)) - setupSyncActivitiesMocks(cacheData, listOf(existingActivity)) + setupSyncActivitiesMocks(cacheData) + wheneverBlocking { + coreService.activity.get( + filter = eq(ActivityFilter.ONCHAIN), + txType = eq(PaymentType.SENT), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = eq(10u), + sortDirection = anyOrNull() + ) + }.thenReturn(listOf(existingActivity)) wheneverBlocking { coreService.activity.update(eq(activityId), any()) }.thenReturn(Unit) wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(existingActivity) wheneverBlocking { coreService.activity.getActivity(activityToDeleteId) }.thenReturn(onchainActivityToDelete) @@ -796,7 +850,19 @@ class ActivityRepoTest : BaseUnitTest() { ) val cacheData = AppCacheData(pendingBoostActivities = listOf(pendingBoost)) - setupSyncActivitiesMocks(cacheData, listOf(existingActivity)) + setupSyncActivitiesMocks(cacheData) + wheneverBlocking { + coreService.activity.get( + filter = eq(ActivityFilter.ONCHAIN), + txType = eq(PaymentType.SENT), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = eq(10u), + sortDirection = anyOrNull() + ) + }.thenReturn(listOf(existingActivity)) wheneverBlocking { cacheStore.removeActivityFromPendingBoost(pendingBoost) }.thenReturn(Unit) val result = sut.syncActivities() From 706d9a2e3dbce09872e621c5f7be5e49edf12140 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 19 Nov 2025 18:36:15 +0100 Subject: [PATCH 5/5] feat: use x icon for dropped tx activities --- app/src/main/java/to/bitkit/ext/Activities.kt | 2 + .../activity/components/ActivityIcon.kt | 42 ++++++++++++++++--- .../activity/components/ActivityRow.kt | 32 ++++++-------- .../wallets/activity/utils/PreviewItems.kt | 12 +++--- 4 files changed, 57 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index ae0e9c22a..ae7b48527 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -60,6 +60,8 @@ fun Activity.matchesPaymentId(paymentHashOrTxId: String): Boolean = when (this) fun Activity.isTransfer() = this is Activity.Onchain && this.v1.isTransfer +fun Activity.doesExist() = this is Activity.Onchain && this.v1.doesExist + fun Activity.Onchain.boostType() = when (this.v1.txType) { PaymentType.SENT -> BoostType.RBF PaymentType.RECEIVED -> BoostType.CPFP 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 c97ea3da0..c38aba4f0 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 @@ -3,7 +3,7 @@ package to.bitkit.ui.screens.wallets.activity.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -24,6 +24,7 @@ import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import to.bitkit.R +import to.bitkit.ext.doesExist import to.bitkit.ext.isBoosted import to.bitkit.ext.isFinished import to.bitkit.ext.isTransfer @@ -46,7 +47,7 @@ fun ActivityIcon( val arrowIcon = painterResource(if (txType == PaymentType.SENT) R.drawable.ic_sent else R.drawable.ic_received) when { - activity.isBoosted() && !activity.isFinished() -> { + activity.isBoosted() && !activity.isFinished() && activity.doesExist() -> { CircularIcon( icon = painterResource(R.drawable.ic_timer_alt), iconColor = Colors.Yellow, @@ -90,9 +91,14 @@ fun ActivityIcon( } } + // onchain else -> { CircularIcon( - icon = if (activity.isTransfer()) painterResource(R.drawable.ic_transfer) else arrowIcon, + icon = when { + !activity.doesExist() -> painterResource(R.drawable.ic_x) + activity.isTransfer() -> painterResource(R.drawable.ic_transfer) + else -> arrowIcon + }, iconColor = Colors.Brand, backgroundColor = Colors.Brand16, size = size, @@ -129,8 +135,8 @@ fun CircularIcon( @Composable private fun Preview() { AppThemeSurface { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(16.dp), ) { // Lightning Sent Succeeded @@ -293,6 +299,32 @@ private fun Preview() { ) ) ) + + // Onchain Removed + ActivityIcon( + activity = Activity.Onchain( + v1 = OnchainActivity( + id = "test-onchain-2", + txType = PaymentType.SENT, + txId = "abc123", + value = 100000uL, + fee = 500uL, + feeRate = 8uL, + address = "bc1...", + confirmed = true, + timestamp = (System.currentTimeMillis() / 1000).toULong(), + isBoosted = true, + boostTxIds = emptyList(), + isTransfer = false, + doesExist = false, + confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), + channelId = null, + transferTxId = "transferTxId", + createdAt = null, + updatedAt = null, + ) + ) + ) } } } 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 9541a29cf..7f0a7911e 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 @@ -99,31 +99,23 @@ fun ActivityRow( is Activity.Lightning -> item.v1.message.ifEmpty { formattedTime(timestamp) } is Activity.Onchain -> { when { - !item.v1.doesExist -> { - stringResource(R.string.wallet__activity_removed) - } + !item.v1.doesExist -> stringResource(R.string.wallet__activity_removed) - isTransfer && isSent -> { - if (item.v1.confirmed) { - stringResource(R.string.wallet__activity_transfer_spending_done) - } else { - stringResource(R.string.wallet__activity_transfer_spending_pending) - .replace("{duration}", "1h") // TODO: calculate confirmsIn text - } + isTransfer && isSent -> if (item.v1.confirmed) { + stringResource(R.string.wallet__activity_transfer_spending_done) + } else { + stringResource(R.string.wallet__activity_transfer_spending_pending) + .replace("{duration}", "1h") // TODO: calculate confirmsIn text } - isTransfer && !isSent -> { - if (item.v1.confirmed) { - stringResource(R.string.wallet__activity_transfer_savings_done) - } else { - stringResource(R.string.wallet__activity_transfer_savings_pending) - .replace("{duration}", "1h") // TODO: calculate confirmsIn text - } + isTransfer && !isSent -> if (item.v1.confirmed) { + stringResource(R.string.wallet__activity_transfer_savings_done) + } else { + stringResource(R.string.wallet__activity_transfer_savings_pending) + .replace("{duration}", "1h") // TODO: calculate confirmsIn text } - confirmed == true -> { - formattedTime(timestamp) - } + confirmed == true -> formattedTime(timestamp) else -> { // TODO: calculate confirmsIn text 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 85067bb97..bc99e8794 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 @@ -23,20 +23,20 @@ val previewActivityItems = buildList { id = "1", txType = PaymentType.RECEIVED, txId = "01", - value = 42_000_000_u, + value = 42_000_u, fee = 200_u, feeRate = 1_u, address = "bc1", confirmed = true, timestamp = today.epochSecond(), - isBoosted = false, - boostTxIds = emptyList(), - isTransfer = true, - doesExist = true, + isBoosted = true, + boostTxIds = listOf("02", "03"), + isTransfer = false, + doesExist = false, confirmTimestamp = today.epochSecond(), channelId = "channelId", transferTxId = "transferTxId", - createdAt = today.epochSecond(), + createdAt = today.epochSecond() - 30_000u, updatedAt = today.epochSecond(), ) )