diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index e947c37a..a8a555e1 100644 --- a/lib/core/app_routes.dart +++ b/lib/core/app_routes.dart @@ -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); diff --git a/lib/core/deep_link_interceptor.dart b/lib/core/deep_link_interceptor.dart index 984273ef..4b2c55af 100644 --- a/lib/core/deep_link_interceptor.dart +++ b/lib/core/deep_link_interceptor.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; +import 'package:mostro_mobile/core/deep_link_schemes.dart'; import 'package:mostro_mobile/services/logger_service.dart'; /// A deep link interceptor that prevents custom schemes from reaching GoRouter @@ -23,7 +24,7 @@ class DeepLinkInterceptor extends WidgetsBindingObserver { logger.i('DeepLinkInterceptor: Route information received: $uri'); // Check if this is a custom scheme URL - if (_isCustomScheme(uri)) { + if (isCustomSchemeUri(uri)) { logger.i('DeepLinkInterceptor: Custom scheme detected: ${uri.scheme}, intercepting and preventing GoRouter processing'); // Emit the custom URL for processing @@ -48,7 +49,7 @@ class DeepLinkInterceptor extends WidgetsBindingObserver { try { final uri = Uri.parse(route); - if (_isCustomScheme(uri)) { + if (isCustomSchemeUri(uri)) { logger.i('DeepLinkInterceptor: Custom scheme detected in didPushRoute: ${uri.scheme}, intercepting'); _customUrlController.add(route); return true; @@ -61,12 +62,6 @@ class DeepLinkInterceptor extends WidgetsBindingObserver { return super.didPushRoute(route); } - /// 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); diff --git a/lib/core/deep_link_schemes.dart b/lib/core/deep_link_schemes.dart new file mode 100644 index 00000000..fa6186ff --- /dev/null +++ b/lib/core/deep_link_schemes.dart @@ -0,0 +1,10 @@ +/// Whether the URI uses a scheme the app resolves itself, such as `mostro:` +bool isCustomSchemeUri(Uri uri) => + uri.scheme.isNotEmpty && uri.scheme != 'http' && uri.scheme != 'https'; + +/// [isCustomSchemeUri] for an unparsed location; unparseable input is +/// treated as not custom. +bool isCustomSchemeLocation(String location) { + final uri = Uri.tryParse(location); + return uri != null && isCustomSchemeUri(uri); +} diff --git a/test/core/app_routes_test.dart b/test/core/app_routes_test.dart new file mode 100644 index 00000000..458f0186 --- /dev/null +++ b/test/core/app_routes_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mostro_mobile/core/app_routes.dart'; +import 'package:mostro_mobile/shared/providers/storage_providers.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +const _mostroLink = + 'mostro:8927bb1d-da68-491e-b0e2-db0ed548d52c?relays=wss://relay.mostro.network'; + +/// Holds a single router for the test, so a rebuild cannot make another one. +class _RouterHost extends ConsumerStatefulWidget { + const _RouterHost(); + + @override + ConsumerState<_RouterHost> createState() => _RouterHostState(); +} + +class _RouterHostState extends ConsumerState<_RouterHost> { + late final GoRouter router = createRouter(ref); + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +/// Builds the app's real router inside a scope that can resolve it, and hands +/// it back without mounting any screen. +Future buildRouter(WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(SharedPreferencesAsync()), + ], + child: const _RouterHost(), + ), + ); + final router = + tester.state<_RouterHostState>(find.byType(_RouterHost)).router; + addTearDown(router.dispose); + return router; +} + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + group('createRouter initial location', () { + // Regression test for #670: go_router preferred the cold-start deep link + // over initialLocation and asserted while matching it. + testWidgets('ignores a custom scheme handed over by the platform', + (tester) async { + tester.binding.platformDispatcher.defaultRouteNameTestValue = _mostroLink; + addTearDown( + tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); + + final router = await buildRouter(tester); + + expect( + router.routeInformationProvider.value.uri.toString(), + '/', + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('starts at the root on an ordinary launch', (tester) async { + tester.binding.platformDispatcher.defaultRouteNameTestValue = '/'; + addTearDown( + tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); + + final router = await buildRouter(tester); + + expect(router.routeInformationProvider.value.uri.toString(), '/'); + expect(tester.takeException(), isNull); + }); + + // On web the platform default is a real location and must still win. + testWidgets('honours a real location handed over by the platform', + (tester) async { + tester.binding.platformDispatcher.defaultRouteNameTestValue = '/settings'; + addTearDown( + tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); + + final router = await buildRouter(tester); + + expect( + router.routeInformationProvider.value.uri.toString(), + '/settings', + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/core/deep_link_schemes_test.dart b/test/core/deep_link_schemes_test.dart new file mode 100644 index 00000000..86bbe80e --- /dev/null +++ b/test/core/deep_link_schemes_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/core/deep_link_schemes.dart'; + +void main() { + group('isCustomSchemeLocation', () { + test('claims mostro links', () { + expect( + isCustomSchemeLocation( + 'mostro:8927bb1d-da68-491e-b0e2-db0ed548d52c' + '?relays=wss://relay.mostro.network', + ), + isTrue, + ); + expect(isCustomSchemeLocation('mostro:'), isTrue); + }); + + test('claims other non-web schemes', () { + expect( + isCustomSchemeLocation('lightning:lnbc1...'), + isTrue, + ); + }); + + test('leaves app locations alone', () { + expect(isCustomSchemeLocation('/'), isFalse); + expect( + isCustomSchemeLocation('/take_sell/order-1'), + isFalse, + ); + expect( + isCustomSchemeLocation('/settings?tab=relays'), + isFalse, + ); + expect(isCustomSchemeLocation(''), isFalse); + }); + + test('claims schemes that merely start like a web one', () { + expect( + isCustomSchemeLocation('httpfoo://example.com'), + isTrue, + ); + }); + + test('leaves web locations alone', () { + expect( + isCustomSchemeLocation('https://mostro.network/x'), + isFalse, + ); + expect( + isCustomSchemeLocation('http://localhost:8080/'), + isFalse, + ); + // Uri normalises the scheme, so no case handling of our own is needed. + expect( + isCustomSchemeLocation('HTTPS://mostro.network/x'), + isFalse, + ); + }); + + test('treats an unparseable location as an ordinary one', () { + // Nothing we could hand to the deep link handler either. + expect(isCustomSchemeLocation('::::'), isFalse); + }); + }); +}