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>
This commit is contained in:
klopez4212
2026-08-22 18:37:08 +01:00
committed by GitHub
parent 040b203f73
commit 4baccd5394
10 changed files with 73 additions and 84 deletions

View File

@@ -25,9 +25,7 @@ history reconstructs the visible card without silently reopening a microphone.
3. Sign NIP-42 kind `22242` with `relay=<base relay WebSocket URL>` and the
challenge. The `relay` tag is **not** the Huddle endpoint URL.
4. Send an object envelope containing `type=auth`, the signed event,
`parent_channel_id`, and `protocol_version=3`. Protocol v2 remains available
for released clients with its one-byte relay peer prefix; v3 adds an
occupancy epoch and cannot share a pinned room with v2 clients.
`parent_channel_id`, and `protocol_version=2`.
5. Treat the connection as usable only after a `joined` response. `error`,
unexpected close, handshake timeout, and protocol failures are explicit
states. An established session retries only its media socket with bounded
@@ -48,7 +46,7 @@ burst origin so authorship is spatially clear; minimized calls fall back to the
available Huddle surface. Reaction events never enter the ordinary channel
timeline.
## Media plane: protocol v3
## Media plane: protocol v2
Audio is 48 kHz, mono Opus in 20 ms (960-sample) frames.
@@ -61,14 +59,13 @@ Client to relay:
Relay to client:
```text
peer_index u8 | epoch u8 | 8-byte header | Opus payload
peer_index u8 | 8-byte header | Opus payload
```
The header is network byte order: sequence `u16`, 48 kHz timestamp `u32`,
level dBov `i8` in `-127...0`, and flags `u8` where bit 0 marks DTX. The
occupancy epoch advances whenever a peer index is assigned again, allowing
clients to fence delayed media from the previous occupant. Unknown flag bits
are ignored. Frames are capped at 4096 bytes before the relay peer prefix.
level dBov `i8` in `-127...0`, and flags `u8` where bit 0 marks DTX. Unknown
flag bits are ignored. Frames are capped at 4096 bytes before the relay peer
prefix.
The dBov header also drives the participant speaking treatment. Values above
the existing `-55 dBov` activity threshold are normalized for a 50 ms visual

View File

