Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 10 additions & 3 deletions lib/core/app_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Refactor suggestion | Medium

The discarded platform default is never used as a fallback.

Once the override kicks in, recovering the link rests entirely on app_links.getInitialLink() returning the same thing. If that call fails or returns null on a cold start, the link is gone with no trace: it used to crash, now it's silent.

At minimum log it here; better, hand platformDefaultLocation to _queueDeepLink as a backup source so the two paths can't both come up empty.

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);
Expand Down
13 changes: 10 additions & 3 deletions lib/core/deep_link_interceptor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Refactor suggestion | Medium

The router now depends on the interceptor for a pure predicate, and this wrapper is dead weight.

Deduplicating the three copies is the right call — the home isn't. app_routes.dart importing DeepLinkInterceptor just for two static bools inverts the natural direction: routing is the lower layer here, the interceptor is the consumer.

Extracting to lib/core/deep_link_schemes.dart (isCustomSchemeUri / isCustomSchemeLocation) makes all three callers symmetric and lets _isCustomScheme go away — with the static in scope it's now a pure alias over two call sites.


/// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nitpick

The doc comment is cut off mid-sentence — unparseable means no is missing its noun.

Suggested change
/// [isCustomSchemeUri] for an unparsed location; unparseable means no
/// [isCustomSchemeUri] for an unparsed location; unparseable input is
/// treated as not custom.

static bool isCustomSchemeLocation(String location) {
final uri = Uri.tryParse(location);
return uri != null && isCustomSchemeUri(uri);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

/// Dispose the interceptor
Expand Down
85 changes: 85 additions & 0 deletions test/core/app_routes_test.dart
Original file line number Diff line number Diff line change
@@ -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<GoRouter> 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;
}
Comment on lines +31 to +44

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nitpick

createRouter(ref) runs inside a Consumer.builder, which may be invoked more than once — each pass constructs another GoRouter that nobody disposes, and the late router assignment quietly depends on build ordering.

A small StatefulWidget creating the router in initState, or a plain ProviderContainer + Consumer-free call, would make this deterministic. Not blocking — the assertions themselves are good, and I confirmed the first test does fail against the unfixed 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);
});
});
}
53 changes: 53 additions & 0 deletions test/core/deep_link_interceptor_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Loading