Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
0353572
feat: detect admin/dispute DM format in background notification service
AndreaDiazCorreia Feb 24, 2026
bc17853
refactor: extract DM payload detection into shared NostrUtils.isDmPay…
AndreaDiazCorreia Feb 24, 2026
353a7a5
feat: add sendDm and cooperativeCancelAccepted to notification data e…
AndreaDiazCorreia Feb 24, 2026
f35ac00
fix: simplify dispute chat message handling with p-tag verification
AndreaDiazCorreia Mar 11, 2026
feade94
fix: remove redundant DM payload parsing in dispute chat message hand…
AndreaDiazCorreia Mar 13, 2026
34ee2b9
fix: strengthen DM payload detection and prevent duplicate event storage
AndreaDiazCorreia Mar 24, 2026
6c72f9a
feat: add dedicated notification strings for admin/dispute messages
AndreaDiazCorreia Mar 24, 2026
bf56441
feat: add admin message notification string mappings to NotificationM…
AndreaDiazCorreia Mar 24, 2026
b7944f9
feat: add dedicated dispute chat subscription using adminSharedKey
AndreaDiazCorreia Mar 24, 2026
9848df1
feat: add JSON payload support for admin DM notification navigation
AndreaDiazCorreia Mar 24, 2026
13f3fff
refactor: strengthen DM payload validation and improve error handling
AndreaDiazCorreia Mar 27, 2026
e616b8f
refactor: extract notification route resolution into pure function
AndreaDiazCorreia Mar 27, 2026
31df925
refactor: use resolveNotificationRoute for notification launch naviga…
AndreaDiazCorreia Mar 27, 2026
4924125
feat: persist disputeId in session for background notification routing
AndreaDiazCorreia Mar 27, 2026
b9d3315
refactor: improve notification route fallback handling for malformed …
AndreaDiazCorreia Mar 30, 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
9 changes: 5 additions & 4 deletions lib/core/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,12 @@ class _MostroAppState extends ConsumerState<MostroApp> {
if (!_notificationLaunchHandled && _router != null) {
_notificationLaunchHandled = true;
WidgetsBinding.instance.addPostFrameCallback((_) async {
final orderId = await getNotificationLaunchOrderId();
final payload = await getNotificationLaunchOrderId();
if (!mounted) return;
if (orderId != null && orderId.isNotEmpty) {
debugPrint('App launched from notification tap, navigating to order: $orderId');
_router!.push('/trade_detail/$orderId');
if (payload != null && payload.isNotEmpty) {
final route = resolveNotificationRoute(payload);
debugPrint('App launched from notification tap, navigating to: $route');
_router!.push(route);
}
});
}
Expand Down
11 changes: 11 additions & 0 deletions lib/data/models/session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class Session {
NostrKeyPairs? _sharedKey;
String? _adminPubkey;
NostrKeyPairs? _adminSharedKey;
String? disputeId;

Session({
required this.masterKey,
Expand All @@ -31,6 +32,7 @@ class Session {
this.orderId,
this.parentOrderId,
this.role,
this.disputeId,
Peer? peer,
String? adminPubkey,
}) {
Expand All @@ -56,6 +58,7 @@ class Session {
'role': role?.value,
'peer': peer?.publicKey,
'admin_peer': _adminPubkey,
'dispute_id': disputeId,
};

factory Session.fromJson(Map<String, dynamic> json) {
Expand Down Expand Up @@ -166,6 +169,13 @@ class Session {
}
}

// Parse optional dispute ID
String? disputeId;
final disputeIdValue = json['dispute_id'];
if (disputeIdValue != null && disputeIdValue is String && disputeIdValue.isNotEmpty) {
disputeId = disputeIdValue;
}

return Session(
masterKey: masterKeyValue,
tradeKey: tradeKeyValue,
Expand All @@ -177,6 +187,7 @@ class Session {
role: role,
peer: peer,
adminPubkey: adminPubkey,
disputeId: disputeId,
);
} catch (e) {
throw FormatException('Failed to parse Session from JSON: $e');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import 'package:mostro_mobile/features/key_manager/key_derivator.dart';
import 'package:mostro_mobile/features/key_manager/key_manager.dart';
import 'package:mostro_mobile/features/key_manager/key_storage.dart';
import 'package:mostro_mobile/features/notifications/utils/notification_data_extractor.dart';
import 'package:mostro_mobile/shared/utils/nostr_utils.dart';
import 'package:mostro_mobile/features/notifications/utils/notification_message_mapper.dart';
import 'package:mostro_mobile/generated/l10n.dart';
import 'package:mostro_mobile/generated/l10n_de.dart';
Expand Down Expand Up @@ -57,6 +58,46 @@ Future<String?> getNotificationLaunchOrderId() async {
return null;
}

/// Resolves the navigation route from a notification payload string.
///
/// Returns the route path to navigate to. Pure function, no side effects.
String resolveNotificationRoute(String? payload) {
if (payload == null || payload.isEmpty) {
return '/notifications';
}

try {
final decoded = jsonDecode(payload);
if (decoded is! Map<String, dynamic>) {
return '/notifications';
}

final type = decoded['type'] as String?;
final orderId = decoded['orderId'] as String?;
final disputeId = decoded['disputeId'] as String?;

if (type == 'admin_dm' && orderId != null) {
if (disputeId != null) {
return '/dispute_details/$disputeId';
}
return '/trade_detail/$orderId';
}

// Valid JSON but unknown type — use orderId if available, else notifications
if (orderId != null) {
return '/trade_detail/$orderId';
}
return '/notifications';
} on FormatException {
// Not JSON — treat as plain orderId (legacy format)
} catch (e) {
logger.e('Unexpected error parsing notification payload: $e');
return '/notifications';
}

return '/trade_detail/$payload';
}

void _onNotificationTap(NotificationResponse response) {
try {
final context = MostroApp.navigatorKey.currentContext;
Expand All @@ -65,14 +106,9 @@ void _onNotificationTap(NotificationResponse response) {
return;
}

final orderId = response.payload;
if (orderId != null && orderId.isNotEmpty) {
context.push('/trade_detail/$orderId');
logger.i('Navigated to trade detail for order: $orderId');
} else {
context.push('/notifications');
logger.i('Navigated to notifications screen');
}
final route = resolveNotificationRoute(response.payload);
context.push(route);
logger.i('Navigated to: $route');
} catch (e) {
logger.e('Navigation error: $e');
}
Expand Down Expand Up @@ -129,13 +165,22 @@ Future<void> showLocalNotification(NostrEvent event) async {
),
);

// Build payload: JSON for admin DMs, plain orderId for standard notifications
final notificationPayload = notificationData.action == mostro_action.Action.sendDm
? jsonEncode({
'type': 'admin_dm',
'orderId': mostroMessage.id,
'disputeId': matchingSession?.disputeId,
})
: mostroMessage.id;

// Use fixed ID (0) with tag for replacement - Android uses tag+id combo
await flutterLocalNotificationsPlugin.show(
0, // Fixed ID - tag 'mostro-trade' makes it unique and replaceable
notificationText.title,
notificationText.body,
details,
payload: mostroMessage.id,
payload: notificationPayload,
);

logger.i('Shown: ${notificationText.title} - ${notificationText.body}');
Expand All @@ -153,6 +198,17 @@ Future<MostroMessage?> _decryptAndProcessEvent(NostrEvent event) async {

final sessions = await _loadSessionsFromDatabase();

// Try matching by adminSharedKey first (dispute chat DMs)
final adminSession = sessions.cast<Session?>().firstWhere(
(s) => s?.adminSharedKey?.public == event.recipient,
orElse: () => null,
);

if (adminSession != null) {
return _processAdminDm(event, adminSession);
}

// Standard Mostro message: match by tradeKey
final matchingSession = sessions.cast<Session?>().firstWhere(
(s) => s?.tradeKey.public == event.recipient,
orElse: () => null,
Expand All @@ -172,6 +228,20 @@ Future<MostroMessage?> _decryptAndProcessEvent(NostrEvent event) async {
return null;
}

// Detect admin/dispute DM format that arrived via tradeKey
final firstItem = result[0];
if (NostrUtils.isDmPayload(firstItem)) {
if (matchingSession.orderId == null) {
logger.w('DM received but session has no orderId (recipient: ${event.recipient}), skipping notification');
return null;
}
return MostroMessage(
action: mostro_action.Action.sendDm,
id: matchingSession.orderId,
timestamp: event.createdAt?.millisecondsSinceEpoch,
);
}

final mostroMessage = MostroMessage.fromJson(result[0]);
mostroMessage.timestamp = event.createdAt?.millisecondsSinceEpoch;

Expand All @@ -182,6 +252,29 @@ Future<MostroMessage?> _decryptAndProcessEvent(NostrEvent event) async {
}
}

Future<MostroMessage?> _processAdminDm(NostrEvent event, Session session) async {
try {
final unwrapped = await event.p2pUnwrap(session.adminSharedKey!);
if (unwrapped.content == null || unwrapped.content!.isEmpty) {
return null;
}

if (session.orderId == null) {
logger.w('Admin DM received but session has no orderId (recipient: ${event.recipient}), skipping notification');
return null;
}

return MostroMessage(
action: mostro_action.Action.sendDm,
id: session.orderId,
timestamp: event.createdAt?.millisecondsSinceEpoch,
);
} catch (e) {
logger.e('Admin DM decrypt error: $e');
return null;
}
}

Future<List<Session>> _loadSessionsFromDatabase() async {
try {
final db = await openMostroDatabase('mostro.db');
Expand Down
14 changes: 11 additions & 3 deletions lib/features/notifications/utils/notification_data_extractor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -177,20 +177,28 @@ class NotificationDataExtractor {
// No additional values needed
break;

case Action.sendDm:
// Admin/dispute DM — no payload extraction needed, generic message
break;

case Action.cooperativeCancelAccepted:
// No additional values needed
break;

case Action.cantDo:
final cantDo = event.getPayload<CantDo>();
values['reason'] = cantDo?.cantDoReason.toString();
isTemporary = true; // cantDo notifications are temporary
break;

case Action.rate:
// No additional values needed
break;

case Action.rateReceived:
// This action doesn't generate notifications
return null;

default:
// Unknown actions generate temporary notifications
isTemporary = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ class NotificationMessageMapper {
case mostro.Action.cooperativeCancelAccepted:
return 'notification_cooperative_cancel_accepted_title';
case mostro.Action.sendDm:
return 'notification_new_message_title';
return 'notification_admin_message_title';
Comment thread
AndreaDiazCorreia marked this conversation as resolved.
case mostro.Action.cancel:
case mostro.Action.adminCancel:
case mostro.Action.adminCanceled:
Expand Down Expand Up @@ -166,7 +166,7 @@ class NotificationMessageMapper {
case mostro.Action.cooperativeCancelAccepted:
return 'notification_cooperative_cancel_accepted_message';
case mostro.Action.sendDm:
return 'notification_new_message_message';
return 'notification_admin_message_message';
case mostro.Action.cancel:
case mostro.Action.adminCancel:
case mostro.Action.adminCanceled:
Expand Down Expand Up @@ -330,6 +330,10 @@ class NotificationMessageMapper {
return s.notification_new_message_title;
case 'notification_new_message_message':
return s.notification_new_message_message;
case 'notification_admin_message_title':
return s.notification_admin_message_title;
case 'notification_admin_message_message':
return s.notification_admin_message_message;
case 'notification_order_update_title':
return s.notification_order_update_title;
case 'notification_order_update_message':
Expand Down
12 changes: 12 additions & 0 deletions lib/features/order/notifiers/abstract_mostro_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,12 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {
// Save dispute in state for listing
state = state.copyWith(dispute: disputeWithOrderId);

// Persist disputeId on session for background notification routing
final sessionNotifierForDispute = ref.read(sessionNotifierProvider.notifier);
await sessionNotifierForDispute.updateSession(
orderId, (s) => s.disputeId = disputeWithOrderId.disputeId,
);

// Notification handled by centralized NotificationDataExtractor path
if (kDebugMode) {
logger.i(
Expand Down Expand Up @@ -487,6 +493,12 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {
// Save dispute in state for listing
state = state.copyWith(dispute: disputeWithOrderId);

// Persist disputeId on session for background notification routing
final sessionNotifierForPeerDispute = ref.read(sessionNotifierProvider.notifier);
await sessionNotifierForPeerDispute.updateSession(
orderId, (s) => s.disputeId = disputeWithOrderId.disputeId,
);

// Notification handled by centralized NotificationDataExtractor path
if (kDebugMode) {
logger.i(
Expand Down
18 changes: 18 additions & 0 deletions lib/features/subscriptions/subscription_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ class SubscriptionManager {

final _ordersController = StreamController<NostrEvent>.broadcast();
final _chatController = StreamController<NostrEvent>.broadcast();
final _disputeChatController = StreamController<NostrEvent>.broadcast();
final _relayListController = StreamController<RelayListEvent>.broadcast();

Stream<NostrEvent> get orders => _ordersController.stream;
Stream<NostrEvent> get chat => _chatController.stream;
Stream<NostrEvent> get disputeChat => _disputeChatController.stream;
Stream<RelayListEvent> get relayList => _relayListController.stream;

SubscriptionManager(this.ref) {
Expand Down Expand Up @@ -141,6 +143,16 @@ class SubscriptionManager {
.map((s) => s.sharedKey!.public)
.toList(),
);
case SubscriptionType.disputeChat:
final adminKeys = sessions
.where((s) => s.adminSharedKey?.public != null)
.map((s) => s.adminSharedKey!.public)
.toList();
if (adminKeys.isEmpty) return null;
return NostrFilter(
kinds: [1059],
p: adminKeys,
);
case SubscriptionType.relayList:
// Relay list subscriptions are handled separately via subscribeToMostroRelayList
return null;
Expand All @@ -156,6 +168,9 @@ class SubscriptionManager {
case SubscriptionType.chat:
_chatController.add(event);
break;
case SubscriptionType.disputeChat:
_disputeChatController.add(event);
break;
case SubscriptionType.relayList:
final relayListEvent = RelayListEvent.fromEvent(event);
if (relayListEvent != null) {
Expand Down Expand Up @@ -207,6 +222,8 @@ class SubscriptionManager {
return orders;
case SubscriptionType.chat:
return chat;
case SubscriptionType.disputeChat:
return disputeChat;
case SubscriptionType.relayList:
// RelayList subscriptions should use subscribeToMostroRelayList() instead
throw UnsupportedError('Use subscribeToMostroRelayList() for relay list subscriptions');
Expand Down Expand Up @@ -328,6 +345,7 @@ class SubscriptionManager {
unsubscribeAll();
_ordersController.close();
_chatController.close();
_disputeChatController.close();
_relayListController.close();
}
}
1 change: 1 addition & 0 deletions lib/features/subscriptions/subscription_type.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
enum SubscriptionType {
chat,
orders,
disputeChat,
relayList,
}
2 changes: 2 additions & 0 deletions lib/l10n/intl_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,8 @@
"notification_cant_do_message": "Die angeforderte Aktion kann nicht ausgeführt werden",
"notification_new_message_title": "Neue Nachricht",
"notification_new_message_message": "Du hast eine neue Nachricht erhalten",
"notification_admin_message_title": "Nachricht vom Administrator",
"notification_admin_message_message": "Du hast eine Nachricht vom Streitadministrator erhalten",
"notification_order_update_title": "Order-Update",
"notification_order_update_message": "Deine Order wurde aktualisiert",
"@_comment_notification_details": "Labels für Benachrichtigungsdetails",
Expand Down
2 changes: 2 additions & 0 deletions lib/l10n/intl_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,8 @@
"notification_cant_do_message": "The requested action cannot be performed",
"notification_new_message_title": "New message",
"notification_new_message_message": "You have received a new message",
"notification_admin_message_title": "Message from admin",
"notification_admin_message_message": "You have received a message from the dispute admin",
"notification_order_update_title": "Order update",
"notification_order_update_message": "Your order has been updated",
"@_comment_notification_details": "Notification details labels",
Expand Down
Loading
Loading