c9a6cfec817e

feat: real site favicons with disk cache, initials fallback (v2.10.11)

Parity queue #6 (U1). - Privacy: the browser extension uses Chrome's own favicon cache and never asks a third party; Android has no such cache, so it asks the SITE ITSELF (https://{domain}/favicon.ico, then the page's <link rel=icon>) — never a third-party favicon service - Favicons repository: OkHttp (8s timeouts), 256KB icon cap, 64KB page-read cap for link discovery, parallelism 2, disk cache in cacheDir/favicons, 7-day negative cache, PNG-in-ICO unwrap, stored as 64px PNG - FaviconInitials renders the cached bitmap first (LocalFavicons composition local from MainActivity + picker), initials until the favicon lands - Prefetch from VaultListScreen over entry domains; picker/health/trash rows reuse the same cache - tests: FaviconsTest (7). Full suite 898, only the 5 known pre-existing failures. Visual QA pending (needs real network on-device).

time
2026-08-02 23:47 UTC · 2 months ago
machine
koh2
parent
8fe28904f1b4
7 files VERSION+1 −1app/src/main/java/com/peachpasswords/MainActivity.kt+12 −7app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+5 −0app/src/main/java/com/peachpasswords/ui/components/FaviconInitials.kt+40 −3app/src/main/java/com/peachpasswords/ui/components/Favicons.kt+189 −0app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+10 −0app/src/test/java/com/peachpasswords/ui/components/FaviconsTest.kt+68 −0
VERSION+1 −1
@@ -1 +1 @@1-2.10.101+2.10.11

view file

app/src/main/java/com/peachpasswords/MainActivity.kt+12 −7
@@ -25,6 +25,8 @@2525 import com.peachpasswords.autofill.AutofillIntents2626 import com.peachpasswords.autofill.AutofillNavRepository2727 import com.peachpasswords.ui.PeachAppNavHost28+import com.peachpasswords.ui.components.Favicons29+import com.peachpasswords.ui.components.LocalFavicons2830 import com.peachpasswords.ui.components.QaFlags2931 import com.peachpasswords.ui.components.SecureWindowPolicy3032 import com.peachpasswords.ui.onboarding.ONBOARD_KEY@@ -42,6 +44,7 @@4244 @AndroidEntryPoint4345 class MainActivity : FragmentActivity() {4446     @Inject lateinit var vault: VaultRepository47+    @Inject lateinit var favicons: Favicons4548     @Inject lateinit var lockTimer: VaultLockTimer4649     @Inject lateinit var updates: UpdateRepository4750     @Inject lateinit var autofillNav: AutofillNavRepository@@ -115,13 +118,15 @@115118                     SecureWindowPolicy.setUserSecure(window, !settings.allowScreenshots)116119                 }117120             }118-            PeachTheme(119-                accent = accent,120-                reduceMotion = settings.reduceMotion,121-                textSize = settings.textSize,122-            ) {123-                WideScreenScale {124-                    PeachAppNavHost(startDestination = start, isUnlocked = vault.isUnlocked, uiGateLocked = vault.uiGateLocked)121+            CompositionLocalProvider(LocalFavicons provides favicons) {122+                PeachTheme(123+                    accent = accent,124+                    reduceMotion = settings.reduceMotion,125+                    textSize = settings.textSize,126+                ) {127+                    WideScreenScale {128+                        PeachAppNavHost(startDestination = start, isUnlocked = vault.isUnlocked, uiGateLocked = vault.uiGateLocked)129+                    }125130                 }126131             }127132         }

view file

app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+5 −0
@@ -26,6 +26,7 @@2626 import androidx.compose.foundation.text.KeyboardActions2727 import androidx.compose.foundation.text.KeyboardOptions2828 import androidx.compose.runtime.Composable29+import androidx.compose.runtime.CompositionLocalProvider2930 import androidx.compose.runtime.LaunchedEffect3031 import androidx.compose.runtime.getValue3132 import androidx.compose.runtime.mutableStateOf@@ -51,6 +52,7 @@5152 import com.peachpasswords.ui.lock.BiometricKeyStore5253 import com.peachpasswords.ui.lock.Biometrics5354 import com.peachpasswords.ui.components.FaviconInitials55+import com.peachpasswords.ui.components.LocalFavicons5456 import com.peachpasswords.ui.components.QaFlags5557 import com.peachpasswords.ui.components.SecureWindowPolicy5658 import com.peachpasswords.ui.theme.ACCENT_COLORS@@ -93,6 +95,7 @@9395 class AutofillEntryPickerActivity : FragmentActivity() {9496 9597     @Inject lateinit var vault: VaultRepository98+    @Inject lateinit var favicons: com.peachpasswords.ui.components.Favicons9699     private var debugSecureLease = false97100 98101     @OptIn(ExperimentalMaterial3Api::class)@@ -137,6 +140,7 @@137140         val accent = settings.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT138141 139142         setContent {143+            CompositionLocalProvider(LocalFavicons provides favicons) {140144             PeachTheme(accent = accent, textSize = settings.textSize) {141145                 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)142146                 val scope = rememberCoroutineScope()@@ -261,6 +265,7 @@261265                         },262266                     )263267                 }268+            }264269             }265270         }266271     }

