From 1b42fa59dc07386da2d7155acb65fa5a9e218983 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:16:56 +0300 Subject: [PATCH 1/3] fix(mtproto): harden PFS and session ownership --- native/Cargo.lock | 1 + native/mtproto-rs/src/auth/auth_key.rs | 57 +- native/mtproto-rs/src/auth/bind_temp.rs | 428 ++++++++---- native/mtproto-rs/src/client/auth.rs | 35 +- native/mtproto-rs/src/client/dispatch.rs | 16 +- native/mtproto-rs/src/client/lanes.rs | 14 +- .../mtproto-rs/src/client/media_download.rs | 40 +- native/mtproto-rs/src/client/media_lane.rs | 82 +-- native/mtproto-rs/src/client/mod.rs | 160 +++-- native/mtproto-rs/src/client/persist.rs | 34 +- native/mtproto-rs/src/client/session.rs | 88 ++- native/mtproto-rs/src/client/updates.rs | 16 +- native/mtproto-rs/src/rpc/framing.rs | 43 +- native/mtproto-rs/src/rpc/supervisor.rs | 14 +- native/mtproto-rs/src/scheduler.rs | 64 +- native/mtproto-rs/src/session_file.rs | 2 +- native/mtproto-rs/src/tcp.rs | 23 +- native/mtproto-rs/src/transfer_policy.rs | 4 + .../tests/unit/auth_auth_key_tests.rs | 131 ++++ .../tests/unit/auth_bind_temp_tests.rs | 241 +++++++ .../tests/unit/client_mgr_channel_tests.rs | 5 +- .../tests/unit/client_mgr_lane_tests.rs | 5 + .../tests/unit/client_mgr_logout_tests.rs | 15 +- .../tests/unit/client_mgr_pfs_tests.rs | 658 ++++++++++++++++++ .../tests/unit/client_mgr_session_tests.rs | 14 +- .../tests/unit/media_download_tests.rs | 13 +- .../tests/unit/rpc_control_tests.rs | 27 + .../mtproto-rs/tests/unit/scheduler_tests.rs | 63 +- native/mtproto-rs/tests/unit/tcp_tests.rs | 59 ++ native/mtproto-transport/src/tcp.rs | 24 +- .../session/SessionCoreOwnershipTest.kt | 11 + 31 files changed, 1955 insertions(+), 432 deletions(-) create mode 100644 native/mtproto-rs/tests/unit/auth_bind_temp_tests.rs create mode 100644 native/mtproto-rs/tests/unit/client_mgr_pfs_tests.rs create mode 100644 native/mtproto-rs/tests/unit/rpc_control_tests.rs diff --git a/native/Cargo.lock b/native/Cargo.lock index 24f16e195..1252cb09a 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -1561,6 +1561,7 @@ dependencies = [ "getrandom 0.4.3", "serde", "thiserror", + "zeroize", ] [[package]] diff --git a/native/mtproto-rs/src/auth/auth_key.rs b/native/mtproto-rs/src/auth/auth_key.rs index b7978f68f..a13f113b1 100644 --- a/native/mtproto-rs/src/auth/auth_key.rs +++ b/native/mtproto-rs/src/auth/auth_key.rs @@ -19,6 +19,7 @@ use tellers_mtproto_session::{Clock, Snapshot}; use tellers_mtproto_transport::{Connection, Framing, PaddedIntermediate}; use crate::MtprotoError; +use zeroize::Zeroize; const PROD_RSA_PEM: &str = "-----BEGIN RSA PUBLIC KEY-----\n\ MIIBCgKCAQEA6LszBcC1LGzyr992NzE0ieY+BSaOW622Aa9Bd4ZHLl+TuFQ4lo4g\n\ @@ -129,6 +130,15 @@ struct AuthAdapter { g_b: Option>, auth_key: Option>, expires_in: Option, + expires_at: Option, + time_offset_micros: Option, + clock: fn() -> i64, +} + +impl Drop for AuthAdapter { + fn drop(&mut self) { + self.auth_key.zeroize(); + } } impl AuthAdapter { @@ -150,6 +160,9 @@ impl AuthAdapter { g_b: None, auth_key: None, expires_in: None, + expires_at: None, + time_offset_micros: None, + clock: || SystemClock.unix_micros(), }) } } @@ -272,6 +285,9 @@ impl AuthorizationAdapter for AuthAdapter { let server_nonce = self.server_nonce.clone().ok_or_else(|| { tellers_mtproto_engine::Error::Authorization("missing server_nonce".into()) })?; + if ok.nonce != self.nonce || ok.server_nonce != server_nonce { + return Err(tellers_mtproto_engine::Error::Authorization("DH envelope nonce mismatch".into())); + } let new_nonce = self.new_nonce.clone().ok_or_else(|| { tellers_mtproto_engine::Error::Authorization("missing new_nonce".into()) })?; @@ -299,6 +315,18 @@ impl AuthorizationAdapter for AuthAdapter { )); }; + if inner.nonce != self.nonce || inner.server_nonce != server_nonce { + return Err(tellers_mtproto_engine::Error::Authorization("DH inner nonce mismatch".into())); + } + let received_at = (self.clock)(); + if inner.server_time <= 0 { + return Err(tellers_mtproto_engine::Error::Authorization("invalid DH server time".into())); + } + let expires_at = self.expires_in.map(|seconds| inner.server_time.checked_add(seconds) + .filter(|expiry| *expiry > inner.server_time) + .ok_or_else(|| tellers_mtproto_engine::Error::Authorization("invalid temporary key expiry".into()))) + .transpose()?; + use num_bigint::BigUint; let prime = BigUint::from_bytes_be(&inner.dh_prime); let g_a = BigUint::from_bytes_be(&inner.g_a); @@ -310,7 +338,7 @@ impl AuthorizationAdapter for AuthAdapter { let prime_bytes = usize::try_from(prime.bits().div_ceil(8)) .unwrap_or(256) .max(256); - let mut b_bytes = vec![0_u8; prime_bytes]; + let mut b_bytes = zeroize::Zeroizing::new(vec![0_u8; prime_bytes]); fill_random(&mut b_bytes) .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; let b = BigUint::from_bytes_be(&b_bytes); @@ -322,6 +350,8 @@ impl AuthorizationAdapter for AuthAdapter { let auth_key = derive_auth_key(&g_a, &b, &prime) .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; self.auth_key = Some(auth_key); + self.expires_at = expires_at; + self.time_offset_micros = Some((i64::from(inner.server_time) * 1_000_000).saturating_sub(received_at)); let client_inner = tellers_mtproto::transport::ClientDhInnerDataConstructor { nonce: self.nonce.clone(), @@ -357,7 +387,7 @@ impl AuthorizationAdapter for AuthAdapter { ) -> Result<(Vec, i64), tellers_mtproto_engine::Error> { let answer = decode_boxed::(response) .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - let auth_key = self.auth_key.clone().ok_or_else(|| { + let auth_key = self.auth_key.as_ref().ok_or_else(|| { tellers_mtproto_engine::Error::Authorization("missing auth_key".into()) })?; let new_nonce = self.new_nonce.clone().ok_or_else(|| { @@ -368,6 +398,9 @@ impl AuthorizationAdapter for AuthAdapter { })?; match answer { SetClientDhParamsAnswer::DhGenOk(ok) => { + if ok.nonce != self.nonce || ok.server_nonce != server_nonce { + return Err(tellers_mtproto_engine::Error::Authorization("DH completion nonce mismatch".into())); + } let expected = new_nonce_hash(&i256_to_bytes(&new_nonce), &auth_key, 1); let got = i128_to_bytes(&ok.new_nonce_hash1); if got != expected { @@ -377,13 +410,17 @@ impl AuthorizationAdapter for AuthAdapter { } let salt = initial_server_salt(&i256_to_bytes(&new_nonce), &i128_to_bytes(&server_nonce)); - Ok((auth_key, salt)) + Ok((self.auth_key.take().expect("validated auth key"), salt)) } _ => Err(tellers_mtproto_engine::Error::Authorization( "dh_gen not ok".into(), )), } } + + fn time_offset_micros(&self) -> Option { + self.time_offset_micros + } } fn send_plain( @@ -418,6 +455,9 @@ fn recv_plain( if n == 0 { return Err(MtprotoError::Message("connection closed".into())); } + if input.len().saturating_add(n) > 1024 * 1024 + 4 { + return Err(MtprotoError::Message("authorization response exceeds size limit".into())); + } input.extend_from_slice(&buf[..n]); match framing.decode(&input) { Ok(packet) => { @@ -446,10 +486,11 @@ pub fn create_temp_auth_key( framing: &mut PaddedIntermediate, snapshot: &mut Snapshot, expires_in: i32, -) -> Result<(), MtprotoError> { +) -> Result { let mut adapter = AuthAdapter::with_pem(snapshot.dc_id, rsa_pem().to_string())?; adapter.expires_in = Some(expires_in); - run_exchange(adapter, conn, framing, snapshot) + run_exchange(adapter, conn, framing, snapshot)? + .ok_or_else(|| MtprotoError::Message("missing temporary key expiry".into())) } pub fn create_auth_key_with_pem( @@ -459,7 +500,7 @@ pub fn create_auth_key_with_pem( pem: &str, ) -> Result<(), MtprotoError> { let adapter = AuthAdapter::with_pem(snapshot.dc_id, pem.to_string())?; - run_exchange(adapter, conn, framing, snapshot) + run_exchange(adapter, conn, framing, snapshot).map(|_| ()) } fn run_exchange( @@ -467,7 +508,7 @@ fn run_exchange( conn: &mut dyn Connection, framing: &mut PaddedIntermediate, snapshot: &mut Snapshot, -) -> Result<(), MtprotoError> { +) -> Result, MtprotoError> { let clock = SystemClock; let mut auth = Authorization::new(adapter); @@ -498,7 +539,7 @@ fn run_exchange( if next.is_some() || auth.phase() != AuthorizationPhase::Authorized { return Err(MtprotoError::Message("auth key not installed".into())); } - Ok(()) + Ok(auth.into_adapter().expires_at) } /// Padded-intermediate returns payload + 0..=15 extra bytes. Plain envelopes diff --git a/native/mtproto-rs/src/auth/bind_temp.rs b/native/mtproto-rs/src/auth/bind_temp.rs index bad4a1170..2d9154cd3 100644 --- a/native/mtproto-rs/src/auth/bind_temp.rs +++ b/native/mtproto-rs/src/auth/bind_temp.rs @@ -3,7 +3,7 @@ //! https://core.telegram.org/method/auth.bindTempAuthKey //! https://core.telegram.org/mtproto/description_v1 -use tellers_mtproto::codec::{Boxed, Encoder}; +use tellers_mtproto::codec::{Boxed, Decoder, Encoder, Limits, TlDecode}; use tellers_mtproto::transport::BindAuthKeyInnerConstructor; use tellers_mtproto_crypto::{aes_ige_decrypt, aes_ige_encrypt, auth_key_id, fill_random, sha1}; use tellers_mtproto_session::{Clock, OsRandom, Snapshot}; @@ -115,6 +115,160 @@ pub(crate) struct BoundTemp { pub expires_at: i32, } +pub(crate) fn temp_key_valid(snapshot: &Snapshot, expires_at: i32) -> bool { + temp_key_valid_at(snapshot, expires_at, &SystemClock) +} + +fn temp_key_valid_at(snapshot: &Snapshot, expires_at: i32, clock: &impl Clock) -> bool { + let server_now = clock.unix_micros().saturating_add(snapshot.time_offset_micros).div_euclid(1_000_000); + i64::from(expires_at) > server_now.saturating_add(60) +} + +pub(crate) fn ensure_main_temp( + client: &crate::client::Client, + io: &mut crate::client::lanes::SessionIo, + activate: bool, +) -> Result { + ensure_main_temp_with(client, io, activate, &SystemClock, negotiate_bound_temp) +} + +pub(crate) fn ensure_main_temp_with( + client: &crate::client::Client, + io: &mut crate::client::lanes::SessionIo, + activate: bool, + clock: &impl Clock, + negotiate: impl FnOnce(&[u8], i32, i64) -> Result, +) -> Result { + client.connections.wait(std::time::Duration::ZERO) + .map_err(|e| MtprotoError::Message(e.to_string()))?; + if !activate && io.temp_expires_at == 0 && io.perm_key.is_none() { + return Ok(false); + } + let dc = { + let data = client.data.lock(); + if data.user_id.is_none() || data.session_dead { + return Ok(false); + } + data.home_dc + }; + ensure_dc_temp_with(client, io, dc, None, clock, negotiate) +} + +pub(crate) fn dc_permanent_key(data: &crate::client::ClientData, dc: i32) -> Option<&[u8]> { + let key = if dc == data.home_dc { + data.home_auth_key.as_deref() + } else { + data.dc_auth_keys.get(&dc).map(|entry| entry.key.as_slice()) + }; + key.filter(|key| key.len() == 256) +} + +pub(crate) fn ensure_dc_temp_with( + client: &crate::client::Client, + io: &mut crate::client::lanes::SessionIo, + dc: i32, + expected_home: Option, + clock: &impl Clock, + negotiate: impl FnOnce(&[u8], i32, i64) -> Result, +) -> Result { + client.connections.wait(std::time::Duration::ZERO) + .map_err(|e| MtprotoError::Message(e.to_string()))?; + let (permanent, owner, user, cached) = { + let data = client.data.lock(); + if data.user_id.is_none() || data.session_dead + || expected_home.is_some_and(|session| session != data.home_session_id) + { + return Err(crate::client::expired_session_lease()); + } + let permanent = dc_permanent_key(&data, dc).ok_or_else(|| + MtprotoError::Message("permanent session identity unavailable".into()))?.to_vec(); + let cached = expected_home.and_then(|_| data.dc_temp_auth.get(&dc)) + .filter(|entry| entry.perm_key == permanent && temp_key_valid_at(&entry.snapshot, entry.temp_expires_at, clock)) + .cloned(); + (permanent, data.home_snapshot.clone(), data.user_id, cached) + }; + if io.perm_key.as_deref() == Some(permanent.as_slice()) + && io.snapshot.dc_id == dc + && io.snapshot.auth_key.as_ref().is_some_and(|key| key.len() == 256 && key != &permanent) + && temp_key_valid_at(&io.snapshot, io.temp_expires_at, clock) + { + return Ok(false); + } + crate::request_control::check().map_err(|e| MtprotoError::Message(e.to_string()))?; + let bound = if let Some(cached) = cached { + BoundTemp { snapshot: crate::client::fork_session(&cached.snapshot), expires_at: cached.temp_expires_at } + } else { + crate::tcp::with_connection_control(&client.connections, || negotiate(&permanent, dc, owner.time_offset_micros))? + }; + crate::request_control::check().map_err(|e| MtprotoError::Message(e.to_string()))?; + bound.snapshot.validate().map_err(|e| MtprotoError::Message(e.to_string()))?; + if bound.snapshot.dc_id != dc || bound.snapshot.session_id == owner.session_id + || !bound.snapshot.auth_key.as_ref().is_some_and(|key| key.len() == 256 && key != &permanent) + || !temp_key_valid_at(&bound.snapshot, bound.expires_at, clock) + { + return Err(MtprotoError::Message("invalid bound temporary session".into())); + } + let mut data = client.data.lock(); + if !crate::client::session_lease_valid(&data, owner.session_id) || data.user_id != user + || data.home_dc != owner.dc_id || data.home_auth_key != owner.auth_key + || dc_permanent_key(&data, dc) != Some(permanent.as_slice()) + { + return Err(crate::client::expired_session_lease()); + } + client.connections.while_open(|| { + client.connections.wait(std::time::Duration::ZERO)?; + if let Some(mut previous) = io.transport.take() { + let _ = previous.conn.close(); + } + crate::rpc::salts::forget_session(io.snapshot.session_id); + if expected_home.is_some() { + data.dc_temp_auth.insert(dc, crate::client::DcTempAuth { + snapshot: bound.snapshot.clone(), temp_expires_at: bound.expires_at, perm_key: permanent.clone(), + }); + } + io.snapshot = bound.snapshot; + io.temp_expires_at = bound.expires_at; + io.perm_key = Some(permanent); + io.pending_push = Default::default(); + io.last_difference = None; + Ok::<_, tellers_mtproto_transport::Error>(()) + }).map_err(|e| MtprotoError::Message(e.to_string()))? + .map_err(|e| MtprotoError::Message(e.to_string()))?; + Ok(true) +} + +struct DeadlineConnection<'a> { + inner: &'a mut crate::tcp::ObfuscatedTcp, + deadline: std::time::Instant, +} + +impl DeadlineConnection<'_> { + fn prepare(&mut self) -> Result<(), tellers_mtproto_transport::Error> { + let remaining = self.deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return Err(tellers_mtproto_transport::Error::Connection("temporary key negotiation timed out".into())); + } + self.inner.set_io_timeout_ms(remaining.as_millis().min(u128::from(u64::MAX)) as u64); + Ok(()) + } +} + +impl Connection for DeadlineConnection<'_> { + fn send(&mut self, packet: &[u8]) -> Result<(), tellers_mtproto_transport::Error> { + self.prepare()?; + self.inner.send(packet) + } + + fn receive(&mut self, output: &mut [u8]) -> Result { + self.prepare()?; + self.inner.receive(output) + } + + fn close(&mut self) -> Result<(), tellers_mtproto_transport::Error> { + self.inner.close() + } +} + /// Handshake a temporary key and bind it. Leaves the permanent key untouched. pub(crate) fn negotiate_bound_temp( perm_key: &[u8], @@ -124,26 +278,38 @@ pub(crate) fn negotiate_bound_temp( if perm_key.len() != 256 { return Err(MtprotoError::Message("permanent key required".into())); } - let expires_at = unix_now().saturating_add(TEMP_EXPIRES_IN); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); let mut snapshot = Snapshot::new(dc_id, &mut OsRandom).map_err(|e| MtprotoError::Message(e.to_string()))?; snapshot.time_offset_micros = time_offset_micros; let mut conn = crate::tcp::connect_obfuscated_dc(crate::rpc::dc_endpoints(dc_id)) .map_err(|e| MtprotoError::Message(e.to_string()))?; + let mut conn = DeadlineConnection { inner: &mut conn, deadline }; let mut framing = PaddedIntermediate::default(); - create_temp_auth_key(&mut conn, &mut framing, &mut snapshot, TEMP_EXPIRES_IN)?; - let temp_key = snapshot + let expires_at = create_temp_auth_key(&mut conn, &mut framing, &mut snapshot, TEMP_EXPIRES_IN)?; + bind_temporary_key(&mut conn, &mut framing, &mut snapshot, perm_key, expires_at, &SystemClock)?; + Ok(BoundTemp { snapshot, expires_at }) +} + +fn bind_temporary_key( + conn: &mut dyn Connection, + framing: &mut PaddedIntermediate, + snapshot: &mut Snapshot, + perm_key: &[u8], + expires_at: i32, + clock: &impl Clock, +) -> Result<(), MtprotoError> { + let temp_key = zeroize::Zeroizing::new(snapshot .auth_key .clone() - .ok_or_else(|| MtprotoError::Message("temp key missing".into()))?; - if temp_key == perm_key { + .ok_or_else(|| MtprotoError::Message("temp key missing".into()))?); + if temp_key.as_slice() == perm_key { return Err(MtprotoError::Message( "temp key matched permanent key".into(), )); } - let clock = SystemClock; let msg_id = snapshot - .next_message_id(&clock) + .next_message_id(clock) .map_err(|e| MtprotoError::Message(e.to_string()))?; let mut nonce_bytes = [0_u8; 8]; fill_random(&mut nonce_bytes).map_err(|e| MtprotoError::Message(e.to_string()))?; @@ -156,7 +322,9 @@ pub(crate) fn negotiate_bound_temp( temp_session_id: snapshot.session_id, expires_at, }; - let encrypted = encrypt_bind_message(perm_key, msg_id, &inner, &prefix, &[0_u8; 8])?; + let mut padding = [0_u8; 8]; + fill_random(&mut padding).map_err(|e| MtprotoError::Message(e.to_string()))?; + let encrypted = encrypt_bind_message(perm_key, msg_id, &inner, &prefix, &padding)?; let request = tellers_mtproto::latest::api::AuthBindTempAuthKeyRequest { perm_auth_key_id: inner.perm_auth_key_id, nonce: inner.nonce, @@ -168,26 +336,22 @@ pub(crate) fn negotiate_bound_temp( .next_sequence(true) .map_err(|e| MtprotoError::Message(e.to_string()))?; send_v2( - &mut conn, - &mut framing, + conn, + framing, &temp_key, - &mut snapshot, + snapshot, msg_id, seq, &body, )?; read_bind_result( - &mut conn, - &mut framing, + conn, + framing, &temp_key, - snapshot.session_id, - msg_id, - )?; - drop(conn); - Ok(BoundTemp { snapshot, - expires_at, - }) + msg_id, + clock, + ) } fn send_v2( @@ -234,8 +398,9 @@ fn read_bind_result( conn: &mut dyn Connection, framing: &mut PaddedIntermediate, temp_key: &[u8], - session_id: i64, + snapshot: &mut Snapshot, msg_id: i64, + clock: &impl Clock, ) -> Result<(), MtprotoError> { let mut buf = vec![0_u8; 64 * 1024]; let mut input = Vec::new(); @@ -247,18 +412,32 @@ fn read_bind_result( let payload = crate::rpc::trim_padded_mtproto_packet(&packet.payload); input.drain(..consumed.min(input.len())); if let Some(control) = tellers_mtproto_transport::payload_control(payload) { - let _ = control; - break; + match control { + tellers_mtproto_transport::Control::QuickAck(_) => break, + tellers_mtproto_transport::Control::TransportError(code) => { + return Err(MtprotoError::Message(format!("temp bind transport {code}"))); + } + } } let message = tellers_mtproto_crypto::decrypt_message( temp_key, payload, tellers_mtproto_crypto::Direction::ServerToClient, - session_id, + snapshot.session_id, 1024 * 1024, ) .map_err(|e| MtprotoError::Message(e.to_string()))?; - if bind_accepted(&message.body, msg_id)? { + if message.sequence < 0 { return Err(malformed_bind()); } + let received = snapshot.register_received_message(message.message_id, message.sequence & 1 != 0, clock, true) + .map_err(|e| MtprotoError::Message(e.to_string()))?; + if received != tellers_mtproto_session::ReceivedMessageResult::Accepted { + break; + } + let response = bind_response(&message.body, msg_id, snapshot, clock, message.message_id)?; + if let Some(salt) = response.server_salt { + snapshot.server_salt = salt; + } + if response.accepted { return Ok(()); } break; @@ -279,164 +458,179 @@ fn read_bind_result( Err(MtprotoError::Message("temp bind had no result".into())) } -fn bind_accepted(body: &[u8], msg_id: i64) -> Result { - if body.len() < 4 { - return Ok(false); +#[derive(Default)] +struct BindResponse { + accepted: bool, + server_salt: Option, +} + +fn bind_response(body: &[u8], msg_id: i64, snapshot: &mut Snapshot, clock: &impl Clock, outer_id: i64) -> Result { + bind_response_at_depth(body, msg_id, 0, &mut 1024, snapshot, clock, outer_id) +} + +fn malformed_bind() -> MtprotoError { + MtprotoError::Message("malformed temporary key binding response".into()) +} + +fn bind_response_at_depth(body: &[u8], msg_id: i64, depth: usize, remaining: &mut usize, snapshot: &mut Snapshot, clock: &impl Clock, outer_id: i64) -> Result { + if depth > 8 || *remaining == 0 || body.len() < 4 || body.len() % 4 != 0 { + return Err(malformed_bind()); } + *remaining -= 1; let ctor = u32::from_le_bytes(body[..4].try_into().expect("ctor")); if ctor == MSG_CONTAINER { - return container_accepted(body, msg_id); + return container_accepted(body, msg_id, depth, remaining, snapshot, clock, outer_id); } if ctor == RPC_RESULT { - return rpc_result_accepted(body, msg_id); + return Ok(BindResponse { accepted: rpc_result_accepted(body, msg_id)?, ..Default::default() }); + } + if ctor == 0x9ec2_0908 { + if body.len() != 28 { + return Err(malformed_bind()); + } + return Ok(BindResponse { server_salt: Some(i64::from_le_bytes(body[20..28].try_into().expect("salt"))), ..Default::default() }); } - Ok(false) + Ok(BindResponse::default()) } -fn container_accepted(body: &[u8], msg_id: i64) -> Result { +fn container_accepted(body: &[u8], msg_id: i64, depth: usize, remaining: &mut usize, snapshot: &mut Snapshot, clock: &impl Clock, outer_id: i64) -> Result { if body.len() < 8 { - return Ok(false); + return Err(malformed_bind()); } let count = u32::from_le_bytes(body[4..8].try_into().expect("count")) as usize; + if count > *remaining || count > (body.len() - 8) / 20 { + return Err(malformed_bind()); + } let mut offset = 8; + let mut response = BindResponse::default(); for _ in 0..count { if offset + 16 > body.len() { - return Ok(false); + return Err(malformed_bind()); + } + let child_id = i64::from_le_bytes(body[offset..offset + 8].try_into().expect("child message id")); + let sequence = i32::from_le_bytes(body[offset + 8..offset + 12].try_into().expect("sequence")); + if child_id >= outer_id || child_id & 1 == 0 || sequence < 0 { + return Err(malformed_bind()); } offset += 8 + 4; let len = u32::from_le_bytes(body[offset..offset + 4].try_into().expect("len")) as usize; offset += 4; - let end = offset.saturating_add(len); - if end > body.len() { - return Ok(false); + let end = offset.checked_add(len).ok_or_else(malformed_bind)?; + if end > body.len() || len < 4 || len % 4 != 0 { + return Err(malformed_bind()); } - if bind_accepted(&body[offset..end], msg_id)? { - return Ok(true); + // Fresh containers can retransmit old children; their IDs still need + // replay registration and content-related acknowledgements. + let disposition = snapshot.register_received_message(child_id, sequence & 1 != 0, clock, false) + .map_err(|_| malformed_bind())?; + if disposition == tellers_mtproto_session::ReceivedMessageResult::Accepted { + let child = bind_response_at_depth(&body[offset..end], msg_id, depth + 1, remaining, snapshot, clock, child_id)?; + response.accepted |= child.accepted; + response.server_salt = child.server_salt.or(response.server_salt); + } else { + *remaining = remaining.checked_sub(1).ok_or_else(malformed_bind)?; } offset = end; } - Ok(false) + if offset != body.len() { + return Err(malformed_bind()); + } + Ok(response) } fn rpc_result_accepted(body: &[u8], msg_id: i64) -> Result { if body.len() < 16 { - return Ok(false); + return Err(malformed_bind()); } let req = i64::from_le_bytes(body[4..12].try_into().expect("req")); if req != msg_id { return Ok(false); } let inner = u32::from_le_bytes(body[12..16].try_into().expect("inner")); - if inner == BOOL_TRUE { - return Ok(true); - } - if inner == BOOL_FALSE { - return Err(MtprotoError::Message("temp bind rejected".into())); + if inner == BOOL_TRUE || inner == BOOL_FALSE { + if body.len() != 16 { + return Err(malformed_bind()); + } + return if inner == BOOL_TRUE { Ok(true) } else { Err(MtprotoError::Message("temp bind rejected".into())) }; } if inner == RPC_ERROR { - let code = if body.len() >= 20 { - i32::from_le_bytes(body[16..20].try_into().expect("code")) - } else { - 0 - }; - return Err(MtprotoError::Message(format!("temp bind {code}"))); + if body.len() > 148 { + return Err(malformed_bind()); + } + let mut decoder = Decoder::new(&body[16..], Limits::default()).map_err(|_| malformed_bind())?; + let error = tellers_mtproto::transport::RpcErrorConstructor::decode(&mut decoder).map_err(|_| malformed_bind())?; + decoder.finish().map_err(|_| malformed_bind())?; + let token = if error.error_message.len() <= 128 + && error.error_message.bytes().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == b'_') + { error.error_message.as_str() } else { "UNKNOWN" }; + return Err(MtprotoError::Message(format!("temp bind RPC {}: {token}", error.error_code))); } Err(MtprotoError::Message("temp bind result".into())) } -fn promote_main(client: &crate::client::Client, perm: &[u8], dc: i32, offset: i64) -> bool { - let now = unix_now(); - { - let main = client.main.lock(); - if main.temp_expires_at > now && main.snapshot.auth_key.as_deref() != Some(perm) { - return true; - } - } - let Ok(session) = negotiate_bound_temp(perm, dc, offset) else { - eprintln!("monogram.api main temp bind failed"); - return false; - }; - let mut main = client.main.lock(); - main.snapshot = session.snapshot; - main.temp_expires_at = session.expires_at; - main.transport = None; - main.pending_push = Default::default(); - main.last_difference = None; - true +fn promote_main( + client: &crate::client::Client, + negotiate: &mut impl FnMut(&[u8], i32, i64) -> Result, +) -> Result { + let _gate = client.main_gate.acquire(crate::scheduler::RequestClass::BackgroundRead)?; + let mut main = crate::client::lanes::lock_request_lane(&client.main, "lane_wait.pfs")?; + ensure_main_temp_with(client, &mut main, true, &SystemClock, negotiate) } fn bind_lane( + client: &crate::client::Client, lane: &crate::client::lanes::Lane, - perm: &[u8], - dc: i32, - offset: i64, - now: i32, + negotiate: &mut impl FnMut(&[u8], i32, i64) -> Result, ) -> bool { + let Some(_gate) = lane.gate.try_acquire(crate::scheduler::RequestClass::BackgroundRead) else { + return false; + }; let Some(mut io) = lane.io.try_lock() else { return false; }; - if io.temp_expires_at > now - && io.snapshot.auth_key.as_deref() != Some(perm) - && io.snapshot.auth_key.as_ref().map(Vec::len) == Some(256) - { - return true; - } - match negotiate_bound_temp(perm, dc, offset) { - Ok(session) => { - io.snapshot = session.snapshot; - io.temp_expires_at = session.expires_at; - io.transport = None; - io.pending_push = Default::default(); - io.last_difference = None; - true - } - Err(_) => { - eprintln!("monogram.api temp session bind failed"); - false - } - } + ensure_main_temp_with(client, &mut io, true, &SystemClock, negotiate).is_ok() + && io.perm_key.is_some() } -/// After `help.getConfig`, bind telegram extra sockets. -/// Read lanes stay within `tmp_sessions`. Upload lanes 1..4 are file sockets. -/// Failure leaves the single main connection and upload lane 0 in place. -pub(crate) fn prepare_extra_main_sessions(handle: u64) { - let Ok(client) = crate::client::get_client(handle) else { - return; - }; - let (perm, dc, offset) = { +/// Additional main sessions are admitted only after their temporary key is bound. +pub(crate) fn prepare_extra_main_sessions( + handle: u64, + mut negotiate: impl FnMut(&[u8], i32, i64) -> Result, +) -> Result<(), MtprotoError> { + let client = crate::client::get_client(handle)?; + let _policy = crate::transfer_policy::bind(client.policy.clone()); + { let data = client.data.lock(); - if data.user_id.is_none() || data.session_dead { + if data.user_id.is_none() || data.session_dead || data.home_auth_key.is_none() { crate::scheduler::set_bound_extra_sessions(0); - return; + return Ok(()); } - let Some(key) = data.home_auth_key.clone() else { - crate::scheduler::set_bound_extra_sessions(0); - return; - }; - (key, data.home_dc, data.home_time_offset) - }; - let now = unix_now(); - let _ = promote_main(&client, &perm, dc, offset); + } + promote_main(&client, &mut negotiate)?; for lane in &client.upload { - let _ = bind_lane(lane, &perm, dc, offset, now); + let _ = bind_lane(&client, lane, &mut negotiate); } let allowance = crate::scheduler::main_session_allowance(); if allowance <= 1 { crate::scheduler::set_bound_extra_sessions(0); - return; + return Ok(()); } let want = usize::try_from(allowance.saturating_sub(1)).unwrap_or(0); let want = want.min(crate::scheduler::READ_LANES); let mut bound = 0_usize; for lane in client.rpc.iter().take(want) { - if bind_lane(lane, &perm, dc, offset, now) { + if bind_lane(&client, lane, &mut negotiate) { bound += 1; } } crate::scheduler::set_bound_extra_sessions(bound); + Ok(()) } +#[cfg(test)] +#[path = "../../tests/unit/auth_bind_temp_tests.rs"] +mod wire_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/native/mtproto-rs/src/client/auth.rs b/native/mtproto-rs/src/client/auth.rs index a2bf29920..411738fa8 100644 --- a/native/mtproto-rs/src/client/auth.rs +++ b/native/mtproto-rs/src/client/auth.rs @@ -10,6 +10,13 @@ use crate::{AuthCodeSent, AuthSignedIn, MtprotoError}; use super::*; pub fn connect(handle: u64) -> Result<(), MtprotoError> { + connect_with_binding(handle, crate::auth::bind_temp::negotiate_bound_temp) +} + +pub(crate) fn connect_with_binding( + handle: u64, + negotiate: impl FnMut(&[u8], i32, i64) -> Result, +) -> Result<(), MtprotoError> { with_client_mut(handle, |state| { if state.session_dead { // A server-revoked key must never be reused. Auth flow will create @@ -54,7 +61,7 @@ pub fn connect(handle: u64) -> Result<(), MtprotoError> { recover_imported_user(state)?; Ok(()) })?; - crate::auth::bind_temp::prepare_extra_main_sessions(handle); + crate::auth::bind_temp::prepare_extra_main_sessions(handle, negotiate)?; Ok(()) } @@ -83,11 +90,10 @@ fn recover_imported_user(state: &mut ClientState) -> Result<(), MtprotoError> { } pub fn is_authorized(handle: u64) -> Result { - with_client_mut(handle, |state| { - Ok(!state.session_dead - && state.user_id.is_some() - && state.snapshot.auth_key.as_ref().map(|k| k.len()) == Some(256)) - }) + let client = get_client(handle)?; + let data = client.data.lock(); + Ok(!data.session_dead && data.user_id.is_some() + && data.home_auth_key.as_ref().map(Vec::len) == Some(256)) } pub(crate) fn is_auth_restart(err: &MtprotoError) -> bool { @@ -112,6 +118,7 @@ pub(crate) fn apply_test_dc( crate::rpc::set_use_test_dc(want_test); state.test_dc = want_test; state.dc_auth_keys.clear(); + state.permanent = None; state.snapshot = Snapshot::new(want_dc, &mut OsRandom).map_err(|e| MtprotoError::Message(e.to_string()))?; state.session_dead = false; @@ -203,6 +210,15 @@ pub fn resend_auth_code( }) } +pub(crate) fn complete_sign_in(state: &mut ClientState, user_id: i64) -> Result<(), MtprotoError> { + state.user_id = Some(user_id); + if state.permanent.is_none() { + state.permanent = Some(state.snapshot.clone()); + state.temp_expires_at = 1; + } + persist(state) +} + pub fn sign_in( handle: u64, phone: String, @@ -232,8 +248,7 @@ pub fn sign_in( } Err(err) => return Err(err), }; - state.user_id = Some(signed.user_id); - persist(state)?; + complete_sign_in(state, signed.user_id)?; Ok(signed) })?; drop_updates_transport(handle); @@ -253,8 +268,7 @@ pub fn check_password(handle: u64, password: String) -> Result return Err(err), }; - state.user_id = Some(signed.user_id); - persist(state)?; + complete_sign_in(state, signed.user_id)?; Ok(signed) })?; drop_updates_transport(handle); @@ -297,6 +311,7 @@ pub fn logout(handle: u64) -> Result<(), MtprotoError> { state.session_dead = false; state.new_session = None; state.dc_auth_keys.clear(); + state.permanent = None; state.snapshot = Snapshot::new(dc, &mut OsRandom).map_err(|e| MtprotoError::Message(e.to_string()))?; if state.logout_tokens.is_empty() { diff --git a/native/mtproto-rs/src/client/dispatch.rs b/native/mtproto-rs/src/client/dispatch.rs index b289f37fa..ef4d23950 100644 --- a/native/mtproto-rs/src/client/dispatch.rs +++ b/native/mtproto-rs/src/client/dispatch.rs @@ -616,10 +616,16 @@ pub(crate) fn save_items_with( ) -> Result, MtprotoError> { let _ = api_id; let _policy = crate::transfer_policy::bind(Arc::clone(&client.policy)); - let home = client.main.lock().snapshot.clone(); + let (home, pfs, owner) = { + let _gate = client.main_gate.acquire(scheduler::current_class())?; + let mut main = lock_request_lane(&client.main, "lane_wait.upload_home")?; + crate::auth::bind_temp::ensure_main_temp(client, &mut main, false)?; + (main.snapshot.clone(), main.temp_expires_at != 0 || main.perm_key.is_some(), client.data.lock().home_session_id) + }; crate::media::check_transfer_flood(home.dc_id, crate::media::TransferClass::Upload)?; let mut io = super::lanes::lock_upload_lane(client); - if io.temp_expires_at <= crate::auth::bind_temp::unix_now() + crate::auth::bind_temp::ensure_main_temp(client, &mut io, pfs)?; + if io.temp_expires_at == 0 && io.perm_key.is_none() && sync_upload_snapshot(&mut io.snapshot, &home) { io.transport = None; @@ -672,10 +678,14 @@ pub(crate) fn save_items_with( Ok(out) }) }); + let temporary = saved.as_ref().err().is_some_and(|error| expire_temporary_lane(client, &mut io, error)); io.transport = if saved.is_ok() { transport } else { None }; drop(io); + if !temporary && saved.as_ref().err().is_some_and(|error| mark_lane_session_dead(client, owner, error)) { + let _ = persist_updates_data(client, owner); + } schedule_file_cleanup(client); - saved + if temporary { Err(temporary_key_retry_error()) } else { saved } } pub(crate) fn send_uploaded_metadata( diff --git a/native/mtproto-rs/src/client/lanes.rs b/native/mtproto-rs/src/client/lanes.rs index 9a1a84c86..6c693c914 100644 --- a/native/mtproto-rs/src/client/lanes.rs +++ b/native/mtproto-rs/src/client/lanes.rs @@ -125,7 +125,7 @@ pub(crate) fn lock_media_lane(client: &Client) -> Result, MtprotoE match scheduler::family(class) { scheduler::LaneFamily::Media => { let cap = client.policy.media_lanes(); - let waiter = client.media_gate.enqueue(class); + let waiter = client.media_gate.enqueue(class)?; loop { crate::request_control::check() .map_err(|e| MtprotoError::Message(e.to_string()))?; @@ -211,10 +211,13 @@ pub(crate) fn schedule_file_cleanup(client: &Arc) { .spawn(move || { let ack_due = std::time::Instant::now() + crate::rpc::ACK_DELAY; loop { - std::thread::sleep(crate::media::DOWNLOAD_SESSION_IDLE); let Some(client) = weak.upgrade() else { break; }; + if client.connections.wait(crate::media::DOWNLOAD_SESSION_IDLE).is_err() { + client.file_cleanup_running.store(false, Ordering::Release); + break; + } let now = std::time::Instant::now(); let files_pending = close_idle_file_transports(&client, now); if flush_idle_main_acks(&client, now, ack_due) || files_pending { @@ -322,10 +325,11 @@ pub(crate) fn acquire_lane_family<'a>( VisibleWaitGuard(false) }; let wait = crate::perf::span(span); - let waiters: Vec<_> = order.clone().map(|index| { + let mut waiters = Vec::new(); + for index in order.clone() { let lane = lane_at(index); - (lane, lane.gate.enqueue(class)) - }).collect(); + waiters.push((lane, lane.gate.enqueue(class)?)); + } if waiters.is_empty() { return Err(MtprotoError::Message("no authorized request lane".into())); } diff --git a/native/mtproto-rs/src/client/media_download.rs b/native/mtproto-rs/src/client/media_download.rs index b7eec796b..1f841b548 100644 --- a/native/mtproto-rs/src/client/media_download.rs +++ b/native/mtproto-rs/src/client/media_download.rs @@ -27,7 +27,7 @@ include!("media_lane.rs"); fn home_lease_ok(state: &ClientState, home_session_id: i64) -> bool { - state.snapshot.session_id == home_session_id || state.perm_session_id == home_session_id + state.snapshot.session_id == home_session_id || state.permanent.as_ref().is_some_and(|snapshot| snapshot.session_id == home_session_id) } fn download_temp_ready( @@ -36,7 +36,7 @@ fn download_temp_ready( dc: i32, home_key: Option<&[u8]>, ) -> bool { - if io.temp_expires_at <= crate::auth::bind_temp::unix_now() || io.snapshot.dc_id != dc { + if !crate::auth::bind_temp::temp_key_valid(&io.snapshot, io.temp_expires_at) || io.snapshot.dc_id != dc { return false; } let Some(lane_key) = io.snapshot.auth_key.as_deref() else { @@ -49,41 +49,7 @@ fn download_temp_ready( return false; }; let data = client.data.lock(); - let master = if dc == data.home_dc { - data.home_auth_key.as_deref() - } else { - data.dc_auth_keys - .get(&dc) - .map(|cached| cached.key.as_slice()) - .filter(|key| key.len() == 256) - }; - master == Some(perm) -} - -fn matching_temp(client: &Client, dc: i32, perm_key: &[u8]) -> Option { - if perm_key.len() != 256 { - return None; - } - let data = client.data.lock(); - let entry = data.dc_temp_auth.get(&dc)?; - if entry.temp_expires_at <= crate::auth::bind_temp::unix_now() || entry.perm_key.as_slice() != perm_key { - return None; - } - Some(entry.clone()) -} - -fn remember_temp(client: &Client, dc: i32, snapshot: &tellers_mtproto_session::Snapshot, expires: i32, perm_key: &[u8]) { - if perm_key.len() != 256 || expires <= crate::auth::bind_temp::unix_now() { - return; - } - client.data.lock().dc_temp_auth.insert( - dc, - DcTempAuth { - snapshot: snapshot.clone(), - temp_expires_at: expires, - perm_key: perm_key.to_vec(), - }, - ); + crate::auth::bind_temp::dc_permanent_key(&data, dc) == Some(perm) } fn stage_for(dest: &Path, offset: Option) -> Result { diff --git a/native/mtproto-rs/src/client/media_lane.rs b/native/mtproto-rs/src/client/media_lane.rs index af5dc40a2..5d2920d36 100644 --- a/native/mtproto-rs/src/client/media_lane.rs +++ b/native/mtproto-rs/src/client/media_lane.rs @@ -214,24 +214,9 @@ pub(crate) fn download_media_range_on_lane_dc( MediaLanePrep::Replaced => io.transport = None, MediaLanePrep::Reuse => {} } - if let Some(key) = io.snapshot.auth_key.clone() { - let dc = io.snapshot.dc_id; - if let Some(cached) = matching_temp(client, dc, &key) { - io.perm_key = Some(cached.perm_key); - io.snapshot = fork_session(&cached.snapshot); - io.temp_expires_at = cached.temp_expires_at; - io.transport = None; - } else { - io.perm_key = Some(key.clone()); - let time_offset = io.snapshot.time_offset_micros; - if let Ok(bound) = crate::auth::bind_temp::negotiate_bound_temp(&key, dc, time_offset) { - remember_temp(client, dc, &bound.snapshot, bound.expires_at, &key); - io.snapshot = bound.snapshot; - io.temp_expires_at = bound.expires_at; - io.transport = None; - } - } - } + crate::auth::bind_temp::ensure_dc_temp_with(client, &mut io, target_dc, + Some(home.session_id), &crate::rpc::SystemClock, + crate::auth::bind_temp::negotiate_bound_temp)?; } if target_dc == home.dc_id && io.snapshot.auth_key.as_ref() == home.auth_key.as_ref() { drop(io); @@ -246,11 +231,6 @@ pub(crate) fn download_media_range_on_lane_dc( ); } let staged = stage_for(dest, offset)?; - let bind_key = io.perm_key.clone().or_else(|| { - (target_dc == home.dc_id) - .then(|| home.auth_key.clone()) - .flatten() - }); let mut io2 = if should_open_second_media_lane(target_dc, media::download_in_flight()) { try_lock_second_media_lane(client) } else { @@ -267,39 +247,20 @@ pub(crate) fn download_media_range_on_lane_dc( } let mut preparation = if io2.is_none() && offset.is_none() && should_open_second_media_lane(target_dc, media::download_in_flight()) { - bind_key.and_then(|key| get_client(handle).ok().map(|worker_client| { + get_client(handle).ok().map(|worker_client| { let class = scheduler::current_class(); - let home_key = home.auth_key.clone(); - let time_offset = home.time_offset_micros; let expected_session = home.session_id; MediaLanePreparation::start(move || { let _policy = crate::transfer_policy::bind(worker_client.policy.clone()); scheduler::with_class(class, || { - tcp::with_connection_control(&worker_client.connections, || { - let _span = crate::perf::span("media_second_lane_prepare"); - let Some(mut second) = try_lock_second_media_lane(&worker_client) else { return false; }; - if !session_lease_valid(&worker_client.data.lock(), expected_session) { return false; } - if download_temp_ready(&second, &worker_client, target_dc, home_key.as_deref()) { return true; } - if let Some(cached) = matching_temp(&worker_client, target_dc, &key) { - second.perm_key = Some(cached.perm_key); - second.snapshot = fork_session(&cached.snapshot); - second.temp_expires_at = cached.temp_expires_at; - second.transport = None; - return true; - } - let Ok(bound) = crate::auth::bind_temp::negotiate_bound_temp(&key, target_dc, time_offset) else { return false; }; - if crate::request_control::check().is_err() - || !session_lease_valid(&worker_client.data.lock(), expected_session) { return false; } - remember_temp(&worker_client, target_dc, &bound.snapshot, bound.expires_at, &key); - second.perm_key = Some(key); - second.snapshot = bound.snapshot; - second.temp_expires_at = bound.expires_at; - second.transport = None; - true - }) + let _span = crate::perf::span("media_second_lane_prepare"); + let Some(mut second) = try_lock_second_media_lane(&worker_client) else { return false; }; + crate::auth::bind_temp::ensure_dc_temp_with(&worker_client, &mut second, target_dc, + Some(expected_session), &crate::rpc::SystemClock, + crate::auth::bind_temp::negotiate_bound_temp).is_ok() }) }) - })) + }) } else { None }; let mut media_snap = io.snapshot.clone(); let mut slot = io.transport.take(); @@ -352,6 +313,7 @@ pub(crate) fn download_media_range_on_lane_dc( }; drop(preparation); io.snapshot = media_snap.clone(); + let mut temporary = path.as_ref().err().is_some_and(|error| expire_temporary_lane(client, &mut io, error)); // FILE_REFERENCE / transport errors must not park this TCP for the next file. io.transport = if path.is_ok() { slot } else { None }; if let Some(second) = io2.as_mut() { @@ -359,9 +321,15 @@ pub(crate) fn download_media_range_on_lane_dc( second.snapshot = snap2; } second.transport = if path.is_ok() { slot2.flatten() } else { None }; + if let Err(error) = &path { + temporary |= expire_temporary_lane(client, second, error); + } } drop(io); drop(io2); + if !temporary && path.as_ref().err().is_some_and(|error| mark_lane_session_dead(client, home.session_id, error)) { + let _ = persist_updates_data(client, home.session_id); + } if path.as_ref().err().is_some_and(media::optional_paused) { staged.keep(); while media::should_pause_optional(Some(media::DownloadProfile::Background)) { @@ -391,7 +359,7 @@ pub(crate) fn download_media_range_on_lane_dc( persist_updates_data(client, home.session_id)?; } if let Err(ref err) = path { - let foreign_key = target_dc != home.dc_id + let foreign_key = !temporary && target_dc != home.dc_id && !(media_snap.dc_id == home.dc_id && media_snap.auth_key == home.auth_key) && is_foreign_auth_key_failure(err); if foreign_key && !DC_AUTH_RETRY.with(std::cell::Cell::get) { @@ -417,20 +385,8 @@ pub(crate) fn download_media_range_on_lane_dc( handle, client, home, api_id, media, dest, offset, target_dc, ); } - if media_snap.dc_id == home.dc_id - && media_snap.auth_key == home.auth_key - && is_unrecoverable_session(err) - { - let _ = with_client_mut(handle, |state| { - if !home_lease_ok(state, home.session_id) { - return Err(expired_session_lease()); - } - mark_session_dead(state, err); - Ok(()) - }); - } } - return finish_staged(staged, path, client, home.session_id, dest); + return finish_staged(staged, if temporary { Err(temporary_key_retry_error()) } else { path }, client, home.session_id, dest); } } diff --git a/native/mtproto-rs/src/client/mod.rs b/native/mtproto-rs/src/client/mod.rs index 0b4dde6bd..f89b19a4d 100644 --- a/native/mtproto-rs/src/client/mod.rs +++ b/native/mtproto-rs/src/client/mod.rs @@ -42,6 +42,9 @@ mod lazy_sync_tests; #[path = "../../tests/unit/client_mgr_logout_tests.rs"] mod logout_tests; #[cfg(test)] +#[path = "../../tests/unit/client_mgr_pfs_tests.rs"] +mod pfs_tests; +#[cfg(test)] #[path = "../../tests/unit/client_mgr_session_tests.rs"] mod session_tests; #[cfg(test)] @@ -89,6 +92,7 @@ pub(crate) struct ClientData { pub(crate) seen_messages: HashSet<(i64, i32)>, pub(crate) logout_tokens: Vec>, pub(crate) new_session: Option, + pub(crate) home_snapshot: Snapshot, pub(crate) home_dc: i32, pub(crate) home_session_id: i64, pub(crate) home_auth_key: Option>, @@ -165,9 +169,8 @@ pub(crate) struct ClientState { pub(crate) test_dc: bool, pub(crate) last_inline: Option, /// Set while the live snapshot uses a PFS temp key. Disk writes keep this key. - pub(crate) perm_auth_key: Option>, - pub(crate) perm_salt: i64, - pub(crate) perm_session_id: i64, + pub(crate) permanent: Option, + pub(crate) temp_expires_at: i32, pub(crate) dc_auth_keys: HashMap, pub(crate) journal: Arc, } @@ -251,6 +254,34 @@ where } } +impl MergeMap for VecDeque { + fn contains_key(&self, key: &i64) -> bool { + self.iter().any(|entry| entry.chat_id == *key) + } + fn get(&self, key: &i64) -> Option<&ChannelRecovery> { + self.iter().find(|entry| entry.chat_id == *key) + } + fn insert(&mut self, key: i64, value: ChannelRecovery) -> Option { + if let Some(entry) = self.iter_mut().find(|entry| entry.chat_id == key) { + Some(std::mem::replace(entry, value)) + } else { + self.push_back(value); + None + } + } + fn retain(&mut self, f: &mut dyn FnMut(&i64, &mut ChannelRecovery) -> bool) { + self.retain_mut(|entry| { + let key = entry.chat_id; + f(&key, entry) + }); + } + fn for_each_entry(self, f: &mut dyn FnMut(i64, ChannelRecovery)) { + for entry in self { + f(entry.chat_id, entry); + } + } +} + // Commit only this lane's changes. An unchanged cloned entry must never // overwrite data fetched by another lane while this lane was on the network. pub(crate) fn merge_changed_entries(current: &mut M, before: &M, incoming: M) -> bool @@ -395,6 +426,7 @@ pub fn create_client(api_id: i32, api_hash: String, session_path: String) -> u64 seen_messages, logout_tokens, new_session, + home_snapshot: snapshot.clone(), home_dc: snapshot.dc_id, home_session_id: snapshot.session_id, home_auth_key: snapshot.auth_key.clone(), @@ -488,6 +520,7 @@ pub(crate) fn with_interactive_client_mut( pub fn destroy_client(handle: u64) { let client = CLIENTS.lock().remove(&handle); if let Some(client) = client { + client.connections.begin_shutdown(); flush_persist(&client); client.connections.close(); } @@ -565,6 +598,14 @@ pub fn client_uses_test_dc(handle: u64) -> bool { pub(crate) fn with_client_mut( handle: u64, f: impl FnOnce(&mut ClientState) -> Result, +) -> Result { + with_client_mut_binding(handle, f, crate::auth::bind_temp::negotiate_bound_temp) +} + +pub(crate) fn with_client_mut_binding( + handle: u64, + f: impl FnOnce(&mut ClientState) -> Result, + negotiate: impl FnOnce(&[u8], i32, i64) -> Result, ) -> Result { let client = get_client(handle)?; let _policy = crate::transfer_policy::bind(std::sync::Arc::clone(&client.policy)); @@ -581,6 +622,7 @@ pub(crate) fn with_client_mut( client.main_gate.acquire(class)? }; let mut io = lock_request_lane(&client.main, "lane_wait.main")?; + crate::auth::bind_temp::ensure_main_temp_with(&client, &mut io, false, &crate::rpc::SystemClock, negotiate)?; let mut state = { let d = client.data.lock(); ClientState { @@ -602,11 +644,9 @@ pub(crate) fn with_client_mut( session_dead_reason: d.session_dead_reason.clone(), test_dc: d.test_dc, last_inline: d.last_inline.clone(), - perm_auth_key: (io.temp_expires_at > crate::auth::bind_temp::unix_now()) - .then(|| d.home_auth_key.clone()) - .flatten(), - perm_salt: d.home_salt, - perm_session_id: d.home_session_id, + permanent: (io.temp_expires_at != 0 || io.perm_key.is_some()) + .then(|| d.home_snapshot.clone()), + temp_expires_at: io.temp_expires_at, dc_auth_keys: d.dc_auth_keys.clone(), journal: Arc::clone(&d.journal), } @@ -615,6 +655,8 @@ pub(crate) fn with_client_mut( crate::rpc::clear_new_session_metadata(); let before_peers = state.peers.clone(); let before_media = state.media.clone(); + let before_channel_pts = state.channel_pts.clone(); + let before_recovery = state.channel_recovery.clone(); let before_user_id = state.user_id; let before_updates = state.updates.clone(); let before_logout = state.logout_tokens.clone(); @@ -625,6 +667,18 @@ pub(crate) fn with_client_mut( let mut slot = io.transport.take(); let before_snapshot = io.snapshot.clone(); let result = with_client_transport(&client, &mut slot, || f(&mut state)); + let temp_failed = result.as_ref().err().is_some_and(|err| temporary_key_failure(&state, err)); + if !temp_failed && !state.session_dead { + if let Err(error) = &result { + if kills_session(state.user_id, error) { + mark_session_dead(&mut state, error); + } + } + } + if temp_failed { + expire_temporary_lane(&client, &mut io, result.as_ref().err().unwrap()); + state.temp_expires_at = 1; + } if let Some(metadata) = crate::rpc::take_new_session_metadata() { state.new_session = Some(metadata); } @@ -634,7 +688,7 @@ pub(crate) fn with_client_mut( let snapshot_changed = before_snapshot.dc_id != state.snapshot.dc_id || before_snapshot.auth_key != state.snapshot.auth_key || before_snapshot.session_id != state.snapshot.session_id; - io.transport = if snapshot_changed || state.session_dead { + io.transport = if snapshot_changed || state.session_dead || temp_failed { None } else { slot @@ -652,6 +706,8 @@ pub(crate) fn with_client_mut( } let dc_keys_changed = before_dc_auth_keys != state.dc_auth_keys; if identity_changed { + client.policy.bound_extra_sessions.store(0, Ordering::Release); + client.policy.main_session_allowance.store(-1, Ordering::Release); if state.user_id.is_none() || before_user_id.is_some() { client.policy.premium.store(false, Ordering::Relaxed); } @@ -662,11 +718,19 @@ pub(crate) fn with_client_mut( let session_meta_changed = before_new_session != state.new_session || before_dead != state.session_dead || before_test_dc != state.test_dc; + let home = durable_snapshot(&state); + if let Some(permanent) = &state.permanent { + io.perm_key = permanent.auth_key.clone(); + io.temp_expires_at = state.temp_expires_at.max(1); + } else if state.snapshot.auth_key != before_snapshot.auth_key || state.snapshot.dc_id != before_snapshot.dc_id { + io.temp_expires_at = 0; + io.perm_key = None; + } let persist_needed; { let mut d = client.data.lock(); d.user_id = state.user_id; - let maps_changed = if identity_changed { + let maps_changed = if before_user_id != state.user_id { d.peers = state.peers; d.media = state.media; d.updates = state.updates; @@ -681,28 +745,30 @@ pub(crate) fn with_client_mut( } else { let peers_changed = merge_changed_entries(&mut d.peers, &before_peers, state.peers); let media_changed = merge_changed_entries(&mut d.media, &before_media, state.media); + let channels_changed = merge_changed_entries(&mut d.channel_pts, &before_channel_pts, state.channel_pts); + let recovery_changed = state.channel_recovery != before_recovery + && merge_changed_entries(&mut d.channel_recovery, &before_recovery, state.channel_recovery); + let seen_before = d.seen_messages.len(); + d.seen_messages.extend(state.seen_messages); d.updates = prefer_newer_cursor(d.updates.clone(), state.updates); d.updates_started |= state.updates_started; - peers_changed || media_changed || updates_changed + peers_changed || media_changed || channels_changed || recovery_changed || seen_before != d.seen_messages.len() || updates_changed }; d.logout_tokens = state.logout_tokens; d.new_session = state.new_session; - d.session_dead = state.session_dead; - d.session_dead_reason = if state.session_dead { - state.session_dead_reason.clone() - } else { - None - }; - d.home_dc = state.snapshot.dc_id; - d.home_time_offset = state.snapshot.time_offset_micros; - if state.session_dead || state.snapshot.auth_key != before_snapshot.auth_key { - io.temp_expires_at = 0; + d.session_dead = if before_dead { state.session_dead } else { d.session_dead || state.session_dead }; + if before_dead || state.session_dead || !d.session_dead { + d.session_dead_reason = if state.session_dead { state.session_dead_reason.clone() } else { None }; } - if io.temp_expires_at <= crate::auth::bind_temp::unix_now() { - d.home_session_id = state.snapshot.session_id; - d.home_auth_key = state.snapshot.auth_key.clone(); - d.home_salt = state.snapshot.server_salt; + if d.session_dead { + io.transport = None; } + d.home_dc = home.dc_id; + d.home_time_offset = home.time_offset_micros; + d.home_session_id = home.session_id; + d.home_auth_key = home.auth_key.clone(); + d.home_salt = home.server_salt; + d.home_snapshot = home; d.test_dc = state.test_dc; d.last_inline = state.last_inline; if dc_keys_changed { @@ -729,7 +795,7 @@ pub(crate) fn with_client_mut( rpc.transport = None; rpc.pending_push = Default::default(); rpc.last_difference = None; - if io.temp_expires_at <= crate::auth::bind_temp::unix_now() { + if io.temp_expires_at == 0 && io.perm_key.is_none() { rpc.snapshot = fork_session(&io.snapshot); } } @@ -740,7 +806,7 @@ pub(crate) fn with_client_mut( schedule_persist(&client, persist_id); } schedule_file_cleanup(&client); - result + if temp_failed { Err(temporary_key_retry_error()) } else { result } } pub(crate) fn home_fork_source(data: &ClientData) -> Option { @@ -761,6 +827,7 @@ pub(crate) fn with_read_lane( mut f: impl FnMut(&mut ClientState) -> Result, ) -> Result { let client = get_client(handle)?; + let _policy = crate::transfer_policy::bind(client.policy.clone()); // A non-media DC permits a single main session. Extra read lanes are home-DC // sessions, so they run only when the server granted them (`config.tmp_sessions`). if scheduler::extra_main_sessions() == 0 { @@ -776,15 +843,8 @@ pub(crate) fn with_read_lane( // Reads wait for a read lane; the `with_client_mut` paths below are authorization // repairs, not contention fallbacks. let mut io = acquire_read_lane(&client)?; - let now = crate::auth::bind_temp::unix_now(); - if io.temp_expires_at > 0 && io.temp_expires_at <= now { - io.temp_expires_at = 0; - io.transport = None; - drop(io); - scheduler::set_bound_extra_sessions(0); - return with_client_mut(handle, f); - } - let bound_temp = io.temp_expires_at > now + crate::auth::bind_temp::ensure_main_temp(&client, &mut io, false)?; + let bound_temp = crate::auth::bind_temp::temp_key_valid(&io.snapshot, io.temp_expires_at) && io.snapshot.auth_key.as_ref() != home.auth_key.as_ref() && io.snapshot.auth_key.as_ref().map(Vec::len) == Some(256); if !bound_temp { @@ -818,11 +878,9 @@ pub(crate) fn with_read_lane( session_dead_reason: d.session_dead_reason.clone(), test_dc: d.test_dc, last_inline: d.last_inline.clone(), - perm_auth_key: (io.temp_expires_at > crate::auth::bind_temp::unix_now()) - .then(|| d.home_auth_key.clone()) - .flatten(), - perm_salt: d.home_salt, - perm_session_id: d.home_session_id, + permanent: (io.temp_expires_at != 0 || io.perm_key.is_some()) + .then(|| d.home_snapshot.clone()), + temp_expires_at: io.temp_expires_at, dc_auth_keys: d.dc_auth_keys.clone(), journal: Arc::clone(&d.journal), }) @@ -840,6 +898,7 @@ pub(crate) fn with_read_lane( let before_peers = state.peers.clone(); let before_media = state.media.clone(); let before_channel_pts = state.channel_pts.clone(); + let before_recovery = state.channel_recovery.clone(); let before_dc_auth_keys = state.dc_auth_keys.clone(); let mut slot = io.transport.take(); let before_snapshot = io.snapshot.clone(); @@ -859,12 +918,18 @@ pub(crate) fn with_read_lane( if let Some(metadata) = crate::rpc::take_new_session_metadata() { state.new_session = Some(metadata); } - let still_temp = io.temp_expires_at > crate::auth::bind_temp::unix_now() + let temp_failed = result.as_ref().err().is_some_and(|err| temporary_key_failure(&state, err)); + if temp_failed { + expire_temporary_lane(&client, &mut io, result.as_ref().err().unwrap()); + return Err(temporary_key_retry_error()); + } + let still_temp = (io.temp_expires_at != 0 || io.perm_key.is_some()) && state.snapshot.auth_key.as_ref() != home_auth.as_ref(); let diverged = state.snapshot.dc_id != home_dc || (state.snapshot.auth_key != home_auth && !still_temp); if diverged { io.temp_expires_at = 0; + io.perm_key = None; io.snapshot = fork_session(&home); io.transport = None; drop(io); @@ -890,6 +955,9 @@ pub(crate) fn with_read_lane( let _ = merge_changed_entries(&mut d.media, &before_media, state.media); let _ = merge_changed_entries(&mut d.channel_pts, &before_channel_pts, state.channel_pts); + if state.channel_recovery != before_recovery { + merge_changed_entries(&mut d.channel_recovery, &before_recovery, state.channel_recovery); + } d.seen_messages.extend(state.seen_messages); d.updates = prefer_newer_cursor(d.updates.clone(), state.updates); d.updates_started |= state.updates_started; @@ -897,17 +965,21 @@ pub(crate) fn with_read_lane( if state.new_session.is_some() { d.new_session = state.new_session; } + let terminal_changed = !d.session_dead && state.session_dead; d.session_dead |= state.session_dead; if state.session_dead { d.session_dead_reason = state.session_dead_reason.clone(); } d.last_inline = state.last_inline; - d.home_salt = state.snapshot.server_salt; + if state.permanent.is_none() && state.snapshot.auth_key == d.home_auth_key { + d.home_salt = state.snapshot.server_salt; + d.home_snapshot.server_salt = state.snapshot.server_salt; + } d.home_time_offset = state.snapshot.time_offset_micros; if before_dc_auth_keys != state.dc_auth_keys { d.dc_auth_keys = state.dc_auth_keys.clone(); } - if result.is_ok() { + if result.is_ok() || terminal_changed { d.persist_epoch = d.persist_epoch.saturating_add(1); } } diff --git a/native/mtproto-rs/src/client/persist.rs b/native/mtproto-rs/src/client/persist.rs index 6ec7f2c0f..355873410 100644 --- a/native/mtproto-rs/src/client/persist.rs +++ b/native/mtproto-rs/src/client/persist.rs @@ -54,12 +54,8 @@ thread_local! { } pub(crate) fn durable_snapshot(state: &ClientState) -> tellers_mtproto_session::Snapshot { - let mut snapshot = state.snapshot.clone(); - if let Some(key) = &state.perm_auth_key { - snapshot.auth_key = Some(key.clone()); - snapshot.server_salt = state.perm_salt; - snapshot.session_id = state.perm_session_id; - } + let mut snapshot = state.permanent.as_ref().unwrap_or(&state.snapshot).clone(); + snapshot.time_offset_micros = state.snapshot.time_offset_micros; snapshot } @@ -137,18 +133,21 @@ pub(crate) fn persist_updates_data(client: &Client, session_id: i64) -> Result crate::auth::bind_temp::unix_now() { - if let Some(key) = d.home_auth_key.clone() { - snapshot.auth_key = Some(key); - snapshot.server_salt = d.home_salt; - snapshot.session_id = d.home_session_id; + let mut snapshot = if main.temp_expires_at != 0 || main.perm_key.is_some() { + if d.home_auth_key.is_none() || d.home_snapshot.auth_key != d.home_auth_key + || d.home_snapshot.session_id != d.home_session_id || d.home_snapshot.dc_id != d.home_dc + { + return Err(MtprotoError::Message("permanent session identity unavailable".into())); } - } + d.home_snapshot.clone() + } else { + main.snapshot.clone() + }; + snapshot.time_offset_micros = main.snapshot.time_offset_micros; let session = ClientSession { snapshot, user_id: d.user_id, @@ -181,7 +180,7 @@ pub(crate) fn persist_updates_data(client: &Client, session_id: i64) -> Result Result Result<(), MtprotoError> { pub(crate) fn ensure_ready_after_migrate( state: &mut ClientState, err: MtprotoError, +) -> Result<(), MtprotoError> { + ensure_ready_after_migrate_with_binding(state, err, crate::auth::bind_temp::negotiate_bound_temp) +} + +pub(crate) fn ensure_ready_after_migrate_with_binding( + state: &mut ClientState, + err: MtprotoError, + negotiate: impl FnOnce(&[u8], i32, i64) -> Result, ) -> Result<(), MtprotoError> { if let MtprotoError::Message(msg) = &err { if let Some(rest) = msg.strip_prefix("MIGRATE_") { @@ -242,14 +252,41 @@ pub(crate) fn ensure_ready_after_migrate( // home auth key here turns a normal USER/NETWORK_MIGRATE // into an unnecessary logout and breaks all update lanes. let destination = copy_authorization_to_dc(state, dc)?; - state.snapshot = destination; + if state.permanent.is_some() { + let key = destination.auth_key.as_deref().ok_or_else(temporary_key_retry_error)?; + let bound = match negotiate(key, dc, destination.time_offset_micros) { + Ok(bound) => bound, + Err(error) => { + if is_foreign_auth_key_failure(&error) { + state.dc_auth_keys.remove(&dc); + persist(state)?; + } + return Err(temporary_key_retry_error()); + } + }; + bound.snapshot.validate().map_err(|_| temporary_key_retry_error())?; + if bound.snapshot.dc_id != dc || bound.snapshot.session_id == destination.session_id + || !bound.snapshot.auth_key.as_ref().is_some_and(|temporary| temporary.len() == 256 && temporary.as_slice() != key) + || !crate::auth::bind_temp::temp_key_valid(&bound.snapshot, bound.expires_at) + { + return Err(temporary_key_retry_error()); + } + state.permanent = Some(destination); + state.snapshot = bound.snapshot; + state.temp_expires_at = bound.expires_at; + } else { + state.snapshot = destination; + state.permanent = None; + state.temp_expires_at = 0; + } // Authorization moved, but these are still the same // account's applied update sequences. state.session_dead = false; return Ok(()); } state.snapshot.dc_id = dc; - state.snapshot.auth_key = None; + state.permanent = None; + state.snapshot.clear_auth_key(); state.snapshot.server_salt = 0; ensure_auth_key(state)?; return Ok(()); @@ -259,6 +296,48 @@ pub(crate) fn ensure_ready_after_migrate( Err(err) } +pub(crate) fn temporary_key_failure(state: &ClientState, err: &MtprotoError) -> bool { + state.permanent.is_some() && temporary_key_error(err) +} + +pub(crate) fn temporary_key_error(err: &MtprotoError) -> bool { + let MtprotoError::Message(message) = err else { return false; }; + if message == "MTProto transport error -404" { + return true; + } + let Some((prefix, token)) = message.split_once(": ") else { return false; }; + prefix.starts_with("RPC ") && matches!(token, + "AUTH_KEY_UNREGISTERED" | "AUTH_KEY_INVALID" | "AUTH_KEY_DUPLICATED" | "AUTH_KEY_PERM_EMPTY") +} + +pub(crate) fn temporary_key_retry_error() -> MtprotoError { + MtprotoError::Message("connection closed: temporary authorization renewal required".into()) +} + +pub(crate) fn expire_temporary_lane(client: &Client, io: &mut super::lanes::SessionIo, err: &MtprotoError) -> bool { + if io.perm_key.is_none() || !temporary_key_error(err) { + return false; + } + io.temp_expires_at = 1; + io.transport = None; + let mut data = client.data.lock(); + if data.dc_temp_auth.get(&io.snapshot.dc_id).is_some_and(|cached| cached.snapshot.auth_key == io.snapshot.auth_key) { + data.dc_temp_auth.remove(&io.snapshot.dc_id); + } + true +} + +pub(crate) fn mark_lane_session_dead(client: &Client, owner: i64, err: &MtprotoError) -> bool { + let mut data = client.data.lock(); + if !session_lease_valid(&data, owner) || !kills_session(data.user_id, err) { + return false; + } + data.session_dead = true; + data.session_dead_reason = Some(session_dead_token(err)); + data.persist_epoch = data.persist_epoch.saturating_add(1); + true +} + pub(crate) fn call_with_migrate( state: &mut ClientState, mut op: impl FnMut(&mut ClientState) -> Result, @@ -268,6 +347,7 @@ pub(crate) fn call_with_migrate( let before = state.snapshot.clone(); match op(state) { Ok(v) => Ok(v), + Err(err) if temporary_key_failure(state, &err) => Err(err), Err(err) if kills_session(state.user_id, &err) => { mark_session_dead(state, &err); Err(err) @@ -285,6 +365,7 @@ pub(crate) fn call_with_migrate( ensure_ready_after_migrate(state, err)?; match op(state) { Ok(v) => Ok(v), + Err(err) if temporary_key_failure(state, &err) => Err(err), Err(err) if kills_session(state.user_id, &err) => { mark_session_dead(state, &err); Err(err) @@ -323,6 +404,7 @@ pub(crate) fn drop_all_transports(handle: u64) { pub(crate) fn recreate_mtproto_session(state: &mut ClientState) -> Result<(), MtprotoError> { let dc = state.snapshot.dc_id; + state.permanent = None; state.user_id = None; state.updates = None; state.updates_started = false; diff --git a/native/mtproto-rs/src/client/updates.rs b/native/mtproto-rs/src/client/updates.rs index b12edefb8..8406040d0 100644 --- a/native/mtproto-rs/src/client/updates.rs +++ b/native/mtproto-rs/src/client/updates.rs @@ -231,7 +231,8 @@ pub fn drain_updates(handle: u64) -> Result { d.lazy_channel_updates && !d.lazy_channel_recovery.is_empty() && d.lazy_retry_at <= now, ) }; - if io.temp_expires_at <= crate::auth::bind_temp::unix_now() { + crate::auth::bind_temp::ensure_main_temp(&client, &mut io, false)?; + if io.temp_expires_at == 0 && io.perm_key.is_none() { apply_home_auth( &mut io.snapshot, home_dc, @@ -587,16 +588,15 @@ pub fn drain_updates(handle: u64) -> Result { pair } Err(err) => { + let temporary = expire_temporary_lane(&client, &mut io, &err); io.transport = None; drop(io); drop(gate); - if is_unrecoverable_session(&err) { - with_client_mut(handle, |state| { - if state.snapshot.session_id == session_id { - mark_session_dead(state, &err); - } - Ok(()) - })?; + if temporary { + return Err(temporary_key_retry_error()); + } + if mark_lane_session_dead(&client, session_id, &err) { + let _ = persist_updates_data(&client, session_id); } return Err(err); } diff --git a/native/mtproto-rs/src/rpc/framing.rs b/native/mtproto-rs/src/rpc/framing.rs index a4b33ea5e..86f91c65d 100644 --- a/native/mtproto-rs/src/rpc/framing.rs +++ b/native/mtproto-rs/src/rpc/framing.rs @@ -19,6 +19,10 @@ use super::timeout::{ is_mid_frame, recv_wait_deadline, rpc_timeout_message, }; +#[cfg(test)] +#[path = "../../tests/unit/rpc_control_tests.rs"] +mod control_tests; + pub struct SystemClock; impl Clock for SystemClock { fn unix_micros(&self) -> i64 { @@ -52,20 +56,35 @@ pub(crate) fn send_framed( .map_err(|e| MtprotoError::Message(e.to_string())) } +fn consume_control(payload: &[u8]) -> Result { + match tellers_mtproto_transport::payload_control(payload) { + Some(tellers_mtproto_transport::Control::QuickAck(_)) => Ok(true), + Some(tellers_mtproto_transport::Control::TransportError(code)) => { + Err(MtprotoError::Message(format!("MTProto transport error {code}"))) + } + None => Ok(false), + } +} + pub(crate) fn try_decode_complete( framing: &mut PaddedIntermediate, input: &mut Vec, ) -> Result>, MtprotoError> { - match framing.decode(input) { - Ok(packet) => { - let consumed = packet.consumed; - let payload = packet.payload; - input.drain(..consumed); - Ok(Some(payload)) + for _ in 0..256 { + match framing.decode(input) { + Ok(packet) => { + let consumed = packet.consumed; + let payload = packet.payload; + input.drain(..consumed); + if !consume_control(&payload)? { + return Ok(Some(payload)); + } + } + Err(tellers_mtproto_transport::Error::Incomplete { .. }) => return Ok(None), + Err(e) => return Err(MtprotoError::Message(e.to_string())), } - Err(tellers_mtproto_transport::Error::Incomplete { .. }) => Ok(None), - Err(e) => Err(MtprotoError::Message(e.to_string())), } + Err(MtprotoError::Message("MTProto control frame limit".into())) } pub(crate) fn recv_framed( @@ -79,6 +98,7 @@ pub(crate) fn recv_framed( idle_wait_ms: u64, ) -> Result, MtprotoError> { let mut buf = vec![0_u8; 64 * 1024]; + let mut controls = 0; loop { // Decode before the deadline check. A blocking recv can return a complete // padded-intermediate frame after the attempt clock; timing out first is @@ -88,6 +108,13 @@ pub(crate) fn recv_framed( let consumed = packet.consumed; let payload = packet.payload; input.drain(..consumed); + if consume_control(&payload)? { + controls += 1; + if controls >= 256 || std::time::Instant::now() >= overall_deadline { + return Err(MtprotoError::Message("MTProto control frame limit".into())); + } + continue; + } return Ok(payload); } Err(tellers_mtproto_transport::Error::Incomplete { diff --git a/native/mtproto-rs/src/rpc/supervisor.rs b/native/mtproto-rs/src/rpc/supervisor.rs index ae97c44aa..c87ebc672 100644 --- a/native/mtproto-rs/src/rpc/supervisor.rs +++ b/native/mtproto-rs/src/rpc/supervisor.rs @@ -3,19 +3,7 @@ use crate::tcp; use super::live::{LiveTransport, SUPERVISING, put_live, take_live}; -pub(crate) fn is_waitable_io(message: &str) -> bool { - let lower = message.to_ascii_lowercase(); - lower.contains("timed out") - || lower.contains("would block") - || lower.contains("try again") - || lower.contains("eagain") - || lower.contains("os error 11") - || lower.contains("os error 35") - || lower.contains("os error 60") - || lower.contains("os error 10060") - || lower.contains("wsaetimedout") - || lower.contains("resource temporarily unavailable") -} +pub(crate) use crate::tcp::is_waitable_io; #[derive(Debug, PartialEq, Eq)] pub(crate) enum FailureClass { diff --git a/native/mtproto-rs/src/scheduler.rs b/native/mtproto-rs/src/scheduler.rs index bd96be45c..69c5c3a8e 100644 --- a/native/mtproto-rs/src/scheduler.rs +++ b/native/mtproto-rs/src/scheduler.rs @@ -109,10 +109,7 @@ pub const UPLOAD_LANES: usize = 4; /// Most parallel main sessions we will ever open on one non-media DC. pub const MAX_MAIN_SESSIONS: i32 = 8; -/// `-1` until `config.tmp_sessions` has been seen. -static MAIN_SESSION_ALLOWANCE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1); - -/// Serializes tests that mutate the process-wide allowance. +/// Serializes tests that change unbound policy defaults. #[cfg(test)] pub(crate) static ALLOWANCE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); @@ -125,38 +122,33 @@ pub(crate) static ALLOWANCE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex:: /// pub fn set_main_session_allowance(tmp_sessions: Option) -> i32 { let allowance = tmp_sessions.unwrap_or(1).clamp(1, MAX_MAIN_SESSIONS); - MAIN_SESSION_ALLOWANCE.store(allowance, std::sync::atomic::Ordering::Relaxed); + crate::transfer_policy::current().main_session_allowance.store(allowance, std::sync::atomic::Ordering::Relaxed); allowance } /// Parallel main sessions the server allows; unknown counts as the safe single one. pub fn main_session_allowance() -> i32 { - match MAIN_SESSION_ALLOWANCE.load(std::sync::atomic::Ordering::Relaxed) { + match crate::transfer_policy::current().main_session_allowance.load(std::sync::atomic::Ordering::Relaxed) { known if known >= 1 => known, _ => 1, } } pub fn main_session_allowance_known() -> bool { - MAIN_SESSION_ALLOWANCE.load(std::sync::atomic::Ordering::Relaxed) >= 1 + crate::transfer_policy::current().main_session_allowance.load(std::sync::atomic::Ordering::Relaxed) >= 1 } -/// Extra main sessions that currently hold a bound temporary key. -/// Zero until [`set_bound_extra_sessions`] records a successful bind. -static BOUND_EXTRA_SESSIONS: std::sync::atomic::AtomicUsize = - std::sync::atomic::AtomicUsize::new(0); - /// Records how many extra home sessions have a bound temporary key. /// /// A server allowance alone does not authorize reusing the permanent key. /// https://core.telegram.org/api/datacenter#parallel-sessions pub fn set_bound_extra_sessions(count: usize) { - BOUND_EXTRA_SESSIONS.store(count.min(READ_LANES), std::sync::atomic::Ordering::Relaxed); + crate::transfer_policy::current().bound_extra_sessions.store(count.min(READ_LANES), std::sync::atomic::Ordering::Relaxed); } /// Extra main sessions that may be used. Stays zero until a temp key is bound. pub fn extra_main_sessions() -> usize { - let bound = BOUND_EXTRA_SESSIONS.load(std::sync::atomic::Ordering::Relaxed); + let bound = crate::transfer_policy::current().bound_extra_sessions.load(std::sync::atomic::Ordering::Relaxed); if bound == 0 { return 0; } @@ -237,6 +229,8 @@ impl SchedulerClock for SystemSchedulerClock { } } +pub(crate) const MAX_LANE_WAITERS: usize = 256; + const AGING_INTERVAL_MS: u64 = 5_000; #[derive(Default)] @@ -347,17 +341,20 @@ impl LaneGate { self.wake.notify_all(); } - pub(crate) fn enqueue(&self, class: RequestClass) -> LaneWaiter<'_> { + pub(crate) fn enqueue(&self, class: RequestClass) -> Result, MtprotoError> { let mut state = self.state.lock(); + if state.waiters.len() >= MAX_LANE_WAITERS { + return Err(MtprotoError::Message("request queue capacity exceeded".into())); + } let ticket = state.next_ticket; - state.next_ticket += 1; + state.next_ticket = ticket.checked_add(1).ok_or_else(|| MtprotoError::Message("request queue ticket exhausted".into()))?; state.waiters.push_back(Waiter { ticket, priority: class.priority(), queued_at: self.clock.now_ms(), }); self.wake.notify_all(); - LaneWaiter { gate: self, ticket } + Ok(LaneWaiter { gate: self, ticket }) } #[cfg(test)] @@ -384,35 +381,16 @@ impl LaneGate { /// Blocks until this request's class holds the lane. pub fn acquire(&self, class: RequestClass) -> Result, MtprotoError> { - let priority = class.priority(); - let ticket = { - let mut state = self.state.lock(); - let ticket = state.next_ticket; - state.next_ticket += 1; - state.waiters.push_back(Waiter { ticket, priority, queued_at: self.clock.now_ms() }); - ticket - }; + let waiter = self.enqueue(class)?; loop { - if let Err(error) = crate::request_control::check() { - self.state - .lock() - .waiters - .retain(|waiter| waiter.ticket != ticket); - self.wake.notify_all(); - return Err(MtprotoError::Message(error.to_string())); + crate::request_control::check().map_err(|e| MtprotoError::Message(e.to_string()))?; + if let Some(promoted) = crate::request_control::promoted_class() { + waiter.promote(promoted); } - let mut state = self.state.lock(); - let best = state - .waiters - .iter() - .min_by_key(|waiter| waiter.order_at(self.clock.now_ms())) - .map(|waiter| waiter.ticket); - if !state.busy && best == Some(ticket) { - state.busy = true; - state.waiters.retain(|waiter| waiter.ticket != ticket); - return Ok(LaneGuard { gate: self }); + if let Some(guard) = waiter.try_acquire() { + return Ok(guard); } - self.wake.wait_for(&mut state, Duration::from_millis(5)); + waiter.wait(); } } } diff --git a/native/mtproto-rs/src/session_file.rs b/native/mtproto-rs/src/session_file.rs index 60a74b56a..c12079ec8 100644 --- a/native/mtproto-rs/src/session_file.rs +++ b/native/mtproto-rs/src/session_file.rs @@ -193,7 +193,7 @@ impl FileSessionStore { .map_err(|_| SessionError::Persistence("client closed".into()))? } - fn save_open(&self, session: &ClientSession) -> Result<(), SessionError> { + pub(crate) fn save_open(&self, session: &ClientSession) -> Result<(), SessionError> { session.snapshot.validate()?; if let Some(parent) = self.path.parent().filter(|p| !p.as_os_str().is_empty()) { fs::create_dir_all(parent).map_err(|e| SessionError::Persistence(e.to_string()))?; diff --git a/native/mtproto-rs/src/tcp.rs b/native/mtproto-rs/src/tcp.rs index 12267702f..b29e8429d 100644 --- a/native/mtproto-rs/src/tcp.rs +++ b/native/mtproto-rs/src/tcp.rs @@ -63,6 +63,23 @@ pub(crate) fn wait_reconnect(delay: Duration) -> Result<(), TransportError> { } } +pub(crate) fn is_waitable_io(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("timed out") + || lower.contains("would block") + || lower.contains("try again") + || lower.contains("interrupted") + || lower.contains("os error 4)") + || lower.contains("os error 10004)") + || lower.contains("eagain") + || lower.contains("os error 11") + || lower.contains("os error 35") + || lower.contains("os error 60") + || lower.contains("os error 10060") + || lower.contains("wsaetimedout") + || lower.contains("resource temporarily unavailable") +} + fn check_open() -> Result<(), TransportError> { wait_reconnect(Duration::ZERO) } @@ -144,8 +161,7 @@ impl Connection for TcpConnection { check_open()?; match self.inner.receive(output) { Ok(result) => return Ok(result), - Err(error) if std::time::Instant::now() < deadline => { - let _ = error; + Err(error) if std::time::Instant::now() < deadline && is_waitable_io(&error.to_string()) => { continue; } Err(error) => return Err(TransportError::Connection(error.to_string())), @@ -214,8 +230,7 @@ impl Connection for ObfuscatedTcp { NativeTransportConnection::Http(connection) => connection.receive(output), } { Ok(result) => return Ok(result), - Err(error) if std::time::Instant::now() < deadline => { - let _ = error; + Err(error) if std::time::Instant::now() < deadline && is_waitable_io(&error.to_string()) => { continue; } Err(error) => return Err(TransportError::Connection(error.to_string())), diff --git a/native/mtproto-rs/src/transfer_policy.rs b/native/mtproto-rs/src/transfer_policy.rs index 1724d0936..fd4234193 100644 --- a/native/mtproto-rs/src/transfer_policy.rs +++ b/native/mtproto-rs/src/transfer_policy.rs @@ -17,6 +17,8 @@ use crate::upload::upload_rpc::{FILE_PART_FAST, FILE_PART_SMALL}; pub type ProgressCallback = Arc; pub(crate) struct TransferPolicy { + pub(crate) main_session_allowance: AtomicI32, + pub(crate) bound_extra_sessions: AtomicUsize, media_lanes: AtomicUsize, pub(crate) premium: AtomicBool, pub(crate) upload_parts: parking_lot::Mutex<(i32, i32)>, @@ -41,6 +43,8 @@ pub(crate) struct TransferPolicy { impl TransferPolicy { pub(crate) fn stock() -> Self { Self { + main_session_allowance: AtomicI32::new(-1), + bound_extra_sessions: AtomicUsize::new(0), media_lanes: AtomicUsize::new(DEFAULT_MEDIA_LANES), premium: AtomicBool::new(false), upload_parts: parking_lot::Mutex::new((4_000, 8_000)), diff --git a/native/mtproto-rs/tests/unit/auth_auth_key_tests.rs b/native/mtproto-rs/tests/unit/auth_auth_key_tests.rs index 9073a7fa3..c55a83aa9 100644 --- a/native/mtproto-rs/tests/unit/auth_auth_key_tests.rs +++ b/native/mtproto-rs/tests/unit/auth_auth_key_tests.rs @@ -1,4 +1,135 @@ use super::*; +use tellers_mtproto::transport::{ServerDhInnerDataConstructor, ServerDhParamsOkConstructor, DhGenOkConstructor}; +use num_bigint::BigUint; + +// Public group-14 prime, RFC 3526 section 3. Synthetic peer/key material only. +const DH_PRIME: &str = concat!( + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1", + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD", + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245", + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED", + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D", + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F", + "83655D23DCA3AD961C62F356208552BB9ED529077096966D", + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B", + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9", + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510", + "15728E5A8AACAA68FFFFFFFFFFFFFFFF" +); + +fn dh_fixture() -> (AuthAdapter, ServerDhInnerDataConstructor, BigUint) { + let mut adapter = AuthAdapter::new(2).unwrap(); + adapter.nonce = i128_from_bytes([1; 16]); + adapter.server_nonce = Some(i128_from_bytes([2; 16])); + adapter.new_nonce = Some(i256_from_bytes([3; 32])); + adapter.expires_in = Some(120); + adapter.clock = || 1_700_000_000_000_000; + let prime = BigUint::parse_bytes(DH_PRIME.as_bytes(), 16).unwrap(); + let secret = BigUint::from_bytes_be(&[5; 256]); + let g_a = BigUint::from(2_u32).modpow(&secret, &prime).to_bytes_be(); + let inner = ServerDhInnerDataConstructor { + nonce: adapter.nonce, + server_nonce: adapter.server_nonce.unwrap(), + g: 2, + dh_prime: prime.to_bytes_be(), + g_a, + server_time: 1_700_000_500, + }; + (adapter, inner, secret) +} + +fn dh_response(adapter: &AuthAdapter, inner: &ServerDhInnerDataConstructor) -> ServerDhParamsOkConstructor { + let bytes = encode_boxed(inner).unwrap(); + let mut plain = tellers_mtproto_crypto::sha1(&bytes).to_vec(); + plain.extend_from_slice(&bytes); + plain.resize(plain.len().next_multiple_of(16), 0); + let (key, iv) = temporary_aes_key_iv(&i256_to_bytes(&adapter.new_nonce.unwrap()), + &i128_to_bytes(&adapter.server_nonce.unwrap())); + ServerDhParamsOkConstructor { + nonce: adapter.nonce, + server_nonce: adapter.server_nonce.unwrap(), + encrypted_answer: aes_ige_encrypt(&plain, &key, &iv).unwrap(), + } +} + +#[test] +fn verified_dh_uses_authenticated_server_clock_expiry_and_independent_shared_key() { + let (mut adapter, inner, secret) = dh_fixture(); + let response = encode_boxed(&dh_response(&adapter, &inner)).unwrap(); + let request_bytes = adapter.accept_dh(&response).unwrap(); + assert_eq!(&request_bytes[..4], &tellers_mtproto::transport::SetClientDhParamsRequest::ID.to_le_bytes()); + let mut decoder = Decoder::new(&request_bytes[4..], Limits::default()).unwrap(); + let request = ::decode(&mut decoder).unwrap(); + decoder.finish().unwrap(); + let (key, iv) = temporary_aes_key_iv(&[3; 32], &[2; 16]); + let plain = aes_ige_decrypt(&request.encrypted_data, &key, &iv).unwrap(); + let (tellers_mtproto::transport::ClientDhInnerData::ClientDhInnerData(client), used) = + decode_boxed_prefix::(&plain[20..]).unwrap(); + assert_eq!(&plain[..20], tellers_mtproto_crypto::sha1(&plain[20..20 + used])); + assert_eq!(client.nonce, inner.nonce); + assert_eq!(client.server_nonce, inner.server_nonce); + let prime = BigUint::from_bytes_be(&inner.dh_prime); + let shared = BigUint::from_bytes_be(&client.g_b).modpow(&secret, &prime).to_bytes_be(); + let expected = pad_be(&shared, 256); + assert_eq!(adapter.auth_key.as_deref(), Some(expected.as_slice())); + assert_eq!(adapter.time_offset_micros(), Some(500_000_000)); + assert_eq!(adapter.expires_at, Some(1_700_000_620)); + let mut hashed = vec![3; 32]; + hashed.push(1); + hashed.extend_from_slice(&tellers_mtproto_crypto::sha1(&expected)[..8]); + let confirmation = DhGenOkConstructor { + nonce: inner.nonce, + server_nonce: inner.server_nonce, + new_nonce_hash1: I128::from_le_slice(&tellers_mtproto_crypto::sha1(&hashed)[4..20]).unwrap(), + }; + for mutate in [0, 1] { + let mut bad = confirmation.clone(); + if mutate == 0 { bad.nonce = i128_from_bytes([0; 16]); } else { bad.server_nonce = i128_from_bytes([0; 16]); } + assert!(adapter.accept_authorization(&encode_boxed(&bad).unwrap()).unwrap_err() + .to_string().contains("completion nonce")); + } + let (key, salt) = adapter.accept_authorization(&encode_boxed(&confirmation).unwrap()).unwrap(); + assert_eq!(key, expected); + assert_eq!(salt, initial_server_salt(&[3; 32], &[2; 16])); + assert!(adapter.auth_key.is_none()); +} + +#[test] +fn dh_nonce_mismatch_is_rejected_before_any_key_or_clock_publication() { + let (mut adapter, inner, _) = dh_fixture(); + for field in 0..4 { + let mut bad_inner = inner.clone(); + if field == 2 { bad_inner.nonce = i128_from_bytes([0; 16]); } + if field == 3 { bad_inner.server_nonce = i128_from_bytes([0; 16]); } + let mut response = dh_response(&adapter, &bad_inner); + if field == 0 { response.nonce = i128_from_bytes([0; 16]); } + if field == 1 { response.server_nonce = i128_from_bytes([0; 16]); } + let error = adapter.accept_dh(&encode_boxed(&response).unwrap()).unwrap_err(); + assert!(error.to_string().contains("nonce mismatch")); + assert!(adapter.auth_key.is_none()); + assert!(adapter.time_offset_micros().is_none()); + assert!(adapter.expires_at.is_none()); + } +} + +#[test] +fn authorization_rejects_an_oversized_incomplete_frame_before_buffering_it_all() { + struct Peer { reads: usize } + impl Connection for Peer { + fn send(&mut self, _: &[u8]) -> Result<(), tellers_mtproto_transport::Error> { Ok(()) } + fn receive(&mut self, output: &mut [u8]) -> Result { + output.fill(0); + if self.reads == 0 { output[..4].copy_from_slice(&(2_u32 * 1024 * 1024).to_le_bytes()); } + self.reads += 1; + Ok(output.len()) + } + fn close(&mut self) -> Result<(), tellers_mtproto_transport::Error> { Ok(()) } + } + let mut peer = Peer { reads: 0 }; + let error = recv_plain(&mut peer, &mut PaddedIntermediate::default()).unwrap_err(); + assert!(error.to_string().contains("size limit")); + assert!(peer.reads <= 18); +} #[test] fn trim_plain_envelope_drops_transport_pad() { diff --git a/native/mtproto-rs/tests/unit/auth_bind_temp_tests.rs b/native/mtproto-rs/tests/unit/auth_bind_temp_tests.rs new file mode 100644 index 000000000..3ea543a5d --- /dev/null +++ b/native/mtproto-rs/tests/unit/auth_bind_temp_tests.rs @@ -0,0 +1,241 @@ +use super::*; +use std::collections::VecDeque; +use tellers_mtproto_crypto::{Direction, MessageToEncrypt, decrypt_message, encrypt_message}; + +struct FixedClock; +impl Clock for FixedClock { + fn unix_micros(&self) -> i64 { 1_700_000_000_000_000 } +} + +struct BindPeer { + session: i64, + mode: &'static str, + input: VecDeque, + requests: usize, +} + +impl Connection for BindPeer { + fn send(&mut self, packet: &[u8]) -> Result<(), tellers_mtproto_transport::Error> { + self.requests += 1; + let frame = PaddedIntermediate::default().decode(packet).unwrap(); + let request = decrypt_message(&[4; 256], &crate::rpc::trim_padded_mtproto_packet(&frame.payload), Direction::ClientToServer, self.session, 4096).unwrap(); + let mut decoder = Decoder::new(&request.body, Limits::default()).unwrap(); + assert_eq!(decoder.read_u32().unwrap(), tellers_mtproto::latest::api::AuthBindTempAuthKeyRequest::ID); + let permanent_id = i64::decode(&mut decoder).unwrap(); + let nonce = i64::decode(&mut decoder).unwrap(); + let expires = i32::decode(&mut decoder).unwrap(); + let encrypted = decoder.read_bytes().unwrap(); + decoder.finish().unwrap(); + let permanent = [3; 256]; + assert_eq!(permanent_id, auth_key_id(&permanent)); + assert_eq!(i64::from_le_bytes(encrypted[..8].try_into().unwrap()), permanent_id); + let message_key = &encrypted[8..24]; + // Derive v1 keys from the documented byte ranges, independently of v1_aes. + let a = sha1(&[message_key, &permanent[..32]].concat()); + let b = sha1(&[&permanent[32..48], message_key, &permanent[48..64]].concat()); + let c = sha1(&[&permanent[64..96], message_key].concat()); + let d = sha1(&[message_key, &permanent[96..128]].concat()); + let key: [u8; 32] = [&a[..8], &b[8..20], &c[4..16]].concat().try_into().unwrap(); + let iv: [u8; 32] = [&a[8..20], &b[..8], &c[16..20], &d[..8]].concat().try_into().unwrap(); + let plain = aes_ige_decrypt(&encrypted[24..], &key, &iv).unwrap(); + assert_eq!(&sha1(&plain[..72])[4..20], message_key); + assert_eq!(i64::from_le_bytes(plain[16..24].try_into().unwrap()), request.message_id); + assert_eq!(&plain[24..28], &0_i32.to_le_bytes()); + assert_eq!(&plain[28..32], &40_i32.to_le_bytes()); + assert_eq!(&plain[32..36], &BindAuthKeyInnerConstructor::ID.to_le_bytes()); + assert_eq!(&plain[36..44], &nonce.to_le_bytes()); + assert_eq!(&plain[44..52], &auth_key_id(&[4; 256]).to_le_bytes()); + assert_eq!(&plain[52..60], &permanent_id.to_le_bytes()); + assert_eq!(&plain[60..68], &self.session.to_le_bytes()); + assert_eq!(&plain[68..72], &expires.to_le_bytes()); + let id = request.message_id + if self.mode == "wrong_request" { 4 } else { 0 }; + let mut body = result_body(id, if self.mode == "false" { BOOL_FALSE } else { BOOL_TRUE }); + if self.mode == "error" { + body.truncate(12); + body.extend_from_slice(&crate::rpc::encode_boxed_bytes(&tellers_mtproto::transport::RpcErrorConstructor { + error_code: 400, error_message: "ENCRYPTED_MESSAGE_INVALID".into(), + }).unwrap()); + } + if matches!(self.mode, "container" | "duplicate_service" | "duplicate_in_container") { + let mut service = 0x9ec2_0908_u32.to_le_bytes().to_vec(); + service.extend_from_slice(&request.message_id.to_le_bytes()); + service.extend_from_slice(&9_i64.to_le_bytes()); + service.extend_from_slice(&123_i64.to_le_bytes()); + if self.mode == "duplicate_in_container" { + let mut replay = service.clone(); + replay[20..28].copy_from_slice(&999_i64.to_le_bytes()); + body = container(&[service, replay, body]); + let first_id = body[8..16].to_vec(); + body[52..60].copy_from_slice(&first_id); + } else { + body = container(&[service, body]); + } + } else if matches!(self.mode, "duplicate_result" | "retired_result" | "future_child" | "negative_child") { + body = container(&[body]); + if self.mode == "future_child" { + body[8..16].copy_from_slice(&(request.message_id + 37).to_le_bytes()); + } + if self.mode == "negative_child" { + body[16..20].copy_from_slice(&(-1_i32).to_le_bytes()); + } + } + let padding = vec![7; v2_pad_len(body.len())]; + let mut reply = encrypt_message(&[4; 256], MessageToEncrypt { + server_salt: 456, + session_id: self.session + i64::from(self.mode == "wrong_session"), + message_id: request.message_id + 33 + if self.mode == "stale" { 40_i64 << 32 } else { 0 }, + sequence: 1, + body: &body, padding: &padding, + }, Direction::ServerToClient).unwrap(); + if self.mode == "corrupt" { reply[24] ^= 1; } + self.input.extend(PaddedIntermediate::default().encode(&reply).unwrap()); + Ok(()) + } + + fn receive(&mut self, output: &mut [u8]) -> Result { + let n = 7.min(output.len()).min(self.input.len()); + for byte in &mut output[..n] { *byte = self.input.pop_front().unwrap(); } + Ok(n) + } + + fn close(&mut self) -> Result<(), tellers_mtproto_transport::Error> { Ok(()) } +} + +fn result_body(request: i64, result: u32) -> Vec { + [RPC_RESULT.to_le_bytes().as_slice(), &request.to_le_bytes(), &result.to_le_bytes()].concat() +} + +fn container(children: &[Vec]) -> Vec { + let mut out = MSG_CONTAINER.to_le_bytes().to_vec(); + out.extend_from_slice(&(children.len() as u32).to_le_bytes()); + for (index, child) in children.iter().enumerate() { + let child_id = ((FixedClock.unix_micros() / 1_000_000) << 32) + 1 + 4 * index as i64; + out.extend_from_slice(&child_id.to_le_bytes()); + out.extend_from_slice(&1_i32.to_le_bytes()); + out.extend_from_slice(&(child.len() as u32).to_le_bytes()); + out.extend_from_slice(child); + } + out +} + +fn bind_response(body: &[u8], request: i64) -> Result { + let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); + super::bind_response(body, request, &mut snapshot, &FixedClock, i64::MAX) +} + +#[test] +fn authenticated_binding_children_preserve_replay_and_acknowledgement_state() { + let base = (FixedClock.unix_micros() / 1_000_000) << 32; + for mode in ["container", "duplicate_service", "duplicate_in_container", "duplicate_result", "retired_result"] { + let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); + snapshot.auth_key = Some(vec![4; 256]); + if matches!(mode, "duplicate_service" | "duplicate_result") { + snapshot.register_received_message(base + 1, true, &FixedClock, false).unwrap(); + snapshot.take_acknowledgements(usize::MAX); + } + if mode == "retired_result" { + snapshot.register_received_message(base + 1, true, &FixedClock, false).unwrap(); + snapshot.take_acknowledgements(usize::MAX); + for index in 0..tellers_mtproto_session::RECEIVED_MESSAGE_WINDOW { + snapshot.register_received_message(base + 7 + 4 * index as i64, false, &FixedClock, false).unwrap(); + } + } + let mut peer = BindPeer { session: snapshot.session_id, mode, input: VecDeque::new(), requests: 0 }; + let result = bind_temporary_key(&mut peer, &mut PaddedIntermediate::default(), &mut snapshot, &[3; 256], 1_700_086_400, &FixedClock); + assert_eq!(result.is_ok(), !matches!(mode, "duplicate_result" | "retired_result"), "{mode}"); + assert!(snapshot.received_message_ids.contains_key(&(base + 33)), "outer must be fresh: {mode}"); + let acknowledgements = snapshot.take_acknowledgements(usize::MAX); + assert!(acknowledgements.contains(&(base + 33))); + if mode == "retired_result" { + assert!(!snapshot.received_message_ids.contains_key(&(base + 1))); + assert!(!acknowledgements.contains(&(base + 1))); + } else { + assert!(acknowledgements.contains(&(base + 1)), "child ACK/re-ACK: {mode}"); + } + if mode == "duplicate_service" { + assert_eq!(snapshot.server_salt, 0); + assert!(acknowledgements.contains(&(base + 5))); + } + if matches!(mode, "container" | "duplicate_in_container") { + assert_eq!(snapshot.server_salt, 123); + assert!(acknowledgements.contains(&(base + if mode == "container" { 5 } else { 9 }))); + } + } +} + +#[test] +fn binding_authenticates_both_envelopes_and_rejects_invalid_replies() { + for mode in ["true", "false", "error", "wrong_request", "wrong_session", "corrupt", "stale", "container", "future_child", "negative_child"] { + let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); + snapshot.auth_key = Some(vec![4; 256]); + let mut peer = BindPeer { session: snapshot.session_id, mode, input: VecDeque::new(), requests: 0 }; + let result = bind_temporary_key(&mut peer, &mut PaddedIntermediate::default(), &mut snapshot, &[3; 256], 1_700_086_400, &FixedClock); + assert_eq!(result.is_ok(), matches!(mode, "true" | "container"), "case {mode}"); + assert_eq!(peer.requests, 1); + if mode == "container" { assert_eq!(snapshot.server_salt, 123); } + if mode == "error" { assert!(result.unwrap_err().to_string().contains("ENCRYPTED_MESSAGE_INVALID")); } + } +} + +#[test] +fn binding_deadline_and_client_close_stop_a_stalled_peer() { + use std::io::Read; + for cancel in [false, true] { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap(); + socket.read_exact(&mut [0; 64]).unwrap(); + socket.read_exact(&mut [0; 1]).unwrap(); + let _ = started_tx.send(()); + let mut buffer = [0; 4096]; + while socket.read(&mut buffer).unwrap_or(0) > 0 {} + }); + let control = std::sync::Arc::new(crate::tcp::ConnectionControl { + config: monogram_mtproto_transport::TransportConfig::default(), + ..Default::default() + }); + let closing = control.clone(); + let closer = std::thread::spawn(move || { + started_rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap(); + if cancel { closing.close(); } + }); + let started = std::time::Instant::now(); + let result = crate::tcp::with_connection_control(&control, || { + let mut socket = crate::tcp::connect_obfuscated_timeout(&address, 1).unwrap(); + let mut connection = DeadlineConnection { + inner: &mut socket, + deadline: std::time::Instant::now() + std::time::Duration::from_millis(100), + }; + let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); + snapshot.auth_key = Some(vec![4; 256]); + bind_temporary_key(&mut connection, &mut PaddedIntermediate::default(), &mut snapshot, &[3; 256], 1_700_086_400, &FixedClock) + }); + assert!(result.is_err()); + assert!(started.elapsed() < std::time::Duration::from_secs(2)); + closer.join().unwrap(); + server.join().unwrap(); + } +} + +#[test] +fn bind_containers_validate_all_siblings_and_bound_depth_and_count() { + let valid = result_body(100, BOOL_TRUE); + assert!(bind_response(&valid, 100).unwrap().accepted); + let mut trailing = valid.clone(); + trailing.extend_from_slice(&0_u32.to_le_bytes()); + assert!(bind_response(&trailing, 100).is_err()); + let mut malformed_sibling = container(&[valid.clone()]); + malformed_sibling[4..8].copy_from_slice(&2_u32.to_le_bytes()); + assert!(bind_response(&malformed_sibling, 100).is_err()); + let mut too_deep = valid.clone(); + for depth in 0..9 { + too_deep = container(&[too_deep]); + let id = ((FixedClock.unix_micros() / 1_000_000) << 32) + 1 + 4 * depth; + too_deep[8..16].copy_from_slice(&id.to_le_bytes()); + } + assert!(bind_response(&too_deep, 100).is_err()); + assert!(bind_response(&container(&vec![valid; 1024]), 100).is_err()); +} diff --git a/native/mtproto-rs/tests/unit/client_mgr_channel_tests.rs b/native/mtproto-rs/tests/unit/client_mgr_channel_tests.rs index 9322321cb..df26eb3ed 100644 --- a/native/mtproto-rs/tests/unit/client_mgr_channel_tests.rs +++ b/native/mtproto-rs/tests/unit/client_mgr_channel_tests.rs @@ -79,9 +79,8 @@ fn begin_updates_preserves_the_unapplied_cursor_in_persistence() { session_dead_reason: None, test_dc: false, last_inline: None, - perm_auth_key: None, - perm_salt: 0, - perm_session_id: 0, + permanent: None, + temp_expires_at: 0, dc_auth_keys: HashMap::new(), journal: Default::default(), }; diff --git a/native/mtproto-rs/tests/unit/client_mgr_lane_tests.rs b/native/mtproto-rs/tests/unit/client_mgr_lane_tests.rs index ffe2504d8..d06232a90 100644 --- a/native/mtproto-rs/tests/unit/client_mgr_lane_tests.rs +++ b/native/mtproto-rs/tests/unit/client_mgr_lane_tests.rs @@ -364,6 +364,8 @@ fn read_does_not_wait_for_unused_secondary_lanes_without_pfs() { fn authorize_test_client(handle: u64) -> (i64, Vec) { let client = get_client(handle).expect("client"); + let _policy = crate::transfer_policy::bind(client.policy.clone()); + crate::scheduler::set_main_session_allowance(Some(3)); let mut main = client.main.lock(); main.snapshot.auth_key = Some(vec![7_u8; 256]); main.snapshot.server_salt = 99; @@ -375,6 +377,7 @@ fn authorize_test_client(handle: u64) -> (i64, Vec) { data.home_session_id = home_id; data.home_auth_key = Some(auth.clone()); data.home_salt = 99; + data.home_snapshot = main.snapshot.clone(); (home_id, auth) } @@ -456,6 +459,7 @@ fn bound_temp_read_lane_does_not_reuse_the_permanent_key() { let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); let (home_id, home_auth) = authorize_test_client(handle); let client = get_client(handle).expect("client"); + let _policy = crate::transfer_policy::bind(client.policy.clone()); let temp_key = vec![9_u8; 256]; let temp_id = { let mut io = client.rpc[0].io.lock(); @@ -464,6 +468,7 @@ fn bound_temp_read_lane_does_not_reuse_the_permanent_key() { snap.server_salt = 99; let id = snap.session_id; io.snapshot = snap; + io.perm_key = Some(home_auth.clone()); io.temp_expires_at = crate::auth::bind_temp::unix_now().saturating_add(3600); io.transport = None; id diff --git a/native/mtproto-rs/tests/unit/client_mgr_logout_tests.rs b/native/mtproto-rs/tests/unit/client_mgr_logout_tests.rs index 34870bc53..08bbf0a4b 100644 --- a/native/mtproto-rs/tests/unit/client_mgr_logout_tests.rs +++ b/native/mtproto-rs/tests/unit/client_mgr_logout_tests.rs @@ -28,9 +28,8 @@ fn logout_clears_unauthorized_local_session_without_network() { session_dead_reason: None, test_dc: false, last_inline: None, - perm_auth_key: None, - perm_salt: 0, - perm_session_id: 0, + permanent: None, + temp_expires_at: 0, dc_auth_keys: HashMap::new(), journal: Default::default(), }; @@ -112,9 +111,8 @@ fn cached_foreign_dc_key_skips_export_until_the_key_is_unusable() { session_dead_reason: None, test_dc: false, last_inline: None, - perm_auth_key: None, - perm_salt: 0, - perm_session_id: 0, + permanent: None, + temp_expires_at: 0, dc_auth_keys, journal: Default::default(), }; @@ -207,9 +205,8 @@ fn identity_changes_drop_dc_auth_and_a_later_save_cannot_restore_them() { session_dead_reason: None, test_dc: false, last_inline: None, - perm_auth_key: None, - perm_salt: 0, - perm_session_id: 0, + permanent: None, + temp_expires_at: 0, dc_auth_keys: client.data.lock().dc_auth_keys.clone(), journal: Default::default(), }; diff --git a/native/mtproto-rs/tests/unit/client_mgr_pfs_tests.rs b/native/mtproto-rs/tests/unit/client_mgr_pfs_tests.rs new file mode 100644 index 000000000..b80b7ecc7 --- /dev/null +++ b/native/mtproto-rs/tests/unit/client_mgr_pfs_tests.rs @@ -0,0 +1,658 @@ +use super::*; +use crate::UpdateEventDto; + +struct Fixture { + handle: u64, + path: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("monogram-pfs-{}-{nonce}.json", std::process::id())); + let handle = create_client(1, String::new(), path.to_string_lossy().into_owned()); + Self { handle, path } + } + fn temporary(&self, expires_at: i32) -> Arc { + let client = get_client(self.handle).unwrap(); + { + let mut main = client.main.lock(); + let mut data = client.data.lock(); + data.user_id = Some(7); + data.persist_epoch = 1; + data.home_auth_key = Some(vec![3; 256]); + data.home_snapshot.auth_key = data.home_auth_key.clone(); + main.snapshot = fork_session(&main.snapshot); + main.snapshot.auth_key = Some(vec![4; 256]); + main.temp_expires_at = expires_at; + main.perm_key = Some(vec![3; 256]); + } + client + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + destroy_client(self.handle); + let _ = std::fs::remove_file(&self.path); + } +} + +#[test] +fn main_renews_at_lane_boundary_and_preserves_permanent_identity() { + let now = crate::auth::bind_temp::unix_now(); + for expiry in [1, now + 30, now + 3600] { + let fixture = Fixture::new(); + let client = fixture.temporary(expiry); + let original_home = client.data.lock().home_session_id; + let calls = Cell::new(0); + with_client_mut_binding(fixture.handle, |state| { + let expected = if expiry > now + 60 { 4 } else { 5 }; + assert!(state.snapshot.auth_key.as_deref() == Some([expected; 256].as_slice())); + assert!(state.permanent.as_ref().unwrap().auth_key.as_deref() == Some([3; 256].as_slice())); + persist(state) + }, |permanent, dc, offset| { + calls.set(calls.get() + 1); + assert!(permanent == [3; 256]); + let mut snapshot = Snapshot::new(dc, &mut OsRandom).unwrap(); + snapshot.auth_key = Some(vec![5; 256]); + snapshot.time_offset_micros = offset; + Ok(crate::auth::bind_temp::BoundTemp { snapshot, expires_at: now + 3600 }) + }).unwrap(); + assert_eq!(calls.get(), usize::from(expiry <= now + 60)); + let restored = FileSessionStore::new(&fixture.path).load().unwrap().unwrap(); + assert!(restored.snapshot.auth_key.as_deref() == Some([3; 256].as_slice())); + assert_eq!(restored.snapshot.session_id, original_home); + let data = client.data.lock(); + assert!(data.home_auth_key.as_deref() == Some([3; 256].as_slice())); + assert_eq!(data.home_session_id, original_home); + } +} + +#[test] +fn failed_or_stale_renewal_never_runs_the_application_request_or_publishes_a_key() { + for mode in ["failure", "session", "account", "dc", "close"] { + let fixture = Fixture::new(); + let client = fixture.temporary(1); + let original = client.main.lock().snapshot.clone(); + let invoked = Cell::new(false); + let result = with_client_mut_binding(fixture.handle, |_| { + invoked.set(true); + Ok(()) + }, |_, dc, _| { + if mode == "failure" { return Err(MtprotoError::Message("injected bind failure".into())); } + match mode { + "session" => client.data.lock().home_session_id = original.session_id, + "account" => client.data.lock().user_id = Some(8), + "dc" => client.data.lock().home_dc = dc + 1, + "close" => client.connections.close(), + _ => unreachable!(), + } + let mut snapshot = Snapshot::new(dc, &mut OsRandom).unwrap(); + snapshot.auth_key = Some(vec![5; 256]); + Ok(crate::auth::bind_temp::BoundTemp { snapshot, expires_at: crate::auth::bind_temp::unix_now() + 3600 }) + }); + assert!(result.is_err(), "case {mode}"); + assert!(!invoked.get()); + assert!(client.main.lock().snapshot == original); + assert!(!fixture.path.exists()); + } +} + +#[test] +fn temporary_key_rejection_preserves_login_and_renews_before_the_next_request() { + for message in ["RPC 401: AUTH_KEY_UNREGISTERED", "RPC 401: AUTH_KEY_PERM_EMPTY", "RPC 406: AUTH_KEY_DUPLICATED", "MTProto transport error -404"] { + let fixture = Fixture::new(); + let client = fixture.temporary(crate::auth::bind_temp::unix_now() + 3600); + let original_home = client.data.lock().home_session_id; + let invocations = Cell::new(0); + let result = with_client_mut_binding(fixture.handle, |state| { + call_with_migrate(state, |_| { + invocations.set(invocations.get() + 1); + Err::<(), _>(MtprotoError::Message(message.into())) + }) + }, |_, _, _| panic!("valid key was renewed before rejection")); + assert_eq!(result.unwrap_err().to_string(), "connection closed: temporary authorization renewal required"); + assert_eq!(invocations.get(), 1); + assert_eq!(client.main.lock().temp_expires_at, 1); + { + let data = client.data.lock(); + assert!(!data.session_dead); + assert_eq!(data.home_session_id, original_home); + assert!(data.home_auth_key.as_deref() == Some([3; 256].as_slice())); + } + with_client_mut_binding(fixture.handle, |state| { + assert!(state.snapshot.auth_key.as_deref() == Some([5; 256].as_slice())); + Ok(()) + }, |_, dc, offset| { + let mut snapshot = Snapshot::new(dc, &mut OsRandom).unwrap(); + snapshot.auth_key = Some(vec![5; 256]); + snapshot.time_offset_micros = offset; + Ok(crate::auth::bind_temp::BoundTemp { snapshot, expires_at: crate::auth::bind_temp::unix_now() + 3600 }) + }).unwrap(); + } +} + +#[test] +fn permanent_authorization_revocation_remains_terminal_under_pfs() { + let fixture = Fixture::new(); + let client = fixture.temporary(crate::auth::bind_temp::unix_now() + 3600); + let result = with_client_mut_binding(fixture.handle, |state| { + call_with_migrate(state, |_| Err::<(), _>(MtprotoError::Message("RPC 401: SESSION_REVOKED".into()))) + }, |_, _, _| panic!("valid temporary key was renewed")); + assert!(result.is_err()); + assert!(client.data.lock().session_dead); + let restored = FileSessionStore::new(&fixture.path).load().unwrap().unwrap(); + assert!(restored.session_dead); + assert!(restored.snapshot.auth_key.as_deref() == Some([3; 256].as_slice())); +} + +#[test] +fn destruction_cancels_renewal_then_saves_the_permanent_session() { + let fixture = Fixture::new(); + let client = fixture.temporary(1); + let handle = fixture.handle; + let waiting = client.clone(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (finished_tx, finished_rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let result: Result<(), MtprotoError> = with_client_mut_binding(handle, |_| panic!("request ran during shutdown"), |_, _, _| { + entered_tx.send(()).unwrap(); + waiting.connections.wait(std::time::Duration::from_secs(60)) + .map_err(|e| MtprotoError::Message(e.to_string()))?; + unreachable!("shutdown did not cancel renewal") + }); + finished_tx.send(result.is_err()).unwrap(); + }); + entered_rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap(); + let started = std::time::Instant::now(); + destroy_client(handle); + assert!(started.elapsed() < std::time::Duration::from_secs(2)); + assert!(finished_rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap()); + worker.join().unwrap(); + assert!(!client_exists(handle)); + let restored = FileSessionStore::new(&fixture.path).load().unwrap().unwrap(); + assert!(restored.snapshot.auth_key.as_deref() == Some([3; 256].as_slice())); + assert_eq!(restored.user_id, Some(7)); + assert!(client.connections.while_persisting(|| panic!("late persistence")).is_err()); +} + +#[test] +fn failed_final_save_does_not_advance_persistence_state() { + let fixture = Fixture::new(); + let client = fixture.temporary(1); + std::fs::create_dir(&fixture.path).unwrap(); + let home_id = client.data.lock().home_session_id; + { + let mut data = client.data.lock(); + data.persist_epoch = 7; + data.persisted_epoch = 0; + } + assert!(persist_updates_data(&client, home_id).is_err()); + assert_eq!(client.data.lock().persisted_epoch, 0); + std::fs::remove_dir(&fixture.path).unwrap(); +} + +#[test] +fn scheduler_permissions_and_bound_keys_are_isolated_between_clients() { + let first = Fixture::new(); + let second = Fixture::new(); + with_client_mut(first.handle, |_| { + crate::scheduler::set_main_session_allowance(Some(3)); + crate::scheduler::set_bound_extra_sessions(2); + assert_eq!(crate::scheduler::extra_main_sessions(), 2); + Ok(()) + }).unwrap(); + with_client_mut(second.handle, |_| { + assert!(!crate::scheduler::main_session_allowance_known()); + assert_eq!(crate::scheduler::extra_main_sessions(), 0); + crate::scheduler::set_main_session_allowance(Some(1)); + crate::scheduler::set_bound_extra_sessions(2); + assert_eq!(crate::scheduler::extra_main_sessions(), 0); + Ok(()) + }).unwrap(); + with_client_mut(first.handle, |_| { + assert_eq!(crate::scheduler::main_session_allowance(), 3); + assert_eq!(crate::scheduler::extra_main_sessions(), 2); + crate::scheduler::set_bound_extra_sessions(0); + Ok(()) + }).unwrap(); + with_client_mut(second.handle, |_| { + assert_eq!(crate::scheduler::main_session_allowance(), 1); + assert_eq!(crate::transfer_policy::current().bound_extra_sessions.load(Ordering::Acquire), 2); + Ok(()) + }).unwrap(); +} + +#[test] +fn saturated_sibling_queue_releases_partial_family_reservations() { + let fixture = Fixture::new(); + let client = get_client(fixture.handle).unwrap(); + let saturated = &client.media[1].gate; + let waiters: Vec<_> = (0..crate::scheduler::MAX_LANE_WAITERS) + .map(|_| saturated.enqueue(scheduler::RequestClass::BackgroundMedia).unwrap()).collect(); + let result = super::lanes::acquire_lane_family(0..2, scheduler::RequestClass::InteractiveMedia, + "queue_capacity", "queue_capacity", |index| &client.media[index]); + assert!(result.is_err()); + assert_eq!(client.media[0].gate.waiter_count(), 0); + assert_eq!(saturated.waiter_count(), crate::scheduler::MAX_LANE_WAITERS); + drop(waiters); + assert_eq!(saturated.waiter_count(), 0); +} + +#[test] +fn key_migration_preserves_same_account_journal_and_concurrent_cursors() { + for replace_user in [false, true] { + let fixture = Fixture::new(); + let cursor = |pts| UpdatesStateDto { pts, qts: 0, date: pts, seq: pts }; + with_client_mut(fixture.handle, |state| { + state.user_id = Some(7); + state.snapshot.auth_key = Some(vec![3; 256]); + state.updates = Some(cursor(100)); + state.channel_pts.insert(7, 100); + for chat_id in [1, 2] { + state.channel_recovery.push_back(ChannelRecovery { chat_id, due_at: 100, watching: false }); + } + Ok(()) + }).unwrap(); + let client = get_client(fixture.handle).unwrap(); + let generation = { + let mut data = client.data.lock(); + Arc::make_mut(&mut data.journal).push(vec![UpdateEventDto::MessagesDeleted { + chat_id: Some(7), message_ids: vec![1], + }], Some(cursor(100)), vec![(7, 100)]); + data.journal.account_generation + }; + with_client_mut(fixture.handle, |state| { + { + let mut data = client.data.lock(); + data.updates = Some(cursor(200)); + data.channel_pts.insert(7, 200); + data.channel_recovery.iter_mut().find(|entry| entry.chat_id == 2).unwrap().due_at = 200; + data.channel_recovery.push_back(ChannelRecovery { chat_id: 9, due_at: 100, watching: false }); + Arc::make_mut(&mut data.journal).push(vec![UpdateEventDto::MessagesDeleted { + chat_id: Some(7), message_ids: vec![2], + }], Some(cursor(200)), vec![(7, 200)]); + } + state.snapshot = Snapshot::new(4, &mut OsRandom).unwrap(); + state.snapshot.auth_key = Some(vec![8; 256]); + state.channel_recovery.clear(); + if replace_user { + state.user_id = Some(8); + state.updates = None; + state.channel_pts.clear(); + } else { + state.channel_recovery.push_back(ChannelRecovery { chat_id: 8, due_at: 100, watching: false }); + } + Ok(()) + }).unwrap(); + let session = client.data.lock().home_session_id; + persist_updates_data(&client, session).unwrap(); + let saved = FileSessionStore::new(&fixture.path).load().unwrap().unwrap(); + if replace_user { + assert_ne!(saved.journal.account_generation, generation); + assert!(saved.journal.pending().is_empty()); + assert!(saved.updates.is_none()); + assert!(saved.channel_pts.is_empty()); + assert!(saved.channel_recovery.is_empty()); + } else { + assert_eq!(saved.journal.account_generation, generation); + assert_eq!(saved.journal.pending().len(), 2); + assert_eq!(saved.updates.as_ref().unwrap().pts, 200); + assert_eq!(saved.channel_pts.get(&7), Some(&200)); + assert_eq!(saved.channel_recovery.iter().map(|entry| entry.chat_id).collect::>(), vec![2, 9, 8]); + assert_eq!(saved.channel_recovery.front().unwrap().due_at, 200); + } + } +} + +#[test] +fn successful_sign_in_requires_pfs_before_business_io_without_blocking_local_authorization() { + let fixture = Fixture::new(); + with_client_mut(fixture.handle, |state| { + state.snapshot.auth_key = Some(vec![3; 256]); + super::auth::complete_sign_in(state, 7) + }).unwrap(); + let client = get_client(fixture.handle).unwrap(); + let permanent = client.data.lock().home_snapshot.clone(); + assert!(is_authorized(fixture.handle).unwrap()); + assert_eq!(client.main.lock().temp_expires_at, 1); + let invoked = Cell::new(false); + let failed = with_client_mut_binding(fixture.handle, |_| { + invoked.set(true); + Ok(()) + }, |_, _, _| Err(MtprotoError::Message("injected offline bind failure".into()))); + assert!(failed.is_err()); + assert!(!invoked.get()); + assert!(is_authorized(fixture.handle).unwrap()); + with_client_mut_binding(fixture.handle, |state| { + invoked.set(true); + assert_eq!(state.snapshot.auth_key.as_deref(), Some(&[4; 256][..])); + assert_eq!(state.permanent.as_ref(), Some(&permanent)); + Ok(()) + }, |_, dc, offset| { + let mut snapshot = Snapshot::new(dc, &mut OsRandom).unwrap(); + snapshot.auth_key = Some(vec![4; 256]); + snapshot.time_offset_micros = offset; + Ok(crate::auth::bind_temp::BoundTemp { snapshot, + expires_at: crate::auth::bind_temp::unix_now() + 3600 }) + }).unwrap(); + assert!(invoked.get()); + client.main.lock().temp_expires_at = 1; + assert!(is_authorized(fixture.handle).unwrap()); + persist_updates_data(&client, permanent.session_id).unwrap(); + let saved = FileSessionStore::new(&fixture.path).load().unwrap().unwrap(); + assert_eq!(saved.snapshot, permanent); + assert_eq!(saved.user_id, Some(7)); +} + +#[test] +fn media_binding_publishes_only_for_the_current_account_dc_master_and_open_client() { + for mode in ["ok", "failure", "session", "account", "home_key", "master", "dc", "shutdown"] { + let fixture = Fixture::new(); + let client = fixture.temporary(crate::auth::bind_temp::unix_now() + 3600); + client.data.lock().dc_auth_keys.insert(4, crate::session_file::DcAuthKey { key: vec![6; 256], salt: 9 }); + let owner = client.data.lock().home_snapshot.clone(); + let mut io = client.media[0].io.lock(); + let original = io.snapshot.clone(); + let result = crate::auth::bind_temp::ensure_dc_temp_with(&client, &mut io, 4, + Some(owner.session_id), &crate::rpc::SystemClock, |key, dc, offset| { + assert_eq!(key, &[6; 256]); + match mode { + "failure" => return Err(MtprotoError::Message("injected media binding failure".into())), + "session" => client.data.lock().home_session_id = owner.session_id.wrapping_add(1), + "account" => client.data.lock().user_id = Some(8), + "home_key" => client.data.lock().home_auth_key = Some(vec![9; 256]), + "master" => client.data.lock().dc_auth_keys.get_mut(&4).unwrap().key = vec![9; 256], + "dc" => client.data.lock().home_dc = 5, + "shutdown" => client.connections.begin_shutdown(), + _ => {}, + } + let mut snapshot = Snapshot::new(dc, &mut OsRandom).unwrap(); + snapshot.auth_key = Some(vec![8; 256]); + snapshot.time_offset_micros = offset; + Ok(crate::auth::bind_temp::BoundTemp { snapshot, + expires_at: crate::auth::bind_temp::unix_now() + 3600 }) + }); + if mode == "ok" { + assert!(result.unwrap()); + assert_eq!(io.snapshot.auth_key.as_deref(), Some(&[8; 256][..])); + assert_eq!(client.data.lock().dc_temp_auth.get(&4).unwrap().snapshot, io.snapshot); + let first = io.snapshot.clone(); + drop(io); + let mut second = client.media[1].io.lock(); + crate::auth::bind_temp::ensure_dc_temp_with(&client, &mut second, 4, + Some(owner.session_id), &crate::rpc::SystemClock, + |_, _, _| panic!("valid cached binding must be reused")).unwrap(); + assert_eq!(second.snapshot.auth_key, first.auth_key); + assert_ne!(second.snapshot.session_id, first.session_id); + } else { + assert!(result.is_err(), "{mode}"); + assert_eq!(io.snapshot, original); + assert!(client.data.lock().dc_temp_auth.is_empty()); + let mut data = client.data.lock(); + data.home_dc = owner.dc_id; + data.home_session_id = owner.session_id; + data.home_auth_key = owner.auth_key.clone(); + data.user_id = Some(7); + } + } +} + +#[test] +fn terminal_lane_error_survives_a_concurrent_main_commit_and_persists_the_permanent_owner() { + let fixture = Fixture::new(); + let client = fixture.temporary(crate::auth::bind_temp::unix_now() + 3600); + let owner = client.data.lock().home_session_id; + let error = MtprotoError::Message("RPC 401: SESSION_REVOKED".into()); + assert!(!mark_lane_session_dead(&client, owner.wrapping_add(1), &error)); + with_client_mut_binding(fixture.handle, |_| { + assert!(mark_lane_session_dead(&client, owner, &error)); + Ok(()) + }, |_, _, _| panic!("terminal failure must not negotiate")).unwrap(); + assert!(client.data.lock().session_dead); + persist_updates_data(&client, owner).unwrap(); + let saved = FileSessionStore::new(&fixture.path).load().unwrap().unwrap(); + assert!(saved.session_dead); + assert_eq!(saved.snapshot.auth_key.as_deref(), Some(&[3; 256][..])); + assert_eq!(saved.snapshot.session_id, owner); +} + +#[test] +fn temporary_lane_rejection_preserves_dc_master_and_does_not_evict_a_newer_cached_key() { + let fixture = Fixture::new(); + let client = fixture.temporary(crate::auth::bind_temp::unix_now() + 3600); + client.data.lock().dc_auth_keys.insert(4, crate::session_file::DcAuthKey { key: vec![6; 256], salt: 9 }); + let mut io = client.media[0].io.lock(); + io.snapshot.dc_id = 4; + io.snapshot.auth_key = Some(vec![8; 256]); + io.perm_key = Some(vec![6; 256]); + io.temp_expires_at = crate::auth::bind_temp::unix_now() + 3600; + client.data.lock().dc_temp_auth.insert(4, DcTempAuth { + snapshot: io.snapshot.clone(), temp_expires_at: io.temp_expires_at, perm_key: vec![6; 256], + }); + let error = MtprotoError::Message("MTProto transport error -404".into()); + assert!(expire_temporary_lane(&client, &mut io, &error)); + assert_eq!(io.temp_expires_at, 1); + assert!(client.data.lock().dc_temp_auth.is_empty()); + let mut newer = io.snapshot.clone(); + newer.auth_key = Some(vec![9; 256]); + client.data.lock().dc_temp_auth.insert(4, DcTempAuth { + snapshot: newer, temp_expires_at: crate::auth::bind_temp::unix_now() + 3600, perm_key: vec![6; 256], + }); + assert!(expire_temporary_lane(&client, &mut io, &error)); + let data = client.data.lock(); + assert!(!data.session_dead); + assert_eq!(data.dc_auth_keys.get(&4).unwrap().key, vec![6; 256]); + assert_eq!(data.dc_temp_auth.get(&4).unwrap().snapshot.auth_key.as_deref(), Some(&[9; 256][..])); +} + +#[test] +fn upload_rejection_expires_the_lane_without_replaying_the_batch_or_revoking_login() { + let fixture = Fixture::new(); + let client = fixture.temporary(crate::auth::bind_temp::unix_now() + 3600); + let home = client.main.lock().snapshot.clone(); + for lane in &client.upload { + let mut io = lane.io.lock(); + io.snapshot = fork_session(&home); + io.perm_key = Some(vec![3; 256]); + io.temp_expires_at = crate::auth::bind_temp::unix_now() + 3600; + } + let payload = fixture.path.with_extension("payload"); + std::fs::write(&payload, [0_u8; 8]).unwrap(); + let item = crate::UploadItemDto { + path: payload.to_string_lossy().into_owned(), kind: "document".into(), + mime_type: String::new(), file_name: "fixture.bin".into(), caption: String::new(), + entities_json: None, duration: 0, width: 0, height: 0, random_id: 7, + }; + let calls = Cell::new(0); + let result = save_items_with(&client, 1, &[item], |_, _| { + calls.set(calls.get() + 1); + Err(MtprotoError::Message("RPC 401: AUTH_KEY_UNREGISTERED".into())) + }); + std::fs::remove_file(payload).unwrap(); + assert_eq!(result.err().unwrap().to_string(), "connection closed: temporary authorization renewal required"); + assert_eq!(calls.get(), 1); + assert_eq!(client.upload.iter().filter(|lane| lane.io.lock().temp_expires_at == 1).count(), 1); + assert!(!client.data.lock().session_dead); + assert!(is_authorized(fixture.handle).unwrap()); +} + +#[test] +fn pfs_migration_binds_before_retry_and_rolls_back_on_binding_failure() { + for mode in ["ok", "failure", "invalid"] { + let fixture = Fixture::new(); + let client = fixture.temporary(crate::auth::bind_temp::unix_now() + 3600); + client.data.lock().dc_auth_keys.insert(4, crate::session_file::DcAuthKey { key: vec![6; 256], salt: 9 }); + let original = client.data.lock().home_snapshot.clone(); + let retry = Cell::new(false); + let expires = crate::auth::bind_temp::unix_now() + 3600; + let result = with_client_mut_binding(fixture.handle, |state| { + ensure_ready_after_migrate_with_binding(state, MtprotoError::Message("MIGRATE_4".into()), + |key, dc, offset| { + assert_eq!(key, &[6; 256]); + if mode == "failure" { return Err(MtprotoError::Message("offline".into())); } + let mut snapshot = Snapshot::new(dc, &mut OsRandom).unwrap(); + snapshot.auth_key = Some(vec![if mode == "invalid" { 6 } else { 8 }; 256]); + snapshot.time_offset_micros = offset; + Ok(crate::auth::bind_temp::BoundTemp { snapshot, expires_at: expires }) + })?; + retry.set(true); + assert_eq!(state.snapshot.auth_key.as_deref(), Some(&[8; 256][..])); + assert_eq!(state.permanent.as_ref().unwrap().auth_key.as_deref(), Some(&[6; 256][..])); + Ok(()) + }, |_, _, _| panic!("existing home binding must not renew")); + if mode == "ok" { + result.unwrap(); + assert!(retry.get()); + assert_eq!(client.main.lock().temp_expires_at, expires); + let owner = client.data.lock().home_session_id; + persist_updates_data(&client, owner).unwrap(); + let saved = FileSessionStore::new(&fixture.path).load().unwrap().unwrap(); + assert_eq!(saved.snapshot.auth_key.as_deref(), Some(&[6; 256][..])); + assert_eq!(saved.snapshot.dc_id, 4); + assert_eq!(saved.user_id, Some(7)); + } else { + assert!(result.is_err()); + assert!(!retry.get()); + assert_eq!(client.data.lock().home_snapshot, original); + assert_eq!(client.main.lock().snapshot.auth_key.as_deref(), Some(&[4; 256][..])); + } + } +} + +#[test] +fn secondary_read_lane_distinguishes_temporary_rejection_from_account_revocation() { + for revoked in [false, true] { + let fixture = Fixture::new(); + let client = fixture.temporary(crate::auth::bind_temp::unix_now() + 3600); + let owner = client.data.lock().home_session_id; + let _policy = crate::transfer_policy::bind(client.policy.clone()); + scheduler::set_main_session_allowance(Some(2)); + scheduler::set_bound_extra_sessions(1); + { + let mut io = client.rpc[0].io.lock(); + io.snapshot.auth_key = Some(vec![5; 256]); + io.perm_key = Some(vec![3; 256]); + io.temp_expires_at = crate::auth::bind_temp::unix_now() + 3600; + } + let result = with_read_lane(fixture.handle, |state| { + assert_eq!(state.snapshot.auth_key.as_deref(), Some(&[5; 256][..])); + call_with_migrate(state, |_| Err::<(), _>(MtprotoError::Message( + if revoked { "RPC 401: SESSION_REVOKED" } else { "RPC 401: AUTH_KEY_UNREGISTERED" }.into()))) + }); + let error = result.unwrap_err().to_string(); + if revoked { + assert!(error.contains("SESSION_REVOKED")); + assert!(client.data.lock().session_dead); + persist_updates_data(&client, owner).unwrap(); + let saved = FileSessionStore::new(&fixture.path).load().unwrap().unwrap(); + assert!(saved.session_dead); + assert_eq!(saved.snapshot.auth_key.as_deref(), Some(&[3; 256][..])); + } else { + assert_eq!(error, "connection closed: temporary authorization renewal required"); + assert_eq!(client.rpc[0].io.lock().temp_expires_at, 1); + assert!(!client.data.lock().session_dead); + assert!(is_authorized(fixture.handle).unwrap()); + } + } +} + +#[test] +fn missing_permanent_identity_cannot_publish_temporary_key() { + let fixture = Fixture::new(); + let client = get_client(fixture.handle).unwrap(); + let home_id = client.data.lock().home_session_id; + { + let mut main = client.main.lock(); + main.snapshot.auth_key = Some(vec![4; 256]); + main.temp_expires_at = 1; + main.perm_key = Some(vec![3; 256]); + } + assert!(persist_updates_data(&client, home_id).is_err()); + assert!(!fixture.path.exists()); +} + +#[test] +fn permanent_sequence_state_survives_initial_pfs_activation_and_all_persist_paths() { + let fixture = Fixture::new(); + with_client_mut(fixture.handle, |state| { + state.user_id = Some(7); + state.snapshot.auth_key = Some(vec![3; 256]); + state.snapshot.server_salt = 123; + state.snapshot.content_sequence = 11; + state.snapshot.last_message_id = 400; + state.snapshot.pending_acknowledgements.insert(1); + state.snapshot.received_message_ids.insert(1, true); + Ok(()) + }).unwrap(); + let client = get_client(fixture.handle).unwrap(); + let permanent = client.main.lock().snapshot.clone(); + { + let mut main = client.main.lock(); + crate::auth::bind_temp::ensure_main_temp_with(&client, &mut main, true, &crate::rpc::SystemClock, + |_, dc, offset| { + let mut snapshot = Snapshot::new(dc, &mut OsRandom).unwrap(); + snapshot.auth_key = Some(vec![4; 256]); + snapshot.time_offset_micros = offset; + snapshot.content_sequence = 88; + Ok(crate::auth::bind_temp::BoundTemp { snapshot, + expires_at: crate::auth::bind_temp::unix_now() + 3600 }) + }).unwrap(); + } + with_client_mut_binding(fixture.handle, |state| { + assert_eq!(state.snapshot.content_sequence, 88); + assert_eq!(state.permanent.as_ref(), Some(&permanent)); + persist(state) + }, |_, _, _| panic!("valid bound key must not renew")).unwrap(); + let direct = FileSessionStore::new(&fixture.path).load().unwrap().unwrap(); + assert_eq!(direct.snapshot, permanent); + persist_updates_data(&client, permanent.session_id).unwrap(); + let background = FileSessionStore::new(&fixture.path).load().unwrap().unwrap(); + assert_eq!(background.snapshot, permanent); +} + +#[test] +fn expired_temporary_key_never_replaces_durable_login_identity() { + for expires_at in [crate::auth::bind_temp::unix_now() + 3600, 1] { + let fixture = Fixture::new(); + let client = get_client(fixture.handle).unwrap(); + let permanent = vec![3; 256]; + let home_id; + { + let mut main = client.main.lock(); + let mut data = client.data.lock(); + data.user_id = Some(7); + data.home_auth_key = Some(permanent.clone()); + data.home_salt = 123; + data.home_snapshot = main.snapshot.clone(); + data.home_snapshot.auth_key = Some(permanent.clone()); + data.home_snapshot.server_salt = 123; + home_id = data.home_session_id; + let mut temporary = fork_session(&main.snapshot); + temporary.auth_key = Some(vec![4; 256]); + temporary.server_salt = 456; + temporary.content_sequence = 99; + temporary.last_message_id = 400; + temporary.pending_acknowledgements.insert(1); + temporary.received_message_ids.insert(1, true); + main.snapshot = temporary; + main.temp_expires_at = expires_at; + main.perm_key = Some(permanent.clone()); + } + assert!(persist_updates_data(&client, home_id).unwrap()); + let restored = crate::session_file::FileSessionStore::new(&fixture.path).load().unwrap().unwrap(); + assert_eq!(restored.snapshot.auth_key.as_deref(), Some(permanent.as_slice())); + assert_eq!(restored.snapshot.session_id, home_id); + assert_eq!(restored.snapshot.server_salt, 123); + assert_eq!(restored.snapshot.content_sequence, 0); + assert_eq!(restored.snapshot.last_message_id, 0); + assert!(restored.snapshot.pending_acknowledgements.is_empty()); + assert!(restored.snapshot.received_message_ids.is_empty()); + assert_eq!(restored.user_id, Some(7)); + let data = client.data.lock(); + assert_eq!(data.home_auth_key.as_deref(), Some(permanent.as_slice())); + assert_eq!(data.home_session_id, home_id); + } +} diff --git a/native/mtproto-rs/tests/unit/client_mgr_session_tests.rs b/native/mtproto-rs/tests/unit/client_mgr_session_tests.rs index 623c5f9fa..5e0109bcc 100644 --- a/native/mtproto-rs/tests/unit/client_mgr_session_tests.rs +++ b/native/mtproto-rs/tests/unit/client_mgr_session_tests.rs @@ -227,7 +227,19 @@ fn cached_connect_does_not_rewrite_session() { .unwrap(); let before = std::fs::read(&path).unwrap(); assert!(!before.is_empty()); - connect(handle).expect("cached connect"); + let mut issued = 0_u8; + connect_with_binding(handle, |permanent, dc, offset| { + assert!(permanent == [7; 256]); + issued += 1; + let mut snapshot = Snapshot::new(dc, &mut OsRandom).unwrap(); + snapshot.auth_key = Some(vec![8 + issued; 256]); + snapshot.time_offset_micros = offset; + Ok(crate::auth::bind_temp::BoundTemp { + snapshot, + expires_at: crate::auth::bind_temp::unix_now() + 3600, + }) + }).expect("cached connect"); + assert_eq!(issued, 1 + crate::scheduler::UPLOAD_LANES as u8); let after = std::fs::read(&path).unwrap(); assert_eq!( after, before, diff --git a/native/mtproto-rs/tests/unit/media_download_tests.rs b/native/mtproto-rs/tests/unit/media_download_tests.rs index 57074b52c..c8a2a94b5 100644 --- a/native/mtproto-rs/tests/unit/media_download_tests.rs +++ b/native/mtproto-rs/tests/unit/media_download_tests.rs @@ -1005,13 +1005,14 @@ fn one_mib_chunks_are_rejected() { fn overlapping_ranges_collapse_and_a_second_claim_waits() { let offsets = dedup_offsets(&[0, 100, 128 * 1024, 50, 128 * 1024], 128 * 1024); assert_eq!(offsets, vec![0, 128 * 1024]); - let first = claim_offsets(9, &[0, 128 * 1024]); + let file_id = i64::MIN + 9; + let first = claim_offsets(file_id, &[0, 128 * 1024]); assert_eq!(first, vec![0, 128 * 1024]); - assert!(claim_offsets(9, &[0]).is_empty()); - assert_eq!(claim_offsets(9, &[256 * 1024]), vec![256 * 1024]); - release_offsets(9, &[0, 128 * 1024, 256 * 1024]); - assert_eq!(claim_offsets(9, &[0]), vec![0]); - release_offsets(9, &[0]); + assert!(claim_offsets(file_id, &[0]).is_empty()); + assert_eq!(claim_offsets(file_id, &[256 * 1024]), vec![256 * 1024]); + release_offsets(file_id, &[0, 128 * 1024, 256 * 1024]); + assert_eq!(claim_offsets(file_id, &[0]), vec![0]); + release_offsets(file_id, &[0]); } #[test] diff --git a/native/mtproto-rs/tests/unit/rpc_control_tests.rs b/native/mtproto-rs/tests/unit/rpc_control_tests.rs new file mode 100644 index 000000000..61779480d --- /dev/null +++ b/native/mtproto-rs/tests/unit/rpc_control_tests.rs @@ -0,0 +1,27 @@ +use super::*; + +fn frame(payload: &[u8]) -> Vec { + PaddedIntermediate::default().encode_with_padding(payload, &[]).unwrap() +} + +#[test] +fn quick_ack_preserves_buffered_authenticated_frame_and_missing_key_is_not_revocation() { + let ack = [vec![0xff; 4], 7_u32.to_le_bytes().to_vec()].concat(); + let payload = vec![0xab; 32]; + let mut input = [frame(&ack), frame(&payload)].concat(); + assert_eq!(try_decode_complete(&mut PaddedIntermediate::default(), &mut input).unwrap(), Some(payload)); + assert!(input.is_empty()); + let mut missing = frame(&(-404_i32).to_le_bytes()); + let error = try_decode_complete(&mut PaddedIntermediate::default(), &mut missing).unwrap_err(); + assert_eq!(error.to_string(), "MTProto transport error -404"); + assert_ne!(crate::rpc::supervisor::failure_class(&error), crate::rpc::supervisor::FailureClass::Authorization); +} + +#[test] +fn quick_ack_flood_is_bounded() { + let ack = [vec![0xff; 4], 7_u32.to_le_bytes().to_vec()].concat(); + let mut input = frame(&ack).repeat(256); + let error = try_decode_complete(&mut PaddedIntermediate::default(), &mut input).unwrap_err(); + assert!(error.to_string().contains("control frame limit")); + assert!(input.is_empty()); +} diff --git a/native/mtproto-rs/tests/unit/scheduler_tests.rs b/native/mtproto-rs/tests/unit/scheduler_tests.rs index 664d7ed3c..248151807 100644 --- a/native/mtproto-rs/tests/unit/scheduler_tests.rs +++ b/native/mtproto-rs/tests/unit/scheduler_tests.rs @@ -1,6 +1,30 @@ use super::*; use std::sync::mpsc; +#[test] +fn lane_queue_capacity_is_bounded_and_cancelled_admission_releases_capacity() { + let gate = LaneGate::new(); + let held = gate.acquire(RequestClass::InteractiveRead).unwrap(); + let mut waiters: Vec<_> = (0..MAX_LANE_WAITERS) + .map(|_| gate.enqueue(RequestClass::BackgroundRead).unwrap()).collect(); + assert_eq!(gate.waiter_count(), MAX_LANE_WAITERS); + assert!(gate.enqueue(RequestClass::InteractiveWrite).is_err()); + assert!(gate.acquire(RequestClass::InteractiveWrite).is_err()); + assert_eq!(gate.waiter_count(), MAX_LANE_WAITERS); + drop(waiters.pop()); + let request = crate::request_control::create(); + let previous = crate::request_control::bind(request); + crate::request_control::cancel(request); + assert!(gate.acquire(RequestClass::InteractiveRead).is_err()); + crate::request_control::bind(previous); + crate::request_control::release(request); + assert_eq!(gate.waiter_count(), MAX_LANE_WAITERS - 1); + drop(waiters); + drop(held); + assert_eq!(gate.waiter_count(), 0); + assert!(gate.try_acquire(RequestClass::InteractiveRead).is_some()); +} + #[test] fn background_aging_never_overtakes_writes() { let waiter = Waiter { ticket: 0, priority: 4, queued_at: 0 }; @@ -29,12 +53,12 @@ impl SchedulerClock for ManualClock { fn injected_clock_ages_background_work_past_a_later_interactive_read_without_sleeping() { let clock = Arc::new(ManualClock(std::sync::atomic::AtomicU64::new(0))); let gate = LaneGate::with_clock(clock.clone()); - let background = gate.enqueue(RequestClass::BackgroundMedia); + let background = gate.enqueue(RequestClass::BackgroundMedia).unwrap(); clock.advance(4_999); - let read = gate.enqueue(RequestClass::InteractiveRead); + let read = gate.enqueue(RequestClass::InteractiveRead).unwrap(); assert!(background.try_acquire().is_none(), "a fresh interactive read still wins"); clock.advance(20_000); - let read_late = gate.enqueue(RequestClass::InteractiveRead); + let read_late = gate.enqueue(RequestClass::InteractiveRead).unwrap(); assert!(read_late.try_acquire().is_none(), "ticket order decides equal effective priority"); drop(read); let lease = background.try_acquire().expect("aged background request is next"); @@ -46,9 +70,9 @@ fn injected_clock_ages_background_work_past_a_later_interactive_read_without_sle fn aging_stops_below_interactive_write_priority() { let clock = Arc::new(ManualClock(std::sync::atomic::AtomicU64::new(0))); let gate = LaneGate::with_clock(clock.clone()); - let background = gate.enqueue(RequestClass::BackgroundMedia); + let background = gate.enqueue(RequestClass::BackgroundMedia).unwrap(); clock.advance(10 * 60_000); - let write = gate.enqueue(RequestClass::InteractiveWrite); + let write = gate.enqueue(RequestClass::InteractiveWrite).unwrap(); assert!(background.try_acquire().is_none()); assert!(write.try_acquire().is_some()); } @@ -56,14 +80,14 @@ fn aging_stops_below_interactive_write_priority() { #[test] fn promotion_raises_a_queued_ticket_and_never_lowers_it() { let gate = LaneGate::new(); - let read = gate.enqueue(RequestClass::InteractiveRead); - let media = gate.enqueue(RequestClass::BackgroundMedia); + let read = gate.enqueue(RequestClass::InteractiveRead).unwrap(); + let media = gate.enqueue(RequestClass::BackgroundMedia).unwrap(); media.promote(RequestClass::InteractiveWrite); assert!(read.try_acquire().is_none()); assert!(media.try_acquire().is_some()); let other = LaneGate::new(); - let first = other.enqueue(RequestClass::InteractiveRead); - let second = other.enqueue(RequestClass::InteractiveRead); + let first = other.enqueue(RequestClass::InteractiveRead).unwrap(); + let second = other.enqueue(RequestClass::InteractiveRead).unwrap(); second.promote(RequestClass::BackgroundMedia); assert!(first.try_acquire().is_some(), "promotion to a lower class changes nothing"); drop(second); @@ -75,8 +99,8 @@ fn a_freed_sibling_lane_is_taken_without_waiting_for_the_first_lane() { let second = LaneGate::new(); let busy = first.acquire(RequestClass::InteractiveRead).unwrap(); let waiters = [ - first.enqueue(RequestClass::InteractiveMedia), - second.enqueue(RequestClass::InteractiveMedia), + first.enqueue(RequestClass::InteractiveMedia).unwrap(), + second.enqueue(RequestClass::InteractiveMedia).unwrap(), ]; let acquired = waiters.iter().find_map(|waiter| waiter.try_acquire()); assert!(acquired.is_some(), "the free lane serves the request while another is busy"); @@ -88,8 +112,8 @@ fn cancelling_a_family_request_releases_every_reservation() { let first = LaneGate::new(); let second = LaneGate::new(); let waiters = [ - first.enqueue(RequestClass::BackgroundMedia), - second.enqueue(RequestClass::BackgroundMedia), + first.enqueue(RequestClass::BackgroundMedia).unwrap(), + second.enqueue(RequestClass::BackgroundMedia).unwrap(), ]; assert_eq!(first.waiter_count() + second.waiter_count(), 2); drop(waiters); @@ -100,7 +124,7 @@ fn cancelling_a_family_request_releases_every_reservation() { #[test] fn fast_admission_cannot_pass_an_equal_priority_waiter() { let gate = LaneGate::new(); - let waiter = gate.enqueue(RequestClass::InteractiveRead); + let waiter = gate.enqueue(RequestClass::InteractiveRead).unwrap(); assert!(gate.try_acquire(RequestClass::InteractiveRead).is_none()); let lease = waiter.try_acquire().expect("queued owner acquires the lane"); assert!(gate.try_acquire(RequestClass::InteractiveRead).is_none()); @@ -170,17 +194,11 @@ fn lane_orders_cover_every_lane_exactly_once() { #[test] fn unknown_allowance_keeps_a_single_main_session() { - let _serial = ALLOWANCE_TEST_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - set_bound_extra_sessions(0); - let original = MAIN_SESSION_ALLOWANCE.load(std::sync::atomic::Ordering::Relaxed); - MAIN_SESSION_ALLOWANCE.store(-1, std::sync::atomic::Ordering::Relaxed); + let _policy = crate::transfer_policy::bind(std::sync::Arc::new(crate::transfer_policy::TransferPolicy::stock())); assert!(!main_session_allowance_known()); assert_eq!(main_session_allowance(), 1); assert_eq!(extra_main_sessions(), 0); assert_eq!(read_lanes(), 0); - MAIN_SESSION_ALLOWANCE.store(original, std::sync::atomic::Ordering::Relaxed); } #[test] @@ -188,8 +206,8 @@ fn tmp_sessions_bounds_extra_main_sessions() { let _serial = ALLOWANCE_TEST_LOCK .lock() .unwrap_or_else(|e| e.into_inner()); + let _policy = crate::transfer_policy::bind(std::sync::Arc::new(crate::transfer_policy::TransferPolicy::stock())); set_bound_extra_sessions(0); - let original = MAIN_SESSION_ALLOWANCE.load(std::sync::atomic::Ordering::Relaxed); // Absent `tmp_sessions` means one main session: no parallel home-DC lanes. assert_eq!(set_main_session_allowance(None), 1); assert!(main_session_allowance_known()); @@ -210,7 +228,6 @@ fn tmp_sessions_bounds_extra_main_sessions() { assert_eq!(extra_main_sessions(), 2); set_bound_extra_sessions(0); assert_eq!(extra_main_sessions(), 0); - MAIN_SESSION_ALLOWANCE.store(original, std::sync::atomic::Ordering::Relaxed); } #[test] diff --git a/native/mtproto-rs/tests/unit/tcp_tests.rs b/native/mtproto-rs/tests/unit/tcp_tests.rs index 11dbf8b18..8f9274ae7 100644 --- a/native/mtproto-rs/tests/unit/tcp_tests.rs +++ b/native/mtproto-rs/tests/unit/tcp_tests.rs @@ -2,6 +2,65 @@ use super::*; use std::io::Read; use std::net::TcpListener; +#[test] +fn malformed_http_transport_returns_without_spinning_until_the_io_deadline() { + use std::io::Write; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let peer = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + socket.read_exact(&mut byte).unwrap(); + head.push(byte[0]); + assert!(head.len() < 16 * 1024); + } + socket.read_exact(&mut [0; 4]).unwrap(); + socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: invalid\r\n\r\n").unwrap(); + }); + let control = Arc::new(ConnectionControl { + config: monogram_mtproto_transport::TransportConfig { + mode: monogram_mtproto_transport::TransportMode::Http, + ..Default::default() + }, + ..Default::default() + }); + with_connection_control(&control, || { + let mut connection = connect_obfuscated_timeout(&address, 3).unwrap(); + connection.send(&[1, 2, 3, 4]).unwrap(); + let started = std::time::Instant::now(); + let error = connection.receive(&mut [0; 64]).unwrap_err(); + assert!(error.to_string().to_ascii_lowercase().contains("http")); + assert!(started.elapsed() < Duration::from_secs(2)); + }); + peer.join().unwrap(); +} + +#[test] +fn waitable_io_distinguishes_transient_errors_from_terminal_failures() { + for transient in ["operation timed out", "os error 10060", "Interrupted system call (os error 4)", "resource temporarily unavailable"] { + assert!(is_waitable_io(transient)); + } + for terminal in ["invalid HTTP content length", "connection reset by peer", "client closed", "MTProto transport error -404"] { + assert!(!is_waitable_io(terminal)); + } +} + +#[test] +fn shutdown_stops_io_and_publication_before_the_final_save() { + let control = ConnectionControl::default(); + control.begin_shutdown(); + assert!(control.wait(Duration::ZERO).is_err()); + assert!(control.while_open(|| panic!("publication during shutdown")).is_err()); + let saved = std::cell::Cell::new(false); + control.while_persisting(|| saved.set(true)).unwrap(); + assert!(saved.get()); + control.close(); + assert!(control.while_persisting(|| panic!("save after close")).is_err()); +} + #[test] fn persistence_does_not_block_io_and_close_joins_active_save() { let control = Arc::new(ConnectionControl::default()); diff --git a/native/mtproto-transport/src/tcp.rs b/native/mtproto-transport/src/tcp.rs index 49629bcb9..02b4a3b90 100644 --- a/native/mtproto-transport/src/tcp.rs +++ b/native/mtproto-transport/src/tcp.rs @@ -12,6 +12,7 @@ static CONFIG: LazyLock> = #[derive(Default)] pub struct ControlState { pub closed: bool, + pub closing: bool, pub streams: Vec>, } @@ -38,15 +39,23 @@ impl ConnectionControl { self.state.lock().streams.clear(); } pub fn while_open(&self, operation: impl FnOnce() -> T) -> Result { + self.while_active(false, operation) + } + pub fn while_persisting(&self, operation: impl FnOnce() -> T) -> Result { + self.while_active(true, operation) + } + fn while_active(&self, finalizing: bool, operation: impl FnOnce() -> T) -> Result { let _commit = self.persistence.lock(); - if self.state.lock().closed { + let state = self.state.lock(); + if state.closed || (state.closing && !finalizing) { return Err(closed_error()); } + drop(state); Ok(operation()) } - pub fn close(&self) { + pub fn begin_shutdown(&self) { let mut state = self.state.lock(); - state.closed = true; + state.closing = true; for stream in state .streams .drain(..) @@ -55,12 +64,15 @@ impl ConnectionControl { let _ = stream.shutdown(std::net::Shutdown::Both); } self.wake.notify_all(); - drop(state); + } + pub fn close(&self) { + self.begin_shutdown(); + self.state.lock().closed = true; let _commit = self.persistence.lock(); } pub fn register(&self, stream: &Arc) -> Result<(), TransportError> { let mut state = self.state.lock(); - if state.closed { + if state.closed || state.closing { let _ = stream.shutdown(std::net::Shutdown::Both); return Err(closed_error()); } @@ -74,7 +86,7 @@ impl ConnectionControl { pub fn wait(&self, delay: Duration) -> Result<(), TransportError> { let deadline = std::time::Instant::now() + delay; let mut state = self.state.lock(); - while !state.closed { + while !state.closed && !state.closing { let remaining = deadline.saturating_duration_since(std::time::Instant::now()); if remaining.is_zero() { return Ok(()); diff --git a/network/bridge/src/test/java/org/monogram/network/bridge/session/SessionCoreOwnershipTest.kt b/network/bridge/src/test/java/org/monogram/network/bridge/session/SessionCoreOwnershipTest.kt index 86ea4df7c..8226b9ff0 100644 --- a/network/bridge/src/test/java/org/monogram/network/bridge/session/SessionCoreOwnershipTest.kt +++ b/network/bridge/src/test/java/org/monogram/network/bridge/session/SessionCoreOwnershipTest.kt @@ -16,6 +16,17 @@ import org.monogram.mtproto.MtprotoNative @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class SessionCoreOwnershipTest { + @Test + fun temporaryKeyRecoveryDoesNotRequireAccountReauthorization() { + val temporary = org.monogram.core.common.telegram.TelegramError.parse( + "connection closed: temporary authorization renewal required", + ) + assertEquals(org.monogram.core.common.telegram.TelegramError.Kind.Network, temporary.kind) + assertFalse(temporary.requiresReauth) + val revoked = org.monogram.core.common.telegram.TelegramError.parse("RPC 401: SESSION_REVOKED") + assertTrue(revoked.requiresReauth) + } + @Test fun cancellingOneReaderKeepsTheProducerForTheRemainingReader() = runTest { val core = SessionCore( From 9e2993f665c2542ac75aa135dca219199309c21c Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:15:15 +0300 Subject: [PATCH 2/3] build(mtproto): bump tellers-mtproto-impl to 4df6d69 --- vendor/telers-mtproto-impl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/telers-mtproto-impl b/vendor/telers-mtproto-impl index 1026e9883..4df6d699f 160000 --- a/vendor/telers-mtproto-impl +++ b/vendor/telers-mtproto-impl @@ -1 +1 @@ -Subproject commit 1026e9883c0692f0d127d0826439b44f462afa63 +Subproject commit 4df6d699fa1e606b24d005e13bcdb8d0d34a770f From 643669169f703005da55ca961836203c10392dda Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:18:46 +0300 Subject: [PATCH 3/3] feat(chats): speedup batch load, refactor scroll controller --- feature/chats/build.gradle.kts | 1 + .../feature/chats/BridgeCatchupDeviceTest.kt | 188 ++ .../feature/chats/CatchupLoadingDeviceTest.kt | 103 ++ .../monogram/feature/chats/ChatsExecutor.kt | 42 +- .../feature/chats/ChatsStoreFactory.kt | 2 + .../feature/chats/ChatsStoreCacheTest.kt | 98 ++ feature/dialog/build.gradle.kts | 1 + .../dialog/HistoryLoadingDeviceTest.kt | 191 ++ .../feature/dialog/DialogNavigation.kt | 21 +- .../monogram/feature/dialog/DialogStore.kt | 1 + .../feature/dialog/store/DialogExecutor.kt | 13 +- .../feature/dialog/store/DialogHistory.kt | 24 +- .../feature/dialog/store/DialogReducer.kt | 6 +- .../org/monogram/feature/dialog/store/Msg.kt | 6 +- .../feature/dialog/ui/DialogContent.kt | 144 +- .../feature/dialog/ui/DialogHistoryPane.kt | 82 +- .../feature/dialog/ui/DialogScreen.kt | 4 +- .../feature/dialog/DialogForumStoreTest.kt | 236 ++- .../feature/dialog/DialogNavigationTest.kt | 31 + .../monogram_mtproto/monogram_mtproto.kt | 1549 +++++++++-------- .../network/bridge/session/SessionCore.kt | 18 +- .../network/bridge/updates/UpdatesPump.kt | 46 +- .../bridge/BridgedMtprotoClientHistoryTest.kt | 6 +- .../BridgedMtprotoClientLifecycleTest.kt | 306 ++++ 24 files changed, 2141 insertions(+), 978 deletions(-) create mode 100644 feature/chats/src/androidTest/java/org/monogram/feature/chats/BridgeCatchupDeviceTest.kt create mode 100644 feature/chats/src/androidTest/java/org/monogram/feature/chats/CatchupLoadingDeviceTest.kt create mode 100644 feature/dialog/src/androidTest/java/org/monogram/feature/dialog/HistoryLoadingDeviceTest.kt diff --git a/feature/chats/build.gradle.kts b/feature/chats/build.gradle.kts index c924da59b..1e1d1efcc 100644 --- a/feature/chats/build.gradle.kts +++ b/feature/chats/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { testImplementation(platform(libs.androidx.compose.bom)) testImplementation(libs.androidx.compose.runtime) debugImplementation(libs.androidx.compose.ui.test.manifest) + androidTestImplementation(project(":native:mtproto")) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.activity.compose) androidTestImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/feature/chats/src/androidTest/java/org/monogram/feature/chats/BridgeCatchupDeviceTest.kt b/feature/chats/src/androidTest/java/org/monogram/feature/chats/BridgeCatchupDeviceTest.kt new file mode 100644 index 000000000..78c4826be --- /dev/null +++ b/feature/chats/src/androidTest/java/org/monogram/feature/chats/BridgeCatchupDeviceTest.kt @@ -0,0 +1,188 @@ +package org.monogram.feature.chats + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.arkivanov.decompose.DefaultComponentContext +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.arkivanov.essenty.lifecycle.destroy +import com.arkivanov.essenty.lifecycle.resume +import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.monogram.core.common.TelegramCredentials +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.models.Chat +import org.monogram.core.models.PeerId +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.feature.chats.ui.ChatsContent +import org.monogram.mtproto.MtprotoNative +import org.monogram.network.bridge.BridgedMtprotoClient +import uniffi.monogram_mtproto.* +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +class BridgeCatchupDeviceTest { + @get:Rule + val compose = createAndroidComposeRule() + + private class Native : MtprotoNative by MtprotoNative.Stub { + val connects = AtomicInteger() + val failNext = AtomicBoolean() + val nextId = AtomicInteger(1) + val acknowledged = AtomicInteger() + private val pending = mutableListOf() + override fun createClient(apiId: Int, apiHash: String, sessionPath: String) = 1L + override fun connect(handle: Long) { + connects.incrementAndGet() + } + + override fun isAuthorized(handle: Long) = true + override fun startUpdates(handle: Long) = Unit + override fun getChats(handle: Long) = listOf(chat()) + @Synchronized + fun armGap() { + nextId.set(100); failNext.set(true) + } + + @Synchronized + override fun pendingUpdateBatches(handle: Long) = pending.toList() + @Synchronized + override fun acknowledgeUpdateBatch(handle: Long, accountGeneration: Long, batchId: Long) { + check(pending.removeAll { it.batchId == batchId }) + acknowledged.incrementAndGet() + } + + @Synchronized + override fun drainUpdateBatches(handle: Long): UpdateDrainDto { + if (failNext.getAndSet(false)) throw MtprotoException.Message("connection closed") + val id = nextId.getAndIncrement() + if (id > 2 && id != 100) return UpdateDrainDto(emptyList(), emptyList(), false) + val batch = UpdateBatchDto( + 7, + id.toLong(), + listOf(UpdateEventDto.ReadInbox(600001, id, 0)), + UpdatesStateDto(id, 0, id, id), + emptyList() + ) + pending += batch + return UpdateDrainDto(listOf(batch), listOf(UpdateEventDto.SyncState(id == 1)), false) + } + + companion object { + fun chat() = ChatDto( + id = 600001, + title = "Synthetic bridge startup", + isChannel = false, + isGroup = false, + isForum = false, + left = false, + unreadCount = 0, + lastMessagePreview = "Synthetic preview", + lastMessageDate = 1, + archived = false, + muted = false, + isContact = false, + isBot = false, + isVerified = false, + photoCacheKey = null, + pinned = false, + readInboxMaxId = 0, + readOutboxMaxId = 0, + peerStatus = null, + peerStatusAt = null, + lastMediaThumbCacheKey = null, + lastMessageId = 1, + canView = true, + canSendPlain = true, + canSendPhotos = true, + canForward = true, + canDeleteOthers = false, + emojiStatusDocumentId = null, + lastMessageOutgoing = false, + muteOverride = false, + unreadMark = false, + unreadMentionsCount = 0, + unreadReactionsCount = 0, + canManageTopics = false, + ) + } + } + + @Test + fun coldStartupAndReconnectExerciseProductionBridge() = scenario(false) + @Test + fun warmStartupAndReconnectExerciseProductionBridge() = scenario(true) + + private fun scenario(warm: Boolean) { + val baselineTrace = androidx.test.platform.app.InstrumentationRegistry.getArguments() + .getString("baselineTrace") == "true" + val native = Native() + val permit = AtomicBoolean(false) + val attempts = AtomicInteger() + val lifecycle = LifecycleRegistry() + val client = BridgedMtprotoClient( + TelegramCredentials(1, "synthetic"), + "unused-synthetic.session", + native, + refreshDcSidecar = {}) + client.installDurableUpdateSink { batch -> + check(batch.accountGeneration == 7L && batch.cursor?.pts == batch.batchId.toInt()) + if (batch.batchId == 1L) true else { + attempts.incrementAndGet(); permit.get() + } + } + val cache = object : OfflineWarmup() { + override suspend fun chatsWindow(limit: Int, archiveLimit: Int) = + if (warm) listOf(Chat(PeerId(600001), "Synthetic bridge startup")) else emptyList() + + override suspend fun mainListCount() = if (warm) 1 else 0 + } + lateinit var component: ChatsComponent + compose.runOnUiThread { + lifecycle.resume() + component = ChatsComponent( + DefaultComponentContext(lifecycle), + DefaultStoreFactory(), + client, + cache, + null, + onOpenChat = { _, _, _ -> }) + } + compose.setContent { MonogramTheme(dynamicColor = false) { ChatsContent(component) } } + try { + compose.waitUntil(15000) { attempts.get() >= 2 && !component.state.value.loading } + compose.onNodeWithText("Synthetic bridge startup").assertIsDisplayed() + android.util.Log.i( + "SyntheticBridgeTrace", + "phase=startupBlocked warm=$warm baseline=$baselineTrace sync=${component.state.value.syncing} acks=${native.acknowledged.get()} rows=${component.state.value.chats.size}" + ) + if (!baselineTrace) compose.onNodeWithText("Updating").assertIsDisplayed() + assertEquals(1, native.acknowledged.get()) + permit.set(true) + compose.waitUntil(10000) { !component.state.value.syncing && native.acknowledged.get() == 2 } + permit.set(false) + val prior = attempts.get() + native.armGap() + compose.waitUntil(15000) { native.connects.get() >= 2 && attempts.get() > prior } + android.util.Log.i( + "SyntheticBridgeTrace", + "phase=reconnectBlocked warm=$warm baseline=$baselineTrace sync=${component.state.value.syncing} acks=${native.acknowledged.get()} rows=${component.state.value.chats.size}" + ) + if (!baselineTrace) compose.onNodeWithText("Updating").assertIsDisplayed() + assertEquals(2, native.acknowledged.get()) + permit.set(true) + compose.waitUntil(10000) { !component.state.value.syncing && native.acknowledged.get() == 3 } + compose.onNodeWithText("Synthetic bridge startup").assertIsDisplayed() + android.util.Log.i( + "SyntheticBridgeTrace", + "warm=$warm connects=${native.connects.get()} commits=${native.acknowledged.get()} failedAttempts=${attempts.get()} finalSync=${component.state.value.syncing}" + ) + } finally { + permit.set(true) + compose.runOnUiThread { lifecycle.destroy(); client.close() } + } + } +} diff --git a/feature/chats/src/androidTest/java/org/monogram/feature/chats/CatchupLoadingDeviceTest.kt b/feature/chats/src/androidTest/java/org/monogram/feature/chats/CatchupLoadingDeviceTest.kt new file mode 100644 index 000000000..b730ad903 --- /dev/null +++ b/feature/chats/src/androidTest/java/org/monogram/feature/chats/CatchupLoadingDeviceTest.kt @@ -0,0 +1,103 @@ +package org.monogram.feature.chats + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.arkivanov.decompose.DefaultComponentContext +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.arkivanov.essenty.lifecycle.destroy +import com.arkivanov.essenty.lifecycle.resume +import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import org.junit.Rule +import org.junit.Test +import org.monogram.core.common.Outcome +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.models.Chat +import org.monogram.core.models.NotifySettings +import org.monogram.core.models.PeerId +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.feature.chats.ui.ChatsContent +import org.monogram.network.bridge.MtprotoClient +import org.monogram.network.bridge.MtprotoUpdate +import org.monogram.network.bridge.session.ProxyConnectionStatus +import java.lang.reflect.Proxy +import kotlin.coroutines.Continuation +import kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED +import kotlin.coroutines.resume + +class CatchupLoadingDeviceTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun cachedStartupAndReconnectKeepUpdatingUntilRecoveryCompletes() { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + val lifecycle = LifecycleRegistry() + val events = MutableSharedFlow(replay = 1) + events.tryEmit(MtprotoUpdate.SyncState(true)) + val row = + Chat(PeerId(600001), "Synthetic startup chat", lastMessageDate = 1, lastMessageId = 1) + val cache = object : OfflineWarmup() { + override suspend fun chatsWindow(limit: Int, archiveLimit: Int) = listOf(row) + override suspend fun mainListCount() = 1 + } + + @Suppress("UNCHECKED_CAST") + val client = Proxy.newProxyInstance( + MtprotoClient::class.java.classLoader, + arrayOf(MtprotoClient::class.java) + ) { proxy, method, args -> + when (method.name) { + "equals" -> proxy === args?.firstOrNull() + "hashCode" -> System.identityHashCode(proxy) + "toString", "libraryVersion" -> "SyntheticCatchupClient" + "updates" -> events + "sessionLost" -> emptyFlow() + "proxyConnectionStatus" -> ProxyConnectionStatus.Disabled + "getChats" -> { + val continuation = args!!.last() as Continuation + scope.launch { delay(300); continuation.resume(Outcome.Ok(listOf(row))) } + COROUTINE_SUSPENDED + } + + "loadMoreChats", "loadMoreFolderChats", "getFolders" -> Outcome.Ok(emptyList()) + "getNotifySettings" -> Outcome.Ok(NotifySettings()) + "close" -> Unit + else -> Outcome.Err("unsupported fixture method") + } + } as MtprotoClient + lateinit var component: ChatsComponent + compose.runOnUiThread { + lifecycle.resume() + component = ChatsComponent( + DefaultComponentContext(lifecycle), DefaultStoreFactory(), client, cache, + sessionStore = null, onOpenChat = { _, _, _ -> }) + } + compose.setContent { MonogramTheme(dynamicColor = false) { ChatsContent(component) } } + try { + compose.onNodeWithText("Synthetic startup chat").assertIsDisplayed() + compose.waitUntil(15000) { !component.state.value.loading && !component.state.value.fromCache } + compose.onNodeWithText("Updating").assertIsDisplayed() + compose.runOnUiThread { events.tryEmit(MtprotoUpdate.SyncState(false)) } + compose.waitUntil(10000) { !component.state.value.syncing } + compose.onNodeWithText("Updating").assertDoesNotExist() + compose.runOnUiThread { events.tryEmit(MtprotoUpdate.SyncState(true)); component.onRefresh() } + compose.waitUntil(10000) { component.state.value.syncing && !component.state.value.loading } + compose.onNodeWithText("Updating").assertIsDisplayed() + compose.runOnUiThread { events.tryEmit(MtprotoUpdate.SyncState(false)) } + compose.waitUntil(10000) { !component.state.value.syncing } + compose.onNodeWithText("Synthetic startup chat").assertIsDisplayed() + } finally { + compose.runOnUiThread { lifecycle.destroy(); scope.cancel() } + } + } +} diff --git a/feature/chats/src/main/java/org/monogram/feature/chats/ChatsExecutor.kt b/feature/chats/src/main/java/org/monogram/feature/chats/ChatsExecutor.kt index 2a05f3932..658286c9b 100644 --- a/feature/chats/src/main/java/org/monogram/feature/chats/ChatsExecutor.kt +++ b/feature/chats/src/main/java/org/monogram/feature/chats/ChatsExecutor.kt @@ -55,10 +55,23 @@ internal class ChatsExecutor( private val notifications: NotificationLocalStore?, private val refreshMergeHook: (suspend () -> Unit)? = null, private val readStateHook: (suspend () -> Unit)? = null, + private val ioContext: kotlin.coroutines.CoroutineContext = Dispatchers.IO, ) : CoroutineExecutor() { private val refreshInFlight = AtomicBoolean(false) private val listPublicationMutex = Mutex() private var lastNetworkPage: List = emptyList() + private var refreshSyncing = false + private var protocolSyncing = false + + private fun publishSyncing() { + val syncing = refreshSyncing || protocolSyncing + if (state().syncing != syncing) dispatch(Msg.Syncing(syncing)) + } + + private fun setRefreshSyncing(value: Boolean) { + refreshSyncing = value + publishSyncing() + } private var mainNetworkHasMore = true private val failedOffsetPeers = mutableSetOf() @@ -127,6 +140,11 @@ internal class ChatsExecutor( client.updates() .onEach { update -> when (update) { + is MtprotoUpdate.SyncState -> { + protocolSyncing = update.isSyncing + publishSyncing() + } + is MtprotoUpdate.ChatsChanged -> { if (update.chats.isNotEmpty()) { val mapped = withEffectiveMutes( @@ -143,7 +161,7 @@ internal class ChatsExecutor( val listed = allListed() refreshMergeHook?.invoke() val rebuilt = if (listed.size > 64) { - withContext(Dispatchers.IO) { mergeChats(listed, mapped) } + withContext(ioContext) { mergeChats(listed, mapped) } } else { mergeChats(listed, mapped) } @@ -555,7 +573,7 @@ internal class ChatsExecutor( if (message.outgoing) return message if (!message.senderName.isNullOrBlank()) return message val senderId = message.senderId?.value ?: return message - val title = withContext(Dispatchers.IO) { + val title = withContext(ioContext) { sessionStore?.readProfile(senderId)?.title?.trim().orEmpty() } return if (title.isEmpty()) message else message.copy(senderName = title) @@ -604,12 +622,12 @@ internal class ChatsExecutor( val muted = muteChats(cached) val (first, tail) = if (!hasMemory) { if (muted.size > 64) { - withContext(Dispatchers.IO) { paintDialogsWindow(muted) } + withContext(ioContext) { paintDialogsWindow(muted) } } else { paintDialogsWindow(muted) } } else if (muted.size > 64) { - withContext(Dispatchers.IO) { sortChats(muted) } to emptyList() + withContext(ioContext) { sortChats(muted) } to emptyList() } else { sortChats(muted) to emptyList() } @@ -621,7 +639,7 @@ internal class ChatsExecutor( ), ) dispatch(Msg.Loading(false)) - dispatch(Msg.Syncing(true)) + setRefreshSyncing(true) cachedTail = tail recoverMissingTitles() if (cachedTail.none { it.isMainListRow() }) { @@ -652,7 +670,7 @@ internal class ChatsExecutor( } else if (!hasMemory && cached.isEmpty() && !state().loading) { dispatch(Msg.Loading(true)) } else if (hasMemory || cached.isNotEmpty()) { - dispatch(Msg.Syncing(true)) + setRefreshSyncing(true) } // The first network page must already carry inherited mutes; a failure here is // retried on the next refresh so a slow RPC never blocks the list. @@ -689,7 +707,7 @@ internal class ChatsExecutor( val source = allListed() refreshMergeHook?.invoke() val merged = if (source.size > 64) { - withContext(Dispatchers.IO) { + withContext(ioContext) { withNetworkPinOrder(mergeChats(source, mapped), mapped) } } else { @@ -746,7 +764,7 @@ internal class ChatsExecutor( } } if (state().loading) dispatch(Msg.Loading(false)) - if (state().syncing) dispatch(Msg.Syncing(false)) + setRefreshSyncing(false) catalogSeed?.let { seed -> refreshInFlight.set(false) if (generation == catalogGeneration) { @@ -758,7 +776,7 @@ internal class ChatsExecutor( throw error } catch (_: Throwable) { if (state().loading) dispatch(Msg.Loading(false)) - if (state().syncing) dispatch(Msg.Syncing(false)) + setRefreshSyncing(false) } finally { refreshInFlight.set(false) } @@ -1198,7 +1216,7 @@ internal class ChatsExecutor( ) { val archiveLimit = archiveKeep(merged) val (first, tail) = if (merged.size > 64) { - withContext(Dispatchers.IO) { paintDialogsWindow(merged, keep, archiveLimit) } + withContext(ioContext) { paintDialogsWindow(merged, keep, archiveLimit) } } else { paintDialogsWindow(merged, keep, archiveLimit) } @@ -1313,7 +1331,7 @@ internal class ChatsExecutor( } persistHidden?.let { warmup?.upsertChats(listOf(it)) } if (published) return@launch - val stored = withContext(Dispatchers.IO) { + val stored = withContext(ioContext) { warmup?.chat(message.id.chatId) } ?: return@launch if (!stored.isMainListRow()) { @@ -1426,7 +1444,7 @@ internal class ChatsExecutor( withEffectiveMutes(chats, notifyDefaults, notifyDefaultsLoaded) } return if (chats.size > 64) { - withContext(Dispatchers.IO) { apply() } + withContext(ioContext) { apply() } } else { apply() } diff --git a/feature/chats/src/main/java/org/monogram/feature/chats/ChatsStoreFactory.kt b/feature/chats/src/main/java/org/monogram/feature/chats/ChatsStoreFactory.kt index bb6627e27..be058e409 100644 --- a/feature/chats/src/main/java/org/monogram/feature/chats/ChatsStoreFactory.kt +++ b/feature/chats/src/main/java/org/monogram/feature/chats/ChatsStoreFactory.kt @@ -23,6 +23,7 @@ internal class ChatsStoreFactory( private val notifications: NotificationLocalStore? = null, private val refreshMergeHook: (suspend () -> Unit)? = null, private val readStateHook: (suspend () -> Unit)? = null, + private val ioContext: kotlin.coroutines.CoroutineContext = kotlinx.coroutines.Dispatchers.IO, ) { fun create(): ChatsStore = object : @@ -47,6 +48,7 @@ internal class ChatsStoreFactory( notifications, refreshMergeHook, readStateHook, + ioContext, ) }, reducer = ChatsReducer, diff --git a/feature/chats/src/test/java/org/monogram/feature/chats/ChatsStoreCacheTest.kt b/feature/chats/src/test/java/org/monogram/feature/chats/ChatsStoreCacheTest.kt index 1d165ffc7..ed6e67d48 100644 --- a/feature/chats/src/test/java/org/monogram/feature/chats/ChatsStoreCacheTest.kt +++ b/feature/chats/src/test/java/org/monogram/feature/chats/ChatsStoreCacheTest.kt @@ -46,6 +46,19 @@ import java.util.ArrayDeque @OptIn(ExperimentalCoroutinesApi::class) class ChatsStoreCacheTest { + private fun ChatsStoreFactory( + storeFactory: com.arkivanov.mvikotlin.core.store.StoreFactory, + client: MtprotoClient, + warmup: OfflineWarmup?, + sessionStore: org.monogram.core.database.SessionMetadataStore?, + notifications: org.monogram.core.common.push.NotificationLocalStore? = null, + refreshMergeHook: (suspend () -> Unit)? = null, + readStateHook: (suspend () -> Unit)? = null, + ) = ChatsStoreFactory( + storeFactory, client, warmup, sessionStore, notifications, refreshMergeHook, readStateHook, + ioContext = Dispatchers.Main.immediate, + ) + @Before fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) @@ -56,6 +69,91 @@ class ChatsStoreCacheTest { Dispatchers.resetMain() } + @Test + fun protocolCatchupKeepsUpdatingAfterDialogsRefreshCompletes() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val release = CompletableDeferred() + val cached = Chat(PeerId(1), "Synthetic", lastMessageDate = 5, lastMessageId = 1) + val warmup = object : OfflineWarmup() { + override suspend fun chatsWindow(limit: Int, archiveLimit: Int) = listOf(cached) + override suspend fun mainListCount() = 1 + } + val client = StubClient(chats = Outcome.Ok(listOf(cached))) + client.chatsGate = release + val store = + ChatsStoreFactory(DefaultStoreFactory(), client, warmup, sessionStore = null).create() + try { + runCurrent() + client.events.emit(MtprotoUpdate.SyncState(true)) + release.complete(Unit) + advanceUntilIdle() + assertFalse(store.state.loading) + assertEquals(cached.id, store.state.chats.single().id) + assertTrue( + "Unfinished protocol recovery must keep Updating visible", + store.state.syncing + ) + client.events.emit(MtprotoUpdate.SyncState(false)) + runCurrent() + assertFalse(store.state.syncing) + } finally { + release.complete(Unit) + store.dispose() + } + } + + @Test + fun reconnectCatchupUpdatesAlreadyLoadedChats() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val client = StubClient(chats = Outcome.Ok(emptyList())) + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + try { + advanceUntilIdle() + assertFalse(store.state.syncing) + client.events.emit(MtprotoUpdate.SyncState(true)) + runCurrent() + assertTrue(store.state.syncing) + client.events.emit(MtprotoUpdate.SyncState(false)) + runCurrent() + assertFalse(store.state.syncing) + } finally { + store.dispose() + } + } + + @Test + fun protocolCompletionDoesNotClearAnOutstandingDialogsRefresh() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val release = CompletableDeferred() + val cached = Chat(PeerId(1), "Synthetic", lastMessageDate = 5, lastMessageId = 1) + val warmup = object : OfflineWarmup() { + override suspend fun chatsWindow(limit: Int, archiveLimit: Int) = listOf(cached) + override suspend fun mainListCount() = 1 + } + val client = StubClient(chats = Outcome.Ok(listOf(cached))) + client.chatsGate = release + val store = + ChatsStoreFactory(DefaultStoreFactory(), client, warmup, sessionStore = null).create() + try { + runCurrent() + client.events.emit(MtprotoUpdate.SyncState(true)) + client.events.emit(MtprotoUpdate.SyncState(false)) + runCurrent() + assertTrue(store.state.syncing) + release.complete(Unit) + advanceUntilIdle() + assertFalse(store.state.syncing) + } finally { + release.complete(Unit) + store.dispose() + } + } + @Test fun cachePaintsBeforeDialogsRequest() = runTest { val release = CompletableDeferred() diff --git a/feature/dialog/build.gradle.kts b/feature/dialog/build.gradle.kts index 0646bc15c..a8e1f02cf 100644 --- a/feature/dialog/build.gradle.kts +++ b/feature/dialog/build.gradle.kts @@ -61,6 +61,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mvikotlin.main) + androidTestImplementation(libs.mvikotlin.main) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.junit) diff --git a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/HistoryLoadingDeviceTest.kt b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/HistoryLoadingDeviceTest.kt new file mode 100644 index 000000000..5e9ed8d87 --- /dev/null +++ b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/HistoryLoadingDeviceTest.kt @@ -0,0 +1,191 @@ +package org.monogram.feature.dialog + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.arkivanov.decompose.DefaultComponentContext +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.arkivanov.essenty.lifecycle.destroy +import com.arkivanov.essenty.lifecycle.resume +import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.monogram.core.common.Outcome +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.models.* +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.feature.dialog.ui.DialogContent +import org.monogram.network.bridge.MtprotoClient +import org.monogram.network.bridge.MtprotoUpdate +import java.lang.reflect.Proxy +import kotlin.coroutines.Continuation +import kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED +import kotlin.coroutines.resume + +class HistoryLoadingDeviceTest { + @get:Rule + val compose = createAndroidComposeRule() + private val peer = PeerId(500001) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val events = MutableSharedFlow(extraBufferCapacity = 32) + private var offline = false + private var historyCalls = 0 + private var olderCalls = 0 + private var newerCalls = 0 + private fun message(id: Int) = Message( + id = MessageId(peer, id), senderId = peer, text = "Fixture $id", + date = 1700000000L + id, outgoing = false, + ) + + @Suppress("UNCHECKED_CAST") + private fun client() = Proxy.newProxyInstance( + MtprotoClient::class.java.classLoader, arrayOf(MtprotoClient::class.java), + ) { proxy, method, args -> + when (method.name) { + "equals" -> proxy === args?.firstOrNull() + "hashCode" -> System.identityHashCode(proxy) + "toString", "libraryVersion" -> "SyntheticHistoryClient" + "updates" -> events + "sessionLost" -> emptyFlow() + "getHistory", "getHistoryPage", "getHistoryPageBackground" -> { + val limit = args!![1] as Int + check(limit <= 100) + val offset = if (method.name == "getHistory") 0 else args[2] as Int + val addOffset = if (method.name == "getHistory") 0 else args[4] as Int + if (method.name == "getHistory") historyCalls++ + else if (addOffset < 0) newerCalls++ else olderCalls++ + val rows = if (offset == 0) (240 downTo 1).take(limit) + else if (addOffset < 0) ((offset - addOffset - 1).coerceAtMost(240) downTo 1).take( + limit + ) + else ((offset - 1) downTo 1).take(limit) + val continuation = args.last() as Continuation + val failure = offline + scope.launch { + delay(180) + continuation.resume( + if (failure) Outcome.Err("offline") else Outcome.Ok( + rows.map( + ::message + ) + ) + ) + } + COROUTINE_SUSPENDED + } + + "getPinnedMessages", "getSavedGifs" -> Outcome.Ok(emptyList()) + "getProfile", "getNotifySettings", "getForumTopics", "getForumTopicsById" -> Outcome.Err( + "unsupported fixture metadata" + ) + + "setDialogForeground", "close", "setForeground" -> Unit + else -> if (method.returnType == Void.TYPE) Unit else Outcome.Err("unsupported fixture method") + } + } as MtprotoClient + + private fun scenario(cached: Boolean, unread: Boolean, body: (DialogComponent) -> Unit) { + val lifecycle = LifecycleRegistry() + val frames = mutableListOf() + val frameListener = android.view.Window.OnFrameMetricsAvailableListener { _, metrics, _ -> + frames += metrics.getMetric(android.view.FrameMetrics.TOTAL_DURATION) + } + lateinit var component: DialogComponent + val cache = object : OfflineWarmup() { + override suspend fun chat(chatId: PeerId) = Chat( + peer, "Synthetic conversation", lastMessageId = 240, + unreadCount = if (unread) 20 else 0, readInboxMaxId = if (unread) 220 else 240, + ) + + override suspend fun messages(chatId: PeerId, limit: Int) = + if (cached) (240 downTo 201).map(::message) else emptyList() + } + compose.runOnUiThread { + compose.activity.window.addOnFrameMetricsAvailableListener( + frameListener, + android.os.Handler(android.os.Looper.getMainLooper()) + ) + lifecycle.resume() + component = DialogComponent( + DefaultComponentContext(lifecycle), DefaultStoreFactory(), client(), cache, + sessionStore = null, mediaRepository = null, chatId = peer, isForum = false, + onBack = {}, + ) + } + compose.setContent { + MonogramTheme(dynamicColor = false) { + DialogContent( + component, + Modifier.fillMaxSize() + ) + } + } + try { + body(component) + } finally { + compose.runOnUiThread { + compose.activity.window.removeOnFrameMetricsAvailableListener(frameListener) + val sorted = frames.sorted() + val p95 = sorted.getOrNull(((sorted.size - 1) * 0.95).toInt()) ?: 0L + android.util.Log.i( + "SyntheticHistoryFrames", + "cached=$cached unread=$unread frames=${sorted.size} p95Ms=${p95 / 1000000.0}" + ) + android.util.Log.i( + "SyntheticHistoryTrace", + "cached=$cached unread=$unread history=$historyCalls older=$olderCalls newer=$newerCalls rows=${component.state.value.messages.size} loading=${component.state.value.loading} anchor=${component.state.value.anchorMessageId}" + ) + lifecycle.destroy() + scope.cancel() + } + } + } + + @Test + fun uncachedOpeningPagingAndIncomingMessagesRender() = scenario(false, false) { component -> + compose.waitUntil(15000) { !component.state.value.loading && component.state.value.messages.isNotEmpty() } + compose.onNodeWithText("Fixture 240", substring = true).assertIsDisplayed() + compose.runOnUiThread { events.tryEmit(MtprotoUpdate.NewMessage(message(241))) } + compose.waitUntil(10000) { component.state.value.messages.any { it.id.id == 241 } } + compose.onNodeWithText("Fixture 241", substring = true).assertIsDisplayed() + compose.runOnUiThread { component.onLoadOlder() } + compose.waitUntil(10000) { component.state.value.messages.size > 40 } + compose.onAllNodes(hasScrollAction()).onFirst().performTouchInput { swipeDown() } + compose.onAllNodes(hasScrollAction()).onFirst() + .performScrollToNode(hasText("Fixture 210", substring = true)) + compose.onNodeWithText("Fixture 210", substring = true).assertIsDisplayed() + compose.runOnUiThread { events.tryEmit(MtprotoUpdate.NewMessage(message(242))) } + compose.waitUntil(10000) { component.state.value.messages.any { it.id.id == 242 } } + compose.onNodeWithText("Fixture 210", substring = true).assertIsDisplayed() + assertEquals( + component.state.value.messages.size, + component.state.value.messages.map { it.id }.distinct().size + ) + compose.runOnUiThread { component.onJumpToMessage(50) } + compose.waitUntil(10000) { component.state.value.anchorMessageId == 50 } + compose.runOnUiThread { component.onLoadNewer() } + compose.waitUntil(10000) { newerCalls >= 2 } + compose.runOnUiThread { component.onLoadOlder() } + compose.waitUntil(10000) { olderCalls > 0 } + assertTrue(historyCalls >= 1) + } + + @Test + fun cachedUnreadOpeningSurvivesOfflineAndRetry() = scenario(true, true) { component -> + compose.waitUntil(15000) { component.state.value.messages.isNotEmpty() && !component.state.value.loading } + compose.waitUntil(10000) { component.state.value.anchorMessageId == 221 } + compose.onNodeWithText("Fixture 221", substring = true).assertIsDisplayed() + compose.runOnUiThread { offline = true; component.onRefresh() } + compose.waitUntil(10000) { historyCalls >= 2 && !component.state.value.loading } + assertTrue(component.state.value.messages.isNotEmpty()) + compose.runOnUiThread { offline = false; component.onRefresh() } + compose.waitUntil(10000) { historyCalls >= 3 && !component.state.value.loading } + assertTrue(component.state.value.messages.isNotEmpty()) + } +} diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogNavigation.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogNavigation.kt index d0dc830a3..d1a140e29 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogNavigation.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogNavigation.kt @@ -411,6 +411,9 @@ fun shouldPageOlder( return true } +fun isHistoryLiveEdge(firstVisibleIndex: Int, canScrollBackward: Boolean): Boolean = + firstVisibleIndex == 0 || !canScrollBackward + fun shouldPageNewer( firstVisibleIndex: Int, hasNewer: Boolean, @@ -608,16 +611,16 @@ fun messageRowIndex(messages: List, messageId: Int): Int { return (0..index).count { isAlbumHead(messages, it) } - 1 } -fun visibleAlbumMessageIds(messages: List, visibleRowIndices: Set): Set = - buildSet { - var row = 0 - messages.indices.forEach { index -> - if (isAlbumHead(messages, index)) { - if (row in visibleRowIndices) albumSlice(messages, index).forEach { add(it.id.id) } - row++ - } - } +fun visibleAlbumMessageIds( + messages: List, + visibleRowIndices: Set, + rowMessageIndices: List = messages.indices.filter { isAlbumHead(messages, it) }, +): Set = buildSet { + visibleRowIndices.forEach { row -> + val index = rowMessageIndices.getOrNull(row) ?: return@forEach + albumSlice(messages, index).forEach { add(it.id.id) } } +} private val ALBUM_VISUAL_KINDS = setOf("photo", "video", "gif") diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogStore.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogStore.kt index efc052437..0c688dd67 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogStore.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogStore.kt @@ -223,6 +223,7 @@ interface DialogStore : Store { val readOutboxMaxId: Int = 0, val photoCacheKey: String? = null, val anchorMessageId: Int? = null, + val anchorRequestId: String? = null, /** The anchor is the first unread row: show it at the top edge, like Telegram does. */ val anchorAtTop: Boolean = false, diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogExecutor.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogExecutor.kt index 08b8b65a5..3d6f1360b 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogExecutor.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogExecutor.kt @@ -1,6 +1,7 @@ package org.monogram.feature.dialog.store import com.arkivanov.mvikotlin.extensions.coroutines.CoroutineExecutor +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn @@ -781,8 +782,16 @@ internal class DialogExecutor( } else if (!hasMemory) { dispatch(Msg.Loading(true)) } - hydratePinned() - hydrateSenderTags() + launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { + try { + hydratePinned(gen) + hydrateSenderTags(gen) + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + AppLog.warn("dialog", "cached metadata unavailable") + } + } val slice = warmup?.historySlice(chatId, limit = HISTORY_FIRST_LIMIT) if (slice != null && slice.rows.isNotEmpty() && cached.isEmpty()) { publishPaintedHistory( diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogHistory.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogHistory.kt index 7420ab0cb..8974413c4 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogHistory.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogHistory.kt @@ -26,8 +26,8 @@ import org.monogram.feature.dialog.pinnedMetaKey import org.monogram.feature.dialog.unreadAnchorId import kotlin.time.Duration.Companion.milliseconds -internal suspend fun DialogExecutor.hydrateSenderTags() { - if (snapshot().senderTags.isNotEmpty()) return +internal suspend fun DialogExecutor.hydrateSenderTags(generation: Int = historyGen) { + if (generation != historyGen || snapshot().senderTags.isNotEmpty()) return val ram = org.monogram.feature.dialog.SenderTagMemory.get(chatId.value) if (ram.isNotEmpty()) { emit(Msg.SenderTags(ram)) @@ -36,13 +36,13 @@ internal suspend fun DialogExecutor.hydrateSenderTags() { val parsed = org.monogram.feature.dialog.parseSenderTags( sessionStore?.readMeta(org.monogram.feature.dialog.tagsMetaKey(chatId.value)), ) - if (parsed.isEmpty()) return + if (generation != historyGen || snapshot().senderTags.isNotEmpty() || parsed.isEmpty()) return org.monogram.feature.dialog.SenderTagMemory.put(chatId.value, parsed) emit(Msg.SenderTags(parsed)) } -internal suspend fun DialogExecutor.hydratePinned() { - if (snapshot().pinnedMessages.isNotEmpty()) return +internal suspend fun DialogExecutor.hydratePinned(generation: Int = historyGen) { + if (generation != historyGen || pinnedRequested || snapshot().pinnedMessages.isNotEmpty()) return val ids = parsePinnedIds(sessionStore?.readMeta(pinnedMetaKey(chatId.value))) if (ids.isEmpty()) return val messages = warmup?.let { cache -> @@ -52,7 +52,7 @@ internal suspend fun DialogExecutor.hydratePinned() { cache.messagesByIds(chatId, ids) } }.orEmpty() - if (messages.isEmpty()) return + if (generation != historyGen || pinnedRequested || snapshot().pinnedMessages.isNotEmpty() || messages.isEmpty()) return AppLog.api("dialog", "pinned cache chat=${chatId.value} count=${messages.size}") emit(Msg.Pinned(messages)) rememberPinned() @@ -239,16 +239,7 @@ internal suspend fun DialogExecutor.publishPaintedHistory( } if (generation != null && generation != historyGen) return cachedOlderTail = tail - val sameWindow = current.size == paint.size && - current.indices.all { index -> - val old = current[index] - val next = paint[index] - old.id.id == next.id.id && - old.text == next.text && - old.mediaCacheKey == next.mediaCacheKey && - old.mediaKind == next.mediaKind - } - if (!sameWindow) { + if (current != paint) { emit( Msg.Messages( paint, @@ -496,6 +487,7 @@ internal fun DialogExecutor.loadNewer() { } else { null } + if (!ownsRequest()) return@launch if (newerSlice != null && newerSlice.rows.isNotEmpty()) { emit(Msg.Prepend(newerSlice.rows)) } diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogReducer.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogReducer.kt index 09cbeadc7..7fb7cde25 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogReducer.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogReducer.kt @@ -213,7 +213,11 @@ internal object DialogReducer : Reducer { ) is Msg.Drop -> copy(messages = messages.filterNot { it.id.id == msg.messageId }) is Msg.PhotoCacheKey -> copy(photoCacheKey = msg.value) - is Msg.Anchor -> copy(anchorMessageId = msg.value, anchorAtTop = msg.atTop) + is Msg.Anchor -> copy( + anchorMessageId = msg.value, + anchorAtTop = msg.atTop, + anchorRequestId = msg.requestId, + ) is Msg.SavedGifs -> copy( savedGifs = msg.value, savedGifsLoaded = true, diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/Msg.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/Msg.kt index f44915ee8..4d15b5671 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/Msg.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/Msg.kt @@ -114,7 +114,11 @@ internal sealed interface Msg { data class MarkFailed(val pendingId: Int) : Msg data class Drop(val messageId: Int) : Msg data class PhotoCacheKey(val value: String?) : Msg - data class Anchor(val value: Int?, val atTop: Boolean = false) : Msg + data class Anchor( + val value: Int?, + val atTop: Boolean = false, + val requestId: String = java.util.UUID.randomUUID().toString(), + ) : Msg data class SavedGifs(val value: List, val error: Boolean = false) : Msg data class SavedGifsLoaded(val value: Boolean) : Msg data class GifPicker(val open: Boolean) : Msg diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogContent.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogContent.kt index 339f002c1..5e08b1158 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogContent.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogContent.kt @@ -1,163 +1,23 @@ package org.monogram.feature.dialog.ui -import android.content.pm.PackageManager -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowBack -import androidx.compose.material.icons.outlined.Close -import androidx.compose.material.icons.outlined.KeyboardArrowDown -import androidx.compose.material.icons.outlined.Search -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SmallFloatingActionButton -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.withFrameNanos -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.ClipEntry -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import androidx.core.content.ContextCompat -import com.arkivanov.decompose.extensions.compose.subscribeAsState -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import org.monogram.core.common.Outcome -import org.monogram.core.models.GeoPlace +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.monogram.core.models.Message import org.monogram.core.models.PeerId -import org.monogram.core.models.UploadItem -import org.monogram.core.models.displayedChatAction -import org.monogram.core.models.peerAvatarCacheKey -import org.monogram.core.ui.AppearanceSettings import org.monogram.core.ui.components.AppModalSheet -import org.monogram.core.ui.components.AppStatusBanner -import org.monogram.core.ui.components.AppSyncStatus -import org.monogram.core.ui.components.ChatWallpaper -import org.monogram.core.ui.components.MessageListSkeleton -import org.monogram.core.ui.components.OnlineLeaseExpiry -import org.monogram.core.ui.components.PeerAvatar -import org.monogram.core.ui.components.SearchField -import org.monogram.core.ui.components.TypingDots -import org.monogram.core.ui.components.UnreadBadge -import org.monogram.core.ui.components.listItemMotion -import org.monogram.core.ui.components.liveActionTransition -import org.monogram.core.ui.components.peerStatusLabel -import org.monogram.core.ui.components.rememberPeerStatusNow -import org.monogram.core.ui.components.typingStatusText -import org.monogram.core.ui.components.uiLabel -import org.monogram.core.ui.loading.MonogramLoading -import org.monogram.core.ui.loading.MonogramLoadingListSize -import org.monogram.core.ui.menu.AppMenuPlacementState -import org.monogram.core.ui.menu.AppMenuScrimPopup -import org.monogram.core.ui.rememberEnsuredFile -import org.monogram.feature.dialog.ComposerAt -import org.monogram.feature.dialog.ComposerPanels import org.monogram.feature.dialog.DialogComponent -import org.monogram.feature.dialog.DialogDayKind -import org.monogram.feature.dialog.DialogStore -import org.monogram.feature.dialog.DialogTime -import org.monogram.feature.dialog.R -import org.monogram.feature.dialog.albumSlice -import org.monogram.feature.dialog.dialogSyncStatus -import org.monogram.feature.dialog.followBottomFromScroll -import org.monogram.feature.dialog.historyPagingAllowed -import org.monogram.feature.dialog.isAlbumHead -import org.monogram.feature.dialog.liftAnchorTarget -import org.monogram.feature.dialog.shouldFollowIncomingNewest -import org.monogram.feature.dialog.shouldPageNewer -import org.monogram.feature.dialog.shouldPageOlder -import org.monogram.feature.dialog.unreadDividerIndex -import org.monogram.network.http.MediaPriority -import java.io.File -import java.time.ZoneId -import java.util.Locale -import kotlin.time.Duration.Companion.milliseconds -import org.monogram.core.ui.components.SponsorBadge @OptIn(ExperimentalMaterial3Api::class) @Composable fun DialogContent(component: DialogComponent, modifier: Modifier = Modifier) { - val state by component.state.collectAsState() + val state by component.state.collectAsStateWithLifecycle() var mediaViewerSeenBy by remember { mutableStateOf(null) } val receiptHolder = remember { ReadReceiptHolder() } val uriHandler = androidx.compose.ui.platform.LocalUriHandler.current diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogHistoryPane.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogHistoryPane.kt index 1ad909986..719e34052 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogHistoryPane.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogHistoryPane.kt @@ -44,7 +44,6 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableIntState import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -80,6 +79,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.monogram.core.models.Message @@ -107,7 +107,6 @@ import org.monogram.feature.dialog.shouldPageNewer import org.monogram.feature.dialog.shouldPageOlder import org.monogram.feature.dialog.unreadDividerIndex import org.monogram.feature.dialog.visibleAlbumMessageIds -import org.monogram.feature.dialog.visibleUnreadBadgeCount import java.time.ZoneId @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @@ -132,7 +131,17 @@ internal fun ColumnScope.DialogHistoryPane( val listState = rememberLazyListState( cacheWindow = LazyLayoutCacheWindow(ahead = 1000.dp, behind = 1000.dp), ) - var lastScrolledAnchor by rememberSaveable(state.chatId.value) { mutableStateOf(null) } + var lastScrolledAnchorRequest by rememberSaveable(state.chatId.value) { + mutableStateOf( + null + ) + } + val messageHeads = remember(state.messages) { + state.messages.mapIndexedNotNull { index, message -> + if (isAlbumHead(state.messages, index)) index to message else null + } + } + val rowMessageIndices = remember(messageHeads) { messageHeads.map { it.first } } var followBottom by rememberSaveable(state.chatId.value) { mutableStateOf(false) } var previousFromCache by rememberSaveable(state.chatId.value) { mutableStateOf(false) } var previousCount by rememberSaveable(state.chatId.value) { mutableIntStateOf(0) } @@ -157,8 +166,9 @@ internal fun ColumnScope.DialogHistoryPane( menuMessageId = null } } - val menuMessage = state.messages.firstOrNull { it.id.id == menuMessageId } - val selectingMessage = state.messages.firstOrNull { it.id.id == selectingMessageId } + val menuMessage = menuMessageId?.let { id -> state.messages.firstOrNull { it.id.id == id } } + val selectingMessage = + selectingMessageId?.let { id -> state.messages.firstOrNull { it.id.id == id } } val multiSelecting = selectedIds.isNotEmpty() androidx.activity.compose.BackHandler(enabled = menuExpanded && selectingMessageId == null) { val effect = backOutMessageSelection(selectedIds, selectingMessageId, menuOpen = true) @@ -185,7 +195,7 @@ internal fun ColumnScope.DialogHistoryPane( menuExpanded = false } } - val appearance by AppearanceSettings.state.collectAsState() + val appearance by AppearanceSettings.state.collectAsStateWithLifecycle() val pinned = state.pinnedMessages.getOrNull(state.pinnedIndex) ?: state.pinnedMessages.firstOrNull() val showPinned = !state.isCommentThread && pinned != null @@ -269,11 +279,6 @@ internal fun ColumnScope.DialogHistoryPane( previousFromCache = state.fromCache } val zone = remember { ZoneId.systemDefault() } - val messageHeads = remember(state.messages) { - state.messages.mapIndexedNotNull { index, message -> - if (isAlbumHead(state.messages, index)) index to message else null - } - } val messageById = remember(state.messages) { state.messages.associateBy { it.id.id } } @@ -292,14 +297,19 @@ internal fun ColumnScope.DialogHistoryPane( } val unreadRow = unreadAt?.let { messageRowIndex(state.messages, state.messages[it].id.id) } - LaunchedEffect(state.anchorMessageId, state.messages.size, state.anchorAtTop) { + LaunchedEffect( + state.anchorRequestId, + state.anchorMessageId, + state.messages.size, + state.anchorAtTop + ) { val anchor = state.anchorMessageId ?: return@LaunchedEffect - if (anchor == lastScrolledAnchor) return@LaunchedEffect + if (state.anchorRequestId == lastScrolledAnchorRequest) return@LaunchedEffect val alreadyVisible = !state.anchorAtTop && listState.firstVisibleItemIndex == 0 && state.messages.firstOrNull()?.id?.id == anchor if (alreadyVisible) { - lastScrolledAnchor = anchor + lastScrolledAnchorRequest = state.anchorRequestId return@LaunchedEffect } val index = messageRowIndex(state.messages, anchor) @@ -320,14 +330,16 @@ internal fun ColumnScope.DialogHistoryPane( listState.scrollToItem(index, row.size - height) } } - lastScrolledAnchor = anchor + lastScrolledAnchorRequest = state.anchorRequestId } } LaunchedEffect( listState, state.messages.size, state.hasOlder, - state.loadingOlder + state.loadingOlder, + state.searchQuery, + messageHeads.size, ) { snapshotFlow { val visible = listState.layoutInfo.visibleItemsInfo @@ -355,7 +367,8 @@ internal fun ColumnScope.DialogHistoryPane( listState, state.messages.size, state.hasNewer, - state.loadingNewer + state.loadingNewer, + state.searchQuery, ) { snapshotFlow { listState.firstVisibleItemIndex @@ -373,9 +386,9 @@ internal fun ColumnScope.DialogHistoryPane( val scrolling = listState.isScrollInProgress val index = listState.layoutInfo.visibleItemsInfo.minByOrNull { it.index }?.index - // reverseLayout: index 0 / no forward scroll means the newest row is on screen. - val atLiveEdge = - listState.firstVisibleItemIndex == 0 || !listState.canScrollForward + val atLiveEdge = org.monogram.feature.dialog.isHistoryLiveEdge( + listState.firstVisibleItemIndex, listState.canScrollBackward, + ) Triple( scrolling, index?.let { messageHeads.getOrNull(it)?.second?.id?.id }, @@ -389,12 +402,15 @@ internal fun ColumnScope.DialogHistoryPane( } LaunchedEffect(listState, state.chatId, messageHeads) { snapshotFlow { - visibleAlbumMessageIds( - state.messages, - listState.layoutInfo.visibleItemsInfo.mapTo(HashSet()) { it.index }, + listState.layoutInfo.visibleItemsInfo.mapTo(HashSet()) { it.index } + }.collect { rows -> + component.onVisibleWindow( + visibleAlbumMessageIds( + state.messages, + rows, + rowMessageIndices + ) ) - }.collect { ids -> - component.onVisibleWindow(ids) } } val scrollGate = remember { Any() } @@ -747,7 +763,6 @@ internal fun ColumnScope.DialogHistoryPane( Box { SmallFloatingActionButton( onClick = { - lastScrolledAnchor = null component.onJumpUnreadReaction() }, containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, @@ -780,7 +795,6 @@ internal fun ColumnScope.DialogHistoryPane( val mentionJump = stringResource(R.string.dialog_jump_mention) SmallFloatingActionButton( onClick = { - lastScrolledAnchor = null component.onJumpMention() }, containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, @@ -818,8 +832,6 @@ internal fun ColumnScope.DialogHistoryPane( SmallFloatingActionButton( onClick = { if (unreadRow != null && firstVisible > unreadRow) { - // Re-arm the anchor: the same unread message may be tapped twice. - lastScrolledAnchor = null component.onJumpUnread() } else { followBottom = true @@ -842,12 +854,7 @@ internal fun ColumnScope.DialogHistoryPane( ) } UnreadBadge( - count = visibleUnreadBadgeCount( - state.messages, - state.unreadCount, - state.readInboxMaxId, - state.hasNewer, - ), + count = if (unreadAt != null) state.unreadCount else 0, muted = false, compact = true, modifier = Modifier @@ -900,10 +907,7 @@ internal fun ColumnScope.DialogHistoryPane( val scrolling = listState.isScrollInProgress val topIndex = listState.layoutInfo.visibleItemsInfo.maxByOrNull { it.index }?.index - val heads = state.messages.mapIndexedNotNull { index, message -> - if (isAlbumHead(state.messages, index)) message else null - } - val date = topIndex?.let { heads.getOrNull(it)?.date } + val date = topIndex?.let { messageHeads.getOrNull(it)?.second?.date } scrolling to date }.collect { (scrolling, date) -> if (date != null) dateOverlayEpoch = date diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogScreen.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogScreen.kt index fbce60bff..ba7f85c57 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogScreen.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -45,6 +44,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import org.monogram.core.models.GeoPlace import org.monogram.core.models.PeerId @@ -64,7 +64,7 @@ import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun DialogScreen(component: DialogComponent, modifier: Modifier) { - val state by component.state.collectAsState() + val state by component.state.collectAsStateWithLifecycle() val mediaTitleHolder = LocalChatTitleHolder.current SideEffect { mediaTitleHolder?.value = state.title } val receiptHolder = LocalReadReceiptHolder.current diff --git a/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogForumStoreTest.kt b/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogForumStoreTest.kt index 7725c6600..2bb54edca 100644 --- a/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogForumStoreTest.kt +++ b/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogForumStoreTest.kt @@ -74,6 +74,86 @@ class DialogForumStoreTest { StickerPackMemory.clear() } + @Test + fun slowPinnedCacheDoesNotDelayFirstHistoryPublication() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val gate = CompletableDeferred() + val chatId = PeerId(5) + val meta = object : SessionMetadataStore() { + override suspend fun readMeta(key: String): String? { + if (key == pinnedMetaKey(chatId.value)) gate.await() + return null + } + + override suspend fun writeMeta(key: String, value: String) = Unit + } + val message = Message( + id = MessageId(chatId, 7), + senderId = null, + text = "Synthetic", + date = 1L, + outgoing = false + ) + val client = FakeClient().apply { history = listOf(message) } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, FakeWarmup(), meta, + chatId = chatId, seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + runCurrent() + assertFalse(gate.isCompleted) + assertEquals( + "History must publish while unrelated pinned metadata is suspended", + listOf(7), + store.state.messages.map { it.id.id }) + assertFalse(store.state.loading) + } finally { + gate.complete(Unit) + store.dispose() + } + } + + @Test + fun delayedPinnedCacheCannotReplaceNewerNetworkPins() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val gate = CompletableDeferred() + val chatId = PeerId(5) + val oldPin = Message( + id = MessageId(chatId, 7), + senderId = null, + text = "Old synthetic pin", + date = 1L, + outgoing = false + ) + val newPin = oldPin.copy(id = MessageId(chatId, 8), text = "New synthetic pin") + val meta = object : SessionMetadataStore() { + override suspend fun readMeta(key: String): String? { + if (key != pinnedMetaKey(chatId.value)) return null + gate.await() + return "7" + } + + override suspend fun writeMeta(key: String, value: String) = Unit + } + val warmup = FakeWarmup().apply { storedMessages[chatId.value] = listOf(oldPin) } + val client = FakeClient().apply { history = listOf(newPin); pinned = listOf(newPin) } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup, meta, chatId = chatId, seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + runCurrent() + assertEquals(listOf(8), store.state.pinnedMessages.map { it.id.id }) + gate.complete(Unit) + advanceUntilIdle() + assertEquals(listOf(8), store.state.pinnedMessages.map { it.id.id }) + } finally { + gate.complete(Unit) + store.dispose() + } + } + @Test fun rememberedHistoryPaintsBeforeRpc() = runBlocking { val gate = CompletableDeferred() @@ -790,7 +870,7 @@ class DialogForumStoreTest { } @Test - fun visibleReadAcknowledgesOnlyMessagesOnScreen() = runTest { + fun visibleReadAtOlderEndAcknowledgesOnlyMessagesOnScreen() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) val client = FakeClient().apply { history = listOf(topicMessage(12), topicMessage(11), topicMessage(10)) @@ -802,8 +882,13 @@ class DialogForumStoreTest { ).create() try { advanceUntilIdle() - // 12 is loaded but off screen (the user scrolled up): only 11 may be acknowledged. - store.accept(DialogStore.Intent.VisibleRead(11)) + // 12 is loaded but off screen at the older end: only 11 may be acknowledged. + store.accept( + DialogStore.Intent.VisibleRead( + 11, + atLiveEdge = isHistoryLiveEdge(firstVisibleIndex = 1, canScrollBackward = true), + ) + ) advanceUntilIdle() assertEquals("reads=${client.readIds} messages=${store.state.messages.map { it.id.id }}", listOf(11), client.readIds) assertEquals(11, store.state.readInboxMaxId) @@ -1745,6 +1830,130 @@ class DialogForumStoreTest { } } + @Test + fun networkRefreshPublishesFormattingAndAlbumChangesWithUnchangedText() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val gate = CompletableDeferred() + val client = FakeClient() + val cached = client.topicMessage(44).copy(text = "same text") + val updated = cached.copy( + groupedId = 9L, + entities = listOf(TextEntity("bold", 0, 4)), + ) + client.history = listOf(updated) + client.historyGate = gate + val warmup = FakeWarmup().apply { storedMessages[5L] = listOf(cached) } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = warmup, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + assertEquals(cached.entities, store.state.messages.single().entities) + gate.complete(Unit) + advanceUntilIdle() + assertEquals(updated.entities, store.state.messages.single().entities) + assertEquals(updated.groupedId, store.state.messages.single().groupedId) + } finally { + store.dispose() + } + } + + @Test + fun recreatedStoreDoesNotReuseThePreviousScrollRequestToken() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val client = FakeClient().apply { history = listOf(topicMessage(40), topicMessage(39)) } + val factory = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ) + val first = factory.create() + val previousToken = try { + advanceUntilIdle() + first.accept(DialogStore.Intent.JumpToMessage(40)) + first.state.anchorRequestId.also { assertTrue(it != null) } + } finally { + first.dispose() + } + val recreated = factory.create() + try { + advanceUntilIdle() + recreated.accept(DialogStore.Intent.JumpToMessage(40)) + assertEquals(40, recreated.state.anchorMessageId) + assertTrue(recreated.state.anchorRequestId != null) + assertFalse(previousToken == recreated.state.anchorRequestId) + } finally { + recreated.dispose() + } + } + + @Test + fun repeatedJumpToSameMessagePublishesAnotherScrollRequest() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val client = FakeClient().apply { history = listOf(topicMessage(40), topicMessage(39)) } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + val id = store.state.messages.first().id.id + store.accept(DialogStore.Intent.JumpToMessage(id)) + advanceUntilIdle() + val previous = store.state + store.accept(DialogStore.Intent.JumpToMessage(id)) + advanceUntilIdle() + assertEquals(id, store.state.anchorMessageId) + assertEquals(previous.messages, store.state.messages) + assertFalse( + "repeated navigation must survive StateFlow equality", + previous == store.state + ) + } finally { + store.dispose() + } + } + + @Test + fun dateJumpRejectsLateNewerCacheRowsAndEndMarker() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val gate = CompletableDeferred() + val client = FakeClient().apply { + history = (1..80).reversed().map { topicMessage(it) } + jumpPage = (101..140).reversed().map { topicMessage(it) } + } + val warmup = FakeWarmup().apply { + newerSliceGate = gate + newerSliceRows = (141..180).reversed().map(client::topicMessage) + } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = warmup, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + store.accept(DialogStore.Intent.JumpToDate(42)) + advanceUntilIdle() + store.accept(DialogStore.Intent.LoadNewer) + advanceUntilIdle() + assertTrue(store.state.loadingNewer) + client.jumpPage = (401..440).reversed().map(client::topicMessage) + store.accept(DialogStore.Intent.JumpToDate(43)) + advanceUntilIdle() + gate.complete(Unit) + advanceUntilIdle() + assertTrue(store.state.messages.all { it.id.id in 401..440 }) + assertTrue(store.state.hasNewer) + assertFalse(store.state.loadingNewer) + } finally { + store.dispose() + } + } + @Test fun dateJumpInvalidatesInFlightNewerRequest() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) @@ -3575,6 +3784,27 @@ class DialogForumStoreTest { } private class FakeWarmup : OfflineWarmup() { + var newerSliceGate: CompletableDeferred? = null + var newerSliceRows: List = emptyList() + override suspend fun historySlice( + chatId: PeerId, beforeId: Int?, afterId: Int?, limit: Int, + ): org.monogram.core.database.HistorySlice { + if (afterId == null || newerSliceGate == null) return super.historySlice( + chatId, + beforeId, + afterId, + limit + ) + newerSliceGate?.await() + return org.monogram.core.database.HistorySlice( + newerSliceRows, + true, + emptyList(), + false, + true, + true + ) + } var olderPage: List = emptyList() var beforeDatePage: List = emptyList() override suspend fun olderMessages(chatId: PeerId, beforeId: Int, limit: Int): List { diff --git a/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogNavigationTest.kt b/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogNavigationTest.kt index 511f778a8..997a28cd1 100644 --- a/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogNavigationTest.kt +++ b/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogNavigationTest.kt @@ -335,6 +335,37 @@ class DialogNavigationTest { assertEquals(1, unreadDividerIndex(messages, unreadCount = 4, readInboxMaxId = 0)) } + @Test + fun oldestLoadedEndIsNotTheLiveEdgeOfAReversedHistory() { + assertFalse(isHistoryLiveEdge(firstVisibleIndex = 40, canScrollBackward = true)) + assertTrue(isHistoryLiveEdge(firstVisibleIndex = 0, canScrollBackward = false)) + assertTrue(isHistoryLiveEdge(firstVisibleIndex = 0, canScrollBackward = true)) + } + + @Test + fun visibleAlbumExpansionReadsOnlyIndexedVisibleMessages() { + val backing = (10_000 downTo 1).map { id -> + msg(id).let { if (id in 9978..9980) it.copy(groupedId = 1L) else it } + } + val rowIndices = backing.indices.filter { isAlbumHead(backing, it) } + var reads = 0 + val messages = object : AbstractList() { + override val size: Int get() = backing.size + override fun get(index: Int): Message { + reads++ + return backing[index] + } + } + val visibleRows = setOf(0, 20, rowIndices.lastIndex, -1, 20_000) + val expanded = visibleAlbumMessageIds(messages, visibleRows) + assertTrue("uncached row indexing traverses the loaded history", reads >= backing.size) + reads = 0 + val indexed = visibleAlbumMessageIds(messages, visibleRows, rowIndices) + assertEquals(setOf(10_000, 9980, 9979, 9978, 1), indexed) + assertEquals(expanded, indexed) + assertTrue("viewport work must not scan 10,000 messages: $reads reads", reads < 20) + } + @Test fun visibleAlbumMessageIdsExpandsVisibleAlbumRowsAndSkipsInvalidRows() { val messages = listOf( diff --git a/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt b/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt index ef2b2f793..6094760cb 100644 --- a/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt +++ b/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt @@ -17,18 +17,13 @@ package uniffi.monogram_mtproto // compile the Rust component. The easiest way to ensure this is to bundle the Kotlin // helpers directly inline like we're doing here. -import com.sun.jna.Library -import com.sun.jna.IntegerType import com.sun.jna.Native import com.sun.jna.Pointer import com.sun.jna.Structure -import com.sun.jna.Callback -import com.sun.jna.ptr.* import java.nio.ByteBuffer import java.nio.ByteOrder import java.nio.CharBuffer import java.nio.charset.CodingErrorAction -import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.ConcurrentHashMap // This is a helper for safely working with byte buffers returned from the Rust code. @@ -56,7 +51,7 @@ open class RustBuffer : Structure() { } companion object { - internal fun alloc(size: ULong = 0UL) = uniffiRustCall() { status -> + internal fun alloc(size: ULong = 0UL) = uniffiRustCall { status -> // Note: need to convert the size to a `Long` value to make this work with JVM. UniffiLib.ffi_monogram_mtproto_rustbuffer_alloc(size.toLong(), status) }.also { @@ -65,15 +60,15 @@ open class RustBuffer : Structure() { } } - internal fun create(capacity: ULong, len: ULong, data: Pointer?): RustBuffer.ByValue { - var buf = RustBuffer.ByValue() + internal fun create(capacity: ULong, len: ULong, data: Pointer?): ByValue { + var buf = ByValue() buf.capacity = capacity.toLong() buf.len = len.toLong() buf.data = data return buf } - internal fun free(buf: RustBuffer.ByValue) = uniffiRustCall() { status -> + internal fun free(buf: ByValue) = uniffiRustCall { status -> UniffiLib.ffi_monogram_mtproto_rustbuffer_free(buf, status) } } @@ -111,28 +106,28 @@ internal open class ForeignBytes : Structure() { // stable native address that JNA can expose via `getDirectBufferPointer`. // The returned `ForeignBytes.ByValue` is only valid for the duration of // the FFI call; the Rust side treats it as a borrow. -internal object FfiConverterByRefBytes : FfiConverter { - override fun lower(value: java.nio.ByteBuffer): ForeignBytes.ByValue { +internal object FfiConverterByRefBytes : FfiConverter { + override fun lower(value: ByteBuffer): ForeignBytes.ByValue { require(value.isDirect) { "UniFFI zero-copy &[u8] requires a direct ByteBuffer. Use ByteBuffer.allocateDirect()." } val remaining = value.remaining() val fb = ForeignBytes.ByValue() fb.len = remaining // Zero-length direct buffers: skip getDirectBufferPointer (platform-variable behavior) // and pass null. The Rust side treats (null, 0) as &[]. - fb.data = if (remaining == 0) null else com.sun.jna.Native.getDirectBufferPointer(value) + fb.data = if (remaining == 0) null else Native.getDirectBufferPointer(value) return fb } - override fun lift(value: ForeignBytes.ByValue): java.nio.ByteBuffer = + override fun lift(value: ForeignBytes.ByValue): ByteBuffer = error("ByRef bytes cannot be lifted: zero-copy &[u8] only flows foreign->Rust") - override fun read(buf: java.nio.ByteBuffer): java.nio.ByteBuffer = + override fun read(buf: ByteBuffer): ByteBuffer = error("ByRef bytes cannot be read from a buffer: zero-copy &[u8] is only supported in argument position, not nested in records/options/etc.") - override fun write(value: java.nio.ByteBuffer, buf: java.nio.ByteBuffer): Unit = + override fun write(value: ByteBuffer, buf: ByteBuffer): Unit = error("ByRef bytes cannot be written to a buffer: zero-copy &[u8] is only supported in argument position, not nested in records/options/etc.") - override fun allocationSize(value: java.nio.ByteBuffer): ULong = + override fun allocationSize(value: ByteBuffer): ULong = error("ByRef bytes have no RustBuffer allocation size: zero-copy &[u8] is only supported in argument position, not nested in records/options/etc.") } /** @@ -143,7 +138,7 @@ internal object FfiConverterByRefBytes : FfiConverter { +interface FfiConverter { // Convert an FFI type to a Kotlin type fun lift(value: FfiType): KotlinType @@ -210,7 +205,7 @@ public interface FfiConverter { * * @suppress */ -public interface FfiConverterRustBuffer: FfiConverter { +interface FfiConverterRustBuffer: FfiConverter { override fun lift(value: RustBuffer.ByValue) = liftFromRustBuffer(value) override fun lower(value: KotlinType) = lowerIntoRustBuffer(value) } @@ -241,8 +236,8 @@ internal open class UniffiRustCallStatus : Structure() { } companion object { - fun create(code: Byte, errorBuf: RustBuffer.ByValue): UniffiRustCallStatus.ByValue { - val callStatus = UniffiRustCallStatus.ByValue() + fun create(code: Byte, errorBuf: RustBuffer.ByValue): ByValue { + val callStatus = ByValue() callStatus.code = code callStatus.error_buf = errorBuf return callStatus @@ -250,7 +245,7 @@ internal open class UniffiRustCallStatus : Structure() { } } -class InternalException(message: String) : kotlin.Exception(message) +class InternalException(message: String) : Exception(message) /** * Each top-level error class has a companion object that can lift the error from the call status's rust buffer @@ -258,7 +253,7 @@ class InternalException(message: String) : kotlin.Exception(message) * @suppress */ interface UniffiRustCallStatusErrorHandler { - fun lift(error_buf: RustBuffer.ByValue): E; + fun lift(error_buf: RustBuffer.ByValue): E } // Helpers for calling Rust @@ -266,7 +261,7 @@ interface UniffiRustCallStatusErrorHandler { // synchronize itself // Call a rust function that returns a Result<>. Pass in the Error class companion that corresponds to the Err -private inline fun uniffiRustCallWithError(errorHandler: UniffiRustCallStatusErrorHandler, callback: (UniffiRustCallStatus) -> U): U { +private inline fun uniffiRustCallWithError(errorHandler: UniffiRustCallStatusErrorHandler, callback: (UniffiRustCallStatus) -> U): U { var status = UniffiRustCallStatus() val return_value = callback(status) uniffiCheckCallStatus(errorHandler, status) @@ -274,7 +269,7 @@ private inline fun uniffiRustCallWithError(errorHandler } // Check UniffiRustCallStatus and throw an error if the call wasn't successful -private fun uniffiCheckCallStatus(errorHandler: UniffiRustCallStatusErrorHandler, status: UniffiRustCallStatus) { +private fun uniffiCheckCallStatus(errorHandler: UniffiRustCallStatusErrorHandler, status: UniffiRustCallStatus) { if (status.isSuccess()) { return } else if (status.isError()) { @@ -317,7 +312,7 @@ internal inline fun uniffiTraitInterfaceCall( ) { try { writeReturn(makeCall()) - } catch(e: kotlin.Exception) { + } catch(e: Exception) { val err = try { e.stackTraceToString() } catch(_: Throwable) { "" } callStatus.code = UNIFFI_CALL_UNEXPECTED_ERROR callStatus.error_buf = FfiConverterString.lower(err) @@ -332,7 +327,7 @@ internal inline fun uniffiTraitInterfaceCallWithError( ) { try { writeReturn(makeCall()) - } catch(e: kotlin.Exception) { + } catch(e: Exception) { if (e is E) { callStatus.code = UNIFFI_CALL_ERROR callStatus.error_buf = lowerError(e) @@ -416,7 +411,7 @@ internal open class UniffiForeignFutureDroppedCallbackStruct( class UniffiByValue( `handle`: Long = 0.toLong(), `free`: UniffiForeignFutureDroppedCallback? = null, - ): UniffiForeignFutureDroppedCallbackStruct(`handle`,`free`,), Structure.ByValue + ): UniffiForeignFutureDroppedCallbackStruct(`handle`,`free`,), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureDroppedCallbackStruct) { `handle` = other.`handle` @@ -432,7 +427,7 @@ internal open class UniffiForeignFutureResultU8( class UniffiByValue( `returnValue`: Byte = 0.toByte(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultU8(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultU8(`returnValue`,`callStatus`,), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultU8) { `returnValue` = other.`returnValue` @@ -451,7 +446,7 @@ internal open class UniffiForeignFutureResultI8( class UniffiByValue( `returnValue`: Byte = 0.toByte(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultI8(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultI8(`returnValue`,`callStatus`,), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultI8) { `returnValue` = other.`returnValue` @@ -470,7 +465,7 @@ internal open class UniffiForeignFutureResultU16( class UniffiByValue( `returnValue`: Short = 0.toShort(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultU16(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultU16(`returnValue`,`callStatus`,), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultU16) { `returnValue` = other.`returnValue` @@ -489,7 +484,7 @@ internal open class UniffiForeignFutureResultI16( class UniffiByValue( `returnValue`: Short = 0.toShort(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultI16(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultI16(`returnValue`,`callStatus`,), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultI16) { `returnValue` = other.`returnValue` @@ -508,7 +503,7 @@ internal open class UniffiForeignFutureResultU32( class UniffiByValue( `returnValue`: Int = 0, `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultU32(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultU32(`returnValue`,`callStatus`,), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultU32) { `returnValue` = other.`returnValue` @@ -527,7 +522,7 @@ internal open class UniffiForeignFutureResultI32( class UniffiByValue( `returnValue`: Int = 0, `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultI32(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultI32(`returnValue`,`callStatus`,), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultI32) { `returnValue` = other.`returnValue` @@ -546,7 +541,7 @@ internal open class UniffiForeignFutureResultU64( class UniffiByValue( `returnValue`: Long = 0.toLong(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultU64(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultU64(`returnValue`,`callStatus`,), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultU64) { `returnValue` = other.`returnValue` @@ -565,7 +560,7 @@ internal open class UniffiForeignFutureResultI64( class UniffiByValue( `returnValue`: Long = 0.toLong(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultI64(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultI64(`returnValue`,`callStatus`,), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultI64) { `returnValue` = other.`returnValue` @@ -584,7 +579,7 @@ internal open class UniffiForeignFutureResultF32( class UniffiByValue( `returnValue`: Float = 0.0f, `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultF32(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultF32(`returnValue`,`callStatus`,), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultF32) { `returnValue` = other.`returnValue` @@ -603,7 +598,7 @@ internal open class UniffiForeignFutureResultF64( class UniffiByValue( `returnValue`: Double = 0.0, `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultF64(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultF64(`returnValue`,`callStatus`,), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultF64) { `returnValue` = other.`returnValue` @@ -622,7 +617,7 @@ internal open class UniffiForeignFutureResultRustBuffer( class UniffiByValue( `returnValue`: RustBuffer.ByValue = RustBuffer.ByValue(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultRustBuffer(`returnValue`,`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultRustBuffer(`returnValue`,`callStatus`,), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultRustBuffer) { `returnValue` = other.`returnValue` @@ -639,7 +634,7 @@ internal open class UniffiForeignFutureResultVoid( ) : Structure() { class UniffiByValue( `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultVoid(`callStatus`,), Structure.ByValue + ): UniffiForeignFutureResultVoid(`callStatus`,), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultVoid) { `callStatus` = other.`callStatus` @@ -662,7 +657,7 @@ internal open class UniffiVTableCallbackInterfaceDownloadProgressListener( `uniffiFree`: UniffiCallbackInterfaceFree? = null, `uniffiClone`: UniffiCallbackInterfaceClone? = null, `onProgress`: UniffiCallbackInterfaceDownloadProgressListenerMethod0? = null, - ): UniffiVTableCallbackInterfaceDownloadProgressListener(`uniffiFree`,`uniffiClone`,`onProgress`,), Structure.ByValue + ): UniffiVTableCallbackInterfaceDownloadProgressListener(`uniffiFree`,`uniffiClone`,`onProgress`,), ByValue internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceDownloadProgressListener) { `uniffiFree` = other.`uniffiFree` @@ -993,25 +988,32 @@ internal object UniffiLib { } external fun uniffi_monogram_mtproto_fn_init_callback_vtable_downloadprogresslistener(`vtable`: UniffiVTableCallbackInterfaceDownloadProgressListener, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_acknowledge_update_batch(`handle`: Long,`accountGeneration`: Long,`batchId`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_add_waveform_pcm(`handle`: Long,`samples`: RustBuffer.ByValue,`sampleRate`: Int,`channels`: Int,`presentationTimeUs`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_animated_emoji_max(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Int external fun uniffi_monogram_mtproto_fn_func_append_todo_items(`handle`: Long,`chatId`: Long,`messageId`: Int,`firstId`: Int,`titles`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_bind_request_control(`id`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_monogram_mtproto_fn_func_cancel_request_control(`id`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_check_password(`handle`: Long,`password`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_clear_active_dialog(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_clear_proxy(uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_client_api_id(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Int external fun uniffi_monogram_mtproto_fn_func_client_exists(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1019,7 +1021,8 @@ internal object UniffiLib { external fun uniffi_monogram_mtproto_fn_func_client_uses_test_dc(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Byte external fun uniffi_monogram_mtproto_fn_func_connect(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_contacts_search(`handle`: Long,`query`: RustBuffer.ByValue,`limit`: Int,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_create_client(`apiId`: Int,`apiHash`: RustBuffer.ByValue,`sessionPath`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -1043,17 +1046,23 @@ internal object UniffiLib { external fun uniffi_monogram_mtproto_fn_func_decrypt_push_payload(`secret`: RustBuffer.ByValue,`payload`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_delete_folder(`handle`: Long,`id`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_delete_message(`handle`: Long,`chatId`: Long,`messageId`: Int,`revoke`: Byte,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_destroy_client(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_destroy_lottie(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_destroy_vpx_decoder(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_destroy_waveform(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_download_chunk_kib(uniffi_out_err: UniffiRustCallStatus, ): Int external fun uniffi_monogram_mtproto_fn_func_download_concurrency(uniffi_out_err: UniffiRustCallStatus, @@ -1073,7 +1082,8 @@ internal object UniffiLib { external fun uniffi_monogram_mtproto_fn_func_drain_updates(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_edit_forum_topic_hidden(`handle`: Long,`chatId`: Long,`topicId`: Int,`hidden`: Byte,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_edit_text_message(`handle`: Long,`chatId`: Long,`messageId`: Int,`text`: RustBuffer.ByValue,`entitiesJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_finish_waveform(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1161,7 +1171,8 @@ internal object UniffiLib { external fun uniffi_monogram_mtproto_fn_func_load_more_chats(`handle`: Long,`offsetDate`: Int,`offsetId`: Int,`offsetPeerId`: Long,`folderId`: Int,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_logout(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_lottie_frame_count(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Int external fun uniffi_monogram_mtproto_fn_func_lottie_frame_rate(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1169,7 +1180,8 @@ internal object UniffiLib { external fun uniffi_monogram_mtproto_fn_func_lottie_size(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_mark_dialog_unread(`handle`: Long,`chatId`: Long,`unread`: Byte,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_peek_message_inline_thumb(`handle`: Long,`chatId`: Long,`messageId`: Int,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_peek_peer_inline_thumb(`handle`: Long,`peerId`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1177,33 +1189,43 @@ internal object UniffiLib { external fun uniffi_monogram_mtproto_fn_func_pending_update_batches(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_perf_set_enabled(`enabled`: Byte,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_perf_snapshot(`reset`: Byte,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_ping_proxy(`kind`: RustBuffer.ByValue,`host`: RustBuffer.ByValue,`port`: Short,`username`: RustBuffer.ByValue,`password`: RustBuffer.ByValue,`secret`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_monogram_mtproto_fn_func_promote_request_control(`id`: Long,`class`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_read_discussion(`handle`: Long,`chatId`: Long,`msgId`: Int,`readMaxId`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_read_history(`handle`: Long,`chatId`: Long,`maxId`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_read_mentions(`handle`: Long,`chatId`: Long,`topMsgId`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_read_message_contents(`handle`: Long,`chatId`: Long,`messageIds`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_read_reactions(`handle`: Long,`chatId`: Long,`topMsgId`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_register_device(`handle`: Long,`tokenType`: Int,`token`: RustBuffer.ByValue,`secret`: RustBuffer.ByValue,`noMuted`: Byte,`appSandbox`: Byte,`otherUids`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_release_request_control(`id`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_render_lottie_frame(`handle`: Long,`frame`: Float,`width`: Int,`height`: Int,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_resend_auth_code(`handle`: Long,`phone`: RustBuffer.ByValue,`phoneCodeHash`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_reset_notify_settings(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_resolve_username(`handle`: Long,`username`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_search_global(`handle`: Long,`query`: RustBuffer.ByValue,`offsetRate`: Int,`offsetPeerId`: Long,`offsetId`: Int,`limit`: Int,`folderId`: Int,uniffi_out_err: UniffiRustCallStatus, @@ -1219,13 +1241,16 @@ internal object UniffiLib { external fun uniffi_monogram_mtproto_fn_func_send_inline_bot_result(`handle`: Long,`chatId`: Long,`queryId`: Long,`resultId`: RustBuffer.ByValue,`replyToMsgId`: Int,`topMsgId`: Int,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_send_location(`handle`: Long,`chatId`: Long,`latitude`: Double,`longitude`: Double,`livePeriod`: Int,`heading`: Int,`replyToMsgId`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_send_photo_message(`handle`: Long,`chatId`: Long,`path`: RustBuffer.ByValue,`caption`: RustBuffer.ByValue,`replyToMsgId`: Int,`topMsgId`: Int,`entitiesJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_send_poll_vote(`handle`: Long,`chatId`: Long,`messageId`: Int,`options`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_send_reaction(`handle`: Long,`chatId`: Long,`messageId`: Int,`emoticon`: RustBuffer.ByValue,`documentId`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_send_saved_gif(`handle`: Long,`chatId`: Long,`documentId`: Long,`replyToMsgId`: Int,`topMsgId`: Int,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_send_text_message(`handle`: Long,`chatId`: Long,`text`: RustBuffer.ByValue,`replyToMsgId`: Int,`entitiesJson`: RustBuffer.ByValue,`topMsgId`: Int,`webpageUrl`: RustBuffer.ByValue,`clientRandomId`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1235,151 +1260,208 @@ internal object UniffiLib { external fun uniffi_monogram_mtproto_fn_func_send_uploaded_media(`handle`: Long,`chatId`: Long,`item`: RustBuffer.ByValue,`replyToMsgId`: Int,`topMsgId`: Int,`entitiesJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_set_client_test_dc(`handle`: Long,`test`: Byte,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_set_contact_joined_silent(`handle`: Long,`silent`: Byte,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_set_dispatch_class(`class`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_set_download_chunk_kib(`kib`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_set_download_concurrency(`lanes`: Int,`parts`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_set_download_progress_listener(`listener`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_set_file_part_kib(`kib`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_set_init_connection_info(`info`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_set_proxy(`kind`: RustBuffer.ByValue,`host`: RustBuffer.ByValue,`port`: Short,`username`: RustBuffer.ByValue,`password`: RustBuffer.ByValue,`secret`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_set_transport_mode(`mode`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_set_typing(`handle`: Long,`chatId`: Long,`typing`: Byte,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_sign_in(`handle`: Long,`phone`: RustBuffer.ByValue,`phoneCodeHash`: RustBuffer.ByValue,`phoneCode`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_start_updates(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_toggle_todo_completed(`handle`: Long,`chatId`: Long,`messageId`: Int,`completed`: RustBuffer.ByValue,`incompleted`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_unblock_user(`handle`: Long,`chatId`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_unregister_device(`handle`: Long,`tokenType`: Int,`token`: RustBuffer.ByValue,`otherUids`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_update_folder(`handle`: Long,`folder`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_update_folder_order(`handle`: Long,`order`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_update_lazy_sync_config(`handle`: Long,`lazy`: Byte,`exceptions`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_update_notify_settings(`handle`: Long,`peerKind`: RustBuffer.ByValue,`chatId`: Long,`showPreviews`: RustBuffer.ByValue,`silent`: Byte,`muteUntil`: Int,`storiesMuted`: Byte,`sound`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_update_status(`handle`: Long,`offline`: Byte,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun ffi_monogram_mtproto_rustbuffer_alloc(`size`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun ffi_monogram_mtproto_rustbuffer_from_bytes(`bytes`: ForeignBytes.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun ffi_monogram_mtproto_rustbuffer_free(`buf`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + external fun ffi_monogram_mtproto_rustbuffer_reserve(`buf`: RustBuffer.ByValue,`additional`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun ffi_monogram_mtproto_rust_future_poll_u8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_cancel_u8(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_free_u8(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_complete_u8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Int external fun ffi_monogram_mtproto_rust_future_poll_i8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_cancel_i8(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_free_i8(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_complete_i8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Byte external fun ffi_monogram_mtproto_rust_future_poll_u16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_cancel_u16(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_free_u16(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_complete_u16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Int external fun ffi_monogram_mtproto_rust_future_poll_i16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_cancel_i16(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_free_i16(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_complete_i16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Short external fun ffi_monogram_mtproto_rust_future_poll_u32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_cancel_u32(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_free_u32(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_complete_u32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Int external fun ffi_monogram_mtproto_rust_future_poll_i32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_cancel_i32(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_free_i32(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_complete_i32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Int external fun ffi_monogram_mtproto_rust_future_poll_u64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_cancel_u64(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_free_u64(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_complete_u64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun ffi_monogram_mtproto_rust_future_poll_i64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_cancel_i64(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_free_i64(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_complete_i64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun ffi_monogram_mtproto_rust_future_poll_f32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_cancel_f32(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_free_f32(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_complete_f32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Float external fun ffi_monogram_mtproto_rust_future_poll_f64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_cancel_f64(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_free_f64(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_complete_f64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Double external fun ffi_monogram_mtproto_rust_future_poll_rust_buffer(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_cancel_rust_buffer(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_free_rust_buffer(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_complete_rust_buffer(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun ffi_monogram_mtproto_rust_future_poll_void(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_cancel_void(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_free_void(`handle`: Long, - ): Unit + ) + external fun ffi_monogram_mtproto_rust_future_complete_void(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) } @@ -1826,7 +1908,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { /** * @suppress */ -public fun uniffiEnsureInitialized() { +fun uniffiEnsureInitialized() { IntegrityCheckingUniffiLib // UniffiLib() initialized as objects are used, but we still need to explicitly // reference it so initialization across crates works as expected. @@ -1923,7 +2005,7 @@ internal const val UNIFFI_CALLBACK_UNEXPECTED_ERROR = 2 /** * @suppress */ -public abstract class FfiConverterCallbackInterface: FfiConverter { +abstract class FfiConverterCallbackInterface: FfiConverter { internal val handleMap = UniffiHandleMap() internal fun drop(handle: Long) { @@ -1948,7 +2030,7 @@ public abstract class FfiConverterCallbackInterface: Ffi /** * @suppress */ -public object FfiConverterUShort: FfiConverter { +object FfiConverterUShort: FfiConverter { override fun lift(value: Short): UShort { return value.toUShort() } @@ -1975,7 +2057,7 @@ public object FfiConverterUShort: FfiConverter { /** * @suppress */ -public object FfiConverterShort: FfiConverter { +object FfiConverterShort: FfiConverter { override fun lift(value: Short): Short { return value } @@ -1998,7 +2080,7 @@ public object FfiConverterShort: FfiConverter { /** * @suppress */ -public object FfiConverterUInt: FfiConverter { +object FfiConverterUInt: FfiConverter { override fun lift(value: Int): UInt { return value.toUInt() } @@ -2021,7 +2103,7 @@ public object FfiConverterUInt: FfiConverter { /** * @suppress */ -public object FfiConverterInt: FfiConverter { +object FfiConverterInt: FfiConverter { override fun lift(value: Int): Int { return value } @@ -2044,7 +2126,7 @@ public object FfiConverterInt: FfiConverter { /** * @suppress */ -public object FfiConverterULong: FfiConverter { +object FfiConverterULong: FfiConverter { override fun lift(value: Long): ULong { return value.toULong() } @@ -2067,7 +2149,7 @@ public object FfiConverterULong: FfiConverter { /** * @suppress */ -public object FfiConverterLong: FfiConverter { +object FfiConverterLong: FfiConverter { override fun lift(value: Long): Long { return value } @@ -2090,7 +2172,7 @@ public object FfiConverterLong: FfiConverter { /** * @suppress */ -public object FfiConverterFloat: FfiConverter { +object FfiConverterFloat: FfiConverter { override fun lift(value: Float): Float { return value } @@ -2113,7 +2195,7 @@ public object FfiConverterFloat: FfiConverter { /** * @suppress */ -public object FfiConverterDouble: FfiConverter { +object FfiConverterDouble: FfiConverter { override fun lift(value: Double): Double { return value } @@ -2136,7 +2218,7 @@ public object FfiConverterDouble: FfiConverter { /** * @suppress */ -public object FfiConverterBoolean: FfiConverter { +object FfiConverterBoolean: FfiConverter { override fun lift(value: Byte): Boolean { return value.toInt() != 0 } @@ -2159,7 +2241,7 @@ public object FfiConverterBoolean: FfiConverter { /** * @suppress */ -public object FfiConverterString: FfiConverter { +object FfiConverterString: FfiConverter { // Note: we don't inherit from FfiConverterRustBuffer, because we use a // special encoding when lowering/lifting. We can use `RustBuffer.len` to // store our length and avoid writing it out to the buffer. @@ -2216,7 +2298,7 @@ public object FfiConverterString: FfiConverter { /** * @suppress */ -public object FfiConverterByteArray: FfiConverterRustBuffer { +object FfiConverterByteArray: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ByteArray { val len = buf.getInt() val byteArr = ByteArray(len) @@ -2235,13 +2317,13 @@ public object FfiConverterByteArray: FfiConverterRustBuffer { data class AuthCodeSent ( - var `phone`: kotlin.String + var `phone`: String , - var `phoneCodeHash`: kotlin.String + var `phoneCodeHash`: String , - var `codeType`: kotlin.String + var `codeType`: String , - var `codeLength`: kotlin.Int + var `codeLength`: Int ){ @@ -2255,7 +2337,7 @@ data class AuthCodeSent ( /** * @suppress */ -public object FfiConverterTypeAuthCodeSent: FfiConverterRustBuffer { +object FfiConverterTypeAuthCodeSent: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): AuthCodeSent { return AuthCodeSent( FfiConverterString.read(buf), @@ -2283,9 +2365,9 @@ public object FfiConverterTypeAuthCodeSent: FfiConverterRustBuffer data class AuthSignedIn ( - var `userId`: kotlin.Long + var `userId`: Long , - var `dcId`: kotlin.Int + var `dcId`: Int ){ @@ -2299,7 +2381,7 @@ data class AuthSignedIn ( /** * @suppress */ -public object FfiConverterTypeAuthSignedIn: FfiConverterRustBuffer { +object FfiConverterTypeAuthSignedIn: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): AuthSignedIn { return AuthSignedIn( FfiConverterLong.read(buf), @@ -2321,13 +2403,13 @@ public object FfiConverterTypeAuthSignedIn: FfiConverterRustBuffer data class BotCallbackAnswerDto ( - var `alert`: kotlin.Boolean + var `alert`: Boolean , - var `message`: kotlin.String? + var `message`: String? , - var `url`: kotlin.String? + var `url`: String? , - var `cacheTime`: kotlin.Int + var `cacheTime`: Int ){ @@ -2341,7 +2423,7 @@ data class BotCallbackAnswerDto ( /** * @suppress */ -public object FfiConverterTypeBotCallbackAnswerDto: FfiConverterRustBuffer { +object FfiConverterTypeBotCallbackAnswerDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): BotCallbackAnswerDto { return BotCallbackAnswerDto( FfiConverterBoolean.read(buf), @@ -2369,9 +2451,9 @@ public object FfiConverterTypeBotCallbackAnswerDto: FfiConverterRustBuffer { +object FfiConverterTypeChannelCursorDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ChannelCursorDto { return ChannelCursorDto( FfiConverterLong.read(buf), @@ -2407,97 +2489,97 @@ public object FfiConverterTypeChannelCursorDto: FfiConverterRustBuffer { +object FfiConverterTypeChatDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ChatDto { return ChatDto( FfiConverterLong.read(buf), @@ -2645,7 +2727,7 @@ data class ContactsSearchDto ( /** * @suppress */ -public object FfiConverterTypeContactsSearchDto: FfiConverterRustBuffer { +object FfiConverterTypeContactsSearchDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ContactsSearchDto { return ContactsSearchDto( FfiConverterSequenceTypeSearchPeerDto.read(buf), @@ -2667,9 +2749,9 @@ public object FfiConverterTypeContactsSearchDto: FfiConverterRustBuffer { +object FfiConverterTypeDiscussionDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): DiscussionDto { return DiscussionDto( FfiConverterLong.read(buf), @@ -2705,36 +2787,36 @@ public object FfiConverterTypeDiscussionDto: FfiConverterRustBuffer + var `chatIds`: List , - var `excludeChatIds`: List + var `excludeChatIds`: List , - var `includeContacts`: kotlin.Boolean + var `includeContacts`: Boolean , - var `includeNonContacts`: kotlin.Boolean + var `includeNonContacts`: Boolean , - var `includeGroups`: kotlin.Boolean + var `includeGroups`: Boolean , - var `includeChannels`: kotlin.Boolean + var `includeChannels`: Boolean , - var `includeBots`: kotlin.Boolean + var `includeBots`: Boolean , - var `excludeMuted`: kotlin.Boolean + var `excludeMuted`: Boolean , - var `excludeRead`: kotlin.Boolean + var `excludeRead`: Boolean , - var `excludeArchived`: kotlin.Boolean + var `excludeArchived`: Boolean , - var `emoticon`: kotlin.String + var `emoticon`: String , /** * `dialogFilter.pinned_peers`, distinct from include peers in [chat_ids]. */ - var `pinnedChatIds`: List + var `pinnedChatIds`: List ){ @@ -2748,7 +2830,7 @@ data class FolderDto ( /** * @suppress */ -public object FfiConverterTypeFolderDto: FfiConverterRustBuffer { +object FfiConverterTypeFolderDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): FolderDto { return FolderDto( FfiConverterInt.read(buf), @@ -2806,37 +2888,37 @@ public object FfiConverterTypeFolderDto: FfiConverterRustBuffer { data class ForumTopicDto ( - var `id`: kotlin.Int + var `id`: Int , - var `title`: kotlin.String + var `title`: String , - var `iconColor`: kotlin.Int + var `iconColor`: Int , - var `iconEmojiId`: kotlin.Long? + var `iconEmojiId`: Long? , - var `topMessage`: kotlin.Int + var `topMessage`: Int , - var `date`: kotlin.Int + var `date`: Int , - var `unreadCount`: kotlin.Int + var `unreadCount`: Int , - var `unreadMentionsCount`: kotlin.Int + var `unreadMentionsCount`: Int , - var `unreadReactionsCount`: kotlin.Int + var `unreadReactionsCount`: Int , - var `readInboxMaxId`: kotlin.Int + var `readInboxMaxId`: Int , - var `pinned`: kotlin.Boolean + var `pinned`: Boolean , - var `closed`: kotlin.Boolean + var `closed`: Boolean , - var `hidden`: kotlin.Boolean + var `hidden`: Boolean , - var `short`: kotlin.Boolean + var `short`: Boolean , - var `deleted`: kotlin.Boolean + var `deleted`: Boolean , - var `lastMessagePreview`: kotlin.String? + var `lastMessagePreview`: String? ){ @@ -2850,7 +2932,7 @@ data class ForumTopicDto ( /** * @suppress */ -public object FfiConverterTypeForumTopicDto: FfiConverterRustBuffer { +object FfiConverterTypeForumTopicDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ForumTopicDto { return ForumTopicDto( FfiConverterInt.read(buf), @@ -2914,7 +2996,7 @@ public object FfiConverterTypeForumTopicDto: FfiConverterRustBuffer @@ -2930,7 +3012,7 @@ data class ForumTopicsPageDto ( /** * @suppress */ -public object FfiConverterTypeForumTopicsPageDto: FfiConverterRustBuffer { +object FfiConverterTypeForumTopicsPageDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ForumTopicsPageDto { return ForumTopicsPageDto( FfiConverterInt.read(buf), @@ -2954,11 +3036,11 @@ public object FfiConverterTypeForumTopicsPageDto: FfiConverterRustBuffer , - var `nextRate`: kotlin.Int + var `nextRate`: Int , - var `nextPeerId`: kotlin.Long + var `nextPeerId`: Long , - var `nextOffsetId`: kotlin.Int + var `nextOffsetId`: Int ){ @@ -2972,7 +3054,7 @@ data class GlobalMessageSearchDto ( /** * @suppress */ -public object FfiConverterTypeGlobalMessageSearchDto: FfiConverterRustBuffer { +object FfiConverterTypeGlobalMessageSearchDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): GlobalMessageSearchDto { return GlobalMessageSearchDto( FfiConverterSequenceTypeMessageDto.read(buf), @@ -3004,17 +3086,17 @@ public object FfiConverterTypeGlobalMessageSearchDto: FfiConverterRustBuffer { +object FfiConverterTypeInitConnectionInfo: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): InitConnectionInfo { return InitConnectionInfo( FfiConverterString.read(buf), @@ -3062,19 +3144,19 @@ public object FfiConverterTypeInitConnectionInfo: FfiConverterRustBuffer { +object FfiConverterTypeInlineBotResultDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): InlineBotResultDto { return InlineBotResultDto( FfiConverterString.read(buf), @@ -3125,13 +3207,13 @@ public object FfiConverterTypeInlineBotResultDto: FfiConverterRustBuffer @@ -3147,7 +3229,7 @@ data class InlineBotResultsDto ( /** * @suppress */ -public object FfiConverterTypeInlineBotResultsDto: FfiConverterRustBuffer { +object FfiConverterTypeInlineBotResultsDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): InlineBotResultsDto { return InlineBotResultsDto( FfiConverterLong.read(buf), @@ -3178,31 +3260,31 @@ public object FfiConverterTypeInlineBotResultsDto: FfiConverterRustBuffer { +object FfiConverterTypeInstantViewDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): InstantViewDto { return InstantViewDto( FfiConverterString.read(buf), @@ -3271,11 +3353,11 @@ public object FfiConverterTypeInstantViewDto: FfiConverterRustBuffer { +object FfiConverterTypeJoinChatDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): JoinChatDto { return JoinChatDto( FfiConverterBoolean.read(buf), @@ -3314,9 +3396,9 @@ public object FfiConverterTypeJoinChatDto: FfiConverterRustBuffer { data class LottieSize ( - var `width`: kotlin.UInt + var `width`: UInt , - var `height`: kotlin.UInt + var `height`: UInt ){ @@ -3330,7 +3412,7 @@ data class LottieSize ( /** * @suppress */ -public object FfiConverterTypeLottieSize: FfiConverterRustBuffer { +object FfiConverterTypeLottieSize: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): LottieSize { return LottieSize( FfiConverterUInt.read(buf), @@ -3352,82 +3434,82 @@ public object FfiConverterTypeLottieSize: FfiConverterRustBuffer { data class MessageDto ( - var `chatId`: kotlin.Long + var `chatId`: Long , - var `id`: kotlin.Int + var `id`: Int , - var `senderId`: kotlin.Long? + var `senderId`: Long? , - var `text`: kotlin.String? + var `text`: String? , - var `date`: kotlin.Long + var `date`: Long , - var `editDate`: kotlin.Long? + var `editDate`: Long? , - var `outgoing`: kotlin.Boolean + var `outgoing`: Boolean , - var `mediaKind`: kotlin.String? + var `mediaKind`: String? , - var `mediaCacheKey`: kotlin.String? + var `mediaCacheKey`: String? , - var `thumbCacheKey`: kotlin.String? + var `thumbCacheKey`: String? , - var `mediaDuration`: kotlin.Int? + var `mediaDuration`: Int? , - var `mediaWidth`: kotlin.Int? + var `mediaWidth`: Int? , - var `mediaHeight`: kotlin.Int? + var `mediaHeight`: Int? , - var `replyQuote`: kotlin.String? + var `replyQuote`: String? , /** * Compact JSON list of `{kind,offset,length,url?}` message entities. */ - var `entitiesJson`: kotlin.String? + var `entitiesJson`: String? , - var `noforwards`: kotlin.Boolean + var `noforwards`: Boolean , - var `replyToMsgId`: kotlin.Int? + var `replyToMsgId`: Int? , - var `replyToTopId`: kotlin.Int? + var `replyToTopId`: Int? , - var `fwdFrom`: kotlin.String? + var `fwdFrom`: String? , - var `fwdFromId`: kotlin.Long? + var `fwdFromId`: Long? , - var `fwdDate`: kotlin.Long? + var `fwdDate`: Long? , - var `viaBot`: kotlin.String? + var `viaBot`: String? , - var `senderName`: kotlin.String? + var `senderName`: String? , - var `senderEmojiStatusDocumentId`: kotlin.Long? + var `senderEmojiStatusDocumentId`: Long? , - var `groupedId`: kotlin.Long? + var `groupedId`: Long? , - var `fileName`: kotlin.String? + var `fileName`: String? , - var `fileSize`: kotlin.Long? + var `fileSize`: Long? , - var `supportsStreaming`: kotlin.Boolean + var `supportsStreaming`: Boolean , - var `reactionsJson`: kotlin.String? + var `reactionsJson`: String? , - var `repliesCount`: kotlin.Int + var `repliesCount`: Int , - var `discussionPeerId`: kotlin.Long? + var `discussionPeerId`: Long? , /** * Compact JSON for reply/inline keyboards. Never log. */ - var `replyMarkupJson`: kotlin.String? + var `replyMarkupJson`: String? , - var `forumTopic`: kotlin.Boolean + var `forumTopic`: Boolean , /** * Avatar already indexed at `(sender_id, 0)` when its key starts with `avatar:`. */ - var `senderPhotoCacheKey`: kotlin.String? + var `senderPhotoCacheKey`: String? ){ @@ -3441,7 +3523,7 @@ data class MessageDto ( /** * @suppress */ -public object FfiConverterTypeMessageDto: FfiConverterRustBuffer { +object FfiConverterTypeMessageDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): MessageDto { return MessageDto( FfiConverterLong.read(buf), @@ -3561,7 +3643,7 @@ public object FfiConverterTypeMessageDto: FfiConverterRustBuffer { data class MessagesPageDto ( var `messages`: List , - var `totalCount`: kotlin.Int + var `totalCount`: Int ){ @@ -3575,7 +3657,7 @@ data class MessagesPageDto ( /** * @suppress */ -public object FfiConverterTypeMessagesPageDto: FfiConverterRustBuffer { +object FfiConverterTypeMessagesPageDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): MessagesPageDto { return MessagesPageDto( FfiConverterSequenceTypeMessageDto.read(buf), @@ -3597,23 +3679,23 @@ public object FfiConverterTypeMessagesPageDto: FfiConverterRustBuffer { +object FfiConverterTypeNotifyExceptionDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): NotifyExceptionDto { return NotifyExceptionDto( FfiConverterString.read(buf), @@ -3670,17 +3752,17 @@ public object FfiConverterTypeNotifyExceptionDto: FfiConverterRustBuffer { +object FfiConverterTypeNotifySettingsDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): NotifySettingsDto { return NotifySettingsDto( FfiConverterOptionalBoolean.read(buf), @@ -3728,9 +3810,9 @@ public object FfiConverterTypeNotifySettingsDto: FfiConverterRustBuffer { +object FfiConverterTypeOutboxReadDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): OutboxReadDto { return OutboxReadDto( FfiConverterInt.read(buf), @@ -3766,13 +3848,13 @@ public object FfiConverterTypeOutboxReadDto: FfiConverterRustBuffer + var `options`: List ){ @@ -3786,7 +3868,7 @@ data class PollVoterDto ( /** * @suppress */ -public object FfiConverterTypePollVoterDto: FfiConverterRustBuffer { +object FfiConverterTypePollVoterDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): PollVoterDto { return PollVoterDto( FfiConverterLong.read(buf), @@ -3816,7 +3898,7 @@ public object FfiConverterTypePollVoterDto: FfiConverterRustBuffer data class PollVotersDto ( var `voters`: List , - var `count`: kotlin.Int + var `count`: Int ){ @@ -3830,7 +3912,7 @@ data class PollVotersDto ( /** * @suppress */ -public object FfiConverterTypePollVotersDto: FfiConverterRustBuffer { +object FfiConverterTypePollVotersDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): PollVotersDto { return PollVotersDto( FfiConverterSequenceTypePollVoterDto.read(buf), @@ -3852,28 +3934,28 @@ public object FfiConverterTypePollVotersDto: FfiConverterRustBuffer { +object FfiConverterTypeProfileDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ProfileDto { return ProfileDto( FfiConverterLong.read(buf), @@ -3933,9 +4015,9 @@ public object FfiConverterTypeProfileDto: FfiConverterRustBuffer { data class ReactionChoiceDto ( - var `emoticon`: kotlin.String + var `emoticon`: String , - var `documentId`: kotlin.Long + var `documentId`: Long ){ @@ -3949,7 +4031,7 @@ data class ReactionChoiceDto ( /** * @suppress */ -public object FfiConverterTypeReactionChoiceDto: FfiConverterRustBuffer { +object FfiConverterTypeReactionChoiceDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReactionChoiceDto { return ReactionChoiceDto( FfiConverterString.read(buf), @@ -3971,15 +4053,15 @@ public object FfiConverterTypeReactionChoiceDto: FfiConverterRustBuffer { +object FfiConverterTypeReactionPeerDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReactionPeerDto { return ReactionPeerDto( FfiConverterLong.read(buf), @@ -4026,7 +4108,7 @@ public object FfiConverterTypeReactionPeerDto: FfiConverterRustBuffer , - var `count`: kotlin.Int + var `count`: Int ){ @@ -4040,7 +4122,7 @@ data class ReactionPeersDto ( /** * @suppress */ -public object FfiConverterTypeReactionPeersDto: FfiConverterRustBuffer { +object FfiConverterTypeReactionPeersDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReactionPeersDto { return ReactionPeersDto( FfiConverterSequenceTypeReactionPeerDto.read(buf), @@ -4062,9 +4144,9 @@ public object FfiConverterTypeReactionPeersDto: FfiConverterRustBuffer { +object FfiConverterTypeReadParticipantDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReadParticipantDto { return ReadParticipantDto( FfiConverterLong.read(buf), @@ -4102,7 +4184,7 @@ public object FfiConverterTypeReadParticipantDto: FfiConverterRustBuffer , - var `error`: kotlin.String? + var `error`: String? ){ @@ -4116,7 +4198,7 @@ data class ReadParticipantsDto ( /** * @suppress */ -public object FfiConverterTypeReadParticipantsDto: FfiConverterRustBuffer { +object FfiConverterTypeReadParticipantsDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReadParticipantsDto { return ReadParticipantsDto( FfiConverterSequenceTypeReadParticipantDto.read(buf), @@ -4138,13 +4220,13 @@ public object FfiConverterTypeReadParticipantsDto: FfiConverterRustBuffer { +object FfiConverterTypeReadReceiptConfigDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReadReceiptConfigDto { return ReadReceiptConfigDto( FfiConverterInt.read(buf), @@ -4186,13 +4268,13 @@ public object FfiConverterTypeReadReceiptConfigDto: FfiConverterRustBuffer { +object FfiConverterTypeResolvedPeerDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ResolvedPeerDto { return ResolvedPeerDto( FfiConverterLong.read(buf), @@ -4234,15 +4316,15 @@ public object FfiConverterTypeResolvedPeerDto: FfiConverterRustBuffer { +object FfiConverterTypeSavedGifDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): SavedGifDto { return SavedGifDto( FfiConverterLong.read(buf), @@ -4287,19 +4369,19 @@ public object FfiConverterTypeSavedGifDto: FfiConverterRustBuffer { data class SearchPeerDto ( - var `peerId`: kotlin.Long + var `peerId`: Long , - var `title`: kotlin.String + var `title`: String , - var `username`: kotlin.String? + var `username`: String? , - var `kind`: kotlin.String + var `kind`: String , - var `isBot`: kotlin.Boolean + var `isBot`: Boolean , - var `isGroup`: kotlin.Boolean + var `isGroup`: Boolean , - var `isChannel`: kotlin.Boolean + var `isChannel`: Boolean ){ @@ -4313,7 +4395,7 @@ data class SearchPeerDto ( /** * @suppress */ -public object FfiConverterTypeSearchPeerDto: FfiConverterRustBuffer { +object FfiConverterTypeSearchPeerDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): SearchPeerDto { return SearchPeerDto( FfiConverterLong.read(buf), @@ -4350,9 +4432,9 @@ public object FfiConverterTypeSearchPeerDto: FfiConverterRustBuffer @@ -4368,7 +4450,7 @@ data class StickerCatalogDto ( /** * @suppress */ -public object FfiConverterTypeStickerCatalogDto: FfiConverterRustBuffer { +object FfiConverterTypeStickerCatalogDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): StickerCatalogDto { return StickerCatalogDto( FfiConverterLong.read(buf), @@ -4393,11 +4475,11 @@ public object FfiConverterTypeStickerCatalogDto: FfiConverterRustBuffer + var `documentIds`: List ){ @@ -4411,7 +4493,7 @@ data class StickerListDto ( /** * @suppress */ -public object FfiConverterTypeStickerListDto: FfiConverterRustBuffer { +object FfiConverterTypeStickerListDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): StickerListDto { return StickerListDto( FfiConverterLong.read(buf), @@ -4436,19 +4518,19 @@ public object FfiConverterTypeStickerListDto: FfiConverterRustBuffer + var `previewDocumentIds`: List ){ @@ -4462,7 +4544,7 @@ data class StickerPackDto ( /** * @suppress */ -public object FfiConverterTypeStickerPackDto: FfiConverterRustBuffer { +object FfiConverterTypeStickerPackDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): StickerPackDto { return StickerPackDto( FfiConverterLong.read(buf), @@ -4499,9 +4581,9 @@ public object FfiConverterTypeStickerPackDto: FfiConverterRustBuffer , @@ -4521,7 +4603,7 @@ data class UpdateBatchDto ( /** * @suppress */ -public object FfiConverterTypeUpdateBatchDto: FfiConverterRustBuffer { +object FfiConverterTypeUpdateBatchDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UpdateBatchDto { return UpdateBatchDto( FfiConverterLong.read(buf), @@ -4556,7 +4638,7 @@ data class UpdateDrainDto ( , var `liveEvents`: List , - var `replayRequired`: kotlin.Boolean + var `replayRequired`: Boolean ){ @@ -4570,7 +4652,7 @@ data class UpdateDrainDto ( /** * @suppress */ -public object FfiConverterTypeUpdateDrainDto: FfiConverterRustBuffer { +object FfiConverterTypeUpdateDrainDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UpdateDrainDto { return UpdateDrainDto( FfiConverterSequenceTypeUpdateBatchDto.read(buf), @@ -4595,13 +4677,13 @@ public object FfiConverterTypeUpdateDrainDto: FfiConverterRustBuffer { +object FfiConverterTypeUpdatesStateDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UpdatesStateDto { return UpdatesStateDto( FfiConverterInt.read(buf), @@ -4643,25 +4725,25 @@ public object FfiConverterTypeUpdatesStateDto: FfiConverterRustBuffer { +object FfiConverterTypeUploadItemDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UploadItemDto { return UploadItemDto( FfiConverterString.read(buf), @@ -4721,11 +4803,11 @@ public object FfiConverterTypeUploadItemDto: FfiConverterRustBuffer { +object FfiConverterTypeVpxAlphaFrame: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): VpxAlphaFrame { return VpxAlphaFrame( FfiConverterUInt.read(buf), @@ -4764,11 +4846,11 @@ public object FfiConverterTypeVpxAlphaFrame: FfiConverterRustBuffer { +object FfiConverterTypeVpxFrame: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): VpxFrame { return VpxFrame( FfiConverterUInt.read(buf), @@ -4807,9 +4889,9 @@ public object FfiConverterTypeVpxFrame: FfiConverterRustBuffer { data class WallpaperCatalogDto ( - var `hash`: kotlin.Long + var `hash`: Long , - var `notModified`: kotlin.Boolean + var `notModified`: Boolean , var `wallpapers`: List @@ -4825,7 +4907,7 @@ data class WallpaperCatalogDto ( /** * @suppress */ -public object FfiConverterTypeWallpaperCatalogDto: FfiConverterRustBuffer { +object FfiConverterTypeWallpaperCatalogDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): WallpaperCatalogDto { return WallpaperCatalogDto( FfiConverterLong.read(buf), @@ -4850,29 +4932,29 @@ public object FfiConverterTypeWallpaperCatalogDto: FfiConverterRustBuffer + var `colors`: List , - var `intensity`: kotlin.Int? + var `intensity`: Int? , - var `rotation`: kotlin.Int + var `rotation`: Int , - var `blur`: kotlin.Boolean + var `blur`: Boolean , - var `motion`: kotlin.Boolean + var `motion`: Boolean ){ @@ -4886,7 +4968,7 @@ data class WallpaperDto ( /** * @suppress */ -public object FfiConverterTypeWallpaperDto: FfiConverterRustBuffer { +object FfiConverterTypeWallpaperDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): WallpaperDto { return WallpaperDto( FfiConverterLong.read(buf), @@ -4939,29 +5021,26 @@ public object FfiConverterTypeWallpaperDto: FfiConverterRustBuffer -sealed class MtprotoException: kotlin.Exception() { +sealed class MtprotoException: Exception() { - class UnknownClient( - ) : MtprotoException() { + class UnknownClient : MtprotoException() { override val message get() = "" } - class RegistrationRequired( - ) : MtprotoException() { + class RegistrationRequired : MtprotoException() { override val message get() = "" } - class PasswordRequired( - ) : MtprotoException() { + class PasswordRequired : MtprotoException() { override val message get() = "" } class Message( - val v1: kotlin.String + val v1: String ) : MtprotoException() { override val message get() = "v1=${ v1 }" @@ -4981,7 +5060,7 @@ sealed class MtprotoException: kotlin.Exception() { /** * @suppress */ -public object FfiConverterTypeMtprotoError : FfiConverterRustBuffer { +object FfiConverterTypeMtprotoError : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): MtprotoException { @@ -5035,7 +5114,6 @@ public object FfiConverterTypeMtprotoError : FfiConverterRustBuffer { buf.putInt(4) FfiConverterString.write(value.v1, buf) - Unit } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } @@ -5053,7 +5131,7 @@ sealed class UpdateEventDto { data class NewMessage( - val `message`: uniffi.monogram_mtproto.MessageDto) : UpdateEventDto() + val `message`: MessageDto) : UpdateEventDto() { @@ -5062,7 +5140,7 @@ sealed class UpdateEventDto { } data class MessageEdited( - val `message`: uniffi.monogram_mtproto.MessageDto) : UpdateEventDto() + val `message`: MessageDto) : UpdateEventDto() { @@ -5071,8 +5149,8 @@ sealed class UpdateEventDto { } data class MessagesDeleted( - val `chatId`: kotlin.Long?, - val `messageIds`: List) : UpdateEventDto() + val `chatId`: Long?, + val `messageIds`: List) : UpdateEventDto() { @@ -5081,10 +5159,10 @@ sealed class UpdateEventDto { } data class PeerTyping( - val `chatId`: kotlin.Long, - val `userId`: kotlin.Long, - val `typing`: kotlin.Boolean, - val `action`: kotlin.String) : UpdateEventDto() + val `chatId`: Long, + val `userId`: Long, + val `typing`: Boolean, + val `action`: String) : UpdateEventDto() { @@ -5093,9 +5171,9 @@ sealed class UpdateEventDto { } data class PeerStatus( - val `userId`: kotlin.Long, - val `status`: kotlin.String?, - val `statusAt`: kotlin.Long?) : UpdateEventDto() + val `userId`: Long, + val `status`: String?, + val `statusAt`: Long?) : UpdateEventDto() { @@ -5104,8 +5182,8 @@ sealed class UpdateEventDto { } data class PeerEmojiStatus( - val `userId`: kotlin.Long, - val `documentId`: kotlin.Long?) : UpdateEventDto() + val `userId`: Long, + val `documentId`: Long?) : UpdateEventDto() { @@ -5114,9 +5192,9 @@ sealed class UpdateEventDto { } data class ReadInbox( - val `chatId`: kotlin.Long, - val `maxId`: kotlin.Int, - val `stillUnread`: kotlin.Int) : UpdateEventDto() + val `chatId`: Long, + val `maxId`: Int, + val `stillUnread`: Int) : UpdateEventDto() { @@ -5125,8 +5203,8 @@ sealed class UpdateEventDto { } data class ReadOutbox( - val `chatId`: kotlin.Long, - val `maxId`: kotlin.Int) : UpdateEventDto() + val `chatId`: Long, + val `maxId`: Int) : UpdateEventDto() { @@ -5138,9 +5216,9 @@ sealed class UpdateEventDto { data class MessageReactions( - val `chatId`: kotlin.Long, - val `messageId`: kotlin.Int, - val `reactionsJson`: kotlin.String) : UpdateEventDto() + val `chatId`: Long, + val `messageId`: Int, + val `reactionsJson`: String) : UpdateEventDto() { @@ -5149,9 +5227,9 @@ sealed class UpdateEventDto { } data class DiscussionInbox( - val `channelId`: kotlin.Long, - val `topMessageId`: kotlin.Int, - val `readMaxId`: kotlin.Int) : UpdateEventDto() + val `channelId`: Long, + val `topMessageId`: Int, + val `readMaxId`: Int) : UpdateEventDto() { @@ -5163,7 +5241,7 @@ sealed class UpdateEventDto { * Dialog rows from a lazy `messages.getPeerDialogs` batch. Not a full chat refresh. */ data class DialogsPatched( - val `chats`: List) : UpdateEventDto() + val `chats`: List) : UpdateEventDto() { @@ -5175,7 +5253,7 @@ sealed class UpdateEventDto { * Edge-triggered catch-up signal. True while difference or channel recovery is in flight. */ data class SyncState( - val `isSyncing`: kotlin.Boolean) : UpdateEventDto() + val `isSyncing`: Boolean) : UpdateEventDto() { @@ -5184,7 +5262,7 @@ sealed class UpdateEventDto { } data class Ignored( - val `kind`: kotlin.String) : UpdateEventDto() + val `kind`: String) : UpdateEventDto() { @@ -5205,7 +5283,7 @@ sealed class UpdateEventDto { /** * @suppress */ -public object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer{ +object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer{ override fun read(buf: ByteBuffer): UpdateEventDto { return when(buf.getInt()) { 1 -> UpdateEventDto.ChatsChanged @@ -5407,18 +5485,15 @@ public object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer { buf.putInt(3) FfiConverterTypeMessageDto.write(value.`message`, buf) - Unit } is UpdateEventDto.MessageEdited -> { buf.putInt(4) FfiConverterTypeMessageDto.write(value.`message`, buf) - Unit } is UpdateEventDto.MessagesDeleted -> { buf.putInt(5) FfiConverterOptionalLong.write(value.`chatId`, buf) FfiConverterSequenceInt.write(value.`messageIds`, buf) - Unit } is UpdateEventDto.PeerTyping -> { buf.putInt(6) @@ -5426,33 +5501,28 @@ public object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer { buf.putInt(7) FfiConverterLong.write(value.`userId`, buf) FfiConverterOptionalString.write(value.`status`, buf) FfiConverterOptionalLong.write(value.`statusAt`, buf) - Unit } is UpdateEventDto.PeerEmojiStatus -> { buf.putInt(8) FfiConverterLong.write(value.`userId`, buf) FfiConverterOptionalLong.write(value.`documentId`, buf) - Unit } is UpdateEventDto.ReadInbox -> { buf.putInt(9) FfiConverterLong.write(value.`chatId`, buf) FfiConverterInt.write(value.`maxId`, buf) FfiConverterInt.write(value.`stillUnread`, buf) - Unit } is UpdateEventDto.ReadOutbox -> { buf.putInt(10) FfiConverterLong.write(value.`chatId`, buf) FfiConverterInt.write(value.`maxId`, buf) - Unit } is UpdateEventDto.SavedGifsChanged -> { buf.putInt(11) @@ -5463,29 +5533,24 @@ public object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer { buf.putInt(13) FfiConverterLong.write(value.`channelId`, buf) FfiConverterInt.write(value.`topMessageId`, buf) FfiConverterInt.write(value.`readMaxId`, buf) - Unit } is UpdateEventDto.DialogsPatched -> { buf.putInt(14) FfiConverterSequenceTypeChatDto.write(value.`chats`, buf) - Unit } is UpdateEventDto.SyncState -> { buf.putInt(15) FfiConverterBoolean.write(value.`isSyncing`, buf) - Unit } is UpdateEventDto.Ignored -> { buf.putInt(16) FfiConverterString.write(value.`kind`, buf) - Unit } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } @@ -5497,9 +5562,9 @@ public object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer + val makeCall = { uniffiObj.`onProgress`( FfiConverterString.lift(`path`), FfiConverterLong.lift(`downloaded`), FfiConverterLong.lift(`total`), ) } - val writeReturn = { _: Unit -> Unit } + val writeReturn = { _: Unit -> } uniffiTraitInterfaceCall(uniffiCallStatus, makeCall, writeReturn) } } @@ -5553,7 +5618,7 @@ internal object uniffiCallbackInterfaceDownloadProgressListener { * * @suppress */ -public object FfiConverterTypeDownloadProgressListener: FfiConverterCallbackInterface() +object FfiConverterTypeDownloadProgressListener: FfiConverterCallbackInterface() @@ -5561,15 +5626,15 @@ public object FfiConverterTypeDownloadProgressListener: FfiConverterCallbackInte /** * @suppress */ -public object FfiConverterOptionalInt: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): kotlin.Int? { +object FfiConverterOptionalInt: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): Int? { if (buf.get().toInt() == 0) { return null } return FfiConverterInt.read(buf) } - override fun allocationSize(value: kotlin.Int?): ULong { + override fun allocationSize(value: Int?): ULong { if (value == null) { return 1UL } else { @@ -5577,7 +5642,7 @@ public object FfiConverterOptionalInt: FfiConverterRustBuffer { } } - override fun write(value: kotlin.Int?, buf: ByteBuffer) { + override fun write(value: Int?, buf: ByteBuffer) { if (value == null) { buf.put(0) } else { @@ -5593,15 +5658,15 @@ public object FfiConverterOptionalInt: FfiConverterRustBuffer { /** * @suppress */ -public object FfiConverterOptionalLong: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): kotlin.Long? { +object FfiConverterOptionalLong: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): Long? { if (buf.get().toInt() == 0) { return null } return FfiConverterLong.read(buf) } - override fun allocationSize(value: kotlin.Long?): ULong { + override fun allocationSize(value: Long?): ULong { if (value == null) { return 1UL } else { @@ -5609,7 +5674,7 @@ public object FfiConverterOptionalLong: FfiConverterRustBuffer { } } - override fun write(value: kotlin.Long?, buf: ByteBuffer) { + override fun write(value: Long?, buf: ByteBuffer) { if (value == null) { buf.put(0) } else { @@ -5625,15 +5690,15 @@ public object FfiConverterOptionalLong: FfiConverterRustBuffer { /** * @suppress */ -public object FfiConverterOptionalBoolean: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): kotlin.Boolean? { +object FfiConverterOptionalBoolean: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): Boolean? { if (buf.get().toInt() == 0) { return null } return FfiConverterBoolean.read(buf) } - override fun allocationSize(value: kotlin.Boolean?): ULong { + override fun allocationSize(value: Boolean?): ULong { if (value == null) { return 1UL } else { @@ -5641,7 +5706,7 @@ public object FfiConverterOptionalBoolean: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): kotlin.String? { +object FfiConverterOptionalString: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): String? { if (buf.get().toInt() == 0) { return null } return FfiConverterString.read(buf) } - override fun allocationSize(value: kotlin.String?): ULong { + override fun allocationSize(value: String?): ULong { if (value == null) { return 1UL } else { @@ -5673,7 +5738,7 @@ public object FfiConverterOptionalString: FfiConverterRustBuffer } } - override fun write(value: kotlin.String?, buf: ByteBuffer) { + override fun write(value: String?, buf: ByteBuffer) { if (value == null) { buf.put(0) } else { @@ -5689,15 +5754,15 @@ public object FfiConverterOptionalString: FfiConverterRustBuffer /** * @suppress */ -public object FfiConverterOptionalByteArray: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): kotlin.ByteArray? { +object FfiConverterOptionalByteArray: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): ByteArray? { if (buf.get().toInt() == 0) { return null } return FfiConverterByteArray.read(buf) } - override fun allocationSize(value: kotlin.ByteArray?): ULong { + override fun allocationSize(value: ByteArray?): ULong { if (value == null) { return 1UL } else { @@ -5705,7 +5770,7 @@ public object FfiConverterOptionalByteArray: FfiConverterRustBuffer { +object FfiConverterOptionalTypeUpdatesStateDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UpdatesStateDto? { if (buf.get().toInt() == 0) { return null @@ -5753,7 +5818,7 @@ public object FfiConverterOptionalTypeUpdatesStateDto: FfiConverterRustBuffer { +object FfiConverterOptionalTypeVpxAlphaFrame: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): VpxAlphaFrame? { if (buf.get().toInt() == 0) { return null @@ -5785,7 +5850,7 @@ public object FfiConverterOptionalTypeVpxAlphaFrame: FfiConverterRustBuffer { +object FfiConverterOptionalTypeVpxFrame: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): VpxFrame? { if (buf.get().toInt() == 0) { return null @@ -5817,21 +5882,21 @@ public object FfiConverterOptionalTypeVpxFrame: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +object FfiConverterSequenceShort: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { + return List(len) { FfiConverterShort.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL val sizeForItems = value.map { FfiConverterShort.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { FfiConverterShort.write(it, buf) @@ -5845,21 +5910,21 @@ public object FfiConverterSequenceShort: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +object FfiConverterSequenceInt: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { + return List(len) { FfiConverterInt.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL val sizeForItems = value.map { FfiConverterInt.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { FfiConverterInt.write(it, buf) @@ -5873,21 +5938,21 @@ public object FfiConverterSequenceInt: FfiConverterRustBuffer> /** * @suppress */ -public object FfiConverterSequenceLong: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +object FfiConverterSequenceLong: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { + return List(len) { FfiConverterLong.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL val sizeForItems = value.map { FfiConverterLong.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { FfiConverterLong.write(it, buf) @@ -5901,21 +5966,21 @@ public object FfiConverterSequenceLong: FfiConverterRustBuffer /** * @suppress */ -public object FfiConverterSequenceFloat: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +object FfiConverterSequenceFloat: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { + return List(len) { FfiConverterFloat.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL val sizeForItems = value.map { FfiConverterFloat.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { FfiConverterFloat.write(it, buf) @@ -5929,21 +5994,21 @@ public object FfiConverterSequenceFloat: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +object FfiConverterSequenceString: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { + return List(len) { FfiConverterString.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL val sizeForItems = value.map { FfiConverterString.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { FfiConverterString.write(it, buf) @@ -5957,21 +6022,21 @@ public object FfiConverterSequenceString: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +object FfiConverterSequenceByteArray: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { + return List(len) { FfiConverterByteArray.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL val sizeForItems = value.map { FfiConverterByteArray.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { FfiConverterByteArray.write(it, buf) @@ -5985,7 +6050,7 @@ public object FfiConverterSequenceByteArray: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeChannelCursorDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6013,7 +6078,7 @@ public object FfiConverterSequenceTypeChannelCursorDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeChatDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6041,7 +6106,7 @@ public object FfiConverterSequenceTypeChatDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeFolderDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6069,7 +6134,7 @@ public object FfiConverterSequenceTypeFolderDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeForumTopicDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6097,7 +6162,7 @@ public object FfiConverterSequenceTypeForumTopicDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeInlineBotResultDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6125,7 +6190,7 @@ public object FfiConverterSequenceTypeInlineBotResultDto: FfiConverterRustBuffer /** * @suppress */ -public object FfiConverterSequenceTypeMessageDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeMessageDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6153,7 +6218,7 @@ public object FfiConverterSequenceTypeMessageDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeNotifyExceptionDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6181,7 +6246,7 @@ public object FfiConverterSequenceTypeNotifyExceptionDto: FfiConverterRustBuffer /** * @suppress */ -public object FfiConverterSequenceTypePollVoterDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypePollVoterDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6209,7 +6274,7 @@ public object FfiConverterSequenceTypePollVoterDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeReactionChoiceDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6237,7 +6302,7 @@ public object FfiConverterSequenceTypeReactionChoiceDto: FfiConverterRustBuffer< /** * @suppress */ -public object FfiConverterSequenceTypeReactionPeerDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeReactionPeerDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6265,7 +6330,7 @@ public object FfiConverterSequenceTypeReactionPeerDto: FfiConverterRustBuffer
  • > { +object FfiConverterSequenceTypeReadParticipantDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6293,7 +6358,7 @@ public object FfiConverterSequenceTypeReadParticipantDto: FfiConverterRustBuffer /** * @suppress */ -public object FfiConverterSequenceTypeSavedGifDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeSavedGifDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6321,7 +6386,7 @@ public object FfiConverterSequenceTypeSavedGifDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeSearchPeerDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6349,7 +6414,7 @@ public object FfiConverterSequenceTypeSearchPeerDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeStickerPackDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6377,7 +6442,7 @@ public object FfiConverterSequenceTypeStickerPackDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeUpdateBatchDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6405,7 +6470,7 @@ public object FfiConverterSequenceTypeUpdateBatchDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeUploadItemDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6433,7 +6498,7 @@ public object FfiConverterSequenceTypeUploadItemDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeWallpaperDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6461,7 +6526,7 @@ public object FfiConverterSequenceTypeWallpaperDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6482,7 +6547,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_acknowledge_update_batch( @@ -6495,7 +6560,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer, `sampleRate`: kotlin.UInt, `channels`: kotlin.UInt, `presentationTimeUs`: kotlin.ULong) + @Throws(MtprotoException::class) fun `addWaveformPcm`(`handle`: ULong, `samples`: List, `sampleRate`: UInt, `channels`: UInt, `presentationTimeUs`: ULong) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_add_waveform_pcm( @@ -6510,7 +6575,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_animated_emoji_max( @@ -6522,7 +6587,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer) + @Throws(MtprotoException::class) fun `appendTodoItems`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `firstId`: Int, `titles`: List) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_append_todo_items( @@ -6536,9 +6601,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_bind_request_control( @@ -6547,9 +6612,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_cancel_request_control( @@ -6558,7 +6623,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_check_password( @@ -6571,7 +6636,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_clear_active_dialog( @@ -6591,9 +6656,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_client_api_id( @@ -6602,9 +6667,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_client_exists( @@ -6613,9 +6678,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_client_uses_test_dc( @@ -6625,7 +6690,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_connect( @@ -6636,7 +6701,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_contacts_search( @@ -6649,9 +6714,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_create_client( @@ -6663,7 +6728,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_create_encrypted_client( @@ -6678,7 +6743,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_create_lottie( @@ -6689,9 +6754,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_create_request_control( _status) @@ -6700,7 +6765,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_create_vpx_decoder( @@ -6711,7 +6776,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_create_waveform( @@ -6723,7 +6788,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_custom_emoji_is_free( @@ -6736,7 +6801,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_decode_vpx_alpha_packet( @@ -6749,7 +6814,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_decode_vpx_packet( @@ -6762,7 +6827,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_decrypt_push_payload( @@ -6775,7 +6840,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_delete_folder( @@ -6787,7 +6852,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_delete_message( @@ -6800,9 +6865,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_destroy_client( @@ -6810,9 +6875,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_destroy_lottie( @@ -6820,9 +6885,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_destroy_vpx_decoder( @@ -6830,9 +6895,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_destroy_waveform( @@ -6840,9 +6905,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_chunk_kib( _status) @@ -6850,9 +6915,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + fun `downloadConcurrency`(): List { return FfiConverterSequenceInt.lift( - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_concurrency( _status) @@ -6861,7 +6926,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_download_custom_emoji( @@ -6875,7 +6940,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_download_message_display( @@ -6890,7 +6955,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_download_message_media( @@ -6905,7 +6970,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_download_message_media_chunk( @@ -6921,7 +6986,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_download_message_thumb( @@ -6936,7 +7001,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_download_wallpaper( @@ -6951,7 +7016,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_drain_updates( @@ -6963,7 +7028,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_edit_forum_topic_hidden( @@ -6977,7 +7042,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_edit_text_message( @@ -6993,7 +7058,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) fun `finishWaveform`(`handle`: ULong): List { return FfiConverterSequenceFloat.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_finish_waveform( @@ -7005,7 +7070,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer, `toChatId`: kotlin.Long, `dropAuthor`: kotlin.Boolean): List { + @Throws(MtprotoException::class) fun `forwardMessages`(`handle`: ULong, `fromChatId`: Long, `messageIds`: List, `toChatId`: Long, `dropAuthor`: Boolean): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_forward_messages( @@ -7021,7 +7086,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_all_stickers( @@ -7034,7 +7099,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_bot_callback_answer( @@ -7049,7 +7114,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) fun `getChats`(`handle`: ULong): List { return FfiConverterSequenceTypeChatDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_chats( @@ -7064,7 +7129,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_common_chats( @@ -7079,7 +7144,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_discussion_message( @@ -7093,7 +7158,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_emoji_stickers( @@ -7106,7 +7171,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) fun `getFolders`(`handle`: ULong): List { return FfiConverterSequenceTypeFolderDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_folders( @@ -7118,7 +7183,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_forum_topics( @@ -7135,7 +7200,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer): ForumTopicsPageDto { + @Throws(MtprotoException::class) fun `getForumTopicsById`(`handle`: ULong, `chatId`: Long, `topicIds`: List): ForumTopicsPageDto { return FfiConverterTypeForumTopicsPageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_forum_topics_by_id( @@ -7149,7 +7214,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_group_admin_tags( @@ -7162,7 +7227,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) fun `getHistory`(`handle`: ULong, `chatId`: Long, `limit`: Int): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_history( @@ -7176,7 +7241,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) fun `getHistoryPage`(`handle`: ULong, `chatId`: Long, `limit`: Int, `offsetId`: Int, `offsetDate`: Int, `addOffset`: Int): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_history_page( @@ -7193,7 +7258,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_inline_bot_results( @@ -7209,7 +7274,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_message_reactions_list( @@ -7223,7 +7288,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_message_read_participants( @@ -7237,7 +7302,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) fun `getNotifyExceptions`(`handle`: ULong, `compareSound`: Boolean): List { return FfiConverterSequenceTypeNotifyExceptionDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_notify_exceptions( @@ -7250,7 +7315,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_notify_settings( @@ -7264,7 +7329,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_outbox_read_date( @@ -7281,7 +7346,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_participants( @@ -7298,7 +7363,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) fun `getPinnedMessages`(`handle`: ULong, `chatId`: Long, `limit`: Int): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_pinned_messages( @@ -7312,7 +7377,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_poll_votes( @@ -7326,7 +7391,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_profile( @@ -7339,7 +7404,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_read_receipt_config( @@ -7351,7 +7416,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) fun `getRecentReactions`(`handle`: ULong): List { return FfiConverterSequenceTypeReactionChoiceDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_recent_reactions( @@ -7363,7 +7428,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) fun `getReplies`(`handle`: ULong, `chatId`: Long, `msgId`: Int, `limit`: Int, `offsetId`: Int, `addOffset`: Int): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_replies( @@ -7380,7 +7445,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) fun `getSavedGifs`(`handle`: ULong): List { return FfiConverterSequenceTypeSavedGifDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_saved_gifs( @@ -7395,7 +7460,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer): kotlin.String { + @Throws(MtprotoException::class) fun `getSearchCounters`(`handle`: ULong, `chatId`: Long, `filters`: List): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_search_counters( @@ -7409,7 +7474,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_sticker_pack( @@ -7422,7 +7487,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_sticker_set( @@ -7436,7 +7501,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_stickers( @@ -7453,7 +7518,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) fun `getUnreadMentions`(`handle`: ULong, `chatId`: Long, `offsetId`: Int, `addOffset`: Int, `limit`: Int, `topMsgId`: Int): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_unread_mentions( @@ -7473,7 +7538,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) fun `getUnreadReactions`(`handle`: ULong, `chatId`: Long, `offsetId`: Int, `addOffset`: Int, `limit`: Int, `topMsgId`: Int): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_unread_reactions( @@ -7490,7 +7555,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_updates_state( @@ -7502,7 +7567,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_wallpapers( @@ -7515,7 +7580,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_web_page( @@ -7529,7 +7594,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_web_page_preview( @@ -7542,7 +7607,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_is_authorized( @@ -7554,7 +7619,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_join_chat( @@ -7566,9 +7631,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_library_version( _status) @@ -7577,7 +7642,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) fun `loadMoreChats`(`handle`: ULong, `offsetDate`: Int, `offsetId`: Int, `offsetPeerId`: Long, `folderId`: Int): List { return FfiConverterSequenceTypeChatDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_load_more_chats( @@ -7593,7 +7658,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_logout( @@ -7604,7 +7669,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_lottie_frame_count( @@ -7616,7 +7681,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_lottie_frame_rate( @@ -7628,7 +7693,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_lottie_size( @@ -7643,7 +7708,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_mark_dialog_unread( @@ -7655,9 +7720,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_peek_message_inline_thumb( @@ -7668,9 +7733,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_peek_peer_inline_thumb( @@ -7681,7 +7746,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) fun `pendingUpdateBatches`(`handle`: ULong): List { return FfiConverterSequenceTypeUpdateBatchDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_pending_update_batches( @@ -7695,9 +7760,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_perf_set_enabled( @@ -7708,9 +7773,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_perf_snapshot( @@ -7720,7 +7785,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_ping_proxy( @@ -7736,9 +7801,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_promote_request_control( @@ -7748,7 +7813,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_read_discussion( @@ -7762,7 +7827,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_read_history( @@ -7778,7 +7843,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_read_mentions( @@ -7795,7 +7860,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer) + @Throws(MtprotoException::class) fun `readMessageContents`(`handle`: ULong, `chatId`: Long, `messageIds`: List) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_read_message_contents( @@ -7811,7 +7876,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_read_reactions( @@ -7824,7 +7889,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer) + @Throws(MtprotoException::class) fun `registerDevice`(`handle`: ULong, `tokenType`: Int, `token`: String, `secret`: ByteArray, `noMuted`: Boolean, `appSandbox`: Boolean, `otherUids`: List) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_register_device( @@ -7840,9 +7905,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_release_request_control( @@ -7851,7 +7916,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_render_lottie_frame( @@ -7866,7 +7931,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_resend_auth_code( @@ -7880,7 +7945,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_reset_notify_settings( @@ -7891,7 +7956,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_resolve_username( @@ -7904,7 +7969,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_search_global( @@ -7922,7 +7987,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) fun `searchMessages`(`handle`: ULong, `chatId`: Long, `query`: String, `limit`: Int): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_search_messages( @@ -7937,7 +8002,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) fun `searchMessagesFiltered`(`handle`: ULong, `chatId`: Long, `query`: String, `filter`: String, `offsetId`: Int, `addOffset`: Int, `limit`: Int): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_search_messages_filtered( @@ -7955,7 +8020,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_search_messages_page( @@ -7973,7 +8038,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_send_auth_code( @@ -7986,7 +8051,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_send_inline_bot_result( @@ -8003,7 +8068,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_send_location( @@ -8020,7 +8085,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_send_photo_message( @@ -8038,7 +8103,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer) + @Throws(MtprotoException::class) fun `sendPollVote`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `options`: List) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_poll_vote( @@ -8052,7 +8117,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_send_reaction( @@ -8067,7 +8132,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_send_saved_gif( @@ -8083,7 +8148,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_send_text_message( @@ -8102,7 +8167,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer, `replyToMsgId`: kotlin.Int, `topMsgId`: kotlin.Int): List { + @Throws(MtprotoException::class) fun `sendUploadedAlbum`(`handle`: ULong, `chatId`: Long, `items`: List, `replyToMsgId`: Int, `topMsgId`: Int): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_uploaded_album( @@ -8118,7 +8183,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_send_uploaded_media( @@ -8135,7 +8200,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_set_client_test_dc( @@ -8147,7 +8212,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_set_contact_joined_silent( @@ -8162,9 +8227,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_dispatch_class( @@ -8172,9 +8237,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_download_chunk_kib( @@ -8185,9 +8250,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_download_concurrency( @@ -8198,7 +8263,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_download_progress_listener( @@ -8206,9 +8271,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_file_part_kib( @@ -8218,7 +8283,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_init_connection_info( @@ -8227,7 +8292,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_set_proxy( @@ -8243,7 +8308,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_set_transport_mode( @@ -8254,7 +8319,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_set_typing( @@ -8267,7 +8332,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_sign_in( @@ -8282,7 +8347,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_start_updates( @@ -8293,7 +8358,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer, `incompleted`: List) + @Throws(MtprotoException::class) fun `toggleTodoCompleted`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `completed`: List, `incompleted`: List) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_toggle_todo_completed( @@ -8308,7 +8373,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_unblock_user( @@ -8320,7 +8385,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer) + @Throws(MtprotoException::class) fun `unregisterDevice`(`handle`: ULong, `tokenType`: Int, `token`: String, `otherUids`: List) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_unregister_device( @@ -8334,7 +8399,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_update_folder( @@ -8346,7 +8411,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer) + @Throws(MtprotoException::class) fun `updateFolderOrder`(`handle`: ULong, `order`: List) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_update_folder_order( @@ -8357,9 +8422,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer) + fun `updateLazySyncConfig`(`handle`: ULong, `lazy`: Boolean, `exceptions`: List) = - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_update_lazy_sync_config( @@ -8370,7 +8435,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_update_notify_settings( @@ -8388,7 +8453,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_update_status( diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionCore.kt b/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionCore.kt index c09e8c645..13ce55fd0 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionCore.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionCore.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -97,6 +98,15 @@ internal class SessionCore( internal var durableSink: DurableUpdateSink? = null internal val replayPending = AtomicBoolean(true) + internal val syncState = MutableStateFlow(MtprotoUpdate.SyncState(false)) + + internal fun publishSyncState(expectedHandle: Long, syncing: Boolean) { + synchronized(handleLock) { + if (!closed && !sessionDead && handle == expectedHandle) { + syncState.value = MtprotoUpdate.SyncState(syncing) + } + } + } /** Wakes the update loop when a handle is created, reconnected, or closed. */ internal val updatesWake = Channel(capacity = Channel.CONFLATED) @@ -203,6 +213,7 @@ internal class SessionCore( internal fun markSessionDegraded(telegram: TelegramError, raw: String = "") { if (sessionDead) return sessionDead = true + syncState.value = MtprotoUpdate.SyncState(false) AppLog.warn("session", "degraded ${nativeFailureLogLine(telegram, raw)}") updatesWake.trySend(Unit) sessionLostFlow.tryEmit(Unit) @@ -329,6 +340,7 @@ internal class SessionCore( } try { if (native.isAuthorized(activeHandle)) { + publishSyncState(activeHandle, true) native.startUpdates(activeHandle) native.updateLazySyncConfig( activeHandle, @@ -378,6 +390,7 @@ internal class SessionCore( val connected = rpc("connect failed") { activeHandle -> refreshDcSidecar(sessionPath) try { + if (updatesStartedHandle == activeHandle) publishSyncState(activeHandle, true) native.connect(activeHandle) if (isCurrentHandle(activeHandle)) connectedHandle = activeHandle } catch (e: Exception) { @@ -707,7 +720,10 @@ internal class SessionCore( internal fun resetHandle() { val oldHandle = synchronized(handleLock) { - handle.also { handle = 0L } + handle.also { + handle = 0L + syncState.value = MtprotoUpdate.SyncState(false) + } } connectedHandle = 0L if (proxyState == ProxyConnectionStatus.Connected) proxyState = diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdatesPump.kt b/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdatesPump.kt index f1aba7555..be6e00557 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdatesPump.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdatesPump.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch +import kotlinx.coroutines.yield import org.monogram.core.common.AppLog import org.monogram.core.common.Outcome import org.monogram.core.common.PerfLog @@ -33,6 +34,17 @@ private const val REPLAY_WAIT_MS = 200L internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { private var premiumRefreshPending = false + private var completionHandle = 0L + private var durablePendingHandle = 0L + + private fun publishCompletionIfReady(handle: Long) { + if (completionHandle == handle && + (durablePendingHandle != handle || !core.replayPending.get()) + ) { + core.publishSyncState(handle, false) + completionHandle = 0L + } + } private var chatsRefreshPending = false private var foldersRefreshPending = false @@ -102,6 +114,7 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { delay(drainDelayMs) continue } + publishCompletionIfReady(activeHandle) val drainAt = if (PerfLog.isEnabled()) System.nanoTime() else 0L val drained = nativeRequest( core.native, @@ -154,10 +167,13 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { } UpdateDrainDto(emptyList(), emptyList(), false) } + if (drain.batches.isNotEmpty() || drain.replayRequired) { + durablePendingHandle = activeHandle + } for (batch in drain.batches) { - deliverEvents(batch.events, batch.accountGeneration, batch.batchId) + deliverEvents(batch.events, batch.accountGeneration, batch.batchId, activeHandle) } - deliverEvents(drain.liveEvents, 0L, 0L) + deliverEvents(drain.liveEvents, 0L, 0L, activeHandle) for (batch in drain.batches) { rememberEmitted(batch) if (!commitAndAcknowledge(activeHandle, batch)) { @@ -165,6 +181,7 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { break } } + publishCompletionIfReady(activeHandle) // Chat metadata events carry no self-user flags. Coalesce the fallback // lookup after delivering messages and bound it during busy updates. val now = core.clock.elapsedMs() @@ -258,7 +275,11 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { } } if (core.isCurrentHandle(activeHandle)) { - delay(drainDelayMs) + if (drain.batches.isNotEmpty() && !core.replayPending.get()) { + yield() + } else { + delay(drainDelayMs) + } } } } @@ -300,7 +321,7 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { AppLog.warn("updates", "durable commit failed") false } - if (!committed) return false + if (!committed || !core.isCurrentHandle(handle)) return false nativeRequest(core.native, core.nativeDispatcher, DispatchClass.BACKGROUND_READ) { core.native.acknowledgeUpdateBatch(handle, batch.accountGeneration, batch.batchId) } @@ -317,9 +338,10 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { nativeRequest(core.native, core.nativeDispatcher, DispatchClass.BACKGROUND_READ) { core.native.pendingUpdateBatches(handle) } + if (!core.isCurrentHandle(handle)) return false for (batch in pending) { if ((batch.accountGeneration to batch.batchId) !in emitted) { - deliverEvents(batch.events, batch.accountGeneration, batch.batchId) + deliverEvents(batch.events, batch.accountGeneration, batch.batchId, handle) rememberEmitted(batch) } if (!commitAndAcknowledge(handle, batch)) return false @@ -332,9 +354,20 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { events: List, accountGeneration: Long, acceptedOrder: Long, + handle: Long, ) { for (event in events) { + if (!core.isCurrentHandle(handle)) return when (event) { + is UpdateEventDto.SyncState -> { + if (event.isSyncing) { + completionHandle = 0L + core.publishSyncState(handle, true) + } else { + completionHandle = handle + } + } + is UpdateEventDto.ChatsChanged -> { chatsRefreshPending = true premiumRefreshPending = true @@ -362,6 +395,7 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { core.updatesWake.trySend(Unit) } - override fun updates(): Flow = merge(core.updatesEvents, core.localUpdates) + override fun updates(): Flow = + merge(core.updatesEvents, core.localUpdates, core.syncState) override fun sessionLost(): Flow = core.sessionLostFlow.asSharedFlow() } diff --git a/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientHistoryTest.kt b/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientHistoryTest.kt index 6f0011672..929ce0211 100644 --- a/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientHistoryTest.kt +++ b/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientHistoryTest.kt @@ -100,7 +100,9 @@ class BridgedMtprotoClientHistoryTest { ) try { val events = mutableListOf() - backgroundScope.async { client.updates().toList(events) } + backgroundScope.async { + client.updates().filter { it !is MtprotoUpdate.SyncState }.toList(events) + } runCurrent() assertTrue(client.readHistory(PeerId(42), 18) is Outcome.Ok) runCurrent() @@ -240,7 +242,7 @@ class BridgedMtprotoClientHistoryTest { seen.filterIsInstance().single().chats.single().title, ) assertEquals( - listOf(true, false), + listOf(false, true, false), seen.filterIsInstance().map { it.isSyncing }, ) collector.cancel() diff --git a/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientLifecycleTest.kt b/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientLifecycleTest.kt index ec7676ee3..2889f97ef 100644 --- a/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientLifecycleTest.kt +++ b/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientLifecycleTest.kt @@ -1,6 +1,7 @@ package org.monogram.network.bridge import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.TimeoutCancellationException @@ -941,17 +942,23 @@ class BridgedMtprotoClientLifecycleTest { private class DurableNative : RecordingNative() { val drainResults = ArrayDeque() + var drainFailure: Exception? = null /** Like the native journal: delivered batches stay until acknowledged. */ val unacked = mutableListOf() + var maxUnacked = 0 val calls = mutableListOf() override fun isAuthorized(handle: Long) = true + override fun getHistory(handle: Long, chatId: Long, limit: Int): List = + emptyList() override fun drainUpdateBatches(handle: Long): UpdateDrainDto { calls += "drain" + drainFailure?.let { failure -> drainFailure = null; throw failure } val result = drainResults.removeFirstOrNull() ?: UpdateDrainDto(emptyList(), emptyList(), false) unacked += result.batches + maxUnacked = maxOf(maxUnacked, unacked.size) return result } @@ -979,6 +986,305 @@ class BridgedMtprotoClientLifecycleTest { testScheduler.runCurrent() } + @Test + fun catchupCompletionWaitsForDurableCommit() = runTest { + val native = DurableNative() + native.drainResults += UpdateDrainDto( + listOf(durableBatch(1)), + listOf(UpdateEventDto.SyncState(true)), + false + ) + native.drainResults += UpdateDrainDto( + listOf(durableBatch(2)), + listOf(UpdateEventDto.SyncState(false)), + false + ) + val client = client(native, StandardTestDispatcher(testScheduler)) + val release = CompletableDeferred() + val committing = CompletableDeferred() + val statuses = mutableListOf() + backgroundScope.launch(StandardTestDispatcher(testScheduler)) { + client.updates().filterIsInstance() + .collect { statuses += it.isSyncing } + } + client.installDurableUpdateSink { batch -> + if (batch.batchId == 2L) { + committing.complete(Unit) + release.await() + } + true + } + try { + testScheduler.runCurrent() + assertTrue(client.connect() is Outcome.Ok) + settle(100) + assertTrue(committing.isCompleted) + assertTrue( + "Final page is not complete while its Room commit is suspended", + statuses.last() + ) + assertTrue(native.calls.none { it == "ack:7:2" }) + release.complete(Unit) + settle(100) + assertEquals(false, statuses.last()) + assertTrue(native.calls.contains("ack:7:2")) + } finally { + release.complete(Unit) + client.close() + } + } + + @Test + fun lateSubscriberReceivesCurrentCatchupStatusWithoutReplayingMessages() = runTest { + val native = DurableNative() + native.drainResults += UpdateDrainDto( + listOf(durableBatch(1)), + listOf(UpdateEventDto.SyncState(true)), + false + ) + val client = client(native, StandardTestDispatcher(testScheduler)) + client.installDurableUpdateSink { true } + val received = mutableListOf() + try { + assertTrue(client.connect() is Outcome.Ok) + settle(100) + backgroundScope.launch(StandardTestDispatcher(testScheduler)) { + client.updates().collect { received += it } + } + testScheduler.runCurrent() + assertEquals(listOf(MtprotoUpdate.SyncState(true)), received) + } finally { + client.close() + } + } + + @Test + fun failedFinalCommitKeepsCatchupActiveUntilReplaySucceeds() = runTest { + val native = DurableNative() + native.drainResults += UpdateDrainDto( + listOf(durableBatch(1)), + listOf(UpdateEventDto.SyncState(false)), + false + ) + val client = client(native, StandardTestDispatcher(testScheduler)) + var allowCommit = false + var attempts = 0 + val received = mutableListOf() + backgroundScope.launch(StandardTestDispatcher(testScheduler)) { + client.updates().collect { received += it } + } + client.installDurableUpdateSink { + attempts++ + allowCommit + } + try { + testScheduler.runCurrent() + assertTrue(client.connect() is Outcome.Ok) + settle(100) + assertTrue(attempts > 1) + assertTrue(received.filterIsInstance().last().isSyncing) + assertEquals(1, native.unacked.size) + assertTrue(native.calls.none { it.startsWith("ack") }) + allowCommit = true + settle(100) + assertEquals( + false, + received.filterIsInstance().last().isSyncing + ) + assertEquals(1, native.calls.count { it.startsWith("ack") }) + assertEquals(1, received.filterIsInstance().size) + assertTrue(native.unacked.isEmpty()) + } finally { + client.close() + } + } + + @Test + fun closingDuringDurableCommitCannotAcknowledgeDestroyedHandle() = runTest { + val native = DurableNative() + native.drainResults += UpdateDrainDto( + listOf(durableBatch(1)), + listOf(UpdateEventDto.SyncState(false)), + false + ) + val client = client(native, StandardTestDispatcher(testScheduler)) + val committing = CompletableDeferred() + val release = CompletableDeferred() + client.installDurableUpdateSink { + committing.complete(Unit) + kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { release.await() } + true + } + try { + assertTrue(client.connect() is Outcome.Ok) + settle(50) + assertTrue(committing.isCompleted) + client.close() + release.complete(Unit) + testScheduler.runCurrent() + assertTrue(native.calls.none { it.startsWith("ack") }) + assertEquals(1, native.unacked.size) + } finally { + release.complete(Unit) + client.close() + } + } + + @Test + fun committedCatchupPagesContinueWithoutAnArtificialSleepAndYieldToHistory() = runTest { + val native = DurableNative() + repeat(16) { index -> + native.drainResults += UpdateDrainDto( + listOf( + durableBatch(index + 1L).copy( + events = List(64) { newMessageEvent(index * 64 + it + 1) }, + cursorAfter = UpdatesStateDto((index + 1) * 64, 0, 1, 1), + ) + ), + listOf(UpdateEventDto.SyncState(index != 15)), + false, + ) + } + val client = client(native, StandardTestDispatcher(testScheduler)) + val publishedAt = mutableListOf() + val acceptedIds = mutableListOf() + var interactiveDrainCount = Int.MAX_VALUE + client.installDurableUpdateSink { batch -> + publishedAt += testScheduler.currentTime + acceptedIds += batch.updates.filterIsInstance() + .map { it.message.id.id } + if (batch.batchId == 1L) { + backgroundScope.launch(StandardTestDispatcher(testScheduler)) { + assertTrue(client.getHistory(PeerId(42)) is Outcome.Ok) + interactiveDrainCount = native.calls.count { it == "drain" } + } + } + true + } + try { + assertTrue(client.connect() is Outcome.Ok) + settle(500) + assertEquals(16, publishedAt.size) + assertEquals((1..1024).toList(), acceptedIds) + assertEquals(1, native.maxUnacked) + assertEquals(16, native.calls.count { it.startsWith("ack") }) + assertTrue( + "Interactive history must run before the backlog finishes: drain=$interactiveDrainCount calls=${native.calls}", + interactiveDrainCount < 16 + ) + assertEquals( + "Ready slices must not incur 15 fixed 25ms sleeps", + 0L, + publishedAt.last() - publishedAt.first() + ) + val drainsBeforeIdle = native.calls.count { it == "drain" } + settle(100) + val idleDrains = native.calls.count { it == "drain" } - drainsBeforeIdle + assertTrue("Idle drains retain pacing: $idleDrains", idleDrains in 3..5) + } finally { + client.close() + } + } + + @Test + fun reconnectMarksCatchupActiveUntilRecoveredPageIsCommitted() = runTest { + val native = DurableNative() + native.drainResults += UpdateDrainDto( + emptyList(), + listOf(UpdateEventDto.SyncState(false)), + false + ) + val client = client(native, StandardTestDispatcher(testScheduler)) + val release = CompletableDeferred() + val committing = CompletableDeferred() + val statuses = mutableListOf() + backgroundScope.launch(StandardTestDispatcher(testScheduler)) { + client.updates().filterIsInstance() + .collect { statuses += it.isSyncing } + } + client.installDurableUpdateSink { + committing.complete(Unit) + release.await() + true + } + try { + testScheduler.runCurrent() + assertTrue(client.connect() is Outcome.Ok) + settle(100) + assertEquals(false, statuses.last()) + native.drainFailure = MtprotoException.Message("connection closed") + native.drainResults += UpdateDrainDto( + listOf(durableBatch(1)), + listOf(UpdateEventDto.SyncState(false)), + false + ) + settle(5000) + assertTrue(committing.isCompleted) + assertEquals(true, statuses.last()) + release.complete(Unit) + settle(100) + assertEquals(false, statuses.last()) + assertEquals(1, native.calls.count { it.startsWith("ack") }) + } finally { + release.complete(Unit) + client.close() + } + } + + @Test + fun retiredClientCompletionCannotClearReplacementCatchup() = runTest { + val oldNative = DurableNative().apply { + drainResults += UpdateDrainDto( + listOf(durableBatch(1)), + listOf(UpdateEventDto.SyncState(false)), + false + ) + } + val nextNative = DurableNative().apply { + drainResults += UpdateDrainDto( + listOf(durableBatch(2).copy(accountGeneration = 8)), + listOf(UpdateEventDto.SyncState(false)), + false + ) + } + val dispatcher = StandardTestDispatcher(testScheduler) + val old = client(oldNative, dispatcher) + val replacement = client(nextNative, dispatcher) + val oldCommit = CompletableDeferred() + val nextCommit = CompletableDeferred() + val statuses = mutableListOf() + old.installDurableUpdateSink { + kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { oldCommit.await() } + true + } + replacement.installDurableUpdateSink { nextCommit.await(); true } + backgroundScope.launch(dispatcher) { + replacement.updates().filterIsInstance() + .collect { statuses += it.isSyncing } + } + try { + old.connect() + settle(50) + old.close() + replacement.connect() + settle(50) + assertEquals(true, statuses.last()) + oldCommit.complete(Unit) + settle(50) + assertEquals(true, statuses.last()) + assertTrue(oldNative.calls.none { it.startsWith("ack") }) + nextCommit.complete(Unit) + settle(50) + assertEquals(false, statuses.last()) + assertEquals(listOf("ack:8:2"), nextNative.calls.filter { it.startsWith("ack") }) + } finally { + oldCommit.complete(Unit) + nextCommit.complete(Unit) + old.close() + replacement.close() + } + } + @Test fun durableBatchIsCommittedBeforeItIsAcknowledged() = runTest { val native = DurableNative()