From 3aff0290024d4c457f29935f2ff7510a52340fba Mon Sep 17 00:00:00 2001 From: chrisdlangham Date: Thu, 26 Oct 2023 13:30:06 -0500 Subject: [PATCH] [url_launcher] migrating objc plugin to swift (#4753) This PR converts the iOS portion of the url_launcher plugin from objc to swift. *List which issues are fixed by this PR. You must list at least one issue.* https://github.com/flutter/flutter/issues/119102 --- .../url_launcher_ios/CHANGELOG.md | 4 + .../ios/Runner.xcodeproj/project.pbxproj | 4 +- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- .../ios/RunnerTests/URLLauncherTests.swift | 130 ++++++----- .../ios/Classes/FLTURLLauncherPlugin.h | 10 - .../ios/Classes/FLTURLLauncherPlugin.m | 202 ---------------- .../ios/Classes/FLTURLLauncherPlugin_Test.h | 11 - .../ios/Classes/FULLauncher.h | 19 -- .../ios/Classes/Launcher.swift | 20 ++ .../ios/Classes/URLLaunchSession.swift | 63 +++++ .../ios/Classes/URLLauncherPlugin.swift | 99 ++++++++ .../url_launcher_ios/ios/Classes/messages.g.h | 40 ---- .../url_launcher_ios/ios/Classes/messages.g.m | 126 ---------- .../ios/Classes/messages.g.swift | 163 +++++++++++++ .../ios/url_launcher_ios.podspec | 2 +- .../url_launcher_ios/lib/src/messages.g.dart | 57 +++-- .../lib/url_launcher_ios.dart | 62 ++++- .../url_launcher_ios/pigeons/messages.dart | 43 +++- .../url_launcher_ios/pubspec.yaml | 8 +- .../test/url_launcher_ios_test.dart | 215 ++++++++++-------- .../test/url_launcher_ios_test.mocks.dart | 79 +++++++ 21 files changed, 751 insertions(+), 608 deletions(-) delete mode 100644 packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin.h delete mode 100644 packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin.m delete mode 100644 packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin_Test.h delete mode 100644 packages/url_launcher/url_launcher_ios/ios/Classes/FULLauncher.h create mode 100644 packages/url_launcher/url_launcher_ios/ios/Classes/Launcher.swift create mode 100644 packages/url_launcher/url_launcher_ios/ios/Classes/URLLaunchSession.swift create mode 100644 packages/url_launcher/url_launcher_ios/ios/Classes/URLLauncherPlugin.swift delete mode 100644 packages/url_launcher/url_launcher_ios/ios/Classes/messages.g.h delete mode 100644 packages/url_launcher/url_launcher_ios/ios/Classes/messages.g.m create mode 100644 packages/url_launcher/url_launcher_ios/ios/Classes/messages.g.swift create mode 100644 packages/url_launcher/url_launcher_ios/test/url_launcher_ios_test.mocks.dart diff --git a/packages/url_launcher/url_launcher_ios/CHANGELOG.md b/packages/url_launcher/url_launcher_ios/CHANGELOG.md index ae630129705d..4e9d077e55d4 100644 --- a/packages/url_launcher/url_launcher_ios/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_ios/CHANGELOG.md @@ -1,3 +1,7 @@ +## 6.2.1 + +* Migrates plugin from Objective-C to Swift. + ## 6.2.0 * Implements `supportsMode` and `supportsCloseForMode`. diff --git a/packages/url_launcher/url_launcher_ios/example/ios/Runner.xcodeproj/project.pbxproj b/packages/url_launcher/url_launcher_ios/example/ios/Runner.xcodeproj/project.pbxproj index e40cd34be9d7..c10bff136dc4 100644 --- a/packages/url_launcher/url_launcher_ios/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/url_launcher/url_launcher_ios/example/ios/Runner.xcodeproj/project.pbxproj @@ -269,7 +269,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1430; ORGANIZATIONNAME = "The Flutter Authors"; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -631,6 +631,7 @@ baseConfigurationReference = 666BCD7C181C34F8BE58929B /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -651,6 +652,7 @@ baseConfigurationReference = D25C434271ACF6555E002440 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/packages/url_launcher/url_launcher_ios/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/url_launcher/url_launcher_ios/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index ad0ebfab1b88..fa4e0bbd319c 100644 --- a/packages/url_launcher/url_launcher_ios/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/url_launcher/url_launcher_ios/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ Bool { + return URL(string: "b a d U R L") == nil +} + final class URLLauncherTests: XCTestCase { - private func createPlugin() -> FLTURLLauncherPlugin { + private func createPlugin() -> URLLauncherPlugin { let launcher = FakeLauncher() - return FLTURLLauncherPlugin(launcher: launcher) + return URLLauncherPlugin(launcher: launcher) } - private func createPlugin(launcher: FakeLauncher) -> FLTURLLauncherPlugin { - FLTURLLauncherPlugin(launcher: launcher) + private func createPlugin(launcher: FakeLauncher) -> URLLauncherPlugin { + return URLLauncherPlugin(launcher: launcher) } func testCanLaunchSuccess() { - var error: FlutterError? - let result = createPlugin().canLaunchURL("good://url", error: &error) - - XCTAssertNotNil(result) - XCTAssertTrue(result?.boolValue ?? false) - XCTAssertNil(error) + let result = createPlugin().canLaunchUrl(url: "good://url") + XCTAssertEqual(result, .success) } func testCanLaunchFailure() { - var error: FlutterError? - let result = createPlugin().canLaunchURL("bad://url", error: &error) - - XCTAssertNotNil(result) - XCTAssertFalse(result?.boolValue ?? true) + let result = createPlugin().canLaunchUrl(url: "bad://url") + XCTAssertEqual(result, .failure) } func testCanLaunchFailureWithInvalidURL() { - var error: FlutterError? - let result = createPlugin().canLaunchURL("urls can't have spaces", error: &error) - - if (error == nil) { - // When linking against the iOS 17 SDK or later, NSURL uses a lenient parser, and won't - // fail to parse URLs, so the test must allow for either outcome. - XCTAssertNotNil(result) - XCTAssertFalse(result?.boolValue ?? true) - XCTAssertNil(error) + let result = createPlugin().canLaunchUrl(url: "urls can't have spaces") + + if urlParsingIsStrict() { + XCTAssertEqual(result, .invalidUrl) } else { - XCTAssertNil(result) - XCTAssertNotNil(error) - XCTAssertEqual(error?.code, "argument_error") - XCTAssertEqual(error?.message, "Unable to parse URL") - XCTAssertEqual(error?.details as? String, "Provided URL: urls can't have spaces") + XCTAssertEqual(result, .failure) } } func testLaunchSuccess() { let expectation = XCTestExpectation(description: "completion called") - createPlugin().launchURL("good://url", universalLinksOnly: false) { result, error in - XCTAssertNotNil(result) - XCTAssertTrue(result?.boolValue ?? false) - XCTAssertNil(error) + createPlugin().launchUrl(url: "good://url", universalLinksOnly: false) { result in + switch result { + case .success(let details): + XCTAssertEqual(details, .success) + case .failure(let error): + XCTFail("Unexpected error: \(error)") + } expectation.fulfill() } @@ -68,11 +61,13 @@ final class URLLauncherTests: XCTestCase { func testLaunchFailure() { let expectation = XCTestExpectation(description: "completion called") - - createPlugin().launchURL("bad://url", universalLinksOnly: false) { result, error in - XCTAssertNotNil(result) - XCTAssertFalse(result?.boolValue ?? true) - XCTAssertNil(error) + createPlugin().launchUrl(url: "bad://url", universalLinksOnly: false) { result in + switch result { + case .success(let details): + XCTAssertEqual(details, .failure) + case .failure(let error): + XCTFail("Unexpected error: \(error)") + } expectation.fulfill() } @@ -81,22 +76,17 @@ final class URLLauncherTests: XCTestCase { func testLaunchFailureWithInvalidURL() { let expectation = XCTestExpectation(description: "completion called") - - createPlugin().launchURL("urls can't have spaces", universalLinksOnly: false) { result, error in - if (error == nil) { - // When linking against the iOS 17 SDK or later, NSURL uses a lenient parser, and won't - // fail to parse URLs, so the test must allow for either outcome. - XCTAssertNotNil(result) - XCTAssertFalse(result?.boolValue ?? true) - XCTAssertNil(error) - } else { - XCTAssertNil(result) - XCTAssertNotNil(error) - XCTAssertEqual(error?.code, "argument_error") - XCTAssertEqual(error?.message, "Unable to parse URL") - XCTAssertEqual(error?.details as? String, "Provided URL: urls can't have spaces") + createPlugin().launchUrl(url: "urls can't have spaces", universalLinksOnly: false) { result in + switch result { + case .success(let details): + if urlParsingIsStrict() { + XCTAssertEqual(details, .invalidUrl) + } else { + XCTAssertEqual(details, .failure) + } + case .failure(let error): + XCTFail("Unexpected error: \(error)") } - expectation.fulfill() } @@ -108,13 +98,17 @@ final class URLLauncherTests: XCTestCase { let plugin = createPlugin(launcher: launcher) let expectation = XCTestExpectation(description: "completion called") - plugin.launchURL("good://url", universalLinksOnly: false) { result, error in - XCTAssertNil(error) + plugin.launchUrl(url: "good://url", universalLinksOnly: false) { result in + switch result { + case .success(let details): + XCTAssertEqual(details, .success) + case .failure(let error): + XCTFail("Unexpected error: \(error)") + } expectation.fulfill() } wait(for: [expectation], timeout: 1) - XCTAssertEqual(launcher.passedOptions?[.universalLinksOnly] as? Bool, false) } @@ -123,31 +117,35 @@ final class URLLauncherTests: XCTestCase { let plugin = createPlugin(launcher: launcher) let expectation = XCTestExpectation(description: "completion called") - - plugin.launchURL("good://url", universalLinksOnly: true) { result, error in - XCTAssertNil(error) + plugin.launchUrl(url: "good://url", universalLinksOnly: true) { result in + switch result { + case .success(let details): + XCTAssertEqual(details, .success) + case .failure(let error): + XCTFail("Unexpected error: \(error)") + } expectation.fulfill() } wait(for: [expectation], timeout: 1) - XCTAssertEqual(launcher.passedOptions?[.universalLinksOnly] as? Bool, true) } } -final private class FakeLauncher: NSObject, FULLauncher { +final private class FakeLauncher: NSObject, Launcher { var passedOptions: [UIApplication.OpenExternalURLOptionsKey: Any]? - func canOpen(_ url: URL) -> Bool { - return url.scheme == "good" + func canOpenURL(_ url: URL) -> Bool { + url.scheme == "good" } func open( - _ url: URL, options: [UIApplication.OpenExternalURLOptionsKey: Any] = [:], - completionHandler: ((Bool) -> Void)? = nil + _ url: URL, + options: [UIApplication.OpenExternalURLOptionsKey: Any], + completionHandler completion: ((Bool) -> Void)? ) { self.passedOptions = options - completionHandler?(url.scheme == "good") + completion?(url.scheme == "good") } } diff --git a/packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin.h b/packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin.h deleted file mode 100644 index 7b3480e3d47d..000000000000 --- a/packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin.h +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -#import - -#import "messages.g.h" - -@interface FLTURLLauncherPlugin : NSObject -@end diff --git a/packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin.m b/packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin.m deleted file mode 100644 index 5d6a75f97aa2..000000000000 --- a/packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin.m +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -#import - -#import "FLTURLLauncherPlugin.h" -#import "FLTURLLauncherPlugin_Test.h" -#import "FULLauncher.h" -#import "messages.g.h" - -typedef void (^OpenInSafariVCResponse)(NSNumber *_Nullable, FlutterError *_Nullable); - -@interface FLTURLLaunchSession : NSObject - -@property(copy, nonatomic) OpenInSafariVCResponse completion; -@property(strong, nonatomic) NSURL *url; -@property(strong, nonatomic) SFSafariViewController *safari; -@property(nonatomic, copy) void (^didFinish)(void); - -@end - -@implementation FLTURLLaunchSession - -- (instancetype)initWithURL:url completion:completion { - self = [super init]; - if (self) { - self.url = url; - self.completion = completion; - self.safari = [[SFSafariViewController alloc] initWithURL:url]; - self.safari.delegate = self; - } - return self; -} - -- (void)safariViewController:(SFSafariViewController *)controller - didCompleteInitialLoad:(BOOL)didLoadSuccessfully { - if (didLoadSuccessfully) { - self.completion(@YES, nil); - } else { - self.completion( - nil, [FlutterError - errorWithCode:@"Error" - message:[NSString stringWithFormat:@"Error while launching %@", self.url] - details:nil]); - } -} - -- (void)safariViewControllerDidFinish:(SFSafariViewController *)controller { - [controller dismissViewControllerAnimated:YES completion:nil]; - self.didFinish(); -} - -- (void)close { - [self safariViewControllerDidFinish:self.safari]; -} - -@end - -#pragma mark - - -/// Default implementation of FULLancher, using UIApplication. -@interface FULUIApplicationLauncher : NSObject -@end - -@implementation FULUIApplicationLauncher -- (BOOL)canOpenURL:(nonnull NSURL *)url { - return [[UIApplication sharedApplication] canOpenURL:url]; -} - -- (void)openURL:(nonnull NSURL *)url - options:(nonnull NSDictionary *)options - completionHandler:(void (^_Nullable)(BOOL))completion { - [[UIApplication sharedApplication] openURL:url options:options completionHandler:completion]; -} - -@end - -#pragma mark - - -@interface FLTURLLauncherPlugin () - -@property(strong, nonatomic) FLTURLLaunchSession *currentSession; -@property(strong, nonatomic) NSObject *launcher; - -@end - -@implementation FLTURLLauncherPlugin - -+ (void)registerWithRegistrar:(NSObject *)registrar { - FLTURLLauncherPlugin *plugin = [[FLTURLLauncherPlugin alloc] init]; - FULUrlLauncherApiSetup(registrar.messenger, plugin); -} - -- (instancetype)init { - return [self initWithLauncher:[[FULUIApplicationLauncher alloc] init]]; -} - -- (instancetype)initWithLauncher:(NSObject *)launcher { - if (self = [super init]) { - _launcher = launcher; - } - return self; -} - -- (nullable NSNumber *)canLaunchURL:(NSString *)urlString - error:(FlutterError *_Nullable *_Nonnull)error { - NSURL *url = [NSURL URLWithString:urlString]; - if (!url) { - *error = [self invalidURLErrorForURLString:urlString]; - return nil; - } - return @([self.launcher canOpenURL:url]); -} - -- (void)launchURL:(NSString *)urlString - universalLinksOnly:(NSNumber *)universalLinksOnly - completion:(void (^)(NSNumber *_Nullable, FlutterError *_Nullable))completion { - NSURL *url = [NSURL URLWithString:urlString]; - if (!url) { - completion(nil, [self invalidURLErrorForURLString:urlString]); - return; - } - NSDictionary *options = @{UIApplicationOpenURLOptionUniversalLinksOnly : universalLinksOnly}; - [self.launcher openURL:url - options:options - completionHandler:^(BOOL success) { - completion(@(success), nil); - }]; -} - -- (void)openSafariViewControllerWithURL:(NSString *)urlString - completion:(OpenInSafariVCResponse)completion { - NSURL *url = [NSURL URLWithString:urlString]; - if (!url) { - completion(nil, [self invalidURLErrorForURLString:urlString]); - return; - } - self.currentSession = [[FLTURLLaunchSession alloc] initWithURL:url completion:completion]; - __weak typeof(self) weakSelf = self; - self.currentSession.didFinish = ^(void) { - weakSelf.currentSession = nil; - }; - [self.topViewController presentViewController:self.currentSession.safari - animated:YES - completion:nil]; -} - -- (void)closeSafariViewControllerWithError:(FlutterError *_Nullable *_Nonnull)error { - [self.currentSession close]; -} - -- (UIViewController *)topViewController { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-declarations" - // TODO(stuartmorgan) Provide a non-deprecated codepath. See - // https://github.com/flutter/flutter/issues/104117 - return [self topViewControllerFromViewController:[UIApplication sharedApplication] - .keyWindow.rootViewController]; -#pragma clang diagnostic pop -} - -/** - * This method recursively iterate 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. - */ -- (UIViewController *)topViewControllerFromViewController:(UIViewController *)viewController { - if ([viewController isKindOfClass:[UINavigationController class]]) { - UINavigationController *navigationController = (UINavigationController *)viewController; - return [self - topViewControllerFromViewController:[navigationController.viewControllers lastObject]]; - } - if ([viewController isKindOfClass:[UITabBarController class]]) { - UITabBarController *tabController = (UITabBarController *)viewController; - return [self topViewControllerFromViewController:tabController.selectedViewController]; - } - if (viewController.presentedViewController) { - return [self topViewControllerFromViewController:viewController.presentedViewController]; - } - return viewController; -} - -/** - * Creates an error for an invalid URL string. - * - * @param url The invalid URL string - * @return The error to return - */ -- (FlutterError *)invalidURLErrorForURLString:(NSString *)url { - return [FlutterError errorWithCode:@"argument_error" - message:@"Unable to parse URL" - details:[NSString stringWithFormat:@"Provided URL: %@", url]]; -} -@end diff --git a/packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin_Test.h b/packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin_Test.h deleted file mode 100644 index 112682a94693..000000000000 --- a/packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin_Test.h +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -#import "FLTURLLauncherPlugin.h" -#import "FULLauncher.h" - -/// APIs exposed for testing. -@interface FLTURLLauncherPlugin (Test) -- (instancetype)initWithLauncher:(NSObject *)launcher; -@end diff --git a/packages/url_launcher/url_launcher_ios/ios/Classes/FULLauncher.h b/packages/url_launcher/url_launcher_ios/ios/Classes/FULLauncher.h deleted file mode 100644 index 63f8e04b66da..000000000000 --- a/packages/url_launcher/url_launcher_ios/ios/Classes/FULLauncher.h +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -#import - -NS_ASSUME_NONNULL_BEGIN - -/// Protocol for UIApplication methods relating to launching URLs. -/// -/// This protocol exists to allow injecting an alternate implementation for testing. -@protocol FULLauncher -- (BOOL)canOpenURL:(NSURL *)url; -- (void)openURL:(NSURL *)url - options:(NSDictionary *)options - completionHandler:(void (^__nullable)(BOOL success))completion; -@end - -NS_ASSUME_NONNULL_END diff --git a/packages/url_launcher/url_launcher_ios/ios/Classes/Launcher.swift b/packages/url_launcher/url_launcher_ios/ios/Classes/Launcher.swift new file mode 100644 index 000000000000..f97db9db9c5e --- /dev/null +++ b/packages/url_launcher/url_launcher_ios/ios/Classes/Launcher.swift @@ -0,0 +1,20 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Protocol for UIApplication methods relating to launching URLs. +/// +/// This protocol exists to allow injecting an alternate implementation for testing. +protocol Launcher { + /// Returns a Boolean value that indicates whether an app is available to handle a URL scheme. + func canOpenURL(_ url: URL) -> Bool + + /// Attempts to asynchronously open the resource at the specified URL. + func open( + _ url: URL, + options: [UIApplication.OpenExternalURLOptionsKey: Any], + completionHandler completion: ((Bool) -> Void)?) +} + +/// Launcher is intentionally a direct passthroguh to UIApplication. +extension UIApplication: Launcher {} diff --git a/packages/url_launcher/url_launcher_ios/ios/Classes/URLLaunchSession.swift b/packages/url_launcher/url_launcher_ios/ios/Classes/URLLaunchSession.swift new file mode 100644 index 000000000000..b0761e57f08e --- /dev/null +++ b/packages/url_launcher/url_launcher_ios/ios/Classes/URLLaunchSession.swift @@ -0,0 +1,63 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import Flutter +import SafariServices + +typealias OpenInSafariCompletionHandler = (Result) -> Void + +/// A session responsible for launching a URL in Safari and handling its events. +final class URLLaunchSession: NSObject, SFSafariViewControllerDelegate { + + private let completion: OpenInSafariCompletionHandler + private let url: URL + + /// The Safari view controller used for displaying the URL. + let safariViewController: SFSafariViewController + + // A closure to be executed after the Safari view controller finishes. + var didFinish: (() -> Void)? + + /// Initializes a new URLLaunchSession with the provided URL and completion handler. + /// + /// - Parameters: + /// - url: The URL to be opened in Safari. + /// - completion: The completion handler to be called after attempting to open the URL. + init(url: URL, completion: @escaping OpenInSafariCompletionHandler) { + self.url = url + self.completion = completion + self.safariViewController = SFSafariViewController(url: url) + super.init() + self.safariViewController.delegate = self + } + + /// Called when the Safari view controller completes the initial load. + /// + /// - Parameters: + /// - controller: The Safari view controller. + /// - didLoadSuccessfully: Indicates if the initial load was successful. + func safariViewController( + _ controller: SFSafariViewController, + didCompleteInitialLoad didLoadSuccessfully: Bool + ) { + if didLoadSuccessfully { + completion(.success(.success)) + } else { + completion(.success(.failedToLoad)) + } + } + + /// Called when the user finishes using the Safari view controller. + /// + /// - Parameter controller: The Safari view controller. + func safariViewControllerDidFinish(_ controller: SFSafariViewController) { + controller.dismiss(animated: true, completion: nil) + didFinish?() + } + + /// Closes the Safari view controller. + func close() { + safariViewControllerDidFinish(safariViewController) + } +} diff --git a/packages/url_launcher/url_launcher_ios/ios/Classes/URLLauncherPlugin.swift b/packages/url_launcher/url_launcher_ios/ios/Classes/URLLauncherPlugin.swift new file mode 100644 index 000000000000..18800319218e --- /dev/null +++ b/packages/url_launcher/url_launcher_ios/ios/Classes/URLLauncherPlugin.swift @@ -0,0 +1,99 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import Flutter + +public final class URLLauncherPlugin: NSObject, FlutterPlugin, UrlLauncherApi { + + public static func register(with registrar: FlutterPluginRegistrar) { + let plugin = URLLauncherPlugin() + UrlLauncherApiSetup.setUp(binaryMessenger: registrar.messenger(), api: plugin) + registrar.publish(plugin) + } + + private var currentSession: URLLaunchSession? + private let launcher: Launcher + + 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 = UIApplication.shared) { + self.launcher = launcher + } + + func canLaunchUrl(url: String) -> LaunchResult { + guard let url = URL(string: url) else { + return .invalidUrl + } + let canOpen = launcher.canOpenURL(url) + return canOpen ? .success : .failure + } + + func launchUrl( + url: String, + universalLinksOnly: Bool, + completion: @escaping (Result) -> Void + ) { + guard let url = URL(string: url) else { + completion(.success(.invalidUrl)) + return + } + let options = [UIApplication.OpenExternalURLOptionsKey.universalLinksOnly: universalLinksOnly] + launcher.open(url, options: options) { result in + completion(.success(result ? .success : .failure)) + } + } + + func openUrlInSafariViewController( + url: String, + completion: @escaping (Result) -> Void + ) { + guard let url = URL(string: url) else { + completion(.success(.invalidUrl)) + 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) + } + + 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/Classes/messages.g.h b/packages/url_launcher/url_launcher_ios/ios/Classes/messages.g.h deleted file mode 100644 index 3a63e0722713..000000000000 --- a/packages/url_launcher/url_launcher_ios/ios/Classes/messages.g.h +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. -// Autogenerated from Pigeon (v9.2.4), do not edit directly. -// See also: https://pub.dev/packages/pigeon - -#import - -@protocol FlutterBinaryMessenger; -@protocol FlutterMessageCodec; -@class FlutterError; -@class FlutterStandardTypedData; - -NS_ASSUME_NONNULL_BEGIN - -/// The codec used by FULUrlLauncherApi. -NSObject *FULUrlLauncherApiGetCodec(void); - -@protocol FULUrlLauncherApi -/// Returns true if the URL can definitely be launched. -/// -/// @return `nil` only when `error != nil`. -- (nullable NSNumber *)canLaunchURL:(NSString *)url error:(FlutterError *_Nullable *_Nonnull)error; -/// Opens the URL externally, returning true if successful. -- (void)launchURL:(NSString *)url - universalLinksOnly:(NSNumber *)universalLinksOnly - completion:(void (^)(NSNumber *_Nullable, FlutterError *_Nullable))completion; -/// Opens the URL in an in-app SFSafariViewController, returning true -/// when it has loaded successfully. -- (void)openSafariViewControllerWithURL:(NSString *)url - completion: - (void (^)(NSNumber *_Nullable, FlutterError *_Nullable))completion; -/// Closes the view controller opened by [openUrlInSafariViewController]. -- (void)closeSafariViewControllerWithError:(FlutterError *_Nullable *_Nonnull)error; -@end - -extern void FULUrlLauncherApiSetup(id binaryMessenger, - NSObject *_Nullable api); - -NS_ASSUME_NONNULL_END diff --git a/packages/url_launcher/url_launcher_ios/ios/Classes/messages.g.m b/packages/url_launcher/url_launcher_ios/ios/Classes/messages.g.m deleted file mode 100644 index 4a38efbe4f0d..000000000000 --- a/packages/url_launcher/url_launcher_ios/ios/Classes/messages.g.m +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. -// Autogenerated from Pigeon (v9.2.4), do not edit directly. -// See also: https://pub.dev/packages/pigeon - -#import "messages.g.h" -#import - -#if !__has_feature(objc_arc) -#error File requires ARC to be enabled. -#endif - -static NSArray *wrapResult(id result, FlutterError *error) { - if (error) { - return @[ - error.code ?: [NSNull null], error.message ?: [NSNull null], error.details ?: [NSNull null] - ]; - } - return @[ result ?: [NSNull null] ]; -} -static id GetNullableObjectAtIndex(NSArray *array, NSInteger key) { - id result = array[key]; - return (result == [NSNull null]) ? nil : result; -} - -NSObject *FULUrlLauncherApiGetCodec(void) { - static FlutterStandardMessageCodec *sSharedObject = nil; - sSharedObject = [FlutterStandardMessageCodec sharedInstance]; - return sSharedObject; -} - -void FULUrlLauncherApiSetup(id binaryMessenger, - NSObject *api) { - /// Returns true if the URL can definitely be launched. - { - FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:@"dev.flutter.pigeon.UrlLauncherApi.canLaunchUrl" - binaryMessenger:binaryMessenger - codec:FULUrlLauncherApiGetCodec()]; - if (api) { - NSCAssert([api respondsToSelector:@selector(canLaunchURL:error:)], - @"FULUrlLauncherApi api (%@) doesn't respond to @selector(canLaunchURL:error:)", - api); - [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { - NSArray *args = message; - NSString *arg_url = GetNullableObjectAtIndex(args, 0); - FlutterError *error; - NSNumber *output = [api canLaunchURL:arg_url error:&error]; - callback(wrapResult(output, error)); - }]; - } else { - [channel setMessageHandler:nil]; - } - } - /// Opens the URL externally, returning true if successful. - { - FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:@"dev.flutter.pigeon.UrlLauncherApi.launchUrl" - binaryMessenger:binaryMessenger - codec:FULUrlLauncherApiGetCodec()]; - if (api) { - NSCAssert([api respondsToSelector:@selector(launchURL:universalLinksOnly:completion:)], - @"FULUrlLauncherApi api (%@) doesn't respond to " - @"@selector(launchURL:universalLinksOnly:completion:)", - api); - [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { - NSArray *args = message; - NSString *arg_url = GetNullableObjectAtIndex(args, 0); - NSNumber *arg_universalLinksOnly = GetNullableObjectAtIndex(args, 1); - [api launchURL:arg_url - universalLinksOnly:arg_universalLinksOnly - completion:^(NSNumber *_Nullable output, FlutterError *_Nullable error) { - callback(wrapResult(output, error)); - }]; - }]; - } else { - [channel setMessageHandler:nil]; - } - } - /// Opens the URL in an in-app SFSafariViewController, returning true - /// when it has loaded successfully. - { - FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:@"dev.flutter.pigeon.UrlLauncherApi.openUrlInSafariViewController" - binaryMessenger:binaryMessenger - codec:FULUrlLauncherApiGetCodec()]; - if (api) { - NSCAssert([api respondsToSelector:@selector(openSafariViewControllerWithURL:completion:)], - @"FULUrlLauncherApi api (%@) doesn't respond to " - @"@selector(openSafariViewControllerWithURL:completion:)", - api); - [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { - NSArray *args = message; - NSString *arg_url = GetNullableObjectAtIndex(args, 0); - [api openSafariViewControllerWithURL:arg_url - completion:^(NSNumber *_Nullable output, - FlutterError *_Nullable error) { - callback(wrapResult(output, error)); - }]; - }]; - } else { - [channel setMessageHandler:nil]; - } - } - /// Closes the view controller opened by [openUrlInSafariViewController]. - { - FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:@"dev.flutter.pigeon.UrlLauncherApi.closeSafariViewController" - binaryMessenger:binaryMessenger - codec:FULUrlLauncherApiGetCodec()]; - if (api) { - NSCAssert([api respondsToSelector:@selector(closeSafariViewControllerWithError:)], - @"FULUrlLauncherApi api (%@) doesn't respond to " - @"@selector(closeSafariViewControllerWithError:)", - api); - [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { - FlutterError *error; - [api closeSafariViewControllerWithError:&error]; - callback(wrapResult(nil, error)); - }]; - } else { - [channel setMessageHandler:nil]; - } - } -} diff --git a/packages/url_launcher/url_launcher_ios/ios/Classes/messages.g.swift b/packages/url_launcher/url_launcher_ios/ios/Classes/messages.g.swift new file mode 100644 index 000000000000..c3b0b8a65df1 --- /dev/null +++ b/packages/url_launcher/url_launcher_ios/ios/Classes/messages.g.swift @@ -0,0 +1,163 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// Autogenerated from Pigeon (v11.0.1), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +/// Possible outcomes of launching a URL. +enum LaunchResult: Int { + /// The URL was successfully launched (or could be, for `canLaunchUrl`). + case success = 0 + /// There was no handler available for the URL. + case failure = 1 + /// The URL could not be launched because it is invalid. + case invalidUrl = 2 +} + +/// Possible outcomes of handling a URL within the application. +enum InAppLoadResult: Int { + /// The URL was successfully loaded. + case success = 0 + /// The URL did not load successfully. + case failedToLoad = 1 + /// The URL could not be launched because it is invalid. + case invalidUrl = 2 +} + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol UrlLauncherApi { + /// Checks whether a URL can be loaded. + func canLaunchUrl(url: String) throws -> LaunchResult + /// Opens the URL externally, returning the status of launching it. + func launchUrl( + url: String, universalLinksOnly: Bool, + completion: @escaping (Result) -> Void) + /// Opens the URL in an in-app SFSafariViewController, returning the results + /// of loading it. + func openUrlInSafariViewController( + url: String, completion: @escaping (Result) -> Void) + /// Closes the view controller opened by [openUrlInSafariViewController]. + func closeSafariViewController() throws +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class UrlLauncherApiSetup { + /// The codec used by UrlLauncherApi. + /// Sets up an instance of `UrlLauncherApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: UrlLauncherApi?) { + /// Checks whether a URL can be loaded. + let canLaunchUrlChannel = FlutterBasicMessageChannel( + name: "dev.flutter.pigeon.url_launcher_ios.UrlLauncherApi.canLaunchUrl", + binaryMessenger: binaryMessenger) + if let api = api { + canLaunchUrlChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let urlArg = args[0] as! String + do { + let result = try api.canLaunchUrl(url: urlArg) + reply(wrapResult(result.rawValue)) + } catch { + reply(wrapError(error)) + } + } + } else { + canLaunchUrlChannel.setMessageHandler(nil) + } + /// Opens the URL externally, returning the status of launching it. + let launchUrlChannel = FlutterBasicMessageChannel( + name: "dev.flutter.pigeon.url_launcher_ios.UrlLauncherApi.launchUrl", + binaryMessenger: binaryMessenger) + if let api = api { + launchUrlChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let urlArg = args[0] as! String + let universalLinksOnlyArg = args[1] as! Bool + api.launchUrl(url: urlArg, universalLinksOnly: universalLinksOnlyArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res.rawValue)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + launchUrlChannel.setMessageHandler(nil) + } + /// Opens the URL in an in-app SFSafariViewController, returning the results + /// of loading it. + let openUrlInSafariViewControllerChannel = FlutterBasicMessageChannel( + name: "dev.flutter.pigeon.url_launcher_ios.UrlLauncherApi.openUrlInSafariViewController", + binaryMessenger: binaryMessenger) + if let api = api { + openUrlInSafariViewControllerChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let urlArg = args[0] as! String + api.openUrlInSafariViewController(url: urlArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res.rawValue)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + openUrlInSafariViewControllerChannel.setMessageHandler(nil) + } + /// Closes the view controller opened by [openUrlInSafariViewController]. + let closeSafariViewControllerChannel = FlutterBasicMessageChannel( + name: "dev.flutter.pigeon.url_launcher_ios.UrlLauncherApi.closeSafariViewController", + binaryMessenger: binaryMessenger) + if let api = api { + closeSafariViewControllerChannel.setMessageHandler { _, reply in + do { + try api.closeSafariViewController() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + closeSafariViewControllerChannel.setMessageHandler(nil) + } + } +} diff --git a/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios.podspec b/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios.podspec index 3dd3eb97c95b..400ad7384ed8 100644 --- a/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios.podspec +++ b/packages/url_launcher/url_launcher_ios/ios/url_launcher_ios.podspec @@ -14,7 +14,7 @@ A Flutter plugin for making the underlying platform (Android or iOS) launch a UR s.source = { :http => 'https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_ios' } s.documentation_url = 'https://pub.dev/packages/url_launcher' s.swift_version = '5.0' - s.source_files = 'Classes/**/*' + s.source_files = 'Classes/**/*.swift' s.xcconfig = { 'LIBRARY_SEARCH_PATHS' => '$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)/ $(SDKROOT)/usr/lib/swift', 'LD_RUNPATH_SEARCH_PATHS' => '/usr/lib/swift', 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 562a408bd1c1..a7e9a8c6e5ae 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 @@ -1,7 +1,7 @@ // Copyright 2013 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// Autogenerated from Pigeon (v9.2.4), do not edit directly. +// Autogenerated from Pigeon (v11.0.1), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import @@ -11,6 +11,30 @@ import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; import 'package:flutter/services.dart'; +/// Possible outcomes of launching a URL. +enum LaunchResult { + /// The URL was successfully launched (or could be, for `canLaunchUrl`). + success, + + /// There was no handler available for the URL. + failure, + + /// The URL could not be launched because it is invalid. + invalidUrl, +} + +/// Possible outcomes of handling a URL within the application. +enum InAppLoadResult { + /// The URL was successfully loaded. + success, + + /// The URL did not load successfully. + failedToLoad, + + /// The URL could not be launched because it is invalid. + invalidUrl, +} + class UrlLauncherApi { /// Constructor for [UrlLauncherApi]. The [binaryMessenger] named argument is /// available for dependency injection. If it is left null, the default @@ -21,10 +45,11 @@ class UrlLauncherApi { static const MessageCodec codec = StandardMessageCodec(); - /// Returns true if the URL can definitely be launched. - Future canLaunchUrl(String arg_url) async { + /// Checks whether a URL can be loaded. + Future canLaunchUrl(String arg_url) async { final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.UrlLauncherApi.canLaunchUrl', codec, + 'dev.flutter.pigeon.url_launcher_ios.UrlLauncherApi.canLaunchUrl', + codec, binaryMessenger: _binaryMessenger); final List? replyList = await channel.send([arg_url]) as List?; @@ -45,14 +70,15 @@ class UrlLauncherApi { message: 'Host platform returned null value for non-null return value.', ); } else { - return (replyList[0] as bool?)!; + return LaunchResult.values[replyList[0]! as int]; } } - /// Opens the URL externally, returning true if successful. - Future launchUrl(String arg_url, bool arg_universalLinksOnly) async { + /// Opens the URL externally, returning the status of launching it. + Future launchUrl( + String arg_url, bool arg_universalLinksOnly) async { final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.UrlLauncherApi.launchUrl', codec, + 'dev.flutter.pigeon.url_launcher_ios.UrlLauncherApi.launchUrl', codec, binaryMessenger: _binaryMessenger); final List? replyList = await channel .send([arg_url, arg_universalLinksOnly]) as List?; @@ -73,15 +99,15 @@ class UrlLauncherApi { message: 'Host platform returned null value for non-null return value.', ); } else { - return (replyList[0] as bool?)!; + return LaunchResult.values[replyList[0]! as int]; } } - /// Opens the URL in an in-app SFSafariViewController, returning true - /// when it has loaded successfully. - Future openUrlInSafariViewController(String arg_url) async { + /// Opens the URL in an in-app SFSafariViewController, returning the results + /// of loading it. + Future openUrlInSafariViewController(String arg_url) async { final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.UrlLauncherApi.openUrlInSafariViewController', + 'dev.flutter.pigeon.url_launcher_ios.UrlLauncherApi.openUrlInSafariViewController', codec, binaryMessenger: _binaryMessenger); final List? replyList = @@ -103,14 +129,15 @@ class UrlLauncherApi { message: 'Host platform returned null value for non-null return value.', ); } else { - return (replyList[0] as bool?)!; + return InAppLoadResult.values[replyList[0]! as int]; } } /// Closes the view controller opened by [openUrlInSafariViewController]. Future closeSafariViewController() async { final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.UrlLauncherApi.closeSafariViewController', codec, + 'dev.flutter.pigeon.url_launcher_ios.UrlLauncherApi.closeSafariViewController', + codec, binaryMessenger: _binaryMessenger); final List? replyList = await channel.send(null) as List?; if (replyList == null) { 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 66969787fba3..9d1ebc9c2361 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 @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:flutter/services.dart'; import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; @@ -26,8 +27,9 @@ class UrlLauncherIOS extends UrlLauncherPlatform { final LinkDelegate? linkDelegate = null; @override - Future canLaunch(String url) { - return _hostApi.canLaunchUrl(url); + Future canLaunch(String url) async { + final LaunchResult result = await _hostApi.canLaunchUrl(url); + return _mapLaunchResult(result); } @override @@ -90,10 +92,12 @@ class UrlLauncherIOS extends UrlLauncherPlatform { } if (inApp) { - return _hostApi.openUrlInSafariViewController(url); + return _mapInAppLoadResult( + await _hostApi.openUrlInSafariViewController(url), + url: url); } else { - return _hostApi.launchUrl(url, - options.mode == PreferredLaunchMode.externalNonBrowserApplication); + return _mapLaunchResult(await _hostApi.launchUrl(url, + options.mode == PreferredLaunchMode.externalNonBrowserApplication)); } } @@ -120,4 +124,52 @@ class UrlLauncherIOS extends UrlLauncherPlatform { return mode == PreferredLaunchMode.inAppWebView || mode == PreferredLaunchMode.inAppBrowserView; } + + bool _mapLaunchResult(LaunchResult result) { + switch (result) { + case LaunchResult.success: + return true; + case LaunchResult.failure: + return false; + case LaunchResult.invalidUrl: + throw _invalidUrlException(); + } + } + + bool _mapInAppLoadResult(InAppLoadResult result, {required String url}) { + switch (result) { + case InAppLoadResult.success: + return true; + case InAppLoadResult.failedToLoad: + throw _failedSafariViewControllerLoadException(url); + case InAppLoadResult.invalidUrl: + throw _invalidUrlException(); + } + } + + // TODO(stuartmorgan): Remove this as part of standardizing error handling. + // See https://github.com/flutter/flutter/issues/127665 + // + // This PlatformException (including the exact string details, since those + // are a defacto part of the API) is for compatibility with the previous + // native implementation. + PlatformException _invalidUrlException() { + throw PlatformException( + code: 'argument_error', + message: 'Unable to parse URL', + ); + } + + // TODO(stuartmorgan): Remove this as part of standardizing error handling. + // See https://github.com/flutter/flutter/issues/127665 + // + // This PlatformException (including the exact string details, since those + // are a defacto part of the API) is for compatibility with the previous + // native implementation. + PlatformException _failedSafariViewControllerLoadException(String url) { + throw PlatformException( + code: 'Error', + message: 'Error while launching $url', + ); + } } diff --git a/packages/url_launcher/url_launcher_ios/pigeons/messages.dart b/packages/url_launcher/url_launcher_ios/pigeons/messages.dart index f6935cbd8821..f5dc1052b320 100644 --- a/packages/url_launcher/url_launcher_ios/pigeons/messages.dart +++ b/packages/url_launcher/url_launcher_ios/pigeons/messages.dart @@ -6,27 +6,50 @@ import 'package:pigeon/pigeon.dart'; @ConfigurePigeon(PigeonOptions( dartOut: 'lib/src/messages.g.dart', - objcOptions: ObjcOptions(prefix: 'FUL'), - objcHeaderOut: 'ios/Classes/messages.g.h', - objcSourceOut: 'ios/Classes/messages.g.m', + swiftOut: 'ios/Classes/messages.g.swift', copyrightHeader: 'pigeons/copyright.txt', )) + +/// Possible outcomes of launching a URL. +enum LaunchResult { + /// The URL was successfully launched (or could be, for `canLaunchUrl`). + success, + + /// There was no handler available for the URL. + failure, + + /// The URL could not be launched because it is invalid. + invalidUrl, +} + +/// Possible outcomes of handling a URL within the application. +enum InAppLoadResult { + /// The URL was successfully loaded. + success, + + /// The URL did not load successfully. + failedToLoad, + + /// The URL could not be launched because it is invalid. + invalidUrl, +} + @HostApi() abstract class UrlLauncherApi { - /// Returns true if the URL can definitely be launched. + /// Checks whether a URL can be loaded. @ObjCSelector('canLaunchURL:') - bool canLaunchUrl(String url); + LaunchResult canLaunchUrl(String url); - /// Opens the URL externally, returning true if successful. + /// Opens the URL externally, returning the status of launching it. @async @ObjCSelector('launchURL:universalLinksOnly:') - bool launchUrl(String url, bool universalLinksOnly); + LaunchResult launchUrl(String url, bool universalLinksOnly); - /// Opens the URL in an in-app SFSafariViewController, returning true - /// when it has loaded successfully. + /// Opens the URL in an in-app SFSafariViewController, returning the results + /// of loading it. @async @ObjCSelector('openSafariViewControllerWithURL:') - bool openUrlInSafariViewController(String url); + InAppLoadResult openUrlInSafariViewController(String url); /// Closes the view controller opened by [openUrlInSafariViewController]. void closeSafariViewController(); diff --git a/packages/url_launcher/url_launcher_ios/pubspec.yaml b/packages/url_launcher/url_launcher_ios/pubspec.yaml index 6047568083b8..56b337b47ede 100644 --- a/packages/url_launcher/url_launcher_ios/pubspec.yaml +++ b/packages/url_launcher/url_launcher_ios/pubspec.yaml @@ -2,7 +2,7 @@ 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.2.0 +version: 6.2.1 environment: sdk: ">=2.19.0 <4.0.0" @@ -13,7 +13,7 @@ flutter: implements: url_launcher platforms: ios: - pluginClass: FLTURLLauncherPlugin + pluginClass: URLLauncherPlugin dartPluginClass: UrlLauncherIOS dependencies: @@ -22,9 +22,11 @@ dependencies: url_launcher_platform_interface: ^2.2.0 dev_dependencies: + build_runner: ^2.3.3 flutter_test: sdk: flutter - pigeon: ^9.2.4 + mockito: 5.4.2 + pigeon: ^11.0.1 plugin_platform_interface: ^2.0.0 test: ^1.16.3 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 bacea3132c13..195db6302947 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 @@ -4,17 +4,23 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; import 'package:url_launcher_ios/src/messages.g.dart'; import 'package:url_launcher_ios/url_launcher_ios.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); +import 'url_launcher_ios_test.mocks.dart'; + +// A web URL to use in tests where the specifics of the URL don't matter. +const String _webUrl = 'https://example.com/'; - late _FakeUrlLauncherApi api; +@GenerateMocks([UrlLauncherApi]) +void main() { + late MockUrlLauncherApi api; setUp(() { - api = _FakeUrlLauncherApi(); + api = MockUrlLauncherApi(); }); test('registers instance', () { @@ -24,28 +30,38 @@ void main() { group('canLaunch', () { test('handles success', () async { + when(api.canLaunchUrl(_webUrl)) + .thenAnswer((_) async => LaunchResult.success); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); - expect(await launcher.canLaunch('http://example.com/'), true); + expect(await launcher.canLaunch(_webUrl), true); }); test('handles failure', () async { + when(api.canLaunchUrl(_webUrl)) + .thenAnswer((_) async => LaunchResult.failure); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); - expect(await launcher.canLaunch('unknown://scheme'), false); + expect(await launcher.canLaunch(_webUrl), false); }); - test('passes invalid URL PlatformException through', () async { + test('throws PlatformException for invalid URL', () async { + when(api.canLaunchUrl(_webUrl)) + .thenAnswer((_) async => LaunchResult.invalidUrl); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); - await expectLater(launcher.canLaunch('invalid://u r l'), - throwsA(isA())); + await expectLater( + launcher.canLaunch(_webUrl), + throwsA(isA().having( + (PlatformException e) => e.code, 'code', 'argument_error'))); }); }); group('legacy launch', () { test('handles success', () async { + when(api.launchUrl(_webUrl, any)) + .thenAnswer((_) async => LaunchResult.success); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); expect( await launcher.launch( - 'http://example.com/', + _webUrl, useSafariVC: false, useWebView: false, enableJavaScript: false, @@ -54,14 +70,16 @@ void main() { headers: const {}, ), true); - expect(api.passedUniversalLinksOnly, false); + verifyNever(api.openUrlInSafariViewController(any)); }); test('handles failure', () async { + when(api.launchUrl(_webUrl, any)) + .thenAnswer((_) async => LaunchResult.failure); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); expect( await launcher.launch( - 'unknown://scheme', + _webUrl, useSafariVC: false, useWebView: false, enableJavaScript: false, @@ -70,14 +88,16 @@ void main() { headers: const {}, ), false); - expect(api.passedUniversalLinksOnly, false); + verifyNever(api.openUrlInSafariViewController(any)); }); - test('passes invalid URL PlatformException through', () async { + test('throws PlatformException for invalid URL', () async { + when(api.launchUrl(_webUrl, any)) + .thenAnswer((_) async => LaunchResult.invalidUrl); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); await expectLater( launcher.launch( - 'invalid://u r l', + _webUrl, useSafariVC: false, useWebView: false, enableJavaScript: false, @@ -85,14 +105,17 @@ void main() { universalLinksOnly: false, headers: const {}, ), - throwsA(isA())); + throwsA(isA().having( + (PlatformException e) => e.code, 'code', 'argument_error'))); }); test('force SafariVC is handled', () async { + when(api.openUrlInSafariViewController(_webUrl)) + .thenAnswer((_) async => InAppLoadResult.success); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); expect( await launcher.launch( - 'http://example.com/', + _webUrl, useSafariVC: true, useWebView: false, enableJavaScript: false, @@ -101,14 +124,16 @@ void main() { headers: const {}, ), true); - expect(api.usedSafariViewController, true); + verifyNever(api.launchUrl(any, any)); }); test('universal links only is handled', () async { + when(api.launchUrl(_webUrl, any)) + .thenAnswer((_) async => LaunchResult.success); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); expect( await launcher.launch( - 'http://example.com/', + _webUrl, useSafariVC: false, useWebView: false, enableJavaScript: false, @@ -117,14 +142,16 @@ void main() { headers: const {}, ), true); - expect(api.passedUniversalLinksOnly, true); + verifyNever(api.openUrlInSafariViewController(any)); }); test('disallowing SafariVC is handled', () async { + when(api.launchUrl(_webUrl, any)) + .thenAnswer((_) async => LaunchResult.success); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); expect( await launcher.launch( - 'http://example.com/', + _webUrl, useSafariVC: false, useWebView: false, enableJavaScript: false, @@ -133,109 +160,147 @@ void main() { headers: const {}, ), true); - expect(api.usedSafariViewController, false); + verifyNever(api.openUrlInSafariViewController(any)); }); }); test('closeWebView calls through', () async { final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); await launcher.closeWebView(); - expect(api.closed, true); + verify(api.closeSafariViewController()).called(1); }); group('launch without webview', () { test('calls through', () async { + when(api.launchUrl(_webUrl, any)) + .thenAnswer((_) async => LaunchResult.success); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); final bool launched = await launcher.launchUrl( - 'http://example.com/', + _webUrl, const LaunchOptions(mode: PreferredLaunchMode.externalApplication), ); expect(launched, true); - expect(api.usedSafariViewController, false); + verifyNever(api.openUrlInSafariViewController(any)); }); - test('passes invalid URL PlatformException through', () async { + test('throws PlatformException for invalid URL', () async { + when(api.launchUrl(_webUrl, any)) + .thenAnswer((_) async => LaunchResult.invalidUrl); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); await expectLater( - launcher.launchUrl('invalid://u r l', const LaunchOptions()), - throwsA(isA())); + launcher.launchUrl( + _webUrl, + const LaunchOptions(mode: PreferredLaunchMode.externalApplication), + ), + throwsA(isA().having( + (PlatformException e) => e.code, 'code', 'argument_error'))); }); }); group('launch with Safari view controller', () { test('calls through with inAppWebView', () async { + when(api.openUrlInSafariViewController(_webUrl)) + .thenAnswer((_) async => InAppLoadResult.success); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); - final bool launched = await launcher.launchUrl('http://example.com/', - const LaunchOptions(mode: PreferredLaunchMode.inAppWebView)); + final bool launched = await launcher.launchUrl( + _webUrl, const LaunchOptions(mode: PreferredLaunchMode.inAppWebView)); expect(launched, true); - expect(api.usedSafariViewController, true); + verifyNever(api.launchUrl(any, any)); }); test('calls through with inAppBrowserView', () async { + when(api.openUrlInSafariViewController(_webUrl)) + .thenAnswer((_) async => InAppLoadResult.success); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); - final bool launched = await launcher.launchUrl('http://example.com/', + final bool launched = await launcher.launchUrl(_webUrl, const LaunchOptions(mode: PreferredLaunchMode.inAppBrowserView)); expect(launched, true); - expect(api.usedSafariViewController, true); + verifyNever(api.launchUrl(any, any)); }); - test('passes invalid URL PlatformException through', () async { + test('throws PlatformException for invalid URL', () async { + when(api.openUrlInSafariViewController(_webUrl)) + .thenAnswer((_) async => InAppLoadResult.invalidUrl); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); await expectLater( - launcher.launchUrl('invalid://u r l', + launcher.launchUrl(_webUrl, const LaunchOptions(mode: PreferredLaunchMode.inAppWebView)), - throwsA(isA())); + throwsA(isA().having( + (PlatformException e) => e.code, 'code', 'argument_error'))); + }); + + test('throws PlatformException for load failure', () async { + when(api.openUrlInSafariViewController(_webUrl)) + .thenAnswer((_) async => InAppLoadResult.failedToLoad); + final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); + await expectLater( + launcher.launchUrl(_webUrl, + const LaunchOptions(mode: PreferredLaunchMode.inAppWebView)), + throwsA(isA() + .having((PlatformException e) => e.code, 'code', 'Error'))); }); }); group('launch with universal links', () { test('calls through', () async { + when(api.launchUrl(_webUrl, any)) + .thenAnswer((_) async => LaunchResult.success); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); final bool launched = await launcher.launchUrl( - 'http://example.com/', + _webUrl, const LaunchOptions( mode: PreferredLaunchMode.externalNonBrowserApplication), ); expect(launched, true); - expect(api.usedSafariViewController, false); - expect(api.passedUniversalLinksOnly, true); + verifyNever(api.openUrlInSafariViewController(any)); }); - test('passes invalid URL PlatformException through', () async { + test('throws PlatformException for invalid URL', () async { + when(api.launchUrl(_webUrl, any)) + .thenAnswer((_) async => LaunchResult.invalidUrl); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); await expectLater( launcher.launchUrl( - 'invalid://u r l', + _webUrl, const LaunchOptions( mode: PreferredLaunchMode.externalNonBrowserApplication)), - throwsA(isA())); + throwsA(isA().having( + (PlatformException e) => e.code, 'code', 'argument_error'))); }); }); group('launch with platform default', () { test('uses Safari view controller for http', () async { + const String httpUrl = 'http://example.com/'; + when(api.openUrlInSafariViewController(httpUrl)) + .thenAnswer((_) async => InAppLoadResult.success); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); - final bool launched = await launcher.launchUrl( - 'http://example.com/', const LaunchOptions()); + final bool launched = + await launcher.launchUrl(httpUrl, const LaunchOptions()); expect(launched, true); - expect(api.usedSafariViewController, true); + verifyNever(api.launchUrl(any, any)); }); test('uses Safari view controller for https', () async { + const String httpsUrl = 'https://example.com/'; + when(api.openUrlInSafariViewController(httpsUrl)) + .thenAnswer((_) async => InAppLoadResult.success); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); - final bool launched = await launcher.launchUrl( - 'https://example.com/', const LaunchOptions()); + final bool launched = + await launcher.launchUrl(httpsUrl, const LaunchOptions()); expect(launched, true); - expect(api.usedSafariViewController, true); + verifyNever(api.launchUrl(any, any)); }); test('uses standard external for other schemes', () async { + const String nonWebUrl = 'supportedcustomscheme://example.com/'; + when(api.launchUrl(nonWebUrl, any)) + .thenAnswer((_) async => LaunchResult.success); final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); - final bool launched = await launcher.launchUrl( - 'supportedcustomscheme://example.com/', const LaunchOptions()); + final bool launched = + await launcher.launchUrl(nonWebUrl, const LaunchOptions()); expect(launched, true); - expect(api.usedSafariViewController, false); - expect(api.passedUniversalLinksOnly, false); + verifyNever(api.openUrlInSafariViewController(any)); }); }); @@ -303,49 +368,3 @@ void main() { }); }); } - -/// A fake implementation of the host API that reacts to specific schemes. -/// -/// See _isLaunchable for the behaviors. -class _FakeUrlLauncherApi implements UrlLauncherApi { - bool? passedUniversalLinksOnly; - bool? usedSafariViewController; - bool? closed; - - @override - Future canLaunchUrl(String url) async { - return _isLaunchable(url); - } - - @override - Future launchUrl(String url, bool universalLinksOnly) async { - passedUniversalLinksOnly = universalLinksOnly; - usedSafariViewController = false; - return _isLaunchable(url); - } - - @override - Future openUrlInSafariViewController(String url) async { - usedSafariViewController = true; - return _isLaunchable(url); - } - - @override - Future closeSafariViewController() async { - closed = true; - } - - bool _isLaunchable(String url) { - final String scheme = url.split(':')[0]; - switch (scheme) { - case 'http': - case 'https': - case 'supportedcustomscheme': - return true; - case 'invalid': - throw PlatformException(code: 'argument_error'); - default: - return false; - } - } -} diff --git a/packages/url_launcher/url_launcher_ios/test/url_launcher_ios_test.mocks.dart b/packages/url_launcher/url_launcher_ios/test/url_launcher_ios_test.mocks.dart new file mode 100644 index 000000000000..e9eccab16213 --- /dev/null +++ b/packages/url_launcher/url_launcher_ios/test/url_launcher_ios_test.mocks.dart @@ -0,0 +1,79 @@ +// Mocks generated by Mockito 5.4.2 from annotations +// in url_launcher_ios/test/url_launcher_ios_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:url_launcher_ios/src/messages.g.dart' as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [UrlLauncherApi]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockUrlLauncherApi extends _i1.Mock implements _i2.UrlLauncherApi { + MockUrlLauncherApi() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future<_i2.LaunchResult> canLaunchUrl(String? arg_url) => + (super.noSuchMethod( + Invocation.method( + #canLaunchUrl, + [arg_url], + ), + returnValue: + _i3.Future<_i2.LaunchResult>.value(_i2.LaunchResult.success), + ) as _i3.Future<_i2.LaunchResult>); + + @override + _i3.Future<_i2.LaunchResult> launchUrl( + String? arg_url, + bool? arg_universalLinksOnly, + ) => + (super.noSuchMethod( + Invocation.method( + #launchUrl, + [ + arg_url, + arg_universalLinksOnly, + ], + ), + returnValue: + _i3.Future<_i2.LaunchResult>.value(_i2.LaunchResult.success), + ) as _i3.Future<_i2.LaunchResult>); + + @override + _i3.Future<_i2.InAppLoadResult> openUrlInSafariViewController( + String? arg_url) => + (super.noSuchMethod( + Invocation.method( + #openUrlInSafariViewController, + [arg_url], + ), + returnValue: + _i3.Future<_i2.InAppLoadResult>.value(_i2.InAppLoadResult.success), + ) as _i3.Future<_i2.InAppLoadResult>); + + @override + _i3.Future closeSafariViewController() => (super.noSuchMethod( + Invocation.method( + #closeSafariViewController, + [], + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); +}