From 6d27ec9874c8f17828fc69a74641f45a13e72c7a Mon Sep 17 00:00:00 2001 From: crackededed <90209774+crackededed@users.noreply.github.com> Date: Wed, 29 May 2024 18:56:19 +0300 Subject: [PATCH] fix chat download out of memory error (cherry picked from commit ea00431cdb2af4534b84266e6bb11db42e7d04ba) --- app/build.gradle.kts | 4 +- .../xtra/model/chat/CheerEmote.kt | 2 +- .../andreyasadchy/xtra/model/chat/Emote.kt | 2 +- .../xtra/model/chat/TwitchBadge.kt | 2 +- .../xtra/model/chat/TwitchEmote.kt | 2 +- .../xtra/ui/chat/ChatFragment.kt | 2 +- .../xtra/ui/chat/ChatViewModel.kt | 572 +++++++++++------- .../xtra/ui/common/ChatAdapter.kt | 93 ++- .../xtra/ui/download/DownloadWorker.kt | 365 ++++++----- .../xtra/ui/view/chat/ChatView.kt | 6 +- 10 files changed, 612 insertions(+), 438 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 24c7c52b9..c4686099e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { applicationId = "com.github.andreyasadchy.xtra" minSdk = 21 targetSdk = 34 - versionCode = 239 - versionName = "2.32.0" + versionCode = 240 + versionName = "2.32.1" } buildTypes { diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/model/chat/CheerEmote.kt b/app/src/main/java/com/github/andreyasadchy/xtra/model/chat/CheerEmote.kt index 28e52e196..6a9ec2290 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/model/chat/CheerEmote.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/model/chat/CheerEmote.kt @@ -2,7 +2,7 @@ package com.github.andreyasadchy.xtra.model.chat class CheerEmote( val name: String, - val localData: ByteArray? = null, + val localData: Pair? = null, val url1x: String? = null, val url2x: String? = null, val url3x: String? = null, diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/model/chat/Emote.kt b/app/src/main/java/com/github/andreyasadchy/xtra/model/chat/Emote.kt index 49bd2eb3a..761e15e1b 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/model/chat/Emote.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/model/chat/Emote.kt @@ -2,7 +2,7 @@ package com.github.andreyasadchy.xtra.model.chat class Emote( val name: String? = null, - val localData: ByteArray? = null, + val localData: Pair? = null, val url1x: String? = null, val url2x: String? = null, val url3x: String? = null, diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/model/chat/TwitchBadge.kt b/app/src/main/java/com/github/andreyasadchy/xtra/model/chat/TwitchBadge.kt index eed773d7d..547867f3d 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/model/chat/TwitchBadge.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/model/chat/TwitchBadge.kt @@ -3,7 +3,7 @@ package com.github.andreyasadchy.xtra.model.chat class TwitchBadge( val setId: String, val version: String, - val localData: ByteArray? = null, + val localData: Pair? = null, val url1x: String? = null, val url2x: String? = null, val url3x: String? = null, diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/model/chat/TwitchEmote.kt b/app/src/main/java/com/github/andreyasadchy/xtra/model/chat/TwitchEmote.kt index 300d6b1f6..275574cc2 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/model/chat/TwitchEmote.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/model/chat/TwitchEmote.kt @@ -3,7 +3,7 @@ package com.github.andreyasadchy.xtra.model.chat class TwitchEmote( val id: String? = null, val name: String? = null, - val localData: ByteArray? = null, + val localData: Pair? = null, val url1x: String? = "https://static-cdn.jtvnw.net/emoticons/v2/$id/default/dark/1.0", val url2x: String? = "https://static-cdn.jtvnw.net/emoticons/v2/$id/default/dark/2.0", val url3x: String? = "https://static-cdn.jtvnw.net/emoticons/v2/$id/default/dark/2.0", diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/chat/ChatFragment.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/chat/ChatFragment.kt index 015a20b42..fbf5e1da8 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/chat/ChatFragment.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/chat/ChatFragment.kt @@ -73,7 +73,7 @@ class ChatFragment : BaseNetworkFragment(), LifecycleListener, MessageClickedDia true } chatUrl != null || (args.getString(KEY_VIDEO_ID) != null && !args.getBoolean(KEY_START_TIME_EMPTY)) -> { - chatView.init(this@ChatFragment, channelId) + chatView.init(this@ChatFragment, channelId, viewModel::getEmoteBytes, chatUrl) true } else -> { diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/chat/ChatViewModel.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/chat/ChatViewModel.kt index c31f33898..5051e3227 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/chat/ChatViewModel.kt @@ -475,6 +475,19 @@ class ChatViewModel @Inject constructor( loadEmotes(helixClientId, helixToken, gqlHeaders, channelId, channelLogin, emoteQuality, animateGifs, enableStv, enableBttv, enableFfz, checkIntegrity) } + fun getEmoteBytes(chatUrl: String, localData: Pair): ByteArray? { + return if (chatUrl.toUri().scheme == ContentResolver.SCHEME_CONTENT) { + applicationContext.contentResolver.openInputStream(chatUrl.toUri())?.bufferedReader() + } else { + FileInputStream(File(chatUrl)).bufferedReader() + }?.use { fileReader -> + val buffer = CharArray(localData.second) + fileReader.skip(localData.first) + fileReader.read(buffer, 0, localData.second) + Base64.decode(buffer.concatToString(), Base64.NO_WRAP or Base64.NO_PADDING) + } + } + inner class LiveChatController( private val useChatWebSocket: Boolean, private val useSSL: Boolean, @@ -1312,264 +1325,344 @@ class ChatViewModel @Inject constructor( try { val messages = mutableListOf() var startTimeMs = 0L + val twitchEmotes = mutableListOf() + val twitchBadges = mutableListOf() + val cheerEmotesList = mutableListOf() + val emotes = mutableListOf() if (url.toUri().scheme == ContentResolver.SCHEME_CONTENT) { applicationContext.contentResolver.openInputStream(url.toUri())?.bufferedReader() } else { FileInputStream(File(url)).bufferedReader() }?.use { fileReader -> JsonReader(fileReader).use { reader -> - reader.beginObject() + var position = 0L + reader.beginObject().also { position += 1 } while (reader.hasNext()) { - when (reader.nextName()) { - "comments" -> { - reader.beginArray() - while (reader.hasNext()) { - reader.beginObject() - val message = StringBuilder() - var id: String? = null - var offsetSeconds: Int? = null - var userId: String? = null - var userLogin: String? = null - var userName: String? = null - var color: String? = null - val emotesList = mutableListOf() - val badgesList = mutableListOf() - while (reader.hasNext()) { - when (reader.nextName()) { - "id" -> id = reader.nextString() - "commenter" -> { - reader.beginObject() - while (reader.hasNext()) { - when (reader.nextName()) { - "id" -> userId = reader.nextString() - "login" -> userLogin = reader.nextString() - "displayName" -> userName = reader.nextString() - else -> reader.skipValue() + when (reader.peek()) { + JsonToken.NAME -> { + when (reader.nextName().also { position += it.length + 3 }) { + "comments" -> { + reader.beginArray().also { position += 1 } + while (reader.hasNext()) { + reader.beginObject().also { position += 1 } + val message = StringBuilder() + var id: String? = null + var offsetSeconds: Int? = null + var userId: String? = null + var userLogin: String? = null + var userName: String? = null + var color: String? = null + val emotesList = mutableListOf() + val badgesList = mutableListOf() + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "id" -> id = reader.nextString().also { position += it.length + 2 } + "commenter" -> { + reader.beginObject().also { position += 1 } + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "id" -> userId = reader.nextString().also { position += it.length + 2 } + "login" -> userLogin = reader.nextString().also { position += it.length + 2 } + "displayName" -> userName = reader.nextString().also { position += it.length + 2 } + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 + } + } + reader.endObject().also { position += 1 } } - } - reader.endObject() - } - "contentOffsetSeconds" -> offsetSeconds = reader.nextInt() - "message" -> { - reader.beginObject() - while (reader.hasNext()) { - when (reader.nextName()) { - "fragments" -> { - reader.beginArray() - while (reader.hasNext()) { - reader.beginObject() - var emoteId: String? = null - var fragmentText: String? = null - while (reader.hasNext()) { - when (reader.nextName()) { - "emote" -> { - when (reader.peek()) { - JsonToken.BEGIN_OBJECT -> { - reader.beginObject() - while (reader.hasNext()) { - when (reader.nextName()) { - "emoteID" -> emoteId = reader.nextString() - else -> reader.skipValue() + "contentOffsetSeconds" -> offsetSeconds = reader.nextInt().also { position += it.toString().length } + "message" -> { + reader.beginObject().also { position += 1 } + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "fragments" -> { + reader.beginArray().also { position += 1 } + while (reader.hasNext()) { + reader.beginObject().also { position += 1 } + var emoteId: String? = null + var fragmentText: String? = null + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "emote" -> { + when (reader.peek()) { + JsonToken.BEGIN_OBJECT -> { + reader.beginObject().also { position += 1 } + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "emoteID" -> emoteId = reader.nextString().also { position += it.length + 2 } + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 + } + } + reader.endObject().also { position += 1 } } + else -> position += skipJsonValue(reader) } - reader.endObject() } - else -> reader.skipValue() + "text" -> fragmentText = reader.nextString().also { position += it.length + 2 + it.count { c -> c == '"' || c == '\\' } } + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 } } - "text" -> fragmentText = reader.nextString() - else -> reader.skipValue() + if (fragmentText != null && !emoteId.isNullOrBlank()) { + emotesList.add(TwitchEmote( + id = emoteId, + begin = message.codePointCount(0, message.length), + end = message.codePointCount(0, message.length) + fragmentText.lastIndex + )) + } + message.append(fragmentText) + reader.endObject().also { position += 1 } + if (reader.peek() != JsonToken.END_ARRAY) { + position += 1 + } } + reader.endArray().also { position += 1 } } - if (fragmentText != null && !emoteId.isNullOrBlank()) { - emotesList.add(TwitchEmote( - id = emoteId, - begin = message.codePointCount(0, message.length), - end = message.codePointCount(0, message.length) + fragmentText.lastIndex - )) - } - message.append(fragmentText) - reader.endObject() - } - reader.endArray() - } - "userBadges" -> { - reader.beginArray() - while (reader.hasNext()) { - reader.beginObject() - var set: String? = null - var version: String? = null - while (reader.hasNext()) { - when (reader.nextName()) { - "setID" -> set = reader.nextString() - "version" -> version = reader.nextString() - else -> reader.skipValue() + "userBadges" -> { + reader.beginArray().also { position += 1 } + while (reader.hasNext()) { + reader.beginObject().also { position += 1 } + var set: String? = null + var version: String? = null + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "setID" -> set = reader.nextString().also { position += it.length + 2 } + "version" -> version = reader.nextString().also { position += it.length + 2 } + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 + } + } + if (!set.isNullOrBlank() && !version.isNullOrBlank()) { + badgesList.add(Badge(set, version)) + } + reader.endObject().also { position += 1 } + if (reader.peek() != JsonToken.END_ARRAY) { + position += 1 + } } + reader.endArray().also { position += 1 } } - if (!set.isNullOrBlank() && !version.isNullOrBlank()) { - badgesList.add(Badge(set, version)) + "userColor" -> { + when (reader.peek()) { + JsonToken.STRING -> color = reader.nextString().also { position += it.length + 2 } + else -> position += skipJsonValue(reader) + } } - reader.endObject() + else -> position += skipJsonValue(reader) } - reader.endArray() - } - "userColor" -> { - when (reader.peek()) { - JsonToken.STRING -> color = reader.nextString() - else -> reader.skipValue() + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 } } - else -> reader.skipValue() + messages.add(VideoChatMessage( + id = id, + offsetSeconds = offsetSeconds, + userId = userId, + userLogin = userLogin, + userName = userName, + message = message.toString(), + color = color, + emotes = emotesList, + badges = badgesList, + fullMsg = null + )) + reader.endObject().also { position += 1 } } + else -> position += skipJsonValue(reader) } - messages.add(VideoChatMessage( - id = id, - offsetSeconds = offsetSeconds, - userId = userId, - userLogin = userLogin, - userName = userName, - message = message.toString(), - color = color, - emotes = emotesList, - badges = badgesList, - fullMsg = null - )) - reader.endObject() + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 + } + } + reader.endObject().also { position += 1 } + if (reader.peek() != JsonToken.END_ARRAY) { + position += 1 } - else -> reader.skipValue() } + reader.endArray().also { position += 1 } } - reader.endObject() - } - reader.endArray() - } - "twitchEmotes" -> { - reader.beginArray() - val twitchEmotes = mutableListOf() - while (reader.hasNext()) { - reader.beginObject() - var id: String? = null - var data: String? = null - while (reader.hasNext()) { - when (reader.nextName()) { - "data" -> data = reader.nextString() - "id" -> id = reader.nextString() - else -> reader.skipValue() + "twitchEmotes" -> { + reader.beginArray().also { position += 1 } + while (reader.hasNext()) { + reader.beginObject().also { position += 1 } + var id: String? = null + var data: Pair? = null + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "data" -> { + position += 1 + val length = reader.nextString().length + data = Pair(position, length) + position += length + 1 + } + "id" -> id = reader.nextString().also { position += it.length + 2 } + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 + } + } + if (!id.isNullOrBlank() && data != null) { + twitchEmotes.add(TwitchEmote( + id = id, + localData = data + )) + } + reader.endObject().also { position += 1 } + if (reader.peek() != JsonToken.END_ARRAY) { + position += 1 + } } + reader.endArray().also { position += 1 } } - if (!id.isNullOrBlank() && !data.isNullOrBlank()) { - twitchEmotes.add(TwitchEmote( - id = id, - localData = Base64.decode(data.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING) - )) - } - reader.endObject() - } - localTwitchEmotes.postValue(twitchEmotes) - reader.endArray() - } - "twitchBadges" -> { - reader.beginArray() - val twitchBadges = mutableListOf() - while (reader.hasNext()) { - reader.beginObject() - var setId: String? = null - var version: String? = null - var data: String? = null - while (reader.hasNext()) { - when (reader.nextName()) { - "data" -> data = reader.nextString() - "setId" -> setId = reader.nextString() - "version" -> version = reader.nextString() - else -> reader.skipValue() + "twitchBadges" -> { + reader.beginArray().also { position += 1 } + while (reader.hasNext()) { + reader.beginObject().also { position += 1 } + var setId: String? = null + var version: String? = null + var data: Pair? = null + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "data" -> { + position += 1 + val length = reader.nextString().length + data = Pair(position, length) + position += length + 1 + } + "setId" -> setId = reader.nextString().also { position += it.length + 2 } + "version" -> version = reader.nextString().also { position += it.length + 2 } + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 + } + } + if (!setId.isNullOrBlank() && !version.isNullOrBlank() && data != null) { + twitchBadges.add(TwitchBadge( + setId = setId, + version = version, + localData = data + )) + } + reader.endObject().also { position += 1 } + if (reader.peek() != JsonToken.END_ARRAY) { + position += 1 + } } + reader.endArray().also { position += 1 } } - if (!setId.isNullOrBlank() && !version.isNullOrBlank() && !data.isNullOrBlank()) { - twitchBadges.add(TwitchBadge( - setId = setId, - version = version, - localData = Base64.decode(data.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING) - )) - } - reader.endObject() - } - channelBadges.postValue(twitchBadges) - reader.endArray() - } - "cheerEmotes" -> { - reader.beginArray() - val cheerEmotesList = mutableListOf() - while (reader.hasNext()) { - reader.beginObject() - var name: String? = null - var data: String? = null - var minBits: Int? = null - var color: String? = null - while (reader.hasNext()) { - when (reader.nextName()) { - "data" -> data = reader.nextString() - "name" -> name = reader.nextString() - "minBits" -> minBits = reader.nextInt() - "color" -> { - when (reader.peek()) { - JsonToken.STRING -> color = reader.nextString() - else -> reader.skipValue() + "cheerEmotes" -> { + reader.beginArray().also { position += 1 } + while (reader.hasNext()) { + reader.beginObject().also { position += 1 } + var name: String? = null + var data: Pair? = null + var minBits: Int? = null + var color: String? = null + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "data" -> { + position += 1 + val length = reader.nextString().length + data = Pair(position, length) + position += length + 1 + } + "name" -> name = reader.nextString().also { position += it.length + 2 } + "minBits" -> minBits = reader.nextInt().also { position += it.toString().length } + "color" -> { + when (reader.peek()) { + JsonToken.STRING -> color = reader.nextString().also { position += it.length + 2 } + else -> position += skipJsonValue(reader) + } + } + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 } } - else -> reader.skipValue() + if (!name.isNullOrBlank() && minBits != null && data != null) { + cheerEmotesList.add(CheerEmote( + name = name, + localData = data, + minBits = minBits, + color = color + )) + } + reader.endObject().also { position += 1 } + if (reader.peek() != JsonToken.END_ARRAY) { + position += 1 + } } + reader.endArray().also { position += 1 } } - if (!name.isNullOrBlank() && minBits != null && !data.isNullOrBlank()) { - cheerEmotesList.add(CheerEmote( - name = name, - localData = Base64.decode(data.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING), - minBits = minBits, - color = color - )) - } - reader.endObject() - } - cheerEmotes.postValue(cheerEmotesList) - reader.endArray() - } - "emotes" -> { - reader.beginArray() - val emotes = mutableListOf() - while (reader.hasNext()) { - reader.beginObject() - var data: String? = null - var name: String? = null - var isZeroWidth = false - while (reader.hasNext()) { - when (reader.nextName()) { - "data" -> data = reader.nextString() - "name" -> name = reader.nextString() - "isZeroWidth" -> isZeroWidth = reader.nextBoolean() - else -> reader.skipValue() + "emotes" -> { + reader.beginArray().also { position += 1 } + while (reader.hasNext()) { + reader.beginObject().also { position += 1 } + var data: Pair? = null + var name: String? = null + var isZeroWidth = false + while (reader.hasNext()) { + when (reader.nextName().also { position += it.length + 3 }) { + "data" -> { + position += 1 + val length = reader.nextString().length + data = Pair(position, length) + position += length + 1 + } + "name" -> name = reader.nextString().also { position += it.length + 2 } + "isZeroWidth" -> isZeroWidth = reader.nextBoolean().also { position += it.toString().length } + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 + } + } + if (!name.isNullOrBlank() && data != null) { + emotes.add(Emote( + name = name, + localData = data, + isZeroWidth = isZeroWidth + )) + } + reader.endObject().also { position += 1 } + if (reader.peek() != JsonToken.END_ARRAY) { + position += 1 + } } + reader.endArray().also { position += 1 } } - if (!name.isNullOrBlank() && !data.isNullOrBlank()) { - emotes.add(Emote( - name = name, - localData = Base64.decode(data.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING), - isZeroWidth = isZeroWidth - )) - } - reader.endObject() - } - channelStvEmotes.postValue(emotes) - if (emotes.isEmpty()) { - viewModelScope.launch { - loadEmotes(helixClientId, helixToken, gqlHeaders, channelId, channelLogin, emoteQuality, animateGifs, enableStv, enableBttv, enableFfz, checkIntegrity) - } + "startTime" -> { startTimeMs = reader.nextInt().also { position += it.toString().length }.times(1000L) } + else -> position += skipJsonValue(reader) } - reader.endArray() } - "startTime" -> { startTimeMs = reader.nextInt().times(1000L) } - else -> reader.skipValue() + else -> position += skipJsonValue(reader) + } + if (reader.peek() != JsonToken.END_OBJECT) { + position += 1 } } - reader.endObject() + reader.endObject().also { position += 1 } + } + } + localTwitchEmotes.postValue(twitchEmotes) + channelBadges.postValue(twitchBadges) + cheerEmotes.postValue(cheerEmotesList) + channelStvEmotes.postValue(emotes) + if (emotes.isEmpty()) { + viewModelScope.launch { + loadEmotes(helixClientId, helixToken, gqlHeaders, channelId, channelLogin, emoteQuality, animateGifs, enableStv, enableBttv, enableFfz, checkIntegrity) } } if (messages.isNotEmpty()) { @@ -1582,6 +1675,49 @@ class ChatViewModel @Inject constructor( } } } + + private fun skipJsonValue(reader: JsonReader): Int { + var length = 0 + when (reader.peek()) { + JsonToken.BEGIN_ARRAY -> { + reader.beginArray().also { length += 1 } + while (reader.hasNext()) { + when (reader.peek()) { + JsonToken.NAME -> length += reader.nextName().length + 3 + else -> { + length += skipJsonValue(reader) + if (reader.peek() != JsonToken.END_ARRAY) { + length += 1 + } + } + } + } + reader.endArray().also { length += 1 } + } + JsonToken.END_ARRAY -> length += 1 + JsonToken.BEGIN_OBJECT -> { + reader.beginObject().also { length += 1 } + while (reader.hasNext()) { + when (reader.peek()) { + JsonToken.NAME -> length += reader.nextName().length + 3 + else -> { + length += skipJsonValue(reader) + if (reader.peek() != JsonToken.END_OBJECT) { + length += 1 + } + } + } + } + reader.endObject().also { length += 1 } + } + JsonToken.END_OBJECT -> length += 1 + JsonToken.STRING -> reader.nextString().let { length += it.length + 2 + it.count { c -> c == '"' || c == '\\' } } + JsonToken.NUMBER -> length += reader.nextString().length + JsonToken.BOOLEAN -> length += reader.nextBoolean().toString().length + else -> reader.skipValue() + } + return length + } } abstract inner class ChatController : OnChatMessageReceivedListener { diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/common/ChatAdapter.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/common/ChatAdapter.kt index 1d185e946..0a6dbf270 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/common/ChatAdapter.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/common/ChatAdapter.kt @@ -42,6 +42,7 @@ import kotlin.collections.set import kotlin.math.floor import kotlin.math.pow + private const val RED_HUE_DEGREES = 0f private const val GREEN_HUE_DEGREES = 120f private const val BLUE_HUE_DEGREES = 240f @@ -67,7 +68,9 @@ class ChatAdapter( private val redeemedChatMsg: String, private val redeemedNoMsg: String, private val imageLibrary: String?, - private val channelId: String?) : RecyclerView.Adapter() { + private val channelId: String?, + private val getEmoteBytes: ((String, Pair) -> ByteArray?)?, + private val chatUrl: String?) : RecyclerView.Adapter() { var messages: MutableList? = null set(value) { @@ -82,6 +85,10 @@ class ChatAdapter( private val random = Random() private val userColors = HashMap() private val savedColors = HashMap() + private val savedLocalTwitchEmotes = mutableMapOf() + private val savedLocalBadges = mutableMapOf() + private val savedLocalCheerEmotes = mutableMapOf() + private val savedLocalEmotes = mutableMapOf() private var localTwitchEmotes: List? = null private var globalStvEmotes: List? = null private var channelStvEmotes: List? = null @@ -191,7 +198,7 @@ class ChatAdapter( if (badge != null) { builder.append(" ") images.add(Image( - localData = badge.localData, + localData = badge.localData?.let { getLocalBadgeData(badge.setId + badge.version, it) }, url1x = badge.url1x, url2x = badge.url2x, url3x = badge.url3x, @@ -265,18 +272,20 @@ class ChatAdapter( } e.end -= length } - copy.forEach { images.add(Image( - localData = it.localData, - url1x = it.url1x, - url2x = it.url2x, - url3x = it.url3x, - url4x = it.url4x, - format = it.format, - isAnimated = it.isAnimated, - isEmote = true, - start = builderIndex + it.begin, - end = builderIndex + it.end + 1 - )) } + copy.forEach { emote -> + images.add(Image( + localData = emote.localData?.let { getLocalTwitchEmoteData(emote.id!!, it) }, + url1x = emote.url1x, + url2x = emote.url2x, + url3x = emote.url3x, + url4x = emote.url4x, + format = emote.format, + isAnimated = emote.isAnimated, + isEmote = true, + start = builderIndex + emote.begin, + end = builderIndex + emote.end + 1 + )) + } } val split = builder.substring(builderIndex).split(" ") var emotesFound = 0 @@ -299,7 +308,7 @@ class ChatAdapter( builder.replace(builderIndex, builderIndex + bitsName.length, ".") builder.setSpan(ForegroundColorSpan(Color.TRANSPARENT), builderIndex, builderIndex + 1, SPAN_EXCLUSIVE_EXCLUSIVE) images.add(Image( - localData = emote.localData, + localData = emote.localData?.let { getLocalCheerEmoteData(emote.name + emote.minBits, it) }, url1x = emote.url1x, url2x = emote.url2x, url3x = emote.url3x, @@ -340,7 +349,7 @@ class ChatAdapter( builder.replace(builderIndex, builderIndex + value.length, ".") builder.setSpan(ForegroundColorSpan(Color.TRANSPARENT), builderIndex, builderIndex + 1, SPAN_EXCLUSIVE_EXCLUSIVE) images.add(Image( - localData = emote.localData, + localData = emote.localData?.let { getLocalEmoteData(emote.name!!, it) }, url1x = emote.url1x, url2x = emote.url2x, url3x = emote.url3x, @@ -503,6 +512,58 @@ class ChatAdapter( }) } + private fun getLocalTwitchEmoteData(name: String, data: Pair): ByteArray? { + return savedLocalTwitchEmotes[name] ?: chatUrl?.let{ url -> + getEmoteBytes?.let { get -> + get(url, data)?.also { + if (savedLocalTwitchEmotes.size >= 100) { + savedLocalTwitchEmotes.remove(savedLocalTwitchEmotes.keys.first()) + } + savedLocalTwitchEmotes[name] = it + } + } + } + } + + private fun getLocalBadgeData(name: String, data: Pair): ByteArray? { + return savedLocalBadges[name] ?: chatUrl?.let{ url -> + getEmoteBytes?.let { get -> + get(url, data)?.also { + if (savedLocalBadges.size >= 100) { + savedLocalBadges.remove(savedLocalBadges.keys.first()) + } + savedLocalBadges[name] = it + } + } + } + } + + private fun getLocalCheerEmoteData(name: String, data: Pair): ByteArray? { + return savedLocalCheerEmotes[name] ?: chatUrl?.let{ url -> + getEmoteBytes?.let { get -> + get(url, data)?.also { + if (savedLocalCheerEmotes.size >= 100) { + savedLocalCheerEmotes.remove(savedLocalCheerEmotes.keys.first()) + } + savedLocalCheerEmotes[name] = it + } + } + } + } + + private fun getLocalEmoteData(name: String, data: Pair): ByteArray? { + return savedLocalEmotes[name] ?: chatUrl?.let{ url -> + getEmoteBytes?.let { get -> + get(url, data)?.also { + if (savedLocalEmotes.size >= 100) { + savedLocalEmotes.remove(savedLocalEmotes.keys.first()) + } + savedLocalEmotes[name] = it + } + } + } + } + fun addLocalTwitchEmotes(list: List?) { localTwitchEmotes = list } diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/DownloadWorker.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/DownloadWorker.kt index 4aa76ed18..e62ba4dfe 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/DownloadWorker.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/download/DownloadWorker.kt @@ -21,6 +21,7 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import com.github.andreyasadchy.xtra.R import com.github.andreyasadchy.xtra.model.Account +import com.github.andreyasadchy.xtra.model.chat.CheerEmote import com.github.andreyasadchy.xtra.model.chat.Emote import com.github.andreyasadchy.xtra.model.chat.TwitchBadge import com.github.andreyasadchy.xtra.model.chat.TwitchEmote @@ -34,7 +35,6 @@ import com.github.andreyasadchy.xtra.util.C import com.github.andreyasadchy.xtra.util.TwitchApiHelper import com.github.andreyasadchy.xtra.util.prefs import com.google.gson.JsonElement -import com.google.gson.JsonObject import com.iheartradio.m3u8.Encoding import com.iheartradio.m3u8.Format import com.iheartradio.m3u8.ParsingMode @@ -61,7 +61,6 @@ import okio.appendingSink import okio.buffer import okio.sink import okio.use -import org.json.JSONObject import java.io.File import java.io.FileInputStream import java.io.FileOutputStream @@ -359,85 +358,88 @@ class DownloadWorker @AssistedInject constructor( } } val savedEmotes = mutableListOf() - val comments = mutableListOf() - val twitchEmotes = mutableListOf() - val twitchBadges = mutableListOf() - val cheerEmotes = mutableListOf() - val emotes = mutableListOf() - var cursor: String? = null - do { - val get = if (cursor == null) { - graphQLRepository.loadVideoMessagesDownload(gqlHeaders, videoId, offset = startTimeSeconds) - } else { - graphQLRepository.loadVideoMessagesDownload(gqlHeaders, videoId, cursor = cursor) - } - cursor = get.cursor - comments.addAll(get.data) - if (downloadEmotes) { - get.emotes.forEach { - if (!savedTwitchEmotes.contains(it)) { - savedTwitchEmotes.add(it) - val emote = TwitchEmote(id = it) - val url = when (emoteQuality) { - "4" -> emote.url4x ?: emote.url3x ?: emote.url2x ?: emote.url1x - "3" -> emote.url3x ?: emote.url2x ?: emote.url1x - "2" -> emote.url2x ?: emote.url1x - else -> emote.url1x - }!! - okHttpClient.newCall(Request.Builder().url(url).build()).execute().use { response -> - twitchEmotes.add(JSONObject().apply { - put("data", Base64.encodeToString(response.body.source().readByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)) - put("id", it) - }) - } + if (isShared) { + context.contentResolver.openOutputStream(fileUri.toUri())!!.bufferedWriter() + } else { + FileOutputStream(fileUri).bufferedWriter() + }.use { fileWriter -> + JsonWriter(fileWriter).use { writer -> + writer.beginObject() + writer.name("video") + writer.beginObject() + writer.name("id").value(videoId) + writer.name("title").value(offlineVideo.name) + writer.name("uploadDate").value(offlineVideo.uploadDate) + writer.name("channelId").value(offlineVideo.channelId) + writer.name("channelLogin").value(offlineVideo.channelLogin) + writer.name("channelName").value(offlineVideo.channelName) + writer.name("gameId").value(offlineVideo.gameId) + writer.name("gameSlug").value(offlineVideo.gameSlug) + writer.name("gameName").value(offlineVideo.gameName) + writer.endObject() + writer.name("startTime").value(startTimeSeconds) + var cursor: String? = null + do { + val get = if (cursor == null) { + graphQLRepository.loadVideoMessagesDownload(gqlHeaders, videoId, offset = startTimeSeconds) + } else { + graphQLRepository.loadVideoMessagesDownload(gqlHeaders, videoId, cursor = cursor) } - } - get.badges.forEach { - val badge = badgeList.find { badge -> badge.setId == it.setId && badge.version == it.version } - if (badge != null && !savedBadges.contains(badge)) { - savedBadges.add(badge) - val url = when (emoteQuality) { - "4" -> badge.url4x ?: badge.url3x ?: badge.url2x ?: badge.url1x - "3" -> badge.url3x ?: badge.url2x ?: badge.url1x - "2" -> badge.url2x ?: badge.url1x - else -> badge.url1x - }!! - okHttpClient.newCall(Request.Builder().url(url).build()).execute().use { response -> - twitchBadges.add(JSONObject().apply { - put("data", Base64.encodeToString(response.body.source().readByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)) - put("setId", badge.setId) - put("version", badge.version) - }) + cursor = get.cursor + val comments = get.data + if (comments.isNotEmpty()) { + writer.name("comments") + writer.beginArray() + comments.forEach { json -> + writer.beginObject() + json.keySet().forEach { key -> + writeJsonElement(key, json.get(key), writer) + } + writer.endObject() } + writer.endArray() } - } - get.words.forEach { word -> - if (!savedEmotes.contains(word)) { - val bitsCount = word.takeLastWhile { it.isDigit() } - val cheerEmote = if (bitsCount.isNotEmpty()) { - val bitsName = word.substringBeforeLast(bitsCount) - cheerEmoteList.findLast { it.name.equals(bitsName, true) && it.minBits <= bitsCount.toInt() } - } else null - if (cheerEmote != null) { - savedEmotes.add(word) - val url = when (emoteQuality) { - "4" -> cheerEmote.url4x ?: cheerEmote.url3x ?: cheerEmote.url2x ?: cheerEmote.url1x - "3" -> cheerEmote.url3x ?: cheerEmote.url2x ?: cheerEmote.url1x - "2" -> cheerEmote.url2x ?: cheerEmote.url1x - else -> cheerEmote.url1x - }!! - okHttpClient.newCall(Request.Builder().url(url).build()).execute().use { response -> - cheerEmotes.add(JSONObject().apply { - put("data", Base64.encodeToString(response.body.source().readByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)) - put("name", cheerEmote.name) - put("minBits", cheerEmote.minBits) - put("color", cheerEmote.color) - }) + if (downloadEmotes) { + val twitchEmotes = mutableListOf() + val twitchBadges = mutableListOf() + val cheerEmotes = mutableListOf() + val emotes = mutableListOf() + get.emotes.forEach { + if (!savedTwitchEmotes.contains(it)) { + savedTwitchEmotes.add(it) + twitchEmotes.add(TwitchEmote(id = it)) } - } else { - val emote = emoteList.find { it.name == word } - if (emote != null) { - savedEmotes.add(word) + } + get.badges.forEach { + val badge = badgeList.find { badge -> badge.setId == it.setId && badge.version == it.version } + if (badge != null && !savedBadges.contains(badge)) { + savedBadges.add(badge) + twitchBadges.add(badge) + } + } + get.words.forEach { word -> + if (!savedEmotes.contains(word)) { + val bitsCount = word.takeLastWhile { it.isDigit() } + val cheerEmote = if (bitsCount.isNotEmpty()) { + val bitsName = word.substringBeforeLast(bitsCount) + cheerEmoteList.findLast { it.name.equals(bitsName, true) && it.minBits <= bitsCount.toInt() } + } else null + if (cheerEmote != null) { + savedEmotes.add(word) + cheerEmotes.add(cheerEmote) + } else { + val emote = emoteList.find { it.name == word } + if (emote != null) { + savedEmotes.add(word) + emotes.add(emote) + } + } + } + } + if (twitchEmotes.isNotEmpty()) { + writer.name("twitchEmotes") + writer.beginArray() + twitchEmotes.forEach { emote -> val url = when (emoteQuality) { "4" -> emote.url4x ?: emote.url3x ?: emote.url2x ?: emote.url1x "3" -> emote.url3x ?: emote.url2x ?: emote.url1x @@ -445,111 +447,82 @@ class DownloadWorker @AssistedInject constructor( else -> emote.url1x }!! okHttpClient.newCall(Request.Builder().url(url).build()).execute().use { response -> - emotes.add(JSONObject().apply { - put("data", Base64.encodeToString(response.body.source().readByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)) - put("name", emote.name) - put("isZeroWidth", emote.isZeroWidth) - }) + writer.beginObject() + writer.name("data").value(Base64.encodeToString(response.body.source().readByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)) + writer.name("id").value(emote.id) + writer.endObject() } } + writer.endArray() } - } - } - } - if (get.lastOffsetSeconds != null) { - offlineRepository.updateVideo(offlineVideo.apply { - chatProgress = min((((get.lastOffsetSeconds - startTimeSeconds).toFloat() / durationSeconds) * 100f).toInt(), 100) - }) - } - } while (get.lastOffsetSeconds?.let { it < endTimeSeconds } != false && !get.cursor.isNullOrBlank() && get.hasNextPage != false) - if (isShared) { - context.contentResolver.openOutputStream(fileUri.toUri())!!.bufferedWriter() - } else { - FileOutputStream(fileUri).bufferedWriter() - }.use { fileWriter -> - JsonWriter(fileWriter).use { writer -> - writer.beginObject() - writer.name("comments") - writer.beginArray() - comments.forEach { json -> - writer.beginObject() - json.keySet().forEach { key -> - writeJsonElement(key, json.get(key), writer) - } - writer.endObject() - } - writer.endArray() - if (downloadEmotes) { - writer.name("twitchEmotes") - writer.beginArray() - twitchEmotes.forEach { json -> - writer.beginObject() - json.keys().forEach { key -> - when (val value = json.get(key)) { - is String -> writer.name(key).value(value) - is Boolean -> writer.name(key).value(value) - is Int -> writer.name(key).value(value) + if (twitchBadges.isNotEmpty()) { + writer.name("twitchBadges") + writer.beginArray() + twitchBadges.forEach { badge -> + val url = when (emoteQuality) { + "4" -> badge.url4x ?: badge.url3x ?: badge.url2x ?: badge.url1x + "3" -> badge.url3x ?: badge.url2x ?: badge.url1x + "2" -> badge.url2x ?: badge.url1x + else -> badge.url1x + }!! + okHttpClient.newCall(Request.Builder().url(url).build()).execute().use { response -> + writer.beginObject() + writer.name("data").value(Base64.encodeToString(response.body.source().readByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)) + writer.name("setId").value(badge.setId) + writer.name("version").value(badge.version) + writer.endObject() + } } + writer.endArray() } - writer.endObject() - } - writer.endArray() - writer.name("twitchBadges") - writer.beginArray() - twitchBadges.forEach { json -> - writer.beginObject() - json.keys().forEach { key -> - when (val value = json.get(key)) { - is String -> writer.name(key).value(value) - is Boolean -> writer.name(key).value(value) - is Int -> writer.name(key).value(value) + if (cheerEmotes.isNotEmpty()) { + writer.name("cheerEmotes") + writer.beginArray() + cheerEmotes.forEach { cheerEmote -> + val url = when (emoteQuality) { + "4" -> cheerEmote.url4x ?: cheerEmote.url3x ?: cheerEmote.url2x ?: cheerEmote.url1x + "3" -> cheerEmote.url3x ?: cheerEmote.url2x ?: cheerEmote.url1x + "2" -> cheerEmote.url2x ?: cheerEmote.url1x + else -> cheerEmote.url1x + }!! + okHttpClient.newCall(Request.Builder().url(url).build()).execute().use { response -> + writer.beginObject() + writer.name("data").value(Base64.encodeToString(response.body.source().readByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)) + writer.name("name").value(cheerEmote.name) + writer.name("minBits").value(cheerEmote.minBits) + writer.name("color").value(cheerEmote.color) + writer.endObject() + } } + writer.endArray() } - writer.endObject() - } - writer.endArray() - writer.name("cheerEmotes") - writer.beginArray() - cheerEmotes.forEach { json -> - writer.beginObject() - json.keys().forEach { key -> - when (val value = json.get(key)) { - is String -> writer.name(key).value(value) - is Boolean -> writer.name(key).value(value) - is Int -> writer.name(key).value(value) + if (emotes.isNotEmpty()) { + writer.name("emotes") + writer.beginArray() + emotes.forEach { emote -> + val url = when (emoteQuality) { + "4" -> emote.url4x ?: emote.url3x ?: emote.url2x ?: emote.url1x + "3" -> emote.url3x ?: emote.url2x ?: emote.url1x + "2" -> emote.url2x ?: emote.url1x + else -> emote.url1x + }!! + okHttpClient.newCall(Request.Builder().url(url).build()).execute().use { response -> + writer.beginObject() + writer.name("data").value(Base64.encodeToString(response.body.source().readByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)) + writer.name("name").value(emote.name) + writer.name("isZeroWidth").value(emote.isZeroWidth) + writer.endObject() + } } + writer.endArray() } - writer.endObject() } - writer.endArray() - writer.name("emotes") - writer.beginArray() - emotes.forEach { json -> - writer.beginObject() - json.keys().forEach { key -> - when (val value = json.get(key)) { - is String -> writer.name(key).value(value) - is Boolean -> writer.name(key).value(value) - is Int -> writer.name(key).value(value) - } - } - writer.endObject() + if (get.lastOffsetSeconds != null) { + offlineRepository.updateVideo(offlineVideo.apply { + chatProgress = min((((get.lastOffsetSeconds - startTimeSeconds).toFloat() / durationSeconds) * 100f).toInt(), 100) + }) } - writer.endArray() - } - writer.name("startTime").value(startTimeSeconds) - writer.name("video") - writer.beginObject() - writer.name("id").value(videoId) - writer.name("title").value(offlineVideo.name) - writer.name("uploadDate").value(offlineVideo.uploadDate) - writer.name("channelId").value(offlineVideo.channelId) - writer.name("channelLogin").value(offlineVideo.channelLogin) - writer.name("channelName").value(offlineVideo.channelName) - writer.name("gameId").value(offlineVideo.gameId) - writer.name("gameSlug").value(offlineVideo.gameSlug) - writer.name("gameName").value(offlineVideo.gameName) - writer.endObject() + } while (get.lastOffsetSeconds?.let { it < endTimeSeconds } != false && !get.cursor.isNullOrBlank() && get.hasNextPage != false) writer.endObject() } } @@ -562,34 +535,36 @@ class DownloadWorker @AssistedInject constructor( } private fun writeJsonElement(key: String, value: JsonElement, writer: JsonWriter) { - when { - value.isJsonObject -> { - writer.name(key) - writer.beginObject() - value.asJsonObject.entrySet().forEach { - writeJsonElement(it.key, it.value, writer) + if (key != "__typename") { + when { + value.isJsonObject -> { + writer.name(key) + writer.beginObject() + value.asJsonObject.entrySet().forEach { + writeJsonElement(it.key, it.value, writer) + } + writer.endObject() } - writer.endObject() - } - value.isJsonArray -> { - writer.name(key) - writer.beginArray() - value.asJsonArray.forEach { json -> - if (json.isJsonObject) { - writer.beginObject() - json.asJsonObject.entrySet().forEach { - writeJsonElement(it.key, it.value, writer) + value.isJsonArray -> { + writer.name(key) + writer.beginArray() + value.asJsonArray.forEach { json -> + if (json.isJsonObject) { + writer.beginObject() + json.asJsonObject.entrySet().forEach { + writeJsonElement(it.key, it.value, writer) + } + writer.endObject() } - writer.endObject() } + writer.endArray() } - writer.endArray() - } - value.isJsonPrimitive -> { - when { - value.asJsonPrimitive.isString -> writer.name(key).value(value.asString) - value.asJsonPrimitive.isBoolean -> writer.name(key).value(value.asBoolean) - value.asJsonPrimitive.isNumber -> writer.name(key).value(value.asNumber) + value.isJsonPrimitive -> { + when { + value.asJsonPrimitive.isString -> writer.name(key).value(value.asString) + value.asJsonPrimitive.isBoolean -> writer.name(key).value(value.asBoolean) + value.asJsonPrimitive.isNumber -> writer.name(key).value(value.asNumber) + } } } } diff --git a/app/src/main/java/com/github/andreyasadchy/xtra/ui/view/chat/ChatView.kt b/app/src/main/java/com/github/andreyasadchy/xtra/ui/view/chat/ChatView.kt index 10022e36c..64e8266d7 100644 --- a/app/src/main/java/com/github/andreyasadchy/xtra/ui/view/chat/ChatView.kt +++ b/app/src/main/java/com/github/andreyasadchy/xtra/ui/view/chat/ChatView.kt @@ -102,7 +102,7 @@ class ChatView : ConstraintLayout { _binding = ViewChatBinding.inflate(LayoutInflater.from(context), this, true) } - fun init(fragment: Fragment, channelId: String?) { + fun init(fragment: Fragment, channelId: String?, getEmoteBytes: ((String, Pair) -> ByteArray?)? = null, chatUrl: String? = null) { this.fragment = fragment with(binding) { adapter = ChatAdapter( @@ -124,7 +124,9 @@ class ChatView : ConstraintLayout { redeemedChatMsg = context.getString(R.string.redeemed), redeemedNoMsg = context.getString(R.string.user_redeemed), imageLibrary = context.prefs().getString(C.CHAT_IMAGE_LIBRARY, "0"), - channelId = channelId + channelId = channelId, + getEmoteBytes = getEmoteBytes, + chatUrl = chatUrl ) recyclerView.let { it.adapter = adapter