Files
tajniak81andClaude Opus 5.5 b6e82c3633 Mobile: a call shows as a call in the chat list, not "Unknown event"
The chat list and message notifications now say "Video call" or
"Voice call" for a call's ring (org.matrix.msc4075.rtc.notification)
and "Call declined" for its decline, as the web client does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:33:05 +01:00

1045 lines
33 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
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 'call_service.dart';
import 'notifications.dart';
import 'package:webrtc_interface/webrtc_interface.dart' as rtc;
import 'package:flutter_webrtc/flutter_webrtc.dart'
show Helper, 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`).
///
/// While this device is in a call, a foreground service on Android
/// ([CallService]) keeps it going with the app in the background, and lets
/// it share the screen.
///
/// 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));
CallService.listen(onHangUp: leave, onOpen: () => _openRequests.add(null));
_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';
/// What the room list and a message notification say for a call's ring
/// or its decline, which the SDK only knows as "Unknown event"; null for
/// any other event.
static String? summary(Event event, {required bool withSender}) {
final String text;
if (event.type == notificationType) {
text = event.content['m.call.intent'] == 'audio'
? 'Voice call'
: 'Video call';
} else if (event.type == declineType) {
text = 'Call declined';
} else {
return null;
}
if (!withSender) return text;
return '${event.senderFromMemoryOrFallback.calcDisplayname()}: $text';
}
/// 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();
final _openRequests = StreamController<void>.broadcast();
/// The ongoing call notification was tapped: show the call.
Stream<void> get openRequests => _openRequests.stream;
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);
final people = members.map((m) => m.userId).toSet().length;
// Someone came: stop ringing them.
if (ringingOut && people > 1) {
ringingOut = false;
changed = true;
}
if (encrypted) _shareKeyWithNewDevices(room, members);
// Everyone else left: there's no one to talk to, so hang up too.
if (people > 1) {
_hadCompany = true;
} else if (_hadCompany) {
errors.add('The call ended.');
leave();
return;
}
}
}
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;
/// Someone else has been in our call since we joined.
bool _hadCompany = false;
String? _delayId;
Timer? _delayTimer;
Timer? _ringOutTimer;
lk.BaseKeyProvider? _keyProvider;
lk.EventsListener<lk.RoomEvent>? _lkListener;
/// Our media key, its index, and who has it: the membership of each
/// device we sent it to, by when it was made, so a device that leaves and
/// comes back gets it again.
Uint8List? _key;
final _keyIndex = 0;
final _keySentTo = <String, DateTime>{};
/// Remote participants whose media key has arrived.
final _keyed = <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;
_hadCompany = false;
_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");
}
}
// Now that the microphone and camera are asked for, the service can
// declare them.
_mediaStarted = true;
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;
_hadCompany = false;
_mediaStarted = false;
_sharingScreen = false;
_ringOutTimer?.cancel();
_delayTimer?.cancel();
_keyProvider = null;
_key = null;
_keySentTo.clear();
_keyed.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['${m.userId}:${m.deviceId}'] != m.createdAt,
)
.toList();
if (targets.isEmpty) return;
for (final m in targets) {
_keySentTo['${m.userId}:${m.deviceId}'] = m.createdAt;
}
try {
DeviceKeys? known(CallMember m) =>
client.userDeviceKeys[m.userId]?.deviceKeys[m.deviceId];
// A device signed in since we last looked isn't in our list yet:
// without asking, it would never get the key, and hear and see
// nothing of us.
final unknown = targets.where((m) => known(m) == null);
if (unknown.isNotEmpty) {
await client.updateUserDeviceKeys(
additionalUsers: unknown.map((m) => m.userId).toSet(),
);
}
final devices = <DeviceKeys>[];
for (final m in targets) {
final d = known(m);
if (d != null) {
devices.add(d);
} else {
// Try again on the next sync.
_keySentTo.remove('${m.userId}:${m.deviceId}');
Logs().w('No keys for ${m.userId}:${m.deviceId}; the call key waits');
}
}
if (devices.isEmpty) return;
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;
}
_setKey(provider, identity, bytes, index);
}
Future<void> _setKey(
lk.BaseKeyProvider provider,
String identity,
Uint8List key,
int index,
) async {
await provider.setRawKey(key, participantId: identity, keyIndex: index);
if (_keyProvider != provider || !_keyed.add(identity)) return;
// Video that came before its key was dropped, keyframe and all, and
// the decoder waits for another that may never come: black, while the
// audio plays on. Subscribing again makes the server ask for one.
final p = livekit?.getParticipantByIdentity(identity);
if (p is! lk.RemoteParticipant) return;
for (final pub in p.videoTrackPublications) {
if (!pub.subscribed) continue;
await pub.unsubscribe();
await pub.subscribe();
}
}
// --- Controls ---------------------------------------------------------------
bool get micOn => livekit?.localParticipant?.isMicrophoneEnabled() ?? false;
bool get cameraOn => livekit?.localParticipant?.isCameraEnabled() ?? false;
bool get screenSharing =>
livekit?.localParticipant?.isScreenShareEnabled() ?? false;
/// Screen sharing: Android only (iOS needs a broadcast extension).
bool get canShareScreen => Platform.isAndroid;
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();
}
/// Starts or stops sharing the screen. Android asks the user first, and
/// the call service must declare media projection before the capture
/// starts.
Future<void> toggleScreenShare() async {
final p = livekit?.localParticipant;
if (p == null) return;
if (p.isScreenShareEnabled()) {
await _stopScreenShare();
return;
}
if (!canShareScreen) return;
try {
if (!await Helper.requestCapturePermission()) return;
_sharingScreen = true;
if (!await _syncService()) {
_sharingScreen = false;
errors.add("Android didn't let the app share the screen.");
return;
}
final pub = await p.setScreenShareEnabled(true);
// Stopped from Android's own controls: unpublish it too. LiveKit
// listens to the track's end as well, so its handler is kept.
final track = pub?.track?.mediaStreamTrack;
if (track != null) {
final previous = track.onEnded;
track.onEnded = () {
previous?.call();
if (_sharingScreen) _stopScreenShare();
};
}
} catch (e) {
_sharingScreen = false;
errors.add("Can't share the screen: $e");
}
notifyListeners();
}
Future<void> _stopScreenShare() async {
_sharingScreen = false;
try {
await livekit?.localParticipant?.setScreenShareEnabled(false);
} catch (e) {
Logs().w('Could not stop sharing the screen', e);
}
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();
}
// --- The call service ------------------------------------------------------
/// The microphone and camera have been asked for.
bool _mediaStarted = false;
/// Screen sharing is on, or about to be.
bool _sharingScreen = false;
/// What the service was last told, so it is told only of changes.
String? _serviceState;
@override
void notifyListeners() {
super.notifyListeners();
_syncService();
}
/// Starts, updates or stops the call service to match the call.
Future<bool> _syncService() async {
final rid = roomId;
final since = joinedAt;
if (rid == null || since == null || !_mediaStarted) {
if (_serviceState != null) {
_serviceState = null;
await CallService.stop();
}
return false;
}
final title = client.getRoomById(rid)?.getLocalizedDisplayname() ?? 'Call';
final camera = cameraOn;
final screen = _sharingScreen || screenSharing;
final state = '$rid|$title|$camera|$screen';
if (state == _serviceState) return true;
_serviceState = state;
final ok = await CallService.update(
title: title,
startedAt: since,
camera: camera,
screen: screen,
);
if (!ok) _serviceState = null;
return ok;
}
@override
void dispose() {
for (final s in _subs) {
s.cancel();
}
_expiryTimer?.cancel();
leave();
CallService.stop();
errors.close();
_openRequests.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,
);
}
}