diff --git a/packages/url_launcher/url_launcher_ios/CHANGELOG.md b/packages/url_launcher/url_launcher_ios/CHANGELOG.md index 26e9c7870e3..0af8882c366 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 6507a95a9d0..64a17994b6f 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 +@property(copy, nonatomic) NSDictionary *passedOptions; +@end + +@implementation FULFakeLauncher +- (BOOL)canOpenURL:(NSURL *)url { + return [url.scheme isEqualToString:@"good"]; +} + +- (void)openURL:(NSURL *)url + options:(NSDictionary *)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 ebfb6e8eed1..89c77b9dfb6 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 @@ dev_dependencies: 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 73589d2a0b7..7b3480e3d47 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 -@interface FLTURLLauncherPlugin : NSObject +#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 index 375d5e2a235..5d6a75f97aa 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 #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) 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 @@ @interface FLTURLLaunchSession : NSObject @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 @@ - (instancetype)initWithUrl:url withFlutterResult:result { - (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 @@ - (void)close { @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 { - FlutterMethodChannel *channel = - [FlutterMethodChannel methodChannelWithName:@"plugins.flutter.io/url_launcher_ios" - binaryMessenger:registrar.messenger]; FLTURLLauncherPlugin *plugin = [[FLTURLLauncherPlugin alloc] init]; - [registrar addMethodCallDelegate:plugin channel:channel]; -} - -- (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); + FULUrlLauncherApiSetup(registrar.messenger, plugin); +} + +- (instancetype)init { + return [self initWithLauncher:[[FULUIApplicationLauncher alloc] init]]; +} + +- (instancetype)initWithLauncher:(NSObject *)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 @@ - (void)launchURLInVC:(NSString *)urlString result:(FlutterResult)result { 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 @@ - (UIViewController *)topViewControllerFromViewController:(UIViewController *)vi } 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 00000000000..112682a9469 --- /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 *)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 00000000000..63f8e04b66d --- /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 + +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/messages.g.h b/packages/url_launcher/url_launcher_ios/ios/Classes/messages.g.h new file mode 100644 index 00000000000..92089205d46 --- /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 + +@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 new file mode 100644 index 00000000000..5c655bd2827 --- /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 + +#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() { + 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/lib/src/messages.g.dart b/packages/url_launcher/url_launcher_ios/lib/src/messages.g.dart new file mode 100644 index 00000000000..43ec1ed1275 --- /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 codec = StandardMessageCodec(); + + /// Returns true if the URL can definitely be launched. + Future canLaunchUrl(String arg_url) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.UrlLauncherApi.canLaunchUrl', codec, + binaryMessenger: _binaryMessenger); + final List? replyList = + await channel.send([arg_url]) as List?; + 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 launchUrl(String arg_url, bool arg_universalLinksOnly) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.UrlLauncherApi.launchUrl', codec, + binaryMessenger: _binaryMessenger); + final List? replyList = await channel + .send([arg_url, arg_universalLinksOnly]) as List?; + 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 openUrlInSafariViewController(String arg_url) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.UrlLauncherApi.openUrlInSafariViewController', + codec, + binaryMessenger: _binaryMessenger); + final List? replyList = + await channel.send([arg_url]) as List?; + 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 closeSafariViewController() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.UrlLauncherApi.closeSafariViewController', codec, + binaryMessenger: _binaryMessenger); + final List? replyList = await channel.send(null) as List?; + 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 84b811b2572..2f0e9f47b94 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 @@ class UrlLauncherIOS extends UrlLauncherPlatform { @override Future canLaunch(String url) { - return _channel.invokeMethod( - 'canLaunch', - {'url': url}, - ).then((bool? value) => value ?? false); + return _hostApi.canLaunchUrl(url); } @override Future closeWebView() { - return _channel.invokeMethod('closeWebView'); + return _hostApi.closeSafariViewController(); } @override @@ -45,16 +46,10 @@ class UrlLauncherIOS extends UrlLauncherPlatform { required Map headers, String? webOnlyWindowName, }) { - return _channel.invokeMethod( - 'launch', - { - '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 00000000000..1236b63caf3 --- /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 00000000000..f6935cbd882 --- /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 3cdceb27a94..f4b3e3a1671 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.1.2 +version: 6.1.3 environment: sdk: '>=2.18.0 <3.0.0' @@ -24,6 +24,6 @@ dependencies: 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 34dac1c4f92..f87859e3fc4 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 log = []; - _ambiguate(TestDefaultBinaryMessengerBinding.instance)! - .defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - log.add(methodCall); - - // Return null explicitly instead of relying on the implicit null - // returned by the method channel if no return statement is specified. - return null; - }); + group('UrlLauncherIOS', () { + late _FakeUrlLauncherApi api; - tearDown(() { - log.clear(); + setUp(() { + api = _FakeUrlLauncherApi(); }); test('registers instance', () { @@ -33,184 +23,167 @@ void main() { expect(UrlLauncherPlatform.instance, isA()); }); - test('canLaunch', () async { - final UrlLauncherIOS launcher = UrlLauncherIOS(); - await launcher.canLaunch('http://example.com/'); - expect( - log, - [ - isMethodCall('canLaunch', arguments: { - '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/'); + test('canLaunch failure', () async { + final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); + expect(await launcher.canLaunch('unknown://scheme'), false); + }); - expect(canLaunch, false); + test('canLaunch invalid URL passes the PlatformException through', + () async { + final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); + expectLater(launcher.canLaunch('invalid://u r l'), + throwsA(isA())); }); - 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 {}, - ); + test('launch success', () async { + final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); expect( - log, - [ - isMethodCall('launch', arguments: { - 'url': 'http://example.com/', - 'useSafariVC': true, - 'enableJavaScript': false, - 'enableDomStorage': false, - 'universalLinksOnly': false, - 'headers': {}, - }) - ], - ); + await launcher.launch( + 'http://example.com/', + useSafariVC: false, + useWebView: false, + enableJavaScript: false, + enableDomStorage: false, + universalLinksOnly: false, + headers: const {}, + ), + true); + expect(api.passedUniversalLinksOnly, false); }); - 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 {'key': 'value'}, - ); + test('launch failure', () async { + final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); expect( - log, - [ - isMethodCall('launch', arguments: { - 'url': 'http://example.com/', - 'useSafariVC': true, - 'enableJavaScript': false, - 'enableDomStorage': false, - 'universalLinksOnly': false, - 'headers': {'key': 'value'}, - }) - ], - ); + await launcher.launch( + 'unknown://scheme', + useSafariVC: false, + useWebView: false, + enableJavaScript: false, + enableDomStorage: false, + universalLinksOnly: false, + headers: const {}, + ), + 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 {}, + ), + throwsA(isA())); }); 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 {}, - ); + final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); expect( - log, - [ - isMethodCall('launch', arguments: { - 'url': 'http://example.com/', - 'useSafariVC': true, - 'enableJavaScript': false, - 'enableDomStorage': false, - 'universalLinksOnly': false, - 'headers': {}, - }) - ], - ); + await launcher.launch( + 'http://example.com/', + useSafariVC: true, + useWebView: false, + enableJavaScript: false, + enableDomStorage: false, + universalLinksOnly: false, + headers: const {}, + ), + 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 {}, - ); + final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); expect( - log, - [ - isMethodCall('launch', arguments: { - 'url': 'http://example.com/', - 'useSafariVC': false, - 'enableJavaScript': false, - 'enableDomStorage': false, - 'universalLinksOnly': true, - 'headers': {}, - }) - ], - ); + await launcher.launch( + 'http://example.com/', + useSafariVC: false, + useWebView: false, + enableJavaScript: false, + enableDomStorage: false, + universalLinksOnly: true, + headers: const {}, + ), + 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 {}, - ); + final UrlLauncherIOS launcher = UrlLauncherIOS(api: api); expect( - log, - [ - isMethodCall('launch', arguments: { - 'url': 'http://example.com/', - 'useSafariVC': false, - 'enableJavaScript': false, - 'enableDomStorage': false, - 'universalLinksOnly': false, - 'headers': {}, - }) - ], - ); - }); - - 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 {}, - ); - - expect(launched, false); + await launcher.launch( + 'http://example.com/', + useSafariVC: false, + useWebView: false, + enableJavaScript: false, + enableDomStorage: false, + universalLinksOnly: false, + headers: const {}, + ), + 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, - [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? value) => value; +/// 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': + return true; + case 'invalid': + throw PlatformException(code: 'argument_error'); + default: + return false; + } + } +}