diff --git a/mobile/ios/Runner/NativeCommunityEntry.swift b/mobile/ios/Runner/NativeCommunityEntry.swift index ea2abc2f50..a3fc6d2ad9 100644 --- a/mobile/ios/Runner/NativeCommunityEntry.swift +++ b/mobile/ios/Runner/NativeCommunityEntry.swift @@ -24,10 +24,9 @@ final class NativeCommunityEntryCoordinator: NSObject, UIAdaptivePresentationCon let root = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } .filter { $0.activationState == .foregroundActive }.flatMap(\.windows) .first(where: \.isKeyWindow)?.rootViewController - guard var presenter = root else { + guard let presenter = root, presenter.presentedViewController == nil else { result(FlutterError(code: "presentation_failed", message: nil, details: nil)); return } - while let presented = presenter.presentedViewController { presenter = presented } let controller = NativeCommunityEntryController(configuration: args) controller.action = { [weak self] method, payload, completion in guard let self, self.session == session else { return } @@ -47,6 +46,10 @@ final class NativeCommunityEntryCoordinator: NSObject, UIAdaptivePresentationCon self.navigation = navigation controller.configureDetents() presenter.present(navigation, animated: !UIAccessibility.isReduceMotionEnabled) + guard presenter.presentedViewController === navigation else { + self.complete(FlutterError(code: "presentation_failed", message: nil, details: nil)) + return + } navigation.presentationController?.delegate = self } } @@ -71,12 +74,12 @@ final class NativeCommunityEntryCoordinator: NSObject, UIAdaptivePresentationCon } } - private func complete(_ joined: Bool) { + private func complete(_ value: Any?) { let result = pending pending = nil navigation = nil session = nil - result?(joined) + result?(value) } } @@ -236,6 +239,7 @@ private final class NativeCommunityEntryController: UIViewController, UITextFiel submit = addButton("Join community", primary: true) { [weak self] in self?.request("join", [:]) } + submit?.accessibilityIdentifier = "community-invite-join" } else { for choice in choices { addButton("\(choice["label"] ?? "Saved identity")\n\(choice["npub"] ?? "")") { [weak self] in diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 846c0d1637..4a85e5b162 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -8,6 +8,39 @@ import XCTest class RunnerTests: XCTestCase { + @MainActor + func testRejectedCommunityEntryPresentationCompletesAndAllowsRetry() throws { + let messenger = NavigationTestMessenger() + let presenter = RejectedCommunityEntryPresenter() + let window = UIWindow(frame: UIScreen.main.bounds) + window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + window.rootViewController = presenter + window.makeKeyAndVisible() + defer { window.isHidden = true } + let coordinator = NativeCommunityEntryCoordinator(messenger: messenger) + for session in [1, 2] { + var reply: Any? + messenger.invoke("present", arguments: ["session": session, "name": "Local test", "invite": true]) { reply = $0 } + XCTAssertEqual((reply as? FlutterError)?.code, "presentation_failed") + XCTAssertEqual(presenter.attempts, session) + } + let root = UIViewController() + window.rootViewController = root + window.makeKeyAndVisible() + let temporary = UIViewController() + let presented = expectation(description: "Temporary native modal appeared") + root.present(temporary, animated: false) { presented.fulfill() } + wait(for: [presented], timeout: 2) + XCTAssertTrue(root.presentedViewController === temporary) + XCTAssertNotNil(temporary.view.window) + var reply: Any? + messenger.invoke("present", arguments: ["session": 3, "name": "Local test", "invite": true]) { reply = $0 } + XCTAssertEqual((reply as? FlutterError)?.code, "presentation_failed") + XCTAssertNil(temporary.presentedViewController) + root.dismiss(animated: false) + withExtendedLifetime(coordinator) {} + } + @MainActor func testCustomFlutterHeaderUsesDecorativeSoftEdge() throws { guard #available(iOS 27.0, *) else { return } @@ -2085,3 +2118,11 @@ private final class NavigationTestMessenger: NSObject, FlutterBinaryMessenger { handler?(message) { _ in } } } + +private final class RejectedCommunityEntryPresenter: UIViewController { + var attempts = 0 + override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { + // Reproduce UIKit declining a presentation during a scene transition. + attempts += 1 + } +}