Skip to main content

Client Caching Strategies

This document covers caching approaches used in the mobile app for assets, data, and AI analysis results.

Key Files

  • crates/bf_mobile/src/ota.rs - OTA asset caching
  • crates/bf_resident/src/handlers/floor_plan_layouts.rs - AI analysis caching
  • Native Lynx plugin - Credential and session caching

Overview

Multiple caching layers optimize performance and offline capability:

┌─────────────────────────────────────────────────────────────────────┐
│ Caching Architecture │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Mobile App │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ OTA Assets │ │ Credentials │ │ Session │ │ │
│ │ │ A/B dirs │ │ Keychain │ │ Cache │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │ │ │ │
│ │ │ Native Storage │ │ │
│ │ │ │ │ │ │
│ └─────────┼────────────────┼────────────────┼───────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Backend Services │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ R2 Storage │ │ KV Cache │ │ D1 │ │ │
│ │ │ (assets) │ │ (AI cache) │ │ (metadata) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘

OTA Asset Caching

Compressed Storage

Assets stored compressed on device:

// crates/bf_mobile/src/ota.rs

pub struct RuntimeAssets {
/// Brotli-compressed assets
assets: Vec<(String, Vec<u8>)>,
/// CSP hashes
global_hashes: Vec<RuntimeCspHash>,
html_hashes: Vec<(String, Vec<RuntimeCspHash>)>,
}

On-Demand Decompression

Assets decompressed when requested:

impl<R: Runtime> Assets<R> for OtaAssets<R> {
fn get(&self, key: &AssetKey) -> Option<Cow<'_, [u8]>> {
if let Some(displaying) = self.displaying.as_ref() {
displaying
.assets
.get(key.as_ref())
.map(|compressed_data| {
let mut buf = Vec::with_capacity(compressed_data.len());
brotli::BrotliDecompress(&mut compressed_data, &mut buf)
.map(|()| buf)
})
.and_then(Result::ok)
.map(Cow::Owned)
} else {
// Fall back to embedded assets
self.default_assets.get(key)
}
}
}

Embedded Fallback

Embedded assets serve as fallback when no OTA version exists:

impl GlobalOtaAssets {
pub fn new(default_assets: Box<dyn Assets<Wry>>) -> Self {
// Wrap default assets with OTA layer
// OTA versions override when available
}
}

Credential Caching

Native Secure Storage

iOS Keychain and Android Keystore via Lynx plugin:

// Exposed as Tauri commands

#[tauri::command]
fn store_credential(key: String, value: String) -> Result<(), String> {
lynx::store_credential(&key, &value)
}

#[tauri::command]
fn get_credential(key: String) -> Result<Option<String>, String> {
lynx::get_credential(&key)
}

Clerk Session Caching

Clerk SDK handles session persistence internally:

#[tauri::command]
pub async fn clerk_restore_session() -> Result<ClerkUser, String> {
// Restores session from native storage
lynx::clerk_restore_session().await
}

Backend KV Caching

AI Floor Plan Analysis

Expensive AI analysis cached in Cloudflare KV:

// crates/bf_resident/src/handlers/floor_plan_layouts.rs

const AI_CACHE_TTL_SECONDS: u64 = 30 * 24 * 60 * 60; // 30 days

pub async fn get_floor_plan_analysis(
env: &Env,
floor_plan_hash: &str,
) -> Result<Option<FloorPlanAnalysis>> {
let kv = env.kv("AI_CACHE")?;

// Check cache first
if let Some(cached) = kv.get(floor_plan_hash).json().await? {
return Ok(Some(cached));
}

// Run AI analysis (expensive)
let analysis = run_ai_analysis(floor_plan_url).await?;

// Cache result
kv.put(floor_plan_hash, &analysis)?
.expiration_ttl(AI_CACHE_TTL_SECONDS)
.execute()
.await?;

Ok(Some(analysis))
}

Cache Key Strategy

Content-addressed caching for floor plans:

// Hash the floor plan image URL for cache key
let cache_key = format!("floor_plan:{}", sha256_hash(floor_plan_url));

Session Token Caching

OTA Token Format

Token includes version info for backend version checking:

// crates/bf_mobile/src/ota.rs

pub async fn get_token() -> String {
let current = match &client.displaying_version {
AssetVersion::A(x) | AssetVersion::B(x) => *x,
_ => 0,
};

let app = handle.config().version.unwrap_or("0.0.0");
let access_token = lynx::clerk_get_token().await?;

// Format: "ota_version|app_version|clerk_token"
format!("{current}|{app}|{access_token}")
}

Network-Aware Caching

Connectivity Check

OTA checks only when connected:

pub async fn check_latest_version_fn() {
// 0 = connected, 1 = cellular only, 2 = no connection
if lynx::get_connectivity_error() == 0 {
let token = get_token().await;
if !token.is_empty() {
lynx::check_latest_version(token, base_url);
}
}
}

Platform-Specific Behavior

// iOS: Regular background checks
#[cfg(target_os = "ios")]
{
let mut interval = tokio::time::interval(Duration::from_secs(60 * 5));
loop {
interval.tick().await;
check_latest_version_fn().await;
}
}

// Android: Single check to conserve battery
#[cfg(target_os = "android")]
{
tokio::time::sleep(Duration::from_secs(1)).await;
check_latest_version_fn().await;
}

Cache Invalidation

App Version Change

OTA cache cleared on native app update:

fn add_app_handle(&mut self, handle: AppHandle) {
let current_version = handle.package_info().version.to_string();

if current_version != previous_version {
// Clear OTA directories
std::fs::remove_dir_all(&version_a_dir)?;
std::fs::remove_dir_all(&version_b_dir)?;

// Store new version
std::fs::write(&version_check_path, &current_version)?;
}
}

Manual Reset

User can force reset to embedded assets:

#[tauri::command]
pub fn reset_remote() {
if let Some(singleton) = OTA_ASSETS.get() {
singleton.reset() // Clears A/ and B/ directories
}
}

Environment Switch

Changing environments requires fresh start:

#[tauri::command]
fn set_environment(app_handle: AppHandle, env_name: String) -> Result<(), String> {
// Write new environment selection
// App restart required for Clerk SDK reinitialization
}

#[tauri::command]
fn exit_app(app_handle: AppHandle) {
app_handle.exit(0); // Force restart
}

Data Persistence Locations

iOS

Data TypeLocationNotes
OTA AssetsDocuments/A/, Documents/B/Backed up by iCloud
CredentialsKeychainSecure enclave
EnvironmentDocuments/env.jsonUser preference
Version CheckDocuments/version.checkApp version marker

Android

Data TypeLocationNotes
OTA Assetsfiles/A/, files/B/Internal storage
CredentialsKeystoreHardware-backed
Environmentfiles/env.jsonUser preference
Version Checkfiles/version.checkApp version marker

Cache Size Considerations

OTA Bundle Size

Logged on export for monitoring:

let zip = ota::make_data();
lynx::nslog(format!("OTA bundle size: {} bytes", zip.len()));

Compression Ratios

StageCompressionTypical Ratio
Build timeBrotli~70-80% reduction
TransferZstd level 22~50-60% further
StorageBrotli (kept)Saves device space
ServeDecompressOn-demand

See Also