diff --git a/mobile/ios/Runner/PushNativeState.swift b/mobile/ios/Runner/PushNativeState.swift index 6cb7609cb8..13a18961d3 100644 --- a/mobile/ios/Runner/PushNativeState.swift +++ b/mobile/ios/Runner/PushNativeState.swift @@ -16,7 +16,14 @@ final class BuzzOneShotCompletion { let completion = completion self.completion = nil lock.unlock() - completion?() + guard let completion else { return } + if Thread.isMainThread { + completion() + } else { + // Reply publication finishes on a URLSession callback queue, while the + // notification response completion crosses back into UIKit. + DispatchQueue.main.async(execute: completion) + } } } diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift index 624b50e64b..9b2d28d5a3 100644 --- a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -217,6 +217,24 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase { } final class BuzzPushNotificationResponseTests: XCTestCase { + func testBackgroundCompletionReturnsToMainThreadExactlyOnce() { + let completed = expectation(description: "completion returned on main thread") + var completionCount = 0 + let completion = BuzzOneShotCompletion { + XCTAssertTrue(Thread.isMainThread) + completionCount += 1 + completed.fulfill() + } + + DispatchQueue.global().async { + completion.call() + completion.call() + } + + wait(for: [completed], timeout: 1) + XCTAssertEqual(completionCount, 1) + } + func testValidReminderActionRoutesContextWithoutOpeningFlutter() { let context = BuzzPushReplyContext( eventID: "message-id",