Skip to main content

deltachat/
location.rs

1//! Location handling.
2//!
3//! Delta Chat handles two kind of locations.
4//!
5//! There are two kinds of locations:
6//! - Independent locations, also known as Points of Interest (POI).
7//! - Path locations.
8//!
9//! Locations are sent as KML attachments.
10//! Independent locations are sent in `message.kml` attachments
11//! and path locations are sent in `location.kml` attachments.
12
13use std::time::Duration;
14
15use anyhow::{Context as _, Result, ensure};
16use async_channel::Receiver;
17use quick_xml::events::{BytesEnd, BytesStart, BytesText};
18use tokio::time::timeout;
19
20use crate::chat::{self, ChatId};
21use crate::constants::DC_CHAT_ID_TRASH;
22use crate::contact::ContactId;
23use crate::context::Context;
24use crate::events::EventType;
25use crate::log::warn;
26use crate::message::{Message, MsgId, Viewtype};
27use crate::mimeparser::SystemMessage;
28use crate::tools::{duration_to_str, time};
29use crate::{chatlist_events, stock_str};
30
31/// Location record.
32#[derive(Debug, Clone, Default)]
33pub struct Location {
34    /// Row ID of the location.
35    pub location_id: u32,
36
37    /// Location latitude.
38    pub latitude: f64,
39
40    /// Location longitude.
41    pub longitude: f64,
42
43    /// Nonstandard `accuracy` attribute of the `coordinates` tag.
44    pub accuracy: f64,
45
46    /// Location timestamp in seconds.
47    pub timestamp: i64,
48
49    /// Contact ID.
50    pub contact_id: ContactId,
51
52    /// Message ID.
53    pub msg_id: u32,
54
55    /// Chat ID.
56    pub chat_id: ChatId,
57
58    /// A marker string, such as an emoji, to be displayed on top of the location.
59    pub marker: Option<String>,
60
61    /// Whether location is independent, i.e. not part of the path.
62    pub independent: u32,
63}
64
65impl Location {
66    /// Creates a new empty location.
67    pub fn new() -> Self {
68        Default::default()
69    }
70}
71
72/// KML document.
73///
74/// See <https://www.ogc.org/standards/kml/> for the standard and
75/// <https://developers.google.com/kml> for documentation.
76#[derive(Debug, Clone, Default)]
77pub struct Kml {
78    /// Nonstandard `addr` attribute of the `Document` tag storing the user email address.
79    pub addr: Option<String>,
80
81    /// Placemarks.
82    pub locations: Vec<Location>,
83
84    /// Currently parsed XML tag.
85    tag: KmlTag,
86
87    /// Currently parsed placemark.
88    pub curr: Location,
89}
90
91#[derive(Default, Debug, Clone, PartialEq, Eq)]
92enum KmlTag {
93    #[default]
94    Undefined,
95    Placemark,
96    PlacemarkTimestamp,
97    PlacemarkTimestampWhen,
98    PlacemarkPoint,
99    PlacemarkPointCoordinates,
100}
101
102impl Kml {
103    /// Creates a new empty KML document.
104    pub fn new() -> Self {
105        Default::default()
106    }
107
108    /// Parses a KML document.
109    pub fn parse(to_parse: &[u8]) -> Result<Self> {
110        ensure!(to_parse.len() <= 1024 * 1024, "kml-file is too large");
111
112        let mut reader = quick_xml::Reader::from_reader(to_parse);
113        reader.config_mut().trim_text(true);
114
115        let mut kml = Kml::new();
116        kml.locations = Vec::with_capacity(100);
117
118        let mut buf = Vec::new();
119
120        loop {
121            match reader.read_event_into(&mut buf).with_context(|| {
122                format!(
123                    "location parsing error at position {}",
124                    reader.buffer_position()
125                )
126            })? {
127                quick_xml::events::Event::Start(ref e) => kml.starttag_cb(e, &reader),
128                quick_xml::events::Event::End(ref e) => kml.endtag_cb(e),
129                quick_xml::events::Event::Text(ref e) => kml.text_cb(e),
130                quick_xml::events::Event::Eof => break,
131                _ => (),
132            }
133            buf.clear();
134        }
135
136        Ok(kml)
137    }
138
139    fn text_cb(&mut self, event: &BytesText) {
140        if self.tag == KmlTag::PlacemarkTimestampWhen
141            || self.tag == KmlTag::PlacemarkPointCoordinates
142        {
143            let val = event.xml_content().unwrap_or_default();
144
145            let val = val.replace(['\n', '\r', '\t', ' '], "");
146
147            if self.tag == KmlTag::PlacemarkTimestampWhen && val.len() >= 19 {
148                // YYYY-MM-DDTHH:MM:SSZ
149                // 0   4  7  10 13 16 19
150                match chrono::NaiveDateTime::parse_from_str(&val, "%Y-%m-%dT%H:%M:%SZ") {
151                    Ok(res) => {
152                        self.curr.timestamp = res.and_utc().timestamp();
153                        let now = time();
154                        if self.curr.timestamp > now {
155                            self.curr.timestamp = now;
156                        }
157                    }
158                    Err(_err) => {
159                        self.curr.timestamp = time();
160                    }
161                }
162            } else if self.tag == KmlTag::PlacemarkPointCoordinates {
163                let parts = val.splitn(2, ',').collect::<Vec<_>>();
164                if let [longitude, latitude] = &parts[..] {
165                    self.curr.longitude = longitude.parse().unwrap_or_default();
166                    self.curr.latitude = latitude.parse().unwrap_or_default();
167                }
168            }
169        }
170    }
171
172    fn endtag_cb(&mut self, event: &BytesEnd) {
173        let tag = String::from_utf8_lossy(event.name().as_ref())
174            .trim()
175            .to_lowercase();
176
177        match self.tag {
178            KmlTag::PlacemarkTimestampWhen => {
179                if tag == "when" {
180                    self.tag = KmlTag::PlacemarkTimestamp
181                }
182            }
183            KmlTag::PlacemarkTimestamp => {
184                if tag == "timestamp" {
185                    self.tag = KmlTag::Placemark
186                }
187            }
188            KmlTag::PlacemarkPointCoordinates => {
189                if tag == "coordinates" {
190                    self.tag = KmlTag::PlacemarkPoint
191                }
192            }
193            KmlTag::PlacemarkPoint => {
194                if tag == "point" {
195                    self.tag = KmlTag::Placemark
196                }
197            }
198            KmlTag::Placemark => {
199                if tag == "placemark" {
200                    if 0 != self.curr.timestamp
201                        && 0. != self.curr.latitude
202                        && 0. != self.curr.longitude
203                    {
204                        self.locations
205                            .push(std::mem::replace(&mut self.curr, Location::new()));
206                    }
207                    self.tag = KmlTag::Undefined;
208                }
209            }
210            KmlTag::Undefined => {}
211        }
212    }
213
214    fn starttag_cb<B: std::io::BufRead>(
215        &mut self,
216        event: &BytesStart,
217        reader: &quick_xml::Reader<B>,
218    ) {
219        let tag = String::from_utf8_lossy(event.name().as_ref())
220            .trim()
221            .to_lowercase();
222        if tag == "document" {
223            if let Some(addr) = event.attributes().filter_map(|a| a.ok()).find(|attr| {
224                String::from_utf8_lossy(attr.key.as_ref())
225                    .trim()
226                    .to_lowercase()
227                    == "addr"
228            }) {
229                self.addr = addr
230                    .decode_and_unescape_value(reader.decoder())
231                    .ok()
232                    .map(|a| a.into_owned());
233            }
234        } else if tag == "placemark" {
235            self.tag = KmlTag::Placemark;
236            self.curr.timestamp = 0;
237            self.curr.latitude = 0.0;
238            self.curr.longitude = 0.0;
239            self.curr.accuracy = 0.0
240        } else if tag == "timestamp" && self.tag == KmlTag::Placemark {
241            self.tag = KmlTag::PlacemarkTimestamp;
242        } else if tag == "when" && self.tag == KmlTag::PlacemarkTimestamp {
243            self.tag = KmlTag::PlacemarkTimestampWhen;
244        } else if tag == "point" && self.tag == KmlTag::Placemark {
245            self.tag = KmlTag::PlacemarkPoint;
246        } else if tag == "coordinates" && self.tag == KmlTag::PlacemarkPoint {
247            self.tag = KmlTag::PlacemarkPointCoordinates;
248            if let Some(acc) = event.attributes().find_map(|attr| {
249                attr.ok().filter(|a| {
250                    String::from_utf8_lossy(a.key.as_ref())
251                        .trim()
252                        .eq_ignore_ascii_case("accuracy")
253                })
254            }) {
255                let v = acc
256                    .decode_and_unescape_value(reader.decoder())
257                    .unwrap_or_default();
258
259                self.curr.accuracy = v.trim().parse().unwrap_or_default();
260            }
261        }
262    }
263}
264
265/// Enables location streaming in chat identified by `chat_id` for `seconds` seconds.
266#[expect(clippy::arithmetic_side_effects)]
267pub async fn send_to_chat(context: &Context, chat_id: ChatId, seconds: i64) -> Result<()> {
268    ensure!(seconds >= 0);
269    ensure!(!chat_id.is_special());
270    let now = time();
271    let is_sending_locations_before = is_sending_to_chat(context, chat_id).await?;
272    context
273        .sql
274        .execute(
275            "UPDATE chats    \
276         SET locations_send_begin=?,        \
277         locations_send_until=?  \
278         WHERE id=?",
279            (
280                if 0 != seconds { now } else { 0 },
281                if 0 != seconds { now + seconds } else { 0 },
282                chat_id,
283            ),
284        )
285        .await?;
286    if 0 != seconds && !is_sending_locations_before {
287        let mut msg = Message::new_text(stock_str::msg_location_enabled(context));
288        msg.param.set_cmd(SystemMessage::LocationStreamingEnabled);
289        chat::send_msg(context, chat_id, &mut msg)
290            .await
291            .unwrap_or_default();
292    } else if 0 == seconds && is_sending_locations_before {
293        let stock_str = stock_str::msg_location_disabled(context);
294        chat::add_info_msg(context, chat_id, &stock_str).await?;
295    }
296    context.emit_event(EventType::ChatModified(chat_id));
297    chatlist_events::emit_chatlist_item_changed(context, chat_id);
298    if 0 != seconds {
299        context.scheduler.interrupt_location().await;
300    }
301    Ok(())
302}
303
304/// Returns whether any chat is sending locations.
305pub async fn is_sending(context: &Context) -> Result<bool> {
306    context
307        .sql
308        .exists(
309            "SELECT COUNT(id) FROM chats WHERE locations_send_until>?",
310            (time(),),
311        )
312        .await
313}
314
315/// Returns whether `chat_id` is sending locations.
316pub async fn is_sending_to_chat(context: &Context, chat_id: ChatId) -> Result<bool> {
317    context
318        .sql
319        .exists(
320            "SELECT COUNT(id) FROM chats WHERE id=? AND locations_send_until>?",
321            (chat_id, time()),
322        )
323        .await
324}
325
326/// Returns a list of chats in which location streaming is enabled.
327async fn get_chats_with_location_streaming(context: &Context) -> Result<Vec<ChatId>> {
328    context
329        .sql
330        .query_map_vec(
331            "SELECT id FROM chats WHERE locations_send_until>?",
332            (time(),),
333            |row| {
334                let chat_id: ChatId = row.get(0)?;
335                Ok(chat_id)
336            },
337        )
338        .await
339}
340
341/// Stop sending locations in all chats.
342pub async fn stop_sending(context: &Context) -> Result<()> {
343    for chat_id in get_chats_with_location_streaming(context).await? {
344        send_to_chat(context, chat_id, 0).await?;
345    }
346    Ok(())
347}
348
349/// Sets current location of the user device.
350pub async fn set(context: &Context, latitude: f64, longitude: f64, accuracy: f64) -> Result<bool> {
351    if latitude == 0.0 && longitude == 0.0 {
352        return Ok(true);
353    }
354    let mut continue_streaming = false;
355    let now = time();
356
357    let chats = context
358        .sql
359        .query_map_vec(
360            "SELECT id FROM chats WHERE locations_send_until>?;",
361            (now,),
362            |row| {
363                let id: i32 = row.get(0)?;
364                Ok(id)
365            },
366        )
367        .await?;
368
369    let mut stored_location = false;
370    for chat_id in chats {
371        context.sql.execute(
372                "INSERT INTO locations  \
373                 (latitude, longitude, accuracy, timestamp, chat_id, from_id) VALUES (?,?,?,?,?,?);",
374                 (
375                    latitude,
376                    longitude,
377                    accuracy,
378                    now,
379                    chat_id,
380                    ContactId::SELF,
381                )).await.context("Failed to store location")?;
382        stored_location = true;
383
384        info!(context, "Stored location for chat {chat_id}.");
385        continue_streaming = true;
386    }
387    if continue_streaming {
388        context.emit_location_changed(Some(ContactId::SELF)).await?;
389    };
390    if stored_location {
391        // Interrupt location loop so it may send a location-only message.
392        context.scheduler.interrupt_location().await;
393    }
394
395    Ok(continue_streaming)
396}
397
398/// Searches for locations in the given time range, optionally filtering by chat and contact IDs.
399#[expect(clippy::arithmetic_side_effects)]
400pub async fn get_range(
401    context: &Context,
402    chat_id: Option<ChatId>,
403    contact_id: Option<u32>,
404    timestamp_from: i64,
405    mut timestamp_to: i64,
406) -> Result<Vec<Location>> {
407    if timestamp_to == 0 {
408        timestamp_to = time() + 10;
409    }
410
411    let (disable_chat_id, chat_id) = match chat_id {
412        Some(chat_id) => (0, chat_id),
413        None => (1, ChatId::new(0)), // this ChatId is unused
414    };
415    let (disable_contact_id, contact_id) = match contact_id {
416        Some(contact_id) => (0, contact_id),
417        None => (1, 0), // this contact_id is unused
418    };
419    let list = context
420        .sql
421        .query_map_vec(
422            "SELECT l.id, l.latitude, l.longitude, l.accuracy, l.timestamp, l.independent, \
423             COALESCE(m.id, 0) AS msg_id, l.from_id, l.chat_id, COALESCE(m.txt, '') AS txt \
424             FROM locations l  LEFT JOIN msgs m ON l.id=m.location_id  WHERE (? OR l.chat_id=?) \
425             AND (? OR l.from_id=?) \
426             AND (l.independent=1 OR (l.timestamp>=? AND l.timestamp<=?)) \
427             ORDER BY l.timestamp DESC, l.id DESC, msg_id DESC;",
428            (
429                disable_chat_id,
430                chat_id,
431                disable_contact_id,
432                contact_id as i32,
433                timestamp_from,
434                timestamp_to,
435            ),
436            |row| {
437                let msg_id = row.get(6)?;
438                let txt: String = row.get(9)?;
439                let marker = if msg_id != 0 && is_marker(&txt) {
440                    Some(txt)
441                } else {
442                    None
443                };
444                let loc = Location {
445                    location_id: row.get(0)?,
446                    latitude: row.get(1)?,
447                    longitude: row.get(2)?,
448                    accuracy: row.get(3)?,
449                    timestamp: row.get(4)?,
450                    independent: row.get(5)?,
451                    msg_id,
452                    contact_id: row.get(7)?,
453                    chat_id: row.get(8)?,
454                    marker,
455                };
456                Ok(loc)
457            },
458        )
459        .await?;
460    Ok(list)
461}
462
463fn is_marker(txt: &str) -> bool {
464    let mut chars = txt.chars();
465    if let Some(c) = chars.next() {
466        !c.is_whitespace() && chars.next().is_none()
467    } else {
468        false
469    }
470}
471
472/// Deletes expired locations.
473///
474/// Only path locations are deleted.
475/// POIs should be deleted when corresponding message is deleted.
476pub(crate) async fn delete_expired(context: &Context, now: i64) -> Result<()> {
477    let Some(delete_device_after) = context.get_config_delete_device_after().await? else {
478        return Ok(());
479    };
480
481    let threshold_timestamp = now.saturating_sub(delete_device_after);
482    let deleted = context
483        .sql
484        .execute(
485            "DELETE FROM locations WHERE independent=0 AND timestamp < ?",
486            (threshold_timestamp,),
487        )
488        .await?
489        > 0;
490    if deleted {
491        info!(context, "Deleted {deleted} expired locations.");
492        context.emit_location_changed(None).await?;
493    }
494    Ok(())
495}
496
497/// Deletes location if it is an independent location.
498///
499/// This function is used when a message is deleted
500/// that has a corresponding `location_id`.
501pub(crate) async fn delete_poi(context: &Context, location_id: u32) -> Result<()> {
502    context
503        .sql
504        .execute(
505            "DELETE FROM locations WHERE independent = 1 AND id=?",
506            (location_id as i32,),
507        )
508        .await?;
509    Ok(())
510}
511
512/// Deletes POI locations that don't have corresponding message anymore.
513pub(crate) async fn delete_orphaned_poi(context: &Context) -> Result<()> {
514    context.sql.execute("
515    DELETE FROM locations
516    WHERE independent=1 AND id NOT IN
517    (SELECT location_id from MSGS LEFT JOIN locations
518     ON locations.id=location_id
519     WHERE location_id>0 -- This check makes the query faster by not looking for locations with ID 0 that don't exist.
520     AND msgs.chat_id != ?)", (DC_CHAT_ID_TRASH,)).await?;
521    Ok(())
522}
523
524/// Returns `location.kml` contents.
525#[expect(clippy::arithmetic_side_effects)]
526pub async fn get_kml(context: &Context, chat_id: ChatId) -> Result<Option<(String, u32)>> {
527    let mut last_added_location_id = 0;
528
529    let self_addr = context.get_primary_self_addr().await?;
530
531    let (locations_send_begin, locations_send_until, locations_last_sent) = context.sql.query_row(
532        "SELECT locations_send_begin, locations_send_until, locations_last_sent  FROM chats  WHERE id=?;",
533        (chat_id,), |row| {
534            let send_begin: i64 = row.get(0)?;
535            let send_until: i64 = row.get(1)?;
536            let last_sent: i64 = row.get(2)?;
537
538            Ok((send_begin, send_until, last_sent))
539        })
540        .await?;
541
542    let now = time();
543    let mut location_count = 0;
544    let mut ret = String::new();
545    if locations_send_begin != 0 && now <= locations_send_until {
546        ret += &format!(
547            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
548            <kml xmlns=\"http://www.opengis.net/kml/2.2\">\n<Document addr=\"{self_addr}\">\n",
549        );
550
551        context
552            .sql
553            .query_map(
554                "SELECT id, latitude, longitude, accuracy, timestamp \
555             FROM locations  WHERE from_id=? \
556             AND timestamp>=? \
557             AND (timestamp>=? OR \
558                  timestamp=(SELECT MAX(timestamp) FROM locations WHERE from_id=?)) \
559             AND independent=0 \
560             GROUP BY timestamp \
561             ORDER BY timestamp;",
562             (
563                    ContactId::SELF,
564                    locations_send_begin,
565                    locations_last_sent,
566                    ContactId::SELF
567                ),
568                |row| {
569                    let location_id: i32 = row.get(0)?;
570                    let latitude: f64 = row.get(1)?;
571                    let longitude: f64 = row.get(2)?;
572                    let accuracy: f64 = row.get(3)?;
573                    let timestamp = get_kml_timestamp(row.get(4)?);
574
575                    Ok((location_id, latitude, longitude, accuracy, timestamp))
576                },
577                |rows| {
578                    for row in rows {
579                        let (location_id, latitude, longitude, accuracy, timestamp) = row?;
580                        ret += &format!(
581                            "<Placemark>\
582                <Timestamp><when>{timestamp}</when></Timestamp>\
583                <Point><coordinates accuracy=\"{accuracy}\">{longitude},{latitude}</coordinates></Point>\
584                </Placemark>\n"
585                        );
586                        location_count += 1;
587                        last_added_location_id = location_id as u32;
588                    }
589                    Ok(())
590                },
591            )
592            .await?;
593        ret += "</Document>\n</kml>";
594    }
595
596    if location_count > 0 {
597        Ok(Some((ret, last_added_location_id)))
598    } else {
599        Ok(None)
600    }
601}
602
603fn get_kml_timestamp(utc: i64) -> String {
604    // Returns a string formatted as YYYY-MM-DDTHH:MM:SSZ. The trailing `Z` indicates UTC.
605    chrono::DateTime::<chrono::Utc>::from_timestamp(utc, 0)
606        .unwrap()
607        .format("%Y-%m-%dT%H:%M:%SZ")
608        .to_string()
609}
610
611/// Returns a KML document containing a single location with the given timestamp and coordinates.
612pub fn get_message_kml(timestamp: i64, latitude: f64, longitude: f64) -> String {
613    format!(
614        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
615         <kml xmlns=\"http://www.opengis.net/kml/2.2\">\n\
616         <Document>\n\
617         <Placemark>\
618         <Timestamp><when>{}</when></Timestamp>\
619         <Point><coordinates>{},{}</coordinates></Point>\
620         </Placemark>\n\
621         </Document>\n\
622         </kml>",
623        get_kml_timestamp(timestamp),
624        longitude,
625        latitude,
626    )
627}
628
629/// Sets the timestamp of the last time location was sent in the chat.
630pub async fn set_kml_sent_timestamp(
631    context: &Context,
632    chat_id: ChatId,
633    timestamp: i64,
634) -> Result<()> {
635    context
636        .sql
637        .execute(
638            "UPDATE chats SET locations_last_sent=? WHERE id=?;",
639            (timestamp, chat_id),
640        )
641        .await?;
642    Ok(())
643}
644
645/// Sets the location of the message.
646pub async fn set_msg_location_id(context: &Context, msg_id: MsgId, location_id: u32) -> Result<()> {
647    context
648        .sql
649        .execute(
650            "UPDATE msgs SET location_id=? WHERE id=?;",
651            (location_id, msg_id),
652        )
653        .await?;
654
655    Ok(())
656}
657
658/// Saves given locations to the database.
659///
660/// Returns the database row ID of the location with the highest timestamp.
661pub(crate) async fn save(
662    context: &Context,
663    chat_id: ChatId,
664    contact_id: ContactId,
665    locations: &[Location],
666    independent: bool,
667) -> Result<Option<u32>> {
668    ensure!(!chat_id.is_special(), "Invalid chat id");
669
670    let mut newest_timestamp = 0;
671    let mut newest_location_id = None;
672
673    let stmt_insert = "INSERT INTO locations\
674             (timestamp, from_id, chat_id, latitude, longitude, accuracy, independent) \
675             VALUES (?,?,?,?,?,?,?);";
676
677    for location in locations {
678        let &Location {
679            timestamp,
680            latitude,
681            longitude,
682            accuracy,
683            ..
684        } = location;
685
686        context
687            .sql
688            .call_write(|conn| {
689                let mut stmt_test = conn
690                    .prepare_cached("SELECT id FROM locations WHERE timestamp=? AND from_id=?")?;
691                let mut stmt_insert = conn.prepare_cached(stmt_insert)?;
692
693                let exists = stmt_test.exists((timestamp, contact_id))?;
694
695                if independent || !exists {
696                    stmt_insert.execute((
697                        timestamp,
698                        contact_id,
699                        chat_id,
700                        latitude,
701                        longitude,
702                        accuracy,
703                        independent,
704                    ))?;
705
706                    if timestamp > newest_timestamp {
707                        newest_timestamp = timestamp;
708                        newest_location_id = Some(u32::try_from(conn.last_insert_rowid())?);
709                    }
710                }
711
712                Ok(())
713            })
714            .await?;
715    }
716
717    Ok(newest_location_id)
718}
719
720pub(crate) async fn location_loop(context: &Context, interrupt_receiver: Receiver<()>) {
721    loop {
722        let next_event = match maybe_send(context).await {
723            Err(err) => {
724                warn!(context, "location::maybe_send failed: {:#}", err);
725                Some(60) // Retry one minute later.
726            }
727            Ok(next_event) => next_event,
728        };
729
730        let duration = if let Some(next_event) = next_event {
731            Duration::from_secs(next_event)
732        } else {
733            Duration::from_secs(86400)
734        };
735
736        info!(
737            context,
738            "Location loop is waiting for {} or interrupt",
739            duration_to_str(duration)
740        );
741        match timeout(duration, interrupt_receiver.recv()).await {
742            Err(_err) => {
743                info!(context, "Location loop timeout.");
744            }
745            Ok(Err(err)) => {
746                warn!(
747                    context,
748                    "Interrupt channel closed, location loop exits now: {err:#}."
749                );
750                return;
751            }
752            Ok(Ok(())) => {
753                info!(context, "Location loop received interrupt.");
754            }
755        }
756    }
757}
758
759/// Returns number of seconds until the next time location streaming for some chat ends
760/// automatically.
761#[expect(clippy::arithmetic_side_effects)]
762async fn maybe_send(context: &Context) -> Result<Option<u64>> {
763    let mut next_event: Option<u64> = None;
764
765    let now = time();
766    let rows = context
767        .sql
768        .query_map_vec(
769            "SELECT id, locations_send_begin, locations_send_until, locations_last_sent
770             FROM chats
771             WHERE locations_send_until>0",
772            [],
773            |row| {
774                let chat_id: ChatId = row.get(0)?;
775                let locations_send_begin: i64 = row.get(1)?;
776                let locations_send_until: i64 = row.get(2)?;
777                let locations_last_sent: i64 = row.get(3)?;
778                Ok((
779                    chat_id,
780                    locations_send_begin,
781                    locations_send_until,
782                    locations_last_sent,
783                ))
784            },
785        )
786        .await
787        .context("failed to query location streaming chats")?;
788
789    for (chat_id, locations_send_begin, locations_send_until, locations_last_sent) in rows {
790        if locations_send_begin > 0 && locations_send_until > now {
791            let can_send = now > locations_last_sent + 60;
792            let has_locations = context
793                .sql
794                .exists(
795                    "SELECT COUNT(id) \
796     FROM locations \
797     WHERE from_id=? \
798     AND timestamp>=? \
799     AND timestamp>? \
800     AND independent=0",
801                    (ContactId::SELF, locations_send_begin, locations_last_sent),
802                )
803                .await?;
804
805            next_event = next_event
806                .into_iter()
807                .chain(u64::try_from(locations_send_until - now))
808                .min();
809
810            if has_locations {
811                if can_send {
812                    // Send location-only message.
813                    // Pending locations are attached automatically to every message,
814                    // so also to this empty text message.
815                    info!(
816                        context,
817                        "Chat {} has pending locations, sending them.", chat_id
818                    );
819                    let mut msg = Message::new(Viewtype::Text);
820                    msg.hidden = true;
821                    msg.param.set_cmd(SystemMessage::LocationOnly);
822                    chat::send_msg(context, chat_id, &mut msg).await?;
823                } else {
824                    // Wait until pending locations can be sent.
825                    info!(
826                        context,
827                        "Chat {} has pending locations, but they can't be sent yet.", chat_id
828                    );
829                    next_event = next_event
830                        .into_iter()
831                        .chain(u64::try_from(locations_last_sent + 61 - now))
832                        .min();
833                }
834            } else {
835                info!(
836                    context,
837                    "Chat {} has location streaming enabled, but no pending locations.", chat_id
838                );
839            }
840        } else {
841            // Location streaming was either explicitly disabled (locations_send_begin = 0) or
842            // locations_send_until is in the past.
843            info!(
844                context,
845                "Disabling location streaming for chat {}.", chat_id
846            );
847            context
848                .sql
849                .execute(
850                    "UPDATE chats \
851                         SET locations_send_begin=0, locations_send_until=0 \
852                         WHERE id=?",
853                    (chat_id,),
854                )
855                .await
856                .context("failed to disable location streaming")?;
857
858            let stock_str = stock_str::msg_location_disabled(context);
859            chat::add_info_msg(context, chat_id, &stock_str).await?;
860            context.emit_event(EventType::ChatModified(chat_id));
861            chatlist_events::emit_chatlist_item_changed(context, chat_id);
862        }
863    }
864
865    Ok(next_event)
866}
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871    use crate::config::Config;
872    use crate::message::MessageState;
873    use crate::receive_imf::receive_imf;
874    use crate::test_utils::{ExpectedEvents, TestContext, TestContextManager};
875    use crate::tools::SystemTime;
876
877    #[test]
878    fn test_kml_parse() {
879        let xml =
880            b"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<kml xmlns=\"http://www.opengis.net/kml/2.2\">\n<Document addr=\"user@example.org\">\n<Placemark><Timestamp><when>2019-03-06T21:09:57Z</when></Timestamp><Point><coordinates accuracy=\"32.000000\">9.423110,53.790302</coordinates></Point></Placemark>\n<PlaceMARK>\n<Timestamp><WHEN > \n\t2018-12-13T22:11:12Z\t</WHEN></Timestamp><Point><coordinates aCCuracy=\"2.500000\"> 19.423110 \t , \n 63.790302\n </coordinates></Point></PlaceMARK>\n</Document>\n</kml>";
881
882        let kml = Kml::parse(xml).expect("parsing failed");
883
884        assert!(kml.addr.is_some());
885        assert_eq!(kml.addr.as_ref().unwrap(), "user@example.org",);
886
887        let locations_ref = &kml.locations;
888        assert_eq!(locations_ref.len(), 2);
889
890        assert!(locations_ref[0].latitude > 53.6f64);
891        assert!(locations_ref[0].latitude < 53.8f64);
892        assert!(locations_ref[0].longitude > 9.3f64);
893        assert!(locations_ref[0].longitude < 9.5f64);
894        assert!(locations_ref[0].accuracy > 31.9f64);
895        assert!(locations_ref[0].accuracy < 32.1f64);
896        assert_eq!(locations_ref[0].timestamp, 1551906597);
897
898        assert!(locations_ref[1].latitude > 63.6f64);
899        assert!(locations_ref[1].latitude < 63.8f64);
900        assert!(locations_ref[1].longitude > 19.3f64);
901        assert!(locations_ref[1].longitude < 19.5f64);
902        assert!(locations_ref[1].accuracy > 2.4f64);
903        assert!(locations_ref[1].accuracy < 2.6f64);
904        assert_eq!(locations_ref[1].timestamp, 1544739072);
905    }
906
907    #[test]
908    fn test_kml_parse_error() {
909        let xml = b"<?><xmlversi\"\"\">?</document>";
910        assert!(Kml::parse(xml).is_err());
911    }
912
913    #[test]
914    fn test_get_message_kml() {
915        let timestamp = 1598490000;
916
917        let xml = get_message_kml(timestamp, 51.423723f64, 8.552556f64);
918        let kml = Kml::parse(xml.as_bytes()).expect("parsing failed");
919        let locations_ref = &kml.locations;
920        assert_eq!(locations_ref.len(), 1);
921
922        assert!(locations_ref[0].latitude >= 51.423723f64);
923        assert!(locations_ref[0].latitude < 51.423724f64);
924        assert!(locations_ref[0].longitude >= 8.552556f64);
925        assert!(locations_ref[0].longitude < 8.552557f64);
926        assert!(locations_ref[0].accuracy.abs() < f64::EPSILON);
927        assert_eq!(locations_ref[0].timestamp, timestamp);
928    }
929
930    #[test]
931    fn test_is_marker() {
932        assert!(is_marker("f"));
933        assert!(!is_marker("foo"));
934        assert!(is_marker("🏠"));
935        assert!(!is_marker(" "));
936        assert!(!is_marker("\t"));
937    }
938
939    /// Tests that location.kml is hidden.
940    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
941    async fn receive_location_kml() -> Result<()> {
942        let alice = TestContext::new_alice().await;
943
944        receive_imf(
945            &alice,
946            br#"Subject: Hello
947Message-ID: hello@example.net
948To: Alice <alice@example.org>
949From: Bob <bob@example.net>
950Date: Mon, 20 Dec 2021 00:00:00 +0000
951Chat-Version: 1.0
952Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
953
954Text message."#,
955            false,
956        )
957        .await?;
958        let received_msg = alice.get_last_msg().await;
959        assert_eq!(received_msg.text, "Text message.");
960
961        receive_imf(
962            &alice,
963            br#"Subject: locations
964MIME-Version: 1.0
965To: <alice@example.org>
966From: <bob@example.net>
967Date: Tue, 21 Dec 2021 00:00:00 +0000
968Chat-Version: 1.0
969Message-ID: <foobar@example.net>
970Content-Type: multipart/mixed; boundary="U8BOG8qNXfB0GgLiQ3PKUjlvdIuLRF"
971
972
973--U8BOG8qNXfB0GgLiQ3PKUjlvdIuLRF
974Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
975
976
977
978--U8BOG8qNXfB0GgLiQ3PKUjlvdIuLRF
979Content-Type: application/vnd.google-earth.kml+xml
980Content-Disposition: attachment; filename="location.kml"
981
982<?xml version="1.0" encoding="UTF-8"?>
983<kml xmlns="http://www.opengis.net/kml/2.2">
984<Document addr="bob@example.net">
985<Placemark><Timestamp><when>2021-11-21T00:00:00Z</when></Timestamp><Point><coordinates accuracy="1.0000000000000000">10.00000000000000,20.00000000000000</coordinates></Point></Placemark>
986</Document>
987</kml>
988
989--U8BOG8qNXfB0GgLiQ3PKUjlvdIuLRF--"#,
990            false,
991        )
992        .await?;
993
994        // Received location message is not visible, last message stays the same.
995        let received_msg2 = alice.get_last_msg().await;
996        assert_eq!(received_msg2.id, received_msg.id);
997
998        let locations = get_range(&alice, None, None, 0, 0).await?;
999        assert_eq!(locations.len(), 1);
1000        Ok(())
1001    }
1002
1003    /// Tests that `location.kml` is not hidden and not seen if it contains a message.
1004    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1005    async fn receive_visible_location_kml() -> Result<()> {
1006        let alice = TestContext::new_alice().await;
1007
1008        receive_imf(
1009            &alice,
1010            br#"Subject: locations
1011MIME-Version: 1.0
1012To: <alice@example.org>
1013From: <bob@example.net>
1014Date: Tue, 21 Dec 2021 00:00:00 +0000
1015Chat-Version: 1.0
1016Message-ID: <foobar@localhost>
1017Content-Type: multipart/mixed; boundary="U8BOG8qNXfB0GgLiQ3PKUjlvdIuLRF"
1018
1019
1020--U8BOG8qNXfB0GgLiQ3PKUjlvdIuLRF
1021Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
1022
1023Text message.
1024
1025
1026--U8BOG8qNXfB0GgLiQ3PKUjlvdIuLRF
1027Content-Type: application/vnd.google-earth.kml+xml
1028Content-Disposition: attachment; filename="location.kml"
1029
1030<?xml version="1.0" encoding="UTF-8"?>
1031<kml xmlns="http://www.opengis.net/kml/2.2">
1032<Document addr="bob@example.net">
1033<Placemark><Timestamp><when>2021-11-21T00:00:00Z</when></Timestamp><Point><coordinates accuracy="1.0000000000000000">10.00000000000000,20.00000000000000</coordinates></Point></Placemark>
1034</Document>
1035</kml>
1036
1037--U8BOG8qNXfB0GgLiQ3PKUjlvdIuLRF--"#,
1038            false,
1039        )
1040        .await?;
1041
1042        let received_msg = alice.get_last_msg().await;
1043        assert_eq!(received_msg.text, "Text message.");
1044        assert_eq!(received_msg.state, MessageState::InFresh);
1045
1046        let locations = get_range(&alice, None, None, 0, 0).await?;
1047        assert_eq!(locations.len(), 1);
1048        Ok(())
1049    }
1050
1051    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1052    async fn test_send_locations_to_chat() -> Result<()> {
1053        let alice = TestContext::new_alice().await;
1054        let bob = TestContext::new_bob().await;
1055
1056        let alice_chat = alice.create_chat(&bob).await;
1057        send_to_chat(&alice, alice_chat.id, 1000).await?;
1058        let sent = alice.pop_sent_msg().await;
1059        let msg = bob.recv_msg(&sent).await;
1060        assert_eq!(msg.text, "Location streaming enabled by alice@example.org.");
1061        let bob_chat_id = msg.chat_id;
1062
1063        assert_eq!(set(&alice, 10.0, 20.0, 1.0).await?, true);
1064
1065        // Send image without text.
1066        let file_name = "image.png";
1067        let bytes = include_bytes!("../test-data/image/logo.png");
1068        let file = alice.get_blobdir().join(file_name);
1069        tokio::fs::write(&file, bytes).await?;
1070        let mut msg = Message::new(Viewtype::Image);
1071        msg.set_file_and_deduplicate(&alice, &file, Some("logo.png"), None)?;
1072        let sent = alice.send_msg(alice_chat.id, &mut msg).await;
1073        let alice_msg = Message::load_from_db(&alice, sent.sender_msg_id).await?;
1074        assert_eq!(alice_msg.has_location(), false);
1075
1076        let msg = bob.recv_msg_opt(&sent).await.unwrap();
1077        assert!(msg.chat_id == bob_chat_id);
1078        assert_eq!(msg.msg_ids.len(), 1);
1079
1080        let bob_msg = Message::load_from_db(&bob, *msg.msg_ids.first().unwrap()).await?;
1081        assert_eq!(bob_msg.chat_id, bob_chat_id);
1082        assert_eq!(bob_msg.viewtype, Viewtype::Image);
1083        assert_eq!(bob_msg.has_location(), false);
1084
1085        let bob_locations = get_range(&bob, None, None, 0, 0).await?;
1086        assert_eq!(bob_locations.len(), 1);
1087
1088        Ok(())
1089    }
1090
1091    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1092    async fn test_delete_expired_locations() -> Result<()> {
1093        let mut tcm = TestContextManager::new();
1094        let alice = &tcm.alice().await;
1095        let bob = &tcm.bob().await;
1096
1097        // Alice enables deletion of messages from device after 1 week.
1098        alice
1099            .set_config(Config::DeleteDeviceAfter, Some("604800"))
1100            .await?;
1101        // Bob enables deletion of messages from device after 1 day.
1102        bob.set_config(Config::DeleteDeviceAfter, Some("86400"))
1103            .await?;
1104
1105        let alice_chat = alice.create_chat(bob).await;
1106        // Bob needs the chat accepted so that "normal" messages from Alice trigger `IncomingMsg`.
1107        // Location-only messages still must trigger `MsgsChanged`.
1108        bob.create_chat(alice).await;
1109
1110        // Alice enables location streaming.
1111        // Bob receives a message saying that Alice enabled location streaming.
1112        send_to_chat(alice, alice_chat.id, 60).await?;
1113        bob.recv_msg(&alice.pop_sent_msg().await).await;
1114
1115        // Alice gets new location from GPS.
1116        assert_eq!(set(alice, 10.0, 20.0, 1.0).await?, true);
1117        assert_eq!(get_range(alice, None, None, 0, 0).await?.len(), 1);
1118
1119        // 10 seconds later location sending stream manages to send location.
1120        SystemTime::shift(Duration::from_secs(10));
1121        delete_expired(alice, time()).await?;
1122        maybe_send(alice).await?;
1123        bob.evtracker.clear_events();
1124        bob.recv_msg_opt(&alice.pop_sent_msg().await).await;
1125        bob.evtracker
1126            .get_matching_ex(
1127                bob,
1128                ExpectedEvents {
1129                    expected: |e| matches!(e, EventType::MsgsChanged { .. }),
1130                    unexpected: |e| matches!(e, EventType::IncomingMsg { .. }),
1131                },
1132            )
1133            .await
1134            .unwrap();
1135        assert_eq!(get_range(alice, None, None, 0, 0).await?.len(), 1);
1136        assert_eq!(get_range(bob, None, None, 0, 0).await?.len(), 1);
1137
1138        // Location-only messages are "auto-generated", but they mustn't make the contact a bot.
1139        let contact = bob.add_or_lookup_contact(alice).await;
1140        assert!(!contact.is_bot());
1141
1142        // Day later Bob removes location.
1143        SystemTime::shift(Duration::from_secs(86400));
1144        delete_expired(alice, time()).await?;
1145        delete_expired(bob, time()).await?;
1146        assert_eq!(get_range(alice, None, None, 0, 0).await?.len(), 1);
1147        assert_eq!(get_range(bob, None, None, 0, 0).await?.len(), 0);
1148
1149        // Week late Alice removes location.
1150        SystemTime::shift(Duration::from_secs(604800));
1151        delete_expired(alice, time()).await?;
1152        delete_expired(bob, time()).await?;
1153        assert_eq!(get_range(alice, None, None, 0, 0).await?.len(), 0);
1154        assert_eq!(get_range(bob, None, None, 0, 0).await?.len(), 0);
1155
1156        Ok(())
1157    }
1158}