diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 9e1cd449c..c0d671046 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -1178,6 +1178,65 @@ int dc_send_webxdc_status_update (dc_context_t* context, uint32_t msg_id, const */ char* dc_get_webxdc_status_updates (dc_context_t* context, uint32_t msg_id, uint32_t serial); + +/** + * Set Webxdc file as integration. + * see dc_init_webxdc_integration() for more details about Webxdc integrations. + * + * @warning This is an experimental API which may change in the future + * + * @memberof dc_context_t + * @param context The context object. + * @param file The .xdc file to use as Webxdc integration. + */ +void dc_set_webxdc_integration (dc_context_t* context, const char* file); + + +/** + * Init a Webxdc integration. + * + * A Webxdc integration is + * a Webxdc showing a map, getting locations via setUpdateListener(), setting POIs via sendUpdate(); + * core takes eg. care of feeding locations to the Webxdc or sending the data out. + * + * @warning This is an experimental API, esp. support of integration types (eg. image editor, tools) is left out for simplicity + * + * Currently, Webxdc integrations are .xdc files shipped together with the main app. + * Before dc_init_webxdc_integration() can be called, + * UI has to call dc_set_webxdc_integration() to define a .xdc file to be used as integration. + * + * dc_init_webxdc_integration() returns a Webxdc message ID that + * UI can open and use mostly as usual. + * + * Concrete behaviour and status updates depend on the integration, driven by UI needs. + * + * There is no need to de-initialize the integration, + * however, unless documented otherwise, + * the integration is valid only as long as not re-initialized + * In other words, UI must not have a Webxdc with the same integration open twice. + * + * Example: + * + * ~~~ + * // Define a .xdc file to be used as maps integration + * dc_set_webxdc_integration(context, path_to_maps_xdc); + * + * // Integrate the map to a chat, the map will show locations for this chat then: + * uint32_t webxdc_instance = dc_init_webxdc_integration(context, any_chat_id); + * + * // Or use the Webxdc as a global map, showing locations of all chats: + * uint32_t webxdc_instance = dc_init_webxdc_integration(context, 0); + * ~~~ + * + * @memberof dc_context_t + * @param context The context object. + * @param chat_id The chat to get the integration for. + * @return ID of the message that refers to the Webxdc instance. + * UI can open a Webxdc as usual with this instance. + */ +uint32_t dc_init_webxdc_integration (dc_context_t* context, uint32_t chat_id); + + /** * Save a draft for a chat in the database. * @@ -4107,7 +4166,6 @@ char* dc_msg_get_webxdc_blob (const dc_msg_t* msg, const char* * true if the Webxdc should get full internet access, including Webrtc. * currently, this is only true for encrypted Webxdc's in the self chat * that have requested internet access in the manifest. - * this is useful for development and maybe for internal integrations at some point. * * @memberof dc_msg_t * @param msg The webxdc instance. diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index a1011908a..4f691267b 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -1063,6 +1063,43 @@ pub unsafe extern "C" fn dc_get_webxdc_status_updates( .strdup() } +#[no_mangle] +pub unsafe extern "C" fn dc_set_webxdc_integration( + context: *mut dc_context_t, + file: *const libc::c_char, +) { + if context.is_null() || file.is_null() { + eprintln!("ignoring careless call to dc_set_webxdc_integration()"); + return; + } + let ctx = &*context; + block_on(ctx.set_webxdc_integration(&to_string_lossy(file))) + .log_err(ctx) + .unwrap_or_default(); +} + +#[no_mangle] +pub unsafe extern "C" fn dc_init_webxdc_integration( + context: *mut dc_context_t, + chat_id: u32, +) -> u32 { + if context.is_null() { + eprintln!("ignoring careless call to dc_init_webxdc_integration()"); + return 0; + } + let ctx = &*context; + let chat_id = if chat_id == 0 { + None + } else { + Some(ChatId::new(chat_id)) + }; + + block_on(ctx.init_webxdc_integration(chat_id)) + .log_err(ctx) + .map(|msg_id| msg_id.map(|id| id.to_u32()).unwrap_or_default()) + .unwrap_or(0) +} + #[no_mangle] pub unsafe extern "C" fn dc_set_draft( context: *mut dc_context_t, diff --git a/src/chat.rs b/src/chat.rs index 9797a416e..f6a7a2b8b 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -2049,6 +2049,7 @@ impl Chat { msg.id = MsgId::new(u32::try_from(raw_id)?); maybe_set_logging_xdc(context, msg, self.id).await?; + context.update_webxdc_integration_database(msg).await?; } context.scheduler.interrupt_ephemeral_task().await; Ok(msg.id) @@ -2714,7 +2715,7 @@ async fn send_msg_inner(context: &Context, chat_id: ChatId, msg: &mut Message) - } if msg.param.exists(Param::SetLatitude) { - context.emit_event(EventType::LocationChanged(Some(ContactId::SELF))); + context.emit_location_changed(Some(ContactId::SELF)).await?; } context.scheduler.interrupt_smtp().await; @@ -2781,6 +2782,11 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) - recipients.push(from); } + // Webxdc integrations are messages, however, shipped with main app and must not be sent out + if msg.param.get_int(Param::WebxdcIntegration).is_some() { + recipients.clear(); + } + if recipients.is_empty() { // may happen eg. for groups with only SELF and bcc_self disabled info!( diff --git a/src/config.rs b/src/config.rs index 2dc87d714..3394b7a2a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -356,6 +356,9 @@ pub enum Config { /// This key is sent to the self_reporting bot so that the bot can recognize the user /// without storing the email address SelfReportingId, + + /// MsgId of webxdc map integration. + WebxdcIntegration, } impl Config { @@ -668,7 +671,7 @@ impl Context { { return Ok(()); } - self.send_sync_msg().await.log_err(self).ok(); + Box::pin(self.send_sync_msg()).await.log_err(self).ok(); Ok(()) } diff --git a/src/context.rs b/src/context.rs index 4682abebf..5586c4c7f 100644 --- a/src/context.rs +++ b/src/context.rs @@ -605,6 +605,23 @@ impl Context { chatlist_events::emit_chatlist_item_changed(self, chat_id); } + /// Emits an LocationChanged event and a WebxdcStatusUpdate in case there is a maps integration + pub async fn emit_location_changed(&self, contact_id: Option) -> Result<()> { + self.emit_event(EventType::LocationChanged(contact_id)); + + if let Some(msg_id) = self + .get_config_parsed::(Config::WebxdcIntegration) + .await? + { + self.emit_event(EventType::WebxdcStatusUpdate { + msg_id: MsgId::new(msg_id), + status_update_serial: Default::default(), + }) + } + + Ok(()) + } + /// Returns a receiver for emitted events. /// /// Multiple emitters can be created, but note that in this case each emitted event will @@ -1631,6 +1648,7 @@ mod tests { "socks5_user", "socks5_password", "key_id", + "webxdc_integration", ]; let t = TestContext::new().await; let info = t.get_info().await.unwrap(); diff --git a/src/location.rs b/src/location.rs index 5f2706c8a..a934cd5be 100644 --- a/src/location.rs +++ b/src/location.rs @@ -367,7 +367,7 @@ pub async fn set(context: &Context, latitude: f64, longitude: f64, accuracy: f64 continue_streaming = true; } if continue_streaming { - context.emit_event(EventType::LocationChanged(Some(ContactId::SELF))); + context.emit_location_changed(Some(ContactId::SELF)).await?; }; Ok(continue_streaming) @@ -457,7 +457,7 @@ fn is_marker(txt: &str) -> bool { /// Deletes all locations from the database. pub async fn delete_all(context: &Context) -> Result<()> { context.sql.execute("DELETE FROM locations;", ()).await?; - context.emit_event(EventType::LocationChanged(None)); + context.emit_location_changed(None).await?; Ok(()) } diff --git a/src/message.rs b/src/message.rs index fd36961c6..33e3e557a 100644 --- a/src/message.rs +++ b/src/message.rs @@ -83,6 +83,16 @@ impl MsgId { Ok(result) } + pub(crate) async fn get_param(self, context: &Context) -> Result { + let res: Option = context + .sql + .query_get_value("SELECT param FROM msgs WHERE id=?", (self,)) + .await?; + Ok(res + .map(|s| s.parse().unwrap_or_default()) + .unwrap_or_default()) + } + /// Put message into trash chat and delete message text. /// /// It means the message is deleted locally, but not on the server. diff --git a/src/param.rs b/src/param.rs index 3066a4479..9971d2457 100644 --- a/src/param.rs +++ b/src/param.rs @@ -187,6 +187,12 @@ pub enum Param { /// For Webxdc Message Instances: timestamp of summary update. WebxdcSummaryTimestamp = b'Q', + /// For Webxdc Message Instances: Webxdc is an integration, see init_webxdc_integration() + WebxdcIntegration = b'3', + + /// For Webxdc Message Instances: Chat to integrate the Webxdc for. + WebxdcIntegrateFor = b'2', + /// For messages: Whether [crate::message::Viewtype::Sticker] should be forced. ForceSticker = b'X', // 'L' was defined as ProtectionSettingsTimestamp for Chats, however, never used in production. diff --git a/src/receive_imf.rs b/src/receive_imf.rs index c87dbc8b9..67eb19b31 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -1687,7 +1687,7 @@ async fn save_locations( } } if send_event { - context.emit_event(EventType::LocationChanged(Some(from_id))); + context.emit_location_changed(Some(from_id)).await?; } Ok(()) } diff --git a/src/webxdc.rs b/src/webxdc.rs index 6ce19d23f..207189383 100644 --- a/src/webxdc.rs +++ b/src/webxdc.rs @@ -15,6 +15,9 @@ //! - `last_serial` - serial number of the last status update to send //! - `descr` - text to send along with the updates +mod integration; +mod maps_integration; + use std::path::Path; use anyhow::{anyhow, bail, ensure, format_err, Context as _, Result}; @@ -456,6 +459,12 @@ impl Context { bail!("send_webxdc_status_update: message {instance_msg_id} is not a webxdc message, but a {viewtype} message."); } + if instance.param.get_int(Param::WebxdcIntegration).is_some() { + return self + .intercept_send_webxdc_status_update(instance, status_update) + .await; + } + let chat_id = instance.chat_id; let chat = Chat::load_from_db(self, chat_id) .await @@ -625,6 +634,14 @@ impl Context { instance_msg_id: MsgId, last_known_serial: StatusUpdateSerial, ) -> Result { + let param = instance_msg_id.get_param(self).await?; + if param.get_int(Param::WebxdcIntegration).is_some() { + let instance = Message::load_from_db(self, instance_msg_id).await?; + return self + .intercept_get_webxdc_status_updates(instance, last_known_serial) + .await; + } + let json = self .sql .query_map( diff --git a/src/webxdc/integration.rs b/src/webxdc/integration.rs new file mode 100644 index 000000000..500cd36bf --- /dev/null +++ b/src/webxdc/integration.rs @@ -0,0 +1,129 @@ +use crate::chat::{send_msg, ChatId}; +use crate::config::Config; +use crate::contact::ContactId; +use crate::context::Context; +use crate::message::{Message, MsgId, Viewtype}; +use crate::param::Param; +use crate::webxdc::{maps_integration, StatusUpdateItem, StatusUpdateSerial}; +use anyhow::Result; + +impl Context { + /// Sets Webxdc file as integration. + /// `file` is the .xdc to use as Webxdc integration. + pub async fn set_webxdc_integration(&self, file: &str) -> Result<()> { + let chat_id = ChatId::create_for_contact(self, ContactId::SELF).await?; + let mut msg = Message::new(Viewtype::Webxdc); + msg.set_file(file, None); + msg.hidden = true; + msg.param.set_int(Param::WebxdcIntegration, 1); + msg.param.set_int(Param::GuaranteeE2ee, 1); // needed to pass `internet_access` requirements + send_msg(self, chat_id, &mut msg).await?; + Ok(()) + } + + /// Returns Webxdc instance used for optional integrations. + /// UI can open the Webxdc as usual. + /// Returns `None` if there is no integration; the caller can add one using `set_webxdc_integration` then. + /// `integrate_for` is the chat to get the integration for. + pub async fn init_webxdc_integration( + &self, + integrate_for: Option, + ) -> Result> { + let Some(instance_id) = self + .get_config_parsed::(Config::WebxdcIntegration) + .await? + else { + return Ok(None); + }; + + let Some(mut instance) = + Message::load_from_db_optional(self, MsgId::new(instance_id)).await? + else { + return Ok(None); + }; + + if instance.viewtype != Viewtype::Webxdc { + return Ok(None); + } + + let integrate_for = integrate_for.unwrap_or_default().to_u32() as i32; + if instance.param.get_int(Param::WebxdcIntegrateFor) != Some(integrate_for) { + instance + .param + .set_int(Param::WebxdcIntegrateFor, integrate_for); + instance.update_param(self).await?; + } + Ok(Some(instance.id)) + } + + // Check if a Webxdc shall be used as an integration and remember that. + pub(crate) async fn update_webxdc_integration_database(&self, msg: &Message) -> Result<()> { + if msg.viewtype == Viewtype::Webxdc && msg.param.get_int(Param::WebxdcIntegration).is_some() + { + self.set_config_internal( + Config::WebxdcIntegration, + Some(&msg.id.to_u32().to_string()), + ) + .await?; + } + Ok(()) + } + + // Intercepts sending updates from Webxdc to core. + pub(crate) async fn intercept_send_webxdc_status_update( + &self, + instance: Message, + status_update: StatusUpdateItem, + ) -> Result<()> { + let chat_id = instance.webxdc_integrated_for(); + maps_integration::intercept_send_update(self, chat_id, status_update).await + } + + // Intercepts Webxdc requesting updates from core. + pub(crate) async fn intercept_get_webxdc_status_updates( + &self, + instance: Message, + last_known_serial: StatusUpdateSerial, + ) -> Result { + let chat_id = instance.webxdc_integrated_for(); + maps_integration::intercept_get_updates(self, chat_id, last_known_serial).await + } +} + +impl Message { + // Get chat the Webxdc is integrated for. + // This is the chat given to `init_webxdc_integration()`. + fn webxdc_integrated_for(&self) -> Option { + let raw_id = self.param.get_int(Param::WebxdcIntegrateFor).unwrap_or(0) as u32; + if raw_id > 0 { + Some(ChatId::new(raw_id)) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use crate::config::Config; + use crate::test_utils::TestContext; + use anyhow::Result; + use std::time::Duration; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_default_integrations_are_single_device() -> Result<()> { + let t = TestContext::new_alice().await; + t.set_config_bool(Config::BccSelf, false).await?; + + let bytes = include_bytes!("../../test-data/webxdc/minimal.xdc"); + let file = t.get_blobdir().join("maps.xdc"); + tokio::fs::write(&file, bytes).await.unwrap(); + t.set_webxdc_integration(file.to_str().unwrap()).await?; + + // default integrations are shipped with the apps and should not be sent over the wire + let sent = t.pop_sent_msg_opt(Duration::from_secs(1)).await; + assert!(sent.is_none()); + + Ok(()) + } +} diff --git a/src/webxdc/maps_integration.rs b/src/webxdc/maps_integration.rs new file mode 100644 index 000000000..38d3f5c0b --- /dev/null +++ b/src/webxdc/maps_integration.rs @@ -0,0 +1,277 @@ +//! # Maps Webxdc Integration. +//! +//! A Maps Webxdc Integration uses `sendUpdate()` and `setUpdateListener()` as usual, +//! however, it agrees with the core on the following update format: +//! +//! ## Setting POIs via `sendUpdate()` +//! +//! ```json +//! payload: { +//! action: "pos", +//! lat: 53.550556, +//! lng: 9.993333, +//! label: "my poi" +//! } +//! ``` +//! +//! Just sent POI are received via `setUpdateListener()`, as well as old POI. +//! +//! ## Receiving Locations via `setUpdateListener()` +//! +//! ```json +//! payload: { +//! action: "pos", +//! lat: 47.994828, +//! lng: 7.849881, +//! timestamp: 1712928222, +//! contactId: 123, // can be used as a unique ID to differ tracks etc +//! name: "Alice", +//! color: "#ff8080", +//! independent: false, // false: current or past position of contact, true: a POI +//! label: "" // used for POI only +//! } +//! ``` + +use crate::{chat, location}; +use std::collections::{hash_map, HashMap}; + +use crate::context::Context; +use crate::message::{Message, MsgId, Viewtype}; + +use crate::chat::ChatId; +use crate::color::color_int_to_hex_string; +use crate::contact::{Contact, ContactId}; +use crate::tools::time; +use crate::webxdc::{StatusUpdateItem, StatusUpdateItemAndSerial, StatusUpdateSerial}; +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +struct MapsActionPayload { + action: String, + lat: Option, + lng: Option, + label: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct LocationItem { + action: String, + #[serde(rename = "contactId")] + contact_id: u32, + lat: f64, + lng: f64, + independent: bool, + timestamp: i64, + label: String, + name: String, + color: String, +} + +pub(crate) async fn intercept_send_update( + context: &Context, + chat_id: Option, + status_update: StatusUpdateItem, +) -> Result<()> { + let payload = serde_json::from_value::(status_update.payload)?; + let lat = payload.lat.unwrap_or_default(); + let lng = payload.lng.unwrap_or_default(); + let label = payload.label.unwrap_or_default(); + + if payload.action == "pos" && !label.is_empty() { + let chat_id = if let Some(chat_id) = chat_id { + chat_id + } else { + ChatId::create_for_contact(context, ContactId::SELF).await? + }; + + let mut poi_msg = Message::new(Viewtype::Text); + poi_msg.text = label; + poi_msg.set_location(lat, lng); + chat::send_msg(context, chat_id, &mut poi_msg).await?; + } else { + warn!(context, "unknown maps integration action"); + } + + Ok(()) +} + +pub(crate) async fn intercept_get_updates( + context: &Context, + chat_id: Option, + last_known_serial: StatusUpdateSerial, +) -> Result { + let mut json = String::default(); + let mut contact_data: HashMap = HashMap::new(); + + let begin = time() - 24 * 60 * 60; + let locations = location::get_range(context, chat_id, None, begin, 0).await?; + for location in locations.iter().rev() { + if location.location_id > last_known_serial.to_u32() { + let (name, color) = match contact_data.entry(location.contact_id) { + hash_map::Entry::Vacant(e) => { + let contact = Contact::get_by_id(context, location.contact_id).await?; + let name = contact.get_display_name().to_string(); + let color = color_int_to_hex_string(contact.get_color()); + e.insert((name, color)).clone() + } + hash_map::Entry::Occupied(e) => e.get().clone(), + }; + + let mut label = String::new(); + if location.independent != 0 { + if let Some(marker) = &location.marker { + label = marker.to_string() // marker contains one-char labels only + } else if location.msg_id != 0 { + if let Some(msg) = + Message::load_from_db_optional(context, MsgId::new(location.msg_id)).await? + { + label = msg.get_text() + } + } + } + + let location_item = LocationItem { + action: "pos".to_string(), + contact_id: location.contact_id.to_u32(), + lat: location.latitude, + lng: location.longitude, + independent: location.independent != 0, + timestamp: location.timestamp, + label, + name, + color, + }; + + let update_item = StatusUpdateItemAndSerial { + item: StatusUpdateItem { + payload: serde_json::to_value(location_item)?, + info: None, + document: None, + summary: None, + uid: None, + }, + serial: StatusUpdateSerial(location.location_id), + max_serial: StatusUpdateSerial(location.location_id), + }; + + if !json.is_empty() { + json.push_str(",\n"); + } + json.push_str(&serde_json::to_string(&update_item)?); + } + } + + Ok(format!("[{json}]")) +} + +#[cfg(test)] +mod tests { + use crate::chat::{create_group_chat, ChatId, ProtectionStatus}; + use crate::chatlist::Chatlist; + use crate::contact::Contact; + use crate::message::Message; + use crate::test_utils::TestContext; + use crate::webxdc::StatusUpdateSerial; + use crate::{location, EventType}; + use anyhow::Result; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_maps_integration() -> Result<()> { + let t = TestContext::new_alice().await; + + let bytes = include_bytes!("../../test-data/webxdc/mapstest.xdc"); + let file = t.get_blobdir().join("maps.xdc"); + tokio::fs::write(&file, bytes).await.unwrap(); + t.set_webxdc_integration(file.to_str().unwrap()).await?; + + let chatlist = Chatlist::try_load(&t, 0, None, None).await?; + let summary = chatlist.get_summary(&t, 0, None).await?; + assert_eq!(summary.text, "No messages."); + + // Integrate Webxdc into a chat with Bob; + // sending updates is intercepted by integrations and results in setting a POI in core + let bob_id = Contact::create(&t, "", "bob@example.net").await?; + let bob_chat_id = ChatId::create_for_contact(&t, bob_id).await?; + let integration_id = t.init_webxdc_integration(Some(bob_chat_id)).await?.unwrap(); + assert!(!integration_id.is_special()); + + let integration = Message::load_from_db(&t, integration_id).await?; + let info = integration.get_webxdc_info(&t).await?; + assert_eq!(info.name, "Maps Test"); + assert_eq!(info.internet_access, true); + + t.send_webxdc_status_update( + integration_id, + r#"{"payload": {"action": "pos", "lat": 11.0, "lng": 12.0, "label": "poi #1"}}"#, + "descr", + ) + .await?; + t.evtracker + .get_matching(|evt| matches!(evt, EventType::WebxdcStatusUpdate { .. })) + .await; + let updates = t + .get_webxdc_status_updates(integration_id, StatusUpdateSerial(0)) + .await?; + assert!(updates.contains(r#""lat":11"#)); + assert!(updates.contains(r#""lng":12"#)); + assert!(updates.contains(r#""label":"poi #1""#)); + assert!(updates.contains(r#""contactId":"#)); // checking for sth. that is not in the sent update make sure integration is called + assert!(updates.contains(r#""name":"Me""#)); + assert!(updates.contains(r##""color":"#"##)); + let locations = location::get_range(&t, Some(bob_chat_id), None, 0, 0).await?; + assert_eq!(locations.len(), 1); + let location = locations.last().unwrap(); + assert_eq!(location.latitude, 11.0); + assert_eq!(location.longitude, 12.0); + assert_eq!(location.independent, 1); + let msg = t.get_last_msg().await; + assert_eq!(msg.text, "poi #1"); + assert_eq!(msg.chat_id, bob_chat_id); + + // Integrate Webxdc into another group + let group_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let integration_id = t.init_webxdc_integration(Some(group_id)).await?.unwrap(); + + let locations = location::get_range(&t, Some(group_id), None, 0, 0).await?; + assert_eq!(locations.len(), 0); + t.send_webxdc_status_update( + integration_id, + r#"{"payload": {"action": "pos", "lat": 22.0, "lng": 23.0, "label": "poi #2"}}"#, + "descr", + ) + .await?; + let updates = t + .get_webxdc_status_updates(integration_id, StatusUpdateSerial(0)) + .await?; + assert!(!updates.contains(r#""lat":11"#)); + assert!(!updates.contains(r#""label":"poi #1""#)); + assert!(updates.contains(r#""lat":22"#)); + assert!(updates.contains(r#""lng":23"#)); + assert!(updates.contains(r#""label":"poi #2""#)); + let locations = location::get_range(&t, Some(group_id), None, 0, 0).await?; + assert_eq!(locations.len(), 1); + let location = locations.last().unwrap(); + assert_eq!(location.latitude, 22.0); + assert_eq!(location.longitude, 23.0); + assert_eq!(location.independent, 1); + let msg = t.get_last_msg().await; + assert_eq!(msg.text, "poi #2"); + assert_eq!(msg.chat_id, group_id); + + // In global map, both POI are visible + let integration_id = t.init_webxdc_integration(None).await?.unwrap(); + + let updates = t + .get_webxdc_status_updates(integration_id, StatusUpdateSerial(0)) + .await?; + assert!(updates.contains(r#""lat":11"#)); + assert!(updates.contains(r#""label":"poi #1""#)); + assert!(updates.contains(r#""lat":22"#)); + assert!(updates.contains(r#""label":"poi #2""#)); + let locations = location::get_range(&t, None, None, 0, 0).await?; + assert_eq!(locations.len(), 2); + + Ok(()) + } +} diff --git a/test-data/webxdc/mapstest.xdc b/test-data/webxdc/mapstest.xdc new file mode 100644 index 000000000..07f2890f9 Binary files /dev/null and b/test-data/webxdc/mapstest.xdc differ