diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 7e63149..515d5ee 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -292,6 +292,16 @@ class AppProvider with ChangeNotifier { String? _resolveContactNameForNotification(Uint8List? publicKey) { if (publicKey == null || publicKey.isEmpty) return null; + final ownPublicKey = connectionProvider.deviceInfo.publicKey; + final ownName = + connectionProvider.deviceInfo.deviceName ?? + connectionProvider.deviceInfo.selfName; + if (_matchesPublicKeyPrefix(publicKey, ownPublicKey) && + ownName != null && + ownName.trim().isNotEmpty) { + return ownName; + } + Contact? contact; if (publicKey.length >= 32) { contact = contactsProvider.findContactByKey(publicKey); @@ -304,6 +314,60 @@ class AppProvider with ChangeNotifier { return contact?.advName; } + static bool shouldIgnoreSelfReplay({ + required Message message, + required Uint8List? ownPublicKey, + required String? ownName, + }) { + if ((!message.isContactMessage && !message.isChannelMessage) || + message.pathLen > 0) { + return false; + } + + if (_matchesPublicKeyPrefix(message.senderPublicKeyPrefix, ownPublicKey)) { + return true; + } + + final trimmedSenderName = message.senderName?.trim(); + final trimmedOwnName = ownName?.trim(); + if (trimmedSenderName == null || + trimmedSenderName.isEmpty || + trimmedOwnName == null || + trimmedOwnName.isEmpty) { + return false; + } + + return trimmedSenderName == trimmedOwnName; + } + + static bool _matchesPublicKeyPrefix( + Uint8List? candidateKey, + Uint8List? ownPublicKey, + ) { + if (candidateKey == null || + candidateKey.isEmpty || + ownPublicKey == null || + ownPublicKey.isEmpty) { + return false; + } + + final compareLength = candidateKey.length >= 6 && ownPublicKey.length >= 6 + ? 6 + : (candidateKey.length < ownPublicKey.length + ? candidateKey.length + : ownPublicKey.length); + if (compareLength <= 0) { + return false; + } + + for (var index = 0; index < compareLength; index++) { + if (candidateKey[index] != ownPublicKey[index]) { + return false; + } + } + return true; + } + /// Load map enabled setting from shared preferences Future _loadMapEnabled() async { try { @@ -850,6 +914,17 @@ class AppProvider with ChangeNotifier { // When a message is received connectionProvider.onMessageReceived = (message) { + if (AppProvider.shouldIgnoreSelfReplay( + message: message, + ownPublicKey: connectionProvider.deviceInfo.publicKey, + ownName: + connectionProvider.deviceInfo.deviceName ?? + connectionProvider.deviceInfo.selfName, + )) { + debugPrint('⏭️ [AppProvider] Ignoring self replay: ${message.id}'); + return; + } + // Enrich message with sender name from contacts first Message enrichedMessage = message; Contact? senderContact; diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 1844b4e..856b9ca 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -505,6 +505,7 @@ class MessagesProvider with ChangeNotifier { ); } } + enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage); // For channel messages with sender name, try to link with contact Message finalMessage = enhancedMessage; @@ -634,17 +635,36 @@ class MessagesProvider with ChangeNotifier { if (existing.channelIdx != message.channelIdx) { return false; } - final existingSender = existing.senderKeyShort ?? existing.senderName; - final incomingSender = message.senderKeyShort ?? message.senderName; - if (existingSender == incomingSender) { + final existingSenderKey = existing.senderKeyShort; + final incomingSenderKey = message.senderKeyShort; + if (existingSenderKey != null && + incomingSenderKey != null && + existingSenderKey == incomingSenderKey) { return true; } - // When we send to a channel we add a local "sent" bubble immediately, - // then firmware may later sync back the same message as a received - // channel item with only the public sender handle. Only fold that replay - // into the original bubble after LOG_RX_DATA has already confirmed it as - // our own transmitted packet. + final existingSenderName = _normalizeSenderName(existing.senderName); + final incomingSenderName = _normalizeSenderName(message.senderName); + if (existingSenderName != null && + incomingSenderName != null && + existingSenderName == incomingSenderName) { + if (!existing.isSentMessage && !message.isSentMessage) { + return true; + } + + // When we send to a channel we add a local "sent" bubble immediately, + // then firmware may later sync back the same message as a received + // channel item under our public handle. Only fold that replay into the + // original bubble after LOG_RX_DATA has already confirmed it as our + // transmitted packet. + if (existing.isSentMessage && + existing.echoCount > 0 && + !message.isSentMessage && + (existing.senderTimestamp - message.senderTimestamp).abs() <= 1) { + return true; + } + } + if (existing.isSentMessage && existing.echoCount > 0 && !message.isSentMessage && @@ -694,6 +714,29 @@ class MessagesProvider with ChangeNotifier { notifyListeners(); } + String? _normalizeSenderName(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + return trimmed.toLowerCase(); + } + + Message _resolveSenderNameIfNeeded(Message message) { + if (message.senderName != null || message.senderPublicKeyPrefix == null) { + return message; + } + + final resolvedSenderName = resolveContactNameCallback?.call( + message.senderPublicKeyPrefix, + ); + if (resolvedSenderName == null || resolvedSenderName.trim().isEmpty) { + return message; + } + + return message.copyWith(senderName: resolvedSenderName.trim()); + } + /// Trigger urgent notification for SAR marker Future _triggerSarNotification( Message message, @@ -1251,6 +1294,7 @@ class MessagesProvider with ChangeNotifier { ); } } + enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage); // Check for duplicates (shouldn't happen for sent messages, but be safe) if (_findDuplicateMessageIndex(enhancedMessage) != -1) { diff --git a/test/providers/app_provider_channel_info_test.dart b/test/providers/app_provider_channel_info_test.dart index a2f42bb..cbeadd3 100644 --- a/test/providers/app_provider_channel_info_test.dart +++ b/test/providers/app_provider_channel_info_test.dart @@ -1,30 +1,137 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/message.dart'; import 'package:meshcore_sar_app/providers/app_provider.dart'; +Uint8List _ownPublicKey() => + Uint8List.fromList([1, 2, 3, 4, 5, 6, ...List.filled(26, 0)]); + void main() { group('AppProvider channel info handling', () { test('treats zeroed unnamed non-public channel as deleted', () { + expect(AppProvider.isDeletedChannelInfo(2, '', Uint8List(16)), isTrue); + }); + + test('keeps unnamed non-public channel when secret is configured', () { + final secret = Uint8List.fromList([1, ...List.filled(15, 0)]); + + expect(AppProvider.isDeletedChannelInfo(2, '', secret), isFalse); + expect(AppProvider.channelContactName(2, ''), 'Channel 2'); + }); + }); + + group('AppProvider self replay handling', () { + test('ignores direct self replay without hops', () { + final message = Message( + id: 'dm-self', + messageType: MessageType.contact, + senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]), + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'hello', + receivedAt: DateTime.now(), + ); + expect( - AppProvider.isDeletedChannelInfo(2, '', Uint8List(16)), + AppProvider.shouldIgnoreSelfReplay( + message: message, + ownPublicKey: _ownPublicKey(), + ownName: 'dz0ny (SI)', + ), isTrue, ); }); - test('keeps unnamed non-public channel when secret is configured', () { - final secret = Uint8List.fromList([ - 1, - ...List.filled(15, 0), - ]); + test('ignores room self replay without hops', () { + final message = Message( + id: 'room-self', + messageType: MessageType.contact, + senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]), + recipientPublicKey: Uint8List.fromList([9, 9, 9, 9, 9, 9]), + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'hello room', + receivedAt: DateTime.now(), + ); expect( - AppProvider.isDeletedChannelInfo(2, '', secret), + AppProvider.shouldIgnoreSelfReplay( + message: message, + ownPublicKey: _ownPublicKey(), + ownName: 'dz0ny (SI)', + ), + isTrue, + ); + }); + + test('keeps direct self replay when it traversed hops', () { + final message = Message( + id: 'dm-self-routed', + messageType: MessageType.contact, + senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]), + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'hello', + receivedAt: DateTime.now(), + ); + + expect( + AppProvider.shouldIgnoreSelfReplay( + message: message, + ownPublicKey: _ownPublicKey(), + ownName: 'dz0ny (SI)', + ), isFalse, ); + }); + + test('ignores channel self replay by self name without hops', () { + final message = Message( + id: 'channel-self', + messageType: MessageType.channel, + senderName: 'dz0ny (SI)', + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'hello public', + receivedAt: DateTime.now(), + ); + expect( - AppProvider.channelContactName(2, ''), - 'Channel 2', + AppProvider.shouldIgnoreSelfReplay( + message: message, + ownPublicKey: _ownPublicKey(), + ownName: 'dz0ny (SI)', + ), + isTrue, + ); + }); + + test('keeps channel self replay when it traversed hops', () { + final message = Message( + id: 'channel-self-routed', + messageType: MessageType.channel, + senderName: 'dz0ny (SI)', + channelIdx: 0, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'hello public', + receivedAt: DateTime.now(), + ); + + expect( + AppProvider.shouldIgnoreSelfReplay( + message: message, + ownPublicKey: _ownPublicKey(), + ownName: 'dz0ny (SI)', + ), + isFalse, ); }); }); diff --git a/test/providers/messages_provider_retransmission_test.dart b/test/providers/messages_provider_retransmission_test.dart index 19b4e8e..f73dd34 100644 --- a/test/providers/messages_provider_retransmission_test.dart +++ b/test/providers/messages_provider_retransmission_test.dart @@ -163,6 +163,7 @@ void main() { test('channel messages are marked sent immediately', () { final provider = MessagesProvider(); + provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; provider.addSentMessage( _buildSentChannelMessage(id: 'c1', senderTimestamp: 1700000000), ); @@ -179,6 +180,7 @@ void main() { 'channel replay is deduped after raw echo detection confirms our send', () { final provider = MessagesProvider(); + provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; provider.addSentMessage( _buildSentChannelMessage(id: 'c-echo', senderTimestamp: 1700000100), ); @@ -188,12 +190,14 @@ void main() { provider.addMessage( _buildReceivedChannelReplay( id: 'c-echo-incoming', - senderTimestamp: 1700000100, + senderTimestamp: 1700000101, + senderName: 'dz0ny (SI)', ), ); expect(provider.messages, hasLength(1)); expect(provider.messages.single.id, equals('c-echo')); + expect(provider.messages.single.senderName, equals('dz0ny (SI)')); }, ); @@ -201,6 +205,7 @@ void main() { 'channel replay is not deduped before raw echo detection confirms it', () { final provider = MessagesProvider(); + provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; provider.addSentMessage( _buildSentChannelMessage( id: 'c-no-echo', @@ -212,7 +217,8 @@ void main() { provider.addMessage( _buildReceivedChannelReplay( id: 'c-no-echo-incoming', - senderTimestamp: 1700000200, + senderTimestamp: 1700000201, + senderName: 'dz0ny (SI)', ), );