From 284c50b3127b02767e6b6e96b5f1987004e50c19 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 28 Sep 2026 08:18:33 -0300 Subject: [PATCH 01/21] fix: stop blocking contact pay on endpoint publish Co-Authored-By: Claude Opus 5.5 --- .../bitkit/repositories/PrivatePaykitRepo.kt | 2 +- .../repositories/PrivatePaykitRepoTest.kt | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 83d8e0f582..c47685ee50 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -582,7 +582,7 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun beginSavedContactPaymentWithRetry(publicKey: String): PublicPaykitPaymentResult { - refreshPrivateEndpointsBeforePayment(publicKey) + retryScope.launch { refreshPrivateEndpointsBeforePayment(publicKey) } var result = beginContactPayment(publicKey, paymentRequest = null).getOrThrow() for (retryDelay in privatePaymentResolutionRetryDelays) { if (result != PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) return result diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 4bc0d724b0..62d819ff91 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -63,6 +63,7 @@ import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -1191,6 +1192,34 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } + @Test + fun `beginSavedContactPayment does not wait for the local endpoint publish`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + val publishStarted = CompletableDeferred() + val stalledPublish = CompletableDeferred() + whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.doSuspendableAnswer { + publishStarted.complete(Unit) + stalledPublish.await() + privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY)) + } + whenever { + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) + whenever(coreService.decode(PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) + + val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + + assertIs(result) + publishStarted.await() + stalledPublish.complete(Unit) + } + @Test fun `beginSavedContactPayment opens private endpoint with its list version`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) From ed5ee744a2c256df8256a0610e08aabf3d25f3bb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 28 Sep 2026 08:20:27 -0300 Subject: [PATCH 02/21] fix: refresh contact request eligibility on pay Co-Authored-By: Claude Opus 5.5 --- .../repositories/PaykitPaymentRequestRepo.kt | 26 +++++ app/src/main/java/to/bitkit/ui/ContentView.kt | 20 +--- .../screens/contacts/ContactDetailScreen.kt | 36 +++--- .../contacts/ContactDetailViewModel.kt | 109 +++++++++++++---- .../PaykitPaymentRequestRepoTest.kt | 36 ++++++ .../contacts/ContactDetailViewModelTest.kt | 110 +++++++++++++++++- 6 files changed, 278 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 26890757f7..745eeb23f4 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -458,6 +458,32 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + suspend fun refreshEligibleTarget(savedPublicKey: String): Result { + val generation = stateGeneration.get() + val expectedIdentity = activeIdentity + return withContext(ioDispatcher) { + runSuspendCatching { + val publicKey = PubkyPublicKeyFormat.normalized(savedPublicKey) ?: return@runSuspendCatching null + if (!isAvailable() || expectedIdentity == null) return@runSuspendCatching null + val previousTargets = _eligibleTargets.value.associateBy { it.publicKey } + val discovery = targetContext(listOf(publicKey), expectedIdentity) + ?.let { eligibleTargets(it, previousTargets) } + ?: PaykitPaymentRequestTargetDiscovery(emptyList(), isComplete = true) + val target = discovery.targets.firstOrNull() + operationMutex.withLock { + if (!isCurrentState(generation, expectedIdentity)) return@withLock + if (target == null && !discovery.isComplete) return@withLock + _eligibleTargets.update { targets -> + targets.filterNot { it.publicKey == publicKey } + listOfNotNull(target) + } + } + target + }.onFailure { + Logger.warn("Failed to refresh Paykit payment request recipient", it, context = TAG) + } + } + } + suspend fun propose( draft: PaykitPaymentRequestDraft, target: PaykitPaymentRequestTarget, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index c067d77d79..bf9df52fc2 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -61,7 +61,6 @@ import to.bitkit.env.Env import to.bitkit.ext.rawId import to.bitkit.ext.walletId import to.bitkit.models.NodeLifecycleState -import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.Toast import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.PaykitPaymentRequestId @@ -1412,10 +1411,6 @@ private fun NavGraphBuilder.contacts( PaykitRouteGuard(settingsViewModel, navController) { val route = backStackEntry.toRoute() val viewModel: ContactDetailViewModel = hiltViewModel() - val paymentRequestTargets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() - val paymentRequestTarget = paymentRequestTargets.firstOrNull { - PubkyPublicKeyFormat.matches(it.publicKey, route.publicKey) - } ContactDetailScreen( viewModel = viewModel, onBackClick = { navController.popBackStack() }, @@ -1423,18 +1418,15 @@ private fun NavGraphBuilder.contacts( appViewModel.openContactPayment(paymentRequest, publicKey, privatePaymentContext) }, onActivityClick = { navController.navigateTo(Routes.ContactActivity(it)) }, - canRequestPayment = paymentRequestTarget != null, onRequestPayment = { - paymentRequestTarget?.let { - appViewModel.showSheet( - Sheet.Receive( - route = ReceiveRoute.PaymentRequestAmount( - publicKey = it.publicKey, - receiverPath = it.receiverPath, - ) + appViewModel.showSheet( + Sheet.Receive( + route = ReceiveRoute.PaymentRequestAmount( + publicKey = it.publicKey, + receiverPath = it.receiverPath, ) ) - } + ) }, showDeleteAction = route.showDeleteAction, onContactDeleted = { diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt index 71f33aaf09..7b2a7d5b63 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt @@ -20,9 +20,6 @@ import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -37,6 +34,7 @@ import kotlinx.collections.immutable.persistentListOf import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink +import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.ui.components.ActionButton import to.bitkit.ui.components.AddTagSheet @@ -72,15 +70,13 @@ fun ContactDetailScreen( onBackClick: () -> Unit, onPayContact: (String, String, PrivatePaykitPaymentContext?) -> Unit, onActivityClick: (String) -> Unit, - canRequestPayment: Boolean = false, - onRequestPayment: () -> Unit = {}, + onRequestPayment: (PaykitPaymentRequestTarget) -> Unit = {}, showDeleteAction: Boolean = false, onContactDeleted: () -> Unit = {}, onEditContact: (String) -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current - var showRequestOrPay by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.effects.collect { @@ -99,13 +95,7 @@ fun ContactDetailScreen( showDeleteAction = showDeleteAction, onClickDelete = { viewModel.showDeleteConfirmation() }, onClickCopy = { viewModel.copyPublicKey() }, - onClickPay = { - if (canRequestPayment) { - showRequestOrPay = true - } else { - viewModel.payContact() - } - }, + onClickPay = { viewModel.onClickPay() }, onClickActivity = { uiState.profile?.publicKey?.let { onActivityClick(it) } }, onClickShare = { uiState.profile?.publicKey?.let { shareText(context, it) } }, onClickRetry = { viewModel.loadContact() }, @@ -117,17 +107,16 @@ fun ContactDetailScreen( onConfirmDelete = { viewModel.deleteContact() }, ) - if (showRequestOrPay && uiState.profile != null) { + val requestOrPayContact = uiState.profile + val paymentRequestTarget = uiState.paymentRequestTarget + if (uiState.showRequestOrPaySheet && requestOrPayContact != null && paymentRequestTarget != null) { RequestOrPaySheet( - contact = requireNotNull(uiState.profile), - onDismiss = { showRequestOrPay = false }, - onPay = { - showRequestOrPay = false - viewModel.payContact() - }, + contact = requestOrPayContact, + onDismiss = { viewModel.dismissRequestOrPaySheet() }, + onPay = { viewModel.payContact() }, onRequest = { - showRequestOrPay = false - onRequestPayment() + viewModel.dismissRequestOrPaySheet() + onRequestPayment(paymentRequestTarget) }, ) } @@ -235,6 +224,7 @@ private fun Content( profile = currentProfile, tags = uiState.tags, showPayButton = uiState.showPayButton, + isPayLoading = uiState.isPayLoading, showDeleteAction = showDeleteAction, onClickEdit = onClickEdit, onClickDelete = onClickDelete, @@ -273,6 +263,7 @@ private fun ContactBody( profile: PubkyProfile, tags: ImmutableList, showPayButton: Boolean, + isPayLoading: Boolean, showDeleteAction: Boolean, onClickEdit: () -> Unit, onClickDelete: () -> Unit, @@ -318,6 +309,7 @@ private fun ContactBody( ActionButton( onClick = onClickPay, iconRes = R.drawable.ic_coins, + enabled = !isPayLoading, modifier = Modifier.testTag("ContactPay") ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index 9f0c2d0cbd..312d514f9e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -10,6 +10,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -19,12 +22,15 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull import to.bitkit.R import to.bitkit.ext.setClipboardText import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.Toast +import to.bitkit.repositories.PaykitPaymentRequestRepo +import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo @@ -32,17 +38,23 @@ import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds +@Suppress("TooManyFunctions") @HiltViewModel class ContactDetailViewModel @Inject constructor( @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, private val privatePaykitRepo: PrivatePaykitRepo, + private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo, savedStateHandle: SavedStateHandle, ) : ViewModel() { companion object { private const val TAG = "ContactDetailViewModel" + + /** How long a Pay tap waits for the payment request check before falling back to paying. */ + private val PAYMENT_REQUEST_TARGET_WAIT = 2.seconds } private val publicKey: String = checkNotNull( @@ -58,9 +70,14 @@ class ContactDetailViewModel @Inject constructor( private val _effects = MutableSharedFlow(extraBufferCapacity = 1) val effects = _effects.asSharedFlow() + private var payJob: Job? = null + private var paymentRequestTargetRefresh: Deferred? = null + init { loadContact() observeContactUpdates() + observePaymentRequestTarget() + refreshPaymentRequestTarget() } fun loadContact() { @@ -100,32 +117,77 @@ class ContactDetailViewModel @Inject constructor( } } + fun onClickPay() { + if (payJob?.isActive == true) return + payJob = viewModelScope.launch { + _uiState.update { it.copy(isPayLoading = true) } + val target = _uiState.value.paymentRequestTarget + ?: withTimeoutOrNull(PAYMENT_REQUEST_TARGET_WAIT) { refreshPaymentRequestTarget().await() } + if (target != null) { + _uiState.update { it.copy(isPayLoading = false, showRequestOrPaySheet = true) } + return@launch + } + openPayment() + _uiState.update { it.copy(isPayLoading = false) } + } + } + + fun dismissRequestOrPaySheet() { + _uiState.update { it.copy(showRequestOrPaySheet = false) } + } + fun payContact() { + _uiState.update { it.copy(showRequestOrPaySheet = false) } + if (payJob?.isActive == true) return + payJob = viewModelScope.launch { + _uiState.update { it.copy(isPayLoading = true) } + openPayment() + _uiState.update { it.copy(isPayLoading = false) } + } + } + + private fun observePaymentRequestTarget() { viewModelScope.launch { - privatePaykitRepo.beginSavedContactPayment(publicKey) - .onSuccess { result -> - when (result) { - is PublicPaykitPaymentResult.Opened -> - _effects.emit( - ContactDetailEffect.OpenPayment( - result.paymentRequest, - publicKey, - result.privatePaymentContext, - ) + paykitPaymentRequestRepo.eligibleTargets.collect { targets -> + val target = targets.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, publicKey) } + _uiState.update { it.copy(paymentRequestTarget = target) } + } + } + } + + private fun refreshPaymentRequestTarget(): Deferred { + paymentRequestTargetRefresh?.let { return it } + return viewModelScope.async { + val isSaved = pubkyRepo.contacts.value.any { PubkyPublicKeyFormat.matches(it.publicKey, publicKey) } + if (!isSaved) return@async null + paykitPaymentRequestRepo.refreshEligibleTarget(publicKey).getOrNull() + }.also { paymentRequestTargetRefresh = it } + } + + private suspend fun openPayment() { + privatePaykitRepo.beginSavedContactPayment(publicKey) + .onSuccess { + when (it) { + is PublicPaykitPaymentResult.Opened -> + _effects.emit( + ContactDetailEffect.OpenPayment( + it.paymentRequest, + publicKey, + it.privatePaymentContext, ) - PublicPaykitPaymentResult.NoEndpoint -> - showPayError(R.string.slashtags__error_pay_empty_msg) - PublicPaykitPaymentResult.NotOpened -> - showPayError(R.string.slashtags__error_pay_not_opened_msg) - PublicPaykitPaymentResult.WaitingForUpdatedPaymentList -> - showPayError(R.string.slashtags__error_pay_waiting_msg) - } - } - .onFailure { - Logger.warn("Failed to begin Paykit payment for '$redactedPublicKey'", it, context = TAG) - showPayError(R.string.slashtags__error_pay_not_opened_msg) + ) + PublicPaykitPaymentResult.NoEndpoint -> + showPayError(R.string.slashtags__error_pay_empty_msg) + PublicPaykitPaymentResult.NotOpened -> + showPayError(R.string.slashtags__error_pay_not_opened_msg) + PublicPaykitPaymentResult.WaitingForUpdatedPaymentList -> + showPayError(R.string.slashtags__error_pay_waiting_msg) } - } + } + .onFailure { + Logger.warn("Failed to begin Paykit payment for '$redactedPublicKey'", it, context = TAG) + showPayError(R.string.slashtags__error_pay_not_opened_msg) + } } private suspend fun showPayError(messageRes: Int) { @@ -253,6 +315,9 @@ data class ContactDetailUiState( val tags: ImmutableList = persistentListOf(), val isLoading: Boolean = false, val showPayButton: Boolean = false, + val isPayLoading: Boolean = false, + val paymentRequestTarget: PaykitPaymentRequestTarget? = null, + val showRequestOrPaySheet: Boolean = false, val showAddTagSheet: Boolean = false, val showDeleteDialog: Boolean = false, ) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 42699007fd..2500afda74 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -47,6 +47,7 @@ import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds @@ -676,6 +677,41 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService, times(1)) { paymentRequestReceiverPaths(COUNTERPARTY) } } + @Test + fun `single recipient refresh adds a newly eligible contact`() = test { + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + ) + whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( + listOf(PaykitReceiverPaths.SERVER), + ) + + val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() + + val expected = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + assertEquals(expected, target) + assertEquals(listOf(expected), sut.eligibleTargets.value) + } + + @Test + fun `single recipient refresh removes a contact that is no longer linked`() = test { + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + emptyList(), + ) + whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( + listOf(PaykitReceiverPaths.SERVER), + ) + sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() + + val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() + + assertNull(target) + assertTrue(sut.eligibleTargets.value.isEmpty()) + } + @Test fun `recipient discovery retries capabilities that are not published yet`() = test { whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt index 8f9d3467ad..30648a9552 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -12,27 +12,39 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PaykitPaymentRequestRepo +import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class ContactDetailViewModelTest : BaseUnitTest() { companion object { - private const val TEST_PUBLIC_KEY = "pubkytest-contact" + private const val TEST_PUBLIC_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" } private val context: Context = mock() private val pubkyRepo: PubkyRepo = mock() private val privatePaykitRepo: PrivatePaykitRepo = mock() + private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo = mock() + private val eligibleTargets = MutableStateFlow>(emptyList()) + private val target = PaykitPaymentRequestTarget(TEST_PUBLIC_KEY, "bitkit/wallet") + private val openedPayment = PublicPaykitPaymentResult.Opened( + paymentRequest = "bitcoin:bcrt1qtest", + privatePaymentContext = null, + ) @Test fun `deleting contact emits deleted effect`() = test { @@ -200,10 +212,106 @@ class ContactDetailViewModelTest : BaseUnitTest() { verify(pubkyRepo, times(2)).updateContact(any(), any(), any(), anyOrNull(), any(), any()) } + @Test + fun `pay tap shows request or pay sheet for an eligible contact`() = test { + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(target)) + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(openedPayment)) + eligibleTargets.value = listOf(target) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.onClickPay() + advanceUntilIdle() + + assertTrue(sut.uiState.value.showRequestOrPaySheet) + assertEquals(target, sut.uiState.value.paymentRequestTarget) + expectNoEvents() + } + } + + @Test + fun `pay tap refreshes eligibility when the contact is not a known target`() = test { + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(target)) + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(openedPayment)) + val sut = createSut() + advanceUntilIdle() + + sut.onClickPay() + advanceUntilIdle() + + verify(paykitPaymentRequestRepo).refreshEligibleTarget(TEST_PUBLIC_KEY) + assertTrue(sut.uiState.value.showRequestOrPaySheet) + } + + @Test + fun `pay tap opens payment when the contact cannot receive requests`() = test { + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(null)) + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(openedPayment)) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.onClickPay() + advanceUntilIdle() + + assertFalse(sut.uiState.value.showRequestOrPaySheet) + assertFalse(sut.uiState.value.isPayLoading) + assertIs(awaitItem()) + } + } + + @Test + fun `paying from the request or pay sheet opens the payment`() = test { + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(target)) + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(openedPayment)) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.onClickPay() + advanceUntilIdle() + sut.payContact() + advanceUntilIdle() + + val effect = assertIs(awaitItem()) + assertEquals(openedPayment.paymentRequest, effect.paymentRequest) + assertFalse(sut.uiState.value.showRequestOrPaySheet) + assertFalse(sut.uiState.value.isPayLoading) + } + } + + @Test + fun `unsaved contact skips the payment request check`() = test { + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(emptyList())) + whenever(pubkyRepo.fetchContactProfile(TEST_PUBLIC_KEY)).thenReturn(Result.success(createContact())) + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(openedPayment)) + val sut = createSut() + advanceUntilIdle() + + sut.onClickPay() + advanceUntilIdle() + + verify(paykitPaymentRequestRepo, never()).refreshEligibleTarget(any()) + assertFalse(sut.uiState.value.showRequestOrPaySheet) + } + private fun createSut() = ContactDetailViewModel( context = context, pubkyRepo = pubkyRepo, privatePaykitRepo = privatePaykitRepo, + paykitPaymentRequestRepo = paykitPaymentRequestRepo.also { + whenever(it.eligibleTargets).thenReturn(eligibleTargets) + }, savedStateHandle = SavedStateHandle(mapOf("publicKey" to TEST_PUBLIC_KEY)), ) From 9ce6c2d1cffba17430be31310bbe37aff2bfa323 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 28 Sep 2026 08:20:54 -0300 Subject: [PATCH 03/21] feat: show loading state on contact pay Co-Authored-By: Claude Opus 5.5 --- app/src/main/java/to/bitkit/ui/components/ActionButton.kt | 4 +++- .../to/bitkit/ui/screens/contacts/ContactDetailScreen.kt | 6 +++++- .../to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt | 3 +-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/ActionButton.kt b/app/src/main/java/to/bitkit/ui/components/ActionButton.kt index b29abf7269..645b6bfe4f 100644 --- a/app/src/main/java/to/bitkit/ui/components/ActionButton.kt +++ b/app/src/main/java/to/bitkit/ui/components/ActionButton.kt @@ -23,11 +23,12 @@ fun ActionButton( @DrawableRes iconRes: Int? = null, imageVector: ImageVector? = null, enabled: Boolean = true, + isLoading: Boolean = false, modifier: Modifier = Modifier, ) { IconButton( onClick = rememberDebouncedClick(onClick = onClick), - enabled = enabled, + enabled = enabled && !isLoading, modifier = modifier .size(48.dp) .clip(CircleShape) @@ -39,6 +40,7 @@ fun ActionButton( ) { val tint = if (enabled) Colors.White else Colors.White32 when { + isLoading -> GradientCircularProgressIndicator(modifier = Modifier.size(24.dp)) iconRes != null -> Icon( painter = painterResource(iconRes), contentDescription = null, diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt index 7b2a7d5b63..07227a2909 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt @@ -112,6 +112,7 @@ fun ContactDetailScreen( if (uiState.showRequestOrPaySheet && requestOrPayContact != null && paymentRequestTarget != null) { RequestOrPaySheet( contact = requestOrPayContact, + isPayLoading = uiState.isPayLoading, onDismiss = { viewModel.dismissRequestOrPaySheet() }, onPay = { viewModel.payContact() }, onRequest = { @@ -126,6 +127,7 @@ fun ContactDetailScreen( @OptIn(ExperimentalMaterial3Api::class) private fun RequestOrPaySheet( contact: PubkyProfile, + isPayLoading: Boolean, onDismiss: () -> Unit, onPay: () -> Unit, onRequest: () -> Unit, @@ -163,6 +165,7 @@ private fun RequestOrPaySheet( SecondaryButton( text = stringResource(R.string.wallet__payment_request_pay), onClick = onPay, + isLoading = isPayLoading, icon = { Icon( painter = painterResource(R.drawable.ic_sent), @@ -175,6 +178,7 @@ private fun RequestOrPaySheet( PrimaryButton( text = stringResource(R.string.wallet__payment_request_request), onClick = onRequest, + enabled = !isPayLoading, icon = { Icon( painter = painterResource(R.drawable.ic_received), @@ -309,7 +313,7 @@ private fun ContactBody( ActionButton( onClick = onClickPay, iconRes = R.drawable.ic_coins, - enabled = !isPayLoading, + isLoading = isPayLoading, modifier = Modifier.testTag("ContactPay") ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index 312d514f9e..3dc4efe8bb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -137,12 +137,11 @@ class ContactDetailViewModel @Inject constructor( } fun payContact() { - _uiState.update { it.copy(showRequestOrPaySheet = false) } if (payJob?.isActive == true) return payJob = viewModelScope.launch { _uiState.update { it.copy(isPayLoading = true) } openPayment() - _uiState.update { it.copy(isPayLoading = false) } + _uiState.update { it.copy(isPayLoading = false, showRequestOrPaySheet = false) } } } From baaa7089b48cdf4915891ed97e67b0c90c2555fd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 28 Sep 2026 08:30:22 -0300 Subject: [PATCH 04/21] test: add contact request or pay journey Co-Authored-By: Claude Opus 5.5 --- journeys/README.md | 2 ++ journeys/payment-requests/README.md | 5 +++++ .../contact-request-or-pay.xml | 20 +++++++++++++++++++ 3 files changed, 27 insertions(+) create mode 100644 journeys/payment-requests/contact-request-or-pay.xml diff --git a/journeys/README.md b/journeys/README.md index b481acfc01..f237b0c0ed 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -153,6 +153,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `activity/date-range-rapid-month-taps.xml` | not ported — iOS has no activity journey suite, and the rapid month tap behaviour was not checked there | | `coin-selection/manual-coin-selection.xml` | not ported — iOS has the screen (`SendUtxoSelectionView`) but no accessibility identifiers on it yet | | `payment-requests/requested-resolution-failure.xml` | not ported | +| `payment-requests/contact-request-or-pay.xml` | not ported yet — the iOS port of the eligibility refresh, non-blocking Pay and loading state follows in the next bitkit-ios PR | | `node-lifecycle/cancelled-node-restart.xml` | not ported — the routes run through Android's LDK Debug and Rapid-Gossip-Sync screens and assert on Android app-log lines | | `restore-wallet/paste-seed-fragment.xml` | not ported — the iOS Restore screen still has the 12/24-only paste guard, so the behaviour does not exist there yet | | `send/own-invoice-guard.xml` | not ported — iOS has no own-invoice guard | @@ -209,6 +210,7 @@ and Settings (`Tab-general`, `Tab-security`, `Tab-advanced`, `NavigationBack`, ` | Home suggestion cards | `Suggestion-` | — *(cards expose no identifier)* | | Receive QR copy button | `ReceiveCopyQR` | `ReceiveCopyQR` *(absent from `snapshot-ui` targets; see below)* | | Payment Request row | `PaymentRequestRow-` | `PaymentRequestRow--` *(`-one-time` for a one-off)* | +| Request or Pay sheet | `RequestOrPaySheet` | `RequestOrPay` | Two of those are unreconciled rather than intentional: the Send screen emitting both `AvailableAmount` and `available_balance`, and the background-payments row name. Settling either is a diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index a4f2c609b4..5f211ef678 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -20,6 +20,8 @@ Rejected fixture shapes stay in unit tests because Bitkit intentionally does not `request-summary.xml` uses a second Bitkit instance as the requester instead of the fixture issuer: both instances are authenticated Pubky identities, saved as each other's contacts and linked on receiver path `bitkit/wallet`, and the payer holds enough balance to pay 21,000 sats. +`contact-request-or-pay.xml` uses the same two-instance setup and starts from the payer's Contact Detail screen, opened through the `bitkit://contact` deeplink. Its timing step assumes the payer has been running for about a minute: right after launch, the Paykit session restore and link refresh hold the SDK and can push the Pay step well past the budget. + ## Reference evidence The source wallet-leg run completed this path on regtest on 2026-08-22: Bitkit presented the incoming request, opened the on-chain payment, broadcast it, and confirmed transaction @@ -39,5 +41,8 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, - Confirmation invoice note: `PaymentRequestInvoiceNote` - Confirmation details: `SendConfirmToggleDetails` - Saved-contact recipient: `ReviewContactRecipient` +- Contact Detail pay action: `ContactPay` +- Request or Pay sheet: `RequestOrPaySheet` (its Pay and Request buttons carry no tag; find them by text) +- Payment Request amount screen: `PaymentRequestAmount` `android layout` can omit test tags applied to plain `Box` and `Column` containers. Use the raw UI Automator hierarchy when a documented container tag is not present in the formatted layout output. diff --git a/journeys/payment-requests/contact-request-or-pay.xml b/journeys/payment-requests/contact-request-or-pay.xml new file mode 100644 index 0000000000..575a09769f --- /dev/null +++ b/journeys/payment-requests/contact-request-or-pay.xml @@ -0,0 +1,20 @@ + + + Verifies that Pay on a linked contact offers Request or Pay, that paying shows progress on the sheet until the amount screen opens within a few seconds, and that Request opens the Payment Request amount screen. Requires two Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet", with the payer funded; see README.md. Let the payer run for a minute after launch before starting, so start-up Paykit work does not dominate the timings. Open the contact with adb shell am start -a android.intent.action.VIEW -d "bitkit://contact?pubky=<requester-public-key>" to.bitkit.dev. + + + Open bitkit://contact?pubky=<requester-public-key> on the payer + Verify Contact Detail opens with the requester's name (testTag "ContactViewName") + Tap Pay (testTag "ContactPay") + Verify the Request or Pay sheet (testTag "RequestOrPaySheet") appears with a Pay and a Request button + Tap Pay on the sheet + Verify the sheet stays open, its Pay button shows a loading spinner and the Request button is disabled + Verify the send amount screen (testTag "send_amount_screen") opens within 3 seconds and the Request or Pay sheet is gone + Close the send amount screen without paying + Verify Contact Detail is visible and Pay (testTag "ContactPay") is enabled + Tap Pay (testTag "ContactPay") + Verify the Request or Pay sheet (testTag "RequestOrPaySheet") appears + Tap Request on the sheet + Verify the Payment Request amount screen (testTag "PaymentRequestAmount") opens + + From 2cb246e3bb21aaef575f5992c35449d1aa6b81c2 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 28 Sep 2026 08:39:10 -0300 Subject: [PATCH 05/21] chore: add changelog fragment Co-Authored-By: Claude Opus 5.5 --- changelog.d/next/1349.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1349.fixed.md diff --git a/changelog.d/next/1349.fixed.md b/changelog.d/next/1349.fixed.md new file mode 100644 index 0000000000..fb31a69dea --- /dev/null +++ b/changelog.d/next/1349.fixed.md @@ -0,0 +1 @@ +Paying a contact now always offers Request or Pay when the contact accepts requests, and opens the amount screen in about a second with a loading indicator. From 07a6d2b693e1b107d8676675c3fadfcfbc91679d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 28 Sep 2026 08:49:32 -0300 Subject: [PATCH 06/21] fix: recheck stale contact request eligibility Co-Authored-By: Claude Opus 5.5 --- .../contacts/ContactDetailViewModel.kt | 13 +++++-- .../contacts/ContactDetailViewModelTest.kt | 37 ++++++++++++++++++- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index 3dc4efe8bb..67044ea84c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -124,7 +124,9 @@ class ContactDetailViewModel @Inject constructor( val target = _uiState.value.paymentRequestTarget ?: withTimeoutOrNull(PAYMENT_REQUEST_TARGET_WAIT) { refreshPaymentRequestTarget().await() } if (target != null) { - _uiState.update { it.copy(isPayLoading = false, showRequestOrPaySheet = true) } + _uiState.update { + it.copy(isPayLoading = false, paymentRequestTarget = target, showRequestOrPaySheet = true) + } return@launch } openPayment() @@ -149,13 +151,18 @@ class ContactDetailViewModel @Inject constructor( viewModelScope.launch { paykitPaymentRequestRepo.eligibleTargets.collect { targets -> val target = targets.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, publicKey) } - _uiState.update { it.copy(paymentRequestTarget = target) } + _uiState.update { + it.copy( + paymentRequestTarget = target, + showRequestOrPaySheet = it.showRequestOrPaySheet && target != null, + ) + } } } } private fun refreshPaymentRequestTarget(): Deferred { - paymentRequestTargetRefresh?.let { return it } + paymentRequestTargetRefresh?.takeIf { it.isActive }?.let { return it } return viewModelScope.async { val isSaved = pubkyRepo.contacts.value.any { PubkyPublicKeyFormat.matches(it.publicKey, publicKey) } if (!isSaved) return@async null diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt index 30648a9552..7402e87325 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -244,10 +244,45 @@ class ContactDetailViewModelTest : BaseUnitTest() { sut.onClickPay() advanceUntilIdle() - verify(paykitPaymentRequestRepo).refreshEligibleTarget(TEST_PUBLIC_KEY) + verify(paykitPaymentRequestRepo, times(2)).refreshEligibleTarget(TEST_PUBLIC_KEY) assertTrue(sut.uiState.value.showRequestOrPaySheet) } + @Test + fun `pay tap rechecks eligibility after an earlier check found none`() = test { + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(null), Result.success(target)) + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(openedPayment)) + val sut = createSut() + advanceUntilIdle() + + sut.onClickPay() + advanceUntilIdle() + + verify(paykitPaymentRequestRepo, times(2)).refreshEligibleTarget(TEST_PUBLIC_KEY) + assertTrue(sut.uiState.value.showRequestOrPaySheet) + assertEquals(target, sut.uiState.value.paymentRequestTarget) + } + + @Test + fun `request or pay sheet closes when the contact stops being eligible`() = test { + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(target)) + eligibleTargets.value = listOf(target) + val sut = createSut() + advanceUntilIdle() + sut.onClickPay() + advanceUntilIdle() + assertTrue(sut.uiState.value.showRequestOrPaySheet) + + eligibleTargets.value = emptyList() + advanceUntilIdle() + + assertFalse(sut.uiState.value.showRequestOrPaySheet) + } + @Test fun `pay tap opens payment when the contact cannot receive requests`() = test { whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) From 8c1a3c6f14a34bab933444a34e901031192738cd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 28 Sep 2026 08:51:58 -0300 Subject: [PATCH 07/21] fix: cancel contact pay when the sheet is dismissed Co-Authored-By: Claude Opus 5.5 --- .../contacts/ContactDetailViewModel.kt | 3 +- .../contacts/ContactDetailViewModelTest.kt | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index 67044ea84c..8ddbd52a0d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -135,7 +135,8 @@ class ContactDetailViewModel @Inject constructor( } fun dismissRequestOrPaySheet() { - _uiState.update { it.copy(showRequestOrPaySheet = false) } + if (_uiState.value.isPayLoading) payJob?.cancel() + _uiState.update { it.copy(isPayLoading = false, showRequestOrPaySheet = false) } } fun payContact() { diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt index 7402e87325..44376f0f24 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -3,12 +3,15 @@ package to.bitkit.ui.screens.contacts import android.content.Context import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock @@ -283,6 +286,35 @@ class ContactDetailViewModelTest : BaseUnitTest() { assertFalse(sut.uiState.value.showRequestOrPaySheet) } + @Test + fun `dismissing the sheet while paying cancels the payment`() = test { + val paymentStarted = CompletableDeferred() + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(target)) + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)).doSuspendableAnswer { + paymentStarted.complete(Unit) + awaitCancellation() + } + eligibleTargets.value = listOf(target) + val sut = createSut() + advanceUntilIdle() + sut.onClickPay() + advanceUntilIdle() + + sut.effects.test { + sut.payContact() + paymentStarted.await() + assertTrue(sut.uiState.value.isPayLoading) + + sut.dismissRequestOrPaySheet() + advanceUntilIdle() + + assertFalse(sut.uiState.value.isPayLoading) + assertFalse(sut.uiState.value.showRequestOrPaySheet) + expectNoEvents() + } + } + @Test fun `pay tap opens payment when the contact cannot receive requests`() = test { whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) From 9e1ffd6e43a84b0673f8f3bac24a8ca577bd503e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 28 Sep 2026 09:03:18 -0300 Subject: [PATCH 08/21] fix: keep request or pay sheet open while paying Co-Authored-By: Claude Opus 5.5 --- .../screens/contacts/ContactDetailScreen.kt | 8 +++-- .../contacts/ContactDetailViewModel.kt | 2 +- .../contacts/ContactDetailViewModelTest.kt | 34 +++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt index 07227a2909..c05461a6f4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt @@ -109,15 +109,16 @@ fun ContactDetailScreen( val requestOrPayContact = uiState.profile val paymentRequestTarget = uiState.paymentRequestTarget - if (uiState.showRequestOrPaySheet && requestOrPayContact != null && paymentRequestTarget != null) { + if (uiState.showRequestOrPaySheet && requestOrPayContact != null) { RequestOrPaySheet( contact = requestOrPayContact, isPayLoading = uiState.isPayLoading, + canRequest = paymentRequestTarget != null, onDismiss = { viewModel.dismissRequestOrPaySheet() }, onPay = { viewModel.payContact() }, onRequest = { viewModel.dismissRequestOrPaySheet() - onRequestPayment(paymentRequestTarget) + paymentRequestTarget?.let(onRequestPayment) }, ) } @@ -128,6 +129,7 @@ fun ContactDetailScreen( private fun RequestOrPaySheet( contact: PubkyProfile, isPayLoading: Boolean, + canRequest: Boolean, onDismiss: () -> Unit, onPay: () -> Unit, onRequest: () -> Unit, @@ -178,7 +180,7 @@ private fun RequestOrPaySheet( PrimaryButton( text = stringResource(R.string.wallet__payment_request_request), onClick = onRequest, - enabled = !isPayLoading, + enabled = canRequest && !isPayLoading, icon = { Icon( painter = painterResource(R.drawable.ic_received), diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index 8ddbd52a0d..1f7826edb7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -155,7 +155,7 @@ class ContactDetailViewModel @Inject constructor( _uiState.update { it.copy( paymentRequestTarget = target, - showRequestOrPaySheet = it.showRequestOrPaySheet && target != null, + showRequestOrPaySheet = it.showRequestOrPaySheet && (target != null || it.isPayLoading), ) } } diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt index 44376f0f24..3634b08d20 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -286,6 +286,40 @@ class ContactDetailViewModelTest : BaseUnitTest() { assertFalse(sut.uiState.value.showRequestOrPaySheet) } + @Test + fun `request or pay sheet stays open while paying when the contact stops being eligible`() = test { + val paymentStarted = CompletableDeferred() + val paymentResult = CompletableDeferred>() + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(target)) + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)).doSuspendableAnswer { + paymentStarted.complete(Unit) + paymentResult.await() + } + eligibleTargets.value = listOf(target) + val sut = createSut() + advanceUntilIdle() + sut.onClickPay() + advanceUntilIdle() + + sut.effects.test { + sut.payContact() + paymentStarted.await() + eligibleTargets.value = emptyList() + advanceUntilIdle() + + assertTrue(sut.uiState.value.showRequestOrPaySheet) + assertTrue(sut.uiState.value.isPayLoading) + + paymentResult.complete(Result.success(openedPayment)) + advanceUntilIdle() + + assertIs(awaitItem()) + assertFalse(sut.uiState.value.showRequestOrPaySheet) + assertFalse(sut.uiState.value.isPayLoading) + } + } + @Test fun `dismissing the sheet while paying cancels the payment`() = test { val paymentStarted = CompletableDeferred() From 27018b15c078d43b833f8ab31211417b738a5d2d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 28 Sep 2026 09:13:47 -0300 Subject: [PATCH 09/21] docs: drop contact journey from not-ported list Co-Authored-By: Claude Opus 5.5 --- journeys/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/journeys/README.md b/journeys/README.md index f237b0c0ed..d16d1c235b 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -153,7 +153,6 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `activity/date-range-rapid-month-taps.xml` | not ported — iOS has no activity journey suite, and the rapid month tap behaviour was not checked there | | `coin-selection/manual-coin-selection.xml` | not ported — iOS has the screen (`SendUtxoSelectionView`) but no accessibility identifiers on it yet | | `payment-requests/requested-resolution-failure.xml` | not ported | -| `payment-requests/contact-request-or-pay.xml` | not ported yet — the iOS port of the eligibility refresh, non-blocking Pay and loading state follows in the next bitkit-ios PR | | `node-lifecycle/cancelled-node-restart.xml` | not ported — the routes run through Android's LDK Debug and Rapid-Gossip-Sync screens and assert on Android app-log lines | | `restore-wallet/paste-seed-fragment.xml` | not ported — the iOS Restore screen still has the 12/24-only paste guard, so the behaviour does not exist there yet | | `send/own-invoice-guard.xml` | not ported — iOS has no own-invoice guard | From 5bb171b6e50ca882be36c46aecc0cfd852ec15a8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 28 Sep 2026 09:22:14 -0300 Subject: [PATCH 10/21] fix: drop contacts that stop accepting requests Co-Authored-By: Claude Opus 5.5 --- .../repositories/PaykitPaymentRequestRepo.kt | 1 - .../PaykitPaymentRequestRepoTest.kt | 34 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 745eeb23f4..1d9fc65c4e 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -472,7 +472,6 @@ class PaykitPaymentRequestRepo @Inject constructor( val target = discovery.targets.firstOrNull() operationMutex.withLock { if (!isCurrentState(generation, expectedIdentity)) return@withLock - if (target == null && !discovery.isComplete) return@withLock _eligibleTargets.update { targets -> targets.filterNot { it.publicKey == publicKey } + listOfNotNull(target) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 2500afda74..157d6859d9 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -712,6 +712,40 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(sut.eligibleTargets.value.isEmpty()) } + @Test + fun `single recipient refresh removes a contact that stopped accepting requests`() = test { + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + ) + whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) + .thenReturn(listOf(PaykitReceiverPaths.SERVER), emptyList()) + sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() + + val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() + + assertNull(target) + assertTrue(sut.eligibleTargets.value.isEmpty()) + } + + @Test + fun `single recipient refresh keeps a known target while capability lookup fails`() = test { + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + ) + whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) + .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + .thenThrow(IllegalStateException("marker unavailable")) + sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() + + val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() + + val expected = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + assertEquals(expected, target) + assertEquals(listOf(expected), sut.eligibleTargets.value) + } + @Test fun `recipient discovery retries capabilities that are not published yet`() = test { whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) From 10bcac1d5ab54756afebc88740b3dffe6a02bbee Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 28 Sep 2026 09:23:31 -0300 Subject: [PATCH 11/21] fix: serialize contact request eligibility refreshes Co-Authored-By: Claude Opus 5.5 --- .../repositories/PaykitPaymentRequestRepo.kt | 22 +++++++------- .../PaykitPaymentRequestRepoTest.kt | 29 +++++++++++++++++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 1d9fc65c4e..e889cb5f3a 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -465,18 +465,20 @@ class PaykitPaymentRequestRepo @Inject constructor( runSuspendCatching { val publicKey = PubkyPublicKeyFormat.normalized(savedPublicKey) ?: return@runSuspendCatching null if (!isAvailable() || expectedIdentity == null) return@runSuspendCatching null - val previousTargets = _eligibleTargets.value.associateBy { it.publicKey } - val discovery = targetContext(listOf(publicKey), expectedIdentity) - ?.let { eligibleTargets(it, previousTargets) } - ?: PaykitPaymentRequestTargetDiscovery(emptyList(), isComplete = true) - val target = discovery.targets.firstOrNull() - operationMutex.withLock { - if (!isCurrentState(generation, expectedIdentity)) return@withLock - _eligibleTargets.update { targets -> - targets.filterNot { it.publicKey == publicKey } + listOfNotNull(target) + targetDiscoveryMutex.withLock { + val previousTargets = _eligibleTargets.value.associateBy { it.publicKey } + val discovery = targetContext(listOf(publicKey), expectedIdentity) + ?.let { eligibleTargets(it, previousTargets) } + ?: PaykitPaymentRequestTargetDiscovery(emptyList(), isComplete = true) + val target = discovery.targets.firstOrNull() + operationMutex.withLock { + if (!isCurrentState(generation, expectedIdentity)) return@withLock + _eligibleTargets.update { targets -> + targets.filterNot { it.publicKey == publicKey } + listOfNotNull(target) + } } + target } - target }.onFailure { Logger.warn("Failed to refresh Paykit payment request recipient", it, context = TAG) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 157d6859d9..2f671c9fbd 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -746,6 +746,35 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(listOf(expected), sut.eligibleTargets.value) } + @Test + fun `single recipient refresh is not overwritten by an older full refresh`() = test { + val fullLookupStarted = CompletableDeferred() + val releaseFullLookup = CompletableDeferred() + var lookups = 0 + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + ) + whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + lookups += 1 + if (lookups > 1) return@doSuspendableAnswer emptyList() + fullLookupStarted.complete(Unit) + releaseFullLookup.await() + listOf(PaykitReceiverPaths.SERVER) + } + + val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY)) } + fullLookupStarted.await() + val singleRefresh = async { sut.refreshEligibleTarget(COUNTERPARTY) } + runCurrent() + releaseFullLookup.complete(Unit) + fullRefresh.await().getOrThrow() + val target = singleRefresh.await().getOrThrow() + + assertNull(target) + assertTrue(sut.eligibleTargets.value.isEmpty()) + } + @Test fun `recipient discovery retries capabilities that are not published yet`() = test { whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) From ea82619d86a3d1f20a06948dc165b9b862827dfe Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 28 Sep 2026 09:24:32 -0300 Subject: [PATCH 12/21] fix: drop deleted contacts when recipient discovery fails Co-Authored-By: Claude Opus 5.5 --- .../repositories/PaykitPaymentRequestRepo.kt | 5 +++++ .../PaykitPaymentRequestRepoTest.kt | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index e889cb5f3a..31670e67fa 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -454,6 +454,11 @@ class PaykitPaymentRequestRepo @Inject constructor( } }.onFailure { Logger.warn("Failed to refresh Paykit payment request recipients", it, context = TAG) + val savedKeys = savedPublicKeys.mapNotNull(PubkyPublicKeyFormat::normalized).toSet() + operationMutex.withLock { + if (!isCurrentState(generation, expectedIdentity)) return@withLock + _eligibleTargets.update { targets -> targets.filter { it.publicKey in savedKeys } } + } } } } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 2f671c9fbd..36301c15b8 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -775,6 +775,23 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(sut.eligibleTargets.value.isEmpty()) } + @Test + fun `failed recipient discovery drops contacts that are no longer saved`() = test { + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.linkedPeers()) + .thenReturn(listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER))) + .thenThrow(IllegalStateException("linked peers unavailable")) + whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( + listOf(PaykitReceiverPaths.SERVER), + ) + sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() + + val result = sut.refreshEligibleTargets(listOf(SECOND_IDENTITY), force = true) + + assertTrue(result.isFailure) + assertTrue(sut.eligibleTargets.value.isEmpty()) + } + @Test fun `recipient discovery retries capabilities that are not published yet`() = test { whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) From b42cd20740ffe292d4a8205457e96006b574a3b3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 28 Sep 2026 13:45:38 -0300 Subject: [PATCH 13/21] fix: keep contact eligibility check responsive Co-Authored-By: Claude Opus 5.5 --- .../repositories/PaykitPaymentRequestRepo.kt | 39 +++++++---- .../PaykitPaymentRequestRepoTest.kt | 66 ++++++++++++++++++- 2 files changed, 90 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 31670e67fa..240e0c741b 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -270,6 +270,8 @@ class PaykitPaymentRequestRepo @Inject constructor( private val repoScope = appScope(ioDispatcher, TAG) private var expirationJob: Job? = null private var cachedTargetContext: PaykitPaymentRequestTargetContext? = null + private val targetRefreshTicket = AtomicLong() + private val targetWriteTickets = mutableMapOf() private val _pendingRequests = MutableStateFlow>(emptyList()) val pendingRequests: StateFlow> = _pendingRequests.asStateFlow() private val _paymentRequestHistory = MutableStateFlow>(emptyList()) @@ -437,10 +439,12 @@ class PaykitPaymentRequestRepo @Inject constructor( return@operation } cachedTargetContext = null + targetWriteTickets.clear() _eligibleTargets.update { emptyList() } } return@discovery } + val ticket = targetRefreshTicket.incrementAndGet() val context = targetContext(savedPublicKeys, expectedIdentity) if (!force && context == cachedTargetContext) return@discovery val previousTargets = _eligibleTargets.value.associateBy { it.publicKey } @@ -449,7 +453,14 @@ class PaykitPaymentRequestRepo @Inject constructor( operationMutex.withLock operation@{ if (!isCurrentState(generation, expectedIdentity)) return@operation cachedTargetContext = context.takeIf { discovery.isComplete } - _eligibleTargets.update { discovery.targets } + val newerKeys = targetWriteTickets.filterValues { it > ticket }.keys + savedPublicKeys.mapNotNull(PubkyPublicKeyFormat::normalized) + .filterNot { it in newerKeys } + .forEach { targetWriteTickets[it] = ticket } + _eligibleTargets.update { current -> + discovery.targets.filterNot { it.publicKey in newerKeys } + + current.filter { it.publicKey in newerKeys } + } } } }.onFailure { @@ -470,17 +481,20 @@ class PaykitPaymentRequestRepo @Inject constructor( runSuspendCatching { val publicKey = PubkyPublicKeyFormat.normalized(savedPublicKey) ?: return@runSuspendCatching null if (!isAvailable() || expectedIdentity == null) return@runSuspendCatching null - targetDiscoveryMutex.withLock { - val previousTargets = _eligibleTargets.value.associateBy { it.publicKey } - val discovery = targetContext(listOf(publicKey), expectedIdentity) - ?.let { eligibleTargets(it, previousTargets) } - ?: PaykitPaymentRequestTargetDiscovery(emptyList(), isComplete = true) - val target = discovery.targets.firstOrNull() - operationMutex.withLock { - if (!isCurrentState(generation, expectedIdentity)) return@withLock - _eligibleTargets.update { targets -> - targets.filterNot { it.publicKey == publicKey } + listOfNotNull(target) - } + val ticket = targetRefreshTicket.incrementAndGet() + val previousTargets = _eligibleTargets.value.associateBy { it.publicKey } + val discovery = targetContext(listOf(publicKey), expectedIdentity) + ?.let { eligibleTargets(it, previousTargets) } + ?: PaykitPaymentRequestTargetDiscovery(emptyList(), isComplete = true) + val target = discovery.targets.firstOrNull() + operationMutex.withLock { + if (!isCurrentState(generation, expectedIdentity)) return@withLock target + if ((targetWriteTickets[publicKey] ?: 0L) > ticket) { + return@withLock _eligibleTargets.value.firstOrNull { it.publicKey == publicKey } + } + targetWriteTickets[publicKey] = ticket + _eligibleTargets.update { targets -> + targets.filterNot { it.publicKey == publicKey } + listOfNotNull(target) } target } @@ -1253,6 +1267,7 @@ class PaykitPaymentRequestRepo @Inject constructor( _subscriptions.update { emptyList() } _eligibleTargets.update { emptyList() } cachedTargetContext = null + targetWriteTickets.clear() subscriptionNotificationScheduler.cancel() } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 36301c15b8..dfa4634489 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -765,16 +765,76 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY)) } fullLookupStarted.await() - val singleRefresh = async { sut.refreshEligibleTarget(COUNTERPARTY) } - runCurrent() + val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() releaseFullLookup.complete(Unit) fullRefresh.await().getOrThrow() - val target = singleRefresh.await().getOrThrow() assertNull(target) assertTrue(sut.eligibleTargets.value.isEmpty()) } + @Test + fun `older single recipient refresh does not overwrite a newer full refresh`() = test { + val singleLookupStarted = CompletableDeferred() + val releaseSingleLookup = CompletableDeferred() + var lookups = 0 + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + ) + whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + lookups += 1 + if (lookups > 1) return@doSuspendableAnswer listOf(PaykitReceiverPaths.SERVER) + singleLookupStarted.complete(Unit) + releaseSingleLookup.await() + emptyList() + } + + val singleRefresh = async { sut.refreshEligibleTarget(COUNTERPARTY) } + singleLookupStarted.await() + sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() + releaseSingleLookup.complete(Unit) + val target = singleRefresh.await().getOrThrow() + + val expected = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + assertEquals(expected, target) + assertEquals(listOf(expected), sut.eligibleTargets.value) + } + + @Test + fun `older full refresh keeps its results for other contacts`() = test { + val fullLookupStarted = CompletableDeferred() + val releaseFullLookup = CompletableDeferred() + var counterpartyLookups = 0 + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf( + linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), + linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), + ), + ) + whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + counterpartyLookups += 1 + if (counterpartyLookups > 1) return@doSuspendableAnswer emptyList() + fullLookupStarted.complete(Unit) + releaseFullLookup.await() + listOf(PaykitReceiverPaths.SERVER) + } + whenever(paykitSdkService.paymentRequestReceiverPaths(SECOND_IDENTITY)) + .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + + val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY, SECOND_IDENTITY)) } + fullLookupStarted.await() + sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() + releaseFullLookup.complete(Unit) + fullRefresh.await().getOrThrow() + + assertEquals( + listOf(PaykitPaymentRequestTarget(SECOND_IDENTITY, PaykitReceiverPaths.SERVER)), + sut.eligibleTargets.value, + ) + } + @Test fun `failed recipient discovery drops contacts that are no longer saved`() = test { whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) From 572d22822bd5a565563fdada5e3cf3faa9e11818 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 28 Sep 2026 15:11:56 -0300 Subject: [PATCH 14/21] fix: run one contact endpoint publish at a time Co-Authored-By: Claude Opus 5.5 --- .../bitkit/repositories/PrivatePaykitRepo.kt | 6 +++- .../repositories/PrivatePaykitRepoTest.kt | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index c47685ee50..f59a22e68e 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -48,6 +48,7 @@ import to.bitkit.services.PubkyService import to.bitkit.utils.Logger import java.security.MessageDigest import java.time.Instant +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock @@ -98,6 +99,7 @@ class PrivatePaykitRepo @Inject constructor( private val publicationMutex = Mutex() private val serializedDispatcher = ioDispatcher.limitedParallelism(1) private val retryScope = appScope(serializedDispatcher, TAG) + private val paymentPublishJobs = ConcurrentHashMap() private val knownSavedContactKeys = mutableSetOf() private var state: PrivatePaykitState? = null private val pendingMessageDrainRetryLock = Any() @@ -582,7 +584,9 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun beginSavedContactPaymentWithRetry(publicKey: String): PublicPaykitPaymentResult { - retryScope.launch { refreshPrivateEndpointsBeforePayment(publicKey) } + paymentPublishJobs.compute(publicKey) { _, job -> + job?.takeIf { it.isActive } ?: retryScope.launch { refreshPrivateEndpointsBeforePayment(publicKey) } + } var result = beginContactPayment(publicKey, paymentRequest = null).getOrThrow() for (retryDelay in privatePaymentResolutionRetryDelays) { if (result != PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) return result diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 62d819ff91..7a2ff36a4e 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import org.junit.After import org.junit.Before @@ -1220,6 +1221,39 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { stalledPublish.complete(Unit) } + @Test + fun `beginSavedContactPayment runs one endpoint publish per contact at a time`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + val publishStarted = CompletableDeferred() + val stalledPublish = CompletableDeferred() + var publishes = 0 + whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.doSuspendableAnswer { + publishes += 1 + publishStarted.complete(Unit) + stalledPublish.await() + privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY)) + } + whenever { + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) + whenever(coreService.decode(PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) + + sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + publishStarted.await() + sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + stalledPublish.complete(Unit) + advanceUntilIdle() + + assertEquals(1, publishes) + } + @Test fun `beginSavedContactPayment opens private endpoint with its list version`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) From bcd8f4b00c26a6052361ca9d1db4044fae5d29be Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 28 Sep 2026 15:13:26 -0300 Subject: [PATCH 15/21] fix: cancel timed out contact eligibility check Co-Authored-By: Claude Opus 5.5 --- .../contacts/ContactDetailViewModel.kt | 11 ++++++-- .../contacts/ContactDetailViewModelTest.kt | 28 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index 1f7826edb7..246a4fd241 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -13,6 +13,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -121,8 +122,7 @@ class ContactDetailViewModel @Inject constructor( if (payJob?.isActive == true) return payJob = viewModelScope.launch { _uiState.update { it.copy(isPayLoading = true) } - val target = _uiState.value.paymentRequestTarget - ?: withTimeoutOrNull(PAYMENT_REQUEST_TARGET_WAIT) { refreshPaymentRequestTarget().await() } + val target = _uiState.value.paymentRequestTarget ?: awaitPaymentRequestTarget() if (target != null) { _uiState.update { it.copy(isPayLoading = false, paymentRequestTarget = target, showRequestOrPaySheet = true) @@ -162,6 +162,13 @@ class ContactDetailViewModel @Inject constructor( } } + private suspend fun awaitPaymentRequestTarget(): PaykitPaymentRequestTarget? { + val refresh = refreshPaymentRequestTarget() + val target = withTimeoutOrNull(PAYMENT_REQUEST_TARGET_WAIT) { refresh.await() } + if (refresh.isActive) refresh.cancelAndJoin() + return target + } + private fun refreshPaymentRequestTarget(): Deferred { paymentRequestTargetRefresh?.takeIf { it.isActive }?.let { return it } return viewModelScope.async { diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt index 3634b08d20..4c08699801 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -349,6 +349,34 @@ class ContactDetailViewModelTest : BaseUnitTest() { } } + @Test + fun `pay tap cancels a stalled eligibility check before paying`() = test { + var isCheckCancelled = false + var wasCheckCancelledBeforePayment = false + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).doSuspendableAnswer { + try { + awaitCancellation() + } finally { + isCheckCancelled = true + } + } + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)).doSuspendableAnswer { + wasCheckCancelledBeforePayment = isCheckCancelled + Result.success(openedPayment) + } + val sut = createSut() + + sut.effects.test { + sut.onClickPay() + advanceUntilIdle() + + assertIs(awaitItem()) + assertTrue(wasCheckCancelledBeforePayment) + assertFalse(sut.uiState.value.showRequestOrPaySheet) + } + } + @Test fun `pay tap opens payment when the contact cannot receive requests`() = test { whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) From cf14c7ece5b69f9ba10c016f83859e5766f9716a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 28 Sep 2026 15:14:43 -0300 Subject: [PATCH 16/21] fix: reuse recent contact eligibility check Co-Authored-By: Claude Opus 5.5 --- .../contacts/ContactDetailViewModel.kt | 16 +++++++++- .../contacts/ContactDetailViewModelTest.kt | 32 +++++++++++++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index 246a4fd241..8433d19a08 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull import to.bitkit.R +import to.bitkit.ext.nowMs import to.bitkit.ext.setClipboardText import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink @@ -39,6 +40,7 @@ import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject +import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds @Suppress("TooManyFunctions") @@ -48,6 +50,7 @@ class ContactDetailViewModel @Inject constructor( private val pubkyRepo: PubkyRepo, private val privatePaykitRepo: PrivatePaykitRepo, private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo, + private val clock: Clock, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -56,6 +59,9 @@ class ContactDetailViewModel @Inject constructor( /** How long a Pay tap waits for the payment request check before falling back to paying. */ private val PAYMENT_REQUEST_TARGET_WAIT = 2.seconds + + /** How long a finished payment request check is reused before Pay checks again. */ + private val PAYMENT_REQUEST_TARGET_REUSE = 30.seconds } private val publicKey: String = checkNotNull( @@ -73,6 +79,7 @@ class ContactDetailViewModel @Inject constructor( private var payJob: Job? = null private var paymentRequestTargetRefresh: Deferred? = null + private var paymentRequestTargetCheckedAt: Long? = null init { loadContact() @@ -170,14 +177,21 @@ class ContactDetailViewModel @Inject constructor( } private fun refreshPaymentRequestTarget(): Deferred { - paymentRequestTargetRefresh?.takeIf { it.isActive }?.let { return it } + paymentRequestTargetRefresh?.takeIf { it.isActive || isPaymentRequestTargetCheckRecent() }?.let { return it } + paymentRequestTargetCheckedAt = null return viewModelScope.async { val isSaved = pubkyRepo.contacts.value.any { PubkyPublicKeyFormat.matches(it.publicKey, publicKey) } if (!isSaved) return@async null paykitPaymentRequestRepo.refreshEligibleTarget(publicKey).getOrNull() + .also { paymentRequestTargetCheckedAt = clock.nowMs() } }.also { paymentRequestTargetRefresh = it } } + private fun isPaymentRequestTargetCheckRecent(): Boolean { + val checkedAt = paymentRequestTargetCheckedAt ?: return false + return clock.nowMs() - checkedAt < PAYMENT_REQUEST_TARGET_REUSE.inWholeMilliseconds + } + private suspend fun openPayment() { privatePaykitRepo.beginSavedContactPayment(publicKey) .onSuccess { diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt index 4c08699801..185efc1a2f 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -31,6 +31,9 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class) class ContactDetailViewModelTest : BaseUnitTest() { @@ -42,6 +45,10 @@ class ContactDetailViewModelTest : BaseUnitTest() { private val pubkyRepo: PubkyRepo = mock() private val privatePaykitRepo: PrivatePaykitRepo = mock() private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo = mock() + private var now = Instant.fromEpochSeconds(1_800_000_000) + private val clock = object : Clock { + override fun now() = now + } private val eligibleTargets = MutableStateFlow>(emptyList()) private val target = PaykitPaymentRequestTarget(TEST_PUBLIC_KEY, "bitkit/wallet") private val openedPayment = PublicPaykitPaymentResult.Opened( @@ -247,12 +254,31 @@ class ContactDetailViewModelTest : BaseUnitTest() { sut.onClickPay() advanceUntilIdle() - verify(paykitPaymentRequestRepo, times(2)).refreshEligibleTarget(TEST_PUBLIC_KEY) + verify(paykitPaymentRequestRepo, times(1)).refreshEligibleTarget(TEST_PUBLIC_KEY) assertTrue(sut.uiState.value.showRequestOrPaySheet) } @Test - fun `pay tap rechecks eligibility after an earlier check found none`() = test { + fun `pay tap reuses a recent check that found no request support`() = test { + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(null)) + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(openedPayment)) + val sut = createSut() + advanceUntilIdle() + now += 10.seconds + + sut.effects.test { + sut.onClickPay() + advanceUntilIdle() + + assertIs(awaitItem()) + verify(paykitPaymentRequestRepo, times(1)).refreshEligibleTarget(TEST_PUBLIC_KEY) + } + } + + @Test + fun `pay tap rechecks eligibility once an earlier empty check is stale`() = test { whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) .thenReturn(Result.success(null), Result.success(target)) @@ -260,6 +286,7 @@ class ContactDetailViewModelTest : BaseUnitTest() { .thenReturn(Result.success(openedPayment)) val sut = createSut() advanceUntilIdle() + now += 31.seconds sut.onClickPay() advanceUntilIdle() @@ -441,6 +468,7 @@ class ContactDetailViewModelTest : BaseUnitTest() { paykitPaymentRequestRepo = paykitPaymentRequestRepo.also { whenever(it.eligibleTargets).thenReturn(eligibleTargets) }, + clock = clock, savedStateHandle = SavedStateHandle(mapOf("publicKey" to TEST_PUBLIC_KEY)), ) From 62b1d8fef9c3c6aeb763e26da7c00c470140d7d3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 28 Sep 2026 15:17:35 -0300 Subject: [PATCH 17/21] fix: keep contact pay loading until the amount screen opens Co-Authored-By: Claude Opus 5.5 --- .../screens/contacts/ContactDetailScreen.kt | 9 ++-- .../contacts/ContactDetailViewModel.kt | 33 +++++++++--- .../contacts/ContactDetailViewModelTest.kt | 52 ++++++++++++++++++- 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt index c05461a6f4..67d8a6a2ed 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Job import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink @@ -68,7 +69,7 @@ import to.bitkit.ui.utils.withAccent fun ContactDetailScreen( viewModel: ContactDetailViewModel, onBackClick: () -> Unit, - onPayContact: (String, String, PrivatePaykitPaymentContext?) -> Unit, + onPayContact: (String, String, PrivatePaykitPaymentContext?) -> Job?, onActivityClick: (String) -> Unit, onRequestPayment: (PaykitPaymentRequestTarget) -> Unit = {}, showDeleteAction: Boolean = false, @@ -81,8 +82,10 @@ fun ContactDetailScreen( LaunchedEffect(Unit) { viewModel.effects.collect { when (it) { - is ContactDetailEffect.OpenPayment -> - onPayContact(it.paymentRequest, it.publicKey, it.privatePaymentContext) + is ContactDetailEffect.OpenPayment -> { + val scanJob = onPayContact(it.paymentRequest, it.publicKey, it.privatePaymentContext) + viewModel.onPaymentOpening(scanJob) + } ContactDetailEffect.ContactDeleted -> onContactDeleted() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index 8433d19a08..ea083353ee 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -78,6 +78,7 @@ class ContactDetailViewModel @Inject constructor( val effects = _effects.asSharedFlow() private var payJob: Job? = null + private var paymentScanJob: Job? = null private var paymentRequestTargetRefresh: Deferred? = null private var paymentRequestTargetCheckedAt: Long? = null @@ -126,7 +127,7 @@ class ContactDetailViewModel @Inject constructor( } fun onClickPay() { - if (payJob?.isActive == true) return + if (payJob?.isActive == true || _uiState.value.isPayLoading) return payJob = viewModelScope.launch { _uiState.update { it.copy(isPayLoading = true) } val target = _uiState.value.paymentRequestTarget ?: awaitPaymentRequestTarget() @@ -136,21 +137,33 @@ class ContactDetailViewModel @Inject constructor( } return@launch } - openPayment() - _uiState.update { it.copy(isPayLoading = false) } + if (!openPayment()) _uiState.update { it.copy(isPayLoading = false) } } } fun dismissRequestOrPaySheet() { - if (_uiState.value.isPayLoading) payJob?.cancel() + if (_uiState.value.isPayLoading) { + payJob?.cancel() + paymentScanJob?.cancel() + paymentScanJob = null + } _uiState.update { it.copy(isPayLoading = false, showRequestOrPaySheet = false) } } fun payContact() { - if (payJob?.isActive == true) return + if (payJob?.isActive == true || _uiState.value.isPayLoading) return payJob = viewModelScope.launch { _uiState.update { it.copy(isPayLoading = true) } - openPayment() + if (!openPayment()) _uiState.update { it.copy(isPayLoading = false, showRequestOrPaySheet = false) } + } + } + + fun onPaymentOpening(scanJob: Job?) { + paymentScanJob = scanJob + viewModelScope.launch { + scanJob?.join() + if (paymentScanJob !== scanJob) return@launch + paymentScanJob = null _uiState.update { it.copy(isPayLoading = false, showRequestOrPaySheet = false) } } } @@ -192,11 +205,13 @@ class ContactDetailViewModel @Inject constructor( return clock.nowMs() - checkedAt < PAYMENT_REQUEST_TARGET_REUSE.inWholeMilliseconds } - private suspend fun openPayment() { + private suspend fun openPayment(): Boolean { + var isOpened = false privatePaykitRepo.beginSavedContactPayment(publicKey) .onSuccess { when (it) { - is PublicPaykitPaymentResult.Opened -> + is PublicPaykitPaymentResult.Opened -> { + isOpened = true _effects.emit( ContactDetailEffect.OpenPayment( it.paymentRequest, @@ -204,6 +219,7 @@ class ContactDetailViewModel @Inject constructor( it.privatePaymentContext, ) ) + } PublicPaykitPaymentResult.NoEndpoint -> showPayError(R.string.slashtags__error_pay_empty_msg) PublicPaykitPaymentResult.NotOpened -> @@ -216,6 +232,7 @@ class ContactDetailViewModel @Inject constructor( Logger.warn("Failed to begin Paykit payment for '$redactedPublicKey'", it, context = TAG) showPayError(R.string.slashtags__error_pay_not_opened_msg) } + return isOpened } private suspend fun showPayError(messageRes: Int) { diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt index 185efc1a2f..ad0ed3593a 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle @@ -342,11 +343,43 @@ class ContactDetailViewModelTest : BaseUnitTest() { advanceUntilIdle() assertIs(awaitItem()) + assertTrue(sut.uiState.value.showRequestOrPaySheet) + sut.onPaymentOpening(null) + advanceUntilIdle() + assertFalse(sut.uiState.value.showRequestOrPaySheet) assertFalse(sut.uiState.value.isPayLoading) } } + @Test + fun `dismissing the sheet while the amount screen opens cancels the scan`() = test { + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(target)) + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(openedPayment)) + eligibleTargets.value = listOf(target) + val sut = createSut() + advanceUntilIdle() + sut.onClickPay() + advanceUntilIdle() + + sut.effects.test { + sut.payContact() + advanceUntilIdle() + assertIs(awaitItem()) + val scanJob = Job() + sut.onPaymentOpening(scanJob) + + sut.dismissRequestOrPaySheet() + advanceUntilIdle() + + assertTrue(scanJob.isCancelled) + assertFalse(sut.uiState.value.isPayLoading) + assertFalse(sut.uiState.value.showRequestOrPaySheet) + } + } + @Test fun `dismissing the sheet while paying cancels the payment`() = test { val paymentStarted = CompletableDeferred() @@ -418,8 +451,15 @@ class ContactDetailViewModelTest : BaseUnitTest() { advanceUntilIdle() assertFalse(sut.uiState.value.showRequestOrPaySheet) - assertFalse(sut.uiState.value.isPayLoading) assertIs(awaitItem()) + assertTrue(sut.uiState.value.isPayLoading) + + val scanJob = Job() + sut.onPaymentOpening(scanJob) + scanJob.complete() + advanceUntilIdle() + + assertFalse(sut.uiState.value.isPayLoading) } } @@ -440,6 +480,16 @@ class ContactDetailViewModelTest : BaseUnitTest() { val effect = assertIs(awaitItem()) assertEquals(openedPayment.paymentRequest, effect.paymentRequest) + val scanJob = Job() + sut.onPaymentOpening(scanJob) + advanceUntilIdle() + + assertTrue(sut.uiState.value.showRequestOrPaySheet) + assertTrue(sut.uiState.value.isPayLoading) + + scanJob.complete() + advanceUntilIdle() + assertFalse(sut.uiState.value.showRequestOrPaySheet) assertFalse(sut.uiState.value.isPayLoading) } From 554de3d194ae2e8bec48eb010669e883887348ce Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 29 Sep 2026 13:03:48 -0300 Subject: [PATCH 18/21] fix: only trust completed contact eligibility checks Co-Authored-By: Claude Opus 5.5 --- .../repositories/PaykitPaymentRequestRepo.kt | 36 ++++++-- .../contacts/ContactDetailViewModel.kt | 16 +++- .../PaykitPaymentRequestRepoTest.kt | 80 +++++++++++++++-- .../contacts/ContactDetailViewModelTest.kt | 90 ++++++++++++++++--- 4 files changed, 196 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 240e0c741b..b98f2c81b4 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -234,6 +234,12 @@ private data class PaykitPaymentRequestTargetContext( private data class PaykitPaymentRequestTargetDiscovery( val targets: List, val isComplete: Boolean, + val failedPublicKeys: Set = emptySet(), +) + +data class PaykitPaymentRequestTargetCheck( + val target: PaykitPaymentRequestTarget?, + val isComplete: Boolean, ) sealed class PaykitPaymentRequestError(message: String) : AppError(message) { @@ -430,6 +436,7 @@ class PaykitPaymentRequestRepo @Inject constructor( ): Result { val generation = stateGeneration.get() val expectedIdentity = activeIdentity + val startTicket = targetRefreshTicket.get() return withContext(ioDispatcher) { runSuspendCatching { targetDiscoveryMutex.withLock discovery@{ @@ -468,19 +475,24 @@ class PaykitPaymentRequestRepo @Inject constructor( val savedKeys = savedPublicKeys.mapNotNull(PubkyPublicKeyFormat::normalized).toSet() operationMutex.withLock { if (!isCurrentState(generation, expectedIdentity)) return@withLock - _eligibleTargets.update { targets -> targets.filter { it.publicKey in savedKeys } } + _eligibleTargets.update { targets -> + targets.filter { + it.publicKey in savedKeys || (targetWriteTickets[it.publicKey] ?: 0L) > startTicket + } + } } } } } - suspend fun refreshEligibleTarget(savedPublicKey: String): Result { + suspend fun refreshEligibleTarget(savedPublicKey: String): Result { val generation = stateGeneration.get() val expectedIdentity = activeIdentity return withContext(ioDispatcher) { runSuspendCatching { - val publicKey = PubkyPublicKeyFormat.normalized(savedPublicKey) ?: return@runSuspendCatching null - if (!isAvailable() || expectedIdentity == null) return@runSuspendCatching null + val unavailable = PaykitPaymentRequestTargetCheck(target = null, isComplete = true) + val publicKey = PubkyPublicKeyFormat.normalized(savedPublicKey) ?: return@runSuspendCatching unavailable + if (!isAvailable() || expectedIdentity == null) return@runSuspendCatching unavailable val ticket = targetRefreshTicket.incrementAndGet() val previousTargets = _eligibleTargets.value.associateBy { it.publicKey } val discovery = targetContext(listOf(publicKey), expectedIdentity) @@ -488,15 +500,21 @@ class PaykitPaymentRequestRepo @Inject constructor( ?: PaykitPaymentRequestTargetDiscovery(emptyList(), isComplete = true) val target = discovery.targets.firstOrNull() operationMutex.withLock { - if (!isCurrentState(generation, expectedIdentity)) return@withLock target + val current = _eligibleTargets.value.firstOrNull { it.publicKey == publicKey } + if (!isCurrentState(generation, expectedIdentity)) { + return@withLock PaykitPaymentRequestTargetCheck(target, isComplete = false) + } + if (publicKey in discovery.failedPublicKeys) { + return@withLock PaykitPaymentRequestTargetCheck(current, isComplete = false) + } if ((targetWriteTickets[publicKey] ?: 0L) > ticket) { - return@withLock _eligibleTargets.value.firstOrNull { it.publicKey == publicKey } + return@withLock PaykitPaymentRequestTargetCheck(current, isComplete = true) } targetWriteTickets[publicKey] = ticket _eligibleTargets.update { targets -> targets.filterNot { it.publicKey == publicKey } + listOfNotNull(target) } - target + PaykitPaymentRequestTargetCheck(target, isComplete = true) } }.onFailure { Logger.warn("Failed to refresh Paykit payment request recipient", it, context = TAG) @@ -1024,6 +1042,7 @@ class PaykitPaymentRequestRepo @Inject constructor( previousTargets: Map = emptyMap(), ): PaykitPaymentRequestTargetDiscovery { var isComplete = true + val failedPublicKeys = mutableSetOf() val targets = context.savedPublicKeys.mapNotNull { publicKey -> val linked = context.linkedReceiverPaths[publicKey] ?: return@mapNotNull null val lookup = withTimeoutOrNull(TARGET_DISCOVERY_TIMEOUT) { @@ -1031,6 +1050,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } if (lookup == null) { isComplete = false + failedPublicKeys += publicKey Logger.warn( "Timed out inspecting payment request support for '${PubkyPublicKeyFormat.redacted(publicKey)}'", context = TAG, @@ -1040,6 +1060,7 @@ class PaykitPaymentRequestRepo @Inject constructor( val capable = lookup .onFailure { isComplete = false + failedPublicKeys += publicKey Logger.warn( "Failed to inspect payment request support for '${PubkyPublicKeyFormat.redacted(publicKey)}'", it, @@ -1059,6 +1080,7 @@ class PaykitPaymentRequestRepo @Inject constructor( return PaykitPaymentRequestTargetDiscovery( targets = targets, isComplete = isComplete, + failedPublicKeys = failedPublicKeys, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index ea083353ee..7a7e8a4287 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.cancelAndJoin @@ -172,6 +173,7 @@ class ContactDetailViewModel @Inject constructor( viewModelScope.launch { paykitPaymentRequestRepo.eligibleTargets.collect { targets -> val target = targets.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, publicKey) } + if (target == null) discardCheckedPaymentRequestTarget() _uiState.update { it.copy( paymentRequestTarget = target, @@ -195,11 +197,21 @@ class ContactDetailViewModel @Inject constructor( return viewModelScope.async { val isSaved = pubkyRepo.contacts.value.any { PubkyPublicKeyFormat.matches(it.publicKey, publicKey) } if (!isSaved) return@async null - paykitPaymentRequestRepo.refreshEligibleTarget(publicKey).getOrNull() - .also { paymentRequestTargetCheckedAt = clock.nowMs() } + val check = paykitPaymentRequestRepo.refreshEligibleTarget(publicKey).getOrNull() + ?: return@async null + if (check.isComplete) paymentRequestTargetCheckedAt = clock.nowMs() + check.target }.also { paymentRequestTargetRefresh = it } } + @OptIn(ExperimentalCoroutinesApi::class) + private fun discardCheckedPaymentRequestTarget() { + val refresh = paymentRequestTargetRefresh ?: return + if (!refresh.isCompleted || refresh.isCancelled || refresh.getCompleted() == null) return + paymentRequestTargetRefresh = null + paymentRequestTargetCheckedAt = null + } + private fun isPaymentRequestTargetCheckRecent(): Boolean { val checkedAt = paymentRequestTargetCheckedAt ?: return false return clock.nowMs() - checkedAt < PAYMENT_REQUEST_TARGET_REUSE.inWholeMilliseconds diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index dfa4634489..506d979113 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -687,7 +687,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { listOf(PaykitReceiverPaths.SERVER), ) - val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() + val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow().target val expected = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) assertEquals(expected, target) @@ -706,7 +706,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() - val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() + val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow().target assertNull(target) assertTrue(sut.eligibleTargets.value.isEmpty()) @@ -722,7 +722,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { .thenReturn(listOf(PaykitReceiverPaths.SERVER), emptyList()) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() - val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() + val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow().target assertNull(target) assertTrue(sut.eligibleTargets.value.isEmpty()) @@ -739,13 +739,79 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { .thenThrow(IllegalStateException("marker unavailable")) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() - val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() + val check = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() val expected = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) - assertEquals(expected, target) + assertEquals(expected, check.target) + assertFalse(check.isComplete) assertEquals(listOf(expected), sut.eligibleTargets.value) } + @Test + fun `failed single recipient refresh does not overwrite an older full refresh`() = test { + val fullLookupStarted = CompletableDeferred() + val releaseFullLookup = CompletableDeferred() + var lookups = 0 + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + ) + whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + lookups += 1 + when (lookups) { + 1 -> listOf(PaykitReceiverPaths.SERVER) + 2 -> { + fullLookupStarted.complete(Unit) + releaseFullLookup.await() + emptyList() + } + else -> error("marker unavailable") + } + } + sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() + + val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY), force = true) } + fullLookupStarted.await() + val check = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() + releaseFullLookup.complete(Unit) + fullRefresh.await().getOrThrow() + + assertFalse(check.isComplete) + assertTrue(sut.eligibleTargets.value.isEmpty()) + } + + @Test + fun `failed full refresh keeps a newer single recipient result`() = test { + val fullLinkLookupStarted = CompletableDeferred() + val releaseFullLinkLookup = CompletableDeferred() + var linkLookups = 0 + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.linkedPeers()).doSuspendableAnswer { + linkLookups += 1 + if (linkLookups > 1) { + return@doSuspendableAnswer listOf( + linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), + ) + } + fullLinkLookupStarted.complete(Unit) + releaseFullLinkLookup.await() + error("linked peers unavailable") + } + whenever(paykitSdkService.paymentRequestReceiverPaths(SECOND_IDENTITY)) + .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + + val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY)) } + fullLinkLookupStarted.await() + sut.refreshEligibleTarget(SECOND_IDENTITY).getOrThrow() + releaseFullLinkLookup.complete(Unit) + + assertTrue(fullRefresh.await().isFailure) + assertEquals( + listOf(PaykitPaymentRequestTarget(SECOND_IDENTITY, PaykitReceiverPaths.SERVER)), + sut.eligibleTargets.value, + ) + } + @Test fun `single recipient refresh is not overwritten by an older full refresh`() = test { val fullLookupStarted = CompletableDeferred() @@ -765,7 +831,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY)) } fullLookupStarted.await() - val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() + val target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow().target releaseFullLookup.complete(Unit) fullRefresh.await().getOrThrow() @@ -794,7 +860,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { singleLookupStarted.await() sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() releaseSingleLookup.complete(Unit) - val target = singleRefresh.await().getOrThrow() + val target = singleRefresh.await().getOrThrow().target val expected = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) assertEquals(expected, target) diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt index ad0ed3593a..38d020cd4b 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -23,6 +23,7 @@ import org.mockito.kotlin.whenever import to.bitkit.models.PubkyProfile import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitPaymentRequestTarget +import to.bitkit.repositories.PaykitPaymentRequestTargetCheck import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult @@ -226,7 +227,8 @@ class ContactDetailViewModelTest : BaseUnitTest() { @Test fun `pay tap shows request or pay sheet for an eligible contact`() = test { whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) - whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(target)) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(targetCheck(target))) whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) .thenReturn(Result.success(openedPayment)) eligibleTargets.value = listOf(target) @@ -246,7 +248,8 @@ class ContactDetailViewModelTest : BaseUnitTest() { @Test fun `pay tap refreshes eligibility when the contact is not a known target`() = test { whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) - whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(target)) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(targetCheck(target))) whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) .thenReturn(Result.success(openedPayment)) val sut = createSut() @@ -262,7 +265,8 @@ class ContactDetailViewModelTest : BaseUnitTest() { @Test fun `pay tap reuses a recent check that found no request support`() = test { whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) - whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(null)) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(targetCheck(null))) whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) .thenReturn(Result.success(openedPayment)) val sut = createSut() @@ -282,7 +286,7 @@ class ContactDetailViewModelTest : BaseUnitTest() { fun `pay tap rechecks eligibility once an earlier empty check is stale`() = test { whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) - .thenReturn(Result.success(null), Result.success(target)) + .thenReturn(Result.success(targetCheck(null)), Result.success(targetCheck(target))) whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) .thenReturn(Result.success(openedPayment)) val sut = createSut() @@ -297,10 +301,66 @@ class ContactDetailViewModelTest : BaseUnitTest() { assertEquals(target, sut.uiState.value.paymentRequestTarget) } + @Test + fun `pay tap rechecks eligibility after an incomplete check`() = test { + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(targetCheck(null, isComplete = false)), Result.success(targetCheck(target))) + val sut = createSut() + advanceUntilIdle() + now += 10.seconds + + sut.onClickPay() + advanceUntilIdle() + + verify(paykitPaymentRequestRepo, times(2)).refreshEligibleTarget(TEST_PUBLIC_KEY) + assertTrue(sut.uiState.value.showRequestOrPaySheet) + } + + @Test + fun `pay tap rechecks eligibility after a failed check`() = test { + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) + .thenReturn(Result.failure(AppError("linked peers unavailable")), Result.success(targetCheck(target))) + val sut = createSut() + advanceUntilIdle() + now += 10.seconds + + sut.onClickPay() + advanceUntilIdle() + + verify(paykitPaymentRequestRepo, times(2)).refreshEligibleTarget(TEST_PUBLIC_KEY) + assertTrue(sut.uiState.value.showRequestOrPaySheet) + } + + @Test + fun `pay tap rechecks eligibility once a checked target leaves the eligible list`() = test { + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(targetCheck(target)), Result.success(targetCheck(null))) + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(openedPayment)) + eligibleTargets.value = listOf(target) + val sut = createSut() + advanceUntilIdle() + eligibleTargets.value = emptyList() + advanceUntilIdle() + + sut.effects.test { + sut.onClickPay() + advanceUntilIdle() + + assertIs(awaitItem()) + verify(paykitPaymentRequestRepo, times(2)).refreshEligibleTarget(TEST_PUBLIC_KEY) + assertFalse(sut.uiState.value.showRequestOrPaySheet) + } + } + @Test fun `request or pay sheet closes when the contact stops being eligible`() = test { whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) - whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(target)) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(targetCheck(target))) eligibleTargets.value = listOf(target) val sut = createSut() advanceUntilIdle() @@ -319,7 +379,8 @@ class ContactDetailViewModelTest : BaseUnitTest() { val paymentStarted = CompletableDeferred() val paymentResult = CompletableDeferred>() whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) - whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(target)) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(targetCheck(target))) whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)).doSuspendableAnswer { paymentStarted.complete(Unit) paymentResult.await() @@ -355,7 +416,8 @@ class ContactDetailViewModelTest : BaseUnitTest() { @Test fun `dismissing the sheet while the amount screen opens cancels the scan`() = test { whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) - whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(target)) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(targetCheck(target))) whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) .thenReturn(Result.success(openedPayment)) eligibleTargets.value = listOf(target) @@ -384,7 +446,8 @@ class ContactDetailViewModelTest : BaseUnitTest() { fun `dismissing the sheet while paying cancels the payment`() = test { val paymentStarted = CompletableDeferred() whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) - whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(target)) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(targetCheck(target))) whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)).doSuspendableAnswer { paymentStarted.complete(Unit) awaitCancellation() @@ -440,7 +503,8 @@ class ContactDetailViewModelTest : BaseUnitTest() { @Test fun `pay tap opens payment when the contact cannot receive requests`() = test { whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) - whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(null)) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(targetCheck(null))) whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) .thenReturn(Result.success(openedPayment)) val sut = createSut() @@ -466,7 +530,8 @@ class ContactDetailViewModelTest : BaseUnitTest() { @Test fun `paying from the request or pay sheet opens the payment`() = test { whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) - whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)).thenReturn(Result.success(target)) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(targetCheck(target))) whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) .thenReturn(Result.success(openedPayment)) val sut = createSut() @@ -511,6 +576,11 @@ class ContactDetailViewModelTest : BaseUnitTest() { assertFalse(sut.uiState.value.showRequestOrPaySheet) } + private fun targetCheck( + target: PaykitPaymentRequestTarget?, + isComplete: Boolean = true, + ) = PaykitPaymentRequestTargetCheck(target, isComplete) + private fun createSut() = ContactDetailViewModel( context = context, pubkyRepo = pubkyRepo, From ecd7a977aefc36fdc96d5be5174344fd51a4c8f4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 29 Sep 2026 13:03:53 -0300 Subject: [PATCH 19/21] fix: cancel contact pay when contact detail leaves Co-Authored-By: Claude Opus 5.5 --- .../screens/contacts/ContactDetailScreen.kt | 5 ++ .../contacts/ContactDetailViewModel.kt | 17 +++++-- .../contacts/ContactDetailViewModelTest.kt | 51 +++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt index 67d8a6a2ed..d861534525 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -79,6 +80,10 @@ fun ContactDetailScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + DisposableEffect(viewModel) { + onDispose { viewModel.dismissRequestOrPaySheet() } + } + LaunchedEffect(Unit) { viewModel.effects.collect { when (it) { diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index 7a7e8a4287..3cf4345e1d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -143,14 +143,21 @@ class ContactDetailViewModel @Inject constructor( } fun dismissRequestOrPaySheet() { - if (_uiState.value.isPayLoading) { - payJob?.cancel() - paymentScanJob?.cancel() - paymentScanJob = null - } + if (_uiState.value.isPayLoading) cancelPendingPayment() _uiState.update { it.copy(isPayLoading = false, showRequestOrPaySheet = false) } } + override fun onCleared() { + paymentScanJob?.cancel() + super.onCleared() + } + + private fun cancelPendingPayment() { + payJob?.cancel() + paymentScanJob?.cancel() + paymentScanJob = null + } + fun payContact() { if (payJob?.isActive == true || _uiState.value.isPayLoading) return payJob = viewModelScope.launch { diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt index 38d020cd4b..b543bdb7b4 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -472,6 +472,57 @@ class ContactDetailViewModelTest : BaseUnitTest() { } } + @Test + fun `leaving the screen while paying cancels the payment`() = test { + val paymentStarted = CompletableDeferred() + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(targetCheck(null))) + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)).doSuspendableAnswer { + paymentStarted.complete(Unit) + awaitCancellation() + } + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.onClickPay() + paymentStarted.await() + assertTrue(sut.uiState.value.isPayLoading) + + sut.dismissRequestOrPaySheet() + advanceUntilIdle() + + assertFalse(sut.uiState.value.isPayLoading) + expectNoEvents() + } + } + + @Test + fun `leaving the screen while the amount screen opens cancels the scan`() = test { + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(targetCheck(null))) + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(openedPayment)) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.onClickPay() + advanceUntilIdle() + assertIs(awaitItem()) + val scanJob = Job() + sut.onPaymentOpening(scanJob) + + sut.dismissRequestOrPaySheet() + advanceUntilIdle() + + assertTrue(scanJob.isCancelled) + assertFalse(sut.uiState.value.isPayLoading) + } + } + @Test fun `pay tap cancels a stalled eligibility check before paying`() = test { var isCheckCancelled = false From 0a1257eee559b442a53ec5f00db966af6f5a505e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 29 Sep 2026 13:03:53 -0300 Subject: [PATCH 20/21] fix: clear contact context when its scan is cancelled Co-Authored-By: Claude Opus 5.5 --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 11 ++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 63156f3ef6..19a8138119 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2357,7 +2357,7 @@ class AppViewModel @Inject constructor( scheduledScan = nextScheduledScan nextJob.invokeOnCompletion { if (scheduledScan === nextScheduledScan) scheduledScan = null - if (nextJob.isCancelled) return@invokeOnCompletion + if (nextJob.isCancelled) return@invokeOnCompletion clearCancelledContactContext(contactPaymentContext) viewModelScope.launch { flushDeferredScan() } } @@ -3221,6 +3221,15 @@ class AppViewModel @Inject constructor( paykitPaymentRequestRepo.markPresented(request) } + private fun clearCancelledContactContext(context: ContactPaymentContext?) { + if (context == null) return + synchronized(contactPaymentContextLock) { + if (activeContactPaymentContext !== context) return + activeContactPaymentContext = null + preparedContactPaymentContext = null + } + } + private fun setActiveContactPaymentContext(context: ContactPaymentContext?) { synchronized(contactPaymentContextLock) { if (activeContactPaymentContext != context) preparedContactPaymentContext = null diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 81701df9e3..5c4f69536f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -3569,6 +3570,24 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(toastManager).enqueue(any()) } + @Test + fun `cancelled contact scan clears its payment context`() = test { + sut.setIsAuthenticated(true) + val bolt11 = "lnbcrt1cancelledcontactscan" + val scanStarted = CompletableDeferred() + whenever(coreService.decode(bolt11)).doSuspendableAnswer { + scanStarted.complete(Unit) + awaitCancellation() + } + + val scanJob = sut.openContactPayment(paymentRequest = bolt11, publicKey = testPublicKey) + scanStarted.await() + assertEquals(testPublicKey, activeContactPaymentContext()?.publicKey) + scanJob?.cancelAndJoin() + + assertNull(activeContactPaymentContext()) + } + @Test fun `contact payment rejects pubky auth without blocking later incoming requests`() = test { val paymentState = SendUiState(address = "existing-payment", amount = 1_000u) From def4a3e160e1f41515224a9a1202128490645989 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 29 Sep 2026 13:03:53 -0300 Subject: [PATCH 21/21] docs: drop matched request or pay identifier row Co-Authored-By: Claude Opus 5.5 --- journeys/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/journeys/README.md b/journeys/README.md index d16d1c235b..b481acfc01 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -209,7 +209,6 @@ and Settings (`Tab-general`, `Tab-security`, `Tab-advanced`, `NavigationBack`, ` | Home suggestion cards | `Suggestion-` | — *(cards expose no identifier)* | | Receive QR copy button | `ReceiveCopyQR` | `ReceiveCopyQR` *(absent from `snapshot-ui` targets; see below)* | | Payment Request row | `PaymentRequestRow-` | `PaymentRequestRow--` *(`-one-time` for a one-off)* | -| Request or Pay sheet | `RequestOrPaySheet` | `RequestOrPay` | Two of those are unreconciled rather than intentional: the Send screen emitting both `AvailableAmount` and `available_balance`, and the background-payments row name. Settling either is a