Favicons.kt

7.4 KB · raw

1package com.peachpasswords.ui.components23import android.content.Context4import android.graphics.Bitmap5import android.graphics.BitmapFactory6import com.peachpasswords.shared.extractDomain7import dagger.hilt.android.qualifiers.ApplicationContext8import java.io.File9import java.net.IDN10import java.util.Collections11import java.util.Locale12import java.util.concurrent.ConcurrentHashMap13import java.util.concurrent.TimeUnit14import javax.inject.Inject15import javax.inject.Singleton16import kotlinx.coroutines.CoroutineScope17import kotlinx.coroutines.Dispatchers18import kotlinx.coroutines.SupervisorJob19import kotlinx.coroutines.flow.MutableStateFlow20import kotlinx.coroutines.flow.StateFlow21import kotlinx.coroutines.launch22import kotlinx.coroutines.withContext23import okhttp3.OkHttpClient24import okhttp3.Request2526/**27 * U1: real site favicons, offline-first with a disk cache.28 *29 * Source: DuckDuckGo's icon API (`icons.duckduckgo.com/ip3/{domain}.ico`) —30 * June's explicit choice (2026-08-03), superseding the v2.10.1131 * direct-from-site strategy. One small request per domain; the domain name is32 * the only thing transmitted. Failures negative-cache for [MISS_RETRY_MS] and33 * fall back to initials.34 */35@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)36@Singleton37class Favicons @Inject constructor(@ApplicationContext private val context: Context) {3839    private val client: OkHttpClient by lazy {40        OkHttpClient.Builder()41            .connectTimeout(8, TimeUnit.SECONDS)42            .readTimeout(8, TimeUnit.SECONDS)43            .followRedirects(true)44            .build()45    }46    private val dir: File by lazy { File(context.cacheDir, "favicons").apply { mkdirs() } }47    private val fetchDispatcher = Dispatchers.IO.limitedParallelism(2)48    private val scope = CoroutineScope(SupervisorJob() + fetchDispatcher)49    private val inFlight = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())5051    /** Bumped whenever any favicon lands on disk; composables key recomposition on it. */52    private val _revision = MutableStateFlow(0)53    val revision: StateFlow<Int> = _revision5455    private fun fileFor(domain: String): File? = faviconCacheFile(dir, domain, ".png")56    private fun missFileFor(domain: String): File? = faviconCacheFile(dir, domain, ".miss")5758    fun bitmap(domain: String, sizePx: Int): Bitmap? = runCatching {59        val file = fileFor(domain) ?: return null60        if (!file.exists()) return null61        val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }62        BitmapFactory.decodeFile(file.absolutePath, bounds)63        if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null64        var sample = 165        while (bounds.outWidth / sample > sizePx * 2 && bounds.outHeight / sample > sizePx * 2) sample *= 266        BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample })67    }.getOrNull()6869    /** Fire-and-forget fetches for every domain missing from the cache. */70    fun prefetch(domains: List<String>) {71        for (domain in domains.mapNotNull(::canonicalFaviconDomain).distinct()) {72            if (!needsFetch(domain) || !inFlight.add(domain)) continue73            scope.launch {74                try {75                    fetch(domain)76                } finally {77                    inFlight.remove(domain)78                }79            }80        }81    }8283    private fun needsFetch(domain: String): Boolean {84        if (fileFor(domain)?.exists() == true) return false85        val miss = missFileFor(domain) ?: return false86        return !miss.exists() || System.currentTimeMillis() - miss.lastModified() >= MISS_RETRY_MS87    }8889    internal suspend fun fetch(domain: String) = withContext(Dispatchers.IO) {90        val canonicalDomain = canonicalFaviconDomain(domain) ?: return@withContext91        val bytes = fetchUrl("https://icons.duckduckgo.com/ip3/$canonicalDomain.ico")92            ?.let(::decodeOrUnwrapIco)93        val target = fileFor(canonicalDomain) ?: return@withContext94        val saved = bytes != null && saveScaledPng(bytes, target)95        if (saved) {96            _revision.value++97        } else {98            runCatching { missFileFor(canonicalDomain)?.createNewFile() }99        }100    }101102    /** GET with a bounded read; returns null on any failure or oversize body. */103    private fun fetchUrl(url: String, maxBytes: Long = MAX_ICON_BYTES): ByteArray? = runCatching {104        val request = Request.Builder().url(url).header("User-Agent", "Peach/1.0").build()105        client.newCall(request).execute().use { response ->106            if (!response.isSuccessful) return@runCatching null107            val body = response.body ?: return@runCatching null108            val source = body.source()109            source.request(maxBytes + 1)110            if (source.buffer.size > maxBytes) null else source.readByteArray()111        }112    }.getOrNull()113114    private fun saveScaledPng(bytes: ByteArray, target: File): Boolean = runCatching {115        val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return false116        val scaled = Bitmap.createScaledBitmap(decoded, ICON_PX, ICON_PX, true)117        target.outputStream().use { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) }118        if (scaled !== decoded) scaled.recycle()119        decoded.recycle()120        true121    }.getOrDefault(false)122123    companion object {124        const val ICON_PX = 64125        const val MAX_ICON_BYTES = 256L * 1024L126        const val MISS_RETRY_MS = 7L * 24 * 60 * 60 * 1000127    }128}129130/** Canonical registrable DNS host accepted by the favicon network/cache boundary. */131internal fun canonicalFaviconDomain(raw: String): String? {132    if (raw.isEmpty() || raw != raw.trim()) return null133    if (raw.any { it <= '\u001F' || it == '\u007F' || it in "/\\@?#:" }) return null134    if (raw.endsWith('.')) return null135    val ascii = runCatching { IDN.toASCII(raw, IDN.USE_STD3_ASCII_RULES) }136        .getOrNull()137        ?.lowercase(Locale.ROOT)138        ?: return null139    if (ascii.length > 253 || ascii.split('.').any { it.isEmpty() || it.length > 63 }) return null140    return ascii.takeIf { extractDomain(it) == it }141}142143/** Builds a contained direct-child cache path only after host validation. */144internal fun faviconCacheFile(directory: File, rawDomain: String, suffix: String): File? {145    if (suffix != ".png" && suffix != ".miss") return null146    val domain = canonicalFaviconDomain(rawDomain) ?: return null147    val base = runCatching { directory.canonicalFile }.getOrNull() ?: return null148    val target = runCatching { File(base, "$domain$suffix").canonicalFile }.getOrNull() ?: return null149    return target.takeIf { it.parentFile == base }150}151152/**153 * ICO containers usually wrap a PNG; BitmapFactory sniffs magic bytes and154 * rejects the container. Unwrap the first embedded PNG when present.155 */156internal fun decodeOrUnwrapIco(bytes: ByteArray): ByteArray? {157    if (bytes.size >= 8 && bytes[0] == 0.toByte() && bytes[1] == 0.toByte() &&158        bytes[2] == 1.toByte() && bytes[3] == 0.toByte()159    ) {160        if (bytes.size < 22) return null161        val offset = (bytes[18].toInt() and 0xFF) or162            ((bytes[19].toInt() and 0xFF) shl 8) or163            ((bytes[20].toInt() and 0xFF) shl 16) or164            ((bytes[21].toInt() and 0xFF) shl 24)165        if (offset > 0 && offset + 8 <= bytes.size &&166            bytes[offset] == 0x89.toByte() && bytes[offset + 1] == 0x50.toByte()167        ) {168            return bytes.copyOfRange(offset, bytes.size)169        }170        return null171    }172    return bytes173}