From f0e1621fdfbfb0fde85b811cc018d3354b150087 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Wed, 10 Jun 2026 07:38:21 +0900 Subject: [PATCH] feat(core,bridge): add get_icon with coalescing and negative cache - FileTransferService::get_icon() mirrors get_avatar pattern - ChanoraSession::get_icon() delegates through FileTransferService - Bridge download_icon() exposed for Flutter - Dart downloadIcon() shim added - Uses PREFIX_ICON (ic_) cache key format - 1 new unit test (cached icon hit) --- apps/chanora_flutter/lib/src/rust/api.dart | 9 +++ core/chanora_core/src/file_transfer.rs | 90 +++++++++++++++++++++- core/chanora_core/src/lib.rs | 12 +++ crates/chanora_bridge/src/api.rs | 9 +++ 4 files changed, 119 insertions(+), 1 deletion(-) diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index 9ce450c..c743d12 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -252,6 +252,15 @@ Future?> downloadAvatar({ } } +Future?> downloadIcon({required BigInt iconId}) { + final api = RustLib.instance.api as dynamic; + try { + return api.crateApiDownloadIcon(iconId: iconId) as Future?>; + } on NoSuchMethodError { + return Future.value(); + } +} + /// List persisted bookmarks. Future> listBookmarks() => RustLib.instance.api.crateApiListBookmarks(); diff --git a/core/chanora_core/src/file_transfer.rs b/core/chanora_core/src/file_transfer.rs index 28d4540..c53c86f 100644 --- a/core/chanora_core/src/file_transfer.rs +++ b/core/chanora_core/src/file_transfer.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; -use chanora_cache::{BlobCache, BlobCacheError, PREFIX_AVATAR}; +use chanora_cache::{BlobCache, BlobCacheError, PREFIX_AVATAR, PREFIX_ICON}; use chanora_protocol::{ProtocolClient, ProtocolError}; use tokio::sync::{Mutex, Semaphore, oneshot}; use tracing::warn; @@ -103,6 +103,50 @@ impl FileTransferService { result } + pub async fn get_icon(&self, icon_id: u64) -> Result>, FileTransferError> { + let icon_key = icon_id.to_string(); + let negative_key = format!("ic_{icon_id}"); + let in_flight_key = format!("icon_{icon_id}"); + + if let Some(bytes) = self.cache.get(PREFIX_ICON, &icon_key).await? { + return Ok(Some(bytes)); + } + + if self.is_negative_cache_hit(&negative_key).await { + return Ok(None); + } + + let rx = { + let mut in_flight = self.in_flight.lock().await; + if let Some(waiters) = in_flight.get_mut(&in_flight_key) { + let (tx, rx) = oneshot::channel(); + waiters.push(tx); + Some(rx) + } else { + in_flight.insert(in_flight_key.clone(), Vec::new()); + None + } + }; + + if let Some(rx) = rx { + return rx.await.unwrap_or_else(|_| { + Err(FileTransferError::Protocol(ProtocolError::Lost( + "coalesced icon download waiter dropped".to_string(), + ))) + }); + } + + let _permit = self + .semaphore + .acquire() + .await + .expect("file transfer semaphore should stay open"); + + let result = self.do_download_icon(icon_id).await; + self.finish_in_flight(&in_flight_key, &result).await; + result + } + pub async fn clear_cache(&self) -> Result<(), FileTransferError> { self.cache.clear().await?; self.negative_cache.lock().await.clear(); @@ -145,6 +189,37 @@ impl FileTransferService { } } + async fn do_download_icon(&self, icon_id: u64) -> Result>, FileTransferError> { + let icon_key = icon_id.to_string(); + let negative_key = format!("ic_{icon_id}"); + + if let Some(bytes) = self.cache.get(PREFIX_ICON, &icon_key).await? { + return Ok(Some(bytes)); + } + + if self.is_negative_cache_hit(&negative_key).await { + return Ok(None); + } + + let protocol = self.protocol.lock().await; + let client = protocol.as_ref().ok_or(FileTransferError::NotConnected)?; + match client.download_icon(icon_id).await { + Ok(bytes) => { + self.cache.put(PREFIX_ICON, &icon_key, &bytes).await?; + self.negative_cache.lock().await.remove(&negative_key); + Ok(Some(bytes)) + } + Err(ProtocolError::ServerRejected { .. }) => { + self.negative_cache + .lock() + .await + .insert(negative_key, Instant::now() + NEGATIVE_CACHE_TTL); + Ok(None) + } + Err(error) => Err(FileTransferError::Protocol(error)), + } + } + async fn finish_in_flight( &self, avatar_hash: &str, @@ -253,4 +328,17 @@ mod tests { assert_eq!(avatar, None); let _ = std::fs::remove_dir_all(cache_dir); } + + #[tokio::test] + async fn returns_cached_icon_without_connection() { + let cache_dir = test_cache_dir("icon-cache-hit"); + let cache = BlobCache::new(&cache_dir, 1024).unwrap(); + cache.put(PREFIX_ICON, "12345", b"icon").await.unwrap(); + let service = FileTransferService::new(cache, Arc::new(Mutex::new(None))); + + let icon = service.get_icon(12345).await.unwrap(); + + assert_eq!(icon, Some(b"icon".to_vec())); + let _ = std::fs::remove_dir_all(cache_dir); + } } diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 9b4120c..b06a23e 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -390,6 +390,18 @@ impl ChanoraSession { Ok(Some(client.download_avatar(client_uid).await?)) } + /// Resolve icon bytes. + pub async fn get_icon(&self, icon_id: u64) -> Result>, CoreError> { + let service = { self.file_transfer.lock().await.clone() }; + if let Some(service) = service { + return Ok(service.get_icon(icon_id).await?); + } + + let protocol = self.protocol.lock().await; + let client = protocol.as_ref().ok_or(CoreError::NotConnected)?; + Ok(Some(client.download_icon(icon_id).await?)) + } + /// Purge cached protocol-owned assets. pub async fn clear_cache(&self) -> Result<(), CoreError> { let service = { self.file_transfer.lock().await.clone() }; diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index a808484..50f04fa 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -1486,6 +1486,15 @@ pub async fn download_avatar( .map_err(BridgeError::from) } +/// Resolve icon bytes through the bridge. +pub async fn download_icon(icon_id: u64) -> Result>, BridgeError> { + runtime() + .spawn(async move { session().get_icon(icon_id).await }) + .await + .map_err(|e| task_join_error("download_icon", e))? + .map_err(BridgeError::from) +} + /// Purge cached protocol-owned assets. pub async fn clear_file_cache() -> Result<(), BridgeError> { runtime()