Files
buzz/mobile/lib/shared/huddle/huddle_wire.dart
klopez4212 4baccd5394 Downgrade mobile Huddles to audio protocol v2 (#6558)
## Summary

- downgrade Mobile Huddle authentication and native media configuration
from protocol v3 to the currently deployed relay's v2 contract
- restore the released one-byte relay peer prefix while retaining later
reconnect, roster, and playout-reset reliability fixes
- update Android, iOS, protocol documentation, and focused tests
together

Protocol v2 does not carry v3's occupancy epoch on audio frames, so it
cannot fence the narrow delayed-packet/peer-index-reuse race. This is an
intentional compatibility tradeoff until the relay v3 rollout is ready.

### Related issue

None found.

### Testing

- `just mobile-check`
- `just mobile-test` — 1,661 tests passed
- Android debug build installed and launched on Pixel 10 as
`xyz.block.buzz.mobile.sprout_mobile_profile_settings`; foreground
process verified
- signed iOS Release build installed and launched on iPhone as
`com.buzz.buzzMobile`; running process verified

A live two-device Huddle audio call remains a manual verification step.

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-22 18:37:08 +01:00

194 lines
6.1 KiB
Dart

import 'package:flutter/foundation.dart';
/// Fixed audio framing contract shared with Buzz Desktop and `buzz-relay`.
///
/// Huddle audio rooms pin the first participant's protocol version. This
/// compatibility build requests the released v2 contract so it can connect to
/// relays that have not yet rolled out v3.
abstract final class HuddleWireV2 {
static const protocolVersion = 2;
static const sampleRateHz = 48000;
static const channels = 1;
static const frameSamples = 960;
static const frameDuration = Duration(milliseconds: 20);
static const headerLength = 8;
static const relayPeerPrefixLength = 1;
static const dtxFlag = 0x01;
static const maxBinaryFrameLength = 4096;
/// Encodes a client-to-relay frame: `header | opus payload`.
static Uint8List encodeClientFrame(
HuddleAudioHeader header,
Uint8List opusPayload,
) {
if (opusPayload.isEmpty) {
throw const HuddleWireException('Opus payload must not be empty.');
}
final length = headerLength + opusPayload.length;
if (length > maxBinaryFrameLength) {
throw HuddleWireException(
'Huddle audio frame is $length bytes; maximum is '
'$maxBinaryFrameLength.',
);
}
return Uint8List.fromList([...header.encode(), ...opusPayload]);
}
/// Decodes a relay-to-client frame:
/// `peer index | header | opus payload`.
static HuddleRemoteAudioFrame decodeRelayFrame(Uint8List bytes) {
final minimumLength = relayPeerPrefixLength + headerLength + 1;
if (bytes.length < minimumLength) {
throw HuddleWireException(
'Relay audio frame is ${bytes.length} bytes; expected at least '
'$minimumLength.',
);
}
if (bytes.length > maxBinaryFrameLength + relayPeerPrefixLength) {
throw HuddleWireException(
'Relay audio frame is ${bytes.length} bytes; maximum is '
'${maxBinaryFrameLength + relayPeerPrefixLength}.',
);
}
return HuddleRemoteAudioFrame(
peerIndex: bytes[0],
// Protocol v2 has no occupancy epoch on media frames. Keep the internal
// field at its legacy value so the rest of the playout model remains
// unchanged while routing by the authoritative peer index.
epoch: 0,
header: HuddleAudioHeader.decode(bytes, offset: relayPeerPrefixLength),
opusPayload: Uint8List.fromList(
bytes.sublist(relayPeerPrefixLength + headerLength),
),
);
}
}
/// The fixed eight-byte v2 header, encoded in network byte order.
///
/// Layout:
/// - bytes 0-1: wrapping sequence number (`u16`)
/// - bytes 2-5: sender media timestamp at 48 kHz (`u32`)
/// - byte 6: sender-authored audio level in dBov (`i8`, `-127...0`)
/// - byte 7: flags (`bit 0 = Opus DTX`; unknown bits are ignored)
@immutable
final class HuddleAudioHeader {
final int sequence;
final int timestamp48k;
final int levelDbov;
final int flags;
const HuddleAudioHeader({
required this.sequence,
required this.timestamp48k,
required this.levelDbov,
required this.flags,
}) : assert(sequence >= 0 && sequence <= 0xffff),
assert(timestamp48k >= 0 && timestamp48k <= 0xffffffff),
assert(levelDbov >= -127 && levelDbov <= 0),
assert(flags >= 0 && flags <= 0xff);
/// Builds a header while enforcing the wire ranges in release builds too.
factory HuddleAudioHeader.checked({
required int sequence,
required int timestamp48k,
required int levelDbov,
required int flags,
}) {
_validateValues(sequence, timestamp48k, levelDbov, flags);
return HuddleAudioHeader(
sequence: sequence,
timestamp48k: timestamp48k,
levelDbov: levelDbov,
flags: flags,
);
}
bool get isDtx => flags & HuddleWireV2.dtxFlag != 0;
Uint8List encode() {
_validate();
final bytes = Uint8List(HuddleWireV2.headerLength);
final view = ByteData.sublistView(bytes);
view.setUint16(0, sequence, Endian.big);
view.setUint32(2, timestamp48k, Endian.big);
view.setInt8(6, levelDbov);
view.setUint8(7, flags);
return bytes;
}
static HuddleAudioHeader decode(Uint8List bytes, {int offset = 0}) {
if (offset < 0 || bytes.length - offset < HuddleWireV2.headerLength) {
throw const HuddleWireException(
'Huddle audio header requires eight bytes.',
);
}
final view = ByteData.sublistView(
bytes,
offset,
offset + HuddleWireV2.headerLength,
);
final rawLevel = view.getInt8(6);
// Match the desktop contract: malformed VU telemetry becomes the silent
// floor but never causes an otherwise valid Opus packet to be dropped.
final level = rawLevel >= -127 && rawLevel <= 0 ? rawLevel : -127;
return HuddleAudioHeader(
sequence: view.getUint16(0, Endian.big),
timestamp48k: view.getUint32(2, Endian.big),
levelDbov: level,
flags: view.getUint8(7),
);
}
void _validate() => _validateValues(sequence, timestamp48k, levelDbov, flags);
static void _validateValues(
int sequence,
int timestamp48k,
int levelDbov,
int flags,
) {
if (sequence < 0 || sequence > 0xffff) {
throw RangeError.range(sequence, 0, 0xffff, 'sequence');
}
if (timestamp48k < 0 || timestamp48k > 0xffffffff) {
throw RangeError.range(timestamp48k, 0, 0xffffffff, 'timestamp48k');
}
if (levelDbov < -127 || levelDbov > 0) {
throw RangeError.range(levelDbov, -127, 0, 'levelDbov');
}
if (flags < 0 || flags > 0xff) {
throw RangeError.range(flags, 0, 0xff, 'flags');
}
}
}
@immutable
final class HuddleRemoteAudioFrame {
final int peerIndex;
/// Always zero for protocol v2, whose relay prefix contains only
/// `peerIndex`. Retained as an internal compatibility field.
final int epoch;
final HuddleAudioHeader header;
final Uint8List opusPayload;
const HuddleRemoteAudioFrame({
required this.peerIndex,
required this.epoch,
required this.header,
required this.opusPayload,
});
}
final class HuddleWireException implements Exception {
final String message;
const HuddleWireException(this.message);
@override
String toString() => 'HuddleWireException: $message';
}