Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit 266435d

Browse files
authored
Support iOS universal links route deep linking (#27874)
1 parent 6f8134e commit 266435d

File tree

4 files changed

+119
-87
lines changed

4 files changed

+119
-87
lines changed

shell/platform/darwin/ios/framework/Source/FlutterAppDelegate.mm

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -132,22 +132,11 @@ - (void)userNotificationCenter:(UNUserNotificationCenter*)center
132132
}
133133
}
134134

135-
static BOOL IsDeepLinkingEnabled(NSDictionary* infoDictionary) {
136-
NSNumber* isEnabled = [infoDictionary objectForKey:@"FlutterDeepLinkingEnabled"];
137-
if (isEnabled) {
138-
return [isEnabled boolValue];
139-
} else {
140-
return NO;
141-
}
142-
}
143-
144-
- (BOOL)application:(UIApplication*)application
145-
openURL:(NSURL*)url
146-
options:(NSDictionary<UIApplicationOpenURLOptionsKey, id>*)options
147-
infoPlistGetter:(NSDictionary* (^)())infoPlistGetter {
148-
if ([_lifeCycleDelegate application:application openURL:url options:options]) {
149-
return YES;
150-
} else if (!IsDeepLinkingEnabled(infoPlistGetter())) {
135+
- (BOOL)openURL:(NSURL*)url {
136+
NSNumber* isDeepLinkingEnabled =
137+
[[NSBundle mainBundle] objectForInfoDictionaryKey:@"FlutterDeepLinkingEnabled"];
138+
if (!isDeepLinkingEnabled.boolValue) {
139+
// Not set or NO.
151140
return NO;
152141
} else {
153142
FlutterViewController* flutterViewController = [self rootFlutterViewController];
@@ -181,12 +170,10 @@ - (BOOL)application:(UIApplication*)application
181170
- (BOOL)application:(UIApplication*)application
182171
openURL:(NSURL*)url
183172
options:(NSDictionary<UIApplicationOpenURLOptionsKey, id>*)options {
184-
return [self application:application
185-
openURL:url
186-
options:options
187-
infoPlistGetter:^NSDictionary*() {
188-
return [[NSBundle mainBundle] infoDictionary];
189-
}];
173+
if ([_lifeCycleDelegate application:application openURL:url options:options]) {
174+
return YES;
175+
}
176+
return [self openURL:url];
190177
}
191178

192179
- (BOOL)application:(UIApplication*)application handleOpenURL:(NSURL*)url {
@@ -229,9 +216,12 @@ - (BOOL)application:(UIApplication*)application
229216
continueUserActivity:(NSUserActivity*)userActivity
230217
restorationHandler:(void (^)(NSArray* __nullable restorableObjects))restorationHandler {
231218
#endif
232-
return [_lifeCycleDelegate application:application
233-
continueUserActivity:userActivity
234-
restorationHandler:restorationHandler];
219+
if ([_lifeCycleDelegate application:application
220+
continueUserActivity:userActivity
221+
restorationHandler:restorationHandler]) {
222+
return YES;
223+
}
224+
return [self openURL:userActivity.webpageURL];
235225
}
236226

237227
#pragma mark - FlutterPluginRegistry methods. All delegating to the rootViewController

shell/platform/darwin/ios/framework/Source/FlutterAppDelegateTest.mm

Lines changed: 102 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -14,84 +14,129 @@
1414
FLUTTER_ASSERT_ARC
1515

1616
@interface FlutterAppDelegateTest : XCTestCase
17+
@property(strong) FlutterAppDelegate* appDelegate;
18+
19+
@property(strong) id mockMainBundle;
20+
@property(strong) id mockNavigationChannel;
21+
22+
// Retain callback until the tests are done.
23+
// https://github.com/flutter/flutter/issues/74267
24+
@property(strong) id mockEngineFirstFrameCallback;
1725
@end
1826

1927
@implementation FlutterAppDelegateTest
2028

21-
- (void)testLaunchUrl {
29+
- (void)setUp {
30+
[super setUp];
31+
32+
id mockMainBundle = OCMClassMock([NSBundle class]);
33+
OCMStub([mockMainBundle mainBundle]).andReturn(mockMainBundle);
34+
self.mockMainBundle = mockMainBundle;
35+
2236
FlutterAppDelegate* appDelegate = [[FlutterAppDelegate alloc] init];
37+
self.appDelegate = appDelegate;
38+
2339
FlutterViewController* viewController = OCMClassMock([FlutterViewController class]);
24-
FlutterEngine* engine = OCMClassMock([FlutterEngine class]);
2540
FlutterMethodChannel* navigationChannel = OCMClassMock([FlutterMethodChannel class]);
41+
self.mockNavigationChannel = navigationChannel;
42+
43+
FlutterEngine* engine = OCMClassMock([FlutterEngine class]);
2644
OCMStub([engine navigationChannel]).andReturn(navigationChannel);
2745
OCMStub([viewController engine]).andReturn(engine);
28-
// Set blockNoInvoker to a strong local to retain to end of scope.
29-
id blockNoInvoker = [OCMArg invokeBlockWithArgs:@NO, nil];
30-
OCMStub([engine waitForFirstFrame:3.0 callback:blockNoInvoker]);
46+
47+
id mockEngineFirstFrameCallback = [OCMArg invokeBlockWithArgs:@NO, nil];
48+
self.mockEngineFirstFrameCallback = mockEngineFirstFrameCallback;
49+
OCMStub([engine waitForFirstFrame:3.0 callback:mockEngineFirstFrameCallback]);
3150
appDelegate.rootFlutterViewControllerGetter = ^{
3251
return viewController;
3352
};
34-
NSURL* url = [NSURL URLWithString:@"http://myApp/custom/route?query=test"];
35-
NSDictionary<UIApplicationOpenURLOptionsKey, id>* options = @{};
36-
BOOL result = [appDelegate application:[UIApplication sharedApplication]
37-
openURL:url
38-
options:options
39-
infoPlistGetter:^NSDictionary*() {
40-
return @{@"FlutterDeepLinkingEnabled" : @(YES)};
41-
}];
53+
}
54+
55+
- (void)tearDown {
56+
// Explicitly stop mocking the NSBundle class property.
57+
[self.mockMainBundle stopMocking];
58+
[super tearDown];
59+
}
60+
61+
- (void)testLaunchUrl {
62+
OCMStub([self.mockMainBundle objectForInfoDictionaryKey:@"FlutterDeepLinkingEnabled"])
63+
.andReturn(@YES);
64+
65+
BOOL result =
66+
[self.appDelegate application:[UIApplication sharedApplication]
67+
openURL:[NSURL URLWithString:@"http://myApp/custom/route?query=test"]
68+
options:@{}];
4269
XCTAssertTrue(result);
43-
OCMVerify([navigationChannel invokeMethod:@"pushRoute" arguments:@"/custom/route?query=test"]);
70+
OCMVerify([self.mockNavigationChannel invokeMethod:@"pushRoute"
71+
arguments:@"/custom/route?query=test"]);
72+
}
73+
74+
- (void)testLaunchUrlWithDeepLinkingNotSet {
75+
OCMStub([self.mockMainBundle objectForInfoDictionaryKey:@"FlutterDeepLinkingEnabled"])
76+
.andReturn(nil);
77+
78+
BOOL result =
79+
[self.appDelegate application:[UIApplication sharedApplication]
80+
openURL:[NSURL URLWithString:@"http://myApp/custom/route?query=test"]
81+
options:@{}];
82+
XCTAssertFalse(result);
83+
OCMReject([self.mockNavigationChannel invokeMethod:OCMOCK_ANY arguments:OCMOCK_ANY]);
84+
}
85+
86+
- (void)testLaunchUrlWithDeepLinkingDisabled {
87+
OCMStub([self.mockMainBundle objectForInfoDictionaryKey:@"FlutterDeepLinkingEnabled"])
88+
.andReturn(@NO);
89+
90+
BOOL result =
91+
[self.appDelegate application:[UIApplication sharedApplication]
92+
openURL:[NSURL URLWithString:@"http://myApp/custom/route?query=test"]
93+
options:@{}];
94+
XCTAssertFalse(result);
95+
OCMReject([self.mockNavigationChannel invokeMethod:OCMOCK_ANY arguments:OCMOCK_ANY]);
4496
}
4597

4698
- (void)testLaunchUrlWithQueryParameterAndFragment {
47-
FlutterAppDelegate* appDelegate = [[FlutterAppDelegate alloc] init];
48-
FlutterViewController* viewController = OCMClassMock([FlutterViewController class]);
49-
FlutterEngine* engine = OCMClassMock([FlutterEngine class]);
50-
FlutterMethodChannel* navigationChannel = OCMClassMock([FlutterMethodChannel class]);
51-
OCMStub([engine navigationChannel]).andReturn(navigationChannel);
52-
OCMStub([viewController engine]).andReturn(engine);
53-
// Set blockNoInvoker to a strong local to retain to end of scope.
54-
id blockNoInvoker = [OCMArg invokeBlockWithArgs:@NO, nil];
55-
OCMStub([engine waitForFirstFrame:3.0 callback:blockNoInvoker]);
56-
appDelegate.rootFlutterViewControllerGetter = ^{
57-
return viewController;
58-
};
59-
NSURL* url = [NSURL URLWithString:@"http://myApp/custom/route?query=test#fragment"];
60-
NSDictionary<UIApplicationOpenURLOptionsKey, id>* options = @{};
61-
BOOL result = [appDelegate application:[UIApplication sharedApplication]
62-
openURL:url
63-
options:options
64-
infoPlistGetter:^NSDictionary*() {
65-
return @{@"FlutterDeepLinkingEnabled" : @(YES)};
66-
}];
99+
OCMStub([self.mockMainBundle objectForInfoDictionaryKey:@"FlutterDeepLinkingEnabled"])
100+
.andReturn(@YES);
101+
102+
BOOL result = [self.appDelegate
103+
application:[UIApplication sharedApplication]
104+
openURL:[NSURL URLWithString:@"http://myApp/custom/route?query=test#fragment"]
105+
options:@{}];
67106
XCTAssertTrue(result);
68-
OCMVerify([navigationChannel invokeMethod:@"pushRoute"
69-
arguments:@"/custom/route?query=test#fragment"]);
107+
OCMVerify([self.mockNavigationChannel invokeMethod:@"pushRoute"
108+
arguments:@"/custom/route?query=test#fragment"]);
70109
}
71110

72111
- (void)testLaunchUrlWithFragmentNoQueryParameter {
73-
FlutterAppDelegate* appDelegate = [[FlutterAppDelegate alloc] init];
74-
FlutterViewController* viewController = OCMClassMock([FlutterViewController class]);
75-
FlutterEngine* engine = OCMClassMock([FlutterEngine class]);
76-
FlutterMethodChannel* navigationChannel = OCMClassMock([FlutterMethodChannel class]);
77-
OCMStub([engine navigationChannel]).andReturn(navigationChannel);
78-
OCMStub([viewController engine]).andReturn(engine);
79-
// Set blockNoInvoker to a strong local to retain to end of scope.
80-
id blockNoInvoker = [OCMArg invokeBlockWithArgs:@NO, nil];
81-
OCMStub([engine waitForFirstFrame:3.0 callback:blockNoInvoker]);
82-
appDelegate.rootFlutterViewControllerGetter = ^{
83-
return viewController;
84-
};
85-
NSURL* url = [NSURL URLWithString:@"http://myApp/custom/route#fragment"];
86-
NSDictionary<UIApplicationOpenURLOptionsKey, id>* options = @{};
87-
BOOL result = [appDelegate application:[UIApplication sharedApplication]
88-
openURL:url
89-
options:options
90-
infoPlistGetter:^NSDictionary*() {
91-
return @{@"FlutterDeepLinkingEnabled" : @(YES)};
92-
}];
112+
OCMStub([self.mockMainBundle objectForInfoDictionaryKey:@"FlutterDeepLinkingEnabled"])
113+
.andReturn(@YES);
114+
115+
BOOL result =
116+
[self.appDelegate application:[UIApplication sharedApplication]
117+
openURL:[NSURL URLWithString:@"http://myApp/custom/route#fragment"]
118+
options:@{}];
119+
XCTAssertTrue(result);
120+
OCMVerify([self.mockNavigationChannel invokeMethod:@"pushRoute"
121+
arguments:@"/custom/route#fragment"]);
122+
}
123+
124+
#pragma mark - Deep linking
125+
126+
- (void)testUniversalLinkPushRoute {
127+
OCMStub([self.mockMainBundle objectForInfoDictionaryKey:@"FlutterDeepLinkingEnabled"])
128+
.andReturn(@YES);
129+
130+
NSUserActivity* userActivity = [[NSUserActivity alloc] initWithActivityType:@"com.example.test"];
131+
userActivity.webpageURL = [NSURL URLWithString:@"http://myApp/custom/route?query=test"];
132+
BOOL result = [self.appDelegate
133+
application:[UIApplication sharedApplication]
134+
continueUserActivity:userActivity
135+
restorationHandler:^(NSArray<id<UIUserActivityRestoring>>* __nullable restorableObjects){
136+
}];
93137
XCTAssertTrue(result);
94-
OCMVerify([navigationChannel invokeMethod:@"pushRoute" arguments:@"/custom/route#fragment"]);
138+
OCMVerify([self.mockNavigationChannel invokeMethod:@"pushRoute"
139+
arguments:@"/custom/route?query=test"]);
95140
}
96141

97142
@end

shell/platform/darwin/ios/framework/Source/FlutterAppDelegate_Test.h

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,4 @@
77
@interface FlutterAppDelegate (Test)
88
@property(nonatomic, copy) FlutterViewController* (^rootFlutterViewControllerGetter)(void);
99

10-
- (BOOL)application:(UIApplication*)application
11-
openURL:(NSURL*)url
12-
options:(NSDictionary<UIApplicationOpenURLOptionsKey, id>*)options
13-
infoPlistGetter:(NSDictionary* (^)())infoPlistGetter;
14-
1510
@end

testing/ios/IosUnitTests/IosUnitTests.xcodeproj/project.pbxproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
0D6AB73E22BD8F0200EEE540 /* FlutterEngineConfig.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = FlutterEngineConfig.xcconfig; sourceTree = "<group>"; };
7171
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 = "<group>"; };
7272
F7521D7526BB673E005F15C5 /* libocmock_shared.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libocmock_shared.dylib; path = "../../../../out/$(FLUTTER_ENGINE)/libocmock_shared.dylib"; sourceTree = "<group>"; };
73+
F7A3FDE026B9E0A300EADD61 /* FlutterAppDelegateTest.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FlutterAppDelegateTest.mm; sourceTree = "<group>"; };
7374
/* End PBXFileReference section */
7475

7576
/* Begin PBXFrameworksBuildPhase section */
@@ -93,6 +94,7 @@
9394
0AC232E924BA71D300A85907 /* Source */ = {
9495
isa = PBXGroup;
9596
children = (
97+
F7A3FDE026B9E0A300EADD61 /* FlutterAppDelegateTest.mm */,
9698
0AC232F424BA71D300A85907 /* SemanticsObjectTest.mm */,
9799
0AC232F724BA71D300A85907 /* FlutterEngineTest.mm */,
98100
0AC2330324BA71D300A85907 /* accessibility_bridge_test.mm */,

0 commit comments

Comments
 (0)