From 95c7f7723158c68b663ed36b09a581334d063e53 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 5 May 2026 20:45:17 -0500 Subject: [PATCH 1/3] fix: polish public contact payments --- app/src/main/java/to/bitkit/ext/Activities.kt | 6 +++ .../to/bitkit/repositories/ActivityRepo.kt | 6 ++- .../java/to/bitkit/services/CoreService.kt | 3 +- app/src/main/java/to/bitkit/ui/ContentView.kt | 1 + .../viewmodels/ActivityListViewModel.kt | 15 +------- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../bitkit/repositories/ActivityRepoTest.kt | 37 +++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 14 +++++++ changelog.d/next/931.fixed.md | 1 + 9 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 changelog.d/next/931.fixed.md diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index 89708073b..8b99d5ff7 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -66,6 +66,12 @@ fun Activity.isTransfer() = this is Activity.Onchain && this.v1.isTransfer fun Activity.doesExist() = this is Activity.Onchain && this.v1.doesExist +fun Activity.isReplacedSentTransaction(txIdsInBoostTxIds: Set): Boolean = + this is Activity.Onchain && + !v1.doesExist && + v1.txType == PaymentType.SENT && + v1.txId in txIdsInBoostTxIds + fun Activity.paymentState(): PaymentState? = when (this) { is Activity.Lightning -> this.v1.status is Activity.Onchain -> null diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 851a7f4f2..08ccd6217 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -36,6 +36,7 @@ import to.bitkit.di.BgDispatcher import to.bitkit.di.IoDispatcher import to.bitkit.ext.amountOnClose import to.bitkit.ext.contact +import to.bitkit.ext.isReplacedSentTransaction import to.bitkit.ext.matchesPaymentId import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp @@ -345,10 +346,13 @@ class ActivityRepo @Inject constructor( suspend fun contactActivities(publicKey: String): Result> = withContext(ioDispatcher) { runCatching { val normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: publicKey + val txIdsInBoostTxIds = getTxIdsInBoostTxIds() getActivities( filter = ActivityFilter.ALL, sortDirection = SortDirection.DESC, - ).getOrThrow().filter { PubkyPublicKeyFormat.matches(it.contact(), normalizedKey) } + ).getOrThrow() + .filterNot { it.isReplacedSentTransaction(txIdsInBoostTxIds) } + .filter { PubkyPublicKeyFormat.matches(it.contact(), normalizedKey) } }.onFailure { Logger.error("Failed to load contact activities for '$publicKey'", it, context = TAG) } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 527dff93d..95f9f43aa 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -1021,7 +1021,8 @@ class ActivityService( val updatedActivity = replacementActivity.copy( boostTxIds = replacementActivity.boostTxIds + txid, isBoosted = true, - updatedAt = System.currentTimeMillis().toULong() / 1000u + contact = replacementActivity.contact ?: replacedActivity?.contact, + updatedAt = System.currentTimeMillis().toULong() / 1000u, ) updateActivity(replacementActivity.id, Activity.Onchain(updatedActivity)) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 262b99f51..6d9fefecf 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1005,6 +1005,7 @@ private fun NavGraphBuilder.contacts( onBackClick = { navController.popBackStack() }, onContactSaved = { navController.popBackStack() }, onPayContact = { paymentRequest, publicKey -> + navController.popBackStack() appViewModel.openContactPayment(paymentRequest, publicKey) }, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index 0eae13da9..f887782f9 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.di.BgDispatcher +import to.bitkit.ext.isReplacedSentTransaction import to.bitkit.ext.isTransfer import to.bitkit.models.PubkyProfile import to.bitkit.repositories.ActivityRepo @@ -146,19 +147,7 @@ class ActivityListViewModel @Inject constructor( private suspend fun filterOutReplacedSentTransactions(activities: List): List { val txIdsInBoostTxIds = activityRepo.getTxIdsInBoostTxIds() - - return activities.filter { - if (it is Activity.Onchain) { - val onchain = it.v1 - if (!onchain.doesExist && - onchain.txType == PaymentType.SENT && - txIdsInBoostTxIds.contains(onchain.txId) - ) { - return@filter false - } - } - true - } + return activities.filterNot { it.isReplacedSentTransaction(txIdsInBoostTxIds) } } fun updateAvailableTags() { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 97099a267..65ddfb650 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1137,7 +1137,7 @@ class AppViewModel @Inject constructor( } private fun onAddressContinue(data: String) { - launchScan(source = ScanSource.ADDRESS_CONTINUE, data = data) + launchScan(source = ScanSource.ADDRESS_CONTINUE, data = data, routePubkyKeys = true) } private suspend fun onAmountChange(amount: ULong) { diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index f148b1635..55b1e0667 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -87,6 +87,7 @@ class ActivityRepoTest : BaseUnitTest() { confirmTimestamp: ULong? = baseOnchainActivity.confirmTimestamp, channelId: String? = baseOnchainActivity.channelId, transferTxId: String? = baseOnchainActivity.transferTxId, + contact: String? = baseOnchainActivity.contact, createdAt: ULong? = baseOnchainActivity.createdAt, updatedAt: ULong? = baseOnchainActivity.updatedAt, ): Activity.Onchain { @@ -107,6 +108,7 @@ class ActivityRepoTest : BaseUnitTest() { confirmTimestamp = confirmTimestamp, channelId = channelId, transferTxId = transferTxId, + contact = contact, createdAt = createdAt, updatedAt = updatedAt ) @@ -267,6 +269,41 @@ class ActivityRepoTest : BaseUnitTest() { assertNull(result.getOrThrow()) } + @Test + fun `contactActivities filters replaced sent transaction`() = test { + val contactPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + val replacedTxId = "replaced_tx_id" + val replacedActivity = createOnchainActivity( + id = "replaced_activity_id", + txId = replacedTxId, + doesExist = false, + contact = contactPublicKey, + ) + val replacementActivity = createOnchainActivity( + id = "replacement_activity_id", + txId = "replacement_tx_id", + boostTxIds = listOf(replacedTxId), + contact = contactPublicKey, + ) + whenever(coreService.activity.getTxIdsInBoostTxIds()).thenReturn(setOf(replacedTxId)) + whenever( + coreService.activity.get( + filter = ActivityFilter.ALL, + txType = null, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = SortDirection.DESC, + ) + ).thenReturn(listOf(replacedActivity, replacementActivity)) + + val result = sut.contactActivities(contactPublicKey) + + assertEquals(listOf(replacementActivity), result.getOrThrow()) + } + @Test fun `updateActivity updates successfully when not deleted`() = test { val activityId = "activity123" diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 900101fe9..b3f6ee9d9 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1,6 +1,7 @@ package to.bitkit.viewmodels import android.content.Context +import app.cash.turbine.test import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner @@ -50,6 +51,7 @@ import to.bitkit.services.AppUpdaterService import to.bitkit.services.CoreService import to.bitkit.services.MigrationService import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute @@ -93,6 +95,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val settingsData = MutableStateFlow(SettingsData()) private val walletState = MutableStateFlow(WalletState()) private val nodeEvents = MutableSharedFlow() + private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private val timedSheetManager = mock() @@ -123,6 +126,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { widgetsRepo.refreshEnabledWidgets() }.thenReturn(Unit) whenever { lightningRepo.updateGeoBlockState() }.thenReturn(Unit) whenever(pubkyRepo.sessionRestorationFailed).thenReturn(MutableStateFlow(false)) + whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(null)) + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(emptyList())) whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())) .thenReturn(Result.failure(Exception("not mocked"))) whenever { lightningRepo.calculateTotalFee(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) } @@ -218,6 +223,15 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertFalse(sut.sendUiState.value.canSwitchWallet) } + @Test + fun `manual address continue routes pubky to add contact`() = test { + sut.mainScreenEffect.test { + sut.setSendEvent(SendEvent.AddressContinue(testPublicKey)) + + assertEquals(MainScreenEffect.Navigate(Routes.AddContact(testPublicKey)), awaitItem()) + } + } + @Test fun `canSwitchWallet is false when amount exceeds lightning balance`() = test { balanceState.value = BalanceState( diff --git a/changelog.d/next/931.fixed.md b/changelog.d/next/931.fixed.md new file mode 100644 index 000000000..884bd904a --- /dev/null +++ b/changelog.d/next/931.fixed.md @@ -0,0 +1 @@ +Improved public contact payment flows for manual Pubky entry, add-contact payments, and RBF activity display. From 17c72568d037caecbd59524df78ee4d119efc5dd Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 6 May 2026 08:03:30 -0500 Subject: [PATCH 2/3] fix: propagate rbf contact updates --- .../to/bitkit/repositories/ActivityRepo.kt | 18 +++++++ .../bitkit/repositories/ActivityRepoTest.kt | 50 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 08ccd6217..b13fba708 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -386,11 +386,29 @@ class ActivityRepo @Inject constructor( val updatedAt = nowTimestamp().epochSecond.toULong() val updatedActivity = activity.withContact(normalizedKey, updatedAt) updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow() + updateReplacementContactIfNeeded(updatedActivity, normalizedKey, updatedAt) }.onFailure { Logger.error("Failed to set contact for payment '$forPaymentId'", it, context = TAG) } } + private suspend fun updateReplacementContactIfNeeded( + activity: Activity, + normalizedKey: String, + updatedAt: ULong, + ) { + if (activity !is Activity.Onchain || activity.v1.doesExist || activity.v1.txType != PaymentType.SENT) return + + getActivities(filter = ActivityFilter.ONCHAIN).getOrThrow() + .filterIsInstance() + .filter { activity.v1.txId in it.v1.boostTxIds } + .filterNot { PubkyPublicKeyFormat.matches(it.v1.contact, normalizedKey) } + .forEach { + val updatedReplacement = Activity.Onchain(it.v1.copy(contact = normalizedKey, updatedAt = updatedAt)) + updateActivity(updatedReplacement.rawId(), updatedReplacement).getOrThrow() + } + } + private suspend fun findActivityForPaymentId(forPaymentId: String, syncLdkPayments: Boolean): Activity? { val activity = getActivityByPaymentId(forPaymentId) if (activity != null) return activity diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 55b1e0667..7bc95e4f0 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -304,6 +304,56 @@ class ActivityRepoTest : BaseUnitTest() { assertEquals(listOf(replacementActivity), result.getOrThrow()) } + @Test + fun `setContact propagates contact to replacement transaction`() = test { + val contactPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + val replacedTxId = "replaced_tx_id" + val replacedActivity = createOnchainActivity( + id = "replaced_activity_id", + txId = replacedTxId, + doesExist = false, + ) + val replacementActivity = createOnchainActivity( + id = "replacement_activity_id", + txId = "replacement_tx_id", + boostTxIds = listOf(replacedTxId), + ) + whenever(coreService.activity.getActivity(replacedTxId)).thenReturn(null) + whenever(coreService.activity.getOnchainActivityByTxId(replacedTxId)).thenReturn(replacedActivity.v1) + whenever( + coreService.activity.get( + filter = ActivityFilter.ONCHAIN, + txType = null, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null, + ) + ).thenReturn(listOf(replacedActivity, replacementActivity)) + + val result = sut.setContact( + contactPublicKey = contactPublicKey, + forPaymentId = replacedTxId, + syncLdkPayments = false, + ) + + assertTrue(result.isSuccess) + verify(coreService.activity).update( + eq(replacedActivity.v1.id), + argThat { + this is Activity.Onchain && v1.contact == contactPublicKey + }, + ) + verify(coreService.activity).update( + eq(replacementActivity.v1.id), + argThat { + this is Activity.Onchain && v1.contact == contactPublicKey + }, + ) + } + @Test fun `updateActivity updates successfully when not deleted`() = test { val activityId = "activity123" From 29fdd32c8af258360cec82aa13cb6cbe4e02ab82 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 6 May 2026 08:21:01 -0500 Subject: [PATCH 3/3] fix: handle pubky send input --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 +++++ .../viewmodels/AppViewModelSendFlowTest.kt | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c7c999d40..4957c9f74 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -939,6 +939,11 @@ class AppViewModel @Inject constructor( // Skip validation for empty input if (valueWithoutSpaces.isEmpty()) return + if (PubkyPublicKeyFormat.normalized(valueWithoutSpaces) != null) { + _sendUiState.update { it.copy(isAddressInputValid = true) } + return + } + // Start debounced validation addressValidationJob = viewModelScope.launch { delay(ADDRESS_VALIDATION_DEBOUNCE_MS) @@ -1420,6 +1425,7 @@ class AppViewModel @Inject constructor( if (route != null) { clearActiveContactPaymentContext() + if (currentSheet.value is Sheet.Send) hideSheet() mainScreenEffect(MainScreenEffect.Navigate(route)) return@withContext } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index b3f6ee9d9..5f52fd256 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -18,6 +18,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.AppCacheData @@ -232,6 +233,29 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `manual address input accepts pubky without decode error`() = test { + sut.setSendEvent(SendEvent.AddressChange(testPublicKey)) + advanceUntilIdle() + + assertEquals(testPublicKey, sut.sendUiState.value.addressInput) + assertTrue(sut.sendUiState.value.isAddressInputValid) + verify(coreService, never()).decode(any()) + } + + @Test + fun `pubky routing dismisses send sheet before navigation`() = test { + sut.showSheet(Sheet.Send()) + advanceUntilIdle() + + sut.mainScreenEffect.test { + sut.setSendEvent(SendEvent.AddressContinue(testPublicKey)) + + assertEquals(MainScreenEffect.Navigate(Routes.AddContact(testPublicKey)), awaitItem()) + assertNull(sut.currentSheet.value) + } + } + @Test fun `canSwitchWallet is false when amount exceeds lightning balance`() = test { balanceState.value = BalanceState(