Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
284c50b
fix: stop blocking contact pay on endpoint publish
jvsena42 Sep 28, 2026
ed5ee74
fix: refresh contact request eligibility on pay
jvsena42 Sep 28, 2026
9ce6c2d
feat: show loading state on contact pay
jvsena42 Sep 28, 2026
baaa708
test: add contact request or pay journey
jvsena42 Sep 28, 2026
2cb246e
chore: add changelog fragment
jvsena42 Sep 28, 2026
07a6d2b
fix: recheck stale contact request eligibility
jvsena42 Sep 28, 2026
8c1a3c6
fix: cancel contact pay when the sheet is dismissed
jvsena42 Sep 28, 2026
9e1ffd6
fix: keep request or pay sheet open while paying
jvsena42 Sep 28, 2026
27018b1
docs: drop contact journey from not-ported list
jvsena42 Sep 28, 2026
5bb171b
fix: drop contacts that stop accepting requests
jvsena42 Sep 28, 2026
10bcac1
fix: serialize contact request eligibility refreshes
jvsena42 Sep 28, 2026
ea82619
fix: drop deleted contacts when recipient discovery fails
jvsena42 Sep 28, 2026
b42cd20
fix: keep contact eligibility check responsive
jvsena42 Sep 28, 2026
572d228
fix: run one contact endpoint publish at a time
jvsena42 Sep 28, 2026
bcd8f4b
fix: cancel timed out contact eligibility check
jvsena42 Sep 28, 2026
cf14c7e
fix: reuse recent contact eligibility check
jvsena42 Sep 28, 2026
62b1d8f
fix: keep contact pay loading until the amount screen opens
jvsena42 Sep 28, 2026
554de3d
fix: only trust completed contact eligibility checks
jvsena42 Sep 29, 2026
ecd7a97
fix: cancel contact pay when contact detail leaves
jvsena42 Sep 29, 2026
0a1257e
fix: clear contact context when its scan is cancelled
jvsena42 Sep 29, 2026
def4a3e
docs: drop matched request or pay identifier row
jvsena42 Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,12 @@ private data class PaykitPaymentRequestTargetContext(
private data class PaykitPaymentRequestTargetDiscovery(
val targets: List<PaykitPaymentRequestTarget>,
val isComplete: Boolean,
val failedPublicKeys: Set<String> = emptySet(),
)

data class PaykitPaymentRequestTargetCheck(
val target: PaykitPaymentRequestTarget?,
val isComplete: Boolean,
)

sealed class PaykitPaymentRequestError(message: String) : AppError(message) {
Expand Down Expand Up @@ -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<String, Long>()
private val _pendingRequests = MutableStateFlow<List<PaykitPaymentRequest>>(emptyList())
val pendingRequests: StateFlow<List<PaykitPaymentRequest>> = _pendingRequests.asStateFlow()
private val _paymentRequestHistory = MutableStateFlow<List<PaykitPaymentRequest>>(emptyList())
Expand Down Expand Up @@ -428,6 +436,7 @@ class PaykitPaymentRequestRepo @Inject constructor(
): Result<Unit> {
val generation = stateGeneration.get()
val expectedIdentity = activeIdentity
val startTicket = targetRefreshTicket.get()
return withContext(ioDispatcher) {
runSuspendCatching {
targetDiscoveryMutex.withLock discovery@{
Expand All @@ -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 }
Expand All @@ -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<PaykitPaymentRequestTargetCheck> {
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()
Comment thread
jvsena42 marked this conversation as resolved.
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)
}
}
}
Expand Down Expand Up @@ -978,13 +1042,15 @@ class PaykitPaymentRequestRepo @Inject constructor(
previousTargets: Map<String, PaykitPaymentRequestTarget> = emptyMap(),
): PaykitPaymentRequestTargetDiscovery {
var isComplete = true
val failedPublicKeys = mutableSetOf<String>()
val targets = context.savedPublicKeys.mapNotNull { publicKey ->
val linked = context.linkedReceiverPaths[publicKey] ?: return@mapNotNull null
val lookup = withTimeoutOrNull(TARGET_DISCOVERY_TIMEOUT) {
runSuspendCatching { paykitSdkService.paymentRequestReceiverPaths(publicKey) }
}
if (lookup == null) {
isComplete = false
failedPublicKeys += publicKey
Logger.warn(
"Timed out inspecting payment request support for '${PubkyPublicKeyFormat.redacted(publicKey)}'",
context = TAG,
Expand All @@ -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,
Expand All @@ -1013,6 +1080,7 @@ class PaykitPaymentRequestRepo @Inject constructor(
return PaykitPaymentRequestTargetDiscovery(
targets = targets,
isComplete = isComplete,
failedPublicKeys = failedPublicKeys,
)
}

Expand Down Expand Up @@ -1221,6 +1289,7 @@ class PaykitPaymentRequestRepo @Inject constructor(
_subscriptions.update { emptyList() }
_eligibleTargets.update { emptyList() }
cachedTargetContext = null
targetWriteTickets.clear()
subscriptionNotificationScheduler.cancel()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String, Job>()
private val knownSavedContactKeys = mutableSetOf<String>()
private var state: PrivatePaykitState? = null
private val pendingMessageDrainRetryLock = Any()
Expand Down Expand Up @@ -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
Expand Down
20 changes: 6 additions & 14 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1412,29 +1411,22 @@ private fun NavGraphBuilder.contacts(
PaykitRouteGuard(settingsViewModel, navController) {
val route = backStackEntry.toRoute<Routes.ContactDetail>()
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() },
onPayContact = { paymentRequest, publicKey, privatePaymentContext ->
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 = {
Expand Down
4 changes: 3 additions & 1 deletion app/src/main/java/to/bitkit/ui/components/ActionButton.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
}
}
Expand All @@ -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() },
Expand All @@ -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() },
Comment thread
jvsena42 marked this conversation as resolved.
onRequest = {
showRequestOrPay = false
onRequestPayment()
viewModel.dismissRequestOrPaySheet()
paymentRequestTarget?.let(onRequestPayment)
},
)
}
Expand All @@ -137,6 +136,8 @@ fun ContactDetailScreen(
@OptIn(ExperimentalMaterial3Api::class)
private fun RequestOrPaySheet(
contact: PubkyProfile,
isPayLoading: Boolean,
canRequest: Boolean,
onDismiss: () -> Unit,
onPay: () -> Unit,
onRequest: () -> Unit,
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand Down Expand Up @@ -235,6 +238,7 @@ private fun Content(
profile = currentProfile,
tags = uiState.tags,
showPayButton = uiState.showPayButton,
isPayLoading = uiState.isPayLoading,
showDeleteAction = showDeleteAction,
onClickEdit = onClickEdit,
onClickDelete = onClickDelete,
Expand Down Expand Up @@ -273,6 +277,7 @@ private fun ContactBody(
profile: PubkyProfile,
tags: ImmutableList<String>,
showPayButton: Boolean,
isPayLoading: Boolean,
showDeleteAction: Boolean,
onClickEdit: () -> Unit,
onClickDelete: () -> Unit,
Expand Down Expand Up @@ -318,6 +323,7 @@ private fun ContactBody(
ActionButton(
onClick = onClickPay,
iconRes = R.drawable.ic_coins,
isLoading = isPayLoading,
modifier = Modifier.testTag("ContactPay")
)
}
Expand Down
Loading
Loading