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:
iOS pushes the same screen twice.
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
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,
);
}
}
From Flutter 3.24, you must disable it explicitly.
Navigate to ios/Runner/Info.plist file.
Add the following in <dict>
chapter:
<key>FlutterDeepLinkingEnabled</key>
<false/>
If you have a custom AppDelegate with overridden methods either:
application(_:willFinishLaunchingWithOptions:)
or application(_:didFinishLaunchingWithOptions:)
or another package also dealing with Universal Links or Custom URL schemes (This can be difficult to detect, so try the example project if links are not forwarded).
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.
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)
}
}