Add voice image packet retry

This commit is contained in:
Janez T
2026-03-01 19:25:19 +01:00
parent 0e7ba5572d
commit a0c4cdeede
10 changed files with 388 additions and 44 deletions

View File

@@ -47,6 +47,15 @@ class AppProvider with ChangeNotifier {
bool _isVoiceLimiterEnabled = true; bool _isVoiceLimiterEnabled = true;
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled; bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
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, Timer> _imageMissingRetryTimers = {};
final Map<String, int> _voiceMissingRetryAttempts = {};
final Map<String, int> _imageMissingRetryAttempts = {};
AppProvider({ AppProvider({
required this.connectionProvider, required this.connectionProvider,
required this.contactsProvider, required this.contactsProvider,
@@ -472,6 +481,9 @@ class AppProvider with ChangeNotifier {
voiceProvider.serveSessionTo( voiceProvider.serveSessionTo(
sessionId: voiceFetchRequest.sessionId, sessionId: voiceFetchRequest.sessionId,
requester: requester, requester: requester,
requestedIndices: voiceFetchRequest.want == 'missing'
? voiceFetchRequest.missingIndices.toSet()
: null,
), ),
); );
return; return;
@@ -519,6 +531,9 @@ class AppProvider with ChangeNotifier {
// Voice envelope message (new public/direct on-demand format). // Voice envelope message (new public/direct on-demand format).
final voiceEnvelope = VoiceEnvelope.tryParseText(enrichedMessage.text); final voiceEnvelope = VoiceEnvelope.tryParseText(enrichedMessage.text);
if (voiceEnvelope != null) { if (voiceEnvelope != null) {
_voiceSessionSenderKey6[voiceEnvelope.sessionId] = voiceEnvelope
.senderKey6
.toLowerCase();
enrichedMessage = enrichedMessage.copyWith( enrichedMessage = enrichedMessage.copyWith(
isVoice: true, isVoice: true,
voiceId: voiceEnvelope.sessionId, voiceId: voiceEnvelope.sessionId,
@@ -544,7 +559,9 @@ class AppProvider with ChangeNotifier {
} }
// Image fetch request (IR1): requester asks us to stream image fragments. // 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) { if (imageFetchRequest != null) {
final senderPrefix = enrichedMessage.senderPublicKeyPrefix; final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
if (senderPrefix != null) { if (senderPrefix != null) {
@@ -562,6 +579,9 @@ class AppProvider with ChangeNotifier {
imageProvider.serveSessionTo( imageProvider.serveSessionTo(
sessionId: imageFetchRequest.sessionId, sessionId: imageFetchRequest.sessionId,
requester: requester, requester: requester,
requestedIndices: imageFetchRequest.want == 'missing'
? imageFetchRequest.missingIndices.toSet()
: null,
), ),
); );
} }
@@ -573,6 +593,9 @@ class AppProvider with ChangeNotifier {
// Image envelope (IE1): announce image availability. // Image envelope (IE1): announce image availability.
final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text); final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text);
if (imageEnvelope != null) { if (imageEnvelope != null) {
_imageSessionSenderKey6[imageEnvelope.sessionId] = imageEnvelope
.senderKey6
.toLowerCase();
imageProvider.registerEnvelope(imageEnvelope); imageProvider.registerEnvelope(imageEnvelope);
messagesProvider.addMessage( messagesProvider.addMessage(
enrichedMessage, enrichedMessage,
@@ -660,11 +683,12 @@ class AppProvider with ChangeNotifier {
if (frag == null) return; if (frag == null) return;
debugPrint('📷 [AppProvider] Binary image fragment received: $frag'); debugPrint('📷 [AppProvider] Binary image fragment received: $frag');
final session = imageProvider.session(frag.sessionId); final session = imageProvider.session(frag.sessionId);
imageProvider.addFragment( final justComplete = imageProvider.addFragment(
frag, frag,
width: session?.width ?? 0, width: session?.width ?? 0,
height: session?.height ?? 0, height: session?.height ?? 0,
); );
_scheduleImageMissingRetry(frag.sessionId, justComplete: justComplete);
return; return;
} }
@@ -673,6 +697,7 @@ class AppProvider with ChangeNotifier {
if (pkt == null) return; if (pkt == null) return;
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt'); debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
final justComplete = voiceProvider.addPacket(pkt); final justComplete = voiceProvider.addPacket(pkt);
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
// Insert or update the placeholder message in the chat list // Insert or update the placeholder message in the chat list
_handleIncomingVoicePacket(pkt, justComplete: justComplete); _handleIncomingVoicePacket(pkt, justComplete: justComplete);
}; };
@@ -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<void> _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<void> _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. /// Insert or update a voice placeholder message for binary raw-data packets.
/// ///
/// Binary voice packets arrive without a chat message, so we synthesise one /// Binary voice packets arrive without a chat message, so we synthesise one
@@ -1120,6 +1297,18 @@ class AppProvider with ChangeNotifier {
messagesProvider.clearAll(); messagesProvider.clearAll();
unawaited(voiceProvider.clearStoredVoiceData()); unawaited(voiceProvider.clearStoredVoiceData());
unawaited(imageProvider.clearAll()); 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(); notifyListeners();
} }
@@ -1148,6 +1337,12 @@ class AppProvider with ChangeNotifier {
locationTrackingService.onTrackingStateChanged = null; locationTrackingService.onTrackingStateChanged = null;
// Dispose the location tracking service to stop GPS stream and clean up resources // Dispose the location tracking service to stop GPS stream and clean up resources
locationTrackingService.dispose(); locationTrackingService.dispose();
for (final timer in _voiceMissingRetryTimers.values) {
timer.cancel();
}
for (final timer in _imageMissingRetryTimers.values) {
timer.cancel();
}
super.dispose(); super.dispose();
} }
} }

View File

@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -63,6 +62,16 @@ class ImageProvider with ChangeNotifier {
_sessions[sessionId]?.isComplete ?? false; _sessions[sessionId]?.isComplete ?? false;
bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId); bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId);
List<int> missingFragmentIndices(String sessionId) {
final session = _sessions[sessionId];
if (session == null) return const [];
final missing = <int>[];
for (var i = 0; i < session.total; i++) {
if (session.fragments[i] == null) missing.add(i);
}
return missing;
}
// ── Incoming fragment reception ────────────────────────────────────────── // ── Incoming fragment reception ──────────────────────────────────────────
/// Add a received [fragment]. Creates the session on first fragment using /// Add a received [fragment]. Creates the session on first fragment using
@@ -166,6 +175,7 @@ class ImageProvider with ChangeNotifier {
Future<bool> serveSessionTo({ Future<bool> serveSessionTo({
required String sessionId, required String sessionId,
required Contact requester, required Contact requester,
Set<int>? requestedIndices,
}) async { }) async {
final cached = _outgoing[sessionId]; final cached = _outgoing[sessionId];
if (cached == null) { if (cached == null) {
@@ -177,13 +187,15 @@ class ImageProvider with ChangeNotifier {
return false; return false;
} }
if (requester.outPathLen < 0) { if (requester.outPathLen < 0) {
debugPrint( debugPrint('⚠️ [ImageProvider] ${requester.advName} has no direct path');
'⚠️ [ImageProvider] ${requester.advName} has no direct path',
);
return false; return false;
} }
for (final fragment in cached.fragments) { for (final fragment in cached.fragments) {
if (requestedIndices != null &&
!requestedIndices.contains(fragment.index)) {
continue;
}
try { try {
await sendRawPacketCallback!( await sendRawPacketCallback!(
contactPath: requester.outPath, contactPath: requester.outPath,
@@ -217,9 +229,7 @@ class ImageProvider with ChangeNotifier {
void _evictExpiredOutgoing() { void _evictExpiredOutgoing() {
final now = DateTime.now(); final now = DateTime.now();
_outgoing.removeWhere( _outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl);
(_, s) => now.difference(s.cachedAt) > _outgoingTtl,
);
} }
Future<void> _persist() async { Future<void> _persist() async {
@@ -237,9 +247,7 @@ class ImageProvider with ChangeNotifier {
'height': s.height, 'height': s.height,
'fragments': s.fragments 'fragments': s.fragments
.map( .map(
(f) => f == null (f) => f == null ? null : base64.encode(f.encodeBinary()),
? null
: base64.encode(f.encodeBinary()),
) )
.toList(), .toList(),
}, },

View File

@@ -93,6 +93,16 @@ class VoiceProvider with ChangeNotifier {
bool hasOutgoingSession(String sessionId) => bool hasOutgoingSession(String sessionId) =>
_outgoingSessions.containsKey(sessionId); _outgoingSessions.containsKey(sessionId);
List<int> missingPacketIndices(String sessionId) {
final session = _sessions[sessionId];
if (session == null) return const [];
final missing = <int>[];
for (var i = 0; i < session.total; i++) {
if (session.packets[i] == null) missing.add(i);
}
return missing;
}
// ── Packet reception ───────────────────────────────────────────────────── // ── Packet reception ─────────────────────────────────────────────────────
/// Add an incoming [packet] to its session. Creates the session on first packet. /// Add an incoming [packet] to its session. Creates the session on first packet.
@@ -132,6 +142,7 @@ class VoiceProvider with ChangeNotifier {
Future<bool> serveSessionTo({ Future<bool> serveSessionTo({
required String sessionId, required String sessionId,
required Contact requester, required Contact requester,
Set<int>? requestedIndices,
}) async { }) async {
final cached = _outgoingSessions[sessionId]; final cached = _outgoingSessions[sessionId];
if (cached == null) { if (cached == null) {
@@ -152,6 +163,10 @@ class VoiceProvider with ChangeNotifier {
} }
for (final packet in cached.packets) { for (final packet in cached.packets) {
if (requestedIndices != null &&
!requestedIndices.contains(packet.index)) {
continue;
}
try { try {
await sendRawPacketCallback!( await sendRawPacketCallback!(
contactPath: requester.outPath, contactPath: requester.outPath,
@@ -229,9 +244,7 @@ class VoiceProvider with ChangeNotifier {
'sessionId': session.sessionId, 'sessionId': session.sessionId,
'modeId': session.mode.id, 'modeId': session.mode.id,
'total': session.total, 'total': session.total,
'packets': session.packets 'packets': session.packets.map((p) => p?.encodeText()).toList(),
.map((p) => p?.encodeText())
.toList(),
}, },
) )
.toList(), .toList(),
@@ -264,7 +277,10 @@ class VoiceProvider with ChangeNotifier {
final sessionId = map['sessionId'] as String?; final sessionId = map['sessionId'] as String?;
final modeId = map['modeId'] as int?; final modeId = map['modeId'] as int?;
final total = map['total'] 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; continue;
} }
final mode = VoicePacketMode.fromId(modeId); final mode = VoicePacketMode.fromId(modeId);
@@ -325,8 +341,5 @@ class _OutgoingVoiceSession {
final String sessionId; final String sessionId;
final List<VoicePacket> packets; final List<VoicePacket> packets;
const _OutgoingVoiceSession({ const _OutgoingVoiceSession({required this.sessionId, required this.packets});
required this.sessionId,
required this.packets,
});
} }

View File

@@ -446,13 +446,15 @@ class _MessagesTabState extends State<MessagesTab> {
setState(() => _isSendingImage = true); setState(() => _isSendingImage = true);
try { 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 maxSize = await ImagePreferences.getMaxSize();
final compression = await ImagePreferences.getCompression(); final compression = await ImagePreferences.getCompression();
final grayscale = await ImagePreferences.getGrayscale();
final result = await ImageCodecService.compress( final result = await ImageCodecService.compress(
rawBytes, rawBytes,
maxDimension: maxSize, maxDimension: maxSize,
compression: compression, compression: compression,
grayscale: grayscale,
); );
if (result == null) { if (result == null) {
ToastLogger.error(context, 'Image compression failed'); ToastLogger.error(context, 'Image compression failed');
@@ -467,10 +469,21 @@ class _MessagesTabState extends State<MessagesTab> {
).join(); ).join();
// Fragment. // Fragment.
var imageDataBytesPerFragment = ImagePacket.maxDataBytes;
if (_destinationType ==
MessageDestinationPreferences.destinationTypeContact &&
_selectedRecipient != null &&
_selectedRecipient!.outPathLen >= 0) {
imageDataBytesPerFragment = safeImageDataBytesForPath(
_selectedRecipient!.outPathLen,
);
}
final fragments = fragmentImage( final fragments = fragmentImage(
sessionId: sessionId, sessionId: sessionId,
format: ImageFormat.avif, format: ImageFormat.avif,
bytes: compressed, bytes: compressed,
maxDataBytes: imageDataBytesPerFragment,
); );
if (fragments.isEmpty) { if (fragments.isEmpty) {
@@ -548,7 +561,8 @@ class _MessagesTabState extends State<MessagesTab> {
debugPrint( debugPrint(
'📷 [Image] Sent IE1 for session $sessionId: ' '📷 [Image] Sent IE1 for session $sessionId: '
'${fragments.length} fragments, ${compressed.length}B', '${fragments.length} fragments, ${compressed.length}B, '
'chunk=${imageDataBytesPerFragment}B',
); );
} catch (e, st) { } catch (e, st) {
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st'); debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');

View File

@@ -50,6 +50,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
int _voiceBitrate = VoiceBitratePreferences.defaultBitrate; int _voiceBitrate = VoiceBitratePreferences.defaultBitrate;
int _imageMaxSize = ImagePreferences.defaultMaxSize; int _imageMaxSize = ImagePreferences.defaultMaxSize;
int _imageCompression = ImagePreferences.defaultQuality; int _imageCompression = ImagePreferences.defaultQuality;
bool _imageGrayscale = ImagePreferences.defaultGrayscale;
final LocationTrackingService _locationService = LocationTrackingService(); final LocationTrackingService _locationService = LocationTrackingService();
@override @override
@@ -119,10 +120,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _loadImagePreferences() async { Future<void> _loadImagePreferences() async {
final size = await ImagePreferences.getMaxSize(); final size = await ImagePreferences.getMaxSize();
final compression = await ImagePreferences.getCompression(); final compression = await ImagePreferences.getCompression();
final grayscale = await ImagePreferences.getGrayscale();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_imageMaxSize = size; _imageMaxSize = size;
_imageCompression = compression; _imageCompression = compression;
_imageGrayscale = grayscale;
}); });
} }
@@ -695,6 +698,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
onChangeEnd: (v) => _saveImageCompression(v.round()), 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(), const Divider(),
// Templates Section // Templates Section

View File

@@ -21,6 +21,7 @@ class ImageCodecService {
Uint8List rawBytes, { Uint8List rawBytes, {
int maxDimension = 256, int maxDimension = 256,
int compression = 90, int compression = 90,
bool grayscale = true,
}) async { }) async {
try { try {
// 1a. Probe original dimensions (no resize). // 1a. Probe original dimensions (no resize).
@@ -64,8 +65,9 @@ class ImageCodecService {
image.dispose(); image.dispose();
if (byteData == null) return null; 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(); final rgba = byteData.buffer.asUint8List();
if (grayscale) {
for (var i = 0; i < rgba.length; i += 4) { for (var i = 0; i < rgba.length; i += 4) {
final lum = final lum =
(0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2]) (0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2])
@@ -74,7 +76,8 @@ class ImageCodecService {
rgba[i] = lum; rgba[i] = lum;
rgba[i + 1] = lum; rgba[i + 1] = lum;
rgba[i + 2] = lum; rgba[i + 2] = lum;
rgba[i + 3] = 255; // fully opaque rgba[i + 3] = 255;
}
} }
// 4. Re-encode grayscale RGBA → PNG so encodeAvif can decode it. // 4. Re-encode grayscale RGBA → PNG so encodeAvif can decode it.

View File

@@ -5,9 +5,11 @@ class ImagePreferences {
static const String _maxSizeKey = 'image_max_size'; static const String _maxSizeKey = 'image_max_size';
// Keep the legacy key name so existing users retain their saved value. // Keep the legacy key name so existing users retain their saved value.
static const String _qualityKey = 'image_quality'; static const String _qualityKey = 'image_quality';
static const String _grayscaleKey = 'image_grayscale';
static const int defaultMaxSize = 256; static const int defaultMaxSize = 256;
static const int defaultQuality = 90; static const int defaultQuality = 90;
static const bool defaultGrayscale = true;
static const List<int> supportedSizes = [64, 128, 256]; static const List<int> supportedSizes = [64, 128, 256];
@@ -32,4 +34,14 @@ class ImagePreferences {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_qualityKey, compression.clamp(10, 90)); await prefs.setInt(_qualityKey, compression.clamp(10, 90));
} }
static Future<bool> getGrayscale() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_grayscaleKey) ?? defaultGrayscale;
}
static Future<void> setGrayscale(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_grayscaleKey, value);
}
} }

View File

@@ -1,5 +1,9 @@
import 'dart:typed_data'; 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. /// Compressed image format used in the image packet protocol.
enum ImageFormat { enum ImageFormat {
avif(0, 'AVIF'), avif(0, 'AVIF'),
@@ -20,7 +24,7 @@ enum ImageFormat {
/// Binary format (direct contacts, via pushRawData / cmdSendRawData): /// Binary format (direct contacts, via pushRawData / cmdSendRawData):
/// [0x49 'I'][sessionId:4B][fmt:1B][idx:1B][total:1B][imageData...] /// [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 { class ImagePacket {
final String sessionId; // 8 hex chars (4 bytes) final String sessionId; // 8 hex chars (4 bytes)
final ImageFormat format; final ImageFormat format;
@@ -38,7 +42,8 @@ class ImagePacket {
static const int _magic = 0x49; // 'I' static const int _magic = 0x49; // 'I'
static const int _headerLen = 8; // magic(1)+session(4)+fmt(1)+idx(1)+total(1) 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) => static bool isImageBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _magic; payload.isNotEmpty && payload[0] == _magic;
@@ -90,6 +95,30 @@ class ImagePacket {
'ImagePacket($sessionId ${format.label} [$index/${total - 1}] ${data.length}B)'; '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). /// Envelope announcing image availability (control plane).
/// ///
/// Text format: /// Text format:
@@ -165,7 +194,7 @@ class ImageEnvelope {
} }
String encode() => 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). /// Direct request to fetch image fragments (control plane).
@@ -178,7 +207,8 @@ class ImageFetchRequest {
static const String prefix = 'IR1:'; static const String prefix = 'IR1:';
final String sessionId; final String sessionId;
final String want; // always 'all' final String want; // 'all' or 'missing'
final List<int> missingIndices;
final String requesterKey6; // 12 hex chars final String requesterKey6; // 12 hex chars
final int timestampSec; final int timestampSec;
final int version; final int version;
@@ -186,6 +216,7 @@ class ImageFetchRequest {
const ImageFetchRequest({ const ImageFetchRequest({
required this.sessionId, required this.sessionId,
this.want = 'all', this.want = 'all',
this.missingIndices = const [],
required this.requesterKey6, required this.requesterKey6,
required this.timestampSec, required this.timestampSec,
this.version = 1, this.version = 1,
@@ -204,10 +235,24 @@ class ImageFetchRequest {
final requesterKey6 = parts[2]; final requesterKey6 = parts[2];
final ts = int.tryParse(parts[3]); final ts = int.tryParse(parts[3]);
final ver = int.tryParse(parts[4]); 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 (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) return null;
if (normalizedWant != 'all') return null; final missingIndices = <int>[];
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 (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null;
if (ts == null || ts <= 0) return null; if (ts == null || ts <= 0) return null;
if (ver == null || ver != 1) return null; if (ver == null || ver != 1) return null;
@@ -215,6 +260,7 @@ class ImageFetchRequest {
return ImageFetchRequest( return ImageFetchRequest(
sessionId: sid.toLowerCase(), sessionId: sid.toLowerCase(),
want: normalizedWant, want: normalizedWant,
missingIndices: missingIndices,
requesterKey6: requesterKey6.toLowerCase(), requesterKey6: requesterKey6.toLowerCase(),
timestampSec: ts, timestampSec: ts,
version: ver, version: ver,
@@ -225,8 +271,10 @@ class ImageFetchRequest {
} }
String encode() { String encode() {
final wantToken = want == 'all' ? 'a' : want; final wantToken = want == 'missing' && missingIndices.isNotEmpty
return '${prefix}${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version'; ? 'm-${missingIndices.join(',')}'
: (want == 'all' ? 'a' : want);
return '$prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version';
} }
} }
@@ -239,8 +287,9 @@ List<ImagePacket> fragmentImage({
required String sessionId, required String sessionId,
required ImageFormat format, required ImageFormat format,
required Uint8List bytes, required Uint8List bytes,
int maxDataBytes = ImagePacket.maxDataBytes,
}) { }) {
const chunkSize = ImagePacket.maxDataBytes; final chunkSize = maxDataBytes.clamp(1, 255).toInt();
final chunks = <Uint8List>[]; final chunks = <Uint8List>[];
for (var offset = 0; offset < bytes.length; offset += chunkSize) { for (var offset = 0; offset < bytes.length; offset += chunkSize) {
final end = (offset + chunkSize).clamp(0, bytes.length); final end = (offset + chunkSize).clamp(0, bytes.length);

View File

@@ -259,6 +259,7 @@ class VoiceFetchRequest {
final String sessionId; final String sessionId;
final String want; final String want;
final List<int> missingIndices;
final String requesterKey6; final String requesterKey6;
final int timestampSec; final int timestampSec;
final int version; final int version;
@@ -266,6 +267,7 @@ class VoiceFetchRequest {
const VoiceFetchRequest({ const VoiceFetchRequest({
required this.sessionId, required this.sessionId,
this.want = 'all', this.want = 'all',
this.missingIndices = const [],
required this.requesterKey6, required this.requesterKey6,
required this.timestampSec, required this.timestampSec,
this.version = 1, this.version = 1,
@@ -288,12 +290,26 @@ class VoiceFetchRequest {
final requesterKey6 = parts[2]; final requesterKey6 = parts[2];
final ts = int.tryParse(parts[3]); final ts = int.tryParse(parts[3]);
final ver = int.tryParse(parts[4]); 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)) { if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) {
return null; return null;
} }
if (normalizedWant != 'all') return null; final missingIndices = <int>[];
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)) { if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
return null; return null;
} }
@@ -303,6 +319,7 @@ class VoiceFetchRequest {
return VoiceFetchRequest( return VoiceFetchRequest(
sessionId: sid.toLowerCase(), sessionId: sid.toLowerCase(),
want: normalizedWant, want: normalizedWant,
missingIndices: missingIndices,
requesterKey6: requesterKey6.toLowerCase(), requesterKey6: requesterKey6.toLowerCase(),
timestampSec: ts, timestampSec: ts,
version: ver, version: ver,
@@ -313,7 +330,9 @@ class VoiceFetchRequest {
} }
String encodeText() { 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'; return '$_prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version';
} }
} }

View File

@@ -59,6 +59,22 @@ void main() {
isNull, 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', () { group('VoicePacket backward compatibility', () {