// 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. // // The JWT endpoint is defined in AppConfig.livekitJwtUrl and uses the Matrix // access token as Bearer auth. The LiveKit server URL is the same host as the // 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; import 'package:livekit_client/livekit_client.dart'; import 'package:matrix/matrix.dart' show Client; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/auth/auth_notifier.dart'; import '../../../core/auth/auth_state.dart'; import '../../../core/config/app_config.dart'; 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(); } final class LiveKitNotAuthenticated extends LiveKitFailure { const LiveKitNotAuthenticated(); } final class LiveKitJwtFetchFailed extends LiveKitFailure { const LiveKitJwtFetchFailed(this.message); final String message; } final class LiveKitConnectFailed extends LiveKitFailure { const LiveKitConnectFailed(this.message); final String message; } /// Result of a LiveKit connection attempt. sealed class LiveKitResult { const LiveKitResult(); } final class LiveKitConnected extends LiveKitResult { const LiveKitConnected({required this.room}); final Room room; } final class LiveKitFailed extends LiveKitResult { const LiveKitFailed(this.failure); final LiveKitFailure failure; } /// Manages LiveKit room connections for MatrixRTC. class LiveKitService { LiveKitService({required Ref ref}) : _ref = ref; final Ref _ref; Room? _activeRoom; String? _activeMatrixRoomId; String? _membershipId; Room? get activeRoom => _activeRoom; /// Connect to LiveKit for [matrixRoomId]. /// /// Steps: /// 1. GET `/_matrix/livekit/jwt?roomId={id}&userId={id}` /// 2. Use returned token + LiveKit WS URL to connect a [Room] Future connect(String matrixRoomId) async { final auth = _ref.read(authProvider); debugPrint('[LiveKit] Auth state: ${auth.runtimeType}'); if (auth is! AuthAuthenticated) { debugPrint('[LiveKit] Not authenticated — cannot fetch JWT'); return const LiveKitFailed(LiveKitNotAuthenticated()); } final userId = auth.userId; debugPrint('[LiveKit] Fetching JWT for room=$matrixRoomId user=$userId'); // Step 1 — get OpenID token from Synapse (proves our identity) final client = _ref.read(matrixClientProvider); final jwtResult = await _fetchJwt( client: client, userId: userId, matrixRoomId: matrixRoomId, ); if (jwtResult is _JwtError) { return LiveKitFailed(LiveKitJwtFetchFailed(jwtResult.message)); } final jwt = (jwtResult as _JwtOk).token; final livekitUrl = (jwtResult).url; // 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, clearing the MatrixRTC state event. /// Order: disconnect LiveKit first (so Element X sees us leave the SFU), /// then clear the state event (so Element X knows the call session ended). Future disconnect() async { final room = _activeRoom; final matrixRoomId = _activeMatrixRoomId; _activeRoom = null; _activeMatrixRoomId = null; _membershipId = null; // Step 1: Leave LiveKit — Element X will see us disappear from the SFU if (room != null) { await room.disconnect(); await room.dispose(); debugPrint('[LiveKit] LiveKit room disconnected'); } // Step 2: Clear the call.member state event — Element X sees 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'); } } } /// Fetches a LiveKit JWT via the MSC4143 flow: /// 1. Request OpenID token from Synapse (proves our identity) /// 2. POST it to /_matrix/livekit/jwt/sfu/get with the room ID /// 3. Server verifies OpenID token with Synapse and returns LiveKit JWT + URL Future<_JwtFetchResult> _fetchJwt({ required Client client, required String userId, required String matrixRoomId, }) async { try { // Step 1: Get OpenID token from Synapse debugPrint('[LiveKit] Requesting OpenID token from Synapse...'); final openId = await client.requestOpenIdToken(userId, {}); debugPrint('[LiveKit] OpenID token received'); // Step 2: POST to the LiveKit JWT endpoint // The nginx location on matrix.m8chat.au expects /sfu/get sub-path final uri = Uri.parse('${AppConfig.livekitJwtUrl}/sfu/get'); debugPrint('[LiveKit] POSTing to $uri'); final response = await http.post( uri, headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'room': matrixRoomId, 'openid_token': openId.toJson(), }), ); debugPrint('[LiveKit] JWT response: ${response.statusCode}'); if (response.statusCode != 200) { debugPrint('[LiveKit] JWT body: ${response.body}'); return _JwtError( 'JWT endpoint returned ${response.statusCode}: ${response.body}', ); } final json = jsonDecode(response.body) as Map; // lk-jwt-service returns 'jwt' (not 'token') per Element spec final token = (json['jwt'] ?? json['token']) as String?; final url = json['url'] as String?; if (token == null || url == null) { debugPrint('[LiveKit] Response keys: ${json.keys.toList()}'); return _JwtError('JWT response missing jwt or url. Got: ${json.keys}'); } debugPrint('[LiveKit] JWT obtained, LiveKit URL: $url'); return _JwtOk(token: token, url: url); } on Exception catch (e) { debugPrint('[LiveKit] Error: $e'); 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. sealed class _JwtFetchResult {} final class _JwtOk extends _JwtFetchResult { _JwtOk({required this.token, required this.url}); final String token; final String url; } final class _JwtError extends _JwtFetchResult { _JwtError(this.message); final String message; } @Riverpod(keepAlive: true) LiveKitService liveKitService(Ref ref) { final service = LiveKitService(ref: ref); ref.onDispose(() async => service.disconnect()); return service; }