Files
chatmail-core/src/webxdc/maps_integration.rs
bjoern d63a2b39aa feat: allow the user to replace maps integration (#5678)
with this PR, when an `.xdc` with `request_integration = map` in the
manifest is added to the "Saved Messages" chat, it is used _locally_ as
an replacement for the shipped maps.xdc (other devices will see the
`.xdc` but not use it)

this allows easy development and adapting the map to use services that
work better in some area.

there are lots of known discussions and ideas about adding more barriers
of safety. however, after internal discussions, we decided to move
forward and also to allow internet, if requested by an integration (as
discussed at
https://github.com/deltachat/deltachat-core-rust/pull/3516).
the gist is to ease development and to make users who want to adapt,
actionable _now_, without making things too hard and adding too high
barriers or stressing our own resources/power too much.
note, that things are still experimental and will be the next time -
without the corresponding switch being enabled, nothing will work at
all, so we can be quite relaxed here :)

for android/ios, things will work directly. for desktop, allow_internet
needs to be accepted unconditionally from core. for the future, we might
add a question before using an integration and/or add signing. or sth.
completely different - but for now, the thing is to get started.

nb: "integration" field in the webxdc-info is experimental as well and
should not be used in UIs at all currently, it may vanish again and is
there mainly for simplicity of the code; therefore, no need to document
that.

successor of https://github.com/deltachat/deltachat-core-rust/pull/5461

this is how it looks like currently - again, please note that all that
is an experiment!

<img width=320
src=https://github.com/deltachat/deltachat-core-rust/assets/9800740/f659c891-f46a-4e28-9d0a-b6783d69be8d>
&nbsp; &nbsp; <img width=320
src=https://github.com/deltachat/deltachat-core-rust/assets/9800740/54549b3c-a894-4568-9e27-d5f1caea2d22>

... when going out of experimental, there are loots of ideas, eg.
changing "Start" to "integrate"
2024-11-29 14:18:35 +00:00

277 lines
10 KiB
Rust

//! # 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};
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<f64>,
lng: Option<f64>,
label: Option<String>,
}
#[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<ChatId>,
status_update: StatusUpdateItem,
) -> Result<()> {
let payload = serde_json::from_value::<MapsActionPayload>(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_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<ChatId>,
last_known_serial: StatusUpdateSerial,
) -> Result<String> {
let mut json = String::default();
let mut contact_data: HashMap<ContactId, (String, String)> = 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,
href: None,
document: None,
summary: None,
uid: None,
notify: 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-integration-set.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 2");
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"}}"#,
)
.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"}}"#,
)
.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(())
}
}