diff --git a/.changes/protocol-v16 b/.changes/protocol-v16 new file mode 100644 index 000000000..9946efe09 --- /dev/null +++ b/.changes/protocol-v16 @@ -0,0 +1 @@ +minor type="feature" "Support up to protocol v16 with room move and request response handling" diff --git a/example/lib/widgets/controls.dart b/example/lib/widgets/controls.dart index fbe8c815e..3c96b4ebc 100644 --- a/example/lib/widgets/controls.dart +++ b/example/lib/widgets/controls.dart @@ -236,11 +236,11 @@ class _ControlsWidgetState extends State { } if (SimulateScenarioResult.participantMetadata == result) { - widget.room.localParticipant?.setMetadata('new metadata ${widget.room.localParticipant?.identity}'); + await widget.room.localParticipant?.setMetadata('new metadata ${widget.room.localParticipant?.identity}'); } if (SimulateScenarioResult.participantName == result) { - widget.room.localParticipant?.setName('new name for ${widget.room.localParticipant?.identity}'); + await widget.room.localParticipant?.setName('new name for ${widget.room.localParticipant?.identity}'); } await widget.room.sendSimulateScenario( diff --git a/lib/src/core/engine.dart b/lib/src/core/engine.dart index 494ef976c..2bcb9a128 100644 --- a/lib/src/core/engine.dart +++ b/lib/src/core/engine.dart @@ -1381,35 +1381,43 @@ class Engine extends Disposable with EventsEmittable { token = event.token; }) ..on((event) async { + logger.fine('[Signal] Leave received, action: ${event.action}, reason: ${event.reason}'); if (event.regions != null && _regionUrlProvider != null) { logger.fine('updating regions'); _regionUrlProvider?.setServerReportedRegions(event.regions!); } - switch (event.action) { - case lk_rtc.LeaveRequest_Action.DISCONNECT: - if (connectionState == ConnectionState.reconnecting) { - logger.warning('[Signal] Received Leave while engine is reconnecting, ignoring...'); - return; - } - await signalClient.cleanUp(); - fullReconnectOnNext = false; - await disconnect(); - events.emit(EngineDisconnectedEvent(reason: event.reason.toSDKType())); - break; - case lk_rtc.LeaveRequest_Action.RECONNECT: - fullReconnectOnNext = true; - // reconnect immediately instead of waiting for next attempt - await handleReconnect(ClientDisconnectReason.leaveReconnect); - break; - case lk_rtc.LeaveRequest_Action.RESUME: - // reconnect immediately instead of waiting for next attempt - await handleReconnect(ClientDisconnectReason.leaveReconnect); - default: - break; + // Protocol v13: LeaveRequest.action replaces the deprecated canReconnect boolean. + // canReconnect is still checked for backward compatibility with v12 servers + // (where action defaults to DISCONNECT=0 since it's unset). + if (event.action == lk_rtc.LeaveRequest_Action.RESUME) { + fullReconnectOnNext = false; + // reconnect immediately instead of waiting for next attempt + await handleReconnect(ClientDisconnectReason.leaveReconnect); + } else if (event.action == lk_rtc.LeaveRequest_Action.RECONNECT || event.canReconnect) { + fullReconnectOnNext = true; + // reconnect immediately instead of waiting for next attempt + await handleReconnect(ClientDisconnectReason.leaveReconnect); + } else { + // DISCONNECT or v12 server with canReconnect=false + await signalClient.cleanUp(); + fullReconnectOnNext = false; + await disconnect(reason: event.reason.toSDKType()); } + }) + ..on((event) async { + events.emit(EngineRequestResponseEvent(response: event.response)); + }) + ..on((event) async { + logger.fine('[Signal] RoomMoved received, room: ${event.response.room.name}'); + if (event.response.hasParticipant()) { + signalClient.participantSid = event.response.participant.sid; + } + events.emit(EngineRoomMovedEvent(response: event.response)); }); - Future disconnect() async { + Future disconnect({ + DisconnectReason reason = DisconnectReason.clientInitiated, + }) async { _isClosed = true; events.emit(EngineClosingEvent()); if (connectionState == ConnectionState.connected) { @@ -1420,11 +1428,9 @@ class Engine extends Disposable with EventsEmittable { await signalClient.cleanUp(); await _signalListener.cancelAll(); clearPendingReconnect(); - events.emit(EngineDisconnectedEvent( - reason: DisconnectReason.clientInitiated, - )); } await cleanUp(); + events.emit(EngineDisconnectedEvent(reason: reason)); } } diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index b88b2e85e..d35317af7 100644 --- a/lib/src/core/room.dart +++ b/lib/src/core/room.dart @@ -390,15 +390,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable { state: publication.subscriptionState, )); }) - ..on((event) async { - _metadata = event.room.metadata; - _roomInfo = event.room; - emitWhenConnected(RoomMetadataChangedEvent(metadata: event.room.metadata)); - if (_isRecording != event.room.activeRecording) { - _isRecording = event.room.activeRecording; - emitWhenConnected(RoomRecordingStatusChanged(activeRecording: _isRecording)); - } - }) + ..on((event) async => _applyRoomUpdate(event.room)) ..on((event) async { final publication = localParticipant?.trackPublications[event.sid]; @@ -421,17 +413,10 @@ class Room extends DisposableChangeNotifier with EventsEmittable { void _setUpEngineListeners() => _engineListener ..on((event) async { - _roomInfo = event.response.room; - _name = event.response.room.name; - _metadata = event.response.room.metadata; + _applyRoomUpdate(event.response.room); _serverVersion = event.response.serverVersion; _serverRegion = event.response.serverRegion; - if (_isRecording != event.response.room.activeRecording) { - _isRecording = event.response.room.activeRecording; - emitWhenConnected(RoomRecordingStatusChanged(activeRecording: _isRecording)); - } - logger.fine('[Engine] Received JoinResponse, ' 'serverVersion: ${event.response.serverVersion}'); @@ -579,6 +564,39 @@ class Room extends DisposableChangeNotifier with EventsEmittable { ..on((event) => _onEngineActiveSpeakersUpdateEvent(event.speakers)) ..on(_onDataMessageEvent) ..on(_onTranscriptionEvent) + ..on((event) { + localParticipant?.handleSignalRequestResponse(event.response); + }) + ..on((event) async { + final response = event.response; + logger.fine('Room moved to: ${response.room.name}'); + + // Apply room info from move response + if (response.hasRoom()) { + _applyRoomUpdate(response.room); + } + + // Disconnect all remote participants + final identities = _remoteParticipants.byIdentity.keys.toList(); + for (final identity in identities) { + await _handleParticipantDisconnect(identity); + } + + // Emit public event + events.emit(RoomMovedEvent(roomName: response.room.name)); + + // Update local participant info + if (response.hasParticipant()) { + await localParticipant?.updateFromInfo(response.participant); + } + + // Add new participants + if (response.otherParticipants.isNotEmpty) { + await _onParticipantUpdateEvent(response.otherParticipants); + } + + notifyListeners(); + }) ..on((event) { _handleAudioPlaybackStarted(); }) @@ -993,12 +1011,30 @@ extension RoomPrivateMethods on Room { await NativeAudioManagement.stop(); // reset params + _roomInfo = null; _name = null; _metadata = null; + _isRecording = false; _serverVersion = null; _serverRegion = null; } + /// Applies room info from server. Skips metadata event on first join + /// since there is no previous state to compare against. + void _applyRoomUpdate(lk_models.Room room) { + final oldRoom = _roomInfo; + _roomInfo = room; + _name = room.name; + _metadata = room.metadata; + if (oldRoom != null && oldRoom.metadata != room.metadata) { + emitWhenConnected(RoomMetadataChangedEvent(metadata: room.metadata)); + } + if (oldRoom?.activeRecording != room.activeRecording) { + _isRecording = room.activeRecording; + emitWhenConnected(RoomRecordingStatusChanged(activeRecording: _isRecording)); + } + } + @internal void emitWhenConnected(RoomEvent event) { if (connectionState == ConnectionState.connected) { diff --git a/lib/src/core/signal_client.dart b/lib/src/core/signal_client.dart index cd1236b93..91311e327 100644 --- a/lib/src/core/signal_client.dart +++ b/lib/src/core/signal_client.dart @@ -57,6 +57,14 @@ class SignalClient extends Disposable with EventsEmittable { int _pingCount = 0; String? participantSid; + int _requestId = 0; + + @internal + int getNextRequestId() { + _requestId += 1; + return _requestId; + } + List _connectivityResult = []; StreamSubscription>? _connectivitySubscription; @@ -199,7 +207,11 @@ class SignalClient extends Disposable with EventsEmittable { Future sendLeave() async { _sendRequest(lk_rtc.SignalRequest( - leave: lk_rtc.LeaveRequest(canReconnect: false, reason: lk_models.DisconnectReason.CLIENT_INITIATED))); + leave: lk_rtc.LeaveRequest( + reason: lk_models.DisconnectReason.CLIENT_INITIATED, + // server doesn't process this field, keeping it here to indicate the intent of a full disconnect + action: lk_rtc.LeaveRequest_Action.DISCONNECT, + ))); } // resets internal state to a re-usable state @@ -334,6 +346,17 @@ class SignalClient extends Disposable with EventsEmittable { case lk_rtc.SignalResponse_Message.reconnect: events.emit(SignalReconnectResponseEvent(response: msg.reconnect)); break; + case lk_rtc.SignalResponse_Message.requestResponse: + logger.fine('received request response: ${msg.requestResponse.reason}'); + events.emit(SignalRequestResponseEvent(response: msg.requestResponse)); + break; + case lk_rtc.SignalResponse_Message.roomMoved: + logger.fine('received room moved: ${msg.roomMoved.room.name}'); + if (msg.roomMoved.token.isNotEmpty) { + events.emit(SignalTokenUpdatedEvent(token: msg.roomMoved.token)); + } + events.emit(SignalRoomMovedEvent(response: msg.roomMoved)); + break; default: logger.warning('received unknown signal message'); } @@ -428,9 +451,12 @@ extension SignalClientRequests on SignalClient { )); @internal - void sendUpdateLocalMetadata(lk_rtc.UpdateParticipantMetadata metadata) => _sendRequest(lk_rtc.SignalRequest( - updateMetadata: metadata, - )); + int sendUpdateLocalMetadata(lk_rtc.UpdateParticipantMetadata metadata) { + final requestId = getNextRequestId(); + metadata.requestId = requestId; + _sendRequest(lk_rtc.SignalRequest(updateMetadata: metadata)); + return requestId; + } @internal void sendUpdateTrackSettings(lk_rtc.UpdateTrackSettings settings) => _sendRequest(lk_rtc.SignalRequest( diff --git a/lib/src/events.dart b/lib/src/events.dart index 7b0eda7e2..2161a7388 100644 --- a/lib/src/events.dart +++ b/lib/src/events.dart @@ -637,3 +637,13 @@ class PreConnectAudioBufferStoppedEvent with RoomEvent { String toString() => '${runtimeType}' '(bufferedSize: ${bufferedSize}, isDataSent: ${isBufferSent})'; } + +/// Fired when the participant has been moved to a different room by the server. +/// Emitted by [Room]. +class RoomMovedEvent with RoomEvent { + final String roomName; + const RoomMovedEvent({required this.roomName}); + + @override + String toString() => '${runtimeType}(roomName: $roomName)'; +} diff --git a/lib/src/extensions.dart b/lib/src/extensions.dart index d919da94b..7c678a86f 100644 --- a/lib/src/extensions.dart +++ b/lib/src/extensions.dart @@ -66,6 +66,10 @@ extension ProtocolVersionExt on ProtocolVersion { ProtocolVersion.v10: '10', ProtocolVersion.v11: '11', ProtocolVersion.v12: '12', + ProtocolVersion.v13: '13', + ProtocolVersion.v14: '14', + ProtocolVersion.v15: '15', + ProtocolVersion.v16: '16', }[this]!; } diff --git a/lib/src/internal/events.dart b/lib/src/internal/events.dart index 22fa59709..b4af51238 100644 --- a/lib/src/internal/events.dart +++ b/lib/src/internal/events.dart @@ -543,10 +543,47 @@ class SignalTokenUpdatedEvent with SignalEvent, InternalEvent { String toString() => '${runtimeType}(token: ${token})'; } +@internal +class SignalRequestResponseEvent with SignalEvent, InternalEvent { + final lk_rtc.RequestResponse response; + const SignalRequestResponseEvent({required this.response}); + + @override + String toString() => '${runtimeType}' + '(requestId: ${response.requestId}, reason: ${response.reason})'; +} + +@internal +class SignalRoomMovedEvent with SignalEvent, InternalEvent { + final lk_rtc.RoomMovedResponse response; + const SignalRoomMovedEvent({required this.response}); + + @override + String toString() => '${runtimeType}(room: ${response.room.name})'; +} + // ---------------------------------------------------------------------- // Engine events // ---------------------------------------------------------------------- +@internal +class EngineRequestResponseEvent with EngineEvent, InternalEvent { + final lk_rtc.RequestResponse response; + const EngineRequestResponseEvent({required this.response}); + + @override + String toString() => '${runtimeType}(requestId: ${response.requestId})'; +} + +@internal +class EngineRoomMovedEvent with EngineEvent, InternalEvent { + final lk_rtc.RoomMovedResponse response; + const EngineRoomMovedEvent({required this.response}); + + @override + String toString() => '${runtimeType}(room: ${response.room.name})'; +} + @internal class EngineTrackAddedEvent with EngineEvent, InternalEvent { final rtc.MediaStreamTrack track; diff --git a/lib/src/options.dart b/lib/src/options.dart index af31ce5fd..f37cf43de 100644 --- a/lib/src/options.dart +++ b/lib/src/options.dart @@ -64,7 +64,7 @@ class ConnectOptions { const ConnectOptions({ this.autoSubscribe = true, this.rtcConfiguration = const RTCConfiguration(), - this.protocolVersion = ProtocolVersion.v12, + this.protocolVersion = ProtocolVersion.v16, this.timeouts = Timeouts.defaultTimeouts, }); } diff --git a/lib/src/participant/local.dart b/lib/src/participant/local.dart index 2716c703e..dcf8b8810 100644 --- a/lib/src/participant/local.dart +++ b/lib/src/participant/local.dart @@ -67,6 +67,9 @@ class LocalParticipant extends Participant { // RPC Pending Responses final Map _pendingResponses = {}; + // Pending signal request responses (keyed by requestId) + final Map> _pendingSignalRequests = {}; + LocalParticipant._({ required Room room, required String sid, @@ -99,6 +102,15 @@ class LocalParticipant extends Participant { participant.onDispose(() async { BroadcastManager().removeListener(participant._broadcastStateChanged); + // Fail any pending signal requests + for (final completer in participant._pendingSignalRequests.values) { + if (!completer.isCompleted) { + completer.completeError( + UnexpectedStateException('Participant disposed'), + ); + } + } + participant._pendingSignalRequests.clear(); await participant.unpublishAllTracks(); }); @@ -621,30 +633,66 @@ class LocalParticipant extends Participant { /// Sets and updates the metadata of the local participant. /// Note: this requires `CanUpdateOwnMetadata` permission encoded in the token. /// @param metadata - void setMetadata(String metadata) { - room.engine.signalClient.sendUpdateLocalMetadata(lk_rtc.UpdateParticipantMetadata( - name: name, - metadata: metadata, - )); + Future setMetadata(String metadata) { + final requestId = room.engine.signalClient.sendUpdateLocalMetadata( + lk_rtc.UpdateParticipantMetadata( + name: name, + metadata: metadata, + ), + ); + return _waitForRequestResponse(requestId); } /// Sets and updates the attributes of the local participant. /// @attributes key-value pairs to set - void setAttributes(Map attributes) { - room.engine.signalClient.sendUpdateLocalMetadata(lk_rtc.UpdateParticipantMetadata( - attributes: attributes.entries, - )); + Future setAttributes(Map attributes) { + final requestId = room.engine.signalClient.sendUpdateLocalMetadata( + lk_rtc.UpdateParticipantMetadata( + name: name, + metadata: metadata, + attributes: attributes.entries, + ), + ); + return _waitForRequestResponse(requestId); } /// Sets and updates the name of the local participant. /// Note: this requires `CanUpdateOwnMetadata` permission encoded in the token. /// @param name - void setName(String name) { - super.updateName(name); - room.engine.signalClient.sendUpdateLocalMetadata(lk_rtc.UpdateParticipantMetadata( - name: name, - metadata: metadata, - )); + Future setName(String name) { + final requestId = room.engine.signalClient.sendUpdateLocalMetadata( + lk_rtc.UpdateParticipantMetadata( + name: name, + metadata: metadata, + ), + ); + return _waitForRequestResponse(requestId); + } + + Future _waitForRequestResponse(int requestId) { + final completer = Completer(); + _pendingSignalRequests[requestId] = completer; + return completer.future.timeout( + const Duration(seconds: 5), + onTimeout: () { + _pendingSignalRequests.remove(requestId); + throw TimeoutException('Signal request timed out'); + }, + ); + } + + @internal + void handleSignalRequestResponse(lk_rtc.RequestResponse response) { + final completer = _pendingSignalRequests.remove(response.requestId); + if (completer != null && !completer.isCompleted) { + if (response.reason != lk_rtc.RequestResponse_Reason.OK) { + completer.completeError( + UnexpectedStateException('Signal request failed: ${response.reason} - ${response.message}'), + ); + } else { + completer.complete(); + } + } } /// A convenience property to get all video tracks. diff --git a/lib/src/types/other.dart b/lib/src/types/other.dart index 46f39b07b..d6d99d77a 100644 --- a/lib/src/types/other.dart +++ b/lib/src/types/other.dart @@ -35,6 +35,10 @@ enum ProtocolVersion { v10, v11, v12, + v13, // Regions in leave request, canReconnect obsoleted by action + v14, + v15, // Non-error signal responses, room move + v16, // Supports moving (full participant move) } /// Connection state type used throughout the SDK.