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