Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 98 additions & 40 deletions lib/core/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import 'package:mostro_mobile/core/app_routes.dart';
import 'package:mostro_mobile/core/app_theme.dart';
import 'package:mostro_mobile/core/deep_link_handler.dart';
import 'package:mostro_mobile/core/deep_link_interceptor.dart';
import 'package:mostro_mobile/core/deep_link_schemes.dart';
import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart';
import 'package:mostro_mobile/generated/l10n.dart';
import 'package:mostro_mobile/features/auth/notifiers/auth_state.dart';
import 'package:mostro_mobile/services/lifecycle_manager.dart';
import 'package:mostro_mobile/services/logger_service.dart';
import 'package:mostro_mobile/features/notifications/services/background_notification_service.dart';
import 'package:mostro_mobile/shared/providers/app_init_provider.dart';
import 'package:mostro_mobile/features/settings/settings_provider.dart';
Expand All @@ -32,11 +34,16 @@ class MostroApp extends ConsumerStatefulWidget {
ConsumerState<MostroApp> createState() => _MostroAppState();
}

/// How many frames a pending deep link waits before it is dropped
const _maxDeepLinkAttempts = 10;

class _MostroAppState extends ConsumerState<MostroApp> {
GoRouter? _router;
bool _deepLinksInitialized = false;
bool _notificationLaunchHandled = false;
DeepLinkInterceptor? _deepLinkInterceptor;
Uri? _pendingDeepLink;
int _deepLinkAttempts = 0;
StreamSubscription<String>? _customUrlSubscription;

@override
Expand All @@ -54,60 +61,110 @@ class _MostroAppState extends ConsumerState<MostroApp> {

// Listen for intercepted custom URLs
_customUrlSubscription = _deepLinkInterceptor!.customUrlStream.listen(
(url) async {
debugPrint('Intercepted custom URL: $url');

// Process the URL through our deep link handler
if (_router != null) {
try {
final uri = Uri.parse(url);
final deepLinkHandler = ref.read(deepLinkHandlerProvider);
await deepLinkHandler.handleInitialDeepLink(uri, _router!);
} catch (e) {
debugPrint('Error handling intercepted URL: $e');
}
(url) {
logger.i('Intercepted custom URL: $url');
final uri = Uri.tryParse(url);
if (uri == null) {
logger.w('Ignoring unparseable custom URL: $url');
return;
}
_queueDeepLink(uri);
},
onError: (error) {
debugPrint('Error in custom URL stream: $error');
logger.e('Error in custom URL stream: $error');
},
);
}

/// Process initial deep link before router initialization
Future<void> _processInitialDeepLink() async {
Uri? initialUri;
try {
final appLinks = AppLinks();
final initialUri = await appLinks.getInitialLink();
initialUri = await AppLinks().getInitialLink();
} catch (e, stack) {
logger.e('Error processing initial deep link',
error: e, stackTrace: stack);
}
initialUri ??= _platformDefaultDeepLink();

if (initialUri != null && isCustomSchemeUri(initialUri)) {
logger.i('Initial deep link detected: $initialUri');
_queueDeepLink(initialUri);
}
}

if (initialUri != null && initialUri.scheme == 'mostro') {
// Store the initial mostro URL for later processing
// and prevent it from being passed to GoRouter
debugPrint('Initial mostro deep link detected: $initialUri');
/// The cold start link createRouter discards, in case app_links missed it
Uri? _platformDefaultDeepLink() {
final location =
WidgetsBinding.instance.platformDispatcher.defaultRouteName;
if (!isCustomSchemeLocation(location)) return null;
logger.i('Falling back to the platform default location: $location');
return Uri.tryParse(location);
}

// Schedule the deep link processing after the router is ready
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleInitialMostroLink(initialUri);
});
}
} catch (e) {
debugPrint('Error processing initial deep link: $e');
/// Keep the link until there is a router and a navigator to open it with.
/// One slot is enough: links arrive one at a time, and the newest is the one
/// the user just asked for.
void _queueDeepLink(Uri uri) {
if (_pendingDeepLink != null && _pendingDeepLink != uri) {
logger.w('Replacing pending deep link $_pendingDeepLink with $uri');
}
_pendingDeepLink = uri;
_deepLinkAttempts = 0;
_deliverPendingDeepLink();
}

void _deliverPendingDeepLink() {
if (!mounted) return;
final uri = _pendingDeepLink;
final router = _router;
if (uri == null || router == null) return;

// The handler needs the navigator, which only exists once the router has
// rendered a frame.
if (router.routerDelegate.navigatorKey.currentContext == null) {
_retryPendingDeepLink();
return;
}

_pendingDeepLink = null;
unawaited(_handleDeepLink(uri, router));
}

/// Try again on the next frame, up to [_maxDeepLinkAttempts]. Retrying on
/// consecutive frames keeps a link from opening an order long after the user
/// asked for it.
void _retryPendingDeepLink() {
_deepLinkAttempts++;
if (_deepLinkAttempts > _maxDeepLinkAttempts) {
logger.w('Giving up on deep link $_pendingDeepLink');
_pendingDeepLink = null;
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _deliverPendingDeepLink();
});
// A post frame callback does not request a frame on its own.
WidgetsBinding.instance.ensureVisualUpdate();
}

