1package com.peachpasswords.ui.components23import android.content.Context4import android.graphics.Bitmap5import android.graphics.BitmapFactory6import dagger.hilt.android.qualifiers.ApplicationContext7import java.io.File8import java.util.Collections9import java.util.concurrent.ConcurrentHashMap10import java.util.concurrent.TimeUnit11import javax.inject.Inject12import javax.inject.Singleton13import kotlinx.coroutines.CoroutineScope14import kotlinx.coroutines.Dispatchers15import kotlinx.coroutines.SupervisorJob16import kotlinx.coroutines.flow.MutableStateFlow17import kotlinx.coroutines.flow.StateFlow18import kotlinx.coroutines.launch19import kotlinx.coroutines.withContext20import okhttp3.OkHttpClient21import okhttp3.Request2223242526272829303132@Singleton33class Favicons @Inject constructor(@ApplicationContext private val context: Context) {3435 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>())4647 48 private val _revision = MutableStateFlow(0)49 val revision: StateFlow<Int> = _revision5051 fun fileFor(domain: String): File = File(dir, "$domain.png")52 private fun missFileFor(domain: String): File = File(dir, "$domain.miss")5354 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()6465 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 }7879 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 }8485 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 }9697 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 }103104 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()115116 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)124125 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}132133134135136137internal 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}155156157158159160161internal 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}175176177internal 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}