Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[deep link][ios] Update openURL method to reflect the result from framework #52643

Open
wants to merge 21 commits into
base: main
Choose a base branch
from

Conversation

hangyujin
Copy link
Contributor

@hangyujin hangyujin commented May 7, 2024

follow up on comments on #52350

framework pr : flutter/flutter#147901

Pre-launch Checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • I read the Tree Hygiene wiki page, which explains my responsibilities.
  • I read and followed the Flutter Style Guide and the C++, Objective-C, Java style guides.
  • I listed at least one issue that this PR fixes in the description above.
  • I added new tests to check the change I am making or feature I am adding, or the PR is test-exempt. See testing the engine for instructions on writing and running engine tests.
  • I updated/added relevant documentation (doc comments with ///).
  • I signed the CLA.
  • All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel on Discord.

@flutter-dashboard

This comment was marked as outdated.

@jmagman jmagman self-requested a review May 8, 2024 21:09
@hangyujin
Copy link
Contributor Author

hangyujin commented May 8, 2024

i realized
openURL:options:completionHandler: is an async method
but application:openURL:options:(https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1623112-application?language=objc ) is a non-async method so I cannot just add a completionHandler to it ,

So i guess the return value of it can only reflect the deep link flag value, not the bool result from framework then.

@hangyujin hangyujin requested a review from chunhtai May 9, 2024 00:54
- (void)openURL:(NSURL*)url
options:(NSDictionary<UIApplicationOpenExternalURLOptionsKey, id>*)options
completionHandler:(void (^)(BOOL success))completion {
if (![self isFlutterDeepLinkingEnabled]) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even this is No, we still want to go through plugins, I think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain why we want to do that?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for those who use uni-link or their own plugin to handle deeplink, they would set this to no, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes they would set this to no.
but for those using uni-link and didn't set isFlutterDeepLinkingEnabled flag, the current behavior is already return NO for these methods and don't invoke the framework method. This PR is not changing their experience.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are right, I misread the code, looks like the plugin would have been called already before reaching here.

@@ -171,7 +188,14 @@ - (BOOL)application:(UIApplication*)application
if ([_lifeCycleDelegate application:application openURL:url options:options]) {
return YES;
}
return [self openURL:url];
if (![self isFlutterDeepLinkingEnabled]) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we still ned this check if we go delegate to other selector in line 194?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just add this so the return value reflect the value of the flag.

options:options
completionHandler:^(BOOL success){
}];
return YES;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this method get the result from completion handler only?

Also is implementing this selector optional if we already implement the one with completionhandler?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this method get the result from completion handler only?>>

this application:openURL:options: method is non-async, i don't think its return value can be set from the completion handler so i used the deep link flag value.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But should this method return before the completion handler is complete? Before, this method didn't return until pushRouteInformation was actually sent, whereas now it would return instantly.
It seems like you'd want to spin the runloop until the completion handler was done, and then return the value from the completion handler.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the best practice to "spin the runloop"?

I updated the code to wait for the completion handler but got failures :

Failures for clang-tidy on /Volumes/Work/s/w/ir/cache/builder/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate.mm:
/Volumes/Work/s/w/ir/cache/builder/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate.mm:178:3: error: Waiting on a callback using a semaphore creates useless threads and is subject to priority inversion; consider using a synchronous API or changing the caller to be asynchronous [clang-analyzer-optin.performance.GCDAntipattern,-warnings-as-errors]
178 | dispatch_semaphore_wait(semaphore, 5 * NSEC_PER_SEC);

Copy link
Contributor

@chunhtai chunhtai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

- (void)openURL:(NSURL*)url
options:(NSDictionary<UIApplicationOpenExternalURLOptionsKey, id>*)options
completionHandler:(void (^)(BOOL success))completion {
if (![self isFlutterDeepLinkingEnabled]) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are right, I misread the code, looks like the plugin would have been called already before reaching here.

@@ -149,18 +155,29 @@ - (BOOL)openURL:(NSURL*)url {
if (didTimeout) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that this is async, can we get rid of the waitForFirstFrame?

Copy link
Contributor

@chunhtai chunhtai May 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remembered this was added to workaround the issue that when we receive openurl, we don't know what the current state of flutter is in. If we send if before the first frame is drawn, the message will be dropped because the widgetbindingobserver is not yet registered. It is also too late to set initialroute at this point since it is already consumed by the framework.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but this was a long time ago, I am not sure if this is still the case

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like FlutterAppDelegate needs to know a lot of details about the engine, the navigationChannel, and the available method. What if this changed to:

- (void)openURL:(NSURL*)url
              options:(NSDictionary<UIApplicationOpenExternalURLOptionsKey, id>*)options
    completionHandler:(void (^)(BOOL success))completion {
  if (![self isFlutterDeepLinkingEnabled]) {
    completion(NO);
  } else {
    FlutterViewController* flutterViewController = [self rootFlutterViewController];
    if (flutterViewController) {
      [flutterViewController openDeepLink:url completionHandler:completion];
    } else {
      FML_LOG(ERROR) << "Attempting to open an URL without a Flutter RootViewController.";
      completion(NO);
    }
  }
}

FlutterViewController_Internal.h

- (void)openDeepLink:(NSURL*)url completionHandler:(void (^)(BOOL success))completion;

and then in FlutterViewController:

- (void)openDeepLink:(NSURL*)url completionHandler:(void (^)(BOOL success))completion {
  [_engine.get()
      waitForFirstFrame:3.0
               callback:^(BOOL didTimeout) {
                 if (didTimeout) {
                   FML_LOG(ERROR) << "Timeout waiting for the first frame when launching an URL.";
                   completion(NO);
                 } else {
                   // invove the method and get the result
                   [[_engine.get() navigationChannel]
                       invokeMethod:@"pushRouteInformation"
                          arguments:@{
                            @"location" : url.absoluteString ?: [NSNull null],
                          }
                             result:^(id _Nullable result) {
                               BOOL success =
                                   [result isKindOfClass:[NSNumber class]] && [result boolValue];
                               if (!success) {
                                 // Logging the error if the result is not successful
                                 FML_LOG(ERROR) << "Failed to handle route information in Flutter.";
                               }
                               completion(success);
                             }];
                 }
               }];
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup, It does seem like the viewcontroller may be a better place to put these logic

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks, this looks good, updated!

@hangyujin hangyujin requested a review from jmagman May 14, 2024 18:26
Comment on lines 168 to 169
// Use a dispatch semaphore to wait for the completion handler
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You won't want a dispatch_semaphore on the main thread, that will cause hangs and potentially deadlocks.

The run loop is a processing loop for event handling, and there's one assigned to the main thread. I believe what you want is to kick off the request, and spin the main run loop until it completes, then return the value from this method.

I didn't run this code so you should test it, but something like:

  __block BOOL openURLSuccess = NO;
  __block BOOL openURLCompleted = NO;
  [self openURL:url
                options:options
      completionHandler:^(BOOL success) {
        openURLSuccess = success;
        openURLCompleted = YES;
      }];

  while (!openURLCompleted) {
    [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
  }
  return openURLSuccess;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for educating me about the The run loop

I have some more questions :

  1. is a while loop a form of busy waiting and will it cause some performance issues? Should i also add a timeout to prevent an infinite loop?
  2. Looks like the async api [openURL:options:completionHandler:] is newer than the non-async api [application:openURL:options:] , is it possible the async one is replacing the non-async one? if so, it may be ok the async api returns the value from the framework but the old non-async api remains the old behavior to return the flag value.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is a while loop a form of busy waiting and will it cause some performance issues? Should i also add a timeout to prevent an infinite loop?

Are you concerned the code you're calling didTimeout logic won't work? You could pass in that timeout into the view controller method so it's obvious it handles timing out?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also a bit concerned " navigationChannel invokeMethod:arguments:result: " will time out 😅 Is it possible?

Copy link
Member

@jmagman jmagman May 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could add a timeout instead of the while loop, that would work too. And return NO if it times out.
There are a lot of timeouts going on here, it seems like it would be bad if it times out but the pushRouteInformation request has gone through, but not yet returned?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

basically it's like

- (void)fakedispatch:(void (^)(BOOL success))completion {
  dispatch_queue_t queue = dispatch_get_main_queue();
  dispatch_async(queue, ^{
     NSLog(@"fakedispatch:completionHandler: "); 
      completion(YES);
  });
}

- (void)fakeRunloop {
  __block BOOL completed = NO;
  CFTimeInterval startTime = CACurrentMediaTime();
  [self fakedispatch:^(BOOL success) {
    completed = YES;
  }];
  while (!completed) {
    NSLog(@"runing loop %f", CACurrentMediaTime() - startTime);
    [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1.0]];
  }
}

and it will be a deadlock

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hangyujin I just tried the above code in a dummy project and didn't get a deadlock. Note that im calling from viewDidLoad, but the idea should be similar.

@implementation ViewController

- (void)viewDidLoad {
  [super viewDidLoad];
  
  [self fakeRunloop];
  NSLog(@"completed fakeRunLoop");
}
@end

As you can see NSLog(@"completed fakeRunLoop"); gets printed out.

Screenshot 2024-05-23 at 1 05 19 PM

What exact behavior are you observing? Does it hang?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I called it from application:continueUserActivity:restorationHandler because this method will be invoked by deep linking,

- (BOOL)application:(UIApplication*)application
    continueUserActivity:(NSUserActivity*)userActivity
      restorationHandler:
          (void (^)(NSArray<id<UIUserActivityRestoring>>* __nullable restorableObjects))
              restorationHandler {

  NSLog(@"application:continueUserActivity:restorationHandler"); 
  [self fakeRunloop];
  NSLog(@"completed fakeRunLoop");
  return NO;
}

And it's just running forever and can't complete
Screenshot 2024-05-23 at 14 32 32

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will look into this today

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wanna update my progress so far. So for the deadlock, I wasn't able to find anything useful online, but I did come up with a theory - the continueUserActivity runs on main queue (hence also the main thread); the viewDidLoad runs on main thread, but NOT the main queue. This can explain why it works in viewDidLoad, but deadlock in continueUserActivity.

If my theory is correct, I don't think there's a way to synchronize the async API. I am thinking about a workaround (which I am still hashing out). Basically we want to return YES synchronously (marking as "handled"), then call the async API. If the async API comes back as failure, then call UIAppilcation::openURL which should throw the url back to iOS. Something like:

- (BOOL) continueUserActivity {
  if vc not present { return NO; }
  runAsyncAPI(completion: (success) { 
    if (!success) { 
      // throw it back to iOS
      [UIApplication.sharedApplication openURL: url]
    }
  })
  return YES;
}

@hangyujin hangyujin requested a review from jmagman May 14, 2024 23:19
@hangyujin
Copy link
Contributor Author

@jmagman ping for review

@jmagman jmagman requested a review from hellohuanlin May 16, 2024 22:54
// Not set or NO.
return NO;
// if not set, return NO
return isDeepLinkingEnabled ? [isDeepLinkingEnabled boolValue] : NO;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this can be just isDeepLinkingEnabled.boolValue since sending message to nil will return NO.

Copy link
Contributor Author

@hangyujin hangyujin May 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will keep this and flip NO to YES in #52350

while (!openURLCompleted) {
[NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
}
return openURLSuccess;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can you write a helper to avoid duplicate code?


while (!openURLCompleted) {
[NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: If you are iffy about using BOOL flag for completeness, a semaphore version can be (pseudocode):

__block BOOL openURLSuccess = NO;
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
[self openURL: url ... {
    openURLSuccess = success;
    dispatch_semaphore_signal(semaphore)
}];

while (dispatch_semaphore_wait(semaphore, DISPATCH_TIME_NOW)) {
    [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
}

Though a BOOL flag would be equally as good, since callback happens on main thread. I don't have a preference here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I just saw @jmagman's comment on semaphore.

To use semaphore on the same thread, you can't block it due to deadlock, as jenn previously commented. Instead, you pass in DISPATCH_TIME_NOW in dispatch_semaphore_wait, and then kick off the runloop so it can pick up the completion handler.

@hangyujin hangyujin changed the title [ios] Update openURL method to reflect the result from framework [deep link][ios] Update openURL method to reflect the result from framework May 23, 2024
@zanderso
Copy link
Member

From PR triage: What is the status of this PR? @hangyujin is this ready for another review pass from @hellohuanlin?

@hellohuanlin
Copy link
Contributor

From PR triage: What is the status of this PR? @hangyujin is this ready for another review pass from @hellohuanlin?

There's a deadlock issue that I am looking into and will unblock @hangyujin when it's done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
5 participants