/// Handle initial mostro link after router is ready
Future<void> _handleInitialMostroLink(Uri uri) async {
Future<void> _handleDeepLink(Uri uri, GoRouter router) async {
var handled = false;
try {
// Wait for router to be ready
await Future.delayed(const Duration(milliseconds: 100));

if (_router != null) {
final deepLinkHandler = ref.read(deepLinkHandlerProvider);
await deepLinkHandler.handleInitialDeepLink(uri, _router!);
}
} catch (e) {
debugPrint('Error handling initial mostro link: $e');
handled = await ref
.read(deepLinkHandlerProvider)
.handleInitialDeepLink(uri, router);
} catch (e, stack) {
logger.e('Error handling deep link', error: e, stackTrace: stack);
}
if (handled) {
_deepLinkAttempts = 0;
return;
}
// The app was not in a state to open it, so keep it for another frame.
if (!mounted) return;
_pendingDeepLink ??= uri;
_retryPendingDeepLink();
}

@override
Expand Down Expand Up @@ -150,6 +207,7 @@ class _MostroAppState extends ConsumerState<MostroApp> {

// Initialize router if not already done
_router ??= createRouter(ref);
_deliverPendingDeepLink();

// Initialize deep links after router is created
if (!_deepLinksInitialized && _router != null) {
Expand All @@ -162,8 +220,8 @@ class _MostroAppState extends ConsumerState<MostroApp> {
} catch (e, stackTrace) {
// Log the error but don't set _deepLinksInitialized to true
// This allows retries on subsequent builds
debugPrint('Failed to initialize deep links: $e');
debugPrint('Stack trace: $stackTrace');
logger.e('Failed to initialize deep links',
error: e, stackTrace: stackTrace);
}
});
}
Expand All @@ -176,7 +234,7 @@ class _MostroAppState extends ConsumerState<MostroApp> {
if (!mounted) return;
if (payload != null && payload.isNotEmpty) {
final route = resolveNotificationRoute(payload);
debugPrint(
logger.i(
'App launched from notification tap, navigating to: $route');
_router!.push(route);
}
Expand Down
17 changes: 14 additions & 3 deletions lib/core/app_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,28 @@ import 'package:mostro_mobile/features/walkthrough/providers/first_run_provider.
import 'package:mostro_mobile/shared/widgets/navigation_listener_widget.dart';
import 'package:mostro_mobile/shared/widgets/notification_listener_widget.dart';
import 'package:mostro_mobile/generated/l10n.dart';
import 'package:mostro_mobile/core/deep_link_schemes.dart';
import 'package:mostro_mobile/services/logger_service.dart';

GoRouter createRouter(WidgetRef ref) {
// A cold-start deep link arrives as the platform default route, which
// go_router prefers over initialLocation; matching it asserts. Kept
// conditional so web still opens at the requested URL.
final platformDefaultLocation =
WidgetsBinding.instance.platformDispatcher.defaultRouteName;
final overridesPlatformDefault =
isCustomSchemeLocation(platformDefaultLocation);
if (overridesPlatformDefault) {
logger.i('Ignoring platform default location: $platformDefaultLocation');
}

return GoRouter(
navigatorKey: MostroApp.navigatorKey,
initialLocation: '/',
overridePlatformDefaultLocation: overridesPlatformDefault,
redirect: (context, state) {
// Redirect custom schemes to home to prevent assertion failures
if (state.uri.scheme == 'mostro' ||
(!state.uri.scheme.startsWith('http') &&
state.uri.scheme.isNotEmpty)) {
if (isCustomSchemeUri(state.uri)) {
return '/';
}
final firstRunState = ref.read(firstRunProvider);
Expand Down
50 changes: 33 additions & 17 deletions lib/core/deep_link_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,51 +39,58 @@ class DeepLinkHandler {
);
}

/// Handles initial deep link from app launch
Future<void> handleInitialDeepLink(Uri uri, GoRouter router) async {
await _handleDeepLink(uri, router);
/// Handles initial deep link from app launch. Returns false when the app was
/// not in a state to attempt it, so the caller can hand it over again.
Future<bool> handleInitialDeepLink(Uri uri, GoRouter router) {
return _handleDeepLink(uri, router);
}

/// Handles incoming deep links
Future<void> _handleDeepLink(Uri uri, GoRouter router) async {
Future<bool> _handleDeepLink(Uri uri, GoRouter router) async {
try {
logger.i('Handling deep link: $uri');

// Check if it's a mostro: scheme
if (uri.scheme == 'mostro') {
await _handleMostroDeepLink(uri.toString(), router);
return await _handleMostroDeepLink(uri.toString(), router);
} else {
logger.w('Unsupported deep link scheme: ${uri.scheme}');
final context = router.routerDelegate.navigatorKey.currentContext;
if (context != null && context.mounted) {
_showErrorSnackBar(context, S.of(context)!.unsupportedLinkFormat);
}
}
return true;
} catch (e) {
logger.e('Error handling deep link: $e');
final context = router.routerDelegate.navigatorKey.currentContext;
if (context != null && context.mounted) {
_showErrorSnackBar(context, S.of(context)!.failedToOpenLink);
}
return true;
}
}

/// Handles mostro: scheme deep links
Future<void> _handleMostroDeepLink(String url, GoRouter router) async {
Future<bool> _handleMostroDeepLink(String url, GoRouter router) async {
final now = DateTime.now();
final isDuplicateRecent =
_lastHandledDeepLinkUrl == url &&
_lastHandledDeepLinkAt != null &&
now.difference(_lastHandledDeepLinkAt!) < const Duration(seconds: 2);

if (_isHandlingMostroDeepLink || isDuplicateRecent) {
logger.i('Ignoring duplicate/concurrent deep link handling for: $url');
return;
if (isDuplicateRecent) {
logger.i('Ignoring duplicate deep link handling for: $url');
return true;
}

if (_isHandlingMostroDeepLink) {
// Another link is being opened right now; this one has not been tried.
logger.i('Deferring deep link while another one is handled: $url');
return false;
}

_isHandlingMostroDeepLink = true;
_lastHandledDeepLinkUrl = url;
_lastHandledDeepLinkAt = now;

BuildContext? context;
try {
Expand All @@ -93,18 +100,24 @@ class DeepLinkHandler {
_showLoadingDialog(context);
}

// Get the services
final nostrService = _ref.read(nostrServiceProvider);
final deepLinkService = _ref.read(deepLinkServiceProvider);

// Ensure we have a valid context for processing
final processingContext =
context ?? router.routerDelegate.navigatorKey.currentContext;
if (processingContext == null || !processingContext.mounted) {
logger.e('No valid context available for deep link processing');
return;
_hideLoadingDialog();
return false;
}

// Get the services
final nostrService = _ref.read(nostrServiceProvider);
final deepLinkService = _ref.read(deepLinkServiceProvider);

// Stamped here so a link that was never attempted is not taken for a
// duplicate when it comes back.
_lastHandledDeepLinkUrl = url;
_lastHandledDeepLinkAt = now;

// Process the mostro link
final result = await deepLinkService.processMostroLink(
url,
Expand Down Expand Up @@ -132,7 +145,7 @@ class DeepLinkHandler {
);
if (shouldSwitch != true) {
logger.i('User declined Mostro switch for deep link');
return;
return true;
}
// Switch Mostro instance
await _ref
Expand All @@ -149,6 +162,7 @@ class DeepLinkHandler {
logger.i(
'Successfully navigated to order: ${orderInfo.orderId} (${orderInfo.orderType.value})',
);
return true;
} else {
final errorContext = router.routerDelegate.navigatorKey.currentContext;
if (errorContext != null && errorContext.mounted) {
Expand All @@ -157,6 +171,7 @@ class DeepLinkHandler {
_showErrorSnackBar(errorContext, errorMessage);
}
logger.w('Failed to process mostro link: ${result.error}');
return true;
}
} catch (e) {
logger.e('Error processing mostro deep link: $e');
Expand All @@ -166,6 +181,7 @@ class DeepLinkHandler {
if (errorContext != null && errorContext.mounted) {
_showErrorSnackBar(errorContext, S.of(errorContext)!.failedToOpenOrder);
}
return true;
} finally {
_isHandlingMostroDeepLink = false;
}
Expand Down
Loading
Loading