Remove 100ms BLE command delay

This commit is contained in:
Janez T
2026-03-07 09:23:29 +01:00
parent f6c5a3a4ca
commit 2bc0e21cf0
13 changed files with 260 additions and 274 deletions

View File

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

View File

@@ -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()

View File

@@ -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',

View File

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

View File

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

View File

@@ -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,

View File

@@ -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,

View File

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

View File

@@ -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,

View File

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

View File

@@ -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"

View File

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

View File

@@ -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 {