mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Remove 100ms BLE command delay
This commit is contained in:
@@ -1,3 +1,26 @@
|
|||||||
|
const int _transmitEstimateToleranceMs = 1500;
|
||||||
|
|
||||||
|
int? sanitizeEstimatedTransmitMs({
|
||||||
|
required int? estimatedTransmitMs,
|
||||||
|
required int? senderToReceiptMs,
|
||||||
|
}) {
|
||||||
|
if (estimatedTransmitMs == null || estimatedTransmitMs <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (senderToReceiptMs == null || senderToReceiptMs <= 0) {
|
||||||
|
return estimatedTransmitMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender timestamps are second-granularity, so allow a small cushion before
|
||||||
|
// treating the estimate as impossible for the observed delivery time.
|
||||||
|
if (estimatedTransmitMs > senderToReceiptMs + _transmitEstimateToleranceMs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return estimatedTransmitMs;
|
||||||
|
}
|
||||||
|
|
||||||
class MessageReceptionDetails {
|
class MessageReceptionDetails {
|
||||||
final DateTime capturedAt;
|
final DateTime capturedAt;
|
||||||
final DateTime? packetLoggedAt;
|
final DateTime? packetLoggedAt;
|
||||||
|
|||||||
@@ -70,10 +70,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
final Map<String, int> _voiceMissingRetryAttempts = {};
|
final Map<String, int> _voiceMissingRetryAttempts = {};
|
||||||
final Map<String, Timer> _imageMissingRetryTimers = {};
|
final Map<String, Timer> _imageMissingRetryTimers = {};
|
||||||
final Map<String, int> _imageMissingRetryAttempts = {};
|
final Map<String, int> _imageMissingRetryAttempts = {};
|
||||||
final FragmentAckWaitRegistry _voiceFragmentAckWaiters =
|
|
||||||
FragmentAckWaitRegistry();
|
|
||||||
final FragmentAckWaitRegistry _imageFragmentAckWaiters =
|
|
||||||
FragmentAckWaitRegistry();
|
|
||||||
final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry();
|
final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry();
|
||||||
final Map<String, Future<bool>> _pendingRawRouteProbes = {};
|
final Map<String, Future<bool>> _pendingRawRouteProbes = {};
|
||||||
Timer? _packetCaptureFlushTimer;
|
Timer? _packetCaptureFlushTimer;
|
||||||
@@ -468,27 +464,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
payload: payload,
|
payload: payload,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
voiceProvider.waitForFragmentAckCallback =
|
|
||||||
({
|
|
||||||
required sessionId,
|
|
||||||
required index,
|
|
||||||
timeout = const Duration(seconds: 8),
|
|
||||||
}) => _waitForVoiceFragmentAck(
|
|
||||||
sessionId: sessionId,
|
|
||||||
index: index,
|
|
||||||
timeout: timeout,
|
|
||||||
);
|
|
||||||
imageProvider.waitForFragmentAckCallback =
|
|
||||||
({
|
|
||||||
required sessionId,
|
|
||||||
required index,
|
|
||||||
timeout = const Duration(seconds: 8),
|
|
||||||
}) => _waitForImageFragmentAck(
|
|
||||||
sessionId: sessionId,
|
|
||||||
index: index,
|
|
||||||
timeout: timeout,
|
|
||||||
);
|
|
||||||
|
|
||||||
// When a contact is received from BLE
|
// When a contact is received from BLE
|
||||||
connectionProvider.onContactReceived = (contact) {
|
connectionProvider.onContactReceived = (contact) {
|
||||||
// Pass device public key to filter out our own contact
|
// Pass device public key to filter out our own contact
|
||||||
@@ -814,18 +789,27 @@ class AppProvider with ChangeNotifier {
|
|||||||
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
|
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
|
||||||
final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload);
|
final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload);
|
||||||
if (rawProbeRequest != null) {
|
if (rawProbeRequest != null) {
|
||||||
|
debugPrint(
|
||||||
|
'📡 [AppProvider] Incoming raw route probe: nonce=${rawProbeRequest.nonce.toRadixString(16)} requester=${rawProbeRequest.requesterKey6}',
|
||||||
|
);
|
||||||
_handleRawRouteProbeRequest(rawProbeRequest);
|
_handleRawRouteProbeRequest(rawProbeRequest);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final rawProbeAck = RawRouteProbeAck.tryParseBinary(payload);
|
final rawProbeAck = RawRouteProbeAck.tryParseBinary(payload);
|
||||||
if (rawProbeAck != null) {
|
if (rawProbeAck != null) {
|
||||||
|
debugPrint(
|
||||||
|
'📡 [AppProvider] Incoming raw route probe ACK: nonce=${rawProbeAck.nonce.toRadixString(16)}',
|
||||||
|
);
|
||||||
_completeRawRouteProbeAck(rawProbeAck.nonce);
|
_completeRawRouteProbeAck(rawProbeAck.nonce);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload);
|
final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload);
|
||||||
if (voiceFetchRequest != null) {
|
if (voiceFetchRequest != null) {
|
||||||
|
debugPrint(
|
||||||
|
'🎙️ [AppProvider] Incoming voice fetch request: session=${voiceFetchRequest.sessionId} want=${voiceFetchRequest.want} requester=${voiceFetchRequest.requesterKey6}',
|
||||||
|
);
|
||||||
final requester = _resolveVoiceFetchRequester(voiceFetchRequest);
|
final requester = _resolveVoiceFetchRequester(voiceFetchRequest);
|
||||||
if (requester == null) {
|
if (requester == null) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -906,18 +890,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final voiceAck = VoiceFragmentAck.tryParseBinary(payload);
|
|
||||||
if (voiceAck != null) {
|
|
||||||
_completeVoiceFragmentAck(voiceAck.sessionId, voiceAck.index);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final imageAck = ImageFragmentAck.tryParseBinary(payload);
|
|
||||||
if (imageAck != null) {
|
|
||||||
_completeImageFragmentAck(imageAck.sessionId, imageAck.index);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ImagePacket.isImageBinary(payload)) {
|
if (ImagePacket.isImageBinary(payload)) {
|
||||||
final frag = ImagePacket.tryParseBinary(payload);
|
final frag = ImagePacket.tryParseBinary(payload);
|
||||||
if (frag == null) return;
|
if (frag == null) return;
|
||||||
@@ -928,7 +900,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
width: session?.width ?? 0,
|
width: session?.width ?? 0,
|
||||||
height: session?.height ?? 0,
|
height: session?.height ?? 0,
|
||||||
);
|
);
|
||||||
_sendImageFragmentAck(frag);
|
|
||||||
_scheduleImageMissingRetry(
|
_scheduleImageMissingRetry(
|
||||||
frag.sessionId,
|
frag.sessionId,
|
||||||
justComplete: imageProvider.isComplete(frag.sessionId),
|
justComplete: imageProvider.isComplete(frag.sessionId),
|
||||||
@@ -941,7 +912,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
if (pkt == null) return;
|
if (pkt == null) return;
|
||||||
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
|
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
|
||||||
final justComplete = voiceProvider.addPacket(pkt);
|
final justComplete = voiceProvider.addPacket(pkt);
|
||||||
_sendVoiceFragmentAck(pkt);
|
|
||||||
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
|
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
|
||||||
// Insert or update the placeholder message in the chat list
|
// Insert or update the placeholder message in the chat list
|
||||||
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
|
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
|
||||||
@@ -1586,7 +1556,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
messagesProvider.addMessage(placeholder, contactLookup: (_) => '');
|
messagesProvider.addMessage(placeholder, contactLookup: (_) => '');
|
||||||
}
|
}
|
||||||
|
|
||||||
String _fragmentAckKey(String sessionId, int index) => '$sessionId:$index';
|
|
||||||
String _rawProbeKey(int nonce) =>
|
String _rawProbeKey(int nonce) =>
|
||||||
nonce.toRadixString(16).padLeft(8, '0').toLowerCase();
|
nonce.toRadixString(16).padLeft(8, '0').toLowerCase();
|
||||||
|
|
||||||
@@ -1627,6 +1596,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
debugPrint(
|
||||||
|
'📡 [AppProvider] Outgoing raw route probe: target=${target.advName} hops=${target.outPathLen} nonce=${nonce.toRadixString(16)}',
|
||||||
|
);
|
||||||
await connectionProvider.sendRawVoicePacket(
|
await connectionProvider.sendRawVoicePacket(
|
||||||
contactPath: target.outPath,
|
contactPath: target.outPath,
|
||||||
contactPathLen: target.outPathLen,
|
contactPathLen: target.outPathLen,
|
||||||
@@ -1660,54 +1632,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
return 'name:${target.advName}:${target.outPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
|
return 'name:${target.advName}:${target.outPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _waitForVoiceFragmentAck({
|
|
||||||
required String sessionId,
|
|
||||||
required int index,
|
|
||||||
Duration timeout = const Duration(seconds: 8),
|
|
||||||
}) => _voiceFragmentAckWaiters.waitFor(
|
|
||||||
_fragmentAckKey(sessionId, index),
|
|
||||||
timeout: timeout,
|
|
||||||
);
|
|
||||||
|
|
||||||
void _completeVoiceFragmentAck(String sessionId, int index) {
|
|
||||||
final completed = _voiceFragmentAckWaiters.complete(
|
|
||||||
_fragmentAckKey(sessionId, index),
|
|
||||||
);
|
|
||||||
if (completed == 0) {
|
|
||||||
debugPrint(
|
|
||||||
'ℹ️ [AppProvider] Voice fragment ACK had no waiter: $sessionId#$index',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
debugPrint(
|
|
||||||
'✅ [AppProvider] Voice fragment ACK received for $sessionId#$index ($completed waiter(s))',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> _waitForImageFragmentAck({
|
|
||||||
required String sessionId,
|
|
||||||
required int index,
|
|
||||||
Duration timeout = const Duration(seconds: 8),
|
|
||||||
}) => _imageFragmentAckWaiters.waitFor(
|
|
||||||
_fragmentAckKey(sessionId, index),
|
|
||||||
timeout: timeout,
|
|
||||||
);
|
|
||||||
|
|
||||||
void _completeImageFragmentAck(String sessionId, int index) {
|
|
||||||
final completed = _imageFragmentAckWaiters.complete(
|
|
||||||
_fragmentAckKey(sessionId, index),
|
|
||||||
);
|
|
||||||
if (completed == 0) {
|
|
||||||
debugPrint(
|
|
||||||
'ℹ️ [AppProvider] Image fragment ACK had no waiter: $sessionId#$index',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
debugPrint(
|
|
||||||
'✅ [AppProvider] Image fragment ACK received for $sessionId#$index ($completed waiter(s))',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleRawRouteProbeRequest(RawRouteProbeRequest request) {
|
void _handleRawRouteProbeRequest(RawRouteProbeRequest request) {
|
||||||
final requester = _resolveContactByPrefixHex(request.requesterKey6);
|
final requester = _resolveContactByPrefixHex(request.requesterKey6);
|
||||||
if (requester == null) {
|
if (requester == null) {
|
||||||
@@ -1726,6 +1650,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
if (requester.outPath.isEmpty) {
|
if (requester.outPath.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
debugPrint(
|
||||||
|
'📡 [AppProvider] Outgoing raw route probe ACK: requester=${requester.advName} hops=${requester.outPathLen} nonce=${request.nonce.toRadixString(16)}',
|
||||||
|
);
|
||||||
unawaited(
|
unawaited(
|
||||||
connectionProvider.sendRawVoicePacket(
|
connectionProvider.sendRawVoicePacket(
|
||||||
contactPath: requester.outPath,
|
contactPath: requester.outPath,
|
||||||
@@ -1739,46 +1666,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
_rawProbeWaiters.complete(_rawProbeKey(nonce));
|
_rawProbeWaiters.complete(_rawProbeKey(nonce));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _sendVoiceFragmentAck(VoicePacket packet) {
|
|
||||||
final senderKey6 = _voiceSessionSenderKey6[packet.sessionId];
|
|
||||||
if (senderKey6 == null) return;
|
|
||||||
final sender = _resolveContactByPrefixHex(senderKey6);
|
|
||||||
if (sender == null) return;
|
|
||||||
if (sender.outPathLen < 0 || sender.outPathLen > _maxDirectPayloadHops) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
unawaited(
|
|
||||||
connectionProvider.sendRawVoicePacket(
|
|
||||||
contactPath: sender.outPath,
|
|
||||||
contactPathLen: sender.outPathLen,
|
|
||||||
payload: VoiceFragmentAck(
|
|
||||||
sessionId: packet.sessionId,
|
|
||||||
index: packet.index,
|
|
||||||
).encodeBinary(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _sendImageFragmentAck(ImagePacket fragment) {
|
|
||||||
final senderKey6 = _imageSessionSenderKey6[fragment.sessionId];
|
|
||||||
if (senderKey6 == null) return;
|
|
||||||
final sender = _resolveContactByPrefixHex(senderKey6);
|
|
||||||
if (sender == null) return;
|
|
||||||
if (sender.outPathLen < 0 || sender.outPathLen > _maxDirectPayloadHops) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
unawaited(
|
|
||||||
connectionProvider.sendRawVoicePacket(
|
|
||||||
contactPath: sender.outPath,
|
|
||||||
contactPathLen: sender.outPathLen,
|
|
||||||
payload: ImageFragmentAck(
|
|
||||||
sessionId: fragment.sessionId,
|
|
||||||
index: fragment.index,
|
|
||||||
).encodeBinary(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
MessageReceptionDetails? _buildReceptionDetailsSnapshot(Message message) {
|
MessageReceptionDetails? _buildReceptionDetailsSnapshot(Message message) {
|
||||||
final matchedRxLog = _findBestMatchingRxLog(message);
|
final matchedRxLog = _findBestMatchingRxLog(message);
|
||||||
final estimatedTx = estimateMessageTransmitDuration(
|
final estimatedTx = estimateMessageTransmitDuration(
|
||||||
@@ -1788,9 +1675,12 @@ class AppProvider with ChangeNotifier {
|
|||||||
radioCr: connectionProvider.deviceInfo.radioCr,
|
radioCr: connectionProvider.deviceInfo.radioCr,
|
||||||
);
|
);
|
||||||
final senderToReceiptMs = _senderToReceiptMs(message);
|
final senderToReceiptMs = _senderToReceiptMs(message);
|
||||||
final estimatedTransmitMs = estimatedTx > Duration.zero
|
final estimatedTransmitMs = sanitizeEstimatedTransmitMs(
|
||||||
|
estimatedTransmitMs: estimatedTx > Duration.zero
|
||||||
? estimatedTx.inMilliseconds
|
? estimatedTx.inMilliseconds
|
||||||
: null;
|
: null,
|
||||||
|
senderToReceiptMs: senderToReceiptMs,
|
||||||
|
);
|
||||||
final postTransmitDelayMs =
|
final postTransmitDelayMs =
|
||||||
senderToReceiptMs != null && estimatedTransmitMs != null
|
senderToReceiptMs != null && estimatedTransmitMs != null
|
||||||
? (senderToReceiptMs - estimatedTransmitMs).clamp(0, 86400000).toInt()
|
? (senderToReceiptMs - estimatedTransmitMs).clamp(0, 86400000).toInt()
|
||||||
|
|||||||
@@ -9,13 +9,6 @@ typedef RawPacketSender =
|
|||||||
required Uint8List payload,
|
required Uint8List payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
typedef FragmentAckWaiter =
|
|
||||||
Future<bool> Function({
|
|
||||||
required String sessionId,
|
|
||||||
required int index,
|
|
||||||
Duration timeout,
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<bool> serveCachedSessionFragments<T>({
|
Future<bool> serveCachedSessionFragments<T>({
|
||||||
required String providerLabel,
|
required String providerLabel,
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
@@ -25,9 +18,7 @@ Future<bool> serveCachedSessionFragments<T>({
|
|||||||
required int Function(T fragment) indexOf,
|
required int Function(T fragment) indexOf,
|
||||||
required Uint8List Function(T fragment) encodeBinary,
|
required Uint8List Function(T fragment) encodeBinary,
|
||||||
required RawPacketSender? sendRawPacket,
|
required RawPacketSender? sendRawPacket,
|
||||||
FragmentAckWaiter? waitForFragmentAck,
|
|
||||||
Set<int>? requestedIndices,
|
Set<int>? requestedIndices,
|
||||||
Duration ackTimeout = const Duration(seconds: 8),
|
|
||||||
}) async {
|
}) async {
|
||||||
if (fragments.isEmpty) {
|
if (fragments.isEmpty) {
|
||||||
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
|
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
|
||||||
@@ -65,24 +56,12 @@ Future<bool> serveCachedSessionFragments<T>({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final ackFuture = waitForFragmentAck?.call(
|
|
||||||
sessionId: sessionId,
|
|
||||||
index: index,
|
|
||||||
timeout: ackTimeout,
|
|
||||||
);
|
|
||||||
await sendRawPacket(
|
await sendRawPacket(
|
||||||
contactPath: requester.outPath,
|
contactPath: requester.outPath,
|
||||||
contactPathLen: requester.outPathLen,
|
contactPathLen: requester.outPathLen,
|
||||||
payload: encodeBinary(fragment),
|
payload: encodeBinary(fragment),
|
||||||
);
|
);
|
||||||
servedCount++;
|
servedCount++;
|
||||||
if (ackFuture != null) {
|
|
||||||
final acked = await ackFuture;
|
|
||||||
if (!acked) {
|
|
||||||
debugPrint('⚠️ [$providerLabel] ACK timeout for $sessionId#$index');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st',
|
'❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st',
|
||||||
|
|||||||
@@ -70,12 +70,6 @@ class ImageProvider with ChangeNotifier {
|
|||||||
required Uint8List payload,
|
required Uint8List payload,
|
||||||
})?
|
})?
|
||||||
sendRawPacketCallback;
|
sendRawPacketCallback;
|
||||||
Future<bool> Function({
|
|
||||||
required String sessionId,
|
|
||||||
required int index,
|
|
||||||
Duration timeout,
|
|
||||||
})?
|
|
||||||
waitForFragmentAckCallback;
|
|
||||||
|
|
||||||
ImageProvider() {
|
ImageProvider() {
|
||||||
_restore();
|
_restore();
|
||||||
@@ -264,7 +258,6 @@ class ImageProvider with ChangeNotifier {
|
|||||||
indexOf: (fragment) => fragment.index,
|
indexOf: (fragment) => fragment.index,
|
||||||
encodeBinary: (fragment) => fragment.encodeBinary(),
|
encodeBinary: (fragment) => fragment.encodeBinary(),
|
||||||
sendRawPacket: sendRawPacketCallback,
|
sendRawPacket: sendRawPacketCallback,
|
||||||
waitForFragmentAck: waitForFragmentAckCallback,
|
|
||||||
requestedIndices: requestedIndices,
|
requestedIndices: requestedIndices,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,12 +71,6 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
required Uint8List payload,
|
required Uint8List payload,
|
||||||
})?
|
})?
|
||||||
sendRawPacketCallback;
|
sendRawPacketCallback;
|
||||||
Future<bool> Function({
|
|
||||||
required String sessionId,
|
|
||||||
required int index,
|
|
||||||
Duration timeout,
|
|
||||||
})?
|
|
||||||
waitForFragmentAckCallback;
|
|
||||||
|
|
||||||
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
|
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
|
||||||
|
|
||||||
@@ -217,7 +211,6 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
indexOf: (packet) => packet.index,
|
indexOf: (packet) => packet.index,
|
||||||
encodeBinary: (packet) => packet.encodeBinary(),
|
encodeBinary: (packet) => packet.encodeBinary(),
|
||||||
sendRawPacket: sendRawPacketCallback,
|
sendRawPacket: sendRawPacketCallback,
|
||||||
waitForFragmentAck: waitForFragmentAckCallback,
|
|
||||||
requestedIndices: requestedIndices,
|
requestedIndices: requestedIndices,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,6 +406,9 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
|||||||
|
|
||||||
final payload = request.encodeBinary();
|
final payload = request.encodeBinary();
|
||||||
try {
|
try {
|
||||||
|
debugPrint(
|
||||||
|
'📷 [ImageMessageBubble] Outgoing image fetch request: session=${envelope.sessionId} want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.outPathLen}',
|
||||||
|
);
|
||||||
await conn.sendRawVoicePacket(
|
await conn.sendRawVoicePacket(
|
||||||
contactPath: sender.outPath,
|
contactPath: sender.outPath,
|
||||||
contactPathLen: sender.outPathLen,
|
contactPathLen: sender.outPathLen,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import '../../utils/sar_message_parser.dart';
|
|||||||
import '../../utils/key_comparison.dart';
|
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/message_airtime_estimator.dart';
|
||||||
import '../../utils/tictactoe_message_parser.dart';
|
import '../../utils/tictactoe_message_parser.dart';
|
||||||
import '../../utils/location_formats.dart';
|
import '../../utils/location_formats.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
@@ -2465,13 +2466,33 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
// Show single message delivery status
|
// Show single message delivery status
|
||||||
else if (!message.isChannelMessage ||
|
else if (!message.isChannelMessage ||
|
||||||
message.deliveryStatus == MessageDeliveryStatus.failed)
|
message.deliveryStatus == MessageDeliveryStatus.failed)
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final txEstimate = estimateMessageTransmitDuration(
|
||||||
|
message,
|
||||||
|
radioBw: connectionProvider.deviceInfo.radioBw,
|
||||||
|
radioSf: connectionProvider.deviceInfo.radioSf,
|
||||||
|
radioCr: connectionProvider.deviceInfo.radioCr,
|
||||||
|
);
|
||||||
|
final showSentDirectStats =
|
||||||
|
message.isContactMessage &&
|
||||||
|
message.deliveryStatus ==
|
||||||
|
MessageDeliveryStatus.delivered &&
|
||||||
|
_showReceivedStats &&
|
||||||
|
message.roundTripTimeMs != null;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
Row(
|
Row(
|
||||||
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(
|
||||||
@@ -2481,7 +2502,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
message.getLocalizedDeliveryStatus(context),
|
message.getLocalizedDeliveryStatus(context),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
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,
|
||||||
@@ -2496,14 +2519,17 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
MessageDeliveryStatus.failed) ...[
|
MessageDeliveryStatus.failed) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => _retryFailedMessage(context, message),
|
onTap: () =>
|
||||||
|
_retryFailedMessage(context, message),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 6,
|
horizontal: 6,
|
||||||
vertical: 2,
|
vertical: 2,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.orange.withValues(alpha: 0.2),
|
color: Colors.orange.withValues(
|
||||||
|
alpha: 0.2,
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Colors.orange,
|
color: Colors.orange,
|
||||||
@@ -2521,7 +2547,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'Retry',
|
'Retry',
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelSmall
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: Colors.orange,
|
color: Colors.orange,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -2534,6 +2562,19 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (showSentDirectStats) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
buildSentDirectSignalStatus(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
roundTripTimeMs: message.roundTripTimeMs!,
|
||||||
|
txEstimate: txEstimate,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
if (shouldShowSentChannelStats(
|
if (shouldShowSentChannelStats(
|
||||||
message,
|
message,
|
||||||
showReceivedStats: _showReceivedStats,
|
showReceivedStats: _showReceivedStats,
|
||||||
|
|||||||
@@ -179,6 +179,85 @@ Widget buildReceivedSignalStatus(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget buildSentDirectSignalStatus(
|
||||||
|
BuildContext context,
|
||||||
|
Message message, {
|
||||||
|
required int roundTripTimeMs,
|
||||||
|
required Duration txEstimate,
|
||||||
|
}) {
|
||||||
|
final estimatedTransmitMs = sanitizeEstimatedTransmitMs(
|
||||||
|
estimatedTransmitMs: txEstimate > Duration.zero
|
||||||
|
? txEstimate.inMilliseconds
|
||||||
|
: null,
|
||||||
|
senderToReceiptMs: roundTripTimeMs,
|
||||||
|
);
|
||||||
|
final postTransmitDelayMs = estimatedTransmitMs != null
|
||||||
|
? (roundTripTimeMs - estimatedTransmitMs).clamp(0, 86400000).toInt()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
_techChip(
|
||||||
|
context,
|
||||||
|
icon: Icons.alt_route,
|
||||||
|
label: hopDisplayLabel(message),
|
||||||
|
color: Colors.indigo,
|
||||||
|
),
|
||||||
|
_techChip(
|
||||||
|
context,
|
||||||
|
icon: Icons.schedule,
|
||||||
|
label: _formatMs(roundTripTimeMs),
|
||||||
|
color: Colors.deepPurple,
|
||||||
|
),
|
||||||
|
if (estimatedTransmitMs != null)
|
||||||
|
_techChip(
|
||||||
|
context,
|
||||||
|
icon: Icons.timelapse,
|
||||||
|
label: '~${_formatMs(estimatedTransmitMs)} tx',
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
if (postTransmitDelayMs != null)
|
||||||
|
_techChip(
|
||||||
|
context,
|
||||||
|
icon: Icons.hourglass_bottom,
|
||||||
|
label: '+${_formatMs(postTransmitDelayMs)} lag',
|
||||||
|
color: Colors.orange,
|
||||||
|
),
|
||||||
|
if (message.retryAttempt > 0)
|
||||||
|
_techChip(
|
||||||
|
context,
|
||||||
|
icon: Icons.refresh,
|
||||||
|
label: 'retry ${message.retryAttempt}/3',
|
||||||
|
color: Colors.redAccent,
|
||||||
|
),
|
||||||
|
if (message.suggestedTimeoutMs != null)
|
||||||
|
_techChip(
|
||||||
|
context,
|
||||||
|
icon: Icons.timer_outlined,
|
||||||
|
label: 'timeout ${_formatMs(message.suggestedTimeoutMs!)}',
|
||||||
|
color: Colors.blueGrey,
|
||||||
|
),
|
||||||
|
if (message.usedFloodFallback)
|
||||||
|
_techChip(
|
||||||
|
context,
|
||||||
|
icon: Icons.waves,
|
||||||
|
label: 'flood fallback',
|
||||||
|
color: Colors.teal,
|
||||||
|
)
|
||||||
|
else if (message.expectedAckTag != null)
|
||||||
|
_techChip(
|
||||||
|
context,
|
||||||
|
icon: Icons.route,
|
||||||
|
label: 'direct ACK',
|
||||||
|
color: Colors.indigo,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
String _formatMs(int value) {
|
String _formatMs(int value) {
|
||||||
if (value >= 60000) {
|
if (value >= 60000) {
|
||||||
final minutes = value ~/ 60000;
|
final minutes = value ~/ 60000;
|
||||||
|
|||||||
@@ -374,6 +374,9 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
debugPrint(
|
||||||
|
'🎙️ [VoiceMessageBubble] Outgoing voice fetch request: session=$sessionId want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.outPathLen}',
|
||||||
|
);
|
||||||
await connectionProvider.sendRawVoicePacket(
|
await connectionProvider.sendRawVoicePacket(
|
||||||
contactPath: sender.outPath,
|
contactPath: sender.outPath,
|
||||||
contactPathLen: sender.outPathLen,
|
contactPathLen: sender.outPathLen,
|
||||||
|
|||||||
@@ -849,11 +849,9 @@ packages:
|
|||||||
meshcore_client:
|
meshcore_client:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "../meshcore_client"
|
||||||
ref: main
|
relative: true
|
||||||
resolved-ref: "3f870e98ee9527a3137bfcbdd1454036912fb609"
|
source: path
|
||||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
|
||||||
source: git
|
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ dev_dependencies:
|
|||||||
|
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
path_provider_foundation: 2.5.1
|
path_provider_foundation: 2.5.1
|
||||||
|
meshcore_client:
|
||||||
|
path: ../meshcore_client
|
||||||
|
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: "launcher_icon"
|
android: "launcher_icon"
|
||||||
|
|||||||
@@ -2,6 +2,29 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:meshcore_sar_app/models/message_reception_details.dart';
|
import 'package:meshcore_sar_app/models/message_reception_details.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
test('drops impossible transmit estimate for received messages', () {
|
||||||
|
expect(
|
||||||
|
sanitizeEstimatedTransmitMs(
|
||||||
|
estimatedTransmitMs: 16 * 60 * 1000 + 54 * 1000,
|
||||||
|
senderToReceiptMs: 4200,
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'keeps close transmit estimate despite second-level timestamp rounding',
|
||||||
|
() {
|
||||||
|
expect(
|
||||||
|
sanitizeEstimatedTransmitMs(
|
||||||
|
estimatedTransmitMs: 1800,
|
||||||
|
senderToReceiptMs: 900,
|
||||||
|
),
|
||||||
|
1800,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('round trips reception details json', () {
|
test('round trips reception details json', () {
|
||||||
final details = MessageReceptionDetails(
|
final details = MessageReceptionDetails(
|
||||||
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000000),
|
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000000),
|
||||||
|
|||||||
@@ -47,9 +47,8 @@ void main() {
|
|||||||
expect(ok, isFalse);
|
expect(ok, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sends only requested indices and waits for ack', () async {
|
test('sends only requested indices', () async {
|
||||||
final sent = <Uint8List>[];
|
final sent = <Uint8List>[];
|
||||||
final waited = <int>[];
|
|
||||||
final ok = await serveCachedSessionFragments<_Fragment>(
|
final ok = await serveCachedSessionFragments<_Fragment>(
|
||||||
providerLabel: 'TestProvider',
|
providerLabel: 'TestProvider',
|
||||||
sessionId: 'deadbeef',
|
sessionId: 'deadbeef',
|
||||||
@@ -70,15 +69,6 @@ void main() {
|
|||||||
}) async {
|
}) async {
|
||||||
sent.add(payload);
|
sent.add(payload);
|
||||||
},
|
},
|
||||||
waitForFragmentAck:
|
|
||||||
({
|
|
||||||
required sessionId,
|
|
||||||
required index,
|
|
||||||
timeout = const Duration(seconds: 8),
|
|
||||||
}) async {
|
|
||||||
waited.add(index);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
requestedIndices: {1, 2},
|
requestedIndices: {1, 2},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -86,37 +76,6 @@ void main() {
|
|||||||
expect(sent.length, equals(2));
|
expect(sent.length, equals(2));
|
||||||
expect(sent[0], equals(Uint8List.fromList([20])));
|
expect(sent[0], equals(Uint8List.fromList([20])));
|
||||||
expect(sent[1], equals(Uint8List.fromList([30])));
|
expect(sent[1], equals(Uint8List.fromList([30])));
|
||||||
expect(waited, equals([1, 2]));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('fails when ack does not arrive', () async {
|
|
||||||
final ok = await serveCachedSessionFragments<_Fragment>(
|
|
||||||
providerLabel: 'TestProvider',
|
|
||||||
sessionId: 'deadbeef',
|
|
||||||
requester: _buildContact(outPathLen: 1),
|
|
||||||
fragments: [
|
|
||||||
_Fragment(0, Uint8List.fromList([1])),
|
|
||||||
],
|
|
||||||
maxDirectPayloadHops: 3,
|
|
||||||
indexOf: (f) => f.index,
|
|
||||||
encodeBinary: (f) => f.payload,
|
|
||||||
sendRawPacket:
|
|
||||||
({
|
|
||||||
required contactPath,
|
|
||||||
required contactPathLen,
|
|
||||||
required payload,
|
|
||||||
}) async {},
|
|
||||||
waitForFragmentAck:
|
|
||||||
({
|
|
||||||
required sessionId,
|
|
||||||
required index,
|
|
||||||
timeout = const Duration(seconds: 8),
|
|
||||||
}) async {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ok, isFalse);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fails when no requested index matches cached fragments', () async {
|
test('fails when no requested index matches cached fragments', () async {
|
||||||
|
|||||||
Reference in New Issue
Block a user