diff --git a/Cargo.toml b/Cargo.toml index ffbaa20c7a2..905814fa186 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,8 @@ ruma = { git = "https://github.com/matrix-org/ruma", rev = "f25b3220d0c3ece77200 "compat-encrypted-stickers", "unstable-msc3401", "unstable-msc3266", + "unstable-msc3488", + "unstable-msc3489", "unstable-msc4075", "unstable-msc4140", ] } diff --git a/crates/matrix-sdk-base/src/rooms/mod.rs b/crates/matrix-sdk-base/src/rooms/mod.rs index 4d530b73f51..9035ca2be9d 100644 --- a/crates/matrix-sdk-base/src/rooms/mod.rs +++ b/crates/matrix-sdk-base/src/rooms/mod.rs @@ -18,6 +18,7 @@ pub use normal::{ use ruma::{ assign, events::{ + beacon_info::BeaconInfoEventContent, call::member::CallMemberEventContent, macros::EventContent, room::{ @@ -81,6 +82,9 @@ impl fmt::Display for DisplayName { pub struct BaseRoomInfo { /// The avatar URL of this room. pub(crate) avatar: Option>, + /// All shared live location beacons of this room. + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + pub(crate) beacons: BTreeMap>, /// The canonical alias of this room. pub(crate) canonical_alias: Option>, /// The `m.room.create` event content of this room. @@ -141,6 +145,9 @@ impl BaseRoomInfo { /// Returns true if the event modified the info, false otherwise. pub fn handle_state_event(&mut self, ev: &AnySyncStateEvent) -> bool { match ev { + AnySyncStateEvent::BeaconInfo(b) => { + self.beacons.insert(b.state_key().clone(), b.into()); + } // No redacted branch - enabling encryption cannot be undone. AnySyncStateEvent::RoomEncryption(SyncStateEvent::Original(encryption)) => { self.encryption = Some(encryption.content.clone()); @@ -335,6 +342,7 @@ impl Default for BaseRoomInfo { fn default() -> Self { Self { avatar: None, + beacons: BTreeMap::new(), canonical_alias: None, create: None, dm_targets: Default::default(), diff --git a/crates/matrix-sdk-base/src/store/migration_helpers.rs b/crates/matrix-sdk-base/src/store/migration_helpers.rs index fc2332fd7ca..89633af1a2b 100644 --- a/crates/matrix-sdk-base/src/store/migration_helpers.rs +++ b/crates/matrix-sdk-base/src/store/migration_helpers.rs @@ -199,6 +199,7 @@ impl BaseRoomInfoV1 { Box::new(BaseRoomInfo { avatar, + beacons: BTreeMap::new(), canonical_alias, create, dm_targets, diff --git a/crates/matrix-sdk/src/error.rs b/crates/matrix-sdk/src/error.rs index 79a9a6f887d..04ea4bd6257 100644 --- a/crates/matrix-sdk/src/error.rs +++ b/crates/matrix-sdk/src/error.rs @@ -467,6 +467,39 @@ pub enum ImageError { ThumbnailBiggerThanOriginal, } +/// Errors that can happen when interacting with the beacon API. +#[derive(Debug, Error)] +pub enum BeaconError { + // A network error occurred. + #[error("Network error: {0}")] + Network(#[from] HttpError), + + // The beacon information is not found. + #[error("Existing beacon information not found.")] + NotFound, + + // The redacted event is not an error, but it's not useful for the client. + #[error("Beacon event is redacted and cannot be processed.")] + Redacted, + + // The client must join the room to access the beacon information. + #[error("Must join the room to access beacon information.")] + Stripped, + + #[error("Deserialization error: {0}")] + Deserialization(#[from] serde_json::Error), + + // Allow for other errors to be wrapped. + #[error("Other error: {0}")] + Other(Box), +} + +impl From for BeaconError { + fn from(err: Error) -> Self { + BeaconError::Other(Box::new(err)) + } +} + /// Errors that can happen when refreshing an access token. /// /// This is usually only returned by [`Client::refresh_access_token()`], unless diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index 04a1635c75f..694ca325e3e 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -52,6 +52,7 @@ use ruma::{ }, assign, events::{ + beacon_info::BeaconInfoEventContent, call::notify::{ApplicationType, CallNotifyEventContent, NotifyType}, direct::DirectEventContent, marked_unread::MarkedUnreadEventContent, @@ -97,7 +98,7 @@ use crate::{ attachment::AttachmentConfig, client::WeakClient, config::RequestConfig, - error::WrongRoomState, + error::{BeaconError, WrongRoomState}, event_cache::{self, EventCacheDropHandles, RoomEventCache}, event_handler::{EventHandler, EventHandlerDropGuard, EventHandlerHandle, SyncEvent}, media::{MediaFormat, MediaRequest}, @@ -2668,6 +2669,69 @@ impl Room { Ok(()) } + /// Start sharing live location in the room. + /// + /// # Arguments + /// + /// * `duration_millis` - The duration for which the live location is + /// shared, in milliseconds. + /// * `description` - An optional description for the live location share. + /// + /// # Errors + /// + /// Returns an error if the room is not joined or if the state event could + /// not be sent. + pub async fn start_live_location_share( + &self, + duration_millis: u64, + description: Option, + ) -> Result { + self.ensure_room_joined()?; + + self.send_state_event_for_key( + self.own_user_id(), + BeaconInfoEventContent::new( + description, + Duration::from_millis(duration_millis), + true, + None, + ), + ) + .await + } + + /// Stop sharing live location in the room. + /// + /// # Errors + /// + /// Returns an error if the room is not joined, if the beacon information + /// is redacted or stripped, or if the state event is not found. + pub async fn stop_live_location_share( + &self, + ) -> Result { + self.ensure_room_joined()?; + + if let Some(raw_event) = self + .get_state_event_static_for_key::(self.own_user_id()) + .await? + { + match raw_event.deserialize() { + Ok(SyncOrStrippedState::Sync(SyncStateEvent::Original(beacon_info))) => { + let mut content = beacon_info.content.clone(); + content.stop(); + Ok(self.send_state_event_for_key(self.own_user_id(), content).await?) + } + Ok(SyncOrStrippedState::Sync(SyncStateEvent::Redacted(_))) => { + Err(BeaconError::Redacted) + } + Ok(SyncOrStrippedState::Stripped(_)) => Err(BeaconError::Stripped), + Err(e) => Err(BeaconError::Deserialization(e)), + } + } else { + Err(BeaconError::NotFound) + } + } + /// Send a call notification event in the current room. /// /// This is only supposed to be used in **custom** situations where the user diff --git a/crates/matrix-sdk/tests/integration/room/joined.rs b/crates/matrix-sdk/tests/integration/room/joined.rs index 865870dcbe8..71cb1d3b73b 100644 --- a/crates/matrix-sdk/tests/integration/room/joined.rs +++ b/crates/matrix-sdk/tests/integration/room/joined.rs @@ -4,12 +4,13 @@ use std::{ }; use futures_util::future::join_all; +use js_int::uint; use matrix_sdk::{ config::SyncSettings, room::{edit::EditedContent, Receipts, ReportedContentScore, RoomMemberRole}, test_utils::events::EventFactory, }; -use matrix_sdk_base::RoomState; +use matrix_sdk_base::{deserialized_responses::AnySyncOrStrippedState, RoomState}; use matrix_sdk_test::{ async_test, test_json, test_json::sync::CUSTOM_ROOM_POWER_LEVELS, EphemeralTestEvent, GlobalAccountDataTestEvent, JoinedRoomBuilder, SyncResponseBuilder, DEFAULT_TEST_ROOM_ID, @@ -18,11 +19,13 @@ use ruma::{ api::client::{membership::Invite3pidInit, receipt::create_receipt::v3::ReceiptType}, assign, event_id, events::{ + location::AssetType, receipt::ReceiptThread, room::message::{RoomMessageEventContent, RoomMessageEventContentWithoutRelation}, - TimelineEventType, + AnySyncStateEvent, StateEventType, TimelineEventType, }, - int, mxc_uri, owned_event_id, room_id, thirdparty, user_id, OwnedUserId, TransactionId, + int, mxc_uri, owned_event_id, room_id, thirdparty, user_id, MilliSecondsSinceUnixEpoch, + OwnedUserId, TransactionId, }; use serde_json::{json, Value}; use wiremock::{ @@ -768,3 +771,235 @@ async fn test_make_reply_event_doesnt_require_event_cache() { // make_edit_event works, even if the event cache hasn't been enabled. room.make_edit_event(resp_event_id, EditedContent::RoomMessage(new_content)).await.unwrap(); } + +#[async_test] +async fn test_start_live_location_share_for_room() { + let (client, server) = logged_in_client_with_server().await; + + // Validate request body and response, partial body matching due to + // auto-generated `org.matrix.msc3488.ts`. + Mock::given(method("PUT")) + .and(path_regex(r"^/_matrix/client/r0/rooms/.*/state/org.matrix.msc3672.beacon_info/.*")) + .and(header("authorization", "Bearer 1234")) + .and(body_partial_json(json!({ + "description": "Live Share", + "live": true, + "timeout": 3000, + "org.matrix.msc3488.asset": { "type": "m.self" } + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::EVENT_ID)) + .mount(&server) + .await; + + let sync_settings = SyncSettings::new().timeout(Duration::from_millis(3000)); + + mock_sync(&server, &*test_json::SYNC, None).await; + + client.sync_once(sync_settings.clone()).await.unwrap(); + + let room = client.get_room(&DEFAULT_TEST_ROOM_ID).unwrap(); + + let response = + room.start_live_location_share(3000, Some("Live Share".to_owned())).await.unwrap(); + + assert_eq!(event_id!("$h29iv0s8:example.com"), response.event_id); + server.reset().await; + + mock_sync( + &server, + json!({ + "next_batch": "s526_47314_0_7_1_1_1_1_1", + "rooms": { + "join": { + *DEFAULT_TEST_ROOM_ID: { + "state": { + "events": [ + { + "content": { + "description": "Live Share", + "live": true, + "org.matrix.msc3488.ts": 1_636_829_458, + "timeout": 3000, + "org.matrix.msc3488.asset": { "type": "m.self" } + }, + "event_id": "$15139375514XsgmR:localhost", + "origin_server_ts": 1_636_829_458, + "sender": "@example:localhost", + "state_key": "@example:localhost", + "type": "org.matrix.msc3672.beacon_info", + "unsigned": { + "age": 7034220 + } + }, + ] + } + } + } + } + + }), + None, + ) + .await; + + client.sync_once(sync_settings.clone()).await.unwrap(); + server.reset().await; + + let state_events = room.get_state_events(StateEventType::BeaconInfo).await.unwrap(); + assert_eq!(state_events.len(), 1); + + let raw_event = state_events.first().expect("There should be a beacon_info state event"); + + let ev = match raw_event.deserialize().expect("Failed to deserialize event") { + AnySyncOrStrippedState::Sync(AnySyncStateEvent::BeaconInfo(ev)) => ev, + _ => panic!("Expected a BeaconInfo event"), + }; + + let content = ev.as_original().unwrap().content.clone(); + + assert_eq!(ev.sender(), room.own_user_id()); + assert_eq!(ev.state_key(), "@example:localhost"); + assert_eq!(ev.event_id(), event_id!("$15139375514XsgmR:localhost")); + assert_eq!(ev.event_type(), StateEventType::BeaconInfo); + assert_eq!(ev.origin_server_ts(), MilliSecondsSinceUnixEpoch(uint!(1_636_829_458))); + + assert_eq!(content.description, Some("Live Share".to_owned())); + assert_eq!(content.timeout, Duration::from_millis(3000)); + assert_eq!(content.ts, MilliSecondsSinceUnixEpoch(uint!(1_636_829_458))); + assert_eq!(content.asset.type_, AssetType::Self_); + + assert!(content.live); +} + +#[async_test] +async fn test_stop_sharing_live_location() { + let (client, server) = logged_in_client_with_server().await; + + // Validate request body and response, partial body matching due to + // auto-generated `org.matrix.msc3488.ts`. + Mock::given(method("PUT")) + .and(path_regex(r"^/_matrix/client/r0/rooms/.*/state/org.matrix.msc3672.beacon_info/.*")) + .and(header("authorization", "Bearer 1234")) + .and(body_partial_json(json!({ + "description": "Live Share", + "live": false, + "timeout": 3000, + "org.matrix.msc3488.asset": { "type": "m.self" } + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::EVENT_ID)) + .mount(&server) + .await; + + let sync_settings = SyncSettings::new().timeout(Duration::from_millis(3000)); + + mock_sync( + &server, + json!({ + "next_batch": "s526_47314_0_7_1_1_1_1_1", + "rooms": { + "join": { + *DEFAULT_TEST_ROOM_ID: { + "state": { + "events": [ + { + "content": { + "description": "Live Share", + "live": true, + "org.matrix.msc3488.ts": 1_636_829_458, + "timeout": 3000, + "org.matrix.msc3488.asset": { "type": "m.self" } + }, + "event_id": "$15139375514XsgmR:localhost", + "origin_server_ts": 1_636_829_458, + "sender": "@example:localhost", + "state_key": "@example:localhost", + "type": "org.matrix.msc3672.beacon_info", + "unsigned": { + "age": 7034220 + } + }, + ] + } + } + } + } + + }), + None, + ) + .await; + + client.sync_once(sync_settings.clone()).await.unwrap(); + + let room = client.get_room(&DEFAULT_TEST_ROOM_ID).unwrap(); + + let response = room.stop_live_location_share().await.unwrap(); + + assert_eq!(event_id!("$h29iv0s8:example.com"), response.event_id); + server.reset().await; + + mock_sync( + &server, + json!({ + "next_batch": "s526_47314_1_7_1_1_1_1_1", + "rooms": { + "join": { + *DEFAULT_TEST_ROOM_ID: { + "state": { + "events": [ + { + "content": { + "description": "Live Share", + "live": false, + "org.matrix.msc3488.ts": 1_636_829_458, + "timeout": 3000, + "org.matrix.msc3488.asset": { "type": "m.self" } + }, + "event_id": "$15139375514XsgmR:localhost", + "origin_server_ts": 1_636_829_458, + "sender": "@example:localhost", + "state_key": "@example:localhost", + "type": "org.matrix.msc3672.beacon_info", + "unsigned": { + "age": 7034220 + } + }, + ] + } + } + } + } + + }), + None, + ) + .await; + + client.sync_once(sync_settings.clone()).await.unwrap(); + server.reset().await; + + let state_events = room.get_state_events(StateEventType::BeaconInfo).await.unwrap(); + assert_eq!(state_events.len(), 1); + + let raw_event = state_events.first().expect("There should be a beacon_info state event"); + + let ev = match raw_event.deserialize().expect("Failed to deserialize event") { + AnySyncOrStrippedState::Sync(AnySyncStateEvent::BeaconInfo(ev)) => ev, + _ => panic!("Expected a BeaconInfo event"), + }; + + let content = ev.as_original().unwrap().content.clone(); + + assert_eq!(ev.sender(), room.own_user_id()); + assert_eq!(ev.state_key(), "@example:localhost"); + assert_eq!(ev.event_id(), event_id!("$15139375514XsgmR:localhost")); + assert_eq!(ev.event_type(), StateEventType::BeaconInfo); + assert_eq!(ev.origin_server_ts(), MilliSecondsSinceUnixEpoch(uint!(1_636_829_458))); + + assert_eq!(content.description, Some("Live Share".to_owned())); + assert_eq!(content.timeout, Duration::from_millis(3000)); + assert_eq!(content.ts, MilliSecondsSinceUnixEpoch(uint!(1_636_829_458))); + assert_eq!(content.asset.type_, AssetType::Self_); + + assert!(!content.live); +}