Skip to content

Remove manual Safari fallback for unhandled universal links - #185430

Merged
auto-submit[bot] merged 11 commits into
flutter:masterfrom
okorohelijah:fix_170665
Aug 11, 2026
Merged

auto-submit[bot] merged 11 commits into
flutter:masterfrom
okorohelijah:fix_170665

Conversation

@okorohelijah

@okorohelijah okorohelijah commented Apr 22, 2026 •

Copy link
Copy Markdown
Contributor

When a universal link (https) opens an iOS app on a cold start, the Dart framework may not be ready to handle routes yet — onGenerateRoute can return null simply because initialization hasn't completed. The engine interprets this as "unhandled" and relays the URL back to iOS via openURL:, which opens Safari. This is incorrect and disruptive.

This PR completely removes the relay-to-system fallback for unhandled universal links (for both warm and cold starts). The modern iOS API for scenes (UISceneDelegate continueUserActivity) returns void, meaning iOS natively no longer expects feedback on whether a link was handled, and natively stopped bouncing unrecognized universal links to Safari. Removing our manual [UIApplication openURL:] fallback aligns Flutter's engine with Apple's modern native behavior.

PR with the help of Gemini

Fixes #170665

If you had to change anything in the flutter/tests repo, include a link to the migration guide as per the breaking change policy.

Pre-launch Checklist

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

If this change needs to override an active code freeze, provide a comment explaining why. The code freeze workflow can be overridden by code reviewers. See pinned issues for any active code freezes with guidance.

Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the gemini-code-assist bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.

@okorohelijah
okorohelijah requested a review from a team as a code owner April 22, 2026 19:13
@github-actions github-actions Bot added platform-ios iOS applications specifically engine flutter/engine related. See also e: labels. team-ios Owned by iOS platform team labels Apr 22, 2026
@okorohelijah okorohelijah added the CICD Run CI/CD label Apr 22, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request modifies the iOS platform code to prevent unhandled deep links from being relayed to the system during a cold start. It introduces a hasBeenActive property in FlutterAppDelegate to track the application's lifecycle state, ensuring that unhandled URLs are only passed back to iOS once the app has been active. Additionally, FlutterSceneLifeCycle was updated to disable relaying during initial scene connection, and new test cases were added to verify the logic for both cold and warm start scenarios. I have no feedback to provide.

@vashworth

vashworth commented Apr 22, 2026 •

Copy link
Copy Markdown
Contributor

Huh, did you verify that the bug was a regression? Like did it work with an older version of Flutter? I would not expect the app launch sequence to be related if it's related to a specific Flutter version (unless it was UIScene related, but that was introduced in Flutter 3.38).

Were you able to recreate locally and verify that this fixed it?

@okorohelijah

okorohelijah commented Apr 22, 2026 •

Copy link
Copy Markdown
Contributor Author

@vashworth I didn't test prior Flutter versions since it was bisected to #164247 and I was able to reproduce the bug locally. I also verified that the fix works

The fix was applied to both the Appdelegate and the UIScene since they both have their process for handling the deeplink

@okorohelijah
okorohelijah requested a review from vashworth April 23, 2026 01:48
// response does not mean the link is genuinely unsupported — it may just be too early. Only
// relay unhandled https links back to iOS (which opens Safari) once the app has been active.
//
// Trade-off: if the app genuinely can't handle the route on cold start (e.g., AASA claims

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

what's AASA?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Apple App Site Association. It basically tells ios to open the app instead of the website. With this approach, If a user taps on the link and it genuinely fails on a cold start either because it isnt ready or the app doesnt have the route, it simply takes the user to the app(usually the home screen) which i think is the lesser evil

@hellohuanlin

Copy link
Copy Markdown
Contributor

I didn't test prior Flutter versions since it was bisected to #164247 and I was able to reproduce the bug locally. I also verified that the fix works

@okorohelijah Can you try reverting to the old API and see if it works (it's one line of code so you don't have to bisect)? From your PR description this doesn't seem related to that change.

@okorohelijah

okorohelijah commented Apr 29, 2026 •

Copy link
Copy Markdown
Contributor Author

@hellohuanlin I tested by reverting my change and #164247 separately:

