Favicons.kt

7.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 * The browser extension renders favicons from Chrome's OWN favicon cache and27 * never asks a third party ("no domain name leaves the browser"). Android has28 * no such cache, so it asks the site itself — `https://{domain}/favicon.ico`,29 * then the page's `<link rel="icon">` — never a third-party favicon service.30 * Failures negative-cache for [MISS_RETRY_MS] and 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://$domain/favicon.ico")87            ?.let(::decodeOrUnwrapIco)88            ?: fetchLinkedIcon(domain)89        val saved = bytes != null && saveScaledPng(bytes, fileFor(domain))90        if (saved) {91            _revision.value++92        } else {93            runCatching { missFileFor(domain).createNewFile() }94        }95    }9697    private fun fetchLinkedIcon(domain: String): ByteArray? {98        val page = fetchUrl("https://$domain/", maxBytes = PAGE_READ_LIMIT) ?: return null99        val href = extractIconHref(String(page, Charsets.UTF_8)) ?: return null100        val url = resolveIconUrl(href, domain) ?: return null101        return fetchUrl(url)?.let(::decodeOrUnwrapIco)102    }103104    /** GET with a bounded read; returns null on any failure or oversize body. */105    private fun fetchUrl(url: String, maxBytes: Long = MAX_ICON_BYTES): ByteArray? = runCatching {106        val request = Request.Builder().url(url).header("User-Agent", "Peach/1.0").build()107        client.newCall(request).execute().use { response ->108            if (!response.isSuccessful) return@runCatching null109            val body = response.body ?: return@runCatching null110            val source = body.source()111            source.request(maxBytes + 1)112            if (source.buffer.size > maxBytes) null else source.readByteArray()113        }114    }.getOrNull()115116    private fun saveScaledPng(bytes: ByteArray, target: File): Boolean = runCatching {117        val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return false118        val scaled = Bitmap.createScaledBitmap(decoded, ICON_PX, ICON_PX, true)119        target.outputStream().use { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) }120        if (scaled !== decoded) scaled.recycle()121        decoded.recycle()122        true123    }.getOrDefault(false)124125    companion object {126        const val ICON_PX = 64127        const val MAX_ICON_BYTES = 256L * 1024L128        const val PAGE_READ_LIMIT = 64L * 1024L129        const val MISS_RETRY_MS = 7L * 24 * 60 * 60 * 1000130    }131}132133/**134 * ICO containers usually wrap a PNG; BitmapFactory sniffs magic bytes and135 * rejects the container. Unwrap the first embedded PNG when present.136 */137internal fun decodeOrUnwrapIco(bytes: ByteArray): ByteArray? {138    if (bytes.size >= 8 && bytes[0] == 0.toByte() && bytes[1] == 0.toByte() &&139        bytes[2] == 1.toByte() && bytes[3] == 0.toByte()140    ) {141        if (bytes.size < 22) return null142        val offset = (bytes[18].toInt() and 0xFF) or143            ((bytes[19].toInt() and 0xFF) shl 8) or144            ((bytes[20].toInt() and 0xFF) shl 16) or145            ((bytes[21].toInt() and 0xFF) shl 24)146        if (offset > 0 && offset + 8 <= bytes.size &&147            bytes[offset] == 0x89.toByte() && bytes[offset + 1] == 0x50.toByte()148        ) {149            return bytes.copyOfRange(offset, bytes.size)150        }151        return null152    }153    return bytes154}155156/**157 * First `<link rel="icon|shortcut icon|apple-touch-icon">` href in the page158 * head. Pure string scan — the browser does the same without a DOM parser159 * dependency here (we only have the first [Favicons.PAGE_READ_LIMIT] bytes).160 */161internal fun extractIconHref(html: String): String? {162    for (tag in Regex("<link[^>]+>", RegexOption.IGNORE_CASE).findAll(html)) {163        val rel = Regex("rel=[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE)164            .find(tag.value)?.groupValues?.get(1)?.trim() ?: continue165        val isIcon = rel.equals("icon", true) ||166            rel.equals("shortcut icon", true) ||167            rel.equals("apple-touch-icon", true)168        if (!isIcon) continue169        val href = Regex("href=[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE)170            .find(tag.value)?.groupValues?.get(1)?.trim()171        if (!href.isNullOrEmpty()) return href172    }173    return null174}175176/** Resolve an icon href against the site origin; rejects data: and script URLs. */177internal fun resolveIconUrl(href: String, domain: String): String? {178    val trimmed = href.trim()179    if (trimmed.isEmpty() || trimmed.startsWith("data:", true) || trimmed.startsWith("javascript:", true)) {180        return null181    }182    return when {183        trimmed.startsWith("//") -> "https:$trimmed"184        trimmed.startsWith("http://", true) -> "https://" + trimmed.removePrefix("http://")185        trimmed.startsWith("https://", true) -> trimmed186        trimmed.startsWith("/") -> "https://$domain$trimmed"187        else -> "https://$domain/$trimmed"188    }189}