From 682ee55498133856606fcf01c7450d889738467f Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 27 Oct 2026 14:28:02 +0000 Subject: [PATCH] Fence Duo APIs by UIKit SDK availability Keep the standard navigation and sheet paths compilable with older SDKs. Exercise native fallback behavior and include the status editor in the native control harness. Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Signed-off-by: kenny lopez --- mobile/ios/Runner/IosNavigationBar.swift | 9 +++++++++ mobile/ios/Runner/NativeProfileTextEditor.swift | 7 +++++++ mobile/ios/Runner/NativeStatusEditor.swift | 5 +++++ .../ios/RunnerTests/ProfileSetupRecoveryTests.swift | 12 ++++++++++-- mobile/ios/RunnerTests/RunnerTests.swift | 13 ++++++++++--- scripts/test-mobile-me-agent-accessibility.sh | 1 + 6 files changed, 42 insertions(+), 5 deletions(-) diff --git a/mobile/ios/Runner/IosNavigationBar.swift b/mobile/ios/Runner/IosNavigationBar.swift index 7a036785f0..7d7dc8294a 100644 --- a/mobile/ios/Runner/IosNavigationBar.swift +++ b/mobile/ios/Runner/IosNavigationBar.swift @@ -1,6 +1,9 @@ import Flutter import UIKit +// UIKit 9127.0.85 is the iOS 27.1 SDK with Duo APIs. SDK checks must +// surround runtime availability checks: older SDKs cannot resolve these symbols. + /// Each Flutter route owns a UIKit navigation controller. Its private scroll /// view mirrors the Flutter viewport so UIKit performs the large-title layout. final class IosNavigationBarFactory: NSObject, FlutterPlatformViewFactory { @@ -316,6 +319,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { result(nil) } configure(args as? [String: Any] ?? [:]) + #if canImport(UIKit, _version: 9127.0.85) if #available(iOS 27.1, *), standaloneControl { container.addInteraction(UIHingeInteraction { [weak self] _, _ in guard let self else { return } @@ -327,6 +331,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { DispatchQueue.main.asyncAfter(deadline: .now() + 1.2, execute: refresh) }) } + #endif } func view() -> UIView { container } @@ -421,6 +426,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { } private func reportRailGeometry() { + #if canImport(UIKit, _version: 9127.0.85) guard #available(iOS 27.1, *), let window = container.window else { return } let inset = window.safeAreaInsets let right = inset.right > 0 && inset.left == 0 @@ -458,6 +464,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { DispatchQueue.main.async { [weak self] in self?.channel.invokeMethod("railGeometry", arguments: values) } + #endif } private func reportAvatarBounds() { @@ -872,9 +879,11 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { stack.bottomAnchor.constraint(equalTo: surface.contentView.bottomAnchor) ]) let item = UIBarButtonItem(customView: surface) + #if canImport(UIKit, _version: 9127.0.85) if #available(iOS 27.1, *), actions.allSatisfy({ $0["keepInHeaderOnDuo"] as? Bool == true }) { item.axisBehavior = .horizontalOnly } + #endif if #available(iOS 26.0, *) { item.hidesSharedBackground = true } return item } diff --git a/mobile/ios/Runner/NativeProfileTextEditor.swift b/mobile/ios/Runner/NativeProfileTextEditor.swift index 4a8fd38da2..419118a033 100644 --- a/mobile/ios/Runner/NativeProfileTextEditor.swift +++ b/mobile/ios/Runner/NativeProfileTextEditor.swift @@ -1,6 +1,9 @@ import Flutter import UIKit +// UIKit 9127.0.85 is the iOS 27.1 SDK with Duo APIs. SDK checks must +// surround runtime availability checks: older SDKs cannot resolve these symbols. + final class NativeProfileTextEditorCoordinator: NSObject, UIAdaptivePresentationControllerDelegate { @@ -207,11 +210,13 @@ final class NativeProfileTextEditorCoordinator: NSObject, @MainActor private func configureDuoPlacement(_ navigation: UINavigationController, presenter: UIViewController) { + #if canImport(UIKit, _version: 9127.0.85) guard #available(iOS 27.1, *) else { return } let insets = presenter.view.safeAreaInsets guard insets.top == 0, insets.left > 0 || insets.right > 0 else { return } navigation.sheetPresentationController?.preferredPlacement = presenter.view.effectiveUserInterfaceLayoutDirection == .rightToLeft ? .leading : .trailing + #endif } @MainActor @@ -432,10 +437,12 @@ final class NativeProfileTextEditorViewController: navigationItem.rightBarButtonItem?.image = UIImage(systemName: "checkmark") navigationItem.rightBarButtonItem?.accessibilityLabel = "Create" } + #if canImport(UIKit, _version: 9127.0.85) if #available(iOS 27.1, *) { navigationItem.leftBarButtonItem?.axisBehavior = .verticalPreferred navigationItem.rightBarButtonItem?.axisBehavior = .verticalPreferred } + #endif updateNavigation() } diff --git a/mobile/ios/Runner/NativeStatusEditor.swift b/mobile/ios/Runner/NativeStatusEditor.swift index f1d1a71cf5..64b5793b83 100644 --- a/mobile/ios/Runner/NativeStatusEditor.swift +++ b/mobile/ios/Runner/NativeStatusEditor.swift @@ -1,5 +1,8 @@ import UIKit +// UIKit 9127.0.85 is the iOS 27.1 SDK with Duo APIs. SDK checks must +// surround runtime availability checks: older SDKs cannot resolve these symbols. + /// Status uses the same navigation-controller sheet as the profile text forms. final class NativeStatusEditorViewController: UITableViewController, UITextFieldDelegate { let statusField = UITextField() @@ -48,10 +51,12 @@ final class NativeStatusEditorViewController: UITableViewController, UITextField navigationItem.leftBarButtonItem?.accessibilityLabel = "Close" navigationItem.rightBarButtonItem = UIBarButtonItem( title: "Set", style: .done, target: self, action: #selector(save)) + #if canImport(UIKit, _version: 9127.0.85) if #available(iOS 27.1, *) { navigationItem.leftBarButtonItem?.axisBehavior = .verticalPreferred navigationItem.rightBarButtonItem?.axisBehavior = .verticalPreferred } + #endif statusField.text = configuration["text"] as? String ?? "" statusField.placeholder = "What’s your status?" statusField.accessibilityLabel = "Status" diff --git a/mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift b/mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift index 4e20ee397b..805f3aca81 100644 --- a/mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift +++ b/mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift @@ -7,7 +7,6 @@ import XCTest final class ProfileSetupRecoveryTests: XCTestCase { @MainActor func testNativeEditorsPreferRightPaneOnlyWithDuoRail() async throws { - guard #available(iOS 27.1, *) else { return } for duo in [false, true] { for method in ["presentStatus", "present"] { let messenger = ProfileSetupTestMessenger() @@ -26,7 +25,12 @@ final class ProfileSetupRecoveryTests: XCTestCase { DispatchQueue.main.async { continuation.resume() } } let sheet = try XCTUnwrap(parent.captured?.sheetPresentationController) - XCTAssertEqual(sheet.preferredPlacement, duo ? .trailing : .automatic) + #if canImport(UIKit, _version: 9127.0.85) + if #available(iOS 27.1, *) { + XCTAssertEqual(sheet.preferredPlacement, duo ? .trailing : .automatic) + } + #endif + XCTAssertEqual(sheet.detents.map(\.identifier), [.large]) XCTAssertNil(sheet.sourceView) } } @@ -56,10 +60,12 @@ final class ProfileSetupRecoveryTests: XCTestCase { let submit = try XCTUnwrap(editor.navigationItem.rightBarButtonItem) XCTAssertEqual(cancel.accessibilityLabel, "Close") XCTAssertFalse(submit.isEnabled) + #if canImport(UIKit, _version: 9127.0.85) if #available(iOS 27.1, *) { XCTAssertEqual(cancel.axisBehavior, .verticalPreferred) XCTAssertEqual(submit.axisBehavior, .verticalPreferred) } + #endif XCTAssertEqual(editor.tableView(editor.tableView, numberOfRowsInSection: 1), 2) editor.tableView(editor.tableView, didSelectRowAt: IndexPath(row: 0, section: 2)) XCTAssertTrue(submit.isEnabled) @@ -94,10 +100,12 @@ final class ProfileSetupRecoveryTests: XCTestCase { editor.loadViewIfNeeded() let cancel = try XCTUnwrap(editor.navigationItem.leftBarButtonItem) let submit = try XCTUnwrap(editor.navigationItem.rightBarButtonItem) + #if canImport(UIKit, _version: 9127.0.85) if #available(iOS 27.1, *) { XCTAssertEqual(cancel.axisBehavior, .verticalPreferred) XCTAssertEqual(submit.axisBehavior, .verticalPreferred) } + #endif XCTAssertTrue(cancel.isEnabled) XCTAssertFalse(submit.isEnabled) UIApplication.shared.sendAction(try XCTUnwrap(cancel.action), to: cancel.target, from: cancel, for: nil) diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index aafb7073a0..0f314ea8c2 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -659,7 +659,6 @@ class RunnerTests: XCTestCase { @MainActor func testStandaloneRailObservesBookPosture() throws { - guard #available(iOS 27.1, *) else { return } for standalone in [false, true] { let parent = UIViewController() let bar = IosNavigationBarFactory( @@ -667,8 +666,16 @@ class RunnerTests: XCTestCase { ).create(withFrame: CGRect(x: 0, y: 0, width: 48, height: 48), viewIdentifier: 999989, arguments: ["standaloneControl": standalone]) - XCTAssertEqual( - bar.view().interactions.contains { $0 is UIHingeInteraction }, standalone) + #if canImport(UIKit, _version: 9127.0.85) + if #available(iOS 27.1, *) { + XCTAssertEqual( + bar.view().interactions.contains { $0 is UIHingeInteraction }, standalone) + } else { + XCTAssertTrue(bar.view().interactions.isEmpty) + } + #else + XCTAssertTrue(bar.view().interactions.isEmpty) + #endif } } diff --git a/scripts/test-mobile-me-agent-accessibility.sh b/scripts/test-mobile-me-agent-accessibility.sh index 6fd75eb013..49dfa0ec22 100755 --- a/scripts/test-mobile-me-agent-accessibility.sh +++ b/scripts/test-mobile-me-agent-accessibility.sh @@ -9,6 +9,7 @@ fixture=$(mktemp -d) trap 'rm -r "$fixture"' EXIT # Compile in one file to exercise the private production controller directly. cat "$repo_root/mobile/ios/Runner/MeAvatarLoader.swift" \ + "$repo_root/mobile/ios/Runner/NativeStatusEditor.swift" \ "$repo_root/mobile/ios/Runner/NativeProfileTextEditor.swift" \ "$repo_root/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift" > "$fixture/test.swift" xcrun --sdk iphonesimulator swiftc -parse-as-library \