zoe_client/
client.rs

1use crate::services::MultiRelayMessageManager;
2use crate::services::blob_store::MultiRelayBlobService;
3use crate::{FileStorage, RelayClient, SessionManager};
4use async_broadcast;
5use eyeball::SharedObservable;
6use std::collections::BTreeMap;
7use std::sync::Arc;
8use tokio::sync::RwLock;
9use tokio::task::JoinHandle;
10use zoe_client_storage::SqliteMessageStorage;
11use zoe_wire_protocol::KeyId;
12
13#[cfg(not(feature = "frb-api"))]
14mod api;
15#[cfg(feature = "frb-api")]
16pub mod api; // needs to be public for flutter rust bridge
17mod builder;
18mod info;
19mod secret;
20
21pub use builder::ClientBuilder;
22pub use info::{
23    OverallConnectionStatus, RelayConnectionInfo, RelayConnectionStatus, RelayInfo,
24    RelayStatusUpdate,
25};
26pub use secret::ClientSecret;
27
28// Re-export from api module
29pub use api::relay::RelayConnectionHandle;
30
31#[cfg(feature = "frb-api")]
32use flutter_rust_bridge::frb;
33
34pub type ZoeClientStorage = SqliteMessageStorage;
35pub type ZoeClientSessionManager = SessionManager<ZoeClientStorage, ZoeClientMessageManager>;
36pub type ZoeClientMessageManager = MultiRelayMessageManager<ZoeClientStorage>;
37pub type ZoeClientBlobService = MultiRelayBlobService<ZoeClientStorage>;
38pub type ZoeClientFileStorage = FileStorage<ZoeClientBlobService>;
39#[derive(Clone)]
40#[cfg_attr(feature = "frb-api", frb(opaque))]
41pub struct Client {
42    pub(crate) client_secret: Arc<ClientSecret>,
43    pub(crate) fs: Arc<ZoeClientFileStorage>,
44    // All clients now use multi-relay architecture
45    pub(crate) storage: Arc<ZoeClientStorage>,
46    pub(crate) message_manager: Arc<ZoeClientMessageManager>,
47    pub(crate) blob_service: Arc<ZoeClientBlobService>,
48    pub(crate) relay_connections: Arc<RwLock<BTreeMap<KeyId, RelayClient>>>,
49    pub(crate) relay_info: Arc<RwLock<BTreeMap<KeyId, RelayConnectionInfo>>>,
50    pub(crate) encryption_key: [u8; 32],
51    /// Observable state for client secret updates - third parties can subscribe to changes
52    pub(crate) client_secret_observable: SharedObservable<ClientSecret>,
53    /// Broadcast channel for per-relay connection status updates
54    pub(crate) relay_status_sender: Arc<async_broadcast::Sender<RelayStatusUpdate>>,
55    /// Keeper receiver to prevent relay status broadcast channel closure (not actively used)
56    /// Arc-wrapped to ensure channel stays open even when Client instances are cloned and dropped
57    _relay_status_keeper: Arc<async_broadcast::InactiveReceiver<RelayStatusUpdate>>,
58    /// Connection monitoring tasks for each relay
59    pub(crate) connection_monitors: Arc<RwLock<BTreeMap<KeyId, JoinHandle<()>>>>,
60    /// Session manager for the client
61    pub(crate) session_manager: Arc<ZoeClientSessionManager>,
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
68    use tempfile::TempDir;
69    use tokio::fs;
70    use zoe_app_primitives::RelayAddress;
71    use zoe_wire_protocol::KeyPair;
72
73    async fn create_test_client_offline() -> (Client, TempDir, TempDir) {
74        let media_temp_dir = TempDir::new().unwrap();
75        let db_temp_dir = TempDir::new().unwrap();
76
77        let mut builder = ClientBuilder::default();
78        builder.media_storage_dir_pathbuf(media_temp_dir.path().to_path_buf());
79        builder.db_storage_dir_pathbuf(db_temp_dir.path().to_path_buf());
80        builder.encryption_key([42u8; 32]);
81        builder.autoconnect(false); // Offline mode
82
83        let client = builder.build().await.unwrap();
84        (client, media_temp_dir, db_temp_dir)
85    }
86
87    #[tokio::test]
88    async fn test_client_file_storage_offline() {
89        let (client, media_temp_dir, _db_temp_dir) = create_test_client_offline().await;
90
91        // Create a test file
92        let test_file_path = media_temp_dir.path().join("test_file.txt");
93        let test_content = b"Hello, offline world!";
94        fs::write(&test_file_path, test_content).await.unwrap();
95
96        // Store the file
97        let file_ref = client.store_file(test_file_path.clone()).await.unwrap();
98        assert!(!file_ref.blob_hash.is_empty());
99
100        // Check if file exists
101        assert!(client.has_file(&file_ref).await.unwrap());
102
103        // Retrieve file as bytes
104        let retrieved_content = client.retrieve_file_bytes(&file_ref).await.unwrap();
105        assert_eq!(retrieved_content, test_content);
106
107        // Retrieve file to disk
108        let output_path = media_temp_dir.path().join("retrieved_file.txt");
109        client
110            .retrieve_file(&file_ref, output_path.clone())
111            .await
112            .unwrap();
113
114        let disk_content = fs::read(&output_path).await.unwrap();
115        assert_eq!(disk_content, test_content);
116    }
117
118    #[tokio::test]
119    async fn test_client_relay_management_offline() {
120        let (client, _media_temp_dir, _db_temp_dir) = create_test_client_offline().await;
121
122        // Initially no relays
123        assert!(client.get_relay_status().await.unwrap().is_empty());
124        assert!(!client.has_connected_relays().await);
125
126        // Adding a relay should fail (no actual server running)
127        let relay_keypair = KeyPair::generate(&mut rand::thread_rng());
128        let relay_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080);
129        let relay_address =
130            RelayAddress::new(relay_keypair.public_key()).with_address(relay_addr.into());
131
132        let result = client.add_relay(relay_address).await;
133        assert!(result.is_err());
134
135        // Relay should be tracked as failed
136        let status = client.get_relay_status().await.unwrap();
137        assert_eq!(status.len(), 1);
138        assert!(matches!(
139            status[0].status,
140            RelayConnectionStatus::Failed { .. }
141        ));
142    }
143
144    #[tokio::test]
145    async fn test_client_builder_validation() {
146        // Missing media storage dir
147        let mut builder = ClientBuilder::default();
148        builder.db_storage_dir_pathbuf(TempDir::new().unwrap().path().to_path_buf());
149        let result = builder.build().await;
150        assert!(result.is_err());
151
152        // Missing db storage dir
153        let mut builder = ClientBuilder::default();
154        builder.media_storage_dir_pathbuf(TempDir::new().unwrap().path().to_path_buf());
155        let result = builder.build().await;
156        assert!(result.is_err());
157
158        // Missing server info in autoconnect mode
159        let mut builder = ClientBuilder::default();
160        builder.media_storage_dir_pathbuf(TempDir::new().unwrap().path().to_path_buf());
161        builder.db_storage_dir_pathbuf(TempDir::new().unwrap().path().to_path_buf());
162        builder.autoconnect(true);
163        let result = builder.build().await;
164        assert!(result.is_err());
165    }
166
167    #[tokio::test]
168    async fn test_client_public_key_access() {
169        let (client, _media_temp_dir, _db_temp_dir) = create_test_client_offline().await;
170
171        let public_key = client.public_key();
172        let keypair = client.keypair();
173
174        // Public key from keypair should match direct access
175        assert_eq!(public_key, keypair.public_key());
176        assert_eq!(client.id_hex(), hex::encode(public_key.id()));
177    }
178
179    #[tokio::test]
180    async fn test_client_close() {
181        let (client, _media_temp_dir, _db_temp_dir) = create_test_client_offline().await;
182
183        // Close should complete without error
184        client.close().await;
185    }
186}