package com.peachpasswords.ui.components import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.util.Collections 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 fun fileFor(domain: String): File = File(dir, "$domain.png") private fun missFileFor(domain: String): File = File(dir, "$domain.miss") fun bitmap(domain: String, sizePx: Int): Bitmap? = runCatching { val file = fileFor(domain) 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.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()) return false val miss = missFileFor(domain) return !miss.exists() || System.currentTimeMillis() - miss.lastModified() >= MISS_RETRY_MS } internal suspend fun fetch(domain: String) = withContext(Dispatchers.IO) { val bytes = fetchUrl("https://icons.duckduckgo.com/ip3/$domain.ico") ?.let(::decodeOrUnwrapIco) val saved = bytes != null && saveScaledPng(bytes, fileFor(domain)) if (saved) { _revision.value++ } else { runCatching { missFileFor(domain).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 } } /** * 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 }