From fbd6e639a92d27dd79e1a7b48cd3a891d59de961 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 22 Sep 2026 10:59:28 +0300 Subject: [PATCH 1/4] fix: keep foreground inbox checks responsive --- Bitkit/AppScene.swift | 21 ++++++---- .../PaykitPaymentRequestService.swift | 40 +++++++++++------- ...itPaymentRequestPollingScheduleTests.swift | 28 +++++-------- .../PaykitPaymentRequestServiceTests.swift | 42 +++++++++++++++++-- changelog.d/next/769.changed.md | 1 + 5 files changed, 86 insertions(+), 46 deletions(-) create mode 100644 changelog.d/next/769.changed.md diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 86e18d2b6..8b939c7eb 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -152,7 +152,7 @@ enum IncomingPaykitPaymentRequestPresentationDispatcher { } struct PaykitPaymentRequestPollingSchedule { - private static let refreshIntervals: [Duration] = [.seconds(5), .seconds(10), .seconds(15), .seconds(30)] + private static let refreshIntervals: [Duration] = [.seconds(10), .seconds(30), .seconds(60), .seconds(120)] private static let maintenanceIntervals: [Duration] = [.seconds(30), .seconds(60), .seconds(120)] private var refreshIntervalIndex = 0 private var maintenanceIntervalIndex = 0 @@ -170,8 +170,8 @@ struct PaykitPaymentRequestPollingSchedule { return true } - mutating func recordRefresh(requestsChanged: Bool) { - refreshIntervalIndex = requestsChanged ? 0 : min(refreshIntervalIndex + 1, Self.refreshIntervals.count - 1) + mutating func recordRefresh(succeeded: Bool) { + refreshIntervalIndex = succeeded ? 0 : min(refreshIntervalIndex + 1, Self.refreshIntervals.count - 1) } } @@ -994,15 +994,14 @@ struct AppScene: View { if refreshMaintenance { await PaykitPaymentProofService.shared.reconcile() } - let previousRequests = paykitPaymentRequestManager.pendingRequests - await paykitPaymentRequestManager.refresh() + let succeeded = await paykitPaymentRequestManager.refresh() if presentItems { await presentNextIncomingPaykitItem() } if refreshMaintenance { await paykitPaymentRequestManager.refreshEligibleTargets(savedPublicKeys: contactsManager.contacts.map(\.publicKey)) } - return paykitPaymentRequestManager.pendingRequests != previousRequests + return succeeded } private func associateResolvedPaykitOnchainPayment(_ resolution: PaykitOnchainPaymentResolution) async { @@ -1045,15 +1044,19 @@ struct AppScene: View { return } let refreshMaintenance = schedule.takeMaintenanceIfDue() + guard network.isConnected else { + schedule.recordRefresh(succeeded: false) + continue + } if refreshMaintenance { - if network.isConnected { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) } + await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints( wallet: wallet, reason: "payment request polling" ) } - let requestsChanged = await refreshIncomingPaykitPaymentRequests(refreshMaintenance: refreshMaintenance) - schedule.recordRefresh(requestsChanged: requestsChanged) + let succeeded = await refreshIncomingPaykitPaymentRequests(refreshMaintenance: refreshMaintenance) + schedule.recordRefresh(succeeded: succeeded) } } diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index b1630d55c..6b0a851fe 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -393,15 +393,18 @@ struct PaykitPaymentRequestSnapshot: Equatable { let incoming: [PaykitPaymentRequest] let history: [PaykitPaymentRequest] let subscriptions: [PaykitSubscription] + let inboxRefreshSucceeded: Bool init( incoming: [PaykitPaymentRequest], history: [PaykitPaymentRequest], - subscriptions: [PaykitSubscription] = [] + subscriptions: [PaykitSubscription] = [], + inboxRefreshSucceeded: Bool = true ) { self.incoming = incoming self.history = history self.subscriptions = subscriptions + self.inboxRefreshSucceeded = inboxRefreshSucceeded } } @@ -561,7 +564,8 @@ struct PaykitPaymentRequestService { return PaykitPaymentRequestSnapshot( incoming: incoming, history: history, - subscriptions: subscriptions + subscriptions: subscriptions, + inboxRefreshSucceeded: intakeReports.allSatisfy { $0.error == nil } ) } @@ -1027,7 +1031,7 @@ final class PaykitPaymentRequestManager { private var expiredRequestedPresentations: [PaykitPaymentRequest] = [] private var unavailableRequestedPresentations: [PaykitPaymentRequest] = [] private var isPresentingRequests = false - private var refreshTask: Task? + private var refreshTask: Task? private var expirationTask: Task? private var presentationRetryTask: Task? private var refreshGeneration = 0 @@ -1244,7 +1248,8 @@ final class PaykitPaymentRequestManager { return subscription } - func refresh() async { + @discardableResult + func refresh() async -> Bool { await refresh(excludingProtectedRequestId: nil) } @@ -1260,23 +1265,24 @@ final class PaykitPaymentRequestManager { ) } - private func refresh(excludingProtectedRequestId: PaykitPaymentRequest.ID?) async { + @discardableResult + private func refresh(excludingProtectedRequestId: PaykitPaymentRequest.ID?) async -> Bool { if let refreshTask { - await refreshTask.value - return + return await refreshTask.value } refreshGeneration += 1 let generation = refreshGeneration let task = Task { [weak self] in - guard let self else { return } - await performRefresh(generation: generation, excludingProtectedRequestId: excludingProtectedRequestId) + guard let self else { return false } + return await performRefresh(generation: generation, excludingProtectedRequestId: excludingProtectedRequestId) } refreshTask = task - await task.value + let succeeded = await task.value - guard generation == refreshGeneration else { return } + guard generation == refreshGeneration else { return false } refreshTask = nil + return succeeded } func prepareForPayment( @@ -1701,17 +1707,17 @@ final class PaykitPaymentRequestManager { private func performRefresh( generation: Int, excludingProtectedRequestId: PaykitPaymentRequest.ID? - ) async { + ) async -> Bool { do { let snapshot = try await service.synchronize() - guard generation == refreshGeneration, let activeIdentity else { return } + guard generation == refreshGeneration, let activeIdentity else { return false } async let completedProofKinds = completedPaymentProofKinds(activeIdentity) async let inFlightRequestIds = inFlightPaymentRequestIds(activeIdentity) let (locallyCompletedProofKinds, locallyInFlightRequestIds) = await (completedProofKinds, inFlightRequestIds) let locallyCompletedRequestIds = Set(locallyCompletedProofKinds.keys) guard generation == refreshGeneration, PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) - else { return } + else { return false } let refreshDate = now() let handledRequestedExpirationId = recordRequestedPresentationExpiration(at: refreshDate) let previousPending = pendingRequests @@ -1806,12 +1812,14 @@ final class PaykitPaymentRequestManager { persistPresentedRequestIds() discardExpiredRequests(handledRequestedExpirationId: handledRequestedExpirationId) schedulePresentationRetry() + return snapshot.inboxRefreshSucceeded } catch is CancellationError { - return + return false } catch { - guard generation == refreshGeneration else { return } + guard generation == refreshGeneration else { return false } discardExpiredRequests() logWarning("Failed to refresh incoming Paykit payment requests: \(error)") + return false } } diff --git a/BitkitTests/PaykitPaymentRequestPollingScheduleTests.swift b/BitkitTests/PaykitPaymentRequestPollingScheduleTests.swift index cfdfa556e..0e3d9d4ab 100644 --- a/BitkitTests/PaykitPaymentRequestPollingScheduleTests.swift +++ b/BitkitTests/PaykitPaymentRequestPollingScheduleTests.swift @@ -2,38 +2,30 @@ import XCTest final class PaykitPaymentRequestPollingScheduleTests: XCTestCase { - func testIdleInboxAndMaintenanceBackOffIndependently() { + func testSuccessfulIdleInboxKeepsTenSecondChecksAndSlowerMaintenance() { var schedule = PaykitPaymentRequestPollingSchedule() var elapsed: Duration = .zero var maintenanceTimes: [Duration] = [] - for delay in [5, 10, 15, 30, 30, 30, 30, 30, 30] { - XCTAssertEqual(schedule.nextDelay, .seconds(delay)) + for _ in 0 ..< 21 { + XCTAssertEqual(schedule.nextDelay, .seconds(10)) elapsed += schedule.nextDelay if schedule.takeMaintenanceIfDue() { maintenanceTimes.append(elapsed) } - schedule.recordRefresh(requestsChanged: false) + schedule.recordRefresh(succeeded: true) } XCTAssertEqual(maintenanceTimes, [.seconds(30), .seconds(90), .seconds(210)]) } - func testRequestChangesResetInboxWithoutResettingMaintenance() { + func testFailuresBackOffAndSuccessResumesTenSecondChecks() { var schedule = PaykitPaymentRequestPollingSchedule() - var elapsed: Duration = .zero - var maintenanceTimes: [Duration] = [] - for _ in 0 ..< 4 { - elapsed += schedule.nextDelay - if schedule.takeMaintenanceIfDue() { maintenanceTimes.append(elapsed) } - schedule.recordRefresh(requestsChanged: elapsed == .seconds(60)) - } - while elapsed < .seconds(210) { - XCTAssertEqual(schedule.nextDelay, .seconds(5)) - elapsed += schedule.nextDelay - if schedule.takeMaintenanceIfDue() { maintenanceTimes.append(elapsed) } - schedule.recordRefresh(requestsChanged: true) + for delay in [10, 30, 60, 120, 120] { + XCTAssertEqual(schedule.nextDelay, .seconds(delay)) + schedule.recordRefresh(succeeded: false) } - XCTAssertEqual(maintenanceTimes, [.seconds(30), .seconds(90), .seconds(210)]) + schedule.recordRefresh(succeeded: true) + XCTAssertEqual(schedule.nextDelay, .seconds(10)) } } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index ac09cd9c3..bd39c8aa2 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1675,12 +1675,37 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { func testFailedRefreshKeepsPreviouslyLoadedRequests() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) - await manager.refresh() + let initialRefreshSucceeded = await manager.refresh() + XCTAssertTrue(initialRefreshSucceeded) await sdk.setRecords([]) await sdk.setReceiveError(.receive) - await manager.refresh() + let failedRefreshSucceeded = await manager.refresh() + XCTAssertFalse(failedRefreshSucceeded) + + XCTAssertEqual(manager.pendingRequests.count, 1) + await sdk.setReceiveError(nil) + let recoveredRefreshSucceeded = await manager.refresh() + XCTAssertTrue(recoveredRefreshSucceeded) + XCTAssertTrue(manager.pendingRequests.isEmpty) + } + + func testPeerIntakeFailureReportsUnsuccessfulRefreshWithoutLosingRequests() async throws { + let record = try paymentRequestRecord() + let sdk = PaymentRequestSdkMock(records: [record]) + await sdk.setReceiveReports([ + PrivateStreamCounterpartyIntakeReport( + counterparty: record.counterparty, + counterpartyReceiverPath: record.counterpartyReceiverPath, + report: nil, + error: PaymentRequestIntakeError(noPointer: .init()) + ), + ]) + let manager = paymentRequestManager(sdk: sdk) + + let succeeded = await manager.refresh() + XCTAssertFalse(succeeded) XCTAssertEqual(manager.pendingRequests.count, 1) } @@ -3292,6 +3317,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { private var isProcessPaused = false private var processContinuation: CheckedContinuation? private var receiveError: PaymentRequestSdkMockError? + private var receiveReports: [PrivateStreamCounterpartyIntakeReport] = [] private var acceptedRequests: [PaymentRequestInvocation] = [] private var rejectedRequests: [PaymentRequestInvocation] = [] private var acceptFailuresAfterRemoval = 0 @@ -3336,7 +3362,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { if let receiveError { throw receiveError } - return [] + return receiveReports } func paymentRequests() async -> [PaymentRequestRecord] { @@ -3619,6 +3645,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { receiveError = error } + func setReceiveReports(_ reports: [PrivateStreamCounterpartyIntakeReport]) { + receiveReports = reports + } + func snapshot() -> PaymentRequestSdkSnapshot { PaymentRequestSdkSnapshot( uploadCount: uploadCount, @@ -3790,6 +3820,12 @@ private actor PaykitSubscriptionNotificationCenterMock: PaykitSubscriptionNotifi } } +private final class PaymentRequestIntakeError: PrivateOperationError, @unchecked Sendable { + override func redactedContext() -> String { + "transport failure" + } +} + @MainActor private func waitUntil( timeout: Duration = .seconds(2), diff --git a/changelog.d/next/769.changed.md b/changelog.d/next/769.changed.md new file mode 100644 index 000000000..660a1a0c1 --- /dev/null +++ b/changelog.d/next/769.changed.md @@ -0,0 +1 @@ +Incoming payment requests are checked more frequently while the app is open, with slower retries when connectivity fails. From 22adc08a540d391b959f8b6cc0a6b6dd33731448 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 22 Sep 2026 11:11:07 +0300 Subject: [PATCH 2/4] fix: restart inbox polling after reconnect --- Bitkit/AppScene.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 8b939c7eb..b1afefef2 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -317,7 +317,7 @@ struct AppScene: View { config in AppUpdateSheet(config: config) } .task(priority: .userInitiated, setupTask) - .task(id: scenePhase) { await pollIncomingPaykitPaymentRequests() } + .task(id: [scenePhase == .active, network.isConnected]) { await pollIncomingPaykitPaymentRequests() } .task(id: initialPaykitSyncGeneration) { await pollIncomingPaykitPaymentRequestsDuringInitialSync() } .task { await handlePendingPaykitSubscriptionNotification() } .onChange(of: currency.hasStaleData) { _, newValue in handleCurrencyStaleData(newValue) } @@ -1033,9 +1033,9 @@ struct AppScene: View { } private func pollIncomingPaykitPaymentRequests() async { - guard scenePhase == .active else { return } + guard scenePhase == .active, network.isConnected else { return } - if network.isConnected { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) } + await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) var schedule = PaykitPaymentRequestPollingSchedule() while !Task.isCancelled { do { From bb4722aed371652025a981b8fb192eb1c824a82f Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 22 Sep 2026 11:17:31 +0300 Subject: [PATCH 3/4] fix: keep inbox polling at ten seconds --- Bitkit/AppScene.swift | 27 +++---------- .../PaykitPaymentRequestService.swift | 40 ++++++++----------- ...itPaymentRequestPollingScheduleTests.swift | 15 +------ .../PaykitPaymentRequestServiceTests.swift | 14 +++---- changelog.d/next/769.changed.md | 2 +- 5 files changed, 29 insertions(+), 69 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index b1afefef2..1ea61fb4a 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -152,16 +152,11 @@ enum IncomingPaykitPaymentRequestPresentationDispatcher { } struct PaykitPaymentRequestPollingSchedule { - private static let refreshIntervals: [Duration] = [.seconds(10), .seconds(30), .seconds(60), .seconds(120)] + let nextDelay: Duration = .seconds(10) private static let maintenanceIntervals: [Duration] = [.seconds(30), .seconds(60), .seconds(120)] - private var refreshIntervalIndex = 0 private var maintenanceIntervalIndex = 0 private var maintenanceDelay = Self.maintenanceIntervals[0] - var nextDelay: Duration { - Self.refreshIntervals[refreshIntervalIndex] - } - mutating func takeMaintenanceIfDue() -> Bool { maintenanceDelay -= nextDelay guard maintenanceDelay <= .zero else { return false } @@ -169,10 +164,6 @@ struct PaykitPaymentRequestPollingSchedule { maintenanceDelay = Self.maintenanceIntervals[maintenanceIntervalIndex] return true } - - mutating func recordRefresh(succeeded: Bool) { - refreshIntervalIndex = succeeded ? 0 : min(refreshIntervalIndex + 1, Self.refreshIntervals.count - 1) - } } struct AppScene: View { @@ -981,27 +972,25 @@ struct AppScene: View { } } - @discardableResult - private func refreshIncomingPaykitPaymentRequests(presentItems: Bool = true, refreshMaintenance: Bool = true) async -> Bool { + private func refreshIncomingPaykitPaymentRequests(presentItems: Bool = true, refreshMaintenance: Bool = true) async { guard PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true, pubkyProfile.authState == .authenticated else { paykitPaymentRequestManager.clearEligibleTargets() - return false + return } if refreshMaintenance { await PaykitPaymentProofService.shared.reconcile() } - let succeeded = await paykitPaymentRequestManager.refresh() + await paykitPaymentRequestManager.refresh() if presentItems { await presentNextIncomingPaykitItem() } if refreshMaintenance { await paykitPaymentRequestManager.refreshEligibleTargets(savedPublicKeys: contactsManager.contacts.map(\.publicKey)) } - return succeeded } private func associateResolvedPaykitOnchainPayment(_ resolution: PaykitOnchainPaymentResolution) async { @@ -1044,10 +1033,7 @@ struct AppScene: View { return } let refreshMaintenance = schedule.takeMaintenanceIfDue() - guard network.isConnected else { - schedule.recordRefresh(succeeded: false) - continue - } + guard network.isConnected else { continue } if refreshMaintenance { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints( @@ -1055,8 +1041,7 @@ struct AppScene: View { reason: "payment request polling" ) } - let succeeded = await refreshIncomingPaykitPaymentRequests(refreshMaintenance: refreshMaintenance) - schedule.recordRefresh(succeeded: succeeded) + await refreshIncomingPaykitPaymentRequests(refreshMaintenance: refreshMaintenance) } } diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 6b0a851fe..b1630d55c 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -393,18 +393,15 @@ struct PaykitPaymentRequestSnapshot: Equatable { let incoming: [PaykitPaymentRequest] let history: [PaykitPaymentRequest] let subscriptions: [PaykitSubscription] - let inboxRefreshSucceeded: Bool init( incoming: [PaykitPaymentRequest], history: [PaykitPaymentRequest], - subscriptions: [PaykitSubscription] = [], - inboxRefreshSucceeded: Bool = true + subscriptions: [PaykitSubscription] = [] ) { self.incoming = incoming self.history = history self.subscriptions = subscriptions - self.inboxRefreshSucceeded = inboxRefreshSucceeded } } @@ -564,8 +561,7 @@ struct PaykitPaymentRequestService { return PaykitPaymentRequestSnapshot( incoming: incoming, history: history, - subscriptions: subscriptions, - inboxRefreshSucceeded: intakeReports.allSatisfy { $0.error == nil } + subscriptions: subscriptions ) } @@ -1031,7 +1027,7 @@ final class PaykitPaymentRequestManager { private var expiredRequestedPresentations: [PaykitPaymentRequest] = [] private var unavailableRequestedPresentations: [PaykitPaymentRequest] = [] private var isPresentingRequests = false - private var refreshTask: Task? + private var refreshTask: Task? private var expirationTask: Task? private var presentationRetryTask: Task? private var refreshGeneration = 0 @@ -1248,8 +1244,7 @@ final class PaykitPaymentRequestManager { return subscription } - @discardableResult - func refresh() async -> Bool { + func refresh() async { await refresh(excludingProtectedRequestId: nil) } @@ -1265,24 +1260,23 @@ final class PaykitPaymentRequestManager { ) } - @discardableResult - private func refresh(excludingProtectedRequestId: PaykitPaymentRequest.ID?) async -> Bool { + private func refresh(excludingProtectedRequestId: PaykitPaymentRequest.ID?) async { if let refreshTask { - return await refreshTask.value + await refreshTask.value + return } refreshGeneration += 1 let generation = refreshGeneration let task = Task { [weak self] in - guard let self else { return false } - return await performRefresh(generation: generation, excludingProtectedRequestId: excludingProtectedRequestId) + guard let self else { return } + await performRefresh(generation: generation, excludingProtectedRequestId: excludingProtectedRequestId) } refreshTask = task - let succeeded = await task.value + await task.value - guard generation == refreshGeneration else { return false } + guard generation == refreshGeneration else { return } refreshTask = nil - return succeeded } func prepareForPayment( @@ -1707,17 +1701,17 @@ final class PaykitPaymentRequestManager { private func performRefresh( generation: Int, excludingProtectedRequestId: PaykitPaymentRequest.ID? - ) async -> Bool { + ) async { do { let snapshot = try await service.synchronize() - guard generation == refreshGeneration, let activeIdentity else { return false } + guard generation == refreshGeneration, let activeIdentity else { return } async let completedProofKinds = completedPaymentProofKinds(activeIdentity) async let inFlightRequestIds = inFlightPaymentRequestIds(activeIdentity) let (locallyCompletedProofKinds, locallyInFlightRequestIds) = await (completedProofKinds, inFlightRequestIds) let locallyCompletedRequestIds = Set(locallyCompletedProofKinds.keys) guard generation == refreshGeneration, PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) - else { return false } + else { return } let refreshDate = now() let handledRequestedExpirationId = recordRequestedPresentationExpiration(at: refreshDate) let previousPending = pendingRequests @@ -1812,14 +1806,12 @@ final class PaykitPaymentRequestManager { persistPresentedRequestIds() discardExpiredRequests(handledRequestedExpirationId: handledRequestedExpirationId) schedulePresentationRetry() - return snapshot.inboxRefreshSucceeded } catch is CancellationError { - return false + return } catch { - guard generation == refreshGeneration else { return false } + guard generation == refreshGeneration else { return } discardExpiredRequests() logWarning("Failed to refresh incoming Paykit payment requests: \(error)") - return false } } diff --git a/BitkitTests/PaykitPaymentRequestPollingScheduleTests.swift b/BitkitTests/PaykitPaymentRequestPollingScheduleTests.swift index 0e3d9d4ab..c3bf6eacc 100644 --- a/BitkitTests/PaykitPaymentRequestPollingScheduleTests.swift +++ b/BitkitTests/PaykitPaymentRequestPollingScheduleTests.swift @@ -2,7 +2,7 @@ import XCTest final class PaykitPaymentRequestPollingScheduleTests: XCTestCase { - func testSuccessfulIdleInboxKeepsTenSecondChecksAndSlowerMaintenance() { + func testInboxKeepsTenSecondChecksAndSlowerMaintenance() { var schedule = PaykitPaymentRequestPollingSchedule() var elapsed: Duration = .zero var maintenanceTimes: [Duration] = [] @@ -11,21 +11,8 @@ final class PaykitPaymentRequestPollingScheduleTests: XCTestCase { XCTAssertEqual(schedule.nextDelay, .seconds(10)) elapsed += schedule.nextDelay if schedule.takeMaintenanceIfDue() { maintenanceTimes.append(elapsed) } - schedule.recordRefresh(succeeded: true) } XCTAssertEqual(maintenanceTimes, [.seconds(30), .seconds(90), .seconds(210)]) } - - func testFailuresBackOffAndSuccessResumesTenSecondChecks() { - var schedule = PaykitPaymentRequestPollingSchedule() - - for delay in [10, 30, 60, 120, 120] { - XCTAssertEqual(schedule.nextDelay, .seconds(delay)) - schedule.recordRefresh(succeeded: false) - } - - schedule.recordRefresh(succeeded: true) - XCTAssertEqual(schedule.nextDelay, .seconds(10)) - } } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index bd39c8aa2..5ddcac728 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1675,22 +1675,19 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { func testFailedRefreshKeepsPreviouslyLoadedRequests() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) - let initialRefreshSucceeded = await manager.refresh() - XCTAssertTrue(initialRefreshSucceeded) + await manager.refresh() await sdk.setRecords([]) await sdk.setReceiveError(.receive) - let failedRefreshSucceeded = await manager.refresh() - XCTAssertFalse(failedRefreshSucceeded) + await manager.refresh() XCTAssertEqual(manager.pendingRequests.count, 1) await sdk.setReceiveError(nil) - let recoveredRefreshSucceeded = await manager.refresh() - XCTAssertTrue(recoveredRefreshSucceeded) + await manager.refresh() XCTAssertTrue(manager.pendingRequests.isEmpty) } - func testPeerIntakeFailureReportsUnsuccessfulRefreshWithoutLosingRequests() async throws { + func testPeerIntakeFailureDoesNotDropReceivedRequests() async throws { let record = try paymentRequestRecord() let sdk = PaymentRequestSdkMock(records: [record]) await sdk.setReceiveReports([ @@ -1703,9 +1700,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ]) let manager = paymentRequestManager(sdk: sdk) - let succeeded = await manager.refresh() + await manager.refresh() - XCTAssertFalse(succeeded) XCTAssertEqual(manager.pendingRequests.count, 1) } diff --git a/changelog.d/next/769.changed.md b/changelog.d/next/769.changed.md index 660a1a0c1..d5b958d65 100644 --- a/changelog.d/next/769.changed.md +++ b/changelog.d/next/769.changed.md @@ -1 +1 @@ -Incoming payment requests are checked more frequently while the app is open, with slower retries when connectivity fails. +Incoming payment requests are checked every ten seconds while the app is open and online. From c303af9985cb6ae237004db27883c168dccda319 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 22 Sep 2026 10:42:00 +0100 Subject: [PATCH 4/4] test: cover offline Paykit polling rounds --- Bitkit/AppScene.swift | 29 +++++++++++++++---- ...itPaymentRequestPollingScheduleTests.swift | 19 +++++++++++- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 1ea61fb4a..631e01c24 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -151,18 +151,26 @@ enum IncomingPaykitPaymentRequestPresentationDispatcher { } } +enum PaykitPaymentRequestPollingRound: Equatable { + case skip + case refreshInbox + case refreshInboxAndMaintenance +} + struct PaykitPaymentRequestPollingSchedule { let nextDelay: Duration = .seconds(10) private static let maintenanceIntervals: [Duration] = [.seconds(30), .seconds(60), .seconds(120)] private var maintenanceIntervalIndex = 0 private var maintenanceDelay = Self.maintenanceIntervals[0] - mutating func takeMaintenanceIfDue() -> Bool { + mutating func takeRound(isConnected: Bool) -> PaykitPaymentRequestPollingRound { + guard isConnected else { return .skip } + maintenanceDelay -= nextDelay - guard maintenanceDelay <= .zero else { return false } + guard maintenanceDelay <= .zero else { return .refreshInbox } maintenanceIntervalIndex = min(maintenanceIntervalIndex + 1, Self.maintenanceIntervals.count - 1) maintenanceDelay = Self.maintenanceIntervals[maintenanceIntervalIndex] - return true + return .refreshInboxAndMaintenance } } @@ -1032,8 +1040,15 @@ struct AppScene: View { } catch { return } - let refreshMaintenance = schedule.takeMaintenanceIfDue() - guard network.isConnected else { continue } + let refreshMaintenance: Bool + switch schedule.takeRound(isConnected: network.isConnected) { + case .skip: + continue + case .refreshInbox: + refreshMaintenance = false + case .refreshInboxAndMaintenance: + refreshMaintenance = true + } if refreshMaintenance { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints( @@ -1358,7 +1373,9 @@ struct AppScene: View { // to display balances (MoneyText returns "0" if rates are nil) Task { await currency.refresh() - if scenePhase == .active { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) } + if scenePhase == .active { + await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) + } if PaykitFeatureFlags.isUIEnabled { let contactPublicKeys = contactsManager.contacts.map(\.publicKey) await PrivatePaykitService.shared.startInitialLinkBurst( diff --git a/BitkitTests/PaykitPaymentRequestPollingScheduleTests.swift b/BitkitTests/PaykitPaymentRequestPollingScheduleTests.swift index c3bf6eacc..b2fce1e76 100644 --- a/BitkitTests/PaykitPaymentRequestPollingScheduleTests.swift +++ b/BitkitTests/PaykitPaymentRequestPollingScheduleTests.swift @@ -10,9 +10,26 @@ final class PaykitPaymentRequestPollingScheduleTests: XCTestCase { for _ in 0 ..< 21 { XCTAssertEqual(schedule.nextDelay, .seconds(10)) elapsed += schedule.nextDelay - if schedule.takeMaintenanceIfDue() { maintenanceTimes.append(elapsed) } + switch schedule.takeRound(isConnected: true) { + case .skip: + XCTFail("Connected polling rounds should refresh the inbox") + case .refreshInbox: + break + case .refreshInboxAndMaintenance: + maintenanceTimes.append(elapsed) + } } XCTAssertEqual(maintenanceTimes, [.seconds(30), .seconds(90), .seconds(210)]) } + + func testOfflineRoundSkipsWorkWithoutAdvancingMaintenance() { + var schedule = PaykitPaymentRequestPollingSchedule() + + XCTAssertEqual(schedule.takeRound(isConnected: false), .skip) + XCTAssertEqual(schedule.nextDelay, .seconds(10)) + XCTAssertEqual(schedule.takeRound(isConnected: true), .refreshInbox) + XCTAssertEqual(schedule.takeRound(isConnected: true), .refreshInbox) + XCTAssertEqual(schedule.takeRound(isConnected: true), .refreshInboxAndMaintenance) + } }