If I revert #164247 (returning to the deprecated 1-arg openURL:), the cold start works. It seems iOS was silently ignoring that deprecated call during the early launch phase.
If I restore #164247, the 3-arg openURL: is strictly honored by iOS, which triggers the jump to Safari.

The issue is that sendDeepLinkToFramework: calls completion(NO) if it times out (3 seconds) or if the Dart framework returns false. This then hits the (!success && throwBack) check in the AppDelegate, which triggers the openURL: call updated in #164247.

Rather than dropping the link, is it possible to delay the operation until the framework is ready?

sendDeepLinkToFramework: already delays via waitForFirstFrame:3.0 before sending the route to Dart. The problem is that the first frame often renders (e.g., a loading screen) before the app's own initialization (database, auth, etc.) is complete.

When that happens, Dart's onGenerateRoute returns null. From the engine's perspective, "not ready yet" and "genuinely fails" look identical so both return success=NO. We can't tell how long a specific app's initialization will take, and increasing the timeout would just delay the jump to Safari without solving the ambiguity.

@vashworth

Copy link
Copy Markdown
Contributor

It seems iOS was silently ignoring that deprecated call during the early launch phase.

What do you mean by that? Does using the deprecated 1-arg openURL work?

@okorohelijah

Copy link
Copy Markdown
Contributor Author

@vashworth Yes, the deprecated API "works" in the sense that it doesn't bounce to Safari, but it does so by failing silently. When the engine tries to relay back to the system via the legacy [UIApplication openURL:url], iOS sees the app is in the middle of a cold-start transition and simply doesn't execute the command.

@vashworth

Copy link
Copy Markdown
Contributor

@vashworth Yes, the deprecated API "works" in the sense that it doesn't bounce to Safari, but it does so by failing silently. When the engine tries to relay back to the system via the legacy [UIApplication openURL:url], iOS sees the app is in the middle of a cold-start transition and simply doesn't execute the command.

But it doesn't route the deeplink, yes? It opens the app but just to the main screen?

@okorohelijah

okorohelijah commented Apr 29, 2026 •

Copy link
Copy Markdown
Contributor Author

@vashworth the link routing opens because the routing is correct. The problem in this case is that the initialization is just late but because of the timeout, late and failing is treated the same. The legacy api isn't meant meant to work but it works because it ignores [UIApplication openUrl:url] (which is meant to open safari) but the new api honors it

@vashworth

vashworth commented May 5, 2026 •

Copy link
Copy Markdown
Contributor

Okay I think I understand. Basically what this PR does is makes it so if a url is not handled by the flutter app, then on cold start it fails silently (keeping the app open and not redirecting to safari) and on warm start it redirects to safari.

From the issue report, it sounds like the users have come to expect cold start to silently fail because on their older version of Flutter it would call deprecated openURL: to redirect to safari, but since that API is deprecated, it would silently fail. Then after upgrading to the new version of Flutter that uses openURL:options:completionHandler, it starts redirecting to safari as the code originally intended.

I wonder how it works natively. I'm pretty sure that it does not bounce to safari like we do. The AASA should handle directing invalid urls to safari.

@okorohelijah Can you look through the git history and see if you can figure out why we introduced relaying it to the system in the first place?

@vashworth
vashworth marked this pull request as draft May 28, 2026 21:39
@okorohelijah

okorohelijah commented Jun 15, 2026 •

Copy link
Copy Markdown
Contributor Author

@hellohuanlin @vashworth I discovered something new: The 3-second waitForFirstFrame timeout is NOT being hit!

Here is the exact timeline of what happens:

iOS wakes up the app and hands the Deep Link to the Engine.
The Engine immediately forwards it to Dart. Dart receives it perfectly on time.
During initialization, the developer intentionally delays routing. In the repro project, onGenerateRoute explicitly returns null because loading is true

if (loading) {
        // Save link until the app is loaded to handle later.
        globalPendingDeepLink = pathOrUri;
        return null;
      }

When onGenerateRoute returns null, Flutter's Navigator falls back to _onUnknownRoute.
Because the developer didn't pass an onUnknownRoute callback to MaterialApp, the framework throws an internal exception (Null check operator used on a null value in app.dart:1590).
Because of this exception, Dart immediately returns false to the Objective-C Engine via the method channel.
The Engine receives false (signaling "Dart failed to handle the route") and executes its hardcoded fallback: [UIApplication openURL:url], which forcefully kicks the user out of the app and opens Safari.

