diff --git a/lib/features/notifications/services/background_notification_service.dart b/lib/features/notifications/services/background_notification_service.dart index 5b44a1a4..12154dda 100644 --- a/lib/features/notifications/services/background_notification_service.dart +++ b/lib/features/notifications/services/background_notification_service.dart @@ -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_en.dart'; @@ -157,26 +158,70 @@ Future _decryptAndProcessEvent(NostrEvent event) async { orElse: () => null, ); - if (matchingSession == null) { - return null; + if (matchingSession != null) { + return _handleTradeKeyEvent(event, matchingSession); } - final decryptedEvent = await event.unWrap(matchingSession.tradeKey.private); - if (decryptedEvent.content == null) { - return null; + // P2P chat: match by sharedKey.public + final chatMatch = sessions.cast().firstWhere( + (s) => s?.sharedKey?.public == event.recipient, + orElse: () => null, + ); + + if (chatMatch != null) { + return _handleP2PChatEvent(event, chatMatch); } - final result = jsonDecode(decryptedEvent.content!); - if (result is! List || result.isEmpty) { + return null; + } catch (e) { + logger.e('Decrypt error: $e'); + return null; + } +} + +/// Handle events matched by tradeKey (Mostro protocol + admin/dispute DMs) +Future _handleTradeKeyEvent(NostrEvent event, Session session) async { + final decryptedEvent = await event.unWrap(session.tradeKey.private); + if (decryptedEvent.content == null) { + return null; + } + + final result = jsonDecode(decryptedEvent.content!); + if (result is! List || result.isEmpty) { + return null; + } + + // Detect admin/dispute DM format: [{"dm": {"action": "send-dm", ...}}] + final firstItem = result[0]; + if (NostrUtils.isDmPayload(firstItem)) { + return MostroMessage( + action: mostro_action.Action.sendDm, + id: session.orderId, + timestamp: event.createdAt?.millisecondsSinceEpoch, + ); + } + + final mostroMessage = MostroMessage.fromJson(result[0]); + mostroMessage.timestamp = event.createdAt?.millisecondsSinceEpoch; + + return mostroMessage; +} + +/// Handle P2P chat events matched by sharedKey +Future _handleP2PChatEvent(NostrEvent event, Session session) async { + try { + final decryptedEvent = await event.p2pUnwrap(session.sharedKey!); + if (decryptedEvent.content == null || decryptedEvent.content!.isEmpty) { return null; } - final mostroMessage = MostroMessage.fromJson(result[0]); - mostroMessage.timestamp = event.createdAt?.millisecondsSinceEpoch; - - return mostroMessage; + return MostroMessage( + action: mostro_action.Action.sendDm, + id: session.orderId, + timestamp: event.createdAt?.millisecondsSinceEpoch, + ); } catch (e) { - logger.e('Decrypt error: $e'); + logger.e('P2P chat decrypt error: $e'); return null; } } diff --git a/lib/features/notifications/utils/notification_data_extractor.dart b/lib/features/notifications/utils/notification_data_extractor.dart index 34278eee..c437d6e8 100644 --- a/lib/features/notifications/utils/notification_data_extractor.dart +++ b/lib/features/notifications/utils/notification_data_extractor.dart @@ -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(); 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; diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 67baf158..9e3c80bc 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -13,6 +13,7 @@ import 'package:mostro_mobile/shared/providers.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; class MostroService { final Ref ref; @@ -129,6 +130,12 @@ class MostroService { return; } + // Skip dispute chat messages (they have "dm" key and are handled by DisputeChatNotifier) + if (NostrUtils.isDmPayload(result[0])) { + logger.i('Skipping dispute chat message (handled by DisputeChatNotifier)'); + return; + } + // Skip restore-specific payloads that arrive as historical events due to temporary subscription if (result[0] is Map && _isRestorePayload(result[0] as Map)) { return; diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index 2eed1a16..62dd78c8 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -395,6 +395,12 @@ class NostrUtils { } } + /// Checks if a decoded Mostro payload item is a DM (dispute/admin chat) message. + /// DM messages use the format: {"dm": {"action": "send-dm", ...}} + static bool isDmPayload(dynamic item) { + return item is Map && item.containsKey('dm'); + } + static Future encryptNIP44( String content, String privkey, String pubkey) async { try { diff --git a/pubspec.lock b/pubspec.lock index 9b905434..c449bf23 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1132,6 +1132,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce + url: "https://pub.dev" + source: hosted + version: "7.2.0" mockito: dependency: "direct dev" description: diff --git a/test/features/notifications/services/background_notification_dm_detection_test.dart b/test/features/notifications/services/background_notification_dm_detection_test.dart new file mode 100644 index 00000000..c99f5df1 --- /dev/null +++ b/test/features/notifications/services/background_notification_dm_detection_test.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/features/notifications/utils/notification_data_extractor.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; + +/// Tests for the admin/dispute DM background notification pipeline (Phase 1). +/// +/// Validates three layers: +/// 1. [NostrUtils.isDmPayload] — pure detection of the `{"dm": ...}` envelope +/// 2. [MostroMessage] construction — synthetic message with [Action.sendDm] +/// 3. [NotificationDataExtractor] — ensures sendDm is NOT marked temporary +void main() { + group('NostrUtils.isDmPayload', () { + test('detects dm wrapper key in decoded JSON', () { + final dmPayload = jsonDecode( + '[{"dm": {"action": "send-dm", "payload": {"text_message": "Hello"}}}]', + ); + + expect(dmPayload, isList); + expect(dmPayload, isNotEmpty); + expect(NostrUtils.isDmPayload(dmPayload[0]), isTrue); + }); + + test('does not detect dm key in standard Mostro message', () { + final orderPayload = jsonDecode( + '[{"order": {"action": "new-order", "id": "abc123", "payload": null}}]', + ); + + expect(NostrUtils.isDmPayload(orderPayload[0]), isFalse); + }); + + test('does not detect dm key in restore message', () { + final restorePayload = jsonDecode( + '[{"restore": {"action": "restore-session", "id": "abc123"}}]', + ); + + expect(NostrUtils.isDmPayload(restorePayload[0]), isFalse); + }); + + test('does not detect dm key in cant-do message', () { + final cantDoPayload = jsonDecode( + '[{"cant-do": {"action": "cant-do", "payload": null}}]', + ); + + expect(NostrUtils.isDmPayload(cantDoPayload[0]), isFalse); + }); + + test('handles dm payload with minimal content', () { + final dmPayload = jsonDecode('[{"dm": {}}]'); + + expect(NostrUtils.isDmPayload(dmPayload[0]), isTrue); + }); + + test('returns false for non-Map types', () { + expect(NostrUtils.isDmPayload('string'), isFalse); + expect(NostrUtils.isDmPayload(42), isFalse); + expect(NostrUtils.isDmPayload(null), isFalse); + expect(NostrUtils.isDmPayload([]), isFalse); + }); + }); + + group('MostroMessage construction for DM', () { + test('preserves orderId and timestamp with sendDm action', () { + const testOrderId = 'test-order-123'; + const testTimestamp = 1700000000000; + + final message = MostroMessage( + action: Action.sendDm, + id: testOrderId, + timestamp: testTimestamp, + ); + + expect(message.action, Action.sendDm); + expect(message.id, testOrderId); + expect(message.timestamp, testTimestamp); + }); + }); + + group('NotificationDataExtractor for sendDm', () { + test('produces non-temporary notification data', () async { + final message = MostroMessage( + action: Action.sendDm, + id: 'order-abc', + timestamp: 1700000000000, + ); + + final data = await NotificationDataExtractor.extractFromMostroMessage( + message, + null, + ); + + expect(data, isNotNull); + expect(data!.isTemporary, isFalse); + expect(data.action, Action.sendDm); + expect(data.orderId, 'order-abc'); + }); + + test('returns empty values map (no payload extraction)', () async { + final message = MostroMessage( + action: Action.sendDm, + id: 'order-xyz', + ); + + final data = await NotificationDataExtractor.extractFromMostroMessage( + message, + null, + ); + + expect(data, isNotNull); + expect(data!.values, isEmpty); + }); + }); + + group('NotificationDataExtractor for cooperativeCancelAccepted', () { + test('produces non-temporary notification data', () async { + final message = MostroMessage( + action: Action.cooperativeCancelAccepted, + id: 'order-cancel', + ); + + final data = await NotificationDataExtractor.extractFromMostroMessage( + message, + null, + ); + + expect(data, isNotNull); + expect(data!.isTemporary, isFalse); + expect(data.action, Action.cooperativeCancelAccepted); + }); + }); +}