Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2c0986d
feat: handle deep links from different Mostro instances
mostronatorcoder[bot] Mar 27, 2026
392327f
fix: escape angle brackets in doc comment (unintended_html_in_doc_com…
mostronatorcoder[bot] Mar 27, 2026
50007ce
fix: address CodeRabbit review — pubkey validation, strict k-tag, l10…
mostronatorcoder[bot] Mar 28, 2026
1902b97
fix: replace trivial equality tests with real parser-based comparisons
mostronatorcoder[bot] Mar 28, 2026
38019e4
fix: remove unused scheduler.dart import
mostronatorcoder[bot] Mar 28, 2026
014b69c
fix: test pubkey was 66 chars instead of required 64
mostronatorcoder[bot] Mar 28, 2026
327b710
fix: prevent deep-link duplicate handling and null crash in take order
mostronator Mar 30, 2026
7cf8f31
fix: address CodeRabbit review issues from PR #552
mostronator Mar 30, 2026
79fbe72
fix: validate mostroPubkey against event author in deep link resolution
mostronator Mar 30, 2026
bb3aeb7
fix: verify event signatures, fix log interpolation, and reset submit…
mostronator Mar 31, 2026
022ea7e
fix: centralize terminal actions, fix relay fallback, and fix prematu…
mostronator Mar 31, 2026
b68cac0
fix: remove unused action.dart import after isTerminal refactor
mostronator Mar 31, 2026
8819173
fix: restore action.dart import and log exceptions in catch blocks
mostronator Mar 31, 2026
b8ee090
fix: remove spurious action.dart import
mostronator Mar 31, 2026
818a5f4
fix: remove isTerminal from Action enum and scope submit reset correctly
mostronator Mar 31, 2026
d259c32
fix: use alias for action.dart import to avoid ambiguity with flutter…
mostronator Mar 31, 2026
831930b
fix(deep-link): reject unverified Nostr events to prevent pubkey spoo…
mostronatorcoder[bot] Apr 1, 2026
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
42 changes: 42 additions & 0 deletions docs/DEEP_LINK_MOSTRO_SWITCH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Deep Link Mostro Instance Switch

## Overview

When a deep link contains a `mostro=<pubkey>` parameter identifying a different
Mostro instance than the currently connected one, the app shows a confirmation
dialog before switching.

## Deep Link Format

```text
mostro:<order-id>?relays=<relay1>,<relay2>&mostro=<mostro_pubkey>
```

The `mostro` parameter is optional for backward compatibility. When absent, the
app assumes the order belongs to the currently selected Mostro instance.

## Flow

1. App receives `mostro:` deep link
2. `parseMostroUrl` extracts `orderId`, `relays`, and optional `mostroPubkey`
3. `DeepLinkHandler` compares `mostroPubkey` with `settings.mostroPublicKey`
4. If same (or absent) → navigate directly to order (existing behavior)
5. If different → show confirmation dialog
6. If user confirms → call `updateMostroInstance(newPubkey)` then navigate
7. If user cancels → do nothing

## Files Changed

| File | Change |
|------|--------|
| `lib/shared/utils/nostr_utils.dart` | Extract `mostro` query param in `parseMostroUrl` |
| `lib/services/deep_link_service.dart` | Add `mostroPubkey` field to `OrderInfo` |
| `lib/core/deep_link_handler.dart` | Pubkey comparison + switch dialog |
| `lib/l10n/intl_en.arb` | English strings for dialog |
| `lib/l10n/intl_es.arb` | Spanish strings for dialog |
| `test/shared/utils/deep_link_parsing_test.dart` | Unit tests |

## References

- [Issue #541](https://github.com/MostroP2P/mobile/issues/541)
- [Order Event Spec](https://mostro.network/protocol/order_event.html)
218 changes: 183 additions & 35 deletions lib/core/deep_link_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:mostro_mobile/generated/l10n.dart';
import 'package:mostro_mobile/features/settings/settings_provider.dart';
import 'package:mostro_mobile/services/deep_link_service.dart';
import 'package:mostro_mobile/services/logger_service.dart';
import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart';
Expand All @@ -12,6 +13,13 @@ class DeepLinkHandler {
final Ref _ref;
StreamSubscription<Uri>? _subscription;

bool _isHandlingMostroDeepLink = false;
String? _lastHandledDeepLinkUrl;
DateTime? _lastHandledDeepLinkAt;

BuildContext? _loadingDialogContext;
bool _isLoadingDialogVisible = false;

DeepLinkHandler(this._ref);

/// Initializes deep link handling for the app
Expand All @@ -37,10 +45,7 @@ class DeepLinkHandler {
}

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

Expand All @@ -64,10 +69,22 @@ class DeepLinkHandler {
}

/// Handles mostro: scheme deep links
Future<void> _handleMostroDeepLink(
String url,
GoRouter router,
) async {
Future<void> _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;
}

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

BuildContext? context;
try {
// Show loading indicator
Expand All @@ -81,68 +98,198 @@ class DeepLinkHandler {
final deepLinkService = _ref.read(deepLinkServiceProvider);

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

// Process the mostro link
final result = await deepLinkService.processMostroLink(url, nostrService, processingContext);

// Get fresh context after async operation
final currentContext = router.routerDelegate.navigatorKey.currentContext;
final result = await deepLinkService.processMostroLink(
url,
nostrService,
processingContext,
);

// Hide loading indicator
if (currentContext != null && currentContext.mounted) {
Navigator.of(currentContext).pop();
}
_hideLoadingDialog();

if (result.isSuccess && result.orderInfo != null) {
final orderInfo = result.orderInfo!;
final currentContext =
router.routerDelegate.navigatorKey.currentContext;

// Check if the deep link targets a different Mostro instance
if (orderInfo.mostroPubkey != null &&
currentContext != null &&
currentContext.mounted) {
final currentPubkey = _ref.read(settingsProvider).mostroPublicKey;
if (orderInfo.mostroPubkey != currentPubkey) {
final shouldSwitch = await _showMostroSwitchDialog(
currentContext,
orderInfo.mostroPubkey!,
currentPubkey,
);
if (shouldSwitch != true) {
logger.i('User declined Mostro switch for deep link');
return;
}
// Switch Mostro instance
await _ref
.read(settingsProvider.notifier)
.updateMostroInstance(orderInfo.mostroPubkey!);
logger.i('Switched Mostro instance to: ${orderInfo.mostroPubkey}');
}
}

// Navigate to the appropriate screen with proper timing
WidgetsBinding.instance.addPostFrameCallback((_) {
deepLinkService.navigateToOrder(router, result.orderInfo!);
deepLinkService.navigateToOrder(router, orderInfo);
});
logger.i('Successfully navigated to order: ${result.orderInfo!.orderId} (${result.orderInfo!.orderType.value})');
logger.i(
'Successfully navigated to order: ${orderInfo.orderId} (${orderInfo.orderType.value})',
);
} else {
final errorContext = router.routerDelegate.navigatorKey.currentContext;
if (errorContext != null && errorContext.mounted) {
final errorMessage = result.error ?? S.of(errorContext)!.failedToLoadOrder;
final errorMessage =
result.error ?? S.of(errorContext)!.failedToLoadOrder;
_showErrorSnackBar(errorContext, errorMessage);
}
logger.w('Failed to process mostro link: ${result.error}');
}
} catch (e) {
logger.e('Error processing mostro deep link: $e');
_hideLoadingDialog();

final errorContext = router.routerDelegate.navigatorKey.currentContext;
if (errorContext != null && errorContext.mounted) {
Navigator.of(errorContext).pop(); // Hide loading if still showing
_showErrorSnackBar(errorContext, S.of(errorContext)!.failedToOpenOrder);
}
} finally {
_isHandlingMostroDeepLink = false;
}
}

/// Shows a confirmation dialog when a deep link targets a different Mostro instance.
///
/// [targetName] and [currentName] are optional human-readable labels for the
/// Mostro instances. When empty, truncated pubkeys are shown instead.
Future<bool?> _showMostroSwitchDialog(
BuildContext context,
String linkPubkey,
String currentPubkey, {
String targetName = '',
String currentName = '',
}) {
final completer = Completer<bool?>();
final s = S.of(context)!;
final truncatedLink =
'${linkPubkey.substring(0, 8)}...${linkPubkey.substring(linkPubkey.length - 8)}';
final truncatedCurrent =
'${currentPubkey.substring(0, 8)}...${currentPubkey.substring(currentPubkey.length - 8)}';

final targetLabel = targetName.isNotEmpty ? targetName : truncatedLink;
final currentLabel = currentName.isNotEmpty
? currentName
: truncatedCurrent;

WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!context.mounted) {
completer.complete(null);
return;
}
final result = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Row(
children: [
const Icon(Icons.warning_amber_rounded, color: Colors.orange),
const SizedBox(width: 8),
Expanded(child: Text(s.deepLinkDifferentMostroTitle)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(s.deepLinkDifferentMostroBody),
const SizedBox(height: 12),
Text(
'${s.deepLinkDifferentMostroFrom}\n$targetLabel',
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
const SizedBox(height: 8),
Text(
'${s.deepLinkDifferentMostroCurrent}\n$currentLabel',
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(s.cancel),
),
ElevatedButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(s.deepLinkSwitchAndView),
),
],
),
);
completer.complete(result);
});

return completer.future;
}

/// Shows a loading dialog
void _showLoadingDialog(BuildContext context) {
if (_isLoadingDialogVisible) {
return;
}

_isLoadingDialogVisible = true;
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) => Center(
child: Card(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(S.of(dialogContext)!.loadingOrder),
],
builder: (dialogContext) {
_loadingDialogContext = dialogContext;
return Center(
child: Card(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(S.of(dialogContext)!.loadingOrder),
],
),
),
),
),
),
);
);
},
).whenComplete(() {
_isLoadingDialogVisible = false;
_loadingDialogContext = null;
});
}

void _hideLoadingDialog() {
if (!_isLoadingDialogVisible) {
return;
}

final dialogContext = _loadingDialogContext;
if (dialogContext != null && dialogContext.mounted) {
Navigator.of(dialogContext).pop();
}

_isLoadingDialogVisible = false;
_loadingDialogContext = null;
}

/// Shows an error snack bar
Expand All @@ -161,6 +308,7 @@ class DeepLinkHandler {
void dispose() {
_subscription?.cancel();
_subscription = null;
_hideLoadingDialog();
// DeepLinkService disposal is handled by Riverpod provider
}
}
Expand Down
2 changes: 1 addition & 1 deletion lib/data/models/enums/action.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ enum Action {
///
/// Throws an ArgumentError if the string doesn't match any Action value.
static final _valueMap = {
for (var action in Action.values) action.value: action
for (var action in Action.values) action.value: action,
};

static Action fromString(String value) {
Expand Down
Loading
Loading