package me.eternal.purrfect.core.features.impl.spying import android.graphics.Canvas import android.graphics.Paint import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.Shape import android.os.DeadObjectException import com.google.gson.JsonObject import com.google.gson.JsonParser import me.eternal.purrfect.bridge.logger.BridgeLoggedMessage import me.eternal.purrfect.bridge.logger.LoggedChatEdit import me.eternal.purrfect.common.config.impl.MessagingTweaks import me.eternal.purrfect.common.data.ContentType import me.eternal.purrfect.common.data.MessageState import me.eternal.purrfect.common.data.MessagingRuleType import me.eternal.purrfect.common.data.QuotedMessageContentStatus import me.eternal.purrfect.common.data.RuleState import me.eternal.purrfect.common.util.ktx.longHashCode import me.eternal.purrfect.common.util.lazyBridge import me.eternal.purrfect.common.util.protobuf.ProtoReader import me.eternal.purrfect.core.event.events.impl.BindViewEvent import me.eternal.purrfect.core.event.events.impl.BuildMessageEvent import me.eternal.purrfect.core.features.MessagingRuleFeature import me.eternal.purrfect.core.ui.addForegroundDrawable import me.eternal.purrfect.core.ui.removeForegroundDrawable import me.eternal.purrfect.core.util.EvictingMap import me.eternal.purrfect.core.util.ktx.KavaRefFieldBridge import me.eternal.purrfect.core.util.ktx.setObjectField import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.concurrent.Executors import kotlin.system.measureTimeMillis class MessageLogger : MessagingRuleFeature("MessageLogger", MessagingRuleType.MESSAGE_LOGGER) { companion object { const val PREFETCH_MESSAGE_COUNT = 20 const val PREFETCH_FEED_COUNT = 20 } private val loggerInterface by lazyBridge { context.bridgeClient.getMessageLogger() } val isEnabled get() = context.config.messaging.messageLogger.globalState == true private val threadPool = Executors.newFixedThreadPool(10) private val usernameCache = EvictingMap(500) // user id -> username private val groupTitleCache = EvictingMap(500) // conversation id -> group title private val cachedIdLinks = EvictingMap(500) // client id -> server id private val fetchedMessages = mutableListOf() // list of unique message ids private val deletedMessageCache = EvictingMap(200) // unique message id -> message json object private val pendingMessages = mutableListOf() fun isMessageDeleted(conversationId: String, clientMessageId: Long) = makeUniqueIdentifier(conversationId, clientMessageId)?.let { deletedMessageCache.containsKey(it) } ?: false fun deleteMessage(conversationId: String, clientMessageId: Long) { val uniqueMessageId = makeUniqueIdentifier(conversationId, clientMessageId) ?: return fetchedMessages.remove(uniqueMessageId) deletedMessageCache.remove(uniqueMessageId) loggerInterface.deleteMessage(conversationId, uniqueMessageId) } fun isLoggedMessageDeleted(uniqueMessageId: Long): Boolean { return deletedMessageCache.containsKey(uniqueMessageId) } fun getMessageObject(conversationId: String, clientMessageId: Long): JsonObject? { val uniqueMessageId = makeUniqueIdentifier(conversationId, clientMessageId) ?: return null if (deletedMessageCache.containsKey(uniqueMessageId)) { return deletedMessageCache[uniqueMessageId] } return loggerInterface.getMessage(conversationId, uniqueMessageId)?.let { JsonParser.parseString(it.toString(Charsets.UTF_8)).asJsonObject } } fun getMessageProto(conversationId: String, clientMessageId: Long): ProtoReader? { return getMessageObject(conversationId, clientMessageId)?.let { message -> ProtoReader(message.getAsJsonObject("mMessageContent").getAsJsonArray("mContent") .map { it.asByte } .toByteArray()) } } fun getChatEdits(conversationId: String, clientMessageId: Long): List { val uniqueMessageId = makeUniqueIdentifier(conversationId, clientMessageId) ?: return emptyList() return loggerInterface.getChatEdits(conversationId, uniqueMessageId) } private fun computeMessageIdentifier(conversationId: String, orderKey: Long) = (orderKey.toString() + conversationId).longHashCode() private fun makeUniqueIdentifier(conversationId: String, clientMessageId: Long): Long? { val serverMessageId = cachedIdLinks[clientMessageId] ?: context.database.getConversationMessageFromId(clientMessageId)?.serverMessageId?.toLong()?.also { cachedIdLinks[clientMessageId] = it } ?: return run { context.log.error("Failed to get server message id for $conversationId $clientMessageId") null } return computeMessageIdentifier(conversationId, serverMessageId) } private fun flushMessages() { val list = synchronized(pendingMessages) { if (pendingMessages.isEmpty()) return val copy = pendingMessages.toList() pendingMessages.clear() copy } // Binder limit is 1MB. Chunk into groups of 20 to stay safely under the limit. list.chunked(20).forEach { chunk -> try { loggerInterface.addMessages(chunk) } catch (e: Exception) { if (e !is DeadObjectException) { context.log.error("Failed to flush message log chunk", e) } } } } override fun init() { if (!isEnabled) return val keepMyOwnMessages = context.config.messaging.messageLogger.keepMyOwnMessages.get() val messageFilter by context.config.messaging.messageLogger.messageFilter context.coroutineScope.launch { while (true) { delay(1000) flushMessages() } } onNextActivityCreate(defer = true) { if (!context.database.hasArroyo()) return@onNextActivityCreate measureTimeMillis { val conversationIds = context.database.getFeedEntries(PREFETCH_FEED_COUNT).map { it.key!! } if (conversationIds.isEmpty()) return@measureTimeMillis fetchedMessages.addAll(loggerInterface.getLoggedIds(conversationIds.toTypedArray(), PREFETCH_MESSAGE_COUNT).toList()) }.also { context.log.verbose("Loaded ${fetchedMessages.size} cached messages in ${it}ms") } } context.event.subscribe(BuildMessageEvent::class, priority = 1) { event -> val messageInstance = event.message.instanceNonNull() if (event.message.messageState != MessageState.COMMITTED) return@subscribe val clientMessageId = event.message.messageDescriptor!!.messageId!! val orderKey = event.message.orderKey!! cachedIdLinks[clientMessageId] = orderKey val conversationId = event.message.messageDescriptor!!.conversationId.toString() val senderId = event.message.senderId.toString() //exclude messages sent by me if (!keepMyOwnMessages && senderId == context.database.myUserId) return@subscribe val uniqueMessageIdentifier = computeMessageIdentifier(conversationId, orderKey) val messageContentType = event.message.messageContent!!.contentType val isMessageDeleted = messageContentType == ContentType.STATUS || event.message.messageContent!!.quotedMessage?.status?.let { it == QuotedMessageContentStatus.DELETED || it == QuotedMessageContentStatus.STORYMEDIADELETEDBYPOSTER } == true if (!isMessageDeleted) { if (messageFilter.isNotEmpty() && !messageFilter.contains(messageContentType?.name)) return@subscribe if (event.message.messageMetadata?.isEdited != true) { if (fetchedMessages.contains(uniqueMessageIdentifier)) return@subscribe fetchedMessages.add(uniqueMessageIdentifier) } val createdAt = event.message.messageMetadata?.createdAt ?: System.currentTimeMillis() threadPool.execute { runCatching { if (!canUseRule(conversationId)) { return@runCatching } val loggedMsg = BridgeLoggedMessage().also { it.messageId = uniqueMessageIdentifier it.conversationId = conversationId it.userId = senderId it.username = usernameCache[senderId] ?: context.database.getFriendInfo(senderId)?.mutableUsername?.also { resolvedUsername -> usernameCache[senderId] = resolvedUsername } ?: senderId it.sendTimestamp = createdAt it.groupTitle = groupTitleCache[conversationId] ?: context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName?.also { resolvedGroupTitle -> groupTitleCache[conversationId] = resolvedGroupTitle } ?: conversationId it.messageData = context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8) } synchronized(pendingMessages) { pendingMessages.add(loggedMsg) } }.onFailure { e -> context.log.error("Failed to process background message logging", e) } } return@subscribe } //query the deleted message val deletedMessageObject: JsonObject = if (deletedMessageCache.containsKey(uniqueMessageIdentifier)) deletedMessageCache[uniqueMessageIdentifier] else { loggerInterface.getMessage(conversationId, uniqueMessageIdentifier)?.let { JsonParser.parseString(it.toString(Charsets.UTF_8)).asJsonObject } } ?: return@subscribe //if the message is a snap make it playable if (deletedMessageObject["mMessageContent"]?.asJsonObject?.get("mContentType")?.asString == "SNAP") { deletedMessageObject["mMetadata"].asJsonObject.addProperty("mPlayableSnapState", "PLAYABLE") } //serialize all properties of messageJsonObject and put mMessageContent & mMetadata in the message object listOf("mMessageContent", "mMetadata").forEach { fieldName -> deletedMessageObject[fieldName]?.let { fieldValue -> runCatching { val fieldType = KavaRefFieldBridge.getFieldType(messageInstance, fieldName) messageInstance.setObjectField(fieldName, context.gson.fromJson(fieldValue, fieldType)) } } } deletedMessageCache[uniqueMessageIdentifier] = deletedMessageObject } context.event.subscribe(BindViewEvent::class) { event -> event.chatMessage { conversationId, messageId -> event.view.removeForegroundDrawable("deletedMessage") makeUniqueIdentifier(conversationId, messageId.toLong())?.let { serverMessageId -> if (!deletedMessageCache.contains(serverMessageId)) return@chatMessage } ?: return@chatMessage event.view.addForegroundDrawable("deletedMessage", ShapeDrawable(object: Shape() { override fun draw(canvas: Canvas, paint: Paint) { canvas.drawRect(0f, 0f, canvas.width.toFloat(), canvas.height.toFloat(), Paint().apply { color = context.config.messaging.messageLogger.deletedMessageColor.getNullable() ?: MessagingTweaks.DELETED_MESSAGE_COLOR }) } })) } } } }