mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Add transmission timing details
This commit is contained in:
63
lib/models/message_reception_details.dart
Normal file
63
lib/models/message_reception_details.dart
Normal 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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
129
lib/utils/log_rx_route_decoder.dart
Normal file
129
lib/utils/log_rx_route_decoder.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
149
lib/utils/message_airtime_estimator.dart
Normal file
149
lib/utils/message_airtime_estimator.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
lib/utils/raw_route_probe.dart
Normal file
87
lib/utils/raw_route_probe.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
160
lib/widgets/messages/message_bubble_header.dart
Normal file
160
lib/widgets/messages/message_bubble_header.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
303
lib/widgets/messages/message_bubble_signal.dart
Normal file
303
lib/widgets/messages/message_bubble_signal.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
107
lib/widgets/messages/system_message_bubble.dart
Normal file
107
lib/widgets/messages/system_message_bubble.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
27
test/models/message_reception_details_test.dart
Normal file
27
test/models/message_reception_details_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
92
test/utils/log_rx_route_decoder_test.dart
Normal file
92
test/utils/log_rx_route_decoder_test.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user