Add transmission timing details

This commit is contained in:
Janez T
2026-03-07 09:05:55 +01:00
parent 810897d348
commit f6c5a3a4ca
21 changed files with 2010 additions and 602 deletions

View File

@@ -0,0 +1,63 @@
class MessageReceptionDetails {
final DateTime capturedAt;
final DateTime? packetLoggedAt;
final int? rssiDbm;
final double? snrDb;
final List<int>? 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<String, dynamic> toJson() {
return {
'capturedAtMillis': capturedAt.millisecondsSinceEpoch,
'packetLoggedAtMillis': packetLoggedAt?.millisecondsSinceEpoch,
'rssiDbm': rssiDbm,
'snrDb': snrDb,
'pathBytes': pathBytes,
'senderToReceiptMs': senderToReceiptMs,
'estimatedTransmitMs': estimatedTransmitMs,
'postTransmitDelayMs': postTransmitDelayMs,
};
}
static MessageReceptionDetails? fromJson(Map<String, dynamic> 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<num>().map((b) => b.toInt()).toList()
: null,
senderToReceiptMs: json['senderToReceiptMs'] as int?,
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
);
}
}

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'connection_provider.dart'; import 'connection_provider.dart';
@@ -16,9 +17,12 @@ import '../services/packet_capture_storage_service.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/ble_packet_log.dart'; import '../models/ble_packet_log.dart';
import '../models/message_reception_details.dart';
import '../utils/drawing_message_parser.dart'; import '../utils/drawing_message_parser.dart';
import '../utils/raw_route_probe.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
import '../utils/image_message_parser.dart'; import '../utils/image_message_parser.dart';
import '../utils/message_airtime_estimator.dart';
/// Main App Provider - coordinates all other providers /// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier { class AppProvider with ChangeNotifier {
@@ -70,6 +74,8 @@ class AppProvider with ChangeNotifier {
FragmentAckWaitRegistry(); FragmentAckWaitRegistry();
final FragmentAckWaitRegistry _imageFragmentAckWaiters = final FragmentAckWaitRegistry _imageFragmentAckWaiters =
FragmentAckWaitRegistry(); FragmentAckWaitRegistry();
final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry();
final Map<String, Future<bool>> _pendingRawRouteProbes = {};
Timer? _packetCaptureFlushTimer; Timer? _packetCaptureFlushTimer;
String? _lastPersistedPacketSignature; String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false; bool _isPersistingPacketCapture = false;
@@ -633,6 +639,9 @@ class AppProvider with ChangeNotifier {
capturedAt: enrichedMessage.receivedAt, capturedAt: enrichedMessage.receivedAt,
) )
: null; : null;
final receptionDetailsSnapshot = _buildReceptionDetailsSnapshot(
enrichedMessage,
);
// Check if message is a drawing broadcast // Check if message is a drawing broadcast
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) { if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
@@ -664,6 +673,7 @@ class AppProvider with ChangeNotifier {
updatedMessage, updatedMessage,
contactLookup: (name) => '', contactLookup: (name) => '',
contactLocationSnapshot: contactLocationSnapshot, contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
); );
// Broadcast drawing message to SSE clients if server is running // Broadcast drawing message to SSE clients if server is running
@@ -700,6 +710,7 @@ class AppProvider with ChangeNotifier {
} }
}, },
contactLocationSnapshot: contactLocationSnapshot, contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
); );
connectionProvider.broadcastMessageToSseClients(enrichedMessage); connectionProvider.broadcastMessageToSseClients(enrichedMessage);
return; return;
@@ -728,6 +739,7 @@ class AppProvider with ChangeNotifier {
} }
}, },
contactLocationSnapshot: contactLocationSnapshot, contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
); );
connectionProvider.broadcastMessageToSseClients(enrichedMessage); connectionProvider.broadcastMessageToSseClients(enrichedMessage);
return; return;
@@ -765,12 +777,16 @@ class AppProvider with ChangeNotifier {
} }
}, },
contactLocationSnapshot: contactLocationSnapshot, contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
); );
// Broadcast message to SSE clients if server is running // Broadcast message to SSE clients if server is running
connectionProvider.broadcastMessageToSseClients(enrichedMessage); 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) // When telemetry is received via PUSH_CODE_TELEMETRY_RESPONSE (0x8B)
// Used by older firmware versions for telemetry responses // Used by older firmware versions for telemetry responses
connectionProvider.onTelemetryReceived = (publicKey, lppData) { connectionProvider.onTelemetryReceived = (publicKey, lppData) {
@@ -796,6 +812,18 @@ class AppProvider with ChangeNotifier {
// Magic 0x72 'r' = voice fetch request; 0x69 'i' = image fetch request. // Magic 0x72 'r' = voice fetch request; 0x69 'i' = image fetch request.
// Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet. // Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet.
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { 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); final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload);
if (voiceFetchRequest != null) { if (voiceFetchRequest != null) {
final requester = _resolveVoiceFetchRequester(voiceFetchRequest); final requester = _resolveVoiceFetchRequester(voiceFetchRequest);
@@ -1559,6 +1587,78 @@ class AppProvider with ChangeNotifier {
} }
String _fragmentAckKey(String sessionId, int index) => '$sessionId:$index'; String _fragmentAckKey(String sessionId, int index) => '$sessionId:$index';
String _rawProbeKey(int nonce) =>
nonce.toRadixString(16).padLeft(8, '0').toLowerCase();
Future<bool> 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<bool> _waitForVoiceFragmentAck({ Future<bool> _waitForVoiceFragmentAck({
required String sessionId, 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) { void _sendVoiceFragmentAck(VoicePacket packet) {
final senderKey6 = _voiceSessionSenderKey6[packet.sessionId]; final senderKey6 = _voiceSessionSenderKey6[packet.sessionId];
if (senderKey6 == null) return; 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<int>? _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 // Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching // The ConnectionProvider's onMessageWaiting callback handles automatic message fetching

View File

@@ -146,11 +146,15 @@ class ConnectionProvider with ChangeNotifier {
final MessageDeliveryTracker _messageDeliveryTracker = final MessageDeliveryTracker _messageDeliveryTracker =
MessageDeliveryTracker(); MessageDeliveryTracker();
final PingTracker _pingTracker = PingTracker(); final PingTracker _pingTracker = PingTracker();
final Map<String, Future<PingResult>> _pendingSmartPings = {};
// Expose room login states // Expose room login states
Map<String, RoomLoginState> get roomLoginStates => Map<String, RoomLoginState> get roomLoginStates =>
_roomLoginManager.roomLoginStates; _roomLoginManager.roomLoginStates;
bool isPingInProgress(Uint8List publicKey) =>
_pendingSmartPings.containsKey(_publicKeyToHex(publicKey));
// Callbacks for other providers // Callbacks for other providers
Function(Contact)? onContactReceived; Function(Contact)? onContactReceived;
Function(List<Contact>)? onContactsComplete; Function(List<Contact>)? onContactsComplete;
@@ -1316,6 +1320,34 @@ class ConnectionProvider with ChangeNotifier {
required Uint8List contactPublicKey, required Uint8List contactPublicKey,
required bool hasPath, required bool hasPath,
Function()? onRetryWithFlooding, 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<PingResult> _runSmartPing({
required Uint8List contactPublicKey,
required bool hasPath,
Function()? onRetryWithFlooding,
}) async { }) async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
_error = 'Not connected to device'; _error = 'Not connected to device';
@@ -1334,7 +1366,10 @@ class ConnectionProvider with ChangeNotifier {
); );
// Send the ping // Send the ping
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true); await _activeService.requestTelemetry(
contactPublicKey,
zeroHop: firstAttemptDirect,
);
// Wait for response or timeout // Wait for response or timeout
final bool gotResponse = await pingFuture; final bool gotResponse = await pingFuture;
@@ -1361,8 +1396,8 @@ class ConnectionProvider with ChangeNotifier {
wasDirectAttempt: false, wasDirectAttempt: false,
); );
// Retry with flooding (zeroHop=true acts as broadcast to neighbors) // Retry with flooding.
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true); await _activeService.requestTelemetry(contactPublicKey, zeroHop: false);
// Wait for response or timeout // Wait for response or timeout
final bool gotRetryResponse = await retryFuture; 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) /// Send binary request to contact (modern replacement for requestTelemetry)
/// ///
/// Supports multiple request types: /// Supports multiple request types:

View File

@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message_contact_location.dart'; import '../models/message_contact_location.dart';
import '../models/message_reception_details.dart';
import '../models/sar_marker.dart'; import '../models/sar_marker.dart';
import '../models/map_drawing.dart'; import '../models/map_drawing.dart';
import '../services/message_storage_service.dart'; import '../services/message_storage_service.dart';
@@ -22,6 +23,7 @@ class MessagesProvider with ChangeNotifier {
bool _isInitialized = false; bool _isInitialized = false;
AppLocalizations? _localizations; AppLocalizations? _localizations;
final Map<String, MessageContactLocation> _messageContactLocations = {}; final Map<String, MessageContactLocation> _messageContactLocations = {};
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
// Track pending sent messages by expected ACK/TAG // Track pending sent messages by expected ACK/TAG
final Map<int, Message> _pendingSentMessages = {}; final Map<int, Message> _pendingSentMessages = {};
@@ -74,10 +76,7 @@ class MessagesProvider with ChangeNotifier {
})? })?
sendMessageCallback; sendMessageCallback;
Future<void> Function({ Future<void> Function({required Contact contact, required int failureStreak})?
required Contact contact,
required int failureStreak,
})?
onDirectPathFailedCallback; onDirectPathFailedCallback;
List<Message> get messages => List.unmodifiable(_messages); List<Message> get messages => List.unmodifiable(_messages);
@@ -115,6 +114,9 @@ class MessagesProvider with ChangeNotifier {
MessageContactLocation? getMessageContactLocation(String messageId) => MessageContactLocation? getMessageContactLocation(String messageId) =>
_messageContactLocations[messageId]; _messageContactLocations[messageId];
MessageReceptionDetails? getMessageReceptionDetails(String messageId) =>
_messageReceptionDetails[messageId];
/// Set localizations for notifications /// Set localizations for notifications
void setLocalizations(AppLocalizations localizations) { void setLocalizations(AppLocalizations localizations) {
_localizations = localizations; _localizations = localizations;
@@ -145,9 +147,14 @@ class MessagesProvider with ChangeNotifier {
final storedMessages = await _storageService.loadMessages(); final storedMessages = await _storageService.loadMessages();
final storedContactLocations = await _storageService final storedContactLocations = await _storageService
.loadMessageContactLocations(); .loadMessageContactLocations();
final storedReceptionDetails = await _storageService
.loadMessageReceptionDetails();
_messageContactLocations _messageContactLocations
..clear() ..clear()
..addAll(storedContactLocations); ..addAll(storedContactLocations);
_messageReceptionDetails
..clear()
..addAll(storedReceptionDetails);
// Add stored messages with enhancement to ensure SAR detection // Add stored messages with enhancement to ensure SAR detection
for (final message in storedMessages) { for (final message in storedMessages) {
@@ -311,6 +318,7 @@ class MessagesProvider with ChangeNotifier {
Message message, { Message message, {
String Function(String name)? contactLookup, String Function(String name)? contactLookup,
MessageContactLocation? contactLocationSnapshot, MessageContactLocation? contactLocationSnapshot,
MessageReceptionDetails? receptionDetailsSnapshot,
}) { }) {
// Always enhance message with SAR parser to detect SAR markers // Always enhance message with SAR parser to detect SAR markers
var enhancedMessage = SarMessageParser.enhanceMessage(message); var enhancedMessage = SarMessageParser.enhanceMessage(message);
@@ -397,6 +405,22 @@ class MessagesProvider with ChangeNotifier {
debugPrint( debugPrint(
' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...', ' 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 return; // Skip duplicate
} }
@@ -404,6 +428,9 @@ class MessagesProvider with ChangeNotifier {
if (contactLocationSnapshot != null) { if (contactLocationSnapshot != null) {
_messageContactLocations[finalMessage.id] = contactLocationSnapshot; _messageContactLocations[finalMessage.id] = contactLocationSnapshot;
} }
if (receptionDetailsSnapshot != null) {
_messageReceptionDetails[finalMessage.id] = receptionDetailsSnapshot;
}
// If it's a SAR marker message, extract and store the marker // If it's a SAR marker message, extract and store the marker
if (finalMessage.isSarMarker) { if (finalMessage.isSarMarker) {
@@ -593,6 +620,7 @@ class MessagesProvider with ChangeNotifier {
await _storageService.saveMessages( await _storageService.saveMessages(
_messages, _messages,
messageContactLocations: _messageContactLocations, messageContactLocations: _messageContactLocations,
messageReceptionDetails: _messageReceptionDetails,
); );
} catch (e) { } catch (e) {
debugPrint('❌ [MessagesProvider] Error persisting messages: $e'); debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
@@ -709,6 +737,7 @@ class MessagesProvider with ChangeNotifier {
_messageContactMap.remove(messageId); _messageContactMap.remove(messageId);
_groupedMessageMapping.remove(messageId); _groupedMessageMapping.remove(messageId);
_messageContactLocations.remove(messageId); _messageContactLocations.remove(messageId);
_messageReceptionDetails.remove(messageId);
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted'); debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
@@ -738,6 +767,7 @@ class MessagesProvider with ChangeNotifier {
_messages.clear(); _messages.clear();
_sarMarkers.clear(); _sarMarkers.clear();
_messageContactLocations.clear(); _messageContactLocations.clear();
_messageReceptionDetails.clear();
_persistMessages(); _persistMessages();
notifyListeners(); notifyListeners();
} }
@@ -753,6 +783,7 @@ class MessagesProvider with ChangeNotifier {
_messages.clear(); _messages.clear();
_sarMarkers.clear(); _sarMarkers.clear();
_messageContactLocations.clear(); _messageContactLocations.clear();
_messageReceptionDetails.clear();
_persistMessages(); _persistMessages();
notifyListeners(); notifyListeners();
} }
@@ -1062,10 +1093,11 @@ class MessagesProvider with ChangeNotifier {
' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...', ' 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( final updatedMessage = message.copyWith(
deliveryStatus: expectedAckTag > 0 deliveryStatus: MessageDeliveryStatus.sent,
? MessageDeliveryStatus.sending
: MessageDeliveryStatus.sent,
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null, expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null, suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null,
); );

View File

@@ -1,10 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'dart:io'; import 'dart:io';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:meshcore_client/meshcore_client.dart'; import 'package:meshcore_client/meshcore_client.dart';
import '../l10n/app_localizations.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 { class PacketLogScreen extends StatefulWidget {
final MeshCoreBleService bleService; final MeshCoreBleService bleService;
@@ -456,6 +460,26 @@ class _PacketLogCard extends StatelessWidget {
final isRx = log.direction == PacketDirection.rx; final isRx = log.direction == PacketDirection.rx;
final directionColor = isRx ? Colors.green : Colors.blue; final directionColor = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo; final rxInfo = log.logRxDataInfo;
final contacts = context.watch<ContactsProvider>().contacts;
final connectionProvider = context.watch<ConnectionProvider>();
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( return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 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), const SizedBox(height: 12),
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@@ -723,6 +755,162 @@ class _PacketLogCard extends StatelessWidget {
} }
} }
class _RouteSection extends StatelessWidget {
final DecodedLogRxRoute route;
final List<ResolvedNodeHash> 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 { class _FactCard extends StatelessWidget {
final IconData icon; final IconData icon;
final String label; final String label;

View File

@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/message_contact_location.dart'; import '../models/message_contact_location.dart';
import '../models/message_reception_details.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
/// Service for persisting messages to local storage /// Service for persisting messages to local storage
@@ -10,12 +11,15 @@ class MessageStorageService {
static const String _messagesKey = 'stored_messages'; static const String _messagesKey = 'stored_messages';
static const String _messageContactLocationsKey = static const String _messageContactLocationsKey =
'stored_message_contact_locations'; 'stored_message_contact_locations';
static const String _messageReceptionDetailsKey =
'stored_message_reception_details';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages static const int _maxStoredMessages = 1000; // Store up to 1000 messages
/// Save messages to persistent storage /// Save messages to persistent storage
Future<void> saveMessages( Future<void> saveMessages(
List<Message> messages, { List<Message> messages, {
Map<String, MessageContactLocation> messageContactLocations = const {}, Map<String, MessageContactLocation> messageContactLocations = const {},
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
}) async { }) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -34,15 +38,25 @@ class MessageStorageService {
.map((entry) => entry['id'] as String) .map((entry) => entry['id'] as String)
.toSet(); .toSet();
final locationJson = <String, dynamic>{}; final locationJson = <String, dynamic>{};
final receptionJson = <String, dynamic>{};
for (final entry in messageContactLocations.entries) { for (final entry in messageContactLocations.entries) {
if (retainedMessageIds.contains(entry.key)) { if (retainedMessageIds.contains(entry.key)) {
locationJson[entry.key] = entry.value.toJson(); 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( await prefs.setString(
_messageContactLocationsKey, _messageContactLocationsKey,
jsonEncode(locationJson), jsonEncode(locationJson),
); );
await prefs.setString(
_messageReceptionDetailsKey,
jsonEncode(receptionJson),
);
debugPrint( debugPrint(
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage', '✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
@@ -52,8 +66,8 @@ class MessageStorageService {
} }
} }
Future<Map<String, MessageContactLocation>> loadMessageContactLocations() Future<Map<String, MessageContactLocation>>
async { loadMessageContactLocations() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageContactLocationsKey); final jsonString = prefs.getString(_messageContactLocationsKey);
@@ -82,6 +96,36 @@ class MessageStorageService {
} }
} }
Future<Map<String, MessageReceptionDetails>>
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<String, dynamic>) {
return const {};
}
final result = <String, MessageReceptionDetails>{};
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) 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 /// Load messages from persistent storage
Future<List<Message>> loadMessages() async { Future<List<Message>> loadMessages() async {
try { try {
@@ -116,6 +160,7 @@ class MessageStorageService {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_messagesKey); await prefs.remove(_messagesKey);
await prefs.remove(_messageContactLocationsKey); await prefs.remove(_messageContactLocationsKey);
await prefs.remove(_messageReceptionDetailsKey);
debugPrint('✅ [MessageStorage] Cleared all stored messages'); debugPrint('✅ [MessageStorage] Cleared all stored messages');
} catch (e) { } catch (e) {
debugPrint('❌ [MessageStorage] Error clearing messages: $e'); debugPrint('❌ [MessageStorage] Error clearing messages: $e');

View File

@@ -0,0 +1,129 @@
import 'dart:typed_data';
import '../models/contact.dart';
class DecodedLogRxRoute {
final int payloadType;
final List<int> 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<Contact> 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,
);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -3,7 +3,7 @@ import 'dart:typed_data';
import '../models/contact.dart'; import '../models/contact.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar } enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar, unreachable }
class TransmissionTargetResolution { class TransmissionTargetResolution {
final Contact? target; final Contact? target;

View File

@@ -21,10 +21,19 @@ class _ConnectionDialogState extends State<ConnectionDialog>
final List<DiscoveredServer> _discoveredServers = []; final List<DiscoveredServer> _discoveredServers = [];
int _scannedCount = 0; int _scannedCount = 0;
int _totalToScan = 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 // Named listener method for proper cleanup
void _onTabChanged() { void _onTabChanged() {
if (_tabController.index == _lastTabIndex) return;
_lastTabIndex = _tabController.index;
if (_tabController.index == 0) {
_refreshBleDevices();
}
if (_tabController.index == 1) { if (_tabController.index == 1) {
// Switched to network tab // Switched to network tab
if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) { if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) {
@@ -47,13 +56,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
_connectionProvider = Provider.of<ConnectionProvider>(context, listen: false); _connectionProvider = Provider.of<ConnectionProvider>(
context,
listen: false,
);
// Defer scan startup until after the first frame so Provider listeners // Defer scan startup until after the first frame so Provider listeners
// are not notified while this dialog is still being built. // are not notified while this dialog is still being built.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
_connectionProvider.startScan(); _refreshBleDevices();
}); });
// Set up network scanner callbacks // Set up network scanner callbacks
@@ -101,6 +113,12 @@ class _ConnectionDialogState extends State<ConnectionDialog>
_networkScanner.scan(); _networkScanner.scan();
} }
Future<void> _refreshBleDevices() async {
await _connectionProvider.stopScan();
if (!mounted) return;
await _connectionProvider.startScan();
}
Color _getSignalColor(int rssi) { Color _getSignalColor(int rssi) {
if (rssi >= -60) return Colors.green; if (rssi >= -60) return Colors.green;
if (rssi >= -75) return Colors.orange; if (rssi >= -75) return Colors.orange;
@@ -218,10 +236,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
Icons.refresh, Icons.refresh,
color: Theme.of(context).colorScheme.onPrimaryContainer, color: Theme.of(context).colorScheme.onPrimaryContainer,
), ),
onPressed: () { onPressed: _refreshBleDevices,
connectionProvider.stopScan();
connectionProvider.startScan();
},
), ),
], ],
), ),
@@ -255,10 +270,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
TextButton.icon( TextButton.icon(
onPressed: () { onPressed: _refreshBleDevices,
connectionProvider.stopScan();
connectionProvider.startScan();
},
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context)!.scanAgain), label: Text(AppLocalizations.of(context)!.scanAgain),
), ),

View File

@@ -70,6 +70,9 @@ class ContactTile extends StatelessWidget {
// Get room login state if this is a room // Get room login state if this is a room
final connectionProvider = context.watch<ConnectionProvider>(); final connectionProvider = context.watch<ConnectionProvider>();
final isPingInProgress = connectionProvider.isPingInProgress(
contact.publicKey,
);
final roomLoginState = contact.type == ContactType.room final roomLoginState = contact.type == ContactType.room
? connectionProvider.getRoomLoginState(contact.publicKeyPrefix) ? connectionProvider.getRoomLoginState(contact.publicKeyPrefix)
: null; : 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 subtitle: isSimpleMode
@@ -458,39 +472,43 @@ class ContactTile extends StatelessWidget {
_showContactDetails(context, contact); _showContactDetails(context, contact);
} }
}, },
onLongPress: () async { onLongPress: isPingInProgress
final connectionProvider = context.read<ConnectionProvider>(); ? null
: () async {
final connectionProvider = context.read<ConnectionProvider>();
// Determine if we should use flooding (no path) or direct (has path) // Determine if we should use flooding (no path) or direct (has path)
final hasPath = contact.hasPath; final hasPath = contact.hasPath;
// Use smart ping with automatic fallback // Use smart ping with automatic fallback
final result = await connectionProvider.smartPing( final result = await connectionProvider.smartPing(
contactPublicKey: contact.publicKey, contactPublicKey: contact.publicKey,
hasPath: hasPath, hasPath: hasPath,
onRetryWithFlooding: () { onRetryWithFlooding: () {
// Called when retrying with flooding after direct timeout // Called when retrying with flooding after direct timeout
if (context.mounted) { if (context.mounted) {
ToastLogger.warning( ToastLogger.warning(
context, context,
AppLocalizations.of( AppLocalizations.of(
context, context,
)!.directPingTimeout(contact.displayName), )!.directPingTimeout(contact.displayName),
);
}
},
); );
}
},
);
// Show final result // Show final result
if (context.mounted) { if (context.mounted) {
if (!result.success) { if (!result.success) {
ToastLogger.error( ToastLogger.error(
context, context,
AppLocalizations.of(context)!.pingFailed(contact.displayName), AppLocalizations.of(
); context,
} )!.pingFailed(contact.displayName),
} );
}, }
}
},
), ),
); );
} }
@@ -603,8 +621,12 @@ class ContactTile extends StatelessWidget {
minChildSize: 0.4, minChildSize: 0.4,
maxChildSize: 0.9, maxChildSize: 0.9,
expand: false, expand: false,
builder: (context, scrollController) => Column( builder: (context, scrollController) {
children: [ final isPingInProgress = context
.watch<ConnectionProvider>()
.isPingInProgress(contact.publicKey);
return Column(
children: [
// Handle bar // Handle bar
Container( Container(
margin: const EdgeInsets.only(top: 8, bottom: 16), margin: const EdgeInsets.only(top: 8, bottom: 16),
@@ -847,15 +869,25 @@ class ContactTile extends StatelessWidget {
), ),
), ),
TextButton.icon( TextButton.icon(
onPressed: () { onPressed: isPingInProgress
final connectionProvider = context ? null
.read<ConnectionProvider>(); : () {
connectionProvider.requestTelemetry( final connectionProvider = context
contact.publicKey, .read<ConnectionProvider>();
zeroHop: true, connectionProvider.requestTelemetry(
); contact.publicKey,
}, zeroHop: true,
icon: const Icon(Icons.refresh, size: 18), );
},
icon: isPingInProgress
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.refresh, size: 18),
label: Text(AppLocalizations.of(context)!.refresh), label: Text(AppLocalizations.of(context)!.refresh),
style: TextButton.styleFrom( style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@@ -991,8 +1023,9 @@ class ContactTile extends StatelessWidget {
], ],
), ),
), ),
], ],
), );
},
), ),
); );
} }

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_avif/flutter_avif.dart'; import 'package:flutter_avif/flutter_avif.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/image_provider.dart' as ip; import '../../providers/image_provider.dart' as ip;
@@ -254,11 +255,17 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
int pathLen = 0, int pathLen = 0,
}) async { }) async {
if (_isRequesting) return; if (_isRequesting) return;
setState(() {
_isRequesting = true;
_errorText = null;
});
final conn = context.read<ConnectionProvider>(); final conn = context.read<ConnectionProvider>();
final imageProvider = context.read<ip.ImageProvider>(); final imageProvider = context.read<ip.ImageProvider>();
imageProvider.resumeIncomingSession(envelope.sessionId); imageProvider.resumeIncomingSession(envelope.sessionId);
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final resolution = await TransmissionTargetResolver.resolveFetchTarget( final appProvider = context.read<AppProvider>();
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider, contactsProvider: contactsProvider,
refreshContacts: conn.getContacts, refreshContacts: conn.getContacts,
isSentByMe: widget.isSentByMe, isSentByMe: widget.isSentByMe,
@@ -271,6 +278,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (!mounted) return; if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) { if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert( await _showBlockingAlert(
'Cannot fetch image', 'Cannot fetch image',
'Sender contact is unknown. Sync contacts first.', 'Sender contact is unknown. Sync contacts first.',
@@ -278,6 +286,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return; return;
} }
if (resolution.failure == TransmissionTargetFailure.unknownRoute) { if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert( await _showBlockingAlert(
'Cannot fetch image', 'Cannot fetch image',
'Sender route is unknown. Sync contacts/path first.', 'Sender route is unknown. Sync contacts/path first.',
@@ -285,14 +294,76 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return; return;
} }
if (resolution.failure == TransmissionTargetFailure.tooFar) { if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert( await _showBlockingAlert(
'Cannot fetch image', 'Cannot fetch image',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).', 'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
); );
return; 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) { if (sender.outPathLen >= 2) {
_showToast( _showToast(
'Image fetch over ${sender.outPathLen} hops may take a while.', 'Image fetch over ${sender.outPathLen} hops may take a while.',
@@ -302,6 +373,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
setState(() => _errorText = null); setState(() => _errorText = null);
final deviceKey = conn.deviceInfo.publicKey; final deviceKey = conn.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) { if (deviceKey == null || deviceKey.length < 6) {
_clearRequestState();
await _showBlockingAlert( await _showBlockingAlert(
'Cannot fetch image', 'Cannot fetch image',
'Device key is unavailable.', 'Device key is unavailable.',
@@ -332,11 +404,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
); );
setState(() {
_isRequesting = true;
_errorText = null;
});
final payload = request.encodeBinary(); final payload = request.encodeBinary();
try { try {
await conn.sendRawVoicePacket( await conn.sendRawVoicePacket(
@@ -405,6 +472,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
}); });
} }
void _clearRequestState() {
if (!mounted) return;
setState(() {
_isRequesting = false;
});
}
Future<void> _showBlockingAlert(String title, String message) async { Future<void> _showBlockingAlert(String title, String message) async {
if (!mounted) return; if (!mounted) return;
_showToast('$title: $message'); _showToast('$title: $message');

View File

@@ -23,15 +23,16 @@ import '../../utils/key_comparison.dart';
import '../../utils/voice_message_parser.dart'; import '../../utils/voice_message_parser.dart';
import '../../utils/image_message_parser.dart'; import '../../utils/image_message_parser.dart';
import '../../utils/tictactoe_message_parser.dart'; import '../../utils/tictactoe_message_parser.dart';
import '../../utils/avatar_label_helper.dart';
import '../../utils/location_formats.dart'; import '../../utils/location_formats.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart'; import '../../utils/message_extensions.dart';
import '../common/contact_avatar.dart';
import 'voice_message_bubble.dart'; import 'voice_message_bubble.dart';
import 'image_message_bubble.dart'; import 'image_message_bubble.dart';
import 'tictactoe_message_bubble.dart'; import 'tictactoe_message_bubble.dart';
import 'message_trace_sheet.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: /// Reusable message bubble widget that displays messages with various types:
/// - Regular text messages (channel or direct) /// - Regular text messages (channel or direct)
@@ -65,104 +66,6 @@ class _MessageBubbleState extends State<MessageBubble> {
bool _isExpanded = false; bool _isExpanded = false;
bool _showReceivedStats = 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 = <Widget>[];
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 @override
void didUpdateWidget(MessageBubble oldWidget) { void didUpdateWidget(MessageBubble oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -508,6 +411,9 @@ class _MessageBubbleState extends State<MessageBubble> {
final senderLocationSnapshot = messagesProvider.getMessageContactLocation( final senderLocationSnapshot = messagesProvider.getMessageContactLocation(
widget.message.id, widget.message.id,
); );
final receptionDetails = messagesProvider.getMessageReceptionDetails(
widget.message.id,
);
final envelope = VoiceEnvelope.tryParseText(widget.message.text); final envelope = VoiceEnvelope.tryParseText(widget.message.text);
final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text); final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text);
@@ -572,16 +478,19 @@ class _MessageBubbleState extends State<MessageBubble> {
widget.message, widget.message,
); );
final packetPathBytes = _extractPathBytesFromLog(matchedRxLog); final packetPathBytes = _extractPathBytesFromLog(matchedRxLog);
final packetPathHex = packetPathBytes final packetPathHex = (receptionDetails?.pathBytes ?? packetPathBytes)
?.map((b) => b.toRadixString(16).padLeft(2, '0')) ?.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(':'); .join(':');
final snrDb = final snrDb =
receptionDetails?.snrDb ??
matchedRxLog?.logRxDataInfo?.snrDb ?? matchedRxLog?.logRxDataInfo?.snrDb ??
(widget.message.lastEchoSnrRaw != null (widget.message.lastEchoSnrRaw != null
? (widget.message.lastEchoSnrRaw!.toSigned(8) / 4.0) ? (widget.message.lastEchoSnrRaw!.toSigned(8) / 4.0)
: null); : null);
final rssiDbm = final rssiDbm =
matchedRxLog?.logRxDataInfo?.rssiDbm ?? widget.message.lastEchoRssiDbm; receptionDetails?.rssiDbm ??
matchedRxLog?.logRxDataInfo?.rssiDbm ??
widget.message.lastEchoRssiDbm;
final retryCause = _retryCauseLabel(widget.message); final retryCause = _retryCauseLabel(widget.message);
final retryResult = _retryResultLabel(widget.message); final retryResult = _retryResultLabel(widget.message);
final retryMode = _retryModeLabel(widget.message); final retryMode = _retryModeLabel(widget.message);
@@ -604,6 +513,9 @@ class _MessageBubbleState extends State<MessageBubble> {
'Matched RX RSSI: ${rssiDbm ?? '-'}', 'Matched RX RSSI: ${rssiDbm ?? '-'}',
'Matched RX SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}', 'Matched RX SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}',
'Matched path bytes: ${packetPathHex ?? '-'}', '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 ?? '-'}', 'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}',
'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}', 'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}',
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}', 'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
@@ -767,7 +679,7 @@ class _MessageBubbleState extends State<MessageBubble> {
_techBadge( _techBadge(
context, context,
icon: Icons.route, icon: Icons.route,
label: _hopDisplayLabel(widget.message), label: hopDisplayLabel(widget.message),
), ),
_techBadge( _techBadge(
context, context,
@@ -855,6 +767,30 @@ class _MessageBubbleState extends State<MessageBubble> {
value: widget.message.expectedAckTag! value: widget.message.expectedAckTag!
.toString(), .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) if (widget.message.suggestedTimeoutMs != null)
_detailRow( _detailRow(
context, context,
@@ -1263,6 +1199,18 @@ class _MessageBubbleState extends State<MessageBubble> {
'${fraction}Z'; '${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( BlePacketLog? _findBestMatchingRxLog(
List<BlePacketLog> logs, List<BlePacketLog> logs,
Message message, Message message,
@@ -1591,225 +1539,12 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
} }
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) { String _hopDebugLabel(Message message) {
if (message.pathLen >= 255 && message.isContactMessage) { if (message.pathLen >= 255 && message.isContactMessage) {
return 'Direct (raw: ${message.pathLen})'; return 'Direct (raw: ${message.pathLen})';
} }
if (message.pathLen >= 255) return 'Unknown (raw: ${message.pathLen})'; if (message.pathLen >= 255) return 'Unknown (raw: ${message.pathLen})';
return _hopDisplayLabel(message); return hopDisplayLabel(message);
} }
String? _retryCauseLabel(Message message) { String? _retryCauseLabel(Message message) {
@@ -1891,110 +1626,6 @@ class _MessageBubbleState extends State<MessageBubble> {
return null; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Display system messages with minimal styling // Display system messages with minimal styling
@@ -2015,9 +1646,13 @@ class _MessageBubbleState extends State<MessageBubble> {
// Determine if this is own message // Determine if this is own message
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
final selfPublicKey = connectionProvider.deviceInfo.publicKey; final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage = final isOwnMessage =
message.isSentMessage || message.isFromSelf(selfPublicKey); message.isSentMessage || message.isFromSelf(selfPublicKey);
final receptionDetails = !isOwnMessage
? messagesProvider.getMessageReceptionDetails(message.id)
: null;
final matchedRxLog = !isOwnMessage final matchedRxLog = !isOwnMessage
? _findBestMatchingRxLog( ? _findBestMatchingRxLog(
connectionProvider.bleService.packetLogs, connectionProvider.bleService.packetLogs,
@@ -2025,12 +1660,15 @@ class _MessageBubbleState extends State<MessageBubble> {
) )
: null; : null;
final snrDb = final snrDb =
receptionDetails?.snrDb ??
matchedRxLog?.logRxDataInfo?.snrDb ?? matchedRxLog?.logRxDataInfo?.snrDb ??
(message.lastEchoSnrRaw != null (message.lastEchoSnrRaw != null
? (message.lastEchoSnrRaw!.toSigned(8) / 4.0) ? (message.lastEchoSnrRaw!.toSigned(8) / 4.0)
: null); : null);
final rssiDbm = final rssiDbm =
matchedRxLog?.logRxDataInfo?.rssiDbm ?? message.lastEchoRssiDbm; receptionDetails?.rssiDbm ??
matchedRxLog?.logRxDataInfo?.rssiDbm ??
message.lastEchoRssiDbm;
// Look up contact information for rich display name // Look up contact information for rich display name
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
@@ -2297,7 +1935,7 @@ class _MessageBubbleState extends State<MessageBubble> {
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
), ),
_buildHeaderAvatar( buildMessageHeaderAvatar(
context, context,
isOwnMessage: isOwnMessage, isOwnMessage: isOwnMessage,
isChannelMessage: message.isChannelMessage, isChannelMessage: message.isChannelMessage,
@@ -2330,7 +1968,7 @@ class _MessageBubbleState extends State<MessageBubble> {
if (message.isChannelMessage) if (message.isChannelMessage)
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: _buildChannelHeaderPill( child: buildChannelHeaderPill(
context, context,
label: isOwnMessage label: isOwnMessage
? recipientDisplayName! ? recipientDisplayName!
@@ -2340,7 +1978,7 @@ class _MessageBubbleState extends State<MessageBubble> {
else else
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: _buildDirectHeaderCounterpart( child: buildDirectHeaderCounterpart(
context, context,
label: directCounterpartLabel!, label: directCounterpartLabel!,
), ),
@@ -2636,9 +2274,10 @@ class _MessageBubbleState extends State<MessageBubble> {
!message.isSentMessage && !message.isSentMessage &&
_showReceivedStats) ...[ _showReceivedStats) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
_buildReceivedSignalStatus( buildReceivedSignalStatus(
context, context,
message, message,
receptionDetails: receptionDetails,
rssiDbm: rssiDbm, rssiDbm: rssiDbm,
snrDb: snrDb, snrDb: snrDb,
), ),
@@ -2830,9 +2469,9 @@ class _MessageBubbleState extends State<MessageBubble> {
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
children: [ children: [
Icon( Icon(
_getDeliveryStatusIcon(message.deliveryStatus), getDeliveryStatusIcon(message.deliveryStatus),
size: 12, size: 12,
color: _getDeliveryStatusColor(message.deliveryStatus), color: getDeliveryStatusColor(message.deliveryStatus),
), ),
const SizedBox(width: 3), const SizedBox(width: 3),
Expanded( Expanded(
@@ -2844,7 +2483,7 @@ class _MessageBubbleState extends State<MessageBubble> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall style: Theme.of(context).textTheme.labelSmall
?.copyWith( ?.copyWith(
color: _getDeliveryStatusColor( color: getDeliveryStatusColor(
message.deliveryStatus, message.deliveryStatus,
), ),
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
@@ -2895,9 +2534,12 @@ class _MessageBubbleState extends State<MessageBubble> {
], ],
], ],
), ),
if (_shouldShowSentChannelStats(message)) ...[ if (shouldShowSentChannelStats(
message,
showReceivedStats: _showReceivedStats,
)) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
_buildChannelEchoStatus(context, message), buildChannelEchoStatus(context, message),
], ],
], ],
], ],
@@ -2912,7 +2554,7 @@ class _MessageBubbleState extends State<MessageBubble> {
: CrossAxisAlignment.start, : CrossAxisAlignment.start,
children: [ children: [
bubble, bubble,
_buildBubbleMetaFooter( buildBubbleMetaFooter(
context, context,
message: message, message: message,
isSarMarker: isSarMarker, isSarMarker: isSarMarker,
@@ -2932,85 +2574,3 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
} }
} }
/// 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,
),
),
],
),
);
}
}

View File

@@ -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 = <Widget>[];
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,
);
}

View File

@@ -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;
}
}

View File

@@ -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),
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/voice_provider.dart'; import '../../providers/voice_provider.dart';
@@ -218,11 +219,18 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
int pathLen = 0, int pathLen = 0,
}) async { }) async {
if (_isRequesting) return; if (_isRequesting) return;
setState(() {
_isRequesting = true;
_autoPlayWhenReady = true;
_errorText = null;
});
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final voiceProvider = context.read<VoiceProvider>(); final voiceProvider = context.read<VoiceProvider>();
voiceProvider.resumeIncomingSession(sessionId); voiceProvider.resumeIncomingSession(sessionId);
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final resolution = await TransmissionTargetResolver.resolveFetchTarget( final appProvider = context.read<AppProvider>();
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider, contactsProvider: contactsProvider,
refreshContacts: connectionProvider.getContacts, refreshContacts: connectionProvider.getContacts,
isSentByMe: widget.isSentByMe, isSentByMe: widget.isSentByMe,
@@ -235,6 +243,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (!mounted) return; if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) { if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert( await _showBlockingAlert(
'Cannot fetch voice', 'Cannot fetch voice',
'Sender contact is unknown. Sync contacts first.', 'Sender contact is unknown. Sync contacts first.',
@@ -242,6 +251,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
return; return;
} }
if (resolution.failure == TransmissionTargetFailure.unknownRoute) { if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert( await _showBlockingAlert(
'Cannot fetch voice', 'Cannot fetch voice',
'Sender route is unknown. Sync contacts/path first.', 'Sender route is unknown. Sync contacts/path first.',
@@ -249,27 +259,85 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
return; return;
} }
if (resolution.failure == TransmissionTargetFailure.tooFar) { if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert( await _showBlockingAlert(
'Cannot fetch voice', 'Cannot fetch voice',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).', 'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
); );
return; 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) { if (sender.outPathLen >= 2) {
_showToast( _showToast(
'Voice fetch over ${sender.outPathLen} hops may take a while.', 'Voice fetch over ${sender.outPathLen} hops may take a while.',
); );
} }
if (!mounted) return;
setState(() {
_errorText = null;
});
final deviceKey = connectionProvider.deviceInfo.publicKey; final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) { if (deviceKey == null || deviceKey.length < 6) {
_clearRequestState();
await _showBlockingAlert( await _showBlockingAlert(
'Cannot fetch voice', 'Cannot fetch voice',
'Device key is unavailable.', 'Device key is unavailable.',
@@ -305,12 +373,6 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
version: 2, version: 2,
); );
setState(() {
_isRequesting = true;
_autoPlayWhenReady = true;
_errorText = null;
});
try { try {
await connectionProvider.sendRawVoicePacket( await connectionProvider.sendRawVoicePacket(
contactPath: sender.outPath, contactPath: sender.outPath,
@@ -373,6 +435,14 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
}); });
} }
void _clearRequestState() {
if (!mounted) return;
setState(() {
_isRequesting = false;
_autoPlayWhenReady = false;
});
}
void _cancelReceive(String sessionId) { void _cancelReceive(String sessionId) {
if (!mounted) return; if (!mounted) return;
_requestTimeoutTimer?.cancel(); _requestTimeoutTimer?.cancel();

View File

@@ -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);
});
}

View File

@@ -40,7 +40,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
group('MessagesProvider retransmission', () { group('MessagesProvider retransmission', () {
test('direct messages stay pending until delivery ACK arrives', () { test('direct messages become sent before delivery ACK arrives', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
provider.addSentMessage( provider.addSentMessage(
_buildDirectMessage('m1'), _buildDirectMessage('m1'),
@@ -51,7 +51,7 @@ void main() {
expect( expect(
provider.messages.single.deliveryStatus, provider.messages.single.deliveryStatus,
MessageDeliveryStatus.sending, MessageDeliveryStatus.sent,
); );
expect(provider.messages.single.expectedAckTag, 77); expect(provider.messages.single.expectedAckTag, 77);
@@ -64,6 +64,24 @@ void main() {
expect(provider.messages.single.roundTripTimeMs, 180); 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', () { test('channel messages are marked sent immediately', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
provider.addSentMessage( provider.addSentMessage(

View File

@@ -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,
);
}