diff --git a/core/chanora_core/Cargo.toml b/core/chanora_core/Cargo.toml index 3003815..b7b87be 100644 --- a/core/chanora_core/Cargo.toml +++ b/core/chanora_core/Cargo.toml @@ -10,6 +10,7 @@ repository.workspace = true publish.workspace = true [dependencies] +chanora_cache = { path = "../../crates/chanora_cache" } chanora_protocol = { path = "../../crates/chanora_protocol" } chanora_state = { path = "../../crates/chanora_state" } chanora_audio = { path = "../../crates/chanora_audio" } diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 03a4e6e..374d213 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -92,6 +92,9 @@ pub enum CoreError { /// Storage error. #[error("storage: {0}")] Storage(#[from] chanora_storage::StorageError), + /// Blob-cache failure. + #[error("cache: {0}")] + Cache(#[from] chanora_cache::BlobCacheError), /// Diagnostics error. #[error("diagnostics: {0}")] Diagnostics(#[from] chanora_diagnostics::DiagnosticsError), @@ -197,6 +200,7 @@ pub struct ChanoraSession { /// extension). Lives alongside the identity file. Wired by /// [`Self::init_storage`]. bookmark_store: Arc>>, + blob_cache: Arc>>, /// Invisible server-address prefetch cache. Warmed by Flutter typing /// but validated by Rust before Connect can reuse it. server_prefetch: ServerPrefetcher, @@ -252,6 +256,7 @@ impl ChanoraSession { network_tx, identity_store: Arc::new(Mutex::new(None)), bookmark_store: Arc::new(Mutex::new(None)), + blob_cache: Arc::new(Mutex::new(None)), server_prefetch: ServerPrefetcher::new(), voice_selector: selector, release_tail, @@ -337,6 +342,65 @@ impl ChanoraSession { Ok(()) } + /// Configure the blob cache root. + pub async fn init_cache(&self, dir: &str) -> Result<(), CoreError> { + let cache = chanora_cache::BlobCache::new(dir, 100 * 1024 * 1024)?; + cache.evict().await?; + let mut guard = self.blob_cache.lock().await; + *guard = Some(cache); + Ok(()) + } + + /// Resolve avatar bytes. + pub async fn get_avatar( + &self, + avatar_hash: &str, + client_uid: &str, + ) -> Result>, CoreError> { + { + let guard = self.blob_cache.lock().await; + if let Some(ref cache) = *guard { + if let Some(bytes) = cache.get(chanora_cache::PREFIX_AVATAR, avatar_hash).await? { + return Ok(Some(bytes)); + } + } + } + + let guard = self.inner.lock().await; + let state = guard.as_ref().ok_or(CoreError::NotConnected)?; + let bytes = state.protocol.download_avatar(client_uid).await?; + + { + let cache_guard = self.blob_cache.lock().await; + if let Some(ref cache) = *cache_guard { + let _ = cache + .put(chanora_cache::PREFIX_AVATAR, avatar_hash, &bytes) + .await; + } + } + + Ok(Some(bytes)) + } + + /// Purge cached protocol-owned assets. + pub async fn clear_cache(&self) -> Result<(), CoreError> { + let guard = self.blob_cache.lock().await; + if let Some(ref cache) = *guard { + cache.clear().await?; + } + Ok(()) + } + + /// Report the configured blob-cache size. + pub async fn cache_size(&self) -> Result { + let guard = self.blob_cache.lock().await; + if let Some(ref cache) = *guard { + Ok(cache.total_size().await?) + } else { + Ok(0) + } + } + /// List persisted bookmarks. Returns an empty list if the store /// has not been wired or has no entries. pub async fn list_bookmarks(&self) -> Result, CoreError> { diff --git a/core/chanora_core/tests/avatar_cache.rs b/core/chanora_core/tests/avatar_cache.rs new file mode 100644 index 0000000..5a8b01e --- /dev/null +++ b/core/chanora_core/tests/avatar_cache.rs @@ -0,0 +1,50 @@ +use std::env; +use std::path::PathBuf; +use std::process; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[tokio::test] +async fn get_avatar_returns_cached_bytes_without_connection() { + let tmp = mktemp("chanora_core_avatar_cache_test"); + let session = chanora_core::ChanoraSession::new(); + session.init_cache(tmp.to_str().unwrap()).await.unwrap(); + + let cache = chanora_cache::BlobCache::new(&tmp, 100 * 1024 * 1024).unwrap(); + let hash = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"; + cache + .put(chanora_cache::PREFIX_AVATAR, hash, b"avatar-bytes") + .await + .unwrap(); + + let bytes = session + .get_avatar(hash, "client-uid") + .await + .unwrap() + .unwrap(); + assert_eq!(bytes, b"avatar-bytes"); + + let _ = std::fs::remove_dir_all(&tmp); +} + +#[tokio::test] +async fn get_avatar_without_cache_or_connection_returns_not_connected() { + let session = chanora_core::ChanoraSession::new(); + let err = session + .get_avatar("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client-uid") + .await + .unwrap_err(); + + assert!(matches!(err, chanora_core::CoreError::NotConnected)); +} + +fn mktemp(label: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let p = env::temp_dir() + .join(label) + .join(format!("{}-{nanos}", process::id())); + std::fs::create_dir_all(&p).unwrap(); + p +}