diff --git a/Cargo.toml b/Cargo.toml index 10f6e9ab6b7..5eaae246718 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,7 +83,8 @@ ruma = { git = "https://github.com/ruma/ruma", rev = "57049282e3a74f67f86e4eb238 "unstable-msc4278", "unstable-msc4286", "unstable-msc4306", - "unstable-msc4308" + "unstable-msc4308", + "unstable-msc4310" ] } sentry = { version = "0.42.0", default-features = false } sentry-tracing = "0.42.0" diff --git a/bindings/matrix-sdk-ffi/CHANGELOG.md b/bindings/matrix-sdk-ffi/CHANGELOG.md index c5e0c8b9d0e..d0f16a7c2ea 100644 --- a/bindings/matrix-sdk-ffi/CHANGELOG.md +++ b/bindings/matrix-sdk-ffi/CHANGELOG.md @@ -33,6 +33,8 @@ All notable changes to this project will be documented in this file. This is primarily for Element X to give a dedicated error message in case it connects a homeserver with only this method available. ([#5222](https://github.com/matrix-org/matrix-rust-sdk/pull/5222)) +- Add new API to decline calls ([MSC4310](https://github.com/matrix-org/matrix-spec-proposals/pull/4310)): `Room::decline_call` and `Room::subscribe_to_call_decline_events` + ([#5614](https://github.com/matrix-org/matrix-rust-sdk/pull/5614)) ### Breaking changes: diff --git a/bindings/matrix-sdk-ffi/src/error.rs b/bindings/matrix-sdk-ffi/src/error.rs index 746d9c9fb1a..e6e9322102d 100644 --- a/bindings/matrix-sdk-ffi/src/error.rs +++ b/bindings/matrix-sdk-ffi/src/error.rs @@ -5,7 +5,7 @@ use matrix_sdk::{ encryption::{identities::RequestVerificationError, CryptoStoreError}, event_cache::EventCacheError, reqwest, - room::edit::EditError, + room::{calls::CallError, edit::EditError}, send_queue::RoomSendQueueError, HttpError, IdParseError, NotificationSettingsError as SdkNotificationSettingsError, QueueWedgeError as SdkQueueWedgeError, StoreError, @@ -187,6 +187,12 @@ impl From for ClientError { } } +impl From for ClientError { + fn from(e: CallError) -> Self { + Self::from_err(e) + } +} + impl From for ClientError { fn from(e: RoomSendQueueError) -> Self { Self::from_err(e) diff --git a/bindings/matrix-sdk-ffi/src/room/mod.rs b/bindings/matrix-sdk-ffi/src/room/mod.rs index a5151efbcb7..8a0c626ef74 100644 --- a/bindings/matrix-sdk-ffi/src/room/mod.rs +++ b/bindings/matrix-sdk-ffi/src/room/mod.rs @@ -1006,6 +1006,44 @@ impl Room { Ok(()) } + /// Declines a call (and stop ringing). + /// + /// # Arguments + /// + /// * `rtc_notification_event_id` - the event id of the m.rtc.notification + /// event. + pub async fn decline_call(&self, rtc_notification_event_id: String) -> Result<(), ClientError> { + let parsed_id = EventId::parse(rtc_notification_event_id.as_str())?; + + let content = self.inner.make_decline_call_event(&parsed_id).await?; + + self.inner.send_queue().send(content.into()).await?; + + Ok(()) + } + + /// Subscribes to call decline for a currently ringing call, using a + /// `listener` to be notified when someone declines. + /// + /// Will error if `rtc_notification_event_id` is not a valid event id. + /// Use the [`TaskHandle`] to cancel the subscription. + pub fn subscribe_to_call_decline_events( + self: Arc, + rtc_notification_event_id: String, + listener: Box, + ) -> Result, ClientError> { + let parsed_id = EventId::parse(rtc_notification_event_id.as_str())?; + + Ok(Arc::new(TaskHandle::new(get_runtime_handle().spawn(async move { + let (_event_handler_drop_guard, mut subscriber) = + self.inner.subscribe_to_call_decline_events(&parsed_id); + + while let Ok(user_id) = subscriber.recv().await { + listener.call(user_id.to_string()); + } + })))) + } + /// Subscribes to live location shares in this room, using a `listener` to /// be notified of the changes. /// @@ -1153,6 +1191,12 @@ pub trait LiveLocationShareListener: SyncOutsideWasm + SendOutsideWasm { fn call(&self, live_location_shares: Vec); } +/// A listener for receiving call decline events in a room. +#[matrix_sdk_ffi_macros::export(callback_interface)] +pub trait CallDeclineListener: SyncOutsideWasm + SendOutsideWasm { + fn call(&self, decliner_user_id: String); +} + impl From for KnockRequest { fn from(request: matrix_sdk::room::knock_requests::KnockRequest) -> Self { Self { diff --git a/crates/matrix-sdk/CHANGELOG.md b/crates/matrix-sdk/CHANGELOG.md index c0f4b5d91df..7b6ee484ff8 100644 --- a/crates/matrix-sdk/CHANGELOG.md +++ b/crates/matrix-sdk/CHANGELOG.md @@ -44,6 +44,8 @@ All notable changes to this project will be documented in this file. - Add support to accept historic room key bundles that arrive out of order, i.e. the bundle arrives after the invite has already been accepted. ([#5322](https://github.com/matrix-org/matrix-rust-sdk/pull/5322)) +- Add new API to decline calls ([MSC4310](https://github.com/matrix-org/matrix-spec-proposals/pull/4310)): `Room::make_decline_call_event` and `Room::subscribe_to_call_decline_events` + ([#5614](https://github.com/matrix-org/matrix-rust-sdk/pull/5614)) - [**breaking**] `OAuth::login` now allows requesting additional scopes for the authorization code grant. ([#5395](https://github.com/matrix-org/matrix-rust-sdk/pull/5395)) diff --git a/crates/matrix-sdk/src/room/calls.rs b/crates/matrix-sdk/src/room/calls.rs new file mode 100644 index 00000000000..d7e6bf2cff7 --- /dev/null +++ b/crates/matrix-sdk/src/room/calls.rs @@ -0,0 +1,163 @@ +// Copyright 2025 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Facilities to handle incoming calls. + +use ruma::{ + events::{ + rtc::decline::{RtcDeclineEventContent, SyncRtcDeclineEvent}, + AnySyncMessageLikeEvent, AnySyncTimelineEvent, + }, + EventId, OwnedUserId, UserId, +}; +use thiserror::Error; +use tokio::sync::broadcast; +use tracing::instrument; + +use crate::{event_handler::EventHandlerDropGuard, room::EventSource, Room}; + +/// An error occurring while interacting with a call/rtc event. +#[derive(Debug, Error)] +pub enum CallError { + /// We couldn't fetch the remote notification event. + #[error("Couldn't fetch the remote event: {0}")] + Fetch(Box), + + /// We tried to decline an event which is not of type m.rtc.notification. + #[error("You cannot decline this event type.")] + BadEventType, + + /// We tried to decline a call started by ourselves. + #[error("You cannot decline your own call.")] + DeclineOwnCall, + + /// We couldn't properly deserialize the target event. + #[error(transparent)] + Deserialize(#[from] serde_json::Error), +} + +impl Room { + /// Create a new decline call event for the target notification event id . + /// + /// The event can then be sent with [`Room::send`] or a + /// [`crate::send_queue::RoomSendQueue`]. + #[instrument(skip(self), fields(room = %self.room_id()))] + pub async fn make_decline_call_event( + &self, + notification_event_id: &EventId, + ) -> Result { + make_call_decline_event(self, self.own_user_id(), notification_event_id).await + } + + /// Subscribe to decline call event for this room. + /// + /// The returned receiver will receive the sender UserID for each decline + /// for the matching notify event. + /// Example: + /// - A push is received for an `m.call.notify` event. + /// - The app starts ringing on this device. + /// - The app subscribes to decline events for that notify event and stops + /// ringing if another device declines the call. + /// + /// In case of outgoing call, you can subscribe to see if your call was + /// denied from the other side. + /// + /// ```rust + /// # async fn start_ringing() {} + /// # async fn stop_ringing() {} + /// # async fn show_incoming_call_ui() {} + /// # async fn dismiss_incoming_call_ui() {} + /// # + /// # async fn on_push_for_call_notify(room: matrix_sdk::Room, notify_event_id: &ruma::EventId) { + /// // 1) We just received a push for an `m.call.notify` in `room`. + /// show_incoming_call_ui().await; + /// start_ringing().await; + /// + /// // 2) Subscribe to declines for this notify event, in case the call is declined from another device. + /// let (drop_guard, mut declines) = room.subscribe_to_call_decline_events(notify_event_id); + /// + /// // Keep the subscription alive while we wait for a decline. + /// // You might store `drop_guard` alongside your call state. + /// tokio::spawn(async move { + /// loop { + /// match declines.recv().await { + /// Ok(_decliner) => { + /// // 3) Check the mxID -> I declined this call from another device. + /// stop_ringing().await; + /// dismiss_incoming_call_ui().await; + /// // Exiting ends the task; dropping the guard unsubscribes the handler. + /// drop(drop_guard); + /// break; + /// } + /// Err(broadcast_err) => { + /// // Channel closed or lagged; stop waiting. + /// // In practice you might want to handle Lagged specifically. + /// eprintln!("decline subscription ended: {broadcast_err}"); + /// drop(drop_guard); + /// break; + /// } + /// } + /// } + /// }); + /// # } + /// ``` + pub fn subscribe_to_call_decline_events( + &self, + notification_event_id: &EventId, + ) -> (EventHandlerDropGuard, broadcast::Receiver) { + let (sender, receiver) = broadcast::channel(16); + + let decline_call_event_handler_handle = + self.client.add_room_event_handler(self.room_id(), { + let own_notification_event_id = notification_event_id.to_owned(); + move |event: SyncRtcDeclineEvent| async move { + // Ignore decline for other unrelated notification events. + if let Some(declined_event_id) = + event.as_original().map(|ev| ev.content.relates_to.event_id.clone()) + { + if declined_event_id == own_notification_event_id { + let _ = sender.send(event.sender().to_owned()); + } + } + } + }); + let drop_guard = self.client().event_handler_drop_guard(decline_call_event_handler_handle); + (drop_guard, receiver) + } +} + +async fn make_call_decline_event( + room: &Room, + own_user_id: &UserId, + notification_event_id: &EventId, +) -> Result { + let target = room + .get_event(notification_event_id) + .await + .map_err(|err| CallError::Fetch(Box::new(err)))?; + + let event = target.raw().deserialize().map_err(CallError::Deserialize)?; + + // The event must be CallNotify-like. + if let AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::CallNotify(notify)) = event { + if notify.sender() == own_user_id { + // Cannot decline own call. + Err(CallError::DeclineOwnCall) + } else { + Ok(RtcDeclineEventContent::new(notification_event_id)) + } + } else { + Err(CallError::BadEventType) + } +} diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index 179bf08c799..eb434df4c7a 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -189,6 +189,8 @@ mod messages; pub mod power_levels; pub mod reply; +pub mod calls; + /// Contains all the functionality for modifying the privacy settings in a room. pub mod privacy_settings; diff --git a/crates/matrix-sdk/src/widget/settings/element_call.rs b/crates/matrix-sdk/src/widget/settings/element_call.rs index cb0905c7b63..ff33241efa2 100644 --- a/crates/matrix-sdk/src/widget/settings/element_call.rs +++ b/crates/matrix-sdk/src/widget/settings/element_call.rs @@ -82,7 +82,8 @@ struct ElementCallParams { /// Defines if a call is encrypted and which encryption system should be used. /// /// This controls the url parameters: `perParticipantE2EE`, `password`. -#[derive(Debug, PartialEq, Default, uniffi::Enum, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[derive(Debug, PartialEq, Default, Clone)] pub enum EncryptionSystem { /// Equivalent to the element call url parameter: `perParticipantE2EE=false` /// and no password. @@ -102,7 +103,8 @@ pub enum EncryptionSystem { /// Defines the intent of showing the call. /// /// This controls whether to show or skip the lobby. -#[derive(Debug, PartialEq, Serialize, Default, uniffi::Enum, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[derive(Debug, PartialEq, Serialize, Default, Clone)] #[serde(rename_all = "snake_case")] pub enum Intent { #[default] @@ -113,7 +115,8 @@ pub enum Intent { } /// Defines how (if) element-call renders a header. -#[derive(Debug, PartialEq, Serialize, Default, uniffi::Enum, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[derive(Debug, PartialEq, Serialize, Default, Clone)] #[serde(rename_all = "snake_case")] pub enum HeaderStyle { /// The normal header with branding. @@ -126,7 +129,8 @@ pub enum HeaderStyle { } /// Types of call notifications. -#[derive(Debug, PartialEq, Serialize, uniffi::Enum, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[derive(Debug, PartialEq, Serialize, Clone)] #[serde(rename_all = "snake_case")] pub enum NotificationType { /// The receiving client should display a visual notification. @@ -136,7 +140,8 @@ pub enum NotificationType { } /// Properties to create a new virtual Element Call widget. -#[derive(Debug, Default, uniffi::Record, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug, Default, Clone)] pub struct VirtualElementCallWidgetOptions { /// The url to the app. /// diff --git a/crates/matrix-sdk/tests/integration/room/calls.rs b/crates/matrix-sdk/tests/integration/room/calls.rs new file mode 100644 index 00000000000..57cdd2ed60d --- /dev/null +++ b/crates/matrix-sdk/tests/integration/room/calls.rs @@ -0,0 +1,159 @@ +use std::sync::{Arc, Mutex}; + +use assert_matches2::assert_matches; +use matrix_sdk::{room::calls::CallError, test_utils::mocks::MatrixMockServer}; +use matrix_sdk_test::{async_test, event_factory::EventFactory, JoinedRoomBuilder, StateTestEvent}; +use ruma::{owned_event_id, room_id, user_id, OwnedUserId}; +use tokio::spawn; + +#[async_test] +async fn test_subscribe_to_decline_call_events() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let decliners_sequences: Arc>> = Arc::new(Mutex::new(Vec::new())); + let asserted_decliners = vec![user_id!("@bob:matrix.org"), user_id!("@carl:example.com")]; + + let room_id = room_id!("!test:example.org"); + + let notification_event_id = owned_event_id!("$00000:localhost"); + + let f = EventFactory::new(); + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_timeline_event( + f.call_notify( + "call_id".to_owned(), + ruma::events::call::notify::ApplicationType::Call, + ruma::events::call::notify::NotifyType::Ring, + ruma::events::Mentions::new(), + ) + .sender(user_id!("@alice:matrix.org")) + .event_id(¬ification_event_id), + ), + ) + .await; + + let room = server.sync_joined_room(&client, room_id).await; + + let to_subscribe_to = notification_event_id.clone(); + let join_handle = spawn({ + let decliners_sequences = Arc::clone(&decliners_sequences); + async move { + let (_drop_guard, mut subscriber) = + room.subscribe_to_call_decline_events(&to_subscribe_to); + + while let Ok(user_id) = subscriber.recv().await { + let mut decliners_sequences = decliners_sequences.lock().unwrap(); + decliners_sequences.push(user_id); + + // When we have received 2 typing notifications, we can stop listening. + if decliners_sequences.len() == 2 { + break; + } + } + } + }); + + let other_notification_event_id = owned_event_id!("$11111:localhost"); + + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id) + .add_timeline_event( + // declines another call + f.call_decline(&other_notification_event_id) + .sender(user_id!("@valere:matrix.org")), + ) + .add_timeline_event( + // declines another call + f.call_decline(¬ification_event_id).sender(user_id!("@bob:matrix.org")), + ) + .add_timeline_event( + // declines another call + f.call_decline(¬ification_event_id).sender(user_id!("@carl:example.com")), + ), + ) + .await; + + join_handle.await.unwrap(); + assert_eq!(decliners_sequences.lock().unwrap().to_vec(), asserted_decliners); +} + +#[async_test] +async fn test_decline_call() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + println!("client id is: {}", client.user_id().unwrap()); + let room_id = room_id!("!test:example.org"); + + let notification_event_id = owned_event_id!("$00000:localhost"); + let a_message_event_id = owned_event_id!("$00001:localhost"); + let unknown_event_id = owned_event_id!("$00002:localhost"); + let own_notification_event_id = owned_event_id!("$00003:localhost"); + + // Subscribe to the event cache (to avoid having to remotely fetch the related + // event) + let event_cache = client.event_cache(); + event_cache.subscribe().unwrap(); + + let f = EventFactory::new(); + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id) + .add_state_event(StateTestEvent::Encryption) + .add_timeline_event( + f.call_notify( + "call_id".to_owned(), + ruma::events::call::notify::ApplicationType::Call, + ruma::events::call::notify::NotifyType::Ring, + ruma::events::Mentions::new(), + ) + .sender(user_id!("@alice:matrix.org")) + .event_id(¬ification_event_id), + ) + .add_timeline_event( + f.call_notify( + "call_id_1".to_owned(), + ruma::events::call::notify::ApplicationType::Call, + ruma::events::call::notify::NotifyType::Ring, + ruma::events::Mentions::new(), + ) + .sender(user_id!("@example:localhost")) + .event_id(&own_notification_event_id), + ) + .add_timeline_event( + f.text_msg("Hello, HRU? ") + .event_id(&a_message_event_id) + .sender(user_id!("@alice:matrix.org")), + ), + ) + .await; + + let room = server.sync_joined_room(&client, room_id).await; + + // Declining an unknown call should fail + let result = room.make_decline_call_event(&unknown_event_id).await; + assert!(result.is_err()); + assert_matches!(result.unwrap_err(), CallError::Fetch(_)); + + // Declining a non call notification event should fail + let result = room.make_decline_call_event(&a_message_event_id).await; + assert!(result.is_err()); + assert_matches!(result.unwrap_err(), CallError::BadEventType); + + // Declining your own call notification event should fail + let result = room.make_decline_call_event(&own_notification_event_id).await; + assert!(result.is_err()); + assert_matches!(result.unwrap_err(), CallError::DeclineOwnCall); + + let event = + room.make_decline_call_event(¬ification_event_id).await.expect("Should not fail"); + + // try to queue + room.send_queue().send(event.into()).await.expect("Should not fail"); +} diff --git a/crates/matrix-sdk/tests/integration/room/mod.rs b/crates/matrix-sdk/tests/integration/room/mod.rs index 7232afabe3c..7323ae1b377 100644 --- a/crates/matrix-sdk/tests/integration/room/mod.rs +++ b/crates/matrix-sdk/tests/integration/room/mod.rs @@ -1,6 +1,7 @@ mod attachment; mod beacon; mod beacon_info; +mod calls; mod common; mod joined; mod left; diff --git a/testing/matrix-sdk-test/src/event_factory.rs b/testing/matrix-sdk-test/src/event_factory.rs index e8c6776533e..28cd57e255a 100644 --- a/testing/matrix-sdk-test/src/event_factory.rs +++ b/testing/matrix-sdk-test/src/event_factory.rs @@ -72,6 +72,7 @@ use ruma::{ tombstone::RoomTombstoneEventContent, topic::RoomTopicEventContent, }, + rtc::decline::RtcDeclineEventContent, space::{child::SpaceChildEventContent, parent::SpaceParentEventContent}, sticker::StickerEventContent, typing::TypingEventContent, @@ -1008,6 +1009,14 @@ impl EventFactory { self.event(CallNotifyEventContent::new(call_id, application, notify_type, mentions)) } + // Creates a new `org.matrix.msc4310.rtc.decline` event. + pub fn call_decline( + &self, + notification_event_id: &EventId, + ) -> EventBuilder { + self.event(RtcDeclineEventContent::new(notification_event_id)) + } + /// Create a new `m.direct` global account data event. pub fn direct(&self) -> EventBuilder { let mut builder = self.event(DirectEventContent::default());