feat: experimental Webxdc Integration API, Maps Integration (#5461)

as discussed in several chats, this PR starts making it possible to use
Webxdc as integrations to the main app. In other word: selected parts of
the main app can be integrated as Webxdc, eg. Maps [^1]

this PR contains two parts:

- draft an Webxdc Integration API
- use the Webxdc Integration API to create a Maps Integration

to be clear: a Webxdc is not part of this PR. the PR is about marking a
Webxdc being used as a Map - and core then feeds the Webxdc with
location data. from the view of the Webxdc, the normal
`sendUpdate()`/`setUpdateListener()` is used.

things are still marked as "experimental", idea is to get that in to
allow @adbenitez and @nicodh to move forward on the integrations into
android and desktop, as well as improving the maps.xdc itself.
good news is that we currently can change the protocol between Webxdc
and core at any point :)


# Webxdc Integration API

see `dc_init_webxdc_integration()` in `deltachat.h` for overview and
documentation.

rust code is mostly in `webxdc/integration.rs` that is called by other
places as needed. current [user of the API is
deltachat-ios](https://github.com/deltachat/deltachat-ios/pull/1912),
android/desktop will probably follow.

the jsonrpc part is missing and can come in another PR when things are
settled and desktop is really starting [^2] (so we won't need to do all
iterations twice :) makes also sense, when this is done by someone
actually trying that out on desktop

while the API is prepared to allow other types of integrations (photo
editor, compose tools ...) internally, we currently ignore the type. if
that gets more crazy, we probably also need a dedicated table for the
integrations and not just a single param.

# Maps Integration

rust code is mostly in `webxdc/maps_integration.rs` that is called by
`webxdc/integration.rs` as needed.

EDIT: the idea of having a split here, is that
`webxdc/maps_integration.rs` really can focus on the json part, on the
communication with the .xdc, including tests

this PR is basic implementation, enabling to move forward on
integrations on iOS, but also on desktop and android.

the current implementation allows already the following:
- global and per-chat maps
- add and display POIs
- show positions and tracks of the last 24 hours

the current maps.xdc uses leaflet, and is in some regards better than
the current android/desktop implementations (much faster, show age of
positions, fade out positions, always show names of POIs, clearer UI).
however, we are also not bound to leaflet, it can be anything

> [**screenshots of the current
state**](https://github.com/deltachat/deltachat-ios/pull/1912)
> 👆

to move forward faster and to keep this PR small, the following will go
to a subsequent PR:

- consider allowing webxdc to use a different timewindow for the
location
- delete POIs
- jsonrpc 


[^1]: maps are a good example as anyways barely native (see android
app), did cause a lot of pain on many levels in the past (technically,
bureaucratically), and have a comparable simple api
[^2]: only going for jsonrpc would only make sense if large parts of
android/ios would use jsonrpc, we're not there

---------

Co-authored-by: link2xt <link2xt@testrun.org>
This commit is contained in:
bjoern
2024-04-20 18:09:35 +02:00
committed by GitHub
parent f43f5c6c0f
commit 242547f1e9
13 changed files with 567 additions and 6 deletions

View File

@@ -1178,6 +1178,65 @@ 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);
/**
* Set Webxdc file as integration.
* see dc_init_webxdc_integration() for more details about Webxdc integrations.
*
* @warning This is an experimental API which may change in the future
*
* @memberof dc_context_t
* @param context The context object.
* @param file The .xdc file to use as Webxdc integration.
*/
void dc_set_webxdc_integration (dc_context_t* context, const char* file);
/**
* Init a Webxdc integration.
*
* A Webxdc integration is
* 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.
*
* @warning This is an experimental API, esp. support of integration types (eg. image editor, tools) is left out for simplicity
*
* Currently, Webxdc integrations are .xdc files shipped together with the main app.
* Before dc_init_webxdc_integration() can be called,
* UI has to call dc_set_webxdc_integration() to define a .xdc file to be used as integration.
*
* dc_init_webxdc_integration() returns a Webxdc message ID that
* UI can open and use mostly as usual.
*
* Concrete behaviour and status updates depend on the integration, driven by UI needs.
*
* There is no need to de-initialize the integration,
* however, unless documented otherwise,
* the integration is valid only as long as not re-initialized
* In other words, UI must not have a Webxdc with the same integration open twice.
*
* Example:
*
* ~~~
* // Define a .xdc file to be used as maps integration
* dc_set_webxdc_integration(context, path_to_maps_xdc);
*
* // Integrate the map to a chat, the map will show locations for this chat then:
* uint32_t webxdc_instance = dc_init_webxdc_integration(context, any_chat_id);
*
* // Or use the Webxdc as a global map, showing locations of all chats:
* uint32_t webxdc_instance = dc_init_webxdc_integration(context, 0);
* ~~~
*
* @memberof dc_context_t
* @param context The context object.
* @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, uint32_t chat_id);
/**
* Save a draft for a chat in the database.
*
@@ -4107,7 +4166,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.

View File

@@ -1063,6 +1063,43 @@ pub unsafe extern "C" fn dc_get_webxdc_status_updates(
.strdup()
}
#[no_mangle]
pub unsafe extern "C" fn dc_set_webxdc_integration(
context: *mut dc_context_t,
file: *const libc::c_char,
) {
if context.is_null() || file.is_null() {
eprintln!("ignoring careless call to dc_set_webxdc_integration()");
return;
}
let ctx = &*context;
block_on(ctx.set_webxdc_integration(&to_string_lossy(file)))
.log_err(ctx)
.unwrap_or_default();
}
#[no_mangle]
pub unsafe extern "C" fn dc_init_webxdc_integration(
context: *mut dc_context_t,
chat_id: u32,
) -> u32 {
if context.is_null() {
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,

View File

@@ -2049,6 +2049,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)
@@ -2714,7 +2715,7 @@ async fn send_msg_inner(context: &Context, chat_id: ChatId, msg: &mut Message) -
}
if msg.param.exists(Param::SetLatitude) {
context.emit_event(EventType::LocationChanged(Some(ContactId::SELF)));
context.emit_location_changed(Some(ContactId::SELF)).await?;
}
context.scheduler.interrupt_smtp().await;
@@ -2781,6 +2782,11 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
recipients.push(from);
}
// Webxdc integrations are messages, however, shipped with main app and must not be sent out
if msg.param.get_int(Param::WebxdcIntegration).is_some() {
recipients.clear();
}
if recipients.is_empty() {
// may happen eg. for groups with only SELF and bcc_self disabled
info!(

View File

@@ -356,6 +356,9 @@ pub enum Config {
/// This key is sent to the self_reporting bot so that the bot can recognize the user
/// without storing the email address
SelfReportingId,
/// MsgId of webxdc map integration.
WebxdcIntegration,
}
impl Config {
@@ -668,7 +671,7 @@ impl Context {
{
return Ok(());
}
self.send_sync_msg().await.log_err(self).ok();
Box::pin(self.send_sync_msg()).await.log_err(self).ok();
Ok(())
}

View File

@@ -605,6 +605,23 @@ impl Context {
chatlist_events::emit_chatlist_item_changed(self, chat_id);
}
/// Emits an LocationChanged event and a WebxdcStatusUpdate in case there is a maps integration
pub async fn emit_location_changed(&self, contact_id: Option<ContactId>) -> Result<()> {
self.emit_event(EventType::LocationChanged(contact_id));
if let Some(msg_id) = self
.get_config_parsed::<u32>(Config::WebxdcIntegration)
.await?
{
self.emit_event(EventType::WebxdcStatusUpdate {
msg_id: MsgId::new(msg_id),
status_update_serial: Default::default(),
})
}
Ok(())
}
/// Returns a receiver for emitted events.
///
/// Multiple emitters can be created, but note that in this case each emitted event will
@@ -1631,6 +1648,7 @@ mod tests {
"socks5_user",
"socks5_password",
"key_id",
"webxdc_integration",
];
let t = TestContext::new().await;
let info = t.get_info().await.unwrap();

View File

@@ -367,7 +367,7 @@ pub async fn set(context: &Context, latitude: f64, longitude: f64, accuracy: f64
continue_streaming = true;
}
if continue_streaming {
context.emit_event(EventType::LocationChanged(Some(ContactId::SELF)));
context.emit_location_changed(Some(ContactId::SELF)).await?;
};
Ok(continue_streaming)
@@ -457,7 +457,7 @@ fn is_marker(txt: &str) -> bool {
/// Deletes all locations from the database.
pub async fn delete_all(context: &Context) -> Result<()> {
context.sql.execute("DELETE FROM locations;", ()).await?;
context.emit_event(EventType::LocationChanged(None));
context.emit_location_changed(None).await?;
Ok(())
}

View File

@@ -83,6 +83,16 @@ impl MsgId {
Ok(result)
}
pub(crate) async fn get_param(self, context: &Context) -> Result<Params> {
let res: Option<String> = 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.

View File

@@ -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'3',
/// For Webxdc Message Instances: Chat to integrate the Webxdc for.
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.

View File

@@ -1687,7 +1687,7 @@ async fn save_locations(
}
}
if send_event {
context.emit_event(EventType::LocationChanged(Some(from_id)));
context.emit_location_changed(Some(from_id)).await?;
}
Ok(())
}

View File

@@ -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
@@ -625,6 +634,14 @@ impl Context {
instance_msg_id: MsgId,
last_known_serial: StatusUpdateSerial,
) -> Result<String> {
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(

129
src/webxdc/integration.rs Normal file
View File

@@ -0,0 +1,129 @@
use crate::chat::{send_msg, ChatId};
use crate::config::Config;
use crate::contact::ContactId;
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 Context {
/// Sets Webxdc file as integration.
/// `file` is the .xdc to use as Webxdc integration.
pub async fn set_webxdc_integration(&self, file: &str) -> Result<()> {
let chat_id = ChatId::create_for_contact(self, ContactId::SELF).await?;
let mut msg = Message::new(Viewtype::Webxdc);
msg.set_file(file, None);
msg.hidden = true;
msg.param.set_int(Param::WebxdcIntegration, 1);
msg.param.set_int(Param::GuaranteeE2ee, 1); // needed to pass `internet_access` requirements
send_msg(self, chat_id, &mut msg).await?;
Ok(())
}
/// Returns Webxdc instance used for optional integrations.
/// UI can open the Webxdc as usual.
/// Returns `None` if there is no integration; the caller can add one using `set_webxdc_integration` then.
/// `integrate_for` is the chat to get the integration for.
pub async fn init_webxdc_integration(
&self,
integrate_for: Option<ChatId>,
) -> Result<Option<MsgId>> {
let Some(instance_id) = self
.get_config_parsed::<u32>(Config::WebxdcIntegration)
.await?
else {
return Ok(None);
};
let Some(mut instance) =
Message::load_from_db_optional(self, MsgId::new(instance_id)).await?
else {
return Ok(None);
};
if instance.viewtype != Viewtype::Webxdc {
return Ok(None);
}
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?;
}
Ok(Some(instance.id))
}
// 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()
{
self.set_config_internal(
Config::WebxdcIntegration,
Some(&msg.id.to_u32().to_string()),
)
.await?;
}
Ok(())
}
// Intercepts 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 = instance.webxdc_integrated_for();
maps_integration::intercept_send_update(self, chat_id, status_update).await
}
// Intercepts Webxdc requesting updates from core.
pub(crate) async fn intercept_get_webxdc_status_updates(
&self,
instance: Message,
last_known_serial: StatusUpdateSerial,
) -> Result<String> {
let chat_id = instance.webxdc_integrated_for();
maps_integration::intercept_get_updates(self, chat_id, last_known_serial).await
}
}
impl Message {
// Get chat the Webxdc is integrated for.
// This is the chat given to `init_webxdc_integration()`.
fn webxdc_integrated_for(&self) -> Option<ChatId> {
let raw_id = self.param.get_int(Param::WebxdcIntegrateFor).unwrap_or(0) as u32;
if raw_id > 0 {
Some(ChatId::new(raw_id))
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use crate::config::Config;
use crate::test_utils::TestContext;
use anyhow::Result;
use std::time::Duration;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_default_integrations_are_single_device() -> Result<()> {
let t = TestContext::new_alice().await;
t.set_config_bool(Config::BccSelf, false).await?;
let bytes = include_bytes!("../../test-data/webxdc/minimal.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?;
// default integrations are shipped with the apps and should not be sent over the wire
let sent = t.pop_sent_msg_opt(Duration::from_secs(1)).await;
assert!(sent.is_none());
Ok(())
}
}

View File

@@ -0,0 +1,277 @@
//! # 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, Viewtype};
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(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<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,
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, 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.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");
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"}}"#,
"descr",
)
.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"}}"#,
"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(())
}
}

Binary file not shown.