Merge pull request #2130 from famedly/td/delayedEvents
feat: (BREAKING CHANGE) delayed and device owned state events support for group calls
This commit is contained in:
commit
f815665803
|
|
@ -37,7 +37,7 @@ jobs:
|
|||
|
||||
coverage_without_olm:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
NO_OLM: 1
|
||||
steps:
|
||||
|
|
@ -60,7 +60,7 @@ jobs:
|
|||
coverage:
|
||||
#runs-on: arm-ubuntu-latest-16core
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cat .github/workflows/versions.env >> $GITHUB_ENV
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export 'src/voip/models/webrtc_delegate.dart';
|
|||
export 'src/voip/models/call_participant.dart';
|
||||
export 'src/voip/models/key_provider.dart';
|
||||
export 'src/voip/models/matrixrtc_call_event.dart';
|
||||
export 'src/voip/models/call_membership.dart';
|
||||
export 'src/voip/utils/conn_tester.dart';
|
||||
export 'src/voip/utils/voip_constants.dart';
|
||||
export 'src/voip/utils/rtc_candidate_extension.dart';
|
||||
|
|
@ -80,6 +81,7 @@ export 'msc_extensions/msc_1236_widgets/msc_1236_widgets.dart';
|
|||
export 'msc_extensions/msc_2835_uia_login/msc_2835_uia_login.dart';
|
||||
export 'msc_extensions/msc_3814_dehydrated_devices/msc_3814_dehydrated_devices.dart';
|
||||
export 'msc_extensions/extension_timeline_export/timeline_export.dart';
|
||||
export 'msc_extensions/msc_4140_delayed_events/api.dart';
|
||||
|
||||
export 'src/utils/web_worker/web_worker_stub.dart'
|
||||
if (dart.library.html) 'src/utils/web_worker/web_worker.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
export 'msc_4140_delayed_events.dart';
|
||||
export 'models.dart';
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
class ScheduledDelayedEventsResponse {
|
||||
final List<ScheduledDelayedEvent> scheduledEvents;
|
||||
final String? nextBatch;
|
||||
|
||||
ScheduledDelayedEventsResponse({
|
||||
required this.scheduledEvents,
|
||||
this.nextBatch,
|
||||
});
|
||||
|
||||
factory ScheduledDelayedEventsResponse.fromJson(Map<String, dynamic> json) {
|
||||
final list = json['delayed_events'] ?? json['scheduled'] as List;
|
||||
final List<ScheduledDelayedEvent> scheduledEvents =
|
||||
list.map((e) => ScheduledDelayedEvent.fromJson(e)).toList();
|
||||
|
||||
return ScheduledDelayedEventsResponse(
|
||||
scheduledEvents: scheduledEvents,
|
||||
nextBatch: json['next_batch'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScheduledDelayedEvent {
|
||||
final String delayId;
|
||||
final String roomId;
|
||||
final String type;
|
||||
final String? stateKey;
|
||||
final int delay;
|
||||
final int runningSince;
|
||||
final Map<String, Object?> content;
|
||||
|
||||
ScheduledDelayedEvent({
|
||||
required this.delayId,
|
||||
required this.roomId,
|
||||
required this.type,
|
||||
this.stateKey,
|
||||
required this.delay,
|
||||
required this.runningSince,
|
||||
required this.content,
|
||||
});
|
||||
|
||||
factory ScheduledDelayedEvent.fromJson(Map<String, dynamic> json) {
|
||||
return ScheduledDelayedEvent(
|
||||
delayId: json['delay_id'] as String,
|
||||
roomId: json['room_id'] as String,
|
||||
type: json['type'] as String,
|
||||
stateKey: json['state_key'] as String?,
|
||||
delay: json['delay'] as int,
|
||||
runningSince: json['running_since'] as int,
|
||||
content: json['content'] as Map<String, Object?>,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'delay_id': delayId,
|
||||
'room_id': roomId,
|
||||
'type': type,
|
||||
'state_key': stateKey,
|
||||
'delay': delay,
|
||||
'running_since': runningSince,
|
||||
'content': content,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
enum DelayedEventAction { send, cancel, restart }
|
||||
|
||||
extension DelayedEventsHandler on Client {
|
||||
static const _delayedEventsEndpoint =
|
||||
'_matrix/client/unstable/org.matrix.msc4140/delayed_events';
|
||||
|
||||
/// State events can be sent using this endpoint. These events will be
|
||||
/// overwritten if `<room id>`, `<event type>` and `<state key>` all
|
||||
/// match.
|
||||
///
|
||||
/// Requests to this endpoint **cannot use transaction IDs**
|
||||
/// like other `PUT` paths because they cannot be differentiated from the
|
||||
/// `state_key`. Furthermore, `POST` is unsupported on state paths.
|
||||
///
|
||||
/// The body of the request should be the content object of the event; the
|
||||
/// fields in this object will vary depending on the type of event. See
|
||||
/// [Room Events](https://spec.matrix.org/unstable/client-server-api/#room-events) for the `m.` event specification.
|
||||
///
|
||||
/// If the event type being sent is `m.room.canonical_alias` servers
|
||||
/// SHOULD ensure that any new aliases being listed in the event are valid
|
||||
/// per their grammar/syntax and that they point to the room ID where the
|
||||
/// state event is to be sent. Servers do not validate aliases which are
|
||||
/// being removed or are already present in the state event.
|
||||
///
|
||||
///
|
||||
/// [roomId] The room to set the state in
|
||||
///
|
||||
/// [eventType] The type of event to send.
|
||||
///
|
||||
/// [stateKey] The state_key for the state to send. Defaults to the empty string. When
|
||||
/// an empty string, the trailing slash on this endpoint is optional.
|
||||
///
|
||||
/// [delayInMs] Optional number of milliseconds the homeserver should wait before sending the event.
|
||||
/// If no delay is provided, the event is sent immediately as normal.
|
||||
///
|
||||
/// [body]
|
||||
///
|
||||
/// returns `event_id`:
|
||||
/// A unique identifier for the event.
|
||||
/// If a delay is provided, the homeserver schedules the event to be sent with the specified delay
|
||||
/// and responds with an opaque delay_id field (omitting the event_id as it is not available)
|
||||
Future<String> setRoomStateWithKeyWithDelay(
|
||||
String roomId,
|
||||
String eventType,
|
||||
String stateKey,
|
||||
int? delayInMs,
|
||||
Map<String, Object?> body,
|
||||
) async {
|
||||
final requestUri = Uri(
|
||||
path:
|
||||
'_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/state/${Uri.encodeComponent(eventType)}/${Uri.encodeComponent(stateKey)}',
|
||||
queryParameters: {
|
||||
if (delayInMs != null) 'org.matrix.msc4140.delay': delayInMs.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
final request = http.Request('PUT', baseUri!.resolveUri(requestUri));
|
||||
request.headers['authorization'] = 'Bearer ${bearerToken!}';
|
||||
request.headers['content-type'] = 'application/json';
|
||||
request.bodyBytes = utf8.encode(jsonEncode(body));
|
||||
final response = await httpClient.send(request);
|
||||
final responseBody = await response.stream.toBytes();
|
||||
if (response.statusCode != 200) unexpectedResponse(response, responseBody);
|
||||
final responseString = utf8.decode(responseBody);
|
||||
final json = jsonDecode(responseString);
|
||||
return json['event_id'] ?? json['delay_id'] as String;
|
||||
}
|
||||
|
||||
Future<void> manageDelayedEvent(
|
||||
String delayedId,
|
||||
DelayedEventAction delayedEventAction,
|
||||
) async {
|
||||
final requestUri = Uri(
|
||||
path: '$_delayedEventsEndpoint/$delayedId',
|
||||
);
|
||||
|
||||
final request = http.Request('POST', baseUri!.resolveUri(requestUri));
|
||||
request.headers['authorization'] = 'Bearer ${bearerToken!}';
|
||||
request.headers['content-type'] = 'application/json';
|
||||
request.bodyBytes = utf8.encode(
|
||||
jsonEncode({
|
||||
'action': delayedEventAction.name,
|
||||
}),
|
||||
);
|
||||
final response = await httpClient.send(request);
|
||||
final responseBody = await response.stream.toBytes();
|
||||
if (response.statusCode != 200) unexpectedResponse(response, responseBody);
|
||||
}
|
||||
|
||||
// This should use the /delayed_events/scheduled endpoint
|
||||
// but synapse implementation uses the /delayed_events
|
||||
Future<ScheduledDelayedEventsResponse> getScheduledDelayedEvents({
|
||||
String? from,
|
||||
}) async {
|
||||
final requestUri = Uri(
|
||||
path: _delayedEventsEndpoint,
|
||||
queryParameters: {if (from != null) 'from': from},
|
||||
);
|
||||
|
||||
final request = http.Request('GET', baseUri!.resolveUri(requestUri));
|
||||
request.headers['authorization'] = 'Bearer ${bearerToken!}';
|
||||
request.headers['content-type'] = 'application/json';
|
||||
final response = await httpClient.send(request);
|
||||
final responseBody = await response.stream.toBytes();
|
||||
if (response.statusCode != 200) {
|
||||
return await _getScheduledDelayedEventsAccordingToSpec(from: from);
|
||||
}
|
||||
final responseString = utf8.decode(responseBody);
|
||||
final json = jsonDecode(responseString);
|
||||
final res = ScheduledDelayedEventsResponse.fromJson(json);
|
||||
return res;
|
||||
}
|
||||
|
||||
// maybe the synapse impl changes, I don't want stuff to break
|
||||
Future<ScheduledDelayedEventsResponse>
|
||||
_getScheduledDelayedEventsAccordingToSpec({
|
||||
String? from,
|
||||
}) async {
|
||||
final requestUri = Uri(
|
||||
path: '$_delayedEventsEndpoint/scheduled',
|
||||
queryParameters: {if (from != null) 'from': from},
|
||||
);
|
||||
|
||||
final request = http.Request('GET', baseUri!.resolveUri(requestUri));
|
||||
request.headers['authorization'] = 'Bearer ${bearerToken!}';
|
||||
request.headers['content-type'] = 'application/json';
|
||||
final response = await httpClient.send(request);
|
||||
final responseBody = await response.stream.toBytes();
|
||||
if (response.statusCode != 200) unexpectedResponse(response, responseBody);
|
||||
final responseString = utf8.decode(responseBody);
|
||||
final json = jsonDecode(responseString);
|
||||
final res = ScheduledDelayedEventsResponse.fromJson(json);
|
||||
return res;
|
||||
}
|
||||
|
||||
/// TODO: implement the remaining APIs
|
||||
/// GET /_matrix/client/unstable/org.matrix.msc4140/delayed_events/finalised
|
||||
}
|
||||
|
|
@ -1265,12 +1265,16 @@ class Client extends MatrixApi {
|
|||
final _versionsCache =
|
||||
AsyncCache<GetVersionsResponse>(const Duration(hours: 1));
|
||||
|
||||
Future<GetVersionsResponse> get versionsResponse =>
|
||||
_versionsCache.tryFetch(() => getVersions());
|
||||
|
||||
Future<bool> authenticatedMediaSupported() async {
|
||||
final versionsResponse = await _versionsCache.tryFetch(() => getVersions());
|
||||
return versionsResponse.versions.any(
|
||||
(v) => isVersionGreaterThanOrEqualTo(v, 'v1.11'),
|
||||
) ||
|
||||
versionsResponse.unstableFeatures?['org.matrix.msc3916.stable'] == true;
|
||||
return (await versionsResponse).versions.any(
|
||||
(v) => isVersionGreaterThanOrEqualTo(v, 'v1.11'),
|
||||
) ||
|
||||
(await versionsResponse)
|
||||
.unstableFeatures?['org.matrix.msc3916.stable'] ==
|
||||
true;
|
||||
}
|
||||
|
||||
final _serverConfigCache = AsyncCache<MediaConfig>(const Duration(hours: 1));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:matrix/src/voip/models/call_membership.dart';
|
||||
|
||||
abstract class CallBackend {
|
||||
String type;
|
||||
|
|
|
|||
|
|
@ -4,22 +4,11 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:matrix/src/utils/crypto/crypto.dart';
|
||||
import 'package:matrix/src/voip/models/call_membership.dart';
|
||||
|
||||
class LiveKitBackend extends CallBackend {
|
||||
final String livekitServiceUrl;
|
||||
final String livekitAlias;
|
||||
|
||||
/// A delay after a member leaves before we create and publish a new key, because people
|
||||
/// tend to leave calls at the same time
|
||||
final Duration makeKeyDelay;
|
||||
|
||||
/// The delay between creating and sending a new key and starting to encrypt with it. This gives others
|
||||
/// a chance to receive the new key to minimise the chance they don't get media they can't decrypt.
|
||||
/// The total time between a member leaving and the call switching to new keys is therefore
|
||||
/// makeKeyDelay + useKeyDelay
|
||||
final Duration useKeyDelay;
|
||||
|
||||
@override
|
||||
final bool e2eeEnabled;
|
||||
|
||||
|
|
@ -28,8 +17,6 @@ class LiveKitBackend extends CallBackend {
|
|||
required this.livekitAlias,
|
||||
super.type = 'livekit',
|
||||
this.e2eeEnabled = true,
|
||||
this.makeKeyDelay = CallTimeouts.makeKeyDelay,
|
||||
this.useKeyDelay = CallTimeouts.useKeyDelay,
|
||||
});
|
||||
|
||||
Timer? _memberLeaveEncKeyRotateDebounceTimer;
|
||||
|
|
@ -44,9 +31,14 @@ class LiveKitBackend extends CallBackend {
|
|||
/// used to send the key again incase someone `onCallEncryptionKeyRequest` but don't just send
|
||||
/// the last one because you also cycle back in your window which means you
|
||||
/// could potentially end up sharing a past key
|
||||
/// we don't really care about what if setting or sending fails right now
|
||||
int get latestLocalKeyIndex => _latestLocalKeyIndex;
|
||||
int _latestLocalKeyIndex = 0;
|
||||
|
||||
/// stores when the last new key was made (makeNewSenderKey), is not used
|
||||
/// for ratcheted keys at the moment
|
||||
DateTime _lastNewKeyTime = DateTime(1900);
|
||||
|
||||
/// the key currently being used by the local cryptor, can possibly not be the latest
|
||||
/// key, check `latestLocalKeyIndex` for latest key
|
||||
int get currentLocalKeyIndex => _currentLocalKeyIndex;
|
||||
|
|
@ -58,8 +50,8 @@ class LiveKitBackend extends CallBackend {
|
|||
|
||||
/// always chooses the next possible index, we cycle after 16 because
|
||||
/// no real adv with infinite list
|
||||
int _getNewEncryptionKeyIndex() {
|
||||
final newIndex = _indexCounter % 16;
|
||||
int _getNewEncryptionKeyIndex(int keyRingSize) {
|
||||
final newIndex = _indexCounter % keyRingSize;
|
||||
_indexCounter++;
|
||||
return newIndex;
|
||||
}
|
||||
|
|
@ -76,10 +68,26 @@ class LiveKitBackend extends CallBackend {
|
|||
/// also does the sending for you
|
||||
Future<void> _makeNewSenderKey(
|
||||
GroupCallSession groupCall,
|
||||
bool delayBeforeUsingKeyOurself,
|
||||
) async {
|
||||
bool delayBeforeUsingKeyOurself, {
|
||||
bool skipJoinDebounce = false,
|
||||
}) async {
|
||||
if (_lastNewKeyTime
|
||||
.add(groupCall.voip.timeouts!.makeKeyOnJoinDelay)
|
||||
.isAfter(DateTime.now()) &&
|
||||
!skipJoinDebounce) {
|
||||
Logs().d(
|
||||
'_makeNewSenderKey using previous key because last created at ${_lastNewKeyTime.toString()}',
|
||||
);
|
||||
// still a fairly new key, just send that
|
||||
await _sendEncryptionKeysEvent(
|
||||
groupCall,
|
||||
_latestLocalKeyIndex,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final key = secureRandomBytes(32);
|
||||
final keyIndex = _getNewEncryptionKeyIndex();
|
||||
final keyIndex = _getNewEncryptionKeyIndex(groupCall.voip.keyRingSize);
|
||||
Logs().i('[VOIP E2EE] Generated new key $key at index $keyIndex');
|
||||
|
||||
await _setEncryptionKey(
|
||||
|
|
@ -96,6 +104,9 @@ class LiveKitBackend extends CallBackend {
|
|||
Future<void> _ratchetLocalParticipantKey(
|
||||
GroupCallSession groupCall,
|
||||
List<CallParticipant> sendTo,
|
||||
|
||||
/// only used for makeSenderKey fallback
|
||||
bool delayBeforeUsingKeyOurself,
|
||||
) async {
|
||||
final keyProvider = groupCall.voip.delegate.keyProvider;
|
||||
|
||||
|
|
@ -114,17 +125,28 @@ class LiveKitBackend extends CallBackend {
|
|||
|
||||
Uint8List? ratchetedKey;
|
||||
|
||||
while (ratchetedKey == null || ratchetedKey.isEmpty) {
|
||||
Logs().i('[VOIP E2EE] Ignoring empty ratcheted key');
|
||||
int ratchetTryCounter = 0;
|
||||
|
||||
while (ratchetTryCounter <= 8 &&
|
||||
(ratchetedKey == null || ratchetedKey.isEmpty)) {
|
||||
Logs().d(
|
||||
'[VOIP E2EE] Ignoring empty ratcheted key, ratchetTryCounter: $ratchetTryCounter',
|
||||
);
|
||||
|
||||
ratchetedKey = await keyProvider.onRatchetKey(
|
||||
groupCall.localParticipant!,
|
||||
latestLocalKeyIndex,
|
||||
);
|
||||
ratchetTryCounter++;
|
||||
}
|
||||
|
||||
Logs().i(
|
||||
'[VOIP E2EE] Ratched latest key to $ratchetedKey at idx $latestLocalKeyIndex',
|
||||
);
|
||||
if (ratchetedKey == null || ratchetedKey.isEmpty) {
|
||||
Logs().d(
|
||||
'[VOIP E2EE] ratcheting failed, falling back to creating a new key',
|
||||
);
|
||||
await _makeNewSenderKey(groupCall, delayBeforeUsingKeyOurself);
|
||||
return;
|
||||
}
|
||||
|
||||
await _setEncryptionKey(
|
||||
groupCall,
|
||||
|
|
@ -133,6 +155,7 @@ class LiveKitBackend extends CallBackend {
|
|||
ratchetedKey,
|
||||
delayBeforeUsingKeyOurself: false,
|
||||
send: true,
|
||||
setKey: false,
|
||||
sendTo: sendTo,
|
||||
);
|
||||
}
|
||||
|
|
@ -144,7 +167,11 @@ class LiveKitBackend extends CallBackend {
|
|||
) async {
|
||||
if (!e2eeEnabled) return;
|
||||
if (groupCall.voip.enableSFUE2EEKeyRatcheting) {
|
||||
await _ratchetLocalParticipantKey(groupCall, anyJoined);
|
||||
await _ratchetLocalParticipantKey(
|
||||
groupCall,
|
||||
anyJoined,
|
||||
delayBeforeUsingKeyOurself,
|
||||
);
|
||||
} else {
|
||||
await _makeNewSenderKey(groupCall, delayBeforeUsingKeyOurself);
|
||||
}
|
||||
|
|
@ -159,6 +186,9 @@ class LiveKitBackend extends CallBackend {
|
|||
Uint8List encryptionKeyBin, {
|
||||
bool delayBeforeUsingKeyOurself = false,
|
||||
bool send = false,
|
||||
|
||||
/// ratchet seems to set on call, so no need to set manually
|
||||
bool setKey = true,
|
||||
List<CallParticipant>? sendTo,
|
||||
}) async {
|
||||
final encryptionKeys =
|
||||
|
|
@ -168,6 +198,7 @@ class LiveKitBackend extends CallBackend {
|
|||
_encryptionKeysMap[participant] = encryptionKeys;
|
||||
if (participant.isLocal) {
|
||||
_latestLocalKeyIndex = encryptionKeyIndex;
|
||||
_lastNewKeyTime = DateTime.now();
|
||||
}
|
||||
|
||||
if (send) {
|
||||
|
|
@ -178,12 +209,23 @@ class LiveKitBackend extends CallBackend {
|
|||
);
|
||||
}
|
||||
|
||||
if (!setKey) {
|
||||
Logs().i(
|
||||
'[VOIP E2EE] sent ratchetd key $encryptionKeyBin but not setting',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (delayBeforeUsingKeyOurself) {
|
||||
Logs().d(
|
||||
'[VOIP E2EE] starting delayed set for ${participant.id} idx $encryptionKeyIndex key $encryptionKeyBin, current idx $currentLocalKeyIndex key ${encryptionKeys[currentLocalKeyIndex]}',
|
||||
);
|
||||
// now wait for the key to propogate and then set it, hopefully users can
|
||||
// stil decrypt everything
|
||||
final useKeyTimeout = Future.delayed(useKeyDelay, () async {
|
||||
final useKeyTimeout =
|
||||
Future.delayed(groupCall.voip.timeouts!.useKeyDelay, () async {
|
||||
Logs().i(
|
||||
'[VOIP E2EE] setting key changed event for ${participant.id} idx $encryptionKeyIndex key $encryptionKeyBin',
|
||||
'[VOIP E2EE] delayed setting key changed event for ${participant.id} idx $encryptionKeyIndex key $encryptionKeyBin',
|
||||
);
|
||||
await groupCall.voip.delegate.keyProvider?.onSetEncryptionKey(
|
||||
participant,
|
||||
|
|
@ -257,7 +299,7 @@ class LiveKitBackend extends CallBackend {
|
|||
EventTypes.GroupCallMemberEncryptionKeys,
|
||||
);
|
||||
} catch (e, s) {
|
||||
Logs().e('[VOIP] Failed to send e2ee keys, retrying', e, s);
|
||||
Logs().e('[VOIP E2EE] Failed to send e2ee keys, retrying', e, s);
|
||||
await _sendEncryptionKeysEvent(
|
||||
groupCall,
|
||||
keyIndex,
|
||||
|
|
@ -274,7 +316,7 @@ class LiveKitBackend extends CallBackend {
|
|||
) async {
|
||||
if (remoteParticipants.isEmpty) return;
|
||||
Logs().v(
|
||||
'[VOIP] _sendToDeviceEvent: sending ${data.toString()} to ${remoteParticipants.map((e) => e.id)} ',
|
||||
'[VOIP E2EE] _sendToDeviceEvent: sending ${data.toString()} to ${remoteParticipants.map((e) => e.id)} ',
|
||||
);
|
||||
final txid =
|
||||
VoIP.customTxid ?? groupCall.client.generateUniqueTransactionId();
|
||||
|
|
@ -355,7 +397,7 @@ class LiveKitBackend extends CallBackend {
|
|||
Map<String, dynamic> content,
|
||||
) async {
|
||||
if (!e2eeEnabled) {
|
||||
Logs().w('[VOIP] got sframe key but we do not support e2ee');
|
||||
Logs().w('[VOIP E2EE] got sframe key but we do not support e2ee');
|
||||
return;
|
||||
}
|
||||
final keyContent = EncryptionKeysEventContent.fromJson(content);
|
||||
|
|
@ -398,37 +440,65 @@ class LiveKitBackend extends CallBackend {
|
|||
Map<String, dynamic> content,
|
||||
) async {
|
||||
if (!e2eeEnabled) {
|
||||
Logs().w('[VOIP] got sframe key request but we do not support e2ee');
|
||||
Logs().w('[VOIP E2EE] got sframe key request but we do not support e2ee');
|
||||
return;
|
||||
}
|
||||
final mems = groupCall.room.getCallMembershipsForUser(userId);
|
||||
if (mems
|
||||
.where(
|
||||
(mem) =>
|
||||
mem.callId == groupCall.groupCallId &&
|
||||
mem.userId == userId &&
|
||||
mem.deviceId == deviceId &&
|
||||
!mem.isExpired &&
|
||||
// sanity checks
|
||||
mem.backend.type == groupCall.backend.type &&
|
||||
mem.roomId == groupCall.room.id &&
|
||||
mem.application == groupCall.application,
|
||||
)
|
||||
.isNotEmpty) {
|
||||
Logs().d(
|
||||
'[VOIP] onCallEncryptionKeyRequest: request checks out, sending key on index: $latestLocalKeyIndex to $userId:$deviceId',
|
||||
|
||||
Future<bool> checkPartcipantStatusAndRequestKey() async {
|
||||
final mems = groupCall.room.getCallMembershipsForUser(
|
||||
userId,
|
||||
deviceId,
|
||||
groupCall.voip,
|
||||
);
|
||||
await _sendEncryptionKeysEvent(
|
||||
groupCall,
|
||||
_latestLocalKeyIndex,
|
||||
sendTo: [
|
||||
CallParticipant(
|
||||
groupCall.voip,
|
||||
userId: userId,
|
||||
deviceId: deviceId,
|
||||
),
|
||||
],
|
||||
|
||||
if (mems
|
||||
.where(
|
||||
(mem) =>
|
||||
mem.callId == groupCall.groupCallId &&
|
||||
mem.userId == userId &&
|
||||
mem.deviceId == deviceId &&
|
||||
!mem.isExpired &&
|
||||
// sanity checks
|
||||
mem.backend.type == groupCall.backend.type &&
|
||||
mem.roomId == groupCall.room.id &&
|
||||
mem.application == groupCall.application,
|
||||
)
|
||||
.isNotEmpty) {
|
||||
Logs().d(
|
||||
'[VOIP E2EE] onCallEncryptionKeyRequest: request checks out, sending key on index: $latestLocalKeyIndex to $userId:$deviceId',
|
||||
);
|
||||
await _sendEncryptionKeysEvent(
|
||||
groupCall,
|
||||
_latestLocalKeyIndex,
|
||||
sendTo: [
|
||||
CallParticipant(
|
||||
groupCall.voip,
|
||||
userId: userId,
|
||||
deviceId: deviceId,
|
||||
),
|
||||
],
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ((!await checkPartcipantStatusAndRequestKey())) {
|
||||
Logs().i(
|
||||
'[VOIP E2EE] onCallEncryptionKeyRequest: checkPartcipantStatusAndRequestKey returned false, therefore retrying by getting state from server and rebuilding participant list for sanity',
|
||||
);
|
||||
final stateKey =
|
||||
(groupCall.room.roomVersion?.contains('msc3757') ?? false)
|
||||
? '${userId}_$deviceId'
|
||||
: userId;
|
||||
await groupCall.room.client.getRoomStateWithKey(
|
||||
groupCall.room.id,
|
||||
EventTypes.GroupCallMember,
|
||||
stateKey,
|
||||
);
|
||||
await groupCall.onMemberStateChanged();
|
||||
await checkPartcipantStatusAndRequestKey();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -450,8 +520,15 @@ class LiveKitBackend extends CallBackend {
|
|||
if (_memberLeaveEncKeyRotateDebounceTimer != null) {
|
||||
_memberLeaveEncKeyRotateDebounceTimer!.cancel();
|
||||
}
|
||||
_memberLeaveEncKeyRotateDebounceTimer = Timer(makeKeyDelay, () async {
|
||||
await _makeNewSenderKey(groupCall, true);
|
||||
_memberLeaveEncKeyRotateDebounceTimer =
|
||||
Timer(groupCall.voip.timeouts!.makeKeyOnLeaveDelay, () async {
|
||||
// we skipJoinDebounce here because we want to make sure a new key is generated
|
||||
// and that the join debounce does not block us from making a new key
|
||||
await _makeNewSenderKey(
|
||||
groupCall,
|
||||
true,
|
||||
skipJoinDebounce: true,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import 'package:webrtc_interface/webrtc_interface.dart';
|
|||
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:matrix/src/utils/cached_stream_controller.dart';
|
||||
import 'package:matrix/src/voip/models/call_membership.dart';
|
||||
import 'package:matrix/src/voip/models/call_options.dart';
|
||||
import 'package:matrix/src/voip/utils/stream_helper.dart';
|
||||
import 'package:matrix/src/voip/utils/user_media_constraints.dart';
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ class CallSession {
|
|||
|
||||
setCallState(CallState.kRinging);
|
||||
|
||||
_ringingTimer = Timer(CallTimeouts.callInviteLifetime, () {
|
||||
_ringingTimer = Timer(voip.timeouts!.callInviteLifetime, () {
|
||||
if (state == CallState.kRinging) {
|
||||
Logs().v('[VOIP] Call invite has expired. Hanging up.');
|
||||
|
||||
|
|
@ -457,7 +457,7 @@ class CallSession {
|
|||
await sendCallNegotiate(
|
||||
room,
|
||||
callId,
|
||||
CallTimeouts.defaultCallEventLifetime.inMilliseconds,
|
||||
voip.timeouts!.defaultCallEventLifetime.inMilliseconds,
|
||||
localPartyId,
|
||||
answer.sdp!,
|
||||
type: answer.type!,
|
||||
|
|
@ -1081,7 +1081,7 @@ class CallSession {
|
|||
if (pc!.iceGatheringState ==
|
||||
RTCIceGatheringState.RTCIceGatheringStateGathering) {
|
||||
// Allow a short time for initial candidates to be gathered
|
||||
await Future.delayed(CallTimeouts.iceGatheringDelay);
|
||||
await Future.delayed(voip.timeouts!.iceGatheringDelay);
|
||||
}
|
||||
|
||||
if (callHasEnded) return;
|
||||
|
|
@ -1094,7 +1094,7 @@ class CallSession {
|
|||
await sendInviteToCall(
|
||||
room,
|
||||
callId,
|
||||
CallTimeouts.callInviteLifetime.inMilliseconds,
|
||||
voip.timeouts!.callInviteLifetime.inMilliseconds,
|
||||
localPartyId,
|
||||
offer.sdp!,
|
||||
capabilities: callCapabilities,
|
||||
|
|
@ -1115,7 +1115,7 @@ class CallSession {
|
|||
|
||||
setCallState(CallState.kInviteSent);
|
||||
|
||||
_inviteTimer = Timer(CallTimeouts.callInviteLifetime, () {
|
||||
_inviteTimer = Timer(voip.timeouts!.callInviteLifetime, () {
|
||||
if (state == CallState.kInviteSent) {
|
||||
unawaited(hangup(reason: CallErrorCode.inviteTimeout));
|
||||
}
|
||||
|
|
@ -1126,7 +1126,7 @@ class CallSession {
|
|||
await sendCallNegotiate(
|
||||
room,
|
||||
callId,
|
||||
CallTimeouts.defaultCallEventLifetime.inMilliseconds,
|
||||
voip.timeouts!.defaultCallEventLifetime.inMilliseconds,
|
||||
localPartyId,
|
||||
offer.sdp!,
|
||||
type: offer.type!,
|
||||
|
|
@ -1144,7 +1144,7 @@ class CallSession {
|
|||
// onNegotiationNeeded, which causes creatOffer to only include
|
||||
// audio m-line, add delay and wait for video track to be added,
|
||||
// then createOffer can get audio/video m-line correctly.
|
||||
await Future.delayed(CallTimeouts.delayBeforeOffer);
|
||||
await Future.delayed(voip.timeouts!.delayBeforeOffer);
|
||||
final offer = await pc!.createOffer({});
|
||||
await _gotLocalOffer(offer);
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import 'dart:core';
|
|||
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:matrix/src/utils/cached_stream_controller.dart';
|
||||
import 'package:matrix/src/voip/models/call_membership.dart';
|
||||
import 'package:matrix/src/voip/models/voip_id.dart';
|
||||
import 'package:matrix/src/voip/utils/stream_helper.dart';
|
||||
|
||||
|
|
@ -162,10 +161,11 @@ class GroupCallSession {
|
|||
backend: backend,
|
||||
deviceId: client.deviceID!,
|
||||
expiresTs: DateTime.now()
|
||||
.add(CallTimeouts.expireTsBumpDuration)
|
||||
.add(voip.timeouts!.expireTsBumpDuration)
|
||||
.millisecondsSinceEpoch,
|
||||
membershipId: voip.currentSessionId,
|
||||
feeds: backend.getCurrentFeeds(),
|
||||
voip: voip,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -173,7 +173,7 @@ class GroupCallSession {
|
|||
_resendMemberStateEventTimer!.cancel();
|
||||
}
|
||||
_resendMemberStateEventTimer = Timer.periodic(
|
||||
CallTimeouts.updateExpireTsTimerDuration,
|
||||
voip.timeouts!.updateExpireTsTimerDuration,
|
||||
((timer) async {
|
||||
Logs().d('sendMemberStateEvent updating member event with timer');
|
||||
if (state != GroupCallState.ended ||
|
||||
|
|
@ -198,6 +198,7 @@ class GroupCallSession {
|
|||
return room.removeFamedlyCallMemberEvent(
|
||||
groupCallId,
|
||||
client.deviceID!,
|
||||
voip,
|
||||
application: application,
|
||||
scope: scope,
|
||||
);
|
||||
|
|
@ -206,8 +207,10 @@ class GroupCallSession {
|
|||
/// compltetely rebuilds the local _participants list
|
||||
Future<void> onMemberStateChanged() async {
|
||||
// The member events may be received for another room, which we will ignore.
|
||||
final mems =
|
||||
room.getCallMembershipsFromRoom().values.expand((element) => element);
|
||||
final mems = room
|
||||
.getCallMembershipsFromRoom(voip)
|
||||
.values
|
||||
.expand((element) => element);
|
||||
final memsForCurrentGroupCall = mems.where((element) {
|
||||
return element.callId == groupCallId &&
|
||||
!element.isExpired &&
|
||||
|
|
@ -216,15 +219,6 @@ class GroupCallSession {
|
|||
element.roomId == room.id; // sanity checks
|
||||
}).toList();
|
||||
|
||||
final ignoredMems =
|
||||
mems.where((element) => !memsForCurrentGroupCall.contains(element));
|
||||
|
||||
for (final mem in ignoredMems) {
|
||||
Logs().v(
|
||||
'[VOIP] Ignored ${mem.userId}\'s mem event ${mem.toJson()} while updating _participants list for callId: $groupCallId, expiry status: ${mem.isExpired}',
|
||||
);
|
||||
}
|
||||
|
||||
final Set<CallParticipant> newP = {};
|
||||
|
||||
for (final mem in memsForCurrentGroupCall) {
|
||||
|
|
@ -238,12 +232,7 @@ class GroupCallSession {
|
|||
|
||||
if (rp.isLocal) continue;
|
||||
|
||||
if (state != GroupCallState.entered) {
|
||||
Logs().w(
|
||||
'[VOIP] onMemberStateChanged groupCall state is currently $state, skipping member update',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (state != GroupCallState.entered) continue;
|
||||
|
||||
await backend.setupP2PCallWithNewMember(this, rp, mem);
|
||||
}
|
||||
|
|
@ -281,9 +270,6 @@ class GroupCallSession {
|
|||
}
|
||||
|
||||
onGroupCallEvent.add(GroupCallStateChange.participantsChanged);
|
||||
Logs().d(
|
||||
'[VOIP] onMemberStateChanged current list: ${_participants.map((e) => e.id).toString()}',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,14 +9,18 @@ class FamedlyCallMemberEvent {
|
|||
return {'memberships': memberships.map((e) => e.toJson()).toList()};
|
||||
}
|
||||
|
||||
factory FamedlyCallMemberEvent.fromJson(Event event) {
|
||||
factory FamedlyCallMemberEvent.fromJson(Event event, VoIP voip) {
|
||||
final List<CallMembership> callMemberships = [];
|
||||
final memberships = event.content.tryGetList('memberships');
|
||||
if (memberships != null && memberships.isNotEmpty) {
|
||||
for (final mem in memberships) {
|
||||
if (isValidMemEvent(mem)) {
|
||||
final callMem =
|
||||
CallMembership.fromJson(mem, event.senderId, event.room.id);
|
||||
final callMem = CallMembership.fromJson(
|
||||
mem,
|
||||
event.senderId,
|
||||
event.room.id,
|
||||
voip,
|
||||
);
|
||||
if (callMem != null) callMemberships.add(callMem);
|
||||
}
|
||||
}
|
||||
|
|
@ -35,7 +39,7 @@ class CallMembership {
|
|||
final int expiresTs;
|
||||
final String membershipId;
|
||||
final List? feeds;
|
||||
|
||||
final VoIP voip;
|
||||
final String roomId;
|
||||
|
||||
CallMembership({
|
||||
|
|
@ -46,6 +50,7 @@ class CallMembership {
|
|||
required this.expiresTs,
|
||||
required this.roomId,
|
||||
required this.membershipId,
|
||||
required this.voip,
|
||||
this.application = 'm.call',
|
||||
this.scope = 'm.room',
|
||||
this.feeds,
|
||||
|
|
@ -65,7 +70,12 @@ class CallMembership {
|
|||
};
|
||||
}
|
||||
|
||||
static CallMembership? fromJson(Map json, String userId, String roomId) {
|
||||
static CallMembership? fromJson(
|
||||
Map json,
|
||||
String userId,
|
||||
String roomId,
|
||||
VoIP voip,
|
||||
) {
|
||||
try {
|
||||
return CallMembership(
|
||||
userId: userId,
|
||||
|
|
@ -81,6 +91,7 @@ class CallMembership {
|
|||
membershipId:
|
||||
json['membershipID'] ?? 'someone_forgot_to_set_the_membershipID',
|
||||
feeds: json['feeds'],
|
||||
voip: voip,
|
||||
);
|
||||
} catch (e, s) {
|
||||
Logs().e('[VOIP] call membership parsing failed. $json', e, s);
|
||||
|
|
@ -120,6 +131,6 @@ class CallMembership {
|
|||
bool get isExpired =>
|
||||
expiresTs <
|
||||
DateTime.now()
|
||||
.subtract(CallTimeouts.expireTsBumpDuration)
|
||||
.subtract(voip.timeouts!.expireTsBumpDuration)
|
||||
.millisecondsSinceEpoch;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:matrix/src/voip/models/call_membership.dart';
|
||||
|
||||
String? _delayedLeaveEventId;
|
||||
|
||||
Timer? _restartDelayedLeaveEventTimer;
|
||||
|
||||
extension FamedlyCallMemberEventsExtension on Room {
|
||||
/// a map of every users famedly call event, holds the memberships list
|
||||
/// returns sorted according to originTs (oldest to newest)
|
||||
Map<String, FamedlyCallMemberEvent> getFamedlyCallEvents() {
|
||||
Map<String, FamedlyCallMemberEvent> getFamedlyCallEvents(VoIP voip) {
|
||||
final Map<String, FamedlyCallMemberEvent> mappedEvents = {};
|
||||
final famedlyCallMemberStates =
|
||||
states.tryGetMap<String, Event>(EventTypes.GroupCallMember);
|
||||
|
|
@ -16,16 +21,17 @@ extension FamedlyCallMemberEventsExtension on Room {
|
|||
.sorted((a, b) => a.originServerTs.compareTo(b.originServerTs));
|
||||
|
||||
for (final element in sortedEvents) {
|
||||
mappedEvents
|
||||
.addAll({element.senderId: FamedlyCallMemberEvent.fromJson(element)});
|
||||
mappedEvents.addAll(
|
||||
{element.stateKey!: FamedlyCallMemberEvent.fromJson(element, voip)},
|
||||
);
|
||||
}
|
||||
return mappedEvents;
|
||||
}
|
||||
|
||||
/// extracts memberships list form a famedly call event and maps it to a userid
|
||||
/// returns sorted (oldest to newest)
|
||||
Map<String, List<CallMembership>> getCallMembershipsFromRoom() {
|
||||
final parsedMemberEvents = getFamedlyCallEvents();
|
||||
Map<String, List<CallMembership>> getCallMembershipsFromRoom(VoIP voip) {
|
||||
final parsedMemberEvents = getFamedlyCallEvents(voip);
|
||||
final Map<String, List<CallMembership>> memberships = {};
|
||||
for (final element in parsedMemberEvents.entries) {
|
||||
memberships.addAll({element.key: element.value.memberships});
|
||||
|
|
@ -34,18 +40,29 @@ extension FamedlyCallMemberEventsExtension on Room {
|
|||
}
|
||||
|
||||
/// returns a list of memberships in the room for `user`
|
||||
List<CallMembership> getCallMembershipsForUser(String userId) {
|
||||
final parsedMemberEvents = getCallMembershipsFromRoom();
|
||||
final mem = parsedMemberEvents.tryGet<List<CallMembership>>(userId);
|
||||
/// if room version is org.matrix.msc3757.11 it also uses the deviceId
|
||||
List<CallMembership> getCallMembershipsForUser(
|
||||
String userId,
|
||||
String deviceId,
|
||||
VoIP voip,
|
||||
) {
|
||||
final stateKey = (roomVersion?.contains('msc3757') ?? false)
|
||||
? '${userId}_$deviceId'
|
||||
: userId;
|
||||
final parsedMemberEvents = getCallMembershipsFromRoom(voip);
|
||||
final mem = parsedMemberEvents.tryGet<List<CallMembership>>(stateKey);
|
||||
return mem ?? [];
|
||||
}
|
||||
|
||||
/// returns the user count (not sessions, yet) for the group call with id: `groupCallId`.
|
||||
/// returns 0 if group call not found
|
||||
int groupCallParticipantCount(String groupCallId) {
|
||||
int groupCallParticipantCount(
|
||||
String groupCallId,
|
||||
VoIP voip,
|
||||
) {
|
||||
int participantCount = 0;
|
||||
// userid:membership
|
||||
final memberships = getCallMembershipsFromRoom();
|
||||
final memberships = getCallMembershipsFromRoom(voip);
|
||||
|
||||
memberships.forEach((key, value) {
|
||||
for (final membership in value) {
|
||||
|
|
@ -58,17 +75,17 @@ extension FamedlyCallMemberEventsExtension on Room {
|
|||
return participantCount;
|
||||
}
|
||||
|
||||
bool get hasActiveGroupCall {
|
||||
if (activeGroupCallIds.isNotEmpty) {
|
||||
bool hasActiveGroupCall(VoIP voip) {
|
||||
if (activeGroupCallIds(voip).isNotEmpty) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// list of active group call ids
|
||||
List<String> get activeGroupCallIds {
|
||||
List<String> activeGroupCallIds(VoIP voip) {
|
||||
final Set<String> ids = {};
|
||||
final memberships = getCallMembershipsFromRoom();
|
||||
final memberships = getCallMembershipsFromRoom(voip);
|
||||
|
||||
memberships.forEach((key, value) {
|
||||
for (final mem in value) {
|
||||
|
|
@ -82,7 +99,11 @@ extension FamedlyCallMemberEventsExtension on Room {
|
|||
Future<void> updateFamedlyCallMemberStateEvent(
|
||||
CallMembership callMembership,
|
||||
) async {
|
||||
final ownMemberships = getCallMembershipsForUser(client.userID!);
|
||||
final ownMemberships = getCallMembershipsForUser(
|
||||
client.userID!,
|
||||
client.deviceID!,
|
||||
callMembership.voip,
|
||||
);
|
||||
|
||||
// do not bother removing other deviceId expired events because we have no
|
||||
// ownership over them
|
||||
|
|
@ -97,16 +118,24 @@ extension FamedlyCallMemberEventsExtension on Room {
|
|||
'memberships': List.from(ownMemberships.map((e) => e.toJson())),
|
||||
};
|
||||
|
||||
await setFamedlyCallMemberEvent(newContent);
|
||||
await setFamedlyCallMemberEvent(
|
||||
newContent,
|
||||
callMembership.voip,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeFamedlyCallMemberEvent(
|
||||
String groupCallId,
|
||||
String deviceId, {
|
||||
String deviceId,
|
||||
VoIP voip, {
|
||||
String? application = 'm.call',
|
||||
String? scope = 'm.room',
|
||||
}) async {
|
||||
final ownMemberships = getCallMembershipsForUser(client.userID!);
|
||||
final ownMemberships = getCallMembershipsForUser(
|
||||
client.userID!,
|
||||
client.deviceID!,
|
||||
voip,
|
||||
);
|
||||
|
||||
ownMemberships.removeWhere(
|
||||
(mem) =>
|
||||
|
|
@ -119,15 +148,85 @@ extension FamedlyCallMemberEventsExtension on Room {
|
|||
final newContent = {
|
||||
'memberships': List.from(ownMemberships.map((e) => e.toJson())),
|
||||
};
|
||||
await setFamedlyCallMemberEvent(newContent);
|
||||
await setFamedlyCallMemberEvent(newContent, voip);
|
||||
|
||||
_restartDelayedLeaveEventTimer?.cancel();
|
||||
if (_delayedLeaveEventId != null) {
|
||||
await client.manageDelayedEvent(
|
||||
_delayedLeaveEventId!,
|
||||
DelayedEventAction.cancel,
|
||||
);
|
||||
_delayedLeaveEventId = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setFamedlyCallMemberEvent(Map<String, List> newContent) async {
|
||||
Future<void> setFamedlyCallMemberEvent(
|
||||
Map<String, List> newContent,
|
||||
VoIP voip,
|
||||
) async {
|
||||
if (canJoinGroupCall) {
|
||||
final stateKey = (roomVersion?.contains('msc3757') ?? false)
|
||||
? '${client.userID!}_${client.deviceID!}'
|
||||
: client.userID!;
|
||||
|
||||
final useDelayedEvents = (await client.versionsResponse)
|
||||
.unstableFeatures?['org.matrix.msc4140'] ??
|
||||
false;
|
||||
|
||||
/// can use delayed events and haven't used it yet
|
||||
if (useDelayedEvents && _delayedLeaveEventId == null) {
|
||||
// get existing ones
|
||||
final List<ScheduledDelayedEvent> alreadyScheduledEvents = [];
|
||||
String? nextBatch;
|
||||
final sEvents = await client.getScheduledDelayedEvents();
|
||||
alreadyScheduledEvents.addAll(sEvents.scheduledEvents);
|
||||
nextBatch = sEvents.nextBatch;
|
||||
while (nextBatch != null || (nextBatch?.isNotEmpty ?? false)) {
|
||||
final res = await client.getScheduledDelayedEvents();
|
||||
alreadyScheduledEvents.addAll(
|
||||
res.scheduledEvents,
|
||||
);
|
||||
nextBatch = res.nextBatch;
|
||||
}
|
||||
|
||||
final toCancelEvents = alreadyScheduledEvents.where(
|
||||
(element) => element.stateKey == stateKey,
|
||||
);
|
||||
|
||||
for (final toCancelEvent in toCancelEvents) {
|
||||
await client.manageDelayedEvent(
|
||||
toCancelEvent.delayId,
|
||||
DelayedEventAction.cancel,
|
||||
);
|
||||
}
|
||||
|
||||
_delayedLeaveEventId = await client.setRoomStateWithKeyWithDelay(
|
||||
id,
|
||||
EventTypes.GroupCallMember,
|
||||
stateKey,
|
||||
voip.timeouts!.delayedEventApplyLeave.inMilliseconds,
|
||||
{
|
||||
'memberships': [],
|
||||
},
|
||||
);
|
||||
|
||||
_restartDelayedLeaveEventTimer = Timer.periodic(
|
||||
voip.timeouts!.delayedEventRestart,
|
||||
((timer) async {
|
||||
Logs()
|
||||
.v('[_restartDelayedLeaveEventTimer] heartbeat delayed event');
|
||||
await client.manageDelayedEvent(
|
||||
_delayedLeaveEventId!,
|
||||
DelayedEventAction.restart,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await client.setRoomStateWithKey(
|
||||
id,
|
||||
EventTypes.GroupCallMember,
|
||||
client.userID!,
|
||||
stateKey,
|
||||
newContent,
|
||||
);
|
||||
} else {
|
||||
|
|
@ -145,12 +244,16 @@ extension FamedlyCallMemberEventsExtension on Room {
|
|||
}
|
||||
|
||||
/// returns a list of memberships from a famedly call matrix event
|
||||
List<CallMembership> getCallMembershipsFromEvent(MatrixEvent event) {
|
||||
List<CallMembership> getCallMembershipsFromEvent(
|
||||
MatrixEvent event,
|
||||
VoIP voip,
|
||||
) {
|
||||
if (event.roomId != id) return [];
|
||||
return getCallMembershipsFromEventContent(
|
||||
event.content,
|
||||
event.senderId,
|
||||
event.roomId!,
|
||||
voip,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -159,11 +262,12 @@ extension FamedlyCallMemberEventsExtension on Room {
|
|||
Map<String, Object?> content,
|
||||
String senderId,
|
||||
String roomId,
|
||||
VoIP voip,
|
||||
) {
|
||||
final mems = content.tryGetList<Map>('memberships');
|
||||
final callMems = <CallMembership>[];
|
||||
for (final m in mems ?? []) {
|
||||
final mem = CallMembership.fromJson(m, senderId, roomId);
|
||||
final mem = CallMembership.fromJson(m, senderId, roomId, voip);
|
||||
if (mem != null) callMems.add(mem);
|
||||
}
|
||||
return callMems;
|
||||
|
|
|
|||
|
|
@ -6,36 +6,64 @@ const String voipProtoVersion = '1';
|
|||
|
||||
class CallTimeouts {
|
||||
/// The default life time for call events, in millisecond.
|
||||
static const defaultCallEventLifetime = Duration(seconds: 10);
|
||||
final Duration defaultCallEventLifetime;
|
||||
|
||||
/// The length of time a call can be ringing for.
|
||||
static const callInviteLifetime = Duration(seconds: 60);
|
||||
final Duration callInviteLifetime;
|
||||
|
||||
/// The delay for ice gathering.
|
||||
static const iceGatheringDelay = Duration(milliseconds: 200);
|
||||
final Duration iceGatheringDelay;
|
||||
|
||||
/// Delay before createOffer.
|
||||
static const delayBeforeOffer = Duration(milliseconds: 100);
|
||||
final Duration delayBeforeOffer;
|
||||
|
||||
/// How often to update the expiresTs
|
||||
static const updateExpireTsTimerDuration = Duration(minutes: 2);
|
||||
final Duration updateExpireTsTimerDuration;
|
||||
|
||||
/// the expiresTs bump
|
||||
static const expireTsBumpDuration = Duration(minutes: 6);
|
||||
final Duration expireTsBumpDuration;
|
||||
|
||||
/// Update the active speaker value
|
||||
static const activeSpeakerInterval = Duration(seconds: 5);
|
||||
final Duration activeSpeakerInterval;
|
||||
|
||||
// source: element call?
|
||||
/// A delay after a member leaves before we create and publish a new key, because people
|
||||
/// tend to leave calls at the same time
|
||||
static const makeKeyDelay = Duration(seconds: 4);
|
||||
final Duration makeKeyOnLeaveDelay;
|
||||
|
||||
/// A delay used for joins, only creates new keys if last new created key was before
|
||||
/// $makeKeyDelay duration, or it was recently made and it's safe to send that
|
||||
/// The bigger this is the easier key sharing would be, but also less secure
|
||||
/// Not used if ratcheting is enabled
|
||||
final Duration makeKeyOnJoinDelay;
|
||||
|
||||
/// The delay between creating and sending a new key and starting to encrypt with it. This gives others
|
||||
/// a chance to receive the new key to minimise the chance they don't get media they can't decrypt.
|
||||
/// The total time between a member leaving and the call switching to new keys is therefore
|
||||
/// makeKeyDelay + useKeyDelay
|
||||
static const useKeyDelay = Duration(seconds: 4);
|
||||
final Duration useKeyDelay;
|
||||
|
||||
/// After how long the homeserver should send the delayed leave event which
|
||||
/// gracefully leaves you from the call
|
||||
final Duration delayedEventApplyLeave;
|
||||
|
||||
/// How often the delayed event should be restarted on the homeserver
|
||||
final Duration delayedEventRestart;
|
||||
|
||||
CallTimeouts({
|
||||
this.defaultCallEventLifetime = const Duration(seconds: 10),
|
||||
this.callInviteLifetime = const Duration(seconds: 60),
|
||||
this.iceGatheringDelay = const Duration(milliseconds: 200),
|
||||
this.delayBeforeOffer = const Duration(milliseconds: 100),
|
||||
this.updateExpireTsTimerDuration = const Duration(minutes: 2),
|
||||
this.expireTsBumpDuration = const Duration(minutes: 6),
|
||||
this.activeSpeakerInterval = const Duration(seconds: 5),
|
||||
this.makeKeyOnLeaveDelay = const Duration(seconds: 4),
|
||||
this.makeKeyOnJoinDelay = const Duration(seconds: 8),
|
||||
this.useKeyDelay = const Duration(seconds: 4),
|
||||
this.delayedEventApplyLeave = const Duration(seconds: 18),
|
||||
this.delayedEventRestart = const Duration(seconds: 4),
|
||||
});
|
||||
}
|
||||
|
||||
class CallConstants {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import 'package:webrtc_interface/webrtc_interface.dart';
|
|||
import 'package:matrix/matrix.dart';
|
||||
import 'package:matrix/src/utils/cached_stream_controller.dart';
|
||||
import 'package:matrix/src/utils/crypto/crypto.dart';
|
||||
import 'package:matrix/src/voip/models/call_membership.dart';
|
||||
import 'package:matrix/src/voip/models/call_options.dart';
|
||||
import 'package:matrix/src/voip/models/voip_id.dart';
|
||||
import 'package:matrix/src/voip/utils/stream_helper.dart';
|
||||
|
|
@ -71,16 +70,30 @@ class VoIP {
|
|||
/// the current instance of voip, changing this will drop any ongoing mesh calls
|
||||
/// with that sessionId
|
||||
late String currentSessionId;
|
||||
|
||||
/// the following parameters are only used in livekit calls, but cannot be
|
||||
/// in the LivekitBackend class because that could be created from a pre-existing state event
|
||||
|
||||
/// controls how many key indices can you have before looping back to index 0
|
||||
/// only used in livekit calls
|
||||
final int keyRingSize;
|
||||
|
||||
// default values set in super constructor
|
||||
CallTimeouts? timeouts;
|
||||
|
||||
VoIP(
|
||||
this.client,
|
||||
this.delegate, {
|
||||
this.enableSFUE2EEKeyRatcheting = false,
|
||||
this.keyRingSize = 16,
|
||||
this.timeouts,
|
||||
}) : super() {
|
||||
timeouts ??= CallTimeouts();
|
||||
currentSessionId = base64Encode(secureRandomBytes(16));
|
||||
Logs().v('set currentSessionId to $currentSessionId');
|
||||
// to populate groupCalls with already present calls
|
||||
for (final room in client.rooms) {
|
||||
final memsList = room.getCallMembershipsFromRoom();
|
||||
final memsList = room.getCallMembershipsFromRoom(this);
|
||||
for (final mems in memsList.values) {
|
||||
for (final mem in mems) {
|
||||
unawaited(createGroupCallFromRoomStateEvent(mem));
|
||||
|
|
@ -101,8 +114,7 @@ class VoIP {
|
|||
if (event.room.membership != Membership.join) return;
|
||||
if (event.type != EventTypes.GroupCallMember) return;
|
||||
|
||||
Logs().v('[VOIP] onRoomState: type ${event.toJson()}');
|
||||
final mems = event.room.getCallMembershipsFromEvent(event);
|
||||
final mems = event.room.getCallMembershipsFromEvent(event, this);
|
||||
for (final mem in mems) {
|
||||
unawaited(createGroupCallFromRoomStateEvent(mem));
|
||||
}
|
||||
|
|
@ -151,7 +163,7 @@ class VoIP {
|
|||
if (callEvent.type == EventTypes.CallInvite &&
|
||||
age >
|
||||
(callEvent.content.tryGet<int>('lifetime') ??
|
||||
CallTimeouts.callInviteLifetime.inMilliseconds)) {
|
||||
timeouts!.callInviteLifetime.inMilliseconds)) {
|
||||
Logs().w(
|
||||
'[VOIP] Ommiting invite event ${callEvent.eventId} as age was older than lifetime',
|
||||
);
|
||||
|
|
@ -903,12 +915,7 @@ class VoIP {
|
|||
CallMembership membership, {
|
||||
bool emitHandleNewGroupCall = true,
|
||||
}) async {
|
||||
if (membership.isExpired) {
|
||||
Logs().d(
|
||||
'Ignoring expired membership in passive groupCall creator. ${membership.toJson()}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (membership.isExpired) return;
|
||||
|
||||
final room = client.getRoomById(membership.roomId);
|
||||
|
||||
|
|
@ -947,5 +954,5 @@ class VoIP {
|
|||
}
|
||||
|
||||
@Deprecated('Call `hasActiveGroupCall` on the room directly instead')
|
||||
bool hasActiveCall(Room room) => room.hasActiveGroupCall;
|
||||
bool hasActiveCall(Room room) => room.hasActiveGroupCall(this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import 'package:test/test.dart';
|
|||
import 'package:webrtc_interface/webrtc_interface.dart';
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:matrix/src/voip/models/call_membership.dart';
|
||||
import 'package:matrix/src/voip/models/call_options.dart';
|
||||
import 'package:matrix/src/voip/models/voip_id.dart';
|
||||
import 'fake_client.dart';
|
||||
|
|
@ -630,6 +629,7 @@ void main() {
|
|||
.millisecondsSinceEpoch,
|
||||
roomId: room.id,
|
||||
membershipId: voip.currentSessionId,
|
||||
voip: voip,
|
||||
).toJson(),
|
||||
],
|
||||
},
|
||||
|
|
@ -653,6 +653,7 @@ void main() {
|
|||
expiresTs: DateTime.now().millisecondsSinceEpoch,
|
||||
roomId: room.id,
|
||||
membershipId: voip.currentSessionId,
|
||||
voip: voip,
|
||||
).toJson(),
|
||||
],
|
||||
},
|
||||
|
|
@ -676,6 +677,7 @@ void main() {
|
|||
expiresTs: DateTime.now().millisecondsSinceEpoch,
|
||||
roomId: room.id,
|
||||
membershipId: voip.currentSessionId,
|
||||
voip: voip,
|
||||
).toJson(),
|
||||
],
|
||||
},
|
||||
|
|
@ -701,6 +703,7 @@ void main() {
|
|||
.millisecondsSinceEpoch,
|
||||
roomId: room.id,
|
||||
membershipId: voip.currentSessionId,
|
||||
voip: voip,
|
||||
).toJson(),
|
||||
],
|
||||
},
|
||||
|
|
@ -713,35 +716,35 @@ void main() {
|
|||
),
|
||||
);
|
||||
expect(
|
||||
room.getFamedlyCallEvents().entries.elementAt(0).key,
|
||||
room.getFamedlyCallEvents(voip).entries.elementAt(0).key,
|
||||
'@test3:example.com',
|
||||
);
|
||||
expect(
|
||||
room.getFamedlyCallEvents().entries.elementAt(1).key,
|
||||
room.getFamedlyCallEvents(voip).entries.elementAt(1).key,
|
||||
'@test2:example.com',
|
||||
);
|
||||
expect(
|
||||
room.getFamedlyCallEvents().entries.elementAt(2).key,
|
||||
room.getFamedlyCallEvents(voip).entries.elementAt(2).key,
|
||||
'@test2.0:example.com',
|
||||
);
|
||||
expect(
|
||||
room.getFamedlyCallEvents().entries.elementAt(3).key,
|
||||
room.getFamedlyCallEvents(voip).entries.elementAt(3).key,
|
||||
'@test1:example.com',
|
||||
);
|
||||
expect(
|
||||
room.getCallMembershipsFromRoom().entries.elementAt(0).key,
|
||||
room.getCallMembershipsFromRoom(voip).entries.elementAt(0).key,
|
||||
'@test3:example.com',
|
||||
);
|
||||
expect(
|
||||
room.getCallMembershipsFromRoom().entries.elementAt(1).key,
|
||||
room.getCallMembershipsFromRoom(voip).entries.elementAt(1).key,
|
||||
'@test2:example.com',
|
||||
);
|
||||
expect(
|
||||
room.getCallMembershipsFromRoom().entries.elementAt(2).key,
|
||||
room.getCallMembershipsFromRoom(voip).entries.elementAt(2).key,
|
||||
'@test2.0:example.com',
|
||||
);
|
||||
expect(
|
||||
room.getCallMembershipsFromRoom().entries.elementAt(3).key,
|
||||
room.getCallMembershipsFromRoom(voip).entries.elementAt(3).key,
|
||||
'@test1:example.com',
|
||||
);
|
||||
});
|
||||
|
|
@ -866,6 +869,7 @@ void main() {
|
|||
.millisecondsSinceEpoch,
|
||||
roomId: room.id,
|
||||
membershipId: voip.currentSessionId,
|
||||
voip: voip,
|
||||
).toJson(),
|
||||
],
|
||||
},
|
||||
|
|
@ -874,8 +878,8 @@ void main() {
|
|||
),
|
||||
);
|
||||
|
||||
expect(room.groupCallParticipantCount('participants_count'), 0);
|
||||
expect(room.hasActiveGroupCall, false);
|
||||
expect(room.groupCallParticipantCount('participants_count', voip), 0);
|
||||
expect(room.hasActiveGroupCall(voip), false);
|
||||
|
||||
room.setState(
|
||||
Event(
|
||||
|
|
@ -895,6 +899,7 @@ void main() {
|
|||
.millisecondsSinceEpoch,
|
||||
roomId: room.id,
|
||||
membershipId: voip.currentSessionId,
|
||||
voip: voip,
|
||||
).toJson(),
|
||||
],
|
||||
},
|
||||
|
|
@ -902,8 +907,8 @@ void main() {
|
|||
stateKey: '@test2:example.com',
|
||||
),
|
||||
);
|
||||
expect(room.groupCallParticipantCount('participants_count'), 1);
|
||||
expect(room.hasActiveGroupCall, true);
|
||||
expect(room.groupCallParticipantCount('participants_count', voip), 1);
|
||||
expect(room.hasActiveGroupCall(voip), true);
|
||||
|
||||
room.setState(
|
||||
Event(
|
||||
|
|
@ -921,6 +926,7 @@ void main() {
|
|||
expiresTs: DateTime.now().millisecondsSinceEpoch,
|
||||
roomId: room.id,
|
||||
membershipId: voip.currentSessionId,
|
||||
voip: voip,
|
||||
).toJson(),
|
||||
],
|
||||
},
|
||||
|
|
@ -929,8 +935,8 @@ void main() {
|
|||
),
|
||||
);
|
||||
|
||||
expect(room.groupCallParticipantCount('participants_count'), 2);
|
||||
expect(room.hasActiveGroupCall, true);
|
||||
expect(room.groupCallParticipantCount('participants_count', voip), 2);
|
||||
expect(room.hasActiveGroupCall(voip), true);
|
||||
});
|
||||
|
||||
test('call persists after sending invite', () async {
|
||||
|
|
|
|||
Loading…
Reference in New Issue