mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Add voice image packet retry
This commit is contained in:
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user