commit 1e2c71403e2535db4293a9cb4686fc4bf63de09f
parent 476de8dc38b04fef5435d5e4c1559e52a26c0793
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Sat, 30 Sep 2023 16:08:05 +0200

fix(messagelogger): message unique identifier
- refactor bridge
- developer mode (shows additional info about messages)
- add ability to see deleted messages in ff preview
- fix notification username

Diffstat:
Mapp/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt | 14+-------------
Mcore/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl | 24+++---------------------
Acore/src/main/aidl/me/rhunk/snapenhance/bridge/MessageLoggerInterface.aidl | 25+++++++++++++++++++++++++
Mcore/src/main/assets/lang/en_US.json | 4++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/Constants.kt | 1-
Mcore/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt | 2++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt | 10++--------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MessageLoggerWrapper.kt | 65+++++++++++++++++++++++++++++++++++++----------------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Scripting.kt | 1+
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/ConversationMessage.kt | 7-------
Mcore/src/main/kotlin/me/rhunk/snapenhance/data/SnapEnums.kt | 16++++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt | 2+-
Mcore/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/EndToEndEncryption.kt | 1-
Mcore/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt | 93+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt | 2+-
Mcore/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt | 2+-
Mcore/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/ChatActionMenu.kt | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt | 36++++++++++++++++++++----------------
18 files changed, 282 insertions(+), 168 deletions(-)

diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt @@ -109,19 +109,6 @@ class BridgeService : Service() { } } - override fun getLoggedMessageIds(conversationId: String, limit: Int) = - messageLoggerWrapper.getMessageIds(conversationId, limit).toLongArray() - - override fun getMessageLoggerMessage(conversationId: String, id: Long) = - messageLoggerWrapper.getMessage(conversationId, id).second - - override fun addMessageLoggerMessage(conversationId: String, id: Long, message: ByteArray) { - messageLoggerWrapper.addMessage(conversationId, id, message) - } - - override fun deleteMessageLoggerMessage(conversationId: String, id: Long) = - messageLoggerWrapper.deleteMessage(conversationId, id) - override fun getApplicationApkPath(): String = applicationInfo.publicSourceDir override fun fetchLocales(userLocale: String) = @@ -189,6 +176,7 @@ class BridgeService : Service() { override fun getScriptingInterface() = remoteSideContext.scriptManager override fun getE2eeInterface() = remoteSideContext.e2eeImplementation + override fun getMessageLogger() = messageLoggerWrapper override fun openSettingsOverlay() { runCatching { diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl b/core/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl @@ -5,6 +5,7 @@ import me.rhunk.snapenhance.bridge.DownloadCallback; import me.rhunk.snapenhance.bridge.SyncCallback; import me.rhunk.snapenhance.bridge.scripting.IScripting; import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface; +import me.rhunk.snapenhance.bridge.MessageLoggerInterface; interface BridgeInterface { /** @@ -19,27 +20,6 @@ interface BridgeInterface { byte[] fileOperation(int action, int fileType, in @nullable byte[] content); /** - * Get the content of a logged message from the database - * @return message ids that are logged - */ - long[] getLoggedMessageIds(String conversationId, int limit); - - /** - * Get the content of a logged message from the database - */ - @nullable byte[] getMessageLoggerMessage(String conversationId, long id); - - /** - * Add a message to the message logger database - */ - void addMessageLoggerMessage(String conversationId, long id, in byte[] message); - - /** - * Delete a message from the message logger database - */ - void deleteMessageLoggerMessage(String conversationId, long id); - - /** * Get the application APK path (assets for the conversation exporter) */ String getApplicationApkPath(); @@ -97,6 +77,8 @@ interface BridgeInterface { E2eeInterface getE2eeInterface(); + MessageLoggerInterface getMessageLogger(); + void openSettingsOverlay(); void closeSettingsOverlay(); diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/MessageLoggerInterface.aidl b/core/src/main/aidl/me/rhunk/snapenhance/bridge/MessageLoggerInterface.aidl @@ -0,0 +1,24 @@ +package me.rhunk.snapenhance.bridge; + +interface MessageLoggerInterface { + /** + * Get the ids of the messages that are logged + * @return message ids that are logged + */ + long[] getLoggedIds(in String[] conversationIds, int limit); + + /** + * Get the content of a logged message from the database + */ + @nullable byte[] getMessage(String conversationId, long id); + + /** + * Add a message to the message logger database if it is not already there + */ + boolean addMessage(String conversationId, long id, in byte[] message); + + /** + * Delete a message from the message logger database + */ + void deleteMessage(String conversationId, long id); +}+ \ No newline at end of file diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json @@ -548,6 +548,10 @@ "name": "Scripting", "description": "Run custom scripts to extend SnapEnhance", "properties": { + "developer_mode": { + "name": "Developer Mode", + "description": "Shows debug info on Snapchat's UI" + }, "module_folder": { "name": "Module Folder", "description": "The folder where the scripts are located" diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/Constants.kt b/core/src/main/kotlin/me/rhunk/snapenhance/Constants.kt @@ -4,7 +4,6 @@ object Constants { const val SNAPCHAT_PACKAGE_NAME = "com.snapchat.android" val ARROYO_MEDIA_CONTAINER_PROTO_PATH = intArrayOf(4, 4) - val ARROYO_STRING_CHAT_MESSAGE_PROTO = ARROYO_MEDIA_CONTAINER_PROTO_PATH + intArrayOf(2, 1) const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt @@ -58,6 +58,8 @@ class ModContext { val native = NativeLib() val scriptRuntime by lazy { CoreScriptRuntime(log, androidContext.classLoader) } + val isDeveloper by lazy { config.scripting.developerMode.get() } + fun <T : Feature> feature(featureClass: KClass<T>): T { return features.get(featureClass)!! } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt @@ -111,14 +111,6 @@ class BridgeClient( fun isFileExists(fileType: BridgeFileType) = service.fileOperation(FileActionType.EXISTS.ordinal, fileType.value, null).isNotEmpty() - fun getLoggedMessageIds(conversationId: String, limit: Int): LongArray = service.getLoggedMessageIds(conversationId, limit) - - fun getMessageLoggerMessage(conversationId: String, id: Long): ByteArray? = service.getMessageLoggerMessage(conversationId, id) - - fun addMessageLoggerMessage(conversationId: String, id: Long, message: ByteArray) = service.addMessageLoggerMessage(conversationId, id, message) - - fun deleteMessageLoggerMessage(conversationId: String, id: Long) = service.deleteMessageLoggerMessage(conversationId, id) - fun fetchLocales(userLocale: String) = service.fetchLocales(userLocale).map { LocalePair(it.key, it.value) } @@ -148,6 +140,8 @@ class BridgeClient( fun getE2eeInterface(): E2eeInterface = service.getE2eeInterface() + fun getMessageLogger() = service.messageLogger + fun openSettingsOverlay() = service.openSettingsOverlay() fun closeSettingsOverlay() = service.closeSettingsOverlay() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MessageLoggerWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MessageLoggerWrapper.kt @@ -2,14 +2,15 @@ package me.rhunk.snapenhance.core.bridge.wrapper import android.content.ContentValues import android.database.sqlite.SQLiteDatabase +import me.rhunk.snapenhance.bridge.MessageLoggerInterface import me.rhunk.snapenhance.core.util.SQLiteDatabaseHelper import java.io.File +import java.util.UUID class MessageLoggerWrapper( private val databaseFile: File -) { - - lateinit var database: SQLiteDatabase +): MessageLoggerInterface.Stub() { + private lateinit var database: SQLiteDatabase fun init() { database = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE) @@ -23,11 +24,37 @@ class MessageLoggerWrapper( )) } - fun deleteMessage(conversationId: String, messageId: Long) { - database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) + override fun getLoggedIds(conversationId: Array<String>, limit: Int): LongArray { + if (conversationId.any { + runCatching { UUID.fromString(it) }.isFailure + }) return longArrayOf() + + val cursor = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id IN (${ + conversationId.joinToString( + "," + ) { "'$it'" } + }) ORDER BY message_id DESC LIMIT $limit", null) + + val ids = mutableListOf<Long>() + while (cursor.moveToNext()) { + ids.add(cursor.getLong(0)) + } + cursor.close() + return ids.toLongArray() } - fun addMessage(conversationId: String, messageId: Long, serializedMessage: ByteArray): Boolean { + override fun getMessage(conversationId: String?, id: Long): ByteArray? { + val cursor = database.rawQuery("SELECT message_data FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, id.toString())) + val message: ByteArray? = if (cursor.moveToFirst()) { + cursor.getBlob(0) + } else { + null + } + cursor.close() + return message + } + + override fun addMessage(conversationId: String, messageId: Long, serializedMessage: ByteArray): Boolean { val cursor = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) val state = cursor.moveToFirst() cursor.close() @@ -42,29 +69,11 @@ class MessageLoggerWrapper( return true } - fun getMessage(conversationId: String, messageId: Long): Pair<Boolean, ByteArray?> { - val cursor = database.rawQuery("SELECT message_data FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) - val state = cursor.moveToFirst() - val message: ByteArray? = if (state) { - cursor.getBlob(0) - } else { - null - } - cursor.close() - return Pair(state, message) - } - - fun getMessageIds(conversationId: String, limit: Int): List<Long> { - val cursor = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id = ? ORDER BY message_id DESC LIMIT ?", arrayOf(conversationId, limit.toString())) - val messageIds = mutableListOf<Long>() - while (cursor.moveToNext()) { - messageIds.add(cursor.getLong(0)) - } - cursor.close() - return messageIds - } - fun clearMessages() { database.execSQL("DELETE FROM messages") } + + override fun deleteMessage(conversationId: String, messageId: Long) { + database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) + } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Scripting.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Scripting.kt @@ -4,6 +4,7 @@ import me.rhunk.snapenhance.core.config.ConfigContainer import me.rhunk.snapenhance.core.config.ConfigFlag class Scripting : ConfigContainer() { + val developerMode = boolean("developer_mode", false) val moduleFolder = string("module_folder", "modules") { addFlags(ConfigFlag.FOLDER) } val hotReload = boolean("hot_reload", false) } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/ConversationMessage.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/ConversationMessage.kt @@ -40,11 +40,4 @@ data class ConversationMessage( senderId = getStringOrNull("sender_id") } } - - fun getMessageAsString(): String? { - return when (ContentType.fromId(contentType)) { - ContentType.CHAT -> messageContent?.let { ProtoReader(it).getString(*Constants.ARROYO_STRING_CHAT_MESSAGE_PROTO) } - else -> null - } - } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/SnapEnums.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/SnapEnums.kt @@ -1,5 +1,7 @@ package me.rhunk.snapenhance.data +import me.rhunk.snapenhance.core.util.protobuf.ProtoReader + enum class MessageState { PREPARING, SENDING, COMMITTED, FAILED, CANCELING } @@ -63,6 +65,20 @@ enum class ContentType(val id: Int) { fun fromId(i: Int): ContentType { return values().firstOrNull { it.id == i } ?: UNKNOWN } + + fun fromMessageContainer(protoReader: ProtoReader?): ContentType { + if (protoReader == null) return UNKNOWN + return when { + protoReader.containsPath(2) -> CHAT + protoReader.containsPath(11) -> SNAP + protoReader.containsPath(6) -> NOTE + protoReader.containsPath(3) -> EXTERNAL_MEDIA + protoReader.containsPath(4) -> STICKER + protoReader.containsPath(5) -> SHARE + protoReader.containsPath(7) -> EXTERNAL_MEDIA// story replies + else -> UNKNOWN + } + } } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -526,7 +526,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp val friendInfo: FriendInfo = context.database.getFriendInfo(message.senderId!!) ?: throw Exception("Friend not found in database") val authorName = friendInfo.usernameForSorting!! - val decodedAttachments = messageLogger.getMessageObject(message.clientConversationId!!, message.serverMessageId.toLong())?.let { + val decodedAttachments = messageLogger.takeIf { it.isEnabled }?.getMessageObject(message.clientConversationId!!, message.clientMessageId.toLong())?.let { MessageDecoder.decode(it.getAsJsonObject("mMessageContent")) } ?: MessageDecoder.decode( protoReader = ProtoReader(message.messageContent!!) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/EndToEndEncryption.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/EndToEndEncryption.kt @@ -284,7 +284,6 @@ class EndToEndEncryption : MessagingRuleFeature( if (messageTypeId == ENCRYPTED_MESSAGE_ID) { runCatching { - replaceMessageText("Cannot find a key to decrypt this message.") eachBuffer(2) { val participantIdHash = getByteArray(1) ?: return@eachBuffer val iv = getByteArray(2) ?: return@eachBuffer diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt @@ -8,6 +8,7 @@ import android.os.DeadObjectException import com.google.gson.JsonObject import com.google.gson.JsonParser import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.util.protobuf.ProtoReader import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.MessageState import me.rhunk.snapenhance.data.wrapper.impl.Message @@ -39,39 +40,55 @@ class MessageLogger : Feature("MessageLogger", const val DELETED_MESSAGE_COLOR = 0x2Eb71c1c } - private val isEnabled get() = context.config.messaging.messageLogger.get() + private val messageLoggerInterface by lazy { context.bridgeClient.getMessageLogger() } + + val isEnabled get() = context.config.messaging.messageLogger.get() private val threadPool = Executors.newFixedThreadPool(10) - //two level of cache to avoid querying the database - private val fetchedMessages = mutableListOf<Long>() - private val deletedMessageCache = mutableMapOf<Long, JsonObject>() + private val cachedIdLinks = mutableMapOf<Long, Long>() // client id -> server id + private val fetchedMessages = mutableListOf<Long>() // list of unique message ids + private val deletedMessageCache = mutableMapOf<Long, JsonObject>() // unique message id -> message json object - fun isMessageRemoved(conversationId: String, orderKey: Long) = deletedMessageCache.containsKey(computeMessageIdentifier(conversationId, orderKey)) + fun isMessageDeleted(conversationId: String, clientMessageId: Long) + = makeUniqueIdentifier(conversationId, clientMessageId)?.let { deletedMessageCache.containsKey(it) } ?: false fun deleteMessage(conversationId: String, clientMessageId: Long) { - val serverMessageId = getServerMessageIdentifier(conversationId, clientMessageId) ?: return - fetchedMessages.remove(serverMessageId) - deletedMessageCache.remove(serverMessageId) - context.bridgeClient.deleteMessageLoggerMessage(conversationId, serverMessageId) + val uniqueMessageId = makeUniqueIdentifier(conversationId, clientMessageId) ?: return + fetchedMessages.remove(uniqueMessageId) + deletedMessageCache.remove(uniqueMessageId) + messageLoggerInterface.deleteMessage(conversationId, uniqueMessageId) } - fun getMessageObject(conversationId: String, orderKey: Long): JsonObject? { - val messageIdentifier = computeMessageIdentifier(conversationId, orderKey) - if (deletedMessageCache.containsKey(messageIdentifier)) { - return deletedMessageCache[messageIdentifier] + fun getMessageObject(conversationId: String, clientMessageId: Long): JsonObject? { + val uniqueMessageId = makeUniqueIdentifier(conversationId, clientMessageId) ?: return null + if (deletedMessageCache.containsKey(uniqueMessageId)) { + return deletedMessageCache[uniqueMessageId] } - return context.bridgeClient.getMessageLoggerMessage(conversationId, messageIdentifier)?.let { + return messageLoggerInterface.getMessage(conversationId, uniqueMessageId)?.let { JsonParser.parseString(it.toString(Charsets.UTF_8)).asJsonObject } } - private fun computeMessageIdentifier(conversationId: String, orderKey: Long) = (orderKey.toString() + conversationId).longHashCode() - private fun getServerMessageIdentifier(conversationId: String, clientMessageId: Long): Long? { - val serverMessageId = context.database.getConversationMessageFromId(clientMessageId)?.serverMessageId?.toLong() ?: return run { - context.log.error("Failed to get server message id for $conversationId $clientMessageId") - null + fun getMessageProto(conversationId: String, clientMessageId: Long): ProtoReader? { + return getMessageObject(conversationId, clientMessageId)?.let { message -> + ProtoReader(message.getAsJsonObject("mMessageContent").getAsJsonArray("mContent") + .map { it.asByte } + .toByteArray()) } + } + + 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) } @@ -82,9 +99,9 @@ class MessageLogger : Feature("MessageLogger", } measureTime { - context.database.getFeedEntries(PREFETCH_FEED_COUNT).forEach { friendFeedInfo -> - fetchedMessages.addAll(context.bridgeClient.getLoggedMessageIds(friendFeedInfo.key!!, PREFETCH_MESSAGE_COUNT).toList()) - } + val conversationIds = context.database.getFeedEntries(PREFETCH_FEED_COUNT).map { it.key!! } + if (conversationIds.isEmpty()) return@measureTime + fetchedMessages.addAll(messageLoggerInterface.getLoggedIds(conversationIds.toTypedArray(), PREFETCH_MESSAGE_COUNT).toList()) }.also { context.log.verbose("Loaded ${fetchedMessages.size} cached messages in $it") } } @@ -93,22 +110,20 @@ class MessageLogger : Feature("MessageLogger", if (message.messageState != MessageState.COMMITTED) return + cachedIdLinks[message.messageDescriptor.messageId] = message.orderKey + val conversationId = message.messageDescriptor.conversationId.toString() //exclude messages sent by me if (message.senderId.toString() == context.database.myUserId) return - val conversationId = message.messageDescriptor.conversationId.toString() - val serverIdentifier = computeMessageIdentifier(conversationId, message.orderKey) + val uniqueMessageIdentifier = computeMessageIdentifier(conversationId, message.orderKey) if (message.messageContent.contentType != ContentType.STATUS) { - if (fetchedMessages.contains(serverIdentifier)) return - fetchedMessages.add(serverIdentifier) + if (fetchedMessages.contains(uniqueMessageIdentifier)) return + fetchedMessages.add(uniqueMessageIdentifier) threadPool.execute { try { - context.bridgeClient.getMessageLoggerMessage(conversationId, serverIdentifier)?.let { - return@execute - } - context.bridgeClient.addMessageLoggerMessage(conversationId, serverIdentifier, context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8)) + messageLoggerInterface.addMessage(conversationId, uniqueMessageIdentifier, context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8)) } catch (ignored: DeadObjectException) {} } @@ -116,10 +131,10 @@ class MessageLogger : Feature("MessageLogger", } //query the deleted message - val deletedMessageObject: JsonObject = if (deletedMessageCache.containsKey(serverIdentifier)) - deletedMessageCache[serverIdentifier] + val deletedMessageObject: JsonObject = if (deletedMessageCache.containsKey(uniqueMessageIdentifier)) + deletedMessageCache[uniqueMessageIdentifier] else { - context.bridgeClient.getMessageLoggerMessage(conversationId, serverIdentifier)?.let { + messageLoggerInterface.getMessage(conversationId, uniqueMessageIdentifier)?.let { JsonParser.parseString(it.toString(Charsets.UTF_8)).asJsonObject } } ?: return @@ -134,19 +149,13 @@ class MessageLogger : Feature("MessageLogger", //serialize all properties of messageJsonObject and put in the message object messageInstance.javaClass.declaredFields.forEach { field -> field.isAccessible = true + if (field.name == "mDescriptor") return@forEach // prevent the client message id from being overwritten messageJsonObject[field.name]?.let { fieldValue -> field.set(messageInstance, context.gson.fromJson(fieldValue, field.type)) } } - /*//set the message state to PREPARING for visibility - with(message.messageContent.contentType) { - if (this != ContentType.SNAP && this != ContentType.EXTERNAL_MEDIA) { - message.messageState = MessageState.PREPARING - } - }*/ - - deletedMessageCache[serverIdentifier] = deletedMessageObject + deletedMessageCache[uniqueMessageIdentifier] = deletedMessageObject } override fun init() { @@ -161,7 +170,7 @@ class MessageLogger : Feature("MessageLogger", context.event.subscribe(BindViewEvent::class) { event -> event.chatMessage { conversationId, messageId -> event.view.removeForegroundDrawable("deletedMessage") - getServerMessageIdentifier(conversationId, messageId.toLong())?.let { serverMessageId -> + makeUniqueIdentifier(conversationId, messageId.toLong())?.let { serverMessageId -> if (!deletedMessageCache.contains(serverMessageId)) return@chatMessage } ?: return@chatMessage diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt @@ -36,7 +36,7 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, private fun saveMessage(conversationId: SnapUUID, message: Message) { val messageId = message.messageDescriptor.messageId - if (messageLogger.isMessageRemoved(conversationId.toString(), message.orderKey)) return + if (messageLogger.takeIf { it.isEnabled }?.isMessageDeleted(conversationId.toString(), message.messageDescriptor.messageId) == true) return if (message.messageState != MessageState.COMMITTED) return runCatching { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt @@ -220,7 +220,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN val snapMessage = messages.firstOrNull { message -> message.orderKey == messageId } ?: return val senderUsername by lazy { context.database.getFriendInfo(snapMessage.senderId.toString())?.let { - it.displayName ?: it.username + it.displayName ?: it.mutableUsername } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/ChatActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/ChatActionMenu.kt @@ -1,6 +1,7 @@ package me.rhunk.snapenhance.ui.menu.impl import android.annotation.SuppressLint +import android.content.Context import android.os.SystemClock import android.view.MotionEvent import android.view.View @@ -8,61 +9,58 @@ import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams import android.widget.Button import android.widget.LinearLayout +import android.widget.TextView import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.core.util.protobuf.ProtoReader +import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.features.impl.Messaging import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader import me.rhunk.snapenhance.features.impl.spying.MessageLogger +import me.rhunk.snapenhance.ui.ViewAppearanceHelper import me.rhunk.snapenhance.ui.ViewTagState import me.rhunk.snapenhance.ui.applyTheme import me.rhunk.snapenhance.ui.menu.AbstractMenu +import java.time.Instant +@SuppressLint("DiscouragedApi") class ChatActionMenu : AbstractMenu() { private val viewTagState = ViewTagState() - @SuppressLint("SetTextI18n", "DiscouragedApi") - fun inject(viewGroup: ViewGroup) { - val parent = viewGroup.parent.parent as ViewGroup - if (viewTagState[parent]) return - //close the action menu using a touch event - val closeActionMenu = { - viewGroup.dispatchTouchEvent( - MotionEvent.obtain( - SystemClock.uptimeMillis(), - SystemClock.uptimeMillis(), - MotionEvent.ACTION_DOWN, - 0f, - 0f, - 0 - ) - ) - } - - val defaultGap = viewGroup.resources.getDimensionPixelSize( - viewGroup.resources.getIdentifier( + private val defaultGap by lazy { + context.androidContext.resources.getDimensionPixelSize( + context.androidContext.resources.getIdentifier( "default_gap", "dimen", Constants.SNAPCHAT_PACKAGE_NAME ) ) + } - val chatActionMenuItemMargin = viewGroup.resources.getDimensionPixelSize( - viewGroup.resources.getIdentifier( + private val chatActionMenuItemMargin by lazy { + context.androidContext.resources.getDimensionPixelSize( + context.androidContext.resources.getIdentifier( "chat_action_menu_item_margin", "dimen", Constants.SNAPCHAT_PACKAGE_NAME ) ) + } - val actionMenuItemHeight = viewGroup.resources.getDimensionPixelSize( - viewGroup.resources.getIdentifier( + private val actionMenuItemHeight by lazy { + context.androidContext.resources.getDimensionPixelSize( + context.androidContext.resources.getIdentifier( "action_menu_item_height", "dimen", Constants.SNAPCHAT_PACKAGE_NAME ) ) + } + + private fun createContainer(viewGroup: ViewGroup): LinearLayout { + val parent = viewGroup.parent.parent as ViewGroup - val buttonContainer = LinearLayout(viewGroup.context).apply layout@{ + return LinearLayout(viewGroup.context).apply layout@{ orientation = LinearLayout.VERTICAL layoutParams = MarginLayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -72,6 +70,45 @@ class ChatActionMenu : AbstractMenu() { setMargins(chatActionMenuItemMargin, 0, chatActionMenuItemMargin, defaultGap) } } + } + + private fun copyAlertDialog(context: Context, title: String, text: String) { + ViewAppearanceHelper.newAlertDialogBuilder(context).apply { + setTitle(title) + setMessage(text) + setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } + setNegativeButton("Copy") { _, _ -> + val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + clipboardManager.setPrimaryClip(android.content.ClipData.newPlainText("debug", text)) + } + }.show() + } + + private val lastFocusedMessage + get() = context.database.getConversationMessageFromId(context.feature(Messaging::class).lastFocusedMessageId) + + @SuppressLint("SetTextI18n", "DiscouragedApi", "ClickableViewAccessibility") + fun inject(viewGroup: ViewGroup) { + val parent = viewGroup.parent.parent as? ViewGroup ?: return + if (viewTagState[parent]) return + //close the action menu using a touch event + val closeActionMenu = { + viewGroup.dispatchTouchEvent( + MotionEvent.obtain( + SystemClock.uptimeMillis(), + SystemClock.uptimeMillis(), + MotionEvent.ACTION_DOWN, + 0f, + 0f, + 0 + ) + ) + } + + val messaging = context.feature(Messaging::class) + val messageLogger = context.feature(MessageLogger::class) + + val buttonContainer = createContainer(viewGroup) val injectButton = { button: Button -> if (buttonContainer.childCount > 0) { @@ -125,14 +162,66 @@ class ChatActionMenu : AbstractMenu() { setOnClickListener { closeActionMenu() this@ChatActionMenu.context.executeAsync { - feature(Messaging::class).apply { - feature(MessageLogger::class).deleteMessage(openedConversationUUID.toString(), lastFocusedMessageId) - } + messageLogger.deleteMessage(messaging.openedConversationUUID.toString(), messaging.lastFocusedMessageId) } } }) } + if (context.isDeveloper) { + parent.addView(createContainer(viewGroup).apply { + val debugText = StringBuilder() + + setOnClickListener { + val clipboardManager = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + clipboardManager.setPrimaryClip(android.content.ClipData.newPlainText("debug", debugText.toString())) + } + + addView(TextView(viewGroup.context).apply { + setPadding(20, 20, 20, 20) + textSize = 10f + addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + val arroyoMessage = lastFocusedMessage ?: return@addOnLayoutChangeListener + text = debugText.apply { + runCatching { + clear() + append("sender_id: ${arroyoMessage.senderId}\n") + append("client_id: ${arroyoMessage.clientMessageId}, server_id: ${arroyoMessage.serverMessageId}\n") + append("conversation_id: ${arroyoMessage.clientConversationId}\n") + append("arroyo_content_type: ${ContentType.fromId(arroyoMessage.contentType)} (${arroyoMessage.contentType})\n") + append("parsed_content_type: ${ContentType.fromMessageContainer( + ProtoReader(arroyoMessage.messageContent!!).followPath(4, 4) + ).let { "$it (${it.id})" }}\n") + append("creation_timestamp: ${arroyoMessage.creationTimestamp} (${Instant.ofEpochMilli(arroyoMessage.creationTimestamp)})\n") + append("read_timestamp: ${arroyoMessage.readTimestamp} (${Instant.ofEpochMilli(arroyoMessage.readTimestamp)})\n") + append("is_messagelogger_deleted: ${messageLogger.isMessageDeleted(arroyoMessage.clientConversationId!!, arroyoMessage.clientMessageId.toLong())}\n") + append("is_messagelogger_stored: ${messageLogger.getMessageObject(arroyoMessage.clientConversationId!!, arroyoMessage.clientMessageId.toLong()) != null}\n") + }.onFailure { + debugText.append("Error: $it\n") + } + }.toString().trimEnd() + } + }) + + // action buttons + addView(LinearLayout(viewGroup.context).apply { + orientation = LinearLayout.HORIZONTAL + addView(Button(viewGroup.context).apply { + text = "Show Deleted Message Object" + setOnClickListener { + val message = lastFocusedMessage ?: return@setOnClickListener + copyAlertDialog( + viewGroup.context, + "Deleted Message Object", + messageLogger.getMessageObject(message.clientConversationId!!, message.clientMessageId.toLong())?.toString() + ?: "null" + ) + } + }) + }) + }) + } + parent.addView(buttonContainer) } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt @@ -14,10 +14,12 @@ import android.widget.Switch import me.rhunk.snapenhance.core.database.objects.ConversationMessage import me.rhunk.snapenhance.core.database.objects.FriendInfo import me.rhunk.snapenhance.core.database.objects.UserConversationLink +import me.rhunk.snapenhance.core.util.protobuf.ProtoReader import me.rhunk.snapenhance.core.util.snap.BitmojiSelfie import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.FriendLinkType import me.rhunk.snapenhance.features.impl.Messaging +import me.rhunk.snapenhance.features.impl.spying.MessageLogger import me.rhunk.snapenhance.ui.ViewAppearanceHelper import me.rhunk.snapenhance.ui.applyTheme import me.rhunk.snapenhance.ui.menu.AbstractMenu @@ -102,15 +104,11 @@ class FriendFeedInfoMenu : AbstractMenu() { private fun showPreview(userId: String?, conversationId: String, androidCtx: Context?) { //query message - val messages: List<ConversationMessage>? = context.database.getMessagesFromConversationId( + val messageLogger = context.feature(MessageLogger::class) + val messages: List<ConversationMessage> = context.database.getMessagesFromConversationId( conversationId, context.config.messaging.messagePreviewLength.get() - )?.reversed() - - if (messages == null) { - context.longToast("Can't fetch messages") - return - } + )?.reversed() ?: emptyList() val participants: Map<String, FriendInfo> = context.database.getConversationParticipants(conversationId)!! .map { context.database.getFriendInfo(it)!! } @@ -119,19 +117,26 @@ class FriendFeedInfoMenu : AbstractMenu() { val messageBuilder = StringBuilder() messages.forEach { message -> - val sender: FriendInfo? = participants[message.senderId] - - var messageString: String = message.getMessageAsString() ?: ContentType.fromId(message.contentType).name + val sender = participants[message.senderId] + val protoReader = ( + messageLogger.takeIf { it.isEnabled }?.getMessageProto(conversationId, message.clientMessageId.toLong()) ?: ProtoReader(message.messageContent ?: return@forEach).followPath(4, 4) + ) ?: return@forEach + + val contentType = ContentType.fromMessageContainer(protoReader) + var messageString = if (contentType == ContentType.CHAT) { + protoReader.getString(2, 1) ?: return@forEach + } else { + contentType.name + } - if (message.contentType == ContentType.SNAP.id) { - val readTimeStamp: Long = message.readTimestamp + if (contentType == ContentType.SNAP) { messageString = "\uD83D\uDFE5" //red square - if (readTimeStamp > 0) { + if (message.readTimestamp > 0) { messageString += " \uD83D\uDC40 " //eyes messageString += DateFormat.getDateTimeInstance( DateFormat.SHORT, DateFormat.SHORT - ).format(Date(readTimeStamp)) + ).format(Date(message.readTimestamp)) } } @@ -144,8 +149,7 @@ class FriendFeedInfoMenu : AbstractMenu() { messageBuilder.append(displayUsername).append(": ").append(messageString).append("\n") } - val targetPerson: FriendInfo? = - if (userId == null) null else participants[userId] + val targetPerson = if (userId == null) null else participants[userId] targetPerson?.streakExpirationTimestamp?.takeIf { it > 0 }?.let { val timeSecondDiff = ((it - System.currentTimeMillis()) / 1000 / 60).toInt()