Skip to content
Open
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
13 changes: 9 additions & 4 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -712,11 +712,13 @@ struct AppScene: View {
.task(id: pendingPaykitProofRefresh) {
guard let request = pendingPaykitProofRefresh else { return }
do {
try await PaykitPaymentActivity.shared.waitUntilIdle()
try await PaykitPaymentActivity.shared.waitUntilIdle(includingReviews: true)
} catch { return }
guard !Task.isCancelled, request == pendingPaykitProofRefresh else { return }
await refreshIncomingPaykitPaymentRequests(mode: .stored, forceFresh: true, automatic: false)
guard !Task.isCancelled else { return }
let refreshed = await refreshIncomingPaykitPaymentRequests(
mode: .stored, forceFresh: true, refreshPriority: .background, automatic: false
Comment thread
ben-kaufman marked this conversation as resolved.
)
guard !Task.isCancelled, refreshed else { return }
paykitProofRefresh.complete(request)
}
.onReceive(PaykitAllowanceExecutor.eventPublisher.receive(on: DispatchQueue.main)) { event in
Expand Down Expand Up @@ -1451,6 +1453,7 @@ struct AppScene: View {
mode: PaykitPaymentRequestRefreshMode = .full,
forceFresh: Bool = false,
messagePriority: PaykitSdkOperationLock.Priority = .background,
refreshPriority: PaykitSdkOperationLock.Priority? = nil,
onlyWhileActive: Bool = false,
automatic: Bool = true
) async -> Bool {
Expand Down Expand Up @@ -1482,8 +1485,10 @@ struct AppScene: View {
guard canRefresh() else { return false }
let identity = session.publicKey
let refreshed = await paykitPaymentRequestManager.refresh(
mode: mode, forceFresh: forceFresh, messagePriority: messagePriority, canRefresh: canRefresh
mode: mode, forceFresh: forceFresh, messagePriority: messagePriority,
refreshPriority: refreshPriority, canRefresh: canRefresh
)
guard refreshed else { return false }
// Deleting the profile withdraws the payment lists an automatic payment would consume.
if canRefresh(), pubkyProfile.automaticPaykitSession == session, !pubkyProfile.isDeletingProfile {
await paykitAllowanceManager.refresh()
Expand Down
3 changes: 2 additions & 1 deletion Bitkit/Services/PaykitPaymentRequestService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1818,13 +1818,14 @@ final class PaykitPaymentRequestManager {
mode: PaykitPaymentRequestRefreshMode = .full,
forceFresh: Bool = false,
messagePriority: PaykitSdkOperationLock.Priority = .background,
refreshPriority: PaykitSdkOperationLock.Priority? = nil,
canRefresh: @escaping @MainActor () -> Bool = { true }
) async -> Bool {
await refresh(
excludingProtectedRequestId: nil,
mode: mode,
forceFresh: forceFresh,
messagePriority: forceFresh ? .ordered : messagePriority,
messagePriority: refreshPriority ?? (forceFresh ? .ordered : messagePriority),
canRefresh: canRefresh
)
}
Expand Down
9 changes: 7 additions & 2 deletions BitkitTests/PaykitPaymentActivityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,27 +50,32 @@ final class PaykitPaymentActivityTests: XCTestCase {
XCTAssertTrue(didRefresh)
}

func testProofInvalidationsCoalesceUntilForegroundAndPaymentIdle() async throws {
func testProofInvalidationsCoalesceUntilForegroundAndPaymentReviewIdle() async throws {
let profile = PubkyProfileManager()
profile.publicKey = "pubky\(String(repeating: "z", count: 52))"
let session = try XCTUnwrap(profile.currentSession)
var state = PaykitPaymentProofRefreshState()
let activity = PaykitPaymentActivity()
let payment = activity.begin()
let review = activity.beginReview()
for _ in 0 ..< 3 {
state.invalidate(session: session)
XCTAssertNil(state.request(session: session, isActive: false))
}
let request = try XCTUnwrap(state.request(session: session, isActive: true))
var refreshes = 0
let work = Task {
try await activity.waitUntilIdle()
try await activity.waitUntilIdle(includingReviews: true)
refreshes += 1
state.complete(request)
}
await Task.yield()
XCTAssertEqual(refreshes, 0)
activity.end(payment)
await Task.yield()
XCTAssertEqual(refreshes, 0)
XCTAssertEqual(state.request(session: session, isActive: true), request)
activity.endReview(review)
try await work.value
XCTAssertEqual(refreshes, 1)
XCTAssertNil(state.request(session: session, isActive: true))
Expand Down
149 changes: 103 additions & 46 deletions BitkitTests/PaykitPaymentRequestServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -364,31 +364,35 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
}

func testFreshRefreshRereadsRequestsChangedAfterSnapshot() async throws {
let record = try paymentRequestRecord()
let sdk = PaymentRequestSdkMock(records: [record])
let manager = paymentRequestManager(sdk: sdk)
await sdk.pauseNextPaymentRequestList()
let first = Task { await manager.refresh(messagePriority: .background) }
try await waitUntil { await sdk.paymentRequestListIsPaused() }
let refreshPriorities: [PaykitSdkOperationLock.Priority?] = [nil, .background]
for refreshPriority in refreshPriorities {
let record = try paymentRequestRecord()
let sdk = PaymentRequestSdkMock(records: [record])
let manager = paymentRequestManager(sdk: sdk)
await sdk.pauseNextPaymentRequestList()
let first = Task { await manager.refresh(messagePriority: .background) }
try await waitUntil { await sdk.paymentRequestListIsPaused() }

await sdk.setRecords([])
let refreshStarted = expectation(description: "Request state refresh started")
let fresh = Task {
refreshStarted.fulfill()
await manager.refresh(forceFresh: true, messagePriority: .background)
}
await fulfillment(of: [refreshStarted], timeout: 1)
await sdk.resumePaymentRequestList()
_ = await first.value
await fresh.value
await sdk.setRecords([])
let refreshStarted = expectation(description: "Request state refresh started")
let fresh = Task {
refreshStarted.fulfill()
await manager.refresh(forceFresh: true, messagePriority: .background, refreshPriority: refreshPriority)
}
await fulfillment(of: [refreshStarted], timeout: 1)
await sdk.resumePaymentRequestList()
_ = await first.value
await fresh.value

XCTAssertTrue(manager.pendingRequests.isEmpty)
let snapshot = await sdk.snapshot()
XCTAssertEqual(snapshot.paymentRequestListCallCount, 2)
let priorities = await sdk.operationPriorities
XCTAssertEqual(priorities["pending"], [.background, .ordered])
XCTAssertEqual(priorities["receive"], [.background, .ordered])
XCTAssertEqual(priorities["observation"], [.background, .ordered])
XCTAssertTrue(manager.pendingRequests.isEmpty)
let snapshot = await sdk.snapshot()
XCTAssertEqual(snapshot.paymentRequestListCallCount, 2)
let priorities = await sdk.operationPriorities
let expectedPriority = refreshPriority ?? .ordered
XCTAssertEqual(priorities["pending"], [.background, expectedPriority])
XCTAssertEqual(priorities["receive"], [.background, expectedPriority])
XCTAssertEqual(priorities["observation"], [.background, expectedPriority])
}
}

func testFreshRefreshRereadsProofStateChangedDuringNotificationSynchronization() async throws {
Expand Down Expand Up @@ -503,31 +507,34 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
}

func testFreshRefreshDoesNotOutliveCancellationOrIdentityChange() async throws {
for invalidation in ["cancel", "clear", "identity"] {
let sdk = PaymentRequestSdkMock(records: [])
let manager = paymentRequestManager(sdk: sdk)
await sdk.pauseNextPaymentRequestList()
let first = Task { await manager.refresh() }
try await waitUntil { await sdk.paymentRequestListIsPaused() }
let refreshStarted = expectation(description: invalidation)
let fresh = Task {
refreshStarted.fulfill()
await manager.refresh(forceFresh: true)
}
await fulfillment(of: [refreshStarted], timeout: 1)
let refreshPriorities: [PaykitSdkOperationLock.Priority?] = [nil, .background]
for refreshPriority in refreshPriorities {
for invalidation in ["cancel", "clear", "identity"] {
let sdk = PaymentRequestSdkMock(records: [])
let manager = paymentRequestManager(sdk: sdk)
await sdk.pauseNextPaymentRequestList()
let first = Task { await manager.refresh() }
try await waitUntil { await sdk.paymentRequestListIsPaused() }
let refreshStarted = expectation(description: invalidation)
let fresh = Task {
refreshStarted.fulfill()
await manager.refresh(forceFresh: true, refreshPriority: refreshPriority)
}
await fulfillment(of: [refreshStarted], timeout: 1)

switch invalidation {
case "cancel": fresh.cancel()
case "clear": manager.clear()
default: manager.activate(identity: "pubky\(String(repeating: "y", count: 52))")
}
await sdk.resumePaymentRequestList()
_ = await first.value
await fresh.value
switch invalidation {
case "cancel": fresh.cancel()
case "clear": manager.clear()
default: manager.activate(identity: "pubky\(String(repeating: "y", count: 52))")
}
await sdk.resumePaymentRequestList()
_ = await first.value
await fresh.value

let snapshot = await sdk.snapshot()
XCTAssertEqual(snapshot.paymentRequestListCallCount, 1, invalidation)
XCTAssertTrue(manager.pendingRequests.isEmpty, invalidation)
let snapshot = await sdk.snapshot()
XCTAssertEqual(snapshot.paymentRequestListCallCount, 1, invalidation)
XCTAssertTrue(manager.pendingRequests.isEmpty, invalidation)
}
}
}

Expand Down Expand Up @@ -4186,6 +4193,48 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
XCTAssertTrue(manager.pendingRequests.isEmpty)
}

func testBackgroundStateChangeRefreshDefersWithoutConsumingSnapshotOrBlockingRecovery() async throws {
let record = try paymentRequestRecord()
let sdk = PaymentRequestSdkMock(records: [record])
let manager = paymentRequestManager(sdk: sdk)
let loaded = await manager.refresh(mode: .stored)
XCTAssertTrue(loaded)
let pending = manager.pendingRequests
await sdk.pauseNextPaymentRequestList()
let admitted = Task { await manager.refresh(mode: .stored) }
try await waitUntil { await sdk.paymentRequestListIsPaused() }
let started = expectation(description: "Proof display refresh waiting for admitted snapshot")
let fresh = Task {
started.fulfill()
return await manager.refresh(mode: .stored, forceFresh: true, refreshPriority: .background)
}
await fulfillment(of: [started], timeout: 1)
await sdk.setBackgroundObservationDeferred(true)
await sdk.setRecords([])
await sdk.resumePaymentRequestList()
let admittedResult = await admitted.value
let deferredResult = await fresh.value
XCTAssertTrue(admittedResult)
XCTAssertFalse(deferredResult)
XCTAssertEqual(manager.pendingRequests, pending)

let recovered = await manager.refresh(mode: .stored, forceFresh: true)
XCTAssertTrue(recovered)
XCTAssertTrue(manager.pendingRequests.isEmpty)
await sdk.setBackgroundObservationDeferred(false)
let latest = try paymentRequestRecord(id: "latest-request")
await sdk.setRecords([latest])
let retried = await manager.refresh(mode: .stored)
XCTAssertTrue(retried)
XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), [latest.paymentRequestId])
let priorities = await sdk.operationPriorities
XCTAssertEqual(priorities["observation"], [.background, .background, .background, .ordered, .background])
let snapshot = await sdk.snapshot()
XCTAssertEqual(snapshot.paymentRequestListCallCount, 4)
XCTAssertEqual(snapshot.processCallCount, 0)
XCTAssertEqual(snapshot.receiveCallCount, 0)
}

func testRefreshContinuesWhenPendingResponseDeliveryFails() async throws {
let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()])
await sdk.failNextProcess()
Expand Down Expand Up @@ -9531,6 +9580,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym
private var incomingRecords: [PaymentRequestRecord] = []
private var paymentRequestListCallCount = 0
private var paymentRequestListError: Error?
private var isBackgroundObservationDeferred = false
private var peerRecords: [LinkedPeerRecord] = []
private var requestCapabilitiesByPublicKey: [String: Bool] = [:]
private var liveSessionAvailable = true
Expand Down Expand Up @@ -9659,6 +9709,9 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym

func observeState(priority: PaykitSdkOperationLock.Priority, expectedIdentity: String?) async throws -> PaykitStateObservation {
operationPriorities["observation", default: []].append(priority)
if priority == .background, isBackgroundObservationDeferred {
throw PaykitPaymentRequestError.operationInProgress
}
let identity = activeIdentity
if let expectedIdentity {
guard PubkyPublicKeyFormat.matches(identity, expectedIdentity) else { throw PubkyServiceError.identityChanged }
Expand Down Expand Up @@ -9705,6 +9758,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym
paymentRequestListError = error
}

func setBackgroundObservationDeferred(_ deferred: Bool) {
isBackgroundObservationDeferred = deferred
}

func identityStatus() -> IdentityStatus? {
IdentityStatus(publicKey: activeIdentity, capability: liveSessionAvailable ? .privateLinkCapable : .signedOut)
}
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1066.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Background payment-request updates now give priority to payment review and submission.
Loading