31 KiB
File Transfer Design
Date: 2026-06-10
Status: Draft for review
Scope: Download files from TeamSpeak-compatible servers via the native client protocol, starting with avatars and icons.
Direct upstream source: docs/architecture/sad.md (SAD-067, SDD-MOD-009)
1. Goal
Chanora needs to download files stored on TeamSpeak-compatible servers. The most visible use cases are client avatars and server/channel/client icons. The file transfer mechanism is also used for channel file browser features, but this document scopes the initial design to avatar and icon retrieval only.
This document describes:
- How the TeamSpeak file transfer protocol works.
- How
tsclientlibexposes it. - How Chanora should integrate it following the existing protocol adapter pattern.
- How the result flows through the bridge to the Flutter UI layer.
Upload, channel file browsing, and file deletion are explicitly out of scope for the initial implementation.
2. Protocol Background
2.1 Two-Phase Transfer
TeamSpeak file transfer is a two-phase process:
- Command phase — The client sends a command over the main encrypted UDP connection to request a transfer token (
ftkey). - Transfer phase — The client opens a separate TCP connection to the server's file transfer port (default
30033) and sends theftkeyto authenticate the transfer. Raw bytes flow over this TCP stream.
2.2 Relevant ServerQuery Commands
| Command | Direction | Purpose |
|---|---|---|
ftinitdownload |
Client → Server | Initialize a download. Returns ftkey, port, size. |
ftgetfileinfo |
Client → Server | Get metadata for one or more files. |
ftgetfilelist |
Client → Server | List files in a channel's file repository. |
ftinitupload |
Client → Server | Initialize an upload. |
ftlist |
Client → Server | List active file transfers. |
ftstop |
Client → Server | Stop a running transfer. |
ftdeletefile |
Client → Server | Delete a file. |
ftcreatedir |
Client → Server | Create a directory. |
ftrenamefile |
Client → Server | Rename or move a file. |
Initial scope uses only ftinitdownload and ftgetfileinfo.
2.3 File Paths
Files are addressed by a path scoped to a channel ID (cid):
cid=0— Server-level file repository. Avatars and icons live here.cid=N(non-zero) — Channel-specific file repository.
Avatar path: /avatar_<hex> where <hex> is derived from the client's unique identifier (UID). Each byte of the base64-decoded UID is split into two nibbles, and each nibble maps to a letter a through p (0→a, 1→b, ..., 15→p).
Icon path: /icon_<id> where <id> is the icon's signed 64-bit integer ID. If negative, treat as unsigned for the path.
2.4 ftinitdownload Command
ftinitdownload clientftfid={id} name={path} cid={channelId} cpw={password} seekpos={seek} proto=0
Parameters:
| Parameter | Type | Description |
|---|---|---|
clientftfid |
u16 |
Arbitrary client-side transfer ID. |
name |
string |
File path, e.g. /avatar_abcdef. |
cid |
ChannelId |
Channel scope (0 = server). |
cpw |
string |
Channel password. Empty for server-level. |
seekpos |
u64 |
Resume offset. 0 for a fresh download. |
proto |
u8 |
Protocol version. Always 0. |
Server response:
| Field | Type | Description |
|---|---|---|
clientftfid |
u16 |
Echo of the client transfer ID. |
serverftfid |
u16 |
Server-side transfer ID. |
ftkey |
string |
One-time transfer key (hex). |
port |
u16 |
File transfer TCP port (usually 30033). |
size |
u64 |
File size in bytes. |
proto |
u8 |
Protocol version echo. |
ip |
string (optional) |
Override IP for the TCP connection. |
2.5 TCP Transfer
After receiving the ftkey, the client:
- Opens a TCP connection to
server_ip:port. - Sends
ftkeyfollowed by a newline. - Reads exactly
sizebytes of raw file data. - Closes the TCP connection.
2.6 Permissions
File transfer requires the following permissions on the server:
| Permission | Needed for |
|---|---|
i_ft_file_download_power |
Downloading files. |
i_ft_needed_file_download_power |
Required download power on the channel/server. |
b_ft_ignore_password |
Bypassing channel passwords (not needed for avatars). |
Avatar downloads typically require only basic download power because avatars are in the server-level repository (cid=0), which is generally accessible.
2.7 Avatar Detection
When a client connects or updates, the server sends client_flag_avatar as a string (the avatar hash). If non-empty, the client has an avatar. The avatar is downloaded from /avatar_<hex> where <hex> is computed from the client's UID (not from the hash string itself — the hash is just a presence indicator).
3. tsclientlib Support
tsclientlib implements file transfer natively. The library handles the entire command + TCP flow internally:
3.1 Public API
// tsclientlib/src/lib.rs (relevant signatures)
impl Connection {
pub fn download_file(
&mut self,
channel_id: ChannelId,
path: &str,
channel_password: Option<&str>,
seek_position: Option<u64>,
) -> Result<FiletransferHandle>;
pub fn upload_file(
&mut self,
channel_id: ChannelId,
path: &str,
channel_password: Option<&str>,
size: u64,
overwrite: bool,
resume: bool,
) -> Result<FiletransferHandle>;
}
download_file sends the ftinitdownload command and returns a FiletransferHandle(u16) immediately. The actual transfer completes asynchronously.
3.2 Stream Items
The connection's event stream emits:
| StreamItem | When | Data |
|---|---|---|
StreamItem::FileDownload(FileDownloadResult) |
Server responds with ftkey; TCP connected and ftkey written |
{ size: u64, stream: TcpStream } |
StreamItem::FileUpload(FileUploadResult) |
Upload ready | { seek_position: u64, stream: TcpStream } |
StreamItem::FiletransferFailed(FiletransferHandle, Error) |
Transfer failed | Handle + error |
When FileDownload fires, tsclientlib has already:
- Sent
ftinitdownloadover the encrypted UDP command channel. - Received the
ftkey,port, andsizefrom the server. - Opened a TCP connection to
server:port. - Written the
ftkeyto the TCP socket.
The TcpStream in FileDownloadResult is ready to read; Chanora only needs to read exactly size bytes.
3.3 Avatar Helper
tsproto-types provides Uid::as_avatar() which computes the avatar filename from a UID. Chanora's existing uid_to_avatar_path() in adapter.rs does the same thing independently.
3.4 Doc-Comment Examples
tsclientlib's source contains usage examples in doc comments:
/// Download an icon:
/// con.download_file(ChannelId(0), &format!("/icon_{}", icon_id), None, None)
/// Upload an avatar:
/// con.upload_file(ChannelId(0), "/avatar", None, data.len() as u64, true, false)
4. Architecture Integration
4.1 Existing Pattern
The protocol adapter (crates/chanora_protocol) uses a single tokio task that owns the tsclientlib::Connection. All operations follow this pattern:
- Define a
Requestenum variant with parameters and aoneshot::Senderfor the reply. - Send the request through the
mpscchannel to the connection task. - The connection task calls tsclientlib and resolves the oneshot.
File transfer fits this pattern exactly. The only difference is that the result arrives asynchronously via StreamItem::FileDownload rather than immediately from the command call.
4.2 Design
The file transfer integration adds:
Requestvariants for file download.- A pending-downloads map (
HashMap<FiletransferHandle, DownloadContext>) in the connection task, mirroring the existingpending_movespattern. StreamItem::FileDownloadandStreamItem::FiletransferFailedhandling in the event loop.- New DTOs for file transfer results.
- Convenience methods on
ProtocolClientfor avatar and icon downloads.
4.3 Layer Responsibilities
| Layer | Responsibility |
|---|---|
chanora_protocol |
Call tsclientlib::download_file, track pending transfers, read TcpStream, return bytes. No tsclientlib types leak. |
chanora_core |
Orchestrate when to download (e.g., on profile fetch or on avatar cache miss). |
chanora_bridge |
Expose typed download_avatar / download_icon commands to Flutter. |
| Flutter UI | Call bridge, display with Image.memory(). Cache in memory/image cache. |
4.4 Error Mapping
File transfer errors map to the existing ProtocolError variants:
| tsclientlib error | ProtocolError |
|---|---|
| Permission denied (TS3 error code) | ServerRejected { code, message } |
| File not found | ServerRejected { code, message } |
| Network/TCP failure | Backend(String) |
| Timeout | Timeout |
| Connection lost mid-transfer | Lost(String) |
5. Detailed Design
5.1 New Types in dto.rs
/// A downloaded file's raw content and metadata.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DownloadedFile {
/// Raw file bytes.
pub data: Vec<u8>,
/// The server path that was requested.
pub path: String,
/// Channel ID the file was downloaded from.
pub channel_id: u64,
}
5.2 New Request Variants in adapter.rs
enum Request {
// ... existing variants ...
/// Download a file from the server's file repository.
DownloadFile {
/// Channel ID. 0 for server-level (avatars, icons).
channel_id: u64,
/// File path, e.g. "/avatar_abcdef" or "/icon_12345".
path: String,
/// Channel password. None for server-level files.
channel_password: Option<String>,
/// Reply channel for the result.
reply: oneshot::Sender<Result<DownloadedFile, ProtocolError>>,
},
}
5.3 Pending Downloads Map
type PendingDownloads = HashMap<tsclientlib::FiletransferHandle, PendingDownload>;
struct PendingDownload {
path: String,
channel_id: u64,
reply: oneshot::Sender<Result<DownloadedFile, ProtocolError>>,
}
5.4 Event Loop Handling
In the connection task's main loop, add handling for file transfer stream items:
// In handle_non_audio_stream_item or in the main loop:
StreamItem::FileDownload(result) => {
// result: FileDownloadResult { size, stream }
// Look up the handle in pending_downloads
// Use tokio::io::AsyncReadExt::read_exact to read 'size' bytes
// Resolve the oneshot with DownloadedFile
}
StreamItem::FiletransferFailed(handle, error) => {
// Look up the handle in pending_downloads
// Resolve the oneshot with ProtocolError::Backend
}
The TCP read from the TcpStream is an async operation. Since the connection task already runs in a tokio context, the read can be done inline. However, for large files this would block the main event loop. Two approaches:
Option A: Read inline (simple, good for small files like avatars)
Avatars are typically under 100 KB. Reading them inline in the event loop is acceptable and avoids complexity.
Option B: Spawn a reader task
For future channel-file-browser support with potentially large files, spawn a separate tokio task that reads the stream and sends the result back.
Recommendation: Start with Option A. The initial scope is avatars and icons (small files). Refactor to Option B when channel file browsing is implemented.
5.5 Request Handling
When the connection task receives Request::DownloadFile:
Ok(Request::DownloadFile { channel_id, path, channel_password, reply }) => {
let ts_channel_id = TsChannelId(channel_id);
match con.download_file(ts_channel_id, &path, channel_password.as_deref(), None) {
Ok(handle) => {
pending_downloads.insert(handle, PendingDownload {
path,
channel_id,
reply,
});
}
Err(e) => {
let _ = reply.send(Err(ProtocolError::Backend(
format!("download_file init: {e}")
)));
}
}
}
5.6 Public API on ProtocolClient
impl ProtocolClient {
/// Download a file from the server's file repository.
/// `channel_id` 0 means server-level (avatars, icons).
pub async fn download_file(
&self,
channel_id: u64,
path: String,
channel_password: Option<String>,
) -> Result<DownloadedFile, ProtocolError> {
let (tx, rx) = oneshot::channel();
self.tx
.send(Request::DownloadFile { channel_id, path, channel_password, reply: tx })
.await
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
rx.await
.map_err(|_| ProtocolError::Lost("download_file reply dropped".to_string()))?
}
/// Download a client's avatar image. Returns raw image bytes.
/// Pass the `avatar_path` from `ClientProfile`.
pub async fn download_avatar(
&self,
avatar_path: String,
) -> Result<DownloadedFile, ProtocolError> {
self.download_file(0, avatar_path, None).await
}
/// Download a server, channel, or client icon by its icon ID.
pub async fn download_icon(
&self,
icon_id: i64,
) -> Result<DownloadedFile, ProtocolError> {
let unsigned_id = icon_id as u64;
let path = format!("/icon_{}", unsigned_id);
self.download_file(0, path, None).await
}
}
5.7 Exports in lib.rs
pub use dto::DownloadedFile;
5.8 Bridge Layer
In crates/chanora_bridge/src/api.rs, add:
pub async fn download_avatar(&self, avatar_path: String) -> Result<Vec<u8>, BridgeError> {
self.protocol
.download_avatar(avatar_path)
.await
.map(|file| file.data)
.map_err(BridgeError::Protocol)
}
5.9 Flutter Integration
Flutter side:
- Call
clientProfile()to getClientProfile(already exists). - Check if
avatarPathis non-empty. - Call bridge
downloadAvatar(avatarPath)to getUint8List. - Display with
Image.memory(bytes).
Caching strategy:
- In-memory: Use Flutter's standard
ImageCacheor a simpleMap<String, Uint8List>keyed by avatar path. - Disk: Consider caching to local storage for offline display. This is a follow-up decision, not MVP scope.
- The avatar path already encodes the UID, so it can serve as a cache key.
6. Avatar Path Computation
Chanora already has this implemented in adapter.rs:
fn uid_to_avatar_path(uid_b64: &str) -> String {
let decoded = BASE64_STANDARD.decode(uid_b64).unwrap_or_default();
let mut rendered = String::with_capacity(decoded.len() * 2);
for byte in decoded {
rendered.push((b'a' + (byte >> 4)) as char);
rendered.push((b'a' + (byte & 0x0f)) as char);
}
rendered
}
This maps each nibble to a through p (0→a, 1→b, ..., 15→p), matching the canonical TeamSpeak implementation.
The full avatar path is constructed as:
let avatar_path = if client.avatar_hash.is_empty() || unique_id.is_empty() {
String::new()
} else {
format!("/avatar_{}", uid_to_avatar_path(&unique_id))
};
This is already correct and used in ClientProfile.avatar_path. No changes needed.
7. Threading and Concurrency
| Concern | Design |
|---|---|
| TCP read blocking the event loop | For avatar/icon sizes (< 100 KB typically), inline async read is acceptable. Spawn a reader task for larger files when channel file browsing is added. |
| Multiple concurrent downloads | pending_downloads is a HashMap keyed by FiletransferHandle. Multiple downloads can be in flight simultaneously. tsclientlib assigns unique handles. |
| Download timeout | Add a deadline to pending downloads (e.g., 30 seconds). Sweep expired entries similar to the existing pending_moves sweep. |
| Cancellation on disconnect | When the connection task exits, all pending oneshot senders are dropped, which resolves the caller's await with a RecvError. The caller maps this to ProtocolError::Lost. |
8. Diagnostic and Security Considerations
8.1 Diagnostic Redaction
- File transfer paths may contain user-identifying information (UID-derived avatar names). These should be registered for diagnostic redaction if they appear in log output.
- File contents (avatar images) must not appear in log output or diagnostic exports.
8.2 Security
- The
ftkeyis a one-time token and must not be logged. - TCP file transfer connections are not encrypted. This is a TeamSpeak protocol limitation, not a Chanora design choice. Avatar data is public (visible to anyone on the server), so the risk is acceptable.
- File download does not require secrets beyond the existing authenticated connection.
8.3 Privacy
- Avatar downloads reveal to the server that the user is viewing a specific client's avatar. This is inherent in the protocol.
- Chanora should not download avatars proactively for all clients. Download only when the UI needs to display a specific avatar (lazy/on-demand).
9. Out of Scope
The following are explicitly deferred:
- File upload (avatar upload, channel file upload).
- Channel file browser (listing, creating directories, deleting, renaming).
- Resumable downloads (seek position > 0).
- File transfer progress reporting.
- myTeamSpeak avatar resolution (the
client_myteamspeak_avatarfield). - In-memory hot cache in Rust (Flutter's
ImageCachehandles decoded image caching; add Rust-side layer only if profiling shows need). - Upload, file browser, and channel file management.
10. Cache Architecture
10.1 Layer Ownership
| Layer | Responsibility | Storage |
|---|---|---|
chanora_protocol |
Download raw bytes from server. No caching logic. | None |
chanora_cache |
Content-addressed blob store backed by cacache: crash-safe writes, SSRI integrity verification, key validation, eviction, clear. Separate crate from chanora_storage. |
Platform cache directory |
chanora_core |
Session-aware cache orchestration: check freshness, coalesce requests, rate-limit downloads, persist to disk via chanora_cache. |
Delegates to chanora_cache |
chanora_bridge |
Expose typed download_avatar / clear_file_cache / file_cache_size to Flutter. |
None |
| Flutter | Display via Image.memory. Standard ImageCache for hot memory caching. Evict from ImageCache when hash changes. |
In-memory only |
10.2 Why Separate chanora_cache Crate
chanora_cache is a separate crate from chanora_storage for three reasons:
- Different durability semantics.
chanora_storageholds identity, bookmarks, and connection profiles — data the user explicitly created.chanora_cacheholds downloaded blobs that are fully reconstructible from the server. Losing the cache is an inconvenience, not data loss. - Different backup semantics. Cache should be excluded from backups; persistent storage should be included. Platform conventions (iOS
Library/Caches/vsLibrary/Application Support/) reflect this distinction. - Different directory placement. Cache lives in the platform's cache directory (OS may evict under storage pressure on mobile). Persistent storage lives in the support directory.
The cache wraps the cacache crate for production-tested crash safety and integrity verification. It does not share chanora_storage's crate or directory, and does not reimplement cacache's atomic write or content-addressing logic.
10.3 Why Hybrid (Rust Disk + Flutter Memory)
- Flutter's built-in
ImageCacheis an LRU in-memory cache (default 1000 images / 100 MiB). It handles hot display caching automatically when you useMemoryImage. - Flutter has no built-in disk cache.
cached_network_image/flutter_cache_managerare designed for HTTP URLs, not custom binary protocol data. - Rust already owns the protocol, the connection state, and the anti-flood budget. Putting disk cache here avoids a feedback loop across the bridge.
10.4 Cache Storage
chanora_cache wraps the cacache crate for its on-disk storage. The physical layout is managed by cacache:
<app_cache_dir>/chanora/
blobs/ ← cacache content store root
content-v2/ ← content-addressed by SHA-512
<sha512-hex>/
data ← raw blob bytes
tmp/ ← temp files (in-flight writes)
index-v2/ ← entry index (key → content mapping)
Chanora's BlobCache maps protocol keys to cacache string keys:
| Protocol key | cacache key | Example |
|---|---|---|
| Avatar MD5 | "av_<md5hex>" |
"av_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" |
| Icon CRC32 | "ic_<crc32u>" |
"ic_123456789" |
Why cacache:
- Crash safety. Production-tested atomic writes (temp → rename). Handles partial writes, power loss, crash mid-write. No custom crash safety code to maintain.
- Integrity verification. SSRI integrity check on every
read(). Detects corruption, bit rot, partial writes automatically. Better than custom "delete on read failure". - Content dedup. Same bytes stored once regardless of key. Same avatar on two servers = stored once automatically.
- Less code to maintain. ~120 LOC wrapper vs ~200 LOC custom implementation. Crash safety and integrity are the hard parts —
cacacheowns them.
Why no <server_uid> subdirectory: The protocol uses content-addressed identifiers. client_flag_avatar is the MD5 of the avatar bytes — a given avatar hash always maps to the same bytes regardless of which server the user is on. Same avatar on two servers = same content = stored once by cacache. This is a deliberate dedup advantage over per-server namespacing.
Why no metadata sidecars: Content is immutable (a given hash always maps to the same bytes). cacache manages its own entry index with timestamps. No custom metadata files needed.
Key validation rules:
| Prefix | Key format | Validation |
|---|---|---|
av_ |
av_<32 hex chars> |
MD5 is exactly 32 hex characters |
ic_ |
ic_<1-10 digit number> |
CRC32 unsigned, 0–4294967295 |
Keys failing validation are rejected at the BlobCache API boundary. This prevents path traversal or malformed filenames on disk.
Platform paths (Flutter passes the base directory into Rust at startup, matching the existing initStorage pattern):
| Platform | Cache directory |
|---|---|
| Android | context.cacheDir/chanora/ (via getCacheDir()) |
| iOS | Library/Caches/chanora/ (via getApplicationCacheDirectory()) |
| macOS | ~/Library/Caches/chanora/ |
| Windows | %LOCALAPPDATA%/chanora/cache/ |
| Linux | $XDG_CACHE_HOME/chanora/ or ~/.cache/chanora/ |
Flutter already resolves platform-specific paths. The same getApplicationCacheDirectory() call that is available in path_provider across all Chanora target platforms should be used. This follows the existing pattern where app_bootstrap.dart calls getApplicationSupportDirectory() for persistent storage; avatar/icon cache uses the cache-equivalent directory instead.
The cache init call is separate from storage init:
// Bridge init (Flutter calls these at startup)
pub fn init_storage(support_dir: String) -> Result<(), BridgeError>; // existing
pub fn init_cache(cache_dir: String) -> Result<(), BridgeError>; // new
10.5 Cache Freshness Strategy
The client_flag_avatar field on each client is the authoritative freshness signal:
On connect / on client list update:
For each visible client with non-empty avatar_hash:
cache_key = "av_<avatar_hash>"
if cacache entry exists for "av_<avatar_hash>":
use cached file (zero downloads)
else:
enqueue download for avatar_path with expected hash = avatar_hash
On avatar_hash change for a client:
The new hash produces a different cacache key.
The old entry remains until eviction or manual clear.
The new file is downloaded on demand.
This means:
- First connect: No cache hits. Downloads happen lazily as the UI requests avatars.
- Reconnect to same server: All avatars hit cache instantly (hashes match keys). Zero downloads.
- User changes avatar: New hash = new cache key. Old entry becomes orphan. New file downloads on next UI request.
- Same user on different server: Same avatar hash = same cached file. Cross-server dedup for free.
10.6 Anti-Flood and Download Timing
TeamSpeak servers enforce anti-flood rate limiting. Downloading all avatars eagerly on connect would trigger it on servers with many users.
Strategy: lazy + throttled prefetch
| Phase | What | Rate |
|---|---|---|
| Connect settle (first 2-5 s) | Do nothing. Let the initial state snapshot and channel tree arrive. | — |
| After settle | UI requests avatars for visible clients in the current channel. These trigger downloads one at a time. | Max 1-2 concurrent downloads per server |
| Channel switch | UI requests avatars for newly visible clients. | Same throttle |
| Background prefetch (optional, future) | Low-priority downloads for clients in adjacent channels. | 1 request per 500 ms |
Anti-flood handling:
- If the server responds with an anti-flood error (TS3 error code
0x0701=client_could_not_be_banned/ flood-related), back off the download queue. - Implement a simple semaphore in
chanora_core: max 1-2 concurrent downloads. - If a download gets a flood error, pause the queue for 5 seconds, then resume at reduced rate.
10.7 Retry on Failure
| Failure type | Strategy |
|---|---|
| Transient (network timeout, TCP reset) | Retry with exponential backoff: 5 s, 30 s, 2 min, 10 min. Cap at 10 min. |
| Server flood limit hit | Pause queue 5 s, then resume at reduced rate. Do not count as a per-file retry. |
| Permission denied (no download power) | Do not retry. Record negative cache entry. Only retry if hash changes. |
| File not found (avatar removed) | Do not retry. Record negative cache entry. Clear when hash changes or becomes empty. |
| Connection lost | All pending downloads fail. On reconnect, cache check runs fresh with current hashes. |
Negative cache: In-memory HashMap<String, Instant> with 5-minute TTL. Keys like "av_<hash>" or "ic_<id>" that received permanent errors are stored with an expiry. On lookup, expired entries are treated as absent. Cleared entirely on reconnect.
10.8 Request Coalescing
Multiple UI widgets may request the same avatar simultaneously (e.g., channel list + chat view + client info sheet).
Pattern: In chanora_core's FileTransferService, maintain an in-flight map:
HashMap<String, tokio::task::JoinHandle<Result<Vec<u8>, FileTransferError>>>
- First request: start download, store handle.
- Subsequent requests for same key: await the same handle.
- When handle completes: write to cache, wake all waiters, remove from map.
10.9 Cache Eviction and Size Limits
MVP approach:
- No automatic size-based eviction in MVP. Avatars are small (typically 10-100 KB). Even 1000 avatars = ~50-100 MB.
- Rely on platform cache directory semantics (OS may evict under storage pressure on mobile).
- Old hash files accumulate but are harmless.
Post-MVP:
BlobCache::evict(max_bytes)— walkcacache::ls()entries, sort by timestamp (oldest first), delete until total size <max_bytes.cacachemanages timestamps internally. No metadata sidecars needed.- Or simpler:
BlobCache::evict_older_than(duration)— delete entries with timestamp older than N days. - Call on startup and periodically (e.g., every 24 hours or on app resume).
10.10 User-Initiated Cache Clear
Add a bridge method:
pub fn clear_file_cache(&self) -> Result<(), BridgeError> {
// Delete the entire blobs/ directory contents
// Flutter evicts all avatar/icon-related entries from ImageCache
}
pub fn file_cache_size(&self) -> Result<u64, BridgeError> {
// Walk blobs/ and sum file sizes
}
Flutter side:
// In settings or storage management UI:
onPressed: () async {
await api.clearFileCache();
PaintingBinding.instance.imageCache.clear();
}
This should be exposed in the app's settings UI under a "Clear cache" or "Storage management" section.
10.11 Storage Clear Across Servers
Since the cache is flat with content-addressed keys (no server namespacing):
- Connecting to a different server does not conflict — same avatar hash = same file.
- Avatars unique to the old server remain cached. If a user on the new server has the same avatar (same hash), it hits cache instantly (cross-server dedup).
- Cache clear removes all cached data regardless of which server it came from.
10.12 Flutter Display Strategy
Option A: Bytes across bridge (simpler, recommended for MVP)
Rust returns Vec<u8> across the bridge. Flutter uses Image.memory(bytes).
final bytes = await api.downloadAvatar(clientUid: uid);
if (bytes != null && bytes.isNotEmpty) {
return Image.memory(Uint8List.fromList(bytes));
} else {
return CircleAvatar(child: Text(initials)); // fallback
}
Flutter's ImageCache caches the decoded image in memory automatically. Same avatar bytes = cache hit in memory.
Option B: File path across bridge (better for large images, future)
Rust writes to disk and returns the file path. Flutter uses FileImage.
final path = await api.getAvatarPath(avatarHash: hash);
if (path != null) {
return Image.file(File(path));
} else {
return CircleAvatar(child: Text(initials));
}
FileImage does not watch for file changes. When the hash changes, the UI must evict the old entry from ImageCache using PaintingBinding.instance.imageCache.evict(key).
Recommendation: Start with Option A for MVP. It avoids file-path cross-platform complications and works well for small avatar files. The bridge already returns Vec<u8> for the download result.
11. Implementation Sequence
| Phase | Scope | What |
|---|---|---|
| Phase 1 | Protocol download | Request::DownloadFile, StreamItem::FileDownload handling, ProtocolClient::download_avatar() / download_icon(). No caching. |
| Phase 2 | Bridge + Flutter display | Bridge downloadAvatar(), Flutter Image.memory(), initials fallback. Still no caching — every view re-downloads. |
| Phase 3 | Rust disk cache | New chanora_cache crate: cacache-backed content-addressed blob store (BlobCache), key validation, mtime-based eviction, init_cache bridge call. |
| Phase 4 | Session orchestration | chanora_core FileTransferService: request coalescing, rate limiter (semaphore), negative cache (5 min TTL), retry backoff. |
| Phase 5 | Cache management | Bridge clearFileCache() + fileCacheSize(), Flutter settings UI, eviction on startup. |
Phase 1 and 2 deliver visible value (avatars in the UI). Phase 3-5 add robustness.
12. References
| Reference | Use |
|---|---|
ReSpeak/tsdeclarations Messages.toml lines 828-830 |
ftinitdownload command declaration |
ReSpeak/tsdeclarations Messages.toml lines 590 |
FileDownload response structure |
ReSpeak/tsdeclarations ts3protocol.md |
Low-level TeamSpeak protocol specification |
ReSpeak/tsclientlib src/lib.rs lines 956-1005 |
download_file / upload_file public API |
ReSpeak/tsclientlib src/lib.rs lines 1371-1427 |
StreamItem::FileDownload handling |
ReSpeak/tsclientlib src/lib.rs lines 1630-1672 |
Outgoing init commands |
Multivit4min/TS3-NodeJS-Library src/transport/FileTransfer.ts |
Reference TCP transfer implementation |
Speckmops/ts3admin.class lib/ts3admin.class.php lines 1352-1370 |
Reference avatar download flow |
docs/architecture/sad.md SAD-067, SDD-MOD-009 |
Protocol adapter boundary rules |
crates/chanora_protocol/src/adapter.rs lines 1561-1568 |
Existing uid_to_avatar_path implementation |