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::{hash_map, HashMap};
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
99pub(crate) async fn intercept_get_updates(
100    context: &Context,
101    chat_id: Option<ChatId>,
102    last_known_serial: StatusUpdateSerial,
103) -> Result<String> {
104    let mut json = String::default();
105    let mut contact_data: HashMap<ContactId, (String, String)> = HashMap::new();
106
107    let begin = time() - 24 * 60 * 60;
108    let locations = location::get_range(context, chat_id, None, begin, 0).await?;
109    for location in locations.iter().rev() {
110        if location.location_id > last_known_serial.to_u32() {
111            let (name, color) = match contact_data.entry(location.contact_id) {
112                hash_map::Entry::Vacant(e) => {
113                    let contact = Contact::get_by_id(context, location.contact_id).await?;
114                    let name = contact.get_display_name().to_string();
115                    let color = color_int_to_hex_string(contact.get_color());
116                    e.insert((name, color)).clone()
117                }
118                hash_map::Entry::Occupied(e) => e.get().clone(),
119            };
120
121            let mut label = String::new();
122            if location.independent != 0 {
123                if let Some(marker) = &location.marker {
124                    label = marker.to_string() // marker contains one-char labels only
125                } else if location.msg_id != 0 {
126                    if let Some(msg) =
127                        Message::load_from_db_optional(context, MsgId::new(location.msg_id)).await?
128                    {
129                        label = msg.get_text()
130                    }
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_chat, ChatId, ProtectionStatus};
173    use crate::chatlist::Chatlist;
174    use crate::contact::Contact;
175    use crate::message::Message;
176    use crate::test_utils::TestContext;
177    use crate::webxdc::StatusUpdateSerial;
178    use crate::{location, EventType};
179    use anyhow::Result;
180
181    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
182    async fn test_maps_integration() -> Result<()> {
183        let t = TestContext::new_alice().await;
184
185        let bytes = include_bytes!("../../test-data/webxdc/mapstest-integration-set.xdc");
186        let file = t.get_blobdir().join("maps.xdc");
187        tokio::fs::write(&file, bytes).await.unwrap();
188        t.set_webxdc_integration(file.to_str().unwrap()).await?;
189
190        let chatlist = Chatlist::try_load(&t, 0, None, None).await?;
191        let summary = chatlist.get_summary(&t, 0, None).await?;
192        assert_eq!(summary.text, "No messages.");
193
194        // Integrate Webxdc into a chat with Bob;
195        // sending updates is intercepted by integrations and results in setting a POI in core
196        let bob_id = Contact::create(&t, "", "bob@example.net").await?;
197        let bob_chat_id = ChatId::create_for_contact(&t, bob_id).await?;
198        let integration_id = t.init_webxdc_integration(Some(bob_chat_id)).await?.unwrap();
199        assert!(!integration_id.is_special());
200
201        let integration = Message::load_from_db(&t, integration_id).await?;
202        let info = integration.get_webxdc_info(&t).await?;
203        assert_eq!(info.name, "Maps Test 2");
204        assert_eq!(info.internet_access, true);
205
206        t.send_webxdc_status_update(
207            integration_id,
208            r#"{"payload": {"action": "pos", "lat": 11.0, "lng": 12.0, "label": "poi #1"}}"#,
209        )
210        .await?;
211        t.evtracker
212            .get_matching(|evt| matches!(evt, EventType::WebxdcStatusUpdate { .. }))
213            .await;
214        let updates = t
215            .get_webxdc_status_updates(integration_id, StatusUpdateSerial(0))
216            .await?;
217        assert!(updates.contains(r#""lat":11"#));
218        assert!(updates.contains(r#""lng":12"#));
219        assert!(updates.contains(r#""label":"poi #1""#));
220        assert!(updates.contains(r#""contactId":"#)); // checking for sth. that is not in the sent update make sure integration is called
221        assert!(updates.contains(r#""name":"Me""#));
222        assert!(updates.contains(r##""color":"#"##));
223        let locations = location::get_range(&t, Some(bob_chat_id), None, 0, 0).await?;
224        assert_eq!(locations.len(), 1);
225        let location = locations.last().unwrap();
226        assert_eq!(location.latitude, 11.0);
227        assert_eq!(location.longitude, 12.0);
228        assert_eq!(location.independent, 1);
229        let msg = t.get_last_msg().await;
230        assert_eq!(msg.text, "poi #1");
231        assert_eq!(msg.chat_id, bob_chat_id);
232
233        // Integrate Webxdc into another group
234        let group_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?;
235        let integration_id = t.init_webxdc_integration(Some(group_id)).await?.unwrap();
236
237        let locations = location::get_range(&t, Some(group_id), None, 0, 0).await?;
238        assert_eq!(locations.len(), 0);
239        t.send_webxdc_status_update(
240            integration_id,
241            r#"{"payload": {"action": "pos", "lat": 22.0, "lng": 23.0, "label": "poi #2"}}"#,
242        )
243        .await?;
244        let updates = t
245            .get_webxdc_status_updates(integration_id, StatusUpdateSerial(0))
246            .await?;
247        assert!(!updates.contains(r#""lat":11"#));
248        assert!(!updates.contains(r#""label":"poi #1""#));
249        assert!(updates.contains(r#""lat":22"#));
250        assert!(updates.contains(r#""lng":23"#));
251        assert!(updates.contains(r#""label":"poi #2""#));
252        let locations = location::get_range(&t, Some(group_id), None, 0, 0).await?;
253        assert_eq!(locations.len(), 1);
254        let location = locations.last().unwrap();
255        assert_eq!(location.latitude, 22.0);
256        assert_eq!(location.longitude, 23.0);
257        assert_eq!(location.independent, 1);
258        let msg = t.get_last_msg().await;
259        assert_eq!(msg.text, "poi #2");
260        assert_eq!(msg.chat_id, group_id);
261
262        // In global map, both POI are visible
263        let integration_id = t.init_webxdc_integration(None).await?.unwrap();
264
265        let updates = t
266            .get_webxdc_status_updates(integration_id, StatusUpdateSerial(0))
267            .await?;
268        assert!(updates.contains(r#""lat":11"#));
269        assert!(updates.contains(r#""label":"poi #1""#));
270        assert!(updates.contains(r#""lat":22"#));
271        assert!(updates.contains(r#""label":"poi #2""#));
272        let locations = location::get_range(&t, None, None, 0, 0).await?;
273        assert_eq!(locations.len(), 2);
274
275        Ok(())
276    }
277}