c9a6cfec817e
feat: real site favicons with disk cache, initials fallback (v2.10.11)
- time
- 2026-08-02 23:47 UTC · 2 months ago
- machine
- koh2
- parent
- 8fe28904f1b4
↓ peach-android-c9a6cfec.tar.gzatlas at this save
files changed7 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 −0app/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 }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 }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 = modifierapp/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+}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()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+}