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. * * The browser extension renders favicons from Chrome's OWN favicon cache and * never asks a third party ("no domain name leaves the browser"). Android has * no such cache, so it asks the site itself — `https://{domain}/favicon.ico`, * then the page's `` — never a third-party favicon service. * 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://$domain/favicon.ico") ?.let(::decodeOrUnwrapIco) ?: fetchLinkedIcon(domain) val saved = bytes != null && saveScaledPng(bytes, fileFor(domain)) if (saved) { _revision.value++ } else { runCatching { missFileFor(domain).createNewFile() } } } private fun fetchLinkedIcon(domain: String): ByteArray? { val page = fetchUrl("https://$domain/", maxBytes = PAGE_READ_LIMIT) ?: return null val href = extractIconHref(String(page, Charsets.UTF_8)) ?: return null val url = resolveIconUrl(href, domain) ?: return null return fetchUrl(url)?.let(::decodeOrUnwrapIco) } /** 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 PAGE_READ_LIMIT = 64L * 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 } /** * First `` href in the page * head. Pure string scan — the browser does the same without a DOM parser * dependency here (we only have the first [Favicons.PAGE_READ_LIMIT] bytes). */ internal fun extractIconHref(html: String): String? { for (tag in Regex("]+>", RegexOption.IGNORE_CASE).findAll(html)) { val rel = Regex("rel=[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE) .find(tag.value)?.groupValues?.get(1)?.trim() ?: continue val isIcon = rel.equals("icon", true) || rel.equals("shortcut icon", true) || rel.equals("apple-touch-icon", true) if (!isIcon) continue val href = Regex("href=[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE) .find(tag.value)?.groupValues?.get(1)?.trim() if (!href.isNullOrEmpty()) return href } return null } /** Resolve an icon href against the site origin; rejects data: and script URLs. */ internal fun resolveIconUrl(href: String, domain: String): String? { val trimmed = href.trim() if (trimmed.isEmpty() || trimmed.startsWith("data:", true) || trimmed.startsWith("javascript:", true)) { return null } return when { trimmed.startsWith("//") -> "https:$trimmed" trimmed.startsWith("http://", true) -> "https://" + trimmed.removePrefix("http://") trimmed.startsWith("https://", true) -> trimmed trimmed.startsWith("/") -> "https://$domain$trimmed" else -> "https://$domain/$trimmed" } }