Favicons.kt

5.8 KB · raw

1package com.peachpasswords.ui.components23import android.content.Context4import android.graphics.Bitmap5import android.graphics.BitmapFactory6import dagger.hilt.android.qualifiers.ApplicationContext7import java.io.File8import java.util.Collections9import java.util.concurrent.ConcurrentHashMap10import java.util.concurrent.TimeUnit11import javax.inject.Inject12import javax.inject.Singleton13import kotlinx.coroutines.CoroutineScope14import kotlinx.coroutines.Dispatchers15import kotlinx.coroutines.SupervisorJob16import kotlinx.coroutines.flow.MutableStateFlow17import kotlinx.coroutines.flow.StateFlow18import kotlinx.coroutines.launch19import kotlinx.coroutines.withContext20import okhttp3.OkHttpClient21import okhttp3.Request2223/**24 * U1: real site favicons, offline-first with a disk cache.25 *26 * Source: DuckDuckGo's icon API (`icons.duckduckgo.com/ip3/{domain}.ico`) —27 * June's explicit choice (2026-08-03), superseding the v2.10.1128 * direct-from-site strategy. One small request per domain; the domain name is29 * the only thing transmitted. Failures negative-cache for [MISS_RETRY_MS] and30 * fall back to initials.31 */32@Singleton33class Favicons @Inject constructor(@ApplicationContext private val context: Context) {3435    private val client: OkHttpClient by lazy {36        OkHttpClient.Builder()37            .connectTimeout(8, TimeUnit.SECONDS)38            .readTimeout(8, TimeUnit.SECONDS)39            .followRedirects(true)40            .build()41    }42    private val dir: File by lazy { File(context.cacheDir, "favicons").apply { mkdirs() } }43    private val fetchDispatcher = Dispatchers.IO.limitedParallelism(2)44    private val scope = CoroutineScope(SupervisorJob() + fetchDispatcher)45    private val inFlight = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())4647    /** Bumped whenever any favicon lands on disk; composables key recomposition on it. */48    private val _revision = MutableStateFlow(0)49    val revision: StateFlow<Int> = _revision5051    fun fileFor(domain: String): File = File(dir, "$domain.png")52    private fun missFileFor(domain: String): File = File(dir, "$domain.miss")5354    fun bitmap(domain: String, sizePx: Int): Bitmap? = runCatching {55        val file = fileFor(domain)56        if (!file.exists()) return null57        val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }58        BitmapFactory.decodeFile(file.absolutePath, bounds)59        if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null60        var sample = 161        while (bounds.outWidth / sample > sizePx * 2 && bounds.outHeight / sample > sizePx * 2) sample *= 262        BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample })63    }.getOrNull()6465    /** Fire-and-forget fetches for every domain missing from the cache. */66    fun prefetch(domains: List<String>) {67        for (domain in domains.distinct()) {68            if (!needsFetch(domain) || !inFlight.add(domain)) continue69            scope.launch {70                try {71                    fetch(domain)72                } finally {73                    inFlight.remove(domain)74                }75            }76        }77    }7879    private fun needsFetch(domain: String): Boolean {80        if (fileFor(domain).exists()) return false81        val miss = missFileFor(domain)82        return !miss.exists() || System.currentTimeMillis() - miss.lastModified() >= MISS_RETRY_MS83    }8485    internal suspend fun fetch(domain: String) = withContext(Dispatchers.IO) {86        val bytes = fetchUrl("https://icons.duckduckgo.com/ip3/$domain.ico")87            ?.let(::decodeOrUnwrapIco)88        val saved = bytes != null && saveScaledPng(bytes, fileFor(domain))89        if (saved) {90            _revision.value++91        } else {92            runCatching { missFileFor(domain).createNewFile() }93        }94    }9596    /** GET with a bounded read; returns null on any failure or oversize body. */97    private fun fetchUrl(url: String, maxBytes: Long = MAX_ICON_BYTES): ByteArray? = runCatching {98        val request = Request.Builder().url(url).header("User-Agent", "Peach/1.0").build()99        client.newCall(request).execute().use { response ->100            if (!response.isSuccessful) return@runCatching null101            val body = response.body ?: return@runCatching null102            val source = body.source()103            source.request(maxBytes + 1)104            if (source.buffer.size > maxBytes) null else source.readByteArray()105        }106    }.getOrNull()107108    private fun saveScaledPng(bytes: ByteArray, target: File): Boolean = runCatching {109        val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return false110        val scaled = Bitmap.createScaledBitmap(decoded, ICON_PX, ICON_PX, true)111        target.outputStream().use { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) }112        if (scaled !== decoded) scaled.recycle()113        decoded.recycle()114        true115    }.getOrDefault(false)116117    companion object {118        const val ICON_PX = 64119        const val MAX_ICON_BYTES = 256L * 1024L120        const val MISS_RETRY_MS = 7L * 24 * 60 * 60 * 1000121    }122}123124/**125 * ICO containers usually wrap a PNG; BitmapFactory sniffs magic bytes and126 * rejects the container. Unwrap the first embedded PNG when present.127 */128internal fun decodeOrUnwrapIco(bytes: ByteArray): ByteArray? {129    if (bytes.size >= 8 && bytes[0] == 0.toByte() && bytes[1] == 0.toByte() &&130        bytes[2] == 1.toByte() && bytes[3] == 0.toByte()131    ) {132        if (bytes.size < 22) return null133        val offset = (bytes[18].toInt() and 0xFF) or134            ((bytes[19].toInt() and 0xFF) shl 8) or135            ((bytes[20].toInt() and 0xFF) shl 16) or136            ((bytes[21].toInt() and 0xFF) shl 24)137        if (offset > 0 && offset + 8 <= bytes.size &&138            bytes[offset] == 0x89.toByte() && bytes[offset + 1] == 0x50.toByte()139        ) {140            return bytes.copyOfRange(offset, bytes.size)141        }142        return null143    }144    return bytes145}