From af34fc9db43f9340990ced88cdf9dd2c7b3c7de5 Mon Sep 17 00:00:00 2001 From: 21Mill Date: Sat, 22 Aug 2026 01:37:30 +0200 Subject: [PATCH 1/3] fix: do not let a cold-start deep link become the router's location Opening a mostro: link while the app was not running crashed it before anything rendered: 'package:go_router/src/match.dart': Failed assertion: line 245 pos 12: 'uriPathToCompare.startsWith(newMatchedLocationToCompare)': is not true. With no activity alive, Android hands the link over as the engine's defaultRouteName rather than through pushRouteInformation, and go_router prefers that over initialLocation whenever it is not '/'. So the router started up trying to match mostro:?relays=..., which is an opaque URI: its path is the bare id, with no leading slash, and matching it against '/' fails the assertion. The link never reached DeepLinkInterceptor, which guards the other delivery path, and the redirect that sends custom schemes home never ran either, since matching asserts before redirects are consulted. createRouter now sets overridePlatformDefaultLocation when the platform default carries a scheme of ours, so the app starts at '/' and the initial link is left to the handler in MostroApp that already reads it through app_links. The override is conditional rather than always on because on web the platform default is the location the user asked for, and discarding it would break opening the app at a URL. The "is this one of our schemes" test existed twice, in the interceptor and in the redirect, and this adds a third caller, so it now lives in one place as DeepLinkInterceptor.isCustomSchemeUri / isCustomSchemeLocation. Covered by a test that fakes the platform default through TestPlatformDispatcher: against the unfixed router it reports the initial location as the mostro: link itself, which is the defect exactly. --- lib/core/app_routes.dart | 13 +++- lib/core/deep_link_interceptor.dart | 13 +++- test/core/app_routes_test.dart | 85 +++++++++++++++++++++++ test/core/deep_link_interceptor_test.dart | 53 ++++++++++++++ 4 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 test/core/app_routes_test.dart create mode 100644 test/core/deep_link_interceptor_test.dart diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index e947c37a9..e9e1094aa 100644 --- a/lib/core/app_routes.dart +++ b/lib/core/app_routes.dart @@ -37,17 +37,24 @@ 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_interceptor.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; + return GoRouter( navigatorKey: MostroApp.navigatorKey, initialLocation: '/', + overridePlatformDefaultLocation: + DeepLinkInterceptor.isCustomSchemeLocation(platformDefaultLocation), 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 (DeepLinkInterceptor.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 984273ef4..610fbc8f9 100644 --- a/lib/core/deep_link_interceptor.dart +++ b/lib/core/deep_link_interceptor.dart @@ -62,9 +62,16 @@ class DeepLinkInterceptor extends WidgetsBindingObserver { } /// Check if the URI uses a custom scheme - bool _isCustomScheme(Uri uri) { - return uri.scheme == 'mostro' || - (!uri.scheme.startsWith('http') && uri.scheme.isNotEmpty); + bool _isCustomScheme(Uri uri) => isCustomSchemeUri(uri); + + /// Whether the URI uses a scheme the app resolves itself, such as `mostro:` + static bool isCustomSchemeUri(Uri uri) => + uri.scheme.isNotEmpty && !uri.scheme.startsWith('http'); + + /// [isCustomSchemeUri] for an unparsed location; unparseable means no + static bool isCustomSchemeLocation(String location) { + final uri = Uri.tryParse(location); + return uri != null && isCustomSchemeUri(uri); } /// Dispose the interceptor diff --git a/test/core/app_routes_test.dart b/test/core/app_routes_test.dart new file mode 100644 index 000000000..42408ec8c --- /dev/null +++ b/test/core/app_routes_test.dart @@ -0,0 +1,85 @@ +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'; + +/// 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 { + late GoRouter router; + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(SharedPreferencesAsync()), + ], + child: Consumer( + builder: (context, ref, _) { + router = createRouter(ref); + return const SizedBox.shrink(); + }, + ), + ), + ); + 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_interceptor_test.dart b/test/core/deep_link_interceptor_test.dart new file mode 100644 index 000000000..969ffe3a3 --- /dev/null +++ b/test/core/deep_link_interceptor_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/core/deep_link_interceptor.dart'; + +void main() { + group('DeepLinkInterceptor.isCustomSchemeLocation', () { + test('claims mostro links', () { + expect( + DeepLinkInterceptor.isCustomSchemeLocation( + 'mostro:8927bb1d-da68-491e-b0e2-db0ed548d52c' + '?relays=wss://relay.mostro.network', + ), + isTrue, + ); + expect(DeepLinkInterceptor.isCustomSchemeLocation('mostro:'), isTrue); + }); + + test('claims other non-web schemes', () { + expect( + DeepLinkInterceptor.isCustomSchemeLocation('lightning:lnbc1...'), + isTrue, + ); + }); + + test('leaves app locations alone', () { + expect(DeepLinkInterceptor.isCustomSchemeLocation('/'), isFalse); + expect( + DeepLinkInterceptor.isCustomSchemeLocation('/take_sell/order-1'), + isFalse, + ); + expect( + DeepLinkInterceptor.isCustomSchemeLocation('/settings?tab=relays'), + isFalse, + ); + expect(DeepLinkInterceptor.isCustomSchemeLocation(''), isFalse); + }); + + test('leaves web locations alone', () { + expect( + DeepLinkInterceptor.isCustomSchemeLocation('https://mostro.network/x'), + isFalse, + ); + expect( + DeepLinkInterceptor.isCustomSchemeLocation('http://localhost:8080/'), + isFalse, + ); + }); + + test('treats an unparseable location as an ordinary one', () { + // Nothing we could hand to the deep link handler either. + expect(DeepLinkInterceptor.isCustomSchemeLocation('::::'), isFalse); + }); + }); +} From ee2d212e522a0036f495993cf133a8157d43eda2 Mon Sep 17 00:00:00 2001 From: 21Mill Date: Sat, 22 Aug 2026 01:43:04 +0200 Subject: [PATCH 2/3] fix: match http and https exactly when deciding what is ours isCustomSchemeUri asked whether the scheme starts with 'http', which the predicate it replaced already did in both of its copies. A scheme like httpfoo: passes that test, so such a link would be handed to go_router as an ordinary location and assert during a cold start, which is the failure this branch exists to remove. Uri normalises the scheme to lower case, so an exact comparison needs no case handling of its own; a test pins that rather than a defensive toLowerCase. --- lib/core/deep_link_interceptor.dart | 2 +- test/core/deep_link_interceptor_test.dart | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/core/deep_link_interceptor.dart b/lib/core/deep_link_interceptor.dart index 610fbc8f9..dde1297c8 100644 --- a/lib/core/deep_link_interceptor.dart +++ b/lib/core/deep_link_interceptor.dart @@ -66,7 +66,7 @@ class DeepLinkInterceptor extends WidgetsBindingObserver { /// Whether the URI uses a scheme the app resolves itself, such as `mostro:` static bool isCustomSchemeUri(Uri uri) => - uri.scheme.isNotEmpty && !uri.scheme.startsWith('http'); + uri.scheme.isNotEmpty && uri.scheme != 'http' && uri.scheme != 'https'; /// [isCustomSchemeUri] for an unparsed location; unparseable means no static bool isCustomSchemeLocation(String location) { diff --git a/test/core/deep_link_interceptor_test.dart b/test/core/deep_link_interceptor_test.dart index 969ffe3a3..655777dc7 100644 --- a/test/core/deep_link_interceptor_test.dart +++ b/test/core/deep_link_interceptor_test.dart @@ -34,6 +34,13 @@ void main() { expect(DeepLinkInterceptor.isCustomSchemeLocation(''), isFalse); }); + test('claims schemes that merely start like a web one', () { + expect( + DeepLinkInterceptor.isCustomSchemeLocation('httpfoo://example.com'), + isTrue, + ); + }); + test('leaves web locations alone', () { expect( DeepLinkInterceptor.isCustomSchemeLocation('https://mostro.network/x'), @@ -43,6 +50,11 @@ void main() { DeepLinkInterceptor.isCustomSchemeLocation('http://localhost:8080/'), isFalse, ); + // Uri normalises the scheme, so no case handling of our own is needed. + expect( + DeepLinkInterceptor.isCustomSchemeLocation('HTTPS://mostro.network/x'), + isFalse, + ); }); test('treats an unparseable location as an ordinary one', () { From 28dcae8c1a4956052ec3d29708b21411ef1829ad Mon Sep 17 00:00:00 2001 From: 21Mill Date: Sun, 30 Aug 2026 22:08:41 +0200 Subject: [PATCH 3/3] refactor: keep the custom scheme test out of the interceptor app_routes.dart importing DeepLinkInterceptor for two static predicates pointed the dependency the wrong way: routing is the lower layer here and the interceptor is one of its consumers. The predicate now lives on its own in deep_link_schemes.dart, which both callers import, and the private alias in the interceptor goes away with it. createRouter also logs the platform default it discards, so a link that goes missing on a cold start leaves a trace instead of nothing. The router test built the router inside a Consumer.builder, which may run more than once and leave routers nobody disposes; it now holds a single one for the test and disposes it on teardown. --- lib/core/app_routes.dart | 12 +++-- lib/core/deep_link_interceptor.dart | 18 ++----- lib/core/deep_link_schemes.dart | 10 ++++ test/core/app_routes_test.dart | 26 ++++++--- test/core/deep_link_interceptor_test.dart | 65 ----------------------- test/core/deep_link_schemes_test.dart | 65 +++++++++++++++++++++++ 6 files changed, 105 insertions(+), 91 deletions(-) create mode 100644 lib/core/deep_link_schemes.dart delete mode 100644 test/core/deep_link_interceptor_test.dart create mode 100644 test/core/deep_link_schemes_test.dart diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index e9e1094aa..a8a555e18 100644 --- a/lib/core/app_routes.dart +++ b/lib/core/app_routes.dart @@ -37,7 +37,7 @@ 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_interceptor.dart'; +import 'package:mostro_mobile/core/deep_link_schemes.dart'; import 'package:mostro_mobile/services/logger_service.dart'; GoRouter createRouter(WidgetRef ref) { @@ -46,15 +46,19 @@ GoRouter createRouter(WidgetRef ref) { // 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: - DeepLinkInterceptor.isCustomSchemeLocation(platformDefaultLocation), + overridePlatformDefaultLocation: overridesPlatformDefault, redirect: (context, state) { // Redirect custom schemes to home to prevent assertion failures - if (DeepLinkInterceptor.isCustomSchemeUri(state.uri)) { + 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 dde1297c8..4b2c55af0 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,19 +62,6 @@ class DeepLinkInterceptor extends WidgetsBindingObserver { return super.didPushRoute(route); } - /// Check if the URI uses a custom scheme - bool _isCustomScheme(Uri uri) => isCustomSchemeUri(uri); - - /// Whether the URI uses a scheme the app resolves itself, such as `mostro:` - static bool isCustomSchemeUri(Uri uri) => - uri.scheme.isNotEmpty && uri.scheme != 'http' && uri.scheme != 'https'; - - /// [isCustomSchemeUri] for an unparsed location; unparseable means no - static bool isCustomSchemeLocation(String location) { - final uri = Uri.tryParse(location); - return uri != null && isCustomSchemeUri(uri); - } - /// 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 000000000..fa6186ff5 --- /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 index 42408ec8c..458f01860 100644 --- a/test/core/app_routes_test.dart +++ b/test/core/app_routes_test.dart @@ -11,23 +11,35 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p 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 { - late GoRouter router; await tester.pumpWidget( ProviderScope( overrides: [ sharedPreferencesProvider.overrideWithValue(SharedPreferencesAsync()), ], - child: Consumer( - builder: (context, ref, _) { - router = createRouter(ref); - return const SizedBox.shrink(); - }, - ), + child: const _RouterHost(), ), ); + final router = + tester.state<_RouterHostState>(find.byType(_RouterHost)).router; + addTearDown(router.dispose); return router; } diff --git a/test/core/deep_link_interceptor_test.dart b/test/core/deep_link_interceptor_test.dart deleted file mode 100644 index 655777dc7..000000000 --- a/test/core/deep_link_interceptor_test.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:mostro_mobile/core/deep_link_interceptor.dart'; - -void main() { - group('DeepLinkInterceptor.isCustomSchemeLocation', () { - test('claims mostro links', () { - expect( - DeepLinkInterceptor.isCustomSchemeLocation( - 'mostro:8927bb1d-da68-491e-b0e2-db0ed548d52c' - '?relays=wss://relay.mostro.network', - ), - isTrue, - ); - expect(DeepLinkInterceptor.isCustomSchemeLocation('mostro:'), isTrue); - }); - - test('claims other non-web schemes', () { - expect( - DeepLinkInterceptor.isCustomSchemeLocation('lightning:lnbc1...'), - isTrue, - ); - }); - - test('leaves app locations alone', () { - expect(DeepLinkInterceptor.isCustomSchemeLocation('/'), isFalse); - expect( - DeepLinkInterceptor.isCustomSchemeLocation('/take_sell/order-1'), - isFalse, - ); - expect( - DeepLinkInterceptor.isCustomSchemeLocation('/settings?tab=relays'), - isFalse, - ); - expect(DeepLinkInterceptor.isCustomSchemeLocation(''), isFalse); - }); - - test('claims schemes that merely start like a web one', () { - expect( - DeepLinkInterceptor.isCustomSchemeLocation('httpfoo://example.com'), - isTrue, - ); - }); - - test('leaves web locations alone', () { - expect( - DeepLinkInterceptor.isCustomSchemeLocation('https://mostro.network/x'), - isFalse, - ); - expect( - DeepLinkInterceptor.isCustomSchemeLocation('http://localhost:8080/'), - isFalse, - ); - // Uri normalises the scheme, so no case handling of our own is needed. - expect( - DeepLinkInterceptor.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(DeepLinkInterceptor.isCustomSchemeLocation('::::'), isFalse); - }); - }); -} diff --git a/test/core/deep_link_schemes_test.dart b/test/core/deep_link_schemes_test.dart new file mode 100644 index 000000000..86bbe80ed --- /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); + }); + }); +}