mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Add swarm mode transport doc
This commit is contained in:
@@ -3,11 +3,13 @@ import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_avif/flutter_avif.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/image_provider.dart' as ip;
|
||||
import '../../providers/messages_provider.dart';
|
||||
import '../../utils/image_message_parser.dart';
|
||||
import '../../utils/transmission_target_resolver.dart';
|
||||
import 'transfer_timeout.dart';
|
||||
@@ -33,7 +35,9 @@ class ImageMessageBubble extends StatefulWidget {
|
||||
|
||||
class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
static const int _maxFetchHops = 3;
|
||||
static const Duration _recentInboundActivityWindow = Duration(seconds: 3);
|
||||
bool _isRequesting = false;
|
||||
bool _isPartialRequest = false;
|
||||
String? _errorText;
|
||||
Timer? _requestTimeoutTimer;
|
||||
|
||||
@@ -59,6 +63,11 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
|
||||
return Consumer<ip.ImageProvider>(
|
||||
builder: (context, imageProvider, _) {
|
||||
final transferCount = context.select<MessagesProvider, int>(
|
||||
(provider) => provider.transferCountForSession(
|
||||
imageSessionId: envelope.sessionId,
|
||||
),
|
||||
);
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final session = imageProvider.session(envelope.sessionId);
|
||||
final sender = TransmissionTargetResolver.resolveLocalTarget(
|
||||
@@ -66,11 +75,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
);
|
||||
final effectivePathLen = sender != null && sender.outPathLen >= 0
|
||||
? sender.outPathLen
|
||||
final effectivePathLen = sender != null && sender.routeHasPath
|
||||
? sender.routeHopCount
|
||||
: widget.message.pathLen;
|
||||
final isComplete = imageProvider.isComplete(envelope.sessionId);
|
||||
final eta = imageProvider.estimateRemainingTransferTime(
|
||||
@@ -82,6 +90,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = null;
|
||||
});
|
||||
}
|
||||
@@ -91,6 +100,17 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
final received = session?.receivedCount ?? 0;
|
||||
final total = session?.total ?? envelope.total;
|
||||
final imageBytes = isComplete ? session?.imageBytes : null;
|
||||
final fragmentPresence =
|
||||
session?.fragments.map((fragment) => fragment != null).toList() ??
|
||||
List<bool>.filled(total, false);
|
||||
final isReceivingData =
|
||||
!_isRequesting &&
|
||||
!isComplete &&
|
||||
_hasRecentInboundActivity(
|
||||
lastReceivedAt: session?.lastFragmentAt,
|
||||
received: received,
|
||||
total: total,
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: isComplete
|
||||
@@ -110,8 +130,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
imageBytes: imageBytes,
|
||||
isComplete: isComplete,
|
||||
isRequesting: _isRequesting,
|
||||
isReceivingData: isReceivingData,
|
||||
received: received,
|
||||
total: total,
|
||||
fragmentPresence: fragmentPresence,
|
||||
envelope: envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
@@ -125,6 +147,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
_statusText(
|
||||
isComplete: isComplete,
|
||||
isRequesting: _isRequesting,
|
||||
isReceivingData: isReceivingData,
|
||||
isPartialRequest: _isPartialRequest,
|
||||
received: received,
|
||||
total: total,
|
||||
envelope: envelope,
|
||||
@@ -135,6 +159,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
isSentByMe: widget.isSentByMe,
|
||||
eta: eta,
|
||||
pathLen: effectivePathLen,
|
||||
transferCount: transferCount,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
@@ -156,8 +181,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
required Uint8List? imageBytes,
|
||||
required bool isComplete,
|
||||
required bool isRequesting,
|
||||
required bool isReceivingData,
|
||||
required int received,
|
||||
required int total,
|
||||
required List<bool> fragmentPresence,
|
||||
required ImageEnvelope envelope,
|
||||
required int? radioBw,
|
||||
required int? radioSf,
|
||||
@@ -180,19 +207,28 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (isRequesting) ...[
|
||||
// Download progress ring.
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: CircularProgressIndicator(
|
||||
value: total > 0 ? received / total : null,
|
||||
strokeWidth: 3,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 20),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.25),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_PacketBlockProgress(
|
||||
presence: fragmentPresence,
|
||||
activeColor: Theme.of(context).colorScheme.primary,
|
||||
highlightMissing: _isPartialRequest,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'$received/$total',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$received/$total',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
@@ -229,16 +265,25 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
] else ...[
|
||||
// Tap-to-load icon.
|
||||
IconButton(
|
||||
onPressed: () => _requestAndFetch(
|
||||
envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: pathLen,
|
||||
onPressed: isReceivingData
|
||||
? null
|
||||
: () => _requestAndFetch(
|
||||
envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: pathLen,
|
||||
),
|
||||
icon: Icon(
|
||||
isReceivingData
|
||||
? Icons.downloading_rounded
|
||||
: Icons.download_rounded,
|
||||
size: 40,
|
||||
),
|
||||
icon: const Icon(Icons.download_rounded, size: 40),
|
||||
color: Colors.white70,
|
||||
tooltip: 'Load image',
|
||||
tooltip: isReceivingData
|
||||
? 'Image is already being received'
|
||||
: 'Load image',
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -255,10 +300,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
int pathLen = 0,
|
||||
}) async {
|
||||
if (_isRequesting) return;
|
||||
setState(() {
|
||||
_isRequesting = true;
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
final conn = context.read<ConnectionProvider>();
|
||||
final imageProvider = context.read<ip.ImageProvider>();
|
||||
@@ -271,7 +312,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
@@ -322,7 +362,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
@@ -364,9 +403,18 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
}
|
||||
}
|
||||
|
||||
if (sender.outPathLen >= 2) {
|
||||
if (!sender.routeSupportsLegacyRawTransport) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sender.routeHopCount >= 2) {
|
||||
_showToast(
|
||||
'Image fetch over ${sender.outPathLen} hops may take a while.',
|
||||
'Image fetch over ${sender.routeHopCount} hops may take a while.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -390,28 +438,31 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
final missing = imageProvider.missingFragmentIndices(envelope.sessionId);
|
||||
final isPartialResume =
|
||||
missing.isNotEmpty && missing.length < envelope.total;
|
||||
setState(() {
|
||||
_isRequesting = true;
|
||||
_isPartialRequest = isPartialResume;
|
||||
_errorText = null;
|
||||
});
|
||||
final request = isPartialResume
|
||||
? ImageFetchRequest(
|
||||
sessionId: envelope.sessionId,
|
||||
want: 'missing',
|
||||
missingIndices: missing,
|
||||
requesterKey6: requesterKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
)
|
||||
: ImageFetchRequest(
|
||||
sessionId: envelope.sessionId,
|
||||
requesterKey6: requesterKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
|
||||
final payload = request.encodeBinary();
|
||||
try {
|
||||
debugPrint(
|
||||
'📷 [ImageMessageBubble] Outgoing image fetch request: session=${envelope.sessionId} want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.outPathLen}',
|
||||
'📷 [ImageMessageBubble] Outgoing image fetch request: session=${envelope.sessionId} want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.routeHopCount}',
|
||||
);
|
||||
await conn.sendRawVoicePacket(
|
||||
contactPath: sender.outPath,
|
||||
contactPathLen: sender.outPathLen,
|
||||
contactPathLen: sender.routeSignedPathLen,
|
||||
payload: payload,
|
||||
);
|
||||
} catch (_) {
|
||||
@@ -419,6 +470,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
_showToast('Image fetch failed to send request');
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = 'Image unavailable right now';
|
||||
});
|
||||
}
|
||||
@@ -427,8 +479,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
if (!mounted) return;
|
||||
|
||||
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||
final effectivePathLen = sender.outPathLen >= 0
|
||||
? sender.outPathLen
|
||||
final effectivePathLen = sender.routeHasPath
|
||||
? sender.routeHopCount
|
||||
: pathLen;
|
||||
final txEstimate = estimateImageTransmitDuration(
|
||||
fragmentCount: missing.isEmpty ? envelope.total : missing.length,
|
||||
@@ -450,6 +502,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
_showToast('Image fetch timed out');
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = 'Image fetch timed out';
|
||||
});
|
||||
}
|
||||
@@ -471,6 +524,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
_showToast('Image receive canceled');
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = 'Image receive canceled';
|
||||
});
|
||||
}
|
||||
@@ -479,6 +533,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -503,6 +558,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
static String _statusText({
|
||||
required bool isComplete,
|
||||
required bool isRequesting,
|
||||
required bool isReceivingData,
|
||||
required bool isPartialRequest,
|
||||
required int received,
|
||||
required int total,
|
||||
required ImageEnvelope envelope,
|
||||
@@ -513,6 +570,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
required String? error,
|
||||
required bool isSentByMe,
|
||||
required Duration? eta,
|
||||
required int transferCount,
|
||||
}) {
|
||||
final txEstimate = estimateImageTransmitDuration(
|
||||
fragmentCount: envelope.total,
|
||||
@@ -527,16 +585,25 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
if (error != null) return error;
|
||||
if (isRequesting) {
|
||||
final etaLabel = _formatEta(eta);
|
||||
return '📥 Loading… $received/$total · $etaLabel · $txEstimateLabel';
|
||||
final actionLabel = isPartialRequest
|
||||
? '📥 Fetching missing fragments…'
|
||||
: '📥 Loading…';
|
||||
return '$actionLabel $received/$total · $etaLabel · $txEstimateLabel';
|
||||
}
|
||||
if (isReceivingData) {
|
||||
final etaLabel = _formatEta(eta);
|
||||
return '📥 Receiving… $received/$total · $etaLabel · $txEstimateLabel';
|
||||
}
|
||||
if (isComplete) {
|
||||
final base =
|
||||
'🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}';
|
||||
return isSentByMe
|
||||
? '$base · ${envelope.total} seg · $txEstimateLabel'
|
||||
? '$base · ${envelope.total} seg · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
|
||||
: '$base · $txEstimateLabel';
|
||||
}
|
||||
return '🖼️ Tap to load · ${envelope.width}×${envelope.height} · $txEstimateLabel';
|
||||
return isSentByMe
|
||||
? '🖼️ ${envelope.width}×${envelope.height} · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
|
||||
: '🖼️ Tap to load · ${envelope.width}×${envelope.height} · $txEstimateLabel';
|
||||
}
|
||||
|
||||
static String _formatTransmitEstimate(Duration value) {
|
||||
@@ -554,6 +621,22 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
return 'ETA ~${minutes}m ${seconds}s';
|
||||
}
|
||||
|
||||
static String _formatTransferCount(int transferCount) {
|
||||
return '$transferCount transfer${transferCount == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
bool _hasRecentInboundActivity({
|
||||
required DateTime? lastReceivedAt,
|
||||
required int received,
|
||||
required int total,
|
||||
}) {
|
||||
if (lastReceivedAt == null || received <= 0 || received >= total) {
|
||||
return false;
|
||||
}
|
||||
return DateTime.now().difference(lastReceivedAt) <=
|
||||
_recentInboundActivityWindow;
|
||||
}
|
||||
|
||||
void _showFullScreen(BuildContext context, Uint8List imageBytes) {
|
||||
showGeneralDialog<void>(
|
||||
context: context,
|
||||
@@ -600,3 +683,67 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PacketBlockProgress extends StatelessWidget {
|
||||
final List<bool> presence;
|
||||
final Color activeColor;
|
||||
final bool highlightMissing;
|
||||
|
||||
const _PacketBlockProgress({
|
||||
required this.presence,
|
||||
required this.activeColor,
|
||||
this.highlightMissing = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (presence.isEmpty) {
|
||||
return const SizedBox(width: 96, height: 12);
|
||||
}
|
||||
|
||||
final bucketCount = presence.length <= 24 ? presence.length : 24;
|
||||
final bucketFill = List<double>.generate(bucketCount, (bucketIndex) {
|
||||
final start = (bucketIndex * presence.length) ~/ bucketCount;
|
||||
final end = ((bucketIndex + 1) * presence.length) ~/ bucketCount;
|
||||
final safeEnd = end <= start ? start + 1 : end;
|
||||
final slice = presence.sublist(start, safeEnd);
|
||||
final received = slice.where((value) => value).length;
|
||||
return slice.isEmpty ? 0.0 : received / slice.length;
|
||||
});
|
||||
final missingColor = highlightMissing
|
||||
? Colors.amberAccent
|
||||
: Colors.white.withValues(alpha: 0.14);
|
||||
|
||||
return SizedBox(
|
||||
width: 120,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (final fill in bucketFill)
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 12,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: fill > 0
|
||||
? activeColor.withValues(alpha: 0.18 + (0.72 * fill))
|
||||
: missingColor.withValues(
|
||||
alpha: highlightMissing ? 0.45 : 0.14,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(
|
||||
color: fill > 0
|
||||
? Colors.white.withValues(alpha: 0.18)
|
||||
: missingColor.withValues(
|
||||
alpha: highlightMissing ? 0.7 : 0.18,
|
||||
),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import '../../utils/tictactoe_message_parser.dart';
|
||||
import '../../utils/location_formats.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/message_extensions.dart';
|
||||
import '../../models/message_transfer_details.dart';
|
||||
import 'voice_message_bubble.dart';
|
||||
import 'image_message_bubble.dart';
|
||||
import 'tictactoe_message_bubble.dart';
|
||||
@@ -119,20 +120,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Create new message ID for retry
|
||||
final retryMessageId = '${failedMessage.id}_retry';
|
||||
|
||||
// Create retry message
|
||||
final retryMessage = failedMessage.copyWith(
|
||||
id: retryMessageId,
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
);
|
||||
|
||||
// Add retry message to provider
|
||||
Contact? roomContact;
|
||||
if (failedMessage.messageType == MessageType.contact) {
|
||||
if (failedMessage.recipientPublicKey == null) {
|
||||
messagesProvider.markMessageFailed(retryMessageId);
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(context)!.cannotRetryMissingRecipient,
|
||||
@@ -148,13 +138,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
}).firstOrNull;
|
||||
}
|
||||
|
||||
messagesProvider.addSentMessage(retryMessage, contact: roomContact);
|
||||
|
||||
// Resend the message
|
||||
if (failedMessage.messageType == MessageType.contact) {
|
||||
// Direct message retry (for SAR markers sent to rooms)
|
||||
if (failedMessage.recipientPublicKey == null) {
|
||||
messagesProvider.markMessageFailed(retryMessageId);
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(context)!.cannotRetryMissingRecipient,
|
||||
@@ -162,26 +149,39 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resend to the same room
|
||||
final prepared = messagesProvider.prepareMessageForRetry(
|
||||
failedMessage.id,
|
||||
);
|
||||
if (!prepared) {
|
||||
return;
|
||||
}
|
||||
|
||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: failedMessage.recipientPublicKey!,
|
||||
text: failedMessage.text,
|
||||
messageId: retryMessageId,
|
||||
messageId: failedMessage.id,
|
||||
contact: roomContact,
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (!sentSuccessfully) {
|
||||
messagesProvider.markMessageFailed(retryMessageId);
|
||||
messagesProvider.markMessageFailed(failedMessage.id);
|
||||
ToastLogger.error(context, 'Failed to resend message');
|
||||
}
|
||||
} else if (failedMessage.messageType == MessageType.channel) {
|
||||
final prepared = messagesProvider.prepareMessageForRetry(
|
||||
failedMessage.id,
|
||||
);
|
||||
if (!prepared) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Channel message retry
|
||||
await connectionProvider.sendChannelMessage(
|
||||
channelIdx: failedMessage.channelIdx ?? 0,
|
||||
text: failedMessage.text,
|
||||
messageId: retryMessageId,
|
||||
messageId: failedMessage.id,
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
@@ -415,9 +415,11 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
final receptionDetails = messagesProvider.getMessageReceptionDetails(
|
||||
widget.message.id,
|
||||
);
|
||||
final transferDetails = messagesProvider.getMessageTransferDetails(
|
||||
widget.message.id,
|
||||
);
|
||||
|
||||
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
|
||||
final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text);
|
||||
final voiceSession = widget.message.voiceId != null
|
||||
? voiceProvider.session(widget.message.voiceId!)
|
||||
: null;
|
||||
@@ -454,16 +456,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
)
|
||||
: legacyVoicePacket != null
|
||||
? estimateVoiceTransmitDuration(
|
||||
mode: legacyVoicePacket.mode,
|
||||
packetCount: legacyVoicePacket.total,
|
||||
durationMs: legacyVoicePacket.durationMs * legacyVoicePacket.total,
|
||||
pathLen: widget.message.pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
)
|
||||
: Duration.zero;
|
||||
|
||||
final senderPrefixHex = widget.message.senderPublicKeyPrefix
|
||||
@@ -540,10 +532,17 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
'Text length: ${widget.message.text.length}',
|
||||
];
|
||||
|
||||
if (transferDetails != null) {
|
||||
rawLines.add('Transfers served: ${transferDetails.totalTransfers}');
|
||||
rawLines.add(
|
||||
'Downloaded by: ${_formatDownloaderSummary(transferDetails)}',
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.message.isVoice) {
|
||||
rawLines.add('--- Voice Technical ---');
|
||||
if (envelope != null) {
|
||||
rawLines.add('Envelope format: VE1 compact');
|
||||
rawLines.add('Envelope format: VE3 compact');
|
||||
rawLines.add(
|
||||
'Voice mode: ${envelope.mode.label} (id=${envelope.mode.id})',
|
||||
);
|
||||
@@ -551,17 +550,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
rawLines.add(
|
||||
'Estimated duration ms (envelope): ${envelope.durationMs}',
|
||||
);
|
||||
rawLines.add('Envelope senderKey6: ${envelope.senderKey6}');
|
||||
rawLines.add('Envelope ts: ${envelope.timestampSec}');
|
||||
rawLines.add('Envelope ver: ${envelope.version}');
|
||||
} else if (legacyVoicePacket != null) {
|
||||
rawLines.add('Envelope format: legacy V packet');
|
||||
rawLines.add(
|
||||
'Legacy segment index/total: ${legacyVoicePacket.index + 1}/${legacyVoicePacket.total}',
|
||||
);
|
||||
rawLines.add(
|
||||
'Legacy codec mode: ${legacyVoicePacket.mode.label} (id=${legacyVoicePacket.mode.id})',
|
||||
);
|
||||
} else {
|
||||
rawLines.add('Envelope format: unknown');
|
||||
}
|
||||
@@ -604,8 +593,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
rawLines.add(
|
||||
'Estimated image tx: ~${imageTxEstimate.inSeconds}s (current radio)',
|
||||
);
|
||||
rawLines.add('Envelope senderKey6: ${imageEnvelope.senderKey6}');
|
||||
rawLines.add('Envelope ts: ${imageEnvelope.timestampSec}');
|
||||
rawLines.add('Envelope ver: ${imageEnvelope.version}');
|
||||
|
||||
if (imageSession != null) {
|
||||
@@ -916,11 +903,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
_detailRow(
|
||||
context,
|
||||
label: l10n.envelope,
|
||||
value: envelope != null
|
||||
? 'VE1 compact'
|
||||
: legacyVoicePacket != null
|
||||
? 'Legacy V packet'
|
||||
: l10n.unknown,
|
||||
value: envelope != null ? 'VE3 compact' : l10n.unknown,
|
||||
),
|
||||
if (voiceSession != null)
|
||||
_detailRow(
|
||||
@@ -937,6 +920,21 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
? l10n.yes
|
||||
: l10n.no,
|
||||
),
|
||||
if (transferDetails != null)
|
||||
_detailRow(
|
||||
context,
|
||||
label: 'Transfers',
|
||||
value: '${transferDetails.totalTransfers}',
|
||||
),
|
||||
if (transferDetails != null &&
|
||||
transferDetails.downloaders.isNotEmpty)
|
||||
_detailRow(
|
||||
context,
|
||||
label: 'Downloaded by',
|
||||
value: _formatDownloaderSummary(
|
||||
transferDetails,
|
||||
),
|
||||
),
|
||||
if (voiceTxEstimate > Duration.zero)
|
||||
_detailRow(
|
||||
context,
|
||||
@@ -988,6 +986,21 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
? l10n.yes
|
||||
: l10n.no,
|
||||
),
|
||||
if (transferDetails != null)
|
||||
_detailRow(
|
||||
context,
|
||||
label: 'Transfers',
|
||||
value: '${transferDetails.totalTransfers}',
|
||||
),
|
||||
if (transferDetails != null &&
|
||||
transferDetails.downloaders.isNotEmpty)
|
||||
_detailRow(
|
||||
context,
|
||||
label: 'Downloaded by',
|
||||
value: _formatDownloaderSummary(
|
||||
transferDetails,
|
||||
),
|
||||
),
|
||||
if (imageTxEstimate > Duration.zero)
|
||||
_detailRow(
|
||||
context,
|
||||
@@ -1147,6 +1160,20 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDownloaderSummary(MessageTransferDetails transferDetails) {
|
||||
return transferDetails.downloaders.map(_formatDownloaderLabel).join(', ');
|
||||
}
|
||||
|
||||
String _formatDownloaderLabel(MessageTransferDownloader downloader) {
|
||||
final name = downloader.requesterName?.trim();
|
||||
final base = name != null && name.isNotEmpty
|
||||
? '$name (${downloader.requesterKey6})'
|
||||
: downloader.requesterKey6;
|
||||
return downloader.transferCount > 1
|
||||
? '$base ×${downloader.transferCount}'
|
||||
: base;
|
||||
}
|
||||
|
||||
Widget _signalRow(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
|
||||
@@ -65,8 +65,28 @@ Widget buildBubbleMetaFooter(
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.68);
|
||||
|
||||
final items = <Widget>[];
|
||||
final sentEchoLabel = message.isSentMessage && message.echoCount > 0
|
||||
? '${message.echoCount} echo${message.echoCount == 1 ? '' : 'es'}'
|
||||
: null;
|
||||
|
||||
if (!isSarMarker && message.pathLen < 255) {
|
||||
if (!isSarMarker && sentEchoLabel != null) {
|
||||
items.addAll([
|
||||
Icon(Icons.hub_outlined, size: 11, color: metaColor),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
sentEchoLabel,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
Text(
|
||||
' • ',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
]);
|
||||
} else if (!isSarMarker && message.pathLen < 255) {
|
||||
items.addAll([
|
||||
Icon(Icons.alt_route, size: 11, color: metaColor),
|
||||
const SizedBox(width: 3),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../models/ble_packet_log.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../services/mesh_map_nodes_service.dart';
|
||||
|
||||
class MessageTraceSheet extends StatefulWidget {
|
||||
@@ -30,9 +31,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
|
||||
Future<_TraceResult> _loadTrace() async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final nodes = await MeshMapNodesService.fetchNodes(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
);
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final packetPath = _extractPathFromPacketLogs(
|
||||
logs: connectionProvider.bleService.packetLogs,
|
||||
message: widget.message,
|
||||
@@ -43,45 +42,27 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
? _toPrefixHex(widget.message.recipientPublicKey)
|
||||
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
|
||||
|
||||
final senderNode = _bestNodeForPrefix(nodes, senderPrefix);
|
||||
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix);
|
||||
|
||||
if (packetPath != null && packetPath.isNotEmpty) {
|
||||
final matched = _matchNodesFromPathHashes(
|
||||
nodes: nodes,
|
||||
pathHashes: packetPath,
|
||||
senderPrefix: senderPrefix,
|
||||
recipientPrefix: recipientPrefix,
|
||||
);
|
||||
return _TraceResult(
|
||||
mode: TraceMode.packetPath,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
pathHashes: packetPath,
|
||||
matchedPathNodes: matched,
|
||||
);
|
||||
final localNodes = _localNodesFromContacts(contactsProvider);
|
||||
var trace = _buildTraceResult(
|
||||
nodes: localNodes,
|
||||
packetPath: packetPath,
|
||||
senderPrefix: senderPrefix,
|
||||
recipientPrefix: recipientPrefix,
|
||||
);
|
||||
if (_isCompleteTrace(trace, expectedRelayCount: math.max(0, widget.message.pathLen))) {
|
||||
return trace;
|
||||
}
|
||||
|
||||
// Fallback when packet path is unavailable.
|
||||
final inferred = _inferRelaysFromHopCount(
|
||||
nodes: nodes,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
relayCount: math.max(0, widget.message.pathLen),
|
||||
final remoteNodes = await MeshMapNodesService.fetchNodes(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
);
|
||||
final matchedPathNodes = <MeshMapNode?>[
|
||||
if (senderNode != null) senderNode,
|
||||
...inferred,
|
||||
if (recipientNode != null) recipientNode,
|
||||
];
|
||||
|
||||
return _TraceResult(
|
||||
mode: TraceMode.hopCountInference,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
pathHashes: const [],
|
||||
matchedPathNodes: matchedPathNodes,
|
||||
trace = _buildTraceResult(
|
||||
nodes: _mergeNodes(localNodes, remoteNodes),
|
||||
packetPath: packetPath,
|
||||
senderPrefix: senderPrefix,
|
||||
recipientPrefix: recipientPrefix,
|
||||
);
|
||||
return trace;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -109,12 +90,16 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
}
|
||||
|
||||
final trace = snapshot.data!;
|
||||
final mapPoints = trace.matchedPathNodes
|
||||
.whereType<MeshMapNode>()
|
||||
final routeEntries = _displayRouteEntries(trace);
|
||||
final concretePathNodes = routeEntries
|
||||
.where((entry) => entry.node != null)
|
||||
.map((entry) => entry.node!)
|
||||
.toList();
|
||||
final mapPoints = concretePathNodes
|
||||
.map((n) => LatLng(n.latitude, n.longitude))
|
||||
.toList();
|
||||
final hasMapPath = mapPoints.length >= 2;
|
||||
final relayNodes = _relayNodes(trace.matchedPathNodes);
|
||||
final relayNodes = _relayNodes(trace);
|
||||
|
||||
return SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
@@ -197,9 +182,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
],
|
||||
),
|
||||
flutter_map.MarkerLayer(
|
||||
markers: trace.matchedPathNodes
|
||||
.whereType<MeshMapNode>()
|
||||
.toList()
|
||||
markers: concretePathNodes
|
||||
.asMap()
|
||||
.entries
|
||||
.map(
|
||||
@@ -216,10 +199,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
entry.key == 0
|
||||
? Colors.green
|
||||
: (entry.key ==
|
||||
trace.matchedPathNodes
|
||||
.whereType<
|
||||
MeshMapNode
|
||||
>()
|
||||
concretePathNodes
|
||||
.length -
|
||||
1
|
||||
? Colors.red
|
||||
@@ -250,6 +230,48 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'Route',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (routeEntries.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
'No named nodes could be matched for this trace.',
|
||||
),
|
||||
),
|
||||
...routeEntries.asMap().entries.map(
|
||||
(entry) => ListTile(
|
||||
leading: CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: entry.key == 0
|
||||
? Colors.green
|
||||
: (entry.key == routeEntries.length - 1
|
||||
? Colors.red
|
||||
: Colors.blue),
|
||||
child: Text(
|
||||
'${entry.key + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(entry.value.label),
|
||||
subtitle: Text(
|
||||
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
@@ -288,12 +310,71 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
List<MeshMapNode> _relayNodes(List<MeshMapNode?> path) {
|
||||
final concrete = path.whereType<MeshMapNode>().toList();
|
||||
List<MeshMapNode> _relayNodes(_TraceResult trace) {
|
||||
final concrete = trace.matchedPathNodes.whereType<MeshMapNode>().toList();
|
||||
if (concrete.isEmpty) return const [];
|
||||
|
||||
if (trace.mode == TraceMode.packetPath) {
|
||||
if (concrete.length <= 1) return const [];
|
||||
return concrete.sublist(1);
|
||||
}
|
||||
|
||||
if (concrete.length <= 2) return const [];
|
||||
return concrete.sublist(1, concrete.length - 1);
|
||||
}
|
||||
|
||||
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
|
||||
final pathNodes = trace.matchedPathNodes.whereType<MeshMapNode>().toList();
|
||||
if (pathNodes.isEmpty) {
|
||||
return [
|
||||
if (trace.sender != null) _RouteDisplayEntry.fromNode(trace.sender!),
|
||||
if (trace.recipient != null &&
|
||||
trace.recipient!.publicKey != trace.sender?.publicKey)
|
||||
_RouteDisplayEntry.fromNode(trace.recipient!),
|
||||
];
|
||||
}
|
||||
|
||||
if (trace.mode == TraceMode.packetPath) {
|
||||
final entries = trace.matchedPathNodes.asMap().entries.map((entry) {
|
||||
final hashHex = trace.pathHashes[entry.key]
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0');
|
||||
return _RouteDisplayEntry(
|
||||
node: entry.value,
|
||||
label: entry.value?.name ?? 'Unknown',
|
||||
keyLabel: entry.value != null
|
||||
? _prefixKeyLabel(entry.value!.publicKey)
|
||||
: hashHex,
|
||||
);
|
||||
}).toList();
|
||||
final lastKey = pathNodes.last.publicKey;
|
||||
return [
|
||||
...entries,
|
||||
if (trace.recipient != null && trace.recipient!.publicKey != lastKey)
|
||||
_RouteDisplayEntry.fromNode(trace.recipient!),
|
||||
];
|
||||
}
|
||||
|
||||
final firstKey = pathNodes.first.publicKey;
|
||||
final lastKey = pathNodes.last.publicKey;
|
||||
return [
|
||||
if (trace.sender != null && trace.sender!.publicKey != firstKey)
|
||||
_RouteDisplayEntry.fromNode(trace.sender!),
|
||||
...pathNodes.map(_RouteDisplayEntry.fromNode),
|
||||
if (trace.recipient != null && trace.recipient!.publicKey != lastKey)
|
||||
_RouteDisplayEntry.fromNode(trace.recipient!),
|
||||
];
|
||||
}
|
||||
|
||||
String _prefixKeyLabel(String publicKey) =>
|
||||
publicKey.substring(0, math.min(12, publicKey.length));
|
||||
|
||||
String _routeRoleLabel(int index, int total) {
|
||||
if (index == 0) return 'Sender';
|
||||
if (index == total - 1) return 'Recipient';
|
||||
return 'Relay';
|
||||
}
|
||||
|
||||
String? _toPrefixHex(List<int>? key) {
|
||||
if (key == null || key.isEmpty) return null;
|
||||
final take = key.length < 6 ? key.length : 6;
|
||||
@@ -312,6 +393,98 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
return matches.isEmpty ? null : matches.first;
|
||||
}
|
||||
|
||||
List<MeshMapNode> _localNodesFromContacts(ContactsProvider contactsProvider) {
|
||||
return contactsProvider.contactsWithLocation
|
||||
.map((contact) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) return null;
|
||||
return MeshMapNode(
|
||||
type: contact.type.index,
|
||||
name: contact.displayName,
|
||||
publicKey: contact.publicKeyHex.toLowerCase(),
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
updatedAtMs: contact.lastAdvert * 1000,
|
||||
);
|
||||
})
|
||||
.whereType<MeshMapNode>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<MeshMapNode> _mergeNodes(
|
||||
List<MeshMapNode> preferred,
|
||||
List<MeshMapNode> fallback,
|
||||
) {
|
||||
final merged = <String, MeshMapNode>{};
|
||||
for (final node in fallback) {
|
||||
merged[node.publicKey] = node;
|
||||
}
|
||||
for (final node in preferred) {
|
||||
merged[node.publicKey] = node;
|
||||
}
|
||||
return merged.values.toList();
|
||||
}
|
||||
|
||||
_TraceResult _buildTraceResult({
|
||||
required List<MeshMapNode> nodes,
|
||||
required List<int>? packetPath,
|
||||
required String? senderPrefix,
|
||||
required String? recipientPrefix,
|
||||
}) {
|
||||
final senderNode = _bestNodeForPrefix(nodes, senderPrefix);
|
||||
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix);
|
||||
|
||||
if (packetPath != null && packetPath.isNotEmpty) {
|
||||
final matched = _matchNodesFromPathHashes(
|
||||
nodes: nodes,
|
||||
pathHashes: packetPath,
|
||||
senderPrefix: senderPrefix,
|
||||
recipientPrefix: recipientPrefix,
|
||||
);
|
||||
return _TraceResult(
|
||||
mode: TraceMode.packetPath,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
pathHashes: packetPath,
|
||||
matchedPathNodes: matched,
|
||||
);
|
||||
}
|
||||
|
||||
final inferred = _inferRelaysFromHopCount(
|
||||
nodes: nodes,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
relayCount: math.max(0, widget.message.pathLen),
|
||||
);
|
||||
final matchedPathNodes = <MeshMapNode?>[
|
||||
if (senderNode != null) senderNode,
|
||||
...inferred,
|
||||
if (recipientNode != null) recipientNode,
|
||||
];
|
||||
|
||||
return _TraceResult(
|
||||
mode: TraceMode.hopCountInference,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
pathHashes: const [],
|
||||
matchedPathNodes: matchedPathNodes,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) {
|
||||
if (trace.sender == null || trace.recipient == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trace.mode == TraceMode.packetPath) {
|
||||
return trace.matchedPathNodes.length == trace.pathHashes.length &&
|
||||
trace.matchedPathNodes.every((node) => node != null);
|
||||
}
|
||||
|
||||
final concreteCount = trace.matchedPathNodes.whereType<MeshMapNode>().length;
|
||||
return concreteCount >= expectedRelayCount + 2;
|
||||
}
|
||||
|
||||
List<int>? _extractPathFromPacketLogs({
|
||||
required List<BlePacketLog> logs,
|
||||
required Message message,
|
||||
@@ -371,11 +544,6 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
.where((n) => n.publicKey.startsWith(senderPrefix))
|
||||
.toList();
|
||||
if (senderMatches.isNotEmpty) filtered = senderMatches;
|
||||
} else if (i == pathHashes.length - 1 && recipientPrefix != null) {
|
||||
final recipientMatches = filtered
|
||||
.where((n) => n.publicKey.startsWith(recipientPrefix))
|
||||
.toList();
|
||||
if (recipientMatches.isNotEmpty) filtered = recipientMatches;
|
||||
}
|
||||
|
||||
filtered.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
||||
@@ -461,3 +629,23 @@ class _TraceResult {
|
||||
required this.matchedPathNodes,
|
||||
});
|
||||
}
|
||||
|
||||
class _RouteDisplayEntry {
|
||||
final MeshMapNode? node;
|
||||
final String label;
|
||||
final String? keyLabel;
|
||||
|
||||
const _RouteDisplayEntry({
|
||||
required this.node,
|
||||
required this.label,
|
||||
required this.keyLabel,
|
||||
});
|
||||
|
||||
factory _RouteDisplayEntry.fromNode(MeshMapNode node) {
|
||||
return _RouteDisplayEntry(
|
||||
node: node,
|
||||
label: node.name,
|
||||
keyLabel: node.publicKey.substring(0, math.min(12, node.publicKey.length)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/messages_provider.dart';
|
||||
import '../../providers/voice_provider.dart';
|
||||
import '../../utils/transmission_target_resolver.dart';
|
||||
import '../../utils/voice_message_parser.dart';
|
||||
@@ -28,7 +30,9 @@ class VoiceMessageBubble extends StatefulWidget {
|
||||
|
||||
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
static const int _maxFetchHops = 3;
|
||||
static const Duration _recentInboundActivityWindow = Duration(seconds: 3);
|
||||
bool _isRequesting = false;
|
||||
bool _isPartialRequest = false;
|
||||
bool _autoPlayWhenReady = false;
|
||||
String? _errorText;
|
||||
Timer? _requestTimeoutTimer;
|
||||
@@ -55,6 +59,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
|
||||
return Consumer<VoiceProvider>(
|
||||
builder: (context, voiceProvider, _) {
|
||||
final transferCount = context.select<MessagesProvider, int>(
|
||||
(provider) =>
|
||||
provider.transferCountForSession(voiceSessionId: voiceId),
|
||||
);
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final session = voiceProvider.session(voiceId);
|
||||
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
|
||||
@@ -63,11 +71,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope?.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
);
|
||||
final effectivePathLen = sender != null && sender.outPathLen >= 0
|
||||
? sender.outPathLen
|
||||
final effectivePathLen = sender != null && sender.routeHasPath
|
||||
? sender.routeHopCount
|
||||
: widget.message.pathLen;
|
||||
final isPlaying = voiceProvider.isPlaying(voiceId);
|
||||
final isComplete = voiceProvider.isComplete(voiceId);
|
||||
@@ -77,6 +84,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = null;
|
||||
});
|
||||
});
|
||||
@@ -93,9 +101,17 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
final received = session?.receivedCount ?? 0;
|
||||
final total = session?.total ?? envelope?.total ?? 0;
|
||||
final playbackProgress = voiceProvider.playbackProgress(voiceId);
|
||||
final requestProgress = total > 0
|
||||
? (received / total).clamp(0.0, 1.0)
|
||||
: null;
|
||||
final packetPresence =
|
||||
session?.packets.map((packet) => packet != null).toList() ??
|
||||
List<bool>.filled(total, false);
|
||||
final isReceivingData =
|
||||
!_isRequesting &&
|
||||
!isComplete &&
|
||||
_hasRecentInboundActivity(
|
||||
lastReceivedAt: session?.lastPacketAt,
|
||||
received: received,
|
||||
total: total,
|
||||
);
|
||||
final durationSec =
|
||||
session?.estimatedDurationSeconds ??
|
||||
((envelope?.durationMs ?? 0) / 1000.0);
|
||||
@@ -117,32 +133,37 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
|
||||
final eta = voiceProvider.estimateRemainingTransferTime(voiceId);
|
||||
|
||||
Future<void> handlePrimaryTap() async {
|
||||
if (isPlaying) {
|
||||
await voiceProvider.stop();
|
||||
return;
|
||||
}
|
||||
if (_isRequesting) {
|
||||
_cancelReceive(voiceId);
|
||||
return;
|
||||
}
|
||||
if (isComplete) {
|
||||
await voiceProvider.play(voiceId);
|
||||
return;
|
||||
}
|
||||
if (isReceivingData) {
|
||||
return;
|
||||
}
|
||||
await _requestAndPlayVoice(
|
||||
voiceId,
|
||||
envelope: envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: effectivePathLen,
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
if (isPlaying) {
|
||||
await voiceProvider.stop();
|
||||
return;
|
||||
}
|
||||
if (_isRequesting) {
|
||||
_cancelReceive(voiceId);
|
||||
return;
|
||||
}
|
||||
if (isComplete) {
|
||||
await voiceProvider.play(voiceId);
|
||||
return;
|
||||
}
|
||||
await _requestAndPlayVoice(
|
||||
voiceId,
|
||||
envelope: envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: effectivePathLen,
|
||||
);
|
||||
},
|
||||
onTap: handlePrimaryTap,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Container(
|
||||
width: 48,
|
||||
@@ -156,11 +177,16 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
child: Icon(
|
||||
isPlaying
|
||||
? Icons.stop
|
||||
: (_isRequesting ? Icons.close : Icons.play_arrow),
|
||||
: (_isRequesting
|
||||
? Icons.close
|
||||
: (isReceivingData
|
||||
? Icons.downloading_rounded
|
||||
: Icons.play_arrow)),
|
||||
size: 28,
|
||||
color: widget.isSentByMe
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
: Theme.of(context).colorScheme.onSecondaryContainer
|
||||
.withValues(alpha: isReceivingData ? 0.6 : 1.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -169,14 +195,22 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isPlaying || _isRequesting)
|
||||
if (isPlaying)
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: LinearProgressIndicator(
|
||||
value: isPlaying ? playbackProgress : requestProgress,
|
||||
value: playbackProgress,
|
||||
backgroundColor: Colors.grey.withValues(alpha: 0.3),
|
||||
),
|
||||
)
|
||||
else if ((_isRequesting || isReceivingData) && total > 0)
|
||||
_PacketBlockProgress(
|
||||
presence: packetPresence,
|
||||
activeColor: widget.isSentByMe
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.secondary,
|
||||
highlightMissing: _isPartialRequest,
|
||||
)
|
||||
else
|
||||
_WaveformBar(isComplete: isComplete, bars: waveformBars),
|
||||
const SizedBox(height: 4),
|
||||
@@ -189,11 +223,15 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
total: total,
|
||||
isComplete: isComplete,
|
||||
isRequesting: _isRequesting,
|
||||
isReceivingData: isReceivingData,
|
||||
isPartialRequest: _isPartialRequest,
|
||||
errorText: _errorText,
|
||||
requestingLabel: AppLocalizations.of(
|
||||
context,
|
||||
)!.requestingVoice,
|
||||
eta: eta,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
transferCount: transferCount,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
@@ -221,6 +259,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
if (_isRequesting) return;
|
||||
setState(() {
|
||||
_isRequesting = true;
|
||||
_isPartialRequest = false;
|
||||
_autoPlayWhenReady = true;
|
||||
_errorText = null;
|
||||
});
|
||||
@@ -236,7 +275,6 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope?.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
@@ -287,7 +325,6 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope?.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
@@ -329,9 +366,18 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
}
|
||||
}
|
||||
|
||||
if (sender.outPathLen >= 2) {
|
||||
if (!sender.routeSupportsLegacyRawTransport) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sender.routeHopCount >= 2) {
|
||||
_showToast(
|
||||
'Voice fetch over ${sender.outPathLen} hops may take a while.',
|
||||
'Voice fetch over ${sender.routeHopCount} hops may take a while.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -357,29 +403,30 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
);
|
||||
final isPartialResume =
|
||||
missing.isNotEmpty && totalPackets > 0 && missing.length < totalPackets;
|
||||
if (_isPartialRequest != isPartialResume && mounted) {
|
||||
setState(() {
|
||||
_isPartialRequest = isPartialResume;
|
||||
});
|
||||
}
|
||||
final request = isPartialResume
|
||||
? VoiceFetchRequest(
|
||||
sessionId: sessionId,
|
||||
want: 'missing',
|
||||
missingIndices: missing,
|
||||
requesterKey6: requesterKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
version: 2,
|
||||
)
|
||||
: VoiceFetchRequest(
|
||||
sessionId: sessionId,
|
||||
requesterKey6: requesterKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
version: 2,
|
||||
);
|
||||
|
||||
try {
|
||||
debugPrint(
|
||||
'🎙️ [VoiceMessageBubble] Outgoing voice fetch request: session=$sessionId want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.outPathLen}',
|
||||
'🎙️ [VoiceMessageBubble] Outgoing voice fetch request: session=$sessionId want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.routeHopCount}',
|
||||
);
|
||||
await connectionProvider.sendRawVoicePacket(
|
||||
contactPath: sender.outPath,
|
||||
contactPathLen: sender.outPathLen,
|
||||
contactPathLen: sender.routeSignedPathLen,
|
||||
payload: request.encodeBinary(),
|
||||
);
|
||||
} catch (_) {
|
||||
@@ -388,8 +435,8 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
}
|
||||
|
||||
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||
final effectivePathLen = sender.outPathLen >= 0
|
||||
? sender.outPathLen
|
||||
final effectivePathLen = sender.routeHasPath
|
||||
? sender.routeHopCount
|
||||
: pathLen;
|
||||
final estimatedDurationMs =
|
||||
envelope != null &&
|
||||
@@ -433,6 +480,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
_showToast(AppLocalizations.of(context)!.voiceUnavailable);
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_autoPlayWhenReady = false;
|
||||
_errorText = AppLocalizations.of(context)!.voiceUnavailable;
|
||||
});
|
||||
@@ -442,6 +490,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_autoPlayWhenReady = false;
|
||||
});
|
||||
}
|
||||
@@ -453,6 +502,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
_showToast('Voice receive canceled');
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_autoPlayWhenReady = false;
|
||||
_errorText = 'Voice receive canceled';
|
||||
});
|
||||
@@ -497,19 +547,33 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
required int total,
|
||||
required bool isComplete,
|
||||
required bool isRequesting,
|
||||
required bool isReceivingData,
|
||||
required bool isPartialRequest,
|
||||
required String? errorText,
|
||||
required String requestingLabel,
|
||||
required Duration? eta,
|
||||
required bool isSentByMe,
|
||||
required int transferCount,
|
||||
}) {
|
||||
if (errorText != null) return errorText;
|
||||
final progress = total > 0 ? ' ($received/$total)' : '';
|
||||
if (isRequesting) {
|
||||
return '$requestingLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
|
||||
final actionLabel = isPartialRequest
|
||||
? 'Fetching missing voice fragments'
|
||||
: requestingLabel;
|
||||
return '$actionLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
|
||||
}
|
||||
if (isReceivingData) {
|
||||
return 'Receiving voice$progress · ${_formatEta(eta)} · $txEstimateLabel';
|
||||
}
|
||||
if (!isComplete && total > 0) {
|
||||
return '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
|
||||
return isSentByMe
|
||||
? '🎙️ $durationLabel · $modeLabel$progress · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
|
||||
: '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
|
||||
}
|
||||
return '🎙️ $durationLabel · $modeLabel · $txEstimateLabel';
|
||||
return isSentByMe
|
||||
? '🎙️ $durationLabel · $modeLabel · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
|
||||
: '🎙️ $durationLabel · $modeLabel · $txEstimateLabel';
|
||||
}
|
||||
|
||||
List<double> _resolveWaveformBars({
|
||||
@@ -592,6 +656,86 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
final seconds = eta.inSeconds % 60;
|
||||
return 'ETA ~${minutes}m ${seconds}s';
|
||||
}
|
||||
|
||||
static String _formatTransferCount(int transferCount) {
|
||||
return '$transferCount transfer${transferCount == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
bool _hasRecentInboundActivity({
|
||||
required DateTime? lastReceivedAt,
|
||||
required int received,
|
||||
required int total,
|
||||
}) {
|
||||
if (lastReceivedAt == null || received <= 0 || received >= total) {
|
||||
return false;
|
||||
}
|
||||
return DateTime.now().difference(lastReceivedAt) <=
|
||||
_recentInboundActivityWindow;
|
||||
}
|
||||
}
|
||||
|
||||
class _PacketBlockProgress extends StatelessWidget {
|
||||
final List<bool> presence;
|
||||
final Color activeColor;
|
||||
final bool highlightMissing;
|
||||
|
||||
const _PacketBlockProgress({
|
||||
required this.presence,
|
||||
required this.activeColor,
|
||||
this.highlightMissing = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (presence.isEmpty) {
|
||||
return const SizedBox(width: 100, height: 16);
|
||||
}
|
||||
|
||||
final bucketCount = presence.length <= 20 ? presence.length : 20;
|
||||
final bucketFill = List<double>.generate(bucketCount, (bucketIndex) {
|
||||
final start = (bucketIndex * presence.length) ~/ bucketCount;
|
||||
final end = ((bucketIndex + 1) * presence.length) ~/ bucketCount;
|
||||
final safeEnd = end <= start ? start + 1 : end;
|
||||
final slice = presence.sublist(start, safeEnd);
|
||||
final received = slice.where((value) => value).length;
|
||||
return slice.isEmpty ? 0.0 : received / slice.length;
|
||||
});
|
||||
final missingColor = highlightMissing
|
||||
? Colors.amberAccent
|
||||
: Colors.white.withValues(alpha: 0.14);
|
||||
|
||||
return SizedBox(
|
||||
width: 100,
|
||||
height: 16,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (final fill in bucketFill)
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: fill > 0
|
||||
? activeColor.withValues(alpha: 0.18 + (0.72 * fill))
|
||||
: missingColor.withValues(
|
||||
alpha: highlightMissing ? 0.45 : 0.14,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(
|
||||
color: fill > 0
|
||||
? Colors.white.withValues(alpha: 0.18)
|
||||
: missingColor.withValues(
|
||||
alpha: highlightMissing ? 0.7 : 0.18,
|
||||
),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Voice waveform rendered as a row of bars.
|
||||
|
||||
Reference in New Issue
Block a user