Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -164,6 +164,35 @@ class PaymentRequestsScreenTest {
composeTestRule.onNodeWithTag("PaymentRequestCreate").assertIsDisplayed()
}

@Test
fun unpaidHistoryKeepsPrimaryFiatAmountAndShowsLifecycleStatus() {
val accepted = request(id = "accepted").copy(
createdAt = Clock.System.now(),
lifecycleState = PaymentRequestLifecycleState.ACCEPTED,
)

composeTestRule.setContent {
PaymentRequestsTestSurface(CurrencyState(primaryDisplay = PrimaryDisplay.FIAT)) {
PaymentRequestsContent(
requests = persistentListOf(accepted),
pending = persistentListOf(),
contacts = persistentListOf(),
subscriptions = persistentListOf(),
dismissingRequestIds = persistentSetOf(),
canRequestPayment = true,
onBack = {},
onRequestPayment = {},
onPay = {},
onDismiss = { Result.success(Unit) },
onDetails = {},
)
}
}

composeTestRule.onNodeWithTag("MoneyPrimary").assertTextContains("$", substring = true)
composeTestRule.onNodeWithText("Accepted").assertIsDisplayed()
}

@Test
fun outgoingRequestShowsPendingOnFiatLineWhenBitcoinIsPrimary() {
setOutgoingRequestContent(PrimaryDisplay.BITCOIN)
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ import com.synonym.paykit.PaykitException

fun Throwable.isPaykitIdentityError(): Boolean =
generateSequence(this) { it.cause }.any { it is PaykitException.Identity }

fun Throwable.isPaykitRecoveryRequired(): Boolean =
generateSequence(this) { it.cause }.any { it is PaykitException.RecoveryRequired }
90 changes: 76 additions & 14 deletions app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import to.bitkit.data.PrivatePaykitCacheStore
import to.bitkit.data.SettingsStore
import to.bitkit.di.IoDispatcher
import to.bitkit.di.json
import to.bitkit.ext.isPaykitRecoveryRequired
import to.bitkit.ext.runSuspendCatching
import to.bitkit.ext.toHex
import to.bitkit.models.PubkyPublicKeyFormat
Expand Down Expand Up @@ -414,6 +415,29 @@ class PrivatePaykitRepo @Inject constructor(
Logger.warn("Failed to consume private Paykit payment details", it, context = TAG)
}

suspend fun releasePrivatePaymentList(
publicKey: String,
context: PrivatePaykitPaymentContext,
): Result<Unit> = withContext(serializedDispatcher) {
runSuspendCatching {
val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.InvalidPublicKey
val contactState = ensureState().contacts[normalizedKey] ?: return@runSuspendCatching
val consumedVersion = contactState.consumedPrivatePaymentListVersionsByReceiverPath[context.receiverPath]
if (consumedVersion != context.paymentListVersion) return@runSuspendCatching

contactState.consumedPrivatePaymentListVersionsByReceiverPath =
contactState.consumedPrivatePaymentListVersionsByReceiverPath - context.receiverPath
persistState(markWalletBackup = true)
Logger.info(
"Released private Paykit payment list version '${context.paymentListVersion}' " +
"for '${redacted(normalizedKey)}'",
context = TAG,
)
}
}.onFailure {
Logger.warn("Failed to release private Paykit payment details", it, context = TAG)
}

suspend fun discardRemoteLightningEndpoints(
publicKey: String,
paymentHashes: Set<String>,
Expand Down Expand Up @@ -556,20 +580,32 @@ class PrivatePaykitRepo @Inject constructor(
val amount = paymentRequest?.let {
PaymentAmountContext(it.amountValue, PaykitIssuerInterop.BITCOIN_ASSET)
}
val prepared = preparePrivateContactPayment(
publicKey = publicKey,
receiverPath = receiverPath,
consumedVersion = consumedVersion,
amount = amount,
allowPublicResolution = paymentRequest == null,
) ?: return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow()
val prepared = runSuspendCatching {
preparePrivateContactPayment(
publicKey = publicKey,
receiverPath = receiverPath,
consumedVersion = consumedVersion,
amount = amount,
allowPublicResolution = paymentRequest == null,
)
}.getOrElse {
if (paymentRequest == null || !it.isPaykitRecoveryRequired()) throw it
if (paymentRequest.isExpired(clock.now())) throw PaykitPaymentRequestError.RequestExpired
return@runSuspendCatching privateLinkPendingResult(publicKey, receiverPath)
} ?: return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow()
val resolution = prepared.resolution
val linkState = currentLinkState(publicKey, receiverPath, prepared.linkState)
if (paymentRequest == null && canUsePublicPayment(linkState, resolution.status, resolution.state)) {
return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow()
}

val result = privatePaymentResult(
val result = unresolvedPrivateLinkResult(
publicKey = publicKey,
receiverPath = receiverPath,
paymentRequest = paymentRequest,
resolution = resolution,
linkState = linkState,
) ?: privatePaymentResult(
publicKey = publicKey,
receiverPath = receiverPath,
resolution = resolution,
Expand Down Expand Up @@ -665,16 +701,11 @@ class PrivatePaykitRepo @Inject constructor(
)
}

if (
resolution.state == PrivatePaymentResolutionState.RECOVERY_PENDING ||
resolution.status == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST
) {
if (resolution.status == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST) {
schedulePendingPrivateMessageDrainRetries(
reason = "payment recovery",
retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, receiverPath)),
)
}
if (resolution.status == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST) {
Logger.info(
"Waiting for a private Paykit payment list newer than ${consumedVersion ?: "none"} " +
"for '${redacted(publicKey)}'; public resolution is disabled for this request",
Expand All @@ -690,6 +721,37 @@ class PrivatePaykitRepo @Inject constructor(
}
}

private fun unresolvedPrivateLinkResult(
publicKey: String,
receiverPath: String,
paymentRequest: PaykitPaymentRequest?,
resolution: PaykitPrivateContactPaymentResolution,
linkState: LinkedPeerState?,
): PublicPaykitPaymentResult? = when {
resolution.state == PrivatePaymentResolutionState.RECOVERY_PENDING ->
privateLinkPendingResult(publicKey, receiverPath)
paymentRequest == null -> null
linkState == LinkedPeerState.LINKING || linkState == LinkedPeerState.RECOVERY_REQUIRED ->
privateLinkPendingResult(publicKey, receiverPath)
linkState != LinkedPeerState.LINKED -> PublicPaykitPaymentResult.NoEndpoint
Comment thread
ovitrif marked this conversation as resolved.
else -> null
}

private fun privateLinkPendingResult(
publicKey: String,
receiverPath: String,
): PublicPaykitPaymentResult {
schedulePendingPrivateMessageDrainRetries(
reason = "payment link recovery",
retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, receiverPath)),
)
Logger.info(
"Waiting for private Paykit link recovery for '${redacted(publicKey)}'",
context = TAG,
)
return PublicPaykitPaymentResult.PrivateLinkPending
}

private suspend fun currentLinkState(
publicKey: String,
receiverPath: String,
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ sealed interface PublicPaykitPaymentResult {

data object NoEndpoint : PublicPaykitPaymentResult
data object NotOpened : PublicPaykitPaymentResult
data object PrivateLinkPending : PublicPaykitPaymentResult
data object WaitingForUpdatedPaymentList : PublicPaykitPaymentResult
}

Expand Down Expand Up @@ -77,6 +78,7 @@ internal val PublicPaykitPaymentResult.incomingPaymentRequestFailureReason:
is PublicPaykitPaymentResult.Opened -> null
PublicPaykitPaymentResult.NoEndpoint -> IncomingPaykitPaymentRequestFailureReason.NoSupportedEndpoint
PublicPaykitPaymentResult.NotOpened -> IncomingPaykitPaymentRequestFailureReason.EndpointNotPayable
PublicPaykitPaymentResult.PrivateLinkPending,
PublicPaykitPaymentResult.WaitingForUpdatedPaymentList ->
IncomingPaykitPaymentRequestFailureReason.PaymentDetailsPending
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ class AddContactViewModel @Inject constructor(
showPayError(R.string.slashtags__error_pay_empty_msg)
PublicPaykitPaymentResult.NotOpened ->
showPayError(R.string.slashtags__error_pay_not_opened_msg)
PublicPaykitPaymentResult.WaitingForUpdatedPaymentList ->
PublicPaykitPaymentResult.PrivateLinkPending,
PublicPaykitPaymentResult.WaitingForUpdatedPaymentList,
->
showPayError(R.string.slashtags__error_pay_waiting_msg)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,9 @@ class ContactDetailViewModel @Inject constructor(
showPayError(R.string.slashtags__error_pay_empty_msg)
PublicPaykitPaymentResult.NotOpened ->
showPayError(R.string.slashtags__error_pay_not_opened_msg)
PublicPaykitPaymentResult.WaitingForUpdatedPaymentList ->
PublicPaykitPaymentResult.PrivateLinkPending,
PublicPaykitPaymentResult.WaitingForUpdatedPaymentList,
->
showPayError(R.string.slashtags__error_pay_waiting_msg)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.synonym.paykit.PaymentRequestLifecycleState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
Expand Down Expand Up @@ -153,12 +152,22 @@ private fun IncomingPaymentRequestDetailsContent(
)?.let {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Display(
text = "${request.detailsAmountPrefix()} $it".withAccent(accentColor = Colors.White64),
text = request.detailsAmountText(it).withAccent(accentColor = Colors.White64),
modifier = Modifier.testTag("PaymentRequestDetailsAmount")
)
FillWidth()
PaymentRequestDetailsIcon(request)
if (request.hasPaymentEvidence) {
PaymentRequestDetailsIcon(request)
} else {
PubkyContactAvatar(profile = contact, size = 48.dp)
}
}
}
BodyM(
text = paymentRequestStatus(request, isPending = isPending),
color = Colors.White64,
modifier = Modifier.testTag("PaymentRequestDetailsStatus")
)
VerticalSpacer(24.dp)
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
RequestDetailCell(
Expand Down Expand Up @@ -292,27 +301,24 @@ private fun PaymentRequestTags(
}
}

private fun PaykitPaymentRequest.detailsAmountPrefix(): String =
if (direction == PaykitPaymentRequestDirection.Incoming) "-" else "+"
internal fun PaykitPaymentRequest.detailsAmountPrefix(): String = when {
!hasPaymentEvidence -> ""
Comment thread
ovitrif marked this conversation as resolved.
direction == PaykitPaymentRequestDirection.Incoming -> "-"
else -> "+"
}

internal fun PaykitPaymentRequest.detailsAmountText(amount: String): String =
"${detailsAmountPrefix()} $amount".trimStart()

@Composable
private fun PaymentRequestDetailsIcon(request: PaykitPaymentRequest) {
val isCompleted = request.lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED
val isIncomingRequest = request.direction == PaykitPaymentRequestDirection.Incoming
CircularIcon(
icon = painterResource(
if (isCompleted == isIncomingRequest) R.drawable.ic_sent else R.drawable.ic_received
if (isIncomingRequest) R.drawable.ic_sent else R.drawable.ic_received
),
iconColor = when {
isCompleted -> request.paymentRailIconColor
isIncomingRequest -> Colors.Purple
else -> Colors.Brand
},
backgroundColor = when {
isCompleted -> request.paymentRailBackgroundColor
isIncomingRequest -> Colors.Purple16
else -> Colors.Brand16
},
iconColor = request.paymentRailIconColor,
backgroundColor = request.paymentRailBackgroundColor,
size = 48.dp,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

package to.bitkit.ui.screens.paymentrequests

import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
Expand Down Expand Up @@ -329,10 +330,14 @@ internal fun PaymentRequestsContent(
PaymentRequestCard(
request = request,
contact = contacts.contactFor(request),
compactSubtitle = subscriptions.nameFor(request)
?: request.note?.takeIf(String::isNotBlank)
?: paymentRequestDate(request),
showSignedAmount = true,
compactSubtitle = if (request.hasPaymentEvidence) {
subscriptions.nameFor(request)
?: request.note?.takeIf(String::isNotBlank)
?: paymentRequestDate(request)
} else {
paymentRequestStatus(request)
},
showSignedAmount = request.hasPaymentEvidence,
onClick = { onDetails(request.id) },
)
}
Expand Down Expand Up @@ -472,21 +477,13 @@ internal fun paymentRequestDate(request: PaykitPaymentRequest): String = request
} ?: paymentRequestStatus(request)

@Composable
private fun paymentRequestStatus(request: PaykitPaymentRequest): String {
internal fun paymentRequestStatus(request: PaykitPaymentRequest, isPending: Boolean = false): String {
if (request.lifecycleState == PaymentRequestLifecycleState.PROPOSED && request.isExpired(Clock.System.now())) {
return stringResource(R.string.wallet__payment_request_status_expired)
}

return when (request.lifecycleState) {
PaymentRequestLifecycleState.PROPOSED -> {
if (request.direction == PaykitPaymentRequestDirection.Incoming) {
stringResource(R.string.wallet__payment_request_status_unavailable)
} else if (request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) {
stringResource(R.string.wallet__payment_request_waiting)
} else {
stringResource(R.string.wallet__payment_request_sending)
}
}
PaymentRequestLifecycleState.PROPOSED -> stringResource(proposedPaymentRequestStatusRes(request, isPending))
PaymentRequestLifecycleState.PROPOSAL_EXPIRED ->
stringResource(R.string.wallet__payment_request_status_expired)
PaymentRequestLifecycleState.ACCEPTED ->
Expand All @@ -506,6 +503,17 @@ private fun paymentRequestStatus(request: PaykitPaymentRequest): String {
}
}

@StringRes
internal fun proposedPaymentRequestStatusRes(request: PaykitPaymentRequest, isPending: Boolean): Int = when {
request.direction == PaykitPaymentRequestDirection.Incoming && isPending ->
R.string.wallet__payment_request_waiting
request.direction == PaykitPaymentRequestDirection.Incoming ->
R.string.wallet__payment_request_status_unavailable
request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent ->
R.string.wallet__payment_request_waiting
else -> R.string.wallet__payment_request_sending
}

@Composable
internal fun PaymentRequestCard(
request: PaykitPaymentRequest,
Expand Down Expand Up @@ -636,7 +644,10 @@ internal fun PaymentRequestCard(
}
}

private fun PaykitPaymentRequest.amountPrefix(isOutgoingPayment: Boolean, showSignedAmount: Boolean): String = when {
internal val PaykitPaymentRequest.hasPaymentEvidence: Boolean
get() = lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED

internal fun PaykitPaymentRequest.amountPrefix(isOutgoingPayment: Boolean, showSignedAmount: Boolean): String = when {
isOutgoingPayment -> "-"
showSignedAmount && direction == PaykitPaymentRequestDirection.Incoming -> "-"
showSignedAmount -> "+"
Expand All @@ -663,7 +674,7 @@ internal val PaykitPaymentRequest.paymentRailBackgroundColor
get() = if (paymentProofKind == PaykitPaymentProofKind.Lightning) Colors.Purple16 else Colors.Brand16

private fun PaykitPaymentRequest.showsPaymentRailIcon(isOutgoingPayment: Boolean): Boolean =
isOutgoingPayment || lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED
isOutgoingPayment || hasPaymentEvidence

private fun PaykitPaymentRequest.paymentWasSent(isOutgoingPayment: Boolean): Boolean =
isOutgoingPayment || direction == PaykitPaymentRequestDirection.Incoming
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ class SendContactSelectViewModel @Inject constructor(
showPayError(R.string.slashtags__error_pay_empty_msg)
PublicPaykitPaymentResult.NotOpened ->
showPayError(R.string.slashtags__error_pay_not_opened_msg)
PublicPaykitPaymentResult.WaitingForUpdatedPaymentList ->
PublicPaykitPaymentResult.PrivateLinkPending,
PublicPaykitPaymentResult.WaitingForUpdatedPaymentList,
->
showPayError(R.string.slashtags__error_pay_waiting_msg)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ private fun Content(
.fillMaxSize()
.gradientBackground()
.navigationBarsPadding()
.testTag("SendFailure")
) {
SheetTopBar(title)

Expand Down
Loading
Loading