flutterdeep-linkingflutter-getx

Flutter iOS deep linking pushes same screen twice when using GetX and app_links (works fine on Android)


I'm building a Flutter app using GetX for navigation and the app_links package for deep linking. The deep links work perfectly on Android, but on iOS, the same screen is pushed twice when opening a deep link.

✅ Expected Behavior: Deep link like:

https://www.torrins.com/instructor_detail?instructor_name=Muriel+Anderson&instructor_hash=muriel-anderson should push only one instance of /instructor_detail screen with query parameters.

❌ Actual Behavior on iOS:

  1. iOS pushes the same screen twice.

  2. Logs show:

[GETX] GOING TO ROUTE /instructor_detail?instructor_name=Muriel+Anderson&instructor_hash=muriel-anderson
[GETX] Instance "ControllerInstructorDetail" has been created
[GETX] Instance "ControllerInstructorDetail" has been created
  1. This does not happen on Android — the same code pushes the screen only once.

Issue:

When the app is terminated (killed) and I open a deep link, it looks like both the ControllerSplash getLocalData() and DeepLinkingService getInitialLink() are running simultaneously.

As a result, the navigation go to the specific screen then after 2 seconds it goes to the dashboard if the user logged in or login screen if not.

controller_splash.dart

import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:torrins_app/resources/app_log.dart';
import 'package:torrins_app/screens/dashboard/screen_dashboard.dart';
import 'package:torrins_app/screens/onboarding/screen_already_a_member.dart';
import 'package:upgrader/upgrader.dart';

import '../../api/user-agent/custom_user_agent.dart';
import '../../core/app_flow.dart';
import '../../resources/app_preference.dart';
import '../../screens/onboarding/screen_onboarding.dart';
import 'controller_deep_linking.dart';

class ControllerSplash extends GetxController {
  Upgrader? upgrader;

  @override
  void onInit() async {
    super.onInit();
    if (kReleaseMode) {
      upgrader = Upgrader(
        messages: UpgraderMessages(),
        debugDisplayAlways: false,
        debugLogging: true,
        durationUntilAlertAgain: const Duration(days: 1),
        storeController: UpgraderStoreController(
          onAndroid: () => UpgraderPlayStore(),
          oniOS: () => UpgraderAppStore(),
        ),
        willDisplayUpgrade: (
            {required display, installedVersion, versionInfo}) {
          debugPrint("Installed Version: $installedVersion");
          debugPrint("Latest Version: ${versionInfo?.appStoreVersion}");
          debugPrint('Display: $display');

          if (!display) {
            Future.microtask(() => getLocalData());
          }
        },
      );
    } else {
      await getLocalData();
    }

    // if (display) {
    //   await getLocalData();
    // }
  }

  @override
  void onClose() {
    Get.find<DeepLinkingService>().appReady();
    super.onClose();
  }

  getUserAgent() async {
    final UserAgentService userAgentService = UserAgentService();
    String userAgent = await userAgentService.initPlatformState();
    if (userAgent.isNotEmpty) {
      AppLog.i("User Agent $userAgent");
      await AppPreference.writeString(AppPreference.userAgent, userAgent);
    }
  }

  getLocalData() async {
    SharedPreferences preferences = await SharedPreferences.getInstance();
    bool? isFirstTime =
        await AppPreference.readBool(AppPreference.isUsersFirstTime) ?? true;

    if (isFirstTime) {
      await AppPreference.writeBool(AppPreference.isUsersFirstTime, false);
      Future.delayed(
        const Duration(seconds: 2),
        () {
          Get.offAndToNamed(ScreenOnboarding.pageId);
        },
      );
    } else if (preferences.containsKey(AppPreference.authToken)) {
      bool? isFirstTime =
          preferences.containsKey(AppPreference.isUsersFirstTime)
              ? await AppPreference.readBool(AppPreference.isUsersFirstTime)
              : true;

      if (isFirstTime == true) {
        await AppPreference.writeBool(AppPreference.isUsersFirstTime, false);
        Future.delayed(
          const Duration(seconds: 2),
          () {
            Get.offAndToNamed(ScreenOnboarding.pageId);
          },
        );
      } else {
        String? authToken =
            await AppPreference.readString(AppPreference.authToken);
        Future.delayed(const Duration(seconds: 2), () {
          if (authToken?.isNotEmpty == true) {
            Get.offAndToNamed(ScreenDashboard.pageId, arguments: {
              'index': 0,
              'flow': AppDashBoardFlow.direct,
              'flowIndex': 0
            });
          } else {
            Get.offAndToNamed(ScreenAlreadyAMember.pageId);
          }
        });
      }
    } else {
      Future.delayed(const Duration(milliseconds: 500), () {
        Get.offAndToNamed(ScreenAlreadyAMember.pageId);
      });
    }
  }
}

controller_deep_linking.dart

import 'package:app_links/app_links.dart';
import 'package:get/get.dart';
import 'package:torrins_app/core/app_flow.dart';
import 'package:torrins_app/resources/app_log.dart';

class DeepLinkingService extends GetxService {
  final AppLinks _appLinks = AppLinks();
  Uri? _pendingUri;

  bool _isAppInitialized = false;
  bool _isProcessingLink = false;

  bool get isProcessingLink => _isProcessingLink;

  bool get hasPendingLink => _pendingUri != null;

