88b5c2d3481a

refactor: favicons fetch via DuckDuckGo's icon API (v2.10.15)

June: 'we should've used duckduckgo' — supersedes the v2.10.11 direct-from-site strategy. - Favicons.fetch now hits icons.duckduckgo.com/ip3/{domain}.ico only; one small request per domain, domain name is the only thing sent - Deleted the direct /favicon.ico fetch and the homepage <link rel=icon> crawl (fetchLinkedIcon/extractIconHref/resolveIconUrl/PAGE_READ_LIMIT + their 5 tests) - Kept: ICO->PNG unwrap (DDG serves real ICO containers — verified live), decode+scale to 64px, disk cache, 7-day negative cache, 256KB cap - full suite 897, only the 5 known pre-existing failures

time
2026-08-03 16:41 UTC · 2 months ago
machine
koh2
parent
5ba8b0ca927d
3 files VERSION+1 −1app/src/main/java/com/peachpasswords/ui/components/Favicons.kt+6 −50app/src/test/java/com/peachpasswords/ui/components/FaviconsTest.kt+0 −41
VERSION+1 −1
@@ -1 +1 @@1-2.10.141+2.10.15

view file

app/src/main/java/com/peachpasswords/ui/components/Favicons.kt+6 −50
@@ -23,11 +23,11 @@2323 /**2424  * U1: real site favicons, offline-first with a disk cache.2525  *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.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.3131  */3232 @Singleton3333 class Favicons @Inject constructor(@ApplicationContext private val context: Context) {@@ -83,9 +83,8 @@8383     }8484 8585     internal suspend fun fetch(domain: String) = withContext(Dispatchers.IO) {86-        val bytes = fetchUrl("https://$domain/favicon.ico")86+        val bytes = fetchUrl("https://icons.duckduckgo.com/ip3/$domain.ico")8787             ?.let(::decodeOrUnwrapIco)88-            ?: fetchLinkedIcon(domain)8988         val saved = bytes != null && saveScaledPng(bytes, fileFor(domain))9089         if (saved) {9190             _revision.value++@@ -94,13 +93,6 @@9493         }9594     }9695 97-    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-    }103-10496     /** GET with a bounded read; returns null on any failure or oversize body. */10597     private fun fetchUrl(url: String, maxBytes: Long = MAX_ICON_BYTES): ByteArray? = runCatching {10698         val request = Request.Builder().url(url).header("User-Agent", "Peach/1.0").build()@@ -125,7 +117,6 @@125117     companion object {126118         const val ICON_PX = 64127119         const val MAX_ICON_BYTES = 256L * 1024L128-        const val PAGE_READ_LIMIT = 64L * 1024L129120         const val MISS_RETRY_MS = 7L * 24 * 60 * 60 * 1000130121     }131122 }@@ -151,39 +142,4 @@151142         return null152143     }153144     return bytes154-}155-156-/**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- */161-internal 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-}175-176-/** Resolve an icon href against the site origin; rejects data: and script URLs. */177-internal 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-    }189145 }

view file

app/src/test/java/com/peachpasswords/ui/components/FaviconsTest.kt+0 −41
@@ -1,51 +1,10 @@11 package com.peachpasswords.ui.components22 33 import org.junit.Assert.assertArrayEquals4-import org.junit.Assert.assertEquals54 import org.junit.Assert.assertNull65 import org.junit.Test76 87 class FaviconsTest {9-10-    @Test fun finds_rel_icon_href() {11-        val html = """<html><head><link rel="icon" href="/static/icon.png"></head></html>"""12-        assertEquals("/static/icon.png", extractIconHref(html))13-    }14-15-    @Test fun finds_shortcut_icon_and_apple_touch_icon() {16-        assertEquals(17-            "/favicon-32.png",18-            extractIconHref("""<link rel="shortcut icon" href="/favicon-32.png">"""),19-        )20-        assertEquals(21-            "/apple.png",22-            extractIconHref("""<LINK REL="apple-touch-icon" HREF="/apple.png">"""),23-        )24-    }25-26-    @Test fun skips_non_icon_links_and_returns_null_when_absent() {27-        val html = """28-            <link rel="stylesheet" href="/a.css">29-            <link rel="canonical" href="https://x.test/">30-            <link rel="preconnect" href="https://cdn.x.test">31-        """.trimIndent()32-        assertNull(extractIconHref(html))33-        assertNull(extractIconHref("<html><body>no head tags</body></html>"))34-    }35-36-    @Test fun resolves_relative_absolute_and_protocol_relative_hrefs() {37-        assertEquals("https://x.test/img/icon.png", resolveIconUrl("/img/icon.png", "x.test"))38-        assertEquals("https://x.test/icon.png", resolveIconUrl("icon.png", "x.test"))39-        assertEquals("https://cdn.x.test/i.ico", resolveIconUrl("//cdn.x.test/i.ico", "x.test"))40-        assertEquals("https://x.test/a.ico", resolveIconUrl("http://x.test/a.ico", "x.test"))41-        assertEquals("https://y.test/b.ico", resolveIconUrl("https://y.test/b.ico", "x.test"))42-    }43-44-    @Test fun rejects_data_and_javascript_hrefs() {45-        assertNull(resolveIconUrl("data:image/png;base64,AAAA", "x.test"))46-        assertNull(resolveIconUrl("javascript:void(0)", "x.test"))47-        assertNull(resolveIconUrl("   ", "x.test"))48-    }498 509     @Test fun unwraps_png_inside_ico_container() {5110         val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3)

view file