mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user