use std::sync::Mutex; #[cfg(not(feature = "core-session"))] use std::collections::HashMap; #[cfg(feature = "core-session")] use tele_core::session::{ CoreAuthState, CoreChatSummary, CoreDownloadedFile, CoreDraft, CoreEvent, CoreFolder, CoreMedia, CoreMessage, CoreNetworkState, CoreProfile, CoreReaction, CoreSearchResult, CoreSession, }; #[cfg(feature = "core-session")] use tele_core::tdlib::{ChatInfo, MessageBuilder, NetworkState, ProfileInfo}; #[cfg(feature = "core-session")] use tele_core::test_support::FakeTdClient; #[cfg(feature = "core-session")] use tele_core::types::{ChatId, MessageId}; uniffi::setup_scaffolding!(); #[derive(Debug, thiserror::Error, uniffi::Error)] pub enum IosFfiError { #[error("{message}")] Operation { message: String }, } impl From for IosFfiError { fn from(message: String) -> Self { Self::Operation { message } } } #[derive(Debug, Clone, uniffi::Record)] pub struct IosSessionConfig { pub account_id: String, pub display_name: String, pub database_path: String, pub use_fake_tdlib: bool, } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] pub enum IosAuthState { WaitTdlibParameters, WaitPhoneNumber, WaitCode, WaitPassword, Ready, Closed, Error { message: String }, } #[cfg(feature = "core-session")] impl From for IosAuthState { fn from(value: CoreAuthState) -> Self { match value { CoreAuthState::WaitTdlibParameters => Self::WaitTdlibParameters, CoreAuthState::WaitPhoneNumber => Self::WaitPhoneNumber, CoreAuthState::WaitCode => Self::WaitCode, CoreAuthState::WaitPassword => Self::WaitPassword, CoreAuthState::Ready => Self::Ready, CoreAuthState::Closed => Self::Closed, CoreAuthState::Error { message } => Self::Error { message }, } } } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] pub enum IosNetworkState { WaitingForNetwork, ConnectingToProxy, Connecting, Updating, Ready, } #[cfg(feature = "core-session")] impl From for IosNetworkState { fn from(value: CoreNetworkState) -> Self { match value { CoreNetworkState::WaitingForNetwork => Self::WaitingForNetwork, CoreNetworkState::ConnectingToProxy => Self::ConnectingToProxy, CoreNetworkState::Connecting => Self::Connecting, CoreNetworkState::Updating => Self::Updating, CoreNetworkState::Ready => Self::Ready, } } } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] pub enum IosDownloadState { NotDownloaded, Downloading, Downloaded { path: String }, Error { message: String }, } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct IosFolder { pub id: i32, pub name: String, } #[cfg(feature = "core-session")] impl From for IosFolder { fn from(value: CoreFolder) -> Self { Self { id: value.id, name: value.name } } } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct IosDraft { pub chat_id: i64, pub text: String, } #[cfg(feature = "core-session")] impl From for IosDraft { fn from(value: CoreDraft) -> Self { Self { chat_id: value.chat_id.as_i64(), text: value.text } } } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct IosChatSummary { pub id: i64, pub title: String, pub username: Option, pub last_message: String, pub last_message_date: i32, pub unread_count: i32, pub unread_mention_count: i32, pub is_pinned: bool, pub order: i64, pub last_read_outbox_message_id: i64, pub folder_ids: Vec, pub is_muted: bool, pub draft: Option, } #[cfg(feature = "core-session")] impl From for IosChatSummary { fn from(value: CoreChatSummary) -> Self { Self { id: value.id.as_i64(), title: value.title, username: value.username, last_message: value.last_message, last_message_date: value.last_message_date, unread_count: value.unread_count, unread_mention_count: value.unread_mention_count, is_pinned: value.is_pinned, order: value.order, last_read_outbox_message_id: value.last_read_outbox_message_id.as_i64(), folder_ids: value.folder_ids, is_muted: value.is_muted, draft: value.draft.map(IosDraft::from), } } } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct IosReaction { pub emoji: String, pub count: i32, pub is_chosen: bool, } #[cfg(feature = "core-session")] impl From for IosReaction { fn from(value: CoreReaction) -> Self { Self { emoji: value.emoji, count: value.count, is_chosen: value.is_chosen, } } } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct IosReply { pub message_id: i64, pub sender_name: String, pub text: String, } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct IosForward { pub sender_name: String, } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct IosMedia { pub kind: String, pub file_id: i32, pub width: Option, pub height: Option, pub duration: Option, pub mime_type: Option, pub waveform: Option, pub download_state: IosDownloadState, } #[cfg(feature = "core-session")] impl From for IosMedia { fn from(value: CoreMedia) -> Self { match value { CoreMedia::Photo(photo) => Self { kind: "photo".to_string(), file_id: photo.file_id, width: Some(photo.width), height: Some(photo.height), duration: None, mime_type: None, waveform: None, download_state: match photo.download_state { tele_core::session::CoreDownloadState::NotDownloaded => { IosDownloadState::NotDownloaded } tele_core::session::CoreDownloadState::Downloading => { IosDownloadState::Downloading } tele_core::session::CoreDownloadState::Downloaded { path } => { IosDownloadState::Downloaded { path } } tele_core::session::CoreDownloadState::Error { message } => { IosDownloadState::Error { message } } }, }, CoreMedia::Voice(voice) => Self { kind: "voice".to_string(), file_id: voice.file_id, width: None, height: None, duration: Some(voice.duration), mime_type: Some(voice.mime_type), waveform: Some(voice.waveform), download_state: match voice.download_state { tele_core::session::CoreDownloadState::NotDownloaded => { IosDownloadState::NotDownloaded } tele_core::session::CoreDownloadState::Downloading => { IosDownloadState::Downloading } tele_core::session::CoreDownloadState::Downloaded { path } => { IosDownloadState::Downloaded { path } } tele_core::session::CoreDownloadState::Error { message } => { IosDownloadState::Error { message } } }, }, } } } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct IosMessage { pub id: i64, pub sender_name: String, pub date: i32, pub edit_date: Option, pub media_album_id: Option, pub text: String, pub media: Option, pub is_outgoing: bool, pub is_read: bool, pub can_be_edited: bool, pub can_be_deleted_only_for_self: bool, pub can_be_deleted_for_all_users: bool, pub reply: Option, pub forward: Option, pub reactions: Vec, } #[cfg(feature = "core-session")] impl From for IosMessage { fn from(value: CoreMessage) -> Self { Self { id: value.id.as_i64(), sender_name: value.sender_name, date: value.date, edit_date: value.edit_date, media_album_id: value.media_album_id, text: value.text, media: value.media.map(IosMedia::from), is_outgoing: value.is_outgoing, is_read: value.is_read, can_be_edited: value.can_be_edited, can_be_deleted_only_for_self: value.can_be_deleted_only_for_self, can_be_deleted_for_all_users: value.can_be_deleted_for_all_users, reply: value.reply.map(|reply| IosReply { message_id: reply.message_id.as_i64(), sender_name: reply.sender_name, text: reply.text, }), forward: value .forward .map(|forward| IosForward { sender_name: forward.sender_name }), reactions: value.reactions.into_iter().map(IosReaction::from).collect(), } } } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct IosSearchResult { pub chat_id: i64, pub message: IosMessage, } #[cfg(feature = "core-session")] impl From for IosSearchResult { fn from(value: CoreSearchResult) -> Self { Self { chat_id: value.chat_id.as_i64(), message: IosMessage::from(value.message), } } } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct IosProfile { pub chat_id: i64, pub title: String, pub username: Option, pub bio: Option, pub phone_number: Option, pub chat_type: String, pub member_count: Option, pub description: Option, pub invite_link: Option, pub is_group: bool, pub online_status: Option, } #[cfg(feature = "core-session")] impl From for IosProfile { fn from(value: CoreProfile) -> Self { Self { chat_id: value.chat_id.as_i64(), title: value.title, username: value.username, bio: value.bio, phone_number: value.phone_number, chat_type: value.chat_type, member_count: value.member_count, description: value.description, invite_link: value.invite_link, is_group: value.is_group, online_status: value.online_status, } } } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct IosDownloadedFile { pub file_id: i32, pub path: String, } #[cfg(feature = "core-session")] impl From for IosDownloadedFile { fn from(value: CoreDownloadedFile) -> Self { Self { file_id: value.file_id, path: value.path } } } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] pub enum IosEvent { AuthChanged { state: IosAuthState, }, ChatListChanged { chats: Vec, }, FolderListChanged { folders: Vec, }, MessageAdded { chat_id: i64, message: IosMessage, }, MessageUpdated { chat_id: i64, message: IosMessage, }, MessageDeleted { chat_id: i64, message_ids: Vec, }, ReactionChanged { chat_id: i64, message_id: i64, reactions: Vec, }, IncomingNotificationCandidate { chat: IosChatSummary, message: IosMessage, sender_name: String, }, NetworkChanged { state: IosNetworkState, }, DraftChanged { draft: IosDraft, }, ProfileLoaded { profile: IosProfile, }, MediaDownloadProgress { file_id: i32, downloaded_size: i64, total_size: i64, }, } #[cfg(feature = "core-session")] impl From for IosEvent { fn from(value: CoreEvent) -> Self { match value { CoreEvent::AuthChanged(state) => Self::AuthChanged { state: state.into() }, CoreEvent::ChatListChanged(chats) => Self::ChatListChanged { chats: chats.into_iter().map(IosChatSummary::from).collect(), }, CoreEvent::FolderListChanged(folders) => Self::FolderListChanged { folders: folders.into_iter().map(IosFolder::from).collect(), }, CoreEvent::MessageAdded { chat_id, message } => { Self::MessageAdded { chat_id: chat_id.as_i64(), message: message.into() } } CoreEvent::MessageUpdated { chat_id, message } => { Self::MessageUpdated { chat_id: chat_id.as_i64(), message: message.into() } } CoreEvent::MessageDeleted { chat_id, message_ids } => Self::MessageDeleted { chat_id: chat_id.as_i64(), message_ids: message_ids.into_iter().map(|id| id.as_i64()).collect(), }, CoreEvent::ReactionChanged { chat_id, message_id, reactions } => { Self::ReactionChanged { chat_id: chat_id.as_i64(), message_id: message_id.as_i64(), reactions: reactions.into_iter().map(IosReaction::from).collect(), } } CoreEvent::IncomingNotificationCandidate(candidate) => { Self::IncomingNotificationCandidate { chat: candidate.chat.into(), message: candidate.message.into(), sender_name: candidate.sender_name, } } CoreEvent::NetworkChanged(state) => Self::NetworkChanged { state: state.into() }, CoreEvent::DraftChanged(draft) => Self::DraftChanged { draft: draft.into() }, CoreEvent::ProfileLoaded(profile) => Self::ProfileLoaded { profile: profile.into() }, CoreEvent::MediaDownloadProgress { file_id, downloaded_size, total_size } => { Self::MediaDownloadProgress { file_id, downloaded_size, total_size } } CoreEvent::TypingChanged(_) => Self::NetworkChanged { state: IosNetworkState::Ready }, } } } #[derive(uniffi::Object)] #[cfg(feature = "core-session")] pub struct SessionHandle { session: Mutex>, runtime: tokio::runtime::Runtime, } #[uniffi::export] #[cfg(feature = "core-session")] pub fn create_session(config: IosSessionConfig) -> Result { if !config.use_fake_tdlib { return Err(IosFfiError::Operation { message: "real TDLib sessions are not exposed by the iOS FFI crate yet".to_string(), }); } let client = seeded_fake_client(); Ok(SessionHandle { session: Mutex::new(CoreSession::new(client)), runtime: tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .map_err(|error| IosFfiError::Operation { message: error.to_string() })?, }) } #[uniffi::export] #[cfg(feature = "core-session")] impl SessionHandle { pub fn auth_state(&self) -> IosAuthState { self.with_session(|session| session.auth_state().into()) } pub fn poll_events(&self) -> Vec { self.with_session(|session| { session.drain_client_events(); session .poll_events() .into_iter() .map(IosEvent::from) .collect() }) } pub fn send_phone_number(&self, phone: String) -> Result<(), IosFfiError> { let session = self.session.lock().expect("session mutex poisoned"); self.runtime .block_on(session.send_phone_number(phone)) .map_err(IosFfiError::from) } pub fn send_code(&self, code: String) -> Result<(), IosFfiError> { let session = self.session.lock().expect("session mutex poisoned"); self.runtime .block_on(session.send_code(code)) .map_err(IosFfiError::from) } pub fn send_password(&self, password: String) -> Result<(), IosFfiError> { let session = self.session.lock().expect("session mutex poisoned"); self.runtime .block_on(session.send_password(password)) .map_err(IosFfiError::from) } pub fn load_chats(&self, limit: i32) -> Result, IosFfiError> { let mut session = self.session.lock().expect("session mutex poisoned"); let chats = self .runtime .block_on(session.client().load_chats(limit as usize)) .map_err(IosFfiError::from)?; let core_chats: Vec<_> = chats.iter().map(CoreChatSummary::from).collect(); session.enqueue_event(CoreEvent::ChatListChanged(core_chats.clone())); Ok(core_chats.into_iter().map(IosChatSummary::from).collect()) } pub fn load_folders(&self) -> Vec { self.with_session(|session| { session .client() .get_folders() .into_iter() .map(|folder| IosFolder::from(CoreFolder::from(&folder))) .collect() }) } pub fn load_folder_chats( &self, folder_id: i32, limit: i32, ) -> Result, IosFfiError> { let mut session = self.session.lock().expect("session mutex poisoned"); self.runtime .block_on( session .client() .load_folder_chats(folder_id, limit as usize), ) .map_err(IosFfiError::from)?; let chats: Vec<_> = session .client() .get_chats() .into_iter() .filter(|chat| chat.folder_ids.contains(&folder_id)) .map(|chat| CoreChatSummary::from(&chat)) .collect(); session.enqueue_event(CoreEvent::ChatListChanged(chats.clone())); Ok(chats.into_iter().map(IosChatSummary::from).collect()) } pub fn load_history(&self, chat_id: i64, limit: i32) -> Result, IosFfiError> { let mut session = self.session.lock().expect("session mutex poisoned"); self.runtime .block_on(session.open_chat_history(ChatId::new(chat_id), limit)) .map(|messages| messages.into_iter().map(IosMessage::from).collect()) .map_err(IosFfiError::from) } pub fn search_messages( &self, chat_id: i64, query: String, ) -> Result, IosFfiError> { let session = self.session.lock().expect("session mutex poisoned"); self.runtime .block_on(session.search_messages(ChatId::new(chat_id), &query)) .map(|results| results.into_iter().map(IosSearchResult::from).collect()) .map_err(IosFfiError::from) } pub fn open_profile(&self, chat_id: i64) -> Result { let mut session = self.session.lock().expect("session mutex poisoned"); self.runtime .block_on(session.open_profile(ChatId::new(chat_id))) .map(IosProfile::from) .map_err(IosFfiError::from) } pub fn send_message( &self, chat_id: i64, text: String, reply_to_message_id: Option, ) -> Result { let mut session = self.session.lock().expect("session mutex poisoned"); self.runtime .block_on(session.send_text_message( ChatId::new(chat_id), text, reply_to_message_id.map(MessageId::new), None, )) .map(IosMessage::from) .map_err(IosFfiError::from) } pub fn edit_message( &self, chat_id: i64, message_id: i64, text: String, ) -> Result { let mut session = self.session.lock().expect("session mutex poisoned"); self.runtime .block_on(session.edit_text_message( ChatId::new(chat_id), MessageId::new(message_id), text, )) .map(IosMessage::from) .map_err(IosFfiError::from) } pub fn delete_messages( &self, chat_id: i64, message_ids: Vec, revoke: bool, ) -> Result<(), IosFfiError> { let mut session = self.session.lock().expect("session mutex poisoned"); self.runtime .block_on(session.delete_messages( ChatId::new(chat_id), message_ids.into_iter().map(MessageId::new).collect(), revoke, )) .map_err(IosFfiError::from) } pub fn forward_messages( &self, to_chat_id: i64, from_chat_id: i64, message_ids: Vec, ) -> Result<(), IosFfiError> { let mut session = self.session.lock().expect("session mutex poisoned"); self.runtime .block_on(session.forward_messages( ChatId::new(to_chat_id), ChatId::new(from_chat_id), message_ids.into_iter().map(MessageId::new).collect(), )) .map_err(IosFfiError::from) } pub fn react( &self, chat_id: i64, message_id: i64, reaction: String, ) -> Result, IosFfiError> { let mut session = self.session.lock().expect("session mutex poisoned"); self.runtime .block_on(session.toggle_reaction( ChatId::new(chat_id), MessageId::new(message_id), reaction, )) .map(|reactions| reactions.into_iter().map(IosReaction::from).collect()) .map_err(IosFfiError::from) } pub fn set_draft(&self, chat_id: i64, text: String) -> Result<(), IosFfiError> { let mut session = self.session.lock().expect("session mutex poisoned"); self.runtime .block_on(session.set_draft(ChatId::new(chat_id), text)) .map_err(IosFfiError::from) } pub fn download_photo(&self, file_id: i32) -> Result { let session = self.session.lock().expect("session mutex poisoned"); self.runtime .block_on(session.download_photo(file_id)) .map(IosDownloadedFile::from) .map_err(IosFfiError::from) } pub fn download_voice(&self, file_id: i32) -> Result { let session = self.session.lock().expect("session mutex poisoned"); self.runtime .block_on(session.download_voice(file_id)) .map(IosDownloadedFile::from) .map_err(IosFfiError::from) } pub fn simulate_incoming_message(&self, chat_id: i64, text: String, sender_name: String) { self.with_session(|session| { session .client() .simulate_incoming_message(ChatId::new(chat_id), text, &sender_name); }); } } impl SessionHandle { #[cfg(feature = "core-session")] fn with_session(&self, f: impl FnOnce(&mut CoreSession) -> R) -> R { let mut session = self.session.lock().expect("session mutex poisoned"); f(&mut session) } } #[cfg(feature = "core-session")] fn seeded_fake_client() -> FakeTdClient { let chat = ChatInfo { id: ChatId::new(1), title: "Saved Messages".to_string(), username: Some("saved".to_string()), last_message: "Hello from fake TDLib".to_string(), last_message_date: 1_700_000_000, unread_count: 1, unread_mention_count: 0, is_pinned: true, order: 1, last_read_outbox_message_id: MessageId::new(1), folder_ids: vec![0], is_muted: false, draft_text: None, }; let message = MessageBuilder::new(MessageId::new(1)) .sender_name("Alice") .text("Hello from fake TDLib") .date(1_700_000_000) .build(); let profile = ProfileInfo { chat_id: chat.id, title: chat.title.clone(), username: chat.username.clone(), bio: Some("Fake profile for iOS bridge tests".to_string()), phone_number: None, chat_type: "Private".to_string(), member_count: None, description: None, invite_link: None, is_group: false, online_status: Some("online".to_string()), }; FakeTdClient::new() .with_chat(chat.clone()) .with_message(chat.id.as_i64(), message) .with_profile(chat.id.as_i64(), profile) .with_network_state(NetworkState::Ready) .with_downloaded_file(100, "/tmp/fake-photo.jpg") .with_downloaded_file(200, "/tmp/fake-voice.ogg") } #[derive(uniffi::Object)] #[cfg(not(feature = "core-session"))] pub struct SessionHandle { state: Mutex, } #[cfg(not(feature = "core-session"))] struct StandaloneFakeState { auth: IosAuthState, chats: Vec, folders: Vec, messages: HashMap>, profiles: HashMap, events: Vec, downloaded_files: HashMap, next_message_id: i64, } #[cfg(not(feature = "core-session"))] impl StandaloneFakeState { fn seeded() -> Self { let chat = IosChatSummary { id: 1, title: "Saved Messages".to_string(), username: Some("saved".to_string()), last_message: "Hello from fake TDLib".to_string(), last_message_date: 1_700_000_000, unread_count: 1, unread_mention_count: 0, is_pinned: true, order: 1, last_read_outbox_message_id: 1, folder_ids: vec![0], is_muted: false, draft: None, }; let message = IosMessage { id: 1, sender_name: "Alice".to_string(), date: 1_700_000_000, edit_date: None, media_album_id: None, text: "Hello from fake TDLib".to_string(), media: None, is_outgoing: false, is_read: false, can_be_edited: false, can_be_deleted_only_for_self: true, can_be_deleted_for_all_users: false, reply: None, forward: None, reactions: Vec::new(), }; let profile = IosProfile { chat_id: chat.id, title: chat.title.clone(), username: chat.username.clone(), bio: Some("Fake profile for iOS bridge tests".to_string()), phone_number: None, chat_type: "Private".to_string(), member_count: None, description: None, invite_link: None, is_group: false, online_status: Some("online".to_string()), }; let mut messages = HashMap::new(); messages.insert(chat.id, vec![message]); let mut profiles = HashMap::new(); profiles.insert(chat.id, profile); let mut downloaded_files = HashMap::new(); downloaded_files.insert(100, "/tmp/fake-photo.jpg".to_string()); downloaded_files.insert(200, "/tmp/fake-voice.ogg".to_string()); Self { auth: IosAuthState::WaitPhoneNumber, chats: vec![chat.clone()], folders: vec![IosFolder { id: 0, name: "All".to_string() }], messages, profiles, events: vec![IosEvent::ChatListChanged { chats: vec![chat] }], downloaded_files, next_message_id: 2, } } fn message_mut( &mut self, chat_id: i64, message_id: i64, ) -> Result<&mut IosMessage, IosFfiError> { self.messages .get_mut(&chat_id) .and_then(|messages| messages.iter_mut().find(|message| message.id == message_id)) .ok_or_else(|| IosFfiError::Operation { message: "message not found".to_string() }) } } #[uniffi::export] #[cfg(not(feature = "core-session"))] pub fn create_session(config: IosSessionConfig) -> Result { if !config.use_fake_tdlib { return Err(IosFfiError::Operation { message: "real TDLib sessions require the core-session build".to_string(), }); } Ok(SessionHandle { state: Mutex::new(StandaloneFakeState::seeded()) }) } #[uniffi::export] #[cfg(not(feature = "core-session"))] impl SessionHandle { pub fn auth_state(&self) -> IosAuthState { self.state .lock() .expect("session mutex poisoned") .auth .clone() } pub fn poll_events(&self) -> Vec { let mut state = self.state.lock().expect("session mutex poisoned"); std::mem::take(&mut state.events) } pub fn send_phone_number(&self, _phone: String) -> Result<(), IosFfiError> { let mut state = self.state.lock().expect("session mutex poisoned"); state.auth = IosAuthState::WaitCode; let auth = state.auth.clone(); state.events.push(IosEvent::AuthChanged { state: auth }); Ok(()) } pub fn send_code(&self, _code: String) -> Result<(), IosFfiError> { let mut state = self.state.lock().expect("session mutex poisoned"); state.auth = IosAuthState::WaitPassword; let auth = state.auth.clone(); state.events.push(IosEvent::AuthChanged { state: auth }); Ok(()) } pub fn send_password(&self, _password: String) -> Result<(), IosFfiError> { let mut state = self.state.lock().expect("session mutex poisoned"); state.auth = IosAuthState::Ready; let auth = state.auth.clone(); state.events.push(IosEvent::AuthChanged { state: auth }); Ok(()) } pub fn load_chats(&self, limit: i32) -> Result, IosFfiError> { let mut state = self.state.lock().expect("session mutex poisoned"); let chats: Vec<_> = state .chats .iter() .take(limit.max(0) as usize) .cloned() .collect(); state .events .push(IosEvent::ChatListChanged { chats: chats.clone() }); Ok(chats) } pub fn load_folders(&self) -> Vec { self.state .lock() .expect("session mutex poisoned") .folders .clone() } pub fn load_folder_chats( &self, folder_id: i32, limit: i32, ) -> Result, IosFfiError> { let mut state = self.state.lock().expect("session mutex poisoned"); let chats: Vec<_> = state .chats .iter() .filter(|chat| chat.folder_ids.contains(&folder_id)) .take(limit.max(0) as usize) .cloned() .collect(); state .events .push(IosEvent::ChatListChanged { chats: chats.clone() }); Ok(chats) } pub fn load_history(&self, chat_id: i64, limit: i32) -> Result, IosFfiError> { let state = self.state.lock().expect("session mutex poisoned"); Ok(state .messages .get(&chat_id) .cloned() .unwrap_or_default() .into_iter() .take(limit.max(0) as usize) .collect()) } pub fn search_messages( &self, chat_id: i64, query: String, ) -> Result, IosFfiError> { let state = self.state.lock().expect("session mutex poisoned"); Ok(state .messages .get(&chat_id) .cloned() .unwrap_or_default() .into_iter() .filter(|message| { query.is_empty() || message.text.contains(&query) || message.sender_name.contains(&query) }) .map(|message| IosSearchResult { chat_id, message }) .collect()) } pub fn open_profile(&self, chat_id: i64) -> Result { let mut state = self.state.lock().expect("session mutex poisoned"); let profile = state .profiles .get(&chat_id) .cloned() .ok_or_else(|| IosFfiError::Operation { message: "profile not found".to_string() })?; state .events .push(IosEvent::ProfileLoaded { profile: profile.clone() }); Ok(profile) } pub fn send_message( &self, chat_id: i64, text: String, reply_to_message_id: Option, ) -> Result { let mut state = self.state.lock().expect("session mutex poisoned"); let message = IosMessage { id: state.next_message_id, sender_name: "Me".to_string(), date: 1_700_000_001, edit_date: None, media_album_id: None, text: text.clone(), media: None, is_outgoing: true, is_read: true, can_be_edited: true, can_be_deleted_only_for_self: true, can_be_deleted_for_all_users: true, reply: reply_to_message_id.map(|message_id| IosReply { message_id, sender_name: "Alice".to_string(), text: format!("Reply to #{message_id}"), }), forward: None, reactions: Vec::new(), }; state.next_message_id += 1; state .messages .entry(chat_id) .or_default() .push(message.clone()); if let Some(chat) = state.chats.iter_mut().find(|chat| chat.id == chat_id) { chat.last_message = text; chat.draft = None; } state .events .push(IosEvent::MessageAdded { chat_id, message: message.clone() }); Ok(message) } pub fn edit_message( &self, chat_id: i64, message_id: i64, text: String, ) -> Result { let mut state = self.state.lock().expect("session mutex poisoned"); let updated = { let message = state.message_mut(chat_id, message_id)?; message.text = text; message.edit_date = Some(1_700_000_002); message.clone() }; state .events .push(IosEvent::MessageUpdated { chat_id, message: updated.clone() }); Ok(updated) } pub fn delete_messages( &self, chat_id: i64, message_ids: Vec, _revoke: bool, ) -> Result<(), IosFfiError> { let mut state = self.state.lock().expect("session mutex poisoned"); state .messages .entry(chat_id) .or_default() .retain(|message| !message_ids.contains(&message.id)); state .events .push(IosEvent::MessageDeleted { chat_id, message_ids }); Ok(()) } pub fn forward_messages( &self, to_chat_id: i64, from_chat_id: i64, message_ids: Vec, ) -> Result<(), IosFfiError> { let mut state = self.state.lock().expect("session mutex poisoned"); let source_messages: Vec<_> = state .messages .get(&from_chat_id) .cloned() .unwrap_or_default() .into_iter() .filter(|message| message_ids.contains(&message.id)) .collect(); for source in source_messages { let forwarded = IosMessage { id: state.next_message_id, sender_name: "Me".to_string(), date: 1_700_000_003, edit_date: None, media_album_id: source.media_album_id, text: source.text, media: source.media, is_outgoing: true, is_read: true, can_be_edited: true, can_be_deleted_only_for_self: true, can_be_deleted_for_all_users: true, reply: None, forward: Some(IosForward { sender_name: source.sender_name }), reactions: Vec::new(), }; state.next_message_id += 1; state .messages .entry(to_chat_id) .or_default() .push(forwarded.clone()); state .events .push(IosEvent::MessageAdded { chat_id: to_chat_id, message: forwarded }); } Ok(()) } pub fn react( &self, chat_id: i64, message_id: i64, reaction: String, ) -> Result, IosFfiError> { let mut state = self.state.lock().expect("session mutex poisoned"); let reactions = { let message = state.message_mut(chat_id, message_id)?; if let Some(index) = message .reactions .iter() .position(|item| item.emoji == reaction) { message.reactions.remove(index); } else { message .reactions .push(IosReaction { emoji: reaction, count: 1, is_chosen: true }); } message.reactions.clone() }; state.events.push(IosEvent::ReactionChanged { chat_id, message_id, reactions: reactions.clone(), }); Ok(reactions) } pub fn set_draft(&self, chat_id: i64, text: String) -> Result<(), IosFfiError> { let mut state = self.state.lock().expect("session mutex poisoned"); let draft = IosDraft { chat_id, text }; if let Some(chat) = state.chats.iter_mut().find(|chat| chat.id == chat_id) { chat.draft = Some(draft.clone()); } state.events.push(IosEvent::DraftChanged { draft }); Ok(()) } pub fn download_photo(&self, file_id: i32) -> Result { self.standalone_download_file(file_id) } pub fn download_voice(&self, file_id: i32) -> Result { self.standalone_download_file(file_id) } pub fn simulate_incoming_message(&self, chat_id: i64, text: String, sender_name: String) { let mut state = self.state.lock().expect("session mutex poisoned"); let message = IosMessage { id: state.next_message_id, sender_name: sender_name.clone(), date: 1_700_000_004, edit_date: None, media_album_id: None, text: text.clone(), media: None, is_outgoing: false, is_read: false, can_be_edited: false, can_be_deleted_only_for_self: true, can_be_deleted_for_all_users: false, reply: None, forward: None, reactions: Vec::new(), }; state.next_message_id += 1; state .messages .entry(chat_id) .or_default() .push(message.clone()); let notification_chat = if let Some(chat) = state.chats.iter_mut().find(|chat| chat.id == chat_id) { chat.last_message = text; Some(chat.clone()) } else { None }; if let Some(chat) = notification_chat { state.events.push(IosEvent::IncomingNotificationCandidate { chat, message: message.clone(), sender_name, }); } state .events .push(IosEvent::MessageAdded { chat_id, message }); } } #[cfg(not(feature = "core-session"))] impl SessionHandle { fn standalone_download_file(&self, file_id: i32) -> Result { let state = self.state.lock().expect("session mutex poisoned"); state .downloaded_files .get(&file_id) .cloned() .map(|path| IosDownloadedFile { file_id, path }) .ok_or_else(|| IosFfiError::Operation { message: "file not found".to_string() }) } } #[cfg(test)] mod tests { use super::*; #[test] fn fake_session_can_load_send_react_search_and_poll_events() { let session = create_session(IosSessionConfig { account_id: "fake".to_string(), display_name: "Fake".to_string(), database_path: "/tmp/fake".to_string(), use_fake_tdlib: true, }) .unwrap(); let chats = session.load_chats(20).unwrap(); assert_eq!(chats.len(), 1); let history = session.load_history(chats[0].id, 20).unwrap(); assert_eq!(history[0].text, "Hello from fake TDLib"); let sent = session .send_message(chats[0].id, "Hi from Swift".to_string(), None) .unwrap(); assert_eq!(sent.text, "Hi from Swift"); let reactions = session .react(chats[0].id, sent.id, "👍".to_string()) .unwrap(); assert_eq!(reactions[0].emoji, "👍"); let results = session .search_messages(chats[0].id, "Swift".to_string()) .unwrap(); assert_eq!(results.len(), 1); session.simulate_incoming_message(chats[0].id, "Incoming".to_string(), "Bob".to_string()); let events = session.poll_events(); assert!(events .iter() .any(|event| matches!(event, IosEvent::MessageAdded { .. }))); assert!(events .iter() .any(|event| matches!(event, IosEvent::ReactionChanged { .. }))); assert!(events .iter() .any(|event| matches!(event, IosEvent::IncomingNotificationCandidate { .. }))); } #[test] fn real_tdlib_sessions_are_reported_as_not_yet_exposed() { let error = match create_session(IosSessionConfig { account_id: "real".to_string(), display_name: "Real".to_string(), database_path: "/tmp/tdlib".to_string(), use_fake_tdlib: false, }) { Ok(_) => panic!("real TDLib session should not be exposed yet"), Err(error) => error, }; assert!(format!("{error}").contains("real TDLib sessions")); } }