mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Add message clear option
This commit is contained in:
@@ -57,8 +57,11 @@ class AppProvider with ChangeNotifier {
|
||||
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
|
||||
static const int _maxPacketRetryAttempts = 4;
|
||||
final Map<String, String> _voiceSessionSenderKey6 = {};
|
||||
final Map<String, String> _imageSessionSenderKey6 = {};
|
||||
final Map<String, Timer> _voiceMissingRetryTimers = {};
|
||||
final Map<String, int> _voiceMissingRetryAttempts = {};
|
||||
final Map<String, Completer<void>> _voiceFragmentAckWaiters = {};
|
||||
final Map<String, Completer<void>> _imageFragmentAckWaiters = {};
|
||||
Timer? _packetCaptureFlushTimer;
|
||||
String? _lastPersistedPacketSignature;
|
||||
bool _isPersistingPacketCapture = false;
|
||||
@@ -404,6 +407,24 @@ class AppProvider with ChangeNotifier {
|
||||
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
|
||||
connectionProvider.onContactReceived = (contact) {
|
||||
@@ -540,62 +561,6 @@ class AppProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Voice control plane: request sender to stream raw voice packets.
|
||||
final voiceFetchRequest = VoiceFetchRequest.tryParseText(
|
||||
enrichedMessage.text,
|
||||
);
|
||||
if (voiceFetchRequest != null) {
|
||||
final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
|
||||
if (senderPrefix == null) {
|
||||
debugPrint(
|
||||
'⚠️ [AppProvider] Voice fetch request without sender prefix',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final senderPrefixHex = senderPrefix
|
||||
.take(6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
if (senderPrefixHex.toLowerCase() !=
|
||||
voiceFetchRequest.requesterKey6.toLowerCase()) {
|
||||
debugPrint('⚠️ [AppProvider] Voice fetch requester key mismatch');
|
||||
return;
|
||||
}
|
||||
final requester = contactsProvider.findContactByPrefix(senderPrefix);
|
||||
if (requester == null) {
|
||||
debugPrint(
|
||||
'⚠️ [AppProvider] Voice fetch requester contact not found',
|
||||
);
|
||||
messagesProvider.logSystemMessage(
|
||||
text:
|
||||
'Cannot fetch voice: requester contact is unknown. Add/sync contacts first.',
|
||||
level: 'warning',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (requester.outPathLen > _maxDirectPayloadHops) {
|
||||
debugPrint(
|
||||
'⚠️ [AppProvider] Voice fetch requester too far: ${requester.outPathLen} hops',
|
||||
);
|
||||
messagesProvider.logSystemMessage(
|
||||
text:
|
||||
'Cannot fetch voice for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
|
||||
level: 'warning',
|
||||
);
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
voiceProvider.serveSessionTo(
|
||||
sessionId: voiceFetchRequest.sessionId,
|
||||
requester: requester,
|
||||
requestedIndices: voiceFetchRequest.want == 'missing'
|
||||
? voiceFetchRequest.missingIndices.toSet()
|
||||
: null,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if message is a drawing broadcast
|
||||
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
|
||||
debugPrint('🎨 [AppProvider] Drawing message received, parsing...');
|
||||
@@ -665,61 +630,12 @@ class AppProvider with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
// Image fetch request (IR1): requester asks us to stream image fragments.
|
||||
final imageFetchRequest = ImageFetchRequest.tryParse(
|
||||
enrichedMessage.text,
|
||||
);
|
||||
if (imageFetchRequest != null) {
|
||||
final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
|
||||
if (senderPrefix != null) {
|
||||
final senderPrefixHex = senderPrefix
|
||||
.take(6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
if (senderPrefixHex.toLowerCase() ==
|
||||
imageFetchRequest.requesterKey6.toLowerCase()) {
|
||||
final requester = contactsProvider.findContactByPrefix(
|
||||
senderPrefix,
|
||||
);
|
||||
if (requester != null) {
|
||||
if (requester.outPathLen > _maxDirectPayloadHops) {
|
||||
debugPrint(
|
||||
'⚠️ [AppProvider] Image fetch requester too far: ${requester.outPathLen} hops',
|
||||
);
|
||||
messagesProvider.logSystemMessage(
|
||||
text:
|
||||
'Cannot fetch image for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
|
||||
level: 'warning',
|
||||
);
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
imageProvider.serveSessionTo(
|
||||
sessionId: imageFetchRequest.sessionId,
|
||||
requester: requester,
|
||||
requestedIndices: imageFetchRequest.want == 'missing'
|
||||
? imageFetchRequest.missingIndices.toSet()
|
||||
: null,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
debugPrint(
|
||||
'⚠️ [AppProvider] Image fetch requester contact not found',
|
||||
);
|
||||
messagesProvider.logSystemMessage(
|
||||
text:
|
||||
'Cannot fetch image: requester contact is unknown. Add/sync contacts first.',
|
||||
level: 'warning',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return; // IR1 is control-plane only; not displayed in chat
|
||||
}
|
||||
|
||||
// Image envelope (IE1): announce image availability.
|
||||
final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text);
|
||||
if (imageEnvelope != null) {
|
||||
_imageSessionSenderKey6[imageEnvelope.sessionId] = imageEnvelope
|
||||
.senderKey6
|
||||
.toLowerCase();
|
||||
imageProvider.registerEnvelope(imageEnvelope);
|
||||
messagesProvider.addMessage(
|
||||
enrichedMessage,
|
||||
@@ -800,8 +716,99 @@ class AppProvider with ChangeNotifier {
|
||||
};
|
||||
|
||||
// When raw binary data is received (PUSH_CODE_RAW_DATA 0x84)
|
||||
// Magic 0x72 'r' = voice fetch request; 0x69 'i' = image fetch request.
|
||||
// Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet.
|
||||
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
|
||||
final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload);
|
||||
if (voiceFetchRequest != null) {
|
||||
final requester = contactsProvider.findContactByPrefixHex(
|
||||
voiceFetchRequest.requesterKey6,
|
||||
);
|
||||
if (requester == null) {
|
||||
debugPrint(
|
||||
'⚠️ [AppProvider] Voice fetch requester contact not found (binary)',
|
||||
);
|
||||
messagesProvider.logSystemMessage(
|
||||
text:
|
||||
'Cannot fetch voice: requester contact is unknown. Add/sync contacts first.',
|
||||
level: 'warning',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (requester.outPathLen > _maxDirectPayloadHops) {
|
||||
debugPrint(
|
||||
'⚠️ [AppProvider] Voice fetch requester too far: ${requester.outPathLen} hops',
|
||||
);
|
||||
messagesProvider.logSystemMessage(
|
||||
text:
|
||||
'Cannot fetch voice for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
|
||||
level: 'warning',
|
||||
);
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
voiceProvider.serveSessionTo(
|
||||
sessionId: voiceFetchRequest.sessionId,
|
||||
requester: requester,
|
||||
requestedIndices: voiceFetchRequest.want == 'missing'
|
||||
? voiceFetchRequest.missingIndices.toSet()
|
||||
: null,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final imageFetchRequest = ImageFetchRequest.tryParseBinary(payload);
|
||||
if (imageFetchRequest != null) {
|
||||
final requester = contactsProvider.findContactByPrefixHex(
|
||||
imageFetchRequest.requesterKey6,
|
||||
);
|
||||
if (requester == null) {
|
||||
debugPrint(
|
||||
'⚠️ [AppProvider] Image fetch requester contact not found (binary)',
|
||||
);
|
||||
messagesProvider.logSystemMessage(
|
||||
text:
|
||||
'Cannot fetch image: requester contact is unknown. Add/sync contacts first.',
|
||||
level: 'warning',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (requester.outPathLen > _maxDirectPayloadHops) {
|
||||
debugPrint(
|
||||
'⚠️ [AppProvider] Image fetch requester too far: ${requester.outPathLen} hops',
|
||||
);
|
||||
messagesProvider.logSystemMessage(
|
||||
text:
|
||||
'Cannot fetch image for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
|
||||
level: 'warning',
|
||||
);
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
imageProvider.serveSessionTo(
|
||||
sessionId: imageFetchRequest.sessionId,
|
||||
requester: requester,
|
||||
requestedIndices: imageFetchRequest.want == 'missing'
|
||||
? imageFetchRequest.missingIndices.toSet()
|
||||
: null,
|
||||
),
|
||||
);
|
||||
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)) {
|
||||
final frag = ImagePacket.tryParseBinary(payload);
|
||||
if (frag == null) return;
|
||||
@@ -812,6 +819,7 @@ class AppProvider with ChangeNotifier {
|
||||
width: session?.width ?? 0,
|
||||
height: session?.height ?? 0,
|
||||
);
|
||||
_sendImageFragmentAck(frag);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -820,6 +828,7 @@ class AppProvider with ChangeNotifier {
|
||||
if (pkt == null) return;
|
||||
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
|
||||
final justComplete = voiceProvider.addPacket(pkt);
|
||||
_sendVoiceFragmentAck(pkt);
|
||||
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
|
||||
// Insert or update the placeholder message in the chat list
|
||||
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
|
||||
@@ -1209,15 +1218,18 @@ class AppProvider with ChangeNotifier {
|
||||
missingIndices: missing,
|
||||
requesterKey6: requesterKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
version: 1,
|
||||
version: 2,
|
||||
);
|
||||
|
||||
final sent = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: sender.publicKey,
|
||||
text: request.encodeText(),
|
||||
contact: sender,
|
||||
);
|
||||
if (!sent) return;
|
||||
try {
|
||||
await connectionProvider.sendRawVoicePacket(
|
||||
contactPath: sender.outPath,
|
||||
contactPathLen: sender.outPathLen,
|
||||
payload: request.encodeBinary(),
|
||||
);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
_voiceMissingRetryAttempts[sessionId] = attempt + 1;
|
||||
_voiceMissingRetryTimers[sessionId]?.cancel();
|
||||
@@ -1266,6 +1278,102 @@ class AppProvider with ChangeNotifier {
|
||||
messagesProvider.addMessage(placeholder, contactLookup: (_) => '');
|
||||
}
|
||||
|
||||
String _fragmentAckKey(String sessionId, int index) => '$sessionId:$index';
|
||||
|
||||
Future<bool> _waitForVoiceFragmentAck({
|
||||
required String sessionId,
|
||||
required int index,
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
final key = _fragmentAckKey(sessionId, index);
|
||||
final completer = Completer<void>();
|
||||
_voiceFragmentAckWaiters[key] = completer;
|
||||
try {
|
||||
await completer.future.timeout(timeout);
|
||||
return true;
|
||||
} catch (_) {
|
||||
if (_voiceFragmentAckWaiters[key] == completer) {
|
||||
_voiceFragmentAckWaiters.remove(key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void _completeVoiceFragmentAck(String sessionId, int index) {
|
||||
final key = _fragmentAckKey(sessionId, index);
|
||||
final completer = _voiceFragmentAckWaiters.remove(key);
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _waitForImageFragmentAck({
|
||||
required String sessionId,
|
||||
required int index,
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
final key = _fragmentAckKey(sessionId, index);
|
||||
final completer = Completer<void>();
|
||||
_imageFragmentAckWaiters[key] = completer;
|
||||
try {
|
||||
await completer.future.timeout(timeout);
|
||||
return true;
|
||||
} catch (_) {
|
||||
if (_imageFragmentAckWaiters[key] == completer) {
|
||||
_imageFragmentAckWaiters.remove(key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void _completeImageFragmentAck(String sessionId, int index) {
|
||||
final key = _fragmentAckKey(sessionId, index);
|
||||
final completer = _imageFragmentAckWaiters.remove(key);
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
|
||||
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching
|
||||
|
||||
|
||||
@@ -68,6 +68,12 @@ class ImageProvider with ChangeNotifier {
|
||||
required Uint8List payload,
|
||||
})?
|
||||
sendRawPacketCallback;
|
||||
Future<bool> Function({
|
||||
required String sessionId,
|
||||
required int index,
|
||||
Duration timeout,
|
||||
})?
|
||||
waitForFragmentAckCallback;
|
||||
|
||||
ImageProvider() {
|
||||
_restore();
|
||||
@@ -229,11 +235,25 @@ class ImageProvider with ChangeNotifier {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
final ackFuture = waitForFragmentAckCallback?.call(
|
||||
sessionId: sessionId,
|
||||
index: fragment.index,
|
||||
timeout: const Duration(seconds: 8),
|
||||
);
|
||||
await sendRawPacketCallback!(
|
||||
contactPath: requester.outPath,
|
||||
contactPathLen: requester.outPathLen,
|
||||
payload: fragment.encodeBinary(),
|
||||
);
|
||||
if (ackFuture != null) {
|
||||
final acked = await ackFuture;
|
||||
if (!acked) {
|
||||
debugPrint(
|
||||
'⚠️ [ImageProvider] ACK timeout for $sessionId#${fragment.index}',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (e, st) {
|
||||
debugPrint('❌ [ImageProvider] Serve error for $sessionId: $e\n$st');
|
||||
return false;
|
||||
|
||||
@@ -69,6 +69,12 @@ class VoiceProvider with ChangeNotifier {
|
||||
required Uint8List payload,
|
||||
})?
|
||||
sendRawPacketCallback;
|
||||
Future<bool> Function({
|
||||
required String sessionId,
|
||||
required int index,
|
||||
Duration timeout,
|
||||
})?
|
||||
waitForFragmentAckCallback;
|
||||
|
||||
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
|
||||
|
||||
@@ -198,11 +204,25 @@ class VoiceProvider with ChangeNotifier {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
final ackFuture = waitForFragmentAckCallback?.call(
|
||||
sessionId: sessionId,
|
||||
index: packet.index,
|
||||
timeout: const Duration(seconds: 8),
|
||||
);
|
||||
await sendRawPacketCallback!(
|
||||
contactPath: requester.outPath,
|
||||
contactPathLen: requester.outPathLen,
|
||||
payload: packet.encodeBinary(),
|
||||
);
|
||||
if (ackFuture != null) {
|
||||
final acked = await ackFuture;
|
||||
if (!acked) {
|
||||
debugPrint(
|
||||
'⚠️ [VoiceProvider] ACK timeout for $sessionId#${packet.index}',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (e, st) {
|
||||
debugPrint(
|
||||
'❌ [VoiceProvider] Failed serving packet for $sessionId: $e\n$st',
|
||||
|
||||
@@ -621,6 +621,46 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _clearMessages() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear Messages'),
|
||||
content: const Text(
|
||||
'This will permanently delete all stored messages. Are you sure?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(AppLocalizations.of(context)!.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: Text(AppLocalizations.of(context)!.clear),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
final messagesProvider = Provider.of<MessagesProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
messagesProvider.clearMessages();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('All messages cleared'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -694,6 +734,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showLanguageDialog(),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_sweep, color: Colors.red),
|
||||
title: const Text(
|
||||
'Clear Messages',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
subtitle: const Text('Delete all stored message history'),
|
||||
onTap: _clearMessages,
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// Voice Settings Section
|
||||
|
||||
@@ -243,11 +243,11 @@ int _resolveBandwidthHz(int? rawBw) {
|
||||
/// Envelope announcing image availability (control plane).
|
||||
///
|
||||
/// Text format:
|
||||
/// IE1:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}:{ver}
|
||||
/// IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}
|
||||
/// Example:
|
||||
/// IE1:deadbeef:0:7:128:128:1050:aabbccddeeff:1700000000:1
|
||||
/// IE2:deadbeef:0:7:3k:3k:t6:aabbccddeeff:s44we8
|
||||
class ImageEnvelope {
|
||||
static const String prefix = 'IE1:';
|
||||
static const String _prefix = 'IE2:';
|
||||
|
||||
final String sessionId; // 8 hex chars
|
||||
final ImageFormat format;
|
||||
@@ -268,38 +268,36 @@ class ImageEnvelope {
|
||||
required this.sizeBytes,
|
||||
required this.senderKey6,
|
||||
required this.timestampSec,
|
||||
this.version = 1,
|
||||
this.version = 2,
|
||||
});
|
||||
|
||||
static bool isEnvelope(String text) => text.startsWith(prefix);
|
||||
static bool isEnvelope(String text) => text.startsWith(_prefix);
|
||||
|
||||
static ImageEnvelope? tryParse(String text) {
|
||||
if (!isEnvelope(text)) return null;
|
||||
final body = text.substring(prefix.length);
|
||||
final body = text.substring(_prefix.length);
|
||||
final parts = body.split(':');
|
||||
if (parts.length != 9) return null;
|
||||
if (parts.length != 8) return null;
|
||||
try {
|
||||
final sid = parts[0];
|
||||
final fmtId = int.tryParse(parts[1]);
|
||||
final total = int.tryParse(parts[2]);
|
||||
final w = int.tryParse(parts[3]);
|
||||
final h = int.tryParse(parts[4]);
|
||||
final bytes = int.tryParse(parts[5]);
|
||||
final sid = _decodeSessionId(parts[0]);
|
||||
final fmtId = _parseInt(parts[1], base36: true);
|
||||
final total = _parseInt(parts[2], base36: true);
|
||||
final w = _parseInt(parts[3], base36: true);
|
||||
final h = _parseInt(parts[4], base36: true);
|
||||
final bytes = _parseInt(parts[5], base36: true);
|
||||
final senderKey6 = parts[6];
|
||||
final ts = int.tryParse(parts[7]);
|
||||
final ver = int.tryParse(parts[8]);
|
||||
final ts = _parseInt(parts[7], base36: true);
|
||||
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) return null;
|
||||
if (sid == null) return null;
|
||||
if (fmtId == null) return null;
|
||||
if (total == null || total < 1 || total > 255) return null;
|
||||
if (w == null || h == null || w < 1 || h < 1) return null;
|
||||
if (bytes == null || bytes < 1) return null;
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) return null;
|
||||
if (ts == null || ts <= 0) return null;
|
||||
if (ver == null || ver != 1) return null;
|
||||
|
||||
return ImageEnvelope(
|
||||
sessionId: sid.toLowerCase(),
|
||||
sessionId: sid,
|
||||
format: ImageFormat.fromId(fmtId),
|
||||
total: total,
|
||||
width: w,
|
||||
@@ -307,7 +305,7 @@ class ImageEnvelope {
|
||||
sizeBytes: bytes,
|
||||
senderKey6: senderKey6.toLowerCase(),
|
||||
timestampSec: ts,
|
||||
version: ver,
|
||||
version: 2,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
@@ -315,17 +313,21 @@ class ImageEnvelope {
|
||||
}
|
||||
|
||||
String encode() =>
|
||||
'$prefix${sessionId.toLowerCase()}:${format.id}:$total:$width:$height:$sizeBytes:${senderKey6.toLowerCase()}:$timestampSec:$version';
|
||||
'$_prefix${_encodeSessionId(sessionId)}:'
|
||||
'${_toBase36(format.id)}:${_toBase36(total)}:${_toBase36(width)}:'
|
||||
'${_toBase36(height)}:${_toBase36(sizeBytes)}:'
|
||||
'${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
|
||||
}
|
||||
|
||||
/// Direct request to fetch image fragments (control plane).
|
||||
///
|
||||
/// Text format:
|
||||
/// IR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
|
||||
/// IR2:{sid}:{want}:{requesterKey6}:{ts}
|
||||
/// Example:
|
||||
/// IR1:deadbeef:a:aabbccddeeff:1700000010:1
|
||||
/// IR2:deadbeef:a:aabbccddeeff:s44wea
|
||||
class ImageFetchRequest {
|
||||
static const String prefix = 'IR1:';
|
||||
static const String _prefix = 'IR2:';
|
||||
static const int _binaryMagic = 0x69; // 'i'
|
||||
|
||||
final String sessionId;
|
||||
final String want; // 'all' or 'missing'
|
||||
@@ -340,51 +342,89 @@ class ImageFetchRequest {
|
||||
this.missingIndices = const [],
|
||||
required this.requesterKey6,
|
||||
required this.timestampSec,
|
||||
this.version = 1,
|
||||
this.version = 2,
|
||||
});
|
||||
|
||||
static bool isRequest(String text) => text.startsWith(prefix);
|
||||
static bool isRequest(String text) => text.startsWith(_prefix);
|
||||
static bool isRequestBinary(Uint8List payload) =>
|
||||
payload.isNotEmpty && payload[0] == _binaryMagic;
|
||||
|
||||
static ImageFetchRequest? tryParse(String text) {
|
||||
if (!isRequest(text)) return null;
|
||||
final body = text.substring(prefix.length);
|
||||
final body = text.substring(_prefix.length);
|
||||
final parts = body.split(':');
|
||||
if (parts.length != 5) return null;
|
||||
if (parts.length != 4) return null;
|
||||
try {
|
||||
final sid = parts[0];
|
||||
final sid = _decodeSessionId(parts[0]);
|
||||
final wantToken = parts[1];
|
||||
final requesterKey6 = parts[2];
|
||||
final ts = int.tryParse(parts[3]);
|
||||
final ver = int.tryParse(parts[4]);
|
||||
final ts = _parseInt(parts[3], base36: true);
|
||||
final normalizedWant = wantToken == 'a'
|
||||
? 'all'
|
||||
: (wantToken.startsWith('m-') ? 'missing' : wantToken);
|
||||
: ((wantToken.startsWith('m'))
|
||||
? 'missing'
|
||||
: wantToken);
|
||||
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) return null;
|
||||
if (sid == null) return null;
|
||||
final missingIndices = <int>[];
|
||||
if (normalizedWant == 'missing') {
|
||||
final encoded = wantToken.substring(2);
|
||||
final encoded = wantToken.substring(1);
|
||||
if (encoded.isEmpty) return null;
|
||||
for (final raw in encoded.split(',')) {
|
||||
final idx = int.tryParse(raw);
|
||||
if (idx == null || idx < 0 || idx > 254) return null;
|
||||
missingIndices.add(idx);
|
||||
}
|
||||
missingIndices.addAll(_decodeMissingIndicesCompact(encoded));
|
||||
if (missingIndices.isEmpty) return null;
|
||||
} else if (normalizedWant != 'all') {
|
||||
return null;
|
||||
}
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null;
|
||||
if (ts == null || ts <= 0) return null;
|
||||
if (ver == null || ver != 1) return null;
|
||||
|
||||
return ImageFetchRequest(
|
||||
sessionId: sid.toLowerCase(),
|
||||
sessionId: sid,
|
||||
want: normalizedWant,
|
||||
missingIndices: missingIndices,
|
||||
requesterKey6: requesterKey6.toLowerCase(),
|
||||
timestampSec: ts,
|
||||
version: ver,
|
||||
version: 2,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static ImageFetchRequest? tryParseBinary(Uint8List payload) {
|
||||
if (!isRequestBinary(payload)) return null;
|
||||
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count
|
||||
try {
|
||||
final sid = payload
|
||||
.sublist(1, 5)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
final flags = payload[5];
|
||||
final requesterKey6 = payload
|
||||
.sublist(6, 12)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
final ts =
|
||||
(payload[12] << 24) |
|
||||
(payload[13] << 16) |
|
||||
(payload[14] << 8) |
|
||||
payload[15];
|
||||
final missingCount = payload[16];
|
||||
if (payload.length != 17 + missingCount) return null;
|
||||
final wantMissing = (flags & 0x01) == 0x01;
|
||||
final missing = <int>[];
|
||||
for (var i = 0; i < missingCount; i++) {
|
||||
missing.add(payload[17 + i]);
|
||||
}
|
||||
return ImageFetchRequest(
|
||||
sessionId: sid,
|
||||
want: wantMissing ? 'missing' : 'all',
|
||||
missingIndices: missing,
|
||||
requesterKey6: requesterKey6,
|
||||
timestampSec: ts,
|
||||
version: 2,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
@@ -393,10 +433,168 @@ class ImageFetchRequest {
|
||||
|
||||
String encode() {
|
||||
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
||||
? 'm-${missingIndices.join(',')}'
|
||||
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
|
||||
: (want == 'all' ? 'a' : want);
|
||||
return '$prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version';
|
||||
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}';
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
|
||||
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
|
||||
}
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
|
||||
throw ArgumentError.value(
|
||||
requesterKey6,
|
||||
'requesterKey6',
|
||||
'Expected 12 hex chars',
|
||||
);
|
||||
}
|
||||
final useMissing = want == 'missing' && missingIndices.isNotEmpty;
|
||||
final missing = useMissing
|
||||
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
|
||||
: <int>[];
|
||||
|
||||
final out = Uint8List(17 + missing.length);
|
||||
out[0] = _binaryMagic;
|
||||
for (var i = 0; i < 4; i++) {
|
||||
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
out[5] = useMissing ? 0x01 : 0x00;
|
||||
for (var i = 0; i < 6; i++) {
|
||||
out[6 + i] = int.parse(
|
||||
requesterKey6.substring(i * 2, i * 2 + 2),
|
||||
radix: 16,
|
||||
);
|
||||
}
|
||||
out[12] = (timestampSec >> 24) & 0xFF;
|
||||
out[13] = (timestampSec >> 16) & 0xFF;
|
||||
out[14] = (timestampSec >> 8) & 0xFF;
|
||||
out[15] = timestampSec & 0xFF;
|
||||
out[16] = missing.length;
|
||||
for (var i = 0; i < missing.length; i++) {
|
||||
out[17 + i] = missing[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-fragment ACK for raw image payload packets.
|
||||
///
|
||||
/// Binary format:
|
||||
/// [0x6a 'j'][sessionId:4B][index:1B]
|
||||
class ImageFragmentAck {
|
||||
static const int _binaryMagic = 0x6a; // 'j'
|
||||
|
||||
final String sessionId; // 8 hex chars
|
||||
final int index; // 0..254
|
||||
|
||||
const ImageFragmentAck({required this.sessionId, required this.index});
|
||||
|
||||
static bool isImageFragmentAckBinary(Uint8List payload) =>
|
||||
payload.length == 6 && payload[0] == _binaryMagic;
|
||||
|
||||
static ImageFragmentAck? tryParseBinary(Uint8List payload) {
|
||||
if (!isImageFragmentAckBinary(payload)) return null;
|
||||
try {
|
||||
final sid = payload
|
||||
.sublist(1, 5)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
final idx = payload[5];
|
||||
return ImageFragmentAck(sessionId: sid, index: idx);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
|
||||
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
|
||||
}
|
||||
if (index < 0 || index > 254) {
|
||||
throw ArgumentError.value(index, 'index', 'Expected 0..254');
|
||||
}
|
||||
final out = Uint8List(6);
|
||||
out[0] = _binaryMagic;
|
||||
for (var i = 0; i < 4; i++) {
|
||||
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
out[5] = index;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
int? _parseInt(String token, {required bool base36}) =>
|
||||
int.tryParse(token, radix: base36 ? 36 : 10);
|
||||
|
||||
String _toBase36(int value) => value.toRadixString(36);
|
||||
|
||||
String _encodeSessionId(String sessionIdHex) {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
|
||||
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars');
|
||||
}
|
||||
final value = int.parse(sessionIdHex, radix: 16);
|
||||
return value.toRadixString(36);
|
||||
}
|
||||
|
||||
String? _decodeSessionId(String token) {
|
||||
if (!RegExp(r'^[0-9a-z]{1,7}$').hasMatch(token)) return null;
|
||||
final value = int.tryParse(token, radix: 36);
|
||||
if (value == null || value < 0 || value > 0xFFFFFFFF) return null;
|
||||
return value.toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
|
||||
String _encodeMissingIndicesCompact(List<int> indices) {
|
||||
final sorted = indices
|
||||
.where((v) => v >= 0 && v <= 254)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
if (sorted.isEmpty) return '';
|
||||
final chunks = <String>[];
|
||||
var start = sorted.first;
|
||||
var prev = sorted.first;
|
||||
for (var i = 1; i < sorted.length; i++) {
|
||||
final curr = sorted[i];
|
||||
if (curr == prev + 1) {
|
||||
prev = curr;
|
||||
continue;
|
||||
}
|
||||
chunks.add(
|
||||
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
|
||||
);
|
||||
start = curr;
|
||||
prev = curr;
|
||||
}
|
||||
chunks.add(
|
||||
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
|
||||
);
|
||||
return chunks.join('.');
|
||||
}
|
||||
|
||||
List<int> _decodeMissingIndicesCompact(String encoded) {
|
||||
final out = <int>[];
|
||||
for (final token in encoded.split('.')) {
|
||||
if (token.isEmpty) continue;
|
||||
if (!token.contains('-')) {
|
||||
final value = int.tryParse(token, radix: 36);
|
||||
if (value == null || value < 0 || value > 254) return const [];
|
||||
out.add(value);
|
||||
continue;
|
||||
}
|
||||
final parts = token.split('-');
|
||||
if (parts.length != 2) return const [];
|
||||
final start = int.tryParse(parts[0], radix: 36);
|
||||
final end = int.tryParse(parts[1], radix: 36);
|
||||
if (start == null || end == null || start < 0 || end > 254 || start > end) {
|
||||
return const [];
|
||||
}
|
||||
for (var i = start; i <= end; i++) {
|
||||
out.add(i);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Fragment the compressed image bytes into [ImagePacket] list.
|
||||
|
||||
@@ -181,11 +181,11 @@ class VoicePacket {
|
||||
/// Lightweight public/direct message envelope advertising voice availability.
|
||||
///
|
||||
/// Text format:
|
||||
/// VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver}
|
||||
/// VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts}
|
||||
/// Example:
|
||||
/// VE1:00112233:1:4:3200:aabbccddeeff:1234567890:1
|
||||
/// VE2:00112233:1:4:4:aabbccddeeff:kf12oi
|
||||
class VoiceEnvelope {
|
||||
static const String _prefix = 'VE1:';
|
||||
static const String _prefix = 'VE2:';
|
||||
|
||||
final String sessionId;
|
||||
final VoicePacketMode mode;
|
||||
@@ -202,7 +202,7 @@ class VoiceEnvelope {
|
||||
required this.durationMs,
|
||||
required this.senderKey6,
|
||||
required this.timestampSec,
|
||||
this.version = 1,
|
||||
this.version = 2,
|
||||
});
|
||||
|
||||
static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix);
|
||||
@@ -210,43 +210,41 @@ class VoiceEnvelope {
|
||||
static VoiceEnvelope? tryParseText(String text) {
|
||||
if (!isVoiceEnvelopeText(text)) return null;
|
||||
final body = text.substring(_prefix.length);
|
||||
return _tryParseCompact(body);
|
||||
return _tryParse(body);
|
||||
}
|
||||
|
||||
static VoiceEnvelope? _tryParseCompact(String body) {
|
||||
static VoiceEnvelope? _tryParse(String body) {
|
||||
final parts = body.split(':');
|
||||
if (parts.length != 7) return null;
|
||||
if (parts.length != 6) return null;
|
||||
try {
|
||||
final sid = parts[0];
|
||||
final mode = int.tryParse(parts[1]);
|
||||
final total = int.tryParse(parts[2]);
|
||||
final durMs = int.tryParse(parts[3]);
|
||||
final sid = _decodeSessionId(parts[0]);
|
||||
final mode = _parseInt(parts[1], base36: true);
|
||||
final total = _parseInt(parts[2], base36: true);
|
||||
final durS = _parseInt(parts[3], base36: true);
|
||||
final senderKey6 = parts[4];
|
||||
final ts = int.tryParse(parts[5]);
|
||||
final ver = int.tryParse(parts[6]);
|
||||
final ts = _parseInt(parts[5], base36: true);
|
||||
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) {
|
||||
if (sid == null) {
|
||||
return null;
|
||||
}
|
||||
if (mode == null || mode < 0 || mode >= VoicePacketMode.values.length) {
|
||||
return null;
|
||||
}
|
||||
if (total == null || total < 1 || total > 255) return null;
|
||||
if (durMs == null || durMs < 0 || durMs > 10 * 60 * 1000) return null;
|
||||
if (durS == null || durS < 0 || durS > 10 * 60) return null;
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) {
|
||||
return null;
|
||||
}
|
||||
if (ts == null || ts <= 0) return null;
|
||||
if (ver == null || ver != 1) return null;
|
||||
|
||||
return VoiceEnvelope(
|
||||
sessionId: sid.toLowerCase(),
|
||||
sessionId: sid,
|
||||
mode: VoicePacketMode.fromId(mode),
|
||||
total: total,
|
||||
durationMs: durMs,
|
||||
durationMs: durS * 1000,
|
||||
senderKey6: senderKey6.toLowerCase(),
|
||||
timestampSec: ts,
|
||||
version: ver,
|
||||
version: 2,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
@@ -254,7 +252,8 @@ class VoiceEnvelope {
|
||||
}
|
||||
|
||||
String encodeText() {
|
||||
return '$_prefix${sessionId.toLowerCase()}:${mode.id}:$total:$durationMs:${senderKey6.toLowerCase()}:$timestampSec:$version';
|
||||
final durationSec = (durationMs / 1000).ceil().clamp(0, 10 * 60);
|
||||
return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}:${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,11 +414,12 @@ int _resolveBandwidthHz(int? rawBw) {
|
||||
/// Direct control-plane request to fetch voice packets for a session.
|
||||
///
|
||||
/// Text format:
|
||||
/// VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
|
||||
/// VR2:{sid}:{want}:{requesterKey6}:{ts}
|
||||
/// Example:
|
||||
/// VR1:00112233:a:aabbccddeeff:1234567890:1
|
||||
/// VR2:00112233:a:aabbccddeeff:kf12oi
|
||||
class VoiceFetchRequest {
|
||||
static const String _prefix = 'VR1:';
|
||||
static const String _prefix = 'VR2:';
|
||||
static const int _binaryMagic = 0x72; // 'r'
|
||||
|
||||
final String sessionId;
|
||||
final String want;
|
||||
@@ -434,42 +434,82 @@ class VoiceFetchRequest {
|
||||
this.missingIndices = const [],
|
||||
required this.requesterKey6,
|
||||
required this.timestampSec,
|
||||
this.version = 1,
|
||||
this.version = 2,
|
||||
});
|
||||
|
||||
static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix);
|
||||
static bool isVoiceFetchRequestText(String text) =>
|
||||
text.startsWith(_prefix);
|
||||
static bool isVoiceFetchRequestBinary(Uint8List payload) =>
|
||||
payload.isNotEmpty && payload[0] == _binaryMagic;
|
||||
|
||||
static VoiceFetchRequest? tryParseText(String text) {
|
||||
if (!isVoiceFetchRequestText(text)) return null;
|
||||
final body = text.substring(_prefix.length);
|
||||
return _tryParseCompact(body);
|
||||
return _tryParse(body);
|
||||
}
|
||||
|
||||
static VoiceFetchRequest? _tryParseCompact(String body) {
|
||||
final parts = body.split(':');
|
||||
if (parts.length != 5) return null;
|
||||
static VoiceFetchRequest? tryParseBinary(Uint8List payload) {
|
||||
if (!isVoiceFetchRequestBinary(payload)) return null;
|
||||
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count
|
||||
try {
|
||||
final sid = parts[0];
|
||||
final sidBytes = payload.sublist(1, 5);
|
||||
final sid = sidBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
final flags = payload[5];
|
||||
final requesterKey6 = payload
|
||||
.sublist(6, 12)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
final ts =
|
||||
(payload[12] << 24) |
|
||||
(payload[13] << 16) |
|
||||
(payload[14] << 8) |
|
||||
payload[15];
|
||||
final missingCount = payload[16];
|
||||
if (payload.length != 17 + missingCount) return null;
|
||||
final wantMissing = (flags & 0x01) == 0x01;
|
||||
final missing = <int>[];
|
||||
for (var i = 0; i < missingCount; i++) {
|
||||
missing.add(payload[17 + i]);
|
||||
}
|
||||
return VoiceFetchRequest(
|
||||
sessionId: sid,
|
||||
want: wantMissing ? 'missing' : 'all',
|
||||
missingIndices: missing,
|
||||
requesterKey6: requesterKey6,
|
||||
timestampSec: ts,
|
||||
version: 2,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static VoiceFetchRequest? _tryParse(String body) {
|
||||
final parts = body.split(':');
|
||||
if (parts.length != 4) return null;
|
||||
try {
|
||||
final sid = _decodeSessionId(parts[0]);
|
||||
final wantToken = parts[1];
|
||||
final requesterKey6 = parts[2];
|
||||
final ts = int.tryParse(parts[3]);
|
||||
final ver = int.tryParse(parts[4]);
|
||||
final ts = _parseInt(parts[3], base36: true);
|
||||
final normalizedWant = wantToken == 'a'
|
||||
? 'all'
|
||||
: (wantToken.startsWith('m-') ? 'missing' : wantToken);
|
||||
: ((wantToken.startsWith('m'))
|
||||
? 'missing'
|
||||
: wantToken);
|
||||
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) {
|
||||
if (sid == null) {
|
||||
return null;
|
||||
}
|
||||
final missingIndices = <int>[];
|
||||
if (normalizedWant == 'missing') {
|
||||
final encoded = wantToken.substring(2);
|
||||
final encoded = wantToken.substring(1);
|
||||
if (encoded.isEmpty) return null;
|
||||
for (final raw in encoded.split(',')) {
|
||||
final idx = int.tryParse(raw);
|
||||
if (idx == null || idx < 0 || idx > 254) return null;
|
||||
missingIndices.add(idx);
|
||||
}
|
||||
missingIndices.addAll(_decodeMissingIndicesCompact(encoded));
|
||||
if (missingIndices.isEmpty) return null;
|
||||
} else if (normalizedWant != 'all') {
|
||||
return null;
|
||||
@@ -478,15 +518,14 @@ class VoiceFetchRequest {
|
||||
return null;
|
||||
}
|
||||
if (ts == null || ts <= 0) return null;
|
||||
if (ver == null || ver != 1) return null;
|
||||
|
||||
return VoiceFetchRequest(
|
||||
sessionId: sid.toLowerCase(),
|
||||
sessionId: sid,
|
||||
want: normalizedWant,
|
||||
missingIndices: missingIndices,
|
||||
requesterKey6: requesterKey6.toLowerCase(),
|
||||
timestampSec: ts,
|
||||
version: ver,
|
||||
version: 2,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
@@ -495,10 +534,168 @@ class VoiceFetchRequest {
|
||||
|
||||
String encodeText() {
|
||||
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
||||
? 'm-${missingIndices.join(',')}'
|
||||
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
|
||||
: (want == 'all' ? 'a' : want);
|
||||
return '$_prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version';
|
||||
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}';
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
|
||||
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
|
||||
}
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
|
||||
throw ArgumentError.value(
|
||||
requesterKey6,
|
||||
'requesterKey6',
|
||||
'Expected 12 hex chars',
|
||||
);
|
||||
}
|
||||
final useMissing = want == 'missing' && missingIndices.isNotEmpty;
|
||||
final missing = useMissing
|
||||
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
|
||||
: <int>[];
|
||||
|
||||
final out = Uint8List(17 + missing.length);
|
||||
out[0] = _binaryMagic;
|
||||
for (var i = 0; i < 4; i++) {
|
||||
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
out[5] = useMissing ? 0x01 : 0x00;
|
||||
for (var i = 0; i < 6; i++) {
|
||||
out[6 + i] = int.parse(
|
||||
requesterKey6.substring(i * 2, i * 2 + 2),
|
||||
radix: 16,
|
||||
);
|
||||
}
|
||||
out[12] = (timestampSec >> 24) & 0xFF;
|
||||
out[13] = (timestampSec >> 16) & 0xFF;
|
||||
out[14] = (timestampSec >> 8) & 0xFF;
|
||||
out[15] = timestampSec & 0xFF;
|
||||
out[16] = missing.length;
|
||||
for (var i = 0; i < missing.length; i++) {
|
||||
out[17 + i] = missing[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-fragment ACK for raw voice payload packets.
|
||||
///
|
||||
/// Binary format:
|
||||
/// [0x76 'v'][sessionId:4B][index:1B]
|
||||
class VoiceFragmentAck {
|
||||
static const int _binaryMagic = 0x76; // 'v'
|
||||
|
||||
final String sessionId; // 8 hex chars
|
||||
final int index; // 0..254
|
||||
|
||||
const VoiceFragmentAck({required this.sessionId, required this.index});
|
||||
|
||||
static bool isVoiceFragmentAckBinary(Uint8List payload) =>
|
||||
payload.length == 6 && payload[0] == _binaryMagic;
|
||||
|
||||
static VoiceFragmentAck? tryParseBinary(Uint8List payload) {
|
||||
if (!isVoiceFragmentAckBinary(payload)) return null;
|
||||
try {
|
||||
final sid = payload
|
||||
.sublist(1, 5)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
final idx = payload[5];
|
||||
return VoiceFragmentAck(sessionId: sid, index: idx);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
|
||||
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
|
||||
}
|
||||
if (index < 0 || index > 254) {
|
||||
throw ArgumentError.value(index, 'index', 'Expected 0..254');
|
||||
}
|
||||
final out = Uint8List(6);
|
||||
out[0] = _binaryMagic;
|
||||
for (var i = 0; i < 4; i++) {
|
||||
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
out[5] = index;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
int? _parseInt(String token, {required bool base36}) =>
|
||||
int.tryParse(token, radix: base36 ? 36 : 10);
|
||||
|
||||
String _toBase36(int value) => value.toRadixString(36);
|
||||
|
||||
String _encodeSessionId(String sessionIdHex) {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
|
||||
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars');
|
||||
}
|
||||
final value = int.parse(sessionIdHex, radix: 16);
|
||||
return value.toRadixString(36);
|
||||
}
|
||||
|
||||
String? _decodeSessionId(String token) {
|
||||
if (!RegExp(r'^[0-9a-z]{1,7}$').hasMatch(token)) return null;
|
||||
final value = int.tryParse(token, radix: 36);
|
||||
if (value == null || value < 0 || value > 0xFFFFFFFF) return null;
|
||||
return value.toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
|
||||
String _encodeMissingIndicesCompact(List<int> indices) {
|
||||
final sorted = indices
|
||||
.where((v) => v >= 0 && v <= 254)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
if (sorted.isEmpty) return '';
|
||||
final chunks = <String>[];
|
||||
var start = sorted.first;
|
||||
var prev = sorted.first;
|
||||
for (var i = 1; i < sorted.length; i++) {
|
||||
final curr = sorted[i];
|
||||
if (curr == prev + 1) {
|
||||
prev = curr;
|
||||
continue;
|
||||
}
|
||||
chunks.add(
|
||||
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
|
||||
);
|
||||
start = curr;
|
||||
prev = curr;
|
||||
}
|
||||
chunks.add(
|
||||
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
|
||||
);
|
||||
return chunks.join('.');
|
||||
}
|
||||
|
||||
List<int> _decodeMissingIndicesCompact(String encoded) {
|
||||
final out = <int>[];
|
||||
for (final token in encoded.split('.')) {
|
||||
if (token.isEmpty) continue;
|
||||
if (!token.contains('-')) {
|
||||
final value = int.tryParse(token, radix: 36);
|
||||
if (value == null || value < 0 || value > 254) return const [];
|
||||
out.add(value);
|
||||
continue;
|
||||
}
|
||||
final parts = token.split('-');
|
||||
if (parts.length != 2) return const [];
|
||||
final start = int.tryParse(parts[0], radix: 36);
|
||||
final end = int.tryParse(parts[1], radix: 36);
|
||||
if (start == null || end == null || start < 0 || end > 254 || start > end) {
|
||||
return const [];
|
||||
}
|
||||
for (var i = start; i <= end; i++) {
|
||||
out.add(i);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Builds a compact visual waveform from real voice packet bytes.
|
||||
|
||||
@@ -13,8 +13,8 @@ import 'transfer_timeout.dart';
|
||||
|
||||
/// A message bubble that shows a received or sent image.
|
||||
///
|
||||
/// On first render the image is not yet fetched (only the IE1 envelope is
|
||||
/// known). The user taps the thumbnail placeholder → IR1 fetch request is
|
||||
/// On first render the image is not yet fetched (only the IE2 envelope is
|
||||
/// known). The user taps the thumbnail placeholder → IR2 fetch request is
|
||||
/// sent → binary fragments stream in → bubble rebuilds with the full image.
|
||||
class ImageMessageBubble extends StatefulWidget {
|
||||
final Message message;
|
||||
@@ -279,18 +279,23 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
final sent = await conn.sendTextMessage(
|
||||
contactPublicKey: sender.publicKey,
|
||||
text: request.encode(),
|
||||
contact: sender,
|
||||
);
|
||||
if (!sent && mounted) {
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_errorText = 'Image unavailable right now';
|
||||
});
|
||||
final payload = request.encodeBinary();
|
||||
try {
|
||||
await conn.sendRawVoicePacket(
|
||||
contactPath: sender.outPath,
|
||||
contactPathLen: sender.outPathLen,
|
||||
payload: payload,
|
||||
);
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_errorText = 'Image unavailable right now';
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||
final txEstimate = estimateImageTransmitDuration(
|
||||
|
||||
@@ -1682,6 +1682,13 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
}
|
||||
|
||||
final message = widget.message;
|
||||
final ticTacToeEvent = message.isContactMessage
|
||||
? TicTacToeMessageParser.tryParse(message.text)
|
||||
: null;
|
||||
if (ticTacToeEvent?.type == TicTacToeEventType.move) {
|
||||
// Hide move control packets from chat; the game bubble updates itself.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final isSarMarker = message.isSarMarker;
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
@@ -2183,8 +2190,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
!widget.isCompact)
|
||||
ImageMessageBubble(message: message, isSentByMe: isOwnMessage)
|
||||
// Tic-Tac-Toe control message content
|
||||
else if (message.isContactMessage &&
|
||||
TicTacToeMessageParser.isTicTacToe(message.text) &&
|
||||
else if (ticTacToeEvent?.type == TicTacToeEventType.start &&
|
||||
!widget.isCompact)
|
||||
TicTacToeMessageBubble(message: message, isSentByMe: isOwnMessage)
|
||||
// Regular message content
|
||||
|
||||
@@ -259,7 +259,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
sessionId: sessionId,
|
||||
requesterKey6: requesterKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
version: 1,
|
||||
version: 2,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
@@ -268,12 +268,13 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
final sent = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: sender.publicKey,
|
||||
text: request.encodeText(),
|
||||
contact: sender,
|
||||
);
|
||||
if (!sent) {
|
||||
try {
|
||||
await connectionProvider.sendRawVoicePacket(
|
||||
contactPath: sender.outPath,
|
||||
contactPathLen: sender.outPathLen,
|
||||
payload: request.encodeBinary(),
|
||||
);
|
||||
} catch (_) {
|
||||
_setUnavailable();
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user