From a0c4cdeedeedcdd2c9805d9c497d22c4854925dc Mon Sep 17 00:00:00 2001 From: Janez T Date: Sun, 1 Mar 2026 19:25:19 +0100 Subject: [PATCH] Add voice image packet retry --- lib/providers/app_provider.dart | 199 +++++++++++++++++++++- lib/providers/image_provider.dart | 28 +-- lib/providers/voice_provider.dart | 29 +++- lib/screens/messages_tab.dart | 18 +- lib/screens/settings_screen.dart | 15 ++ lib/services/image_codec_service.dart | 23 +-- lib/services/image_preferences.dart | 12 ++ lib/utils/image_message_parser.dart | 67 +++++++- lib/utils/voice_message_parser.dart | 25 ++- test/utils/voice_message_parser_test.dart | 16 ++ 10 files changed, 388 insertions(+), 44 deletions(-) diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 9fe9d1f..695ca59 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -47,6 +47,15 @@ class AppProvider with ChangeNotifier { bool _isVoiceLimiterEnabled = true; bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled; + static const Duration _packetRetryDelay = Duration(milliseconds: 1200); + static const int _maxPacketRetryAttempts = 4; + final Map _voiceSessionSenderKey6 = {}; + final Map _imageSessionSenderKey6 = {}; + final Map _voiceMissingRetryTimers = {}; + final Map _imageMissingRetryTimers = {}; + final Map _voiceMissingRetryAttempts = {}; + final Map _imageMissingRetryAttempts = {}; + AppProvider({ required this.connectionProvider, required this.contactsProvider, @@ -472,6 +481,9 @@ class AppProvider with ChangeNotifier { voiceProvider.serveSessionTo( sessionId: voiceFetchRequest.sessionId, requester: requester, + requestedIndices: voiceFetchRequest.want == 'missing' + ? voiceFetchRequest.missingIndices.toSet() + : null, ), ); return; @@ -519,6 +531,9 @@ class AppProvider with ChangeNotifier { // Voice envelope message (new public/direct on-demand format). final voiceEnvelope = VoiceEnvelope.tryParseText(enrichedMessage.text); if (voiceEnvelope != null) { + _voiceSessionSenderKey6[voiceEnvelope.sessionId] = voiceEnvelope + .senderKey6 + .toLowerCase(); enrichedMessage = enrichedMessage.copyWith( isVoice: true, voiceId: voiceEnvelope.sessionId, @@ -544,7 +559,9 @@ class AppProvider with ChangeNotifier { } // Image fetch request (IR1): requester asks us to stream image fragments. - final imageFetchRequest = ImageFetchRequest.tryParse(enrichedMessage.text); + final imageFetchRequest = ImageFetchRequest.tryParse( + enrichedMessage.text, + ); if (imageFetchRequest != null) { final senderPrefix = enrichedMessage.senderPublicKeyPrefix; if (senderPrefix != null) { @@ -562,6 +579,9 @@ class AppProvider with ChangeNotifier { imageProvider.serveSessionTo( sessionId: imageFetchRequest.sessionId, requester: requester, + requestedIndices: imageFetchRequest.want == 'missing' + ? imageFetchRequest.missingIndices.toSet() + : null, ), ); } @@ -573,6 +593,9 @@ class AppProvider with ChangeNotifier { // 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, @@ -660,11 +683,12 @@ class AppProvider with ChangeNotifier { if (frag == null) return; debugPrint('📷 [AppProvider] Binary image fragment received: $frag'); final session = imageProvider.session(frag.sessionId); - imageProvider.addFragment( + final justComplete = imageProvider.addFragment( frag, width: session?.width ?? 0, height: session?.height ?? 0, ); + _scheduleImageMissingRetry(frag.sessionId, justComplete: justComplete); return; } @@ -673,6 +697,7 @@ class AppProvider with ChangeNotifier { if (pkt == null) return; debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt'); final justComplete = voiceProvider.addPacket(pkt); + _scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete); // Insert or update the placeholder message in the chat list _handleIncomingVoicePacket(pkt, justComplete: justComplete); }; @@ -990,6 +1015,158 @@ class AppProvider with ChangeNotifier { } } + Contact? _resolveContactByPrefixHex(String prefixHex) { + if (prefixHex.length != 12) return null; + return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase()); + } + + void _scheduleVoiceMissingRetry( + String sessionId, { + required bool justComplete, + }) { + if (justComplete || voiceProvider.isComplete(sessionId)) { + _voiceMissingRetryTimers.remove(sessionId)?.cancel(); + _voiceMissingRetryAttempts.remove(sessionId); + return; + } + + _voiceMissingRetryAttempts[sessionId] = 0; + _voiceMissingRetryTimers[sessionId]?.cancel(); + _voiceMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () { + unawaited(_requestMissingVoicePackets(sessionId)); + }); + } + + Future _requestMissingVoicePackets(String sessionId) async { + if (voiceProvider.isComplete(sessionId)) { + _voiceMissingRetryTimers.remove(sessionId)?.cancel(); + _voiceMissingRetryAttempts.remove(sessionId); + return; + } + + final attempt = _voiceMissingRetryAttempts[sessionId] ?? 0; + if (attempt >= _maxPacketRetryAttempts) { + debugPrint( + '⚠️ [AppProvider] Voice re-request limit reached for $sessionId', + ); + _voiceMissingRetryTimers.remove(sessionId)?.cancel(); + return; + } + + final senderKey6 = _voiceSessionSenderKey6[sessionId]; + if (senderKey6 == null) return; + final sender = _resolveContactByPrefixHex(senderKey6); + final deviceKey = connectionProvider.deviceInfo.publicKey; + if (sender == null || deviceKey == null || deviceKey.length < 6) return; + + final missing = voiceProvider.missingPacketIndices(sessionId); + if (missing.isEmpty) { + _voiceMissingRetryTimers.remove(sessionId)?.cancel(); + _voiceMissingRetryAttempts.remove(sessionId); + return; + } + + final requesterKey6 = deviceKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + + final request = VoiceFetchRequest( + sessionId: sessionId, + want: 'missing', + missingIndices: missing, + requesterKey6: requesterKey6, + timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, + version: 1, + ); + + final sent = await connectionProvider.sendTextMessage( + contactPublicKey: sender.publicKey, + text: request.encodeText(), + contact: sender, + ); + if (!sent) return; + + _voiceMissingRetryAttempts[sessionId] = attempt + 1; + _voiceMissingRetryTimers[sessionId]?.cancel(); + _voiceMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () { + unawaited(_requestMissingVoicePackets(sessionId)); + }); + } + + void _scheduleImageMissingRetry( + String sessionId, { + required bool justComplete, + }) { + if (justComplete || imageProvider.isComplete(sessionId)) { + _imageMissingRetryTimers.remove(sessionId)?.cancel(); + _imageMissingRetryAttempts.remove(sessionId); + return; + } + + _imageMissingRetryAttempts[sessionId] = 0; + _imageMissingRetryTimers[sessionId]?.cancel(); + _imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () { + unawaited(_requestMissingImageFragments(sessionId)); + }); + } + + Future _requestMissingImageFragments(String sessionId) async { + if (imageProvider.isComplete(sessionId)) { + _imageMissingRetryTimers.remove(sessionId)?.cancel(); + _imageMissingRetryAttempts.remove(sessionId); + return; + } + + final attempt = _imageMissingRetryAttempts[sessionId] ?? 0; + if (attempt >= _maxPacketRetryAttempts) { + debugPrint( + '⚠️ [AppProvider] Image re-request limit reached for $sessionId', + ); + _imageMissingRetryTimers.remove(sessionId)?.cancel(); + return; + } + + final senderKey6 = _imageSessionSenderKey6[sessionId]; + if (senderKey6 == null) return; + final sender = _resolveContactByPrefixHex(senderKey6); + final deviceKey = connectionProvider.deviceInfo.publicKey; + if (sender == null || deviceKey == null || deviceKey.length < 6) return; + + final missing = imageProvider.missingFragmentIndices(sessionId); + if (missing.isEmpty) { + _imageMissingRetryTimers.remove(sessionId)?.cancel(); + _imageMissingRetryAttempts.remove(sessionId); + return; + } + + final requesterKey6 = deviceKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + + final request = ImageFetchRequest( + sessionId: sessionId, + want: 'missing', + missingIndices: missing, + requesterKey6: requesterKey6, + timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + + final sent = await connectionProvider.sendTextMessage( + contactPublicKey: sender.publicKey, + text: request.encode(), + contact: sender, + ); + if (!sent) return; + + _imageMissingRetryAttempts[sessionId] = attempt + 1; + _imageMissingRetryTimers[sessionId]?.cancel(); + _imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () { + unawaited(_requestMissingImageFragments(sessionId)); + }); + } + /// Insert or update a voice placeholder message for binary raw-data packets. /// /// Binary voice packets arrive without a chat message, so we synthesise one @@ -1120,6 +1297,18 @@ class AppProvider with ChangeNotifier { messagesProvider.clearAll(); unawaited(voiceProvider.clearStoredVoiceData()); unawaited(imageProvider.clearAll()); + for (final timer in _voiceMissingRetryTimers.values) { + timer.cancel(); + } + for (final timer in _imageMissingRetryTimers.values) { + timer.cancel(); + } + _voiceMissingRetryTimers.clear(); + _imageMissingRetryTimers.clear(); + _voiceMissingRetryAttempts.clear(); + _imageMissingRetryAttempts.clear(); + _voiceSessionSenderKey6.clear(); + _imageSessionSenderKey6.clear(); notifyListeners(); } @@ -1148,6 +1337,12 @@ class AppProvider with ChangeNotifier { locationTrackingService.onTrackingStateChanged = null; // Dispose the location tracking service to stop GPS stream and clean up resources locationTrackingService.dispose(); + for (final timer in _voiceMissingRetryTimers.values) { + timer.cancel(); + } + for (final timer in _imageMissingRetryTimers.values) { + timer.cancel(); + } super.dispose(); } } diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index 2aaaca5..62d7313 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; @@ -63,6 +62,16 @@ class ImageProvider with ChangeNotifier { _sessions[sessionId]?.isComplete ?? false; bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId); + List missingFragmentIndices(String sessionId) { + final session = _sessions[sessionId]; + if (session == null) return const []; + final missing = []; + for (var i = 0; i < session.total; i++) { + if (session.fragments[i] == null) missing.add(i); + } + return missing; + } + // ── Incoming fragment reception ────────────────────────────────────────── /// Add a received [fragment]. Creates the session on first fragment using @@ -166,6 +175,7 @@ class ImageProvider with ChangeNotifier { Future serveSessionTo({ required String sessionId, required Contact requester, + Set? requestedIndices, }) async { final cached = _outgoing[sessionId]; if (cached == null) { @@ -177,13 +187,15 @@ class ImageProvider with ChangeNotifier { return false; } if (requester.outPathLen < 0) { - debugPrint( - '⚠️ [ImageProvider] ${requester.advName} has no direct path', - ); + debugPrint('⚠️ [ImageProvider] ${requester.advName} has no direct path'); return false; } for (final fragment in cached.fragments) { + if (requestedIndices != null && + !requestedIndices.contains(fragment.index)) { + continue; + } try { await sendRawPacketCallback!( contactPath: requester.outPath, @@ -217,9 +229,7 @@ class ImageProvider with ChangeNotifier { void _evictExpiredOutgoing() { final now = DateTime.now(); - _outgoing.removeWhere( - (_, s) => now.difference(s.cachedAt) > _outgoingTtl, - ); + _outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl); } Future _persist() async { @@ -237,9 +247,7 @@ class ImageProvider with ChangeNotifier { 'height': s.height, 'fragments': s.fragments .map( - (f) => f == null - ? null - : base64.encode(f.encodeBinary()), + (f) => f == null ? null : base64.encode(f.encodeBinary()), ) .toList(), }, diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index 5483b8c..c59d129 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -93,6 +93,16 @@ class VoiceProvider with ChangeNotifier { bool hasOutgoingSession(String sessionId) => _outgoingSessions.containsKey(sessionId); + List missingPacketIndices(String sessionId) { + final session = _sessions[sessionId]; + if (session == null) return const []; + final missing = []; + for (var i = 0; i < session.total; i++) { + if (session.packets[i] == null) missing.add(i); + } + return missing; + } + // ── Packet reception ───────────────────────────────────────────────────── /// Add an incoming [packet] to its session. Creates the session on first packet. @@ -132,6 +142,7 @@ class VoiceProvider with ChangeNotifier { Future serveSessionTo({ required String sessionId, required Contact requester, + Set? requestedIndices, }) async { final cached = _outgoingSessions[sessionId]; if (cached == null) { @@ -152,6 +163,10 @@ class VoiceProvider with ChangeNotifier { } for (final packet in cached.packets) { + if (requestedIndices != null && + !requestedIndices.contains(packet.index)) { + continue; + } try { await sendRawPacketCallback!( contactPath: requester.outPath, @@ -229,9 +244,7 @@ class VoiceProvider with ChangeNotifier { 'sessionId': session.sessionId, 'modeId': session.mode.id, 'total': session.total, - 'packets': session.packets - .map((p) => p?.encodeText()) - .toList(), + 'packets': session.packets.map((p) => p?.encodeText()).toList(), }, ) .toList(), @@ -264,7 +277,10 @@ class VoiceProvider with ChangeNotifier { final sessionId = map['sessionId'] as String?; final modeId = map['modeId'] as int?; final total = map['total'] as int?; - if (sessionId == null || modeId == null || total == null || total <= 0) { + if (sessionId == null || + modeId == null || + total == null || + total <= 0) { continue; } final mode = VoicePacketMode.fromId(modeId); @@ -325,8 +341,5 @@ class _OutgoingVoiceSession { final String sessionId; final List packets; - const _OutgoingVoiceSession({ - required this.sessionId, - required this.packets, - }); + const _OutgoingVoiceSession({required this.sessionId, required this.packets}); } diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 82d21be..e3bc2bf 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -446,13 +446,15 @@ class _MessagesTabState extends State { setState(() => _isSendingImage = true); try { - // Compress to grayscale AVIF using user-selected size and compression. + // Compress AVIF using user-selected size, compression and color mode. final maxSize = await ImagePreferences.getMaxSize(); final compression = await ImagePreferences.getCompression(); + final grayscale = await ImagePreferences.getGrayscale(); final result = await ImageCodecService.compress( rawBytes, maxDimension: maxSize, compression: compression, + grayscale: grayscale, ); if (result == null) { ToastLogger.error(context, 'Image compression failed'); @@ -467,10 +469,21 @@ class _MessagesTabState extends State { ).join(); // Fragment. + var imageDataBytesPerFragment = ImagePacket.maxDataBytes; + if (_destinationType == + MessageDestinationPreferences.destinationTypeContact && + _selectedRecipient != null && + _selectedRecipient!.outPathLen >= 0) { + imageDataBytesPerFragment = safeImageDataBytesForPath( + _selectedRecipient!.outPathLen, + ); + } + final fragments = fragmentImage( sessionId: sessionId, format: ImageFormat.avif, bytes: compressed, + maxDataBytes: imageDataBytesPerFragment, ); if (fragments.isEmpty) { @@ -548,7 +561,8 @@ class _MessagesTabState extends State { debugPrint( '📷 [Image] Sent IE1 for session $sessionId: ' - '${fragments.length} fragments, ${compressed.length}B', + '${fragments.length} fragments, ${compressed.length}B, ' + 'chunk=${imageDataBytesPerFragment}B', ); } catch (e, st) { debugPrint('❌ [Image] _pickAndSendImage: $e\n$st'); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c060d2d..49edb75 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -50,6 +50,7 @@ class _SettingsScreenState extends State { int _voiceBitrate = VoiceBitratePreferences.defaultBitrate; int _imageMaxSize = ImagePreferences.defaultMaxSize; int _imageCompression = ImagePreferences.defaultQuality; + bool _imageGrayscale = ImagePreferences.defaultGrayscale; final LocationTrackingService _locationService = LocationTrackingService(); @override @@ -119,10 +120,12 @@ class _SettingsScreenState extends State { Future _loadImagePreferences() async { final size = await ImagePreferences.getMaxSize(); final compression = await ImagePreferences.getCompression(); + final grayscale = await ImagePreferences.getGrayscale(); if (!mounted) return; setState(() { _imageMaxSize = size; _imageCompression = compression; + _imageGrayscale = grayscale; }); } @@ -695,6 +698,18 @@ class _SettingsScreenState extends State { onChangeEnd: (v) => _saveImageCompression(v.round()), ), ), + SwitchListTile( + secondary: const Icon(Icons.invert_colors), + title: const Text('Grayscale'), + subtitle: const Text( + 'Converts image to grayscale for smaller file size', + ), + value: _imageGrayscale, + onChanged: (value) async { + await ImagePreferences.setGrayscale(value); + setState(() => _imageGrayscale = value); + }, + ), const Divider(), // Templates Section diff --git a/lib/services/image_codec_service.dart b/lib/services/image_codec_service.dart index cac12d6..03f3d4f 100644 --- a/lib/services/image_codec_service.dart +++ b/lib/services/image_codec_service.dart @@ -21,6 +21,7 @@ class ImageCodecService { Uint8List rawBytes, { int maxDimension = 256, int compression = 90, + bool grayscale = true, }) async { try { // 1a. Probe original dimensions (no resize). @@ -64,17 +65,19 @@ class ImageCodecService { image.dispose(); if (byteData == null) return null; - // 3. Convert to grayscale in-place (luminance, keep alpha = 255). + // 3. Optionally convert to grayscale in-place (luminance). final rgba = byteData.buffer.asUint8List(); - for (var i = 0; i < rgba.length; i += 4) { - final lum = - (0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2]) - .round() - .clamp(0, 255); - rgba[i] = lum; - rgba[i + 1] = lum; - rgba[i + 2] = lum; - rgba[i + 3] = 255; // fully opaque + if (grayscale) { + for (var i = 0; i < rgba.length; i += 4) { + final lum = + (0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2]) + .round() + .clamp(0, 255); + rgba[i] = lum; + rgba[i + 1] = lum; + rgba[i + 2] = lum; + rgba[i + 3] = 255; + } } // 4. Re-encode grayscale RGBA → PNG so encodeAvif can decode it. diff --git a/lib/services/image_preferences.dart b/lib/services/image_preferences.dart index 89c17d6..f883b44 100644 --- a/lib/services/image_preferences.dart +++ b/lib/services/image_preferences.dart @@ -5,9 +5,11 @@ class ImagePreferences { static const String _maxSizeKey = 'image_max_size'; // Keep the legacy key name so existing users retain their saved value. static const String _qualityKey = 'image_quality'; + static const String _grayscaleKey = 'image_grayscale'; static const int defaultMaxSize = 256; static const int defaultQuality = 90; + static const bool defaultGrayscale = true; static const List supportedSizes = [64, 128, 256]; @@ -32,4 +34,14 @@ class ImagePreferences { final prefs = await SharedPreferences.getInstance(); await prefs.setInt(_qualityKey, compression.clamp(10, 90)); } + + static Future getGrayscale() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_grayscaleKey) ?? defaultGrayscale; + } + + static Future setGrayscale(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_grayscaleKey, value); + } } diff --git a/lib/utils/image_message_parser.dart b/lib/utils/image_message_parser.dart index 89e4673..fd5b773 100644 --- a/lib/utils/image_message_parser.dart +++ b/lib/utils/image_message_parser.dart @@ -1,5 +1,9 @@ import 'dart:typed_data'; +const int _maxCompanionFrameBytes = 172; // MeshCore MAX_FRAME_SIZE +const int _cmdSendRawDataOverheadBytes = 2; // cmd + pathLen +const int _maxMeshPacketPayloadBytes = 184; // MeshCore MAX_PACKET_PAYLOAD + /// Compressed image format used in the image packet protocol. enum ImageFormat { avif(0, 'AVIF'), @@ -20,7 +24,7 @@ enum ImageFormat { /// Binary format (direct contacts, via pushRawData / cmdSendRawData): /// [0x49 'I'][sessionId:4B][fmt:1B][idx:1B][total:1B][imageData...] /// -/// Max total packet size is 160 bytes → 152 bytes of image data per fragment. +/// Legacy default is 152 data bytes per fragment. class ImagePacket { final String sessionId; // 8 hex chars (4 bytes) final ImageFormat format; @@ -38,7 +42,8 @@ class ImagePacket { static const int _magic = 0x49; // 'I' static const int _headerLen = 8; // magic(1)+session(4)+fmt(1)+idx(1)+total(1) - static const int maxDataBytes = 152; // 160 - 8 header bytes + static const int maxDataBytes = + 152; // Conservative default for compatibility. static bool isImageBinary(Uint8List payload) => payload.isNotEmpty && payload[0] == _magic; @@ -90,6 +95,30 @@ class ImagePacket { 'ImagePacket($sessionId ${format.label} [$index/${total - 1}] ${data.length}B)'; } +/// Compute the maximum safe image data bytes for a direct route path. +/// +/// This accounts for: +/// - companion command-frame limit (MAX_FRAME_SIZE=172), +/// - cmdSendRawData overhead (`cmd` + `pathLen`), +/// - image packet binary header (8 bytes), +/// - mesh packet payload limit (MAX_PACKET_PAYLOAD=184). +/// +/// Path length follows Contact.outPathLen semantics: 0 = direct, 1+ = hops. +int safeImageDataBytesForPath(int pathLen) { + final normalizedPathLen = pathLen.clamp(0, 64).toInt(); + final maxRawPayloadFromCommandFrame = + _maxCompanionFrameBytes - + _cmdSendRawDataOverheadBytes - + normalizedPathLen; + final maxRawPayloadFromMesh = + _maxMeshPacketPayloadBytes - ImagePacket._headerLen; + final maxRawPayload = maxRawPayloadFromCommandFrame < maxRawPayloadFromMesh + ? maxRawPayloadFromCommandFrame + : maxRawPayloadFromMesh; + final maxData = maxRawPayload - ImagePacket._headerLen; + return maxData.clamp(1, 255).toInt(); +} + /// Envelope announcing image availability (control plane). /// /// Text format: @@ -165,7 +194,7 @@ class ImageEnvelope { } String encode() => - '${prefix}${sessionId.toLowerCase()}:${format.id}:$total:$width:$height:$sizeBytes:${senderKey6.toLowerCase()}:$timestampSec:$version'; + '$prefix${sessionId.toLowerCase()}:${format.id}:$total:$width:$height:$sizeBytes:${senderKey6.toLowerCase()}:$timestampSec:$version'; } /// Direct request to fetch image fragments (control plane). @@ -178,7 +207,8 @@ class ImageFetchRequest { static const String prefix = 'IR1:'; final String sessionId; - final String want; // always 'all' + final String want; // 'all' or 'missing' + final List missingIndices; final String requesterKey6; // 12 hex chars final int timestampSec; final int version; @@ -186,6 +216,7 @@ class ImageFetchRequest { const ImageFetchRequest({ required this.sessionId, this.want = 'all', + this.missingIndices = const [], required this.requesterKey6, required this.timestampSec, this.version = 1, @@ -204,10 +235,24 @@ class ImageFetchRequest { final requesterKey6 = parts[2]; final ts = int.tryParse(parts[3]); final ver = int.tryParse(parts[4]); - final normalizedWant = wantToken == 'a' ? 'all' : wantToken; + final normalizedWant = wantToken == 'a' + ? 'all' + : (wantToken.startsWith('m-') ? 'missing' : wantToken); if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) return null; - if (normalizedWant != 'all') return null; + final missingIndices = []; + if (normalizedWant == 'missing') { + final encoded = wantToken.substring(2); + 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); + } + 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; @@ -215,6 +260,7 @@ class ImageFetchRequest { return ImageFetchRequest( sessionId: sid.toLowerCase(), want: normalizedWant, + missingIndices: missingIndices, requesterKey6: requesterKey6.toLowerCase(), timestampSec: ts, version: ver, @@ -225,8 +271,10 @@ class ImageFetchRequest { } String encode() { - final wantToken = want == 'all' ? 'a' : want; - return '${prefix}${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version'; + final wantToken = want == 'missing' && missingIndices.isNotEmpty + ? 'm-${missingIndices.join(',')}' + : (want == 'all' ? 'a' : want); + return '$prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version'; } } @@ -239,8 +287,9 @@ List fragmentImage({ required String sessionId, required ImageFormat format, required Uint8List bytes, + int maxDataBytes = ImagePacket.maxDataBytes, }) { - const chunkSize = ImagePacket.maxDataBytes; + final chunkSize = maxDataBytes.clamp(1, 255).toInt(); final chunks = []; for (var offset = 0; offset < bytes.length; offset += chunkSize) { final end = (offset + chunkSize).clamp(0, bytes.length); diff --git a/lib/utils/voice_message_parser.dart b/lib/utils/voice_message_parser.dart index 5e91170..cb59a5f 100644 --- a/lib/utils/voice_message_parser.dart +++ b/lib/utils/voice_message_parser.dart @@ -259,6 +259,7 @@ class VoiceFetchRequest { final String sessionId; final String want; + final List missingIndices; final String requesterKey6; final int timestampSec; final int version; @@ -266,6 +267,7 @@ class VoiceFetchRequest { const VoiceFetchRequest({ required this.sessionId, this.want = 'all', + this.missingIndices = const [], required this.requesterKey6, required this.timestampSec, this.version = 1, @@ -288,12 +290,26 @@ class VoiceFetchRequest { final requesterKey6 = parts[2]; final ts = int.tryParse(parts[3]); final ver = int.tryParse(parts[4]); - final normalizedWant = wantToken == 'a' ? 'all' : wantToken; + final normalizedWant = wantToken == 'a' + ? 'all' + : (wantToken.startsWith('m-') ? 'missing' : wantToken); if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) { return null; } - if (normalizedWant != 'all') return null; + final missingIndices = []; + if (normalizedWant == 'missing') { + final encoded = wantToken.substring(2); + 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); + } + if (missingIndices.isEmpty) return null; + } else if (normalizedWant != 'all') { + return null; + } if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) { return null; } @@ -303,6 +319,7 @@ class VoiceFetchRequest { return VoiceFetchRequest( sessionId: sid.toLowerCase(), want: normalizedWant, + missingIndices: missingIndices, requesterKey6: requesterKey6.toLowerCase(), timestampSec: ts, version: ver, @@ -313,7 +330,9 @@ class VoiceFetchRequest { } String encodeText() { - final wantToken = want == 'all' ? 'a' : want; + final wantToken = want == 'missing' && missingIndices.isNotEmpty + ? 'm-${missingIndices.join(',')}' + : (want == 'all' ? 'a' : want); return '$_prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version'; } } diff --git a/test/utils/voice_message_parser_test.dart b/test/utils/voice_message_parser_test.dart index 796a112..e2d5881 100644 --- a/test/utils/voice_message_parser_test.dart +++ b/test/utils/voice_message_parser_test.dart @@ -59,6 +59,22 @@ void main() { isNull, ); }); + + test('encodes and parses missing-packet request', () { + final req = VoiceFetchRequest( + sessionId: '00112233', + want: 'missing', + missingIndices: const [0, 3, 7], + requesterKey6: 'ffeeddccbbaa', + timestampSec: 1700000001, + ); + final text = req.encodeText(); + + final parsed = VoiceFetchRequest.tryParseText(text); + expect(parsed, isNotNull); + expect(parsed!.want, equals('missing')); + expect(parsed.missingIndices, equals([0, 3, 7])); + }); }); group('VoicePacket backward compatibility', () {