From 266435dafee3339283ecaed2b3bf1f34525c8748 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Mon, 9 Aug 2021 14:35:11 -0700 Subject: [PATCH] Support iOS universal links route deep linking (#27874) --- .../framework/Source/FlutterAppDelegate.mm | 40 ++--- .../Source/FlutterAppDelegateTest.mm | 159 +++++++++++------- .../Source/FlutterAppDelegate_Test.h | 5 - .../IosUnitTests.xcodeproj/project.pbxproj | 2 + 4 files changed, 119 insertions(+), 87 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate.mm b/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate.mm index 32b3fe0eb1e0..fa68266619ce 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate.mm @@ -132,22 +132,11 @@ - (void)userNotificationCenter:(UNUserNotificationCenter*)center } } -static BOOL IsDeepLinkingEnabled(NSDictionary* infoDictionary) { - NSNumber* isEnabled = [infoDictionary objectForKey:@"FlutterDeepLinkingEnabled"]; - if (isEnabled) { - return [isEnabled boolValue]; - } else { - return NO; - } -} - -- (BOOL)application:(UIApplication*)application - openURL:(NSURL*)url - options:(NSDictionary*)options - infoPlistGetter:(NSDictionary* (^)())infoPlistGetter { - if ([_lifeCycleDelegate application:application openURL:url options:options]) { - return YES; - } else if (!IsDeepLinkingEnabled(infoPlistGetter())) { +- (BOOL)openURL:(NSURL*)url { + NSNumber* isDeepLinkingEnabled = + [[NSBundle mainBundle] objectForInfoDictionaryKey:@"FlutterDeepLinkingEnabled"]; + if (!isDeepLinkingEnabled.boolValue) { + // Not set or NO. return NO; } else { FlutterViewController* flutterViewController = [self rootFlutterViewController]; @@ -181,12 +170,10 @@ - (BOOL)application:(UIApplication*)application - (BOOL)application:(UIApplication*)application openURL:(NSURL*)url options:(NSDictionary*)options { - return [self application:application - openURL:url - options:options - infoPlistGetter:^NSDictionary*() { - return [[NSBundle mainBundle] infoDictionary]; - }]; + if ([_lifeCycleDelegate application:application openURL:url options:options]) { + return YES; + } + return [self openURL:url]; } - (BOOL)application:(UIApplication*)application handleOpenURL:(NSURL*)url { @@ -229,9 +216,12 @@ - (BOOL)application:(UIApplication*)application continueUserActivity:(NSUserActivity*)userActivity restorationHandler:(void (^)(NSArray* __nullable restorableObjects))restorationHandler { #endif - return [_lifeCycleDelegate application:application - continueUserActivity:userActivity - restorationHandler:restorationHandler]; + if ([_lifeCycleDelegate application:application + continueUserActivity:userActivity + restorationHandler:restorationHandler]) { + return YES; + } + return [self openURL:userActivity.webpageURL]; } #pragma mark - FlutterPluginRegistry methods. All delegating to the rootViewController diff --git a/shell/platform/darwin/ios/framework/Source/FlutterAppDelegateTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterAppDelegateTest.mm index 1997d76bc5b2..c8c4aa700c91 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterAppDelegateTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterAppDelegateTest.mm @@ -14,84 +14,129 @@ FLUTTER_ASSERT_ARC @interface FlutterAppDelegateTest : XCTestCase +@property(strong) FlutterAppDelegate* appDelegate; + +@property(strong) id mockMainBundle; +@property(strong) id mockNavigationChannel; + +// Retain callback until the tests are done. +// https://github.com/flutter/flutter/issues/74267 +@property(strong) id mockEngineFirstFrameCallback; @end @implementation FlutterAppDelegateTest -- (void)testLaunchUrl { +- (void)setUp { + [super setUp]; + + id mockMainBundle = OCMClassMock([NSBundle class]); + OCMStub([mockMainBundle mainBundle]).andReturn(mockMainBundle); + self.mockMainBundle = mockMainBundle; + FlutterAppDelegate* appDelegate = [[FlutterAppDelegate alloc] init]; + self.appDelegate = appDelegate; + FlutterViewController* viewController = OCMClassMock([FlutterViewController class]); - FlutterEngine* engine = OCMClassMock([FlutterEngine class]); FlutterMethodChannel* navigationChannel = OCMClassMock([FlutterMethodChannel class]); + self.mockNavigationChannel = navigationChannel; + + FlutterEngine* engine = OCMClassMock([FlutterEngine class]); OCMStub([engine navigationChannel]).andReturn(navigationChannel); OCMStub([viewController engine]).andReturn(engine); - // Set blockNoInvoker to a strong local to retain to end of scope. - id blockNoInvoker = [OCMArg invokeBlockWithArgs:@NO, nil]; - OCMStub([engine waitForFirstFrame:3.0 callback:blockNoInvoker]); + + id mockEngineFirstFrameCallback = [OCMArg invokeBlockWithArgs:@NO, nil]; + self.mockEngineFirstFrameCallback = mockEngineFirstFrameCallback; + OCMStub([engine waitForFirstFrame:3.0 callback:mockEngineFirstFrameCallback]); appDelegate.rootFlutterViewControllerGetter = ^{ return viewController; }; - NSURL* url = [NSURL URLWithString:@"http://myApp/custom/route?query=test"]; - NSDictionary* options = @{}; - BOOL result = [appDelegate application:[UIApplication sharedApplication] - openURL:url - options:options - infoPlistGetter:^NSDictionary*() { - return @{@"FlutterDeepLinkingEnabled" : @(YES)}; - }]; +} + +- (void)tearDown { + // Explicitly stop mocking the NSBundle class property. + [self.mockMainBundle stopMocking]; + [super tearDown]; +} + +- (void)testLaunchUrl { + OCMStub([self.mockMainBundle objectForInfoDictionaryKey:@"FlutterDeepLinkingEnabled"]) + .andReturn(@YES); + + BOOL result = + [self.appDelegate application:[UIApplication sharedApplication] + openURL:[NSURL URLWithString:@"http://myApp/custom/route?query=test"] + options:@{}]; XCTAssertTrue(result); - OCMVerify([navigationChannel invokeMethod:@"pushRoute" arguments:@"/custom/route?query=test"]); + OCMVerify([self.mockNavigationChannel invokeMethod:@"pushRoute" + arguments:@"/custom/route?query=test"]); +} + +- (void)testLaunchUrlWithDeepLinkingNotSet { + OCMStub([self.mockMainBundle objectForInfoDictionaryKey:@"FlutterDeepLinkingEnabled"]) + .andReturn(nil); + + BOOL result = + [self.appDelegate application:[UIApplication sharedApplication] + openURL:[NSURL URLWithString:@"http://myApp/custom/route?query=test"] + options:@{}]; + XCTAssertFalse(result); + OCMReject([self.mockNavigationChannel invokeMethod:OCMOCK_ANY arguments:OCMOCK_ANY]); +} + +- (void)testLaunchUrlWithDeepLinkingDisabled { + OCMStub([self.mockMainBundle objectForInfoDictionaryKey:@"FlutterDeepLinkingEnabled"]) + .andReturn(@NO); + + BOOL result = + [self.appDelegate application:[UIApplication sharedApplication] + openURL:[NSURL URLWithString:@"http://myApp/custom/route?query=test"] + options:@{}]; + XCTAssertFalse(result); + OCMReject([self.mockNavigationChannel invokeMethod:OCMOCK_ANY arguments:OCMOCK_ANY]); } - (void)testLaunchUrlWithQueryParameterAndFragment { - FlutterAppDelegate* appDelegate = [[FlutterAppDelegate alloc] init]; - FlutterViewController* viewController = OCMClassMock([FlutterViewController class]); - FlutterEngine* engine = OCMClassMock([FlutterEngine class]); - FlutterMethodChannel* navigationChannel = OCMClassMock([FlutterMethodChannel class]); - OCMStub([engine navigationChannel]).andReturn(navigationChannel); - OCMStub([viewController engine]).andReturn(engine); - // Set blockNoInvoker to a strong local to retain to end of scope. - id blockNoInvoker = [OCMArg invokeBlockWithArgs:@NO, nil]; - OCMStub([engine waitForFirstFrame:3.0 callback:blockNoInvoker]); - appDelegate.rootFlutterViewControllerGetter = ^{ - return viewController; - }; - NSURL* url = [NSURL URLWithString:@"http://myApp/custom/route?query=test#fragment"]; - NSDictionary* options = @{}; - BOOL result = [appDelegate application:[UIApplication sharedApplication] - openURL:url - options:options - infoPlistGetter:^NSDictionary*() { - return @{@"FlutterDeepLinkingEnabled" : @(YES)}; - }]; + OCMStub([self.mockMainBundle objectForInfoDictionaryKey:@"FlutterDeepLinkingEnabled"]) + .andReturn(@YES); + + BOOL result = [self.appDelegate + application:[UIApplication sharedApplication] + openURL:[NSURL URLWithString:@"http://myApp/custom/route?query=test#fragment"] + options:@{}]; XCTAssertTrue(result); - OCMVerify([navigationChannel invokeMethod:@"pushRoute" - arguments:@"/custom/route?query=test#fragment"]); + OCMVerify([self.mockNavigationChannel invokeMethod:@"pushRoute" + arguments:@"/custom/route?query=test#fragment"]); } - (void)testLaunchUrlWithFragmentNoQueryParameter { - FlutterAppDelegate* appDelegate = [[FlutterAppDelegate alloc] init]; - FlutterViewController* viewController = OCMClassMock([FlutterViewController class]); - FlutterEngine* engine = OCMClassMock([FlutterEngine class]); - FlutterMethodChannel* navigationChannel = OCMClassMock([FlutterMethodChannel class]); - OCMStub([engine navigationChannel]).andReturn(navigationChannel); - OCMStub([viewController engine]).andReturn(engine); - // Set blockNoInvoker to a strong local to retain to end of scope. - id blockNoInvoker = [OCMArg invokeBlockWithArgs:@NO, nil]; - OCMStub([engine waitForFirstFrame:3.0 callback:blockNoInvoker]); - appDelegate.rootFlutterViewControllerGetter = ^{ - return viewController; - }; - NSURL* url = [NSURL URLWithString:@"http://myApp/custom/route#fragment"]; - NSDictionary* options = @{}; - BOOL result = [appDelegate application:[UIApplication sharedApplication] - openURL:url - options:options - infoPlistGetter:^NSDictionary*() { - return @{@"FlutterDeepLinkingEnabled" : @(YES)}; - }]; + OCMStub([self.mockMainBundle objectForInfoDictionaryKey:@"FlutterDeepLinkingEnabled"]) + .andReturn(@YES); + + BOOL result = + [self.appDelegate application:[UIApplication sharedApplication] + openURL:[NSURL URLWithString:@"http://myApp/custom/route#fragment"] + options:@{}]; + XCTAssertTrue(result); + OCMVerify([self.mockNavigationChannel invokeMethod:@"pushRoute" + arguments:@"/custom/route#fragment"]); +} + +#pragma mark - Deep linking + +- (void)testUniversalLinkPushRoute { + OCMStub([self.mockMainBundle objectForInfoDictionaryKey:@"FlutterDeepLinkingEnabled"]) + .andReturn(@YES); + + NSUserActivity* userActivity = [[NSUserActivity alloc] initWithActivityType:@"com.example.test"]; + userActivity.webpageURL = [NSURL URLWithString:@"http://myApp/custom/route?query=test"]; + BOOL result = [self.appDelegate + application:[UIApplication sharedApplication] + continueUserActivity:userActivity + restorationHandler:^(NSArray>* __nullable restorableObjects){ + }]; XCTAssertTrue(result); - OCMVerify([navigationChannel invokeMethod:@"pushRoute" arguments:@"/custom/route#fragment"]); + OCMVerify([self.mockNavigationChannel invokeMethod:@"pushRoute" + arguments:@"/custom/route?query=test"]); } @end diff --git a/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate_Test.h b/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate_Test.h index a4394fde9fda..d56844a37d5c 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate_Test.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate_Test.h @@ -7,9 +7,4 @@ @interface FlutterAppDelegate (Test) @property(nonatomic, copy) FlutterViewController* (^rootFlutterViewControllerGetter)(void); -- (BOOL)application:(UIApplication*)application - openURL:(NSURL*)url - options:(NSDictionary*)options - infoPlistGetter:(NSDictionary* (^)())infoPlistGetter; - @end diff --git a/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/project.pbxproj b/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/project.pbxproj index 2d30d6669fcf..e563587fad96 100644 --- a/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/project.pbxproj +++ b/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/project.pbxproj @@ -70,6 +70,7 @@ 0D6AB73E22BD8F0200EEE540 /* FlutterEngineConfig.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = FlutterEngineConfig.xcconfig; sourceTree = ""; }; F7521D7226BB671E005F15C5 /* libios_test_flutter.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libios_test_flutter.dylib; path = "../../../../out/$(FLUTTER_ENGINE)/libios_test_flutter.dylib"; sourceTree = ""; }; F7521D7526BB673E005F15C5 /* libocmock_shared.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libocmock_shared.dylib; path = "../../../../out/$(FLUTTER_ENGINE)/libocmock_shared.dylib"; sourceTree = ""; }; + F7A3FDE026B9E0A300EADD61 /* FlutterAppDelegateTest.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FlutterAppDelegateTest.mm; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -93,6 +94,7 @@ 0AC232E924BA71D300A85907 /* Source */ = { isa = PBXGroup; children = ( + F7A3FDE026B9E0A300EADD61 /* FlutterAppDelegateTest.mm */, 0AC232F424BA71D300A85907 /* SemanticsObjectTest.mm */, 0AC232F724BA71D300A85907 /* FlutterEngineTest.mm */, 0AC2330324BA71D300A85907 /* accessibility_bridge_test.mm */,