diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 26890757f7..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) { @@ -270,6 +276,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()) @@ -428,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@{ @@ -437,10 +446,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,11 +460,64 @@ 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 { 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 || (targetWriteTickets[it.publicKey] ?: 0L) > startTicket + } + } + } + } + } + } + + suspend fun refreshEligibleTarget(savedPublicKey: String): Result { + val generation = stateGeneration.get() + val expectedIdentity = activeIdentity + return withContext(ioDispatcher) { + runSuspendCatching { + 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) + ?.let { eligibleTargets(it, previousTargets) } + ?: PaykitPaymentRequestTargetDiscovery(emptyList(), isComplete = true) + val target = discovery.targets.firstOrNull() + operationMutex.withLock { + 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 PaykitPaymentRequestTargetCheck(current, isComplete = true) + } + targetWriteTickets[publicKey] = ticket + _eligibleTargets.update { targets -> + targets.filterNot { it.publicKey == publicKey } + listOfNotNull(target) + } + PaykitPaymentRequestTargetCheck(target, isComplete = true) + } + }.onFailure { + Logger.warn("Failed to refresh Paykit payment request recipient", it, context = TAG) } } } @@ -978,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) { @@ -985,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, @@ -994,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, @@ -1013,6 +1080,7 @@ class PaykitPaymentRequestRepo @Inject constructor( return PaykitPaymentRequestTargetDiscovery( targets = targets, isComplete = isComplete, + failedPublicKeys = failedPublicKeys, ) } @@ -1221,6 +1289,7 @@ class PaykitPaymentRequestRepo @Inject constructor( _subscriptions.update { emptyList() } _eligibleTargets.update { emptyList() } cachedTargetContext = null + targetWriteTickets.clear() subscriptionNotificationScheduler.cancel() } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 83d8e0f582..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 { - 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/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/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 71f33aaf09..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,11 +18,9 @@ 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.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 @@ -34,9 +32,11 @@ 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 +import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.ui.components.ActionButton import to.bitkit.ui.components.AddTagSheet @@ -70,23 +70,27 @@ 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, - 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) } + + DisposableEffect(viewModel) { + onDispose { viewModel.dismissRequestOrPaySheet() } + } 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() } } @@ -99,13 +103,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 +115,18 @@ fun ContactDetailScreen( onConfirmDelete = { viewModel.deleteContact() }, ) - if (showRequestOrPay && uiState.profile != null) { + val requestOrPayContact = uiState.profile + val paymentRequestTarget = uiState.paymentRequestTarget + if (uiState.showRequestOrPaySheet && requestOrPayContact != null) { RequestOrPaySheet( - contact = requireNotNull(uiState.profile), - onDismiss = { showRequestOrPay = false }, - onPay = { - showRequestOrPay = false - viewModel.payContact() - }, + contact = requestOrPayContact, + isPayLoading = uiState.isPayLoading, + canRequest = paymentRequestTarget != null, + onDismiss = { viewModel.dismissRequestOrPaySheet() }, + onPay = { viewModel.payContact() }, onRequest = { - showRequestOrPay = false - onRequestPayment() + viewModel.dismissRequestOrPaySheet() + paymentRequestTarget?.let(onRequestPayment) }, ) } @@ -137,6 +136,8 @@ fun ContactDetailScreen( @OptIn(ExperimentalMaterial3Api::class) private fun RequestOrPaySheet( contact: PubkyProfile, + isPayLoading: Boolean, + canRequest: Boolean, onDismiss: () -> Unit, onPay: () -> Unit, onRequest: () -> Unit, @@ -174,6 +175,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), @@ -186,6 +188,7 @@ private fun RequestOrPaySheet( PrimaryButton( text = stringResource(R.string.wallet__payment_request_request), onClick = onRequest, + enabled = canRequest && !isPayLoading, icon = { Icon( painter = painterResource(R.drawable.ic_received), @@ -235,6 +238,7 @@ private fun Content( profile = currentProfile, tags = uiState.tags, showPayButton = uiState.showPayButton, + isPayLoading = uiState.isPayLoading, showDeleteAction = showDeleteAction, onClickEdit = onClickEdit, onClickDelete = onClickDelete, @@ -273,6 +277,7 @@ private fun ContactBody( profile: PubkyProfile, tags: ImmutableList, showPayButton: Boolean, + isPayLoading: Boolean, showDeleteAction: Boolean, onClickEdit: () -> Unit, onClickDelete: () -> Unit, @@ -318,6 +323,7 @@ private fun ContactBody( ActionButton( onClick = onClickPay, iconRes = R.drawable.ic_coins, + 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 9f0c2d0cbd..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 @@ -10,6 +10,11 @@ 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.ExperimentalCoroutinesApi +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 @@ -19,12 +24,16 @@ 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.nowMs 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 +41,28 @@ 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") @HiltViewModel class ContactDetailViewModel @Inject constructor( @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, private val privatePaykitRepo: PrivatePaykitRepo, + private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo, + private val clock: Clock, 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 + + /** 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( @@ -58,9 +78,16 @@ class ContactDetailViewModel @Inject constructor( private val _effects = MutableSharedFlow(extraBufferCapacity = 1) val effects = _effects.asSharedFlow() + private var payJob: Job? = null + private var paymentScanJob: Job? = null + private var paymentRequestTargetRefresh: Deferred? = null + private var paymentRequestTargetCheckedAt: Long? = null + init { loadContact() observeContactUpdates() + observePaymentRequestTarget() + refreshPaymentRequestTarget() } fun loadContact() { @@ -100,32 +127,131 @@ class ContactDetailViewModel @Inject constructor( } } + fun onClickPay() { + if (payJob?.isActive == true || _uiState.value.isPayLoading) return + payJob = viewModelScope.launch { + _uiState.update { it.copy(isPayLoading = true) } + val target = _uiState.value.paymentRequestTarget ?: awaitPaymentRequestTarget() + if (target != null) { + _uiState.update { + it.copy(isPayLoading = false, paymentRequestTarget = target, showRequestOrPaySheet = true) + } + return@launch + } + if (!openPayment()) _uiState.update { it.copy(isPayLoading = false) } + } + } + + fun dismissRequestOrPaySheet() { + 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 { + _uiState.update { it.copy(isPayLoading = true) } + 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) } + } + } + + 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) } + if (target == null) discardCheckedPaymentRequestTarget() + _uiState.update { + it.copy( + paymentRequestTarget = target, + showRequestOrPaySheet = it.showRequestOrPaySheet && (target != null || it.isPayLoading), + ) + } + } + } + } + + 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 || 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 + 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 + } + + private suspend fun openPayment(): Boolean { + var isOpened = false + privatePaykitRepo.beginSavedContactPayment(publicKey) + .onSuccess { + when (it) { + is PublicPaykitPaymentResult.Opened -> { + isOpened = true + _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) + ) } + 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) - } - } + } + .onFailure { + 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) { @@ -253,6 +379,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/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/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 42699007fd..506d979113 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,247 @@ 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().target + + 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().target + + assertNull(target) + 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().target + + 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 check = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() + + val expected = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + 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() + 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 target = sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow().target + releaseFullLookup.complete(Unit) + fullRefresh.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().target + + 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)) + 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)) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 4bc0d724b0..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 @@ -63,6 +64,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 +1193,67 @@ 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 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)) 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..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 @@ -3,36 +3,60 @@ 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.Job +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 +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.PaykitPaymentRequestTargetCheck 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 +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant @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 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( + paymentRequest = "bitcoin:bcrt1qtest", + privatePaymentContext = null, + ) @Test fun `deleting contact emits deleted effect`() = test { @@ -200,10 +224,422 @@ 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(targetCheck(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(targetCheck(target))) + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(openedPayment)) + val sut = createSut() + advanceUntilIdle() + + sut.onClickPay() + advanceUntilIdle() + + verify(paykitPaymentRequestRepo, times(1)).refreshEligibleTarget(TEST_PUBLIC_KEY) + assertTrue(sut.uiState.value.showRequestOrPaySheet) + } + + @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(targetCheck(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(targetCheck(null)), Result.success(targetCheck(target))) + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(openedPayment)) + val sut = createSut() + advanceUntilIdle() + now += 31.seconds + + sut.onClickPay() + advanceUntilIdle() + + verify(paykitPaymentRequestRepo, times(2)).refreshEligibleTarget(TEST_PUBLIC_KEY) + assertTrue(sut.uiState.value.showRequestOrPaySheet) + 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(targetCheck(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 `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(targetCheck(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()) + 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(targetCheck(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() + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(targetCheck(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 `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 + 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()))) + 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() + + assertFalse(sut.uiState.value.showRequestOrPaySheet) + assertIs(awaitItem()) + assertTrue(sut.uiState.value.isPayLoading) + + val scanJob = Job() + sut.onPaymentOpening(scanJob) + scanJob.complete() + advanceUntilIdle() + + assertFalse(sut.uiState.value.isPayLoading) + } + } + + @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(targetCheck(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) + 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) + } + } + + @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 targetCheck( + target: PaykitPaymentRequestTarget?, + isComplete: Boolean = true, + ) = PaykitPaymentRequestTargetCheck(target, isComplete) + private fun createSut() = ContactDetailViewModel( context = context, pubkyRepo = pubkyRepo, privatePaykitRepo = privatePaykitRepo, + paykitPaymentRequestRepo = paykitPaymentRequestRepo.also { + whenever(it.eligibleTargets).thenReturn(eligibleTargets) + }, + clock = clock, savedStateHandle = SavedStateHandle(mapOf("publicKey" to TEST_PUBLIC_KEY)), ) 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) 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. 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 + +