diff --git a/lib/models/message_reception_details.dart b/lib/models/message_reception_details.dart new file mode 100644 index 0000000..90d264f --- /dev/null +++ b/lib/models/message_reception_details.dart @@ -0,0 +1,63 @@ +class MessageReceptionDetails { + final DateTime capturedAt; + final DateTime? packetLoggedAt; + final int? rssiDbm; + final double? snrDb; + final List? pathBytes; + final int? senderToReceiptMs; + final int? estimatedTransmitMs; + final int? postTransmitDelayMs; + + const MessageReceptionDetails({ + required this.capturedAt, + this.packetLoggedAt, + this.rssiDbm, + this.snrDb, + this.pathBytes, + this.senderToReceiptMs, + this.estimatedTransmitMs, + this.postTransmitDelayMs, + }); + + String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty + ? null + : pathBytes!.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + + Map toJson() { + return { + 'capturedAtMillis': capturedAt.millisecondsSinceEpoch, + 'packetLoggedAtMillis': packetLoggedAt?.millisecondsSinceEpoch, + 'rssiDbm': rssiDbm, + 'snrDb': snrDb, + 'pathBytes': pathBytes, + 'senderToReceiptMs': senderToReceiptMs, + 'estimatedTransmitMs': estimatedTransmitMs, + 'postTransmitDelayMs': postTransmitDelayMs, + }; + } + + static MessageReceptionDetails? fromJson(Map json) { + final capturedAtMillis = json['capturedAtMillis']; + if (capturedAtMillis is! int) { + return null; + } + + final pathBytes = json['pathBytes']; + return MessageReceptionDetails( + capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis), + packetLoggedAt: json['packetLoggedAtMillis'] is int + ? DateTime.fromMillisecondsSinceEpoch( + json['packetLoggedAtMillis'] as int, + ) + : null, + rssiDbm: json['rssiDbm'] as int?, + snrDb: (json['snrDb'] as num?)?.toDouble(), + pathBytes: pathBytes is List + ? pathBytes.whereType().map((b) => b.toInt()).toList() + : null, + senderToReceiptMs: json['senderToReceiptMs'] as int?, + estimatedTransmitMs: json['estimatedTransmitMs'] as int?, + postTransmitDelayMs: json['postTransmitDelayMs'] as int?, + ); + } +} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 7ee5c36..f9cc746 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'connection_provider.dart'; @@ -16,9 +17,12 @@ import '../services/packet_capture_storage_service.dart'; import '../models/contact.dart'; import '../models/message.dart'; import '../models/ble_packet_log.dart'; +import '../models/message_reception_details.dart'; import '../utils/drawing_message_parser.dart'; +import '../utils/raw_route_probe.dart'; import '../utils/voice_message_parser.dart'; import '../utils/image_message_parser.dart'; +import '../utils/message_airtime_estimator.dart'; /// Main App Provider - coordinates all other providers class AppProvider with ChangeNotifier { @@ -70,6 +74,8 @@ class AppProvider with ChangeNotifier { FragmentAckWaitRegistry(); final FragmentAckWaitRegistry _imageFragmentAckWaiters = FragmentAckWaitRegistry(); + final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry(); + final Map> _pendingRawRouteProbes = {}; Timer? _packetCaptureFlushTimer; String? _lastPersistedPacketSignature; bool _isPersistingPacketCapture = false; @@ -633,6 +639,9 @@ class AppProvider with ChangeNotifier { capturedAt: enrichedMessage.receivedAt, ) : null; + final receptionDetailsSnapshot = _buildReceptionDetailsSnapshot( + enrichedMessage, + ); // Check if message is a drawing broadcast if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) { @@ -664,6 +673,7 @@ class AppProvider with ChangeNotifier { updatedMessage, contactLookup: (name) => '', contactLocationSnapshot: contactLocationSnapshot, + receptionDetailsSnapshot: receptionDetailsSnapshot, ); // Broadcast drawing message to SSE clients if server is running @@ -700,6 +710,7 @@ class AppProvider with ChangeNotifier { } }, contactLocationSnapshot: contactLocationSnapshot, + receptionDetailsSnapshot: receptionDetailsSnapshot, ); connectionProvider.broadcastMessageToSseClients(enrichedMessage); return; @@ -728,6 +739,7 @@ class AppProvider with ChangeNotifier { } }, contactLocationSnapshot: contactLocationSnapshot, + receptionDetailsSnapshot: receptionDetailsSnapshot, ); connectionProvider.broadcastMessageToSseClients(enrichedMessage); return; @@ -765,12 +777,16 @@ class AppProvider with ChangeNotifier { } }, contactLocationSnapshot: contactLocationSnapshot, + receptionDetailsSnapshot: receptionDetailsSnapshot, ); // Broadcast message to SSE clients if server is running connectionProvider.broadcastMessageToSseClients(enrichedMessage); }; + // Keep a compact receive-time snapshot because packet logs roll over. + // This lets the UI still show timing/link details after app restarts. + // When telemetry is received via PUSH_CODE_TELEMETRY_RESPONSE (0x8B) // Used by older firmware versions for telemetry responses connectionProvider.onTelemetryReceived = (publicKey, lppData) { @@ -796,6 +812,18 @@ class AppProvider with ChangeNotifier { // Magic 0x72 'r' = voice fetch request; 0x69 'i' = image fetch request. // Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet. connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { + final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload); + if (rawProbeRequest != null) { + _handleRawRouteProbeRequest(rawProbeRequest); + return; + } + + final rawProbeAck = RawRouteProbeAck.tryParseBinary(payload); + if (rawProbeAck != null) { + _completeRawRouteProbeAck(rawProbeAck.nonce); + return; + } + final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload); if (voiceFetchRequest != null) { final requester = _resolveVoiceFetchRequester(voiceFetchRequest); @@ -1559,6 +1587,78 @@ class AppProvider with ChangeNotifier { } String _fragmentAckKey(String sessionId, int index) => '$sessionId:$index'; + String _rawProbeKey(int nonce) => + nonce.toRadixString(16).padLeft(8, '0').toLowerCase(); + + Future verifyRawTransportRoute( + Contact target, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (!connectionProvider.deviceInfo.isConnected) { + return false; + } + if (target.outPathLen < 0 || target.outPathLen > _maxDirectPayloadHops) { + return false; + } + if (target.outPath.isEmpty) { + return false; + } + + final probeKey = _routeProbeTargetKey(target); + final pendingProbe = _pendingRawRouteProbes[probeKey]; + if (pendingProbe != null) { + return pendingProbe; + } + + final deviceKey = connectionProvider.deviceInfo.publicKey; + if (deviceKey == null || deviceKey.length < 6) { + return false; + } + + final requesterKey6 = deviceKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + final future = () async { + final nonce = math.Random.secure().nextInt(0x100000000); + final ackFuture = _rawProbeWaiters.waitFor( + _rawProbeKey(nonce), + timeout: timeout, + ); + + try { + await connectionProvider.sendRawVoicePacket( + contactPath: target.outPath, + contactPathLen: target.outPathLen, + payload: RawRouteProbeRequest( + nonce: nonce, + requesterKey6: requesterKey6, + ).encodeBinary(), + ); + return await ackFuture; + } catch (e) { + debugPrint( + '⚠️ [AppProvider] Raw route probe failed for ${target.advName}: $e', + ); + _rawProbeWaiters.complete(_rawProbeKey(nonce)); + return false; + } + }(); + + _pendingRawRouteProbes[probeKey] = future; + try { + return await future; + } finally { + _pendingRawRouteProbes.remove(probeKey); + } + } + + String _routeProbeTargetKey(Contact target) { + if (target.publicKeyHex.isNotEmpty) { + return 'pk:${target.publicKeyHex}'; + } + return 'name:${target.advName}:${target.outPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'; + } Future _waitForVoiceFragmentAck({ required String sessionId, @@ -1608,6 +1708,37 @@ class AppProvider with ChangeNotifier { ); } + void _handleRawRouteProbeRequest(RawRouteProbeRequest request) { + final requester = _resolveContactByPrefixHex(request.requesterKey6); + if (requester == null) { + debugPrint( + '⚠️ [AppProvider] Raw route probe requester not found: ${request.requesterKey6}', + ); + return; + } + if (requester.outPathLen < 0 || + requester.outPathLen > _maxDirectPayloadHops) { + debugPrint( + '⚠️ [AppProvider] Raw route probe requester out of range: ${requester.outPathLen}', + ); + return; + } + if (requester.outPath.isEmpty) { + return; + } + unawaited( + connectionProvider.sendRawVoicePacket( + contactPath: requester.outPath, + contactPathLen: requester.outPathLen, + payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(), + ), + ); + } + + void _completeRawRouteProbeAck(int nonce) { + _rawProbeWaiters.complete(_rawProbeKey(nonce)); + } + void _sendVoiceFragmentAck(VoicePacket packet) { final senderKey6 = _voiceSessionSenderKey6[packet.sessionId]; if (senderKey6 == null) return; @@ -1648,6 +1779,95 @@ class AppProvider with ChangeNotifier { ); } + MessageReceptionDetails? _buildReceptionDetailsSnapshot(Message message) { + final matchedRxLog = _findBestMatchingRxLog(message); + final estimatedTx = estimateMessageTransmitDuration( + message, + radioBw: connectionProvider.deviceInfo.radioBw, + radioSf: connectionProvider.deviceInfo.radioSf, + radioCr: connectionProvider.deviceInfo.radioCr, + ); + final senderToReceiptMs = _senderToReceiptMs(message); + final estimatedTransmitMs = estimatedTx > Duration.zero + ? estimatedTx.inMilliseconds + : null; + final postTransmitDelayMs = + senderToReceiptMs != null && estimatedTransmitMs != null + ? (senderToReceiptMs - estimatedTransmitMs).clamp(0, 86400000).toInt() + : null; + + if (matchedRxLog == null && + senderToReceiptMs == null && + estimatedTransmitMs == null) { + return null; + } + + return MessageReceptionDetails( + capturedAt: DateTime.now(), + packetLoggedAt: matchedRxLog?.timestamp, + rssiDbm: matchedRxLog?.logRxDataInfo?.rssiDbm, + snrDb: matchedRxLog?.logRxDataInfo?.snrDb, + pathBytes: _extractPathBytesFromLog(matchedRxLog), + senderToReceiptMs: senderToReceiptMs, + estimatedTransmitMs: estimatedTransmitMs, + postTransmitDelayMs: postTransmitDelayMs, + ); + } + + int? _senderToReceiptMs(Message message) { + if (message.senderTimestamp <= 0) return null; + final senderAt = DateTime.fromMillisecondsSinceEpoch( + message.senderTimestamp * 1000, + isUtc: true, + ); + final deltaMs = message.receivedAt + .toUtc() + .difference(senderAt) + .inMilliseconds; + if (deltaMs < 0 || deltaMs > 86400000) return null; + return deltaMs; + } + + BlePacketLog? _findBestMatchingRxLog(Message message) { + if (message.pathLen < 0 || message.pathLen >= 255) return null; + final expectedPayloadType = message.messageType == MessageType.channel + ? 0x05 + : 0x02; + BlePacketLog? bestLog; + var bestDeltaMs = 999999999; + + for (final log in connectionProvider.bleService.packetLogs) { + if (log.responseCode != 0x88) continue; + if (log.rawData.length < 6) continue; + + final raw = log.rawData; + final payloadType = (raw[3] >> 2) & 0x0F; + final pathLen = raw[4]; + if (payloadType != expectedPayloadType) continue; + if (pathLen != message.pathLen) continue; + if (raw.length < 5 + pathLen) continue; + + final deltaMs = + (log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); + if (deltaMs < bestDeltaMs) { + bestDeltaMs = deltaMs; + bestLog = log; + } + } + + if (bestDeltaMs > 30000) return null; + return bestLog; + } + + List? _extractPathBytesFromLog(BlePacketLog? log) { + if (log == null) return null; + final raw = log.rawData; + if (raw.length < 6) return null; + final pathLen = raw[4]; + if (pathLen <= 0 || raw.length < 5 + pathLen) return null; + return raw.sublist(5, 5 + pathLen); + } + // Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events // The ConnectionProvider's onMessageWaiting callback handles automatic message fetching diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index b132d37..28821ec 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -146,11 +146,15 @@ class ConnectionProvider with ChangeNotifier { final MessageDeliveryTracker _messageDeliveryTracker = MessageDeliveryTracker(); final PingTracker _pingTracker = PingTracker(); + final Map> _pendingSmartPings = {}; // Expose room login states Map get roomLoginStates => _roomLoginManager.roomLoginStates; + bool isPingInProgress(Uint8List publicKey) => + _pendingSmartPings.containsKey(_publicKeyToHex(publicKey)); + // Callbacks for other providers Function(Contact)? onContactReceived; Function(List)? onContactsComplete; @@ -1316,6 +1320,34 @@ class ConnectionProvider with ChangeNotifier { required Uint8List contactPublicKey, required bool hasPath, Function()? onRetryWithFlooding, + }) async { + final pingKey = _publicKeyToHex(contactPublicKey); + final pendingPing = _pendingSmartPings[pingKey]; + if (pendingPing != null) { + debugPrint('ℹ️ [Provider] Joining in-flight ping for $pingKey'); + return pendingPing; + } + + final future = _runSmartPing( + contactPublicKey: contactPublicKey, + hasPath: hasPath, + onRetryWithFlooding: onRetryWithFlooding, + ); + _pendingSmartPings[pingKey] = future; + notifyListeners(); + + try { + return await future; + } finally { + _pendingSmartPings.remove(pingKey); + notifyListeners(); + } + } + + Future _runSmartPing({ + required Uint8List contactPublicKey, + required bool hasPath, + Function()? onRetryWithFlooding, }) async { if (!_activeService.isConnected) { _error = 'Not connected to device'; @@ -1334,7 +1366,10 @@ class ConnectionProvider with ChangeNotifier { ); // Send the ping - await _activeService.requestTelemetry(contactPublicKey, zeroHop: true); + await _activeService.requestTelemetry( + contactPublicKey, + zeroHop: firstAttemptDirect, + ); // Wait for response or timeout final bool gotResponse = await pingFuture; @@ -1361,8 +1396,8 @@ class ConnectionProvider with ChangeNotifier { wasDirectAttempt: false, ); - // Retry with flooding (zeroHop=true acts as broadcast to neighbors) - await _activeService.requestTelemetry(contactPublicKey, zeroHop: true); + // Retry with flooding. + await _activeService.requestTelemetry(contactPublicKey, zeroHop: false); // Wait for response or timeout final bool gotRetryResponse = await retryFuture; @@ -1384,6 +1419,10 @@ class ConnectionProvider with ChangeNotifier { } } + String _publicKeyToHex(Uint8List publicKey) { + return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); + } + /// Send binary request to contact (modern replacement for requestTelemetry) /// /// Supports multiple request types: diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 6e52aeb..28b8324 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import '../models/message.dart'; import '../models/contact.dart'; import '../models/message_contact_location.dart'; +import '../models/message_reception_details.dart'; import '../models/sar_marker.dart'; import '../models/map_drawing.dart'; import '../services/message_storage_service.dart'; @@ -22,6 +23,7 @@ class MessagesProvider with ChangeNotifier { bool _isInitialized = false; AppLocalizations? _localizations; final Map _messageContactLocations = {}; + final Map _messageReceptionDetails = {}; // Track pending sent messages by expected ACK/TAG final Map _pendingSentMessages = {}; @@ -74,10 +76,7 @@ class MessagesProvider with ChangeNotifier { })? sendMessageCallback; - Future Function({ - required Contact contact, - required int failureStreak, - })? + Future Function({required Contact contact, required int failureStreak})? onDirectPathFailedCallback; List get messages => List.unmodifiable(_messages); @@ -115,6 +114,9 @@ class MessagesProvider with ChangeNotifier { MessageContactLocation? getMessageContactLocation(String messageId) => _messageContactLocations[messageId]; + MessageReceptionDetails? getMessageReceptionDetails(String messageId) => + _messageReceptionDetails[messageId]; + /// Set localizations for notifications void setLocalizations(AppLocalizations localizations) { _localizations = localizations; @@ -145,9 +147,14 @@ class MessagesProvider with ChangeNotifier { final storedMessages = await _storageService.loadMessages(); final storedContactLocations = await _storageService .loadMessageContactLocations(); + final storedReceptionDetails = await _storageService + .loadMessageReceptionDetails(); _messageContactLocations ..clear() ..addAll(storedContactLocations); + _messageReceptionDetails + ..clear() + ..addAll(storedReceptionDetails); // Add stored messages with enhancement to ensure SAR detection for (final message in storedMessages) { @@ -311,6 +318,7 @@ class MessagesProvider with ChangeNotifier { Message message, { String Function(String name)? contactLookup, MessageContactLocation? contactLocationSnapshot, + MessageReceptionDetails? receptionDetailsSnapshot, }) { // Always enhance message with SAR parser to detect SAR markers var enhancedMessage = SarMessageParser.enhanceMessage(message); @@ -397,6 +405,22 @@ class MessagesProvider with ChangeNotifier { debugPrint( ' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...', ); + final existingIndex = _messages.indexWhere( + (existing) => + existing.messageType == finalMessage.messageType && + existing.senderTimestamp == finalMessage.senderTimestamp && + existing.text == finalMessage.text, + ); + if (existingIndex != -1) { + final existingId = _messages[existingIndex].id; + if (contactLocationSnapshot != null) { + _messageContactLocations[existingId] = contactLocationSnapshot; + } + if (receptionDetailsSnapshot != null) { + _messageReceptionDetails[existingId] = receptionDetailsSnapshot; + } + _persistMessages(); + } return; // Skip duplicate } @@ -404,6 +428,9 @@ class MessagesProvider with ChangeNotifier { if (contactLocationSnapshot != null) { _messageContactLocations[finalMessage.id] = contactLocationSnapshot; } + if (receptionDetailsSnapshot != null) { + _messageReceptionDetails[finalMessage.id] = receptionDetailsSnapshot; + } // If it's a SAR marker message, extract and store the marker if (finalMessage.isSarMarker) { @@ -593,6 +620,7 @@ class MessagesProvider with ChangeNotifier { await _storageService.saveMessages( _messages, messageContactLocations: _messageContactLocations, + messageReceptionDetails: _messageReceptionDetails, ); } catch (e) { debugPrint('❌ [MessagesProvider] Error persisting messages: $e'); @@ -709,6 +737,7 @@ class MessagesProvider with ChangeNotifier { _messageContactMap.remove(messageId); _groupedMessageMapping.remove(messageId); _messageContactLocations.remove(messageId); + _messageReceptionDetails.remove(messageId); debugPrint('🗑️ [MessagesProvider] Message $messageId deleted'); @@ -738,6 +767,7 @@ class MessagesProvider with ChangeNotifier { _messages.clear(); _sarMarkers.clear(); _messageContactLocations.clear(); + _messageReceptionDetails.clear(); _persistMessages(); notifyListeners(); } @@ -753,6 +783,7 @@ class MessagesProvider with ChangeNotifier { _messages.clear(); _sarMarkers.clear(); _messageContactLocations.clear(); + _messageReceptionDetails.clear(); _persistMessages(); notifyListeners(); } @@ -1062,10 +1093,11 @@ class MessagesProvider with ChangeNotifier { ' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...', ); + // Once the device accepts a direct message and returns an ACK tag, the + // send itself succeeded locally even if end-to-end delivery confirmation + // may still arrive later. Keep ACK tracking, but stop showing "waiting". final updatedMessage = message.copyWith( - deliveryStatus: expectedAckTag > 0 - ? MessageDeliveryStatus.sending - : MessageDeliveryStatus.sent, + deliveryStatus: MessageDeliveryStatus.sent, expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null, suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null, ); diff --git a/lib/screens/packet_log_screen.dart b/lib/screens/packet_log_screen.dart index ec05782..25e607c 100644 --- a/lib/screens/packet_log_screen.dart +++ b/lib/screens/packet_log_screen.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; import 'dart:io'; import 'package:path_provider/path_provider.dart'; import 'package:meshcore_client/meshcore_client.dart'; import '../l10n/app_localizations.dart'; +import '../providers/connection_provider.dart'; +import '../providers/contacts_provider.dart'; +import '../utils/log_rx_route_decoder.dart'; class PacketLogScreen extends StatefulWidget { final MeshCoreBleService bleService; @@ -456,6 +460,26 @@ class _PacketLogCard extends StatelessWidget { final isRx = log.direction == PacketDirection.rx; final directionColor = isRx ? Colors.green : Colors.blue; final rxInfo = log.logRxDataInfo; + final contacts = context.watch().contacts; + final connectionProvider = context.watch(); + final decodedRoute = LogRxRouteDecoder.decode(log.rawData); + final ownPublicKey = connectionProvider.deviceInfo.publicKey; + final ownName = + connectionProvider.deviceInfo.selfName ?? + connectionProvider.deviceInfo.displayName; + final resolvedPath = decodedRoute?.pathHashes + .map( + (hash) => LogRxRouteDecoder.resolveHash( + hash, + contacts: contacts, + ownPublicKey: ownPublicKey, + ownName: ownName, + ), + ) + .toList(); + final originalSender = resolvedPath != null && resolvedPath.isNotEmpty + ? resolvedPath.first + : null; return Card( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), @@ -580,6 +604,14 @@ class _PacketLogCard extends StatelessWidget { ), ), ], + if (isRx && decodedRoute != null) ...[ + const SizedBox(height: 12), + _RouteSection( + route: decodedRoute, + path: resolvedPath ?? const [], + originalSender: originalSender, + ), + ], const SizedBox(height: 12), Container( padding: const EdgeInsets.all(12), @@ -723,6 +755,162 @@ class _PacketLogCard extends StatelessWidget { } } +class _RouteSection extends StatelessWidget { + final DecodedLogRxRoute route; + final List path; + final ResolvedNodeHash? originalSender; + + const _RouteSection({ + required this.route, + required this.path, + required this.originalSender, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.alt_route, size: 16), + SizedBox(width: 6), + Text( + 'Mesh Route', + style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12), + ), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _FactCard( + icon: Icons.route, + label: 'Payload', + value: _payloadTypeLabel(route.payloadType), + ), + _FactCard( + icon: Icons.hub, + label: 'Hops', + value: '${route.pathHashes.length}', + ), + if (originalSender != null) + _FactCard( + icon: Icons.person_pin_circle, + label: 'Original sender', + value: _nodeLabel(originalSender!), + ), + ], + ), + const SizedBox(height: 12), + if (path.isEmpty) + Text( + 'Direct packet, no hop path attached.', + style: TextStyle(fontSize: 12, color: Colors.grey[700]), + ) + else + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (var i = 0; i < path.length; i++) ...[ + _RouteHopChip(index: i + 1, node: path[i]), + if (i < path.length - 1) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 2), + child: Icon(Icons.arrow_right_alt, size: 16), + ), + ], + ], + ), + ], + ), + ); + } + + static String _payloadTypeLabel(int payloadType) { + switch (payloadType) { + case 0x00: + return 'REQ'; + case 0x01: + return 'RESP'; + case 0x02: + return 'TXT'; + case 0x03: + return 'ACK'; + case 0x04: + return 'ADVERT'; + case 0x05: + return 'GRP_TXT'; + case 0x06: + return 'GRP_DATA'; + case 0x07: + return 'ANON_REQ'; + case 0x08: + return 'PATH'; + case 0x09: + return 'TRACE'; + case 0x0A: + return 'MULTIPART'; + case 0x0B: + return 'CONTROL'; + default: + return '0x${payloadType.toRadixString(16).padLeft(2, '0')}'; + } + } + + static String _nodeLabel(ResolvedNodeHash node) { + if (node.isOwnNode) { + return '${node.label} (${node.hexLabel})'; + } + if (node.matchCount == 0) { + return node.hexLabel; + } + if (node.isUniqueMatch) { + return '${node.label} (${node.hexLabel})'; + } + return '${node.label} (${node.hexLabel}, ${node.matchCount} matches)'; + } +} + +class _RouteHopChip extends StatelessWidget { + final int index; + final ResolvedNodeHash node; + + const _RouteHopChip({required this.index, required this.node}); + + @override + Widget build(BuildContext context) { + final color = node.isOwnNode + ? Colors.blue + : node.isUniqueMatch + ? Colors.green + : Colors.orange; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.35)), + ), + child: Text( + '$index. ${_RouteSection._nodeLabel(node)}', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ); + } +} + class _FactCard extends StatelessWidget { final IconData icon; final String label; diff --git a/lib/services/message_storage_service.dart b/lib/services/message_storage_service.dart index 04a2728..4604724 100644 --- a/lib/services/message_storage_service.dart +++ b/lib/services/message_storage_service.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/message.dart'; import '../models/message_contact_location.dart'; +import '../models/message_reception_details.dart'; import 'package:latlong2/latlong.dart'; /// Service for persisting messages to local storage @@ -10,12 +11,15 @@ class MessageStorageService { static const String _messagesKey = 'stored_messages'; static const String _messageContactLocationsKey = 'stored_message_contact_locations'; + static const String _messageReceptionDetailsKey = + 'stored_message_reception_details'; static const int _maxStoredMessages = 1000; // Store up to 1000 messages /// Save messages to persistent storage Future saveMessages( List messages, { Map messageContactLocations = const {}, + Map messageReceptionDetails = const {}, }) async { try { final prefs = await SharedPreferences.getInstance(); @@ -34,15 +38,25 @@ class MessageStorageService { .map((entry) => entry['id'] as String) .toSet(); final locationJson = {}; + final receptionJson = {}; for (final entry in messageContactLocations.entries) { if (retainedMessageIds.contains(entry.key)) { locationJson[entry.key] = entry.value.toJson(); } } + for (final entry in messageReceptionDetails.entries) { + if (retainedMessageIds.contains(entry.key)) { + receptionJson[entry.key] = entry.value.toJson(); + } + } await prefs.setString( _messageContactLocationsKey, jsonEncode(locationJson), ); + await prefs.setString( + _messageReceptionDetailsKey, + jsonEncode(receptionJson), + ); debugPrint( '✅ [MessageStorage] Saved ${limitedList.length} messages to storage', @@ -52,8 +66,8 @@ class MessageStorageService { } } - Future> loadMessageContactLocations() - async { + Future> + loadMessageContactLocations() async { try { final prefs = await SharedPreferences.getInstance(); final jsonString = prefs.getString(_messageContactLocationsKey); @@ -82,6 +96,36 @@ class MessageStorageService { } } + Future> + loadMessageReceptionDetails() async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_messageReceptionDetailsKey); + if (jsonString == null || jsonString.isEmpty) { + return const {}; + } + + final decoded = jsonDecode(jsonString); + if (decoded is! Map) { + return const {}; + } + + final result = {}; + for (final entry in decoded.entries) { + final value = entry.value; + if (value is! Map) continue; + final snapshot = MessageReceptionDetails.fromJson(value); + if (snapshot != null) { + result[entry.key] = snapshot; + } + } + return result; + } catch (e) { + debugPrint('❌ [MessageStorage] Error loading reception details: $e'); + return const {}; + } + } + /// Load messages from persistent storage Future> loadMessages() async { try { @@ -116,6 +160,7 @@ class MessageStorageService { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_messagesKey); await prefs.remove(_messageContactLocationsKey); + await prefs.remove(_messageReceptionDetailsKey); debugPrint('✅ [MessageStorage] Cleared all stored messages'); } catch (e) { debugPrint('❌ [MessageStorage] Error clearing messages: $e'); diff --git a/lib/utils/log_rx_route_decoder.dart b/lib/utils/log_rx_route_decoder.dart new file mode 100644 index 0000000..006829c --- /dev/null +++ b/lib/utils/log_rx_route_decoder.dart @@ -0,0 +1,129 @@ +import 'dart:typed_data'; + +import '../models/contact.dart'; + +class DecodedLogRxRoute { + final int payloadType; + final List pathHashes; + + const DecodedLogRxRoute({ + required this.payloadType, + required this.pathHashes, + }); + + int? get originalSenderHash => pathHashes.isEmpty ? null : pathHashes.first; +} + +class ResolvedNodeHash { + final int hash; + final String label; + final bool isOwnNode; + final bool isUniqueMatch; + final int matchCount; + + const ResolvedNodeHash({ + required this.hash, + required this.label, + required this.isOwnNode, + required this.isUniqueMatch, + required this.matchCount, + }); + + String get hexLabel => '0x${hash.toRadixString(16).padLeft(2, '0')}'; +} + +class LogRxRouteDecoder { + const LogRxRouteDecoder._(); + + static DecodedLogRxRoute? decode(Uint8List rawData) { + if (rawData.length < 5 || rawData[0] != 0x88) return null; + + final rawPacketData = rawData.sublist(3); + if (rawPacketData.length < 2) return null; + + final header = rawPacketData[0]; + final routeType = header & 0x03; + final payloadType = (header >> 2) & 0x0F; + + var index = 1; + if (routeType == 0x00 || routeType == 0x03) { + if (rawPacketData.length < index + 5) return null; + index += 4; + } + + if (rawPacketData.length <= index) return null; + final pathLen = rawPacketData[index++]; + if (rawPacketData.length < index + pathLen) return null; + + return DecodedLogRxRoute( + payloadType: payloadType, + pathHashes: rawPacketData.sublist(index, index + pathLen), + ); + } + + static ResolvedNodeHash resolveHash( + int hash, { + required Iterable contacts, + Uint8List? ownPublicKey, + String? ownName, + }) { + final ownHash = ownPublicKey != null && ownPublicKey.isNotEmpty + ? ownPublicKey.first + : null; + if (ownHash == hash) { + final ownLabel = (ownName != null && ownName.trim().isNotEmpty) + ? '$ownName (you)' + : 'You'; + return ResolvedNodeHash( + hash: hash, + label: ownLabel, + isOwnNode: true, + isUniqueMatch: true, + matchCount: 1, + ); + } + + final matches = contacts.where((contact) { + return contact.publicKey.isNotEmpty && contact.publicKey.first == hash; + }).toList(); + + if (matches.isEmpty) { + return ResolvedNodeHash( + hash: hash, + label: 'Unknown', + isOwnNode: false, + isUniqueMatch: false, + matchCount: 0, + ); + } + + if (matches.length == 1) { + return ResolvedNodeHash( + hash: hash, + label: matches.first.displayName, + isOwnNode: false, + isUniqueMatch: true, + matchCount: 1, + ); + } + + final candidateNames = matches + .map((contact) => contact.displayName) + .where((name) => name.trim().isNotEmpty) + .take(2) + .join(', '); + final extraCount = matches.length - 2; + final label = candidateNames.isEmpty + ? '${matches.length} contacts' + : extraCount > 0 + ? '$candidateNames +$extraCount' + : candidateNames; + return ResolvedNodeHash( + hash: hash, + label: label, + isOwnNode: false, + isUniqueMatch: false, + matchCount: matches.length, + ); + } +} diff --git a/lib/utils/message_airtime_estimator.dart b/lib/utils/message_airtime_estimator.dart new file mode 100644 index 0000000..01c9f78 --- /dev/null +++ b/lib/utils/message_airtime_estimator.dart @@ -0,0 +1,149 @@ +import '../models/message.dart'; +import 'image_message_parser.dart'; +import 'voice_message_parser.dart'; + +const int _defaultLoRaSf = 10; +const int _defaultLoRaCr = 5; +const int _defaultLoRaBwHz = 250000; +const int _defaultLoRaPreambleSymbols = 8; +const int _defaultLoRaCrcEnabled = 1; +const int _defaultLoRaExplicitHeader = 1; +const double _defaultAirtimeBudgetFactor = 1.0; +const int _meshPacketHeaderBytes = 2; +const int _textFrameBaseBytes = 10; + +Duration estimateMessageTransmitDuration( + Message message, { + int? radioBw, + int? radioSf, + int? radioCr, +}) { + final imageEnvelope = ImageEnvelope.tryParse(message.text); + if (imageEnvelope != null) { + return estimateImageTransmitDuration( + fragmentCount: imageEnvelope.total, + sizeBytes: imageEnvelope.sizeBytes, + pathLen: message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + } + + final voiceEnvelope = VoiceEnvelope.tryParseText(message.text); + if (voiceEnvelope != null) { + return estimateVoiceTransmitDuration( + mode: voiceEnvelope.mode, + packetCount: voiceEnvelope.total, + durationMs: voiceEnvelope.durationMs, + pathLen: message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + } + + final voicePacket = VoicePacket.tryParseText(message.text); + if (voicePacket != null) { + return estimateVoiceTransmitDuration( + mode: voicePacket.mode, + packetCount: voicePacket.total, + durationMs: voicePacket.durationMs * voicePacket.total, + pathLen: message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + } + + final normalizedPathLen = _normalizedPathLen(message.pathLen); + final payloadBytes = _textFrameBaseBytes + message.text.length; + final hops = normalizedPathLen + 1; + final airtimeMs = _estimateLoRaAirtimeMs( + _meshPacketHeaderBytes + normalizedPathLen + payloadBytes, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + + return Duration( + milliseconds: (airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops) + .round(), + ); +} + +int _normalizedPathLen(int pathLen) { + if (pathLen < 0 || pathLen >= 255) return 0; + return pathLen.clamp(0, 64).toInt(); +} + +double _estimateLoRaAirtimeMs( + int payloadLenBytes, { + int? radioBw, + int? radioSf, + int? radioCr, +}) { + final sf = _normalizeSf(radioSf); + final bw = _resolveBandwidthHz(radioBw).toDouble(); + final cr = (_normalizeCr(radioCr) - 4).clamp(1, 4); + final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1; + final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0; + + final symbolMs = ((1 << sf) / bw) * 1000.0; + final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs; + + final num = + (8 * payloadLenBytes) - + (4 * sf) + + 28 + + (16 * _defaultLoRaCrcEnabled) - + (20 * ih); + final den = 4 * (sf - (2 * de)); + final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil(); + final payloadSymbols = + 8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4); + final payloadMs = payloadSymbols * symbolMs; + + return preambleMs + payloadMs; +} + +int _normalizeSf(int? value) { + if (value == null) return _defaultLoRaSf; + if (value >= 5 && value <= 12) return value; + return _defaultLoRaSf; +} + +int _normalizeCr(int? value) { + if (value == null) return _defaultLoRaCr; + if (value >= 5 && value <= 8) return value; + return _defaultLoRaCr; +} + +int _resolveBandwidthHz(int? rawBw) { + if (rawBw == null) return _defaultLoRaBwHz; + if (rawBw > 1000) return rawBw; + switch (rawBw) { + case 0: + return 7800; + case 1: + return 10400; + case 2: + return 15600; + case 3: + return 20800; + case 4: + return 31250; + case 5: + return 41700; + case 6: + return 62500; + case 7: + return 125000; + case 8: + return 250000; + case 9: + return 500000; + default: + return _defaultLoRaBwHz; + } +} diff --git a/lib/utils/raw_route_probe.dart b/lib/utils/raw_route_probe.dart new file mode 100644 index 0000000..4782a06 --- /dev/null +++ b/lib/utils/raw_route_probe.dart @@ -0,0 +1,87 @@ +import 'dart:typed_data'; + +class RawRouteProbeRequest { + static const int _binaryMagic = 0x70; // 'p' + + final int nonce; + final String requesterKey6; + + const RawRouteProbeRequest({ + required this.nonce, + required this.requesterKey6, + }); + + static RawRouteProbeRequest? tryParseBinary(Uint8List payload) { + if (payload.length != 11 || payload[0] != _binaryMagic) return null; + try { + final nonce = + (payload[1] << 24) | + (payload[2] << 16) | + (payload[3] << 8) | + payload[4]; + final requesterKey6 = payload + .sublist(5, 11) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + return RawRouteProbeRequest(nonce: nonce, requesterKey6: requesterKey6); + } catch (_) { + return null; + } + } + + Uint8List encodeBinary() { + if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) { + throw ArgumentError.value( + requesterKey6, + 'requesterKey6', + 'Expected 12 hex chars', + ); + } + final out = Uint8List(11); + out[0] = _binaryMagic; + out[1] = (nonce >> 24) & 0xFF; + out[2] = (nonce >> 16) & 0xFF; + out[3] = (nonce >> 8) & 0xFF; + out[4] = nonce & 0xFF; + for (var i = 0; i < 6; i++) { + out[5 + i] = int.parse( + requesterKey6.substring(i * 2, i * 2 + 2), + radix: 16, + ); + } + return out; + } +} + +class RawRouteProbeAck { + static const int _binaryMagic = 0x71; // 'q' + + final int nonce; + + const RawRouteProbeAck({required this.nonce}); + + static RawRouteProbeAck? tryParseBinary(Uint8List payload) { + if (payload.length != 5 || payload[0] != _binaryMagic) return null; + try { + final nonce = + (payload[1] << 24) | + (payload[2] << 16) | + (payload[3] << 8) | + payload[4]; + return RawRouteProbeAck(nonce: nonce); + } catch (_) { + return null; + } + } + + Uint8List encodeBinary() { + final out = Uint8List(5); + out[0] = _binaryMagic; + out[1] = (nonce >> 24) & 0xFF; + out[2] = (nonce >> 16) & 0xFF; + out[3] = (nonce >> 8) & 0xFF; + out[4] = nonce & 0xFF; + return out; + } +} diff --git a/lib/utils/transmission_target_resolver.dart b/lib/utils/transmission_target_resolver.dart index 61354b1..3757801 100644 --- a/lib/utils/transmission_target_resolver.dart +++ b/lib/utils/transmission_target_resolver.dart @@ -3,7 +3,7 @@ import 'dart:typed_data'; import '../models/contact.dart'; import '../providers/contacts_provider.dart'; -enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar } +enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar, unreachable } class TransmissionTargetResolution { final Contact? target; diff --git a/lib/widgets/connection_dialog.dart b/lib/widgets/connection_dialog.dart index 56442a9..c5a25c5 100644 --- a/lib/widgets/connection_dialog.dart +++ b/lib/widgets/connection_dialog.dart @@ -21,10 +21,19 @@ class _ConnectionDialogState extends State final List _discoveredServers = []; int _scannedCount = 0; int _totalToScan = 0; - String? _connectingToServerKey; // Track which server is being connected to (ip:port) + int _lastTabIndex = 0; + String? + _connectingToServerKey; // Track which server is being connected to (ip:port) // Named listener method for proper cleanup void _onTabChanged() { + if (_tabController.index == _lastTabIndex) return; + _lastTabIndex = _tabController.index; + + if (_tabController.index == 0) { + _refreshBleDevices(); + } + if (_tabController.index == 1) { // Switched to network tab if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) { @@ -47,13 +56,16 @@ class _ConnectionDialogState extends State void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); - _connectionProvider = Provider.of(context, listen: false); + _connectionProvider = Provider.of( + context, + listen: false, + ); // Defer scan startup until after the first frame so Provider listeners // are not notified while this dialog is still being built. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - _connectionProvider.startScan(); + _refreshBleDevices(); }); // Set up network scanner callbacks @@ -101,6 +113,12 @@ class _ConnectionDialogState extends State _networkScanner.scan(); } + Future _refreshBleDevices() async { + await _connectionProvider.stopScan(); + if (!mounted) return; + await _connectionProvider.startScan(); + } + Color _getSignalColor(int rssi) { if (rssi >= -60) return Colors.green; if (rssi >= -75) return Colors.orange; @@ -218,10 +236,7 @@ class _ConnectionDialogState extends State Icons.refresh, color: Theme.of(context).colorScheme.onPrimaryContainer, ), - onPressed: () { - connectionProvider.stopScan(); - connectionProvider.startScan(); - }, + onPressed: _refreshBleDevices, ), ], ), @@ -255,10 +270,7 @@ class _ConnectionDialogState extends State ), const SizedBox(height: 8), TextButton.icon( - onPressed: () { - connectionProvider.stopScan(); - connectionProvider.startScan(); - }, + onPressed: _refreshBleDevices, icon: const Icon(Icons.refresh), label: Text(AppLocalizations.of(context)!.scanAgain), ), diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 15bef69..d6adb1a 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -70,6 +70,9 @@ class ContactTile extends StatelessWidget { // Get room login state if this is a room final connectionProvider = context.watch(); + final isPingInProgress = connectionProvider.isPingInProgress( + contact.publicKey, + ); final roomLoginState = contact.type == ContactType.room ? connectionProvider.getRoomLoginState(contact.publicKeyPrefix) : null; @@ -188,6 +191,17 @@ class ContactTile extends StatelessWidget { ), ), ], + if (isPingInProgress) ...[ + const SizedBox(width: 6), + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], ], ), subtitle: isSimpleMode @@ -458,39 +472,43 @@ class ContactTile extends StatelessWidget { _showContactDetails(context, contact); } }, - onLongPress: () async { - final connectionProvider = context.read(); + onLongPress: isPingInProgress + ? null + : () async { + final connectionProvider = context.read(); - // Determine if we should use flooding (no path) or direct (has path) - final hasPath = contact.hasPath; + // Determine if we should use flooding (no path) or direct (has path) + final hasPath = contact.hasPath; - // Use smart ping with automatic fallback - final result = await connectionProvider.smartPing( - contactPublicKey: contact.publicKey, - hasPath: hasPath, - onRetryWithFlooding: () { - // Called when retrying with flooding after direct timeout - if (context.mounted) { - ToastLogger.warning( - context, - AppLocalizations.of( - context, - )!.directPingTimeout(contact.displayName), + // Use smart ping with automatic fallback + final result = await connectionProvider.smartPing( + contactPublicKey: contact.publicKey, + hasPath: hasPath, + onRetryWithFlooding: () { + // Called when retrying with flooding after direct timeout + if (context.mounted) { + ToastLogger.warning( + context, + AppLocalizations.of( + context, + )!.directPingTimeout(contact.displayName), + ); + } + }, ); - } - }, - ); - // Show final result - if (context.mounted) { - if (!result.success) { - ToastLogger.error( - context, - AppLocalizations.of(context)!.pingFailed(contact.displayName), - ); - } - } - }, + // Show final result + if (context.mounted) { + if (!result.success) { + ToastLogger.error( + context, + AppLocalizations.of( + context, + )!.pingFailed(contact.displayName), + ); + } + } + }, ), ); } @@ -603,8 +621,12 @@ class ContactTile extends StatelessWidget { minChildSize: 0.4, maxChildSize: 0.9, expand: false, - builder: (context, scrollController) => Column( - children: [ + builder: (context, scrollController) { + final isPingInProgress = context + .watch() + .isPingInProgress(contact.publicKey); + return Column( + children: [ // Handle bar Container( margin: const EdgeInsets.only(top: 8, bottom: 16), @@ -847,15 +869,25 @@ class ContactTile extends StatelessWidget { ), ), TextButton.icon( - onPressed: () { - final connectionProvider = context - .read(); - connectionProvider.requestTelemetry( - contact.publicKey, - zeroHop: true, - ); - }, - icon: const Icon(Icons.refresh, size: 18), + onPressed: isPingInProgress + ? null + : () { + final connectionProvider = context + .read(); + connectionProvider.requestTelemetry( + contact.publicKey, + zeroHop: true, + ); + }, + icon: isPingInProgress + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.refresh, size: 18), label: Text(AppLocalizations.of(context)!.refresh), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( @@ -991,8 +1023,9 @@ class ContactTile extends StatelessWidget { ], ), ), - ], - ), + ], + ); + }, ), ); } diff --git a/lib/widgets/messages/image_message_bubble.dart b/lib/widgets/messages/image_message_bubble.dart index 1f8d946..fe17b38 100644 --- a/lib/widgets/messages/image_message_bubble.dart +++ b/lib/widgets/messages/image_message_bubble.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_avif/flutter_avif.dart'; import 'package:provider/provider.dart'; import '../../models/message.dart'; +import '../../providers/app_provider.dart'; import '../../providers/connection_provider.dart'; import '../../providers/contacts_provider.dart'; import '../../providers/image_provider.dart' as ip; @@ -254,11 +255,17 @@ class _ImageMessageBubbleState extends State { int pathLen = 0, }) async { if (_isRequesting) return; + setState(() { + _isRequesting = true; + _errorText = null; + }); + final conn = context.read(); final imageProvider = context.read(); imageProvider.resumeIncomingSession(envelope.sessionId); final contactsProvider = context.read(); - final resolution = await TransmissionTargetResolver.resolveFetchTarget( + final appProvider = context.read(); + var resolution = await TransmissionTargetResolver.resolveFetchTarget( contactsProvider: contactsProvider, refreshContacts: conn.getContacts, isSentByMe: widget.isSentByMe, @@ -271,6 +278,7 @@ class _ImageMessageBubbleState extends State { if (!mounted) return; if (resolution.failure == TransmissionTargetFailure.unknownContact) { + _clearRequestState(); await _showBlockingAlert( 'Cannot fetch image', 'Sender contact is unknown. Sync contacts first.', @@ -278,6 +286,7 @@ class _ImageMessageBubbleState extends State { return; } if (resolution.failure == TransmissionTargetFailure.unknownRoute) { + _clearRequestState(); await _showBlockingAlert( 'Cannot fetch image', 'Sender route is unknown. Sync contacts/path first.', @@ -285,14 +294,76 @@ class _ImageMessageBubbleState extends State { return; } if (resolution.failure == TransmissionTargetFailure.tooFar) { + _clearRequestState(); await _showBlockingAlert( 'Cannot fetch image', 'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).', ); return; } + if (resolution.failure == TransmissionTargetFailure.unreachable) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch image', + 'Sender route did not respond to a path check. Sync contacts/path and try again.', + ); + return; + } + + var sender = resolution.target!; + var routeVerified = await appProvider.verifyRawTransportRoute(sender); + if (!mounted) return; + if (!routeVerified) { + await conn.getContacts(); + if (!mounted) return; + resolution = await TransmissionTargetResolver.resolveFetchTarget( + contactsProvider: contactsProvider, + refreshContacts: conn.getContacts, + isSentByMe: widget.isSentByMe, + recipientPublicKey: widget.message.recipientPublicKey, + senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, + senderKey6FromEnvelope: envelope.senderKey6, + senderName: widget.message.senderName, + maxFetchHops: _maxFetchHops, + ); + if (!mounted) return; + if (resolution.failure == TransmissionTargetFailure.unknownContact) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch image', + 'Sender contact is unknown. Sync contacts first.', + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.unknownRoute) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch image', + 'Sender route is unknown. Sync contacts/path first.', + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.tooFar) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch image', + 'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).', + ); + return; + } + sender = resolution.target!; + routeVerified = await appProvider.verifyRawTransportRoute(sender); + if (!mounted) return; + if (!routeVerified) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch image', + 'Sender route did not respond on the raw transport path.', + ); + return; + } + } - final sender = resolution.target!; if (sender.outPathLen >= 2) { _showToast( 'Image fetch over ${sender.outPathLen} hops may take a while.', @@ -302,6 +373,7 @@ class _ImageMessageBubbleState extends State { setState(() => _errorText = null); final deviceKey = conn.deviceInfo.publicKey; if (deviceKey == null || deviceKey.length < 6) { + _clearRequestState(); await _showBlockingAlert( 'Cannot fetch image', 'Device key is unavailable.', @@ -332,11 +404,6 @@ class _ImageMessageBubbleState extends State { timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, ); - setState(() { - _isRequesting = true; - _errorText = null; - }); - final payload = request.encodeBinary(); try { await conn.sendRawVoicePacket( @@ -405,6 +472,13 @@ class _ImageMessageBubbleState extends State { }); } + void _clearRequestState() { + if (!mounted) return; + setState(() { + _isRequesting = false; + }); + } + Future _showBlockingAlert(String title, String message) async { if (!mounted) return; _showToast('$title: $message'); diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index 09a3637..8d30ea8 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -23,15 +23,16 @@ import '../../utils/key_comparison.dart'; import '../../utils/voice_message_parser.dart'; import '../../utils/image_message_parser.dart'; import '../../utils/tictactoe_message_parser.dart'; -import '../../utils/avatar_label_helper.dart'; import '../../utils/location_formats.dart'; import '../../l10n/app_localizations.dart'; import '../../utils/message_extensions.dart'; -import '../common/contact_avatar.dart'; import 'voice_message_bubble.dart'; import 'image_message_bubble.dart'; import 'tictactoe_message_bubble.dart'; import 'message_trace_sheet.dart'; +import 'message_bubble_header.dart'; +import 'message_bubble_signal.dart'; +import 'system_message_bubble.dart'; /// Reusable message bubble widget that displays messages with various types: /// - Regular text messages (channel or direct) @@ -65,104 +66,6 @@ class _MessageBubbleState extends State { bool _isExpanded = false; bool _showReceivedStats = false; - Widget _buildHeaderAvatar( - BuildContext context, { - required bool isOwnMessage, - required bool isChannelMessage, - required dynamic senderContact, - required String displayName, - }) { - if (isOwnMessage) { - return CircleAvatar( - radius: 10.5, - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - child: Icon( - Icons.account_circle, - size: 16, - color: Theme.of(context).colorScheme.primary, - ), - ); - } - - if (senderContact is Contact) { - return ContactAvatar( - contact: senderContact, - radius: 10.5, - displayName: displayName, - ); - } - - final background = isChannelMessage - ? Colors.teal.withValues(alpha: 0.16) - : Theme.of(context).colorScheme.surfaceContainerHighest; - final foreground = isChannelMessage - ? Colors.teal.shade800 - : Theme.of(context).colorScheme.onSurfaceVariant; - - return CircleAvatar( - radius: 10.5, - backgroundColor: background, - child: Text( - AvatarLabelHelper.buildLabel(displayName), - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w700, - color: foreground, - letterSpacing: -0.2, - ), - ), - ); - } - - Widget _buildBubbleMetaFooter( - BuildContext context, { - required Message message, - required bool isSarMarker, - }) { - final metaColor = Theme.of( - context, - ).textTheme.labelSmall?.color?.withValues(alpha: 0.68); - - final items = []; - - if (!isSarMarker && message.pathLen < 255) { - items.addAll([ - Icon(Icons.alt_route, size: 11, color: metaColor), - const SizedBox(width: 3), - Text( - message.pathLen == 0 ? 'direct' : '${message.pathLen}hop', - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: metaColor), - ), - Text( - ' • ', - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: metaColor), - ), - ]); - } - - items.add( - Text( - message.getLocalizedTimeAgo(context), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: metaColor, - fontWeight: FontWeight.w500, - ), - ), - ); - - return Padding( - padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18), - child: Align( - alignment: Alignment.centerRight, - child: Row(mainAxisSize: MainAxisSize.min, children: items), - ), - ); - } - @override void didUpdateWidget(MessageBubble oldWidget) { super.didUpdateWidget(oldWidget); @@ -508,6 +411,9 @@ class _MessageBubbleState extends State { final senderLocationSnapshot = messagesProvider.getMessageContactLocation( widget.message.id, ); + final receptionDetails = messagesProvider.getMessageReceptionDetails( + widget.message.id, + ); final envelope = VoiceEnvelope.tryParseText(widget.message.text); final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text); @@ -572,16 +478,19 @@ class _MessageBubbleState extends State { widget.message, ); final packetPathBytes = _extractPathBytesFromLog(matchedRxLog); - final packetPathHex = packetPathBytes + final packetPathHex = (receptionDetails?.pathBytes ?? packetPathBytes) ?.map((b) => b.toRadixString(16).padLeft(2, '0')) .join(':'); final snrDb = + receptionDetails?.snrDb ?? matchedRxLog?.logRxDataInfo?.snrDb ?? (widget.message.lastEchoSnrRaw != null ? (widget.message.lastEchoSnrRaw!.toSigned(8) / 4.0) : null); final rssiDbm = - matchedRxLog?.logRxDataInfo?.rssiDbm ?? widget.message.lastEchoRssiDbm; + receptionDetails?.rssiDbm ?? + matchedRxLog?.logRxDataInfo?.rssiDbm ?? + widget.message.lastEchoRssiDbm; final retryCause = _retryCauseLabel(widget.message); final retryResult = _retryResultLabel(widget.message); final retryMode = _retryModeLabel(widget.message); @@ -604,6 +513,9 @@ class _MessageBubbleState extends State { 'Matched RX RSSI: ${rssiDbm ?? '-'}', 'Matched RX SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}', 'Matched path bytes: ${packetPathHex ?? '-'}', + 'Sender to receipt ms: ${receptionDetails?.senderToReceiptMs ?? '-'}', + 'Estimated transmit ms: ${receptionDetails?.estimatedTransmitMs ?? '-'}', + 'Post-transmit delay ms: ${receptionDetails?.postTransmitDelayMs ?? '-'}', 'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}', 'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}', 'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}', @@ -767,7 +679,7 @@ class _MessageBubbleState extends State { _techBadge( context, icon: Icons.route, - label: _hopDisplayLabel(widget.message), + label: hopDisplayLabel(widget.message), ), _techBadge( context, @@ -855,6 +767,30 @@ class _MessageBubbleState extends State { value: widget.message.expectedAckTag! .toString(), ), + if (receptionDetails?.senderToReceiptMs != null) + _detailRow( + context, + label: 'Sender to receipt', + value: _formatDurationMs( + receptionDetails!.senderToReceiptMs!, + ), + ), + if (receptionDetails?.estimatedTransmitMs != null) + _detailRow( + context, + label: 'Estimated tx', + value: _formatDurationMs( + receptionDetails!.estimatedTransmitMs!, + ), + ), + if (receptionDetails?.postTransmitDelayMs != null) + _detailRow( + context, + label: 'Post-tx delay', + value: _formatDurationMs( + receptionDetails!.postTransmitDelayMs!, + ), + ), if (widget.message.suggestedTimeoutMs != null) _detailRow( context, @@ -1263,6 +1199,18 @@ class _MessageBubbleState extends State { '${fraction}Z'; } + String _formatDurationMs(int durationMs) { + if (durationMs >= 60000) { + final minutes = durationMs ~/ 60000; + final seconds = (durationMs % 60000) ~/ 1000; + return '${minutes}m ${seconds}s'; + } + if (durationMs >= 1000) { + return '${(durationMs / 1000).toStringAsFixed(durationMs >= 10000 ? 0 : 1)} s'; + } + return '$durationMs ms'; + } + BlePacketLog? _findBestMatchingRxLog( List logs, Message message, @@ -1591,225 +1539,12 @@ class _MessageBubbleState extends State { } } - IconData _getDeliveryStatusIcon(MessageDeliveryStatus status) { - switch (status) { - case MessageDeliveryStatus.sending: - return Icons.schedule; - case MessageDeliveryStatus.sent: - return Icons.check; - case MessageDeliveryStatus.delivered: - return Icons.done_all; - case MessageDeliveryStatus.failed: - return Icons.error_outline; - case MessageDeliveryStatus.received: - return Icons.inbox; - } - } - - Color _getDeliveryStatusColor(MessageDeliveryStatus status) { - switch (status) { - case MessageDeliveryStatus.sending: - return Colors.orange; - case MessageDeliveryStatus.sent: - return Colors.blue; - case MessageDeliveryStatus.delivered: - return Colors.green; - case MessageDeliveryStatus.failed: - return Colors.red; - case MessageDeliveryStatus.received: - return Colors.grey; - } - } - - Widget _buildChannelEchoStatus(BuildContext context, Message message) { - final hasEcho = message.echoCount > 0; - - if (!hasEcho) { - return const SizedBox.shrink(); - } - - final statusColor = _getDeliveryStatusColor(message.deliveryStatus); - final rssi = message.lastEchoRssiDbm; - final snr = message.lastEchoSnrRaw != null - ? message.lastEchoSnrRaw!.toSigned(8) / 4.0 - : null; - final quality = _linkQualityLabel(rssi, snr); - final qualityColor = _linkQualityColor(quality); - - return Wrap( - spacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _techChip( - context, - icon: Icons.hub_outlined, - label: 'x${message.echoCount}', - color: statusColor, - ), - if (message.expectedAckTag != null) - _techChip( - context, - icon: Icons.tag, - label: - 'ACK ${message.expectedAckTag!.toRadixString(16).toUpperCase()}', - color: Colors.indigo, - ), - _techChip( - context, - icon: Icons.bolt, - label: quality, - color: qualityColor, - ), - if (message.lastEchoRssiDbm != null) - _signalCapsule( - context, - icon: Icons.network_cell, - label: message.lastEchoRssiDbm!.toString(), - filled: _rssiScore(message.lastEchoRssiDbm!), - color: Colors.blueGrey, - ), - if (message.lastEchoSnrRaw != null) - _signalCapsule( - context, - icon: Icons.graphic_eq, - label: (message.lastEchoSnrRaw!.toSigned(8) / 4.0).toStringAsFixed( - 1, - ), - filled: _snrScore(message.lastEchoSnrRaw!.toSigned(8) / 4.0), - color: Colors.teal, - ), - ], - ); - } - - bool _shouldShowSentChannelStats(Message message) { - if (!message.isSentMessage || !message.isChannelMessage) { - return false; - } - - final hasSignalData = - message.echoCount > 0 || - message.lastEchoRssiDbm != null || - message.lastEchoSnrRaw != null || - message.expectedAckTag != null; - return _showReceivedStats && hasSignalData; - } - - Widget _buildReceivedSignalStatus( - BuildContext context, - Message message, { - required int? rssiDbm, - required double? snrDb, - }) { - final hopLabel = _hopDisplayLabel(message); - - return Wrap( - spacing: 4, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _techChip( - context, - icon: Icons.alt_route, - label: hopLabel, - color: Colors.indigo, - ), - if (rssiDbm != null || snrDb != null) ...[ - _techChip( - context, - icon: Icons.bolt, - label: _linkQualityLabel(rssiDbm, snrDb), - color: _linkQualityColor(_linkQualityLabel(rssiDbm, snrDb)), - ), - if (rssiDbm != null) - _signalCapsule( - context, - icon: Icons.network_cell, - label: '$rssiDbm', - filled: _rssiScore(rssiDbm), - color: Colors.blueGrey, - ), - if (snrDb != null) - _signalCapsule( - context, - icon: Icons.graphic_eq, - label: snrDb.toStringAsFixed(1), - filled: _snrScore(snrDb), - color: Colors.teal, - ), - ], - ], - ); - } - - String _hopDisplayLabel(Message message) { - if (message.pathLen == 0) return 'Direct'; - if (message.pathLen >= 255 && message.isContactMessage) return 'Direct'; - if (message.pathLen >= 255) return 'Unknown'; - return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}'; - } - - Widget _buildChannelHeaderPill( - BuildContext context, { - required String label, - IconData icon = Icons.campaign_outlined, - }) { - final labelColor = Theme.of( - context, - ).textTheme.labelSmall?.color?.withValues(alpha: 0.82); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.65), - borderRadius: BorderRadius.circular(999), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: 11, - color: Theme.of( - context, - ).textTheme.labelSmall?.color?.withValues(alpha: 0.7), - ), - const SizedBox(width: 5), - Flexible( - child: Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: labelColor, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } - - Widget _buildDirectHeaderCounterpart( - BuildContext context, { - required String label, - }) { - return _buildChannelHeaderPill( - context, - label: label, - icon: Icons.alternate_email, - ); - } - String _hopDebugLabel(Message message) { if (message.pathLen >= 255 && message.isContactMessage) { return 'Direct (raw: ${message.pathLen})'; } if (message.pathLen >= 255) return 'Unknown (raw: ${message.pathLen})'; - return _hopDisplayLabel(message); + return hopDisplayLabel(message); } String? _retryCauseLabel(Message message) { @@ -1891,110 +1626,6 @@ class _MessageBubbleState extends State { return null; } - Widget _techChip( - BuildContext context, { - required IconData icon, - required String label, - required Color color, - }) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 10, color: color), - const SizedBox(width: 2), - Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: color, - fontWeight: FontWeight.w600, - fontSize: 10, - ), - ), - ], - ), - ); - } - - Widget _signalCapsule( - BuildContext context, { - required IconData icon, - required String label, - required int filled, - required Color color, - }) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 10, color: color), - const SizedBox(width: 2), - Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(5, (i) { - final active = i < filled; - return Container( - width: 3, - height: (4 + i).toDouble(), - margin: const EdgeInsets.symmetric(horizontal: 0.5), - decoration: BoxDecoration( - color: active ? color : color.withValues(alpha: 0.18), - borderRadius: BorderRadius.circular(1), - ), - ); - }), - ), - const SizedBox(width: 2), - Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: color, - fontWeight: FontWeight.w600, - fontSize: 10, - ), - ), - ], - ), - ); - } - - int _rssiScore(int rssiDbm) => ((rssiDbm + 120) / 10).round().clamp(0, 5); - - int _snrScore(double snrDb) => ((snrDb + 5.0) / 5.0).round().clamp(0, 5); - - String _linkQualityLabel(int? rssiDbm, double? snrDb) { - var score = 0; - if (rssiDbm != null) score += _rssiScore(rssiDbm); - if (snrDb != null) score += _snrScore(snrDb); - if (score >= 8) return 'Excellent'; - if (score >= 6) return 'Good'; - if (score >= 4) return 'Fair'; - return 'Weak'; - } - - Color _linkQualityColor(String quality) { - switch (quality) { - case 'Excellent': - return Colors.green; - case 'Good': - return Colors.lightGreen; - case 'Fair': - return Colors.orange; - default: - return Colors.redAccent; - } - } - @override Widget build(BuildContext context) { // Display system messages with minimal styling @@ -2015,9 +1646,13 @@ class _MessageBubbleState extends State { // Determine if this is own message final connectionProvider = context.read(); + final messagesProvider = context.read(); final selfPublicKey = connectionProvider.deviceInfo.publicKey; final isOwnMessage = message.isSentMessage || message.isFromSelf(selfPublicKey); + final receptionDetails = !isOwnMessage + ? messagesProvider.getMessageReceptionDetails(message.id) + : null; final matchedRxLog = !isOwnMessage ? _findBestMatchingRxLog( connectionProvider.bleService.packetLogs, @@ -2025,12 +1660,15 @@ class _MessageBubbleState extends State { ) : null; final snrDb = + receptionDetails?.snrDb ?? matchedRxLog?.logRxDataInfo?.snrDb ?? (message.lastEchoSnrRaw != null ? (message.lastEchoSnrRaw!.toSigned(8) / 4.0) : null); final rssiDbm = - matchedRxLog?.logRxDataInfo?.rssiDbm ?? message.lastEchoRssiDbm; + receptionDetails?.rssiDbm ?? + matchedRxLog?.logRxDataInfo?.rssiDbm ?? + message.lastEchoRssiDbm; // Look up contact information for rich display name final contactsProvider = context.read(); @@ -2297,7 +1935,7 @@ class _MessageBubbleState extends State { shape: BoxShape.circle, ), ), - _buildHeaderAvatar( + buildMessageHeaderAvatar( context, isOwnMessage: isOwnMessage, isChannelMessage: message.isChannelMessage, @@ -2330,7 +1968,7 @@ class _MessageBubbleState extends State { if (message.isChannelMessage) Align( alignment: Alignment.centerRight, - child: _buildChannelHeaderPill( + child: buildChannelHeaderPill( context, label: isOwnMessage ? recipientDisplayName! @@ -2340,7 +1978,7 @@ class _MessageBubbleState extends State { else Align( alignment: Alignment.centerRight, - child: _buildDirectHeaderCounterpart( + child: buildDirectHeaderCounterpart( context, label: directCounterpartLabel!, ), @@ -2636,9 +2274,10 @@ class _MessageBubbleState extends State { !message.isSentMessage && _showReceivedStats) ...[ const SizedBox(height: 6), - _buildReceivedSignalStatus( + buildReceivedSignalStatus( context, message, + receptionDetails: receptionDetails, rssiDbm: rssiDbm, snrDb: snrDb, ), @@ -2830,9 +2469,9 @@ class _MessageBubbleState extends State { mainAxisSize: MainAxisSize.max, children: [ Icon( - _getDeliveryStatusIcon(message.deliveryStatus), + getDeliveryStatusIcon(message.deliveryStatus), size: 12, - color: _getDeliveryStatusColor(message.deliveryStatus), + color: getDeliveryStatusColor(message.deliveryStatus), ), const SizedBox(width: 3), Expanded( @@ -2844,7 +2483,7 @@ class _MessageBubbleState extends State { overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall ?.copyWith( - color: _getDeliveryStatusColor( + color: getDeliveryStatusColor( message.deliveryStatus, ), fontStyle: FontStyle.italic, @@ -2895,9 +2534,12 @@ class _MessageBubbleState extends State { ], ], ), - if (_shouldShowSentChannelStats(message)) ...[ + if (shouldShowSentChannelStats( + message, + showReceivedStats: _showReceivedStats, + )) ...[ const SizedBox(height: 6), - _buildChannelEchoStatus(context, message), + buildChannelEchoStatus(context, message), ], ], ], @@ -2912,7 +2554,7 @@ class _MessageBubbleState extends State { : CrossAxisAlignment.start, children: [ bubble, - _buildBubbleMetaFooter( + buildBubbleMetaFooter( context, message: message, isSarMarker: isSarMarker, @@ -2932,85 +2574,3 @@ class _MessageBubbleState extends State { ); } } - -/// System message bubble - compact log-style display -class SystemMessageBubble extends StatelessWidget { - final Message message; - - const SystemMessageBubble({super.key, required this.message}); - - Color _getLevelColor(String? level) { - switch (level?.toLowerCase()) { - case 'success': - return Colors.green; - case 'warning': - return Colors.orange; - case 'error': - return Colors.red; - case 'info': - default: - return Colors.blue.shade300; - } - } - - IconData _getLevelIcon(String? level) { - switch (level?.toLowerCase()) { - case 'success': - return Icons.check_circle_outline; - case 'warning': - return Icons.warning_amber_outlined; - case 'error': - return Icons.error_outline; - case 'info': - default: - return Icons.info_outline; - } - } - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - final level = message.senderName ?? 'info'; - final levelColor = _getLevelColor(level); - - return Container( - margin: const EdgeInsets.only(bottom: 2), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: isDarkMode - ? levelColor.withValues(alpha: 0.1) - : levelColor.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(4), - ), - child: Row( - children: [ - Icon(_getLevelIcon(level), size: 14, color: levelColor), - const SizedBox(width: 6), - Text( - message.getLocalizedTimeAgo(context), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of( - context, - ).textTheme.bodySmall?.color?.withValues(alpha: 0.6), - fontSize: 10, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - message.text, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontSize: 11, - color: Theme.of( - context, - ).textTheme.bodySmall?.color?.withValues(alpha: 0.8), - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } -} diff --git a/lib/widgets/messages/message_bubble_header.dart b/lib/widgets/messages/message_bubble_header.dart new file mode 100644 index 0000000..f71b6f0 --- /dev/null +++ b/lib/widgets/messages/message_bubble_header.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; + +import '../../models/contact.dart'; +import '../../models/message.dart'; +import '../../utils/avatar_label_helper.dart'; +import '../../utils/message_extensions.dart'; +import '../common/contact_avatar.dart'; + +Widget buildMessageHeaderAvatar( + BuildContext context, { + required bool isOwnMessage, + required bool isChannelMessage, + required dynamic senderContact, + required String displayName, +}) { + if (isOwnMessage) { + return CircleAvatar( + radius: 10.5, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Icon( + Icons.account_circle, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ); + } + + if (senderContact is Contact) { + return ContactAvatar( + contact: senderContact, + radius: 10.5, + displayName: displayName, + ); + } + + final background = isChannelMessage + ? Colors.teal.withValues(alpha: 0.16) + : Theme.of(context).colorScheme.surfaceContainerHighest; + final foreground = isChannelMessage + ? Colors.teal.shade800 + : Theme.of(context).colorScheme.onSurfaceVariant; + + return CircleAvatar( + radius: 10.5, + backgroundColor: background, + child: Text( + AvatarLabelHelper.buildLabel(displayName), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: foreground, + letterSpacing: -0.2, + ), + ), + ); +} + +Widget buildBubbleMetaFooter( + BuildContext context, { + required Message message, + required bool isSarMarker, +}) { + final metaColor = Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.68); + + final items = []; + + if (!isSarMarker && message.pathLen < 255) { + items.addAll([ + Icon(Icons.alt_route, size: 11, color: metaColor), + const SizedBox(width: 3), + Text( + message.pathLen == 0 ? 'direct' : '${message.pathLen}hop', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: metaColor), + ), + Text( + ' • ', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: metaColor), + ), + ]); + } + + items.add( + Text( + message.getLocalizedTimeAgo(context), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: metaColor, + fontWeight: FontWeight.w500, + ), + ), + ); + + return Padding( + padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18), + child: Align( + alignment: Alignment.centerRight, + child: Row(mainAxisSize: MainAxisSize.min, children: items), + ), + ); +} + +Widget buildChannelHeaderPill( + BuildContext context, { + required String label, + IconData icon = Icons.campaign_outlined, +}) { + final labelColor = Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.82); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 11, + color: Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.7), + ), + const SizedBox(width: 5), + Flexible( + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: labelColor, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); +} + +Widget buildDirectHeaderCounterpart( + BuildContext context, { + required String label, +}) { + return buildChannelHeaderPill( + context, + label: label, + icon: Icons.alternate_email, + ); +} diff --git a/lib/widgets/messages/message_bubble_signal.dart b/lib/widgets/messages/message_bubble_signal.dart new file mode 100644 index 0000000..29abd54 --- /dev/null +++ b/lib/widgets/messages/message_bubble_signal.dart @@ -0,0 +1,303 @@ +import 'package:flutter/material.dart'; + +import '../../models/message.dart'; +import '../../models/message_reception_details.dart'; + +IconData getDeliveryStatusIcon(MessageDeliveryStatus status) { + switch (status) { + case MessageDeliveryStatus.sending: + return Icons.schedule; + case MessageDeliveryStatus.sent: + return Icons.done; + case MessageDeliveryStatus.delivered: + return Icons.done_all; + case MessageDeliveryStatus.failed: + return Icons.error_outline; + case MessageDeliveryStatus.received: + return Icons.inbox; + } +} + +Color getDeliveryStatusColor(MessageDeliveryStatus status) { + switch (status) { + case MessageDeliveryStatus.sending: + return Colors.orange; + case MessageDeliveryStatus.sent: + return Colors.blue; + case MessageDeliveryStatus.delivered: + return Colors.green; + case MessageDeliveryStatus.failed: + return Colors.red; + case MessageDeliveryStatus.received: + return Colors.grey; + } +} + +Widget buildChannelEchoStatus(BuildContext context, Message message) { + final hasEcho = message.echoCount > 0; + + if (!hasEcho) { + return const SizedBox.shrink(); + } + + final statusColor = getDeliveryStatusColor(message.deliveryStatus); + final rssi = message.lastEchoRssiDbm; + final snr = message.lastEchoSnrRaw != null + ? message.lastEchoSnrRaw!.toSigned(8) / 4.0 + : null; + final quality = linkQualityLabel(rssi, snr); + final qualityColor = linkQualityColor(quality); + + return Wrap( + spacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _techChip( + context, + icon: Icons.hub_outlined, + label: 'x${message.echoCount}', + color: statusColor, + ), + if (message.expectedAckTag != null) + _techChip( + context, + icon: Icons.tag, + label: + 'ACK ${message.expectedAckTag!.toRadixString(16).toUpperCase()}', + color: Colors.indigo, + ), + _techChip(context, icon: Icons.bolt, label: quality, color: qualityColor), + if (message.lastEchoRssiDbm != null) + _signalCapsule( + context, + icon: Icons.network_cell, + label: message.lastEchoRssiDbm!.toString(), + filled: rssiScore(message.lastEchoRssiDbm!), + color: Colors.blueGrey, + ), + if (message.lastEchoSnrRaw != null) + _signalCapsule( + context, + icon: Icons.graphic_eq, + label: (message.lastEchoSnrRaw!.toSigned(8) / 4.0).toStringAsFixed(1), + filled: snrScore(message.lastEchoSnrRaw!.toSigned(8) / 4.0), + color: Colors.teal, + ), + ], + ); +} + +bool shouldShowSentChannelStats( + Message message, { + required bool showReceivedStats, +}) { + if (!message.isSentMessage || !message.isChannelMessage) { + return false; + } + + final hasSignalData = + message.echoCount > 0 || + message.lastEchoRssiDbm != null || + message.lastEchoSnrRaw != null || + message.expectedAckTag != null; + return showReceivedStats && hasSignalData; +} + +Widget buildReceivedSignalStatus( + BuildContext context, + Message message, { + MessageReceptionDetails? receptionDetails, + required int? rssiDbm, + required double? snrDb, +}) { + final hopLabel = hopDisplayLabel(message); + + return Wrap( + spacing: 4, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _techChip( + context, + icon: Icons.alt_route, + label: hopLabel, + color: Colors.indigo, + ), + if (receptionDetails?.senderToReceiptMs != null) + _techChip( + context, + icon: Icons.schedule, + label: _formatMs(receptionDetails!.senderToReceiptMs!), + color: Colors.deepPurple, + ), + if (receptionDetails?.estimatedTransmitMs != null) + _techChip( + context, + icon: Icons.timelapse, + label: '~${_formatMs(receptionDetails!.estimatedTransmitMs!)} tx', + color: Colors.blue, + ), + if (receptionDetails?.postTransmitDelayMs != null) + _techChip( + context, + icon: Icons.hourglass_bottom, + label: '+${_formatMs(receptionDetails!.postTransmitDelayMs!)} lag', + color: Colors.orange, + ), + if (receptionDetails?.pathBytesHex != null) + _techChip( + context, + icon: Icons.route, + label: receptionDetails!.pathBytesHex!, + color: Colors.brown, + ), + if (rssiDbm != null || snrDb != null) ...[ + _techChip( + context, + icon: Icons.bolt, + label: linkQualityLabel(rssiDbm, snrDb), + color: linkQualityColor(linkQualityLabel(rssiDbm, snrDb)), + ), + if (rssiDbm != null) + _signalCapsule( + context, + icon: Icons.network_cell, + label: '$rssiDbm', + filled: rssiScore(rssiDbm), + color: Colors.blueGrey, + ), + if (snrDb != null) + _signalCapsule( + context, + icon: Icons.graphic_eq, + label: snrDb.toStringAsFixed(1), + filled: snrScore(snrDb), + color: Colors.teal, + ), + ], + ], + ); +} + +String _formatMs(int value) { + if (value >= 60000) { + final minutes = value ~/ 60000; + final seconds = (value % 60000) ~/ 1000; + return '${minutes}m ${seconds}s'; + } + if (value >= 1000) { + return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)}s'; + } + return '${value}ms'; +} + +String hopDisplayLabel(Message message) { + if (message.pathLen == 0) return 'Direct'; + if (message.pathLen >= 255 && message.isContactMessage) return 'Direct'; + if (message.pathLen >= 255) return 'Unknown'; + return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}'; +} + +Widget _techChip( + BuildContext context, { + required IconData icon, + required String label, + required Color color, +}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 10, color: color), + const SizedBox(width: 2), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + fontSize: 10, + ), + ), + ], + ), + ); +} + +Widget _signalCapsule( + BuildContext context, { + required IconData icon, + required String label, + required int filled, + required Color color, +}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 10, color: color), + const SizedBox(width: 2), + Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(5, (i) { + final active = i < filled; + return Container( + width: 3, + height: (4 + i).toDouble(), + margin: const EdgeInsets.symmetric(horizontal: 0.5), + decoration: BoxDecoration( + color: active ? color : color.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(1), + ), + ); + }), + ), + const SizedBox(width: 2), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + fontSize: 10, + ), + ), + ], + ), + ); +} + +int rssiScore(int rssiDbm) => ((rssiDbm + 120) / 10).round().clamp(0, 5); + +int snrScore(double snrDb) => ((snrDb + 5.0) / 5.0).round().clamp(0, 5); + +String linkQualityLabel(int? rssiDbm, double? snrDb) { + var score = 0; + if (rssiDbm != null) score += rssiScore(rssiDbm); + if (snrDb != null) score += snrScore(snrDb); + if (score >= 8) return 'Excellent'; + if (score >= 6) return 'Good'; + if (score >= 4) return 'Fair'; + return 'Weak'; +} + +Color linkQualityColor(String quality) { + switch (quality) { + case 'Excellent': + return Colors.green; + case 'Good': + return Colors.lightGreen; + case 'Fair': + return Colors.orange; + default: + return Colors.redAccent; + } +} diff --git a/lib/widgets/messages/system_message_bubble.dart b/lib/widgets/messages/system_message_bubble.dart new file mode 100644 index 0000000..34ca845 --- /dev/null +++ b/lib/widgets/messages/system_message_bubble.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; + +import '../../models/message.dart'; +import '../../utils/message_extensions.dart'; + +class SystemMessageBubble extends StatelessWidget { + final Message message; + + const SystemMessageBubble({super.key, required this.message}); + + Color _getLevelColor(String? level) { + switch (level?.toLowerCase()) { + case 'success': + return Colors.green; + case 'warning': + return Colors.orange; + case 'error': + return Colors.red; + case 'info': + default: + return Colors.blue.shade300; + } + } + + IconData _getLevelIcon(String? level) { + switch (level?.toLowerCase()) { + case 'success': + return Icons.check_circle_outline; + case 'warning': + return Icons.warning_amber_outlined; + case 'error': + return Icons.error_outline; + case 'info': + default: + return Icons.info_outline; + } + } + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + final level = message.senderName ?? 'info'; + final levelColor = _getLevelColor(level); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: levelColor.withValues(alpha: isDarkMode ? 0.18 : 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: levelColor.withValues(alpha: isDarkMode ? 0.3 : 0.16), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(_getLevelIcon(level), size: 16, color: levelColor), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + level.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: levelColor, + fontWeight: FontWeight.bold, + letterSpacing: 0.4, + ), + ), + ), + Text( + message.getLocalizedTimeAgo(context), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of( + context, + ).textTheme.bodySmall?.color?.withValues(alpha: 0.6), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + message.text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + height: 1.3, + color: Theme.of( + context, + ).textTheme.bodySmall?.color?.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/messages/voice_message_bubble.dart b/lib/widgets/messages/voice_message_bubble.dart index 8f11bc3..45eff5a 100644 --- a/lib/widgets/messages/voice_message_bubble.dart +++ b/lib/widgets/messages/voice_message_bubble.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../l10n/app_localizations.dart'; import '../../models/message.dart'; +import '../../providers/app_provider.dart'; import '../../providers/connection_provider.dart'; import '../../providers/contacts_provider.dart'; import '../../providers/voice_provider.dart'; @@ -218,11 +219,18 @@ class _VoiceMessageBubbleState extends State { int pathLen = 0, }) async { if (_isRequesting) return; + setState(() { + _isRequesting = true; + _autoPlayWhenReady = true; + _errorText = null; + }); + final connectionProvider = context.read(); final voiceProvider = context.read(); voiceProvider.resumeIncomingSession(sessionId); final contactsProvider = context.read(); - final resolution = await TransmissionTargetResolver.resolveFetchTarget( + final appProvider = context.read(); + var resolution = await TransmissionTargetResolver.resolveFetchTarget( contactsProvider: contactsProvider, refreshContacts: connectionProvider.getContacts, isSentByMe: widget.isSentByMe, @@ -235,6 +243,7 @@ class _VoiceMessageBubbleState extends State { if (!mounted) return; if (resolution.failure == TransmissionTargetFailure.unknownContact) { + _clearRequestState(); await _showBlockingAlert( 'Cannot fetch voice', 'Sender contact is unknown. Sync contacts first.', @@ -242,6 +251,7 @@ class _VoiceMessageBubbleState extends State { return; } if (resolution.failure == TransmissionTargetFailure.unknownRoute) { + _clearRequestState(); await _showBlockingAlert( 'Cannot fetch voice', 'Sender route is unknown. Sync contacts/path first.', @@ -249,27 +259,85 @@ class _VoiceMessageBubbleState extends State { return; } if (resolution.failure == TransmissionTargetFailure.tooFar) { + _clearRequestState(); await _showBlockingAlert( 'Cannot fetch voice', 'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).', ); return; } + if (resolution.failure == TransmissionTargetFailure.unreachable) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch voice', + 'Sender route did not respond to a path check. Sync contacts/path and try again.', + ); + return; + } + + var sender = resolution.target!; + var routeVerified = await appProvider.verifyRawTransportRoute(sender); + if (!mounted) return; + if (!routeVerified) { + await connectionProvider.getContacts(); + if (!mounted) return; + resolution = await TransmissionTargetResolver.resolveFetchTarget( + contactsProvider: contactsProvider, + refreshContacts: connectionProvider.getContacts, + isSentByMe: widget.isSentByMe, + recipientPublicKey: widget.message.recipientPublicKey, + senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, + senderKey6FromEnvelope: envelope?.senderKey6, + senderName: widget.message.senderName, + maxFetchHops: _maxFetchHops, + ); + if (!mounted) return; + if (resolution.failure == TransmissionTargetFailure.unknownContact) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch voice', + 'Sender contact is unknown. Sync contacts first.', + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.unknownRoute) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch voice', + 'Sender route is unknown. Sync contacts/path first.', + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.tooFar) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch voice', + 'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).', + ); + return; + } + sender = resolution.target!; + routeVerified = await appProvider.verifyRawTransportRoute(sender); + if (!mounted) return; + if (!routeVerified) { + _clearRequestState(); + await _showBlockingAlert( + 'Cannot fetch voice', + 'Sender route did not respond on the raw transport path.', + ); + return; + } + } - final sender = resolution.target!; if (sender.outPathLen >= 2) { _showToast( 'Voice fetch over ${sender.outPathLen} hops may take a while.', ); } - if (!mounted) return; - setState(() { - _errorText = null; - }); - final deviceKey = connectionProvider.deviceInfo.publicKey; if (deviceKey == null || deviceKey.length < 6) { + _clearRequestState(); await _showBlockingAlert( 'Cannot fetch voice', 'Device key is unavailable.', @@ -305,12 +373,6 @@ class _VoiceMessageBubbleState extends State { version: 2, ); - setState(() { - _isRequesting = true; - _autoPlayWhenReady = true; - _errorText = null; - }); - try { await connectionProvider.sendRawVoicePacket( contactPath: sender.outPath, @@ -373,6 +435,14 @@ class _VoiceMessageBubbleState extends State { }); } + void _clearRequestState() { + if (!mounted) return; + setState(() { + _isRequesting = false; + _autoPlayWhenReady = false; + }); + } + void _cancelReceive(String sessionId) { if (!mounted) return; _requestTimeoutTimer?.cancel(); diff --git a/test/models/message_reception_details_test.dart b/test/models/message_reception_details_test.dart new file mode 100644 index 0000000..98f17b7 --- /dev/null +++ b/test/models/message_reception_details_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/message_reception_details.dart'; + +void main() { + test('round trips reception details json', () { + final details = MessageReceptionDetails( + capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000000), + packetLoggedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + rssiDbm: -92, + snrDb: 7.5, + pathBytes: const [0xAA, 0xBB, 0xCC], + senderToReceiptMs: 4200, + estimatedTransmitMs: 1800, + postTransmitDelayMs: 2400, + ); + + final decoded = MessageReceptionDetails.fromJson(details.toJson()); + + expect(decoded, isNotNull); + expect(decoded!.rssiDbm, -92); + expect(decoded.snrDb, 7.5); + expect(decoded.pathBytesHex, 'aa:bb:cc'); + expect(decoded.senderToReceiptMs, 4200); + expect(decoded.estimatedTransmitMs, 1800); + expect(decoded.postTransmitDelayMs, 2400); + }); +} diff --git a/test/providers/messages_provider_retransmission_test.dart b/test/providers/messages_provider_retransmission_test.dart index 5f0b59e..6d2ab89 100644 --- a/test/providers/messages_provider_retransmission_test.dart +++ b/test/providers/messages_provider_retransmission_test.dart @@ -40,7 +40,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('MessagesProvider retransmission', () { - test('direct messages stay pending until delivery ACK arrives', () { + test('direct messages become sent before delivery ACK arrives', () { final provider = MessagesProvider(); provider.addSentMessage( _buildDirectMessage('m1'), @@ -51,7 +51,7 @@ void main() { expect( provider.messages.single.deliveryStatus, - MessageDeliveryStatus.sending, + MessageDeliveryStatus.sent, ); expect(provider.messages.single.expectedAckTag, 77); @@ -64,6 +64,24 @@ void main() { expect(provider.messages.single.roundTripTimeMs, 180); }); + test('direct messages stay sent after device accept until confirm arrives', () { + final provider = MessagesProvider(); + provider.addSentMessage( + _buildDirectMessage('m1b'), + contact: _buildContact(), + ); + + provider.markMessageSent('m1b', 78, 250); + + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.sent, + ); + expect(provider.messages.single.expectedAckTag, 78); + expect(provider.messages.single.roundTripTimeMs, isNull); + expect(provider.messages.single.deliveredAt, isNull); + }); + test('channel messages are marked sent immediately', () { final provider = MessagesProvider(); provider.addSentMessage( diff --git a/test/utils/log_rx_route_decoder_test.dart b/test/utils/log_rx_route_decoder_test.dart new file mode 100644 index 0000000..6e25321 --- /dev/null +++ b/test/utils/log_rx_route_decoder_test.dart @@ -0,0 +1,92 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/utils/log_rx_route_decoder.dart'; + +void main() { + group('LogRxRouteDecoder.decode', () { + test('parses route and sender from LOG_RX_DATA packet', () { + final packet = Uint8List.fromList([ + 0x88, + 0x37, + 0xae, + 0x05, + 0x04, + 0xc2, + 0xba, + 0x5f, + 0xde, + 0x5c, + ]); + + final decoded = LogRxRouteDecoder.decode(packet); + + expect(decoded, isNotNull); + expect(decoded!.payloadType, 0x01); + expect(decoded.pathHashes, [0xc2, 0xba, 0x5f, 0xde]); + expect(decoded.originalSenderHash, 0xc2); + }); + }); + + group('LogRxRouteDecoder.resolveHash', () { + test('prefers own node when hash matches device key', () { + final resolved = LogRxRouteDecoder.resolveHash( + 0xc2, + contacts: const [], + ownPublicKey: Uint8List.fromList([0xc2, 0x01, 0x02]), + ownName: 'Base', + ); + + expect(resolved.isOwnNode, isTrue); + expect(resolved.label, 'Base (you)'); + }); + + test('resolves unique contact by first public key byte', () { + final resolved = LogRxRouteDecoder.resolveHash( + 0xc2, + contacts: [_contact(name: 'Alpha', keyPrefix: 0xc2)], + ); + + expect(resolved.isUniqueMatch, isTrue); + expect(resolved.label, 'Alpha'); + }); + + test('marks ambiguous matches without pretending certainty', () { + final resolved = LogRxRouteDecoder.resolveHash( + 0xc2, + contacts: [ + _contact(name: 'Alpha', keyPrefix: 0xc2), + _contact(name: 'Bravo', keyPrefix: 0xc2), + ], + ); + + expect(resolved.isUniqueMatch, isFalse); + expect(resolved.matchCount, 2); + }); + }); +} + +Contact _contact({required String name, required int keyPrefix}) { + return Contact( + publicKey: Uint8List.fromList([ + keyPrefix, + 0x11, + 0x22, + 0x33, + 0x44, + 0x55, + 0x66, + 0x77, + ]), + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(0), + advName: name, + lastAdvert: 0, + advLat: 0, + advLon: 0, + lastMod: 0, + ); +}