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_<crc32u>) cache key format
- 1 new unit test (cached icon hit)
This commit is contained in:
Edison Jwa
2026-06-10 07:38:21 +09:00
parent 82ea1a117f
commit f0e1621fdf
4 changed files with 119 additions and 1 deletions
@@ -252,6 +252,15 @@ Future<List<int>?> downloadAvatar({
}
}
Future<List<int>?> downloadIcon({required BigInt iconId}) {
final api = RustLib.instance.api as dynamic;
try {
return api.crateApiDownloadIcon(iconId: iconId) as Future<List<int>?>;
} on NoSuchMethodError {
return Future.value();
}
}
/// List persisted bookmarks.
Future<List<BridgeBookmark>> listBookmarks() =>
RustLib.instance.api.crateApiListBookmarks();
+89 -1
View File
@@ -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<Option<Vec<u8>>, 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<Option<Vec<u8>>, 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);
}
}
+12
View File
@@ -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<Option<Vec<u8>>, 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() };
+9
View File
@@ -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<Option<Vec<u8>>, 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()