matrix_sdk/automatic_call_status.rs
1// Copyright 2026 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
15//! Automatic mirroring of this device's MatrixRTC participation into the
16//! [MSC4426] `m.call` profile field.
17//!
18//! [MSC4426]: https://github.com/matrix-org/matrix-spec-proposals/pull/4426
19
20use std::{
21 collections::HashSet,
22 sync::{Arc, Mutex},
23 time::SystemTime,
24};
25
26use matrix_sdk_common::executor::spawn;
27use ruma::{
28 OwnedRoomId, SecondsSinceUnixEpoch,
29 events::{OriginalSyncStateEvent, call::member::CallMemberEventContent},
30};
31use tracing::warn;
32
33use crate::{Client, Room, client::WeakClient, event_handler::EventHandlerHandle};
34
35/// Owns the `m.call.member` event handler for auto-syncing the `m.call` profile
36/// field. Dropping this struct deregisters the handler. Holds a [`WeakClient`]
37/// rather than a strong `Client` to avoid a reference cycle.
38pub(crate) struct AutomaticCallStatus {
39 handle: EventHandlerHandle,
40 client: WeakClient,
41}
42
43/// Rooms in which this device is currently participating in an active MatrixRTC
44/// call. Maintained incrementally from `m.call.member` events.
45type ActiveCallRooms = Arc<Mutex<HashSet<OwnedRoomId>>>;
46
47impl Client {
48 /// Enable or disable automatic mirroring of this device's MatrixRTC
49 /// participation into the [MSC4426] `m.call` profile field. Off by default.
50 ///
51 /// Toggling `false -> true` registers a typed event handler for
52 /// `m.call.member` state events. Toggling `true -> false` deregisters it.
53 ///
54 /// Toggling `true -> false` does NOT clear `m.call` on the server, you
55 /// should call [`crate::Account::clear_call`] explicitly if that is
56 /// desired.
57 ///
58 /// [MSC4426]: https://github.com/matrix-org/matrix-spec-proposals/pull/4426
59 pub fn enable_automatic_call_status(&self, enabled: bool) {
60 let mut automatic_call_status = self.inner.automatic_call_status.lock().unwrap();
61 match (enabled, automatic_call_status.is_some()) {
62 (true, false) => {
63 *automatic_call_status = Some(AutomaticCallStatus::new(self));
64 }
65 (false, true) => *automatic_call_status = None,
66 _ => {}
67 }
68 }
69}
70
71impl AutomaticCallStatus {
72 fn new(client: &Client) -> Self {
73 // Start empty: `m.call` is shared across the user's devices, so we
74 // deliberately don't reconcile from current room state on start-up to
75 // avoid stomping on a status set by another device. The trade-off is
76 // that a crash/kill while on a call leaves `m.call` set until the user
77 // clears it manually.
78 let rooms: ActiveCallRooms = Arc::new(Mutex::new(HashSet::new()));
79 let handle = client.add_event_handler(
80 async move |event: OriginalSyncStateEvent<CallMemberEventContent>,
81 room: Room,
82 client: Client| {
83 on_event(&rooms, event, room, client);
84 },
85 );
86 let weak_client = WeakClient::from_client(client);
87 Self { handle, client: weak_client }
88 }
89}
90
91impl Drop for AutomaticCallStatus {
92 fn drop(&mut self) {
93 if let Some(client) = self.client.get() {
94 client.remove_event_handler(self.handle.clone());
95 }
96 }
97}
98
99fn on_event(
100 rooms: &ActiveCallRooms,
101 event: OriginalSyncStateEvent<CallMemberEventContent>,
102 room: Room,
103 client: Client,
104) {
105 let Some(own_user_id) = client.user_id() else { return };
106 let Some(own_device_id) = client.device_id() else { return };
107
108 // Ignore events for other users' memberships.
109 if event.state_key.user_id() != own_user_id {
110 return;
111 }
112
113 // Update the aggregate for this room, then return early if the "in any
114 // call" boolean didn't flip.
115 let is_device_in_room_call = room.is_device_in_active_room_call(own_user_id, own_device_id);
116 let room_id = room.room_id().to_owned();
117 let (was_in_call, now_in_call) = {
118 let mut rooms = rooms.lock().unwrap();
119 let was_in_call = !rooms.is_empty();
120 if is_device_in_room_call {
121 rooms.insert(room_id);
122 } else {
123 rooms.remove(&room_id);
124 }
125 (was_in_call, !rooms.is_empty())
126 };
127 // Return early if no change.
128 if was_in_call == now_in_call {
129 return;
130 }
131 let active_call_rooms = rooms.clone();
132 spawn(async move {
133 let in_call = !active_call_rooms.lock().unwrap().is_empty();
134 let result = if in_call {
135 let joined_ts = SecondsSinceUnixEpoch::from_system_time(SystemTime::now());
136 client.account().set_call(joined_ts).await
137 } else {
138 client.account().clear_call().await
139 };
140 if let Err(error) = result {
141 warn!(?error, in_call, "m.call auto-sync request failed");
142 }
143 });
144}