diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 9d1d32fea9..e429997f22 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -1178,6 +1178,39 @@ 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); + +#define DC_INTEGRATION_MAPS 1 + +/** + * Init a Webxdc integration. + * + * A Webxdc integration is eg. + * 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. + * + * Currently, Webxdc integrations are Webxdc shipped together with the main app; + * before dc_init_webxdc_integration() can be called, + * UI has to mark a Webxdc using dc_msg_set_default_webxdc_integration(). + * Later on, + * we can consider shipping Webxdc integrations with core or + * we can allow users to replace Webxdc integrations. + * + * dc_init_webxdc_integration() returns a Webxdc message ID that + * UI can open and use mostly as usual. + * + * There is no need to de-initialize the integration, + * however, the integration is valid only as long as not re-initialized. + * + * @memberof dc_context_t + * @param context The context object. + * @param integration_type The integration to obtain, one of the DC_INTEGRATION_* constants. + * @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, int integration_type, uint32_t chat_id); + + /** * Save a draft for a chat in the database. * @@ -4107,7 +4140,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. @@ -4678,6 +4710,16 @@ void dc_msg_set_override_sender_name(dc_msg_t* msg, const char* name) void dc_msg_set_file (dc_msg_t* msg, const char* file, const char* filemime); +/** + * Mark Webxdc message shipped with the main app as a default integration. + * See dc_init_webxdc_integration() for details. + * + * @memberof dc_msg_t + * @param msg The Webxdc message object to mark as default integration. + */ +void dc_msg_set_default_webxdc_integration (dc_msg_t* msg); + + /** * Set the dimensions associated with message object. * Typically this is the width and the height of an image or video associated using dc_msg_set_file(). diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index 88dc3819dc..e8e36c2efa 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -1055,6 +1055,29 @@ pub unsafe extern "C" fn dc_get_webxdc_status_updates( .strdup() } +#[no_mangle] +pub unsafe extern "C" fn dc_init_webxdc_integration( + context: *mut dc_context_t, + integration_type: libc::c_int, + chat_id: u32, +) -> u32 { + if context.is_null() || integration_type == 0 { + 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, @@ -3735,6 +3758,16 @@ pub unsafe extern "C" fn dc_msg_set_file( ) } +#[no_mangle] +pub unsafe extern "C" fn dc_msg_set_default_webxdc_integration(msg: *mut dc_msg_t) { + if msg.is_null() { + eprintln!("ignoring careless call to dc_msg_set_default_webxdc_integration()"); + return; + } + let ffi_msg = &mut *msg; + ffi_msg.message.set_default_webxdc_integration() +} + #[no_mangle] pub unsafe extern "C" fn dc_msg_set_dimension( msg: *mut dc_msg_t, diff --git a/src/chat.rs b/src/chat.rs index b3616753d6..2d59fb5a9a 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -2034,6 +2034,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) diff --git a/src/message.rs b/src/message.rs index 4a4eaa621a..e5b0540f1a 100644 --- a/src/message.rs +++ b/src/message.rs @@ -82,6 +82,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 3066a44793..81a8cbe287 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'L', + + /// For Webxdc Message Instances: Chat to integrate in. + 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/webxdc.rs b/src/webxdc.rs index fc5f36b57e..eeaa0d2932 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 @@ -636,6 +645,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 0000000000..3efb74bb97 --- /dev/null +++ b/src/webxdc/integration.rs @@ -0,0 +1,88 @@ +use crate::chat::ChatId; +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 Message { + /// Mark Webxdc message shipped with the main app as a default integration. + pub fn set_default_webxdc_integration(&mut self) { + self.hidden = true; + self.param.set_int(Param::WebxdcIntegration, 1); + } +} + +impl Context { + /// Get Webxdc instance used for optional integrations. + /// If there is no integration, the caller may decide to add a default one. + pub async fn init_webxdc_integration( + &self, + integrate_for: Option, + ) -> Result> { + if let Some(instance_id) = self.sql.get_raw_config_int("webxdc_integration").await? { + if let Some(mut instance) = + Message::load_from_db_optional(self, MsgId::new(instance_id as u32)).await? + { + if instance.viewtype == Viewtype::Webxdc && !instance.chat_id.is_trash() { + 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?; + } + return Ok(Some(instance.id)); + } + } + } + Ok(None) + } + + // 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() + { + // using set_config_internal() leads to recursion warning because of sync messages + self.sql + .set_raw_config_int("webxdc_integration", msg.id.to_u32() as i32) + .await?; + } + Ok(()) + } + + // Intercept 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 = self.integrate_for(&instance)?; + maps_integration::intercept_send_update(self, chat_id, status_update).await + } + + // Intercept 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 = self.integrate_for(&instance)?; + maps_integration::intercept_get_updates(self, chat_id, last_known_serial).await + } + + // Get chat the Webxdc is integrated for. + // This is the chat given to `init_webxdc_integration()`. + fn integrate_for(&self, instance: &Message) -> Result> { + let raw_id = instance + .param + .get_int(Param::WebxdcIntegrateFor) + .unwrap_or(0) as u32; + let chat_id = if raw_id > 0 { + Some(ChatId::new(raw_id)) + } else { + None + }; + Ok(chat_id) + } +} diff --git a/src/webxdc/maps_integration.rs b/src/webxdc/maps_integration.rs new file mode 100644 index 0000000000..9eddedb10a --- /dev/null +++ b/src/webxdc/maps_integration.rs @@ -0,0 +1,272 @@ +// # Maps Webxdc Integration. +// +// A Maps Webxdc Integration uses `sendUpdate()` and `setUpdateListener()` as usual, +// however, it agrees with the core on the following update format: +// +// ## Settings a POI +// +// payload: { +// action: "pos", +// lat: 53.550556, +// lng: 9.993333, +// label: "my poi" +// } +// +// +// ## Receiving Locations +// +// 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 +// } +// +// New locations are announced by `DC_EVENT_LOCATION_CHANGED` +// (not by `DC_EVENT_WEBXDC_STATUS_UPDATE`). + +use crate::{chat, location}; +use std::collections::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::webxdc::{StatusUpdateItem, StatusUpdateItemAndSerial, StatusUpdateSerial}; +use anyhow::{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 locations = location::get_range(context, chat_id, None, 0, 0).await?; + for location in locations.iter().rev() { + if location.location_id > last_known_serial.to_u32() { + if !contact_data.contains_key(&location.contact_id) { + let contact = Contact::get_by_id(context, location.contact_id).await?; + let color = color_int_to_hex_string(contact.get_color()); + contact_data.insert( + location.contact_id, + (contact.get_display_name().to_string(), color.to_string()), + ); + } + let (name, color) = contact_data + .get(&location.contact_id) + .ok_or_else(|| anyhow!("cannot read contact_data"))?; + + 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: name.to_string(), + color: color.to_string(), + }; + + 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, send_msg, ChatId, ProtectionStatus}; + use crate::chatlist::Chatlist; + use crate::contact::Contact; + use crate::location; + use crate::message::{Message, Viewtype}; + use crate::test_utils::TestContext; + use crate::webxdc::StatusUpdateSerial; + use anyhow::Result; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_maps_integration() -> Result<()> { + let t = TestContext::new_alice().await; + let chat = t.get_self_chat().await; + + let bytes = include_bytes!("../../test-data/webxdc/minimal.xdc"); + let mut msg = Message::new(Viewtype::Webxdc); + msg.set_file_from_bytes(&t, "my-maps.xdc", bytes, None) + .await?; + msg.set_default_webxdc_integration(); + let msg_id = send_msg(&t, chat.id, &mut msg).await?; + + let msg = Message::load_from_db(&t, msg_id).await?; + let chatlist = Chatlist::try_load(&t, 0, None, None).await?; + let summary = chatlist.get_summary(&t, 0, None).await?; + assert!(msg.hidden); + 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()); + + t.send_webxdc_status_update( + integration_id, + r#"{"payload": {"action": "pos", "lat": 11.0, "lng": 12.0, "label": "poi #1"}}"#, + "descr", + ) + .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(()) + } +}