diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f5c5b554..5f5e1642 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,12 +27,12 @@ - + - + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 06843223..84f7b061 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -55,15 +55,15 @@ processing remote-notification - + CFBundleURLTypes CFBundleURLName - nostr.scheme + mostro.scheme CFBundleURLSchemes - nostr + mostro CFBundleTypeRole Editor diff --git a/lib/core/app.dart b/lib/core/app.dart index ab2d85c3..1ce8b8d3 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -1,10 +1,13 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:app_links/app_links.dart'; 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/features/auth/providers/auth_notifier_provider.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/features/auth/notifiers/auth_state.dart'; @@ -27,15 +30,84 @@ class MostroApp extends ConsumerStatefulWidget { class _MostroAppState extends ConsumerState { GoRouter? _router; bool _deepLinksInitialized = false; + DeepLinkInterceptor? _deepLinkInterceptor; + StreamSubscription? _customUrlSubscription; @override void initState() { super.initState(); ref.read(lifecycleManagerProvider); + _initializeDeepLinkInterceptor(); + _processInitialDeepLink(); + } + + /// Initialize the deep link interceptor + void _initializeDeepLinkInterceptor() { + _deepLinkInterceptor = DeepLinkInterceptor(); + _deepLinkInterceptor!.initialize(); + + // 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'); + } + } + }, + onError: (error) { + debugPrint('Error in custom URL stream: $error'); + }, + ); + } + + /// Process initial deep link before router initialization + Future _processInitialDeepLink() async { + try { + final appLinks = AppLinks(); + final initialUri = await appLinks.getInitialLink(); + + 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'); + + // Schedule the deep link processing after the router is ready + WidgetsBinding.instance.addPostFrameCallback((_) { + _handleInitialMostroLink(initialUri); + }); + } + } catch (e) { + debugPrint('Error processing initial deep link: $e'); + } + } + + /// Handle initial mostro link after router is ready + Future _handleInitialMostroLink(Uri uri) async { + 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'); + } } @override void dispose() { + _customUrlSubscription?.cancel(); + _deepLinkInterceptor?.dispose(); // Deep link handler disposal is handled automatically by Riverpod super.dispose(); } @@ -75,6 +147,7 @@ class _MostroAppState extends ConsumerState { try { final deepLinkHandler = ref.read(deepLinkHandlerProvider); deepLinkHandler.initialize(_router!); + _deepLinksInitialized = true; } catch (e, stackTrace) { // Log the error but don't set _deepLinksInitialized to true diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index 40031bfe..05f89cf1 100644 --- a/lib/core/app_routes.dart +++ b/lib/core/app_routes.dart @@ -23,10 +23,13 @@ import 'package:mostro_mobile/features/walkthrough/screens/walkthrough_screen.da import 'package:mostro_mobile/features/walkthrough/providers/first_run_provider.dart'; import 'package:mostro_mobile/shared/widgets/navigation_listener_widget.dart'; import 'package:mostro_mobile/shared/widgets/notification_listener_widget.dart'; +import 'package:logger/logger.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; GoRouter createRouter(WidgetRef ref) { return GoRouter( navigatorKey: GlobalKey(), + initialLocation: '/', redirect: (context, state) { final firstRunState = ref.read(firstRunProvider); @@ -41,8 +44,31 @@ GoRouter createRouter(WidgetRef ref) { error: (_, __) => null, ); }, + errorBuilder: (context, state) { + final logger = Logger(); + logger.w('GoRouter error: ${state.error}'); + + // For errors, show a generic error page + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error, size: 64), + const SizedBox(height: 16), + Text(S.of(context)!.deepLinkNavigationError(state.error.toString())), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => context.go('/'), + child: Text(S.of(context)!.deepLinkGoHome), + ), + ], + ), + ), + ); + }, routes: [ - ShellRoute( + ShellRoute( builder: (BuildContext context, GoRouterState state, Widget child) { return NotificationListenerWidget( child: NavigationListenerWidget( @@ -227,10 +253,6 @@ GoRouter createRouter(WidgetRef ref) { ], ), ], - initialLocation: '/', - errorBuilder: (context, state) => Scaffold( - body: Center(child: Text(state.error.toString())), - ), ); } diff --git a/lib/core/deep_link_handler.dart b/lib/core/deep_link_handler.dart index aa24dad6..c729475a 100644 --- a/lib/core/deep_link_handler.dart +++ b/lib/core/deep_link_handler.dart @@ -31,6 +31,11 @@ class DeepLinkHandler { ); } + /// Handles initial deep link from app launch + Future handleInitialDeepLink(Uri uri, GoRouter router) async { + await _handleDeepLink(uri, router); + } + /// Handles incoming deep links Future _handleDeepLink( Uri uri, @@ -39,9 +44,9 @@ class DeepLinkHandler { try { _logger.i('Handling deep link: $uri'); - // Check if it's a nostr: scheme - if (uri.scheme == 'nostr') { - await _handleNostrDeepLink(uri.toString(), router); + // Check if it's a mostro: scheme + if (uri.scheme == 'mostro') { + await _handleMostroDeepLink(uri.toString(), router); } else { _logger.w('Unsupported deep link scheme: ${uri.scheme}'); final context = router.routerDelegate.navigatorKey.currentContext; @@ -58,8 +63,8 @@ class DeepLinkHandler { } } - /// Handles nostr: scheme deep links - Future _handleNostrDeepLink( + /// Handles mostro: scheme deep links + Future _handleMostroDeepLink( String url, GoRouter router, ) async { @@ -75,8 +80,8 @@ class DeepLinkHandler { final nostrService = _ref.read(nostrServiceProvider); final deepLinkService = _ref.read(deepLinkServiceProvider); - // Process the nostr link - final result = await deepLinkService.processNostrLink(url, nostrService); + // Process the mostro link + final result = await deepLinkService.processMostroLink(url, nostrService, context!); // Get fresh context after async operation final currentContext = router.routerDelegate.navigatorKey.currentContext; @@ -87,8 +92,10 @@ class DeepLinkHandler { } if (result.isSuccess && result.orderInfo != null) { - // Navigate to the appropriate screen - deepLinkService.navigateToOrder(router, result.orderInfo!); + // Navigate to the appropriate screen with proper timing + WidgetsBinding.instance.addPostFrameCallback((_) { + deepLinkService.navigateToOrder(router, result.orderInfo!); + }); _logger.i('Successfully navigated to order: ${result.orderInfo!.orderId} (${result.orderInfo!.orderType.value})'); } else { final errorContext = router.routerDelegate.navigatorKey.currentContext; @@ -96,10 +103,10 @@ class DeepLinkHandler { final errorMessage = result.error ?? S.of(errorContext)!.failedToLoadOrder; _showErrorSnackBar(errorContext, errorMessage); } - _logger.w('Failed to process nostr link: ${result.error}'); + _logger.w('Failed to process mostro link: ${result.error}'); } } catch (e) { - _logger.e('Error processing nostr deep link: $e'); + _logger.e('Error processing mostro deep link: $e'); final errorContext = router.routerDelegate.navigatorKey.currentContext; if (errorContext != null && errorContext.mounted) { Navigator.of(errorContext).pop(); // Hide loading if still showing diff --git a/lib/core/deep_link_interceptor.dart b/lib/core/deep_link_interceptor.dart new file mode 100644 index 00000000..2a9862d4 --- /dev/null +++ b/lib/core/deep_link_interceptor.dart @@ -0,0 +1,57 @@ +import 'dart:async'; +import 'package:flutter/widgets.dart'; +import 'package:logger/logger.dart'; + +/// A deep link interceptor that prevents custom schemes from reaching GoRouter +/// This prevents assertion failures when the system tries to parse mostro: URLs +class DeepLinkInterceptor extends WidgetsBindingObserver { + final Logger _logger = Logger(); + final StreamController _customUrlController = + StreamController.broadcast(); + + /// Stream for custom URLs that were intercepted + Stream get customUrlStream => _customUrlController.stream; + + /// Initialize the interceptor + void initialize() { + WidgetsBinding.instance.addObserver(this); + _logger.i('DeepLinkInterceptor initialized'); + } + + @override + Future didPushRouteInformation(RouteInformation routeInformation) async { + final uri = routeInformation.uri; + _logger.i('Route information received: $uri'); + + // Check if this is a custom scheme URL + if (_isCustomScheme(uri)) { + _logger.i('Custom scheme detected: ${uri.scheme}, intercepting'); + + // Emit the custom URL for processing + _customUrlController.add(uri.toString()); + + // Return true to indicate we handled this route, preventing it from + // reaching GoRouter and causing assertion failures + return true; + } + + // Let normal URLs pass through to GoRouter + return super.didPushRouteInformation(routeInformation); + } + + // Note: didPushRoute is deprecated, but we keep it for compatibility + // The main handling is done in didPushRouteInformation above + + /// Check if the URI uses a custom scheme + bool _isCustomScheme(Uri uri) { + return uri.scheme == 'mostro' || + (!uri.scheme.startsWith('http') && uri.scheme.isNotEmpty); + } + + /// Dispose the interceptor + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _customUrlController.close(); + _logger.i('DeepLinkInterceptor disposed'); + } +} \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 749e0ef8..dcaca68d 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -452,5 +452,21 @@ "unsupportedLinkFormat": "Unsupported link format", "failedToOpenLink": "Failed to open link", "failedToLoadOrder": "Failed to load order", - "failedToOpenOrder": "Failed to open order" + "failedToOpenOrder": "Failed to open order", + + "@_comment_deep_link_errors": "Deep Link Error Messages", + "deepLinkInvalidFormat": "Invalid mostro: URL format", + "deepLinkParseError": "Failed to parse mostro: URL", + "deepLinkInvalidOrderId": "Invalid order ID format", + "deepLinkNoRelays": "No relays specified in URL", + "deepLinkOrderNotFound": "Order not found or invalid", + "deepLinkNavigationError": "Navigation Error: {error}", + "@deepLinkNavigationError": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "deepLinkGoHome": "Go Home" } diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 0edfbf22..f0274f22 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -485,6 +485,22 @@ "unsupportedLinkFormat": "Formato de enlace no compatible", "failedToOpenLink": "Error al abrir enlace", "failedToLoadOrder": "Error al cargar orden", - "failedToOpenOrder": "Error al abrir orden" + "failedToOpenOrder": "Error al abrir orden", + + "@_comment_deep_link_errors": "Mensajes de Error de Enlaces Profundos", + "deepLinkInvalidFormat": "Formato de URL mostro: inválido", + "deepLinkParseError": "Error al analizar la URL mostro:", + "deepLinkInvalidOrderId": "Formato de ID de orden inválido", + "deepLinkNoRelays": "No se especificaron relés en la URL", + "deepLinkOrderNotFound": "Orden no encontrada o inválida", + "deepLinkNavigationError": "Error de Navegación: {error}", + "@deepLinkNavigationError": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "deepLinkGoHome": "Ir al Inicio" } \ No newline at end of file diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 317ee750..18bf3dfc 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -493,6 +493,22 @@ "unsupportedLinkFormat": "Formato di link non supportato", "failedToOpenLink": "Impossibile aprire il link", "failedToLoadOrder": "Impossibile caricare l'ordine", - "failedToOpenOrder": "Impossibile aprire l'ordine" + "failedToOpenOrder": "Impossibile aprire l'ordine", + + "@_comment_deep_link_errors": "Messaggi di Errore per Deep Link", + "deepLinkInvalidFormat": "Formato URL mostro: non valido", + "deepLinkParseError": "Impossibile analizzare l'URL mostro:", + "deepLinkInvalidOrderId": "Formato ID ordine non valido", + "deepLinkNoRelays": "Nessun relay specificato nell'URL", + "deepLinkOrderNotFound": "Ordine non trovato o non valido", + "deepLinkNavigationError": "Errore di Navigazione: {error}", + "@deepLinkNavigationError": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "deepLinkGoHome": "Vai alla Home" } diff --git a/lib/services/deep_link_service.dart b/lib/services/deep_link_service.dart index ec2179c3..cd0436ee 100644 --- a/lib/services/deep_link_service.dart +++ b/lib/services/deep_link_service.dart @@ -1,11 +1,14 @@ import 'dart:async'; import 'package:app_links/app_links.dart'; +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; /// Contains order information extracted from a Nostr event class OrderInfo { @@ -23,7 +26,8 @@ class DeepLinkService { final AppLinks _appLinks = AppLinks(); // Stream controller for deep link events - final StreamController _deepLinkController = StreamController.broadcast(); + final StreamController _deepLinkController = + StreamController.broadcast(); Stream get deepLinkStream => _deepLinkController.stream; // Flag to track if service is initialized @@ -45,13 +49,9 @@ class DeepLinkService { }, ); - // Check for deep link when app is launched - final initialUri = await _appLinks.getInitialLink(); - if (initialUri != null) { - _logger.i('App launched with deep link: $initialUri'); - _handleDeepLink(initialUri); - } - + // NOTE: We don't process the initial link here to avoid GoRouter conflicts + // The initial link will be handled by the app initialization in app.dart + _isInitialized = true; _logger.i('DeepLinkService initialized successfully'); } catch (e) { @@ -65,68 +65,119 @@ class DeepLinkService { _deepLinkController.add(uri); } - /// Processes a nostr: deep link and resolves order information - Future processNostrLink( + /// Processes a mostro: deep link and resolves order information + Future processMostroLink( String url, NostrService nostrService, + BuildContext context, ) async { try { - _logger.i('Processing nostr link: $url'); + _logger.i('Processing mostro link: $url'); // Validate URL format - if (!NostrUtils.isValidNostrUrl(url)) { - return DeepLinkResult.error('Invalid nostr: URL format'); + if (!NostrUtils.isValidMostroUrl(url)) { + return DeepLinkResult.error(S.of(context)!.deepLinkInvalidFormat); + } + + // Parse the mostro URL + final orderInfo = NostrUtils.parseMostroUrl(url); + if (orderInfo == null) { + return DeepLinkResult.error(S.of(context)!.deepLinkParseError); } - // Parse the nevent - final eventInfo = NostrUtils.parseNostrUrl(url); - if (eventInfo == null) { - return DeepLinkResult.error('Failed to parse nostr: URL'); + final orderId = orderInfo['orderId'] as String; + final relays = orderInfo['relays'] as List; + + // Validate order ID format (UUID-like string) + if (orderId.isEmpty || orderId.length < 10) { + return DeepLinkResult.error(S.of(context)!.deepLinkInvalidOrderId); } - final eventId = eventInfo['eventId'] as String; - final relays = eventInfo['relays'] as List; + // Validate relays + if (relays.isEmpty) { + return DeepLinkResult.error(S.of(context)!.deepLinkNoRelays); + } - _logger.i('Parsed event ID: $eventId, relays: $relays'); + _logger.i('Parsed order ID: $orderId, relays: $relays'); - // Fetch the order info from the event's tags - final orderInfo = await _fetchOrderInfoFromEvent( - eventId, + // Fetch the order info directly using the order ID + final fetchedOrderInfo = await _fetchOrderInfoById( + orderId, relays, nostrService, ); - if (orderInfo == null) { - return DeepLinkResult.error('Order not found or invalid'); + if (fetchedOrderInfo == null) { + if (context.mounted) { + return DeepLinkResult.error(S.of(context)!.deepLinkOrderNotFound); + } else { + return DeepLinkResult.error('Order not found or invalid'); + } } - return DeepLinkResult.success(orderInfo); + return DeepLinkResult.success(fetchedOrderInfo); } catch (e) { - _logger.e('Error processing nostr link: $e'); + _logger.e('Error processing mostro link: $e'); return DeepLinkResult.error('Failed to process deep link: $e'); } } - /// Fetches order information from the specified event's tags - Future _fetchOrderInfoFromEvent( - String eventId, + /// Fetches order information using the order ID by searching for NIP-69 events with 'd' tag + Future _fetchOrderInfoById( + String orderId, List relays, NostrService nostrService, ) async { try { + // Create a filter to search for NIP-69 order events with the specific order ID + final filter = NostrFilter( + kinds: [38383], // NIP-69 order events + additionalFilters: { + '#d': [orderId] + }, // Order ID is stored in 'd' tag + ); + + List events = []; + // First try to fetch from specified relays if (relays.isNotEmpty) { - final orderInfo = await nostrService.fetchOrderInfoByEventId(eventId, relays); - if (orderInfo != null) { - return orderInfo; + // Use the specific relays from the deep link URL + final orderEvents = await nostrService.fetchEvents(filter, specificRelays: relays); + events.addAll(orderEvents); + } + + // If no events found and we have default relays, try those + if (events.isEmpty) { + _logger.i('Order not found in specified relays, trying default relays'); + final defaultEvents = await nostrService.fetchEvents(filter); + events.addAll(defaultEvents); + } + + // Process the first matching event + if (events.isNotEmpty) { + final event = events.first; + + // Extract order type from 'k' tag + final kTag = event.tags?.firstWhere( + (tag) => tag.isNotEmpty && tag[0] == 'k', + orElse: () => [], + ); + + if (kTag != null && kTag.length > 1) { + final orderTypeValue = kTag[1]; + final orderType = + orderTypeValue == 'sell' ? OrderType.sell : OrderType.buy; + + return OrderInfo( + orderId: orderId, + orderType: orderType, + ); } } - // Fallback: try fetching from default relays - _logger.i('Event not found in specified relays, trying default relays'); - return await nostrService.fetchOrderInfoByEventId(eventId); + return null; } catch (e) { - _logger.e('Error fetching order info from event: $e'); + _logger.e('Error fetching order info by ID: $e'); return null; } } @@ -145,8 +196,30 @@ class DeepLinkService { /// Navigates to the appropriate screen for the given order void navigateToOrder(GoRouter router, OrderInfo orderInfo) { final route = getNavigationRoute(orderInfo); - _logger.i('Navigating to: $route (Order: ${orderInfo.orderId}, Type: ${orderInfo.orderType.value})'); - router.push(route); + _logger.i( + 'Navigating to: $route (Order: ${orderInfo.orderId}, Type: ${orderInfo.orderType.value})'); + + // Use post-frame callback to ensure navigation happens after the current frame + // This prevents GoRouter assertion failures during app lifecycle transitions + WidgetsBinding.instance.addPostFrameCallback((_) { + try { + // Validate that the router is still in a valid state before navigation + final context = router.routerDelegate.navigatorKey.currentContext; + if (context != null && context.mounted) { + router.push(route); + } else { + _logger.w('Router context is not available for navigation to: $route'); + } + } catch (e) { + _logger.e('Error navigating to order: $e'); + // Fallback: try using go instead of push if push fails + try { + router.go(route); + } catch (fallbackError) { + _logger.e('Fallback navigation also failed: $fallbackError'); + } + } + }); } /// Cleans up resources diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index 575eb83f..d53e4f3d 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -89,11 +89,17 @@ class NostrService { } } - Future> fecthEvents(NostrFilter filter) async { + Future> fetchEvents(NostrFilter filter, {List? specificRelays}) async { if (!_isInitialized) { throw Exception('Nostr is not initialized. Call init() first.'); } + // If specific relays are provided, use the relay-specific fetching logic + if (specificRelays != null && specificRelays.isNotEmpty) { + return await _fetchFromSpecificRelays(filter, specificRelays); + } + + // Default behavior: use all configured relays final request = NostrRequest(filters: [filter]); return await _nostr.services.relays.startEventsSubscriptionAsync( request: request, @@ -221,7 +227,7 @@ class NostrService { events = await _fetchFromSpecificRelays(filter, specificRelays); } else { // Use default relays - events = await fecthEvents(filter); + events = await fetchEvents(filter); } if (events.isEmpty) { @@ -256,8 +262,9 @@ class NostrService { } /// Fetches order information from an event by extracting the 'd' tag (order ID) and 'k' tag (order type) - /// This is specifically for deep link handling where the nevent points to an event containing order information - Future fetchOrderInfoByEventId(String eventId, [List? specificRelays]) async { + /// This is specifically for deep link handling where the mostro: URL provides order information + Future fetchOrderInfoByEventId(String eventId, + [List? specificRelays]) async { try { _logger.i('Fetching order ID from event: $eventId'); @@ -273,7 +280,7 @@ class NostrService { events = await _fetchFromSpecificRelays(filter, specificRelays); } else { // Use default relays - events = await fecthEvents(filter); + events = await fetchEvents(filter); } if (events.isEmpty) { @@ -333,7 +340,8 @@ class NostrService { return null; } - _logger.i('Successfully extracted order info - ID: $dTag, Type: ${orderType.value} from event: $eventId'); + _logger.i( + 'Successfully extracted order info - ID: $dTag, Type: ${orderType.value} from event: $eventId'); return OrderInfo(orderId: dTag, orderType: orderType); } catch (e) { _logger.e('Error fetching order ID from event: $e'); @@ -368,7 +376,7 @@ class NostrService { await updateSettings(tempSettings); // Fetch the events - final events = await fecthEvents(filter); + final events = await fetchEvents(filter); // Restore original relays await updateSettings(settings); @@ -376,7 +384,7 @@ class NostrService { return events; } else { // No new relays to add, use normal fetch - return await fecthEvents(filter); + return await fetchEvents(filter); } } catch (e) { _logger.e('Error fetching from specific relays: $e'); diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index cad6b8f3..c6f2c222 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -108,89 +108,63 @@ class NostrUtils { return _instance.services.bech32.encodeBech32(hrp, data); } - /// Decodes a nevent string (NIP-19) to extract event ID and relay information - /// Returns a map with 'eventId' and 'relays' keys - static Map decodeNevent(String nevent) { - if (!nevent.startsWith('nevent1')) { - throw ArgumentError('Invalid nevent format: must start with nevent1'); - } + /// Validates if a string is a valid mostro: URL + /// Format: mostro:order-id&relays=wss://relay1,wss://relay2 + static bool isValidMostroUrl(String url) { + if (!url.startsWith('mostro:')) return false; try { - final decoded = _instance.services.bech32.decodeBech32(nevent); - if (decoded.isEmpty || decoded.length < 2) { - throw FormatException('Invalid bech32 decoding result'); - } - - final data = decoded[0]; // Get the data part (index 0) - final bytes = hex.decode(data); - - final result = { - 'eventId': '', - 'relays': [], - }; - - // Parse TLV (Type-Length-Value) format - int index = 0; - while (index < bytes.length) { - if (index + 2 >= bytes.length) break; - - final type = bytes[index]; - final length = bytes[index + 1]; - - if (index + 2 + length > bytes.length) break; - - final value = bytes.sublist(index + 2, index + 2 + length); - - switch (type) { - case 0: // Event ID (32 bytes) - if (length == 32) { - result['eventId'] = hex.encode(value); - } - break; - case 1: // Relay URL (variable length) - final relayUrl = utf8.decode(value); - if (relayUrl.isNotEmpty) { - (result['relays'] as List).add(relayUrl); - } - break; - // Skip other types for now + final uri = Uri.parse(url); + if (uri.scheme != 'mostro') return false; + + // Check if we have an order ID (path) + final orderId = uri.path; + if (orderId.isEmpty) return false; + + // Check if relays parameter exists + final relaysParam = uri.queryParameters['relays']; + if (relaysParam == null || relaysParam.isEmpty) return false; + + // Validate relay URLs + final relays = relaysParam.split(','); + for (final relay in relays) { + final trimmedRelay = relay.trim(); + if (!trimmedRelay.startsWith('wss://') && + !trimmedRelay.startsWith('ws://')) { + return false; } - - index += 2 + length; } - if ((result['eventId'] as String).isEmpty) { - throw FormatException('No event ID found in nevent'); - } - - return result; + return true; } catch (e) { - throw FormatException('Failed to decode nevent: $e'); + return false; } } - /// Validates if a string is a valid nostr: URL - static bool isValidNostrUrl(String url) { - if (!url.startsWith('nostr:')) return false; - - final nevent = url.substring(6); // Remove 'nostr:' prefix + /// Parses a mostro: URL and returns order information + /// Format: mostro:order-id&relays=wss://relay1,wss://relay2 + /// Returns a map with 'orderId' and 'relays' keys + static Map? parseMostroUrl(String url) { + if (!isValidMostroUrl(url)) return null; try { - decodeNevent(nevent); - return true; - } catch (e) { - return false; - } - } + final uri = Uri.parse(url); - /// Parses a nostr: URL and returns event information - static Map? parseNostrUrl(String url) { - if (!isValidNostrUrl(url)) return null; + final orderId = uri.path; + final relaysParam = uri.queryParameters['relays']; - final nevent = url.substring(6); // Remove 'nostr:' prefix + if (orderId.isEmpty || relaysParam == null) return null; - try { - return decodeNevent(nevent); + final relays = relaysParam + .split(',') + .map((relay) => relay.trim()) + .where((relay) => relay.isNotEmpty) + .toList(); + + return { + 'orderId': orderId, + 'relays': relays, + }; } catch (e) { return null; } diff --git a/test/services/mostro_service_test.mocks.dart b/test/services/mostro_service_test.mocks.dart index 3d371da9..da25fcd0 100644 --- a/test/services/mostro_service_test.mocks.dart +++ b/test/services/mostro_service_test.mocks.dart @@ -179,11 +179,15 @@ class MockNostrService extends _i1.Mock implements _i6.NostrService { ) as _i7.Future); @override - _i7.Future> fecthEvents(_i3.NostrFilter? filter) => + _i7.Future> fetchEvents( + _i3.NostrFilter? filter, { + List? specificRelays, + }) => (super.noSuchMethod( Invocation.method( - #fecthEvents, + #fetchEvents, [filter], + {#specificRelays: specificRelays}, ), returnValue: _i7.Future>.value(<_i3.NostrEvent>[]), ) as _i7.Future>); diff --git a/test/utils/nostr_utils_test.dart b/test/utils/nostr_utils_test.dart index c692dbaa..b5482fdb 100644 --- a/test/utils/nostr_utils_test.dart +++ b/test/utils/nostr_utils_test.dart @@ -2,33 +2,56 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; void main() { - group('NostrUtils nevent decoding tests', () { - const testNevent = 'nevent1qqs9hcuev4z4frqnn5wagnupzg4dahcwkyy9r3xvsjw6wd7cm4tmh4gprfmhxue69uhhyetvv9ujumt0wd68ymewdejhgam0wf4scqjs3l'; - const testUrl = 'nostr:$testNevent'; - test('should decode nevent and extract event ID', () { - final result = NostrUtils.decodeNevent(testNevent); + group('NostrUtils mostro URL tests', () { + const testOrderId = 'order123456'; + const testRelays = 'wss://relay.mostro.network,wss://relay.damus.io'; + const testMostroUrl = 'mostro:$testOrderId?relays=$testRelays'; + + test('should validate mostro URL correctly', () { + expect(NostrUtils.isValidMostroUrl(testMostroUrl), isTrue); + }); + + test('should reject invalid mostro URLs', () { + expect(NostrUtils.isValidMostroUrl('mostro:'), isFalse); + expect(NostrUtils.isValidMostroUrl('mostro:order123'), isFalse); // no relays + expect(NostrUtils.isValidMostroUrl('mostro:?relays=wss://relay.com'), isFalse); // no order id + expect(NostrUtils.isValidMostroUrl('nostr:order123?relays=wss://relay.com'), isFalse); // wrong scheme + expect(NostrUtils.isValidMostroUrl('mostro:order123?relays=http://relay.com'), isFalse); // invalid relay protocol + }); + + test('should parse mostro URL correctly', () { + final result = NostrUtils.parseMostroUrl(testMostroUrl); - expect(result, isA>()); - expect(result['eventId'], isA()); + expect(result, isNotNull); + expect(result!['orderId'], equals(testOrderId)); expect(result['relays'], isA>()); - expect((result['eventId'] as String).isNotEmpty, isTrue); - expect(result['eventId'], equals('5be3996545548c139d1dd44f81122adedf0eb10851c4cc849da737d8dd57bbd5')); expect(result['relays'], contains('wss://relay.mostro.network')); + expect(result['relays'], contains('wss://relay.damus.io')); + expect((result['relays'] as List).length, equals(2)); }); - test('should validate nostr URL correctly', () { - final isValid = NostrUtils.isValidNostrUrl(testUrl); - expect(isValid, isTrue); + test('should handle single relay in mostro URL', () { + const singleRelayUrl = 'mostro:order789?relays=wss://relay.mostro.network'; + final result = NostrUtils.parseMostroUrl(singleRelayUrl); + + expect(result, isNotNull); + expect(result!['orderId'], equals('order789')); + expect(result['relays'], equals(['wss://relay.mostro.network'])); }); - test('should parse nostr URL correctly', () { - final result = NostrUtils.parseNostrUrl(testUrl); + test('should handle relays with spaces', () { + const spacedRelaysUrl = 'mostro:order456?relays=wss://relay1.com, wss://relay2.com , wss://relay3.com'; + final result = NostrUtils.parseMostroUrl(spacedRelaysUrl); expect(result, isNotNull); - expect(result!['eventId'], isA()); - expect((result['eventId'] as String).isNotEmpty, isTrue); - expect(result['eventId'], equals('5be3996545548c139d1dd44f81122adedf0eb10851c4cc849da737d8dd57bbd5')); + expect(result!['relays'], equals(['wss://relay1.com', 'wss://relay2.com', 'wss://relay3.com'])); + }); + + test('should return null for invalid mostro URLs', () { + expect(NostrUtils.parseMostroUrl('invalid:url'), isNull); + expect(NostrUtils.parseMostroUrl('mostro:'), isNull); + expect(NostrUtils.parseMostroUrl('mostro:order123'), isNull); // no relays }); });