import { createProjectFaviconCache, createProjectFaviconImageLoader, PROJECT_FAVICON_MAX_DATA_URL_LENGTH, PROJECT_FAVICON_THUMBNAIL_SIZE, type ProjectFaviconEntry, } from "@t3tools/client-runtime/project-favicon-cache"; import * as Effect from "effect/Effect"; import * as MobileDatabase from "../persistence/mobile-database"; const CACHE_KIND = "project-favicon"; const CACHE_SCHEMA_VERSION = 1; let database: MobileDatabase.MobileDatabase["Service"] | undefined; /** * The cache is a module singleton because the favicon atom family holds it outside * any Effect runtime. Its rows live in `client_cache`, so the environment cache store * hands over the database it already owns instead of the cache re-entering the runtime. */ export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) { database = service; } const runDatabase = ( use: (database: MobileDatabase.MobileDatabase["Service"]) => Effect.Effect, ) => database ? Effect.runPromise(use(database)) : Promise.reject(new Error("Project icon storage is not attached.")); /** * Rasterizes a bitmap that is too large to inline. The native decoder writes the * downsized frame to expo-image's disk cache, which is the only encode path it * exposes; the temporary entry is removed once its bytes are read. */ export async function downscaleProjectFavicon( image: { readonly url: string }, signal: AbortSignal, ) { const [{ Image }, { File }] = await Promise.all([ import("expo-image"), import("expo-file-system"), ]); for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { signal.throwIfAborted(); const decoded = await Image.loadAsync(image.url, { maxWidth: size, maxHeight: size }); const cacheKey = `t3-favicon-thumbnail:${size}:${image.url}`; try { signal.throwIfAborted(); if (decoded.width > size || decoded.height > size) { throw new Error("Project icon was not resized."); } await Image.writeToCacheAsync(decoded, cacheKey); const path = await Image.getCachePathAsync(cacheKey); if (!path) throw new Error("Project icon thumbnail was not written."); const file = new File(path.startsWith("file:") ? path : `file://${path}`); try { if (file.size > PROJECT_FAVICON_MAX_DATA_URL_LENGTH) continue; const base64 = await file.base64(); // SDWebImage chooses JPEG for opaque images and PNG for transparency; Glide always writes PNG. const mimeType = base64.startsWith("/9j/") ? "image/jpeg" : base64.startsWith("iVBORw0KGgo") ? "image/png" : null; if (!mimeType) throw new Error("Unsupported project icon thumbnail encoding."); const dataUrl = `data:${mimeType};base64,${base64}`; if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; } finally { file.delete(); } } finally { decoded.release(); } } throw new Error("Project icon thumbnail exceeds the cache limit."); } /** Rows live in `client_cache` so Settings → Client storage counts and clears them. */ export const projectFaviconCache = createProjectFaviconCache({ storage: { list: () => runDatabase((database) => database.listCache(CACHE_KIND).pipe( Effect.map((payloads) => payloads.flatMap((payload): Array => { try { return [JSON.parse(payload)]; } catch { return []; } }), ), ), ), put: (key, entry: ProjectFaviconEntry) => runDatabase((database) => database.saveCache( entry.environmentId, CACHE_KIND, key, CACHE_SCHEMA_VERSION, JSON.stringify(entry), ), ), remove: (key, entry) => runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)), }, load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), });