Skip to main content

matrix_sdk_base/room/
call.rs

1// Copyright 2025 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use ruma::{OwnedUserId, events::rtc::notification::CallIntent};
16
17use super::Room;
18
19/// Represents the consensus state of call intent among room members. Call
20/// members can advertise their intent to use audio or video, clients can use
21/// this in the UI and also to decide to start camera or not when joining.
22///
23/// This enum distinguishes between full consensus (all members advertise and
24/// agree), partial consensus (only some members advertise, but those who do
25/// agree), and no consensus (either no one advertises or advertisers disagree).
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum CallIntentConsensus {
28    /// All members advertise and agree
29    Full(CallIntent),
30    /// Some members advertise and agree
31    Partial {
32        /// The call intent that advertising members agree on.
33        intent: CallIntent,
34        /// Number of members advertising and agreeing on this intent.
35        agreeing_count: u64,
36        /// Total number of members in the call.
37        total_count: u64,
38    },
39    /// No consensus. No one advertises or advertisers disagree.
40    None,
41}
42
43impl Room {
44    /// Is there a non expired membership with application `m.call` and scope
45    /// `m.room` in this room.
46    pub fn has_active_room_call(&self) -> bool {
47        self.info.read().has_active_room_call()
48    }
49
50    /// Returns a `Vec` of `OwnedUserId`'s that participate in the room call.
51    ///
52    /// MatrixRTC memberships with application `m.call` and scope `m.room` are
53    /// considered. A user can occur twice if they join with two devices.
54    /// Convert to a set depending if the different users are required or the
55    /// amount of sessions.
56    ///
57    /// The vector is ordered by oldest membership user to newest.
58    pub fn active_room_call_participants(&self) -> Vec<OwnedUserId> {
59        self.info.read().active_room_call_participants()
60    }
61
62    /// See [`RoomInfo::is_device_in_active_room_call`].
63    ///
64    /// [`RoomInfo::is_device_in_active_room_call`]: crate::RoomInfo::is_device_in_active_room_call
65    pub fn is_device_in_active_room_call(
66        &self,
67        user_id: &ruma::UserId,
68        device_id: &ruma::DeviceId,
69    ) -> bool {
70        self.info.read().is_device_in_active_room_call(user_id, device_id)
71    }
72
73    /// Get the consensus call intent for the current call, based on what
74    /// members are advertising.
75    pub fn active_room_call_consensus_intent(&self) -> CallIntentConsensus {
76        self.info.read().active_room_call_consensus_intent()
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use std::{ops::Sub, sync::Arc, time::Duration};
83
84    use assign::assign;
85    use matrix_sdk_test::{ALICE, BOB, CAROL, event_factory::EventFactory};
86    use ruma::{
87        DeviceId, EventId, MilliSecondsSinceUnixEpoch, OwnedUserId, UserId, device_id, event_id,
88        events::{
89            AnySyncStateEvent,
90            call::member::{
91                ActiveFocus, ActiveLivekitFocus, Application, CallApplicationContent,
92                CallMemberEventContent, CallMemberStateKey, Focus, LegacyMembershipData,
93                LegacyMembershipDataInit, LivekitFocus,
94            },
95            rtc::notification::CallIntent,
96        },
97        room_id,
98        serde::Raw,
99        time::SystemTime,
100        user_id,
101    };
102    use similar_asserts::assert_eq;
103
104    use super::{
105        super::{Room, RoomState},
106        CallIntentConsensus,
107    };
108    use crate::{
109        store::{MemoryStore, SaveLockedStateStore},
110        utils::RawStateEventWithKeys,
111    };
112
113    fn make_room_test_helper(room_type: RoomState) -> (Arc<MemoryStore>, Room) {
114        let store = Arc::new(MemoryStore::new());
115        let user_id = user_id!("@me:example.org");
116        let room_id = room_id!("!test:localhost");
117        let (sender, _receiver) = tokio::sync::broadcast::channel(1);
118
119        (
120            store.clone(),
121            Room::new(user_id, SaveLockedStateStore::new(store), room_id, room_type, sender),
122        )
123    }
124
125    fn timestamp(minutes_ago: u32) -> MilliSecondsSinceUnixEpoch {
126        MilliSecondsSinceUnixEpoch::from_system_time(
127            SystemTime::now().sub(Duration::from_secs((60 * minutes_ago).into())),
128        )
129        .expect("date out of range")
130    }
131
132    fn legacy_membership_for_my_call(
133        device_id: &DeviceId,
134        membership_id: &str,
135        minutes_ago: u32,
136    ) -> LegacyMembershipData {
137        let (application, foci) = foci_and_application();
138        assign!(
139            LegacyMembershipData::from(LegacyMembershipDataInit {
140                application,
141                device_id: device_id.to_owned(),
142                expires: Duration::from_millis(3_600_000),
143                foci_active: foci,
144                membership_id: membership_id.to_owned(),
145            }),
146            { created_ts: Some(timestamp(minutes_ago)) }
147        )
148    }
149
150    fn legacy_member_state_event(
151        memberships: Vec<LegacyMembershipData>,
152        ev_id: &EventId,
153        user_id: &UserId,
154    ) -> Raw<AnySyncStateEvent> {
155        let content = CallMemberEventContent::new_legacy(memberships);
156        EventFactory::new()
157            .sender(user_id)
158            .event(content)
159            .state_key(CallMemberStateKey::new(user_id.to_owned(), None, false).as_ref())
160            .event_id(ev_id)
161            // we can simply use now here since this will be dropped when using
162            // a MinimalStateEvent in the roomInfo
163            .server_ts(timestamp(0))
164            .into()
165    }
166
167    struct InitData<'a> {
168        device_id: &'a DeviceId,
169        minutes_ago: u32,
170    }
171
172    fn session_member_state_event(
173        ev_id: &EventId,
174        user_id: &UserId,
175        init_data: Option<InitData<'_>>,
176    ) -> Raw<AnySyncStateEvent> {
177        session_member_state_event_with_intent(ev_id, user_id, init_data, None)
178    }
179
180    fn session_member_state_event_with_intent(
181        ev_id: &EventId,
182        user_id: &UserId,
183        init_data: Option<InitData<'_>>,
184        call_intent: Option<CallIntent>,
185    ) -> Raw<AnySyncStateEvent> {
186        let mut app_content = CallApplicationContent::new(
187            "my_call_id_1".to_owned(),
188            ruma::events::call::member::CallScope::Room,
189        );
190        app_content.call_intent = call_intent;
191
192        let application = Application::Call(app_content);
193        let foci_preferred = vec![Focus::Livekit(LivekitFocus::new(
194            "my_call_foci_alias".to_owned(),
195            "https://lk.org".to_owned(),
196        ))];
197        let focus_active = ActiveFocus::Livekit(ActiveLivekitFocus::new());
198
199        let (content, state_key) = match init_data {
200            Some(InitData { device_id, minutes_ago }) => {
201                let member_id = format!("{device_id}_m.call");
202                (
203                    CallMemberEventContent::new(
204                        application,
205                        device_id.to_owned(),
206                        focus_active,
207                        foci_preferred,
208                        Some(timestamp(minutes_ago)),
209                        None,
210                    ),
211                    CallMemberStateKey::new(user_id.to_owned(), Some(member_id), false),
212                )
213            }
214
215            None => (
216                CallMemberEventContent::new_empty(None),
217                CallMemberStateKey::new(user_id.to_owned(), None, false),
218            ),
219        };
220
221        EventFactory::new()
222            .sender(user_id)
223            .event(content)
224            .state_key(state_key.as_ref())
225            .event_id(ev_id)
226            // we can simply use now here since this will be dropped when using
227            // a MinimalStateEvent in the roomInfo
228            .server_ts(timestamp(0))
229            .into()
230    }
231
232    fn foci_and_application() -> (Application, Vec<Focus>) {
233        (
234            Application::Call(CallApplicationContent::new(
235                "my_call_id_1".to_owned(),
236                ruma::events::call::member::CallScope::Room,
237            )),
238            vec![Focus::Livekit(LivekitFocus::new(
239                "my_call_foci_alias".to_owned(),
240                "https://lk.org".to_owned(),
241            ))],
242        )
243    }
244
245    fn receive_state_events(room: &Room, events: Vec<Raw<AnySyncStateEvent>>) {
246        room.info.update_if(|info| {
247            let mut res = false;
248            for ev in events {
249                res |= info.handle_state_event(
250                    &mut RawStateEventWithKeys::try_from_raw_state_event(ev)
251                        .expect("generated state event should be valid"),
252                );
253            }
254            res
255        });
256    }
257
258    /// `user_a`: empty memberships `user_b`: one membership `user_c`: two
259    /// memberships (two devices)
260    fn legacy_create_call_with_member_events_for_user(a: &UserId, b: &UserId, c: &UserId) -> Room {
261        let (_, room) = make_room_test_helper(RoomState::Joined);
262
263        let a_empty = legacy_member_state_event(Vec::new(), event_id!("$1234"), a);
264
265        // make b 10min old
266        let m_init_b = legacy_membership_for_my_call(device_id!("DEVICE_0"), "0", 1);
267        let b_one = legacy_member_state_event(vec![m_init_b], event_id!("$12345"), b);
268
269        // c1 1min old
270        let m_init_c1 = legacy_membership_for_my_call(device_id!("DEVICE_0"), "0", 10);
271        // c2 20min old
272        let m_init_c2 = legacy_membership_for_my_call(device_id!("DEVICE_1"), "0", 20);
273        let c_two = legacy_member_state_event(vec![m_init_c1, m_init_c2], event_id!("$123456"), c);
274
275        // Intentionally use a non time sorted receive order.
276        receive_state_events(&room, vec![c_two, a_empty, b_one]);
277
278        room
279    }
280
281    /// `user_a`: empty memberships `user_b`: one membership `user_c`: two
282    /// memberships (two devices)
283    fn session_create_call_with_member_events_for_user(a: &UserId, b: &UserId, c: &UserId) -> Room {
284        let (_, room) = make_room_test_helper(RoomState::Joined);
285
286        let a_empty = session_member_state_event(event_id!("$1234"), a, None);
287
288        // make b 10min old
289        let b_one = session_member_state_event(
290            event_id!("$12345"),
291            b,
292            Some(InitData { device_id: "DEVICE_0".into(), minutes_ago: 1 }),
293        );
294
295        let m_c1 = session_member_state_event(
296            event_id!("$123456_0"),
297            c,
298            Some(InitData { device_id: "DEVICE_0".into(), minutes_ago: 10 }),
299        );
300        let m_c2 = session_member_state_event(
301            event_id!("$123456_1"),
302            c,
303            Some(InitData { device_id: "DEVICE_1".into(), minutes_ago: 20 }),
304        );
305        // Intentionally use a non time sorted receive order1
306        receive_state_events(&room, vec![m_c1, m_c2, a_empty, b_one]);
307
308        room
309    }
310
311    #[test]
312    fn test_show_correct_active_call_state() {
313        let room_legacy = legacy_create_call_with_member_events_for_user(&ALICE, &BOB, &CAROL);
314
315        // This check also tests the ordering. We want older events to be in the
316        // front. user_b (Bob) is 1min old, c1 (CAROL) 10min old, c2 (CAROL)
317        // 20min old
318        assert_eq!(
319            vec![CAROL.to_owned(), CAROL.to_owned(), BOB.to_owned()],
320            room_legacy.active_room_call_participants()
321        );
322        assert!(room_legacy.has_active_room_call());
323
324        let room_session = session_create_call_with_member_events_for_user(&ALICE, &BOB, &CAROL);
325        assert_eq!(
326            vec![CAROL.to_owned(), CAROL.to_owned(), BOB.to_owned()],
327            room_session.active_room_call_participants()
328        );
329        assert!(room_session.has_active_room_call());
330    }
331
332    #[test]
333    fn test_active_call_is_false_when_everyone_left() {
334        let room = legacy_create_call_with_member_events_for_user(&ALICE, &BOB, &CAROL);
335
336        let b_empty_membership = legacy_member_state_event(Vec::new(), event_id!("$1234_1"), &BOB);
337        let c_empty_membership =
338            legacy_member_state_event(Vec::new(), event_id!("$12345_1"), &CAROL);
339
340        receive_state_events(&room, vec![b_empty_membership, c_empty_membership]);
341
342        // We have no active call anymore after emptying the memberships
343        assert_eq!(Vec::<OwnedUserId>::new(), room.active_room_call_participants());
344        assert!(!room.has_active_room_call());
345    }
346
347    fn consensus_setup(
348        alice_intent: Option<CallIntent>,
349        bob_intent: Option<CallIntent>,
350        call_intent: Option<CallIntent>,
351    ) -> Vec<Raw<AnySyncStateEvent>> {
352        let alice_membership = session_member_state_event_with_intent(
353            event_id!("$1"),
354            user_id!("@alice:server.name"),
355            InitData { device_id: device_id!("AAA0"), minutes_ago: 1 }.into(),
356            alice_intent,
357        );
358        let bob_membership = session_member_state_event_with_intent(
359            event_id!("$1"),
360            user_id!("@bob:server.name"),
361            InitData { device_id: device_id!("BAA0"), minutes_ago: 1 }.into(),
362            bob_intent,
363        );
364        let carl_membership = session_member_state_event_with_intent(
365            event_id!("$2"),
366            user_id!("@carl:server.name"),
367            InitData { device_id: device_id!("CAA0"), minutes_ago: 1 }.into(),
368            call_intent,
369        );
370        vec![alice_membership, bob_membership, carl_membership]
371    }
372
373    #[test]
374    fn test_consensus_intent() {
375        let test_cases = vec![
376            // (alice_intent, bob_intent, carl_intent, expected_consensus, description)
377            (None, None, None, CallIntentConsensus::None, "no intents"),
378            (
379                Some(CallIntent::Audio),
380                None,
381                None,
382                CallIntentConsensus::Partial {
383                    intent: CallIntent::Audio,
384                    agreeing_count: 1,
385                    total_count: 3,
386                },
387                "one intent 1",
388            ),
389            (
390                None,
391                Some(CallIntent::Audio),
392                None,
393                CallIntentConsensus::Partial {
394                    intent: CallIntent::Audio,
395                    agreeing_count: 1,
396                    total_count: 3,
397                },
398                "one intent 2",
399            ),
400            (
401                None,
402                None,
403                Some(CallIntent::Audio),
404                CallIntentConsensus::Partial {
405                    intent: CallIntent::Audio,
406                    agreeing_count: 1,
407                    total_count: 3,
408                },
409                "one intent 3",
410            ),
411            (
412                None,
413                None,
414                Some(CallIntent::Video),
415                CallIntentConsensus::Partial {
416                    intent: CallIntent::Video,
417                    agreeing_count: 1,
418                    total_count: 3,
419                },
420                "one intent 4",
421            ),
422            (
423                None,
424                Some(CallIntent::Video),
425                Some(CallIntent::Video),
426                CallIntentConsensus::Partial {
427                    intent: CallIntent::Video,
428                    agreeing_count: 2,
429                    total_count: 3,
430                },
431                "two matching intents",
432            ),
433            (
434                Some(CallIntent::Video),
435                Some(CallIntent::Video),
436                Some(CallIntent::Video),
437                CallIntentConsensus::Full(CallIntent::Video),
438                "all agree",
439            ),
440            (
441                Some(CallIntent::Video),
442                None,
443                Some(CallIntent::Audio),
444                CallIntentConsensus::None,
445                "disagreement",
446            ),
447            (
448                Some(CallIntent::Video),
449                Some(CallIntent::Video),
450                Some(CallIntent::Audio),
451                CallIntentConsensus::None,
452                "disagreement 2",
453            ),
454        ];
455
456        for (alice, bob, carl, expected, description) in test_cases {
457            let (_, room) = make_room_test_helper(RoomState::Joined);
458            receive_state_events(&room, consensus_setup(alice, bob, carl));
459            let consensus_intent = room.active_room_call_consensus_intent();
460            assert_eq!(expected, consensus_intent, "Failed case: {}", description);
461        }
462    }
463
464    #[test]
465    fn is_device_in_active_room_call_matches_own_device_only() {
466        let (_, room) = make_room_test_helper(RoomState::Joined);
467
468        let alice_a = session_member_state_event(
469            event_id!("$alice_a"),
470            &ALICE,
471            Some(InitData { device_id: device_id!("DEVICE_A"), minutes_ago: 1 }),
472        );
473        let alice_b = session_member_state_event(
474            event_id!("$alice_b"),
475            &ALICE,
476            Some(InitData { device_id: device_id!("DEVICE_B"), minutes_ago: 1 }),
477        );
478        let bob_a = session_member_state_event(
479            event_id!("$bob_a"),
480            &BOB,
481            Some(InitData { device_id: device_id!("DEVICE_A"), minutes_ago: 1 }),
482        );
483
484        receive_state_events(&room, vec![alice_a, alice_b, bob_a]);
485
486        assert!(room.is_device_in_active_room_call(&ALICE, device_id!("DEVICE_A")));
487        assert!(room.is_device_in_active_room_call(&ALICE, device_id!("DEVICE_B")));
488        assert!(!room.is_device_in_active_room_call(&ALICE, device_id!("DEVICE_C")));
489        assert!(room.is_device_in_active_room_call(&BOB, device_id!("DEVICE_A")));
490        assert!(!room.is_device_in_active_room_call(&BOB, device_id!("DEVICE_B")));
491    }
492}