Skip to content
210 changes: 197 additions & 13 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,135 @@ import LDKNode
import SwiftUI
import UserNotifications

struct IncomingPaykitPaymentRequestPresentationFeedback: Equatable {
struct Toast: Equatable {
let titleKey: String
let descriptionKey: String
let accessibilityIdentifier: String
}

let diagnosticReason: IncomingPaykitPaymentRequestFailureReason
let isTerminal: Bool
let toast: Toast?

init(
deferral: PaykitPaymentRequestPresentationDeferral,
fallbackReason: IncomingPaykitPaymentRequestFailureReason
) {
switch deferral {
case .requestedPresentationEnded:
diagnosticReason = fallbackReason
isTerminal = true
toast = Toast(
titleKey: "wallet__payment_request",
descriptionKey: "wallet__payment_request_unavailable",
accessibilityIdentifier: "PaymentRequestUnavailableToast"
)
case let .requestExpired(wasRequested):
diagnosticReason = .requestExpired
isTerminal = true
toast = wasRequested ? Toast(
titleKey: "wallet__payment_request",
descriptionKey: "wallet__payment_request_expired",
accessibilityIdentifier: "PaymentRequestExpiredToast"
) : nil
case .retryScheduled, .ignored:
diagnosticReason = fallbackReason
isTerminal = false
toast = nil
}
}
}

struct IncomingPaykitPaymentRequestPresentationState: Equatable {
let requestedPresentationId: PaykitPaymentRequest.ID?
let retryTrigger: Int
let expirationTrigger: Int
let unavailableTrigger: Int

init(
requestedPresentationId: PaykitPaymentRequest.ID?,
retryTrigger: Int,
expirationTrigger: Int,
unavailableTrigger: Int
) {
self.requestedPresentationId = requestedPresentationId
self.retryTrigger = retryTrigger
self.expirationTrigger = expirationTrigger
self.unavailableTrigger = unavailableTrigger
}

@MainActor
init(_ manager: PaykitPaymentRequestManager) {
self.init(
requestedPresentationId: manager.requestedPresentationId,
retryTrigger: manager.presentationRetryTrigger,
expirationTrigger: manager.requestedPresentationExpirationTrigger,
unavailableTrigger: manager.requestedPresentationUnavailableTrigger
)
}
}

enum IncomingPaykitPaymentRequestPresentationDispatch: Equatable {
case presentFeedback(IncomingPaykitPaymentRequestPresentationFeedback, PaykitPaymentRequest)
case presentNext
}

@MainActor
enum IncomingPaykitPaymentRequestPresentationDispatcher {
static func feedback(
deferring request: PaykitPaymentRequest,
reason: IncomingPaykitPaymentRequestFailureReason,
with manager: PaykitPaymentRequestManager
) -> IncomingPaykitPaymentRequestPresentationFeedback {
IncomingPaykitPaymentRequestPresentationFeedback(
deferral: manager.deferPresentation(request),
fallbackReason: reason
)
}

static func handleStateChange(
from previous: IncomingPaykitPaymentRequestPresentationState,
to current: IncomingPaykitPaymentRequestPresentationState,
manager: PaykitPaymentRequestManager
) -> [IncomingPaykitPaymentRequestPresentationDispatch] {
var dispatches: [IncomingPaykitPaymentRequestPresentationDispatch] = []
if current.expirationTrigger != previous.expirationTrigger {
while let request = manager.consumeExpiredRequestedPresentation() {
dispatches.append(
.presentFeedback(
IncomingPaykitPaymentRequestPresentationFeedback(
deferral: .requestExpired(wasRequested: true),
fallbackReason: .resolutionFailed
),
request
)
)
}
}
if current.unavailableTrigger != previous.unavailableTrigger {
while let request = manager.consumeUnavailableRequestedPresentation() {
dispatches.append(
.presentFeedback(
IncomingPaykitPaymentRequestPresentationFeedback(
deferral: .requestedPresentationEnded,
fallbackReason: .resolutionFailed
),
request
)
)
}
}
if current.retryTrigger != previous.retryTrigger ||
Comment thread
ovitrif marked this conversation as resolved.
previous.requestedPresentationId != current.requestedPresentationId && current.requestedPresentationId != nil ||
current.expirationTrigger != previous.expirationTrigger
{
dispatches.append(.presentNext)
}
return dispatches
}
}

