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