import 'dart:async'; import 'package:app_links/app_links.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/widgets.dart'; import 'package:mc_common_app/config/routes.dart'; import 'package:mc_common_app/generated/locale_keys.g.dart'; import 'package:mc_common_app/models/advertisment_models/ad_details_model.dart'; import 'package:mc_common_app/utils/utils.dart'; import 'package:mc_common_app/view_models/ad_view_model.dart'; import 'package:provider/provider.dart'; /// Handles opening the ad detail screen directly when the app is launched or /// resumed via a shared ad link, for Android App Links / iOS Universal Links /// (https://ms.hmg.com/ad/1541) AND the custom-scheme fallback used by that /// same web page when App Links verification isn't active /// (mowatercustomer://ad/1541, mowaterprovider://ad/1541). class AdDeepLinkService { static final AppLinks _appLinks = AppLinks(); static StreamSubscription? _subscription; static Uri? _lastHandledUri; // Route name given to the inert, self-removing placeholder pushed from main.dart's // onUnknownRoute for Android's native deep-link route forwarding (see that file for // why it exists). It's pushed and removed again within a frame, so the route-settle // polling below must ignore it rather than treat its brief presence/absence as a real // navigation change -- otherwise the route name never reads as "stable" and _openAd // gives up without ever opening the ad. static const String ignoredPlaceholderRouteName = '__ad_deep_link_ignore__'; static String? _currentTopRouteName; /// Register via MaterialApp(navigatorObservers: [AdDeepLinkService.routeObserver]). /// This is the only reliable way to know the current top route name from here -- /// confirmed on a real device: ModalRoute.of(navigatorKey.currentContext) does NOT /// work for this. navigatorKey.currentContext is the Navigator widget's OWN element, /// which sits ABOVE all its routes in the tree, so it has no ModalRoute ancestor and /// that call returns null essentially always. That silently made every route-settle /// poll below read null forever, so _openAd always timed out and gave up without ever /// opening the ad, regardless of login state or what screen was actually showing. static final NavigatorObserver routeObserver = _RouteNameTracker(); /// Call once, after the app's first frame (so [navigatorKey] is attached to a Navigator). static Future init() async { debugPrint("AdDeepLinkService: init() called"); try { final Uri? initialUri = await _appLinks.getInitialLink(); debugPrint("AdDeepLinkService: getInitialLink() -> $initialUri"); if (initialUri != null) { _handleUri(initialUri); } } catch (e) { debugPrint("AdDeepLinkService: failed to read initial link - $e"); } _subscription?.cancel(); _subscription = _appLinks.uriLinkStream.listen( (uri) { debugPrint("AdDeepLinkService: uriLinkStream emitted $uri"); _handleUri(uri); }, onError: (Object e) => debugPrint("AdDeepLinkService: stream error - $e"), ); debugPrint("AdDeepLinkService: uriLinkStream subscription active"); } static void _handleUri(Uri uri) { debugPrint("AdDeepLinkService: received $uri"); // https://ms.hmg.com/ad/1541 -> host="ms.hmg.com", pathSegments=["ad", "1541"] // mowatercustomer://ad/1541 -> host="ad" (Dart treats it as the authority), pathSegments=["1541"] int? adId; if (uri.scheme == 'https') { final segments = uri.pathSegments; if (segments.length >= 2 && segments[0] == 'ad') { adId = int.tryParse(segments[1]); } } else if (uri.host == 'ad' && uri.pathSegments.isNotEmpty) { adId = int.tryParse(uri.pathSegments.first); } if (adId == null) return; // app_links can emit the same initial uri twice (once via getInitialLink, once as // the stream's first event) -- skip the duplicate rather than run _openAd twice. if (uri == _lastHandledUri) { debugPrint("AdDeepLinkService: ignoring duplicate $uri"); return; } _lastHandledUri = uri; _openAd(adId); } // Deliberately no loading dialog anywhere in here: on a cold start (app was killed), // this races the app's own bootstrap navigation -- confirmed on a real device, the // bootstrap chain is SplashPage's hardcoded 5s timer -> pushReplacementNamed to // registerSelection -> (if a saved login exists) a silent auto-login with its own // showLoading/hideLoading dialog -> pushReplacementNamed again towards the dashboard. // Every one of those calls uses a *captured* BuildContext and mutates the same // Navigator via Navigator.of(context), regardless of what else has since been pushed // on top. If we push the ad route while any of that is still in flight, our route gets // clobbered by the next replacement -- in debug this throws "_elements.contains // (element)"; in release (asserts stripped) it silently wedges the Navigator and the // app hangs on a black screen. So: wait for the current route name to stay the same // across two consecutive polls (i.e. bootstrap has actually settled) before touching // the Navigator or fetching anything. static Future _openAd(int adId) async { debugPrint("AdDeepLinkService: _openAd($adId) called"); if (!await _waitUntilRouteSettles()) { debugPrint("AdDeepLinkService: app never settled past bootstrap, aborting"); return; } debugPrint("AdDeepLinkService: route settled at '$_currentTopRouteName'"); // Checking for a saved token here is NOT a reliable "is the user ready" signal -- // confirmed on a real device: a stale token from before the app was killed survives // on disk through the whole re-login flow (splash -> registerSelection -> silent // relogin -> loginMethodSelection OTP screen), so it reads as "already logged in" // while still sitting on the OTP screen, fetches too early with no real session // ready yet, and silently fails. The dashboard route is the one signal that's only // true once a real session is actually in place, however the user got there. if (_currentTopRouteName != AppRoutes.dashboard) { debugPrint("AdDeepLinkService: not on dashboard yet ('$_currentTopRouteName'), waiting for login to finish"); if (!await _waitForRoute(AppRoutes.dashboard)) { debugPrint("AdDeepLinkService: gave up waiting for dashboard, aborting"); return; } debugPrint("AdDeepLinkService: reached dashboard"); } else { debugPrint("AdDeepLinkService: already on dashboard, proceeding directly"); } debugPrint("AdDeepLinkService: fetching ad $adId"); AdDetailsModel? adDetails; try { adDetails = await navigatorKey.currentContext?.read().getAdDetailsById(adId); debugPrint("AdDeepLinkService: fetch result adDetails=${adDetails != null ? 'FOUND id=${adDetails.id}' : 'NULL'}"); } catch (e) { debugPrint("AdDeepLinkService: failed to fetch ad $adId - $e"); } if (adDetails == null) { Utils.showToast(LocaleKeys.somethingWrong.tr()); return; } // Navigate via the NavigatorState directly (not a captured BuildContext) so this // still works even if the route/context active when we started has since changed. debugPrint("AdDeepLinkService: pushing ${AppRoutes.adsDetailView}"); navigatorKey.currentState?.pushNamed(AppRoutes.adsDetailView, arguments: adDetails); } static String? _currentRouteNameIgnoringPlaceholder() { final name = _currentTopRouteName; // The placeholder pops itself a frame after being pushed -- if this poll happens to // land exactly then, pretend nothing was read rather than counting it as a route. return name == ignoredPlaceholderRouteName ? null : name; } static Future _waitUntilRouteSettles({int maxAttempts = 60, Duration retryDelay = const Duration(milliseconds: 300)}) async { String? lastRouteName; for (int attempt = 1; attempt <= maxAttempts; attempt++) { final currentRouteName = _currentRouteNameIgnoringPlaceholder(); if (currentRouteName != null && currentRouteName != AppRoutes.splash && currentRouteName == lastRouteName) { return true; } if (currentRouteName != null) lastRouteName = currentRouteName; if (attempt < maxAttempts) await Future.delayed(retryDelay); } return false; } // Used both for the short "did bootstrap settle" check and for waiting on the // dashboard specifically, which can be user-paced (entering an OTP, picking a login // method) -- default timeout is generous (5 minutes) to cover that case. static Future _waitForRoute(String targetRoute, {int maxAttempts = 600, Duration retryDelay = const Duration(milliseconds: 500)}) async { for (int attempt = 1; attempt <= maxAttempts; attempt++) { if (_currentRouteNameIgnoringPlaceholder() == targetRoute) return true; if (attempt < maxAttempts) await Future.delayed(retryDelay); } return false; } } class _RouteNameTracker extends NavigatorObserver { @override void didPush(Route route, Route? previousRoute) { AdDeepLinkService._currentTopRouteName = route.settings.name; } @override void didPop(Route route, Route? previousRoute) { AdDeepLinkService._currentTopRouteName = previousRoute?.settings.name; } @override void didRemove(Route route, Route? previousRoute) { AdDeepLinkService._currentTopRouteName = previousRoute?.settings.name; } @override void didReplace({Route? newRoute, Route? oldRoute}) { AdDeepLinkService._currentTopRouteName = newRoute?.settings.name; } }