package com.kododake.aabrowser.data import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri import android.os.Handler import android.os.Looper import android.util.LruCache import okhttp3.OkHttpClient import okhttp3.Request import java.io.File import java.io.FileOutputStream import java.util.Collections import java.util.concurrent.TimeUnit object SiteIconCache { private const val ICON_DIR = "site-icons" private val client = OkHttpClient.Builder() .connectTimeout(6, TimeUnit.SECONDS) .readTimeout(6, TimeUnit.SECONDS) .callTimeout(10, TimeUnit.SECONDS) .build() private val mainHandler = Handler(Looper.getMainLooper()) private val inFlightCallbacks = Collections.synchronizedMap(mutableMapOf Unit>>()) private val memoryCache: LruCache by lazy { val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt() val cacheSize = maxMemory / 16 object : LruCache(cacheSize) { override fun sizeOf(key: String, value: Bitmap): Int { return value.byteCount / 1024 } } } fun getCachedIcon(context: Context, url: String?): Bitmap? { val hostKey = hostKey(url) ?: return null val memBitmap = memoryCache.get(hostKey) if (memBitmap != null) return memBitmap val file = iconFile(context, url) ?: return null if (!file.exists()) return null val diskBitmap = runCatching { BitmapFactory.decodeFile(file.absolutePath) }.getOrNull() if (diskBitmap != null) { memoryCache.put(hostKey, diskBitmap) } return diskBitmap } fun cacheIcon(context: Context, pageUrl: String?, bitmap: Bitmap?) { if (bitmap == null) return val hostKey = hostKey(pageUrl) if (hostKey != null) { memoryCache.put(hostKey, bitmap) } val file = iconFile(context, pageUrl) ?: return runCatching { file.parentFile?.mkdirs() FileOutputStream(file).use { output -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, output) } } } fun prefetchIconIfNeeded( context: Context, pageUrl: String?, onComplete: (Bitmap?) -> Unit = {} ) { val cached = getCachedIcon(context, pageUrl) if (cached != null) { onComplete(cached) return } val hostKey = hostKey(pageUrl) ?: run { onComplete(null) return } var isFirstRequest = false val listToNotify = synchronized(inFlightCallbacks) { val list = inFlightCallbacks[hostKey] if (list == null) { isFirstRequest = true val newList = mutableListOf(onComplete) inFlightCallbacks[hostKey] = newList newList } else { list.add(onComplete) null } } if (!isFirstRequest) return Thread { val bitmap = runCatching { val request = Request.Builder() .url(resolveIconUrl(pageUrl) ?: return@runCatching null) .header("User-Agent", "AABrowser Icon Fetcher") .build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) return@use null val body = response.body body.byteStream().use { stream -> BitmapFactory.decodeStream(stream) } } }.getOrNull() if (bitmap != null) { cacheIcon(context, pageUrl, bitmap) } val pending = synchronized(inFlightCallbacks) { inFlightCallbacks.remove(hostKey) } if (pending != null) { mainHandler.post { pending.forEach { callback -> callback(bitmap) } } } }.start() } private fun iconFile(context: Context, url: String?): File? { val hostKey = hostKey(url) ?: return null return File(File(context.cacheDir, ICON_DIR), "$hostKey.png") } private fun hostKey(url: String?): String? { val host = runCatching { Uri.parse(url).host?.lowercase() }.getOrNull() ?.replace(Regex("[^a-z0-9._-]"), "_") ?.takeIf { it.isNotBlank() } return host } private fun resolveIconUrl(pageUrl: String?): String? { val uri = runCatching { Uri.parse(pageUrl) }.getOrNull() ?: return null val host = uri.host?.lowercase() ?: return null return when (host) { "m.youtube.com", "www.youtube.com", "youtube.com" -> "https://www.youtube.com/favicon.ico" "www.google.com", "google.com" -> "https://www.google.com/favicon.ico" else -> { val scheme = uri.scheme?.takeIf { it.equals("http", true) || it.equals("https", true) } ?: "https" "$scheme://$host/favicon.ico" } } } }