Add swarm mode transport doc

This commit is contained in:
Janez T
2026-03-07 14:13:54 +01:00
parent 4e76898c8d
commit 0e4f727e26
43 changed files with 3831 additions and 1078 deletions

View File

@@ -4,7 +4,7 @@ const int _maxCompanionFrameBytes = 172; // MeshCore MAX_FRAME_SIZE
const int _cmdSendRawDataOverheadBytes = 2; // cmd + pathLen
const int _maxMeshPacketPayloadBytes = 184; // MeshCore MAX_PACKET_PAYLOAD
const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload
const int _imagePacketHeaderBytes = 8; // image packet binary header in payload
const int _imagePacketHeaderBytes = 6; // image packet binary header in payload
const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10)
const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5)
const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz)
@@ -31,7 +31,7 @@ enum ImageFormat {
/// A single binary fragment of a compressed image.
///
/// Binary format (direct contacts, via pushRawData / cmdSendRawData):
/// [0x49 'I'][sessionId:4B][fmt:1B][idx:1B][total:1B][imageData...]
/// [0x49 'I'][sessionId:4B][idx:1B][imageData...]
///
/// Legacy default is 152 data bytes per fragment.
class ImagePacket {
@@ -50,7 +50,7 @@ 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 _headerLen = 6; // magic(1)+session(4)+idx(1)
static const int maxDataBytes =
152; // Conservative default for compatibility.
@@ -65,16 +65,14 @@ class ImagePacket {
.sublist(1, 5)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final fmtId = payload[5];
final index = payload[6];
final total = payload[7];
if (total < 1) return null;
final index = payload[5];
final data = payload.sublist(_headerLen);
return ImagePacket(
sessionId: sessionId,
format: ImageFormat.fromId(fmtId),
format: ImageFormat.avif,
index: index,
total: total,
data: payload.sublist(_headerLen),
total: 0,
data: data,
);
} catch (_) {
return null;
@@ -92,16 +90,16 @@ class ImagePacket {
final out = Uint8List(_headerLen + data.length);
out[0] = _magic;
out.setRange(1, 5, sessionBytes);
out[5] = format.id;
out[6] = index;
out[7] = total;
out[5] = index;
out.setRange(_headerLen, out.length, data);
return out;
}
@override
String toString() =>
'ImagePacket($sessionId ${format.label} [$index/${total - 1}] ${data.length}B)';
String toString() {
final suffix = total > 0 ? ' ${format.label} [$index/${total - 1}]' : ' [$index]';
return 'ImagePacket($sessionId$suffix ${data.length}B)';
}
}
/// Compute the maximum safe image data bytes for a direct route path.
@@ -246,11 +244,11 @@ int _resolveBandwidthHz(int? rawBw) {
/// Envelope announcing image availability (control plane).
///
/// Text format:
/// IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}
/// IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes}
/// Example:
/// IE2:deadbeef:0:7:3k:3k:t6:aabbccddeeff:s44we8
/// IE4:deadbeef:0:7:3k:3k:t6
class ImageEnvelope {
static const String _prefix = 'IE2:';
static const String _prefixV4 = 'IE4:';
final String sessionId; // 8 hex chars
final ImageFormat format;
@@ -258,8 +256,6 @@ class ImageEnvelope {
final int width;
final int height;
final int sizeBytes; // total compressed image size
final String senderKey6; // 12 hex chars (6 bytes)
final int timestampSec;
final int version;
const ImageEnvelope({
@@ -269,18 +265,16 @@ class ImageEnvelope {
required this.width,
required this.height,
required this.sizeBytes,
required this.senderKey6,
required this.timestampSec,
this.version = 2,
this.version = 4,
});
static bool isEnvelope(String text) => text.startsWith(_prefix);
static bool isEnvelope(String text) => text.startsWith(_prefixV4);
static ImageEnvelope? tryParse(String text) {
if (!isEnvelope(text)) return null;
final body = text.substring(_prefix.length);
final body = text.substring(_prefixV4.length);
final parts = body.split(':');
if (parts.length != 8) return null;
if (parts.length != 6) return null;
try {
final sid = _decodeSessionId(parts[0]);
final fmtId = _parseInt(parts[1], base36: true);
@@ -288,16 +282,12 @@ class ImageEnvelope {
final w = _parseInt(parts[3], base36: true);
final h = _parseInt(parts[4], base36: true);
final bytes = _parseInt(parts[5], base36: true);
final senderKey6 = parts[6];
final ts = _parseInt(parts[7], base36: true);
if (sid == null) return null;
if (fmtId == null) return null;
if (total == null || total < 1 || total > 255) return null;
if (w == null || h == null || w < 1 || h < 1) return null;
if (bytes == null || bytes < 1) return null;
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) return null;
if (ts == null || ts <= 0) return null;
return ImageEnvelope(
sessionId: sid,
@@ -306,9 +296,7 @@ class ImageEnvelope {
width: w,
height: h,
sizeBytes: bytes,
senderKey6: senderKey6.toLowerCase(),
timestampSec: ts,
version: 2,
version: 4,
);
} catch (_) {
return null;
@@ -316,27 +304,25 @@ class ImageEnvelope {
}
String encode() =>
'$_prefix${_encodeSessionId(sessionId)}:'
'$_prefixV4${_encodeSessionId(sessionId)}:'
'${_toBase36(format.id)}:${_toBase36(total)}:${_toBase36(width)}:'
'${_toBase36(height)}:${_toBase36(sizeBytes)}:'
'${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
'${_toBase36(height)}:${_toBase36(sizeBytes)}';
}
/// Direct request to fetch image fragments (control plane).
///
/// Text format:
/// IR2:{sid}:{want}:{requesterKey6}:{ts}
/// IR4:{sid}:{want}:{requesterKey6}
/// Example:
/// IR2:deadbeef:a:aabbccddeeff:s44wea
/// IR4:deadbeef:a:aabbccddeeff
class ImageFetchRequest {
static const String _prefix = 'IR2:';
static const String _prefixV4 = 'IR4:';
static const int _binaryMagic = 0x69; // 'i'
final String sessionId;
final String want; // 'all' or 'missing'
final List<int> missingIndices;
final String requesterKey6; // 12 hex chars
final int timestampSec;
final int version;
const ImageFetchRequest({
@@ -344,24 +330,22 @@ class ImageFetchRequest {
this.want = 'all',
this.missingIndices = const [],
required this.requesterKey6,
required this.timestampSec,
this.version = 2,
this.version = 4,
});
static bool isRequest(String text) => text.startsWith(_prefix);
static bool isRequest(String text) => text.startsWith(_prefixV4);
static bool isRequestBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic;
static ImageFetchRequest? tryParse(String text) {
if (!isRequest(text)) return null;
final body = text.substring(_prefix.length);
final body = text.substring(_prefixV4.length);
final parts = body.split(':');
if (parts.length != 4) return null;
if (parts.length != 3) return null;
try {
final sid = _decodeSessionId(parts[0]);
final wantToken = parts[1];
final requesterKey6 = parts[2];
final ts = _parseInt(parts[3], base36: true);
final normalizedWant = wantToken == 'a'
? 'all'
: ((wantToken.startsWith('m')) ? 'missing' : wantToken);
@@ -377,15 +361,13 @@ class ImageFetchRequest {
return null;
}
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null;
if (ts == null || ts <= 0) return null;
return ImageFetchRequest(
sessionId: sid,
want: normalizedWant,
missingIndices: missingIndices,
requesterKey6: requesterKey6.toLowerCase(),
timestampSec: ts,
version: 2,
version: 4,
);
} catch (_) {
return null;
@@ -394,7 +376,7 @@ class ImageFetchRequest {
static ImageFetchRequest? tryParseBinary(Uint8List payload) {
if (!isRequestBinary(payload)) return null;
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count
if (payload.length < 13) return null; // magic+sid+flags+key6+count
try {
final sid = payload
.sublist(1, 5)
@@ -407,25 +389,19 @@ class ImageFetchRequest {
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
final ts =
(payload[12] << 24) |
(payload[13] << 16) |
(payload[14] << 8) |
payload[15];
final missingCount = payload[16];
if (payload.length != 17 + missingCount) return null;
final missingCount = payload[12];
if (payload.length != 13 + missingCount) return null;
final wantMissing = (flags & 0x01) == 0x01;
final missing = <int>[];
for (var i = 0; i < missingCount; i++) {
missing.add(payload[17 + i]);
missing.add(payload[13 + i]);
}
return ImageFetchRequest(
sessionId: sid,
want: wantMissing ? 'missing' : 'all',
missingIndices: missing,
requesterKey6: requesterKey6,
timestampSec: ts,
version: 2,
version: 4,
);
} catch (_) {
return null;
@@ -436,7 +412,7 @@ class ImageFetchRequest {
final wantToken = want == 'missing' && missingIndices.isNotEmpty
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
: (want == 'all' ? 'a' : want);
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}';
return '$_prefixV4${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}';
}
Uint8List encodeBinary() {
@@ -455,7 +431,7 @@ class ImageFetchRequest {
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
: <int>[];
final out = Uint8List(17 + missing.length);
final out = Uint8List(13 + missing.length);
out[0] = _binaryMagic;
for (var i = 0; i < 4; i++) {
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
@@ -467,13 +443,9 @@ class ImageFetchRequest {
radix: 16,
);
}
out[12] = (timestampSec >> 24) & 0xFF;
out[13] = (timestampSec >> 16) & 0xFF;
out[14] = (timestampSec >> 8) & 0xFF;
out[15] = timestampSec & 0xFF;
out[16] = missing.length;
out[12] = missing.length;
for (var i = 0; i < missing.length; i++) {
out[17 + i] = missing[i];
out[13 + i] = missing[i];
}
return out;
}

View File

@@ -0,0 +1,168 @@
import 'dart:typed_data';
const int _swarmMagic = 0x6d; // 'm'
const int _swarmKindRequest = 0x01;
const int _swarmKindAvailability = 0x02;
class MediaSwarmRequest {
final String mediaType;
final String sessionId;
final String requesterKey6;
final List<int> missingIndices;
const MediaSwarmRequest({
required this.mediaType,
required this.sessionId,
required this.requesterKey6,
this.missingIndices = const [],
});
bool get requestsAll => missingIndices.isEmpty;
Uint8List encodeBinary() {
final normalizedMissing = missingIndices.toSet().toList()..sort();
final out = Uint8List(14 + normalizedMissing.length);
out[0] = _swarmMagic;
out[1] = _swarmKindRequest;
out[2] = _encodeMediaType(mediaType);
_writeSessionId(out, 3, sessionId);
_writeKey6(out, 7, requesterKey6);
out[13] = normalizedMissing.length;
for (var i = 0; i < normalizedMissing.length; i++) {
out[14 + i] = normalizedMissing[i];
}
return out;
}
static MediaSwarmRequest? tryParseBinary(Uint8List payload) {
if (payload.length < 14 ||
payload[0] != _swarmMagic ||
payload[1] != _swarmKindRequest) {
return null;
}
final mediaType = _decodeMediaType(payload[2]);
if (mediaType == null) return null;
final missingCount = payload[13];
if (payload.length != 14 + missingCount) {
return null;
}
return MediaSwarmRequest(
mediaType: mediaType,
sessionId: _readSessionId(payload, 3),
requesterKey6: _readKey6(payload, 7),
missingIndices: payload.sublist(14),
);
}
}
class MediaSwarmAvailability {
final String mediaType;
final String sessionId;
final String requesterKey6;
final String responderKey6;
final List<int> availableIndices;
const MediaSwarmAvailability({
required this.mediaType,
required this.sessionId,
required this.requesterKey6,
required this.responderKey6,
required this.availableIndices,
});
bool get servesAll => availableIndices.isEmpty;
Uint8List encodeBinary() {
final normalizedAvailable = availableIndices.toSet().toList()..sort();
final out = Uint8List(20 + normalizedAvailable.length);
out[0] = _swarmMagic;
out[1] = _swarmKindAvailability;
out[2] = _encodeMediaType(mediaType);
_writeSessionId(out, 3, sessionId);
_writeKey6(out, 7, requesterKey6);
_writeKey6(out, 13, responderKey6);
out[19] = normalizedAvailable.length;
for (var i = 0; i < normalizedAvailable.length; i++) {
out[20 + i] = normalizedAvailable[i];
}
return out;
}
static MediaSwarmAvailability? tryParseBinary(Uint8List payload) {
if (payload.length < 20 ||
payload[0] != _swarmMagic ||
payload[1] != _swarmKindAvailability) {
return null;
}
final mediaType = _decodeMediaType(payload[2]);
if (mediaType == null) return null;
final availableCount = payload[19];
if (payload.length != 20 + availableCount) {
return null;
}
return MediaSwarmAvailability(
mediaType: mediaType,
sessionId: _readSessionId(payload, 3),
requesterKey6: _readKey6(payload, 7),
responderKey6: _readKey6(payload, 13),
availableIndices: payload.sublist(20),
);
}
}
int _encodeMediaType(String mediaType) {
return switch (mediaType) {
'voice' => 0x01,
'image' => 0x02,
_ => throw ArgumentError.value(mediaType, 'mediaType'),
};
}
String? _decodeMediaType(int raw) {
return switch (raw) {
0x01 => 'voice',
0x02 => 'image',
_ => null,
};
}
void _writeSessionId(Uint8List out, int offset, String sessionId) {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
}
for (var i = 0; i < 4; i++) {
out[offset + i] = int.parse(
sessionId.substring(i * 2, i * 2 + 2),
radix: 16,
);
}
}
String _readSessionId(Uint8List payload, int offset) {
return payload
.sublist(offset, offset + 4)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
}
void _writeKey6(Uint8List out, int offset, String key6) {
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(key6)) {
throw ArgumentError.value(key6, 'key6', 'Expected 12 hex chars');
}
for (var i = 0; i < 6; i++) {
out[offset + i] = int.parse(key6.substring(i * 2, i * 2 + 2), radix: 16);
}
}
String _readKey6(Uint8List payload, int offset) {
return payload
.sublist(offset, offset + 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
}

View File

@@ -3,7 +3,12 @@ import 'dart:typed_data';
import '../models/contact.dart';
import '../providers/contacts_provider.dart';
enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar, unreachable }
enum TransmissionTargetFailure {
unknownContact,
unknownRoute,
tooFar,
unreachable,
}
class TransmissionTargetResolution {
final Contact? target;
@@ -16,7 +21,7 @@ class TransmissionTargetResolution {
required this.maxHops,
});
int get hops => target?.outPathLen ?? -1;
int get hops => target?.routeHopCount ?? -1;
bool get isValid => target != null && failure == null;
}
@@ -32,11 +37,17 @@ class TransmissionTargetResolver {
String? senderName,
}) {
if (isSentByMe) {
final recipient = _findByRecipientKey(contactsProvider, recipientPublicKey);
final recipient = _findByRecipientKey(
contactsProvider,
recipientPublicKey,
);
if (recipient != null) return recipient;
}
final byEnvelope = _findByEnvelopeKey6(contactsProvider, senderKey6FromEnvelope);
final byEnvelope = _findByEnvelopeKey6(
contactsProvider,
senderKey6FromEnvelope,
);
if (byEnvelope != null) return byEnvelope;
final byPrefix = _findByPrefix(contactsProvider, senderPublicKeyPrefix);
@@ -64,7 +75,9 @@ class TransmissionTargetResolver {
senderName: senderName,
);
if (target == null || target.outPathLen < 0 || target.outPathLen > maxFetchHops) {
if (target == null ||
!target.routeHasPath ||
target.routeHopCount > maxFetchHops) {
await refreshContacts();
target = resolveLocalTarget(
contactsProvider: contactsProvider,
@@ -83,14 +96,14 @@ class TransmissionTargetResolver {
maxHops: maxFetchHops,
);
}
if (target.outPathLen < 0) {
if (!target.routeHasPath) {
return TransmissionTargetResolution(
target: target,
failure: TransmissionTargetFailure.unknownRoute,
maxHops: maxFetchHops,
);
}
if (target.outPathLen > maxFetchHops) {
if (target.routeHopCount > maxFetchHops) {
return TransmissionTargetResolution(
target: target,
failure: TransmissionTargetFailure.tooFar,

View File

@@ -2,7 +2,7 @@ import 'dart:convert';
import 'dart:typed_data';
const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload
const int _voicePacketHeaderBytes = 8; // voice packet binary header in payload
const int _voicePacketHeaderBytes = 6; // voice packet binary header in payload
const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10)
const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5)
const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz)
@@ -38,7 +38,7 @@ enum VoicePacketMode {
/// V:{sessionId8hex}:{modeId}:{index}/{total}:{base64Codec2}
///
/// Binary format (direct contacts, received via pushRawData):
/// [0x56 'V'][sessionId:4B][modeId:1B][index:1B][total:1B][codec2Data...]
/// [0x56 'V'][sessionId:4B][index:1B][codec2Data...]
class VoicePacket {
final String sessionId; // 8 hex chars (4 bytes)
final VoicePacketMode mode;
@@ -104,8 +104,7 @@ class VoicePacket {
// ── Binary format ────────────────────────────────────────────────────────
static const int _binaryMagic = 0x56; // 'V'
static const int _binaryHeaderLen =
8; // magic(1)+session(4)+mode(1)+idx(1)+total(1)
static const int _binaryHeaderLen = 6; // magic(1)+session(4)+idx(1)
static bool isVoiceBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic;
@@ -119,16 +118,13 @@ class VoicePacket {
final sessionId = sessionBytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final modeId = payload[5];
final index = payload[6];
final total = payload[7];
if (total < 1) return null;
final index = payload[5];
final codec2Data = payload.sublist(_binaryHeaderLen);
return VoicePacket(
sessionId: sessionId,
mode: VoicePacketMode.fromId(modeId),
mode: VoicePacketMode.mode1300,
index: index,
total: total,
total: 0,
codec2Data: codec2Data,
);
} catch (_) {
@@ -148,9 +144,7 @@ class VoicePacket {
final out = Uint8List(_binaryHeaderLen + codec2Data.length);
out[0] = _binaryMagic;
out.setRange(1, 5, sessionBytes);
out[5] = mode.id;
out[6] = index;
out[7] = total;
out[5] = index;
out.setRange(_binaryHeaderLen, out.length, codec2Data);
return out;
}
@@ -174,25 +168,25 @@ class VoicePacket {
}
@override
String toString() =>
'VoicePacket($sessionId ${mode.label} [$index/${total - 1}] ${codec2Data.length}B)';
String toString() {
final suffix = total > 0 ? ' ${mode.label} [$index/${total - 1}]' : ' [$index]';
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
}
}
/// Lightweight public/direct message envelope advertising voice availability.
///
/// Text format:
/// VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts}
/// VE3:{sid}:{mode}:{total}:{durS}
/// Example:
/// VE2:00112233:1:4:4:aabbccddeeff:kf12oi
/// VE3:00112233:1:4:4
class VoiceEnvelope {
static const String _prefix = 'VE2:';
static const String _prefix = 'VE3:';
final String sessionId;
final VoicePacketMode mode;
final int total;
final int durationMs;
final String senderKey6;
final int timestampSec;
final int version;
const VoiceEnvelope({
@@ -200,9 +194,7 @@ class VoiceEnvelope {
required this.mode,
required this.total,
required this.durationMs,
required this.senderKey6,
required this.timestampSec,
this.version = 2,
this.version = 3,
});
static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix);
@@ -215,14 +207,12 @@ class VoiceEnvelope {
static VoiceEnvelope? _tryParse(String body) {
final parts = body.split(':');
if (parts.length != 6) return null;
if (parts.length != 4) return null;
try {
final sid = _decodeSessionId(parts[0]);
final mode = _parseInt(parts[1], base36: true);
final total = _parseInt(parts[2], base36: true);
final durS = _parseInt(parts[3], base36: true);
final senderKey6 = parts[4];
final ts = _parseInt(parts[5], base36: true);
if (sid == null) {
return null;
@@ -232,19 +222,13 @@ class VoiceEnvelope {
}
if (total == null || total < 1 || total > 255) return null;
if (durS == null || durS < 0 || durS > 10 * 60) return null;
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) {
return null;
}
if (ts == null || ts <= 0) return null;
return VoiceEnvelope(
sessionId: sid,
mode: VoicePacketMode.fromId(mode),
total: total,
durationMs: durS * 1000,
senderKey6: senderKey6.toLowerCase(),
timestampSec: ts,
version: 2,
version: 3,
);
} catch (_) {
return null;
@@ -253,7 +237,7 @@ class VoiceEnvelope {
String encodeText() {
final durationSec = (durationMs / 1000).ceil().clamp(0, 10 * 60);
return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}:${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}';
}
}
@@ -414,18 +398,17 @@ int _resolveBandwidthHz(int? rawBw) {
/// Direct control-plane request to fetch voice packets for a session.
///
/// Text format:
/// VR2:{sid}:{want}:{requesterKey6}:{ts}
/// VR3:{sid}:{want}:{requesterKey6}
/// Example:
/// VR2:00112233:a:aabbccddeeff:kf12oi
/// VR3:00112233:a:aabbccddeeff
class VoiceFetchRequest {
static const String _prefix = 'VR2:';
static const String _prefix = 'VR3:';
static const int _binaryMagic = 0x72; // 'r'
final String sessionId;
final String want;
final List<int> missingIndices;
final String requesterKey6;
final int timestampSec;
final int version;
const VoiceFetchRequest({
@@ -433,8 +416,7 @@ class VoiceFetchRequest {
this.want = 'all',
this.missingIndices = const [],
required this.requesterKey6,
required this.timestampSec,
this.version = 2,
this.version = 3,
});
static bool isVoiceFetchRequestText(String text) =>
@@ -450,7 +432,7 @@ class VoiceFetchRequest {
static VoiceFetchRequest? tryParseBinary(Uint8List payload) {
if (!isVoiceFetchRequestBinary(payload)) return null;
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count
if (payload.length < 13) return null; // magic+sid+flags+key6+count
try {
final sidBytes = payload.sublist(1, 5);
final sid = sidBytes
@@ -463,25 +445,19 @@ class VoiceFetchRequest {
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
final ts =
(payload[12] << 24) |
(payload[13] << 16) |
(payload[14] << 8) |
payload[15];
final missingCount = payload[16];
if (payload.length != 17 + missingCount) return null;
final missingCount = payload[12];
if (payload.length != 13 + missingCount) return null;
final wantMissing = (flags & 0x01) == 0x01;
final missing = <int>[];
for (var i = 0; i < missingCount; i++) {
missing.add(payload[17 + i]);
missing.add(payload[13 + i]);
}
return VoiceFetchRequest(
sessionId: sid,
want: wantMissing ? 'missing' : 'all',
missingIndices: missing,
requesterKey6: requesterKey6,
timestampSec: ts,
version: 2,
version: 3,
);
} catch (_) {
return null;
@@ -490,12 +466,11 @@ class VoiceFetchRequest {
static VoiceFetchRequest? _tryParse(String body) {
final parts = body.split(':');
if (parts.length != 4) return null;
if (parts.length != 3) return null;
try {
final sid = _decodeSessionId(parts[0]);
final wantToken = parts[1];
final requesterKey6 = parts[2];
final ts = _parseInt(parts[3], base36: true);
final normalizedWant = wantToken == 'a'
? 'all'
: ((wantToken.startsWith('m'))
@@ -517,15 +492,13 @@ class VoiceFetchRequest {
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
return null;
}
if (ts == null || ts <= 0) return null;
return VoiceFetchRequest(
sessionId: sid,
want: normalizedWant,
missingIndices: missingIndices,
requesterKey6: requesterKey6.toLowerCase(),
timestampSec: ts,
version: 2,
version: 3,
);
} catch (_) {
return null;
@@ -536,7 +509,7 @@ class VoiceFetchRequest {
final wantToken = want == 'missing' && missingIndices.isNotEmpty
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
: (want == 'all' ? 'a' : want);
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}';
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}';
}
Uint8List encodeBinary() {
@@ -555,7 +528,7 @@ class VoiceFetchRequest {
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
: <int>[];
final out = Uint8List(17 + missing.length);
final out = Uint8List(13 + missing.length);
out[0] = _binaryMagic;
for (var i = 0; i < 4; i++) {
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
@@ -567,13 +540,9 @@ class VoiceFetchRequest {
radix: 16,
);
}
out[12] = (timestampSec >> 24) & 0xFF;
out[13] = (timestampSec >> 16) & 0xFF;
out[14] = (timestampSec >> 8) & 0xFF;
out[15] = timestampSec & 0xFF;
out[16] = missing.length;
out[12] = missing.length;
for (var i = 0; i < missing.length; i++) {
out[17 + i] = missing[i];
out[13 + i] = missing[i];
}
return out;
}