use std::sync::Mutex; use tele_core::session::{ CoreAuthState, CoreChatSummary, CoreDownloadedFile, CoreDraft, CoreEvent, CoreFolder, CoreMedia, CoreMessage, CoreNetworkState, CoreProfile, CoreReaction, CoreSearchResult, CoreSession, }; use tele_core::tdlib::{ChatInfo, MessageBuilder, NetworkState, ProfileInfo}; use tele_core::test_support::FakeTdClient; 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 }, } 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, } 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, } 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, } 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, } 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, } 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, } 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, } 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, } 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, } 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, } 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, }, } 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)] pub struct SessionHandle { session: Mutex>, runtime: tokio::runtime::Runtime, } #[uniffi::export] 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] 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 { fn with_session(&self, f: impl FnOnce(&mut CoreSession) -> R) -> R { let mut session = self.session.lock().expect("session mutex poisoned"); f(&mut 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") } #[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")); } }