diff --git a/lib/features/calls/data/livekit_service.dart b/lib/features/calls/data/livekit_service.dart index f4135f7..c2bcd29 100644 --- a/lib/features/calls/data/livekit_service.dart +++ b/lib/features/calls/data/livekit_service.dart @@ -1,4 +1,4 @@ -// Version: 1.2.1 | Created: 2026-04-01 | Updated: 2026-04-03 +// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-03 // LiveKitService — fetches a JWT from the Matrix server's /_matrix/livekit/jwt // endpoint, then connects a LiveKit Room using that token. // @@ -7,6 +7,7 @@ // Matrix server (as configured on chat.m8chat.au). import 'dart:convert'; +import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; @@ -21,6 +22,9 @@ import '../../../core/network/matrix_client.dart'; part 'livekit_service.g.dart'; +/// MSC3401 MatrixRTC call member event type (what Element X listens for). +const _kCallMemberEventType = 'org.matrix.msc3401.call.member'; + /// Failure type for LiveKit connection attempts. sealed class LiveKitFailure { const LiveKitFailure(); @@ -61,6 +65,8 @@ class LiveKitService { final Ref _ref; Room? _activeRoom; + String? _activeMatrixRoomId; + String? _membershipId; Room? get activeRoom => _activeRoom; @@ -93,23 +99,55 @@ class LiveKitService { final jwt = (jwtResult as _JwtOk).token; final livekitUrl = (jwtResult).url; - // Step 2 — connect to LiveKit + // Step 2 — send MatrixRTC call.member state event so Element X sees the call + _membershipId = _randomId(); + await _sendCallMemberEvent( + client: client, + matrixRoomId: matrixRoomId, + userId: userId, + deviceId: auth.deviceId, + membershipId: _membershipId!, + ); + _activeMatrixRoomId = matrixRoomId; + debugPrint('[LiveKit] Call member state event sent'); + + // Step 3 — connect to LiveKit final room = Room(); try { await room.connect(livekitUrl, jwt); _activeRoom = room; return LiveKitConnected(room: room); } on Exception catch (e) { + // Clean up the state event on failure + await _clearCallMemberEvent(client, matrixRoomId, userId); await room.disconnect(); await room.dispose(); return LiveKitFailed(LiveKitConnectFailed(e.toString())); } } - /// Disconnect and dispose the active room. + /// Disconnect and dispose the active room, clearing the MatrixRTC state event. Future disconnect() async { final room = _activeRoom; + final matrixRoomId = _activeMatrixRoomId; _activeRoom = null; + _activeMatrixRoomId = null; + _membershipId = null; + + // Clear the call member state event so Element X sees the call ended + if (matrixRoomId != null) { + try { + final client = _ref.read(matrixClientProvider); + final userId = client.userID; + if (userId != null) { + await _clearCallMemberEvent(client, matrixRoomId, userId); + debugPrint('[LiveKit] Call member state event cleared'); + } + } catch (e) { + debugPrint('[LiveKit] Failed to clear call member event: $e'); + } + } + if (room != null) { await room.disconnect(); await room.dispose(); @@ -170,6 +208,71 @@ class LiveKitService { return _JwtError('Failed to get call token: $e'); } } + + /// Send MatrixRTC call.member state event so other clients (Element X) + /// see the call and can join or show an incoming call notification. + Future _sendCallMemberEvent({ + required Client client, + required String matrixRoomId, + required String userId, + required String deviceId, + required String membershipId, + }) async { + try { + await client.setRoomStateWithKey( + matrixRoomId, + _kCallMemberEventType, + userId, + { + 'memberships': [ + { + 'application': 'm.call', + 'call_id': '', + 'scope': 'm.room', + 'device_id': deviceId, + 'expires': 3600000, + 'expires_ts': + DateTime.now().millisecondsSinceEpoch + 3600000, + 'membershipID': membershipId, + 'foci_active': [ + { + 'type': 'livekit', + 'livekit_alias': matrixRoomId, + 'livekit_service_url': AppConfig.livekitJwtUrl, + }, + ], + }, + ], + }, + ); + } catch (e) { + debugPrint('[LiveKit] Failed to send call member event: $e'); + } + } + + /// Clear the call.member state event (remove our membership). + Future _clearCallMemberEvent( + Client client, + String matrixRoomId, + String userId, + ) async { + try { + await client.setRoomStateWithKey( + matrixRoomId, + _kCallMemberEventType, + userId, + {'memberships': []}, + ); + } catch (e) { + debugPrint('[LiveKit] Failed to clear call member event: $e'); + } + } + + static String _randomId() { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + final rng = Random(); + return List.generate(12, (_) => chars[rng.nextInt(chars.length)]).join(); + } } // Internal result types for JWT fetch — not exposed outside this file.