[url_launcher] Convert iOS to Pigeon (#3481)

[url_launcher] Convert iOS to Pigeon
diff --git a/packages/url_launcher/url_launcher_ios/CHANGELOG.md b/packages/url_launcher/url_launcher_ios/CHANGELOG.md
index 26e9c78..0af8882 100644
--- a/packages/url_launcher/url_launcher_ios/CHANGELOG.md
+++ b/packages/url_launcher/url_launcher_ios/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 6.1.3
+
+* Switches to Pigeon for internal implementation.
+
 ## 6.1.2
 
 * Clarifies explanation of endorsement in README.
diff --git a/packages/url_launcher/url_launcher_ios/example/ios/RunnerTests/URLLauncherTests.m b/packages/url_launcher/url_launcher_ios/example/ios/RunnerTests/URLLauncherTests.m
index 6507a95..64a1799 100644
--- a/packages/url_launcher/url_launcher_ios/example/ios/RunnerTests/URLLauncherTests.m
+++ b/packages/url_launcher/url_launcher_ios/example/ios/RunnerTests/URLLauncherTests.m
@@ -2,17 +2,156 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+@import Flutter;
 @import url_launcher_ios;
 @import XCTest;
 
+@interface FULFakeLauncher : NSObject <FULLauncher>
+@property(copy, nonatomic) NSDictionary<UIApplicationOpenExternalURLOptionsKey, id> *passedOptions;
+@end
+
+@implementation FULFakeLauncher
+- (BOOL)canOpenURL:(NSURL *)url {
+  return [url.scheme isEqualToString:@"good"];
+}
+
+- (void)openURL:(NSURL *)url
+              options:(NSDictionary<UIApplicationOpenExternalURLOptionsKey, id> *)options
+    completionHandler:(void (^__nullable)(BOOL success))completion {
+  self.passedOptions = options;
+  completion([url.scheme isEqualToString:@"good"]);
+}
+@end
+
+#pragma mark -
+
 @interface URLLauncherTests : XCTestCase
 @end
 
 @implementation URLLauncherTests
 
-- (void)testPlugin {
-  FLTURLLauncherPlugin *plugin = [[FLTURLLauncherPlugin alloc] init];
-  XCTAssertNotNil(plugin);
+- (void)testCanLaunchSuccess {
+  FULFakeLauncher *launcher = [[FULFakeLauncher alloc] init];
+  FLTURLLauncherPlugin *plugin = [[FLTURLLauncherPlugin alloc] initWithLauncher:launcher];
+
+  FlutterError *error;
+  NSNumber *result = [plugin canLaunchURL:@"good://url" error:&error];
+
+  XCTAssertTrue(result.boolValue);
+  XCTAssertNil(error);
+}
+
+- (void)testCanLaunchFailure {
+  FULFakeLauncher *launcher = [[FULFakeLauncher alloc] init];
+  FLTURLLauncherPlugin *plugin = [[FLTURLLauncherPlugin alloc] initWithLauncher:launcher];
+
+  FlutterError *error;
+  NSNumber *result = [plugin canLaunchURL:@"bad://url" error:&error];
+
+  XCTAssertNotNil(result);
+  XCTAssertFalse(result.boolValue);
+  XCTAssertNil(error);
+}
+
+- (void)testCanLaunchInvalidURL {
+  FULFakeLauncher *launcher = [[FULFakeLauncher alloc] init];
+  FLTURLLauncherPlugin *plugin = [[FLTURLLauncherPlugin alloc] initWithLauncher:launcher];
+
+  FlutterError *error;
+  NSNumber *result = [plugin canLaunchURL:@"urls can't have spaces" error:&error];
+
+  XCTAssertNil(result);
+  XCTAssertEqualObjects(error.code, @"argument_error");
+  XCTAssertEqualObjects(error.message, @"Unable to parse URL");
+  XCTAssertEqualObjects(error.details, @"Provided URL: urls can't have spaces");
+}
+
+- (void)testLaunchSuccess {
+  FULFakeLauncher *launcher = [[FULFakeLauncher alloc] init];
+  FLTURLLauncherPlugin *plugin = [[FLTURLLauncherPlugin alloc] initWithLauncher:launcher];
+  XCTestExpectation *resultExpectation = [self expectationWithDescription:@"result"];
+
+  [plugin launchURL:@"good://url"
+      universalLinksOnly:@NO
+              completion:^(NSNumber *_Nullable result, FlutterError *_Nullable error) {
+                XCTAssertTrue(result.boolValue);
+                XCTAssertNil(error);
+                [resultExpectation fulfill];
+              }];
+
+  [self waitForExpectationsWithTimeout:5 handler:nil];
+}
+
+- (void)testLaunchFailure {
+  FULFakeLauncher *launcher = [[FULFakeLauncher alloc] init];
+  FLTURLLauncherPlugin *plugin = [[FLTURLLauncherPlugin alloc] initWithLauncher:launcher];
+  XCTestExpectation *resultExpectation = [self expectationWithDescription:@"result"];
+
+  [plugin launchURL:@"bad://url"
+      universalLinksOnly:@NO
+              completion:^(NSNumber *_Nullable result, FlutterError *_Nullable error) {
+                XCTAssertNotNil(result);
+                XCTAssertFalse(result.boolValue);
+                XCTAssertNil(error);
+                [resultExpectation fulfill];
+              }];
+
+  [self waitForExpectationsWithTimeout:5 handler:nil];
+}
+
+- (void)testLaunchInvalidURL {
+  FULFakeLauncher *launcher = [[FULFakeLauncher alloc] init];
+  FLTURLLauncherPlugin *plugin = [[FLTURLLauncherPlugin alloc] initWithLauncher:launcher];
+  XCTestExpectation *resultExpectation = [self expectationWithDescription:@"result"];
+
+  [plugin launchURL:@"urls can't have spaces"
+      universalLinksOnly:@NO
+              completion:^(NSNumber *_Nullable result, FlutterError *_Nullable error) {
+                XCTAssertNil(result);
+                XCTAssertNotNil(error);
+                XCTAssertEqualObjects(error.code, @"argument_error");
+                XCTAssertEqualObjects(error.message, @"Unable to parse URL");
+                XCTAssertEqualObjects(error.details, @"Provided URL: urls can't have spaces");
+                [resultExpectation fulfill];
+              }];
+
+  [self waitForExpectationsWithTimeout:5 handler:nil];
+}
+
+- (void)testLaunchWithoutUniversalLinks {
+  FULFakeLauncher *launcher = [[FULFakeLauncher alloc] init];
+  FLTURLLauncherPlugin *plugin = [[FLTURLLauncherPlugin alloc] initWithLauncher:launcher];
+  XCTestExpectation *resultExpectation = [self expectationWithDescription:@"result"];
+
+  FlutterError *error;
+  [plugin launchURL:@"good://url"
+      universalLinksOnly:@NO
+              completion:^(NSNumber *_Nullable result, FlutterError *_Nullable error) {
+                [resultExpectation fulfill];
+              }];
+
+  [self waitForExpectationsWithTimeout:5 handler:nil];
+  XCTAssertNil(error);
+  XCTAssertFalse(
+      ((NSNumber *)launcher.passedOptions[UIApplicationOpenURLOptionUniversalLinksOnly]).boolValue);
+}
+
+- (void)testLaunchWithUniversalLinks {
+  FULFakeLauncher *launcher = [[FULFakeLauncher alloc] init];
+  FLTURLLauncherPlugin *plugin = [[FLTURLLauncherPlugin alloc] initWithLauncher:launcher];
+  XCTestExpectation *resultExpectation = [self expectationWithDescription:@"result"];
+
+  FlutterError *error;
+  [plugin launchURL:@"good://url"
+      universalLinksOnly:@YES
+              completion:^(NSNumber *_Nullable result, FlutterError *_Nullable error) {
+                [resultExpectation fulfill];
+              }];
+
+  [self waitForExpectationsWithTimeout:5 handler:nil];
+  XCTAssertNil(error);
+  XCTAssertTrue(
+      ((NSNumber *)launcher.passedOptions[UIApplicationOpenURLOptionUniversalLinksOnly]).boolValue);
 }
 
 @end
diff --git a/packages/url_launcher/url_launcher_ios/example/pubspec.yaml b/packages/url_launcher/url_launcher_ios/example/pubspec.yaml
index ebfb6e8..89c77b9 100644
--- a/packages/url_launcher/url_launcher_ios/example/pubspec.yaml
+++ b/packages/url_launcher/url_launcher_ios/example/pubspec.yaml
@@ -25,7 +25,6 @@
     sdk: flutter
   integration_test:
     sdk: flutter
-  mockito: 5.3.2
   plugin_platform_interface: ^2.0.0
 
 flutter:
diff --git a/packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin.h b/packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin.h
index 73589d2..7b3480e 100644
--- a/packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin.h
+++ b/packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin.h
@@ -4,5 +4,7 @@
 
 #import <Flutter/Flutter.h>
 
-@interface FLTURLLauncherPlugin : NSObject <FlutterPlugin>
+#import "messages.g.h"
+
+@interface FLTURLLauncherPlugin : NSObject <FlutterPlugin, FULUrlLauncherApi>
 @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
index 375d5e2..5d6a75f 100644
--- a/packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin.m
+++ b/packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin.m
@@ -5,10 +5,15 @@
 #import <SafariServices/SafariServices.h>
 
 #import "FLTURLLauncherPlugin.h"
+#import "FLTURLLauncherPlugin_Test.h"
+#import "FULLauncher.h"
+#import "messages.g.h"
+
+typedef void (^OpenInSafariVCResponse)(NSNumber *_Nullable, FlutterError *_Nullable);
 
 @interface FLTURLLaunchSession : NSObject <SFSafariViewControllerDelegate>
 
-@property(copy, nonatomic) FlutterResult flutterResult;
+@property(copy, nonatomic) OpenInSafariVCResponse completion;
 @property(strong, nonatomic) NSURL *url;
 @property(strong, nonatomic) SFSafariViewController *safari;
 @property(nonatomic, copy) void (^didFinish)(void);
@@ -17,11 +22,11 @@
 
 @implementation FLTURLLaunchSession
 
-- (instancetype)initWithUrl:url withFlutterResult:result {
+- (instancetype)initWithURL:url completion:completion {
   self = [super init];
   if (self) {
     self.url = url;
-    self.flutterResult = result;
+    self.completion = completion;
     self.safari = [[SFSafariViewController alloc] initWithURL:url];
     self.safari.delegate = self;
   }
@@ -31,12 +36,13 @@
 - (void)safariViewController:(SFSafariViewController *)controller
       didCompleteInitialLoad:(BOOL)didLoadSuccessfully {
   if (didLoadSuccessfully) {
-    self.flutterResult(@YES);
+    self.completion(@YES, nil);
   } else {
-    self.flutterResult([FlutterError
-        errorWithCode:@"Error"
-              message:[NSString stringWithFormat:@"Error while launching %@", self.url]
-              details:nil]);
+    self.completion(
+        nil, [FlutterError
+                 errorWithCode:@"Error"
+                       message:[NSString stringWithFormat:@"Error while launching %@", self.url]
+                       details:nil]);
   }
 }
 
@@ -51,64 +57,86 @@
 
 @end
 
+#pragma mark -
+
+/// Default implementation of FULLancher, using UIApplication.
+@interface FULUIApplicationLauncher : NSObject <FULLauncher>
+@end
+
+@implementation FULUIApplicationLauncher
+- (BOOL)canOpenURL:(nonnull NSURL *)url {
+  return [[UIApplication sharedApplication] canOpenURL:url];
+}
+
+- (void)openURL:(nonnull NSURL *)url
+              options:(nonnull NSDictionary<UIApplicationOpenExternalURLOptionsKey, id> *)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<FULLauncher> *launcher;
 
 @end
 
 @implementation FLTURLLauncherPlugin
 
 + (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar {
-  FlutterMethodChannel *channel =
-      [FlutterMethodChannel methodChannelWithName:@"plugins.flutter.io/url_launcher_ios"
-                                  binaryMessenger:registrar.messenger];
   FLTURLLauncherPlugin *plugin = [[FLTURLLauncherPlugin alloc] init];
-  [registrar addMethodCallDelegate:plugin channel:channel];
+  FULUrlLauncherApiSetup(registrar.messenger, plugin);
 }
 
-- (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result {
-  NSString *url = call.arguments[@"url"];
-  if ([@"canLaunch" isEqualToString:call.method]) {
-    result(@([self canLaunchURL:url]));
-  } else if ([@"launch" isEqualToString:call.method]) {
-    NSNumber *useSafariVC = call.arguments[@"useSafariVC"];
-    if (useSafariVC.boolValue) {
-      [self launchURLInVC:url result:result];
-    } else {
-      [self launchURL:url call:call result:result];
-    }
-  } else if ([@"closeWebView" isEqualToString:call.method]) {
-    [self closeWebViewWithResult:result];
-  } else {
-    result(FlutterMethodNotImplemented);
+- (instancetype)init {
+  return [self initWithLauncher:[[FULUIApplicationLauncher alloc] init]];
+}
+
+- (instancetype)initWithLauncher:(NSObject<FULLauncher> *)launcher {
+  if (self = [super init]) {
+    _launcher = launcher;
   }
+  return self;
 }
 
-- (BOOL)canLaunchURL:(NSString *)urlString {
+- (nullable NSNumber *)canLaunchURL:(NSString *)urlString
+                              error:(FlutterError *_Nullable *_Nonnull)error {
   NSURL *url = [NSURL URLWithString:urlString];
-  UIApplication *application = [UIApplication sharedApplication];
-  return [application canOpenURL:url];
+  if (!url) {
+    *error = [self invalidURLErrorForURLString:urlString];
+    return nil;
+  }
+  return @([self.launcher canOpenURL:url]);
 }
 
 - (void)launchURL:(NSString *)urlString
-             call:(FlutterMethodCall *)call
-           result:(FlutterResult)result {
+    universalLinksOnly:(NSNumber *)universalLinksOnly
+            completion:(void (^)(NSNumber *_Nullable, FlutterError *_Nullable))completion {
   NSURL *url = [NSURL URLWithString:urlString];
-  UIApplication *application = [UIApplication sharedApplication];
-
-  NSNumber *universalLinksOnly = call.arguments[@"universalLinksOnly"] ?: @0;
+  if (!url) {
+    completion(nil, [self invalidURLErrorForURLString:urlString]);
+    return;
+  }
   NSDictionary *options = @{UIApplicationOpenURLOptionUniversalLinksOnly : universalLinksOnly};
-  [application openURL:url
-                options:options
-      completionHandler:^(BOOL success) {
-        result(@(success));
-      }];
+  [self.launcher openURL:url
+                 options:options
+       completionHandler:^(BOOL success) {
+         completion(@(success), nil);
+       }];
 }
 
-- (void)launchURLInVC:(NSString *)urlString result:(FlutterResult)result {
+- (void)openSafariViewControllerWithURL:(NSString *)urlString
+                             completion:(OpenInSafariVCResponse)completion {
   NSURL *url = [NSURL URLWithString:urlString];
-  self.currentSession = [[FLTURLLaunchSession alloc] initWithUrl:url withFlutterResult:result];
+  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;
@@ -118,11 +146,8 @@
                                      completion:nil];
 }
 
-- (void)closeWebViewWithResult:(FlutterResult)result {
-  if (self.currentSession != nil) {
-    [self.currentSession close];
-  }
-  result(nil);
+- (void)closeSafariViewControllerWithError:(FlutterError *_Nullable *_Nonnull)error {
+  [self.currentSession close];
 }
 
 - (UIViewController *)topViewController {
@@ -162,4 +187,16 @@
   }
   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
new file mode 100644
index 0000000..112682a
--- /dev/null
+++ b/packages/url_launcher/url_launcher_ios/ios/Classes/FLTURLLauncherPlugin_Test.h
@@ -0,0 +1,11 @@
+// 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<FULLauncher> *)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
new file mode 100644
index 0000000..63f8e04
--- /dev/null
+++ b/packages/url_launcher/url_launcher_ios/ios/Classes/FULLauncher.h
@@ -0,0 +1,19 @@
+// 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 <Foundation/Foundation.h>
+
+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<UIApplicationOpenExternalURLOptionsKey, id> *)options
+    completionHandler:(void (^__nullable)(BOOL success))completion;
+@end
+
+NS_ASSUME_NONNULL_END
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
new file mode 100644
index 0000000..9208920
--- /dev/null
+++ b/packages/url_launcher/url_launcher_ios/ios/Classes/messages.g.h
@@ -0,0 +1,40 @@
+// 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.0.7), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+
+#import <Foundation/Foundation.h>
+
+@protocol FlutterBinaryMessenger;
+@protocol FlutterMessageCodec;
+@class FlutterError;
+@class FlutterStandardTypedData;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// The codec used by FULUrlLauncherApi.
+NSObject<FlutterMessageCodec> *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<FlutterBinaryMessenger> binaryMessenger,
+                                   NSObject<FULUrlLauncherApi> *_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
new file mode 100644
index 0000000..5c655bd
--- /dev/null
+++ b/packages/url_launcher/url_launcher_ios/ios/Classes/messages.g.m
@@ -0,0 +1,126 @@
+// 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.0.7), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+
+#import "messages.g.h"
+#import <Flutter/Flutter.h>
+
+#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<FlutterMessageCodec> *FULUrlLauncherApiGetCodec() {
+  static FlutterStandardMessageCodec *sSharedObject = nil;
+  sSharedObject = [FlutterStandardMessageCodec sharedInstance];
+  return sSharedObject;
+}
+
+void FULUrlLauncherApiSetup(id<FlutterBinaryMessenger> binaryMessenger,
+                            NSObject<FULUrlLauncherApi> *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/lib/src/messages.g.dart b/packages/url_launcher/url_launcher_ios/lib/src/messages.g.dart
new file mode 100644
index 0000000..43ec1ed
--- /dev/null
+++ b/packages/url_launcher/url_launcher_ios/lib/src/messages.g.dart
@@ -0,0 +1,131 @@
+// 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.0.7), 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
+
+import 'dart:async';
+import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
+
+import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
+import 'package:flutter/services.dart';
+
+class UrlLauncherApi {
+  /// Constructor for [UrlLauncherApi].  The [binaryMessenger] named argument is
+  /// available for dependency injection.  If it is left null, the default
+  /// BinaryMessenger will be used which routes to the host platform.
+  UrlLauncherApi({BinaryMessenger? binaryMessenger})
+      : _binaryMessenger = binaryMessenger;
+  final BinaryMessenger? _binaryMessenger;
+
+  static const MessageCodec<Object?> codec = StandardMessageCodec();
+
+  /// Returns true if the URL can definitely be launched.
+  Future<bool> canLaunchUrl(String arg_url) async {
+    final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>(
+        'dev.flutter.pigeon.UrlLauncherApi.canLaunchUrl', codec,
+        binaryMessenger: _binaryMessenger);
+    final List<Object?>? replyList =
+        await channel.send(<Object?>[arg_url]) as List<Object?>?;
+    if (replyList == null) {
+      throw PlatformException(
+        code: 'channel-error',
+        message: 'Unable to establish connection on channel.',
+      );
+    } else if (replyList.length > 1) {
+      throw PlatformException(
+        code: replyList[0]! as String,
+        message: replyList[1] as String?,
+        details: replyList[2],
+      );
+    } else if (replyList[0] == null) {
+      throw PlatformException(
+        code: 'null-error',
+        message: 'Host platform returned null value for non-null return value.',
+      );
+    } else {
+      return (replyList[0] as bool?)!;
+    }
+  }
+
+  /// Opens the URL externally, returning true if successful.
+  Future<bool> launchUrl(String arg_url, bool arg_universalLinksOnly) async {
+    final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>(
+        'dev.flutter.pigeon.UrlLauncherApi.launchUrl', codec,
+        binaryMessenger: _binaryMessenger);
+    final List<Object?>? replyList = await channel
+        .send(<Object?>[arg_url, arg_universalLinksOnly]) as List<Object?>?;
+    if (replyList == null) {
+      throw PlatformException(
+        code: 'channel-error',
+        message: 'Unable to establish connection on channel.',
+      );
+    } else if (replyList.length > 1) {
+      throw PlatformException(
+        code: replyList[0]! as String,
+        message: replyList[1] as String?,
+        details: replyList[2],
+      );
+    } else if (replyList[0] == null) {
+      throw PlatformException(
+        code: 'null-error',
+        message: 'Host platform returned null value for non-null return value.',
+      );
+    } else {
+      return (replyList[0] as bool?)!;
+    }
+  }
+
+  /// Opens the URL in an in-app SFSafariViewController, returning true
+  /// when it has loaded successfully.
+  Future<bool> openUrlInSafariViewController(String arg_url) async {
+    final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>(
+        'dev.flutter.pigeon.UrlLauncherApi.openUrlInSafariViewController',
+        codec,
+        binaryMessenger: _binaryMessenger);
+    final List<Object?>? replyList =
+        await channel.send(<Object?>[arg_url]) as List<Object?>?;
+    if (replyList == null) {
+      throw PlatformException(
+        code: 'channel-error',
+        message: 'Unable to establish connection on channel.',
+      );
+    } else if (replyList.length > 1) {
+      throw PlatformException(
+        code: replyList[0]! as String,
+        message: replyList[1] as String?,
+        details: replyList[2],
+      );
+    } else if (replyList[0] == null) {
+      throw PlatformException(
+        code: 'null-error',
+        message: 'Host platform returned null value for non-null return value.',
+      );
+    } else {
+      return (replyList[0] as bool?)!;
+    }
+  }
+
+  /// Closes the view controller opened by [openUrlInSafariViewController].
+  Future<void> closeSafariViewController() async {
+    final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>(
+        'dev.flutter.pigeon.UrlLauncherApi.closeSafariViewController', codec,
+        binaryMessenger: _binaryMessenger);
+    final List<Object?>? replyList = await channel.send(null) as List<Object?>?;
+    if (replyList == null) {
+      throw PlatformException(
+        code: 'channel-error',
+        message: 'Unable to establish connection on channel.',
+      );
+    } else if (replyList.length > 1) {
+      throw PlatformException(
+        code: replyList[0]! as String,
+        message: replyList[1] as String?,
+        details: replyList[2],
+      );
+    } else {
+      return;
+    }
+  }
+}
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 84b811b..2f0e9f4 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
@@ -2,17 +2,21 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'dart:async';
-
-import 'package:flutter/services.dart';
+import 'package:flutter/foundation.dart' show visibleForTesting;
 import 'package:url_launcher_platform_interface/link.dart';
 import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
 
-const MethodChannel _channel =
-    MethodChannel('plugins.flutter.io/url_launcher_ios');
+import 'src/messages.g.dart';
 
 /// An implementation of [UrlLauncherPlatform] for iOS.
 class UrlLauncherIOS extends UrlLauncherPlatform {
+  /// Creates a new plugin implementation instance.
+  UrlLauncherIOS({
+    @visibleForTesting UrlLauncherApi? api,
+  }) : _hostApi = api ?? UrlLauncherApi();
+
+  final UrlLauncherApi _hostApi;
+
   /// Registers this class as the default instance of [UrlLauncherPlatform].
   static void registerWith() {
     UrlLauncherPlatform.instance = UrlLauncherIOS();
@@ -23,15 +27,12 @@
 
   @override
   Future<bool> canLaunch(String url) {
-    return _channel.invokeMethod<bool>(
-      'canLaunch',
-      <String, Object>{'url': url},
-    ).then((bool? value) => value ?? false);
+    return _hostApi.canLaunchUrl(url);
   }
 
   @override
   Future<void> closeWebView() {
-    return _channel.invokeMethod<void>('closeWebView');
+    return _hostApi.closeSafariViewController();
   }
 
   @override
@@ -45,16 +46,10 @@
     required Map<String, String> headers,
     String? webOnlyWindowName,
   }) {
-    return _channel.invokeMethod<bool>(
-      'launch',
-      <String, Object>{
-        'url': url,
-        'useSafariVC': useSafariVC,
-        'enableJavaScript': enableJavaScript,
-        'enableDomStorage': enableDomStorage,
-        'universalLinksOnly': universalLinksOnly,
-        'headers': headers,
-      },
-    ).then((bool? value) => value ?? false);
+    if (useSafariVC) {
+      return _hostApi.openUrlInSafariViewController(url);
+    } else {
+      return _hostApi.launchUrl(url, universalLinksOnly);
+    }
   }
 }
diff --git a/packages/url_launcher/url_launcher_ios/pigeons/copyright.txt b/packages/url_launcher/url_launcher_ios/pigeons/copyright.txt
new file mode 100644
index 0000000..1236b63
--- /dev/null
+++ b/packages/url_launcher/url_launcher_ios/pigeons/copyright.txt
@@ -0,0 +1,3 @@
+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.
diff --git a/packages/url_launcher/url_launcher_ios/pigeons/messages.dart b/packages/url_launcher/url_launcher_ios/pigeons/messages.dart
new file mode 100644
index 0000000..f6935cb
--- /dev/null
+++ b/packages/url_launcher/url_launcher_ios/pigeons/messages.dart
@@ -0,0 +1,33 @@
+// 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 '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',
+  copyrightHeader: 'pigeons/copyright.txt',
+))
+@HostApi()
+abstract class UrlLauncherApi {
+  /// Returns true if the URL can definitely be launched.
+  @ObjCSelector('canLaunchURL:')
+  bool canLaunchUrl(String url);
+
+  /// Opens the URL externally, returning true if successful.
+  @async
+  @ObjCSelector('launchURL:universalLinksOnly:')
+  bool launchUrl(String url, bool universalLinksOnly);
+
+  /// Opens the URL in an in-app SFSafariViewController, returning true
+  /// when it has loaded successfully.
+  @async
+  @ObjCSelector('openSafariViewControllerWithURL:')
+  bool 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 3cdceb2..f4b3e3a 100644
--- a/packages/url_launcher/url_launcher_ios/pubspec.yaml
+++ b/packages/url_launcher/url_launcher_ios/pubspec.yaml
@@ -2,7 +2,7 @@
 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.1.2
+version: 6.1.3
 
 environment:
   sdk: '>=2.18.0 <3.0.0'
@@ -24,6 +24,6 @@
 dev_dependencies:
   flutter_test:
     sdk: flutter
-  mockito: 5.3.2
+  pigeon: ^9.0.7
   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 34dac1c..f87859e 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,28 +4,18 @@
 
 import 'package:flutter/services.dart';
 import 'package:flutter_test/flutter_test.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();
 
-  group('$UrlLauncherIOS', () {
-    const MethodChannel channel =
-        MethodChannel('plugins.flutter.io/url_launcher_ios');
-    final List<MethodCall> log = <MethodCall>[];
-    _ambiguate(TestDefaultBinaryMessengerBinding.instance)!
-        .defaultBinaryMessenger
-        .setMockMethodCallHandler(channel, (MethodCall methodCall) async {
-      log.add(methodCall);
+  group('UrlLauncherIOS', () {
+    late _FakeUrlLauncherApi api;
 
-      // Return null explicitly instead of relying on the implicit null
-      // returned by the method channel if no return statement is specified.
-      return null;
-    });
-
-    tearDown(() {
-      log.clear();
+    setUp(() {
+      api = _FakeUrlLauncherApi();
     });
 
     test('registers instance', () {
@@ -33,184 +23,167 @@
       expect(UrlLauncherPlatform.instance, isA<UrlLauncherIOS>());
     });
 
-    test('canLaunch', () async {
-      final UrlLauncherIOS launcher = UrlLauncherIOS();
-      await launcher.canLaunch('http://example.com/');
-      expect(
-        log,
-        <Matcher>[
-          isMethodCall('canLaunch', arguments: <String, Object>{
-            'url': 'http://example.com/',
-          })
-        ],
-      );
+    test('canLaunch success', () async {
+      final UrlLauncherIOS launcher = UrlLauncherIOS(api: api);
+      expect(await launcher.canLaunch('http://example.com/'), true);
     });
 
-    test('canLaunch should return false if platform returns null', () async {
-      final UrlLauncherIOS launcher = UrlLauncherIOS();
-      final bool canLaunch = await launcher.canLaunch('http://example.com/');
-
-      expect(canLaunch, false);
+    test('canLaunch failure', () async {
+      final UrlLauncherIOS launcher = UrlLauncherIOS(api: api);
+      expect(await launcher.canLaunch('unknown://scheme'), false);
     });
 
-    test('launch', () async {
-      final UrlLauncherIOS launcher = UrlLauncherIOS();
-      await launcher.launch(
-        'http://example.com/',
-        useSafariVC: true,
-        useWebView: false,
-        enableJavaScript: false,
-        enableDomStorage: false,
-        universalLinksOnly: false,
-        headers: const <String, String>{},
-      );
-      expect(
-        log,
-        <Matcher>[
-          isMethodCall('launch', arguments: <String, Object>{
-            'url': 'http://example.com/',
-            'useSafariVC': true,
-            'enableJavaScript': false,
-            'enableDomStorage': false,
-            'universalLinksOnly': false,
-            'headers': <String, String>{},
-          })
-        ],
-      );
+    test('canLaunch invalid URL passes the PlatformException through',
+        () async {
+      final UrlLauncherIOS launcher = UrlLauncherIOS(api: api);
+      expectLater(launcher.canLaunch('invalid://u r l'),
+          throwsA(isA<PlatformException>()));
     });
 
-    test('launch with headers', () async {
-      final UrlLauncherIOS launcher = UrlLauncherIOS();
-      await launcher.launch(
-        'http://example.com/',
-        useSafariVC: true,
-        useWebView: false,
-        enableJavaScript: false,
-        enableDomStorage: false,
-        universalLinksOnly: false,
-        headers: const <String, String>{'key': 'value'},
-      );
+    test('launch success', () async {
+      final UrlLauncherIOS launcher = UrlLauncherIOS(api: api);
       expect(
-        log,
-        <Matcher>[
-          isMethodCall('launch', arguments: <String, Object>{
-            'url': 'http://example.com/',
-            'useSafariVC': true,
-            'enableJavaScript': false,
-            'enableDomStorage': false,
-            'universalLinksOnly': false,
-            'headers': <String, String>{'key': 'value'},
-          })
-        ],
-      );
+          await launcher.launch(
+            'http://example.com/',
+            useSafariVC: false,
+            useWebView: false,
+            enableJavaScript: false,
+            enableDomStorage: false,
+            universalLinksOnly: false,
+            headers: const <String, String>{},
+          ),
+          true);
+      expect(api.passedUniversalLinksOnly, false);
+    });
+
+    test('launch failure', () async {
+      final UrlLauncherIOS launcher = UrlLauncherIOS(api: api);
+      expect(
+          await launcher.launch(
+            'unknown://scheme',
+            useSafariVC: false,
+            useWebView: false,
+            enableJavaScript: false,
+            enableDomStorage: false,
+            universalLinksOnly: false,
+            headers: const <String, String>{},
+          ),
+          false);
+      expect(api.passedUniversalLinksOnly, false);
+    });
+
+    test('launch invalid URL passes the PlatformException through', () async {
+      final UrlLauncherIOS launcher = UrlLauncherIOS(api: api);
+      expectLater(
+          launcher.launch(
+            'invalid://u r l',
+            useSafariVC: false,
+            useWebView: false,
+            enableJavaScript: false,
+            enableDomStorage: false,
+            universalLinksOnly: false,
+            headers: const <String, String>{},
+          ),
+          throwsA(isA<PlatformException>()));
     });
 
     test('launch force SafariVC', () async {
-      final UrlLauncherIOS launcher = UrlLauncherIOS();
-      await launcher.launch(
-        'http://example.com/',
-        useSafariVC: true,
-        useWebView: false,
-        enableJavaScript: false,
-        enableDomStorage: false,
-        universalLinksOnly: false,
-        headers: const <String, String>{},
-      );
+      final UrlLauncherIOS launcher = UrlLauncherIOS(api: api);
       expect(
-        log,
-        <Matcher>[
-          isMethodCall('launch', arguments: <String, Object>{
-            'url': 'http://example.com/',
-            'useSafariVC': true,
-            'enableJavaScript': false,
-            'enableDomStorage': false,
-            'universalLinksOnly': false,
-            'headers': <String, String>{},
-          })
-        ],
-      );
+          await launcher.launch(
+            'http://example.com/',
+            useSafariVC: true,
+            useWebView: false,
+            enableJavaScript: false,
+            enableDomStorage: false,
+            universalLinksOnly: false,
+            headers: const <String, String>{},
+          ),
+          true);
+      expect(api.usedSafariViewController, true);
     });
 
     test('launch universal links only', () async {
-      final UrlLauncherIOS launcher = UrlLauncherIOS();
-      await launcher.launch(
-        'http://example.com/',
-        useSafariVC: false,
-        useWebView: false,
-        enableJavaScript: false,
-        enableDomStorage: false,
-        universalLinksOnly: true,
-        headers: const <String, String>{},
-      );
+      final UrlLauncherIOS launcher = UrlLauncherIOS(api: api);
       expect(
-        log,
-        <Matcher>[
-          isMethodCall('launch', arguments: <String, Object>{
-            'url': 'http://example.com/',
-            'useSafariVC': false,
-            'enableJavaScript': false,
-            'enableDomStorage': false,
-            'universalLinksOnly': true,
-            'headers': <String, String>{},
-          })
-        ],
-      );
+          await launcher.launch(
+            'http://example.com/',
+            useSafariVC: false,
+            useWebView: false,
+            enableJavaScript: false,
+            enableDomStorage: false,
+            universalLinksOnly: true,
+            headers: const <String, String>{},
+          ),
+          true);
+      expect(api.passedUniversalLinksOnly, true);
     });
 
     test('launch force SafariVC to false', () async {
-      final UrlLauncherIOS launcher = UrlLauncherIOS();
-      await launcher.launch(
-        'http://example.com/',
-        useSafariVC: false,
-        useWebView: false,
-        enableJavaScript: false,
-        enableDomStorage: false,
-        universalLinksOnly: false,
-        headers: const <String, String>{},
-      );
+      final UrlLauncherIOS launcher = UrlLauncherIOS(api: api);
       expect(
-        log,
-        <Matcher>[
-          isMethodCall('launch', arguments: <String, Object>{
-            'url': 'http://example.com/',
-            'useSafariVC': false,
-            'enableJavaScript': false,
-            'enableDomStorage': false,
-            'universalLinksOnly': false,
-            'headers': <String, String>{},
-          })
-        ],
-      );
-    });
-
-    test('launch should return false if platform returns null', () async {
-      final UrlLauncherIOS launcher = UrlLauncherIOS();
-      final bool launched = await launcher.launch(
-        'http://example.com/',
-        useSafariVC: true,
-        useWebView: false,
-        enableJavaScript: false,
-        enableDomStorage: false,
-        universalLinksOnly: false,
-        headers: const <String, String>{},
-      );
-
-      expect(launched, false);
+          await launcher.launch(
+            'http://example.com/',
+            useSafariVC: false,
+            useWebView: false,
+            enableJavaScript: false,
+            enableDomStorage: false,
+            universalLinksOnly: false,
+            headers: const <String, String>{},
+          ),
+          true);
+      expect(api.usedSafariViewController, false);
     });
 
     test('closeWebView default behavior', () async {
-      final UrlLauncherIOS launcher = UrlLauncherIOS();
+      final UrlLauncherIOS launcher = UrlLauncherIOS(api: api);
       await launcher.closeWebView();
-      expect(
-        log,
-        <Matcher>[isMethodCall('closeWebView', arguments: null)],
-      );
+      expect(api.closed, true);
     });
   });
 }
 
-/// This allows a value of type T or T? to be treated as a value of type T?.
+/// A fake implementation of the host API that reacts to specific schemes.
 ///
-/// We use this so that APIs that have become non-nullable can still be used
-/// with `!` and `?` on the stable branch.
-T? _ambiguate<T>(T? value) => value;
+/// See _isLaunchable for the behaviors.
+class _FakeUrlLauncherApi implements UrlLauncherApi {
+  bool? passedUniversalLinksOnly;
+  bool? usedSafariViewController;
+  bool? closed;
+
+  @override
+  Future<bool> canLaunchUrl(String url) async {
+    return _isLaunchable(url);
+  }
+
+  @override
+  Future<bool> launchUrl(String url, bool universalLinksOnly) async {
+    passedUniversalLinksOnly = universalLinksOnly;
+    usedSafariViewController = false;
+    return _isLaunchable(url);
+  }
+
+  @override
+  Future<bool> openUrlInSafariViewController(String url) async {
+    usedSafariViewController = true;
+    return _isLaunchable(url);
+  }
+
+  @override
+  Future<void> closeSafariViewController() async {
+    closed = true;
+  }
+
+  bool _isLaunchable(String url) {
+    final String scheme = url.split(':')[0];
+    switch (scheme) {
+      case 'http':
+      case 'https':
+        return true;
+      case 'invalid':
+        throw PlatformException(code: 'argument_error');
+      default:
+        return false;
+    }
+  }
+}