  Future<DeepLinkingService> init() async {
    AppLog.i("Initializing DeepLinkingService");

    _appLinks.uriLinkStream.listen((Uri? uri) async {
      AppLog.i("Received URI while app running: $uri");
      if (uri != null) {
        if (_isAppInitialized) {
          _handleDeepLink(uri);
        } else {
          AppLog.i("App not initialized yet, storing URI for later");
          _pendingUri = uri;
          _isProcessingLink = true;
        }
      }
    });

    try {
      final initialUri = await _appLinks.getInitialLink();
      AppLog.i("Initial URI: $initialUri");
      if (initialUri != null) {
        _pendingUri = initialUri; // Store for later processing
        _isProcessingLink = true;
      }
    } catch (e) {
      AppLog.e("Error getting initial URI: $e");
    }

    return this;
  }

  // Call this after your splash screen completes and home page is loaded
  void appReady() {
    AppLog.i("App is now ready to process deep links");
    _isAppInitialized = true;
    processPendingDeepLink();
  }

  void processPendingDeepLink() {
    if (!_isAppInitialized) {
      AppLog.i("App not initialized yet, will process deep link later");
      return;
    }

    if (_pendingUri != null) {
      AppLog.i("Processing pending deep link: $_pendingUri");
      _handleDeepLink(_pendingUri!);
      _pendingUri = null;
    }
  }

  void _handleDeepLink(Uri uri) {
    AppLog.i("Handling deep link: $uri");
    _navigateToDeepLink(uri);
  }

  void _navigateToDeepLink(Uri uri) {
    AppLog.i("Deep link received: $uri");

    final pathSegments = uri.pathSegments;

    if (pathSegments.isEmpty) {
      return;
    }
    _isProcessingLink = true;

    final routeName = uri.path;
    final arguments = uri.queryParameters;

    print('isAppInitialised: $_isAppInitialized');
    if (_isAppInitialized) {
      if (routeName == '/login' || routeName == '/sign_up') {
        Get.toNamed(routeName, arguments: {'from': AppFlow.direct});
      } else {
        Get.toNamed(routeName, arguments: arguments);
      }
      _isProcessingLink = false;
    } else {
      AppLog.i("App not ready yet, storing URI for later navigation");
      _pendingUri = uri;
    }
  }
}

main.dart

import 'dart:io';

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get/get.dart';
import 'package:torrins_app/firebase_api.dart';
import 'package:torrins_app/resources/app_helper.dart';
import 'package:torrins_app/resources/app_log.dart';
import 'package:torrins_app/resources/app_preference.dart';

import 'api/network_controller.dart';
import 'api/user-agent/custom_user_agent.dart';
import 'firebase_options.dart';
import 'get/controller/controller_deep_linking.dart';
import 'get/dependency_injection.dart';
import 'get/routes/get_routes.dart';

void main() async {
  DependencyInjection.init();
  HttpOverrides.global = MyHttpOverrides();
  WidgetsFlutterBinding.ensureInitialized();
  UserAgentService().initPlatformState();
  await AppHelper.appVersion;
  if (kReleaseMode) {
    FlutterError.onError = (errorDetails) {
      FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails);
    };
    PlatformDispatcher.instance.onError = (error, stack) {
      FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
      return true;
    };
  }
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  final message = await FirebaseMessaging.instance.getInitialMessage();
  // FirebaseMessaging.onBackgroundMessage(handleBackgroundMessage);
  await FirebaseApi().initNotifications();
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp,
  ]);
  dotenv.load(fileName: '.env');

  Get.put(NetworkController(), permanent: true);
  await Get.putAsync(() => DeepLinkingService().init());

  await getUserAgent();

  runApp(MyApp(message: message));
}

getUserAgent() async {
  final UserAgentService userAgentService = UserAgentService();
  String userAgent = await userAgentService.initPlatformState();
  if (userAgent.isNotEmpty) {
    AppLog.i("User Agent from main $userAgent");
    await AppPreference.writeString(AppPreference.userAgent, userAgent);
  }
}

class MyHttpOverrides extends HttpOverrides {
  @override
  HttpClient createHttpClient(SecurityContext? context) {
    return super.createHttpClient(context)
      ..badCertificateCallback =
          (X509Certificate cert, String host, int port) => true;
  }
}

class MyApp extends StatelessWidget {
  final RemoteMessage? message;

  const MyApp({super.key, this.message});

  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      title: 'Torrins',
      useInheritedMediaQuery: true,
      debugShowCheckedModeBanner: false,
      initialRoute: '/',
      getPages: Routes.routes,
    );
  }
}

Solution

  • From Flutter 3.24, you must disable it explicitly.

    1. Navigate to ios/Runner/Info.plist file.

    2. Add the following in <dict> chapter:

      <key>FlutterDeepLinkingEnabled</key>
      <false/>
      

    If you have a custom AppDelegate with overridden methods either:

    this may break the workflow to catch links or provide unwanted behaviours.

    The default workflow requires that both methods call super and return true to enable app link workflow.

    SETUP (only if you can't conform to the notice above).

    AppDelegate.swift

    import app_links
    
    @main
    @objc class AppDelegate: FlutterAppDelegate {
      override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
      ) -> Bool {
        GeneratedPluginRegistrant.register(with: self)
    
        // Retrieve the link from parameters
        if let url = AppLinks.shared.getLink(launchOptions: launchOptions) {
          // We have a link, propagate it to your Flutter app or not
          AppLinks.shared.handleLink(url: url)
          return true // Returning true will stop the propagation to other packages
        }
    
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
      }
    }