From 9ef255c4a1642c3f0f3fdb8c59b9aed2bc839026 Mon Sep 17 00:00:00 2001 From: Stuart Morgan Date: Tue, 2 Dec 2025 10:13:17 -0500 Subject: [PATCH 1/2] [url_launcher] Update for UIScene compatibility Replaces the code that attempted to find the topmost presented view controller as the context for presenting a Safari view controller with a call to the new registrar API to get the Flutter view's controller. Since it's now possible for there not to be a view controller, adds an exception for that case. This should only be possible in an add-to-app scenario where someone tries to show an in-app URL from a Flutter view that is not being displayed, which is an unlikely enough scenario that I'm not going to consider this a breaking change. Fixes https://github.com/flutter/flutter/issues/174415 --- .../url_launcher_ios/CHANGELOG.md | 5 ++ .../ios/RunnerTests/URLLauncherTests.swift | 49 ++++++++++++++++--- .../url_launcher_ios/example/pubspec.yaml | 4 +- .../url_launcher_ios/URLLauncherPlugin.swift | 48 +++++------------- .../url_launcher_ios/ViewPresenter.swift | 44 +++++++++++++++++ .../Sources/url_launcher_ios/messages.g.swift | 4 +- .../url_launcher_ios/lib/src/messages.g.dart | 3 ++ .../lib/url_launcher_ios.dart | 14 ++++++ .../url_launcher_ios/pigeons/messages.dart | 3 ++ .../url_launcher_ios/pubspec.yaml | 6 +-- .../test/url_launcher_ios_test.dart | 20 ++++++++ script/configs/exclude_xcode_deprecation.yaml | 4 -- 12 files changed, 151 insertions(+), 53 deletions(-) create mode 100644 packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/ViewPresenter.swift diff --git a/packages/url_launcher/url_launcher_ios/CHANGELOG.md b/packages/url_launcher/url_launcher_ios/CHANGELOG.md index fd7dff9504e..bf1fef3950b 100644 --- a/packages/url_launcher/url_launcher_ios/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_ios/CHANGELOG.md @@ -1,3 +1,8 @@ +## 6.4.0 + +* Improves compatibility with `UIScene`. +* Updates minimum supported SDK version to Flutter 3.38/Dart 3.10. + ## 6.3.6 * Updates to Pigeon 26. diff --git a/packages/url_launcher/url_launcher_ios/example/ios/RunnerTests/URLLauncherTests.swift b/packages/url_launcher/url_launcher_ios/example/ios/RunnerTests/URLLauncherTests.swift index 392eb8ef409..30cc5903559 100644 --- a/packages/url_launcher/url_launcher_ios/example/ios/RunnerTests/URLLauncherTests.swift +++ b/packages/url_launcher/url_launcher_ios/example/ios/RunnerTests/URLLauncherTests.swift @@ -13,15 +13,32 @@ private func urlParsingIsStrict() -> Bool { return URL(string: "b a d U R L") == nil } -final class URLLauncherTests: XCTestCase { +final class TestViewPresenter: ViewPresenter { + public var presentedController: UIViewController? - private func createPlugin() -> URLLauncherPlugin { - let launcher = FakeLauncher() - return URLLauncherPlugin(launcher: launcher) + func present( + _ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)? = nil + ) { + presentedController = viewControllerToPresent + } +} + +final class StubViewPresenterProvider: ViewPresenterProvider { + var viewPresenter: ViewPresenter? + + init(viewPresenter: ViewPresenter?) { + self.viewPresenter = viewPresenter } +} + +final class URLLauncherTests: XCTestCase { - private func createPlugin(launcher: FakeLauncher) -> URLLauncherPlugin { - return URLLauncherPlugin(launcher: launcher) + private func createPlugin( + launcher: FakeLauncher = FakeLauncher(), viewPresenter: ViewPresenter? = TestViewPresenter() + ) -> URLLauncherPlugin { + return URLLauncherPlugin( + launcher: launcher, + viewPresenterProvider: StubViewPresenterProvider(viewPresenter: viewPresenter)) } func testCanLaunchSuccess() { @@ -133,7 +150,8 @@ final class URLLauncherTests: XCTestCase { func testLaunchSafariViewControllerWithClose() { let launcher = FakeLauncher() - let plugin = createPlugin(launcher: launcher) + let viewPresenter = TestViewPresenter() + let plugin = createPlugin(launcher: launcher, viewPresenter: viewPresenter) let expectation = XCTestExpectation(description: "completion called") plugin.openUrlInSafariViewController(url: "https://flutter.dev") { result in @@ -147,6 +165,23 @@ final class URLLauncherTests: XCTestCase { } plugin.closeSafariViewController() wait(for: [expectation], timeout: 30) + XCTAssertNotNil(viewPresenter.presentedController) + } + + func testLaunchSafariViewControllerFailureWithNoViewPresenter() { + let expectation = XCTestExpectation(description: "completion called") + createPlugin(viewPresenter: nil).openUrlInSafariViewController(url: "https://flutter.dev") { + result in + switch result { + case .success(let details): + XCTAssertEqual(details, .noUI) + case .failure(let error): + XCTFail("Unexpected error: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) } } diff --git a/packages/url_launcher/url_launcher_ios/example/pubspec.yaml b/packages/url_launcher/url_launcher_ios/example/pubspec.yaml index 46fcb2ebcce..ac9e3e05afc 100644 --- a/packages/url_launcher/url_launcher_ios/example/pubspec.yaml +++ b/packages/url_launcher/url_launcher_ios/example/pubspec.yaml @@ -3,8 +3,8 @@ description: Demonstrates how to use the url_launcher plugin. publish_to: none environment: - sdk: ^3.9.0 - flutter: ">=3.35.0" + sdk: ^3.10.0 + flutter: ">=3.38.0" dependencies: flutter: diff --git a/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/URLLauncherPlugin.swift b/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/URLLauncherPlugin.swift index 2ab4ad804ad..7d37cea34c8 100644 --- a/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/URLLauncherPlugin.swift +++ b/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/URLLauncherPlugin.swift @@ -8,22 +8,20 @@ import UIKit public final class URLLauncherPlugin: NSObject, FlutterPlugin, UrlLauncherApi { public static func register(with registrar: FlutterPluginRegistrar) { - let plugin = URLLauncherPlugin() + let plugin = URLLauncherPlugin( + viewPresenterProvider: DefaultViewPresenterProvider(registrar: registrar)) UrlLauncherApiSetup.setUp(binaryMessenger: registrar.messenger(), api: plugin) registrar.publish(plugin) } private var currentSession: URLLaunchSession? private let launcher: Launcher + /// The view controller provider, for showing a Safari view controller. + private let viewPresenterProvider: ViewPresenterProvider - private var topViewController: UIViewController? { - // TODO(stuartmorgan) Provide a non-deprecated codepath. See - // https://github.com/flutter/flutter/issues/104117 - UIApplication.shared.keyWindow?.rootViewController?.topViewController - } - - init(launcher: Launcher = DefaultLauncher()) { + init(launcher: Launcher = DefaultLauncher(), viewPresenterProvider: ViewPresenterProvider) { self.launcher = launcher + self.viewPresenterProvider = viewPresenterProvider } func canLaunchUrl(url: String) -> LaunchResult { @@ -58,43 +56,21 @@ public final class URLLauncherPlugin: NSObject, FlutterPlugin, UrlLauncherApi { return } + guard let presenter = viewPresenterProvider.viewPresenter else { + completion(.success(.noUI)) + return + } + let session = URLLaunchSession(url: url, completion: completion) currentSession = session session.didFinish = { [weak self] in self?.currentSession = nil } - topViewController?.present(session.safariViewController, animated: true, completion: nil) + presenter.present(session.safariViewController, animated: true, completion: nil) } func closeSafariViewController() { currentSession?.close() } } - -/// This method recursively iterates through the view hierarchy -/// to return the top-most view controller. -/// -/// It supports the following scenarios: -/// -/// - The view controller is presenting another view. -/// - The view controller is a UINavigationController. -/// - The view controller is a UITabBarController. -/// -/// @return The top most view controller. -extension UIViewController { - var topViewController: UIViewController { - if let navigationController = self as? UINavigationController { - return navigationController.viewControllers.last?.topViewController - ?? navigationController - .visibleViewController ?? navigationController - } - if let tabBarController = self as? UITabBarController { - return tabBarController.selectedViewController?.topViewController ?? tabBarController - } - if let presented = presentedViewController { - return presented.topViewController - } - return self - } -} diff --git a/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/ViewPresenter.swift b/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/ViewPresenter.swift new file mode 100644 index 00000000000..a0c6a5230f4 --- /dev/null +++ b/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/ViewPresenter.swift @@ -0,0 +1,44 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import Flutter +import UIKit + +/// Protocol for UIViewController methods relating to presenting a controller. +/// +/// This protocol exists to allow injecting an alternate implementation for testing. +protocol ViewPresenter { + /// Presents a view controller modally. + func present( + _ viewControllerToPresent: UIViewController, + animated flag: Bool, + completion: (() -> Void)? + ) +} + +/// ViewPresenter is intentionally a direct passthroguh to UIViewController. +extension UIViewController: ViewPresenter {} + +/// Protocol for FlutterPluginRegistrar method for accessing the view controller. +/// +/// This is necessary because Swift doesn't allow for only partially implementing a protocol, so +/// a stub implementation of FlutterPluginRegistrar for tests would break any time something was +/// added to that protocol. +protocol ViewPresenterProvider { + /// Returns the view controller associated with the Flutter content. + var viewPresenter: ViewPresenter? { get } +} + +/// Non-test implementation of ViewPresenterProvider that forwards to the plugin registrar. +final class DefaultViewPresenterProvider: ViewPresenterProvider { + private let registrar: FlutterPluginRegistrar + + init(registrar: FlutterPluginRegistrar) { + self.registrar = registrar + } + + var viewPresenter: ViewPresenter? { + registrar.viewController + } +} diff --git a/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/messages.g.swift b/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/messages.g.swift index eac7d5ed4dc..0c55d15af8b 100644 --- a/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/messages.g.swift +++ b/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/messages.g.swift @@ -85,8 +85,10 @@ enum InAppLoadResult: Int { case failedToLoad = 1 /// The URL could not be launched because it is invalid. case invalidUrl = 2 + /// The URL could not be launched because no UI is available. + case noUI = 3 /// The controller was closed before loading. - case dismissed = 3 + case dismissed = 4 } private class MessagesPigeonCodecReader: FlutterStandardReader { diff --git a/packages/url_launcher/url_launcher_ios/lib/src/messages.g.dart b/packages/url_launcher/url_launcher_ios/lib/src/messages.g.dart index 995de93e56e..f668ea34c31 100644 --- a/packages/url_launcher/url_launcher_ios/lib/src/messages.g.dart +++ b/packages/url_launcher/url_launcher_ios/lib/src/messages.g.dart @@ -41,6 +41,9 @@ enum InAppLoadResult { /// The URL could not be launched because it is invalid. invalidUrl, + /// The URL could not be launched because no UI is available. + noUI, + /// The controller was closed before loading. dismissed, } diff --git a/packages/url_launcher/url_launcher_ios/lib/url_launcher_ios.dart b/packages/url_launcher/url_launcher_ios/lib/url_launcher_ios.dart index ab42d7b8ac2..db202b398b5 100644 --- a/packages/url_launcher/url_launcher_ios/lib/url_launcher_ios.dart +++ b/packages/url_launcher/url_launcher_ios/lib/url_launcher_ios.dart @@ -148,6 +148,8 @@ class UrlLauncherIOS extends UrlLauncherPlatform { throw _failedSafariViewControllerLoadException(url); case InAppLoadResult.invalidUrl: throw _invalidUrlException(); + case InAppLoadResult.noUI: + throw _noUIException(); case InAppLoadResult.dismissed: return false; } @@ -178,4 +180,16 @@ class UrlLauncherIOS extends UrlLauncherPlatform { message: 'Error while launching $url', ); } + + // TODO(stuartmorgan): Remove this as part of standardizing error handling. + // See https://github.com/flutter/flutter/issues/127665 + // + // This PlatformException is designed to match the pattern of the pre-existing + // exceptions above. + PlatformException _noUIException() { + throw PlatformException( + code: 'no_ui_available', + message: 'No view controller available', + ); + } } diff --git a/packages/url_launcher/url_launcher_ios/pigeons/messages.dart b/packages/url_launcher/url_launcher_ios/pigeons/messages.dart index e5665b33e9e..6f4eecd1f2c 100644 --- a/packages/url_launcher/url_launcher_ios/pigeons/messages.dart +++ b/packages/url_launcher/url_launcher_ios/pigeons/messages.dart @@ -34,6 +34,9 @@ enum InAppLoadResult { /// The URL could not be launched because it is invalid. invalidUrl, + /// The URL could not be launched because no UI is available. + noUI, + /// The controller was closed before loading. dismissed, } diff --git a/packages/url_launcher/url_launcher_ios/pubspec.yaml b/packages/url_launcher/url_launcher_ios/pubspec.yaml index d2db4081994..924494b8095 100644 --- a/packages/url_launcher/url_launcher_ios/pubspec.yaml +++ b/packages/url_launcher/url_launcher_ios/pubspec.yaml @@ -2,11 +2,11 @@ name: url_launcher_ios description: iOS implementation of the url_launcher plugin. repository: https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 6.3.6 +version: 6.4.0 environment: - sdk: ^3.9.0 - flutter: ">=3.35.0" + sdk: ^3.10.0 + flutter: ">=3.38.0" flutter: plugin: diff --git a/packages/url_launcher/url_launcher_ios/test/url_launcher_ios_test.dart b/packages/url_launcher/url_launcher_ios/test/url_launcher_ios_test.dart index 37cb6f32222..a5669f9ff9e 100644 --- a/packages/url_launcher/url_launcher_ios/test/url_launcher_ios_test.dart +++ b/packages/url_launcher/url_launcher_ios/test/url_launcher_ios_test.dart @@ -278,6 +278,26 @@ void main() { ); }); + test('throws PlatformException for missing view controller', () async { + when( + api.openUrlInSafariViewController(_webUrl), + ).thenAnswer((_) async => InAppLoadResult.noUI); + final launcher = UrlLauncherIOS(api: api); + await expectLater( + launcher.launchUrl( + _webUrl, + const LaunchOptions(mode: PreferredLaunchMode.inAppWebView), + ), + throwsA( + isA().having( + (PlatformException e) => e.code, + 'code', + 'no_ui_available', + ), + ), + ); + }); + test('throws PlatformException for load failure', () async { when( api.openUrlInSafariViewController(_webUrl), diff --git a/script/configs/exclude_xcode_deprecation.yaml b/script/configs/exclude_xcode_deprecation.yaml index 27eeccd5b80..416839c8e38 100644 --- a/script/configs/exclude_xcode_deprecation.yaml +++ b/script/configs/exclude_xcode_deprecation.yaml @@ -1,6 +1,2 @@ # TODO(louisehsu): Remove deprecation check when StoreKit 2 is adopted. https://github.com/flutter/flutter/issues/116383 - in_app_purchase_storekit -# TODO(stuartmorgan): Remove once there's a non-deprecated codepath for getting -# the view controller available on stable. -# See https://github.com/flutter/flutter/issues/174415 -- url_launcher_ios From b01db78c115b1421ffee49374b22f15c4ec6a671 Mon Sep 17 00:00:00 2001 From: Stuart Morgan Date: Wed, 3 Dec 2025 09:37:21 -0500 Subject: [PATCH 2/2] Adjust comments --- .../Sources/url_launcher_ios/URLLauncherPlugin.swift | 2 +- .../Sources/url_launcher_ios/ViewPresenter.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/URLLauncherPlugin.swift b/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/URLLauncherPlugin.swift index 7d37cea34c8..f683a1273a3 100644 --- a/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/URLLauncherPlugin.swift +++ b/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/URLLauncherPlugin.swift @@ -16,7 +16,7 @@ public final class URLLauncherPlugin: NSObject, FlutterPlugin, UrlLauncherApi { private var currentSession: URLLaunchSession? private let launcher: Launcher - /// The view controller provider, for showing a Safari view controller. + /// The view presenter provider, for showing a Safari view controller. private let viewPresenterProvider: ViewPresenterProvider init(launcher: Launcher = DefaultLauncher(), viewPresenterProvider: ViewPresenterProvider) { diff --git a/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/ViewPresenter.swift b/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/ViewPresenter.swift index a0c6a5230f4..63758561eec 100644 --- a/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/ViewPresenter.swift +++ b/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios/Sources/url_launcher_ios/ViewPresenter.swift @@ -26,7 +26,7 @@ extension UIViewController: ViewPresenter {} /// a stub implementation of FlutterPluginRegistrar for tests would break any time something was /// added to that protocol. protocol ViewPresenterProvider { - /// Returns the view controller associated with the Flutter content. + /// Returns the view presenter associated with the Flutter content. var viewPresenter: ViewPresenter? { get } }