Files
Warp/apps/mobile/lib/matrix/calls.dart
T
tajniak81andClaude Opus 5.5 41f800ab0b Mobile: voice and video calls (MatrixRTC with LiveKit)
Calls the way the web client and Element Call do them, with the same
events, so the three can share a call. The famedly SDK's own group calls
use com.famedly.call.member events, which the others don't read, so this
is written against the matrix-js-sdk wire format instead (lib/matrix/
calls.dart):

- Membership: org.matrix.msc3401.call.member state, key
  _@user:server_DEVICE_m.call, with the legacy session fields the js-sdk
  sends (application m.call, call_id "", membershipID, expires,
  focus_active, foci_preferred). An empty delayed copy (MSC4140) is
  scheduled first and pushed back every 5 s; leaving sends the empty
  state and cancels it.
- Media: a LiveKit token from the JWT service (the call's, else the
  homeserver's rtc/transports or .well-known rtc_foci) for an OpenID
  token; livekit_client connects.
- Encrypted rooms: a random 16-byte key per device, sent to every other
  device in the call over encrypted to-device
  io.element.call.encryption_keys, keys from others applied per LiveKit
  identity (@user:server:DEVICE); per-participant keys, no ratcheting,
  HKDF, like the web client's livekit-client.
- Ringing: a call started in a room of two sends a ring notification
  (MSC4075); the other side gets an incoming-call card and an insistent
  ringtone notification (full screen on a locked phone), declines with
  MSC4310 or answers by joining. The caller gives up after a minute or on
  a decline.
- UI: voice and video buttons in the room header, a call screen (the
  other person large and you in the corner, or a grid; mute, camera, flip,
  leave), a Join banner and a room-list marker for calls going on.

Not tested end to end yet: it needs a LiveKit SFU and JWT service.

README: replies, reactions, threads, spaces, QR verification, sliding
sync and calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:43:19 +02:00

856 lines
27 KiB
Dart

import 'dart:async';
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' as lk;
import 'package:matrix/matrix.dart';
import 'notifications.dart';
import 'package:webrtc_interface/webrtc_interface.dart' as rtc;
import 'package:flutter_webrtc/flutter_webrtc.dart' show frameCryptorFactory;
/// Voice and video calls: MatrixRTC (MSC4143) with a LiveKit SFU, the way
/// the web client and Element Call do them, so the three can share a call.
///
/// Who is in a room's call is room state: one
/// `org.matrix.msc3401.call.member` event per device, state key
/// `_@user:server_DEVICE_m.call`. Joining sends ours, after scheduling an
/// empty one with the homeserver (a delayed event, MSC4140) that is pushed
/// back every few seconds while the call lasts: if the app dies or loses
/// its connection, the server sends it and the device drops out.
///
/// The media goes through LiveKit, whose token the LiveKit JWT service
/// hands out for an OpenID token from the homeserver. In an encrypted room
/// every member makes its own media key and sends it to the others over
/// encrypted to-device messages (`io.element.call.encryption_keys`); LiveKit
/// encrypts each frame with it.
///
/// Starting a call in a room of two rings the other person
/// (`org.matrix.msc4075.rtc.notification`); declining answers it
/// (`org.matrix.msc4310.rtc.decline`).
///
/// One call at a time.
class Calls extends ChangeNotifier {
Calls(this.client) {
_subs
..add(client.onSync.stream.listen((_) => _refresh()))
..add(client.onTimelineEvent.stream.listen(_onTimelineEvent))
..add(client.onToDeviceEvent.stream.listen(_onToDevice));
_expiryTimer = Timer.periodic(
const Duration(seconds: 30),
(_) => _refresh(),
);
_refresh();
}
final Client client;
final _subs = <StreamSubscription>[];
Timer? _expiryTimer;
static const memberType = 'org.matrix.msc3401.call.member';
static const notificationType = 'org.matrix.msc4075.rtc.notification';
static const declineType = 'org.matrix.msc4310.rtc.decline';
static const keysType = 'io.element.call.encryption_keys';
/// How long a call rings, unless the notification says less.
static const ringLifetime = Duration(seconds: 60);
/// The most a received notification may ring for, whatever it says.
static const maxRing = Duration(seconds: 120);
/// A membership lasts this long unless renewed; calls are shorter.
static const membershipExpiry = Duration(hours: 4);
static const _delayedLeave = Duration(seconds: 8);
static const _delayedLeaveRestart = Duration(seconds: 5);
// --- What the screens show --------------------------------------------
/// The room whose call this device is in.
String? roomId;
CallPhase? phase;
bool video = false;
bool encrypted = false;
lk.Room? livekit;
/// We rang someone and they haven't come yet.
bool ringingOut = false;
/// A call ringing for us.
IncomingCall? incoming;
/// When we joined, for the call's length.
DateTime? joinedAt;
/// How many people (not devices) are in each room's call.
final active = <String, int>{};
/// Errors to show once, then cleared.
final errors = StreamController<String>.broadcast();
bool get inCall => roomId != null;
int inCallCount(String roomId) => active[roomId] ?? 0;
// --- Memberships ------------------------------------------------------
/// The live memberships of [room]'s call, one per device.
List<CallMember> memberships(Room room) {
final events = room.states[memberType]?.values ?? const [];
final now = DateTime.now();
final out = <CallMember>[];
for (final e in events) {
final m = CallMember.fromState(e);
if (m != null && m.expiresAt.isAfter(now)) out.add(m);
}
out.sort((a, b) => a.createdAt.compareTo(b.createdAt));
return out;
}
void _refresh() {
var changed = false;
final seen = <String>{};
for (final room in client.rooms) {
if (room.membership != Membership.join) continue;
final users = memberships(room).map((m) => m.userId).toSet();
final n = users.length;
if (n > 0) {
seen.add(room.id);
if (active[room.id] != n) {
active[room.id] = n;
changed = true;
}
}
}
for (final id in active.keys.toList()) {
if (!seen.contains(id)) {
active.remove(id);
changed = true;
}
}
// The caller gave up, or someone else answered for us.
final ring = incoming;
if (ring != null && inCallCount(ring.roomId) == 0) {
_clearIncoming();
changed = true;
}
final rid = roomId;
if (rid != null) {
final room = client.getRoomById(rid);
if (room != null) {
final members = memberships(room);
// Someone came: stop ringing them.
if (ringingOut && members.map((m) => m.userId).toSet().length > 1) {
ringingOut = false;
changed = true;
}
if (encrypted) _shareKeyWithNewDevices(room, members);
}
}
if (changed) notifyListeners();
}
// --- Ringing ------------------------------------------------------------
final _seenEvents = <String>{};
void _onTimelineEvent(Event event) {
if (event.type != notificationType && event.type != declineType) return;
if (!_seenEvents.add(event.eventId)) return;
final me = client.userID;
final room = event.room;
if (event.type == notificationType) {
if (event.senderId == me || roomId == room.id || inCall) return;
final mentions = event.content.tryGetMap<String, Object?>('m.mentions');
final userIds = mentions?.tryGetList<String>('user_ids') ?? const [];
if (mentions?['room'] != true && !userIds.contains(me)) return;
// A ring for a call of two; in a bigger room the banner says enough.
if (event.content['notification_type'] != 'ring' &&
_otherOfTwo(room) == null) {
return;
}
final senderTs = event.content.tryGet<int>('sender_ts');
final sentAt = senderTs == null
? event.originServerTs
: DateTime.fromMillisecondsSinceEpoch(
min(senderTs, event.originServerTs.millisecondsSinceEpoch),
);
final lifetimeMs = min(
event.content.tryGet<int>('lifetime') ?? ringLifetime.inMilliseconds,
maxRing.inMilliseconds,
);
final expiresAt = sentAt.add(Duration(milliseconds: lifetimeMs));
if (!expiresAt.isAfter(DateTime.now())) return;
final caller = event.senderFromMemoryOrFallback;
incoming = IncomingCall(
roomId: room.id,
eventId: event.eventId,
callerId: event.senderId,
callerName: caller.calcDisplayname(),
callerAvatar: caller.avatarUrl,
roomName: room.getLocalizedDisplayname(),
video: event.content['m.call.intent'] != 'audio',
expiresAt: expiresAt,
);
_ringTimer?.cancel();
_ringTimer = Timer(expiresAt.difference(DateTime.now()), () {
_clearIncoming();
notifyListeners();
});
Notifications.ring(
roomId: room.id,
caller: incoming!.callerName,
video: incoming!.video,
);
notifyListeners();
return;
}
// A decline.
final target = event.relationshipEventId;
if (target == null) return;
if (event.senderId == me && incoming?.eventId == target) {
// Declined on another of our devices.
_clearIncoming();
notifyListeners();
} else if (target == _sentNotification && ringingOut && room.id == roomId) {
errors.add(
'${event.senderFromMemoryOrFallback.calcDisplayname()} declined the call.',
);
leave();
}
}
Timer? _ringTimer;
void _clearIncoming() {
_ringTimer?.cancel();
if (incoming != null) Notifications.stopRinging();
incoming = null;
}
/// Answers the call ringing for us.
Future<void> answer({bool withVideo = true}) async {
final call = incoming;
if (call == null) return;
_clearIncoming();
notifyListeners();
await join(call.roomId, video: withVideo && call.video);
}
/// Declines the call ringing for us.
Future<void> decline() async {
final call = incoming;
if (call == null) return;
_clearIncoming();
notifyListeners();
try {
await client.getRoomById(call.roomId)?.sendEvent({
'm.relates_to': {
'rel_type': RelationshipTypes.reference,
'event_id': call.eventId,
},
}, type: declineType);
} catch (e) {
Logs().w('Could not decline the call', e);
}
}
/// The other person in a room of two.
String? _otherOfTwo(Room room) {
if ((room.summary.mJoinedMemberCount ?? room.getParticipants().length) !=
2) {
return null;
}
return room
.getParticipants([Membership.join])
.map((u) => u.id)
.where((id) => id != client.userID)
.firstOrNull;
}
// --- Joining ------------------------------------------------------------
String? _sentNotification;
String? _delayId;
Timer? _delayTimer;
Timer? _ringOutTimer;
lk.BaseKeyProvider? _keyProvider;
lk.EventsListener<lk.RoomEvent>? _lkListener;
/// Our media key, its index, and who has it.
Uint8List? _key;
final _keyIndex = 0;
final _keySentTo = <String>{};
/// Keys that came before LiveKit was ready, by identity.
final _pendingKeys = <String, (Uint8List, int)>{};
String get _deviceId => client.deviceID!;
String get _identity => '${client.userID}:$_deviceId';
String _stateKey(Room room) {
final key = '${client.userID}_${_deviceId}_m.call';
// Room versions with MSC3757/3779 own-state keys take it bare.
final version = room
.getState(EventTypes.RoomCreate)
?.content
.tryGet<String>('room_version');
return version != null &&
RegExp(r'^org\.matrix\.msc(3757|3779)\b').hasMatch(version)
? key
: '_$key';
}
/// Joins the call in [roomId], or starts one. Starting a call in a room
/// of two rings the other person.
Future<void> join(String roomId, {required bool video}) async {
if (this.roomId == roomId) return;
if (inCall) await leave();
final room = client.getRoomById(roomId);
if (room == null) return;
if (incoming?.roomId == roomId) _clearIncoming();
this.roomId = roomId;
this.video = video;
phase = CallPhase.joining;
encrypted = room.encrypted;
_sentNotification = null;
_keySentTo.clear();
notifyListeners();
final others = memberships(room);
final ringing = others.isEmpty && _otherOfTwo(room) != null;
try {
final transport = await _chooseTransport(others);
final (url, jwt) = await _livekitToken(transport, roomId);
if (this.roomId != roomId) return;
if (encrypted) {
_keyProvider = await _makeKeyProvider();
_key = _randomKey();
await _keyProvider!.setRawKey(
_key!,
participantId: _identity,
keyIndex: _keyIndex,
);
for (final e in _pendingKeys.entries) {
await _keyProvider!.setRawKey(
e.value.$1,
participantId: e.key,
keyIndex: e.value.$2,
);
}
_pendingKeys.clear();
}
final lkRoom = lk.Room(
roomOptions: lk.RoomOptions(
adaptiveStream: true,
dynacast: true,
e2eeOptions: _keyProvider == null
? null
: lk.E2EEOptions(keyProvider: _keyProvider!),
),
);
livekit = lkRoom;
lkRoom.addListener(_onLivekit);
_lkListener = lkRoom.createListener()
..on<lk.RoomReconnectingEvent>((_) {
phase = CallPhase.reconnecting;
notifyListeners();
})
..on<lk.RoomReconnectedEvent>((_) {
phase = CallPhase.connected;
notifyListeners();
})
..on<lk.RoomDisconnectedEvent>((event) {
if (livekit != lkRoom) return;
if (event.reason != lk.DisconnectReason.clientInitiated) {
errors.add('You were disconnected from the call.');
}
leave();
});
// The Matrix side: a delayed leave first, then ourselves.
await _scheduleDelayedLeave(room);
final membershipEventId = await client.setRoomStateWithKey(
roomId,
memberType,
_stateKey(room),
_myMembership(transport, video),
);
if (encrypted) _shareKeyWithNewDevices(room, memberships(room));
if (ringing || others.isEmpty) {
_sendNotification(room, membershipEventId, ring: ringing, video: video);
}
if (ringing) {
ringingOut = true;
_ringOutTimer = Timer(ringLifetime, () {
if (!ringingOut || this.roomId != roomId) return;
errors.add('No answer.');
leave();
});
}
await lkRoom.connect(url, jwt);
if (this.roomId != roomId) {
await lkRoom.disconnect();
return;
}
if (_keyProvider != null) await lkRoom.e2eeManager?.setEnabled(true);
phase = CallPhase.connected;
joinedAt = DateTime.now();
notifyListeners();
try {
await lkRoom.localParticipant?.setMicrophoneEnabled(true);
} catch (e) {
errors.add("Can't use the microphone: $e");
}
if (video) {
try {
await lkRoom.localParticipant?.setCameraEnabled(true);
} catch (e) {
errors.add("Can't use the camera: $e");
}
}
notifyListeners();
} catch (e, s) {
Logs().e('Joining the call failed', e, s);
errors.add(e is CallException ? e.message : 'The call failed: $e');
await leave();
}
}
void _onLivekit() => notifyListeners();
/// Leaves the call this device is in.
Future<void> leave() async {
final rid = roomId;
final lkRoom = livekit;
roomId = null;
livekit = null;
phase = null;
joinedAt = null;
ringingOut = false;
_ringOutTimer?.cancel();
_delayTimer?.cancel();
_keyProvider = null;
_key = null;
_keySentTo.clear();
_pendingKeys.clear();
final delayId = _delayId;
_delayId = null;
notifyListeners();
lkRoom?.removeListener(_onLivekit);
await _lkListener?.dispose();
_lkListener = null;
try {
await lkRoom?.disconnect();
} catch (_) {}
if (rid != null) {
final room = client.getRoomById(rid);
if (room != null) {
try {
await client.setRoomStateWithKey(
rid,
memberType,
_stateKey(room),
{},
);
} catch (e) {
Logs().w('Could not send the leave; the delayed one will', e);
}
}
}
if (delayId != null) {
try {
await _delayedEventAction(delayId, 'cancel');
} catch (_) {}
}
_refresh();
}
Map<String, Object?> _myMembership(
Map<String, Object?> transport,
bool video,
) => {
'application': 'm.call',
'call_id': '',
'scope': 'm.room',
'device_id': _deviceId,
'membershipID': _identity,
'expires': membershipExpiry.inMilliseconds,
'm.call.intent': video ? 'video' : 'audio',
'focus_active': {'type': 'livekit', 'focus_selection': 'multi_sfu'},
'foci_preferred': [transport],
};
void _sendNotification(
Room room,
String membershipEventId, {
required bool ring,
required bool video,
}) {
room
.sendEvent({
'slot_id': 'm.call#ROOM',
'm.mentions': {'user_ids': <String>[], 'room': true},
'notification_type': ring ? 'ring' : 'notification',
'm.relates_to': {
'event_id': membershipEventId,
'rel_type': RelationshipTypes.reference,
},
'sender_ts': DateTime.now().millisecondsSinceEpoch,
'lifetime': ringLifetime.inMilliseconds,
'msc4354_sticky_key': 'm.call#ROOM',
'm.call.intent': video ? 'video' : 'audio',
}, type: notificationType)
.then((id) => _sentNotification = id)
.catchError((Object e) {
Logs().w('Could not send the call notification', e);
return null;
});
}
// --- Delayed leave (MSC4140) ----------------------------------------------
Future<void> _scheduleDelayedLeave(Room room) async {
try {
final response = await client.request(
RequestType.PUT,
'/client/v3/rooms/${Uri.encodeComponent(room.id)}/state/'
'${Uri.encodeComponent(memberType)}/${Uri.encodeComponent(_stateKey(room))}',
query: {'org.matrix.msc4140.delay': '${_delayedLeave.inMilliseconds}'},
data: <String, Object?>{},
);
final id = response['delay_id'];
if (id is! String) return;
_delayId = id;
_delayTimer = Timer.periodic(_delayedLeaveRestart, (_) {
final current = _delayId;
if (current == null) return;
_delayedEventAction(current, 'restart').catchError((Object e) {
Logs().w('Could not push back the delayed leave', e);
});
});
} catch (e) {
// Without it, a crash leaves us in the call until our membership
// expires; the call itself still works.
Logs().w('The server has no delayed events', e);
}
}
Future<void> _delayedEventAction(String id, String action) => client.request(
RequestType.POST,
'/client/unstable/org.matrix.msc4140/delayed_events/'
'${Uri.encodeComponent(id)}/$action',
data: <String, Object?>{},
);
// --- LiveKit --------------------------------------------------------------
/// The LiveKit JWT service: the one the call already uses, else this
/// server's.
Future<Map<String, Object?>> _chooseTransport(List<CallMember> others) async {
for (final m in others) {
final t = m.transport;
if (t != null) return {...t, 'livekit_alias': roomId};
}
List? foci;
try {
final response = await client.request(
RequestType.GET,
'/client/unstable/org.matrix.msc4143/rtc/transports',
);
foci = response['rtc_transports'] as List?;
} catch (_) {
try {
final wellKnown = await http.get(
client.homeserver!.resolve('/.well-known/matrix/client'),
);
final json = jsonDecode(wellKnown.body);
foci = json is Map
? json['org.matrix.msc4143.rtc_foci'] as List?
: null;
} catch (_) {}
}
final focus = foci
?.whereType<Map>()
.where(
(f) => f['type'] == 'livekit' && f['livekit_service_url'] is String,
)
.firstOrNull;
if (focus == null) {
throw const CallException("This server doesn't offer calls.");
}
return {
'type': 'livekit',
'livekit_service_url': focus['livekit_service_url'],
'livekit_alias': roomId,
};
}
/// A LiveKit URL and token from the JWT service, for an OpenID token.
Future<(String, String)> _livekitToken(
Map<String, Object?> transport,
String roomId,
) async {
final openid = await client.requestOpenIdToken(client.userID!, {});
final service = (transport['livekit_service_url'] as String).replaceFirst(
RegExp(r'/$'),
'',
);
final response = await http.post(
Uri.parse('$service/sfu/get'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'room': roomId,
'openid_token': openid.toJson(),
'device_id': _deviceId,
}),
);
if (response.statusCode != 200) {
throw CallException(
'The call service refused to let you in (${response.statusCode}).',
);
}
final body = jsonDecode(response.body);
if (body is! Map || body['url'] is! String || body['jwt'] is! String) {
throw const CallException('The call service gave an unusable answer.');
}
return (body['url'] as String, body['jwt'] as String);
}
/// Per-participant keys, no ratcheting, keys derived with HKDF: the
/// settings of the web client and Element Call, whose livekit-client
/// imports keys as HKDF material.
Future<lk.BaseKeyProvider> _makeKeyProvider() async {
final options = rtc.KeyProviderOptions(
sharedKey: false,
ratchetSalt: Uint8List.fromList(utf8.encode('LKFrameEncryptionKey')),
ratchetWindowSize: 0,
uncryptedMagicBytes: Uint8List.fromList(utf8.encode('LK-ROCKS')),
failureTolerance: -1,
keyRingSize: 256,
keyDerivationAlgorithm: rtc.KeyDerivationAlgorithm.kHKDF,
);
final provider = await frameCryptorFactory.createDefaultKeyProvider(
options,
);
return lk.BaseKeyProvider(provider, options);
}
// --- Media keys -----------------------------------------------------------
Uint8List _randomKey() {
final random = Random.secure();
return Uint8List.fromList(List.generate(16, (_) => random.nextInt(256)));
}
/// Sends our media key to every device in the call that hasn't had it.
Future<void> _shareKeyWithNewDevices(
Room room,
List<CallMember> members,
) async {
final key = _key;
if (key == null) return;
final targets = members
.where(
(m) =>
!(m.userId == client.userID && m.deviceId == _deviceId) &&
!_keySentTo.contains('${m.userId}:${m.deviceId}'),
)
.toList();
if (targets.isEmpty) return;
for (final m in targets) {
_keySentTo.add('${m.userId}:${m.deviceId}');
}
try {
final users = targets.map((m) => m.userId).toSet();
if (users.any((u) => client.userDeviceKeys[u] == null)) {
await client.updateUserDeviceKeys(additionalUsers: users);
}
final devices = [
for (final m in targets)
?client.userDeviceKeys[m.userId]?.deviceKeys[m.deviceId],
];
await client.sendToDeviceEncrypted(devices, keysType, {
'keys': {'index': _keyIndex, 'key': base64Encode(key)},
'room_id': room.id,
'member': {'claimed_device_id': _deviceId, 'id': _identity},
'session': {'call_id': '', 'application': 'm.call', 'scope': 'm.room'},
'sent_ts': DateTime.now().millisecondsSinceEpoch,
});
} catch (e) {
Logs().w('Could not share the call key', e);
for (final m in targets) {
_keySentTo.remove('${m.userId}:${m.deviceId}');
}
}
}
/// Someone's media key, over an encrypted to-device message.
void _onToDevice(ToDeviceEvent event) {
if (event.type != keysType) return;
// Keys sent in the clear are refused, as the web client refuses them.
if (event.encryptedContent == null) return;
final content = event.content;
final keys = content.tryGetMap<String, Object?>('keys');
final member = content.tryGetMap<String, Object?>('member');
final key = keys?.tryGet<String>('key');
final index = keys?.tryGet<int>('index');
final device = member?.tryGet<String>('claimed_device_id');
if (key == null || index == null || device == null) return;
final identity = '${event.senderId}:$device';
Uint8List bytes;
try {
bytes = base64Decode(base64.normalize(key));
} catch (_) {
return;
}
final provider = _keyProvider;
if (provider == null || content['room_id'] != roomId) {
// Keys can come before we have joined: keep them for then.
_pendingKeys[identity] = (bytes, index);
return;
}
provider.setRawKey(bytes, participantId: identity, keyIndex: index);
}
// --- Controls ---------------------------------------------------------------
bool get micOn => livekit?.localParticipant?.isMicrophoneEnabled() ?? false;
bool get cameraOn => livekit?.localParticipant?.isCameraEnabled() ?? false;
Future<void> toggleMic() async {
final p = livekit?.localParticipant;
if (p == null) return;
await p.setMicrophoneEnabled(!p.isMicrophoneEnabled());
notifyListeners();
}
Future<void> toggleCamera() async {
final p = livekit?.localParticipant;
if (p == null) return;
await p.setCameraEnabled(!p.isCameraEnabled());
notifyListeners();
}
/// Switches between the front and the back camera.
Future<void> flipCamera() async {
final track = livekit?.localParticipant?.videoTrackPublications
.where((t) => t.source == lk.TrackSource.camera)
.firstOrNull
?.track;
if (track is! lk.LocalVideoTrack) return;
final options = track.currentOptions;
if (options is! lk.CameraCaptureOptions) return;
await track.setCameraPosition(options.cameraPosition.switched());
notifyListeners();
}
@override
void dispose() {
for (final s in _subs) {
s.cancel();
}
_expiryTimer?.cancel();
leave();
errors.close();
super.dispose();
}
}
enum CallPhase { joining, connected, reconnecting }
class CallException implements Exception {
const CallException(this.message);
final String message;
@override
String toString() => message;
}
/// A call ringing for us.
class IncomingCall {
const IncomingCall({
required this.roomId,
required this.eventId,
required this.callerId,
required this.callerName,
required this.callerAvatar,
required this.roomName,
required this.video,
required this.expiresAt,
});
final String roomId;
/// The notification event, which a decline refers to.
final String eventId;
final String callerId;
final String callerName;
final Uri? callerAvatar;
final String roomName;
final bool video;
final DateTime expiresAt;
}
/// One device's membership of a room's call.
class CallMember {
CallMember({
required this.userId,
required this.deviceId,
required this.createdAt,
required this.expiresAt,
required this.transport,
});
final String userId;
final String deviceId;
final DateTime createdAt;
final DateTime expiresAt;
/// The LiveKit transport it publishes to.
final Map<String, Object?>? transport;
/// Reads a call member state event; null for an empty one (a leave) or
/// one that isn't a room call.
static CallMember? fromState(StrippedStateEvent state) {
final content = state.content;
if (content.isEmpty) return null;
if (content['application'] != 'm.call') return null;
final deviceId = content.tryGet<String>('device_id');
if (deviceId == null) return null;
final origin = state is Event
? state.originServerTs
: DateTime.fromMillisecondsSinceEpoch(0);
final created = content.tryGet<int>('created_ts');
final createdAt = created == null
? origin
: DateTime.fromMillisecondsSinceEpoch(created);
final expires = content.tryGet<int>('expires') ?? 4 * 60 * 60 * 1000;
final foci = content.tryGetList<Object?>('foci_preferred');
final transport = foci
?.whereType<Map>()
.where(
(f) => f['type'] == 'livekit' && f['livekit_service_url'] is String,
)
.map((f) => Map<String, Object?>.from(f))
.firstOrNull;
return CallMember(
userId: state.senderId,
deviceId: deviceId,
createdAt: createdAt,
expiresAt: createdAt.add(Duration(milliseconds: expires)),
transport: transport,
);
}
}