view file

app/src/main/java/com/peachpasswords/ui/components/FaviconInitials.kt+40 −3
@@ -1,27 +1,43 @@11 package com.peachpasswords.ui.components22 3+import androidx.compose.foundation.Image34 import androidx.compose.foundation.layout.Box45 import androidx.compose.foundation.layout.size56 import androidx.compose.foundation.shape.RoundedCornerShape67 import androidx.compose.material3.Text78 import androidx.compose.runtime.Composable9+import androidx.compose.runtime.LaunchedEffect10+import androidx.compose.runtime.getValue11+import androidx.compose.runtime.mutableIntStateOf12+import androidx.compose.runtime.remember13+import androidx.compose.runtime.setValue14+import androidx.compose.runtime.staticCompositionLocalOf815 import androidx.compose.ui.Alignment916 import androidx.compose.ui.Modifier1017 import androidx.compose.ui.draw.clip1118 import androidx.compose.ui.graphics.Color1219 import androidx.compose.ui.draw.drawBehind20+import androidx.compose.ui.graphics.asImageBitmap21+import androidx.compose.ui.layout.ContentScale22+import androidx.compose.ui.platform.LocalDensity1323 import androidx.compose.ui.text.font.FontWeight1424 import androidx.compose.ui.unit.Dp1525 import androidx.compose.ui.unit.dp1626 import androidx.compose.ui.unit.em1727 import androidx.compose.ui.unit.sp28+import com.peachpasswords.shared.extractDomain1829 import com.peachpasswords.ui.theme.peachColors1930 import com.peachpasswords.ui.theme.PeachType2031 32+/** Favicons repository for this window; provided by MainActivity and the picker. */33+val LocalFavicons = staticCompositionLocalOf<Favicons?> { null }34+2135 /**22- * Initials chip used in place of a network favicon (offline-first). Ports the23- * browser titleInitials + titleBackground (deterministic HSL 32%/26%). The24- * corner radius tracks the chip size (≈6dp at 26dp, ≈3dp at 16dp) per spec §4.6.36+ * Site favicon with initials fallback (offline-first). Shows the cached real37+ * favicon (U1, [Favicons]) once fetched; until then the initials chip — the38+ * browser's titleInitials + titleBackground port (deterministic HSL 32%/26%).39+ * The corner radius tracks the chip size (≈6dp at 26dp, ≈3dp at 16dp) per40+ * spec §4.6.2541  */2642 @Composable2743 fun FaviconInitials(@@ -30,6 +46,27 @@3046     size: Dp = 26.dp,3147     cornerRadius: Dp = (size.value * 0.23f).dp,3248 ) {49+    val favicons = LocalFavicons.current50+    val domain = remember(source) { extractDomain(source) }51+    var revision by remember { mutableIntStateOf(0) }52+    LaunchedEffect(favicons) {53+        favicons?.revision?.collect { revision = it }54+    }55+    val sizePx = with(LocalDensity.current) { size.roundToPx() }56+    val bitmap = remember(domain, revision, sizePx) {57+        if (domain != null && favicons != null) favicons.bitmap(domain, sizePx) else null58+    }59+    if (bitmap != null) {60+        Image(61+            bitmap = bitmap.asImageBitmap(),62+            contentDescription = null,63+            contentScale = ContentScale.Crop,64+            modifier = modifier65+                .size(size)66+                .clip(RoundedCornerShape(cornerRadius)),67+        )68+        return69+    }3370     val bg = titleBackground(source)3471     Box(3572         modifier = modifier

view file

app/src/main/java/com/peachpasswords/ui/components/Favicons.kt+189 −0
@@ -0,0 +1,189 @@1+package com.peachpasswords.ui.components2+3+import android.content.Context4+import android.graphics.Bitmap5+import android.graphics.BitmapFactory6+import dagger.hilt.android.qualifiers.ApplicationContext7+import java.io.File8+import java.util.Collections9+import java.util.concurrent.ConcurrentHashMap10+import java.util.concurrent.TimeUnit11+import javax.inject.Inject12+import javax.inject.Singleton13+import kotlinx.coroutines.CoroutineScope14+import kotlinx.coroutines.Dispatchers15+import kotlinx.coroutines.SupervisorJob16+import kotlinx.coroutines.flow.MutableStateFlow17+import kotlinx.coroutines.flow.StateFlow18+import kotlinx.coroutines.launch19+import kotlinx.coroutines.withContext20+import okhttp3.OkHttpClient21+import okhttp3.Request22+23+/**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+@Singleton33+class Favicons @Inject constructor(@ApplicationContext private val context: Context) {34+35+    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>())46+47+    /** Bumped whenever any favicon lands on disk; composables key recomposition on it. */48+    private val _revision = MutableStateFlow(0)49+    val revision: StateFlow<Int> = _revision50+51+    fun fileFor(domain: String): File = File(dir, "$domain.png")52+    private fun missFileFor(domain: String): File = File(dir, "$domain.miss")53+54+    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()64+65+    /** 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+    }78+79+    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+    }84+85+    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+    }96+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+104+    /** 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()115+116+    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)124+125+    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+}132+133+/**134+ * ICO containers usually wrap a PNG; BitmapFactory sniffs magic bytes and135+ * rejects the container. Unwrap the first embedded PNG when present.136+ */137+internal 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+}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+    }189+}

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+10 −0
@@ -99,6 +99,8 @@9999 import com.peachpasswords.ui.sync.SyncViewModel100100 import com.peachpasswords.autofill.AutofillEnablement101101 import com.peachpasswords.ui.PeachNav102+import com.peachpasswords.shared.extractDomain103+import com.peachpasswords.ui.components.LocalFavicons102104 import com.peachpasswords.ui.components.PeachEmptyOrEndState103105 import com.peachpasswords.ui.components.PeachFilterPills104106 import com.peachpasswords.ui.components.PeachSegment@@ -141,6 +143,14 @@141143         }142144     }143145     val entries by vm.entries.collectAsState()146+147+    // U1: warm the on-disk favicon cache for every entry domain; rows render148+    // initials until the site's own favicon lands. Bounded concurrency, cached149+    // and negative-cached domains skip instantly.150+    val favicons = LocalFavicons.current151+    LaunchedEffect(entries) {152+        favicons?.prefetch(entries.mapNotNull { it.url?.let(::extractDomain) })153+    }144154     val sort by vm.sort.collectAsState()145155     val search by vm.search.collectAsState()146156     val total by vm.totalCount.collectAsState()

view file

app/src/test/java/com/peachpasswords/ui/components/FaviconsTest.kt+68 −0
@@ -0,0 +1,68 @@1+package com.peachpasswords.ui.components2+3+import org.junit.Assert.assertArrayEquals4+import org.junit.Assert.assertEquals5+import org.junit.Assert.assertNull6+import org.junit.Test7+8+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+    }49+50+    @Test fun unwraps_png_inside_ico_container() {51+        val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3)52+        val ico = ByteArray(22 + png.size)53+        ico[2] = 1 // ICO type54+        ico[18] = 22 // data offset, little-endian55+        png.copyInto(ico, 22)56+        assertArrayEquals(png, decodeOrUnwrapIco(ico))57+    }58+59+    @Test fun passes_non_ico_bytes_through_and_rejects_ico_without_png() {60+        val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 1, 2, 3)61+        assertArrayEquals(png, decodeOrUnwrapIco(png))62+63+        val icoBmp = ByteArray(22 + 4)64+        icoBmp[2] = 165+        icoBmp[18] = 2266+        assertNull(decodeOrUnwrapIco(icoBmp))67+    }68+}

view file