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