struct AppScene: View {
private static let paykitPaymentRequestRefreshIntervals: [Duration] = [.seconds(30), .seconds(60), .seconds(120)]
private static let initialPaykitSyncRetryDelays = Array(repeating: Duration.seconds(2), count: 14)
Expand Down Expand Up @@ -249,12 +378,8 @@ struct AppScene: View {
await presentNextIncomingPaykitPaymentRequest()
}
}
.onChange(of: paykitPaymentRequestManager.requestedPresentationId) { _, requestId in
guard requestId != nil else { return }
Task { await presentNextIncomingPaykitPaymentRequest() }
}
.onChange(of: paykitPaymentRequestManager.presentationRetryTrigger) {
Task { await presentNextIncomingPaykitPaymentRequest() }
.onChange(of: incomingPaykitPaymentRequestPresentationState) { previous, current in
handleIncomingPaykitPaymentRequestPresentationStateChange(from: previous, to: current)
}
.onChange(of: paykitPaymentRequestManager.pendingRequests) { _, requests in
guard let request = app.contactPaymentContext?.incomingPaymentRequest,
Expand Down Expand Up @@ -837,8 +962,10 @@ struct AppScene: View {
app.contactPaymentContext == nil
else { return }
guard case let .opened(paymentTarget, privatePaymentContext) = result else {
Logger.debug("Incoming Paykit payment request is waiting for private payment details: \(result)", context: "AppScene")
paykitPaymentRequestManager.deferPresentation(request)
deferIncomingPaykitPaymentRequestPresentation(
request,
reason: result.incomingPaymentRequestFailureReason ?? .resolutionFailed
)
continue
}

Expand Down Expand Up @@ -869,7 +996,7 @@ struct AppScene: View {
guard PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) != nil else {
app.resetSendState()
wallet.resetSendState(speed: settings.defaultTransactionSpeed)
paykitPaymentRequestManager.deferPresentation(request)
deferIncomingPaykitPaymentRequestPresentation(request, reason: .paymentTargetNotRoutable)
continue
}

Expand All @@ -891,10 +1018,9 @@ struct AppScene: View {
wallet.resetSendState(speed: settings.defaultTransactionSpeed)
return
}
Logger.warn("Failed to present incoming Paykit payment request: \(error)", context: "AppScene")
app.resetSendState()
wallet.resetSendState(speed: settings.defaultTransactionSpeed)
paykitPaymentRequestManager.deferPresentation(request)
deferIncomingPaykitPaymentRequestPresentation(request, reason: .invalidPaymentTarget)
continue
}

Expand All @@ -906,6 +1032,7 @@ struct AppScene: View {
else {
app.resetSendState()
wallet.resetSendState(speed: settings.defaultTransactionSpeed)
deferIncomingPaykitPaymentRequestPresentation(request, reason: .paymentTargetNotRoutable)
return
}
sheets.showSheet(.send, data: SendConfig(view: route))
Expand All @@ -914,8 +1041,7 @@ struct AppScene: View {
return
} catch {
guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return }
Logger.warn("Failed to present incoming Paykit payment request: \(error)", context: "AppScene")
paykitPaymentRequestManager.deferPresentation(request)
deferIncomingPaykitPaymentRequestPresentation(request, reason: .resolutionFailed)
}
}
}
Expand All @@ -930,6 +1056,64 @@ struct AppScene: View {
await presentNextIncomingPaykitPaymentRequest()
}

private func deferIncomingPaykitPaymentRequestPresentation(
_ request: PaykitPaymentRequest,
reason: IncomingPaykitPaymentRequestFailureReason
) {
presentIncomingPaykitPaymentRequestFeedback(
IncomingPaykitPaymentRequestPresentationDispatcher.feedback(
deferring: request,
reason: reason,
with: paykitPaymentRequestManager
),
for: request
)
}

private var incomingPaykitPaymentRequestPresentationState: IncomingPaykitPaymentRequestPresentationState {
IncomingPaykitPaymentRequestPresentationState(paykitPaymentRequestManager)
}

private func handleIncomingPaykitPaymentRequestPresentationStateChange(
from previous: IncomingPaykitPaymentRequestPresentationState,
to current: IncomingPaykitPaymentRequestPresentationState
) {
for dispatch in IncomingPaykitPaymentRequestPresentationDispatcher.handleStateChange(
from: previous,
to: current,
manager: paykitPaymentRequestManager
) {
switch dispatch {
case let .presentFeedback(feedback, request):
presentIncomingPaykitPaymentRequestFeedback(feedback, for: request)
case .presentNext:
Task { await presentNextIncomingPaykitPaymentRequest() }
}
}
}

private func presentIncomingPaykitPaymentRequestFeedback(
_ feedback: IncomingPaykitPaymentRequestPresentationFeedback,
for request: PaykitPaymentRequest
) {
if feedback.isTerminal {
Logger.warn(
"Rejected incoming Paykit payment request presentation: category=\(feedback.diagnosticReason.category) " +
"reason=\(feedback.diagnosticReason.rawValue) " +
"counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(request.counterparty))",
context: "AppScene"
)
}

guard let toast = feedback.toast else { return }
app.toast(
type: .error,
title: t(toast.titleKey),
description: t(toast.descriptionKey),
accessibilityIdentifier: toast.accessibilityIdentifier
)
}

private func retryPendingPaykitEndpointRemoval() async {
if PublicPaykitService.isCleanupPending {
do {
Expand Down
Loading
Loading