package jp.juggler.subwaytooter.util import android.content.Context import android.os.Handler import android.os.SystemClock import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.api.entity.parseList import jp.juggler.subwaytooter.emoji.CustomEmoji import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.util.coroutine.launchMain import jp.juggler.util.data.JsonObject import jp.juggler.util.data.decodeJsonArray import jp.juggler.util.data.decodeJsonObject import jp.juggler.util.log.LogCategory import jp.juggler.util.network.toRequestBody import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine class CustomEmojiLister( val context: Context, private val handler: Handler, ) { companion object { private val log = LogCategory("CustomEmojiLister") internal const val CACHE_MAX = 50 internal const val ERROR_EXPIRE = 60000L * 5 private val elapsedTime: Long get() = SystemClock.elapsedRealtime() } internal class CacheItem( val key: String, var list: List, var listWithAliases: List, var mapShortCode: Map, // ロードした時刻 var timeUpdate: Long = elapsedTime, // 参照された時刻 var timeUsed: Long = timeUpdate, ) internal class Request( val cont: Continuation>, val accessInfo: SavedAccount, val reportWithAliases: Boolean = false, ) { val caller = Throwable() fun resume(item: CacheItem) { cont.resume( when (reportWithAliases) { true -> item.listWithAliases else -> item.list } ) } } // 成功キャッシュ internal val cache = ConcurrentHashMap() // エラーキャッシュ internal val cacheError = ConcurrentHashMap() private val cacheErrorItem = CacheItem( key = "error", list = emptyList(), listWithAliases = emptyList(), mapShortCode = emptyMap(), ) // ロード要求 internal val queue = ConcurrentLinkedQueue() private val worker = Worker() // ネットワーク接続が変化したらエラーキャッシュをクリア fun onNetworkChanged() { cacheError.clear() } private fun getCached(now: Long, accessInfo: SavedAccount) = getCached(now, accessInfo.apiHost.ascii) private fun getCached(now: Long, apiHostAscii: String?): CacheItem? { apiHostAscii ?: return null // 成功キャッシュ val item = cache[apiHostAscii] if (item != null && now - item.timeUpdate <= ERROR_EXPIRE) { item.timeUsed = now return item } // エラーキャッシュ val timeError = cacheError[apiHostAscii] if (timeError != null && now < timeError + ERROR_EXPIRE) { return cacheErrorItem } return null } // インスタンス用のカスタム絵文字のリストを取得する // または例外を投げる suspend fun getList( accessInfo: SavedAccount, withAliases: Boolean = false, ): List { synchronized(cache) { getCached(elapsedTime, accessInfo) }?.let { return it.list } return suspendCoroutine { cont -> try { queue.add(Request(cont, accessInfo, reportWithAliases = withAliases)) worker.notifyEx() } catch (ex: Throwable) { cont.resumeWithException(ex) } } } fun tryGetList( accessInfo: SavedAccount, withAliases: Boolean = false, callback: ((List) -> Unit)? = null, ): List? { synchronized(cache) { getCached(elapsedTime, accessInfo) }?.let { return it.list } launchMain { try { getList(accessInfo, withAliases).let { callback?.invoke(it) } } catch (ex: Throwable) { log.e(ex, "getList failed.") } } return null } // suspend fun getMap(accessInfo: SavedAccount) = // HashMap().apply { // getList(accessInfo).forEach { put(it.shortcode, it) } // } fun getMapNonBlocking(accessInfo: SavedAccount): HashMap? = tryGetList(accessInfo)?.let { HashMap().apply { it.forEach { put(it.shortcode, it) } } } fun getCachedEmoji(apiHostAscii: String?, shortcode: String): CustomEmoji? { val cache = getCached(elapsedTime, apiHostAscii) if (cache == null) { log.w("getCachedEmoji: missing cache for $apiHostAscii") return null } val emoji = cache.mapShortCode[shortcode] if (emoji == null) { log.w("getCachedEmoji: missing emoji for $shortcode in $apiHostAscii") return null } return emoji } private inner class Worker : WorkerBase() { override fun cancel() { // このスレッドはキャンセルされない。プロセスが生きている限り動き続ける。 } override suspend fun run() { while (true) { try { // リクエストを取得する val request = queue.poll() if (request == null) { // なければ待機 waitEx(86400000L) continue } try { request.resume(handleRequest(request)) } catch (ex: Throwable) { log.e(request.caller, "caller's call stack") request.cont.resumeWithException(ex) } } catch (ex: Throwable) { log.e(ex, "can't load custom emoji list.") waitEx(3000L) } } } private suspend fun handleRequest(request: Request): CacheItem { synchronized(cache) { (getCached(elapsedTime, request.accessInfo) ?.takeIf { it != cacheErrorItem }) .also { if (it == null) { // エラーキャッシュは一定時間で除去される sweepCache() } } }?.let { return it } val accessInfo = request.accessInfo val cacheKey = accessInfo.apiHost.ascii // v12のmetaからemojisをパース suspend fun misskeyEmojis12(): List? = App1.getHttpCachedString( "https://$cacheKey/api/meta", accessInfo = accessInfo ) { builder -> builder.post(JsonObject().toRequestBody()) }?.decodeJsonObject() ?.jsonArray("emojis") ?.let { parseList(it, CustomEmoji::decodeMisskey) } // v13のemojisを読む suspend fun misskeyEmojis13(): List? = App1.getHttpCachedString( "https://$cacheKey/api/emojis", accessInfo = accessInfo, misskeyPost = true, ) { builder -> builder.post(JsonObject().toRequestBody()) } ?.decodeJsonObject() ?.jsonArray("emojis") ?.let { emojis13 -> parseList(emojis13) { CustomEmoji.decodeMisskey13(accessInfo.apiHost, it) } } // マストドンのカスタム絵文字一覧を読む suspend fun mastodonEmojis() = App1.getHttpCachedString( "https://$cacheKey/api/v1/custom_emojis", accessInfo = accessInfo )?.let { data -> parseList(data.decodeJsonArray(), CustomEmoji::decodeMastodon) } val list = when { accessInfo.isMastodon -> mastodonEmojis() else -> misskeyEmojis12() ?: misskeyEmojis13() }?.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.shortcode }) val listWithAlias = list?.let { srcList -> ArrayList(srcList).apply { for (item in srcList) { item.aliases ?.filter { !it.equals(item.shortcode, ignoreCase = true) } ?.forEach { add(item.makeAlias(it)) } } } }?.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.alias ?: it.shortcode }) return synchronized(cache) { val now = elapsedTime if (list == null || listWithAlias == null) { cacheError[cacheKey] = now error("can't load custom emoji for ${accessInfo.apiHost}") } else { val mapShortCode = buildMap { list.forEach { put(it.alias ?: it.shortcode, it) } listWithAlias.forEach { put(it.alias ?: it.shortcode, it) } } var item = cache[cacheKey] if (item == null) { item = CacheItem(cacheKey, list, listWithAlias, mapShortCode) cache[cacheKey] = item } else { item.list = list item.listWithAliases = listWithAlias item.mapShortCode = mapShortCode item.timeUpdate = now } item } } } // キャッシュの掃除 private fun sweepCache() { // 超過してる数 val over = cache.size - CACHE_MAX if (over <= 0) return // 古い要素を一時リストに集める // 昇順ソート // 古い物から順に捨てる val now = elapsedTime cache.entries .filter { now - it.value.timeUsed > 1000L } .sortedBy { it.value.timeUsed } .take(over) .forEach { cache.remove(it.key) } } } }