@@ -23,7 +23,7 @@ import io.flutter.plugin.common.MethodChannel
*
* Owns microphone permission, foreground communication routing, and the native
* realtime Opus engine. PCM remains native; Flutter only handles compressed
* Huddle v3 packets.
* Huddle v2 packets.
*/
internal class HuddleMediaPlugin(
private val activity: Activity,
@@ -142,14 +142,14 @@ internal class HuddleMediaPlugin(
private fun prepare(arguments: Any?, result: MethodChannel.Result) {
val values = arguments as? Map<*, *>
if (values?.get("protocolVersion") != 3 ||
if (values?.get("protocolVersion") != 2 ||
values["sampleRateHz"] != 48_000 ||
values["channels"] != 1 ||
values["frameSamples"] != 960
) {
result.error(
"invalid_configuration",
"Expected the fixed Huddle Opus v3 media configuration.",
"Expected the fixed Huddle Opus v2 media configuration.",
null,
)
return

View File

@@ -139,7 +139,7 @@ final class HuddleMediaPlugin {
private func prepare(arguments: Any?, result: @escaping FlutterResult) {
guard let values = arguments as? [String: Any],
(values["protocolVersion"] as? NSNumber)?.intValue == 3,
(values["protocolVersion"] as? NSNumber)?.intValue == 2,
(values["sampleRateHz"] as? NSNumber)?.intValue == 48_000,
(values["channels"] as? NSNumber)?.intValue == 1,
(values["frameSamples"] as? NSNumber)?.intValue == 960
@@ -147,7 +147,7 @@ final class HuddleMediaPlugin {
result(
FlutterError(
code: "invalid_configuration",
message: "Expected the fixed Huddle Opus v3 media configuration.",
message: "Expected the fixed Huddle Opus v2 media configuration.",
details: nil
)
)

View File

@@ -55,7 +55,7 @@ final class HuddleConnectionParameters {
}
/// Fixed NIP-42 + Huddle auth envelope used after the audio relay challenge.
abstract final class HuddleAuthV3 {
abstract final class HuddleAuthV2 {
static Map<String, dynamic> buildMessage({
required HuddleConnectionParameters parameters,
required String challenge,
@@ -82,7 +82,7 @@ abstract final class HuddleAuthV3 {
'type': 'auth',
'event': event.toMap(),
'parent_channel_id': parameters.parentChannelId,
'protocol_version': HuddleWireV3.protocolVersion,
'protocol_version': HuddleWireV2.protocolVersion,
};
}

View File

@@ -396,10 +396,10 @@ final class MethodChannelHuddleMedia implements HuddleMedia {
try {
final result = await _channel
.invokeMapMethod<dynamic, dynamic>('prepare', {
'protocolVersion': HuddleWireV3.protocolVersion,
'sampleRateHz': HuddleWireV3.sampleRateHz,
'channels': HuddleWireV3.channels,
'frameSamples': HuddleWireV3.frameSamples,
'protocolVersion': HuddleWireV2.protocolVersion,
'sampleRateHz': HuddleWireV2.sampleRateHz,
'channels': HuddleWireV2.channels,
'frameSamples': HuddleWireV2.frameSamples,
});
if (result?['audioSessionPrepared'] != true) {
throw const HuddleMediaError(

View File

@@ -115,7 +115,7 @@ final class HuddleTransportState {
///
/// This socket intentionally does not reuse [RelaySocket]: the main Nostr
/// connection speaks JSON arrays and ignores binary messages, while Huddle
/// audio uses JSON objects for its handshake/control plane and binary Opus v3
/// audio uses JSON objects for its handshake/control plane and binary Opus v2
/// frames for media. [connect] completes only after the relay's `joined`
/// response, making authentication and room admission observable to callers.
abstract interface class HuddleTransportClient {
@@ -295,7 +295,7 @@ final class HuddleTransport implements HuddleTransportClient {
}
try {
channel.sink.add(HuddleWireV3.encodeClientFrame(header, opusPayload));
channel.sink.add(HuddleWireV2.encodeClientFrame(header, opusPayload));
} catch (error) {
throw HuddleTransportError(
code: HuddleTransportErrorCode.sendFailed,
@@ -409,7 +409,7 @@ final class HuddleTransport implements HuddleTransportClient {
}
try {
final auth = HuddleAuthV3.buildMessage(
final auth = HuddleAuthV2.buildMessage(
parameters: parameters,
challenge: challenge,
);
@@ -806,12 +806,11 @@ final class HuddleTransport implements HuddleTransportClient {
return;
}
try {
final frame = HuddleWireV3.decodeRelayFrame(bytes);
// The authoritative control roster owns the routing table. Packets from
// absent/recycled slots are stale and must never allocate playback state.
// The epoch fences the reuse race: an in-flight frame authored by a
// departed occupant that arrives after its index is reassigned carries
// the old epoch and is dropped rather than mis-attributed.
final frame = HuddleWireV2.decodeRelayFrame(bytes);
// The authoritative control roster owns the routing table. Protocol v2
// carries only the peer index, so packets are accepted only while that
// index is currently present. V3's stricter occupancy-epoch fence is not
// available in this compatibility build.
if (!_isCurrentOccupant(frame.peerIndex, frame.epoch)) return;
if (_audioIngress.length == _audioIngressCapacity) {
_audioIngress.removeFirst();
@@ -829,14 +828,10 @@ final class HuddleTransport implements HuddleTransportClient {
}
}
/// Whether `peerIndex` is currently occupied at exactly `epoch`. A frame is
/// deliverable only when both match the authoritative roster: an absent slot
/// is stale, and a slot reused by a later occupant has advanced its epoch, so
/// a departed occupant's in-flight frame is fenced rather than mis-attributed.
bool _isCurrentOccupant(int peerIndex, int epoch) {
final occupant = _state.peers[peerIndex];
return occupant != null && occupant.epoch == epoch;
}
/// Whether `peerIndex` is currently occupied. Protocol v2 has no media epoch,
/// so the control-plane roster is the strongest routing boundary available.
bool _isCurrentOccupant(int peerIndex, int _) =>
_state.peers.containsKey(peerIndex);
void _purgeAudioIngress(Set<int> peerIndices) {
if (peerIndices.isEmpty || _audioIngress.isEmpty) return;
@@ -854,7 +849,8 @@ final class HuddleTransport implements HuddleTransportClient {
// Control and media are handled by the same socket callback, but queued
// media drains on later event-loop turns. Revalidate here so a removal
// or index replacement wins over every frame queued before that control.
// The epoch check rejects a frame whose slot was reused after it queued.
// Revalidate occupancy after the event-loop hop so a control-plane leave
// can purge queued v2 media before it reaches native playout.
if (_isCurrentOccupant(frame.peerIndex, frame.epoch)) {
_audioController.add(frame);
break;

View File

@@ -2,18 +2,17 @@ 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. New mobile
/// clients request v3 because relay-to-client frames add an occupancy epoch to
/// v2's one-byte peer prefix. They must not silently fall back: the relay
/// rejects mixed-version rooms with `upgrade_required`.
abstract final class HuddleWireV3 {
static const protocolVersion = 3;
/// 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 = 2;
static const relayPeerPrefixLength = 1;
static const dtxFlag = 0x01;
static const maxBinaryFrameLength = 4096;
@@ -37,7 +36,7 @@ abstract final class HuddleWireV3 {
}
/// Decodes a relay-to-client frame:
/// `peer index | epoch | header | opus payload`.
/// `peer index | header | opus payload`.
static HuddleRemoteAudioFrame decodeRelayFrame(Uint8List bytes) {
final minimumLength = relayPeerPrefixLength + headerLength + 1;
if (bytes.length < minimumLength) {
@@ -55,7 +54,10 @@ abstract final class HuddleWireV3 {
return HuddleRemoteAudioFrame(
peerIndex: bytes[0],
epoch: bytes[1],
// 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),
@@ -104,11 +106,11 @@ final class HuddleAudioHeader {
);
}
bool get isDtx => flags & HuddleWireV3.dtxFlag != 0;
bool get isDtx => flags & HuddleWireV2.dtxFlag != 0;
Uint8List encode() {
_validate();
final bytes = Uint8List(HuddleWireV3.headerLength);
final bytes = Uint8List(HuddleWireV2.headerLength);
final view = ByteData.sublistView(bytes);
view.setUint16(0, sequence, Endian.big);
view.setUint32(2, timestamp48k, Endian.big);
@@ -118,7 +120,7 @@ final class HuddleAudioHeader {
}
static HuddleAudioHeader decode(Uint8List bytes, {int offset = 0}) {
if (offset < 0 || bytes.length - offset < HuddleWireV3.headerLength) {
if (offset < 0 || bytes.length - offset < HuddleWireV2.headerLength) {
throw const HuddleWireException(
'Huddle audio header requires eight bytes.',
);
@@ -126,7 +128,7 @@ final class HuddleAudioHeader {
final view = ByteData.sublistView(
bytes,
offset,
offset + HuddleWireV3.headerLength,
offset + HuddleWireV2.headerLength,
);
final rawLevel = view.getInt8(6);
// Match the desktop contract: malformed VU telemetry becomes the silent
@@ -167,10 +169,8 @@ final class HuddleAudioHeader {
final class HuddleRemoteAudioFrame {
final int peerIndex;
/// Occupancy epoch of `peerIndex` this frame was authored under. Fenced by
/// the transport against the current roster occupant's epoch so a frame from
/// a departed occupant that arrives after its slot is reused is dropped
/// rather than mis-attributed to the new occupant.
/// 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;

View File

@@ -13,7 +13,7 @@ void main() {
});
test(
'prepares the fixed v3 audio session without overstating codecs',
'prepares the fixed v2 audio session without overstating codecs',
() async {
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
@@ -49,7 +49,7 @@ void main() {
expect(media.state.phase, HuddleMediaPhase.prepared);
final prepare = calls.singleWhere((call) => call.method == 'prepare');
expect(prepare.arguments, {
'protocolVersion': 3,
'protocolVersion': 2,
'sampleRateHz': 48000,
'channels': 1,
'frameSamples': 960,

View File

@@ -14,7 +14,7 @@ const _parentChannelId = '11111111-2222-4333-8444-555555555555';
const _ephemeralChannelId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
void main() {
test('authenticates with object JSON and admits a v3 room', () async {
test('authenticates with object JSON and admits a v2 room', () async {
final channel = _ControlledWebSocketChannel();
final transport = _transport(channel);
addTearDown(transport.dispose);
@@ -28,7 +28,7 @@ void main() {
expect(auth, isA<Map<String, dynamic>>());
expect(auth['type'], 'auth');
expect(auth['parent_channel_id'], _parentChannelId);
expect(auth['protocol_version'], 3);
expect(auth['protocol_version'], 2);
channel.emitText(
jsonEncode({
@@ -72,7 +72,7 @@ void main() {
),
);
channel.emitBinary(
Uint8List.fromList([4, 0, 0, 9, 0, 0, 3, 0xc0, 0xd8, 0, 0xaa]),
Uint8List.fromList([4, 0, 9, 0, 0, 3, 0xc0, 0xd8, 0, 0xaa]),
);
await inbound;
@@ -471,12 +471,10 @@ void main() {
);
test(
'stale-epoch frame is fenced after its index is reused by a new occupant',
'v2 media routes through the current peer-index occupant after reuse',
() async {
// Blocker 1 causal race: a frame authored by the prior occupant of an
// index can be queued in the relay's data path and delivered *after* the
// roster control that reassigns the index. Index-only fencing would
// mis-attribute it to the new occupant; the epoch rejects it.
// Protocol v2 exposes only the peer index on media. After the roster
// reassigns that index, subsequent frames route to the current occupant.
final channel = _ControlledWebSocketChannel();
final transport = _transport(channel);
addTearDown(transport.dispose);
@@ -511,14 +509,13 @@ void main() {
expect(transport.state.peers[4]?.pubkey, 'new');
expect(transport.state.peers[4]?.epoch, 1);
// A residual frame from 'old' (epoch 0) arrives after the reassignment.
// It must be dropped, not delivered as 'new' audio.
// V2 cannot distinguish delayed media from the old occupancy, so both
// packets are delivered through the currently occupied index.
channel.emitBinary(_relayFrame(peerIndex: 4, epoch: 0, sequence: 7));
// A legitimate 'new' frame (epoch 1) is delivered.
channel.emitBinary(_relayFrame(peerIndex: 4, epoch: 1, sequence: 8));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(received, [8]);
expect(received, [7, 8]);
},
);
@@ -822,11 +819,11 @@ Uint8List _relayFrame({
levelDbov: -30,
flags: 0,
);
final clientFrame = HuddleWireV3.encodeClientFrame(
final clientFrame = HuddleWireV2.encodeClientFrame(
header,
Uint8List.fromList([sequence & 0xff]),
);
return Uint8List.fromList([peerIndex, epoch, ...clientFrame]);
return Uint8List.fromList([peerIndex, ...clientFrame]);
}
Future<void> _waitForPhase(

View File

@@ -10,17 +10,17 @@ const _parentChannelId = '11111111-2222-4333-8444-555555555555';
const _ephemeralChannelId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
void main() {
group('HuddleWireV3', () {
test('encodes the desktop-compatible big-endian v3 golden frame', () {
group('HuddleWireV2', () {
test('encodes the desktop-compatible big-endian v2 golden frame', () {
const header = HuddleAudioHeader(
sequence: 0x1234,
timestamp48k: 0x01020304,
levelDbov: -42,
flags: HuddleWireV3.dtxFlag,
flags: HuddleWireV2.dtxFlag,
);
expect(
HuddleWireV3.encodeClientFrame(
HuddleWireV2.encodeClientFrame(
header,
Uint8List.fromList([0xaa, 0xbb]),
),
@@ -29,10 +29,9 @@ void main() {
});
test('decodes peer prefix, v2 header, and Opus payload', () {
final frame = HuddleWireV3.decodeRelayFrame(
final frame = HuddleWireV2.decodeRelayFrame(
Uint8List.fromList([
0x07,
0x00,
0x12,
0x34,
0x01,
@@ -84,21 +83,21 @@ void main() {
);
expect(
() => HuddleWireV3.encodeClientFrame(header, Uint8List(0)),
() => HuddleWireV2.encodeClientFrame(header, Uint8List(0)),
throwsA(isA<HuddleWireException>()),
);
expect(
() => HuddleWireV3.encodeClientFrame(
() => HuddleWireV2.encodeClientFrame(
header,
Uint8List(HuddleWireV3.maxBinaryFrameLength),
Uint8List(HuddleWireV2.maxBinaryFrameLength),
),
throwsA(isA<HuddleWireException>()),
);
});
});
group('HuddleAuthV3', () {
test('uses the base relay URL and fixed v3 auth envelope', () {
group('HuddleAuthV2', () {
test('uses the base relay URL and fixed v2 auth envelope', () {
final parameters = HuddleConnectionParameters(
relayWebSocketUrl: 'wss://buzz.example',
nsec: _privateKey,
@@ -106,7 +105,7 @@ void main() {
ephemeralChannelId: _ephemeralChannelId,
);
final auth = HuddleAuthV3.buildMessage(
final auth = HuddleAuthV2.buildMessage(
parameters: parameters,
challenge: 'relay-challenge',
createdAt: 1_700_000_000,
@@ -117,7 +116,7 @@ void main() {
.toList();
expect(auth['type'], 'auth');
expect(auth['protocol_version'], 3);
expect(auth['protocol_version'], 2);
expect(auth['parent_channel_id'], _parentChannelId);
expect(event['kind'], 22242);
expect(tags[0], ['relay', 'wss://buzz.example']);