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::{User, event_factory::EventFactory};
86    use ruma::{
87        DeviceId, EventId, MilliSecondsSinceUnixEpoch, OwnedUserId, 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    };
101    use similar_asserts::assert_eq;
102
103    use super::{
104        super::{Room, RoomState},
105        CallIntentConsensus,
106    };
107    use crate::{
108        store::{MemoryStore, SaveLockedStateStore},
109        utils::RawStateEventWithKeys,
110    };
111
112    fn make_room_test_helper(room_type: RoomState) -> (Arc<MemoryStore>, Room) {
113        let store = Arc::new(MemoryStore::new());
114        let room_id = room_id!("!test:localhost");
115        let (sender, _receiver) = tokio::sync::broadcast::channel(1);
116
117        (
118            store.clone(),
119            Room::new(
120                User::Alice.into(),
121                SaveLockedStateStore::new(store),
122                room_id,
123                room_type,
124                sender,
125            ),
126        )
127    }
128
129    fn timestamp(minutes_ago: u32) -> MilliSecondsSinceUnixEpoch {
130        MilliSecondsSinceUnixEpoch::from_system_time(
131            SystemTime::now().sub(Duration::from_secs((60 * minutes_ago).into())),
132        )
133        .expect("date out of range")
134    }
135
136    fn legacy_membership_for_my_call(
137        device_id: &DeviceId,
138        membership_id: &str,
139        minutes_ago: u32,
140    ) -> LegacyMembershipData {
141        let (application, foci) = foci_and_application();
142        assign!(
143            LegacyMembershipData::from(LegacyMembershipDataInit {
144                application,
145                device_id: device_id.to_owned(),
146                expires: Duration::from_millis(3_600_000),
147                foci_active: foci,
148                membership_id: membership_id.to_owned(),
149            }),
150            { created_ts: Some(timestamp(minutes_ago)) }
151        )
152    }
153
154    fn legacy_member_state_event(
155        memberships: Vec<LegacyMembershipData>,
156        ev_id: &EventId,
157        user: User,
158    ) -> Raw<AnySyncStateEvent> {
159        let content = CallMemberEventContent::new_legacy(memberships);
160        EventFactory::new()
161            .sender(user)
162            .event(content)
163            .state_key(CallMemberStateKey::new(user.into(), None, false).as_ref())
164            .event_id(ev_id)
165            // we can simply use now here since this will be dropped when using
166            // a MinimalStateEvent in the roomInfo
167            .server_ts(timestamp(0))
168            .into()
169    }
170
171    struct InitData<'a> {
172        device_id: &'a DeviceId,
173        minutes_ago: u32,
174    }
175
176    fn session_member_state_event(
177        ev_id: &EventId,
178        user: User,
179        init_data: Option<InitData<'_>>,
180    ) -> Raw<AnySyncStateEvent> {
181        session_member_state_event_with_intent(ev_id, user, init_data, None)
182    }
183
184    fn session_member_state_event_with_intent(
185        ev_id: &EventId,
186        user: User,
187        init_data: Option<InitData<'_>>,
188        call_intent: Option<CallIntent>,
189    ) -> Raw<AnySyncStateEvent> {
190        let mut app_content = CallApplicationContent::new(
191            "my_call_id_1".to_owned(),
192            ruma::events::call::member::CallScope::Room,
193        );
194        app_content.call_intent = call_intent;
195
196        let application = Application::Call(app_content);
197        let foci_preferred = vec![Focus::Livekit(LivekitFocus::new(
198            "my_call_foci_alias".to_owned(),
199            "https://lk.org".to_owned(),
200        ))];
201        let focus_active = ActiveFocus::Livekit(ActiveLivekitFocus::new());
202
203        let (content, state_key) = match init_data {
204            Some(InitData { device_id, minutes_ago }) => {
205                let member_id = format!("{device_id}_m.call");
206                (
207                    CallMemberEventContent::new(
208                        application,
209                        device_id.to_owned(),
210                        focus_active,
211                        foci_preferred,
212                        Some(timestamp(minutes_ago)),
213                        None,
214                    ),
215                    CallMemberStateKey::new(user.into(), Some(member_id), false),
216                )
217            }
218
219            None => (
220                CallMemberEventContent::new_empty(None),
221                CallMemberStateKey::new(user.into(), None, false),
222            ),
223        };
224
225        EventFactory::new()
226            .sender(user)
227            .event(content)
228            .state_key(state_key.as_ref())
229            .event_id(ev_id)
230            // we can simply use now here since this will be dropped when using
231            // a MinimalStateEvent in the roomInfo
232            .server_ts(timestamp(0))
233            .into()
234    }
235
236    fn foci_and_application() -> (Application, Vec<Focus>) {
237        (
238            Application::Call(CallApplicationContent::new(
239                "my_call_id_1".to_owned(),
240                ruma::events::call::member::CallScope::Room,
241            )),
242            vec![Focus::Livekit(LivekitFocus::new(
243                "my_call_foci_alias".to_owned(),
244                "https://lk.org".to_owned(),
245            ))],
246        )
247    }
248
249    fn receive_state_events(room: &Room, events: Vec<Raw<AnySyncStateEvent>>) {
250        room.info.update_if(|info| {
251            let mut res = false;
252            for ev in events {
253                res |= info.handle_state_event(
254                    &mut RawStateEventWithKeys::try_from_raw_state_event(ev)
255                        .expect("generated state event should be valid"),
256                );
257            }
258            res
259        });
260    }
261
262    /// `user_a`: empty memberships `user_b`: one membership `user_c`: two
263    /// memberships (two devices)
264    fn legacy_create_call_with_member_events_for_user(a: User, b: User, c: User) -> Room {
265        let (_, room) = make_room_test_helper(RoomState::Joined);
266
267        let a_empty = legacy_member_state_event(Vec::new(), event_id!("$1234"), a);
268
269        // make b 10min old
270        let m_init_b = legacy_membership_for_my_call(device_id!("DEVICE_0"), "0", 1);
271        let b_one = legacy_member_state_event(vec![m_init_b], event_id!("$12345"), b);
272
273        // c1 1min old
274        let m_init_c1 = legacy_membership_for_my_call(device_id!("DEVICE_0"), "0", 10);
275        // c2 20min old
276        let m_init_c2 = legacy_membership_for_my_call(device_id!("DEVICE_1"), "0", 20);
277        let c_two = legacy_member_state_event(vec![m_init_c1, m_init_c2], event_id!("$123456"), c);
278
279        // Intentionally use a non time sorted receive order.
280        receive_state_events(&room, vec![c_two, a_empty, b_one]);
281
282        room
283    }
284
285    /// `user_a`: empty memberships `user_b`: one membership `user_c`: two
286    /// memberships (two devices)
287    fn session_create_call_with_member_events_for_user(a: User, b: User, c: User) -> Room {
288        let (_, room) = make_room_test_helper(RoomState::Joined);
289
290        let a_empty = session_member_state_event(event_id!("$1234"), a, None);
291
292        // make b 10min old
293        let b_one = session_member_state_event(
294            event_id!("$12345"),
295            b,
296            Some(InitData { device_id: "DEVICE_0".into(), minutes_ago: 1 }),
297        );
298
299        let m_c1 = session_member_state_event(
300            event_id!("$123456_0"),
301            c,
302            Some(InitData { device_id: "DEVICE_0".into(), minutes_ago: 10 }),
303        );
304        let m_c2 = session_member_state_event(
305            event_id!("$123456_1"),
306            c,
307            Some(InitData { device_id: "DEVICE_1".into(), minutes_ago: 20 }),
308        );
309        // Intentionally use a non time sorted receive order1
310        receive_state_events(&room, vec![m_c1, m_c2, a_empty, b_one]);
311
312        room
313    }
314
315    #[test]
316    fn test_show_correct_active_call_state() {
317        let room_legacy =
318            legacy_create_call_with_member_events_for_user(User::Alice, User::Bob, User::Carol);
319
320        // This check also tests the ordering. We want older events to be in the
321        // front. user_b (Bob) is 1min old, c1 (CAROL) 10min old, c2 (CAROL)
322        // 20min old
323        assert_eq!(
324            room_legacy.active_room_call_participants(),
325            &[User::Carol, User::Carol, User::Bob],
326        );
327        assert!(room_legacy.has_active_room_call());
328
329        let room_session =
330            session_create_call_with_member_events_for_user(User::Alice, User::Bob, User::Carol);
331        assert_eq!(
332            room_session.active_room_call_participants(),
333            &[User::Carol, User::Carol, User::Bob],
334        );
335        assert!(room_session.has_active_room_call());
336    }
337
338    #[test]
339    fn test_active_call_is_false_when_everyone_left() {
340        let room =
341            legacy_create_call_with_member_events_for_user(User::Alice, User::Bob, User::Carol);
342
343        let b_empty_membership =
344            legacy_member_state_event(Vec::new(), event_id!("$1234_1"), User::Bob);
345        let c_empty_membership =
346            legacy_member_state_event(Vec::new(), event_id!("$12345_1"), User::Carol);
347
348        receive_state_events(&room, vec![b_empty_membership, c_empty_membership]);
349
350        // We have no active call anymore after emptying the memberships
351        assert_eq!(Vec::<OwnedUserId>::new(), room.active_room_call_participants());
352        assert!(!room.has_active_room_call());
353    }
354
355    fn consensus_setup(
356        alice_intent: Option<CallIntent>,
357        bob_intent: Option<CallIntent>,
358        carol_intent: Option<CallIntent>,
359    ) -> Vec<Raw<AnySyncStateEvent>> {
360        let alice_membership = session_member_state_event_with_intent(
361            event_id!("$1"),
362            User::Alice,
363            InitData { device_id: device_id!("AAA0"), minutes_ago: 1 }.into(),
364            alice_intent,
365        );
366        let bob_membership = session_member_state_event_with_intent(
367            event_id!("$1"),
368            User::Bob,
369            InitData { device_id: device_id!("BAA0"), minutes_ago: 1 }.into(),
370            bob_intent,
371        );
372        let carol_membership = session_member_state_event_with_intent(
373            event_id!("$2"),
374            User::Carol,
375            InitData { device_id: device_id!("CAA0"), minutes_ago: 1 }.into(),
376            carol_intent,
377        );
378        vec![alice_membership, bob_membership, carol_membership]
379    }
380
381    #[test]
382    fn test_consensus_intent() {
383        let test_cases = vec![
384            // (alice_intent, bob_intent, carol_intent, expected_consensus, description)
385            (None, None, None, CallIntentConsensus::None, "no intents"),
386            (
387                Some(CallIntent::Audio),
388                None,
389                None,
390                CallIntentConsensus::Partial {
391                    intent: CallIntent::Audio,
392                    agreeing_count: 1,
393                    total_count: 3,
394                },
395                "one intent 1",
396            ),
397            (
398                None,
399                Some(CallIntent::Audio),
400                None,
401                CallIntentConsensus::Partial {
402                    intent: CallIntent::Audio,
403                    agreeing_count: 1,
404                    total_count: 3,
405                },
406                "one intent 2",
407            ),
408            (
409                None,
410                None,
411                Some(CallIntent::Audio),
412                CallIntentConsensus::Partial {
413                    intent: CallIntent::Audio,
414                    agreeing_count: 1,
415                    total_count: 3,
416                },
417                "one intent 3",
418            ),
419            (
420                None,
421                None,
422                Some(CallIntent::Video),
423                CallIntentConsensus::Partial {
424                    intent: CallIntent::Video,
425                    agreeing_count: 1,
426                    total_count: 3,
427                },
428                "one intent 4",
429            ),
430            (
431                None,
432                Some(CallIntent::Video),
433                Some(CallIntent::Video),
434                CallIntentConsensus::Partial {
435                    intent: CallIntent::Video,
436                    agreeing_count: 2,
437                    total_count: 3,
438                },
439                "two matching intents",
440            ),
441            (
442                Some(CallIntent::Video),
443                Some(CallIntent::Video),
444                Some(CallIntent::Video),
445                CallIntentConsensus::Full(CallIntent::Video),
446                "all agree",
447            ),
448            (
449                Some(CallIntent::Video),
450                None,
451                Some(CallIntent::Audio),
452                CallIntentConsensus::None,
453                "disagreement",
454            ),
455            (
456                Some(CallIntent::Video),
457                Some(CallIntent::Video),
458                Some(CallIntent::Audio),
459                CallIntentConsensus::None,
460                "disagreement 2",
461            ),
462        ];
463
464        for (alice, bob, carol, expected, description) in test_cases {
465            let (_, room) = make_room_test_helper(RoomState::Joined);
466            receive_state_events(&room, consensus_setup(alice, bob, carol));
467            let consensus_intent = room.active_room_call_consensus_intent();
468            assert_eq!(expected, consensus_intent, "Failed case: {}", description);
469        }
470    }
471
472    #[test]
473    fn test_is_device_in_active_room_call_matches_own_device_only() {
474        let (_, room) = make_room_test_helper(RoomState::Joined);
475
476        let alice_a = session_member_state_event(
477            event_id!("$alice_a"),
478            User::Alice,
479            Some(InitData { device_id: device_id!("DEVICE_A"), minutes_ago: 1 }),
480        );
481        let alice_b = session_member_state_event(
482            event_id!("$alice_b"),
483            User::Alice,
484            Some(InitData { device_id: device_id!("DEVICE_B"), minutes_ago: 1 }),
485        );
486        let bob_a = session_member_state_event(
487            event_id!("$bob_a"),
488            User::Bob,
489            Some(InitData { device_id: device_id!("DEVICE_A"), minutes_ago: 1 }),
490        );
491
492        receive_state_events(&room, vec![alice_a, alice_b, bob_a]);
493
494        assert!(room.is_device_in_active_room_call(User::Alice.into(), device_id!("DEVICE_A")));
495        assert!(room.is_device_in_active_room_call(User::Alice.into(), device_id!("DEVICE_B")));
496        assert!(!room.is_device_in_active_room_call(User::Alice.into(), device_id!("DEVICE_C")));
497        assert!(room.is_device_in_active_room_call(User::Bob.into(), device_id!("DEVICE_A")));
498        assert!(!room.is_device_in_active_room_call(User::Bob.into(), device_id!("DEVICE_B")));
499    }
500}