Add message clear option

This commit is contained in:
Janez T
2026-03-05 11:04:41 +01:00
parent 2d9cb0ddb9
commit 234edf5bb0
14 changed files with 1075 additions and 319 deletions

View File

@@ -181,11 +181,11 @@ class VoicePacket {
/// Lightweight public/direct message envelope advertising voice availability.
///
/// Text format:
/// VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver}
/// VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts}
/// Example:
/// VE1:00112233:1:4:3200:aabbccddeeff:1234567890:1
/// VE2:00112233:1:4:4:aabbccddeeff:kf12oi
class VoiceEnvelope {
static const String _prefix = 'VE1:';
static const String _prefix = 'VE2:';
final String sessionId;
final VoicePacketMode mode;
@@ -202,7 +202,7 @@ class VoiceEnvelope {
required this.durationMs,
required this.senderKey6,
required this.timestampSec,
this.version = 1,
this.version = 2,
});
static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix);
@@ -210,43 +210,41 @@ class VoiceEnvelope {
static VoiceEnvelope? tryParseText(String text) {
if (!isVoiceEnvelopeText(text)) return null;
final body = text.substring(_prefix.length);
return _tryParseCompact(body);
return _tryParse(body);
}
static VoiceEnvelope? _tryParseCompact(String body) {
static VoiceEnvelope? _tryParse(String body) {
final parts = body.split(':');
if (parts.length != 7) return null;
if (parts.length != 6) return null;
try {
final sid = parts[0];
final mode = int.tryParse(parts[1]);
final total = int.tryParse(parts[2]);
final durMs = int.tryParse(parts[3]);
final sid = _decodeSessionId(parts[0]);
final mode = _parseInt(parts[1], base36: true);
final total = _parseInt(parts[2], base36: true);
final durS = _parseInt(parts[3], base36: true);
final senderKey6 = parts[4];
final ts = int.tryParse(parts[5]);
final ver = int.tryParse(parts[6]);
final ts = _parseInt(parts[5], base36: true);
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) {
if (sid == null) {
return null;
}
if (mode == null || mode < 0 || mode >= VoicePacketMode.values.length) {
return null;
}
if (total == null || total < 1 || total > 255) return null;
if (durMs == null || durMs < 0 || durMs > 10 * 60 * 1000) return null;
if (durS == null || durS < 0 || durS > 10 * 60) return null;
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) {
return null;
}
if (ts == null || ts <= 0) return null;
if (ver == null || ver != 1) return null;
return VoiceEnvelope(
sessionId: sid.toLowerCase(),
sessionId: sid,
mode: VoicePacketMode.fromId(mode),
total: total,
durationMs: durMs,
durationMs: durS * 1000,
senderKey6: senderKey6.toLowerCase(),
timestampSec: ts,
version: ver,
version: 2,
);
} catch (_) {
return null;
@@ -254,7 +252,8 @@ class VoiceEnvelope {
}
String encodeText() {
return '$_prefix${sessionId.toLowerCase()}:${mode.id}:$total:$durationMs:${senderKey6.toLowerCase()}:$timestampSec:$version';
final durationSec = (durationMs / 1000).ceil().clamp(0, 10 * 60);
return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}:${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
}
}
@@ -415,11 +414,12 @@ int _resolveBandwidthHz(int? rawBw) {
/// Direct control-plane request to fetch voice packets for a session.
///
/// Text format:
/// VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
/// VR2:{sid}:{want}:{requesterKey6}:{ts}
/// Example:
/// VR1:00112233:a:aabbccddeeff:1234567890:1
/// VR2:00112233:a:aabbccddeeff:kf12oi
class VoiceFetchRequest {
static const String _prefix = 'VR1:';
static const String _prefix = 'VR2:';
static const int _binaryMagic = 0x72; // 'r'
final String sessionId;
final String want;
@@ -434,42 +434,82 @@ class VoiceFetchRequest {
this.missingIndices = const [],
required this.requesterKey6,
required this.timestampSec,
this.version = 1,
this.version = 2,
});
static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix);
static bool isVoiceFetchRequestText(String text) =>
text.startsWith(_prefix);
static bool isVoiceFetchRequestBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic;
static VoiceFetchRequest? tryParseText(String text) {
if (!isVoiceFetchRequestText(text)) return null;
final body = text.substring(_prefix.length);
return _tryParseCompact(body);
return _tryParse(body);
}
static VoiceFetchRequest? _tryParseCompact(String body) {
final parts = body.split(':');
if (parts.length != 5) return null;
static VoiceFetchRequest? tryParseBinary(Uint8List payload) {
if (!isVoiceFetchRequestBinary(payload)) return null;
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count
try {
final sid = parts[0];
final sidBytes = payload.sublist(1, 5);
final sid = sidBytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
final flags = payload[5];
final requesterKey6 = payload
.sublist(6, 12)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
final ts =
(payload[12] << 24) |
(payload[13] << 16) |
(payload[14] << 8) |
payload[15];
final missingCount = payload[16];
if (payload.length != 17 + missingCount) return null;
final wantMissing = (flags & 0x01) == 0x01;
final missing = <int>[];
for (var i = 0; i < missingCount; i++) {
missing.add(payload[17 + i]);
}
return VoiceFetchRequest(
sessionId: sid,
want: wantMissing ? 'missing' : 'all',
missingIndices: missing,
requesterKey6: requesterKey6,
timestampSec: ts,
version: 2,
);
} catch (_) {
return null;
}
}
static VoiceFetchRequest? _tryParse(String body) {
final parts = body.split(':');
if (parts.length != 4) return null;
try {
final sid = _decodeSessionId(parts[0]);
final wantToken = parts[1];
final requesterKey6 = parts[2];
final ts = int.tryParse(parts[3]);
final ver = int.tryParse(parts[4]);
final ts = _parseInt(parts[3], base36: true);
final normalizedWant = wantToken == 'a'
? 'all'
: (wantToken.startsWith('m-') ? 'missing' : wantToken);
: ((wantToken.startsWith('m'))
? 'missing'
: wantToken);
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) {
if (sid == null) {
return null;
}
final missingIndices = <int>[];
if (normalizedWant == 'missing') {
final encoded = wantToken.substring(2);
final encoded = wantToken.substring(1);
if (encoded.isEmpty) return null;
for (final raw in encoded.split(',')) {
final idx = int.tryParse(raw);
if (idx == null || idx < 0 || idx > 254) return null;
missingIndices.add(idx);
}
missingIndices.addAll(_decodeMissingIndicesCompact(encoded));
if (missingIndices.isEmpty) return null;
} else if (normalizedWant != 'all') {
return null;
@@ -478,15 +518,14 @@ class VoiceFetchRequest {
return null;
}
if (ts == null || ts <= 0) return null;
if (ver == null || ver != 1) return null;
return VoiceFetchRequest(
sessionId: sid.toLowerCase(),
sessionId: sid,
want: normalizedWant,
missingIndices: missingIndices,
requesterKey6: requesterKey6.toLowerCase(),
timestampSec: ts,
version: ver,
version: 2,
);
} catch (_) {
return null;
@@ -495,10 +534,168 @@ class VoiceFetchRequest {
String encodeText() {
final wantToken = want == 'missing' && missingIndices.isNotEmpty
? 'm-${missingIndices.join(',')}'
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
: (want == 'all' ? 'a' : want);
return '$_prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version';
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}';
}
Uint8List encodeBinary() {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
}
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
throw ArgumentError.value(
requesterKey6,
'requesterKey6',
'Expected 12 hex chars',
);
}
final useMissing = want == 'missing' && missingIndices.isNotEmpty;
final missing = useMissing
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
: <int>[];
final out = Uint8List(17 + missing.length);
out[0] = _binaryMagic;
for (var i = 0; i < 4; i++) {
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
}
out[5] = useMissing ? 0x01 : 0x00;
for (var i = 0; i < 6; i++) {
out[6 + i] = int.parse(
requesterKey6.substring(i * 2, i * 2 + 2),
radix: 16,
);
}
out[12] = (timestampSec >> 24) & 0xFF;
out[13] = (timestampSec >> 16) & 0xFF;
out[14] = (timestampSec >> 8) & 0xFF;
out[15] = timestampSec & 0xFF;
out[16] = missing.length;
for (var i = 0; i < missing.length; i++) {
out[17 + i] = missing[i];
}
return out;
}
}
/// Per-fragment ACK for raw voice payload packets.
///
/// Binary format:
/// [0x76 'v'][sessionId:4B][index:1B]
class VoiceFragmentAck {
static const int _binaryMagic = 0x76; // 'v'
final String sessionId; // 8 hex chars
final int index; // 0..254
const VoiceFragmentAck({required this.sessionId, required this.index});
static bool isVoiceFragmentAckBinary(Uint8List payload) =>
payload.length == 6 && payload[0] == _binaryMagic;
static VoiceFragmentAck? tryParseBinary(Uint8List payload) {
if (!isVoiceFragmentAckBinary(payload)) return null;
try {
final sid = payload
.sublist(1, 5)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
final idx = payload[5];
return VoiceFragmentAck(sessionId: sid, index: idx);
} catch (_) {
return null;
}
}
Uint8List encodeBinary() {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
}
if (index < 0 || index > 254) {
throw ArgumentError.value(index, 'index', 'Expected 0..254');
}
final out = Uint8List(6);
out[0] = _binaryMagic;
for (var i = 0; i < 4; i++) {
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
}
out[5] = index;
return out;
}
}
int? _parseInt(String token, {required bool base36}) =>
int.tryParse(token, radix: base36 ? 36 : 10);
String _toBase36(int value) => value.toRadixString(36);
String _encodeSessionId(String sessionIdHex) {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars');
}
final value = int.parse(sessionIdHex, radix: 16);
return value.toRadixString(36);
}
String? _decodeSessionId(String token) {
if (!RegExp(r'^[0-9a-z]{1,7}$').hasMatch(token)) return null;
final value = int.tryParse(token, radix: 36);
if (value == null || value < 0 || value > 0xFFFFFFFF) return null;
return value.toRadixString(16).padLeft(8, '0');
}
String _encodeMissingIndicesCompact(List<int> indices) {
final sorted = indices
.where((v) => v >= 0 && v <= 254)
.toSet()
.toList()
..sort();
if (sorted.isEmpty) return '';
final chunks = <String>[];
var start = sorted.first;
var prev = sorted.first;
for (var i = 1; i < sorted.length; i++) {
final curr = sorted[i];
if (curr == prev + 1) {
prev = curr;
continue;
}
chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
);
start = curr;
prev = curr;
}
chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
);
return chunks.join('.');
}
List<int> _decodeMissingIndicesCompact(String encoded) {
final out = <int>[];
for (final token in encoded.split('.')) {
if (token.isEmpty) continue;
if (!token.contains('-')) {
final value = int.tryParse(token, radix: 36);
if (value == null || value < 0 || value > 254) return const [];
out.add(value);
continue;
}
final parts = token.split('-');
if (parts.length != 2) return const [];
final start = int.tryParse(parts[0], radix: 36);
final end = int.tryParse(parts[1], radix: 36);
if (start == null || end == null || start < 0 || end > 254 || start > end) {
return const [];
}
for (var i = start; i <= end; i++) {
out.add(i);
}
}
return out;
}
/// Builds a compact visual waveform from real voice packet bytes.