88b5c2d3481a
refactor: favicons fetch via DuckDuckGo's icon API (v2.10.15)
- time
- 2026-08-03 16:41 UTC · 2 months ago
- machine
- koh2
- parent
- 5ba8b0ca927d
↓ peach-android-88b5c2d3.tar.gzatlas at this save
files changed3 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 −41app/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 }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)