(Mastodon 3.0)カスタム絵文字のカテゴリ表記。カスタム絵文字ロードの並列化。

This commit is contained in:
tateisu 2019-09-25 17:42:25 +09:00
parent 7ae2f5a748
commit 40dce24593
11 changed files with 215 additions and 120 deletions

View File

@ -47,6 +47,7 @@
<w>idat</w>
<w>idempotency</w>
<w>ihdr</w>
<w>infos</w>
<w>kapt</w>
<w>kddi</w>
<w>kenglxn</w>

View File

@ -495,7 +495,7 @@ class App1 : Application() {
}
internal val CACHE_CONTROL = CacheControl.Builder()
.maxAge(5, TimeUnit.MINUTES) // キャッシュが新鮮であると考えられる時間
.maxAge(1, TimeUnit.DAYS) // キャッシュが新鮮であると考えられる時間
.build()
fun getHttpCached(url : String) : ByteArray? {

View File

@ -12,7 +12,8 @@ class CustomEmoji(
val static_url : String?, // アニメーションなしの画像URL
val aliases : ArrayList<String>? = null,
val alias : String? = null,
val visible_in_picker : Boolean = true
val visible_in_picker : Boolean = true,
val category: String? = null
) : Mappable<String> {
fun makeAlias(alias : String) = CustomEmoji(
@ -26,14 +27,17 @@ class CustomEmoji(
get() = shortcode
companion object {
val decode : (JSONObject) -> CustomEmoji = { src ->
CustomEmoji(
shortcode = src.notEmptyOrThrow("shortcode"),
url = src.notEmptyOrThrow("url"),
static_url = src.parseString("static_url"),
visible_in_picker = src.optBoolean("visible_in_picker", true)
visible_in_picker = src.optBoolean("visible_in_picker", true),
category =src.parseString("category")
)
}
val decodeMisskey : (JSONObject) -> CustomEmoji = { src ->
val url = src.parseString("url") ?: error("missing url")

View File

@ -17,9 +17,12 @@ import com.bumptech.glide.Glide
import jp.juggler.emoji.EmojiMap
import jp.juggler.subwaytooter.*
import jp.juggler.subwaytooter.api.entity.CustomEmoji
import jp.juggler.subwaytooter.view.HeaderGridView
import jp.juggler.subwaytooter.view.MyViewPager
import jp.juggler.subwaytooter.view.NetworkEmojiView
import jp.juggler.util.*
import org.jetbrains.anko.padding
import org.jetbrains.anko.textColor
import org.json.JSONObject
import java.util.*
@ -32,6 +35,22 @@ class EmojiPicker(
// onEmojiPickedのinstance引数は通常の絵文字ならnull、カスタム絵文字なら非null、
) : View.OnClickListener, ViewPager.OnPageChangeListener {
class SkinTone(val suffix_list : Array<out String>) {
companion object {
fun create(vararg suffix_list : String) : SkinTone {
return SkinTone(suffix_list)
}
}
}
internal class EmojiItem(val name : String, val instance : String?)
internal class CustomCategory(
val rangeStart : Int,
val rangeLength : Int,
val view : View
)
companion object {
internal val log = LogCategory("EmojiPicker")
@ -66,7 +85,8 @@ class EmojiPicker(
private val recent_list = ArrayList<EmojiItem>()
private val custom_list = ArrayList<EmojiItem>()
private var custom_list = ArrayList<EmojiItem>()
private var custom_categories = ArrayList<CustomCategory>()
private val emoji_url_map = HashMap<String, String>()
@ -74,16 +94,6 @@ class EmojiPicker(
private val custom_page_idx : Int
class SkinTone(val suffix_list : Array<out String>) {
companion object {
fun create(vararg suffix_list : String) : SkinTone {
return SkinTone(suffix_list)
}
}
}
internal class EmojiItem(val name : String, val instance : String?)
init {
// recentをロードする
@ -213,13 +223,52 @@ class EmojiPicker(
private fun setCustomEmojiList(list : ArrayList<CustomEmoji>?) {
if(list == null) return
bInstanceHasCustomEmoji = true
custom_list.clear()
// make categories
val newList = TreeMap<String, ArrayList<EmojiItem>>()
for(emoji in list) {
if(! emoji.visible_in_picker) continue
custom_list.add(EmojiItem(emoji.shortcode, instance))
val category = emoji.category ?: ""
var subList = newList[category]
if(subList == null) {
subList = ArrayList()
newList[category] = subList
}
subList.add(EmojiItem(emoji.shortcode, instance))
emoji_url_map[emoji.shortcode] = emoji.url
}
pager_adapter.getPageViewHolder(custom_page_idx)?.notifyDataSetChanged()
// compose categories data list
val entries = newList.entries
custom_list.clear()
custom_categories.clear()
custom_list.ensureCapacity(entries.sumBy { it.value.size })
custom_categories.ensureCapacity(entries.size)
entries.forEach {
val rangeStart = custom_list.size
custom_list.addAll(it.value)
val rangeLength = custom_list.size - rangeStart
custom_categories.add(CustomCategory(
rangeStart,
rangeLength,
TextView(activity).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT
)
text = when(val name = it.key) {
"" -> this@EmojiPicker.activity.getString(R.string.custom_emoji)
else -> name
}
textColor =
getAttributeColor(this@EmojiPicker.activity, R.attr.colorContentText)
textSize = 16f // SP単位
padding = (resources.displayMetrics.density * 2f + 0.5f).toInt()
}
))
}
pager_adapter.getPageViewHolder(custom_page_idx)?.reloadCustomEmoji()
pager_adapter.getPageViewHolder(recent_page_idx)?.notifyDataSetChanged()
}
@ -293,12 +342,12 @@ class EmojiPicker(
val id = view.id
selected_tone = if(selected_tone == id) 0 else id
showSkinTone()
pager_adapter.eachViewHolder { _, vh -> vh.reload() }
pager_adapter.eachViewHolder { _, vh -> vh.reloadSkinTone() }
}
internal inner class EmojiPickerPage(
val hasSkinTone : Boolean,
category_id : Int,
val category_id : Int,
title_id : Int
) {
@ -326,29 +375,45 @@ class EmojiPicker(
inner class EmojiPickerPageViewHolder(activity : Activity, root : View) : BaseAdapter(),
AdapterView.OnItemClickListener {
private val gridView : GridView
private val wh : Int
private val gridView : HeaderGridView = root.findViewById(R.id.gridView)
private val wh = (0.5f + 48f * activity.resources.displayMetrics.density).toInt()
private var page : EmojiPickerPage? = null
init {
this.gridView = root.findViewById(R.id.gridView)
gridView.adapter = this
gridView.onItemClickListener = this
this.wh = (0.5f + 48f * activity.resources.displayMetrics.density).toInt()
}
internal fun onPageCreate(page : EmojiPickerPage) {
this.page = page
if(page.category_id != CATEGORY_CUSTOM) {
gridView.adapter = this
} else {
reloadCustomEmoji()
}
gridView.onItemClickListener = this
}
internal fun onPageDestroy() {
}
internal fun reload() {
this.notifyDataSetChanged()
internal fun reloadSkinTone() {
val page = this.page ?: throw RuntimeException("page is not assigned")
if(page.category_id != CATEGORY_CUSTOM) {
this.notifyDataSetChanged()
}
}
fun reloadCustomEmoji() {
gridView.reset()
if(custom_categories.size >= 2) {
for(item in custom_categories) {
gridView.addHeaderView(
rangeStart = item.rangeStart,
rangeLength = item.rangeLength,
itemHeight = wh,
v = item.view,
isSelectable = false
)
}
}
gridView.adapter = this
}
override fun getCount() : Int {
@ -376,17 +441,11 @@ class EmojiPicker(
val view : View
val item = page.emoji_list[position]
if(item.instance != null) {
if(viewOld == null) {
view = NetworkEmojiView(activity)
val lp = AbsListView.LayoutParams(wh, wh)
view.layoutParams = lp
} else {
view = viewOld
}
view.setTag(R.id.btnAbout,item)
if(view is NetworkEmojiView) {
view.setEmoji(emoji_url_map[item.name])
view = viewOld ?: NetworkEmojiView(activity).apply {
layoutParams = AbsListView.LayoutParams(wh, wh)
}
view.setTag(R.id.btnAbout, item)
(view as? NetworkEmojiView)?.setEmoji(emoji_url_map[item.name])
} else {
if(viewOld == null) {
view = ImageView(activity)
@ -395,7 +454,7 @@ class EmojiPicker(
} else {
view = viewOld
}
view.setTag(R.id.btnAbout,item)
view.setTag(R.id.btnAbout, item)
if(view is ImageView) {
val name = if(page.hasSkinTone) {
applySkinTone(item.name)
@ -406,50 +465,56 @@ class EmojiPicker(
val info = EmojiMap.sShortNameToEmojiInfo[name]
if(info != null) {
val er = info.er
if(er.isSvg){
if(er.isSvg) {
Glide.with(activity)
.`as`(PictureDrawable::class.java)
.load("file:///android_asset/${er.assetsName}")
.into(view)
}else{
} else {
Glide.with(activity)
.load(er.drawableId)
.into(view)
}
}
}
}
return view
}
override fun onItemClick(adapterView : AdapterView<*>, view : View, idx : Int, l : Long) {
override fun onItemClick(
adapterView : AdapterView<*>,
view : View,
idxArg : Int,
l : Long
) {
val page = this.page ?: return
val item = page.emoji_list[idx]
var name = item.name
if(item.instance != null && item.instance.isNotEmpty()) {
// カスタム絵文字
selected(name, item.instance)
} else {
// 普通の絵文字
EmojiMap.sShortNameToEmojiInfo[name] ?: return
if(page.hasSkinTone) {
val sv = applySkinTone(name)
if(EmojiMap.sShortNameToEmojiInfo[sv] != null) {
name = sv
val idx = gridView.findListItemIndex(idxArg)
if(idx in 0 until page.emoji_list.size) {
val item = page.emoji_list[idx]
var name = item.name
if(item.instance != null && item.instance.isNotEmpty()) {
// カスタム絵文字
selected(name, item.instance)
} else {
// 普通の絵文字
EmojiMap.sShortNameToEmojiInfo[name] ?: return
if(page.hasSkinTone) {
val sv = applySkinTone(name)
if(EmojiMap.sShortNameToEmojiInfo[sv] != null) {
name = sv
}
}
selected(name, null)
}
selected(name, null)
}
}
}
// name はスキントーン適用済みであること

View File

@ -55,13 +55,16 @@ class CustomEmojiCache(internal val context : Context) {
private val queue = LinkedList<Request>()
private val handler : Handler
private val worker : Worker
private val workers = ArrayList<Worker>()
init {
handler = Handler(context.mainLooper)
cache = ConcurrentHashMap()
worker = Worker()
worker.start()
for(i in 0 until 4) {
val worker = Worker(workers)
worker.start()
workers.add(worker)
}
}
// カラムのリロードボタンを押したタイミングでエラーキャッシュをクリアする
@ -123,7 +126,7 @@ class CustomEmojiCache(internal val context : Context) {
synchronized(queue) {
queue.addLast(Request(refDrawTarget, url, onLoadComplete))
}
worker.notifyEx()
workers.first().notifyEx()
} catch(ex : Throwable) {
log.trace(ex)
// たまにcache変数がなぜかnullになる端末があるらしい
@ -131,45 +134,55 @@ class CustomEmojiCache(internal val context : Context) {
return null
}
private inner class Worker : WorkerBase() {
private inner class Worker(waiter : Any?) : WorkerBase(waiter) {
override fun cancel() {
// このスレッドはプロセスが生きてる限りキャンセルされない
}
override fun run() {
var ts : Long
var te : Long
while(true) {
try {
var queue_size : Int
val request = synchronized(queue) {
val x = if(queue.isNotEmpty()) queue.removeFirst() else null
queue_size = queue.size
return@synchronized x
x
}
if(request == null) {
if(DEBUG) log.d("wait")
ts = elapsedTime
waitEx(86400000L)
te = elapsedTime
if(te - ts >= 200L) log.d("sleep ${te - ts}ms")
continue
}
// 描画先がGCされたなら何もしない
request.refTarget.get() ?: continue
ts = elapsedTime
var cache_size : Int = - 1
if(synchronized(cache) {
val now = elapsedTime
val item = getCached(now, request.url)
if(item != null) {
if(item.frames != null) {
fireCallback(request)
}
return@synchronized true
val chache_used = synchronized(cache) {
val now = elapsedTime
val item = getCached(now, request.url)
if(item != null) {
if(item.frames != null) {
fireCallback(request)
}
sweep_cache(now)
cache_size = cache.size
return@synchronized false
}) continue
return@synchronized true
}
sweep_cache(now)
cache_size = cache.size
return@synchronized false
}
te = elapsedTime
if(te - ts >= 200L) log.d("cache_used? ${te - ts}ms")
if(chache_used) continue
if(DEBUG)
log.d(
@ -181,16 +194,27 @@ class CustomEmojiCache(internal val context : Context) {
var frames : ApngFrames? = null
try {
ts = elapsedTime
val data = App1.getHttpCached(request.url)
te = elapsedTime
if(te - ts >= 200L) log.d("image get? ${te - ts}ms")
if(data == null) {
log.e("get failed. url=%s", request.url)
} else {
ts = elapsedTime
frames = decodeAPNG(data, request.url)
te = elapsedTime
if(te - ts >= 200L) log.d("iamge decode? ${te - ts}ms")
}
} catch(ex : Throwable) {
log.trace(ex)
}
ts = elapsedTime
synchronized(cache) {
if(frames == null) {
cache_error.put(request.url, elapsedTime)
@ -207,6 +231,8 @@ class CustomEmojiCache(internal val context : Context) {
fireCallback(request)
}
}
te = elapsedTime
if(te - ts >= 200L) log.d("update_cache? ${te - ts}ms")
} catch(ex : Throwable) {
log.trace(ex)
@ -251,11 +277,10 @@ class CustomEmojiCache(internal val context : Context) {
}
}
private fun decodeAPNG(data : ByteArray, url : String) : ApngFrames? {
try {
// APNGをデコード
val x = ApngFrames.parse(64){ ByteArrayInputStream(data) }
val x = ApngFrames.parse(64) { ByteArrayInputStream(data) }
if(x != null) return x
// fall thru
} catch(ex : Throwable) {
@ -280,7 +305,7 @@ class CustomEmojiCache(internal val context : Context) {
// SVGのロードを試みる
try {
val b = decodeSVG(url,data, 128.toFloat())
val b = decodeSVG(url, data, 128.toFloat())
if(b != null) {
if(DEBUG) log.d("SVG decoded.")
return ApngFrames(b)
@ -296,7 +321,10 @@ class CustomEmojiCache(internal val context : Context) {
private val options = BitmapFactory.Options()
private fun decodeBitmap(data : ByteArray, pixel_max : Int) : Bitmap? {
private fun decodeBitmap(
data : ByteArray,
@Suppress("SameParameterValue") pixel_max : Int
) : Bitmap? {
options.inJustDecodeBounds = true
options.inScaled = false
options.outWidth = 0
@ -319,16 +347,22 @@ class CustomEmojiCache(internal val context : Context) {
return BitmapFactory.decodeByteArray(data, 0, data.size, options)
}
private fun decodeSVG(url:String, data : ByteArray, pixelMax : Float) : Bitmap? {
private fun decodeSVG(
url : String,
data : ByteArray,
@Suppress("SameParameterValue") pixelMax : Float
) : Bitmap? {
try {
val svg = SVG.getFromInputStream(ByteArrayInputStream(data))
val src_w = svg.documentWidth // the width in pixels, or -1 if there is no width available.
val src_h = svg.documentHeight // the height in pixels, or -1 if there is no height available.
val aspect = if( src_w <= 0f || src_h <=0f){
val src_w =
svg.documentWidth // the width in pixels, or -1 if there is no width available.
val src_h =
svg.documentHeight // the height in pixels, or -1 if there is no height available.
val aspect = if(src_w <= 0f || src_h <= 0f) {
// widthやheightの情報がない
1f
}else{
} else {
src_w / src_h
}
@ -337,7 +371,7 @@ class CustomEmojiCache(internal val context : Context) {
if(aspect >= 1f) {
dst_w = pixelMax
dst_h = pixelMax / aspect
}else {
} else {
dst_h = pixelMax
dst_w = pixelMax * aspect
}
@ -352,9 +386,9 @@ class CustomEmojiCache(internal val context : Context) {
svg.renderToCanvas(
canvas,
if(aspect >= 1f) {
RectF(0f, h_ceil-dst_h, dst_w, dst_h) // 後半はw,hを指定する
RectF(0f, h_ceil - dst_h, dst_w, dst_h) // 後半はw,hを指定する
} else {
RectF(w_ceil-dst_w, 0f, dst_w , dst_h) // 後半はw,hを指定する
RectF(w_ceil - dst_w, 0f, dst_w, dst_h) // 後半はw,hを指定する
}
)
return b

View File

@ -4,13 +4,11 @@ import android.content.Context
import android.os.Handler
import android.os.SystemClock
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.entity.CustomEmoji
import jp.juggler.subwaytooter.api.entity.parseList
import jp.juggler.util.LogCategory
import jp.juggler.util.toJsonArray
import jp.juggler.util.toRequestBody
import okhttp3.RequestBody
import org.json.JSONObject
import java.util.*
import java.util.concurrent.ConcurrentHashMap
@ -33,20 +31,12 @@ class CustomEmojiLister(internal val context : Context) {
internal class CacheItem(
val instance : String,
var list : ArrayList<CustomEmoji>? = null,
var listWithAliases : ArrayList<CustomEmoji>? = null
) {
// 参照された時刻
var time_used : Long = 0
var listWithAliases : ArrayList<CustomEmoji>? = null,
// ロードした時刻
var time_update : Long = 0
init {
time_update = elapsedTime
time_used = time_update
}
}
var time_update : Long = elapsedTime,
// 参照された時刻
var time_used : Long = time_update
)
internal class Request(
val instance : String,
@ -101,7 +91,7 @@ class CustomEmojiLister(internal val context : Context) {
) : ArrayList<CustomEmoji>? {
try {
if(_instance.isEmpty()) return null
val instance = _instance.toLowerCase()
val instance = _instance.toLowerCase(Locale.JAPAN)
synchronized(cache) {
val item = getCached(elapsedTime, instance)
@ -123,7 +113,7 @@ class CustomEmojiLister(internal val context : Context) {
) : ArrayList<CustomEmoji>? {
try {
if(_instance.isEmpty()) return null
val instance = _instance.toLowerCase()
val instance = _instance.toLowerCase(Locale.JAPAN)
synchronized(cache) {
val item = getCached(elapsedTime, instance)
@ -196,7 +186,7 @@ class CustomEmojiLister(internal val context : Context) {
try {
val data = if(request.isMisskey) {
App1.getHttpCachedString("https://" + request.instance + "/api/meta") { builder ->
builder.post( JSONObject().toRequestBody() )
builder.post(JSONObject().toRequestBody())
}
} else {
@ -237,7 +227,6 @@ class CustomEmojiLister(internal val context : Context) {
}
}
private fun fireCallback(
request : Request,
list : ArrayList<CustomEmoji>,
@ -297,15 +286,14 @@ class CustomEmojiLister(internal val context : Context) {
}
}
private fun makeListWithAlias(list : ArrayList<CustomEmoji>?) : ArrayList<CustomEmoji> {
val dst = ArrayList<CustomEmoji>()
if( list != null) {
if(list != null) {
dst.addAll(list)
for(item in list) {
val aliases = item.aliases ?: continue
for(alias in aliases) {
if( alias.equals(item.shortcode,ignoreCase = true)) continue
if(alias.equals(item.shortcode, ignoreCase = true)) continue
dst.add(item.makeAlias(alias))
}
}

View File

@ -1,15 +1,15 @@
package jp.juggler.subwaytooter.util
abstract class WorkerBase : Thread() {
abstract class WorkerBase(private val waiter:Any?=null) : Thread() {
abstract fun cancel()
abstract override fun run()
fun waitEx(ms : Long) {
WaitNotifyHelper.waitEx(this,ms)
WaitNotifyHelper.waitEx(waiter ?: this,ms)
}
fun notifyEx() {
WaitNotifyHelper.notifyEx(this)
WaitNotifyHelper.notifyEx(waiter ?:this)
}
}

View File

@ -46,6 +46,7 @@ class NetworkEmojiView : View {
fun setEmoji(url : String?) {
this.url = url
mPaint.isFilterBitmap = true
invalidate()
}
override fun onDraw(canvas : Canvas) {

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<GridView
<jp.juggler.subwaytooter.view.HeaderGridView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/gridView"
android:layout_width="match_parent"

View File

@ -935,5 +935,6 @@
<string name="profile_directory_not_supported_on_misskey">ディレクトリはMisskeyでは利用できません</string>
<string name="show_in_directory">ディレクトリに表示</string>
<string name="featured_hashtags">注目のハッシュタグ</string>
<string name="custom_emoji">カスタム絵文字</string>
</resources>

View File

@ -928,5 +928,6 @@
<string name="profile_directory_not_supported_on_misskey">Profile directory is not supported on Misskey</string>
<string name="show_in_directory">Show in directory</string>
<string name="featured_hashtags">Featured hashtags</string>
<string name="custom_emoji">Custom emoji</string>
</resources>