1package com.peachpasswords.ui.components23import android.content.Context4import android.graphics.Bitmap5import android.graphics.BitmapFactory6import com.peachpasswords.shared.extractDomain7import dagger.hilt.android.qualifiers.ApplicationContext8import java.io.File9import java.net.IDN10import java.util.Collections11import java.util.Locale12import java.util.concurrent.ConcurrentHashMap13import java.util.concurrent.TimeUnit14import javax.inject.Inject15import javax.inject.Singleton16import kotlinx.coroutines.CoroutineScope17import kotlinx.coroutines.Dispatchers18import kotlinx.coroutines.SupervisorJob19import kotlinx.coroutines.flow.MutableStateFlow20import kotlinx.coroutines.flow.StateFlow21import kotlinx.coroutines.launch22import kotlinx.coroutines.withContext23import okhttp3.OkHttpClient24import okhttp3.Request2526272829303132333435@Singleton36class Favicons @Inject constructor(@ApplicationContext private val context: Context) {3738 private val client: OkHttpClient by lazy {39 OkHttpClient.Builder()40 .connectTimeout(8, TimeUnit.SECONDS)41 .readTimeout(8, TimeUnit.SECONDS)42 .followRedirects(true)43 .build()44 }45 private val dir: File by lazy { File(context.cacheDir, "favicons").apply { mkdirs() } }46 private val fetchDispatcher = Dispatchers.IO.limitedParallelism(2)47 private val scope = CoroutineScope(SupervisorJob() + fetchDispatcher)48 private val inFlight = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())4950 51 private val _revision = MutableStateFlow(0)52 val revision: StateFlow<Int> = _revision5354 private fun fileFor(domain: String): File? = faviconCacheFile(dir, domain, ".png")55 private fun missFileFor(domain: String): File? = faviconCacheFile(dir, domain, ".miss")5657 fun bitmap(domain: String, sizePx: Int): Bitmap? = runCatching {58 val file = fileFor(domain) ?: return null59 if (!file.exists()) return null60 val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }61 BitmapFactory.decodeFile(file.absolutePath, bounds)62 if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null63 var sample = 164 while (bounds.outWidth / sample > sizePx * 2 && bounds.outHeight / sample > sizePx * 2) sample *= 265 BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample })66 }.getOrNull()6768 69 fun prefetch(domains: List<String>) {70 for (domain in domains.mapNotNull(::canonicalFaviconDomain).distinct()) {71 if (!needsFetch(domain) || !inFlight.add(domain)) continue72 scope.launch {73 try {74 fetch(domain)75 } finally {76 inFlight.remove(domain)77 }78 }79 }80 }8182 private fun needsFetch(domain: String): Boolean {83 if (fileFor(domain)?.exists() == true) return false84 val miss = missFileFor(domain) ?: return false85 return !miss.exists() || System.currentTimeMillis() - miss.lastModified() >= MISS_RETRY_MS86 }8788 internal suspend fun fetch(domain: String) = withContext(Dispatchers.IO) {89 val canonicalDomain = canonicalFaviconDomain(domain) ?: return@withContext90 val bytes = fetchUrl("https://icons.duckduckgo.com/ip3/$canonicalDomain.ico")91 ?.let(::decodeOrUnwrapIco)92 val target = fileFor(canonicalDomain) ?: return@withContext93 val saved = bytes != null && saveScaledPng(bytes, target)94 if (saved) {95 _revision.value++96 } else {97 runCatching { missFileFor(canonicalDomain)?.createNewFile() }98 }99 }100101 102 private fun fetchUrl(url: String, maxBytes: Long = MAX_ICON_BYTES): ByteArray? = runCatching {103 val request = Request.Builder().url(url).header("User-Agent", "Peach/1.0").build()104 client.newCall(request).execute().use { response ->105 if (!response.isSuccessful) return@runCatching null106 val body = response.body ?: return@runCatching null107 val source = body.source()108 source.request(maxBytes + 1)109 if (source.buffer.size > maxBytes) null else source.readByteArray()110 }111 }.getOrNull()112113 private fun saveScaledPng(bytes: ByteArray, target: File): Boolean = runCatching {114 val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return false115 val scaled = Bitmap.createScaledBitmap(decoded, ICON_PX, ICON_PX, true)116 target.outputStream().use { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) }117 if (scaled !== decoded) scaled.recycle()118 decoded.recycle()119 true120 }.getOrDefault(false)121122 companion object {123 const val ICON_PX = 64124 const val MAX_ICON_BYTES = 256L * 1024L125 const val MISS_RETRY_MS = 7L * 24 * 60 * 60 * 1000126 }127}128129130internal fun canonicalFaviconDomain(raw: String): String? {131 if (raw.isEmpty() || raw != raw.trim()) return null132 if (raw.any { it <= '\u001F' || it == '\u007F' || it in "/\\@?#:" }) return null133 if (raw.endsWith('.')) return null134 val ascii = runCatching { IDN.toASCII(raw, IDN.USE_STD3_ASCII_RULES) }135 .getOrNull()136 ?.lowercase(Locale.ROOT)137 ?: return null138 if (ascii.length > 253 || ascii.split('.').any { it.isEmpty() || it.length > 63 }) return null139 return ascii.takeIf { extractDomain(it) == it }140}141142143internal fun faviconCacheFile(directory: File, rawDomain: String, suffix: String): File? {144 if (suffix != ".png" && suffix != ".miss") return null145 val domain = canonicalFaviconDomain(rawDomain) ?: return null146 val base = runCatching { directory.canonicalFile }.getOrNull() ?: return null147 val target = runCatching { File(base, "$domain$suffix").canonicalFile }.getOrNull() ?: return null148 return target.takeIf { it.parentFile == base }149}150151152153154155internal fun decodeOrUnwrapIco(bytes: ByteArray): ByteArray? {156 if (bytes.size >= 8 && bytes[0] == 0.toByte() && bytes[1] == 0.toByte() &&157 bytes[2] == 1.toByte() && bytes[3] == 0.toByte()158 ) {159 if (bytes.size < 22) return null160 val offset = (bytes[18].toInt() and 0xFF) or161 ((bytes[19].toInt() and 0xFF) shl 8) or162 ((bytes[20].toInt() and 0xFF) shl 16) or163 ((bytes[21].toInt() and 0xFF) shl 24)164 if (offset > 0 && offset + 8 <= bytes.size &&165 bytes[offset] == 0x89.toByte() && bytes[offset + 1] == 0x50.toByte()166 ) {167 return bytes.copyOfRange(offset, bytes.size)168 }169 return null170 }171 return bytes172}