package com.peachpasswords.ui.components
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* U1: real site favicons, offline-first with a disk cache.
*
* The browser extension renders favicons from Chrome's OWN favicon cache and
* never asks a third party ("no domain name leaves the browser"). Android has
* no such cache, so it asks the site itself — `https://{domain}/favicon.ico`,
* then the page's `` — never a third-party favicon service.
* Failures negative-cache for [MISS_RETRY_MS] and fall back to initials.
*/
@Singleton
class Favicons @Inject constructor(@ApplicationContext private val context: Context) {
private val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(8, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.followRedirects(true)
.build()
}
private val dir: File by lazy { File(context.cacheDir, "favicons").apply { mkdirs() } }
private val fetchDispatcher = Dispatchers.IO.limitedParallelism(2)
private val scope = CoroutineScope(SupervisorJob() + fetchDispatcher)
private val inFlight = Collections.newSetFromMap(ConcurrentHashMap())
/** Bumped whenever any favicon lands on disk; composables key recomposition on it. */
private val _revision = MutableStateFlow(0)
val revision: StateFlow = _revision
fun fileFor(domain: String): File = File(dir, "$domain.png")
private fun missFileFor(domain: String): File = File(dir, "$domain.miss")
fun bitmap(domain: String, sizePx: Int): Bitmap? = runCatching {
val file = fileFor(domain)
if (!file.exists()) return null
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
var sample = 1
while (bounds.outWidth / sample > sizePx * 2 && bounds.outHeight / sample > sizePx * 2) sample *= 2
BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample })
}.getOrNull()
/** Fire-and-forget fetches for every domain missing from the cache. */
fun prefetch(domains: List) {
for (domain in domains.distinct()) {
if (!needsFetch(domain) || !inFlight.add(domain)) continue
scope.launch {
try {
fetch(domain)
} finally {
inFlight.remove(domain)
}
}
}
}
private fun needsFetch(domain: String): Boolean {
if (fileFor(domain).exists()) return false
val miss = missFileFor(domain)
return !miss.exists() || System.currentTimeMillis() - miss.lastModified() >= MISS_RETRY_MS
}
internal suspend fun fetch(domain: String) = withContext(Dispatchers.IO) {
val bytes = fetchUrl("https://$domain/favicon.ico")
?.let(::decodeOrUnwrapIco)
?: fetchLinkedIcon(domain)
val saved = bytes != null && saveScaledPng(bytes, fileFor(domain))
if (saved) {
_revision.value++
} else {
runCatching { missFileFor(domain).createNewFile() }
}
}
private fun fetchLinkedIcon(domain: String): ByteArray? {
val page = fetchUrl("https://$domain/", maxBytes = PAGE_READ_LIMIT) ?: return null
val href = extractIconHref(String(page, Charsets.UTF_8)) ?: return null
val url = resolveIconUrl(href, domain) ?: return null
return fetchUrl(url)?.let(::decodeOrUnwrapIco)
}
/** GET with a bounded read; returns null on any failure or oversize body. */
private fun fetchUrl(url: String, maxBytes: Long = MAX_ICON_BYTES): ByteArray? = runCatching {
val request = Request.Builder().url(url).header("User-Agent", "Peach/1.0").build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@runCatching null
val body = response.body ?: return@runCatching null
val source = body.source()
source.request(maxBytes + 1)
if (source.buffer.size > maxBytes) null else source.readByteArray()
}
}.getOrNull()
private fun saveScaledPng(bytes: ByteArray, target: File): Boolean = runCatching {
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return false
val scaled = Bitmap.createScaledBitmap(decoded, ICON_PX, ICON_PX, true)
target.outputStream().use { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) }
if (scaled !== decoded) scaled.recycle()
decoded.recycle()
true
}.getOrDefault(false)
companion object {
const val ICON_PX = 64
const val MAX_ICON_BYTES = 256L * 1024L
const val PAGE_READ_LIMIT = 64L * 1024L
const val MISS_RETRY_MS = 7L * 24 * 60 * 60 * 1000
}
}
/**
* ICO containers usually wrap a PNG; BitmapFactory sniffs magic bytes and
* rejects the container. Unwrap the first embedded PNG when present.
*/
internal fun decodeOrUnwrapIco(bytes: ByteArray): ByteArray? {
if (bytes.size >= 8 && bytes[0] == 0.toByte() && bytes[1] == 0.toByte() &&
bytes[2] == 1.toByte() && bytes[3] == 0.toByte()
) {
if (bytes.size < 22) return null
val offset = (bytes[18].toInt() and 0xFF) or
((bytes[19].toInt() and 0xFF) shl 8) or
((bytes[20].toInt() and 0xFF) shl 16) or
((bytes[21].toInt() and 0xFF) shl 24)
if (offset > 0 && offset + 8 <= bytes.size &&
bytes[offset] == 0x89.toByte() && bytes[offset + 1] == 0x50.toByte()
) {
return bytes.copyOfRange(offset, bytes.size)
}
return null
}
return bytes
}
/**
* First `` href in the page
* head. Pure string scan — the browser does the same without a DOM parser
* dependency here (we only have the first [Favicons.PAGE_READ_LIMIT] bytes).
*/
internal fun extractIconHref(html: String): String? {
for (tag in Regex("]+>", RegexOption.IGNORE_CASE).findAll(html)) {
val rel = Regex("rel=[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE)
.find(tag.value)?.groupValues?.get(1)?.trim() ?: continue
val isIcon = rel.equals("icon", true) ||
rel.equals("shortcut icon", true) ||
rel.equals("apple-touch-icon", true)
if (!isIcon) continue
val href = Regex("href=[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE)
.find(tag.value)?.groupValues?.get(1)?.trim()
if (!href.isNullOrEmpty()) return href
}
return null
}
/** Resolve an icon href against the site origin; rejects data: and script URLs. */
internal fun resolveIconUrl(href: String, domain: String): String? {
val trimmed = href.trim()
if (trimmed.isEmpty() || trimmed.startsWith("data:", true) || trimmed.startsWith("javascript:", true)) {
return null
}
return when {
trimmed.startsWith("//") -> "https:$trimmed"
trimmed.startsWith("http://", true) -> "https://" + trimmed.removePrefix("http://")
trimmed.startsWith("https://", true) -> trimmed
trimmed.startsWith("/") -> "https://$domain$trimmed"
else -> "https://$domain/$trimmed"
}
}