1use crate::{
3 projection::{check_known_table, identity, ownership},
4 writer::{exact_columns, find_database, open_write},
5 *,
6};
7use rusqlite::{params, OptionalExtension};
8use serde::Serialize;
9
10#[derive(Clone, Debug, Serialize)]
11#[serde(rename_all = "camelCase")]
12pub struct ThreadReadProjection {
13 pub session_present: bool,
14 pub removed_unread_rows: usize,
15 pub unread_count_before: i64,
16 pub unread_count_after: i64,
17 pub last_clear_unread_timestamp: i64,
18 pub whole_thread: bool,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ThreadExitReadOutcome {
24 Applied,
25 MissingSession,
26 HiddenSession,
27 ExcludedPeer,
28}
29
30#[derive(Clone, Debug, Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct ThreadExitReadProjection {
33 pub outcome: ThreadExitReadOutcome,
34 pub projection: Option<ThreadReadProjection>,
35}
36
37const EXIT_READ_EXCLUDED_PEERS: &[&str] = &[
40 "brandsessionholder",
41 "brandservicesessionholder",
42 "qmessage",
43 "medianote",
44 "gh_22b87fa7cb3c",
45 "qqsync",
46 "lbsapp",
47 "shakeapp",
48 "floatbottle",
49 "masssendapp",
50 "gh_43f2581f6fd6",
51 "appbrandcustomerservicemsg",
52 "service_officialaccounts",
53 "notifymessage",
54 "notification_messages",
55];
56
57pub fn mark_read_thread(
61 location: &ProfileLocation,
62 keys: &KeyFile,
63 thread: &str,
64 through_time: i64,
65 options: &WriteOptions,
66) -> Result<ThreadReadProjection> {
67 project_thread_read(location, keys, thread, through_time, options, false)?
68 .projection
69 .ok_or(ProfileError::InvalidMetadata)
70}
71
72pub fn mark_read_thread_exit(
76 location: &ProfileLocation,
77 keys: &KeyFile,
78 thread: &str,
79 through_time: i64,
80 options: &WriteOptions,
81) -> Result<ThreadExitReadProjection> {
82 project_thread_read(location, keys, thread, through_time, options, true)
83}
84
85fn project_thread_read(
86 location: &ProfileLocation,
87 keys: &KeyFile,
88 thread: &str,
89 through_time: i64,
90 options: &WriteOptions,
91 visible_ordinary_only: bool,
92) -> Result<ThreadExitReadProjection> {
93 ownership(location, keys, options)?;
94 identity(thread)?;
95 if !u32::try_from(through_time).is_ok_and(|time| time != 0) {
96 return Err(ProfileError::InvalidQuery);
97 }
98 if visible_ordinary_only && EXIT_READ_EXCLUDED_PEERS.contains(&thread) {
99 return Ok(ThreadExitReadProjection {
100 outcome: ThreadExitReadOutcome::ExcludedPeer,
101 projection: None,
102 });
103 }
104 let relative = find_database(location, "session.db")?;
105 let mut database = open_write(location, &relative, keys, options.allow_plaintext)?;
106 check_known_table(&database, "SessionTable")?;
107 check_known_table(&database, "SessionUnreadListTable_1")?;
108 exact_columns(&database, &relative, "Name2Id", &["user_name"])?;
109 let tx = database.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
110 let session = tx
111 .query_row(
112 "SELECT COALESCE(unread_count,0),COALESCE(last_timestamp,0),COALESCE(last_clear_unread_timestamp,0),COALESCE(is_hidden,0) FROM SessionTable WHERE username=?1",
113 [thread],
114 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?, row.get::<_, i64>(3)?)),
115 )
116 .optional()?;
117 let Some((before, last_time, old_clear, is_hidden)) = session else {
118 return Ok(ThreadExitReadProjection {
121 outcome: ThreadExitReadOutcome::MissingSession,
122 projection: Some(ThreadReadProjection {
123 session_present: false,
124 removed_unread_rows: 0,
125 unread_count_before: 0,
126 unread_count_after: 0,
127 last_clear_unread_timestamp: 0,
128 whole_thread: true,
129 }),
130 });
131 };
132 if visible_ordinary_only && is_hidden != 0 {
133 return Ok(ThreadExitReadProjection {
134 outcome: ThreadExitReadOutcome::HiddenSession,
135 projection: None,
136 });
137 }
138 if before < 0 || old_clear < 0 {
139 return Err(ProfileError::WritesUnsupported);
140 }
141 let name_id = tx
142 .query_row(
143 "SELECT rowid FROM Name2Id WHERE user_name=?1",
144 [thread],
145 |row| row.get::<_, i64>(0),
146 )
147 .optional()?;
148 let (removed, remaining, first) = if let Some(name_id) = name_id {
149 let removed = tx.execute(
150 "DELETE FROM SessionUnreadListTable_1 WHERE username_id=?1 AND create_time<=?2",
151 params![name_id, through_time],
152 )?;
153 let remaining: i64 = tx.query_row(
154 "SELECT COUNT(*) FROM SessionUnreadListTable_1 WHERE username_id=?1",
155 [name_id],
156 |row| row.get(0),
157 )?;
158 let first = tx.query_row(
159 "SELECT server_id FROM SessionUnreadListTable_1 WHERE username_id=?1 ORDER BY create_time,local_id,server_id LIMIT 1",
160 [name_id],
161 |row| row.get::<_, i64>(0),
162 ).optional()?.unwrap_or(0);
163 (removed, remaining, first)
164 } else {
165 (0, 0, 0)
166 };
167 let after = if last_time > through_time {
170 before.saturating_sub(removed as i64).max(remaining)
171 } else {
172 remaining
173 };
174 let clear = old_clear.max(through_time);
175 tx.execute(
176 "UPDATE SessionTable SET unread_count=?1,unread_first_msg_srv_id=?2,last_clear_unread_timestamp=?3,unread_first_pat_msg_local_id=CASE WHEN ?1=0 THEN 0 ELSE unread_first_pat_msg_local_id END,unread_first_pat_msg_sort_seq=CASE WHEN ?1=0 THEN 0 ELSE unread_first_pat_msg_sort_seq END WHERE username=?4",
177 params![after, first, clear, thread],
178 )?;
179 tx.commit()?;
180 Ok(ThreadExitReadProjection {
181 outcome: ThreadExitReadOutcome::Applied,
182 projection: Some(ThreadReadProjection {
183 session_present: true,
184 removed_unread_rows: removed,
185 unread_count_before: before,
186 unread_count_after: after,
187 last_clear_unread_timestamp: clear,
188 whole_thread: true,
189 }),
190 })
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use rusqlite::Connection;
197 use std::{collections::BTreeMap, fs};
198
199 fn fixture(reordered: bool) -> (tempfile::TempDir, ProfileLocation, KeyFile, WriteOptions) {
200 let directory = tempfile::tempdir().unwrap();
201 let path = directory
202 .path()
203 .join("account/db_storage/session/session.db");
204 fs::create_dir_all(path.parent().unwrap()).unwrap();
205 let sql = include_str!("layout/session.db.sql");
206 let sql = if reordered {
207 sql.replace(
208 "server_id INTEGER, local_id INTEGER, create_time INTEGER",
209 "server_id INTEGER, create_time INTEGER, local_id INTEGER",
210 )
211 } else {
212 sql.into()
213 };
214 let database = Connection::open(path).unwrap();
215 database.execute_batch(&sql).unwrap();
216 database.execute_batch("INSERT INTO Name2Id(rowid,user_name)VALUES(1,'peer'),(2,'other'); INSERT INTO SessionTable(username,unread_count,unread_first_msg_srv_id,unread_first_pat_msg_local_id,unread_first_pat_msg_sort_seq,last_timestamp,last_clear_unread_timestamp,draft,status)VALUES('peer',9,90,12,13,100,20,'keep draft',7),('other',1,500,0,0,100,0,'other',3); INSERT INTO SessionUnreadListTable_1(username_id,server_id,local_id,create_time)VALUES(1,90,1,90),(1,80,2,100),(2,500,1,100);").unwrap();
217 drop(database);
218 let location = ProfileLocation::new(directory.path().join("account")).unwrap();
219 let keys = KeyFile {
220 account_binding: "account".into(),
221 databases: BTreeMap::new(),
222 image_dat: None,
223 };
224 let options = WriteOptions {
225 exclusive_ownership_confirmed: true,
226 allow_plaintext: true,
227 durable_intent_id: "synthetic-thread-read".into(),
228 };
229 (directory, location, keys, options)
230 }
231
232 #[test]
233 fn whole_thread_clears_legacy_counter_and_advances_watermark_in_both_column_orders() {
234 for reordered in [false, true] {
235 let (_directory, location, keys, options) = fixture(reordered);
236 let result = mark_read_thread(&location, &keys, "peer", 100, &options).unwrap();
237 assert_eq!(
238 (
239 result.removed_unread_rows,
240 result.unread_count_before,
241 result.unread_count_after
242 ),
243 (2, 9, 0)
244 );
245 assert_eq!(result.last_clear_unread_timestamp, 100);
246 let database = Connection::open(location.storage.join("session/session.db")).unwrap();
247 let row = database.query_row("SELECT unread_count,unread_first_msg_srv_id,last_clear_unread_timestamp,unread_first_pat_msg_local_id,unread_first_pat_msg_sort_seq,draft,status FROM SessionTable WHERE username='peer'", [], |row| Ok((row.get::<_,i64>(0)?,row.get::<_,i64>(1)?,row.get::<_,i64>(2)?,row.get::<_,i64>(3)?,row.get::<_,i64>(4)?,row.get::<_,String>(5)?,row.get::<_,i64>(6)?))).unwrap();
248 assert_eq!(row, (0, 0, 100, 0, 0, "keep draft".into(), 7));
249 assert_eq!(
250 database
251 .query_row(
252 "SELECT unread_count FROM SessionTable WHERE username='other'",
253 [],
254 |row| row.get::<_, i64>(0)
255 )
256 .unwrap(),
257 1
258 );
259 assert_eq!(
260 database
261 .query_row(
262 "SELECT COUNT(*) FROM SessionUnreadListTable_1 WHERE username_id=2",
263 [],
264 |row| row.get::<_, i64>(0)
265 )
266 .unwrap(),
267 1
268 );
269 drop(database);
270 let again = mark_read_thread(&location, &keys, "peer", 90, &options).unwrap();
271 assert_eq!(
272 (
273 again.removed_unread_rows,
274 again.unread_count_after,
275 again.last_clear_unread_timestamp
276 ),
277 (0, 0, 100)
278 );
279 }
280 }
281
282 #[test]
283 fn later_arrivals_and_unknown_newer_counter_contributions_survive() {
284 let (_directory, location, keys, options) = fixture(false);
285 let database = Connection::open(location.storage.join("session/session.db")).unwrap();
286 database.execute_batch("UPDATE SessionTable SET last_timestamp=110 WHERE username='peer'; INSERT INTO SessionUnreadListTable_1(username_id,server_id,local_id,create_time)VALUES(1,3,3,110);").unwrap();
287 drop(database);
288 let result = mark_read_thread(&location, &keys, "peer", 100, &options).unwrap();
289 assert_eq!(
290 (result.removed_unread_rows, result.unread_count_after),
291 (2, 7)
292 );
293 let database = Connection::open(location.storage.join("session/session.db")).unwrap();
294 assert_eq!(
295 database
296 .query_row(
297 "SELECT unread_first_msg_srv_id FROM SessionTable WHERE username='peer'",
298 [],
299 |row| row.get::<_, i64>(0)
300 )
301 .unwrap(),
302 3
303 );
304 drop(database);
305 let result = mark_read_thread(&location, &keys, "peer", 110, &options).unwrap();
306 assert_eq!(result.unread_count_after, 0);
307 assert_eq!(result.last_clear_unread_timestamp, 110);
308 }
309
310 #[test]
311 fn unknown_session_is_empty_and_unsafe_writes_are_rejected() {
312 let (_directory, location, keys, mut options) = fixture(false);
313 assert!(
314 !mark_read_thread(&location, &keys, "absent", 100, &options)
315 .unwrap()
316 .session_present
317 );
318 assert!(mark_read_thread(&location, &keys, "peer", 0, &options).is_err());
319 assert!(mark_read_thread(&location, &keys, "peer\n", 100, &options).is_err());
320 options.exclusive_ownership_confirmed = false;
321 assert!(mark_read_thread(&location, &keys, "peer", 100, &options).is_err());
322 options.exclusive_ownership_confirmed = true;
323 let mut other_keys = keys.clone();
324 other_keys.account_binding = "other".into();
325 assert!(mark_read_thread(&location, &other_keys, "peer", 100, &options).is_err());
326 let database = Connection::open(location.storage.join("session/session.db")).unwrap();
327 database
328 .execute_batch(
329 "CREATE TRIGGER unexpected AFTER UPDATE ON SessionTable BEGIN SELECT 1; END;",
330 )
331 .unwrap();
332 drop(database);
333 assert!(mark_read_thread(&location, &keys, "peer", 100, &options).is_err());
334 }
335
336 #[test]
337 fn exit_read_skips_missing_hidden_and_every_excluded_builtin() {
338 let (_directory, location, keys, options) = fixture(false);
339 assert_eq!(
340 mark_read_thread_exit(&location, &keys, "absent", 100, &options)
341 .unwrap()
342 .outcome,
343 ThreadExitReadOutcome::MissingSession
344 );
345 let database = Connection::open(location.storage.join("session/session.db")).unwrap();
346 database
347 .execute(
348 "UPDATE SessionTable SET is_hidden=1 WHERE username='peer'",
349 [],
350 )
351 .unwrap();
352 assert_eq!(
353 mark_read_thread_exit(&location, &keys, "peer", 100, &options)
354 .unwrap()
355 .outcome,
356 ThreadExitReadOutcome::HiddenSession
357 );
358 assert_eq!(
359 database
360 .query_row(
361 "SELECT unread_count FROM SessionTable WHERE username='peer'",
362 [],
363 |row| row.get::<_, i64>(0)
364 )
365 .unwrap(),
366 9
367 );
368 for peer in EXIT_READ_EXCLUDED_PEERS {
369 database.execute("INSERT INTO SessionTable(username,unread_count,is_hidden,last_timestamp,last_clear_unread_timestamp)VALUES(?1,4,0,100,20)", [peer]).unwrap();
370 assert_eq!(
371 mark_read_thread_exit(&location, &keys, peer, 100, &options)
372 .unwrap()
373 .outcome,
374 ThreadExitReadOutcome::ExcludedPeer
375 );
376 assert_eq!(
377 database
378 .query_row(
379 "SELECT unread_count FROM SessionTable WHERE username=?1",
380 [peer],
381 |row| row.get::<_, i64>(0)
382 )
383 .unwrap(),
384 4
385 );
386 }
387 database
388 .execute(
389 "UPDATE SessionTable SET is_hidden=0,last_timestamp=110 WHERE username='peer'",
390 [],
391 )
392 .unwrap();
393 database.execute_batch("INSERT INTO SessionUnreadListTable_1(username_id,server_id,local_id,create_time)VALUES(1,10,3,110)").unwrap();
394 drop(database);
395 let applied = mark_read_thread_exit(&location, &keys, "peer", 100, &options).unwrap();
396 assert_eq!(applied.outcome, ThreadExitReadOutcome::Applied);
397 let projection = applied.projection.unwrap();
398 assert_eq!(
399 (
400 projection.removed_unread_rows,
401 projection.unread_count_after,
402 projection.last_clear_unread_timestamp
403 ),
404 (2, 7, 100)
405 );
406 }
407}