Skip to main content

Resolving Deep Link Conflicts: Fixing Third-Party Plugin Crashes in Flutter 3.27

 The upgrade to Flutter 3.27 has introduced a critical regression for applications relying on established deep linking plugins like uni_links or firebase_dynamic_links. Developers are reporting immediate crashes on startup (specifically IllegalStateException on Android) or silent failures where valid deep links are ignored, defaulting the user to the initial route.

This isn't a bug in your routing logic. It is a fundamental conflict between the Flutter Engine's new default behavior and the method hooking strategies employed by third-party plugins.

Root Cause Analysis: The Race for the Intent

To understand why your app crashes, you must look at the native lifecycle events.

Historically, the Flutter Engine left deep link handling largely to the developer (or plugins). Plugins like uni_links operate by inspecting the Intent (Android) or NSUserActivity (iOS) during the onCreate or application:continueUserActivity lifecycle methods. They "swizzle" or override these methods to intercept the URL before the main Flutter view is fully established.

With recent updates culminating in Flutter 3.27, the Flutter team introduced automatic native deep link parsing. The framework now eagerly consumes the incoming Intent to populate PlatformDispatcher.defaultRouteName immediately upon engine attachment.

The Conflict:

  1. Android: The Flutter Engine calls getIntent() and processes the data URI. In some configurations, it marks the intent as consumed or modifies the internal state of the FlutterActivity. When uni_links or firebase_dynamic_links subsequently attempts to parse the same intent in their plugin layer, they encounter a null object reference or a state exception because the Engine has already "claimed" the event.
  2. iOS: Both the Flutter Engine and the plugins attempt to conform to the delegate methods for universal links. If the Engine handles the userActivity and returns true, the event propagation stops, and the plugin listeners never fire.

To restore functionality to your existing plugins and prevent crashes, you must explicitly disable the Flutter Engine's eager deep link handling.

The Fix: Disabling Native Auto-Handling

You do not need to rewrite your Dart navigation logic immediately. You need to opt-out of the framework's native interception to return control to your plugins.

1. Android Configuration (AndroidManifest.xml)

You must add a specific meta-data tag to your AndroidManifest.xml file. This tells the Flutter embedder v2 to ignore the intent's data URI, allowing plugins to read it raw.

File: android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher"
        android:label="My App">

        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTask"
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            
            <!-- Standard Intent Filter -->
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>

            <!-- Deep Link Intent Filter -->
            <intent-filter android:autoVerify="true">
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="https" android:host="example.com" />
            </intent-filter>

            <!-- THE FIX: Disable Flutter's automatic deep link handling -->
            <meta-data
                android:name="flutter_deeplinking_enabled"
                android:value="false" />
            
        </activity>
    </application>
</manifest>

2. iOS Configuration (Info.plist)

Similarly, on iOS, you must disable the internal flag that instructs the FlutterAppDelegate to automatically bridge the NSUserActivity to the Flutter route.

File: ios/Runner/Info.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <!-- ... other configurations ... -->

    <!-- THE FIX: Disable Flutter's automatic deep link handling -->
    <key>FlutterDeepLinkingEnabled</key>
    <false/>

    <!-- ... other configurations ... -->
</dict>
</plist>

3. Modern Dart Implementation

While the native config stops the crash, reliance on firebase_dynamic_links (deprecated) or older versions of uni_links is technical debt.

Below is a modern implementation using app_links (the maintained successor to uni_links) that works reliably once the native flags above are set. This service handles both "Cold Start" (app launch from link) and "Warm Start" (app running in background) scenarios.

File: lib/services/deep_link_service.dart

import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:flutter/foundation.dart';

class DeepLinkService {
  // Singleton pattern
  static final DeepLinkService _instance = DeepLinkService._internal();
  factory DeepLinkService() => _instance;
  DeepLinkService._internal();

  final AppLinks _appLinks = AppLinks();
  StreamSubscription<Uri>? _linkSubscription;

  /// Initialize deep link listeners.
  /// Call this early in your main() or App initialization.
  Future<void> initialize({
    required Function(Uri uri) onLinkReceived,
  }) async {
    // 1. Handle Cold Start (App launched via link)
    try {
      final Uri? initialUri = await _appLinks.getInitialLink();
      if (initialUri != null) {
        debugPrint('DeepLinkService: Cold start link detected: $initialUri');
        onLinkReceived(initialUri);
      }
    } catch (e) {
      // Ignore format errors on initial link, common on some Android OEMs
      debugPrint('DeepLinkService: Error getting initial link: $e');
    }

    // 2. Handle Warm Start (App resumed via link)
    _linkSubscription = _appLinks.uriLinkStream.listen(
      (Uri? uri) {
        if (uri != null) {
          debugPrint('DeepLinkService: Background link detected: $uri');
          onLinkReceived(uri);
        }
      },
      onError: (Object err) {
        debugPrint('DeepLinkService: Stream error: $err');
      },
    );
  }

  void dispose() {
    _linkSubscription?.cancel();
  }
}

Usage in main.dart

import 'package:flutter/material.dart';
import 'services/deep_link_service.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();

  @override
  void initState() {
    super.initState();
    _initDeepLinks();
  }

  void _initDeepLinks() {
    DeepLinkService().initialize(
      onLinkReceived: (Uri uri) {
        // Normalize logic: Handle your routing here
        // Example: /product/123 -> push product page
        if (uri.pathSegments.contains('product')) {
          final id = uri.pathSegments.last;
          _navigatorKey.currentState?.pushNamed('/product', arguments: id);
        }
      },
    );
  }

  @override
  void dispose() {
    DeepLinkService().dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorKey: _navigatorKey, // Critical for navigating without context
      home: const Scaffold(body: Center(child: Text("Home"))),
      routes: {
        '/product': (context) => const Scaffold(body: Center(child: Text("Product Details"))),
      },
    );
  }
}

Why This Works

By setting flutter_deeplinking_enabled to false (Android) and FlutterDeepLinkingEnabled to NO (iOS), you essentially revert the Flutter Engine's behavior to pre-3.19 standards regarding intent handling.

  1. Android: The FlutterActivity no longer consumes the Intent data during onCreate. This leaves the Intent "fresh" for the plugin's MethodChannel call to retrieve via the Android Activity API.
  2. iOS: The FlutterAppDelegate stops intercepting the application:continueUserActivity:restorationHandler: method, allowing the plugin's swizzled implementation to catch the callback first.

Conclusion

Flutter's move toward native deep link handling is the correct long-term architectural decision, as it removes the dependency on third-party plugins for core functionality. However, for existing applications heavily integrated with uni_links or legacy Firebase implementations, the automatic enablement in 3.27 acts as a breaking change.

The metadata override provides a stable bridge, allowing you to maintain your current deep link architecture without crashes while you plan a migration to the native PlatformDispatcher or go_router solution.