@okorohelijah

Copy link
Copy Markdown
Contributor Author

I still propose this fix because Apple's native design pattern for continueUserActivity allows native apps to return false if they receive a link they aren't ready for, and iOS natively handles this by silently dropping the link and letting the user stay inside the app. The implementation rn intercepts that false and forces a Safari bounce. wdyt?

@okorohelijah
okorohelijah marked this pull request as ready for review June 15, 2026 19:26

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request modifies the iOS platform integration to prevent unhandled universal links from being relayed back to the system during cold starts, as the Dart framework may not yet be ready to handle them. Relaying is now only enabled once the application has transitioned to an active state, tracked via a new hasBeenActive property. The feedback suggests observing UIApplicationDidBecomeActiveNotification rather than relying on applicationDidBecomeActive: to ensure robustness if subclasses override the method without calling super.

@okorohelijah

okorohelijah commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Can you look through the git history and see if you can figure out why we introduced relaying it to the system in the first place?

@vashworth I looked through the history (specifically PR #52643) to trace why this manual openURL: fallback was introduced. It was originally added to handle unsupported paths (bouncing unrecognized links to Safari) but because Apple's continueUserActivity API requires an immediate synchronous BOOL return, the Engine couldn't wait for Dart's asynchronous response and was forced to immediately return YES. Returning YES swallowed iOS's native Safari fallback behavior, so we had to manually simulate the bounce later using openURL: when Dart eventually returned false.

I tested by creating a native app and I confirm that the invalid urls(custom and universal links) just remain on the app instead of the safari bounce. The safari bounce happens only if the app doesnt open in the first place. That is, the path isnt registered in the AASA but once the app opens iOS handsoff completely

hellohuanlin
hellohuanlin previously approved these changes Jun 30, 2026

@hellohuanlin hellohuanlin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

So this turns out to be unrelated to #164247?

Comment on lines 174 to 179

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@okorohelijah Since you determined that native apps don't bounce to Safari, shouldn't we just remove the throw back altogether?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep! and just too add for future reference: the modern API (UISceneDelegate) changed the continueUserActivity return type to void, so iOS no longer receives feedback on whether the link was handled and natively stopped bouncing to Safari.

The old legacy API (UIApplicationDelegate) returned a BOOL. Because Flutter had to return YES synchronously to the system while waiting for Dart, PR #52643 originally added this manual openURL: fallback to emulate Apple's old behavior if Dart eventually returned false.

Since modern iOS has abandoned the Safari fallback natively, I completely agree we should just remove our engine-level fallback entirely.

@auto-submit
auto-submit Bot added this pull request to the merge queue Aug 6, 2026
@Ortes

Ortes commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Thanks a lot for the detailed clarification, that's crystal clear! I see the PR is on its way to being merged, so perfect. 🙏

@cbracken

cbracken commented Aug 6, 2026

Copy link
Copy Markdown
Member

The PR description should be updated to reflect changes -- e.g. hasBeenActive.

@cbracken cbracken left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Overall lgtm -- just a few nits.

@okorohelijah
okorohelijah removed this pull request from the merge queue due to a manual request Aug 6, 2026
@flutter-dashboard flutter-dashboard Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Aug 6, 2026
@okorohelijah okorohelijah changed the title Suppress universal link relay to system on cold start Remove manual Safari fallback for unhandled universal links Aug 7, 2026

@cbracken cbracken left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM stamp from a Japanese personal seal

@okorohelijah okorohelijah added the autosubmit Merge PR when tree becomes green via auto submit App label Aug 10, 2026
@auto-submit auto-submit Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Aug 10, 2026
@auto-submit

auto-submit Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

autosubmit label was removed for flutter/flutter/185430, because - The status or check suite Google testing has failed. Please fix the issues identified (or deflake) before re-applying this label.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD engine flutter/engine related. See also e: labels. platform-ios iOS applications specifically team-ios Owned by iOS platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Regression handling deep links with the app closed in iOS

5 participants