//! # Ephemeral messages //! //! Ephemeral messages are messages that have an Ephemeral-Timer //! header attached to them, which specifies time in seconds after //! which the message should be deleted both from the device and from //! the server. The timer is started when the message is marked as //! seen, which usually happens when its contents is displayed on //! device screen. //! //! Each chat, including 1:1, group chats and "saved messages" chat, //! has its own ephemeral timer setting, which is applied to all //! messages sent to the chat. The setting is synchronized to all the //! devices participating in the chat by applying the timer value from //! all received messages, including BCC-self ones, to the chat. This //! way the setting is eventually synchronized among all participants. //! //! When user changes ephemeral timer setting for the chat, a system //! message is automatically sent to update the setting for all //! participants. This allows changing the setting for a chat like any //! group chat setting, e.g. name and avatar, without the need to //! write an actual message. //! //! ## Device settings //! //! In addition to per-chat ephemeral message setting, each device has //! two global user-configured settings that complement per-chat //! settings: `delete_device_after` and `delete_server_after`. These //! settings are not synchronized among devices and apply to all //! messages known to the device, including messages sent or received //! before configuring the setting. //! //! `delete_device_after` configures the maximum time device is //! storing the messages locally. `delete_server_after` configures the //! time after which device will delete the messages it knows about //! from the server. //! //! ## How messages are deleted //! //! When the message is deleted locally, its contents is removed and //! it is moved to the trash chat. This database entry is then used to //! track the Message-ID and corresponding IMAP folder and UID until //! the message is deleted from the server. Vice versa, when device //! deletes the message from the server, it removes IMAP folder and //! UID information, but keeps the message contents. When database //! entry is both moved to trash chat and does not contain UID //! information, it is deleted from the database, leaving no trace of //! the message. //! //! ## When messages are deleted //! //! Local deletion happens when the chatlist or chat is loaded. A //! `MsgsChanged` event is emitted when a message deletion is due, to //! make UI reload displayed messages and cause actual deletion. //! //! Server deletion happens by generating IMAP deletion jobs based on //! the database entries which are expired either according to their //! ephemeral message timers or global `delete_server_after` setting. use std::convert::{TryFrom, TryInto}; use std::num::ParseIntError; use std::str::FromStr; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{ensure, Error}; use async_std::task; use serde::{Deserialize, Serialize}; use crate::chat::{lookup_by_contact_id, send_msg, ChatId}; use crate::constants::{ Viewtype, DC_CHAT_ID_LAST_SPECIAL, DC_CHAT_ID_TRASH, DC_CONTACT_ID_DEVICE, DC_CONTACT_ID_SELF, }; use crate::context::Context; use crate::dc_tools::time; use crate::events::EventType; use crate::message::{Message, MessageState, MsgId}; use crate::mimeparser::SystemMessage; use crate::sql; use crate::stock_str; #[derive(Debug, PartialEq, Eq, Copy, Clone, Serialize, Deserialize)] pub enum Timer { Disabled, Enabled { duration: u32 }, } impl Timer { pub fn to_u32(self) -> u32 { match self { Self::Disabled => 0, Self::Enabled { duration } => duration, } } pub fn from_u32(duration: u32) -> Self { if duration == 0 { Self::Disabled } else { Self::Enabled { duration } } } } impl Default for Timer { fn default() -> Self { Self::Disabled } } impl ToString for Timer { fn to_string(&self) -> String { self.to_u32().to_string() } } impl FromStr for Timer { type Err = ParseIntError; fn from_str(input: &str) -> Result { input.parse::().map(Self::from_u32) } } impl rusqlite::types::ToSql for Timer { fn to_sql(&self) -> rusqlite::Result { let val = rusqlite::types::Value::Integer(match self { Self::Disabled => 0, Self::Enabled { duration } => i64::from(*duration), }); let out = rusqlite::types::ToSqlOutput::Owned(val); Ok(out) } } impl rusqlite::types::FromSql for Timer { fn column_result(value: rusqlite::types::ValueRef) -> rusqlite::types::FromSqlResult { i64::column_result(value).and_then(|value| { if value == 0 { Ok(Self::Disabled) } else if let Ok(duration) = u32::try_from(value) { Ok(Self::Enabled { duration }) } else { Err(rusqlite::types::FromSqlError::OutOfRange(value)) } }) } } impl ChatId { /// Get ephemeral message timer value in seconds. pub async fn get_ephemeral_timer(self, context: &Context) -> Result { let timer = context .sql .query_get_value_result( "SELECT ephemeral_timer FROM chats WHERE id=?;", paramsv![self], ) .await?; Ok(timer.unwrap_or_default()) } /// Set ephemeral timer value without sending a message. /// /// Used when a message arrives indicating that someone else has /// changed the timer value for a chat. pub(crate) async fn inner_set_ephemeral_timer( self, context: &Context, timer: Timer, ) -> Result<(), Error> { ensure!(!self.is_special(), "Invalid chat ID"); context .sql .execute( "UPDATE chats SET ephemeral_timer=? WHERE id=?;", paramsv![timer, self], ) .await?; context.emit_event(EventType::ChatEphemeralTimerModified { chat_id: self, timer, }); Ok(()) } /// Set ephemeral message timer value in seconds. /// /// If timer value is 0, disable ephemeral message timer. pub async fn set_ephemeral_timer(self, context: &Context, timer: Timer) -> Result<(), Error> { if timer == self.get_ephemeral_timer(context).await? { return Ok(()); } self.inner_set_ephemeral_timer(context, timer).await?; let mut msg = Message::new(Viewtype::Text); msg.text = Some(stock_ephemeral_timer_changed(context, timer, DC_CONTACT_ID_SELF).await); msg.param.set_cmd(SystemMessage::EphemeralTimerChanged); if let Err(err) = send_msg(context, self, &mut msg).await { error!( context, "Failed to send a message about ephemeral message timer change: {:?}", err ); } Ok(()) } } /// Returns a stock message saying that ephemeral timer is changed to `timer` by `from_id`. pub(crate) async fn stock_ephemeral_timer_changed( context: &Context, timer: Timer, from_id: u32, ) -> String { match timer { Timer::Disabled => stock_str::msg_ephemeral_timer_disabled(context, from_id).await, Timer::Enabled { duration } => match duration { 0..=59 => { stock_str::msg_ephemeral_timer_enabled(context, timer.to_string(), from_id).await } 60 => stock_str::msg_ephemeral_timer_minute(context, from_id).await, 61..=3599 => { stock_str::msg_ephemeral_timer_minutes( context, format!("{}", (f64::from(duration) / 6.0).round() / 10.0), from_id, ) .await } 3600 => stock_str::msg_ephemeral_timer_hour(context, from_id).await, 3601..=86399 => { stock_str::msg_ephemeral_timer_hours( context, format!("{}", (f64::from(duration) / 360.0).round() / 10.0), from_id, ) .await } 86400 => stock_str::msg_ephemeral_timer_day(context, from_id).await, 86401..=604_799 => { stock_str::msg_ephemeral_timer_days( context, format!("{}", (f64::from(duration) / 8640.0).round() / 10.0), from_id, ) .await } 604_800 => stock_str::msg_ephemeral_timer_week(context, from_id).await, _ => { stock_str::msg_ephemeral_timer_weeks( context, format!("{}", (f64::from(duration) / 60480.0).round() / 10.0), from_id, ) .await } }, } } impl MsgId { /// Returns ephemeral message timer value for the message. pub(crate) async fn ephemeral_timer(self, context: &Context) -> crate::sql::Result { let res = match context .sql .query_get_value_result( "SELECT ephemeral_timer FROM msgs WHERE id=?", paramsv![self], ) .await? { None | Some(0) => Timer::Disabled, Some(duration) => Timer::Enabled { duration }, }; Ok(res) } /// Starts ephemeral message timer for the message if it is not started yet. pub(crate) async fn start_ephemeral_timer(self, context: &Context) -> crate::sql::Result<()> { if let Timer::Enabled { duration } = self.ephemeral_timer(context).await? { let ephemeral_timestamp = time() + i64::from(duration); context .sql .execute( "UPDATE msgs SET ephemeral_timestamp = ? \ WHERE (ephemeral_timestamp == 0 OR ephemeral_timestamp > ?) \ AND id = ?", paramsv![ephemeral_timestamp, ephemeral_timestamp, self], ) .await?; schedule_ephemeral_task(context).await; } Ok(()) } } /// Deletes messages which are expired according to /// `delete_device_after` setting or `ephemeral_timestamp` column. /// /// Returns true if any message is deleted, so caller can emit /// MsgsChanged event. If nothing has been deleted, returns /// false. This function does not emit the MsgsChanged event itself, /// because it is also called when chatlist is reloaded, and emitting /// MsgsChanged there will cause infinite reload loop. pub(crate) async fn delete_expired_messages(context: &Context) -> Result { let mut updated = context .sql .execute( "UPDATE msgs \ SET chat_id=?, txt='', txt_raw='', from_id=0, to_id=0, param='' \ WHERE \ ephemeral_timestamp != 0 \ AND ephemeral_timestamp <= ? \ AND chat_id != ?", paramsv![DC_CHAT_ID_TRASH, time(), DC_CHAT_ID_TRASH], ) .await? > 0; if let Some(delete_device_after) = context.get_config_delete_device_after().await { let self_chat_id = lookup_by_contact_id(context, DC_CONTACT_ID_SELF) .await .unwrap_or_default() .0; let device_chat_id = lookup_by_contact_id(context, DC_CONTACT_ID_DEVICE) .await .unwrap_or_default() .0; let threshold_timestamp = time() - delete_device_after; // Delete expired messages // // Only update the rows that have to be updated, to avoid emitting // unnecessary "chat modified" events. let rows_modified = context .sql .execute( "UPDATE msgs \ SET txt = 'DELETED', chat_id = ? \ WHERE timestamp < ? \ AND chat_id > ? \ AND chat_id != ? \ AND chat_id != ?", paramsv![ DC_CHAT_ID_TRASH, threshold_timestamp, DC_CHAT_ID_LAST_SPECIAL, self_chat_id, device_chat_id ], ) .await?; updated |= rows_modified > 0; } schedule_ephemeral_task(context).await; Ok(updated) } /// Schedule a task to emit MsgsChanged event when the next local /// deletion happens. Existing task is cancelled to make sure at most /// one such task is scheduled at a time. /// /// UI is expected to reload the chatlist or the chat in response to /// MsgsChanged event, this will trigger actual deletion. /// /// This takes into account only per-chat timeouts, because global device /// timeouts are at least one hour long and deletion is triggered often enough /// by user actions. pub async fn schedule_ephemeral_task(context: &Context) { let ephemeral_timestamp: Option = match context .sql .query_get_value_result( "SELECT ephemeral_timestamp \ FROM msgs \ WHERE ephemeral_timestamp != 0 \ AND chat_id != ? \ ORDER BY ephemeral_timestamp ASC \ LIMIT 1", paramsv![DC_CHAT_ID_TRASH], // Trash contains already deleted messages, skip them ) .await { Err(err) => { warn!(context, "Can't calculate next ephemeral timeout: {}", err); return; } Ok(ephemeral_timestamp) => ephemeral_timestamp, }; // Cancel existing task, if any if let Some(ephemeral_task) = context.ephemeral_task.write().await.take() { ephemeral_task.cancel().await; } if let Some(ephemeral_timestamp) = ephemeral_timestamp { let now = SystemTime::now(); let until = UNIX_EPOCH + Duration::from_secs(ephemeral_timestamp.try_into().unwrap_or(u64::MAX)) + Duration::from_secs(1); if let Ok(duration) = until.duration_since(now) { // Schedule a task, ephemeral_timestamp is in the future let context1 = context.clone(); let ephemeral_task = task::spawn(async move { async_std::task::sleep(duration).await; emit_event!( context1, EventType::MsgsChanged { chat_id: ChatId::new(0), msg_id: MsgId::new(0) } ); }); *context.ephemeral_task.write().await = Some(ephemeral_task); } else { // Emit event immediately emit_event!( context, EventType::MsgsChanged { chat_id: ChatId::new(0), msg_id: MsgId::new(0) } ); } } } /// Returns ID of any expired message that should be deleted from the server. /// /// It looks up the trash chat too, to find messages that are already /// deleted locally, but not deleted on the server. pub(crate) async fn load_imap_deletion_msgid(context: &Context) -> sql::Result> { let now = time(); let threshold_timestamp = match context.get_config_delete_server_after().await { None => 0, Some(delete_server_after) => now - delete_server_after, }; context .sql .query_row_optional( "SELECT id FROM msgs \ WHERE ( \ timestamp < ? \ OR (ephemeral_timestamp != 0 AND ephemeral_timestamp <= ?) \ ) \ AND server_uid != 0 \ LIMIT 1", paramsv![threshold_timestamp, now], |row| row.get::<_, MsgId>(0), ) .await } /// Start ephemeral timers for seen messages if they are not started /// yet. /// /// It is possible that timers are not started due to a missing or /// failed `MsgId.start_ephemeral_timer()` call, either in the current /// or previous version of Delta Chat. /// /// This function is supposed to be called in the background, /// e.g. from housekeeping task. pub(crate) async fn start_ephemeral_timers(context: &Context) -> sql::Result<()> { context .sql .execute( "UPDATE msgs \ SET ephemeral_timestamp = ? + ephemeral_timer \ WHERE ephemeral_timer > 0 \ AND ephemeral_timestamp = 0 \ AND state NOT IN (?, ?, ?)", paramsv![ time(), MessageState::InFresh, MessageState::InNoticed, MessageState::OutDraft ], ) .await?; Ok(()) } #[cfg(test)] mod tests { use async_std::task::sleep; use super::*; use crate::test_utils::TestContext; use crate::{ chat::{self, Chat, ChatItem}, dc_tools::IsNoneOrEmpty, }; #[async_std::test] async fn test_stock_ephemeral_messages() { let context = TestContext::new().await.ctx; assert_eq!( stock_ephemeral_timer_changed(&context, Timer::Disabled, DC_CONTACT_ID_SELF).await, "Message deletion timer is disabled by me." ); assert_eq!( stock_ephemeral_timer_changed( &context, Timer::Enabled { duration: 1 }, DC_CONTACT_ID_SELF ) .await, "Message deletion timer is set to 1 s by me." ); assert_eq!( stock_ephemeral_timer_changed( &context, Timer::Enabled { duration: 30 }, DC_CONTACT_ID_SELF ) .await, "Message deletion timer is set to 30 s by me." ); assert_eq!( stock_ephemeral_timer_changed( &context, Timer::Enabled { duration: 60 }, DC_CONTACT_ID_SELF ) .await, "Message deletion timer is set to 1 minute by me." ); assert_eq!( stock_ephemeral_timer_changed( &context, Timer::Enabled { duration: 90 }, DC_CONTACT_ID_SELF ) .await, "Message deletion timer is set to 1.5 minutes by me." ); assert_eq!( stock_ephemeral_timer_changed( &context, Timer::Enabled { duration: 30 * 60 }, DC_CONTACT_ID_SELF ) .await, "Message deletion timer is set to 30 minutes by me." ); assert_eq!( stock_ephemeral_timer_changed( &context, Timer::Enabled { duration: 60 * 60 }, DC_CONTACT_ID_SELF ) .await, "Message deletion timer is set to 1 hour by me." ); assert_eq!( stock_ephemeral_timer_changed( &context, Timer::Enabled { duration: 5400 }, DC_CONTACT_ID_SELF ) .await, "Message deletion timer is set to 1.5 hours by me." ); assert_eq!( stock_ephemeral_timer_changed( &context, Timer::Enabled { duration: 2 * 60 * 60 }, DC_CONTACT_ID_SELF ) .await, "Message deletion timer is set to 2 hours by me." ); assert_eq!( stock_ephemeral_timer_changed( &context, Timer::Enabled { duration: 24 * 60 * 60 }, DC_CONTACT_ID_SELF ) .await, "Message deletion timer is set to 1 day by me." ); assert_eq!( stock_ephemeral_timer_changed( &context, Timer::Enabled { duration: 2 * 24 * 60 * 60 }, DC_CONTACT_ID_SELF ) .await, "Message deletion timer is set to 2 days by me." ); assert_eq!( stock_ephemeral_timer_changed( &context, Timer::Enabled { duration: 7 * 24 * 60 * 60 }, DC_CONTACT_ID_SELF ) .await, "Message deletion timer is set to 1 week by me." ); assert_eq!( stock_ephemeral_timer_changed( &context, Timer::Enabled { duration: 4 * 7 * 24 * 60 * 60 }, DC_CONTACT_ID_SELF ) .await, "Message deletion timer is set to 4 weeks by me." ); } #[async_std::test] async fn test_ephemeral_timer() -> anyhow::Result<()> { let alice = TestContext::new_alice().await; let bob = TestContext::new_bob().await; let chat_alice = alice.create_chat(&bob).await.id; let chat_bob = bob.create_chat(&alice).await.id; // Alice sends message to Bob let mut msg = Message::new(Viewtype::Text); chat::prepare_msg(&alice.ctx, chat_alice, &mut msg).await?; chat::send_msg(&alice.ctx, chat_alice, &mut msg).await?; let sent = alice.pop_sent_msg().await; bob.recv_msg(&sent).await; // Alice sends second message to Bob, with no timer let mut msg = Message::new(Viewtype::Text); chat::prepare_msg(&alice.ctx, chat_alice, &mut msg).await?; chat::send_msg(&alice.ctx, chat_alice, &mut msg).await?; let sent = alice.pop_sent_msg().await; assert_eq!( chat_bob.get_ephemeral_timer(&bob.ctx).await?, Timer::Disabled ); // Bob sets ephemeral timer and sends a message about timer change chat_bob .set_ephemeral_timer(&bob.ctx, Timer::Enabled { duration: 60 }) .await?; let sent_timer_change = bob.pop_sent_msg().await; assert_eq!( chat_bob.get_ephemeral_timer(&bob.ctx).await?, Timer::Enabled { duration: 60 } ); // Bob receives message from Alice. // Alice message has no timer. However, Bob should not disable timer, // because Alice replies to old message. bob.recv_msg(&sent).await; assert_eq!( chat_alice.get_ephemeral_timer(&alice.ctx).await?, Timer::Disabled ); assert_eq!( chat_bob.get_ephemeral_timer(&bob.ctx).await?, Timer::Enabled { duration: 60 } ); // Alice receives message from Bob alice.recv_msg(&sent_timer_change).await; assert_eq!( chat_alice.get_ephemeral_timer(&alice.ctx).await?, Timer::Enabled { duration: 60 } ); Ok(()) } #[async_std::test] async fn test_ephemeral_delete_msgs() { let t = TestContext::new_alice().await; let chat = t.get_self_chat().await; t.send_text(chat.id, "Saved message, which we delete manually") .await; let msg = t.get_last_msg_in(chat.id).await; msg.id.delete_from_db(&t).await.unwrap(); check_msg_was_deleted(&t, &chat, msg.id).await; chat.id .set_ephemeral_timer(&t, Timer::Enabled { duration: 1 }) .await .unwrap(); let msg = t .send_text(chat.id, "Saved message, disappearing after 1s") .await; sleep(Duration::from_millis(1100)).await; check_msg_was_deleted(&t, &chat, msg.sender_msg_id).await; } async fn check_msg_was_deleted(t: &TestContext, chat: &Chat, msg_id: MsgId) { let chat_items = chat::get_chat_msgs(t, chat.id, 0, None).await; // Check that the chat is empty except for possibly info messages: for item in &chat_items { if let ChatItem::Message { msg_id } = item { let msg = Message::load_from_db(t, *msg_id).await.unwrap(); assert!(msg.is_info()) } } // Check that if there is a message left, the text and metadata are gone if let Ok(msg) = Message::load_from_db(t, msg_id).await { assert_eq!(msg.from_id, 0); assert_eq!(msg.to_id, 0); assert!(msg.text.is_none_or_empty(), msg.text); let rawtxt: Option = t .sql .query_get_value(t, "SELECT txt_raw FROM msgs WHERE id=?;", paramsv![msg_id]) .await; assert!(rawtxt.is_none_or_empty(), rawtxt); } } }