package com.peachpasswords.ui.components import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import com.peachpasswords.shared.extractDomain import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.net.IDN import java.util.Collections import java.util.Locale import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request /** * U1: real site favicons, offline-first with a disk cache. * * Source: DuckDuckGo's icon API (`icons.duckduckgo.com/ip3/{domain}.ico`) — * June's explicit choice (2026-08-03), superseding the v2.10.11 * direct-from-site strategy. One small request per domain; the domain name is * the only thing transmitted. Failures negative-cache for [MISS_RETRY_MS] and * fall back to initials. */ @Singleton class Favicons @Inject constructor(@ApplicationContext private val context: Context) { private val client: OkHttpClient by lazy { OkHttpClient.Builder() .connectTimeout(8, TimeUnit.SECONDS) .readTimeout(8, TimeUnit.SECONDS) .followRedirects(true) .build() } private val dir: File by lazy { File(context.cacheDir, "favicons").apply { mkdirs() } } private val fetchDispatcher = Dispatchers.IO.limitedParallelism(2) private val scope = CoroutineScope(SupervisorJob() + fetchDispatcher) private val inFlight = Collections.newSetFromMap(ConcurrentHashMap()) /** Bumped whenever any favicon lands on disk; composables key recomposition on it. */ private val _revision = MutableStateFlow(0) val revision: StateFlow = _revision private fun fileFor(domain: String): File? = faviconCacheFile(dir, domain, ".png") private fun missFileFor(domain: String): File? = faviconCacheFile(dir, domain, ".miss") fun bitmap(domain: String, sizePx: Int): Bitmap? = runCatching { val file = fileFor(domain) ?: return null if (!file.exists()) return null val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(file.absolutePath, bounds) if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null var sample = 1 while (bounds.outWidth / sample > sizePx * 2 && bounds.outHeight / sample > sizePx * 2) sample *= 2 BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample }) }.getOrNull() /** Fire-and-forget fetches for every domain missing from the cache. */ fun prefetch(domains: List) { for (domain in domains.mapNotNull(::canonicalFaviconDomain).distinct()) { if (!needsFetch(domain) || !inFlight.add(domain)) continue scope.launch { try { fetch(domain) } finally { inFlight.remove(domain) } } } } private fun needsFetch(domain: String): Boolean { if (fileFor(domain)?.exists() == true) return false val miss = missFileFor(domain) ?: return false return !miss.exists() || System.currentTimeMillis() - miss.lastModified() >= MISS_RETRY_MS } internal suspend fun fetch(domain: String) = withContext(Dispatchers.IO) { val canonicalDomain = canonicalFaviconDomain(domain) ?: return@withContext val bytes = fetchUrl("https://icons.duckduckgo.com/ip3/$canonicalDomain.ico") ?.let(::decodeOrUnwrapIco) val target = fileFor(canonicalDomain) ?: return@withContext val saved = bytes != null && saveScaledPng(bytes, target) if (saved) { _revision.value++ } else { runCatching { missFileFor(canonicalDomain)?.createNewFile() } } } /** GET with a bounded read; returns null on any failure or oversize body. */ private fun fetchUrl(url: String, maxBytes: Long = MAX_ICON_BYTES): ByteArray? = runCatching { val request = Request.Builder().url(url).header("User-Agent", "Peach/1.0").build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) return@runCatching null val body = response.body ?: return@runCatching null val source = body.source() source.request(maxBytes + 1) if (source.buffer.size > maxBytes) null else source.readByteArray() } }.getOrNull() private fun saveScaledPng(bytes: ByteArray, target: File): Boolean = runCatching { val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return false val scaled = Bitmap.createScaledBitmap(decoded, ICON_PX, ICON_PX, true) target.outputStream().use { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) } if (scaled !== decoded) scaled.recycle() decoded.recycle() true }.getOrDefault(false) companion object { const val ICON_PX = 64 const val MAX_ICON_BYTES = 256L * 1024L const val MISS_RETRY_MS = 7L * 24 * 60 * 60 * 1000 } } /** Canonical registrable DNS host accepted by the favicon network/cache boundary. */ internal fun canonicalFaviconDomain(raw: String): String? { if (raw.isEmpty() || raw != raw.trim()) return null if (raw.any { it <= '\u001F' || it == '\u007F' || it in "/\\@?#:" }) return null if (raw.endsWith('.')) return null val ascii = runCatching { IDN.toASCII(raw, IDN.USE_STD3_ASCII_RULES) } .getOrNull() ?.lowercase(Locale.ROOT) ?: return null if (ascii.length > 253 || ascii.split('.').any { it.isEmpty() || it.length > 63 }) return null return ascii.takeIf { extractDomain(it) == it } } /** Builds a contained direct-child cache path only after host validation. */ internal fun faviconCacheFile(directory: File, rawDomain: String, suffix: String): File? { if (suffix != ".png" && suffix != ".miss") return null val domain = canonicalFaviconDomain(rawDomain) ?: return null val base = runCatching { directory.canonicalFile }.getOrNull() ?: return null val target = runCatching { File(base, "$domain$suffix").canonicalFile }.getOrNull() ?: return null return target.takeIf { it.parentFile == base } } /** * ICO containers usually wrap a PNG; BitmapFactory sniffs magic bytes and * rejects the container. Unwrap the first embedded PNG when present. */ internal fun decodeOrUnwrapIco(bytes: ByteArray): ByteArray? { if (bytes.size >= 8 && bytes[0] == 0.toByte() && bytes[1] == 0.toByte() && bytes[2] == 1.toByte() && bytes[3] == 0.toByte() ) { if (bytes.size < 22) return null val offset = (bytes[18].toInt() and 0xFF) or ((bytes[19].toInt() and 0xFF) shl 8) or ((bytes[20].toInt() and 0xFF) shl 16) or ((bytes[21].toInt() and 0xFF) shl 24) if (offset > 0 && offset + 8 <= bytes.size && bytes[offset] == 0x89.toByte() && bytes[offset + 1] == 0x50.toByte() ) { return bytes.copyOfRange(offset, bytes.size) } return null } return bytes }