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