Skip to main content

deltachat/webxdc/
maps_integration.rs

1//! # Maps Webxdc Integration.
2//!
3//! A Maps Webxdc Integration uses `sendUpdate()` and `setUpdateListener()` as usual,
4//! however, it agrees with the core on the following update format:
5//!
6//! ## Setting POIs via `sendUpdate()`
7//!
8//! ```json
9//! payload: {
10//!     action: "pos",
11//!     lat:    53.550556,
12//!     lng:    9.993333,
13//!     label:  "my poi"
14//! }
15//! ```
16//!
17//! Just sent POI are received via `setUpdateListener()`, as well as old POI.
18//!
19//! ## Receiving Locations via `setUpdateListener()`
20//!
21//! ```json
22//! payload: {
23//!     action:     "pos",
24//!     lat:        47.994828,
25//!     lng:        7.849881,
26//!     timestamp:  1712928222,
27//!     contactId:  123,    // can be used as a unique ID to differ tracks etc
28//!     name:       "Alice",
29//!     color:      "#ff8080",
30//!     independent: false, // false: current or past position of contact, true: a POI
31//!     label:       ""     // used for POI only
32//! }
33//! ```
34
35use crate::{chat, location};
36use std::collections::{HashMap, hash_map};
37
38use crate::context::Context;
39use crate::message::{Message, MsgId};
40
41use crate::chat::ChatId;
42use crate::color::color_int_to_hex_string;
43use crate::contact::{Contact, ContactId};
44use crate::log::warn;
45use crate::tools::time;
46use crate::webxdc::{StatusUpdateItem, StatusUpdateItemAndSerial, StatusUpdateSerial};
47use anyhow::Result;
48use serde::{Deserialize, Serialize};
49
50#[derive(Debug, Serialize, Deserialize)]
51struct MapsActionPayload {
52    action: String,
53    lat: Option<f64>,
54    lng: Option<f64>,
55    label: Option<String>,
56}
57
58#[derive(Debug, Serialize, Deserialize)]
59struct LocationItem {
60    action: String,
61    #[serde(rename = "contactId")]
62    contact_id: u32,
63    lat: f64,
64    lng: f64,
65    independent: bool,
66    timestamp: i64,
67    label: String,
68    name: String,
69    color: String,
70}
71
72pub(crate) async fn intercept_send_update(
73    context: &Context,
74    chat_id: Option<ChatId>,
75    status_update: StatusUpdateItem,
76) -> Result<()> {
77    let payload = serde_json::from_value::<MapsActionPayload>(status_update.payload)?;
78    let lat = payload.lat.unwrap_or_default();
79    let lng = payload.lng.unwrap_or_default();
80    let label = payload.label.unwrap_or_default();
81
82    if payload.action == "pos" && !label.is_empty() {
83        let chat_id = if let Some(chat_id) = chat_id {
84            chat_id
85        } else {
86            ChatId::create_for_contact(context, ContactId::SELF).await?
87        };
88
89        let mut poi_msg = Message::new_text(label);
90        poi_msg.set_location(lat, lng);
91        chat::send_msg(context, chat_id, &mut poi_msg).await?;
92    } else {
93        warn!(context, "unknown maps integration action");
94    }
95
96    Ok(())
97}
98
99#[expect(clippy::arithmetic_side_effects)]
100pub(crate) async fn intercept_get_updates(
101    context: &Context,
102    chat_id: Option<ChatId>,
103    last_known_serial: StatusUpdateSerial,
104) -> Result<String> {
105    let mut json = String::default();
106    let mut contact_data: HashMap<ContactId, (String, String)> = HashMap::new();
107
108    let begin = time() - 24 * 60 * 60;
109    let locations = location::get_range(context, chat_id, None, begin, 0).await?;
110    for location in locations.iter().rev() {
111        if location.location_id > last_known_serial.to_u32() {
112            let (name, color) = match contact_data.entry(location.contact_id) {
113                hash_map::Entry::Vacant(e) => {
114                    let contact = Contact::get_by_id(context, location.contact_id).await?;
115                    let name = contact.get_display_name().to_string();
116                    let color = color_int_to_hex_string(contact.get_color());
117                    e.insert((name, color)).clone()
118                }
119                hash_map::Entry::Occupied(e) => e.get().clone(),
120            };
121
122            let mut label = String::new();
123            if location.independent != 0 {
124                if let Some(marker) = &location.marker {
125                    label = marker.to_string() // marker contains one-char labels only
126                } else if location.msg_id != 0
127                    && let Some(msg) =
128                        Message::load_from_db_optional(context, MsgId::new(location.msg_id)).await?
129                {
130                    label = msg.get_text()
131                }
132            }
133
134            let location_item = LocationItem {
135                action: "pos".to_string(),
136                contact_id: location.contact_id.to_u32(),
137                lat: location.latitude,
138                lng: location.longitude,
139                independent: location.independent != 0,
140                timestamp: location.timestamp,
141                label,
142                name,
143                color,
144            };
145
146            let update_item = StatusUpdateItemAndSerial {
147                item: StatusUpdateItem {
148                    payload: serde_json::to_value(location_item)?,
149                    info: None,
150                    href: None,
151                    document: None,
152                    summary: None,
153                    uid: None,
154                    notify: None,
155                },
156                serial: StatusUpdateSerial(location.location_id),
157                max_serial: StatusUpdateSerial(location.location_id),
158            };
159
160            if !json.is_empty() {
161                json.push_str(",\n");
162            }
163            json.push_str(&serde_json::to_string(&update_item)?);
164        }
165    }
166
167    Ok(format!("[{json}]"))
168}
169
170#[cfg(test)]
171mod tests {
172    use crate::chat::create_group;
173    use crate::chatlist::Chatlist;
174    use crate::message::Message;
175    use crate::test_utils::TestContextManager;
176    use crate::webxdc::StatusUpdateSerial;
177    use crate::{EventType, location};
178    use anyhow::Result;
179
180    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
181    async fn test_maps_integration() -> Result<()> {
182        let mut tcm = TestContextManager::new();
183        let alice = &tcm.alice().await;
184        let bob = &tcm.bob().await;
185
186        let bytes = include_bytes!("../../test-data/webxdc/mapstest-integration-set.xdc");
187        let file = alice.get_blobdir().join("maps.xdc");
188        tokio::fs::write(&file, bytes).await.unwrap();
189        alice.set_webxdc_integration(file.to_str().unwrap()).await?;
190
191        let chatlist = Chatlist::try_load(alice, 0, None, None).await?;
192        let summary = chatlist.get_summary(alice, 0, None).await?;
193        assert_eq!(summary.text, "No messages.");
194
195        // Integrate Webxdc into a chat with Bob;
196        // sending updates is intercepted by integrations and results in setting a POI in core
197        let bob_chat_id = alice.create_chat_id(bob).await;
198        let integration_id = alice
199            .init_webxdc_integration(Some(bob_chat_id))
200            .await?
201            .unwrap();
202        assert!(!integration_id.is_special());
203
204        let integration = Message::load_from_db(alice, integration_id).await?;
205        let info = integration.get_webxdc_info(alice).await?;
206        assert_eq!(info.name, "Maps Test 2");
207        assert_eq!(info.internet_access, true);
208
209        alice
210            .send_webxdc_status_update(
211                integration_id,
212                r#"{"payload": {"action": "pos", "lat": 11.0, "lng": 12.0, "label": "poi #1"}}"#,
213            )
214            .await?;
215        alice
216            .evtracker
217            .get_matching(|evt| matches!(evt, EventType::WebxdcStatusUpdate { .. }))
218            .await;
219        let updates = alice
220            .get_webxdc_status_updates(integration_id, StatusUpdateSerial(0))
221            .await?;
222        assert!(updates.contains(r#""lat":11"#));
223        assert!(updates.contains(r#""lng":12"#));
224        assert!(updates.contains(r#""label":"poi #1""#));
225        assert!(updates.contains(r#""contactId":"#)); // checking for sth. that is not in the sent update make sure integration is called
226        assert!(updates.contains(r#""name":"Me""#));
227        assert!(updates.contains(r##""color":"#"##));
228        let locations = location::get_range(alice, Some(bob_chat_id), None, 0, 0).await?;
229        assert_eq!(locations.len(), 1);
230        let location = locations.last().unwrap();
231        assert_eq!(location.latitude, 11.0);
232        assert_eq!(location.longitude, 12.0);
233        assert_eq!(location.independent, 1);
234        let msg = alice.get_last_msg().await;
235        assert_eq!(msg.text, "poi #1");
236        assert_eq!(msg.chat_id, bob_chat_id);
237
238        // Integrate Webxdc into another group
239        let group_id = create_group(alice, "foo").await?;
240        let integration_id = alice
241            .init_webxdc_integration(Some(group_id))
242            .await?
243            .unwrap();
244
245        let locations = location::get_range(alice, Some(group_id), None, 0, 0).await?;
246        assert_eq!(locations.len(), 0);
247        alice
248            .send_webxdc_status_update(
249                integration_id,
250                r#"{"payload": {"action": "pos", "lat": 22.0, "lng": 23.0, "label": "poi #2"}}"#,
251            )
252            .await?;
253        let updates = alice
254            .get_webxdc_status_updates(integration_id, StatusUpdateSerial(0))
255            .await?;
256        assert!(!updates.contains(r#""lat":11"#));
257        assert!(!updates.contains(r#""label":"poi #1""#));
258        assert!(updates.contains(r#""lat":22"#));
259        assert!(updates.contains(r#""lng":23"#));
260        assert!(updates.contains(r#""label":"poi #2""#));
261        let locations = location::get_range(alice, Some(group_id), None, 0, 0).await?;
262        assert_eq!(locations.len(), 1);
263        let location = locations.last().unwrap();
264        assert_eq!(location.latitude, 22.0);
265        assert_eq!(location.longitude, 23.0);
266        assert_eq!(location.independent, 1);
267        let msg = alice.get_last_msg().await;
268        assert_eq!(msg.text, "poi #2");
269        assert_eq!(msg.chat_id, group_id);
270
271        // In global map, both POI are visible
272        let integration_id = alice.init_webxdc_integration(None).await?.unwrap();
273
274        let updates = alice
275            .get_webxdc_status_updates(integration_id, StatusUpdateSerial(0))
276            .await?;
277        assert!(updates.contains(r#""lat":11"#));
278        assert!(updates.contains(r#""label":"poi #1""#));
279        assert!(updates.contains(r#""lat":22"#));
280        assert!(updates.contains(r#""label":"poi #2""#));
281        let locations = location::get_range(alice, None, None, 0, 0).await?;
282        assert_eq!(locations.len(), 2);
283
284        Ok(())
285    }
286}